From 78f5b4a1c0cb518de85c441d1a31dafb09f96058 Mon Sep 17 00:00:00 2001 From: Ankit Rathod Date: Fri, 21 Aug 2026 15:03:52 +0530 Subject: [PATCH] Add DAST audit command and related functionality - Implemented AviatorSSCDastAuditCommand for auditing DAST findings in SSC applications. - Created DastAuditRequestMapper and DastAuditResponseMapper for handling DAST audit requests and responses. - Added unit tests for DAST audit request and response mappers, stream processor, and command. - Introduced AviatorSSCFprTransferHelper for managing DAST FPR downloads and uploads. - Updated AviatorSSCAuditHelper to include DAST-specific audit statistics. - Enhanced AviatorSSCCommands to include the new DAST audit command. - Updated internationalization properties for DAST audit command messages. --- .../fcli-app/aviator/resource-config.json | 3 + .../_common/config/AviatorConfigManager.java | 11 + .../audit/DastAuditDecisionMapper.java | 73 +++ .../cli/aviator/audit/DastAuditFPR.java | 195 +++++++ .../cli/aviator/audit/DastAuditFprResult.java | 38 ++ .../cli/aviator/config/TagMappingConfig.java | 48 ++ .../fortify/cli/aviator/dast/DastIssue.java | 5 +- .../cli/aviator/dast/DastReproStep.java | 25 + .../dast/StreamingWebInspectParser.java | 44 +- .../aviator/fpr/processor/AuditProcessor.java | 90 ++++ .../cli/aviator/grpc/AviatorGrpcClient.java | 11 + .../aviator/grpc/DastAuditRequestMapper.java | 66 +++ .../aviator/grpc/DastAuditResponseMapper.java | 50 ++ .../cli/aviator/grpc/DastAuditResult.java | 50 ++ .../aviator/grpc/DastAuditStreamConfig.java | 35 ++ .../grpc/DastAuditStreamProcessor.java | 480 ++++++++++++++++++ .../aviator/grpc/DastAuditStreamResult.java | 27 + .../cli/aviator/grpc/DastAuditWorkItem.java | 21 + .../cli/aviator/grpc/RequestHandler.java | 16 +- .../src/main/proto/dast_audit.proto | 136 +++++ .../resources/default_dast_tag_mapping.yaml | 24 + .../audit/DastAuditDecisionMapperTest.java | 44 ++ .../cli/aviator/audit/DastAuditFPRTest.java | 161 ++++++ .../aviator/config/TagMappingConfigTest.java | 22 + .../dast/StreamingWebInspectParserTest.java | 97 ++++ .../grpc/DastAuditRequestMapperTest.java | 50 ++ .../grpc/DastAuditResponseMapperTest.java | 71 +++ .../grpc/DastAuditStreamConfigTest.java | 33 ++ .../grpc/DastAuditStreamProcessorTest.java | 75 +++ .../cli/aviator/grpc/RequestHandlerTest.java | 47 ++ .../ssc/cli/cmd/AviatorSSCAuditCommand.java | 19 +- .../ssc/cli/cmd/AviatorSSCCommands.java | 1 + .../AviatorSSCCorrelateSastDastCommand.java | 4 +- .../cli/cmd/AviatorSSCDastAuditCommand.java | 172 +++++++ .../ssc/helper/AviatorSSCAuditHelper.java | 32 ++ .../AviatorSSCCorrelateDownloadHelper.java | 23 +- .../helper/AviatorSSCFprTransferHelper.java | 73 +++ .../aviator/i18n/AviatorMessages.properties | 8 + .../cmd/AviatorSSCDastAuditCommandTest.java | 82 +++ 39 files changed, 2406 insertions(+), 56 deletions(-) create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditDecisionMapper.java create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditFPR.java create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditFprResult.java create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastReproStep.java create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditRequestMapper.java create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditResponseMapper.java create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditResult.java create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamConfig.java create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessor.java create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamResult.java create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditWorkItem.java create mode 100644 fcli-core/fcli-aviator-common/src/main/proto/dast_audit.proto create mode 100644 fcli-core/fcli-aviator-common/src/main/resources/default_dast_tag_mapping.yaml create mode 100644 fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/DastAuditDecisionMapperTest.java create mode 100644 fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/DastAuditFPRTest.java create mode 100644 fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/dast/StreamingWebInspectParserTest.java create mode 100644 fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditRequestMapperTest.java create mode 100644 fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditResponseMapperTest.java create mode 100644 fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditStreamConfigTest.java create mode 100644 fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessorTest.java create mode 100644 fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/RequestHandlerTest.java create mode 100644 fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDastAuditCommand.java create mode 100644 fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCFprTransferHelper.java create mode 100644 fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDastAuditCommandTest.java diff --git a/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/aviator/resource-config.json b/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/aviator/resource-config.json index 5d73d4c3157..9db67cb8605 100644 --- a/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/aviator/resource-config.json +++ b/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/aviator/resource-config.json @@ -10,6 +10,9 @@ { "pattern": "default_tag_mapping.yaml" }, + { + "pattern": "default_dast_tag_mapping.yaml" + }, { "pattern": "remediations.xsd" }, diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/config/AviatorConfigManager.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/config/AviatorConfigManager.java index 6787f8a4d0f..236a42b18f4 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/config/AviatorConfigManager.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/config/AviatorConfigManager.java @@ -28,6 +28,7 @@ public class AviatorConfigManager { private static final String EXTENSIONS_CONFIG_RESOURCE = "extensions_config.yaml"; private static final String LANGUAGES_COMMENT_CONFIG_RESOURCE = "languages_comment_config.yaml"; private static final String DEFAULT_TAG_MAPPING_RESOURCE = "default_tag_mapping.yaml"; + private static final String DEFAULT_DAST_TAG_MAPPING_RESOURCE = "default_dast_tag_mapping.yaml"; private static volatile AviatorConfigManager instance; private static final Object lock = new Object(); @@ -35,12 +36,14 @@ public class AviatorConfigManager { private final ExtensionsConfig extensionsConfig; private final LanguagesCommentConfig languagesCommentConfig; private final TagMappingConfig defaultTagMappingConfig; + private final TagMappingConfig defaultDastTagMappingConfig; private AviatorConfigManager() { LOG.debug("Initializing AviatorConfigManager..."); this.extensionsConfig = ResourceUtil.loadYamlResource(EXTENSIONS_CONFIG_RESOURCE, ExtensionsConfig.class); this.languagesCommentConfig = ResourceUtil.loadYamlResource(LANGUAGES_COMMENT_CONFIG_RESOURCE, LanguagesCommentConfig.class); this.defaultTagMappingConfig = ResourceUtil.loadYamlResource(DEFAULT_TAG_MAPPING_RESOURCE, TagMappingConfig.class); + this.defaultDastTagMappingConfig = ResourceUtil.loadYamlResource(DEFAULT_DAST_TAG_MAPPING_RESOURCE, TagMappingConfig.class); if (this.extensionsConfig != null) { FileTypeLanguageMapperUtil.initializeConfig(this.extensionsConfig); @@ -91,4 +94,12 @@ public TagMappingConfig getDefaultTagMappingConfig() { } return defaultTagMappingConfig; } + + public TagMappingConfig getDefaultDastTagMappingConfig() { + if (defaultDastTagMappingConfig == null) { + LOG.error("DefaultDastTagMappingConfig was not loaded. This indicates a bug."); + throw new AviatorBugException("Critical: DefaultDastTagMappingConfig not loaded."); + } + return defaultDastTagMappingConfig; + } } \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditDecisionMapper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditDecisionMapper.java new file mode 100644 index 00000000000..60ddfb92b13 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditDecisionMapper.java @@ -0,0 +1,73 @@ +/* + * 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.audit; + +import com.fortify.cli.aviator.audit.model.AuditResponse; +import com.fortify.cli.aviator.audit.model.AuditResult; +import com.fortify.cli.aviator.grpc.DastAuditResult; +import com.fortify.cli.aviator.util.Constants; + +/** + * Converts structured DAST decisions to conservative FCLI audit results. + */ +public final class DastAuditDecisionMapper { + private DastAuditDecisionMapper() {} + + public static AuditResponse toAuditResponse(DastAuditResult result) { + if (!(result instanceof DastAuditResult.Success success)) { + return AuditResponse.builder() + .issueId(result.issueId()) + .status(result.status()) + .statusMessage(result.statusMessage()) + .build(); + } + + String confidence = normalizedConfidence(success.confidence()); + String tagValue; + String prediction; + String tier; + if (success.truePositive()) { + tagValue = Constants.EXPLOITABLE; + prediction = Constants.AVIATOR_REMEDIATION_REQUIRED; + tier = "GOLD"; + } else if ("HIGH".equals(confidence)) { + tagValue = Constants.NOT_AN_ISSUE; + prediction = Constants.AVIATOR_NOT_AN_ISSUE; + tier = "GOLD"; + } else { + tagValue = Constants.NOT_AN_ISSUE; + prediction = Constants.AVIATOR_LIKELY_FP; + tier = "SILVER"; + } + + String comment = success.finalComment() != null && !success.finalComment().isBlank() + ? success.finalComment() + : success.reasoning(); + return AuditResponse.builder() + .issueId(success.issueId()) + .status("SUCCESS") + .tier(tier) + .aviatorPredictionTag(prediction) + .isAviatorProcessed(true) + .auditResult(AuditResult.builder().tagValue(tagValue).comment(comment).build()) + .build(); + } + + private static String normalizedConfidence(String confidence) { + if (confidence == null) return "LOW"; + return switch (confidence.toUpperCase(java.util.Locale.ROOT)) { + case "HIGH", "MEDIUM", "LOW" -> confidence.toUpperCase(java.util.Locale.ROOT); + default -> "LOW"; + }; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditFPR.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditFPR.java new file mode 100644 index 00000000000..69dcbe49e38 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditFPR.java @@ -0,0 +1,195 @@ +/* + * 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.audit; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.cli.aviator.audit.model.AuditResponse; +import com.fortify.cli.aviator.config.TagMappingConfig; +import com.fortify.cli.aviator.dast.StreamingWebInspectParser; +import com.fortify.cli.aviator.fpr.model.AuditIssue; +import com.fortify.cli.aviator.fpr.processor.AuditProcessor; +import com.fortify.cli.aviator.grpc.DastAuditStreamConfig; +import com.fortify.cli.aviator.grpc.DastAuditStreamResult; +import com.fortify.cli.aviator.grpc.DastAuditWorkItem; +import com.fortify.cli.aviator.util.Constants; +import com.fortify.cli.aviator.util.FprHandle; + +/** + * Coordinates parsing, filtering, server auditing, and DAST audit.xml updates. + */ +public final class DastAuditFPR { + private static final Logger LOG = LoggerFactory.getLogger(DastAuditFPR.class); + + private DastAuditFPR() {} + + private record EligibilityResult( + List workItems, + int missingId, + int duplicate, + int suppressed, + int processed) {} + + @FunctionalInterface + public interface StreamRunner { + CompletableFuture run( + DastAuditStreamConfig config, List workItems, int totalReportedIssues); + } + + public static DastAuditFprResult audit( + FprHandle fprHandle, + DastAuditStreamConfig config, + TagMappingConfig tagMappingConfig, + StreamRunner streamRunner) { + tagMappingConfig.validateForDast(); + var auditProcessor = new AuditProcessor(fprHandle); + Map auditIssues = auditProcessor.processAuditXML(); + var sessions = new StreamingWebInspectParser(fprHandle).parseSessions(); + EligibilityResult eligibility = eligibleWorkItems(sessions, auditIssues); + List workItems = eligibility.workItems(); + int totalReported = sessions.stream().mapToInt(session -> session.getIssues().size()).sum(); + int locallySkipped = totalReported - workItems.size(); + LOG.info("DAST audit eligibility: reported={}, eligible={}, skipped={} " + + "(missingId={}, duplicate={}, suppressed={}, alreadyProcessed={})", + totalReported, workItems.size(), locallySkipped, eligibility.missingId(), + eligibility.duplicate(), eligibility.suppressed(), eligibility.processed()); + + if (workItems.isEmpty()) { + LOG.info("DAST audit skipped because no eligible findings remain"); + return emptyResult(totalReported, locallySkipped); + } + + DastAuditStreamResult streamResult = streamRunner.run(config, workItems, totalReported).join(); + Map successfulResponses = new LinkedHashMap<>(); + int truePositives = 0; + int falsePositivesSuppressed = 0; + int likelyFalsePositives = 0; + int failed = 0; + int serverSkipped = 0; + Set respondedIssueIds = new java.util.HashSet<>(); + + for (var result : streamResult.results()) { + respondedIssueIds.add(result.issueId()); + AuditResponse response = DastAuditDecisionMapper.toAuditResponse(result); + if ("SUCCESS".equalsIgnoreCase(response.getStatus()) && response.getAuditResult() != null) { + successfulResponses.put(result.issueId(), response); + var success = (com.fortify.cli.aviator.grpc.DastAuditResult.Success) result; + LOG.debug("DAST issue {} audited successfully: confidence={}, tier={}, result={}", + result.issueId(), success.confidence(), response.getTier(), response.getAuditResult().getTagValue()); + if (Constants.EXPLOITABLE.equals(response.getAuditResult().getTagValue())) { + truePositives++; + } else if (isSuppressedFalsePositive(response, tagMappingConfig)) { + falsePositivesSuppressed++; + } else { + likelyFalsePositives++; + } + } else if ("SKIPPED".equalsIgnoreCase(result.status())) { + serverSkipped++; + LOG.debug("DAST issue {} skipped by server: statusMessage={}", + result.issueId(), result.statusMessage()); + } else { + failed++; + LOG.warn("DAST issue {} failed: status={}, statusMessage={}", + result.issueId(), result.status(), result.statusMessage()); + } + } + int missingResponses = 0; + for (DastAuditWorkItem workItem : workItems) { + if (!respondedIssueIds.contains(workItem.issue().getId())) { + missingResponses++; + LOG.warn("DAST issue {} received no terminal server response", workItem.issue().getId()); + } + } + failed += missingResponses; + + var updatedFile = successfulResponses.isEmpty() + ? null + : auditProcessor.updateAndSaveDastAuditXml(successfulResponses, tagMappingConfig); + int succeeded = successfulResponses.size(); + LOG.info("DAST audit responses: submitted={}, succeeded={}, serverSkipped={}, failed={}, missingResponses={}", + workItems.size(), succeeded, serverSkipped, failed, missingResponses); + String status = succeeded == workItems.size() ? "AUDITED" + : succeeded > 0 ? "PARTIALLY_AUDITED" : "FAILED"; + String message = succeeded == 0 ? "No DAST audit responses were successfully processed" : null; + return new DastAuditFprResult( + updatedFile, status, message, totalReported, workItems.size(), workItems.size(), succeeded, + truePositives, falsePositivesSuppressed, likelyFalsePositives, + locallySkipped + serverSkipped, failed, + streamResult.reservedQuota(), streamResult.exceededCount(), streamResult.unlimitedQuota(), + streamResult.quotaLastUpdated(), streamResult.nextQuotaUpdateMessage()); + } + + private static boolean isSuppressedFalsePositive(AuditResponse response, TagMappingConfig tagMappingConfig) { + boolean tierOne = "GOLD".equalsIgnoreCase(response.getTier()); + return Boolean.TRUE.equals(tagMappingConfig.getResult( + tierOne, TagMappingConfig.ResultType.FP).getSuppress()); + } + + private static EligibilityResult eligibleWorkItems( + List sessions, + Map auditIssues) { + var workItems = new ArrayList(); + var seenIssueIds = new java.util.HashSet(); + int missingId = 0; + int duplicate = 0; + int suppressed = 0; + int processed = 0; + for (var session : sessions) { + for (var issue : session.getIssues()) { + String issueId = issue.getId(); + if (issueId == null || issueId.isBlank()) { + missingId++; + LOG.debug("Skipping DAST finding without an issue ID in session {}", session.getRequestId()); + continue; + } + if (!seenIssueIds.add(issueId)) { + duplicate++; + LOG.debug("Skipping duplicate DAST issue {} in session {}", issueId, session.getRequestId()); + continue; + } + AuditIssue auditIssue = auditIssues.get(issueId); + if (auditIssue != null && auditIssue.isSuppressed()) { + suppressed++; + LOG.debug("Skipping DAST issue {} because it is already suppressed", issueId); + continue; + } + if (auditIssue != null && isProcessedByAviator(auditIssue)) { + processed++; + LOG.debug("Skipping DAST issue {} because it is already processed by Aviator", issueId); + continue; + } + workItems.add(new DastAuditWorkItem(session, issue)); + } + } + return new EligibilityResult(List.copyOf(workItems), missingId, duplicate, suppressed, processed); + } + + private static boolean isProcessedByAviator(AuditIssue auditIssue) { + return Constants.PROCESSED_BY_AVIATOR.equalsIgnoreCase( + auditIssue.getTags().get(Constants.AVIATOR_STATUS_TAG_ID)); + } + + private static DastAuditFprResult emptyResult(int totalReported, int skipped) { + return new DastAuditFprResult( + null, "SKIPPED", "No eligible DAST findings to audit", totalReported, 0, 0, 0, + 0, 0, 0, skipped, 0, 0, 0, false, null, null); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditFprResult.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditFprResult.java new file mode 100644 index 00000000000..5f7ab46709b --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditFprResult.java @@ -0,0 +1,38 @@ +/* + * 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.audit; + +import java.io.File; + +/** + * Summary of processing one DAST FPR. + */ +public record DastAuditFprResult( + File updatedFile, + String status, + String message, + int totalReported, + int eligible, + int submitted, + int succeeded, + int truePositives, + int falsePositivesSuppressed, + int likelyFalsePositives, + int skipped, + int failed, + int reservedQuota, + int exceededCount, + boolean unlimitedQuota, + String quotaLastUpdated, + String nextQuotaUpdateMessage +) {} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/config/TagMappingConfig.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/config/TagMappingConfig.java index 2f21bf4c364..b6b34b329a9 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/config/TagMappingConfig.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/config/TagMappingConfig.java @@ -61,6 +61,14 @@ public void validate() { } } + public void validateForDast() { + validate(); + if (hasSuppressionExclusions()) { + throw new AviatorSimpleException( + "Invalid DAST tag mapping configuration: suppression_exclusions are not supported"); + } + } + public boolean hasSuppressionExclusions() { return suppression_exclusions != null && suppression_exclusions.stream() @@ -84,6 +92,40 @@ public boolean isSuppressionExcluded(SuppressionExclusionContext context) { .anyMatch(exclusion -> exclusion.matches(context)); } + public Result getResult(boolean tierOne, ResultType resultType) { + Tier tier = tierOne ? mapping.getTier_1() : mapping.getTier_2(); + return switch (resultType) { + case FP -> tier.getFp(); + case TP -> tier.getTp(); + case UNSURE -> tier.getUnsure(); + }; + } + + public Set getMappedValues() { + if (mapping == null) { + return Collections.emptySet(); + } + LinkedHashSet values = new LinkedHashSet<>(); + addMappedValues(values, mapping.getTier_1()); + addMappedValues(values, mapping.getTier_2()); + return Collections.unmodifiableSet(values); + } + + private void addMappedValues(Set values, Tier tier) { + if (tier == null) { + return; + } + addMappedValue(values, tier.getFp()); + addMappedValue(values, tier.getTp()); + addMappedValue(values, tier.getUnsure()); + } + + private void addMappedValue(Set values, Result result) { + if (result != null && result.getValue() != null && !result.getValue().isBlank()) { + values.add(result.getValue()); + } + } + private void validateTier(List errors, String path, Tier tier) { validateRequired(errors, path, tier); if (tier != null) { @@ -259,6 +301,12 @@ public static class Mapping { private Tier tier_2; } + public enum ResultType { + FP, + TP, + UNSURE + } + @Data @Reflectable public static class Tier { private Result fp; diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastIssue.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastIssue.java index cdb564c258f..5b174eb209f 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastIssue.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastIssue.java @@ -13,8 +13,8 @@ package com.fortify.cli.aviator.dast; import java.util.ArrayList; -import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -37,6 +37,7 @@ public class DastIssue { private String cweDescription; // Full CWE description text private String sessionUrl; // URL of the session containing this issue private List reproStepUrls = new ArrayList<>(); + private List reproSteps = new ArrayList<>(); // ReportSections for audit context private String summary; // Summary from ReportSection @@ -46,7 +47,7 @@ public class DastIssue { private String referenceInfo; // Reference Info from ReportSection // Additional classifications - private Map classifications = new HashMap<>(); // kind -> value + private Map classifications = new LinkedHashMap<>(); // kind -> value // Audit status private boolean suppressed = false; diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastReproStep.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastReproStep.java new file mode 100644 index 00000000000..bcf4898d25a --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastReproStep.java @@ -0,0 +1,25 @@ +/* + * 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.dast; + +import lombok.Data; + +/** + * Represents one ordered navigation or attack step reported by WebInspect. + */ +@Data +public class DastReproStep { + private String source; + private String url; + private String postParams; +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/StreamingWebInspectParser.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/StreamingWebInspectParser.java index c3fd3994dd2..f6ac5ec2cf3 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/StreamingWebInspectParser.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/StreamingWebInspectParser.java @@ -32,6 +32,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; import com.fortify.cli.aviator.util.FprHandle; /** @@ -64,7 +65,7 @@ public List parse() { Path webInspectPath = fprHandle.getPath("/webinspect.xml"); if (!Files.exists(webInspectPath)) { - throw new RuntimeException("webinspect.xml not found in DAST FPR"); + throw new AviatorTechnicalException("webinspect.xml not found in DAST FPR"); } try (InputStream inputStream = Files.newInputStream(webInspectPath)) { @@ -86,9 +87,9 @@ public List parse() { logger.info("Parsed {} DAST issues from webinspect.xml (streaming)", issues.size()); } catch (XMLStreamException e) { - throw new RuntimeException("Failed to parse webinspect.xml: " + e.getMessage(), e); + throw new AviatorTechnicalException("Failed to parse webinspect.xml", e); } catch (IOException e) { - throw new RuntimeException("Failed to read webinspect.xml: " + e.getMessage(), e); + throw new AviatorTechnicalException("Failed to read webinspect.xml", e); } return issues; @@ -106,7 +107,7 @@ public List parseSessions() { Path webInspectPath = fprHandle.getPath("/webinspect.xml"); if (!Files.exists(webInspectPath)) { - throw new RuntimeException("webinspect.xml not found in DAST FPR"); + throw new AviatorTechnicalException("webinspect.xml not found in DAST FPR"); } try (InputStream inputStream = Files.newInputStream(webInspectPath)) { @@ -133,9 +134,9 @@ public List parseSessions() { sessions.size(), totalIssues); } catch (XMLStreamException e) { - throw new RuntimeException("Failed to parse webinspect.xml: " + e.getMessage(), e); + throw new AviatorTechnicalException("Failed to parse webinspect.xml", e); } catch (IOException e) { - throw new RuntimeException("Failed to read webinspect.xml: " + e.getMessage(), e); + throw new AviatorTechnicalException("Failed to read webinspect.xml", e); } return sessions; @@ -429,12 +430,12 @@ private void parseReproSteps(XMLStreamReader reader, DastIssue issue) while (reader.hasNext()) { int event = reader.next(); - if (event == XMLStreamConstants.START_ELEMENT) { - if ("Url".equals(reader.getLocalName())) { - String url = readElementText(reader); - if (url != null && !url.isEmpty()) { - issue.getReproStepUrls().add(url); - } + if (event == XMLStreamConstants.START_ELEMENT + && "ReproStep".equals(reader.getLocalName())) { + DastReproStep step = parseReproStep(reader); + if (step.getUrl() != null && !step.getUrl().isEmpty()) { + issue.getReproStepUrls().add(step.getUrl()); + issue.getReproSteps().add(step); } } else if (event == XMLStreamConstants.END_ELEMENT && "ReproSteps".equals(reader.getLocalName())) { @@ -443,6 +444,25 @@ private void parseReproSteps(XMLStreamReader reader, DastIssue issue) } } + private DastReproStep parseReproStep(XMLStreamReader reader) throws XMLStreamException { + var step = new DastReproStep(); + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.START_ELEMENT) { + switch (reader.getLocalName()) { + case "Source" -> step.setSource(readElementText(reader)); + case "Url" -> step.setUrl(readElementText(reader)); + case "PostParams" -> step.setPostParams(readElementText(reader)); + default -> { } + } + } else if (event == XMLStreamConstants.END_ELEMENT + && "ReproStep".equals(reader.getLocalName())) { + return step; + } + } + return step; + } + // ========================================================================= // ReportSection parsing // ========================================================================= diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/AuditProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/AuditProcessor.java index 602367fbf03..dcf29e450b2 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/AuditProcessor.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/AuditProcessor.java @@ -763,6 +763,96 @@ public File updateAndSaveAuditAndRemediationsXml(Map audi return fprHandle.getFprPath().toFile(); } + /** + * Applies DAST audit decisions to audit.xml without creating SAST remediation artifacts. + */ + public File updateAndSaveDastAuditXml(Map auditResponses, + TagMappingConfig tagMappingConfig) { + Set writtenInstanceIds = new HashSet<>(); + for (Map.Entry entry : auditResponses.entrySet()) { + AuditResponse response = entry.getValue(); + if (response == null || !"SUCCESS".equalsIgnoreCase(response.getStatus()) + || response.getAuditResult() == null) { + continue; + } + Element issueElement = findIssueElement(entry.getKey()); + if (issueElement == null) { + issueElement = createDastIssueElement(entry.getKey()); + } else { + int revision = Optional.ofNullable(issueElement.getAttribute("revision")) + .filter(value -> !value.isBlank()) + .map(value -> { + try { return Integer.parseInt(value); } catch (NumberFormatException e) { return 0; } + }) + .orElse(0); + issueElement.setAttribute("revision", String.valueOf(revision + 1)); + } + applyDastAuditResponse(issueElement, response, tagMappingConfig); + writtenInstanceIds.add(entry.getKey()); + } + + AuditXmlIssuePruner.retainOnly(auditDoc, writtenInstanceIds); + + try (OutputStream os = Files.newOutputStream(fprHandle.getPath("/audit.xml"))) { + transformDomToStream(auditDoc, os); + } catch (Exception e) { + throw new AviatorTechnicalException("Failed to write DAST audit data back into the FPR file", e); + } + return fprHandle.getFprPath().toFile(); + } + + private Element createDastIssueElement(String instanceId) { + Element issueList = (Element) auditDoc.getElementsByTagNameNS(AUDIT_NAMESPACE_URI, "IssueList").item(0); + if (issueList == null) { + issueList = auditDoc.createElementNS(AUDIT_NAMESPACE_URI, "IssueList"); + auditDoc.getDocumentElement().appendChild(issueList); + } + Element issueElement = auditDoc.createElementNS(AUDIT_NAMESPACE_URI, "Issue"); + issueElement.setAttribute("instanceId", instanceId); + issueElement.setAttribute("revision", "0"); + issueElement.setAttribute("suppressed", "false"); + issueList.appendChild(issueElement); + return issueElement; + } + + private void applyDastAuditResponse(Element issueElement, AuditResponse response, + TagMappingConfig tagMappingConfig) { + String prediction = response.getAviatorPredictionTag(); + updateOrAddTag(issueElement, Constants.AVIATOR_PREDICTION_TAG_ID, prediction); + TagMappingConfig.Result resultConfig = getDastResultConfig(response, tagMappingConfig); + if (resultConfig.getValue() != null && !resultConfig.getValue().isBlank()) { + updateOrAddTag(issueElement, tagMappingConfig.getTag_id(), resultConfig.getValue()); + } + Boolean suppressedHistoryValue = updateSuppressedState( + issueElement, Boolean.TRUE.equals(resultConfig.getSuppress())); + updateOrAddTag(issueElement, Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR); + if (response.getAuditResult().getComment() != null) { + updateOrAddComment(issueElement, response.getAuditResult().getComment()); + } + Element clientAuditTrail = getClientAuditTrailElement(issueElement); + addTagHistory(clientAuditTrail, Constants.AVIATOR_PREDICTION_TAG_ID, prediction); + if (resultConfig.getValue() != null && !resultConfig.getValue().isBlank()) { + addTagHistory(clientAuditTrail, tagMappingConfig.getTag_id(), resultConfig.getValue()); + } + addTagHistory(clientAuditTrail, Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR); + if (suppressedHistoryValue != null) { + addTagHistory(clientAuditTrail, Constants.SUPPRESSED_TAG_ID, suppressedHistoryValue.toString()); + } + } + + private TagMappingConfig.Result getDastResultConfig(AuditResponse response, + TagMappingConfig tagMappingConfig) { + boolean tierOne = "GOLD".equalsIgnoreCase(response.getTier()); + String tagValue = response.getAuditResult().getTagValue(); + if (Constants.NOT_AN_ISSUE.equalsIgnoreCase(tagValue)) { + return tagMappingConfig.getResult(tierOne, TagMappingConfig.ResultType.FP); + } + if (Constants.EXPLOITABLE.equalsIgnoreCase(tagValue)) { + return tagMappingConfig.getResult(tierOne, TagMappingConfig.ResultType.TP); + } + return tagMappingConfig.getResult(tierOne, TagMappingConfig.ResultType.UNSURE); + } + private Document generateRemediationsXml(Map auditResponses, Map remediationCommentTimestamps, FPRInfo fprInfo) throws AviatorTechnicalException { 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 4c5c9791a70..4f356cea80d 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 @@ -37,6 +37,7 @@ import com.fortify.aviator.application.GetDefaultQuotaResponse; import com.fortify.aviator.application.UpdateApplicationRequest; import com.fortify.aviator.application.ValidateAdminSessionRequest; +import com.fortify.aviator.dastaudit.DastAuditServiceGrpc; import com.fortify.aviator.dastentitlement.DastEntitlement; import com.fortify.aviator.dastentitlement.DastEntitlementServiceGrpc; import com.fortify.aviator.dastentitlement.ListDastEntitlementsByTenantRequest; @@ -84,6 +85,7 @@ public class AviatorGrpcClient implements AutoCloseable { private final EntitlementServiceGrpc.EntitlementServiceBlockingStub entitlementServiceBlockingStub; private final DastEntitlementServiceGrpc.DastEntitlementServiceBlockingStub dastEntitlementServiceBlockingStub; private final CorrelationServiceGrpc.CorrelationServiceStub correlationAsyncStub; + private final DastAuditServiceGrpc.DastAuditServiceStub dastAuditAsyncStub; private final long defaultTimeoutSeconds; private final java.util.concurrent.ExecutorService processingExecutor; private final long pingIntervalSeconds; @@ -100,6 +102,11 @@ public AviatorGrpcClient(ManagedChannel channel, long defaultTimeoutSeconds, IAv this.entitlementServiceBlockingStub = EntitlementServiceGrpc.newBlockingStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); this.dastEntitlementServiceBlockingStub = DastEntitlementServiceGrpc.newBlockingStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); this.correlationAsyncStub = CorrelationServiceGrpc.newStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); + this.dastAuditAsyncStub = DastAuditServiceGrpc.newStub(channel) + .withCompression("gzip") + .withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE) + .withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE) + .withWaitForReady(); this.defaultTimeoutSeconds = defaultTimeoutSeconds; this.processingExecutor = Executors.newFixedThreadPool(4, r -> { Thread t = new Thread(r, "aviator-client-processing-" + r.hashCode()); @@ -297,6 +304,10 @@ public CorrelationServiceGrpc.CorrelationServiceStub getCorrelationAsyncStub() { return correlationAsyncStub; } + public DastAuditServiceGrpc.DastAuditServiceStub getDastAuditAsyncStub() { + return dastAuditAsyncStub; + } + public java.util.concurrent.ScheduledExecutorService getPingScheduler() { return pingScheduler; } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditRequestMapper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditRequestMapper.java new file mode 100644 index 00000000000..56ca5765eb1 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditRequestMapper.java @@ -0,0 +1,66 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import com.fortify.aviator.dastaudit.DastClassification; +import com.fortify.aviator.dastaudit.DastFindingContext; +import com.fortify.aviator.dastaudit.DastReproStep; +import com.fortify.cli.aviator.dast.DastIssue; +import com.fortify.cli.aviator.dast.DastSession; + +/** + * Maps parsed WebInspect data to the DAST audit wire contract. + */ +public final class DastAuditRequestMapper { + private DastAuditRequestMapper() {} + + public static DastFindingContext toFindingContext(DastSession session, DastIssue issue) { + var builder = DastFindingContext.newBuilder() + .setIssueId(value(issue.getId())) + .setCheckTypeId(value(issue.getCheckTypeId())) + .setEngineType(value(issue.getEngineType())) + .setVulnerabilityId(value(issue.getVulnerabilityId())) + .setSeverity(issue.getSeverity()) + .setName(value(issue.getName())) + .setCategory(value(issue.getCategory())) + .setCweId(value(issue.getCweId())) + .setCweDescription(value(issue.getCweDescription())) + .setSessionUrl(value(session.getUrl() != null ? session.getUrl() : issue.getSessionUrl())) + .setSummary(value(issue.getSummary())) + .setImplication(value(issue.getImplication())) + .setExecution(value(issue.getExecution())) + .setFix(value(issue.getFix())) + .setReferenceInfo(value(issue.getReferenceInfo())) + .setRequestId(value(session.getRequestId())) + .setScheme(value(session.getScheme())) + .setHost(value(session.getHost())) + .setPort(session.getPort()) + .setAttackParamDescriptor(value(session.getAttackParamDescriptor())) + .setRawRequest(value(session.getRawRequest())) + .setRawResponse(value(session.getRawResponse())); + + issue.getClassifications().forEach((kind, classificationValue) -> builder.addClassifications( + DastClassification.newBuilder().setKind(value(kind)).setValue(value(classificationValue)).build())); + builder.addAllReproStepUrls(issue.getReproStepUrls()); + issue.getReproSteps().forEach(step -> builder.addReproSteps(DastReproStep.newBuilder() + .setSource(value(step.getSource())) + .setUrl(value(step.getUrl())) + .setPostParams(value(step.getPostParams())) + .build())); + return builder.build(); + } + + private static String value(String value) { + return value != null ? value : ""; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditResponseMapper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditResponseMapper.java new file mode 100644 index 00000000000..3cd45debf81 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditResponseMapper.java @@ -0,0 +1,50 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import com.fortify.aviator.dastaudit.DastAuditResponse; + +/** + * Maps a DAST audit response to the issue associated with its request ID. + */ +final class DastAuditResponseMapper { + private DastAuditResponseMapper() {} + + static DastAuditResult map(DastAuditResponse response, String expectedIssueId) { + String responseIssueId = response.getDastIssueId(); + if (!responseIssueId.isBlank() && !expectedIssueId.equals(responseIssueId)) { + return new DastAuditResult.Failure( + expectedIssueId, "FAILED", + "DAST audit response issue ID mismatch: expected '" + expectedIssueId + + "' but received '" + responseIssueId + "'"); + } + + if ("SKIPPED".equalsIgnoreCase(response.getStatus())) { + return new DastAuditResult.Skipped(expectedIssueId, response.getStatusMessage()); + } + if (!"SUCCESS".equalsIgnoreCase(response.getStatus())) { + return new DastAuditResult.Failure( + expectedIssueId, response.getStatus(), response.getStatusMessage()); + } + if (!response.hasDecision()) { + return new DastAuditResult.Failure( + expectedIssueId, "FAILED", "Successful DAST audit response did not contain a decision"); + } + + var decision = response.getDecision(); + return new DastAuditResult.Success( + expectedIssueId, decision.getTruePositive(), decision.getConfidence(), + decision.getReasoning(), decision.getRemediationAdvice(), decision.getFinalComment(), + decision.getTagValue(), decision.getTier()); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditResult.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditResult.java new file mode 100644 index 00000000000..b4f99f89833 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditResult.java @@ -0,0 +1,50 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +/** Domain representation of one terminal DAST audit response. */ +public sealed interface DastAuditResult permits DastAuditResult.Success, DastAuditResult.Skipped, DastAuditResult.Failure { + String issueId(); + String status(); + String statusMessage(); + + record Success( + String issueId, + boolean truePositive, + String confidence, + String reasoning, + String remediationAdvice, + String finalComment, + String tagValue, + String tier + ) implements DastAuditResult { + @Override + public String status() { + return "SUCCESS"; + } + + @Override + public String statusMessage() { + return ""; + } + } + + record Skipped(String issueId, String statusMessage) implements DastAuditResult { + @Override + public String status() { + return "SKIPPED"; + } + } + + record Failure(String issueId, String status, String statusMessage) implements DastAuditResult {} +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamConfig.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamConfig.java new file mode 100644 index 00000000000..031897beb02 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamConfig.java @@ -0,0 +1,35 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; + +/** + * Configuration for one DAST audit gRPC stream. + */ +public record DastAuditStreamConfig( + String token, + String applicationName, + String sscApplicationName, + String sscApplicationVersion, + String fprBuildId +) { + public DastAuditStreamConfig { + if (token == null || token.isBlank()) { + throw new AviatorSimpleException("Aviator token must be specified for DAST audit"); + } + if (applicationName == null || applicationName.isBlank()) { + throw new AviatorSimpleException("Aviator application name must be specified for DAST audit"); + } + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessor.java new file mode 100644 index 00000000000..7d77ed384a8 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessor.java @@ -0,0 +1,480 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.aviator.dastaudit.DastAuditClientMessage; +import com.fortify.aviator.dastaudit.DastAuditErrorResponse; +import com.fortify.aviator.dastaudit.DastAuditInitResponse; +import com.fortify.aviator.dastaudit.DastAuditPingRequest; +import com.fortify.aviator.dastaudit.DastAuditPongResponse; +import com.fortify.aviator.dastaudit.DastAuditRequest; +import com.fortify.aviator.dastaudit.DastAuditResponse; +import com.fortify.aviator.dastaudit.DastAuditServerMessage; +import com.fortify.aviator.dastaudit.DastAuditServiceGrpc; +import com.fortify.aviator.dastaudit.DastAuditStreamInitRequest; +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; +import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; +import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.aviator.util.Constants; + +import io.grpc.Status; +import io.grpc.stub.ClientCallStreamObserver; +import io.grpc.stub.ClientResponseObserver; +import io.grpc.stub.StreamObserver; + +/** + * Processes DAST findings through the Aviator DAST audit bidirectional stream. + */ +public class DastAuditStreamProcessor implements AutoCloseable { + private static final Logger LOG = LoggerFactory.getLogger(DastAuditStreamProcessor.class); + + private final IAviatorLogger logger; + private final DastAuditServiceGrpc.DastAuditServiceStub asyncStub; + private final ScheduledExecutorService pingScheduler; + private final long pingIntervalSeconds; + private final AtomicBoolean isPinging = new AtomicBoolean(false); + private final AtomicBoolean isClosed = new AtomicBoolean(false); + private final AtomicBoolean isRpcCompleted = new AtomicBoolean(false); + private final AtomicBoolean isRetryScheduled = new AtomicBoolean(false); + private final AtomicInteger streamRetryCount = new AtomicInteger(); + private final AtomicInteger stagnantRetryCount = new AtomicInteger(); + private final AtomicLong streamGeneration = new AtomicLong(); + + private RequestHandler requestHandler; + private ScheduledFuture pingTask; + private ScheduledFuture retryTask; + private ClientCallStreamObserver activeRequestStream; + private String streamId; + private DastAuditStreamConfig config; + private List workItems; + private int totalReportedIssues; + private int lastRetryCompletedCount; + private CompletableFuture resultFuture; + private final List results = java.util.Collections.synchronizedList(new ArrayList<>()); + private final Map requestIssueIds = new ConcurrentHashMap<>(); + private final Map requestIdsByIssue = new ConcurrentHashMap<>(); + private final Set completedRequestIds = ConcurrentHashMap.newKeySet(); + private final Set pendingIssueIds = ConcurrentHashMap.newKeySet(); + private int reservedQuota; + private int exceededCount; + private boolean unlimitedQuota; + private boolean quotaMetadataInitialized; + private String quotaLastUpdated; + private String nextQuotaUpdateMessage; + + public DastAuditStreamProcessor( + IAviatorLogger logger, + DastAuditServiceGrpc.DastAuditServiceStub asyncStub, + ScheduledExecutorService pingScheduler, + long pingIntervalSeconds) { + this.logger = logger; + this.asyncStub = asyncStub; + this.pingScheduler = pingScheduler; + this.pingIntervalSeconds = pingIntervalSeconds; + } + + public CompletableFuture process( + DastAuditStreamConfig config, + List workItems, + int totalReportedIssues) { + this.config = config; + this.totalReportedIssues = totalReportedIssues; + this.resultFuture = new CompletableFuture<>(); + this.requestIdsByIssue.clear(); + this.completedRequestIds.clear(); + this.results.clear(); + this.streamRetryCount.set(0); + this.stagnantRetryCount.set(0); + this.lastRetryCompletedCount = 0; + this.isClosed.set(false); + this.quotaMetadataInitialized = false; + initializeWorkItems(workItems); + startStream(); + return resultFuture; + } + + void initializeWorkItems(List items) { + this.workItems = List.copyOf(items); + this.pendingIssueIds.clear(); + items.forEach(item -> pendingIssueIds.add(item.issue().getId())); + } + + private void startStream() { + if (resultFuture.isDone() || isClosed.get()) return; + this.streamId = UUID.randomUUID().toString(); + this.requestIssueIds.clear(); + this.requestHandler = new RequestHandler<>(streamId); + this.activeRequestStream = null; + this.isRpcCompleted.set(false); + this.isRetryScheduled.set(false); + long generation = streamGeneration.incrementAndGet(); + LOG.debug("Starting DAST audit stream {} with {} pending findings and {} reported findings", + streamId, pendingIssueIds.size(), totalReportedIssues); + + try { + StreamObserver requestObserver = + asyncStub.processDastAuditStream(new ResponseObserver(generation)); + requestHandler.initialize(requestObserver); + startPingPong(); + sendInit(); + } catch (RuntimeException exception) { + handleStreamError(exception); + } + } + + private void sendInit() { + var init = DastAuditStreamInitRequest.newBuilder() + .setToken(value(config.token())) + .setApplicationName(value(config.applicationName())) + .setStreamId(streamId) + .setRequestId(UUID.randomUUID().toString()) + .setTotalReportedIssues(totalReportedIssues) + .setTotalIssuesToAudit(pendingIssueIds.size()); + if (config.fprBuildId() != null) init.setFprBuildId(config.fprBuildId()); + if (config.sscApplicationName() != null) init.setSscApplicationName(config.sscApplicationName()); + if (config.sscApplicationVersion() != null) init.setSscApplicationVersion(config.sscApplicationVersion()); + sendRequest(DastAuditClientMessage.newBuilder().setInit(init).build()); + } + + private void handleInit(DastAuditInitResponse response) { + if (!isSuccess(response.getStatus())) { + fail(new AviatorSimpleException("DAST audit initialization failed: " + response.getStatusMessage())); + return; + } + if (!quotaMetadataInitialized) { + reservedQuota = response.getReservedQuota(); + exceededCount = response.getExceededCount(); + unlimitedQuota = response.getUnlimitedQuota(); + quotaLastUpdated = response.hasQuotaLastUpdated() ? response.getQuotaLastUpdated() : null; + nextQuotaUpdateMessage = response.hasNextQuotaUpdateMessage() ? response.getNextQuotaUpdateMessage() : null; + quotaMetadataInitialized = true; + } + List remainingWorkItems = remainingWorkItems(); + logger.info("DAST audit stream initialized; submitting " + remainingWorkItems.size() + " findings"); + for (DastAuditClientMessage request : prepareAuditRequests(remainingWorkItems, streamId)) { + sendRequest(request); + } + } + + List prepareAuditRequests(List items, String requestStreamId) { + List requests = new ArrayList<>(items.size()); + for (DastAuditWorkItem item : items) { + String issueId = item.issue().getId(); + String requestId = requestIdsByIssue.computeIfAbsent(issueId, ignored -> UUID.randomUUID().toString()); + var request = DastAuditRequest.newBuilder() + .setRequestId(requestId) + .setStreamId(requestStreamId) + .setFinding(DastAuditRequestMapper.toFindingContext(item.session(), item.issue())) + .build(); + pendingIssueIds.add(issueId); + requestIssueIds.put(requestId, issueId); + LOG.debug("Submitting DAST issue {} with request {} on stream {}", + issueId, requestId, requestStreamId); + requests.add(DastAuditClientMessage.newBuilder().setAudit(request).build()); + } + return List.copyOf(requests); + } + + int pendingRequestCount() { + return pendingIssueIds.size(); + } + + List remainingWorkItems() { + return workItems.stream() + .filter(item -> pendingIssueIds.contains(item.issue().getId())) + .toList(); + } + + String completeRequest(String requestId) { + String issueId = requestIssueIds.remove(requestId); + if (issueId != null) { + pendingIssueIds.remove(issueId); + completedRequestIds.add(requestId); + } + return issueId; + } + + private void handleAudit(DastAuditResponse response) { + String issueId = completeRequest(response.getRequestId()); + if (issueId == null) { + LOG.warn("Ignoring DAST audit response for unknown or completed request {}", response.getRequestId()); + return; + } + var decision = response.hasDecision() ? response.getDecision() : null; + LOG.debug("Received DAST audit response: issueId={}, requestId={}, status={}, confidence={}, tier={}, hasDecision={}", + issueId, response.getRequestId(), response.getStatus(), + decision != null ? decision.getConfidence() : null, + decision != null ? decision.getTier() : null, decision != null); + results.add(DastAuditResponseMapper.map(response, issueId)); + logger.progress("Audited %d of %d DAST findings", results.size(), workItems.size()); + completeRequestsIfDone(); + } + + private void handleError(DastAuditErrorResponse response) { + String issueId = completeRequest(response.getRequestId()); + if (issueId != null) { + LOG.debug("Received DAST audit error response: issueId={}, requestId={}, status={}, statusMessage={}", + issueId, response.getRequestId(), response.getStatus(), response.getStatusMessage()); + DastAuditResult result = "SKIPPED".equalsIgnoreCase(response.getStatus()) + ? new DastAuditResult.Skipped(issueId, response.getStatusMessage()) + : new DastAuditResult.Failure(issueId, response.getStatus(), response.getStatusMessage()); + results.add(result); + logger.progress("Audited %d of %d DAST findings", results.size(), workItems.size()); + completeRequestsIfDone(); + } else { + if (completedRequestIds.contains(response.getRequestId())) { + LOG.debug("Ignoring duplicate DAST audit error for completed request {}", response.getRequestId()); + return; + } + fail(new AviatorSimpleException("DAST audit error: " + response.getStatusMessage())); + } + } + + private void handlePong(DastAuditPongResponse response) { + LOG.debug("DAST audit pong received in {} ms", System.currentTimeMillis() - response.getClientTimestamp()); + } + + private void completeRequestsIfDone() { + if (pendingIssueIds.isEmpty() && requestHandler != null && !requestHandler.isCompleted()) { + requestHandler.complete(); + } + } + + private class ResponseObserver implements ClientResponseObserver { + private final long generation; + + private ResponseObserver(long generation) { + this.generation = generation; + } + + private boolean isCurrent() { + return generation == streamGeneration.get(); + } + + @Override + public void beforeStart(ClientCallStreamObserver requestStream) { + if (isCurrent()) activeRequestStream = requestStream; + } + + @Override + public void onNext(DastAuditServerMessage message) { + if (!isCurrent()) return; + switch (message.getResponseTypeCase()) { + case INIT -> handleInit(message.getInit()); + case AUDIT -> handleAudit(message.getAudit()); + case ERROR -> handleError(message.getError()); + case PONG -> handlePong(message.getPong()); + default -> LOG.warn("Unknown DAST audit response type: {}", message.getResponseTypeCase()); + } + } + + @Override + public void onError(Throwable throwable) { + if (!isCurrent()) return; + isRpcCompleted.set(true); + handleStreamError(throwable); + } + + @Override + public void onCompleted() { + if (!isCurrent()) return; + isRpcCompleted.set(true); + stopPingPong(); + LOG.debug("DAST audit stream {} completed with {} terminal responses for {} submitted findings", + streamId, results.size(), workItems.size()); + if (resultFuture.isDone()) return; + if (!pendingIssueIds.isEmpty()) { + handleStreamError(Status.UNAVAILABLE + .withDescription("DAST audit stream completed before all findings received terminal responses") + .asRuntimeException()); + return; + } + completeSuccessfully(); + } + } + + private void startPingPong() { + if (pingScheduler == null || pingIntervalSeconds <= 0) return; + pingTask = pingScheduler.scheduleAtFixedRate(() -> { + if (isPinging.compareAndSet(false, true)) { + try { + if (requestHandler != null && requestHandler.isReady()) { + var ping = DastAuditPingRequest.newBuilder() + .setStreamId(streamId) + .setTimestamp(System.currentTimeMillis()) + .build(); + sendRequest(DastAuditClientMessage.newBuilder().setPing(ping).build()); + } + } finally { + isPinging.set(false); + } + } + }, pingIntervalSeconds, pingIntervalSeconds, TimeUnit.SECONDS); + } + + private void handleStreamError(Throwable throwable) { + stopPingPong(); + if (resultFuture.isDone() || isClosed.get() || isRetryScheduled.get()) return; + + if (pendingIssueIds.isEmpty()) { + completeSuccessfully(); + return; + } + + if (isRetryableError(throwable) && canRetry(throwable)) { + if (!isRetryScheduled.compareAndSet(false, true)) return; + int retryAttempt = streamRetryCount.incrementAndGet(); + long delay = calculateStreamRetryDelay(retryAttempt); + String maxAttempts = isInfiniteRetryError(throwable) + ? "infinite" : String.valueOf(Constants.MAX_STREAM_RETRIES); + logger.info("Retrying DAST audit stream (attempt %d/%s) after %d ms; %d findings remain", + retryAttempt, maxAttempts, delay, pendingIssueIds.size()); + scheduleRetry(delay); + return; + } + + Status status = Status.fromThrowable(throwable); + completeExceptionally(new AviatorTechnicalException( + "DAST audit stream failed: " + status.getDescription(), throwable)); + } + + private boolean canRetry(Throwable throwable) { + int completedCount = workItems.size() - pendingIssueIds.size(); + if (completedCount == lastRetryCompletedCount) { + if (stagnantRetryCount.incrementAndGet() >= 3) { + LOG.error("DAST audit stream made no progress after multiple retries"); + return false; + } + } else { + stagnantRetryCount.set(0); + } + lastRetryCompletedCount = completedCount; + return isInfiniteRetryError(throwable) || streamRetryCount.get() < Constants.MAX_STREAM_RETRIES; + } + + private void scheduleRetry(long delay) { + Runnable retry = () -> { + if (!resultFuture.isDone() && !isClosed.get()) { + isRetryScheduled.set(false); + startStream(); + } + }; + if (!isRpcCompleted.get() && activeRequestStream != null) { + activeRequestStream.cancel("Retrying DAST audit stream", null); + } + if (pingScheduler == null) { + CompletableFuture.delayedExecutor(delay, TimeUnit.MILLISECONDS).execute(retry); + } else { + retryTask = pingScheduler.schedule(retry, delay, TimeUnit.MILLISECONDS); + } + } + + static boolean isRetryableError(Throwable throwable) { + Status status = Status.fromThrowable(throwable); + String description = status.getDescription(); + return status.getCode() == Status.Code.UNAVAILABLE || + status.getCode() == Status.Code.INTERNAL && description != null && + (description.contains("RST_STREAM") || description.contains("PROTOCOL_ERROR")); + } + + static boolean isInfiniteRetryError(Throwable throwable) { + Status status = Status.fromThrowable(throwable); + String description = status.getDescription(); + return status.getCode() == Status.Code.INTERNAL && description != null && + description.contains("PROTOCOL_ERROR"); + } + + static long calculateStreamRetryDelay(int retryCount) { + long delay = (long) (Constants.STREAM_RETRY_BASE_DELAY_MS * Math.pow(2, retryCount - 1)); + return Math.min(delay, Constants.STREAM_RETRY_MAX_DELAY_MS) + + ThreadLocalRandom.current().nextLong(1000); + } + + private void sendRequest(DastAuditClientMessage request) { + RequestHandler currentHandler = requestHandler; + currentHandler.sendRequest(request).whenComplete((sent, throwable) -> { + if (currentHandler != requestHandler || resultFuture.isDone() || isClosed.get()) return; + if (throwable != null || !Boolean.TRUE.equals(sent)) { + Throwable cause = throwable != null ? throwable : Status.UNAVAILABLE + .withDescription("Unable to send DAST audit stream request") + .asRuntimeException(); + handleStreamError(cause); + } + }); + } + + private void fail(RuntimeException exception) { + stopPingPong(); + if (requestHandler != null && !requestHandler.isCompleted()) { + requestHandler.sendError(exception); + } + completeExceptionally(exception); + } + + private void completeExceptionally(RuntimeException exception) { + if (!resultFuture.isDone()) resultFuture.completeExceptionally(exception); + } + + private void completeSuccessfully() { + if (!resultFuture.isDone()) { + resultFuture.complete(new DastAuditStreamResult( + List.copyOf(results), reservedQuota, exceededCount, unlimitedQuota, + quotaLastUpdated, nextQuotaUpdateMessage)); + } + } + + private void stopPingPong() { + if (pingTask != null) pingTask.cancel(false); + } + + private boolean isSuccess(String status) { + return "SUCCESS".equalsIgnoreCase(status) || "OK".equalsIgnoreCase(status); + } + + private String value(String value) { + return value != null ? value : ""; + } + + @Override + public void close() { + isClosed.set(true); + stopPingPong(); + if (retryTask != null) retryTask.cancel(false); + if (!isRpcCompleted.get() && activeRequestStream != null) { + activeRequestStream.cancel("DAST audit stream processor closed", null); + } else if (requestHandler != null && !requestHandler.isCompleted()) { + requestHandler.sendError(new AviatorTechnicalException("DAST audit stream processor closed")); + } + if (resultFuture != null && !resultFuture.isDone()) { + completeExceptionally(new AviatorTechnicalException("DAST audit stream processor closed")); + } + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamResult.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamResult.java new file mode 100644 index 00000000000..c504dd5bf29 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamResult.java @@ -0,0 +1,27 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import java.util.List; + +/** + * Results and quota metadata returned by one DAST audit stream. + */ +public record DastAuditStreamResult( + List results, + int reservedQuota, + int exceededCount, + boolean unlimitedQuota, + String quotaLastUpdated, + String nextQuotaUpdateMessage +) {} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditWorkItem.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditWorkItem.java new file mode 100644 index 00000000000..1aca385b831 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditWorkItem.java @@ -0,0 +1,21 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import com.fortify.cli.aviator.dast.DastIssue; +import com.fortify.cli.aviator.dast.DastSession; + +/** + * Associates a DAST finding with the WebInspect session that contains its HTTP evidence. + */ +public record DastAuditWorkItem(DastSession session, DastIssue issue) {} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/RequestHandler.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/RequestHandler.java index 12dcd9550a7..62f1231eea4 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/RequestHandler.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/RequestHandler.java @@ -134,15 +134,25 @@ public boolean flush() { * Complete the stream and send any remaining requests. */ public CompletableFuture complete() { - if (!isCompleted.compareAndSet(false, true)) { + if (isCompleted.get()) { return CompletableFuture.completedFuture(null); } return CompletableFuture.runAsync(() -> { sendLock.lock(); try { - // Flush any remaining requests - flush(); + if (!isCompleted.compareAndSet(false, true)) { + return; + } + + T request; + while ((request = requestQueue.poll()) != null) { + if (requestObserver != null) { + requestObserver.onNext(request); + totalSent++; + pendingRequests.decrementAndGet(); + } + } // Complete the stream if (requestObserver != null) { diff --git a/fcli-core/fcli-aviator-common/src/main/proto/dast_audit.proto b/fcli-core/fcli-aviator-common/src/main/proto/dast_audit.proto new file mode 100644 index 00000000000..724d0f470e4 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/proto/dast_audit.proto @@ -0,0 +1,136 @@ +syntax = "proto3"; + +option java_multiple_files = true; +option java_package = "com.fortify.aviator.dastaudit"; + +package dastaudit; + +service DastAuditService { + rpc ProcessDastAuditStream(stream DastAuditClientMessage) returns (stream DastAuditServerMessage); +} + +message DastAuditStreamInitRequest { + string token = 1; + string applicationName = 2; + string streamId = 3; + string requestId = 4; + int32 totalReportedIssues = 5; + int32 totalIssuesToAudit = 6; + optional string fprBuildId = 7; + optional string sscApplicationName = 8; + optional string sscApplicationVersion = 9; +} + +message DastAuditClientMessage { + oneof request_type { + DastAuditStreamInitRequest init = 1; + DastAuditRequest audit = 2; + DastAuditPingRequest ping = 3; + } +} + +message DastAuditServerMessage { + oneof response_type { + DastAuditInitResponse init = 1; + DastAuditResponse audit = 2; + DastAuditErrorResponse error = 3; + DastAuditPongResponse pong = 4; + } +} + +message DastAuditInitResponse { + string requestId = 1; + string clientStreamId = 2; + string serverStreamId = 3; + string status = 4; + string statusMessage = 5; + int32 reservedQuota = 6; + int32 exceededCount = 7; + bool unlimitedQuota = 8; + optional string quotaLastUpdated = 9; + optional string nextQuotaUpdateMessage = 10; + optional string reassignedEntitlementId = 11; +} + +message DastAuditRequest { + string requestId = 1; + string streamId = 2; + DastFindingContext finding = 3; +} + +message DastFindingContext { + string issueId = 1; + string checkTypeId = 2; + string engineType = 3; + string vulnerabilityId = 4; + int32 severity = 5; + string name = 6; + string category = 7; + string cweId = 8; + string cweDescription = 9; + string sessionUrl = 10; + repeated string reproStepUrls = 11; + string summary = 12; + string implication = 13; + string execution = 14; + string fix = 15; + string referenceInfo = 16; + repeated DastClassification classifications = 17; + string requestId = 18; + string scheme = 19; + string host = 20; + int32 port = 21; + string attackParamDescriptor = 22; + string rawRequest = 23; + string rawResponse = 24; + repeated DastReproStep reproSteps = 25; +} + +message DastClassification { + string kind = 1; + string value = 2; +} + +message DastReproStep { + string source = 1; + string url = 2; + string postParams = 3; +} + +message DastAuditResponse { + string requestId = 1; + string streamId = 2; + string dastIssueId = 3; + string status = 4; + string statusMessage = 5; + DastAuditDecision decision = 6; +} + +message DastAuditDecision { + bool truePositive = 1; + string confidence = 2; + string reasoning = 3; + string remediationAdvice = 4; + string finalComment = 5; + string tagValue = 6; + string tier = 7; +} + +message DastAuditErrorResponse { + string requestId = 1; + string clientStreamId = 2; + string serverStreamId = 3; + string status = 4; + string statusMessage = 5; +} + +message DastAuditPingRequest { + string streamId = 1; + int64 timestamp = 2; +} + +message DastAuditPongResponse { + string streamId = 1; + int64 serverTimestamp = 2; + int64 clientTimestamp = 3; +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/resources/default_dast_tag_mapping.yaml b/fcli-core/fcli-aviator-common/src/main/resources/default_dast_tag_mapping.yaml new file mode 100644 index 00000000000..5283eadff4c --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/resources/default_dast_tag_mapping.yaml @@ -0,0 +1,24 @@ +# Set the SSC tag used to store DAST audit results. Optional. +# If not set, defaults to the SSC Analysis tag. +tag_id: "87f2364f-dcd4-49e6-861d-f8d3f351686b" +# Map DAST audit decisions to SSC tag values and suppression behavior. +# tier_1 contains high-confidence decisions; tier_2 contains lower-confidence decisions. +mapping: + tier_1: + fp: + value: "Not an Issue" + suppress: true + tp: + value: "Exploitable" + suppress: false + unsure: + suppress: false + tier_2: + fp: + value: "Not an Issue" + suppress: false + tp: + value: "Suspicious" + suppress: false + unsure: + suppress: false diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/DastAuditDecisionMapperTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/DastAuditDecisionMapperTest.java new file mode 100644 index 00000000000..d46133d3cd8 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/DastAuditDecisionMapperTest.java @@ -0,0 +1,44 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.audit; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator.grpc.DastAuditResult; +import com.fortify.cli.aviator.util.Constants; + +class DastAuditDecisionMapperTest { + @Test + void unknownConfidenceFalsePositiveRemainsUnsuppressed() { + var result = new DastAuditResult.Success( + "DAST-1", false, "UNKNOWN", "reason", "", "comment", "bad", "GOLD"); + + var response = DastAuditDecisionMapper.toAuditResponse(result); + + assertEquals("SILVER", response.getTier()); + assertEquals(Constants.AVIATOR_LIKELY_FP, response.getAviatorPredictionTag()); + } + + @Test + void highConfidenceFalsePositiveIsSuppressible() { + var result = new DastAuditResult.Success( + "DAST-1", false, "HIGH", "reason", "", "comment", "", ""); + + var response = DastAuditDecisionMapper.toAuditResponse(result); + + assertEquals("GOLD", response.getTier()); + assertEquals(Constants.AVIATOR_NOT_AN_ISSUE, response.getAviatorPredictionTag()); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/DastAuditFPRTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/DastAuditFPRTest.java new file mode 100644 index 00000000000..95d2ca4c800 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/DastAuditFPRTest.java @@ -0,0 +1,161 @@ +/* + * 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.audit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.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 java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.fortify.cli.aviator._common.config.AviatorConfigManager; +import com.fortify.cli.aviator.config.TagMappingConfig; +import com.fortify.cli.aviator.grpc.DastAuditResult; +import com.fortify.cli.aviator.grpc.DastAuditStreamConfig; +import com.fortify.cli.aviator.grpc.DastAuditStreamResult; +import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.aviator.util.ResourceUtil; + +class DastAuditFPRTest { + @TempDir Path tempDir; + + @Test + void auditsEligibleFindingAndWritesConservativeXml() throws Exception { + Path fpr = createFpr(); + var config = new DastAuditStreamConfig("token", "app", "ssc", "1", null); + + DastAuditFprResult result; + try (FprHandle handle = new FprHandle(fpr)) { + result = DastAuditFPR.audit(handle, config, defaultTagMapping(), (ignoredConfig, items, total) -> + CompletableFuture.completedFuture(new DastAuditStreamResult(List.of( + new DastAuditResult.Success("DAST-1", false, "HIGH", "reason", "", "comment", "", "") + ), 1, 0, false, null, null))); + } + + assertEquals("AUDITED", result.status()); + assertEquals(1, result.falsePositivesSuppressed()); + try (FileSystem zip = FileSystems.newFileSystem(fpr)) { + String auditXml = Files.readString(zip.getPath("/audit.xml")); + assertTrue(auditXml.contains("instanceId=\"DAST-1\"")); + assertTrue(auditXml.contains("suppressed=\"true\"")); + assertTrue(auditXml.contains("PROCESSED_BY_AVIATOR")); + assertFalse(Files.exists(zip.getPath("/remediations.xml"))); + } + } + + @Test + void customTagMappingControlsFinalTagAndSuppression() throws Exception { + Path fpr = createFpr(); + Path tagMapping = tempDir.resolve("dast-tag-mapping.yaml"); + Files.writeString(tagMapping, """ + tag_id: "custom-analysis-tag" + mapping: + tier_1: + fp: { value: "Confirmed FP", suppress: false } + tp: { value: "Confirmed TP", suppress: false } + unsure: { suppress: false } + tier_2: + fp: { value: "Review FP", suppress: true } + tp: { value: "Review TP", suppress: false } + unsure: { suppress: false } + """); + var config = new DastAuditStreamConfig("token", "app", "ssc", "1", null); + + DastAuditFprResult result; + try (FprHandle handle = new FprHandle(fpr)) { + result = DastAuditFPR.audit(handle, config, + ResourceUtil.loadYamlFile(tagMapping.toFile(), TagMappingConfig.class), + (ignoredConfig, items, total) -> + CompletableFuture.completedFuture(new DastAuditStreamResult(List.of( + new DastAuditResult.Success("DAST-1", false, "MEDIUM", "reason", "", "comment", "", "") + ), 1, 0, false, null, null))); + } + + assertEquals(1, result.falsePositivesSuppressed()); + try (FileSystem zip = FileSystems.newFileSystem(fpr)) { + String auditXml = Files.readString(zip.getPath("/audit.xml")); + assertTrue(auditXml.contains("suppressed=\"true\"")); + assertTrue(auditXml.contains("id=\"custom-analysis-tag\"")); + assertTrue(auditXml.contains(">Review FP<")); + } + } + + @Test + void writesOnlyIssuesUpdatedByCurrentAudit() throws Exception { + Path fpr = createFpr(); + try (FileSystem zip = FileSystems.newFileSystem(fpr)) { + Files.writeString(zip.getPath("/audit.xml"), """ + + + + + """); + } + var config = new DastAuditStreamConfig("token", "app", "ssc", "1", null); + + try (FprHandle handle = new FprHandle(fpr)) { + DastAuditFPR.audit(handle, config, defaultTagMapping(), (ignoredConfig, items, total) -> + CompletableFuture.completedFuture(new DastAuditStreamResult(List.of( + new DastAuditResult.Success("DAST-1", true, "HIGH", "reason", "", "comment", "", "") + ), 1, 0, false, null, null))); + } + + try (FileSystem zip = FileSystems.newFileSystem(fpr)) { + String auditXml = Files.readString(zip.getPath("/audit.xml")); + assertTrue(auditXml.contains("instanceId=\"DAST-1\"")); + assertFalse(auditXml.contains("instanceId=\"DAST-2\"")); + } + } + + @Test + void missingTerminalResponseIsCountedAsFailure() throws Exception { + Path fpr = createFpr(); + var config = new DastAuditStreamConfig("token", "app", "ssc", "1", null); + + try (FprHandle handle = new FprHandle(fpr)) { + DastAuditFprResult result = DastAuditFPR.audit( + handle, config, defaultTagMapping(), (ignoredConfig, items, total) -> + CompletableFuture.completedFuture(new DastAuditStreamResult( + List.of(), 1, 0, false, null, null))); + + assertEquals("FAILED", result.status()); + assertEquals(1, result.failed()); + } + } + + private TagMappingConfig defaultTagMapping() { + return AviatorConfigManager.getInstance().getDefaultDastTagMappingConfig(); + } + + private Path createFpr() throws Exception { + Path fpr = tempDir.resolve("dast.fpr"); + try (FileSystem zip = FileSystems.newFileSystem(fpr, Map.of("create", "true"))) { + Files.writeString(zip.getPath("/webinspect.xml"), """ + https://example.test + SQL Injection4 + + """, StandardCharsets.UTF_8); + } + return fpr; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/config/TagMappingConfigTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/config/TagMappingConfigTest.java index 43aeb69ab9b..c1ed01ee269 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/config/TagMappingConfigTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/config/TagMappingConfigTest.java @@ -21,6 +21,7 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Set; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -135,6 +136,27 @@ void testLoadYamlFileBindsSuppressionExclusionsAcrossEntries() throws Exception assertTrue(config.isSuppressionExcluded(new TagMappingConfig.SuppressionExclusionContext("privacy violation"))); } + @Test + void testResolvesResultsAndMappedValues() { + TagMappingConfig config = createValidConfig(); + + assertTrue(config.getResult(true, TagMappingConfig.ResultType.FP).getSuppress()); + assertFalse(config.getResult(false, TagMappingConfig.ResultType.FP).getSuppress()); + assertEquals(Set.of("Not an Issue", "Exploitable"), config.getMappedValues()); + } + + @Test + void testDastValidationRejectsSuppressionExclusions() { + TagMappingConfig config = createValidConfig(); + config.setSuppression_exclusions(new ArrayList<>(List.of(createSuppressionExclusion("Privacy Violation")))); + + AviatorSimpleException exception = assertThrows(AviatorSimpleException.class, config::validateForDast); + + assertEquals( + "Invalid DAST tag mapping configuration: suppression_exclusions are not supported", + exception.getMessage()); + } + private TagMappingConfig createValidConfig() { TagMappingConfig config = new TagMappingConfig(); TagMappingConfig.Mapping mapping = new TagMappingConfig.Mapping(); diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/dast/StreamingWebInspectParserTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/dast/StreamingWebInspectParserTest.java new file mode 100644 index 00000000000..582b6641154 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/dast/StreamingWebInspectParserTest.java @@ -0,0 +1,97 @@ +/* + * 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.dast; + +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.Base64; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.fortify.cli.aviator.util.FprHandle; + +class StreamingWebInspectParserTest { + @TempDir + Path tempDir; + + @Test + void parseSessionsPreservesCompleteAuditContext() throws Exception { + Path fpr = createFpr(); + + try (FprHandle handle = new FprHandle(fpr)) { + var sessions = new StreamingWebInspectParser(handle).parseSessions(); + + assertEquals(1, sessions.size()); + DastSession session = sessions.get(0); + assertEquals("POST /login HTTP/1.1", session.getRawRequest()); + assertEquals("HTTP/1.1 200 OK", session.getRawResponse()); + assertEquals("parameter=username", session.getAttackParamDescriptor()); + + DastIssue issue = session.getIssues().get(0); + assertEquals("Injection", issue.getCategory()); + assertEquals("Improper Neutralization", issue.getCweDescription()); + assertEquals("Summary text", issue.getSummary()); + assertEquals("Fix text", issue.getFix()); + assertEquals(2, issue.getReproSteps().size()); + assertEquals("Macro", issue.getReproSteps().get(0).getSource()); + assertEquals("Attack", issue.getReproSteps().get(1).getSource()); + assertEquals("username=test%27", issue.getReproSteps().get(1).getPostParams()); + assertEquals(issue.getReproSteps().stream().map(DastReproStep::getUrl).toList(), issue.getReproStepUrls()); + } + } + + private Path createFpr() throws Exception { + Path fpr = tempDir.resolve("dast.fpr"); + try (FileSystem zip = FileSystems.newFileSystem(fpr, Map.of("create", "true"))) { + Files.writeString(zip.getPath("/webinspect.xml"), webInspectXml(), StandardCharsets.UTF_8); + } + return fpr; + } + + private String webInspectXml() { + String request = Base64.getEncoder().encodeToString("POST /login HTTP/1.1".getBytes(StandardCharsets.UTF_8)); + String response = Base64.getEncoder().encodeToString("HTTP/1.1 200 OK".getBytes(StandardCharsets.UTF_8)); + return """ + + + https://example.test/login + httpsexample.test443 + parameter=username + %s%s + + 1001WebInspect + WI-10014SQL Injection + + Injection + Improper Neutralization + + + Macrohttps://example.test/login + Attackhttps://example.test/login?user=test%%27 + username=test%%27 + + SummarySummary text

]]>
+ FixFix text

]]>
+
+
+
+ """.formatted(request, response); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditRequestMapperTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditRequestMapperTest.java new file mode 100644 index 00000000000..8f37a595127 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditRequestMapperTest.java @@ -0,0 +1,50 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator.dast.DastIssue; +import com.fortify.cli.aviator.dast.DastReproStep; +import com.fortify.cli.aviator.dast.DastSession; + +class DastAuditRequestMapperTest { + @Test + void mapsCompleteFindingContext() { + var session = new DastSession(); + session.setRequestId("request-1"); + session.setUrl("https://example.test"); + session.setRawRequest("GET / HTTP/1.1"); + session.setRawResponse("HTTP/1.1 200 OK"); + var issue = new DastIssue(); + issue.setId("DAST-1"); + issue.setName("SQL Injection"); + issue.getClassifications().put("CWE", "Improper Neutralization"); + var step = new DastReproStep(); + step.setSource("Attack"); + step.setUrl("https://example.test?id=1"); + step.setPostParams("id=1"); + issue.getReproSteps().add(step); + issue.getReproStepUrls().add(step.getUrl()); + + var context = DastAuditRequestMapper.toFindingContext(session, issue); + + assertEquals("DAST-1", context.getIssueId()); + assertEquals("GET / HTTP/1.1", context.getRawRequest()); + assertEquals("CWE", context.getClassifications(0).getKind()); + assertEquals("Attack", context.getReproSteps(0).getSource()); + assertEquals("id=1", context.getReproSteps(0).getPostParams()); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditResponseMapperTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditResponseMapperTest.java new file mode 100644 index 00000000000..45b7424972c --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditResponseMapperTest.java @@ -0,0 +1,71 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +import org.junit.jupiter.api.Test; + +import com.fortify.aviator.dastaudit.DastAuditDecision; +import com.fortify.aviator.dastaudit.DastAuditResponse; + +class DastAuditResponseMapperTest { + @Test + void usesIssueIdAssociatedWithRequest() { + var response = DastAuditResponse.newBuilder() + .setRequestId("request-1") + .setDastIssueId("DAST-1") + .setStatus("SUCCESS") + .setDecision(DastAuditDecision.newBuilder().setTruePositive(true).setConfidence("HIGH")) + .build(); + + DastAuditResult result = DastAuditResponseMapper.map(response, "DAST-1"); + + var success = assertInstanceOf(DastAuditResult.Success.class, result); + assertEquals("DAST-1", result.issueId()); + assertEquals("SUCCESS", result.status()); + assertEquals(true, success.truePositive()); + } + + @Test + void rejectsMismatchedServerIssueId() { + var response = DastAuditResponse.newBuilder() + .setRequestId("request-1") + .setDastIssueId("DAST-WRONG") + .setStatus("SUCCESS") + .build(); + + DastAuditResult result = DastAuditResponseMapper.map(response, "DAST-1"); + + assertInstanceOf(DastAuditResult.Failure.class, result); + assertEquals("DAST-1", result.issueId()); + assertEquals("FAILED", result.status()); + } + + @Test + void preservesSkippedResponseAsDistinctVariant() { + var response = DastAuditResponse.newBuilder() + .setRequestId("request-1") + .setDastIssueId("DAST-1") + .setStatus("SKIPPED") + .setStatusMessage("Quota exceeded") + .build(); + + DastAuditResult result = DastAuditResponseMapper.map(response, "DAST-1"); + + assertInstanceOf(DastAuditResult.Skipped.class, result); + assertEquals("SKIPPED", result.status()); + assertEquals("Quota exceeded", result.statusMessage()); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditStreamConfigTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditStreamConfigTest.java new file mode 100644 index 00000000000..0cd16e80571 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditStreamConfigTest.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.grpc; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; + +class DastAuditStreamConfigTest { + @Test + void rejectsBlankToken() { + assertThrows(AviatorSimpleException.class, + () -> new DastAuditStreamConfig(" ", "app", "ssc", "1", null)); + } + + @Test + void rejectsBlankApplicationName() { + assertThrows(AviatorSimpleException.class, + () -> new DastAuditStreamConfig("token", " ", "ssc", "1", null)); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessorTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessorTest.java new file mode 100644 index 00000000000..b0a8952b977 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessorTest.java @@ -0,0 +1,75 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.fortify.aviator.dastaudit.DastAuditClientMessage; +import com.fortify.cli.aviator.dast.DastIssue; +import com.fortify.cli.aviator.dast.DastSession; + +import io.grpc.Status; + +class DastAuditStreamProcessorTest { + @Test + void registersAllRequestsBeforeReturningMessagesForSending() { + var processor = new DastAuditStreamProcessor(null, null, null, 0); + + List requests = processor.prepareAuditRequests( + List.of(workItem("DAST-1"), workItem("DAST-2")), "stream-1"); + + assertEquals(List.of("DAST-1", "DAST-2"), requests.stream() + .map(request -> request.getAudit().getFinding().getIssueId()) + .toList()); + assertEquals(2, processor.pendingRequestCount()); + } + + @Test + void reusesRequestIdsAndRequeuesOnlyUnfinishedWork() { + var processor = new DastAuditStreamProcessor(null, null, null, 0); + List workItems = List.of(workItem("DAST-1"), workItem("DAST-2")); + processor.initializeWorkItems(workItems); + List firstAttempt = processor.prepareAuditRequests(workItems, "stream-1"); + + processor.completeRequest(firstAttempt.get(0).getAudit().getRequestId()); + List retry = processor.prepareAuditRequests( + processor.remainingWorkItems(), "stream-2"); + + assertEquals(1, retry.size()); + assertEquals("DAST-2", retry.get(0).getAudit().getFinding().getIssueId()); + assertEquals(firstAttempt.get(1).getAudit().getRequestId(), retry.get(0).getAudit().getRequestId()); + assertEquals("stream-2", retry.get(0).getAudit().getStreamId()); + } + + @Test + void retriesOnlyTransportDisconnections() { + assertTrue(DastAuditStreamProcessor.isRetryableError(Status.UNAVAILABLE.asRuntimeException())); + assertTrue(DastAuditStreamProcessor.isRetryableError( + Status.INTERNAL.withDescription("RST_STREAM closed").asRuntimeException())); + assertTrue(DastAuditStreamProcessor.isInfiniteRetryError( + Status.INTERNAL.withDescription("PROTOCOL_ERROR").asRuntimeException())); + assertFalse(DastAuditStreamProcessor.isRetryableError(Status.INVALID_ARGUMENT.asRuntimeException())); + } + + private DastAuditWorkItem workItem(String issueId) { + var issue = new DastIssue(); + issue.setId(issueId); + return new DastAuditWorkItem(new DastSession(), issue); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/RequestHandlerTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/RequestHandlerTest.java new file mode 100644 index 00000000000..d720d653aed --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/RequestHandlerTest.java @@ -0,0 +1,47 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import io.grpc.stub.StreamObserver; + +class RequestHandlerTest { + @Test + void completeFlushesQueuedRequestsBeforeHalfClose() { + var events = new ArrayList(); + var handler = new RequestHandler("stream"); + handler.sendRequest("one"); + handler.sendRequest("two"); + handler.initialize(observer(events)); + + handler.complete().join(); + + assertEquals(List.of("one", "two", "completed"), events); + assertTrue(handler.isCompleted()); + } + + private StreamObserver observer(List events) { + return new StreamObserver<>() { + @Override public void onNext(String value) { events.add(value); } + @Override public void onError(Throwable throwable) { events.add("error"); } + @Override public void onCompleted() { events.add("completed"); } + }; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommand.java index 2a4e73281df..bcbb949a845 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommand.java @@ -16,7 +16,6 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.LinkedHashSet; import java.util.List; import java.util.Set; @@ -312,7 +311,7 @@ private void validateSSCTagsBeforeUpload(UnirestInstance unirest, SSCAppVersionD LOG.info("Starting SSC tag validation before FPR upload for app version id={}.", av.getVersionId()); TagMappingConfig tagMappingConfig = loadTagMappingForValidation(); LOG.debug("Tag mapping config loaded: tag_id='{}', mapping={}", tagMappingConfig.getTag_id(), tagMappingConfig.getMapping()); - Set analysisTagValues = extractAnalysisTagValues(tagMappingConfig); + Set analysisTagValues = tagMappingConfig.getMappedValues(); LOG.info("Analysis tag values to validate: {}", analysisTagValues); List warnings = AviatorSSCTagValidator.validatePreUpload( unirest, av.getVersionId(), tagMappingConfig.getTag_id(), analysisTagValues, logger); @@ -326,22 +325,6 @@ private TagMappingConfig loadTagMappingForValidation() { return AviatorConfigManager.getInstance().getDefaultTagMappingConfig(); } - private Set extractAnalysisTagValues(TagMappingConfig config) { - Set values = new LinkedHashSet<>(); - if (config.getMapping() != null) { - addTierValues(values, config.getMapping().getTier_1()); - addTierValues(values, config.getMapping().getTier_2()); - } - return values; - } - - private void addTierValues(Set values, TagMappingConfig.Tier tier) { - if (tier == null) return; - if (tier.getFp() != null && tier.getFp().getValue() != null) values.add(tier.getFp().getValue()); - if (tier.getTp() != null && tier.getTp().getValue() != null) values.add(tier.getTp().getValue()); - if (tier.getUnsure() != null && tier.getUnsure().getValue() != null) values.add(tier.getUnsure().getValue()); - } - private Path downloadFpr(UnirestInstance unirest, SSCAppVersionDescriptor av, AviatorLoggerImpl logger) throws IOException { logger.progress("Status: Downloading FPR from SSC for app version: %s:%s (id: %s)", av.getApplicationName(), av.getVersionName(), av.getVersionId()); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCommands.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCommands.java index d7147c455e4..07e7d7429f4 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCommands.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCommands.java @@ -20,6 +20,7 @@ name = "ssc", subcommands = { AviatorSSCAuditCommand.class, + AviatorSSCDastAuditCommand.class, AviatorSSCPrepareCommand.class, AviatorSSCApplyRemediationsCommand.class, AviatorSSCDownloadRemediationsCacheCommand.class, 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 4411e1c2386..78dd93b330a 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 @@ -242,8 +242,8 @@ private String uploadEnrichedDastFpr(DownloadedFprs fprs, List c new DastFprCorrelationEnricher().injectAndRepackage(fprs.dastPath, confirmed); logger.progress("Status: Uploading correlated DAST FPR to SSC..."); - AviatorSSCCorrelateDownloadHelper.uploadEnrichedDastFpr(unirest, av, fprs.dastPath, progressWriter); - String artifactId = fprs.adDast.getId(); + String artifactId = AviatorSSCCorrelateDownloadHelper.uploadEnrichedDastFpr( + unirest, av, fprs.dastPath, progressWriter); logger.progress("Status: Correlated DAST FPR uploaded successfully (artifact id=%s)", artifactId); return artifactId; } 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 new file mode 100644 index 00000000000..20e4beea679 --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDastAuditCommand.java @@ -0,0 +1,172 @@ +/* + * 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 com.fortify.cli.ssc.artifact.helper.SSCArtifactHelper.getLatestDASTArtifact; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.aviator._common.config.AviatorConfigManager; +import com.fortify.cli.aviator._common.session.user.cli.mixin.AviatorUserSessionDescriptorSupplier; +import com.fortify.cli.aviator.audit.DastAuditFPR; +import com.fortify.cli.aviator.audit.DastAuditFprResult; +import com.fortify.cli.aviator.config.AviatorLoggerImpl; +import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.aviator.config.TagMappingConfig; +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; +import com.fortify.cli.aviator.grpc.DastAuditStreamConfig; +import com.fortify.cli.aviator.grpc.DastAuditStreamProcessor; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCAuditHelper; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCFprTransferHelper; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCTagValidator; +import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.aviator.util.ResourceUtil; +import com.fortify.cli.common.exception.FcliTechnicalException; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; +import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; +import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; +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.SSCAppVersionResolverMixin; +import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; +import com.fortify.cli.ssc.artifact.helper.SSCArtifactDescriptor; + +import kong.unirest.UnirestInstance; +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command(name = "audit-dast") +public class AviatorSSCDastAuditCommand extends AbstractSSCJsonNodeOutputCommand implements IActionCommandResultSupplier { + private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCDastAuditCommand.class); + + @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; + @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; + @Mixin private SSCAppVersionResolverMixin.RequiredOption appVersionResolver; + @Mixin private AviatorUserSessionDescriptorSupplier sessionDescriptorSupplier; + @Option(names = {"--app"}) private String appName; + @Option(names = {"--tag-mapping"}) private String tagMapping; + + private String actionResult = "SKIPPED"; + + @Override + public JsonNode getJsonNode(UnirestInstance unirest) { + Path downloadedFpr = null; + try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { + var logger = new AviatorLoggerImpl(progressWriter); + var appVersion = appVersionResolver.getAppVersionDescriptor(unirest); + var session = sessionDescriptorSupplier.getSessionDescriptor(); + TagMappingConfig tagMappingConfig = loadTagMappingConfig(); + SSCArtifactDescriptor artifact = getLatestDASTArtifact(unirest, appVersion.getVersionId()); + downloadedFpr = AviatorSSCFprTransferHelper.downloadArtifactFpr( + unirest, artifact, logger, progressWriter); + + DastAuditFprResult result = auditFpr( + downloadedFpr, appVersion, session, logger, tagMappingConfig); + actionResult = result.status(); + String artifactId = null; + if (result.updatedFile() != null && result.succeeded() > 0) { + validateSSCTagsBeforeUpload(unirest, appVersion, logger, tagMappingConfig); + logger.progress("Status: Uploading audited DAST FPR to SSC"); + artifactId = AviatorSSCFprTransferHelper.uploadDastFpr( + unirest, appVersion, downloadedFpr, progressWriter); + } + return buildOutput(appVersion, result, artifactId); + } catch (RuntimeException e) { + actionResult = "FAILED"; + throw e; + } catch (Exception e) { + actionResult = "FAILED"; + throw new FcliTechnicalException("DAST audit failed", e); + } finally { + if (downloadedFpr != null) { + try { + Files.deleteIfExists(downloadedFpr); + } catch (Exception e) { + LOG.warn("Failed to delete temporary DAST FPR {}", downloadedFpr, e); + } + } + } + } + + private DastAuditFprResult auditFpr( + Path fprPath, + SSCAppVersionDescriptor appVersion, + com.fortify.cli.aviator._common.session.user.helper.AviatorUserSessionDescriptor session, + IAviatorLogger logger, + TagMappingConfig tagMappingConfig) throws Exception { + String effectiveAppName = appName != null ? appName : appVersion.getApplicationName(); + var config = new DastAuditStreamConfig( + session.getAviatorToken(), effectiveAppName, + appVersion.getApplicationName(), appVersion.getVersionName(), null); + try (var grpcClient = AviatorGrpcClientHelper.createClient(session.getAviatorUrl(), logger, 30); + var streamProcessor = new DastAuditStreamProcessor( + logger, grpcClient.getDastAuditAsyncStub(), grpcClient.getPingScheduler(), + grpcClient.getPingIntervalSeconds()); + var fprHandle = new FprHandle(fprPath)) { + long timeout = Math.max(grpcClient.getDefaultTimeoutSeconds(), 300); + return DastAuditFPR.audit(fprHandle, config, tagMappingConfig, (streamConfig, workItems, totalReported) -> + streamProcessor.process(streamConfig, workItems, totalReported) + .orTimeout(timeout, TimeUnit.SECONDS)); + } catch (CompletionException e) { + throw e.getCause() instanceof Exception exception ? exception : e; + } + } + + private ObjectNode buildOutput( + SSCAppVersionDescriptor appVersion, + DastAuditFprResult audit, + String artifactId) { + ObjectNode result = AviatorSSCAuditHelper.buildResultNode(appVersion, artifactId, audit.status()); + AviatorSSCAuditHelper.setDastAuditStats(result, audit); + return result; + } + + private TagMappingConfig loadTagMappingConfig() { + TagMappingConfig tagMappingConfig = tagMapping == null || tagMapping.isBlank() + ? AviatorConfigManager.getInstance().getDefaultDastTagMappingConfig() + : ResourceUtil.loadYamlFile(new File(tagMapping), TagMappingConfig.class); + tagMappingConfig.validateForDast(); + return tagMappingConfig; + } + + private void validateSSCTagsBeforeUpload(UnirestInstance unirest, + SSCAppVersionDescriptor appVersion, IAviatorLogger logger, + TagMappingConfig tagMappingConfig) { + List warnings = AviatorSSCTagValidator.validatePreUpload( + unirest, appVersion.getVersionId(), tagMappingConfig.getTag_id(), + tagMappingConfig.getMappedValues(), logger); + LOG.info("DAST tag validation complete. {} warning(s) found.", warnings.size()); + } + + @Override + public String getActionCommandResult() { + return actionResult; + } + + @Override + public boolean isSingular() { + return true; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java index 542ee85bb86..48539a13b32 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java @@ -27,6 +27,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.fortify.aviator.application.Application; import com.fortify.cli.aviator._common.exception.AviatorSimpleException; +import com.fortify.cli.aviator.audit.DastAuditFprResult; import com.fortify.cli.aviator.audit.model.FPRAuditResult; import com.fortify.cli.aviator.config.AviatorLoggerImpl; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; @@ -141,6 +142,37 @@ public static void setAuditStats(ObjectNode result, FPRAuditResult auditResult) ((ObjectNode) result.get("operation")).set("audit", audit); } + /** + * Populates the standard audit output envelope with DAST-specific statistics. + * + * @param result The result node created by {@link #buildResultNode}. + * @param auditResult The DAST FPR audit result. + */ + public static void setDastAuditStats(ObjectNode result, DastAuditFprResult auditResult) { + ObjectNode audit = JsonHelper.getObjectMapper().createObjectNode(); + audit.put("message", getDastAuditMessage(auditResult)); + audit.put("submitted", auditResult.submitted()); + audit.put("succeeded", auditResult.succeeded()); + audit.put("skipped", auditResult.skipped()); + audit.put("failed", auditResult.failed()); + ((ObjectNode) result.get("operation")).set("audit", audit); + result.remove("state"); + } + + private static String getDastAuditMessage(DastAuditFprResult auditResult) { + return switch (auditResult.status()) { + case "AUDITED" -> "DAST audit completed successfully"; + case "PARTIALLY_AUDITED" -> auditResult.message() != null + ? auditResult.message() : "DAST audit partially completed"; + case "SKIPPED" -> auditResult.message() != null + ? auditResult.message() : "No DAST findings to audit"; + case "FAILED" -> auditResult.message() != null + ? auditResult.message() : "DAST audit failed"; + default -> auditResult.message() != null + ? auditResult.message() : "Unknown DAST audit status"; + }; + } + /** * Sets only the {@code operation.audit.message} field without audit stats. * Used for code paths that don't perform an actual audit (SKIPPED, FAILED, QUOTA_EXCEEDED, etc.). 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 index 2b47c4833c1..876da9d2efb 100644 --- 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 @@ -39,15 +39,7 @@ private AviatorSSCCorrelateDownloadHelper() {} */ public static Path downloadArtifactFpr(UnirestInstance unirest, SSCArtifactDescriptor ad, AviatorLoggerImpl logger, IProgressWriter progressWriter) throws IOException { - Path fprPath = Files.createTempFile("aviator_" + ad.getId() + "_", ".fpr"); - logger.progress("Status: Downloading FPR from SSC (artifact id=" + ad.getId() + ")"); - SSCFileTransferHelper.download( - unirest, - SSCUrls.DOWNLOAD_ARTIFACT(ad.getId(), true), - fprPath.toFile(), - SSCFileTransferHelper.ISSCAddDownloadTokenFunction.ROUTEPARAM_DOWNLOADTOKEN, - progressWriter); - return fprPath; + return AviatorSSCFprTransferHelper.downloadArtifactFpr(unirest, ad, logger, progressWriter); } /** @@ -73,18 +65,11 @@ public static Path downloadCurrentSastFpr(UnirestInstance unirest, SSCAppVersion } /** - * Uploads an enriched DAST FPR to SSC using the HTML upload endpoint. + * Uploads an enriched DAST FPR to SSC and returns the new artifact ID. */ - public static void uploadEnrichedDastFpr(UnirestInstance unirest, SSCAppVersionDescriptor av, + public static String uploadEnrichedDastFpr(UnirestInstance unirest, SSCAppVersionDescriptor av, Path enrichedDastFpr, IProgressWriter progressWriter) { - SSCFileTransferHelper.htmlUpload( - unirest, - SSCUrls.UPLOAD_RESULT_FILE(av.getVersionId()), - enrichedDastFpr.toFile(), - SSCFileTransferHelper.ISSCAddUploadTokenFunction.ROUTEPARAM_UPLOADTOKEN, - String.class, - progressWriter - ); + return AviatorSSCFprTransferHelper.uploadDastFpr(unirest, av, enrichedDastFpr, progressWriter); } /** 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 new file mode 100644 index 00000000000..7ea26de68eb --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCFprTransferHelper.java @@ -0,0 +1,73 @@ +/* + * 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.IAviatorLogger; +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; + +/** + * Shared SSC transfer operations for artifact-specific DAST FPR workflows. + */ +public final class AviatorSSCFprTransferHelper { + private AviatorSSCFprTransferHelper() {} + + public static Path downloadArtifactFpr( + UnirestInstance unirest, + SSCArtifactDescriptor artifact, + IAviatorLogger logger, + IProgressWriter progressWriter) throws IOException { + Path fprPath = Files.createTempFile("aviator_" + artifact.getId() + "_", ".fpr"); + try { + logger.progress("Status: Downloading FPR from SSC (artifact id=%s)", artifact.getId()); + SSCFileTransferHelper.download( + unirest, + SSCUrls.DOWNLOAD_ARTIFACT(artifact.getId(), true), + fprPath.toFile(), + SSCFileTransferHelper.ISSCAddDownloadTokenFunction.ROUTEPARAM_DOWNLOADTOKEN, + progressWriter); + return fprPath; + } catch (RuntimeException e) { + try { + Files.deleteIfExists(fprPath); + } catch (IOException cleanupException) { + e.addSuppressed(cleanupException); + } + throw e; + } + } + + public static String uploadDastFpr( + UnirestInstance unirest, + SSCAppVersionDescriptor appVersion, + Path dastFpr, + IProgressWriter progressWriter) { + JsonNode uploadResponse = SSCFileTransferHelper.restUpload( + unirest, + SSCUrls.PROJECT_VERSION_ARTIFACTS(appVersion.getVersionId()), + dastFpr.toFile(), + JsonNode.class, + progressWriter); + return uploadResponse.path("data").path("id").asText("UPLOAD_FAILED"); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/main/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties b/fcli-core/fcli-aviator/src/main/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties index 577c89836aa..9c04c3ba173 100644 --- a/fcli-core/fcli-aviator/src/main/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties +++ b/fcli-core/fcli-aviator/src/main/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties @@ -142,6 +142,7 @@ fcli.aviator.ssc.audit.usage.description = Downloads the FPR from an SSC applica command to wait until the audited FPR file has been processed by SSC. fcli.aviator.ssc.audit.app = Fortify Aviator application name to associate with the audit. If not provided, the SAST/FPR Build ID of the SSC application is used. fcli.aviator.ssc.audit.tag-mapping = Custom tag mapping for audit results. +fcli.aviator.ssc.audit-dast.tag-mapping = Custom tag mapping for DAST audit results. fcli.aviator.ssc.audit.filterset = Name or ID of the FilterSet to apply. fcli.aviator.ssc.audit.no-filterset = Do not apply any filter sets, including the default enabled filter set from the FPR. fcli.aviator.ssc.audit.folder = Filter issues by a comma-separated list of specific folder names from the selected FilterSet (e.g., 'Hot,Critical'). This option requires a FilterSet to be active. @@ -153,6 +154,12 @@ fcli.aviator.ssc.audit.refresh = By default, this command will refresh the sour Note that for large applications this can lead to an error if the timeout expires. fcli.aviator.ssc.audit.refresh-timeout = Time-out, for example 30s (30 seconds), 5m (5 minutes), 1h (1 hour). Default value: ${DEFAULT-VALUE} +fcli.aviator.ssc.audit-dast.usage.header = Audit DAST findings in an SSC application version using Fortify Aviator. +fcli.aviator.ssc.audit-dast.usage.description = Downloads the latest DAST FPR from SSC, audits eligible WebInspect findings, \ + writes decisions to audit.xml, and uploads the changed DAST FPR. This command requires an active Fortify Aviator user session. +fcli.aviator.ssc.audit-dast.app = Fortify Aviator application name to associate with the DAST audit. \ + If omitted, the SSC application name is used. + fcli.aviator.ssc.apply-remediations.usage.header = Apply auto-remediations from a Fortify Remediation Aviator-processed artifact to source code. fcli.aviator.ssc.apply-remediations.usage.description = Downloads FPR artifact(s) from SSC, or reads a local remediations cache zip, and applies Fortify Remediation Aviator-generated remediations to the specified source directory. \ Exactly one of --from-cache, --artifact-id, --latest, or --all must be specified. Online selection requires an active SSC session; --from-cache does not. \ @@ -226,6 +233,7 @@ fcli.env.default.prefix=FCLI_DEFAULT fcli.aviator.session.output.table.args = name,type,url,created,expires,expired fcli.aviator.admin-config.output.table.args = name,type,url,created fcli.aviator.ssc.audit.output.table.args = id,application.name,name,artifactId,action +fcli.aviator.ssc.audit-dast.output.table.args = id,application.name,name,artifactId,action fcli.aviator.app.create.output.table.args = id,name,entitlement_id,disclaimer,quota_last_updated,quota fcli.aviator.app.add-entitlement.output.table.args = id,name,entitlement_id,entitlement_multiplier,entitlements_consumed,quota_last_updated,quota fcli.aviator.app.delete.output.table.args = message diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDastAuditCommandTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDastAuditCommandTest.java new file mode 100644 index 00000000000..cadbfea97ac --- /dev/null +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDastAuditCommandTest.java @@ -0,0 +1,82 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator.audit.DastAuditFprResult; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCAuditHelper; +import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; + +import picocli.CommandLine; + +class AviatorSSCDastAuditCommandTest { + @Test + void rejectsClientSideToneImprovementOptions() { + assertThrows(CommandLine.ParameterException.class, () -> parse("--improve-tone")); + assertThrows(CommandLine.ParameterException.class, () -> parse("--no-improve-tone")); + } + + @Test + void acceptsCustomTagMapping() { + var commandLine = new CommandLine(new AviatorSSCDastAuditCommand()); + + var parseResult = commandLine.parseArgs("--av", "test:1.0", "--tag-mapping", "dast-tags.yaml"); + + assertEquals("dast-tags.yaml", parseResult.matchedOptionValue("--tag-mapping", null)); + } + + @Test + void dastAuditStatsUseSastAuditOutputEnvelope() { + var appVersion = new SSCAppVersionDescriptor(); + appVersion.setVersionId("42"); + appVersion.setApplicationName("WebGoat"); + appVersion.setVersionName("1.0"); + var auditResult = new DastAuditFprResult( + null, "PARTIALLY_AUDITED", null, 8, 6, 6, 4, + 2, 1, 1, 2, 0, 6, 2, false, null, null); + + var result = AviatorSSCAuditHelper.buildResultNode(appVersion, "2786", auditResult.status()); + AviatorSSCAuditHelper.setDastAuditStats(result, auditResult); + + assertEquals("42", result.path("id").asText()); + assertEquals("WebGoat", result.path("applicationName").asText()); + assertEquals("2786", result.path("artifactId").asText()); + assertEquals("PARTIALLY_AUDITED", result.path("__action__").asText()); + assertEquals(6, result.path("operation").path("audit").path("submitted").asInt()); + assertEquals(4, result.path("operation").path("audit").path("succeeded").asInt()); + assertEquals(2, result.path("operation").path("audit").path("skipped").asInt()); + assertEquals(0, result.path("operation").path("audit").path("failed").asInt()); + assertFalse(result.path("operation").path("audit").has("truePositives")); + assertFalse(result.path("operation").path("audit").has("falsePositivesSuppressed")); + assertFalse(result.path("operation").path("audit").has("likelyFalsePositives")); + assertFalse(result.has("state")); + assertFalse(result.has("submitted")); + } + + private static AviatorSSCDastAuditCommand parse(String... args) { + var command = new AviatorSSCDastAuditCommand(); + var fullArgs = new ArrayList(); + Collections.addAll(fullArgs, "--av", "test:1.0"); + Collections.addAll(fullArgs, args); + new CommandLine(command).parseArgs(fullArgs.toArray(String[]::new)); + return command; + } +} \ No newline at end of file