diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/exception/UnsupportedAviatorUrlSchemeException.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/exception/UnsupportedAviatorUrlSchemeException.java new file mode 100644 index 00000000000..07cb8e97da8 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/exception/UnsupportedAviatorUrlSchemeException.java @@ -0,0 +1,42 @@ +/* + * 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; + +/** + * User-facing rejection of a non-{@code https} Aviator target scheme. + *

+ * Typed so diagnose can map endpoint UX without parsing exception message text. + */ +public class UnsupportedAviatorUrlSchemeException extends AviatorSimpleException { + private static final long serialVersionUID = 1L; + + public static final String STAGE_SUMMARY = "Unsupported URL scheme"; + public static final String STAGE_GUIDANCE = "Use a supported Aviator URL (https://host[:port])"; + + private final String scheme; + private final String providedUrl; + + public UnsupportedAviatorUrlSchemeException(String scheme, String providedUrl) { + super(STAGE_SUMMARY+" '"+scheme+"'. "+STAGE_GUIDANCE+". Provided URL: "+providedUrl); + this.scheme = scheme; + this.providedUrl = providedUrl; + } + + public String getScheme() { + return scheme; + } + + public String getProvidedUrl() { + return providedUrl; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorConnectionDiagnostics.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorConnectionDiagnostics.java index 69bdb94428c..820839f2d8f 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorConnectionDiagnostics.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorConnectionDiagnostics.java @@ -16,10 +16,12 @@ import java.net.InetAddress; import java.util.Arrays; import java.util.List; +import java.util.StringJoiner; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fortify.cli.aviator._common.exception.AviatorBugException; import com.fortify.cli.aviator._common.exception.AviatorSimpleException; +import com.fortify.cli.aviator._common.exception.UnsupportedAviatorUrlSchemeException; import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper.AviatorConnectionPlan; import com.fortify.cli.common.json.JsonHelper; @@ -44,14 +46,14 @@ public AviatorConnectionDiagnostics(IAviatorDiagnosticProbe probe) { } public AviatorDiagnosticReport diagnose(String url, int timeoutSeconds, String sourceType) { + var report = new AviatorDiagnosticReport(); + report.begin(AviatorDiagnosticStage.ENDPOINT); try { - return diagnose(AviatorGrpcClientHelper.createConnectionPlan(url), timeoutSeconds, sourceType); + return diagnoseValidated(report, AviatorGrpcClientHelper.createConnectionPlan(url), timeoutSeconds, + sourceType); } catch (AviatorSimpleException e) { - var report = new AviatorDiagnosticReport(); - report.fail(AviatorDiagnosticStage.ENDPOINT, - "Endpoint is invalid", "Use a valid Aviator host name and optional port", - AviatorDiagnosticEvidence.errorEvidence(e)); - skipAfter(report, null, AviatorDiagnosticStage.ENDPOINT, "endpoint configuration failed"); + failEndpoint(report, e); + skipAfter(report, null, AviatorDiagnosticStage.ENDPOINT, "endpoint validation failed"); return report; } } @@ -61,8 +63,15 @@ public AviatorDiagnosticReport diagnose(String url, int timeoutSeconds, String s */ public AviatorDiagnosticReport diagnose(AviatorConnectionPlan connectionPlan, int timeoutSeconds, String sourceType) { var report = new AviatorDiagnosticReport(); + report.begin(AviatorDiagnosticStage.ENDPOINT); + return diagnoseValidated(report, connectionPlan, timeoutSeconds, sourceType); + } + + private AviatorDiagnosticReport diagnoseValidated(AviatorDiagnosticReport report, + AviatorConnectionPlan connectionPlan, int timeoutSeconds, String sourceType) { report.pass(AviatorDiagnosticStage.ENDPOINT, - "Endpoint is valid", "No action required", endpointEvidence(connectionPlan, sourceType)); + "Endpoint is valid: "+connectionPlan.normalizedUrl(), "No action required", + endpointEvidence(connectionPlan, sourceType)); if (!runDns(report, connectionPlan)) { skipAfter(report, connectionPlan, AviatorDiagnosticStage.DNS, "DNS resolution failed"); @@ -79,7 +88,23 @@ public AviatorDiagnosticReport diagnose(AviatorConnectionPlan connectionPlan, in return report; } + private static void failEndpoint(AviatorDiagnosticReport report, AviatorSimpleException e) { + var evidence = AviatorDiagnosticEvidence.errorEvidence(e); + if (e instanceof UnsupportedAviatorUrlSchemeException schemeFailure) { + evidence.put("scheme", schemeFailure.getScheme()); + evidence.put("providedUrl", schemeFailure.getProvidedUrl()); + report.fail(AviatorDiagnosticStage.ENDPOINT, + UnsupportedAviatorUrlSchemeException.STAGE_SUMMARY, + UnsupportedAviatorUrlSchemeException.STAGE_GUIDANCE, + evidence); + return; + } + report.fail(AviatorDiagnosticStage.ENDPOINT, + "Endpoint is invalid", "Use a valid Aviator host name and optional port", evidence); + } + private boolean runDns(AviatorDiagnosticReport report, AviatorConnectionPlan connectionPlan) { + report.begin(AviatorDiagnosticStage.DNS); try { var evidence = JsonHelper.getObjectMapper().createObjectNode(); addAddresses(evidence, "resolvedAddresses", probe.resolve(connectionPlan.target().host())); @@ -87,7 +112,8 @@ private boolean runDns(AviatorDiagnosticReport report, AviatorConnectionPlan con var proxy = connectionPlan.proxyDescriptor().get(); addAddresses(evidence, "proxyResolvedAddresses", probe.resolve(proxy.getProxyHost())); } - report.pass(AviatorDiagnosticStage.DNS, "Host name resolved", "No action required", evidence); + report.pass(AviatorDiagnosticStage.DNS, + "Host name resolved: "+addresses(evidence, "resolvedAddresses"), "No action required", evidence); return true; } catch (IOException e) { report.fail(AviatorDiagnosticStage.DNS, @@ -105,13 +131,15 @@ private boolean runDns(AviatorDiagnosticReport report, AviatorConnectionPlan con * handshake failure. Do not fold TCP into the tunnel solely to avoid a double connect. */ private boolean runTcp(AviatorDiagnosticReport report, AviatorConnectionPlan connectionPlan, int timeoutSeconds) { + report.begin(AviatorDiagnosticStage.TCP); var proxyDescriptor = connectionPlan.proxyDescriptor(); var nextHopHost = proxyDescriptor.map(proxy -> proxy.getProxyHost()).orElse(connectionPlan.target().host()); var nextHopPort = proxyDescriptor.map(proxy -> proxy.getProxyPort()).orElse(connectionPlan.effectivePort()); var evidence = nextHopEvidence(nextHopHost, nextHopPort, proxyDescriptor.isPresent()); try { probe.connect(nextHopHost, nextHopPort, timeoutSeconds); - report.pass(AviatorDiagnosticStage.TCP, "TCP connection opened", "No action required", evidence); + report.pass(AviatorDiagnosticStage.TCP, + "TCP connection opened to "+nextHopHost+":"+nextHopPort, "No action required", evidence); return true; } catch (Exception e) { putError(evidence, e); @@ -132,6 +160,9 @@ private boolean runTcp(AviatorDiagnosticReport report, AviatorConnectionPlan con */ private boolean runTunnelStages(AviatorDiagnosticReport report, AviatorConnectionPlan connectionPlan, int timeoutSeconds) { + var hasProxy = connectionPlan.proxyDescriptor().isPresent(); + // First stage that will be recorded from this shared tunnel session. + report.begin(hasProxy ? AviatorDiagnosticStage.PROXY : AviatorDiagnosticStage.TLS); var tunnel = probe.probeTunnel(connectionPlan, timeoutSeconds); if (tunnel instanceof AviatorTunnelResult.ProxyConnectFailed failed) { appendProxyFailure(report, connectionPlan, failed); @@ -139,6 +170,9 @@ private boolean runTunnelStages(AviatorDiagnosticReport report, AviatorConnectio return false; } appendProxyPassIfConfigured(report, connectionPlan, tunnel); + if (hasProxy) { + report.begin(AviatorDiagnosticStage.TLS); + } if (tunnel instanceof AviatorTunnelResult.TlsFailed failed) { appendTlsFailure(report, connectionPlan, failed); skipAfter(report, connectionPlan, AviatorDiagnosticStage.TLS, "TLS handshake failed"); @@ -167,7 +201,8 @@ private void appendProxyPassIfConfigured(AviatorDiagnosticReport report, var evidence = JsonHelper.getObjectMapper().createObjectNode(); evidence.put("proxyConnectStatus", tunnel.proxyConnectStatus()); putProxyEvidence(evidence, connectionPlan); - report.pass(AviatorDiagnosticStage.PROXY, "Proxy CONNECT succeeded", "No action required", evidence); + report.pass(AviatorDiagnosticStage.PROXY, + "Proxy CONNECT succeeded through "+proxyEndpoint(evidence), "No action required", evidence); } private void appendTlsSuccess(AviatorDiagnosticReport report, AviatorTunnelResult.TlsSucceeded ok) { @@ -180,10 +215,11 @@ private void appendTlsSuccess(AviatorDiagnosticReport report, AviatorTunnelResul evidence.put("tlsPhase", AviatorTlsPhase.HANDSHAKE.id()); if (!"h2".equals(ok.applicationProtocol())) { report.warn(AviatorDiagnosticStage.TLS, - "TLS works, but HTTP/2 was not enabled", + tlsSummary("TLS works, but HTTP/2 was not enabled", ok), "Allow ALPN h2 through the proxy or gateway to aviator-grpc-server", true, evidence); } else { - report.pass(AviatorDiagnosticStage.TLS, "TLS and HTTP/2 are available", "No action required", evidence); + report.pass(AviatorDiagnosticStage.TLS, + tlsSummary("TLS and HTTP/2 are available", ok), "No action required", evidence); } } @@ -207,6 +243,7 @@ private void appendTlsFailure(AviatorDiagnosticReport report, AviatorConnectionP } private void runGrpc(AviatorDiagnosticReport report, AviatorConnectionPlan connectionPlan, int timeoutSeconds) { + report.begin(AviatorDiagnosticStage.GRPC); try { applyGrpc(report, probe.probeGrpc(connectionPlan.originalUrl(), timeoutSeconds)); } catch (Exception e) { @@ -220,10 +257,11 @@ private void applyGrpc(AviatorDiagnosticReport report, AviatorGrpcReachabilityRe if (grpc.pattern() != null) { evidence.put("pattern", grpc.pattern().wireId()); } + var summary = grpc.stageSummary(); if (grpc.stagePass()) { - report.pass(AviatorDiagnosticStage.GRPC, grpc.stageSummary(), grpc.stageGuidance(), evidence); + report.pass(AviatorDiagnosticStage.GRPC, summary, grpc.stageGuidance(), evidence); } else { - report.fail(AviatorDiagnosticStage.GRPC, grpc.stageSummary(), grpc.stageGuidance(), evidence); + report.fail(AviatorDiagnosticStage.GRPC, summary, grpc.stageGuidance(), evidence); } } @@ -275,7 +313,7 @@ private static void skipAfter(AviatorDiagnosticReport report, AviatorConnectionP if (stage == AviatorDiagnosticStage.PROXY && !hasProxy) { continue; } - report.skipWarn(stage, "Skipped because " + reason, + report.skipWarn(stage, reason, "Resolve the previous failed required stage first", AviatorDiagnosticEvidence.empty()); } @@ -285,4 +323,18 @@ private static void addAddresses(ObjectNode evidence, String fieldName, InetAddr var array = evidence.putArray(fieldName); Arrays.stream(addresses).map(InetAddress::getHostAddress).forEach(array::add); } + + private static String addresses(ObjectNode evidence, String fieldName) { + var result = new StringJoiner(", "); + evidence.withArray(fieldName).forEach(address -> result.add(address.asText())); + return result.toString(); + } + + private static String proxyEndpoint(ObjectNode evidence) { + return evidence.path("proxyHost").asText()+":"+evidence.path("proxyPort").asInt(); + } + + private static String tlsSummary(String summary, AviatorTunnelResult.TlsSucceeded result) { + return summary+": "+result.protocol()+", ALPN "+result.applicationProtocol(); + } } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticReport.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticReport.java index 3e2a96c63cd..64a3544ba50 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticReport.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticReport.java @@ -16,22 +16,77 @@ import java.util.Collections; import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fortify.cli.common.json.JsonHelper; /** - * Owns stage order and collects diagnostic rows for one diagnose run. + * Owns stage order, collects diagnostic rows, and emits support log lines for one diagnose run. + *

+ * Call {@link #begin(AviatorDiagnosticStage)} (or the string overload) before running a stage; + * {@code pass}/{@code fail}/{@code warn}/{@code skip*} record the row and log the outcome. + * Skip APIs take a structured {@code reason} so log formatting never parses summary English. */ public final class AviatorDiagnosticReport { + private static final Logger LOG = LoggerFactory.getLogger(AviatorDiagnosticReport.class); + private final List stages = new ArrayList<>(); public int nextOrder() { return stages.size() + 1; } + /** DEBUG start line before a stage is executed (not for pure skip rows). */ + public void begin(AviatorDiagnosticStage stage) { + LOG.debug("Starting {} diagnostic", stage.displayName()); + } + + /** DEBUG start line for product stages (token/admin) that are not transport enums. */ + public void begin(String stageId) { + LOG.debug("Starting {} diagnostic", AviatorDiagnosticStage.displayNameFor(stageId)); + } + public void add(AviatorDiagnosticStageResult result) { stages.add(result); + logStageOutcome(result, false, null); + } + + private void addSkip(AviatorDiagnosticStageResult result, String reason) { + stages.add(result); + logStageOutcome(result, true, reason); + } + + private static void logStageOutcome(AviatorDiagnosticStageResult result, boolean skipped, String skipReason) { + var name = AviatorDiagnosticStage.displayNameFor(result.stage()); + if (skipped) { + LOG.info("{} skipped because {}", name, nullToEmpty(skipReason)); + return; + } + switch (result.status()) { + case PASS -> LOG.info("{}: PASS - {}", name, nullToEmpty(result.summary())); + case FAIL -> LOG.error("{}: FAIL - {}", name, failDetail(result)); + case WARN -> LOG.info("{}: WARN - {}", name, nullToEmpty(result.summary())); + } + } + + private static String failDetail(AviatorDiagnosticStageResult result) { + var summary = nullToEmpty(result.summary()); + var evidence = result.evidence(); + if (evidence == null || !evidence.hasNonNull("exceptionMessage")) { + return summary; + } + var exceptionMessage = evidence.get("exceptionMessage").asText(); + if (exceptionMessage == null || exceptionMessage.isBlank()) { + return summary; + } + return summary+" ("+exceptionMessage+")"; + } + + private static String nullToEmpty(String value) { + return value == null ? "" : value; } public void pass(AviatorDiagnosticStage stage, String summary, String guidance, ObjectNode evidence) { @@ -43,11 +98,12 @@ public void fail(AviatorDiagnosticStage stage, String summary, String guidance, } /** - * Transport skip WARN. {@code required=true} documents the stage as part of the required - * pipeline; WARN itself never drives process exit (only required FAIL does). + * Required transport skip WARN. Builds summary {@code Skipped because {reason}} and logs + * as a skip without parsing summary text. */ - public void skipWarn(AviatorDiagnosticStage stage, String summary, String guidance, ObjectNode evidence) { - add(AviatorDiagnosticStageResult.warn(nextOrder(), stage, summary, guidance, true, evidence)); + public void skipWarn(AviatorDiagnosticStage stage, String reason, String guidance, ObjectNode evidence) { + addSkip(AviatorDiagnosticStageResult.warn(nextOrder(), stage, skipSummary(reason), guidance, true, evidence), + reason); } public void warn(AviatorDiagnosticStage stage, String summary, String guidance, boolean required, @@ -63,8 +119,16 @@ public void optionalFail(String stage, String description, String summary, Strin add(AviatorDiagnosticStageResult.optionalFail(nextOrder(), stage, description, summary, guidance, evidence)); } - public void optionalSkipWarn(String stage, String description, String summary, String guidance, ObjectNode evidence) { - add(AviatorDiagnosticStageResult.warn(nextOrder(), stage, description, summary, guidance, false, evidence)); + /** + * Optional product-stage skip WARN. Builds summary {@code Skipped because {reason}}. + */ + public void optionalSkipWarn(String stage, String description, String reason, String guidance, ObjectNode evidence) { + addSkip(AviatorDiagnosticStageResult.warn(nextOrder(), stage, description, skipSummary(reason), guidance, false, + evidence), reason); + } + + private static String skipSummary(String reason) { + return "Skipped because "+reason; } public List stages() { diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticStage.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticStage.java index c0cabd7a8af..d4cdab1c173 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticStage.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticStage.java @@ -20,19 +20,22 @@ * string stage ids from the connection diagnose helper only. */ public enum AviatorDiagnosticStage { - ENDPOINT("endpoint", "Aviator endpoint configuration validation"), - DNS("dns", "DNS resolution"), - TCP("tcp", "TCP connectivity"), - PROXY("proxy", "HTTP proxy CONNECT"), - TLS("tls", "TLS handshake"), - GRPC("grpc", "gRPC request/response reachability"); + ENDPOINT("endpoint", "Aviator endpoint configuration validation", "Endpoint"), + DNS("dns", "DNS resolution", "DNS"), + TCP("tcp", "TCP connectivity", "TCP"), + PROXY("proxy", "HTTP proxy CONNECT", "Proxy"), + TLS("tls", "TLS handshake", "TLS"), + GRPC("grpc", "gRPC request/response reachability", "gRPC"); private final String id; private final String description; + /** Human-readable label for log lines (e.g. {@code Endpoint}, {@code gRPC}). */ + private final String displayName; - AviatorDiagnosticStage(String id, String description) { + AviatorDiagnosticStage(String id, String description, String displayName) { this.id = id; this.description = description; + this.displayName = displayName; } public String id() { @@ -42,4 +45,21 @@ public String id() { public String description() { return description; } + + public String displayName() { + return displayName; + } + + /** Resolve wire id to display name; falls back to a simple capitalization of {@code stageId}. */ + public static String displayNameFor(String stageId) { + if (stageId == null || stageId.isBlank()) { + return "Stage"; + } + for (var stage : values()) { + if (stage.id.equals(stageId)) { + return stage.displayName; + } + } + return Character.toUpperCase(stageId.charAt(0)) + stageId.substring(1); + } } 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 3ae94b7a1c5..4c5c9791a70 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 @@ -91,7 +91,7 @@ public class AviatorGrpcClient implements AutoCloseable { final AtomicBoolean isShutdown = new AtomicBoolean(false); public AviatorGrpcClient(ManagedChannel channel, long defaultTimeoutSeconds, IAviatorLogger logger, long pingIntervalSeconds) { - LOG.info("Initializing AviatorGrpcClient with ManagedChannel"); + LOG.debug("Initializing AviatorGrpcClient with ManagedChannel"); this.logger = logger; this.channel = channel; this.asyncStub = AuditorServiceGrpc.newStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); @@ -116,7 +116,7 @@ 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.info("Initialized AviatorGrpcClient - Host: {}, Port: {}", host, port); + LOG.debug("Initialized AviatorGrpcClient - Host: {}, Port: {}", host, port); } public AviatorGrpcClient(ManagedChannel channel, long defaultTimeoutSeconds, IAviatorLogger logger) { @@ -172,7 +172,7 @@ public void close() { } } - LOG.info("Client closed"); + LOG.debug("Client closed"); } public Application createApplication(String name, String tenantName, String signature, String message) { 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 39672b9f650..d01c9d04586 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 @@ -24,8 +24,10 @@ import org.slf4j.LoggerFactory; import com.fortify.cli.aviator._common.exception.AviatorSimpleException; +import com.fortify.cli.aviator._common.exception.UnsupportedAviatorUrlSchemeException; import com.fortify.cli.aviator.config.IAviatorLogger; import com.fortify.cli.aviator.util.Constants; +import com.fortify.cli.common.http.UrlSchemes; import com.fortify.cli.common.http.proxy.helper.ProxyDescriptor; import com.fortify.cli.common.http.proxy.helper.ProxyHelper; import com.fortify.cli.common.http.ssl.trust.FcliTrustManager; @@ -103,12 +105,29 @@ public static ParsedTarget parseTarget(String url) { } } + /** + * Normalizes an Aviator target to an https URL. + *

+ */ public static String normalizeUrl(String url) { var trimmed = url.trim(); - if ( trimmed.matches("^[a-zA-Z][a-zA-Z0-9+\\-.]*://.*$") ) { + if ( !UrlSchemes.hasScheme(trimmed) ) { + return "https://"+trimmed; + } + var schemeSeparator = trimmed.indexOf("://"); + var scheme = trimmed.substring(0, schemeSeparator); + if ( !"https".equalsIgnoreCase(scheme) ) { + throw new UnsupportedAviatorUrlSchemeException(scheme, url); + } + if ( "https".equals(scheme) ) { return trimmed; } - return "https://"+trimmed; + // Canonicalize scheme casing (HTTPS://host → https://host) + return "https"+trimmed.substring(schemeSeparator); } private static NettyChannelBuilder configureBuilder(NettyChannelBuilder builder, Optional proxyDescriptor) { diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/diagnose/AviatorConnectionDiagnosticsTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/diagnose/AviatorConnectionDiagnosticsTest.java index 68a12df6d2b..50fb7bdf946 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/diagnose/AviatorConnectionDiagnosticsTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/diagnose/AviatorConnectionDiagnosticsTest.java @@ -23,6 +23,7 @@ import org.junit.jupiter.api.Test; +import com.fortify.cli.aviator._common.exception.UnsupportedAviatorUrlSchemeException; import com.fortify.cli.aviator.diagnose.support.ConfigurableDiagnosticProbe; import com.fortify.cli.aviator.diagnose.support.OfflineConnectionPlan; @@ -35,7 +36,9 @@ void shouldSkipDependentStagesWhenEndpointIsInvalid() { assertEquals(5, results.size()); assertStage(results.get(0), AviatorDiagnosticStage.ENDPOINT, AviatorDiagnosticStatus.FAIL); + assertEquals("Endpoint is invalid", results.get(0).summary()); assertStage(results.get(1), AviatorDiagnosticStage.DNS, AviatorDiagnosticStatus.WARN); + assertTrue(results.get(1).summary().contains("endpoint validation failed")); assertStage(results.get(2), AviatorDiagnosticStage.TCP, AviatorDiagnosticStatus.WARN); assertStage(results.get(3), AviatorDiagnosticStage.TLS, AviatorDiagnosticStatus.WARN); assertStage(results.get(4), AviatorDiagnosticStage.GRPC, AviatorDiagnosticStatus.WARN); @@ -43,6 +46,52 @@ void shouldSkipDependentStagesWhenEndpointIsInvalid() { assertFalse(probe.tunnelCalled); } + @Test + void shouldFailEndpointForUnsupportedUrlScheme() { + var probe = new ConfigurableDiagnosticProbe(); + var report = new AviatorConnectionDiagnostics(probe).diagnose("mttp://vacant", 5, "url"); + var results = report.stages(); + + assertEquals(5, results.size()); + assertStage(results.get(0), AviatorDiagnosticStage.ENDPOINT, AviatorDiagnosticStatus.FAIL); + assertEquals(UnsupportedAviatorUrlSchemeException.STAGE_SUMMARY, results.get(0).summary()); + assertEquals(UnsupportedAviatorUrlSchemeException.STAGE_GUIDANCE, results.get(0).guidance()); + assertEquals("mttp", results.get(0).evidence().path("scheme").asText()); + assertEquals("mttp://vacant", results.get(0).evidence().path("providedUrl").asText()); + assertStage(results.get(1), AviatorDiagnosticStage.DNS, AviatorDiagnosticStatus.WARN); + assertEquals("Skipped because endpoint validation failed", results.get(1).summary()); + assertStage(results.get(2), AviatorDiagnosticStage.TCP, AviatorDiagnosticStatus.WARN); + assertStage(results.get(3), AviatorDiagnosticStage.TLS, AviatorDiagnosticStatus.WARN); + assertStage(results.get(4), AviatorDiagnosticStage.GRPC, AviatorDiagnosticStatus.WARN); + assertTrue(report.hasRequiredFailure()); + assertFalse(probe.tunnelCalled); + assertFalse(probe.grpcCalled); + } + + @Test + void shouldFailEndpointForFtpSchemeEvenWithValidLookingHost() { + var probe = new ConfigurableDiagnosticProbe(); + var report = new AviatorConnectionDiagnostics(probe) + .diagnose("ftp://aviator-qa01.example.com", 5, "url"); + + assertStage(report.stages().get(0), AviatorDiagnosticStage.ENDPOINT, AviatorDiagnosticStatus.FAIL); + assertEquals(UnsupportedAviatorUrlSchemeException.STAGE_SUMMARY, report.stages().get(0).summary()); + assertEquals("ftp", report.stages().get(0).evidence().path("scheme").asText()); + assertTrue(report.hasRequiredFailure()); + assertFalse(probe.tunnelCalled); + } + + @Test + void shouldFailEndpointForHttpScheme() { + var probe = new ConfigurableDiagnosticProbe(); + var report = new AviatorConnectionDiagnostics(probe).diagnose("http://aviator.invalid", 5, "url"); + + assertStage(report.stages().get(0), AviatorDiagnosticStage.ENDPOINT, AviatorDiagnosticStatus.FAIL); + assertEquals(UnsupportedAviatorUrlSchemeException.STAGE_SUMMARY, report.stages().get(0).summary()); + assertEquals("http", report.stages().get(0).evidence().path("scheme").asText()); + assertFalse(probe.tunnelCalled); + } + @Test void shouldSkipTransportStagesWhenDnsFails() { var probe = new ConfigurableDiagnosticProbe(); 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 019b91ce691..91ceb215f87 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 @@ -15,12 +15,17 @@ import static org.junit.jupiter.api.Assertions.assertEquals; 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.InetSocketAddress; import java.net.SocketAddress; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import com.fortify.cli.aviator._common.exception.UnsupportedAviatorUrlSchemeException; import com.fortify.cli.common.http.proxy.helper.ProxyDescriptor; import io.grpc.HttpConnectProxiedSocketAddress; @@ -64,6 +69,26 @@ void shouldCreateConnectionPlanWithExplicitPort() { assertEquals(8443, plan.effectivePort()); } + @Test + void shouldCanonicalizeHttpsSchemeCasing() { + assertEquals("https://aviator.invalid", AviatorGrpcClientHelper.normalizeUrl("HTTPS://aviator.invalid")); + } + + @ParameterizedTest + @ValueSource(strings = { + "mttp://vacant", + "ftp://aviator.invalid", + "http://aviator.invalid", + "grpc://aviator.invalid" + }) + void shouldRejectNonHttpsSchemes(String url) { + var ex = assertThrows(UnsupportedAviatorUrlSchemeException.class, + () -> AviatorGrpcClientHelper.createConnectionPlan(url)); + assertEquals(url.substring(0, url.indexOf("://")), ex.getScheme()); + assertEquals(url, ex.getProvidedUrl()); + assertTrue(ex.getMessage().contains(UnsupportedAviatorUrlSchemeException.STAGE_SUMMARY)); + } + @Test void shouldBuildHttpConnectProxyAddressWithCredentials() { var proxy = ProxyDescriptor.builder() 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 0cf8d1397fe..0f66987d9d5 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 @@ -77,9 +77,10 @@ public void visitAdmin(CredentialRequest.Admin admin) { /** Skip when gRPC did not respond; otherwise run {@code validate}. */ private void runCredentialStage(AviatorDiagnosticReport report, String stage, String description, Runnable validate) { + report.begin(stage); if (!report.hasGrpcStagePass()) { report.optionalSkipWarn(stage, description, - "Credential check skipped", + "gRPC did not respond", "Fix the gRPC connection first", AviatorDiagnosticEvidence.empty()); return; } @@ -99,7 +100,7 @@ private void validateUserToken(AviatorDiagnosticReport report, String aviatorUrl evidence.put("tenantNamePresent", validationResult.tenantName() != null); if (validationResult.response().getValid()) { report.optionalPass(STAGE_TOKEN, STAGE_TOKEN_DESCRIPTION, - "Aviator token is valid", "No action required", evidence); + "Aviator token is valid for the requested tenant", "No action required", evidence); return; } var errorMessage = validationResult.response().getErrorMessage(); @@ -124,7 +125,7 @@ private void validateAdminConfig(AviatorDiagnosticReport report, AviatorAdminCon var evidence = AviatorDiagnosticEvidence.empty(); evidence.put("tenant", configDescriptor.getTenant()); report.optionalPass(STAGE_ADMIN, STAGE_ADMIN_DESCRIPTION, - "Aviator admin credentials are valid", "No action required", evidence); + "Aviator admin credentials are valid for tenant "+configDescriptor.getTenant(), "No action required", evidence); } catch (RuntimeException e) { rethrowIfBug(e); report.optionalFail(STAGE_ADMIN, STAGE_ADMIN_DESCRIPTION, diff --git a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/http/UrlSchemes.java b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/http/UrlSchemes.java new file mode 100644 index 00000000000..e1db79769fc --- /dev/null +++ b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/http/UrlSchemes.java @@ -0,0 +1,28 @@ +/* + * 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.http; + +/** + * Shared URL scheme detection (RFC 3986 scheme production, lenient for CLI inputs). + * Policy after detection (prepend https, reject non-https, …) stays with the caller. + */ +public final class UrlSchemes { + private static final String SCHEME_PREFIX = "^[a-zA-Z][a-zA-Z0-9+\\-.]*://.*$"; + + private UrlSchemes() {} + + /** True when {@code url} starts with a scheme and {@code ://}. */ + public static boolean hasScheme(String url) { + return url != null && url.matches(SCHEME_PREFIX); + } +} diff --git a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/http/proxy/helper/ProxyHelper.java b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/http/proxy/helper/ProxyHelper.java index 79e8515e085..d7bd1a3390e 100644 --- a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/http/proxy/helper/ProxyHelper.java +++ b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/http/proxy/helper/ProxyHelper.java @@ -26,6 +26,7 @@ import org.slf4j.LoggerFactory; import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.http.UrlSchemes; import com.fortify.cli.common.util.FcliDataHelper; import kong.unirest.UnirestInstance; @@ -75,16 +76,12 @@ static URI parseTargetUri(String targetUrlString) { private static String normalizeTargetUrl(String targetUrlString) { var trimmed = StringUtils.trimToEmpty(targetUrlString); - if ( !hasScheme(trimmed) ) { + if ( !UrlSchemes.hasScheme(trimmed) ) { return "https://"+trimmed; } return trimmed; } - private static boolean hasScheme(String url) { - return url.matches("^[a-zA-Z][a-zA-Z0-9+\\-.]*://.*$"); - } - static Optional getProxyEnvVarName(String targetScheme, Map env) { return getProxyEnvVarCandidates(targetScheme).stream() .filter(envVarName->StringUtils.isNotBlank(env.get(envVarName))) @@ -108,7 +105,7 @@ private static ProxyDescriptor getProxyDescriptorFromEnvVar(String envVarName, S private static String normalizeProxyUri(String envVarName, String proxyString) { var trimmed = StringUtils.trimToEmpty(proxyString); - if ( hasScheme(trimmed) ) { + if ( UrlSchemes.hasScheme(trimmed) ) { return trimmed; } if ( envVarName.toLowerCase(Locale.ROOT).startsWith("https_") ) {