diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationResult.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationResult.java index df66621f21..3b3cdb1bf8 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationResult.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationResult.java @@ -14,17 +14,35 @@ import java.util.List; +import lombok.Builder; + /** * Holds the outcome of a full correlation stream run — both confirmed - * and rejected SAST–DAST pairs, plus the count of correlation requests - * that received a successful response from the server. + * and rejected SAST–DAST pairs, plus request statistics for the correlation phase. * * @param confirmedPairs pairs where Phase 2 validation returned confirmed=true * @param rejectedPairs pairs where Phase 2 validation returned confirmed=false - * @param receivedCorrelationResponses number of Phase 1 correlation requests that received a response + * @param submittedCorrelationRequests number of Phase 1 correlation requests sent to the server + * @param successfulCorrelationResponses number of Phase 1 requests with a successful server response + * @param skippedCorrelationResponses number of Phase 1 requests skipped by the server + * @param failedCorrelationResponses number of Phase 1 requests that failed */ +@Builder public record CorrelationResult( List confirmedPairs, List rejectedPairs, - int receivedCorrelationResponses -) {} + int submittedCorrelationRequests, + int successfulCorrelationResponses, + int skippedCorrelationResponses, + int failedCorrelationResponses +) { + public CorrelationResult { + confirmedPairs = confirmedPairs == null ? List.of() : List.copyOf(confirmedPairs); + rejectedPairs = rejectedPairs == null ? List.of() : List.copyOf(rejectedPairs); + } + + public static CorrelationResult empty() { + return CorrelationResult.builder() + .build(); + } +} 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 5c4effc8c6..4b2b27fb3e 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 @@ -20,6 +20,7 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -94,16 +95,15 @@ public class CorrelationStreamProcessor implements AutoCloseable { // Input data retained for building validation requests private List correlationWorkItems; private Map> urlToDastIssues; - private final java.util.concurrent.ConcurrentHashMap validationRequestToDastId = - new java.util.concurrent.ConcurrentHashMap<>(); + private final Set pendingCorrelationRequestIds = ConcurrentHashMap.newKeySet(); + private final ConcurrentHashMap validationRequestToDastId = new ConcurrentHashMap<>(); private volatile CompletableFuture resultFuture; /** - * Keys of SAST–DAST pairs that were confirmed in a previous run and should - * be skipped during both Phase 1 (correlation) and Phase 2 (validation). + * Keys of SAST–DAST pairs that were confirmed or rejected in a previous run. * Each key is formatted as {@code "sastInstanceId::dastIssueId"}. */ - private Set previouslyCorrelatedPairKeys = Set.of(); + private Set previouslyTriedPairKeys = Set.of(); public CorrelationStreamProcessor( AviatorGrpcClient client, @@ -122,23 +122,25 @@ public CorrelationStreamProcessor( /** * Entry point: run correlation on the provided mixed-category buckets. - * Previously confirmed pairs (from prior runs) are not re-processed. + * Previously tried pairs (from prior runs) are not re-processed. * * @param config stream init configuration (token, app name, etc.) * @param mixedBuckets category buckets containing both SAST and DAST findings * @param scanGuid SAST scan UUID for building CorrelatedPair results - * @param previouslyCorrelatedPairKeys keys of already-confirmed pairs to skip, each formatted as - * {@code "sastInstanceId::dastIssueId"}; may be {@code null} + * @param previouslyTriedPairKeys keys of already-tried pairs to skip, each formatted as + * {@code "sastInstanceId::dastIssueId"}; may be {@code null} * @return future that completes with the list of confirmed correlated pairs */ public CompletableFuture processCorrelation( CorrelationStreamConfig config, List mixedBuckets, String scanGuid, - Set previouslyCorrelatedPairKeys) { + Set previouslyTriedPairKeys) { - this.previouslyCorrelatedPairKeys = - previouslyCorrelatedPairKeys != null ? previouslyCorrelatedPairKeys : Set.of(); + this.previouslyTriedPairKeys = + previouslyTriedPairKeys != null ? previouslyTriedPairKeys : Set.of(); + pendingCorrelationRequestIds.clear(); + validationRequestToDastId.clear(); // Build URL→DAST map first; needed to evaluate Phase 1 skip eligibility this.urlToDastIssues = buildUrlToDastMap(mixedBuckets); @@ -149,7 +151,7 @@ public CompletableFuture processCorrelation( urlToDastIssues.forEach((k,v)->LOG.debug(" For url {} no. of dast issues {}", k, v.size())); if (workItems.isEmpty()) { LOG.info("No SAST findings in mixed buckets; skipping correlation stream."); - return CompletableFuture.completedFuture(new CorrelationResult(List.of(), List.of(), 0)); + return CompletableFuture.completedFuture(CorrelationResult.empty()); } String streamId = UUID.randomUUID().toString(); @@ -167,6 +169,17 @@ public CompletableFuture processCorrelation( return resultFuture; } + private CorrelationResult createResultSnapshot() { + return CorrelationResult.builder() + .confirmedPairs(new ArrayList<>(state.confirmedPairs)) + .rejectedPairs(new ArrayList<>(state.rejectedPairs)) + .submittedCorrelationRequests(state.totalCorrelationRequests) + .successfulCorrelationResponses(state.successfulCorrelations.get()) + .skippedCorrelationResponses(state.skippedCorrelations.get()) + .failedCorrelationResponses(state.failedCorrelations.get()) + .build(); + } + /** * Convenience overload for callers that have no previously confirmed pairs to skip. */ @@ -220,6 +233,7 @@ private void sendCorrelationRequests() { for (var item : correlationWorkItems) { var req = buildCorrelationRequest(state.streamId, item); + pendingCorrelationRequestIds.add(req.getRequestId()); requestHandler.sendRequest( CorrelationClientMessage.newBuilder().setCorrelation(req).build() ); @@ -238,11 +252,7 @@ private void transitionToValidation(String scanGuid) { if (validationItems.isEmpty()) { logger.info("No candidates to validate. Completing stream."); if (!resultFuture.isDone()) { - resultFuture.complete(new CorrelationResult( - new ArrayList<>(state.confirmedPairs), - new ArrayList<>(state.rejectedPairs), - state.successfulCorrelations.get() - )); + resultFuture.complete(createResultSnapshot()); } requestHandler.complete(); streamLatch.countDown(); @@ -312,11 +322,7 @@ public void onCompleted() { state.currentPhase = CorrelationStreamState.Phase.COMPLETE; logger.info("Correlation stream completed — " + state.confirmedPairs.size() + " confirmed pairs"); if (!resultFuture.isDone()) { - resultFuture.complete(new CorrelationResult( - new ArrayList<>(state.confirmedPairs), - new ArrayList<>(state.rejectedPairs), - state.successfulCorrelations.get() - )); + resultFuture.complete(createResultSnapshot()); } streamLatch.countDown(); } @@ -340,6 +346,10 @@ private void handleInitResponse(CorrelationInitResponse resp) { } private void handleCorrelationResponse(CorrelationResponse resp) { + if (!pendingCorrelationRequestIds.remove(resp.getRequestId())) { + LOG.warn("Ignoring correlation response for unknown or completed request {}", resp.getRequestId()); + return; + } int received = state.receivedCorrelations.incrementAndGet(); LOG.debug("Correlation response {}/{} for SAST {}: status={}", received, state.totalCorrelationRequests, resp.getSastId(), resp.getStatus()); @@ -356,7 +366,11 @@ private void handleCorrelationResponse(CorrelationResponse resp) { match.getRationale() )); } + } else if ("SKIPPED".equalsIgnoreCase(resp.getStatus())) { + state.skippedCorrelations.incrementAndGet(); + LOG.debug("Skipped correlation for SAST {}: {}", resp.getSastId(), resp.getNoCorrelationReason()); } else { + state.failedCorrelations.incrementAndGet(); LOG.debug("Non-OK correlation for SAST {}: {} — {}", resp.getSastId(), resp.getStatus(), resp.getNoCorrelationReason()); } @@ -408,11 +422,7 @@ private void handleValidationResponse(CorrelationValidationResponse resp, String state.confirmedPairs.size() + " confirmed pairs, " + state.rejectedPairs.size() + " rejected pairs."); if (!resultFuture.isDone()) { - resultFuture.complete(new CorrelationResult( - new ArrayList<>(state.confirmedPairs), - new ArrayList<>(state.rejectedPairs), - state.successfulCorrelations.get() - )); + resultFuture.complete(createResultSnapshot()); } requestHandler.complete(); streamLatch.countDown(); @@ -568,10 +578,10 @@ private List buildCorrelationWorkItems(List urlList = new ArrayList<>(dastUrls); for (Vulnerability vuln : data.sastFindings()) { - // Strip URLs where every mapped DAST issue is already confirmed with this SAST finding + // Strip URLs where every mapped DAST issue was already tried with this SAST finding. List newUrls = filterNewUrls(vuln.getInstanceID(), urlList); if (newUrls.isEmpty()) { - LOG.debug("Skipping SAST finding {} from Phase 1 — all reachable DAST issues already confirmed", + LOG.debug("Skipping SAST finding {} from Phase 1 — all reachable DAST issues already tried", vuln.getInstanceID()); continue; } @@ -585,16 +595,16 @@ private List buildCorrelationWorkItems(List *
  • URLs with no mapped DAST issues are kept (the server may resolve them).
  • *
  • URLs where every mapped DAST issue is already in - * {@link #previouslyCorrelatedPairKeys} are excluded — they add no new work.
  • + * {@link #previouslyTriedPairKeys} are excluded — they add no new work. * */ private List filterNewUrls(String sastInstanceId, List urls) { - if (previouslyCorrelatedPairKeys.isEmpty()) return urls; // fast path: nothing confirmed yet + if (previouslyTriedPairKeys.isEmpty()) return urls; List result = new ArrayList<>(); for (String url : urls) { List issues = urlToDastIssues.getOrDefault(url, List.of()); @@ -604,7 +614,7 @@ private List filterNewUrls(String sastInstanceId, List urls) { } boolean hasUncorrelated = issues.stream() .filter(d -> d.getId() != null && !d.getId().isEmpty()) - .anyMatch(d -> !previouslyCorrelatedPairKeys.contains(sastInstanceId + "::" + d.getId())); + .anyMatch(d -> !previouslyTriedPairKeys.contains(sastInstanceId + "::" + d.getId())); if (hasUncorrelated) { result.add(url); } @@ -645,8 +655,8 @@ private List buildValidationWorkItems() { for (DastIssue dastIssue : issues) { String pairKey = match.sastInstanceId() + "::" + dastIssue.getId(); - if (previouslyCorrelatedPairKeys.contains(pairKey)) { - LOG.debug("Skipping already confirmed pair sast={} dast={} from Phase 2 validation", + if (previouslyTriedPairKeys.contains(pairKey)) { + LOG.debug("Skipping already-tried pair sast={} dast={} from Phase 2 validation", match.sastInstanceId(), dastIssue.getId()); continue; } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamState.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamState.java index d8ec3e2b19..b6ccdce3f2 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamState.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamState.java @@ -39,6 +39,8 @@ enum Phase { INIT, CORRELATING, VALIDATING, COMPLETE } final AtomicInteger sentCorrelations = new AtomicInteger(0); final AtomicInteger receivedCorrelations = new AtomicInteger(0); final AtomicInteger successfulCorrelations = new AtomicInteger(0); + final AtomicInteger skippedCorrelations = new AtomicInteger(0); + final AtomicInteger failedCorrelations = new AtomicInteger(0); // Validation phase counters int totalValidationRequests; diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessorTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessorTest.java index b0207c6e57..00ca9dc474 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessorTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessorTest.java @@ -15,9 +15,11 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.List; import java.util.Map; +import java.util.Set; import org.junit.jupiter.api.Test; @@ -59,6 +61,25 @@ void buildUrlToDastMapPreservesUrlEncounterOrder() throws Exception { ); } + @Test + void buildCorrelationWorkItemsSkipsFindingWhenAllPairsWereTried() throws Exception { + CorrelationStreamProcessor processor = new CorrelationStreamProcessor(null, null, null, null, 0, 0); + CorrelationStreamProcessor.CorrelationBucketData bucket = createBucket( + "SQL Injection", + List.of("https://example.com/login", "https://example.com/admin") + ); + Map> urlMap = invokeBuildUrlToDastMap(processor, List.of(bucket)); + setField(processor, "urlToDastIssues", urlMap); + setField(processor, "previouslyTriedPairKeys", Set.of( + "SAST-1::https://example.com/login", + "SAST-1::https://example.com/admin" + )); + + List items = invokeBuildCorrelationWorkItems(processor, List.of(bucket)); + + assertEquals(0, items.size()); + } + private CorrelationStreamProcessor.CorrelationBucketData createBucket(String category, List sessionUrls) { List dastIssues = sessionUrls.stream() .map(this::createDastIssue) @@ -96,4 +117,10 @@ private List getDastUrls(Object workItem) throws Exception { method.setAccessible(true); return (List) method.invoke(workItem); } + + private void setField(CorrelationStreamProcessor processor, String fieldName, Object value) throws Exception { + Field field = CorrelationStreamProcessor.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(processor, value); + } } \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AbstractAviatorSSCSastAuditCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AbstractAviatorSSCSastAuditCommand.java index 663274a982..0052ede802 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AbstractAviatorSSCSastAuditCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AbstractAviatorSSCSastAuditCommand.java @@ -33,6 +33,7 @@ import com.fortify.cli.aviator.config.AviatorLoggerImpl; import com.fortify.cli.aviator.config.TagMappingConfig; import com.fortify.cli.aviator.ssc.helper.AviatorSSCAuditHelper; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCRefreshHelper; import com.fortify.cli.aviator.ssc.helper.AviatorSSCTagValidator; import com.fortify.cli.aviator.util.FprHandle; import com.fortify.cli.aviator.util.ResourceUtil; @@ -48,10 +49,7 @@ import com.fortify.cli.ssc.appversion.cli.mixin.SSCAppVersionRefreshOptions; import com.fortify.cli.ssc.appversion.cli.mixin.SSCAppVersionResolverMixin; import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; -import com.fortify.cli.ssc.appversion.helper.SSCAppVersionHelper; import com.fortify.cli.ssc.issue.cli.mixin.SSCIssueFilterSetOptionMixin; -import com.fortify.cli.ssc.system_state.helper.SSCJobDescriptor; -import com.fortify.cli.ssc.system_state.helper.SSCJobHelper; import kong.unirest.UnirestInstance; import lombok.Getter; @@ -94,7 +92,8 @@ public JsonNode getJsonNode(UnirestInstance unirest) { AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); SSCAppVersionDescriptor av = appVersionResolver.getAppVersionDescriptor(unirest); - refreshMetricsIfNeeded(unirest, av, logger); + AviatorSSCRefreshHelper.refreshMetricsIfNeeded( + unirest, av, refreshOptions.isRefresh(), refreshOptions.getRefreshTimeout(), logger); long auditableIssueCount = AviatorSSCAuditHelper.getAuditableIssueCount(unirest, av, logger, isNoFilterSet(), getFilterSetTitleOrId(), folderNames); if (auditableIssueCount == 0) { @@ -136,17 +135,6 @@ boolean isNoFilterSet() { return noFilterSet; } - private void refreshMetricsIfNeeded(UnirestInstance unirest, SSCAppVersionDescriptor av, AviatorLoggerImpl logger) { - if (refreshOptions.isRefresh() && av.isRefreshRequired()) { - logger.progress("Status: Metrics for application version %s:%s are out of date, starting refresh...", av.getApplicationName(), av.getVersionName()); - SSCJobDescriptor refreshJobDesc = SSCAppVersionHelper.refreshMetrics(unirest, av); - if (refreshJobDesc != null) { - SSCJobHelper.waitForJob(unirest, refreshJobDesc, refreshOptions.getRefreshTimeout()); - logger.progress("Status: Metrics refreshed successfully."); - } - } - } - private boolean isSkipIfExceedingQuota() { return quotaHandlingArgGroup.skipIfExceedingQuota; } 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 eaae72f1d4..bdfca5b68e 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 @@ -15,6 +15,8 @@ import static com.fortify.cli.ssc.artifact.helper.SSCArtifactHelper.getLatestDASTArtifact; import static com.fortify.cli.ssc.artifact.helper.SSCArtifactHelper.getLatestSASTArtifact; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.HashSet; import java.util.List; @@ -33,15 +35,16 @@ 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.CorrelatedPair; import com.fortify.cli.aviator.grpc.CorrelationResult; import com.fortify.cli.aviator.grpc.CorrelationStreamConfig; import com.fortify.cli.aviator.grpc.CorrelationStreamProcessor; import com.fortify.cli.aviator.ssc.helper.AviatorSSCAttributeHelper; -import com.fortify.cli.aviator.ssc.helper.AviatorSSCCorrelateDownloadHelper; import com.fortify.cli.aviator.ssc.helper.AviatorSSCCorrelateFprParser; import com.fortify.cli.aviator.ssc.helper.AviatorSSCCorrelateFprParser.ParseResult; import com.fortify.cli.aviator.ssc.helper.AviatorSSCCorrelateHelper; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCCorrelateOutput; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCFprTransferHelper; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCRefreshHelper; import com.fortify.cli.aviator.ssc.helper.CategoryBucket; import com.fortify.cli.aviator.ssc.helper.CategoryGrouper; import com.fortify.cli.aviator.ssc.helper.DastFprCorrelationEnricher; @@ -52,9 +55,9 @@ import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; import com.fortify.cli.common.progress.helper.IProgressWriter; import com.fortify.cli.ssc._common.output.cli.cmd.AbstractSSCJsonNodeOutputCommand; +import com.fortify.cli.ssc.appversion.cli.mixin.SSCAppVersionRefreshOptions; import com.fortify.cli.ssc.appversion.cli.mixin.SSCAppVersionResolverMixin; import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; -import com.fortify.cli.ssc.artifact.helper.SSCArtifactDescriptor; import kong.unirest.UnirestInstance; import lombok.Getter; @@ -69,10 +72,12 @@ public class AviatorSSCCorrelateSastDastCommand extends AbstractSSCJsonNodeOutpu @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; @Mixin private SSCAppVersionResolverMixin.RequiredOption appVersionResolver; @Mixin private AviatorUserSessionDescriptorSupplier sessionDescriptorSupplier; + @Mixin private SSCAppVersionRefreshOptions refreshOptions; @Option(names = {"--app"}) private String appName; private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCCorrelateSastDastCommand.class); private String actionResult = "CORRELATED"; + private String actionMessage; @Override public JsonNode getJsonNode(UnirestInstance unirest) { @@ -97,46 +102,94 @@ private class CorrelationProcessor { private final SSCAppVersionDescriptor av; private final AviatorUserSessionDescriptor sessionDescriptor; - private record DownloadedFprs(Path sastPath, Path dastPath, SSCArtifactDescriptor adDast) {} + private record CorrelationFiles(Path statePath, Path historyPath, boolean unchangedSinceCorrelation) + implements AutoCloseable { + @Override + public void close() { + deleteTemporaryFile(statePath); + deleteTemporaryFile(historyPath); + } + } JsonNode run() { logger.progress("Status: Starting SAST-DAST correlation for %s:%s", av.getApplicationName(), av.getVersionName()); - var fprs = downloadFprs(); - var sastResult = parseFpr(fprs.sastPath, "SAST"); - var dastResult = parseFpr(fprs.dastPath, "DAST"); + AviatorSSCRefreshHelper.refreshMetricsIfNeeded( + unirest, av, refreshOptions.isRefresh(), refreshOptions.getRefreshTimeout(), logger); + try (var files = downloadCorrelationFiles()) { + return correlate(files); + } + } + + private JsonNode correlate(CorrelationFiles files) { + var sastResult = parseFpr(files.statePath(), "SAST"); + var dastResult = parseFpr(files.statePath(), "DAST"); var unsuppressedSast = filterUnsuppressedSast(sastResult); var unsuppressedDast = filterUnsuppressedDast(dastResult); - var alreadyTriedKeys = buildAlreadyTriedKeys(unsuppressedDast, fprs.sastPath, sastResult, dastResult); + var alreadyTriedKeys = buildAlreadyTriedKeys( + unsuppressedDast, files.statePath(), files.historyPath(), sastResult, dastResult); + + if (files.unchangedSinceCorrelation() && !alreadyTriedKeys.isEmpty()) { + actionResult = "SKIPPED"; + actionMessage = "No newer SAST or DAST scan found"; + logger.progress("Status: No newer SAST or DAST scan found — skipping correlation and FPR upload."); + return buildOutputJson(null, CorrelationResult.empty()); + } var mixedBuckets = groupByCategory(unsuppressedSast, unsuppressedDast); - int submitted = countNewSastFindings(mixedBuckets, alreadyTriedKeys); - var grpcResult = correlateViaGrpc(mixedBuckets, alreadyTriedKeys, submitted, sastResult); - String uploadedArtifactId = uploadCorrelatedFprs(fprs, grpcResult); + var grpcResult = correlateViaGrpc(mixedBuckets, alreadyTriedKeys, sastResult); + String uploadedArtifactId = uploadCorrelationResults(files.statePath(), grpcResult); logger.progress("Status: Correlation process complete for %s:%s — result: %s", av.getApplicationName(), av.getVersionName(), actionResult); - return AviatorSSCCorrelateHelper.buildOutputJson( - av, uploadedArtifactId, submitted, grpcResult.succeeded, grpcResult.confirmed, actionResult); + return buildOutputJson(uploadedArtifactId, grpcResult); } - private DownloadedFprs downloadFprs() { + private JsonNode buildOutputJson(String artifactId, CorrelationResult correlationResult) { + return AviatorSSCCorrelateOutput.builder() + .appVersion(av) + .artifactId(artifactId) + .correlationResult(correlationResult) + .actionResult(actionResult) + .message(actionMessage) + .build() + .toJsonNode(); + } + + private CorrelationFiles downloadCorrelationFiles() { + Path statePath = null; + Path historyPath = null; try { - logger.progress("Status: Downloading SAST FPR from SSC for %s:%s", av.getApplicationName(), av.getVersionName()); - var adSast = getLatestSASTArtifact(unirest, av.getVersionId()); - var sastPath = AviatorSSCCorrelateDownloadHelper.downloadArtifactFpr(unirest, adSast, logger, progressWriter); - - logger.progress("Status: Downloading DAST FPR from SSC for %s:%s", av.getApplicationName(), av.getVersionName()); - var adDast = getLatestDASTArtifact(unirest, av.getVersionId()); - var dastPath = AviatorSSCCorrelateDownloadHelper.downloadArtifactFpr(unirest, adDast, logger, progressWriter); - - AviatorSSCCorrelateHelper.validateDownloadedFpr(sastPath, "SAST"); - AviatorSSCCorrelateHelper.validateDownloadedFpr(dastPath, "DAST"); - return new DownloadedFprs(sastPath, dastPath, adDast); - } catch (java.io.IOException e) { + statePath = AviatorSSCFprTransferHelper.downloadCurrentStateFpr( + unirest, av, logger, progressWriter); + var sastArtifact = getLatestSASTArtifact(unirest, av.getVersionId()); + var historyArtifact = getLatestDASTArtifact(unirest, av.getVersionId()); + historyPath = AviatorSSCFprTransferHelper.downloadArtifactFpr( + unirest, historyArtifact, logger, progressWriter); + AviatorSSCCorrelateHelper.validateDownloadedFpr(statePath, "merged"); + AviatorSSCCorrelateHelper.validateDownloadedFpr(historyPath, "correlation history"); + boolean unchangedSinceCorrelation = AviatorSSCCorrelateHelper.isUnchangedSinceCorrelation( + sastArtifact, historyArtifact); + return new CorrelationFiles(statePath, historyPath, unchangedSinceCorrelation); + } catch (IOException e) { + deleteTemporaryFile(statePath); + deleteTemporaryFile(historyPath); throw new FcliSimpleException("Failed to download FPR from SSC: " + e.getMessage(), e); + } catch (RuntimeException e) { + deleteTemporaryFile(statePath); + deleteTemporaryFile(historyPath); + throw e; + } + } + + private static void deleteTemporaryFile(Path path) { + if (path == null) return; + try { + Files.deleteIfExists(path); + } catch (IOException e) { + LOG.warn("Failed to delete temporary correlation FPR {}", path, e); } } @@ -159,17 +212,20 @@ private List filterUnsuppressedDast(ParseResult dastResult) { .collect(Collectors.toList()); } - private Set buildAlreadyTriedKeys(List unsuppressedDast, Path sastFprPath, + private Set buildAlreadyTriedKeys(List unsuppressedDast, Path statePath, Path historyPath, ParseResult sastResult, ParseResult dastResult) { Set confirmedPairKeys = buildPreviouslyCorrelatedPairKeys(unsuppressedDast); - Set rejectedPairKeys = SastFprCorrelationRecorder.readTriedPairKeys(sastFprPath); + Set statePairKeys = SastFprCorrelationRecorder.readTriedPairKeys(statePath); + Set historyPairKeys = SastFprCorrelationRecorder.readTriedPairKeys(historyPath); Set alreadyTriedKeys = new HashSet<>(confirmedPairKeys); - alreadyTriedKeys.addAll(rejectedPairKeys); + alreadyTriedKeys.addAll(statePairKeys); + alreadyTriedKeys.addAll(historyPairKeys); LOG.info("Total SAST issues {}", sastResult.vulnerabilities.size()); LOG.info("Total DAST issues {}", dastResult.dastIssues.size()); LOG.info("Confirmed pairs (from ExternalFindings): {}", confirmedPairKeys.size()); - LOG.info("Pairs from DAST_CORRELATION_STATUS tag: {}", rejectedPairKeys.size()); + LOG.info("Pairs from current-state DAST_CORRELATION_STATUS tags: {}", statePairKeys.size()); + LOG.info("Pairs from latest successful DAST artifact tags: {}", historyPairKeys.size()); LOG.info("Total already-tried pairs (will be skipped): {}", alreadyTriedKeys.size()); return alreadyTriedKeys; } @@ -183,18 +239,17 @@ private List groupByCategory(List sast, List confirmed, List rejected, int succeeded) {} - - private GrpcResult correlateViaGrpc(List mixedBuckets, Set alreadyTriedKeys, - int submitted, ParseResult sastResult) { + private CorrelationResult correlateViaGrpc(List mixedBuckets, + Set alreadyTriedKeys, + ParseResult sastResult) { if (mixedBuckets.isEmpty()) { actionResult = "SKIPPED"; + actionMessage = "No issues present for correlation"; logger.progress("Status: No mixed categories found — skipping correlation."); - return new GrpcResult(List.of(), List.of(), 0); + return CorrelationResult.empty(); } - logger.progress("Status: Found %d mixed category bucket(s) with %d SAST findings to correlate", - mixedBuckets.size(), submitted); + logger.progress("Status: Found %d mixed category bucket(s) to correlate", mixedBuckets.size()); var bucketData = mixedBuckets.stream() .map(b -> new CorrelationStreamProcessor.CorrelationBucketData( @@ -206,56 +261,53 @@ private GrpcResult correlateViaGrpc(List mixedBuckets, Set confirmed; - List rejected; - int succeeded; + CorrelationResult result; try (var grpcClient = AviatorGrpcClientHelper.createClient(sessionDescriptor.getAviatorUrl(), logger, 30)) { - var result = performCorrelation(grpcClient, config, bucketData, sastResult.scanGuid, alreadyTriedKeys); - confirmed = result.confirmedPairs(); - rejected = result.rejectedPairs(); - succeeded = result.receivedCorrelationResponses(); + result = performCorrelation(grpcClient, config, bucketData, sastResult.scanGuid, alreadyTriedKeys); } - logger.progress("Status: Correlation complete — %d of %d SAST findings confirmed as correlated", - confirmed.size(), submitted); - actionResult = succeeded == 0 ? "SKIPPED" : succeeded < submitted ? "PARTIALLY_CORRELATED" : "CORRELATED"; - return new GrpcResult(confirmed, rejected, succeeded); - } - - private String uploadCorrelatedFprs(DownloadedFprs fprs, GrpcResult grpcResult) { - String uploadedArtifactId = null; - if (!grpcResult.confirmed.isEmpty()) { - uploadedArtifactId = uploadEnrichedDastFpr(fprs, grpcResult.confirmed); - } else { - logger.progress("Status: No correlated pairs found — skipping DAST FPR upload."); - } - - if (!grpcResult.confirmed.isEmpty() || !grpcResult.rejected.isEmpty()) { - uploadTaggedSastFpr(fprs.sastPath, grpcResult.confirmed, grpcResult.rejected); - writeLastCorrelationTimestamp(); + logger.progress("Status: Correlation complete — %d pairs confirmed from %d submitted SAST findings", + result.confirmedPairs().size(), result.submittedCorrelationRequests()); + actionResult = getActionResult(result); + if ("SKIPPED".equals(actionResult)) { + actionMessage = "No issues present for correlation"; } - return uploadedArtifactId; + return result; } - private String uploadEnrichedDastFpr(DownloadedFprs fprs, List confirmed) { - logger.progress("Status: Injecting correlation data into DAST FPR (%d correlated pair(s))...", confirmed.size()); - new DastFprCorrelationEnricher().injectAndRepackage(fprs.dastPath, confirmed); - - logger.progress("Status: Uploading correlated DAST FPR to SSC..."); - String artifactId = AviatorSSCCorrelateDownloadHelper.uploadEnrichedDastFpr( - unirest, av, fprs.dastPath, progressWriter); - logger.progress("Status: Correlated DAST FPR uploaded successfully (artifact id=%s)", artifactId); - return artifactId; + private String getActionResult(CorrelationResult result) { + int submitted = result.submittedCorrelationRequests(); + int succeeded = result.successfulCorrelationResponses(); + int skipped = result.skippedCorrelationResponses(); + int failed = result.failedCorrelationResponses(); + return submitted == 0 ? "SKIPPED" + : failed == submitted ? "FAILED" + : succeeded == 0 ? "SKIPPED" + : failed > 0 || skipped > 0 ? "PARTIALLY_CORRELATED" : "CORRELATED"; } - private void uploadTaggedSastFpr(Path sastPath, List confirmed, List rejected) { - logger.progress("Status: Writing correlation status tags to SAST FPR (%d confirmed, %d rejected)...", - confirmed.size(), rejected.size()); - SastFprCorrelationRecorder.writeCorrelationTags(sastPath, confirmed, rejected); + private String uploadCorrelationResults(Path statePath, CorrelationResult result) { + if (result.confirmedPairs().isEmpty() && result.rejectedPairs().isEmpty()) { + logger.progress("Status: No correlation results found — skipping FPR upload."); + return null; + } - logger.progress("Status: Uploading updated SAST FPR to SSC..."); - AviatorSSCCorrelateDownloadHelper.uploadEnrichedSastFpr(unirest, av, sastPath, progressWriter); - logger.progress("Status: Updated SAST FPR uploaded successfully."); + if (!result.confirmedPairs().isEmpty()) { + logger.progress("Status: Injecting correlation data into merged FPR (%d correlated pair(s))...", + result.confirmedPairs().size()); + new DastFprCorrelationEnricher().injectAndRepackage(statePath, result.confirmedPairs()); + } + logger.progress("Status: Writing correlation status tags to merged FPR (%d confirmed, %d rejected)...", + result.confirmedPairs().size(), result.rejectedPairs().size()); + SastFprCorrelationRecorder.writeCorrelationTags( + statePath, result.confirmedPairs(), result.rejectedPairs()); + + logger.progress("Status: Uploading correlated merged FPR to SSC..."); + String artifactId = AviatorSSCFprTransferHelper.uploadFpr( + unirest, av, statePath, progressWriter); + logger.progress("Status: Correlated merged FPR uploaded (artifact id=%s)", artifactId); + writeLastCorrelationTimestamp(); + return artifactId; } private void writeLastCorrelationTimestamp() { @@ -295,21 +347,6 @@ private Set buildPreviouslyCorrelatedPairKeys(List dastIssues return keys; } - private int countNewSastFindings(List buckets, Set alreadyTriedKeys) { - if (alreadyTriedKeys.isEmpty()) { - return buckets.stream().mapToInt(CategoryBucket::getSastCount).sum(); - } - int count = 0; - for (CategoryBucket bucket : buckets) { - for (Vulnerability sast : bucket.getSastFindings()) { - boolean hasNewPairing = bucket.getDastFindings().stream() - .anyMatch(dast -> !alreadyTriedKeys.contains(sast.getInstanceID() + "::" + dast.getId())); - if (hasNewPairing) count++; - } - } - return count; - } - @Override public String getActionCommandResult() { return actionResult; 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 69505c07b0..ce9368798a 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 @@ -39,6 +39,7 @@ import com.fortify.cli.aviator.ssc.helper.AviatorSSCAttributeHelper; import com.fortify.cli.aviator.ssc.helper.AviatorSSCAuditHelper; import com.fortify.cli.aviator.ssc.helper.AviatorSSCFprTransferHelper; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCRefreshHelper; import com.fortify.cli.aviator.ssc.helper.AviatorSSCTagValidator; import com.fortify.cli.aviator.util.FprHandle; import com.fortify.cli.aviator.util.ResourceUtil; @@ -51,9 +52,6 @@ import com.fortify.cli.ssc.appversion.cli.mixin.SSCAppVersionRefreshOptions; import com.fortify.cli.ssc.appversion.cli.mixin.SSCAppVersionResolverMixin; import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; -import com.fortify.cli.ssc.appversion.helper.SSCAppVersionHelper; -import com.fortify.cli.ssc.system_state.helper.SSCJobDescriptor; -import com.fortify.cli.ssc.system_state.helper.SSCJobHelper; import kong.unirest.UnirestInstance; import lombok.Getter; @@ -84,7 +82,8 @@ public JsonNode getJsonNode(UnirestInstance unirest) { var session = sessionDescriptorSupplier.getSessionDescriptor(); TagMappingConfig tagMappingConfig = loadTagMappingConfig(); - refreshMetricsIfNeeded(unirest, appVersion, logger); + AviatorSSCRefreshHelper.refreshMetricsIfNeeded( + unirest, appVersion, refreshOptions.isRefresh(), refreshOptions.getRefreshTimeout(), logger); downloadedFpr = AviatorSSCFprTransferHelper.downloadCurrentStateFpr( unirest, appVersion, logger, progressWriter); @@ -96,7 +95,7 @@ public JsonNode getJsonNode(UnirestInstance unirest) { if (result.updatedFile() != null && result.succeeded() > 0) { validateSSCTagsBeforeUpload(unirest, appVersion, logger, tagMappingConfig); logger.progress("Status: Uploading audited DAST FPR to SSC"); - artifactId = AviatorSSCFprTransferHelper.uploadDastFpr( + artifactId = AviatorSSCFprTransferHelper.uploadFpr( unirest, appVersion, downloadedFpr, progressWriter); } if (result.status() == DastAuditFprStatus.AUDITED @@ -122,21 +121,6 @@ public JsonNode getJsonNode(UnirestInstance unirest) { } } - private void refreshMetricsIfNeeded( - UnirestInstance unirest, - SSCAppVersionDescriptor appVersion, - AviatorLoggerImpl logger) { - if (refreshOptions.isRefresh() && appVersion.isRefreshRequired()) { - logger.progress("Status: Metrics for application version %s:%s are out of date, starting refresh...", - appVersion.getApplicationName(), appVersion.getVersionName()); - SSCJobDescriptor refreshJob = SSCAppVersionHelper.refreshMetrics(unirest, appVersion); - if (refreshJob != null) { - SSCJobHelper.waitForJob(unirest, refreshJob, refreshOptions.getRefreshTimeout()); - logger.progress("Status: Metrics refreshed successfully."); - } - } - } - private DastAuditFprResult auditFpr( Path fprPath, SSCAppVersionDescriptor appVersion, diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateDownloadHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateDownloadHelper.java deleted file mode 100644 index 876da9d2ef..0000000000 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateDownloadHelper.java +++ /dev/null @@ -1,91 +0,0 @@ -/* - * 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.ssc.helper; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.aviator.config.AviatorLoggerImpl; -import com.fortify.cli.common.progress.helper.IProgressWriter; -import com.fortify.cli.ssc._common.rest.ssc.SSCUrls; -import com.fortify.cli.ssc._common.rest.ssc.transfer.SSCFileTransferHelper; -import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; -import com.fortify.cli.ssc.artifact.helper.SSCArtifactDescriptor; - -import kong.unirest.UnirestInstance; - -/** - * Handles FPR download and upload operations against SSC for the correlate-sast-dast command. - */ -public final class AviatorSSCCorrelateDownloadHelper { - - private AviatorSSCCorrelateDownloadHelper() {} - - /** - * Downloads the FPR artifact for a single artifact ID. Used for DAST FPR download - * (individual artifact is needed so its webinspect.xml can be enriched and re-uploaded). - */ - public static Path downloadArtifactFpr(UnirestInstance unirest, SSCArtifactDescriptor ad, - AviatorLoggerImpl logger, IProgressWriter progressWriter) throws IOException { - return AviatorSSCFprTransferHelper.downloadArtifactFpr(unirest, ad, logger, progressWriter); - } - - /** - * Downloads the current merged SAST FPR for an application version. - * This merged FPR is safe to re-upload to SSC after adding audit tags because its - * internal FVDL contains only audit state (no raw scan results), which SSC processes - * as an audit-only update instead of a new scan submission. - * Using DOWNLOAD_ARTIFACT for the SAST FPR and re-uploading it causes SSC to treat - * it as a duplicate scan and puts the artifact into an error state. - */ - public static Path downloadCurrentSastFpr(UnirestInstance unirest, SSCAppVersionDescriptor av, - AviatorLoggerImpl logger, IProgressWriter progressWriter) throws IOException { - Path fprPath = Files.createTempFile("aviator_sast_merged_", ".fpr"); - logger.progress("Status: Downloading current merged SAST FPR from SSC for %s:%s", - av.getApplicationName(), av.getVersionName()); - SSCFileTransferHelper.download( - unirest, - SSCUrls.DOWNLOAD_CURRENT_FPR(av.getVersionId(), false), - fprPath.toFile(), - SSCFileTransferHelper.ISSCAddDownloadTokenFunction.ROUTEPARAM_DOWNLOADTOKEN, - progressWriter); - return fprPath; - } - - /** - * Uploads an enriched DAST FPR to SSC and returns the new artifact ID. - */ - public static String uploadEnrichedDastFpr(UnirestInstance unirest, SSCAppVersionDescriptor av, - Path enrichedDastFpr, IProgressWriter progressWriter) { - return AviatorSSCFprTransferHelper.uploadDastFpr(unirest, av, enrichedDastFpr, progressWriter); - } - - /** - * Uploads an enriched SAST FPR (with updated DAST_CORRELATION_STATUS tags) to SSC. - * Uses the REST artifacts endpoint — same as the audit command — so SSC merges only - * the audit.xml changes without treating the upload as a new scan result. - * Using UPLOAD_RESULT_FILE would cause SSC to reject it as a duplicate scan (error state). - */ - public static void uploadEnrichedSastFpr(UnirestInstance unirest, SSCAppVersionDescriptor av, - Path enrichedSastFpr, IProgressWriter progressWriter) { - SSCFileTransferHelper.restUpload( - unirest, - SSCUrls.PROJECT_VERSION_ARTIFACTS(av.getVersionId()), - enrichedSastFpr.toFile(), - JsonNode.class, - progressWriter - ); - } -} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateHelper.java index 0443067165..1262c926a6 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateHelper.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateHelper.java @@ -14,74 +14,26 @@ import java.nio.file.Files; import java.nio.file.Path; -import java.util.List; import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.fasterxml.jackson.databind.node.ObjectNode; import com.fortify.cli.aviator.fpr.Vulnerability; import com.fortify.cli.aviator.fpr.model.AuditIssue; -import com.fortify.cli.aviator.grpc.CorrelatedPair; import com.fortify.cli.common.exception.FcliSimpleException; -import com.fortify.cli.common.json.JsonHelper; -import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; -import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; +import com.fortify.cli.ssc.artifact.helper.SSCArtifactDescriptor; +import com.fortify.cli.ssc.artifact.helper.SSCArtifactHelper; /** * Stateless utility helpers for the correlate-sast-dast command: - * output JSON construction, suppression check, and FPR path validation. + * suppression checks and FPR path validation. */ public final class AviatorSSCCorrelateHelper { private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCCorrelateHelper.class); private AviatorSSCCorrelateHelper() {} - /** - * Builds the final JSON output node for the correlate-sast-dast command. - */ - public static ObjectNode buildOutputJson(SSCAppVersionDescriptor av, - String artifactId, - int submitted, - int succeeded, - List newPairs, - String actionResult) { - int correlated = newPairs.size(); - int skipped = submitted - succeeded; - - ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); - result.put("id", av.getVersionId()); - result.put("applicationName", av.getApplicationName()); - result.put("versionName", av.getVersionName()); - if (artifactId != null) { - result.put("artifactId", artifactId); - } else { - result.putNull("artifactId"); - } - result.put(IActionCommandResultSupplier.actionFieldName, actionResult); - - ObjectNode operation = result.putObject("operation"); - ObjectNode correlate = operation.putObject("correlate"); - - if (submitted > 0) { - String message = String.format("%d SAST findings submitted, %d correlated pairs confirmed", - submitted, correlated); - correlate.put("message", message); - correlate.put("submitted", submitted); - correlate.put("succeeded", succeeded); - correlate.put("skipped", skipped); - } else { - correlate.putNull("message"); - correlate.putNull("submitted"); - correlate.putNull("succeeded"); - correlate.putNull("skipped"); - } - correlate.put("correlated", correlated); - - return result; - } - /** * Returns true if the given vulnerability is marked as suppressed in the audit map. */ @@ -93,6 +45,17 @@ public static boolean isVulnerabilitySuppressed(Vulnerability vuln, Map 0) { + correlate.put("message", String.format( + "%d SAST findings submitted: %d succeeded, %d skipped, %d failed; %d correlated pairs confirmed", + submitted, succeeded, skipped, failed, correlated)); + } else { + correlate.putNull("message"); + } + } + + private static void addStatistics(ObjectNode correlate, int submitted, int succeeded, int skipped, int failed) { + if (submitted > 0) { + correlate.put("submitted", submitted); + correlate.put("succeeded", succeeded); + correlate.put("skipped", skipped); + correlate.put("failed", failed); + } else { + correlate.putNull("submitted"); + correlate.putNull("succeeded"); + correlate.putNull("skipped"); + correlate.putNull("failed"); + } + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCFprTransferHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCFprTransferHelper.java index d57fff234e..53ed93291f 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCFprTransferHelper.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCFprTransferHelper.java @@ -28,7 +28,7 @@ import kong.unirest.UnirestInstance; /** - * Shared SSC transfer operations for DAST FPR workflows. + * Shared SSC transfer operations for Aviator FPR workflows. */ public final class AviatorSSCFprTransferHelper { private AviatorSSCFprTransferHelper() {} @@ -38,10 +38,19 @@ public static Path downloadCurrentStateFpr( SSCAppVersionDescriptor appVersion, IAviatorLogger logger, IProgressWriter progressWriter) throws IOException { + return downloadCurrentStateFpr(unirest, appVersion, logger, progressWriter, true); + } + + public static Path downloadCurrentStateFpr( + UnirestInstance unirest, + SSCAppVersionDescriptor appVersion, + IAviatorLogger logger, + IProgressWriter progressWriter, + boolean includeSource) throws IOException { logger.progress("Status: Downloading current FPR state from SSC for app version %s:%s (id=%s)", appVersion.getApplicationName(), appVersion.getVersionName(), appVersion.getVersionId()); return downloadFpr(unirest, "aviator_" + appVersion.getVersionId() + "_", - SSCUrls.DOWNLOAD_CURRENT_FPR(appVersion.getVersionId(), true), progressWriter); + SSCUrls.DOWNLOAD_CURRENT_FPR(appVersion.getVersionId(), includeSource), progressWriter); } public static Path downloadArtifactFpr( @@ -78,15 +87,15 @@ private static Path downloadFpr( } } - public static String uploadDastFpr( + public static String uploadFpr( UnirestInstance unirest, SSCAppVersionDescriptor appVersion, - Path dastFpr, + Path fprPath, IProgressWriter progressWriter) { JsonNode uploadResponse = SSCFileTransferHelper.restUpload( unirest, SSCUrls.PROJECT_VERSION_ARTIFACTS(appVersion.getVersionId()), - dastFpr.toFile(), + fprPath.toFile(), JsonNode.class, progressWriter); return getUploadedArtifactId(uploadResponse); @@ -96,7 +105,7 @@ static String getUploadedArtifactId(JsonNode uploadResponse) { String artifactId = uploadResponse == null ? null : uploadResponse.path("data").path("id").asText(null); if (artifactId == null || artifactId.isBlank()) { - throw new FcliTechnicalException("SSC DAST FPR upload response did not contain an artifact ID"); + throw new FcliTechnicalException("SSC FPR upload response did not contain an artifact ID"); } return artifactId; } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCRefreshHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCRefreshHelper.java new file mode 100644 index 0000000000..16f2707bab --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCRefreshHelper.java @@ -0,0 +1,46 @@ +/* + * 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.ssc.helper; + +import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; +import com.fortify.cli.ssc.appversion.helper.SSCAppVersionHelper; +import com.fortify.cli.ssc.system_state.helper.SSCJobHelper; + +import kong.unirest.UnirestInstance; + +/** + * Refreshes stale SSC application-version metrics before workflows read current state. + */ +public final class AviatorSSCRefreshHelper { + private AviatorSSCRefreshHelper() {} + + public static void refreshMetricsIfNeeded( + UnirestInstance unirest, + SSCAppVersionDescriptor appVersion, + boolean refreshEnabled, + String refreshTimeout, + IAviatorLogger logger) { + if (!refreshEnabled || !appVersion.isRefreshRequired()) { + return; + } + + logger.progress("Status: Metrics for application version %s:%s are out of date, starting refresh...", + appVersion.getApplicationName(), appVersion.getVersionName()); + var refreshJob = SSCAppVersionHelper.refreshMetrics(unirest, appVersion); + if (refreshJob != null) { + SSCJobHelper.waitForJob(unirest, refreshJob, refreshTimeout); + logger.progress("Status: Metrics refreshed successfully."); + } + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/DastFprCorrelationEnricher.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/DastFprCorrelationEnricher.java index dd6a0d482a..f3ab49a46d 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/DastFprCorrelationEnricher.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/DastFprCorrelationEnricher.java @@ -19,10 +19,12 @@ import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.transform.OutputKeys; @@ -104,13 +106,12 @@ private int injectAll(Document doc, Map> pairsByDas List pairs = pairsByDastId.get(issueId); if (pairs == null || pairs.isEmpty()) continue; - // Remove any existing ExternalFindings to avoid duplicates on re-run - removeExistingExternalFindings(issue); - - Element externalFindings = doc.createElement("ExternalFindings"); + Element externalFindings = getOrCreateExternalFindings(doc, issue); + Set existingSastIds = getExistingSastIds(externalFindings); String timestamp = OffsetDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); for (CorrelatedPair pair : pairs) { + if (!existingSastIds.add(pair.sastInstanceId())) continue; Element ef = doc.createElement("ExternalFinding"); ef.setAttribute("Origin", "SCA"); @@ -120,26 +121,34 @@ private int injectAll(Document doc, Map> pairsByDas externalFindings.appendChild(ef); } - - issue.appendChild(externalFindings); injectedCount++; } return injectedCount; } - private void removeExistingExternalFindings(Element issue) { + private Element getOrCreateExternalFindings(Document doc, Element issue) { NodeList existing = issue.getElementsByTagName("ExternalFindings"); - // Collect first, then remove (to avoid ConcurrentModificationException) - List toRemove = new ArrayList<>(); for (int i = 0; i < existing.getLength(); i++) { - if (existing.item(i).getParentNode() == issue) { - toRemove.add(existing.item(i)); + if (existing.item(i).getParentNode() == issue && existing.item(i) instanceof Element element) { + return element; } } - for (org.w3c.dom.Node node : toRemove) { - issue.removeChild(node); + Element externalFindings = doc.createElement("ExternalFindings"); + issue.appendChild(externalFindings); + return externalFindings; + } + + private Set getExistingSastIds(Element externalFindings) { + Set result = new HashSet<>(); + NodeList originFindingIds = externalFindings.getElementsByTagName("OriginFindingID"); + for (int i = 0; i < originFindingIds.getLength(); i++) { + String sastId = originFindingIds.item(i).getTextContent(); + if (sastId != null && !sastId.isBlank()) { + result.add(sastId.trim()); + } } + return result; } private void appendChildElement(Document doc, Element parent, String name, String value) { diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCorrelateSastDastCommandTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCorrelateSastDastCommandTest.java new file mode 100644 index 0000000000..60128a2acf --- /dev/null +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCorrelateSastDastCommandTest.java @@ -0,0 +1,33 @@ +/* + * 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.ssc.cli.cmd; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import org.junit.jupiter.api.Test; + +import picocli.CommandLine; + +class AviatorSSCCorrelateSastDastCommandTest { + @Test + void acceptsSscRefreshOptions() { + var commandLine = new CommandLine(new AviatorSSCCorrelateSastDastCommand()); + + var parseResult = commandLine.parseArgs( + "--av", "test:1.0", "--no-refresh", "--refresh-timeout", "2m"); + + assertFalse(parseResult.matchedOptionValue("--refresh", true)); + assertEquals("2m", parseResult.matchedOptionValue("--refresh-timeout", null)); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateFprParserTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateFprParserTest.java index a3c6966fbc..26013338d2 100644 --- a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateFprParserTest.java +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateFprParserTest.java @@ -66,6 +66,26 @@ void parseDastFprPreservesNullCategoryWhen7pkCategoryIsMissing() throws Exceptio assertEquals("SQL Injection", result.dastIssues.get(0).getName()); } + @Test + void parseDastFprMapsSuppressionFromAuditState() throws Exception { + Path fprPath = createMinimalDastFpr(true, true); + + AviatorSSCCorrelateFprParser.ParseResult result = AviatorSSCCorrelateFprParser.parseDastFpr(fprPath); + + assertEquals(1, result.dastIssues.size()); + assertTrue(result.dastIssues.get(0).isSuppressed()); + } + + @Test + void parseSastFprMapsSuppressionFromAuditState() throws Exception { + Path fprPath = createMinimalSastFpr(true); + + AviatorSSCCorrelateFprParser.ParseResult result = AviatorSSCCorrelateFprParser.parseSastFpr(fprPath); + + assertEquals(1, result.vulnerabilities.size()); + assertTrue(result.vulnerabilities.get(0).isSuppressed()); + } + private Path createMinimalSastFpr(boolean includeAuditXml) throws Exception { Path fprPath = tempDir.resolve(includeAuditXml ? "sast-with-audit.fpr" : "sast-no-audit.fpr"); if (Files.exists(fprPath)) { @@ -133,7 +153,10 @@ private String minimalAuditXml() { TestProject - + + + + """; } diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateHelperTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateHelperTest.java index 31382a2526..87ae0fd211 100644 --- a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateHelperTest.java +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateHelperTest.java @@ -31,15 +31,17 @@ import com.fortify.cli.aviator.fpr.Vulnerability; import com.fortify.cli.aviator.fpr.model.AuditIssue; import com.fortify.cli.aviator.grpc.CorrelatedPair; +import com.fortify.cli.aviator.grpc.CorrelationResult; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; +import com.fortify.cli.ssc.artifact.helper.SSCArtifactDescriptor; class AviatorSSCCorrelateHelperTest { @TempDir Path tempDir; - // ── buildOutputJson ────────────────────────────────────────────────── + // ── correlation output ─────────────────────────────────────────────── @Test void testBuildOutputJson_withCorrelatedPairs() { @@ -49,7 +51,19 @@ void testBuildOutputJson_withCorrelatedPairs() { new CorrelatedPair("SAST-2", "DAST-2", "scan-guid", "MEDIUM", "match") ); - var result = AviatorSSCCorrelateHelper.buildOutputJson(av, "artifact-123", 5, 4, pairs, "CORRELATED"); + var correlationResult = CorrelationResult.builder() + .confirmedPairs(pairs) + .submittedCorrelationRequests(5) + .successfulCorrelationResponses(4) + .skippedCorrelationResponses(1) + .build(); + var result = AviatorSSCCorrelateOutput.builder() + .appVersion(av) + .artifactId("artifact-123") + .correlationResult(correlationResult) + .actionResult("CORRELATED") + .build() + .toJsonNode(); assertEquals("37", result.get("id").asText()); assertEquals("MyApp", result.get("applicationName").asText()); @@ -61,25 +75,81 @@ void testBuildOutputJson_withCorrelatedPairs() { assertEquals(5, correlate.get("submitted").asInt()); assertEquals(4, correlate.get("succeeded").asInt()); assertEquals(1, correlate.get("skipped").asInt()); + assertEquals(0, correlate.get("failed").asInt()); assertEquals(2, correlate.get("correlated").asInt()); assertEquals( - "5 SAST findings submitted, 2 correlated pairs confirmed", + "5 SAST findings submitted: 4 succeeded, 1 skipped, 0 failed; 2 correlated pairs confirmed", + correlate.get("message").asText()); + } + + @Test + void testBuildOutputJson_moreSuccessfulResponsesThanSubmitted() { + var av = createAppVersionDescriptor("95", "APPHANDLE", "2"); + + var correlationResult = CorrelationResult.builder() + .submittedCorrelationRequests(40) + .successfulCorrelationResponses(46) + .build(); + var result = AviatorSSCCorrelateOutput.builder() + .appVersion(av) + .artifactId("4168") + .correlationResult(correlationResult) + .actionResult("CORRELATED") + .build() + .toJsonNode(); + + JsonNode correlate = result.get("operation").get("correlate"); + assertEquals(40, correlate.get("submitted").asInt()); + assertEquals(40, correlate.get("succeeded").asInt()); + assertEquals(0, correlate.get("skipped").asInt()); + assertEquals(0, correlate.get("failed").asInt()); + } + + @Test + void testBuildOutputJson_failedResponsesAreNotReportedAsSkipped() { + var av = createAppVersionDescriptor("194", "cor1", "1.0"); + + var correlationResult = CorrelationResult.builder() + .submittedCorrelationRequests(53) + .failedCorrelationResponses(53) + .build(); + var result = AviatorSSCCorrelateOutput.builder() + .appVersion(av) + .correlationResult(correlationResult) + .actionResult("FAILED") + .build() + .toJsonNode(); + + JsonNode correlate = result.get("operation").get("correlate"); + assertEquals(53, correlate.get("submitted").asInt()); + assertEquals(0, correlate.get("succeeded").asInt()); + assertEquals(0, correlate.get("skipped").asInt()); + assertEquals(53, correlate.get("failed").asInt()); + assertEquals( + "53 SAST findings submitted: 0 succeeded, 0 skipped, 53 failed; 0 correlated pairs confirmed", correlate.get("message").asText()); } @Test void testBuildOutputJson_noPairsSubmitted() { var av = createAppVersionDescriptor("42", "TestApp", "2.0"); - var result = AviatorSSCCorrelateHelper.buildOutputJson(av, null, 0, 0, List.of(), "SKIPPED"); + var result = AviatorSSCCorrelateOutput.builder() + .appVersion(av) + .correlationResult(CorrelationResult.empty()) + .actionResult("SKIPPED") + .message("No issues present for correlation") + .build() + .toJsonNode(); assertTrue(result.get("artifactId").isNull()); assertEquals("SKIPPED", result.get(IActionCommandResultSupplier.actionFieldName).asText()); JsonNode correlate = result.get("operation").get("correlate"); - assertTrue(correlate.get("message").isNull()); + assertEquals("No issues present for correlation", correlate.get("message").asText()); assertTrue(correlate.get("submitted").isNull()); assertTrue(correlate.get("succeeded").isNull()); assertTrue(correlate.get("skipped").isNull()); + assertTrue(correlate.get("failed").isNull()); assertEquals(0, correlate.get("correlated").asInt()); } @@ -124,6 +194,32 @@ void testIsVulnerabilitySuppressed_nullInstanceId() { assertFalse(AviatorSSCCorrelateHelper.isVulnerabilitySuppressed(vuln, auditMap)); } + // ── isUnchangedSinceCorrelation ───────────────────────────────────── + + @Test + void identifiesUnchangedAviatorCorrelationArtifact() { + var sastArtifact = createArtifact("100", "aviator_42_state.fpr"); + var dastArtifact = createArtifact("100", "aviator_42_state.fpr"); + + assertTrue(AviatorSSCCorrelateHelper.isUnchangedSinceCorrelation(sastArtifact, dastArtifact)); + } + + @Test + void treatsNewUserMixedArtifactAsChanged() { + var sastArtifact = createArtifact("101", "mixed-scan.fpr"); + var dastArtifact = createArtifact("101", "mixed-scan.fpr"); + + assertFalse(AviatorSSCCorrelateHelper.isUnchangedSinceCorrelation(sastArtifact, dastArtifact)); + } + + @Test + void treatsDifferentLatestArtifactsAsChanged() { + var sastArtifact = createArtifact("101", "sast.fpr"); + var dastArtifact = createArtifact("102", "dast.fpr"); + + assertFalse(AviatorSSCCorrelateHelper.isUnchangedSinceCorrelation(sastArtifact, dastArtifact)); + } + // ── validateDownloadedFpr ──────────────────────────────────────────── @Test @@ -166,4 +262,11 @@ private SSCAppVersionDescriptor createAppVersionDescriptor(String id, String app descriptor.setVersionName(versionName); return descriptor; } + + private SSCArtifactDescriptor createArtifact(String id, String originalFileName) { + var artifact = new SSCArtifactDescriptor(); + artifact.setId(id); + artifact.asObjectNode().put("originalFileName", originalFileName); + return artifact; + } } diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/DastFprCorrelationEnricherTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/DastFprCorrelationEnricherTest.java new file mode 100644 index 0000000000..1fcd6e8b36 --- /dev/null +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/DastFprCorrelationEnricherTest.java @@ -0,0 +1,81 @@ +/* + * 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.ssc.helper; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import javax.xml.parsers.DocumentBuilderFactory; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.fortify.cli.aviator.grpc.CorrelatedPair; +import com.fortify.cli.aviator.util.FprHandle; + +class DastFprCorrelationEnricherTest { + @TempDir Path tempDir; + + @Test + void preservesExistingExternalFindingsAndAvoidsDuplicates() throws Exception { + Path fprPath = createFpr(); + var pairs = List.of( + new CorrelatedPair("SAST-1", "DAST-1", "scan-1", "HIGH", "existing"), + new CorrelatedPair("SAST-2", "DAST-1", "scan-2", "HIGH", "new") + ); + + new DastFprCorrelationEnricher().injectAndRepackage(fprPath, pairs); + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + var factory = DocumentBuilderFactory.newInstance(); + try (var inputStream = Files.newInputStream(fprHandle.getPath("/webinspect.xml"))) { + var document = factory.newDocumentBuilder().parse(inputStream); + var originFindingIds = document.getElementsByTagName("OriginFindingID"); + assertEquals(2, originFindingIds.getLength()); + assertEquals("SAST-1", originFindingIds.item(0).getTextContent()); + assertEquals("SAST-2", originFindingIds.item(1).getTextContent()); + } + } + } + + private Path createFpr() throws Exception { + Path fprPath = tempDir.resolve("merged.fpr"); + try (FileSystem zipFs = FileSystems.newFileSystem(fprPath, Map.of("create", "true"))) { + Files.writeString(zipFs.getPath("/webinspect.xml"), """ + + + + + + + scan-1 + SAST-1 + 2026-01-01T00:00:00Z + + + + + + + """, StandardCharsets.UTF_8); + } + return fprPath; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/artifact/helper/SSCArtifactHelper.java b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/artifact/helper/SSCArtifactHelper.java index 93187cd23f..289806aaa0 100644 --- a/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/artifact/helper/SSCArtifactHelper.java +++ b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/artifact/helper/SSCArtifactHelper.java @@ -256,7 +256,8 @@ private static SSCArtifactDescriptor getLatestArtifactByScanType(UnirestInstance if (data == null || !data.isArray() || data.isEmpty()) { break; } for (JsonNode artifact : data) { - if (hasEmbeddedScanType(artifact, scanType)) { + if ("PROCESS_COMPLETE".equalsIgnoreCase(artifact.path("status").asText()) + && hasEmbeddedScanType(artifact, scanType)) { return getDescriptor(artifact); } }