diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/exception/AviatorConnectivityException.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/exception/AviatorConnectivityException.java new file mode 100644 index 00000000000..6df5f9db285 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/exception/AviatorConnectivityException.java @@ -0,0 +1,27 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.exception; + +/** + * Aviator connection failure with a message that is safe to show. + *

+ * The transport cause is not attached: its text can contain proxy credentials or + * host details, and fcli prints and logs the cause chain of simple exceptions. + */ +public final class AviatorConnectivityException extends AviatorSimpleException { + private static final long serialVersionUID = 1L; + + public AviatorConnectivityException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/IssueAuditor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/IssueAuditor.java index 79e400cd0f1..04b9913b22a 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/IssueAuditor.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/IssueAuditor.java @@ -56,6 +56,7 @@ import com.fortify.cli.aviator.fpr.processor.AuditProcessor; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; +import com.fortify.cli.aviator.grpc.AviatorRequestPolicy; import com.fortify.cli.aviator.util.Constants; import com.fortify.cli.aviator.util.ResourceUtil; import com.fortify.cli.aviator.util.StringUtil; @@ -169,7 +170,7 @@ public AuditOutcome performAudit(Map auditResponses) { if (promptsToAudit.isEmpty()) { options.getLogger().progress("Audit skipped - no issues to process after filtering."); } else { - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(options.getUrl(), options.getLogger(), DEFAULT_PING_INTERVAL_SECONDS)) { + try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(options.getUrl(), options.getLogger(), DEFAULT_PING_INTERVAL_SECONDS, AviatorRequestPolicy.WAIT_FOR_READY)) { CompletableFuture> future = client.processBatchRequests(promptsToAudit, projectName, fprInfo.getBuildId(), SSCApplicationName, SSCApplicationVersion, options.getToken(), options.getFprHandle(), diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDefaultDiagnosticProbe.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDefaultDiagnosticProbe.java index 96ee570dfb7..cf75d1ae425 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDefaultDiagnosticProbe.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDefaultDiagnosticProbe.java @@ -36,6 +36,7 @@ import com.fortify.cli.aviator._common.exception.AviatorBugException; import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper.AviatorConnectionPlan; +import com.fortify.cli.aviator.grpc.AviatorRequestPolicy; import com.fortify.cli.common.http.ssl.trust.FcliTrustManager; import io.grpc.Status; @@ -140,7 +141,7 @@ private static AviatorTunnelResult.TlsSucceeded toTlsSucceeded(SSLSocket sslSock @Override public AviatorGrpcReachabilityResult probeGrpc(String url, int timeoutSeconds) throws Exception { - try (var client = AviatorGrpcClientHelper.createClient(url)) { + try (var client = AviatorGrpcClientHelper.createClient(url, AviatorRequestPolicy.WAIT_FOR_READY)) { client.probeGetDefaultQuota(timeoutSeconds); return AviatorGrpcReachabilityResult.responseReceived("OK", "Default quota response received"); } catch (StatusRuntimeException e) { diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorConnectionContext.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorConnectionContext.java new file mode 100644 index 00000000000..e2746ef4d64 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorConnectionContext.java @@ -0,0 +1,44 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import java.util.Objects; + +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper.AviatorConnectionPlan; + +import io.grpc.CallOptions; + +/** + * Endpoints retained for error messages, without proxy credentials. + * @param proxy null for a direct connection + */ +record AviatorConnectionContext(AviatorEndpoint target, AviatorEndpoint proxy) { + // Present only on fail-fast unary stubs; absence preserves the existing error mapping. + static final CallOptions.Key CALL_OPTION = CallOptions.Key.create("aviator-connectivity"); + + AviatorConnectionContext { + Objects.requireNonNull(target, "target"); + } + + static AviatorConnectionContext of(AviatorConnectionPlan plan) { + var target = new AviatorEndpoint(plan.target().host(), plan.effectivePort()); + var proxy = plan.proxyDescriptor() + .map(d -> new AviatorEndpoint(d.getProxyHost(), d.getProxyPort())) + .orElse(null); + return new AviatorConnectionContext(target, proxy); + } + + boolean isProxied() { + return proxy!=null; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorConnectivityFailureKind.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorConnectivityFailureKind.java new file mode 100644 index 00000000000..0bb534fe118 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorConnectivityFailureKind.java @@ -0,0 +1,124 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import java.net.ConnectException; +import java.net.UnknownHostException; +import java.nio.channels.UnresolvedAddressException; +import java.security.cert.CertPathValidatorException; +import java.security.cert.CertificateException; +import java.util.Optional; + +import javax.net.ssl.SSLException; + +import org.apache.commons.lang3.exception.ExceptionUtils; + +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.netty.shaded.io.netty.channel.ConnectTimeoutException; +import io.grpc.netty.shaded.io.netty.handler.proxy.HttpProxyHandler.HttpProxyConnectException; +import io.grpc.netty.shaded.io.netty.handler.proxy.ProxyConnectException; + +/** Shared classification and messages for ordinary unary request failures. */ +enum AviatorConnectivityFailureKind { + PROXY_REJECTED, + PROXY_NEGOTIATION_FAILED, + CONNECT_TIMEOUT, + PROXY_REFUSED, + DIRECT_REFUSED, + DNS_FAILURE, + TLS_FAILURE, + UNAVAILABLE, + DEADLINE_EXCEEDED; + + /** + * Leaves certificate failures to the fcli PKIX trust-store guidance, and application + * statuses to the existing exception mapping. + */ + static Optional classify(StatusRuntimeException e, AviatorConnectionContext context) { + var code = e.getStatus().getCode(); + if ( code!=Status.Code.UNAVAILABLE && code!=Status.Code.DEADLINE_EXCEEDED ) { + return Optional.empty(); + } + if ( isCertificateFailure(e) ) { + return Optional.empty(); + } + if ( has(e, SSLException.class) ) { + return Optional.of(TLS_FAILURE); + } + return Optional.of(classifyTransport(e, context, code)); + } + + // JDK PKIX and host name failures always carry one of these causes; other + // handshake failures, such as timeouts or protocol mismatches, are TLS_FAILURE. + private static boolean isCertificateFailure(Throwable e) { + return has(e, CertificateException.class) || has(e, CertPathValidatorException.class); + } + + // The order is significant: all Netty connect and proxy exceptions extend ConnectException. + private static AviatorConnectivityFailureKind classifyTransport(Throwable e, AviatorConnectionContext context, Status.Code code) { + var neutral = code==Status.Code.DEADLINE_EXCEEDED ? DEADLINE_EXCEEDED : UNAVAILABLE; + if ( has(e, ProxyConnectException.class) ) { + // A JVM or GRPC_PROXY_EXP proxy can be active without an fcli proxy; fcli cannot name it. + if ( !context.isProxied() ) { return neutral; } + return has(e, HttpProxyConnectException.class) ? PROXY_REJECTED : PROXY_NEGOTIATION_FAILED; + } + if ( has(e, ConnectTimeoutException.class) ) { return CONNECT_TIMEOUT; } + // With an HTTP CONNECT proxy, the proxy is the only TCP peer of the client; + // a target refusal arrives as HttpProxyConnectException instead. + if ( isConnectionRefused(e) ) { return context.isProxied() ? PROXY_REFUSED : DIRECT_REFUSED; } + if ( has(e, UnknownHostException.class) || has(e, UnresolvedAddressException.class) ) { return DNS_FAILURE; } + return neutral; + } + + // Java has no refusal-specific exception type. Match complete known reasons, never + // substrings that could come from a host name; unknown/localized reasons stay neutral. + private static boolean isConnectionRefused(Throwable e) { + return ExceptionUtils.getThrowableList(e).stream() + .filter(ConnectException.class::isInstance) + .map(Throwable::getMessage) + .anyMatch(message -> "Connection refused".equalsIgnoreCase(message) + || "Connection refused: getsockopt".equalsIgnoreCase(message) + || "Connection refused: no further information".equalsIgnoreCase(message) + || "connect(..) failed: Connection refused".equalsIgnoreCase(message) + || "finishConnect(..) failed: Connection refused".equalsIgnoreCase(message)); + } + + private static boolean has(Throwable e, Class type) { + return ExceptionUtils.indexOfType(e, type) >= 0; + } + + String formatMessage(AviatorConnectionContext context) { + var server = "Aviator server " + context.target().display(); + var proxy = context.isProxied() ? "proxy " + context.proxy().display() : null; + return switch (this) { + case PROXY_REJECTED -> "Proxy " + context.proxy().display() + " rejected the connection to " + server + + "; check the proxy credentials and access rules"; + case PROXY_NEGOTIATION_FAILED -> "Could not connect to " + server + " through " + proxy + + "; check that the proxy allows HTTPS connections to the Aviator server"; + case CONNECT_TIMEOUT -> "Timed out connecting to " + (proxy==null ? server : proxy) + + "; check network access and firewall rules"; + case PROXY_REFUSED -> "Proxy " + context.proxy().display() + " refused the connection; check the proxy host and port"; + case DIRECT_REFUSED -> server + " refused the connection; check the Aviator URL"; + case DNS_FAILURE -> proxy==null + ? "Could not resolve the host name of " + server + "; check the Aviator URL" + : "Could not resolve the host name of " + proxy + " or " + server + "; check the proxy settings and the Aviator URL"; + case TLS_FAILURE -> "Could not set up a TLS connection to " + server + + "; check that the Aviator URL uses the HTTPS port of the Aviator server"; + case UNAVAILABLE -> server + " is unavailable or could not be reached" + + "; try again later, or check the Aviator URL, network access, and proxy settings"; + case DEADLINE_EXCEEDED -> "The Aviator request timed out; try again later" + + " (the server may be busy, or network/proxy delays may be involved)"; + }; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorEndpoint.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorEndpoint.java new file mode 100644 index 00000000000..234500c611e --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorEndpoint.java @@ -0,0 +1,36 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +/** Replaces control characters in host names before display. */ +record AviatorEndpoint(String host, int port) { + private static final String UNKNOWN_HOST = "unknown"; + + AviatorEndpoint { + host = sanitize(host); + } + + String display() { + var isUnbracketedIpv6 = host.contains(":") && !host.startsWith("["); + return isUnbracketedIpv6 ? "[" + host + "]:" + port : host + ":" + port; + } + + private static String sanitize(String host) { + if ( host==null || host.isBlank() ) { + return UNKNOWN_HOST; + } + var result = new StringBuilder(host.length()); + host.codePoints().forEach(c -> result.appendCodePoint(Character.isISOControl(c) ? '?' : c)); + return result.toString(); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java index 9903b9a72b6..b5ab582fe80 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java @@ -14,6 +14,7 @@ import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Queue; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; @@ -71,10 +72,7 @@ import com.fortify.grpc.token.TokenValidationResponse; import com.fortify.grpc.token.ValidateUserTokenRequest; -import io.grpc.CompressorRegistry; -import io.grpc.DecompressorRegistry; import io.grpc.ManagedChannel; -import io.grpc.ManagedChannelBuilder; public class AviatorGrpcClient implements AutoCloseable { private static final Logger LOG = LoggerFactory.getLogger(AviatorGrpcClient.class); @@ -94,15 +92,26 @@ public class AviatorGrpcClient implements AutoCloseable { private final java.util.concurrent.ScheduledExecutorService pingScheduler; final AtomicBoolean isShutdown = new AtomicBoolean(false); - public AviatorGrpcClient(ManagedChannel channel, long defaultTimeoutSeconds, IAviatorLogger logger, long pingIntervalSeconds) { + /** Fail-fast clients require a connection context for error messages. */ + AviatorGrpcClient(ManagedChannel channel, long defaultTimeoutSeconds, IAviatorLogger logger, long pingIntervalSeconds, + AviatorRequestPolicy requestPolicy, AviatorConnectionContext connectionContext) { LOG.debug("Initializing AviatorGrpcClient with ManagedChannel"); + Objects.requireNonNull(requestPolicy, "requestPolicy"); this.logger = logger; this.channel = channel; this.asyncStub = AuditorServiceGrpc.newStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); - this.blockingStub = ApplicationServiceGrpc.newBlockingStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); - this.tokenServiceBlockingStub = TokenServiceGrpc.newBlockingStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); - this.entitlementServiceBlockingStub = EntitlementServiceGrpc.newBlockingStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); - this.dastEntitlementServiceBlockingStub = DastEntitlementServiceGrpc.newBlockingStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); + this.blockingStub = requestPolicy.configureNewStub(ApplicationServiceGrpc.newBlockingStub(channel) + .withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE) + .withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE), connectionContext); + this.tokenServiceBlockingStub = requestPolicy.configureNewStub(TokenServiceGrpc.newBlockingStub(channel) + .withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE) + .withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE), connectionContext); + this.entitlementServiceBlockingStub = requestPolicy.configureNewStub(EntitlementServiceGrpc.newBlockingStub(channel) + .withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE) + .withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE), connectionContext); + this.dastEntitlementServiceBlockingStub = requestPolicy.configureNewStub(DastEntitlementServiceGrpc.newBlockingStub(channel) + .withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE) + .withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE), connectionContext); this.correlationAsyncStub = CorrelationServiceGrpc.newStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); this.dastAuditAsyncStub = DastAuditServiceGrpc.newStub(channel) .withCompression("gzip") @@ -123,15 +132,6 @@ public AviatorGrpcClient(ManagedChannel channel, long defaultTimeoutSeconds, IAv }); } - public AviatorGrpcClient(String host, int port, long defaultTimeoutSeconds, IAviatorLogger logger, long pingIntervalSeconds) { - this(ManagedChannelBuilder.forAddress(host, port).useTransportSecurity().maxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).keepAliveTime(30, TimeUnit.SECONDS).keepAliveTimeout(10, TimeUnit.SECONDS).keepAliveWithoutCalls(true).enableRetry().compressorRegistry(CompressorRegistry.getDefaultInstance()).decompressorRegistry(DecompressorRegistry.getDefaultInstance()).build(), defaultTimeoutSeconds, logger, pingIntervalSeconds); - LOG.debug("Initialized AviatorGrpcClient - Host: {}, Port: {}", host, port); - } - - public AviatorGrpcClient(ManagedChannel channel, long defaultTimeoutSeconds, IAviatorLogger logger) { - this(channel, defaultTimeoutSeconds, logger, 30); - } - public CompletableFuture> processBatchRequests(Queue requests, String projectName, String FPRBuildId, String SSCApplicationName, String SSCApplicationVersion, String token, FprHandle fprHandle, List customPriorityOrder, ISourceDecoder sourceDecoder, FVDLMetadata fvdlMetadata) { diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelper.java index d01c9d04586..bace76628ea 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelper.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelper.java @@ -15,6 +15,7 @@ import java.net.InetSocketAddress; import java.net.SocketAddress; import java.net.URI; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -44,12 +45,20 @@ public class AviatorGrpcClientHelper { private static final Logger LOG = LoggerFactory.getLogger(AviatorGrpcClientHelper.class); private static final String AVIATOR_MODULE = "aviator"; - public static AviatorGrpcClient createClient(String url, IAviatorLogger logger, long pingIntervalSeconds) throws AviatorSimpleException { - if (url == null || url.trim().isEmpty()) { - throw new AviatorSimpleException("Aviator URL cannot be null or empty."); - } + /** + * Creates a client with an explicit unary request policy. + * Streaming calls retain their own recovery behavior regardless of this policy. + */ + public static AviatorGrpcClient createClient(String url, IAviatorLogger logger, long pingIntervalSeconds, + AviatorRequestPolicy policy) throws AviatorSimpleException { + Objects.requireNonNull(policy, "policy"); + return createClient(createConnectionPlan(url), logger, pingIntervalSeconds, policy); + } - var connectionPlan = createConnectionPlan(url); + /** Accepts an explicit route so callers need not resolve proxy settings again. */ + static AviatorGrpcClient createClient(AviatorConnectionPlan connectionPlan, IAviatorLogger logger, long pingIntervalSeconds, + AviatorRequestPolicy policy) { + Objects.requireNonNull(policy, "policy"); var proxyDescriptor = connectionPlan.proxyDescriptor(); var target = connectionPlan.target(); var builder = target.port() == null @@ -63,7 +72,8 @@ public static AviatorGrpcClient createClient(String url, IAviatorLogger logger, } var channel = configureBuilder(builder, proxyDescriptor).build(); - return new AviatorGrpcClient(channel, Constants.DEFAULT_TIMEOUT_SECONDS, logger, pingIntervalSeconds); + return new AviatorGrpcClient(channel, Constants.DEFAULT_TIMEOUT_SECONDS, logger, pingIntervalSeconds, + policy, AviatorConnectionContext.of(connectionPlan)); } public static AviatorConnectionPlan createConnectionPlan(String url) { @@ -79,27 +89,29 @@ public static AviatorConnectionPlan createConnectionPlan(String url) { public static ParsedTarget parseTarget(String url) { var normalizedUrl = normalizeUrl(url); + var safeUrl = toSafeUrlForDisplay(url); URI uri; try { uri = URI.create(normalizedUrl); - } catch (Exception e) { - throw new AviatorSimpleException("Aviator URL format is invalid. Expected 'host:port' or a valid target string. Provided URL: " + url, e); + } catch (IllegalArgumentException e) { + // URI parsing errors include the original URL, which may contain credentials. + throw new AviatorSimpleException("Invalid Aviator URL; expected a host and optional port: " + safeUrl); } var host = uri.getHost(); if ( host==null || host.isBlank() ) { - throw new AviatorSimpleException("Aviator URL is invalid: Host cannot be empty. Provided URL: " + url); + throw new AviatorSimpleException("Aviator URL is invalid: Host cannot be empty. Provided URL: " + safeUrl); } if ( uri.getPath()!=null && !uri.getPath().isBlank() && !"/".equals(uri.getPath()) ) { - LOG.warn("WARN: URL contained a path ('{}'), using only the host/port part. Full URL: {}", uri.getPath(), url); + LOG.warn("WARN: Ignoring path in Aviator URL; using only the host and port"); } var port = uri.getPort(); if ( port==-1 ) { return new ParsedTarget(host, null); } else if (port <= 0 || port > 65535) { - throw new AviatorSimpleException("Aviator URL is invalid: Invalid port number '"+port+"'. Provided URL: " + url); + throw new AviatorSimpleException("Aviator URL is invalid: Invalid port number '"+port+"'. Provided URL: " + safeUrl); } else { return new ParsedTarget(host, port); } @@ -121,7 +133,7 @@ public static String normalizeUrl(String url) { var schemeSeparator = trimmed.indexOf("://"); var scheme = trimmed.substring(0, schemeSeparator); if ( !"https".equalsIgnoreCase(scheme) ) { - throw new UnsupportedAviatorUrlSchemeException(scheme, url); + throw new UnsupportedAviatorUrlSchemeException(scheme, toSafeUrlForDisplay(url)); } if ( "https".equals(scheme) ) { return trimmed; @@ -130,6 +142,27 @@ public static String normalizeUrl(String url) { return "https"+trimmed.substring(schemeSeparator); } + /** + * Returns only the URL scheme and authority, with user-info replaced by {@code ***}. + * Paths, queries, and fragments are omitted because they can contain credentials. + */ + static String toSafeUrlForDisplay(String url) { + if ( url==null ) { + return null; + } + var schemeSeparator = url.indexOf("://"); + var authorityStart = schemeSeparator<0 ? 0 : schemeSeparator+3; + var authorityEnd = authorityStart; + while ( authorityEnd proxyDescriptor) { var configuredBuilder = builder .sslContext(createSslContext()) @@ -171,8 +204,11 @@ static ProxiedSocketAddress toProxiedSocketAddress(SocketAddress targetAddress, return builder.build(); } - public static AviatorGrpcClient createClient(String url) throws AviatorSimpleException { - return createClient(url, null, Constants.DEFAULT_PING_INTERVAL_SECONDS); + /** + * Creates a client with the default ping interval and an explicit unary request policy. + */ + public static AviatorGrpcClient createClient(String url, AviatorRequestPolicy policy) throws AviatorSimpleException { + return createClient(url, null, Constants.DEFAULT_PING_INTERVAL_SECONDS, policy); } public record AviatorConnectionPlan( diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorRequestPolicy.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorRequestPolicy.java new file mode 100644 index 00000000000..257a71b4165 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorRequestPolicy.java @@ -0,0 +1,36 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import java.util.Objects; + +import io.grpc.stub.AbstractBlockingStub; + +/** Unary request behavior; audit and diagnostic clients use {@link #WAIT_FOR_READY}. */ +public enum AviatorRequestPolicy { + FAIL_FAST, + WAIT_FOR_READY; + + boolean isWaitForReady() { + return this == WAIT_FOR_READY; + } + + /** Requires a fresh stub: gRPC's default is fail-fast, and wait-for-ready cannot be unset through the stub API. */ + > S configureNewStub(S stub, AviatorConnectionContext context) { + if ( isWaitForReady() ) { + return stub.withWaitForReady(); + } + return stub.withOption(AviatorConnectionContext.CALL_OPTION, Objects.requireNonNull(context, "connectionContext")); + } + +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorStreamProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorStreamProcessor.java index 959c68a53f2..8482bb39788 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorStreamProcessor.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorStreamProcessor.java @@ -87,6 +87,7 @@ class AviatorStreamProcessor implements AutoCloseable { private RequestHandler requestHandler; private ScheduledFuture pingTask; + private volatile StreamInitializationTimeout initializationTimeout; private final AtomicBoolean isPinging = new AtomicBoolean(false); private final ConcurrentLinkedDeque processingQueue = new ConcurrentLinkedDeque<>(); @@ -191,14 +192,23 @@ private void startStreamWithRetry(Map responses, AtomicIn this.streamLatch = new CountDownLatch(1); CountDownLatch initLatch = new CountDownLatch(1); + var attemptHandler = requestHandler; + var initializationTimeout = new StreamInitializationTimeout(pingScheduler, + () -> attemptHandler.sendError(StreamInitializationTimeout.expiredError("Audit"))); + this.initializationTimeout = initializationTimeout; + resultFuture.whenComplete((result, error) -> { + initializationTimeout.close(); + initLatch.countDown(); + }); ClientResponseObserver responseObserver = createResponseObserver( - responses, processedRequests, resultFuture, this.streamLatch, initLatch); + responses, processedRequests, resultFuture, this.streamLatch, initLatch, initializationTimeout); try { asyncStub.processStream(responseObserver); LOG.info("Sending initialization request for stream retry {}", currentStreamState.streamRetryCount); sendInitRequest(); + initializationTimeout.arm(); if (currentStreamState.streamRetryCount > 1) { requestSemaphore.drainPermits(); @@ -208,9 +218,8 @@ private void startStreamWithRetry(Map responses, AtomicIn processingExecutor.submit(() -> { try { // Outer try to propagate to future - if (!initLatch.await(30, TimeUnit.SECONDS)) { - throw new AviatorTechnicalException("Stream initialization timed out"); - } + initLatch.await(); + if (resultFuture.isDone() || !currentStreamState.isStreamInitialized) return; if (currentStreamState.streamRetryCount > 1) { reQueueUnprocessedRequests(); @@ -302,7 +311,7 @@ private void startStreamWithRetry(Map responses, AtomicIn private ClientResponseObserver createResponseObserver( Map responses, AtomicInteger processedRequests, CompletableFuture> resultFuture, - CountDownLatch streamLatch, CountDownLatch initLatch) { + CountDownLatch streamLatch, CountDownLatch initLatch, StreamInitializationTimeout initializationTimeout) { return new ClientResponseObserver() { private final AtomicBoolean isInitialized = new AtomicBoolean(false); @@ -361,6 +370,7 @@ public void onNext(AuditorResponse response) { if (completedWrapper == null) { if (!isInitialized.get()) { if ("SUCCESS".equals(response.getStatus())) { + if (!initializationTimeout.initialized()) return; if (response.hasQuota()) { currentStreamState.quota = response.getQuota(); if (response.hasQuotaLastUpdated()) { @@ -428,7 +438,15 @@ public void onNext(AuditorResponse response) { @Override public void onError(Throwable t) { + boolean initializationExpired = initializationTimeout.isExpired(); + initializationTimeout.close(); + initLatch.countDown(); stopPingPong(); + if (initializationExpired) { + resultFuture.completeExceptionally(StreamInitializationTimeout.expiredError("Audit")); + streamLatch.countDown(); + return; + } if (isRetryableError(t)) { boolean infinite = isInfiniteRetryError(t); @@ -512,6 +530,8 @@ public void onError(Throwable t) { @Override public void onCompleted() { + initializationTimeout.close(); + initLatch.countDown(); stopPingPong(); logger.progress("Stream completed by server"); if (!resultFuture.isDone()) { @@ -1068,6 +1088,7 @@ private void sendPing() { @Override public void close() { + if (initializationTimeout != null) initializationTimeout.close(); stopPingPong(); if (requestHandler != null && !requestHandler.isCompleted()) { requestHandler.complete(); // Flush remaining requests diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessor.java index 4b2b27fb3e5..008b414cdd4 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessor.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessor.java @@ -98,6 +98,7 @@ public class CorrelationStreamProcessor implements AutoCloseable { private final Set pendingCorrelationRequestIds = ConcurrentHashMap.newKeySet(); private final ConcurrentHashMap validationRequestToDastId = new ConcurrentHashMap<>(); private volatile CompletableFuture resultFuture; + private volatile StreamInitializationTimeout initializationTimeout; /** * Keys of SAST–DAST pairs that were confirmed or rejected in a previous run. @@ -164,6 +165,9 @@ public CompletableFuture processCorrelation( this.resultFuture = resultFuture; this.streamLatch = new CountDownLatch(1); + initializationTimeout = new StreamInitializationTimeout(pingScheduler, + () -> requestHandler.sendError(StreamInitializationTimeout.expiredError("Correlation"))); + resultFuture.whenComplete((result, error) -> initializationTimeout.close()); startStream(resultFuture, scanGuid); return resultFuture; @@ -223,6 +227,7 @@ private void startStream(CompletableFuture resultFuture, Stri requestHandler.sendRequest( CorrelationClientMessage.newBuilder().setInit(initReq.build()).build() ); + initializationTimeout.arm(); } // ─── Phase 1: Send correlation requests ──────────────────────────── @@ -308,6 +313,11 @@ public void onNext(CorrelationServerMessage message) { @Override public void onError(Throwable t) { stopPingPong(); + if (initializationTimeout.isExpired()) { + resultFuture.completeExceptionally(StreamInitializationTimeout.expiredError("Correlation")); + streamLatch.countDown(); + return; + } Status status = Status.fromThrowable(t); LOG.error("Correlation stream error: {} - {}", status.getCode(), status.getDescription(), t); resultFuture.completeExceptionally( @@ -331,6 +341,7 @@ public void onCompleted() { // ─── Response handlers ───────────────────────────────────────────── private void handleInitResponse(CorrelationInitResponse resp) { + if (!initializationTimeout.initialized()) return; LOG.info("Correlation stream initialized. Server stream ID: {}, Reserved quota: {}, Status: {}", resp.getServerStreamId(), resp.getReservedQuota(), resp.getStatus()); @@ -717,6 +728,7 @@ public record CorrelationBucketData( @Override public void close() { + if (initializationTimeout != null) initializationTimeout.close(); stopPingPong(); if (requestHandler != null && !requestHandler.isCompleted()) { requestHandler.complete(); diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessor.java index c830ce7949c..e328483bfb9 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessor.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessor.java @@ -84,6 +84,7 @@ public class DastAuditStreamProcessor implements AutoCloseable { private final Map requestIdsByIssue = new ConcurrentHashMap<>(); private final Set completedRequestIds = ConcurrentHashMap.newKeySet(); private final Set pendingIssueIds = ConcurrentHashMap.newKeySet(); + private volatile StreamInitializationTimeout initializationTimeout; private int reservedQuota; private int exceededCount; private boolean unlimitedQuota; @@ -118,6 +119,7 @@ public CompletableFuture process( this.isClosed.set(false); this.quotaMetadataInitialized = false; initializeWorkItems(workItems); + resultFuture.whenComplete((result, error) -> closeInitializationTimeout()); startStream(); return resultFuture; } @@ -141,11 +143,16 @@ private void startStream() { streamId, pendingIssueIds.size(), totalReportedIssues); try { + var attemptHandler = requestHandler; + var attemptTimeout = new StreamInitializationTimeout(pingScheduler, + () -> attemptHandler.sendError(StreamInitializationTimeout.expiredError("DAST audit"))); + initializationTimeout = attemptTimeout; StreamObserver requestObserver = - asyncStub.processDastAuditStream(new ResponseObserver(generation)); + asyncStub.processDastAuditStream(new ResponseObserver(generation, attemptTimeout)); requestHandler.initialize(requestObserver); startPingPong(); sendInit(); + attemptTimeout.arm(); } catch (RuntimeException exception) { handleStreamError(exception); } @@ -166,6 +173,7 @@ private void sendInit() { } private void handleInit(DastAuditInitResponse response) { + if (!initializationTimeout.initialized()) return; if (!isSuccess(response.getStatus())) { fail(new AviatorSimpleException("DAST audit initialization failed: " + response.getStatusMessage())); return; @@ -278,9 +286,11 @@ private void completeRequestsIfDone() { private class ResponseObserver implements ClientResponseObserver { private final long generation; + private final StreamInitializationTimeout attemptTimeout; - private ResponseObserver(long generation) { + private ResponseObserver(long generation, StreamInitializationTimeout attemptTimeout) { this.generation = generation; + this.attemptTimeout = attemptTimeout; } private boolean isCurrent() { @@ -308,6 +318,11 @@ public void onNext(DastAuditServerMessage message) { public void onError(Throwable throwable) { if (!isCurrent()) return; isRpcCompleted.set(true); + if (attemptTimeout.isExpired()) { + stopPingPong(); + completeExceptionally(StreamInitializationTimeout.expiredError("DAST audit")); + return; + } handleStreamError(throwable); } @@ -349,6 +364,7 @@ private void startPingPong() { } private void handleStreamError(Throwable throwable) { + closeInitializationTimeout(); stopPingPong(); if (resultFuture.isDone() || isClosed.get() || isRetryScheduled.get()) return; @@ -468,6 +484,11 @@ private void stopPingPong() { if (pingTask != null) pingTask.cancel(false); } + private void closeInitializationTimeout() { + var timeout = initializationTimeout; + if (timeout != null) timeout.close(); + } + private boolean isSuccess(String status) { return "SUCCESS".equalsIgnoreCase(status) || "OK".equalsIgnoreCase(status); } @@ -479,6 +500,7 @@ private String value(String value) { @Override public void close() { isClosed.set(true); + closeInitializationTimeout(); stopPingPong(); if (retryTask != null) retryTask.cancel(false); if (!isRpcCompleted.get() && activeRequestStream != null) { diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/GrpcUtil.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/GrpcUtil.java index 1fc5785ca33..c3992cd289f 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/GrpcUtil.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/GrpcUtil.java @@ -12,10 +12,13 @@ */ package com.fortify.cli.aviator.grpc; -import java.util.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import org.apache.commons.lang3.exception.ExceptionUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,6 +29,7 @@ import com.fortify.aviator.grpc.Fragment; import com.fortify.aviator.grpc.IssueData; import com.fortify.aviator.grpc.StackTraceElementList; +import com.fortify.cli.aviator._common.exception.AviatorConnectivityException; import com.fortify.cli.aviator._common.exception.AviatorSimpleException; import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; import com.fortify.cli.aviator.audit.model.AuditResponse; @@ -50,61 +54,17 @@ interface GrpcCall { R call(S stub, T request) throws StatusRuntimeException; } + /** + * Applies the 30-second deadline to each unary call. + * Fail-fast stubs carry {@link AviatorConnectionContext#CALL_OPTION} for safe connectivity classification. + * Stubs without that option retain the existing status mapping. + */ static , T, R> R executeGrpcCall(S stub, GrpcCall call, T request, String operation) { try { - S stubWithDeadline = stub.withDeadlineAfter(Constants.DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS); - return call.call(stubWithDeadline, request); + return call.call(stub.withDeadlineAfter(Constants.DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS), request); } catch (StatusRuntimeException e) { - Status status = e.getStatus(); - String serverDescription = status.getDescription() != null && !status.getDescription().isBlank() - ? status.getDescription() - : "No additional details were provided by the server."; - - switch (status.getCode()) { - case INVALID_ARGUMENT: - throw new AviatorSimpleException(String.format("Invalid input for %s. The server reported: %s. Please check the provided arguments.", operation, serverDescription)); - case NOT_FOUND: - throw new AviatorSimpleException(String.format("The requested resource was not found during %s. The server reported: %s. Please verify the name or ID is correct.", operation, serverDescription)); - case ALREADY_EXISTS: - throw new AviatorSimpleException(String.format("Cannot perform %s because a resource with the same identifier already exists. The server reported: %s.", operation, serverDescription)); - case FAILED_PRECONDITION: - throw new AviatorSimpleException(String.format("The %s operation could not be completed because a required condition was not met. The server reported: %s.", operation, serverDescription)); - - case PERMISSION_DENIED: - if (serverDescription.toLowerCase().contains("invalid signature")) { - throw new AviatorSimpleException("Permission Denied: Invalid signature. Please verify the private key in your admin configuration is correct and corresponds to the public key registered with Aviator."); - } else { - throw new AviatorSimpleException(String.format("Permission Denied for %s. You may not have the required roles for this action. Server details: %s", operation, serverDescription)); - } - case UNAUTHENTICATED: - throw new AviatorSimpleException("Authentication Failed: The token or credentials used are invalid or expired. Please log in again using 'fcli aviator session login' or verify your admin configuration."); - - case RESOURCE_EXHAUSTED: - throw new AviatorSimpleException(String.format("The server's resource limits were exceeded during %s. Please try again later or contact support. Server details: %s", operation, serverDescription)); - case DEADLINE_EXCEEDED: - String timeoutMessage = String.format( - "The %s operation timed out because the server did not respond in time.\n\n" + - "Please check the following:\n" + - " 1. The Aviator URL in your configuration is correct and reachable.\n" + - " 2. Your network connection is stable and any firewalls or proxies are properly configured.\n\n" + - "If the URL and network are correct, the server may be experiencing high load. Please try again later.", - operation - ); - throw new AviatorSimpleException(timeoutMessage); - - case CANCELLED: - case UNKNOWN: - case ABORTED: - case UNIMPLEMENTED: - case INTERNAL: - case UNAVAILABLE: - case DATA_LOSS: - default: - String techMessage = String.format("A technical error occurred on the server while performing the %s operation. Please check the fcli logs for details or contact support if the issue persists.", operation); - String logMessage = String.format("gRPC call for '%s' failed with status %s (%s): %s", operation, status.getCode(), status.getCode().name(), serverDescription); - LOG.error(logMessage, e); - throw new AviatorTechnicalException(techMessage, e); - } + throw toConnectivityException(e, stub.getCallOptions().getOption(AviatorConnectionContext.CALL_OPTION)) + .orElseGet(() -> toAviatorException(e, operation)); } catch (Exception e) { String errorMessage = "An unexpected client-side error occurred during the " + operation + " operation."; LOG.error(errorMessage, e); @@ -112,6 +72,77 @@ static , T, R> R executeGrpcCall(S stub, GrpcC } } + private static Optional toConnectivityException(StatusRuntimeException e, AviatorConnectionContext context) { + if ( context == null ) { + return Optional.empty(); + } + return AviatorConnectivityFailureKind.classify(e, context) + .map(kind -> { + // Cause messages can contain credentials, so log only the cause types. + LOG.debug("Aviator connectivity failure: kind={}, causes=[{}]", kind, getCauseTypes(e)); + return new AviatorConnectivityException(kind.formatMessage(context)); + }); + } + + private static String getCauseTypes(Throwable e) { + return ExceptionUtils.getThrowableList(e).stream() + .map(t -> t.getClass().getName()) + .collect(Collectors.joining(" <- ")); + } + + private static RuntimeException toAviatorException(StatusRuntimeException e, String operation) { + Status status = e.getStatus(); + String serverDescription = status.getDescription() != null && !status.getDescription().isBlank() + ? status.getDescription() + : "No additional details were provided by the server."; + + switch (status.getCode()) { + case INVALID_ARGUMENT: + return new AviatorSimpleException(String.format("Invalid input for %s. The server reported: %s. Please check the provided arguments.", operation, serverDescription)); + case NOT_FOUND: + return new AviatorSimpleException(String.format("The requested resource was not found during %s. The server reported: %s. Please verify the name or ID is correct.", operation, serverDescription)); + case ALREADY_EXISTS: + return new AviatorSimpleException(String.format("Cannot perform %s because a resource with the same identifier already exists. The server reported: %s.", operation, serverDescription)); + case FAILED_PRECONDITION: + return new AviatorSimpleException(String.format("The %s operation could not be completed because a required condition was not met. The server reported: %s.", operation, serverDescription)); + + case PERMISSION_DENIED: + if (serverDescription.toLowerCase().contains("invalid signature")) { + return new AviatorSimpleException("Permission Denied: Invalid signature. Please verify the private key in your admin configuration is correct and corresponds to the public key registered with Aviator."); + } else { + return new AviatorSimpleException(String.format("Permission Denied for %s. You may not have the required roles for this action. Server details: %s", operation, serverDescription)); + } + case UNAUTHENTICATED: + return new AviatorSimpleException("Authentication Failed: The token or credentials used are invalid or expired. Please log in again using 'fcli aviator session login' or verify your admin configuration."); + + case RESOURCE_EXHAUSTED: + return new AviatorSimpleException(String.format("Aviator server resource limits were exceeded during %s (server details: %s); try again later, and contact support if the problem continues", operation, serverDescription)); + case DEADLINE_EXCEEDED: + String timeoutMessage = String.format( + "The %s operation timed out because the server did not respond in time.\n\n" + + "Please check the following:\n" + + " 1. The Aviator URL in your configuration is correct and reachable.\n" + + " 2. Your network connection is stable and any firewalls or proxies are properly configured.\n\n" + + "If the URL and network are correct, the server may be experiencing high load. Please try again later.", + operation + ); + return new AviatorSimpleException(timeoutMessage); + + case CANCELLED: + case UNKNOWN: + case ABORTED: + case UNIMPLEMENTED: + case INTERNAL: + case UNAVAILABLE: + case DATA_LOSS: + default: + String techMessage = String.format("The Aviator %s operation failed with status %s; check the Aviator URL and network access, run the command again with --log-level DEBUG to log details, and contact support if the problem continues", operation, status.getCode()); + String logMessage = String.format("gRPC call for '%s' failed with status %s (%s): %s", operation, status.getCode(), status.getCode().name(), serverDescription); + LOG.error(logMessage, e); + return new AviatorTechnicalException(techMessage, e); + } + } + static AuditRequest convertToAuditRequest(UserPrompt userPrompt, String streamId, String requestId) { List stackTraceElementLists = new ArrayList<>(); if (userPrompt.getStackTrace() != null) { diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/StreamInitializationTimeout.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/StreamInitializationTimeout.java new file mode 100644 index 00000000000..e72f3a34ddc --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/StreamInitializationTimeout.java @@ -0,0 +1,86 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; + +/** + * Bounds the wait for one stream attempt's initialization response. The expiry action must only + * cancel the RPC; the processor's own onError path then runs its normal terminal handling. + * + *

TODO: Nothing bounds silence after initialization (pongs are only logged). Add an idle/no-progress + * timeout for the SAST, DAST and correlation streams. + */ +final class StreamInitializationTimeout implements AutoCloseable { + private static final long TIMEOUT_SECONDS = 30; + + private enum State { IDLE, WAITING, INITIALIZED, EXPIRED, CLOSED } + + private final ScheduledExecutorService scheduler; + private final Runnable cancelRpc; + private final AtomicReference state = new AtomicReference<>(State.IDLE); + private volatile ScheduledFuture timeout; + + StreamInitializationTimeout(ScheduledExecutorService scheduler, Runnable cancelRpc) { + this.scheduler = scheduler; + this.cancelRpc = cancelRpc; + } + + static AviatorSimpleException expiredError(String operation) { + return new AviatorSimpleException(operation + + " initialization timed out. Check the Aviator URL, proxy settings, and server availability."); + } + + /** Starts the countdown after the init request is queued. Does nothing without a scheduler. */ + void arm() { + if (scheduler == null || !state.compareAndSet(State.IDLE, State.WAITING)) return; + timeout = scheduler.schedule(this::expire, TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (state.get() != State.WAITING) timeout.cancel(false); + } + + /** Returns false if the timeout or a close won the race with the initialization response. */ + boolean initialized() { + if (state.compareAndSet(State.WAITING, State.INITIALIZED) + || state.compareAndSet(State.IDLE, State.INITIALIZED)) { + cancelTimer(); + } + return state.get() == State.INITIALIZED; + } + + boolean isExpired() { + return state.get() == State.EXPIRED; + } + + @Override + public void close() { + if (state.compareAndSet(State.WAITING, State.CLOSED) || state.compareAndSet(State.IDLE, State.CLOSED)) { + cancelTimer(); + } + } + + private void expire() { + if (state.compareAndSet(State.WAITING, State.EXPIRED)) { + cancelRpc.run(); + } + } + + private void cancelTimer() { + var scheduled = timeout; + if (scheduled != null) scheduled.cancel(false); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/exception/AviatorConnectivityExceptionTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/exception/AviatorConnectivityExceptionTest.java new file mode 100644 index 00000000000..0e7f83292aa --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/exception/AviatorConnectivityExceptionTest.java @@ -0,0 +1,95 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.exception; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.PrintWriter; +import java.io.StringWriter; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; +import com.fortify.cli.common.exception.FcliExecutionExceptionHandler; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.ThrowableProxyUtil; +import ch.qos.logback.core.read.ListAppender; +import picocli.CommandLine; +import picocli.CommandLine.Help.Ansi; +import picocli.CommandLine.Model.CommandSpec; + +class AviatorConnectivityExceptionTest { + private static final String SECRET = "sentinel-secret-value"; + + private final Logger logger = (Logger)LoggerFactory.getLogger(FcliExecutionExceptionHandler.class); + private final ListAppender appender = new ListAppender<>(); + private Level previousLevel; + + @BeforeEach + void setUp() { + previousLevel = logger.getLevel(); + logger.setLevel(Level.DEBUG); + appender.start(); + logger.addAppender(appender); + } + + @AfterEach + void tearDown() { + logger.detachAppender(appender); + logger.setLevel(previousLevel); + } + + @Test + void handlerPrintsTheMessageWithoutCause() { + var err = new StringWriter(); + var exception = new AviatorConnectivityException("Proxy localhost:33444 refused the connection; check the proxy host and port"); + + int exitCode = FcliExecutionExceptionHandler.INSTANCE.handleException(exception, createCommandLine(err)); + + assertEquals(1, exitCode); + assertNull(exception.getCause()); + assertFalse(err.toString().contains("Caused by")); + } + + @Test + void malformedUrlDoesNotExposeCredentialsInOutputOrLogs() { + var exception = assertThrows(AviatorSimpleException.class, + () -> AviatorGrpcClientHelper.parseTarget("https://user:" + SECRET + "@aviator.invalid/%zz")); + var err = new StringWriter(); + + assertEquals(1, FcliExecutionExceptionHandler.INSTANCE.handleException(exception, createCommandLine(err))); + + assertNull(exception.getCause()); + assertFalse(err.toString().contains(SECRET)); + assertEquals(1, appender.list.size()); + var event = appender.list.get(0); + assertFalse(event.getFormattedMessage().contains(SECRET)); + assertFalse(ThrowableProxyUtil.asString(event.getThrowableProxy()).contains(SECRET)); + } + + private static CommandLine createCommandLine(StringWriter err) { + var commandLine = new CommandLine(CommandSpec.create()); + commandLine.setErr(new PrintWriter(err)); + commandLine.setColorScheme(CommandLine.Help.defaultColorScheme(Ansi.OFF)); + return commandLine; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorConnectionContextTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorConnectionContextTest.java new file mode 100644 index 00000000000..1062082a3b4 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorConnectionContextTest.java @@ -0,0 +1,77 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper.AviatorConnectionPlan; +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper.ParsedTarget; +import com.fortify.cli.common.http.proxy.helper.ProxyDescriptor; + +class AviatorConnectionContextTest { + private static final String PROXY_PASSWORD = "sentinel-proxy-password"; + + @Test + void createsDirectContextFromPlan() { + var context = AviatorConnectionContext.of(plan(Optional.empty())); + + assertFalse(context.isProxied()); + assertNull(context.proxy()); + assertEquals("aviator.example.com:443", context.target().display()); + } + + @Test + void createsProxiedContextWithoutCredentials() { + var proxy = ProxyDescriptor.builder() + .proxyHost("localhost").proxyPort(33444) + .proxyUser("sentinel-user").proxyPassword(PROXY_PASSWORD.toCharArray()) + .build(); + + var context = AviatorConnectionContext.of(plan(Optional.of(proxy))); + + assertTrue(context.isProxied()); + assertEquals("localhost:33444", context.proxy().display()); + assertFalse(context.toString().contains(PROXY_PASSWORD)); + assertFalse(context.toString().contains("sentinel-user")); + } + + @Test + void requiresTarget() { + assertThrows(NullPointerException.class, () -> new AviatorConnectionContext(null, null)); + } + + @Test + void replacesControlCharactersInHostNames() { + assertEquals("evil?host:1", new AviatorEndpoint("evil\u001bhost", 1).display()); + assertEquals("unknown:1", new AviatorEndpoint(null, 1).display()); + } + + @Test + void displaysIpv6Hosts() { + assertEquals("[::1]:443", new AviatorEndpoint("::1", 443).display()); + assertEquals("[::1]:443", new AviatorEndpoint("[::1]", 443).display()); + } + + private static AviatorConnectionPlan plan(Optional proxy) { + return new AviatorConnectionPlan("aviator.example.com", "https://aviator.example.com", + new ParsedTarget("aviator.example.com", null), 443, proxy); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorConnectivityFailureKindTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorConnectivityFailureKindTest.java new file mode 100644 index 00000000000..d7cef4fd268 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorConnectivityFailureKindTest.java @@ -0,0 +1,191 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.ConnectException; +import java.net.UnknownHostException; +import java.nio.channels.UnresolvedAddressException; +import java.security.cert.CertPathValidatorException; +import java.security.cert.CertificateException; +import java.util.Optional; + +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLHandshakeException; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.netty.shaded.io.netty.channel.ConnectTimeoutException; +import io.grpc.netty.shaded.io.netty.handler.codec.http.DefaultHttpHeaders; +import io.grpc.netty.shaded.io.netty.handler.proxy.HttpProxyHandler.HttpProxyConnectException; +import io.grpc.netty.shaded.io.netty.handler.proxy.ProxyConnectException; + +class AviatorConnectivityFailureKindTest { + private static final AviatorConnectionContext DIRECT = + new AviatorConnectionContext(new AviatorEndpoint("aviator.example.com", 443), null); + private static final AviatorConnectionContext PROXIED = + new AviatorConnectionContext(new AviatorEndpoint("aviator.example.com", 443), new AviatorEndpoint("localhost", 33444)); + + @Test + void classifiesProxyRejectionBeforeOtherConnectExceptions() { + var cause = new HttpProxyConnectException("status: 407", new DefaultHttpHeaders()); + assertEquals(Optional.of(AviatorConnectivityFailureKind.PROXY_REJECTED), classify(Status.UNAVAILABLE, cause, PROXIED)); + } + + @Test + void classifiesOtherProxyFailuresAsNegotiationFailure() { + var cause = new ProxyConnectException("timeout"); + assertEquals(Optional.of(AviatorConnectivityFailureKind.PROXY_NEGOTIATION_FAILED), classify(Status.UNAVAILABLE, cause, PROXIED)); + } + + @Test + void classifiesConnectTimeoutBeforeRefusal() { + var cause = new ConnectTimeoutException("connection timed out"); + assertEquals(Optional.of(AviatorConnectivityFailureKind.CONNECT_TIMEOUT), classify(Status.UNAVAILABLE, cause, DIRECT)); + } + + @Test + void classifiesConnectExceptionAsProxyRefusalWhenProxied() { + var cause = new ConnectException("Connection refused"); + assertEquals(Optional.of(AviatorConnectivityFailureKind.PROXY_REFUSED), classify(Status.UNAVAILABLE, cause, PROXIED)); + } + + @ParameterizedTest + @ValueSource(strings = {"Connection refused", "Connection refused: getsockopt", "Connection refused: no further information"}) + void classifiesConnectExceptionAsDirectRefusalWhenNotProxied(String reason) { + var cause = new ConnectException(reason); + assertEquals(Optional.of(AviatorConnectivityFailureKind.DIRECT_REFUSED), classify(Status.UNAVAILABLE, cause, DIRECT)); + } + + @Test + void classifiesNameResolutionFailures() { + assertEquals(Optional.of(AviatorConnectivityFailureKind.DNS_FAILURE), + classify(Status.UNAVAILABLE, new UnknownHostException("host"), DIRECT)); + assertEquals(Optional.of(AviatorConnectivityFailureKind.DNS_FAILURE), + classify(Status.UNAVAILABLE, new UnresolvedAddressException(), PROXIED)); + } + + @Test + void usesNeutralKindsWithoutTransportEvidence() { + assertEquals(Optional.of(AviatorConnectivityFailureKind.UNAVAILABLE), classify(Status.UNAVAILABLE, null, DIRECT)); + assertEquals(Optional.of(AviatorConnectivityFailureKind.DEADLINE_EXCEEDED), classify(Status.DEADLINE_EXCEEDED, null, DIRECT)); + } + + @Test + void leavesCertificateFailuresToPkixGuidance() { + var pkix = new SSLHandshakeException("PKIX path building failed"); + pkix.initCause(new CertificateException("untrusted")); + assertEquals(Optional.empty(), classify(Status.UNAVAILABLE, pkix, DIRECT)); + assertEquals(Optional.empty(), classify(Status.UNAVAILABLE, new CertPathValidatorException("untrusted"), DIRECT)); + } + + @Test + void classifiesOtherTlsFailures() { + assertEquals(Optional.of(AviatorConnectivityFailureKind.TLS_FAILURE), + classify(Status.UNAVAILABLE, new SSLException("Unsupported or unrecognized SSL message"), PROXIED)); + assertEquals(Optional.of(AviatorConnectivityFailureKind.TLS_FAILURE), + classify(Status.UNAVAILABLE, new SSLHandshakeException("handshake timed out"), DIRECT)); + } + + @Test + void usesNeutralKindForProxyFailuresWithoutFcliProxy() { + var cause = new HttpProxyConnectException("status: 407", new DefaultHttpHeaders()); + assertEquals(Optional.of(AviatorConnectivityFailureKind.UNAVAILABLE), classify(Status.UNAVAILABLE, cause, DIRECT)); + assertEquals(Optional.of(AviatorConnectivityFailureKind.DEADLINE_EXCEEDED), + classify(Status.DEADLINE_EXCEEDED, new ProxyConnectException("timeout"), DIRECT)); + } + + @ParameterizedTest + @EnumSource(value = Status.Code.class, names = {"UNAVAILABLE", "DEADLINE_EXCEEDED"}, mode = EnumSource.Mode.EXCLUDE) + void ignoresOtherStatusCodes(Status.Code code) { + assertEquals(Optional.empty(), classify(Status.fromCode(code), new ConnectException("Connection refused"), DIRECT)); + } + + @Test + void terminatesOnCyclicCauseChain() { + var first = new RuntimeException("first"); + var second = new RuntimeException("second", first); + first.initCause(second); + + assertEquals(Optional.of(AviatorConnectivityFailureKind.UNAVAILABLE), classify(Status.UNAVAILABLE, first, DIRECT)); + } + + @Test + void ignoresNestedCertificateFailures() { + var cause = new RuntimeException(new CertificateException("untrusted")); + assertEquals(Optional.empty(), classify(Status.UNAVAILABLE, cause, DIRECT)); + } + + @Test + void doesNotInferRefusalFromAnAmbiguousConnectException() { + var cause = new ConnectException("Connection refused: openssl-handshake_failure.example/10.0.0.1:443"); + assertEquals(Optional.of(AviatorConnectivityFailureKind.UNAVAILABLE), classify(Status.UNAVAILABLE, cause, DIRECT)); + } + + @Test + void doesNotMisreportNetworkOrUnknownConnectFailuresAsRefusal() { + for ( var reason : new String[] {"Network is unreachable", "Connection timed out", "Permission denied", null} ) { + assertEquals(Optional.of(AviatorConnectivityFailureKind.UNAVAILABLE), + classify(Status.UNAVAILABLE, new ConnectException(reason), DIRECT)); + } + } + + @Test + void recognizesRefusalInsideNettyAnnotatedCause() { + var cause = new ConnectException("Connection refused: openssl-handshake_failure.example/10.0.0.1:443"); + cause.initCause(new ConnectException("Connection refused")); + assertEquals(Optional.of(AviatorConnectivityFailureKind.DIRECT_REFUSED), classify(Status.UNAVAILABLE, cause, DIRECT)); + } + + @ParameterizedTest + @EnumSource(AviatorConnectivityFailureKind.class) + void everyFailureHasASingleLineActionableMessage(AviatorConnectivityFailureKind kind) { + for ( var context : new AviatorConnectionContext[] {DIRECT, PROXIED} ) { + if ( !context.isProxied() && kind.name().startsWith("PROXY_") ) { continue; } + var message = kind.formatMessage(context); + assertEquals(1, message.lines().count(), message); + assertTrue(message.contains("; check ") || message.contains("; try "), message); + assertFalse(message.endsWith("."), message); + assertFalse(message.contains("null"), message); + } + } + + @Test + void messagesNameTheFailingEndpoint() { + assertEquals("Proxy localhost:33444 refused the connection; check the proxy host and port", + AviatorConnectivityFailureKind.PROXY_REFUSED.formatMessage(PROXIED)); + assertEquals("Aviator server aviator.example.com:443 refused the connection; check the Aviator URL", + AviatorConnectivityFailureKind.DIRECT_REFUSED.formatMessage(DIRECT)); + assertEquals("Timed out connecting to proxy localhost:33444; check network access and firewall rules", + AviatorConnectivityFailureKind.CONNECT_TIMEOUT.formatMessage(PROXIED)); + assertEquals("The Aviator request timed out; try again later" + + " (the server may be busy, or network/proxy delays may be involved)", + AviatorConnectivityFailureKind.DEADLINE_EXCEEDED.formatMessage(DIRECT)); + assertEquals("Aviator server aviator.example.com:443 is unavailable or could not be reached; " + + "try again later, or check the Aviator URL, network access, and proxy settings", + AviatorConnectivityFailureKind.UNAVAILABLE.formatMessage(DIRECT)); + } + + private static Optional classify(Status status, Throwable cause, AviatorConnectionContext context) { + var exception = new StatusRuntimeException(cause==null ? status : status.withCause(cause)); + return AviatorConnectivityFailureKind.classify(exception, context); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientConnectivityTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientConnectivityTest.java new file mode 100644 index 00000000000..47b5599425b --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientConnectivityTest.java @@ -0,0 +1,420 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import com.fortify.aviator.application.ApplicationByTenantName; +import com.fortify.aviator.application.ApplicationList; +import com.fortify.aviator.application.ApplicationServiceGrpc; +import com.fortify.aviator.dastentitlement.DastEntitlementServiceGrpc; +import com.fortify.aviator.dastentitlement.ListDastEntitlementsByTenantRequest; +import com.fortify.aviator.dastentitlement.ListDastEntitlementsByTenantResponse; +import com.fortify.aviator.entitlement.EntitlementServiceGrpc; +import com.fortify.aviator.entitlement.ListEntitlementsByTenantRequest; +import com.fortify.aviator.entitlement.ListEntitlementsByTenantResponse; +import com.fortify.cli.aviator._common.exception.AviatorConnectivityException; +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper.AviatorConnectionPlan; +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper.ParsedTarget; +import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; +import com.fortify.cli.common.http.proxy.helper.ProxyDescriptor; +import com.fortify.grpc.token.ListTokensRequest; +import com.fortify.grpc.token.ListTokensResponse; +import com.fortify.grpc.token.TokenServiceGrpc; + +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.EquivalentAddressGroup; +import io.grpc.ManagedChannel; +import io.grpc.MethodDescriptor; +import io.grpc.NameResolver; +import io.grpc.Server; +import io.grpc.Status; +import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; +import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; +import io.grpc.stub.AbstractBlockingStub; +import io.grpc.stub.StreamObserver; + +/** + * Checks connectivity behavior against real Netty transports on the loopback interface. + */ +class AviatorGrpcClientConnectivityTest { + private static final Duration FAIL_FAST_LIMIT = Duration.ofSeconds(5); + private static final String PROXY_SECRET = "sentinel-proxy-secret"; + private static final String TARGET_HOST = "aviator.example.invalid"; + + private ServerSocket proxyServer; + private Server grpcServer; + private ManagedChannel testChannel; + + @BeforeEach + void setUp() { + FcliExecutionContextHolder.pushNew(); + } + + @AfterEach + void tearDown() throws Exception { + try { + if ( proxyServer!=null ) { proxyServer.close(); } + if ( testChannel!=null ) { testChannel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); } + if ( grpcServer!=null ) { grpcServer.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); } + } finally { + FcliExecutionContextHolder.pop(); + } + } + + @Test + void directRefusalFailsFastWithDirectRefusedMessage() throws Exception { + assumeTrue(System.getenv("GRPC_PROXY_EXP")==null && System.getProperty("https.proxyHost")==null, + "This test requires a direct loopback route without a JVM or gRPC proxy"); + int port = unusedPort(); + var plan = new AviatorConnectionPlan("127.0.0.1:" + port, "https://127.0.0.1:" + port, + new ParsedTarget("127.0.0.1", port), port, Optional.empty()); + + var exception = callAndExpectConnectivityFailure(plan); + + assertEquals("Aviator server 127.0.0.1:" + port + " refused the connection; check the Aviator URL", + exception.getMessage()); + } + + @ParameterizedTest + @EnumSource(AviatorRequestPolicy.class) + void publicClientFactoriesUseExplicitPolicy(AviatorRequestPolicy policy) throws Exception { + for ( boolean withLoggerAndPing : new boolean[] {false, true} ) { + try ( var client = withLoggerAndPing + ? AviatorGrpcClientHelper.createClient("127.0.0.1:1", null, 30, policy) + : AviatorGrpcClientHelper.createClient("127.0.0.1:1", policy) ) { + for ( var name : List.of("blockingStub", "tokenServiceBlockingStub", + "entitlementServiceBlockingStub", "dastEntitlementServiceBlockingStub") ) { + var field = AviatorGrpcClient.class.getDeclaredField(name); + field.setAccessible(true); + var stub = assertInstanceOf(AbstractBlockingStub.class, field.get(client)); + assertEquals(policy.isWaitForReady(), stub.getCallOptions().isWaitForReady(), name); + assertEquals(policy == AviatorRequestPolicy.FAIL_FAST, + stub.getCallOptions().getOption(AviatorConnectionContext.CALL_OPTION) != null, name); + } + } + } + } + + @Test + void proxyRefusalFailsFastWithProxyRefusedMessage() throws Exception { + int proxyPort = unusedPort(); + + var exception = callAndExpectConnectivityFailure(proxiedPlan(proxyPort)); + + assertEquals("Proxy 127.0.0.1:" + proxyPort + " refused the connection; check the proxy host and port", + exception.getMessage()); + } + + @Test + void proxyAuthenticationRejectionIsReportedAsProxyRejection() throws Exception { + proxyServer = new ServerSocket(0, 50, InetAddress.getLoopbackAddress()); + startRejectingProxy(proxyServer); + + var exception = callAndExpectConnectivityFailure(proxiedPlan(proxyServer.getLocalPort())); + + assertTrue(exception.getMessage().startsWith("Proxy 127.0.0.1:" + proxyServer.getLocalPort() + " rejected"), + exception.getMessage()); + assertFalse(exception.getMessage().contains("sentinel-realm")); + } + + @Test + @Timeout(20) + void stalledProxyNegotiationEndsBeforeRequestDeadline() throws Exception { + proxyServer = new ServerSocket(0, 1, InetAddress.getLoopbackAddress()); + // Leave the TCP connection in the listen backlog without answering CONNECT. Netty's real + // proxy handshake timer must end the call, rather than the RPC deadline. + var started = System.nanoTime(); + try ( var client = AviatorGrpcClientHelper.createClient(proxiedPlan(proxyServer.getLocalPort()), + null, 30, AviatorRequestPolicy.FAIL_FAST) ) { + var exception = assertThrows(AviatorConnectivityException.class, + () -> client.listApplication("tenant", "signature", "message")); + assertTrue(exception.getMessage().startsWith("Could not connect to Aviator server " + TARGET_HOST + ":443 through proxy"), + exception.getMessage()); + } + assertTrue(Duration.ofNanos(System.nanoTime() - started).compareTo(Duration.ofSeconds(15)) < 0); + } + + @ParameterizedTest + @EnumSource(AviatorRequestPolicy.class) + void healthyUnaryServicesUseSelectedPolicyAndStreamsKeepWaiting(AviatorRequestPolicy policy) throws Exception { + var captured = new AtomicReference(); + try ( var client = createPlaintextClient(policy, captured) ) { + assertTrue(client.listApplication("tenant", "signature", "message").isEmpty()); + assertCallPolicy(policy, captured.get()); + assertEquals(ListTokensResponse.getDefaultInstance(), client.listTokens("email", "tenant", "signature", "message")); + assertCallPolicy(policy, captured.get()); + assertTrue(client.listEntitlements("tenant", "signature", "message").isEmpty()); + assertCallPolicy(policy, captured.get()); + assertTrue(client.listDastEntitlements("tenant", "signature", "message").isEmpty()); + assertCallPolicy(policy, captured.get()); + assertTrue(client.getCorrelationAsyncStub().getCallOptions().isWaitForReady()); + assertTrue(client.getDastAuditAsyncStub().getCallOptions().isWaitForReady()); + assertNull(client.getCorrelationAsyncStub().getCallOptions().getOption(AviatorConnectionContext.CALL_OPTION)); + assertNull(client.getDastAuditAsyncStub().getCallOptions().getOption(AviatorConnectionContext.CALL_OPTION)); + } + } + + @Test + @Timeout(15) + @SuppressWarnings("deprecation") // Per-channel resolver keeps the fixture out of the global registry. + void refusedFirstAddressFallsBackToHealthyAddress() throws Exception { + grpcServer = NettyServerBuilder.forAddress(new InetSocketAddress("127.0.0.1", 0)) + .addService(new EmptyApplicationService()).build().start(); + var refused = new InetSocketAddress("127.0.0.1", unusedPort()); + var healthy = new InetSocketAddress("127.0.0.1", grpcServer.getPort()); + testChannel = NettyChannelBuilder.forTarget("fixture:///localhost").usePlaintext() + .proxyDetector(address -> null) + .nameResolverFactory(new NameResolver.Factory() { + @Override + public String getDefaultScheme() { return "fixture"; } + + @Override + public NameResolver newNameResolver(URI targetUri, NameResolver.Args args) { + return new NameResolver() { + @Override + public String getServiceAuthority() { return "localhost"; } + + @Override + public void start(Listener2 listener) { + listener.onResult(ResolutionResult.newBuilder().setAddresses(List.of( + new EquivalentAddressGroup(refused), new EquivalentAddressGroup(healthy))).build()); + } + + @Override + public void shutdown() {} + }; + } + }).build(); + var context = new AviatorConnectionContext(new AviatorEndpoint("localhost", grpcServer.getPort()), null); + try ( var client = new AviatorGrpcClient(testChannel, 30, null, 30, AviatorRequestPolicy.FAIL_FAST, context) ) { + assertTrue(client.listApplication("tenant", "signature", "message").isEmpty()); + } + } + + private static void assertCallPolicy(AviatorRequestPolicy policy, CallOptions options) { + assertEquals(policy.isWaitForReady(), options.isWaitForReady()); + assertEquals(!policy.isWaitForReady(), options.getOption(AviatorConnectionContext.CALL_OPTION) != null); + assertDeadlineAtMostDefault(options); + } + + @Test + void serverSentUnavailableUsesNeutralMessageWithoutServerDetails() throws Exception { + grpcServer = NettyServerBuilder.forAddress(new InetSocketAddress("127.0.0.1", 0)) + .addService(new ApplicationServiceGrpc.ApplicationServiceImplBase() { + @Override + public void listApplications(ApplicationByTenantName request, StreamObserver observer) { + observer.onError(Status.UNAVAILABLE.withDescription("sentinel-maintenance-details").asRuntimeException()); + } + }).build().start(); + testChannel = NettyChannelBuilder.forAddress("127.0.0.1", grpcServer.getPort()) + .proxyDetector(address -> null).usePlaintext().build(); + var context = new AviatorConnectionContext(new AviatorEndpoint("127.0.0.1", grpcServer.getPort()), null); + try ( var client = new AviatorGrpcClient(testChannel, 30, null, 30, AviatorRequestPolicy.FAIL_FAST, context) ) { + var e = assertThrows(AviatorConnectivityException.class, + () -> client.listApplication("tenant", "signature", "message")); + + assertTrue(e.getMessage().contains("is unavailable or could not be reached")); + assertFalse(e.getMessage().contains("refused")); + assertFalse(e.getMessage().contains("sentinel-maintenance-details")); + assertNull(e.getCause()); + } + } + + @Test + @Timeout(15) + void serverSentDeadlineDoesNotClaimLocalDeadlineExpired() throws Exception { + grpcServer = NettyServerBuilder.forAddress(new InetSocketAddress("127.0.0.1", 0)) + .addService(new ApplicationServiceGrpc.ApplicationServiceImplBase() { + @Override + public void listApplications(ApplicationByTenantName request, StreamObserver observer) { + observer.onError(Status.DEADLINE_EXCEEDED.withDescription("sentinel-server-deadline-details").asRuntimeException()); + } + }).build().start(); + testChannel = NettyChannelBuilder.forAddress("127.0.0.1", grpcServer.getPort()) + .proxyDetector(address -> null).usePlaintext().build(); + var context = new AviatorConnectionContext(new AviatorEndpoint("127.0.0.1", grpcServer.getPort()), null); + try ( var client = new AviatorGrpcClient(testChannel, 30, null, 30, AviatorRequestPolicy.FAIL_FAST, context) ) { + var e = assertThrows(AviatorConnectivityException.class, + () -> client.listApplication("tenant", "signature", "message")); + + assertTrue(e.getMessage().contains("request timed out")); + assertFalse(e.getMessage().contains("30 seconds")); + assertFalse(e.getMessage().contains("did not respond")); + assertFalse(e.getMessage().contains("Timed out connecting")); + assertFalse(e.getMessage().contains("refused")); + assertFalse(e.getMessage().contains("sentinel-server-deadline-details")); + assertNull(e.getCause()); + } + } + + private static AviatorConnectivityException callAndExpectConnectivityFailure(AviatorConnectionPlan plan) { + try ( var client = AviatorGrpcClientHelper.createClient(plan, null, 30, AviatorRequestPolicy.FAIL_FAST) ) { + long start = System.nanoTime(); + var exception = assertThrows(AviatorConnectivityException.class, + () -> client.listApplication("tenant", "signature", "message")); + var elapsed = Duration.ofNanos(System.nanoTime() - start); + assertTrue(elapsed.compareTo(FAIL_FAST_LIMIT) < 0, "Call took " + elapsed); + assertNull(exception.getCause()); + assertFalse(exception.getMessage().contains(PROXY_SECRET)); + return exception; + } + } + + private static AviatorConnectionPlan proxiedPlan(int proxyPort) { + var proxy = ProxyDescriptor.builder() + .proxyHost("127.0.0.1").proxyPort(proxyPort) + .proxyUser("sentinel-user").proxyPassword(PROXY_SECRET.toCharArray()) + .build(); + return new AviatorConnectionPlan(TARGET_HOST, "https://" + TARGET_HOST, + new ParsedTarget(TARGET_HOST, null), 443, Optional.of(proxy)); + } + + private AviatorGrpcClient createPlaintextClient(AviatorRequestPolicy policy, AtomicReference captured) throws IOException { + grpcServer = NettyServerBuilder.forAddress(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0)) + .addService(new EmptyApplicationService()) + .addService(new EmptyTokenService()) + .addService(new EmptyEntitlementService()) + .addService(new EmptyDastEntitlementService()) + .build() + .start(); + testChannel = NettyChannelBuilder.forAddress(InetAddress.getLoopbackAddress().getHostAddress(), grpcServer.getPort()) + .proxyDetector(address -> null) + .usePlaintext() + .intercept(new CallOptionsCapture(captured)) + .build(); + var context = new AviatorConnectionContext(new AviatorEndpoint("127.0.0.1", grpcServer.getPort()), null); + return new AviatorGrpcClient(testChannel, 30, null, 30, policy, context); + } + + private static void assertDeadlineAtMostDefault(CallOptions callOptions) { + assertNotNull(callOptions.getDeadline()); + long remainingSeconds = callOptions.getDeadline().timeRemaining(TimeUnit.SECONDS); + assertTrue(remainingSeconds <= 30 && remainingSeconds > 20, "Deadline remaining: " + remainingSeconds); + } + + private static int unusedPort() throws IOException { + try ( var socket = new ServerSocket(0, 1, InetAddress.getLoopbackAddress()) ) { + return socket.getLocalPort(); + } + } + + private static void startRejectingProxy(ServerSocket server) { + var thread = new Thread(() -> { + while ( !server.isClosed() ) { + try ( Socket socket = server.accept() ) { + readRequestHeaders(socket.getInputStream()); + OutputStream out = socket.getOutputStream(); + out.write(("HTTP/1.1 407 Proxy Authentication Required\r\n" + + "Proxy-Authenticate: Basic realm=\"sentinel-realm\"\r\n" + + "Content-Length: 0\r\n\r\n").getBytes(StandardCharsets.US_ASCII)); + out.flush(); + } catch ( IOException e ) { + // Server socket closed at test end + } + } + }, "test-rejecting-proxy"); + thread.setDaemon(true); + thread.start(); + } + + private static void readRequestHeaders(InputStream in) throws IOException { + int matched = 0; + byte[] terminator = {'\r', '\n', '\r', '\n'}; + int b; + while ( matched responseObserver) { + responseObserver.onNext(ApplicationList.getDefaultInstance()); + responseObserver.onCompleted(); + } + } + + private static final class EmptyTokenService extends TokenServiceGrpc.TokenServiceImplBase { + @Override + public void listTokens(ListTokensRequest request, StreamObserver observer) { + observer.onNext(ListTokensResponse.getDefaultInstance()); + observer.onCompleted(); + } + } + + private static final class EmptyEntitlementService extends EntitlementServiceGrpc.EntitlementServiceImplBase { + @Override + public void listEntitlementsByTenant(ListEntitlementsByTenantRequest request, + StreamObserver observer) { + observer.onNext(ListEntitlementsByTenantResponse.getDefaultInstance()); + observer.onCompleted(); + } + } + + private static final class EmptyDastEntitlementService extends DastEntitlementServiceGrpc.DastEntitlementServiceImplBase { + @Override + public void listDastEntitlementsByTenant(ListDastEntitlementsByTenantRequest request, + StreamObserver observer) { + observer.onNext(ListDastEntitlementsByTenantResponse.getDefaultInstance()); + observer.onCompleted(); + } + } + + private static final class CallOptionsCapture implements ClientInterceptor { + private final AtomicReference captured; + + private CallOptionsCapture(AtomicReference captured) { + this.captured = captured; + } + + @Override + public ClientCall interceptCall(MethodDescriptor method, + CallOptions callOptions, Channel next) { + captured.set(callOptions); + return next.newCall(method, callOptions); + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelperTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelperTest.java index 91ceb215f87..5407d8e9fdc 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelperTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelperTest.java @@ -13,6 +13,7 @@ package com.fortify.cli.aviator.grpc; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -20,17 +21,68 @@ import java.net.InetSocketAddress; import java.net.SocketAddress; +import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.LoggerFactory; +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; import com.fortify.cli.aviator._common.exception.UnsupportedAviatorUrlSchemeException; import com.fortify.cli.common.http.proxy.helper.ProxyDescriptor; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import io.grpc.HttpConnectProxiedSocketAddress; class AviatorGrpcClientHelperTest { + private static final List URL_MARKERS = + List.of("sentinel-userinfo", "sentinel-path", "sentinel-query", "sentinel-fragment"); + + @Test + void shouldOmitIgnoredUrlDetailsFromWarnings() { + var logger = (Logger)LoggerFactory.getLogger(AviatorGrpcClientHelper.class); + var previousLevel = logger.getLevel(); + var appender = new ListAppender(); + appender.start(); + logger.addAppender(appender); + logger.setLevel(Level.WARN); + try { + var target = AviatorGrpcClientHelper.parseTarget( + "https://user:sentinel-userinfo@aviator.invalid:8443/sentinel-path?token=sentinel-query#sentinel-fragment"); + + assertEquals("aviator.invalid", target.host()); + assertEquals(8443, target.port()); + assertEquals(1, appender.list.size()); + var event = appender.list.get(0); + assertEquals(Level.WARN, event.getLevel()); + assertTrue(event.getFormattedMessage().contains("Ignoring path")); + URL_MARKERS.forEach(marker -> assertFalse(event.getFormattedMessage().contains(marker), marker)); + assertNull(event.getThrowableProxy()); + } finally { + logger.detachAppender(appender); + logger.setLevel(previousLevel); + appender.stop(); + } + } + + @ParameterizedTest + @ValueSource(strings = { + "https://user:sentinel-userinfo@aviator.invalid:99999/sentinel-path?token=sentinel-query#sentinel-fragment", + "https://user:sentinel-userinfo@aviator.invalid/sentinel-path/%zz?token=sentinel-query#sentinel-fragment", + "http://user:sentinel-userinfo@aviator.invalid/sentinel-path?token=sentinel-query#sentinel-fragment" + }) + void shouldOmitUrlDetailsFromInvalidTargetMessages(String url) { + var exception = assertThrows(AviatorSimpleException.class, () -> AviatorGrpcClientHelper.parseTarget(url)); + + URL_MARKERS.forEach(marker -> assertFalse(exception.getMessage().contains(marker), marker)); + assertNull(exception.getCause()); + } + @Test void shouldParseSchemeLessHostAndPort() { var parsed = AviatorGrpcClientHelper.parseTarget("aviator.invalid:443"); @@ -118,4 +170,35 @@ void shouldIgnoreUnsupportedSocketAddressTypes() { assertNull(proxied); } + + @ParameterizedTest + @CsvSource({ + "https://user:sentinel-secret@aviator.invalid:443/path, https://***@aviator.invalid:443", + "user:sentinel-secret@aviator.invalid:443, ***@aviator.invalid:443", + "mttp://sentinel-secret@aviator.invalid, mttp://***@aviator.invalid", + "https://aviator.invalid:443/a@b, https://aviator.invalid:443", + "https://aviator.invalid?token=sentinel-query#sentinel-fragment, https://aviator.invalid", + "aviator.invalid:443, aviator.invalid:443" + }) + void shouldRemoveUserInfoFromDisplayedUrl(String url, String expected) { + assertEquals(expected, AviatorGrpcClientHelper.toSafeUrlForDisplay(url)); + } + + @Test + void shouldNotExposeUserInfoInInvalidPortMessage() { + var ex = assertThrows(AviatorSimpleException.class, + () -> AviatorGrpcClientHelper.parseTarget("https://user:sentinel-secret@aviator.invalid:99999")); + + assertFalse(ex.getMessage().contains("sentinel-secret")); + assertTrue(ex.getMessage().contains("***@aviator.invalid:99999")); + } + + @Test + void shouldNotExposeUserInfoInUnsupportedSchemeException() { + var ex = assertThrows(UnsupportedAviatorUrlSchemeException.class, + () -> AviatorGrpcClientHelper.parseTarget("http://user:sentinel-secret@aviator.invalid")); + + assertFalse(ex.getMessage().contains("sentinel-secret")); + assertFalse(ex.getProvidedUrl().contains("sentinel-secret")); + } } diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/GrpcUtilTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/GrpcUtilTest.java index 09285bae9e9..430c5a6a7e7 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/GrpcUtilTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/GrpcUtilTest.java @@ -13,20 +13,74 @@ package com.fortify.cli.aviator.grpc; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.net.ConnectException; +import java.security.cert.CertificateException; import java.util.List; import java.util.Set; +import javax.net.ssl.SSLHandshakeException; + import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.slf4j.LoggerFactory; +import com.fortify.aviator.application.ApplicationServiceGrpc; import com.fortify.aviator.grpc.AuditorResponse; +import com.fortify.cli.aviator._common.exception.AviatorConnectivityException; +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; +import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; import com.fortify.cli.aviator.audit.model.AuditResponse; import com.fortify.cli.aviator.audit.model.AuditResponse.AuditSkipReason; import com.fortify.cli.aviator.audit.model.File; import com.fortify.cli.aviator.audit.model.UserPrompt; +import com.fortify.cli.common.exception.FcliExceptionHelper; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; class GrpcUtilTest { + @ParameterizedTest + @EnumSource(value = Status.Code.class, names = {"UNAVAILABLE", "DEADLINE_EXCEEDED"}) + void causeFreeStatusesKeepSafeOutputAndLogs(Status.Code code) { + var secret = "sentinel-server-description"; + var logger = (Logger)LoggerFactory.getLogger(GrpcUtil.class); + var previousLevel = logger.getLevel(); + var appender = new ListAppender(); + appender.start(); + logger.addAppender(appender); + logger.setLevel(Level.DEBUG); + try { + var e = assertThrows(AviatorConnectivityException.class, + () -> execute(Status.fromCode(code).withDescription(secret), AviatorRequestPolicy.FAIL_FAST)); + + assertNull(e.getCause()); + assertFalse(FcliExceptionHelper.formatException(e).contains(secret)); + assertTrue(e.getMessage().contains(code==Status.Code.UNAVAILABLE + ? "is unavailable or could not be reached" : "server may be busy")); + assertFalse(e.getMessage().contains("refused")); + assertEquals(1, appender.list.size()); + assertFalse(appender.list.get(0).getFormattedMessage().contains(secret)); + assertNull(appender.list.get(0).getThrowableProxy()); + } finally { + logger.detachAppender(appender); + logger.setLevel(previousLevel); + appender.stop(); + } + } + @Test void shouldPreserveResolvedProgrammingLanguages() { File file = new File(); @@ -58,4 +112,71 @@ void shouldClassifySkippedServerResponseWithoutInspectingMessage() { assertEquals(AuditSkipReason.SKIPPED_BY_AVIATOR, auditResponse.getAuditSkipReason()); } + + @Test + void waitForReadyPolicyKeepsCurrentUnavailableMapping() { + var e = assertThrows(AviatorTechnicalException.class, + () -> execute(Status.UNAVAILABLE.withCause(new ConnectException("Connection refused")), AviatorRequestPolicy.WAIT_FOR_READY)); + + assertEquals("The Aviator test operation failed with status UNAVAILABLE; check the Aviator URL and network access, " + + "run the command again with --log-level DEBUG to log details, and contact support if the problem continues", + e.getMessage()); + } + + @Test + void failFastPolicyClassifiesUnavailable() { + var e = assertThrows(AviatorConnectivityException.class, + () -> execute(Status.UNAVAILABLE.withCause(new ConnectException("Connection refused")), AviatorRequestPolicy.FAIL_FAST)); + + assertEquals("Aviator server 127.0.0.1:1 refused the connection; check the Aviator URL", e.getMessage()); + assertNull(e.getCause()); + } + + @Test + void failFastPolicyKeepsCertificateCauseForPkixGuidance() { + var e = assertThrows(AviatorTechnicalException.class, + () -> execute(Status.UNAVAILABLE.withCause(new SSLHandshakeException("PKIX path building failed") + .initCause(new CertificateException("untrusted"))), AviatorRequestPolicy.FAIL_FAST)); + + assertTrue(FcliExceptionHelper.formatException(e).contains("PKIX SSL certificate validation failed")); + } + + @Test + void failFastPolicyKeepsUnimplementedContractForOlderServerLoginFallback() { + var e = assertThrows(AviatorTechnicalException.class, + () -> execute(Status.UNIMPLEMENTED, AviatorRequestPolicy.FAIL_FAST)); + + var cause = assertInstanceOf(StatusRuntimeException.class, e.getCause()); + assertEquals(Status.Code.UNIMPLEMENTED, cause.getStatus().getCode()); + } + + @Test + void resourceExhaustedPutsRemediationBeforeSupport() { + var e = assertThrows(AviatorSimpleException.class, + () -> execute(Status.RESOURCE_EXHAUSTED.withDescription("quota"), AviatorRequestPolicy.FAIL_FAST)); + + assertEquals("Aviator server resource limits were exceeded during test (server details: quota); " + + "try again later, and contact support if the problem continues", e.getMessage()); + } + + @Test + void failFastPolicyKeepsApplicationStatusMapping() { + var e = assertThrows(AviatorSimpleException.class, + () -> execute(Status.NOT_FOUND.withDescription("app"), AviatorRequestPolicy.FAIL_FAST)); + + assertFalse(e instanceof AviatorConnectivityException); + assertTrue(e.getMessage().contains("not found")); + } + + private static void execute(Status status, AviatorRequestPolicy policy) { + var channel = NettyChannelBuilder.forAddress("127.0.0.1", 1).usePlaintext().build(); + try { + var context = new AviatorConnectionContext(new AviatorEndpoint("127.0.0.1", 1), null); + GrpcUtil.executeGrpcCall(policy.configureNewStub(ApplicationServiceGrpc.newBlockingStub(channel), context), + (stub, request) -> { throw status.asRuntimeException(); }, + "request", "test"); + } finally { + channel.shutdownNow(); + } + } } \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/StreamInitializationTimeoutTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/StreamInitializationTimeoutTest.java new file mode 100644 index 00000000000..cb472d81366 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/StreamInitializationTimeoutTest.java @@ -0,0 +1,299 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.InetAddress; +import java.net.ServerSocket; +import java.util.ArrayDeque; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import com.fortify.aviator.dastaudit.DastAuditInitResponse; +import com.fortify.aviator.dastaudit.DastAuditServerMessage; +import com.fortify.aviator.dastaudit.DastAuditServiceGrpc; +import com.fortify.aviator.grpc.AuditorServiceGrpc; +import com.fortify.aviator.grpc.CorrelationInitResponse; +import com.fortify.aviator.grpc.CorrelationServerMessage; +import com.fortify.aviator.grpc.CorrelationServiceGrpc; +import com.fortify.cli.aviator.audit.model.IssueData; +import com.fortify.cli.aviator.audit.model.UserPrompt; +import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.aviator.dast.DastIssue; +import com.fortify.cli.aviator.dast.DastSession; +import com.fortify.cli.aviator.fpr.Vulnerability; +import com.fortify.cli.aviator.fpr.utils.SourceDecoders; + +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.Status; +import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; + +class StreamInitializationTimeoutTest { + private static final IAviatorLogger LOGGER = new IAviatorLogger() { + public void progress(String format, Object... args) {} + public void info(String format, Object... args) {} + public void warn(String format, Object... args) {} + public void error(String format, Object... args) {} + }; + + @Test + void initializationWinsAgainstAnAlreadyQueuedTimeout() { + try (var scheduler = new ManualScheduler()) { + var expired = new AtomicBoolean(); + var timeout = new StreamInitializationTimeout(scheduler, () -> expired.set(true)); + timeout.arm(); + assertTrue(timeout.initialized()); + scheduler.fire(); + assertFalse(expired.get()); + } + } + + @Test + void expiredInitializationRejectsLateResponses() { + try (var scheduler = new ManualScheduler()) { + var expired = new AtomicBoolean(); + var timeout = new StreamInitializationTimeout(scheduler, () -> expired.set(true)); + timeout.arm(); + scheduler.fire(); + assertTrue(expired.get()); + assertTrue(timeout.isExpired()); + assertFalse(timeout.initialized()); + } + } + + @Test + void closingCancelsInitializationTimeout() { + try (var scheduler = new ManualScheduler()) { + var expired = new AtomicBoolean(); + var timeout = new StreamInitializationTimeout(scheduler, () -> expired.set(true)); + timeout.arm(); + timeout.close(); + scheduler.fire(); + assertFalse(expired.get()); + assertFalse(timeout.initialized()); + } + } + + @Test + void timeoutIsNotScheduledUntilArmed() { + try (var scheduler = new ManualScheduler()) { + var timeout = new StreamInitializationTimeout(scheduler, () -> {}); + assertFalse(scheduler.scheduled()); + timeout.arm(); + assertTrue(scheduler.scheduled()); + } + } + + @Test + void responseBeforeArmingDisablesTheTimeout() { + try (var scheduler = new ManualScheduler()) { + var timeout = new StreamInitializationTimeout(scheduler, () -> {}); + assertTrue(timeout.initialized()); + timeout.arm(); + assertFalse(scheduler.scheduled()); + } + } + + @Test + void missingSchedulerDisablesTheTimeoutWithoutFailing() { + var timeout = new StreamInitializationTimeout(null, () -> {}); + timeout.arm(); + assertFalse(timeout.isExpired()); + assertTrue(timeout.initialized()); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void dastTimeoutOnlyAppliesBeforeInitialization(boolean initialize) { + try (var scheduler = new ManualScheduler()) { + var channel = new SilentChannel(); + try (var processor = new DastAuditStreamProcessor(LOGGER, + DastAuditServiceGrpc.newStub(channel), scheduler, 0)) { + var issue = new DastIssue(); + issue.setId("DAST-1"); + var result = processor.process(DastAuditStreamConfig.builder() + .token("token").applicationName("app").build(), + List.of(new DastAuditWorkItem(new DastSession(), issue)), 1); + if (initialize) channel.respond(DastAuditServerMessage.newBuilder().setInit( + DastAuditInitResponse.newBuilder().setStatus("SUCCESS")).build()); + scheduler.fire(); + if (initialize) { + assertFalse(result.isDone()); + assertFalse(channel.cancelled); + } else { + assertThrows(CompletionException.class, result::join); + assertTrue(channel.cancelled); + int sent = channel.messages; + channel.respond(DastAuditServerMessage.newBuilder().setInit( + DastAuditInitResponse.newBuilder().setStatus("SUCCESS")).build()); + assertEquals(sent, channel.messages, "Late initialization must not submit findings"); + } + } + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void correlationTimeoutOnlyAppliesBeforeInitialization(boolean initialize) { + try (var scheduler = new ManualScheduler()) { + var channel = new SilentChannel(); + try (var processor = new CorrelationStreamProcessor(null, LOGGER, + CorrelationServiceGrpc.newStub(channel), scheduler, 0, 30)) { + var issue = new DastIssue(); + issue.setId("DAST-1"); + issue.setSessionUrl("https://example.com"); + var bucket = new CorrelationStreamProcessor.CorrelationBucketData("SQL Injection", + List.of(Vulnerability.builder().instanceID("SAST-1").build()), List.of(issue)); + var result = processor.processCorrelation(new CorrelationStreamConfig("token", "app", null, null, null), List.of(bucket), "scan", Set.of()); + if (initialize) channel.respond(CorrelationServerMessage.newBuilder().setInit( + CorrelationInitResponse.newBuilder().setStatus("SUCCESS")).build()); + scheduler.fire(); + if (initialize) { + assertFalse(result.isDone()); + assertFalse(channel.cancelled); + } else { + assertThrows(CompletionException.class, result::join); + assertTrue(channel.cancelled); + int sent = channel.messages; + channel.respond(CorrelationServerMessage.newBuilder().setInit( + CorrelationInitResponse.newBuilder().setStatus("SUCCESS")).build()); + assertEquals(sent, channel.messages, "Late initialization must not submit findings"); + } + } + } + } + + @Test + void sastInitializationTimeoutReleasesCallingThreadAndCancelsRpc() throws Exception { + var workers = Executors.newFixedThreadPool(2); + var caller = Executors.newSingleThreadExecutor(); + try (var scheduler = new ManualScheduler(); + var client = AviatorGrpcClientHelper.createClient("127.0.0.1:1", AviatorRequestPolicy.WAIT_FOR_READY)) { + var channel = new SilentChannel(); + try (var processor = new AviatorStreamProcessor(client, LOGGER, + AuditorServiceGrpc.newStub(channel), workers, scheduler, 30, 30, + null, SourceDecoders.defaults(), null)) { + var requests = new ArrayDeque(); + requests.add(UserPrompt.builder().issueData(IssueData.builder().instanceID("SAST-1").build()).build()); + var invocation = caller.submit(() -> processor.processBatchRequests( + requests, "app", "build", "ssc-app", "version", "token", List.of())); + assertTrue(channel.sent.await(5, TimeUnit.SECONDS)); + assertTrue(scheduler.armed.await(5, TimeUnit.SECONDS)); + assertFalse(invocation.isDone(), "The caller waits for initialization"); + scheduler.fire(); + var result = invocation.get(5, TimeUnit.SECONDS); + assertThrows(CompletionException.class, result::join); + assertTrue(channel.cancelled); + } + } finally { + caller.shutdownNow(); + workers.shutdownNow(); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void sastInitializationTimeoutReleasesCallerOnRealChannel(boolean silentServer) throws Exception { + var workers = Executors.newFixedThreadPool(2); + var caller = Executors.newSingleThreadExecutor(); + try (var scheduler = new ManualScheduler(); + var silent = new ServerSocket(0, 1, InetAddress.getLoopbackAddress()); + var client = AviatorGrpcClientHelper.createClient("127.0.0.1:1", AviatorRequestPolicy.WAIT_FOR_READY)) { + // Unreachable port: wait-for-ready keeps the call queued. Silent socket: connects, never answers. + int port = silentServer ? silent.getLocalPort() : 1; + var channel = NettyChannelBuilder.forAddress("127.0.0.1", port).usePlaintext().build(); + try (var processor = new AviatorStreamProcessor(client, LOGGER, + AuditorServiceGrpc.newStub(channel).withWaitForReady(), workers, scheduler, 30, 30, + null, SourceDecoders.defaults(), null)) { + var requests = new ArrayDeque(); + requests.add(UserPrompt.builder().issueData(IssueData.builder().instanceID("SAST-1").build()).build()); + var invocation = caller.submit(() -> processor.processBatchRequests( + requests, "app", "build", "ssc-app", "version", "token", List.of())); + assertTrue(scheduler.armed.await(5, TimeUnit.SECONDS)); + assertFalse(invocation.isDone(), "The caller waits for initialization"); + scheduler.fire(); + var result = invocation.get(10, TimeUnit.SECONDS); + var failure = assertThrows(CompletionException.class, result::join); + assertTrue(failure.getCause().getMessage().contains("initialization timed out")); + } finally { + channel.shutdownNow(); + } + } finally { + caller.shutdownNow(); + workers.shutdownNow(); + } + } + + private static final class ManualScheduler extends ScheduledThreadPoolExecutor implements AutoCloseable { + private volatile Runnable task; + private final CountDownLatch armed = new CountDownLatch(1); + ManualScheduler() { super(1); } + @Override + public ScheduledFuture schedule(Runnable command, long delay, TimeUnit unit) { + assertEquals(30, unit.toSeconds(delay)); + task = command; + armed.countDown(); + return super.schedule(command, 1, TimeUnit.DAYS); + } + void fire() { task.run(); } + boolean scheduled() { return task != null; } + @Override + public void close() { shutdownNow(); } + } + + private static final class SilentChannel extends Channel { + private ClientCall.Listener listener; + private volatile boolean cancelled; + private final CountDownLatch sent = new CountDownLatch(1); + private int messages; + @Override + public String authority() { return "test"; } + @Override + @SuppressWarnings("unchecked") + public ClientCall newCall( + MethodDescriptor method, CallOptions options) { + return new ClientCall<>() { + public void start(Listener responseListener, Metadata headers) { + listener = (Listener) responseListener; + } + public void request(int count) {} + public void cancel(String message, Throwable cause) { + cancelled = true; + listener.onClose(Status.CANCELLED.withCause(cause), new Metadata()); + } + public void halfClose() {} + public void sendMessage(ReqT message) { messages++; sent.countDown(); } + }; + } + void respond(Object response) { listener.onMessage(response); } + } +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/cli/cmd/AviatorAdminConfigCreateCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/cli/cmd/AviatorAdminConfigCreateCommand.java index d4ed177ca91..5e1b0d6ee82 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/cli/cmd/AviatorAdminConfigCreateCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/cli/cmd/AviatorAdminConfigCreateCommand.java @@ -19,6 +19,7 @@ import com.fortify.cli.aviator._common.config.admin.cli.mixin.AviatorAdminConfigNameArgGroup; import com.fortify.cli.aviator._common.config.admin.helper.AviatorAdminConfigDescriptor; import com.fortify.cli.aviator._common.config.admin.helper.AviatorAdminConfigHelper; +import com.fortify.cli.aviator.grpc.AviatorRequestPolicy; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.session.cli.cmd.AbstractSessionLoginCommand; import com.fortify.cli.common.session.cli.mixin.ISessionNameSupplier; @@ -47,6 +48,12 @@ public ISessionNameSupplier getSessionNameSupplier() { protected void logoutBeforeNewLogin(String configName, AviatorAdminConfigDescriptor configDescriptor) { } + @Override + protected boolean shouldReplaceExistingSessionBeforeLogin() { + // login() validates the configuration first; keep the existing configuration if validation fails + return false; + } + @Override protected AviatorAdminConfigDescriptor login(String configName) { String privateKeyContents = configCreateOptions.getPrivateKeyResolver().getPrivateKeyContents(); @@ -55,7 +62,7 @@ protected AviatorAdminConfigDescriptor login(String configName) { configCreateOptions.getTenant(), privateKeyContents); LOG.info("Validating Aviator admin configuration with the Aviator server..."); - configHelper.validateConfig(configDescriptor); + configHelper.validateConfig(configDescriptor, AviatorRequestPolicy.FAIL_FAST); LOG.info("Aviator admin configuration validated successfully."); return configDescriptor; } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/helper/AviatorAdminConfigHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/helper/AviatorAdminConfigHelper.java index e7039f7e94b..c79f5773294 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/helper/AviatorAdminConfigHelper.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/config/admin/helper/AviatorAdminConfigHelper.java @@ -15,6 +15,7 @@ import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; +import com.fortify.cli.aviator.grpc.AviatorRequestPolicy; import com.fortify.cli.common.session.helper.AbstractSessionHelper; public class AviatorAdminConfigHelper extends AbstractSessionHelper { @@ -37,8 +38,8 @@ protected Class getSessionDescriptorType() { return AviatorAdminConfigDescriptor.class; } - public void validateConfig(AviatorAdminConfigDescriptor configDescriptor) { - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + public void validateConfig(AviatorAdminConfigDescriptor configDescriptor, AviatorRequestPolicy policy) { + try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl(), policy)) { String[] messageAndSignature = AviatorSignatureUtils.createMessageAndSignature(configDescriptor, configDescriptor.getTenant()); String message = messageAndSignature[0]; String signature = messageAndSignature[1]; diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/output/cli/cmd/AbstractAviatorAdminSessionOutputCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/output/cli/cmd/AbstractAviatorAdminSessionOutputCommand.java index 12ad4a13068..d198b37574e 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/output/cli/cmd/AbstractAviatorAdminSessionOutputCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/output/cli/cmd/AbstractAviatorAdminSessionOutputCommand.java @@ -15,6 +15,9 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fortify.cli.aviator._common.config.admin.cli.mixin.AviatorAdminConfigDescriptorSupplier; import com.fortify.cli.aviator._common.config.admin.helper.AviatorAdminConfigDescriptor; +import com.fortify.cli.aviator.grpc.AviatorGrpcClient; +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; +import com.fortify.cli.aviator.grpc.AviatorRequestPolicy; import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; import com.fortify.cli.common.output.cli.cmd.IJsonNodeSupplier; @@ -33,4 +36,9 @@ public final JsonNode getJsonNode() { protected abstract JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor); public abstract boolean isSingular(); + + /** Creates a fail-fast client for ordinary admin commands. The caller must close the client. */ + protected final AviatorGrpcClient createGrpcClient(AviatorAdminConfigDescriptor configDescriptor) { + return AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl(), AviatorRequestPolicy.FAIL_FAST); + } } \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/cli/cmd/AviatorUserSessionLoginCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/cli/cmd/AviatorUserSessionLoginCommand.java index 42f93eb2c10..a6e2e3379d3 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/cli/cmd/AviatorUserSessionLoginCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/cli/cmd/AviatorUserSessionLoginCommand.java @@ -25,6 +25,7 @@ import com.fortify.cli.aviator._common.session.user.helper.AviatorUserSessionDescriptor; import com.fortify.cli.aviator._common.session.user.helper.AviatorUserSessionHelper; import com.fortify.cli.aviator._common.util.AviatorJwtUtils; +import com.fortify.cli.aviator.grpc.AviatorRequestPolicy; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.session.cli.cmd.AbstractSessionLoginCommand; @@ -49,6 +50,12 @@ public class AviatorUserSessionLoginCommand extends AbstractSessionLoginCommand< @Override protected void logoutBeforeNewLogin(String sessionName, AviatorUserSessionDescriptor sessionDescriptor) {} + @Override + protected boolean shouldReplaceExistingSessionBeforeLogin() { + // login() validates the token first; keep the existing session if validation fails + return false; + } + @Override protected AviatorUserSessionDescriptor login(String sessionName) { String resolvedToken = tokenResolver.getToken(); @@ -56,7 +63,8 @@ protected AviatorUserSessionDescriptor login(String sessionName) { LOG.info("Validating Aviator user token with the server..."); try { - var validationResponse = sessionHelper.validateToken(sessionLoginOptions.getAviatorUrl(), resolvedToken).response(); + var validationResponse = sessionHelper.validateToken(sessionLoginOptions.getAviatorUrl(), resolvedToken, + AviatorRequestPolicy.FAIL_FAST).response(); if (!validationResponse.getValid()) { String errorMsg = validationResponse.getErrorMessage(); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/helper/AviatorUserSessionHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/helper/AviatorUserSessionHelper.java index dbed50341e3..0ef65f56498 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/helper/AviatorUserSessionHelper.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/helper/AviatorUserSessionHelper.java @@ -14,6 +14,7 @@ import com.fortify.cli.aviator._common.util.AviatorJwtUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; +import com.fortify.cli.aviator.grpc.AviatorRequestPolicy; import com.fortify.cli.common.session.helper.AbstractSessionHelper; import com.fortify.grpc.token.TokenValidationResponse; @@ -37,13 +38,9 @@ protected Class getSessionDescriptorType() { return AviatorUserSessionDescriptor.class; } - public AviatorUserTokenValidationResult validateToken(AviatorUserSessionDescriptor sessionDescriptor) { - return validateToken(sessionDescriptor.getAviatorUrl(), sessionDescriptor.getAviatorToken()); - } - - public AviatorUserTokenValidationResult validateToken(String aviatorUrl, String token) { + public AviatorUserTokenValidationResult validateToken(String aviatorUrl, String token, AviatorRequestPolicy policy) { var tenantName = AviatorJwtUtils.extractTenantNameFromToken(token); - try (var client = AviatorGrpcClientHelper.createClient(aviatorUrl)) { + try (var client = AviatorGrpcClientHelper.createClient(aviatorUrl, policy)) { var response = client.validateUserToken(token, tenantName); return new AviatorUserTokenValidationResult(tenantName, response); } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppAddEntitlementCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppAddEntitlementCommand.java index a3436bc9e94..5978d488555 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppAddEntitlementCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppAddEntitlementCommand.java @@ -21,7 +21,6 @@ import com.fortify.cli.aviator._common.util.AviatorGrpcUtils; import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; -import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; @@ -37,7 +36,7 @@ public class AviatorAppAddEntitlementCommand extends AbstractAviatorAdminSession @Override protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException { - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) { String[] messageAndSignature = AviatorSignatureUtils.createMessageAndSignature( configDescriptor, configDescriptor.getTenant(), diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppCreateCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppCreateCommand.java index 72380a8b9e5..a72e1d6bf33 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppCreateCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppCreateCommand.java @@ -23,7 +23,6 @@ import com.fortify.cli.aviator._common.util.AviatorGrpcUtils; import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; -import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; @@ -42,7 +41,7 @@ public class AviatorAppCreateCommand extends AbstractAviatorAdminSessionOutputCo @Override protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) { - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) { String[] messageAndSignature = createMessageAndSignature(configDescriptor); Application createdApplication = createApplication(client, configDescriptor, messageAndSignature); LOG.info("Application '{}' created successfully for tenant: {}", applicationName, configDescriptor.getTenant()); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppDeleteCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppDeleteCommand.java index eb0c13fe327..fbcca2b56ca 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppDeleteCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppDeleteCommand.java @@ -25,7 +25,6 @@ import com.fortify.cli.aviator._common.output.cli.cmd.AbstractAviatorAdminSessionOutputCommand; import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; -import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import lombok.Getter; @@ -41,7 +40,7 @@ public class AviatorAppDeleteCommand extends AbstractAviatorAdminSessionOutputCo @Override protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException { - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) { String[] messageAndSignature = createMessageAndSignature(configDescriptor); ApplicationResponseMessage response = deleteApplication(client, configDescriptor, messageAndSignature); JsonNode result = processDeleteResponse(response); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppGetCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppGetCommand.java index b1f12d10984..006ea270168 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppGetCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppGetCommand.java @@ -29,7 +29,6 @@ import com.fortify.cli.aviator._common.util.AviatorGrpcUtils; import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; -import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import lombok.Getter; @@ -46,7 +45,7 @@ public class AviatorAppGetCommand extends AbstractAviatorAdminSessionOutputComma @Override protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException { - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) { String[] messageAndSignature = createMessageAndSignature(configDescriptor); Application application = getApplication(client, configDescriptor, messageAndSignature); JsonNode response = processGetApplicationResponse(AviatorGrpcUtils.grpcToJsonNode(application)); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppListCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppListCommand.java index 094f3dc91b7..7595274cf9b 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppListCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppListCommand.java @@ -30,7 +30,6 @@ import com.fortify.cli.aviator._common.util.AviatorGrpcUtils; import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; -import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import lombok.Getter; @@ -45,7 +44,7 @@ public class AviatorAppListCommand extends AbstractAviatorAdminSessionOutputComm @Override protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException { - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) { String[] messageAndSignature = createMessageAndSignature(configDescriptor); List applications = listApplications(client, configDescriptor, messageAndSignature); return formatApplicationsArray(applications, configDescriptor.getTenant()); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppUpdateCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppUpdateCommand.java index da0adf98f6c..7e7e034c0b5 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppUpdateCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/app/cli/cmd/AviatorAppUpdateCommand.java @@ -29,7 +29,6 @@ import com.fortify.cli.aviator._common.util.AviatorGrpcUtils; import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; -import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; @@ -49,7 +48,7 @@ public class AviatorAppUpdateCommand extends AbstractAviatorAdminSessionOutputCo @Override protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException { - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) { String[] messageAndSignature = createMessageAndSignature(configDescriptor); Application updatedApplication = updateApplication(client, configDescriptor, messageAndSignature); JsonNode response = processUpdateApplicationResponse(AviatorGrpcUtils.grpcToJsonNode(updatedApplication)); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/connection/helper/AviatorConnectionDiagnoseHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/connection/helper/AviatorConnectionDiagnoseHelper.java index 0f66987d9d5..f309a869d5f 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/connection/helper/AviatorConnectionDiagnoseHelper.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/connection/helper/AviatorConnectionDiagnoseHelper.java @@ -24,6 +24,7 @@ import com.fortify.cli.aviator.diagnose.AviatorDiagnosticEvidence; import com.fortify.cli.aviator.diagnose.AviatorDiagnosticReport; import com.fortify.cli.aviator.diagnose.AviatorDiagnosticStageResult; +import com.fortify.cli.aviator.grpc.AviatorRequestPolicy; import com.fortify.cli.common.exception.FcliBugException; import lombok.RequiredArgsConstructor; @@ -48,8 +49,9 @@ public class AviatorConnectionDiagnoseHelper { public AviatorConnectionDiagnoseHelper() { this(new AviatorConnectionDiagnostics(), - (url, token) -> AviatorUserSessionHelper.instance().validateToken(url, token), - descriptor -> AviatorAdminConfigHelper.instance().validateConfig(descriptor)); + // Preserve diagnostic status mapping instead of ordinary fail-fast messages. + (url, token) -> AviatorUserSessionHelper.instance().validateToken(url, token, AviatorRequestPolicy.WAIT_FOR_READY), + descriptor -> AviatorAdminConfigHelper.instance().validateConfig(descriptor, AviatorRequestPolicy.WAIT_FOR_READY)); } public DiagnoseRunResult diagnose(AviatorConnectionDiagnoseSource source, int timeoutSeconds) { diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/entitlement/cli/cmd/AbstractAviatorSastEntitlementListCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/entitlement/cli/cmd/AbstractAviatorSastEntitlementListCommand.java index 5f9bfa4ea51..79500af4b48 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/entitlement/cli/cmd/AbstractAviatorSastEntitlementListCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/entitlement/cli/cmd/AbstractAviatorSastEntitlementListCommand.java @@ -26,7 +26,6 @@ import com.fortify.cli.aviator._common.util.AviatorGrpcUtils; import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; -import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; /** * Abstract base class for SAST entitlement list commands. Provides the shared @@ -38,7 +37,7 @@ public abstract class AbstractAviatorSastEntitlementListCommand extends Abstract @Override protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) { - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) { var messageAndSignature = AviatorSignatureUtils.createMessageAndSignature(configDescriptor, configDescriptor.getTenant()); String message = messageAndSignature[0]; String signature = messageAndSignature[1]; diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/entitlement/cli/cmd/AviatorEntitlementListDastCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/entitlement/cli/cmd/AviatorEntitlementListDastCommand.java index 9e619dc1075..5cd8fe81fbd 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/entitlement/cli/cmd/AviatorEntitlementListDastCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/entitlement/cli/cmd/AviatorEntitlementListDastCommand.java @@ -27,7 +27,6 @@ import com.fortify.cli.aviator._common.util.AviatorGrpcUtils; import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; -import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import lombok.Getter; import picocli.CommandLine.Command; @@ -40,7 +39,7 @@ public class AviatorEntitlementListDastCommand extends AbstractAviatorAdminSessi @Override protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) { - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) { var messageAndSignature = AviatorSignatureUtils.createMessageAndSignature(configDescriptor, configDescriptor.getTenant()); String message = messageAndSignature[0]; String signature = messageAndSignature[1]; diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCorrelateSastDastCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCorrelateSastDastCommand.java index acf07548bcd..d0ee2910495 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCorrelateSastDastCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCorrelateSastDastCommand.java @@ -37,6 +37,7 @@ import com.fortify.cli.aviator.fpr.Vulnerability; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; +import com.fortify.cli.aviator.grpc.AviatorRequestPolicy; import com.fortify.cli.aviator.grpc.CorrelationResult; import com.fortify.cli.aviator.grpc.CorrelationStreamConfig; import com.fortify.cli.aviator.grpc.CorrelationStreamProcessor; @@ -278,7 +279,7 @@ private CorrelationResult correlateViaGrpc(List mixedBuckets, av.getApplicationName(), av.getVersionName(), sastResult.buildId); CorrelationResult result; - try (var grpcClient = AviatorGrpcClientHelper.createClient(sessionDescriptor.getAviatorUrl(), logger, 30)) { + try (var grpcClient = AviatorGrpcClientHelper.createClient(sessionDescriptor.getAviatorUrl(), logger, 30, AviatorRequestPolicy.WAIT_FOR_READY)) { result = performCorrelation(grpcClient, config, bucketData, sastResult.scanGuid, alreadyTriedKeys); } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDastAuditCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDastAuditCommand.java index 7f01f08488f..ca3bc5f7be8 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDastAuditCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDastAuditCommand.java @@ -34,6 +34,7 @@ import com.fortify.cli.aviator.config.IAviatorLogger; import com.fortify.cli.aviator.config.TagMappingConfig; import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; +import com.fortify.cli.aviator.grpc.AviatorRequestPolicy; import com.fortify.cli.aviator.grpc.DastAuditStreamConfig; import com.fortify.cli.aviator.grpc.DastAuditStreamProcessor; import com.fortify.cli.aviator.ssc.helper.AviatorSSCAttributeHelper; @@ -133,7 +134,7 @@ private DastAuditFprResult auditFpr( .sscApplicationName(appVersion.getApplicationName()) .sscApplicationVersion(appVersion.getVersionName()) .build(); - try (var grpcClient = AviatorGrpcClientHelper.createClient(session.getAviatorUrl(), logger, 30); + try (var grpcClient = AviatorGrpcClientHelper.createClient(session.getAviatorUrl(), logger, 30, AviatorRequestPolicy.WAIT_FOR_READY); var streamProcessor = new DastAuditStreamProcessor( logger, grpcClient.getDastAuditAsyncStub(), grpcClient.getPingScheduler(), grpcClient.getPingIntervalSeconds()); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java index f52033ea53b..e4be2b669b7 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java @@ -33,6 +33,7 @@ import com.fortify.cli.aviator.config.AviatorLoggerImpl; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; +import com.fortify.cli.aviator.grpc.AviatorRequestPolicy; import com.fortify.cli.aviator.util.Constants; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.json.JsonHelper; @@ -717,7 +718,7 @@ private static String getFolderFilter(boolean noFilterSet, SSCIssueFilterSetDesc public static long getAvailableQuota(String aviatorUrl, String aviatorToken, String appName, AviatorLoggerImpl logger) { try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(aviatorUrl, logger, - Constants.DEFAULT_PING_INTERVAL_SECONDS)) { + Constants.DEFAULT_PING_INTERVAL_SECONDS, AviatorRequestPolicy.WAIT_FOR_READY)) { Application app = client.getApplicationByToken(aviatorToken, appName); long quota = app.getQuota(); logger.progress("Status: Available Aviator quota for app '%s': %s", appName, @@ -745,7 +746,7 @@ public static long getAvailableQuota(String aviatorUrl, String aviatorToken, Str public static long getDefaultQuota(String aviatorUrl, String aviatorToken, AviatorLoggerImpl logger) { try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(aviatorUrl, logger, - Constants.DEFAULT_PING_INTERVAL_SECONDS)) { + Constants.DEFAULT_PING_INTERVAL_SECONDS, AviatorRequestPolicy.WAIT_FOR_READY)) { long quota = client.getDefaultQuota(aviatorToken); logger.progress("Status: Default Aviator quota for new applications: %s", quota < 0 ? "unlimited" : String.valueOf(quota)); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenCreateCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenCreateCommand.java index dee46793140..33b924138aa 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenCreateCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenCreateCommand.java @@ -32,7 +32,6 @@ import com.fortify.cli.aviator._common.util.AviatorGrpcUtils; import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; -import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.aviator.util.FileUtil; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; @@ -57,7 +56,7 @@ public class AviatorTokenCreateCommand extends AbstractAviatorAdminSessionOutput @Override protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException { - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) { String[] messageAndSignature = createMessageAndSignature(configDescriptor); TokenGenerationResponse response = generateToken(client, configDescriptor, messageAndSignature); return processTokenResponse(response); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenDeleteCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenDeleteCommand.java index aa2aa814b24..aef69d516b8 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenDeleteCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenDeleteCommand.java @@ -25,7 +25,6 @@ import com.fortify.cli.aviator._common.session.user.cli.mixin.AviatorUserTokenResolverMixin; import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; -import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.grpc.token.DeleteTokenResponse; @@ -45,7 +44,7 @@ public class AviatorTokenDeleteCommand extends AbstractAviatorAdminSessionOutput protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException { String tokenToDelete = tokenResolver.getToken(); - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) { String[] messageAndSignature = createMessageAndSignature(configDescriptor, tokenToDelete); DeleteTokenResponse response = deleteToken(client, configDescriptor, messageAndSignature, tokenToDelete); return processDeleteResponse(response, tokenToDelete); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenListCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenListCommand.java index 08f450ebe20..c03df4cf811 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenListCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenListCommand.java @@ -29,7 +29,6 @@ import com.fortify.cli.aviator._common.util.AviatorGrpcUtils; import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; -import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.aviator.util.StringUtil; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.grpc.token.ListTokensResponse; @@ -50,7 +49,7 @@ public class AviatorTokenListCommand extends AbstractAviatorAdminSessionOutputCo @Override protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException { - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) { ArrayNode tokensArray = fetchAllTokens(client, configDescriptor); logTokenCount(tokensArray.size()); return tokensArray; diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenRevokeCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenRevokeCommand.java index e85fc311334..258fa698b5c 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenRevokeCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenRevokeCommand.java @@ -25,7 +25,6 @@ import com.fortify.cli.aviator._common.session.user.cli.mixin.AviatorUserTokenResolverMixin; import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; -import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.grpc.token.RevokeTokenResponse; @@ -45,7 +44,7 @@ public class AviatorTokenRevokeCommand extends AbstractAviatorAdminSessionOutput protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException { String tokenToRevoke = tokenResolver.getToken(); - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) { String[] messageAndSignature = createMessageAndSignature(configDescriptor, tokenToRevoke); RevokeTokenResponse response = revokeToken(client, configDescriptor, messageAndSignature, tokenToRevoke); return processRevokeResponse(response, tokenToRevoke); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenValidateCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenValidateCommand.java index 5b682f3af97..84c798d42ee 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenValidateCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/token/cli/cmd/AviatorTokenValidateCommand.java @@ -22,7 +22,6 @@ import com.fortify.cli.aviator._common.session.user.cli.mixin.AviatorUserTokenResolverMixin; import com.fortify.cli.aviator._common.util.AviatorSignatureUtils; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; -import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.grpc.token.TokenValidationResponse; @@ -39,7 +38,7 @@ public class AviatorTokenValidateCommand extends AbstractAviatorAdminSessionOutp protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) throws AviatorSimpleException, AviatorTechnicalException { String tokenToValidate = tokenResolver.getToken(); - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(configDescriptor.getAviatorUrl())) { + try (AviatorGrpcClient client = createGrpcClient(configDescriptor)) { String[] messageAndSignature = createMessageAndSignature(configDescriptor, tokenToValidate); TokenValidationResponse response = validateToken(client, configDescriptor, messageAndSignature, tokenToValidate); return createResponseNode(response); diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/_common/output/cli/cmd/AviatorOrdinaryCommandConnectivityTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/_common/output/cli/cmd/AviatorOrdinaryCommandConnectivityTest.java new file mode 100644 index 00000000000..080e58a62cb --- /dev/null +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/_common/output/cli/cmd/AviatorOrdinaryCommandConnectivityTest.java @@ -0,0 +1,136 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.output.cli.cmd; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Base64; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; +import org.junit.jupiter.api.io.TempDir; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.aviator._common.config.admin.helper.AviatorAdminConfigDescriptor; +import com.fortify.cli.aviator._common.exception.AviatorConnectivityException; +import com.fortify.cli.aviator._common.session.user.helper.AviatorUserSessionHelper; +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; +import com.fortify.cli.aviator.grpc.AviatorRequestPolicy; +import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; +import com.fortify.cli.common.output.cli.mixin.IOutputHelper; + +/** + * Checks that fail-fast Aviator commands fail fast with the shared connectivity messages. + * Each case targets a closed loopback port, so no Aviator server is needed. + */ +class AviatorOrdinaryCommandConnectivityTest { + private static final String FCLI_DATA_DIR_PROPERTY = "fcli.env.FCLI_DATA_DIR"; + private static final Duration FAIL_FAST_LIMIT = Duration.ofSeconds(5); + + // Static, because some fcli helpers cache paths below the fcli data directory in static fields + @TempDir static Path fcliDataDir; + private static String previousFcliDataDir; + private String closedPortUrl; + + @BeforeAll + static void setUpFcliDataDir() { + previousFcliDataDir = System.getProperty(FCLI_DATA_DIR_PROPERTY); + System.setProperty(FCLI_DATA_DIR_PROPERTY, fcliDataDir.toString()); + } + + @AfterAll + static void restoreFcliDataDir() { + if ( previousFcliDataDir==null ) { + System.clearProperty(FCLI_DATA_DIR_PROPERTY); + } else { + System.setProperty(FCLI_DATA_DIR_PROPERTY, previousFcliDataDir); + } + } + + @BeforeEach + void setUp() throws IOException { + FcliExecutionContextHolder.pushNew(); + try ( var socket = new ServerSocket(0, 1, InetAddress.getLoopbackAddress()) ) { + closedPortUrl = "127.0.0.1:" + socket.getLocalPort(); + } + assumeTrue(AviatorGrpcClientHelper.createConnectionPlan(closedPortUrl).proxyDescriptor().isEmpty(), + "This test requires a direct loopback route (configure NO_PROXY for 127.0.0.1)"); + } + + @AfterEach + void tearDown() { + FcliExecutionContextHolder.pop(); + } + + @Test + void adminCommandBaseClassCreatesFailFastClient() { + var descriptor = new AviatorAdminConfigDescriptor(closedPortUrl, "tenant", "unused-private-key"); + var command = new TestAdminCommand(); + + assertFailsFast(() -> { + try ( var client = command.createGrpcClient(descriptor) ) { + client.listApplication("tenant", "signature", "message"); + } + }); + } + + @Test + void userSessionTokenValidationFailsFastWithFailFastPolicy() { + assertFailsFast(() -> AviatorUserSessionHelper.instance() + .validateToken(closedPortUrl, syntheticToken(), AviatorRequestPolicy.FAIL_FAST)); + } + + private static String syntheticToken() { + var encoder = Base64.getUrlEncoder().withoutPadding(); + return encoder.encodeToString("{\"alg\":\"none\"}".getBytes(StandardCharsets.UTF_8)) + + "." + encoder.encodeToString("{\"tenantName\":\"tenant\"}".getBytes(StandardCharsets.UTF_8)) + + ".signature"; + } + + private static void assertFailsFast(Executable executable) { + long start = System.nanoTime(); + var exception = assertThrows(AviatorConnectivityException.class, executable); + var elapsed = Duration.ofNanos(System.nanoTime() - start); + assertTrue(elapsed.compareTo(FAIL_FAST_LIMIT) < 0, "Call took " + elapsed); + assertTrue(exception.getMessage().contains("refused the connection"), exception.getMessage()); + } + + private static final class TestAdminCommand extends AbstractAviatorAdminSessionOutputCommand { + @Override + protected JsonNode getJsonNode(AviatorAdminConfigDescriptor configDescriptor) { + return null; + } + + @Override + public boolean isSingular() { + return true; + } + + @Override + public IOutputHelper getOutputHelper() { + return null; + } + } +} diff --git a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/session/cli/cmd/AbstractSessionLoginCommand.java b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/session/cli/cmd/AbstractSessionLoginCommand.java index ce93114f719..cc7a6ba0d0f 100644 --- a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/session/cli/cmd/AbstractSessionLoginCommand.java +++ b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/session/cli/cmd/AbstractSessionLoginCommand.java @@ -28,7 +28,9 @@ public JsonNode getJsonNode() { var sessionNameSupplier = getSessionNameSupplier(); String sessionName = sessionNameSupplier==null?"default":sessionNameSupplier.getSessionName(); var sessionHelper = getSessionHelper(); - logoutIfSessionExists(sessionName); + if ( shouldReplaceExistingSessionBeforeLogin() ) { + logoutIfSessionExists(sessionName); + } D sessionDescriptor = login(sessionName); sessionHelper.save(sessionName, sessionDescriptor); testAuthenticatedConnection(sessionName); @@ -63,4 +65,13 @@ private void logoutIfSessionExists(String sessionName) { protected abstract void logoutBeforeNewLogin(String sessionName, D sessionDescriptor); protected abstract D login(String sessionName); protected void testAuthenticatedConnection(String sessionName) {} + + /** + * Return false only when no remote logout is needed and {@link #login(String)} preserves the existing session until success. + * The session helper saves the new session after {@code login} returns. + * {@link #testAuthenticatedConnection(String)} runs after saving; failures there do not restore the previous session. + */ + protected boolean shouldReplaceExistingSessionBeforeLogin() { + return true; + } } diff --git a/fcli-core/fcli-common-core/src/test/java/com/fortify/cli/common/session/cli/cmd/AbstractSessionLoginCommandTest.java b/fcli-core/fcli-common-core/src/test/java/com/fortify/cli/common/session/cli/cmd/AbstractSessionLoginCommandTest.java new file mode 100644 index 00000000000..a274fd50f19 --- /dev/null +++ b/fcli-core/fcli-common-core/src/test/java/com/fortify/cli/common/session/cli/cmd/AbstractSessionLoginCommandTest.java @@ -0,0 +1,210 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.common.session.cli.cmd; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import java.util.Date; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fortify.cli.common.cli.util.FcliExecutionContextHolder; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.output.cli.mixin.IOutputHelper; +import com.fortify.cli.common.session.cli.mixin.ISessionNameSupplier; +import com.fortify.cli.common.session.helper.AbstractSessionHelper; +import com.fortify.cli.common.session.helper.ISessionDescriptor; + +class AbstractSessionLoginCommandTest { + private static final String FCLI_DATA_DIR_PROPERTY = "fcli.env.FCLI_DATA_DIR"; + private static final String SESSION_NAME = "test-session"; + + @TempDir Path fcliDataDir; + private String previousFcliDataDir; + private final TestSessionHelper sessionHelper = new TestSessionHelper(); + + @BeforeEach + void setUp() { + previousFcliDataDir = System.getProperty(FCLI_DATA_DIR_PROPERTY); + System.setProperty(FCLI_DATA_DIR_PROPERTY, fcliDataDir.toString()); + FcliExecutionContextHolder.pushNew(); + } + + @AfterEach + void tearDown() { + FcliExecutionContextHolder.pop(); + if ( previousFcliDataDir==null ) { + System.clearProperty(FCLI_DATA_DIR_PROPERTY); + } else { + System.setProperty(FCLI_DATA_DIR_PROPERTY, previousFcliDataDir); + } + } + + @Test + void defaultLifecycleDestroysExistingSessionBeforeLogin() { + sessionHelper.save(SESSION_NAME, new TestSessionDescriptor("old")); + assertTrue(sessionHelper.exists(SESSION_NAME)); + var command = new TestLoginCommand(sessionHelper, null); + + assertThrows(FcliSimpleException.class, command::getJsonNode); + + assertFalse(sessionHelper.exists(SESSION_NAME)); + assertEquals(1, command.logoutCount); + } + + @Test + void optOutKeepsExistingSessionWhenLoginFails() { + sessionHelper.save(SESSION_NAME, new TestSessionDescriptor("old")); + TestLoginCommand command = new OptOutTestLoginCommand(sessionHelper, null); + + assertThrows(FcliSimpleException.class, command::getJsonNode); + + assertTrue(sessionHelper.exists(SESSION_NAME)); + assertEquals("old", sessionHelper.get(SESSION_NAME, true).getValue()); + assertEquals(0, command.logoutCount); + } + + @Test + void optOutCreatesNoSessionWhenLoginFailsForNewName() { + TestLoginCommand command = new OptOutTestLoginCommand(sessionHelper, null); + + assertThrows(FcliSimpleException.class, command::getJsonNode); + + assertFalse(sessionHelper.exists(SESSION_NAME)); + } + + @Test + void optOutReplacesExistingSessionWhenLoginSucceeds() { + sessionHelper.save(SESSION_NAME, new TestSessionDescriptor("old")); + TestLoginCommand command = new OptOutTestLoginCommand(sessionHelper, new TestSessionDescriptor("new")); + + command.getJsonNode(); + + assertEquals("new", sessionHelper.get(SESSION_NAME, true).getValue()); + assertEquals(0, command.logoutCount); + } + + private static class TestLoginCommand extends AbstractSessionLoginCommand { + private final TestSessionHelper sessionHelper; + private final TestSessionDescriptor loginResult; + private int logoutCount; + + private TestLoginCommand(TestSessionHelper sessionHelper, TestSessionDescriptor loginResult) { + this.sessionHelper = sessionHelper; + this.loginResult = loginResult; + } + + @Override + public ISessionNameSupplier getSessionNameSupplier() { + return () -> SESSION_NAME; + } + + @Override + protected void logoutBeforeNewLogin(String sessionName, TestSessionDescriptor sessionDescriptor) { + logoutCount++; + } + + @Override + protected TestSessionDescriptor login(String sessionName) { + if ( loginResult==null ) { + throw new FcliSimpleException("Validation failed"); + } + return loginResult; + } + + @Override + protected AbstractSessionHelper getSessionHelper() { + return sessionHelper; + } + + @Override + public IOutputHelper getOutputHelper() { + return null; + } + } + + private static final class OptOutTestLoginCommand extends TestLoginCommand { + private OptOutTestLoginCommand(TestSessionHelper sessionHelper, TestSessionDescriptor loginResult) { + super(sessionHelper, loginResult); + } + + @Override + protected boolean shouldReplaceExistingSessionBeforeLogin() { + return false; + } + } + + private static final class TestSessionHelper extends AbstractSessionHelper { + @Override + public String getType() { + return "test-login"; + } + + @Override + protected String getLoginCmd() { + return "fcli test session login"; + } + + @Override + protected Class getSessionDescriptorType() { + return TestSessionDescriptor.class; + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static final class TestSessionDescriptor implements ISessionDescriptor { + private String value; + + public TestSessionDescriptor() {} + + TestSessionDescriptor(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + @Override + public String getUrlDescriptor() { + return "test"; + } + + @Override + public Date getCreatedDate() { + return new Date(); + } + + @Override + public Date getExpiryDate() { + return null; + } + + @Override + public String getType() { + return "test-login"; + } + } +}