diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/AuditFPR.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/AuditFPR.java index 5888d18aff..7eff9f7a47 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/AuditFPR.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/AuditFPR.java @@ -31,7 +31,6 @@ import com.fortify.cli.aviator.audit.model.FPRAuditResult; import com.fortify.cli.aviator.audit.model.FilterSelection; import com.fortify.cli.aviator.audit.model.ParsedFprData; -import com.fortify.cli.aviator.config.IAviatorLogger; import com.fortify.cli.aviator.config.TagMappingConfig; import com.fortify.cli.aviator.fpr.FPRProcessor; import com.fortify.cli.aviator.fpr.Vulnerability; @@ -72,10 +71,7 @@ public static FPRAuditResult auditFPR(AuditFprOptions options) // --- STAGE 3: AUDITING --- Map auditResponses = new ConcurrentHashMap<>(); - AuditOutcome auditOutcome = performAviatorAudit( - parsedData, options.getLogger(), options.getToken(), options.getAppVersion(), options.getUrl(), options.getSscAppName(), options.getSscAppVersion(), - auditResponses, filterSelection, options.getFprHandle(), options.getFolderPriorityOrder(), sourceDecoder - ); + AuditOutcome auditOutcome = performAviatorAudit(parsedData, auditResponses, filterSelection, options); // --- STAGE 4: FINALIZATION --- return finalizeFprAudit( @@ -127,32 +123,23 @@ private static Map buildIssueCategoryLookup(List return issueCategoryLookup; } - private static AuditOutcome performAviatorAudit( - ParsedFprData parsedData, IAviatorLogger logger, - String token, String appVersion, String url, String sscAppName, String sscAppVersion, - Map auditResponsesToFill, FilterSelection filterSelection, FprHandle fprHandle, - List folderPriorityOrder, ISourceDecoder sourceDecoder) { + private static AuditOutcome performAviatorAudit(ParsedFprData parsedData, Map auditResponsesToFill, + FilterSelection filterSelection, AuditFprOptions options) { SourceLanguageResolver sourceLanguageResolver = new SourceLanguageResolver(parsedData.streamingFVDLProcessor.getFvdlMetadata()); parsedData.streamingFVDLProcessor.getFvdlMetadata().clearSourceFileTypeIndexes(); - IssueAuditor issueAuditor = new IssueAuditor( - parsedData.vulnerabilities, - parsedData.auditProcessor, - parsedData.auditIssueMap, - parsedData.fprInfo, - sscAppName, - sscAppVersion, - filterSelection, - logger, - folderPriorityOrder, - sourceLanguageResolver, - sourceDecoder, - parsedData.streamingFVDLProcessor.getFvdlMetadata() - ); - return issueAuditor.performAudit( - auditResponsesToFill, token, appVersion, parsedData.fprInfo.getBuildId(), url, fprHandle - ); + IssueAuditor issueAuditor = IssueAuditor.builder() + .vulnerabilities(parsedData.vulnerabilities) + .auditProcessor(parsedData.auditProcessor) + .auditIssueMap(parsedData.auditIssueMap) + .fprInfo(parsedData.fprInfo) + .filterSelection(filterSelection) + .sourceLanguageResolver(sourceLanguageResolver) + .fvdlMetadata(parsedData.streamingFVDLProcessor.getFvdlMetadata()) + .options(options) + .build(); + return issueAuditor.performAudit(auditResponsesToFill); } private static FPRAuditResult finalizeFprAudit( diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/IssueAuditor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/IssueAuditor.java index dc5b689789..40bb0d4c4e 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/IssueAuditor.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/IssueAuditor.java @@ -14,9 +14,11 @@ import static com.fortify.cli.aviator.util.Constants.DEFAULT_PING_INTERVAL_SECONDS; +import java.io.File; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -36,11 +38,12 @@ import com.fortify.cli.aviator._common.exception.AviatorQuotaFilterException; import com.fortify.cli.aviator._common.exception.AviatorSimpleException; import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; +import com.fortify.cli.aviator.audit.model.AuditFprOptions; import com.fortify.cli.aviator.audit.model.AuditOutcome; import com.fortify.cli.aviator.audit.model.AuditResponse; import com.fortify.cli.aviator.audit.model.FilterSelection; import com.fortify.cli.aviator.audit.model.UserPrompt; -import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.aviator.config.TagMappingConfig; import com.fortify.cli.aviator.fpr.Vulnerability; import com.fortify.cli.aviator.fpr.filter.Filter; import com.fortify.cli.aviator.fpr.filter.FilterSet; @@ -51,14 +54,14 @@ import com.fortify.cli.aviator.fpr.model.FPRInfo; import com.fortify.cli.aviator.fpr.model.FVDLMetadata; import com.fortify.cli.aviator.fpr.processor.AuditProcessor; -import com.fortify.cli.aviator.fpr.utils.ISourceDecoder; -import com.fortify.cli.aviator.fpr.utils.SourceDecoders; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.aviator.util.Constants; -import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.aviator.util.ResourceUtil; import com.fortify.cli.aviator.util.StringUtil; +import lombok.Builder; + public class IssueAuditor { @@ -84,27 +87,17 @@ public class IssueAuditor { private TagDefinition humanAuditTag; private TagDefinition aviatorStatusTag; private final SourceLanguageResolver sourceLanguageResolver; - private final ISourceDecoder sourceDecoder; private final FVDLMetadata fvdlMetadata; + private final AuditFprOptions options; + private final Set resultTagIds; - private final IAviatorLogger logger; - private final List customPriorityOrder; - - public IssueAuditor(List vulnerabilities, AuditProcessor auditProcessor, Map auditIssueMap, - FPRInfo fprInfo, String SSCApplicationName, String SSCApplicationVersion, - FilterSelection filterSelection, IAviatorLogger logger, List customPriorityOrder, - SourceLanguageResolver sourceLanguageResolver) { - this(vulnerabilities, auditProcessor, auditIssueMap, fprInfo, SSCApplicationName, SSCApplicationVersion, - filterSelection, logger, customPriorityOrder, sourceLanguageResolver, SourceDecoders.defaults(), null); - } - + @Builder public IssueAuditor(List vulnerabilities, AuditProcessor auditProcessor, Map auditIssueMap, - FPRInfo fprInfo, String SSCApplicationName, String SSCApplicationVersion, - FilterSelection filterSelection, IAviatorLogger logger, List customPriorityOrder, - SourceLanguageResolver sourceLanguageResolver, ISourceDecoder sourceDecoder, - FVDLMetadata fvdlMetadata) { - this.logger = logger; - this.customPriorityOrder = customPriorityOrder; + FPRInfo fprInfo, FilterSelection filterSelection, SourceLanguageResolver sourceLanguageResolver, + FVDLMetadata fvdlMetadata, AuditFprOptions options) { + Objects.requireNonNull(options, "options"); + Objects.requireNonNull(options.getSourceDecoder(), "sourceDecoder"); + this.options = options; this.MAX_PER_CATEGORY = Constants.MAX_PER_CATEGORY; this.MAX_TOTAL = Constants.MAX_TOTAL; this.MAX_PER_CATEGORY_EXCEEDED = Constants.MAX_PER_CATEGORY_EXCEEDED; @@ -115,13 +108,13 @@ public IssueAuditor(List vulnerabilities, AuditProcessor auditPro this.auditIssueMap = auditIssueMap; this.fprInfo = fprInfo; this.filterSelection = filterSelection; - this.SSCApplicationName = SSCApplicationName; - this.SSCApplicationVersion = SSCApplicationVersion; + this.SSCApplicationName = options.getSscAppName(); + this.SSCApplicationVersion = options.getSscAppVersion(); this.sourceLanguageResolver = sourceLanguageResolver; - this.sourceDecoder = Objects.requireNonNull(sourceDecoder, "sourceDecoder"); this.fvdlMetadata = fvdlMetadata; this.analysisTag = fprInfo.getFilterTemplate().getTagDefinitions().stream().filter(t -> "Analysis".equalsIgnoreCase(t.getName())).findFirst().orElse(null); this.resultsTag = resolveResultTag("", "", analysisTag); + this.resultTagIds = resolveResultTagIds(); } private TagDefinition resolveResultTag(String tagName, String tagGuid, TagDefinition analysisTag) { @@ -165,30 +158,30 @@ private TagDefinition resolveHumanAuditStatus() { return new TagDefinition(name, id, values, false); } - public AuditOutcome performAudit(Map auditResponses, String token, - String projectName, String projectBuildId, String url, FprHandle fprHandle) { - projectName = StringUtil.isEmpty(projectName) ? projectBuildId : projectName; - logger.progress("Starting audit for project: %s", projectName); + public AuditOutcome performAudit(Map auditResponses) { + String projectName = StringUtil.isEmpty(options.getAppVersion()) ? fprInfo.getBuildId() : options.getAppVersion(); + options.getLogger().progress("Starting audit for project: %s", projectName); ConcurrentLinkedDeque promptsToAudit = prepareAndFilterPrompts(); int totalIssuesToAudit = promptsToAudit.size(); - logger.progress("Final count of issues to be audited: %d", totalIssuesToAudit); + options.getLogger().progress("Final count of issues to be audited: %d", totalIssuesToAudit); if (promptsToAudit.isEmpty()) { - logger.progress("Audit skipped - no issues to process after filtering."); + options.getLogger().progress("Audit skipped - no issues to process after filtering."); } else { - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(url, logger, DEFAULT_PING_INTERVAL_SECONDS)) { + try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(options.getUrl(), options.getLogger(), DEFAULT_PING_INTERVAL_SECONDS)) { CompletableFuture> future = client.processBatchRequests(promptsToAudit, projectName, fprInfo.getBuildId(), SSCApplicationName, - SSCApplicationVersion, token, fprHandle, customPriorityOrder, sourceDecoder, fvdlMetadata); + SSCApplicationVersion, options.getToken(), options.getFprHandle(), + options.getFolderPriorityOrder(), options.getSourceDecoder(), fvdlMetadata); Map responses = future.get(500, TimeUnit.MINUTES); responses.forEach((requestId, response) -> auditResponses.put(response.getIssueId(), response)); - logger.progress("Audit completed"); + options.getLogger().progress("Audit completed"); } catch (ExecutionException e) { Throwable cause = e.getCause(); // Handle quota filtering exception (all issues filtered out) if (cause instanceof AviatorQuotaFilterException) { - logger.progress("All issues filtered out due to quota constraints: %s", cause.getMessage()); + options.getLogger().progress("All issues filtered out due to quota constraints: %s", cause.getMessage()); // Update totalIssuesToAudit to reflect actual auditable count (0) totalIssuesToAudit = 0; } else if (cause instanceof AviatorSimpleException) { @@ -199,10 +192,10 @@ public AuditOutcome performAudit(Map auditResponses, Stri throw new AviatorTechnicalException("Unexpected error during audit execution", cause); } } catch (TimeoutException e) { - logger.error("Audit failed due to timeout after 500 minutes"); + options.getLogger().error("Audit failed due to timeout after 500 minutes"); throw new AviatorTechnicalException("Audit timed out after 500 minutes", e); } catch (InterruptedException e) { - logger.error("Audit failed due to interruption"); + options.getLogger().error("Audit failed due to interruption"); Thread.currentThread().interrupt(); throw new AviatorTechnicalException("Audit interrupted", e); } @@ -243,31 +236,131 @@ private ConcurrentLinkedDeque prepareAndFilterPrompts() { private boolean shouldInclude(UserPrompt userPrompt) { + AuditIssue auditIssue = auditIssue(userPrompt); + if (options.isForceReaudit()) { + if (hasHumanTriage(auditIssue)) { + LOG.debug("Skipping force re-audit for suppressed or human-triaged issue ID: {}", + userPrompt.getIssueData().getInstanceID()); + return false; + } + if (isAviatorWork(auditIssue)) { + LOG.debug("Including previously processed Aviator issue for force re-audit: {}", + userPrompt.getIssueData().getInstanceID()); + return true; + } + } if (isAudited(userPrompt)) { LOG.debug("Skipping already audited issue ID: {}", userPrompt.getIssueData().getInstanceID()); return false; } - if (humanAuditTag != null) { - String issueId = userPrompt.getIssueData().getInstanceID(); - String status = Optional.ofNullable(auditIssueMap.get(issueId)).map(AuditIssue::getTags).map(tags -> tags.get("604f0fbe-b5fe-47cd-a9cb-587ad8ebe93a")).orElse(null); - if (!StringUtil.isEmpty(status) && !Constants.PENDING_REVIEW.equalsIgnoreCase(status)) { - LOG.debug("Skipping because already manually audited: {}", issueId); - return false; + if (humanAuditTag != null && isManuallyAudited(auditIssue)) { + LOG.debug("Skipping because already manually audited: {}", userPrompt.getIssueData().getInstanceID()); + return false; + } + + if (aviatorStatusTag != null && isProcessedByAviator(auditIssue)) { + LOG.debug("Skipping already processed by Aviator: {}", userPrompt.getIssueData().getInstanceID()); + return false; + } + + return true; + } + + private AuditIssue auditIssue(UserPrompt userPrompt) { + return auditIssueMap.get(userPrompt.getIssueData().getInstanceID()); + } + + private static boolean isManuallyAudited(AuditIssue auditIssue) { + if (auditIssue == null || auditIssue.getTags() == null) { + return false; + } + return !StringUtil.isPendingReviewValue(auditIssue.getTags().get(Constants.FOD_TAG_ID)); + } + + private boolean isProcessedByAviator(AuditIssue auditIssue) { + if (auditIssue == null || auditIssue.getTags() == null) { + return false; + } + String status = auditIssue.getTags().get(Constants.AVIATOR_STATUS_TAG_ID); + return !StringUtil.isEmpty(status) && Constants.PROCESSED_BY_AVIATOR.equalsIgnoreCase(status); + } + + private boolean hasHumanTriage(AuditIssue auditIssue) { + if (auditIssue == null) { + return false; + } + if (auditIssue.isSuppressed()) { + return true; + } + boolean processedByAviator = isProcessedByAviator(auditIssue); + for (String tagId : resultTagIds) { + if (!isResultTagSet(auditIssue, tagId)) { + continue; + } + String username = lastWriterUsername(auditIssue, tagId); + if (Constants.isAviatorAuditUsername(username)) { + continue; + } + if (!StringUtil.isEmpty(username) || Constants.FOD_TAG_ID.equalsIgnoreCase(tagId) || !processedByAviator) { + return true; } } + return false; + } - if (aviatorStatusTag != null) { - String issueId = userPrompt.getIssueData().getInstanceID(); - String status = Optional.ofNullable(auditIssueMap.get(issueId)).map(AuditIssue::getTags).map(tags -> tags.get("FB7B0462-2C2E-46D9-811A-DCC1F3C83051")).orElse(null); - if (!StringUtil.isEmpty(status) && Constants.PROCESSED_BY_AVIATOR.equalsIgnoreCase(status)) { - LOG.debug("Skipping already PROCESSED_BY_AVIATOR: {}", issueId); - return false; + private boolean isAviatorWork(AuditIssue auditIssue) { + if (isProcessedByAviator(auditIssue)) { + return true; + } + for (String tagId : resultTagIds) { + if (isResultTagSet(auditIssue, tagId) && Constants.isAviatorAuditUsername(lastWriterUsername(auditIssue, tagId))) { + return true; } } + return false; + } - return true; + private boolean isResultTagSet(AuditIssue auditIssue, String tagId) { + Map tags = auditIssue.getTags(); + return tags != null && !StringUtil.isPendingReviewValue(tags.get(tagId)); + } + + private String lastWriterUsername(AuditIssue auditIssue, String tagId) { + Map lastTagUsernames = auditIssue.getLastTagUsernames(); + if (lastTagUsernames == null) { + return null; + } + String username = lastTagUsernames.get(tagId); + return StringUtil.isEmpty(username) ? null : username; + } + + private Set resolveResultTagIds() { + Set tagIds = new LinkedHashSet<>(List.of( + Constants.ANALYSIS_TAG_ID, Constants.AUDITOR_STATUS_TAG_ID, Constants.FOD_TAG_ID)); + if (analysisTag != null && !StringUtil.isEmpty(analysisTag.getId())) { + tagIds.add(analysisTag.getId()); + } + String mappedTagId = mappedTagId(); + if (!StringUtil.isEmpty(mappedTagId)) { + tagIds.add(mappedTagId); + } + return tagIds; + } + + private String mappedTagId() { + String tagMappingPath = options.getTagMappingPath(); + if (tagMappingPath == null || tagMappingPath.isBlank()) { + return null; + } + try { + TagMappingConfig config = ResourceUtil.loadYamlFile(new File(tagMappingPath), TagMappingConfig.class); + return config == null ? null : config.getTag_id(); + } catch (Exception e) { + LOG.debug("Could not read tag mapping file {}", tagMappingPath, e); + return null; + } } private boolean isAudited(UserPrompt userPrompt) { @@ -282,7 +375,7 @@ private boolean isAudited(UserPrompt userPrompt) { String auditorStatusTag = Constants.AUDITOR_STATUS_TAG_ID; String auditorStatusValue = tags.get(auditorStatusTag); - if (auditorStatusValue != null && !auditorStatusValue.equalsIgnoreCase("Pending Review")) { + if (!StringUtil.isPendingReviewValue(auditorStatusValue)) { return true; } @@ -299,12 +392,12 @@ private boolean isAudited(UserPrompt userPrompt) { if (analysisTag != null && tags.containsKey(analysisTag.getId())) { String tagValue = tags.get(analysisTag.getId()); - if (tagValue != null && !tagValue.equalsIgnoreCase("Not Set") && !tagValue.equalsIgnoreCase(Constants.PENDING_REVIEW) && !tagValue.trim().isEmpty()) { + if (!StringUtil.isPendingReviewValue(tagValue)) { return true; } } - if (tags.containsKey(analysisTagS) && !tags.get(analysisTagS).equalsIgnoreCase("Not Set") && !StringUtil.isEmpty(tags.get(analysisTagS))) { + if (tags.containsKey(analysisTagS) && !StringUtil.isPendingReviewValue(tags.get(analysisTagS))) { return true; } } @@ -352,7 +445,7 @@ private List filterVulnerabilities(List allVulnera .flatMap(List::stream) .distinct() .collect(Collectors.toList()); - logger.info("FilterSet '{}' applied. {} of {} total vulnerabilities remain.", fs.getTitle(), result.size(), allVulnerabilities.size()); + options.getLogger().info("FilterSet '{}' applied. {} of {} total vulnerabilities remain.", fs.getTitle(), result.size(), allVulnerabilities.size()); return result; } else { Set targetFolderIds = fs.getFolderDefinitions().stream() @@ -371,7 +464,7 @@ private List filterVulnerabilities(List allVulnera .distinct() .collect(Collectors.toList()); - logger.info("Filtered by folder(s) '{}'. {} vulnerabilities remain.", targetFolderNames, result.size()); + options.getLogger().info("Filtered by folder(s) '{}'. {} vulnerabilities remain.", targetFolderNames, result.size()); return result; } } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/model/AuditFprOptions.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/model/AuditFprOptions.java index 8fa1319822..f05b9dffa5 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/model/AuditFprOptions.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/model/AuditFprOptions.java @@ -36,5 +36,6 @@ public class AuditFprOptions { private final boolean noFilterSet; private final List folderNames; private final List folderPriorityOrder; + @Builder.Default private final boolean forceReaudit = false; @Builder.Default private final ISourceDecoder sourceDecoder = SourceDecoders.defaults(); } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/FPRProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/FPRProcessor.java index b0899d4f59..b8744a2902 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/FPRProcessor.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/FPRProcessor.java @@ -146,7 +146,7 @@ private boolean isAudited(AuditIssue auditIssue) { } String auditorStatusValue = tags.get(Constants.AUDITOR_STATUS_TAG_ID); - if (!isPendingReviewValue(auditorStatusValue)) { + if (!StringUtil.isPendingReviewValue(auditorStatusValue)) { return true; } @@ -159,16 +159,7 @@ private boolean isAudited(AuditIssue auditIssue) { } String analysisTagValue = tags.get(Constants.ANALYSIS_TAG_ID); - return analysisTagValue != null - && !analysisTagValue.equalsIgnoreCase("Not Set") - && !analysisTagValue.equalsIgnoreCase(Constants.PENDING_REVIEW) - && !StringUtil.isEmpty(analysisTagValue); - } - - private boolean isPendingReviewValue(String value) { - return StringUtil.isEmpty(value) - || value.equalsIgnoreCase("Pending Review") - || value.equalsIgnoreCase(Constants.PENDING_REVIEW); + return !StringUtil.isPendingReviewValue(analysisTagValue); } private String resolveIssueStatus(AuditIssue auditIssue) { diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/model/AuditIssue.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/model/AuditIssue.java index 75c616be25..f62638a885 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/model/AuditIssue.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/model/AuditIssue.java @@ -30,6 +30,7 @@ public class AuditIssue { private boolean suppressed; private int revision; @Builder.Default private Map tags = new HashMap<>(); + @Builder.Default private Map lastTagUsernames = new HashMap<>(); @Builder.Default private List threadedComments = new ArrayList<>(); public void addTag(String tagId, String tagValue) { 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 d919ff321b..11a79e38ab 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 @@ -249,6 +249,7 @@ private AuditIssue processAuditIssue(Element issueElement) { tags.put(tagId, tagValue); } auditIssueBuilder.tags(tags); + auditIssueBuilder.lastTagUsernames(lastTagUsernames(issueElement)); List threadedComments = new ArrayList<>(); NodeList commentNodes = issueElement.getElementsByTagNameNS(AUDIT_NAMESPACE_URI, "Comment"); @@ -267,6 +268,24 @@ private AuditIssue processAuditIssue(Element issueElement) { } + private Map lastTagUsernames(Element issueElement) { + Map lastTagUsernames = new HashMap<>(); + NodeList tagHistories = issueElement.getElementsByTagNameNS(AUDIT_NAMESPACE_URI, "TagHistory"); + for (int index = 0; index < tagHistories.getLength(); index++) { + Element tagHistory = (Element) tagHistories.item(index); + NodeList tags = tagHistory.getElementsByTagNameNS(AUDIT_NAMESPACE_URI, "Tag"); + if (tags.getLength() == 0) { + continue; + } + String tagId = ((Element) tags.item(0)).getAttribute("id"); + String username = Optional.ofNullable(getFirstElementContentNS(tagHistory, "Username")).orElse(""); + if (tagId != null && !tagId.isBlank()) { + lastTagUsernames.put(tagId, username); + } + } + return lastTagUsernames; + } + private String getTagValue(Element tagElement) { NodeList valueNodes = tagElement.getElementsByTagNameNS(AUDIT_NAMESPACE_URI, "Value"); if (valueNodes.getLength() > 0) { diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessor.java index 2a059b8380..2f717dd66b 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessor.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessor.java @@ -30,6 +30,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.stream.Collectors; import java.util.zip.ZipFile; import javax.xml.parsers.DocumentBuilder; @@ -85,6 +86,7 @@ private enum SkipReason { REMEDIATION_DATA_INVALID("Remediation data invalid"), REMEDIATION_LINE_RANGE_INVALID("Remediation line range invalid"), SOURCE_CONTEXT_NOT_FOUND("Source context not found"), + SOURCE_CONTEXT_AMBIGUOUS("Source context matched multiple locations"), ORIGINAL_CODE_NOT_FOUND("Original code not found"), REMEDIATION_ENCODE_FAILED("Remediation encode failed"), SOURCE_WRITE_FAILED("Source file write failed"), @@ -211,7 +213,7 @@ private boolean processRemediation(Element remediation, Path sourceBasePath, FVD try { Map pendingWrites = prepareFileChanges(remediation, sourceBasePath, fvdlMetadata); if (pendingWrites.isEmpty()) { - recordSkipped(skippedByReason, SkipReason.NO_CHANGES); + recordSkipped(skippedByReason, SkipReason.NO_CHANGES.displayName); return false; } try { @@ -222,15 +224,15 @@ private boolean processRemediation(Element remediation, Path sourceBasePath, FVD throw new SkipRemediationException(SkipReason.SOURCE_WRITE_FAILED, e.getMessage(), e); } } catch (SkipRemediationException e) { - recordSkipped(skippedByReason, e.reason); - LOG.info("Skipping remediation {}: {}", instanceId, e.getMessage()); + recordSkipped(skippedByReason, skipReasonLabel(e)); + LOG.warn("Skipping remediation {}: {}", instanceId, e.getMessage()); LOG.debug("Skip reason for remediation {}: {}", instanceId, e.reason.displayName, e); return false; } catch (RollbackRemediationException e) { throw e; } catch (Exception e) { - recordSkipped(skippedByReason, SkipReason.UNEXPECTED_ERROR); - LOG.info("Skipping remediation {} due to an unexpected processing error", instanceId); + recordSkipped(skippedByReason, SkipReason.UNEXPECTED_ERROR.displayName); + LOG.warn("Skipping remediation {} due to an unexpected processing error", instanceId); LOG.debug("Unexpected error while processing remediation {}", instanceId, e); return false; } @@ -307,7 +309,8 @@ private String applyChange(String instanceId, String filename, String fileHash, LOG.debug("Remediation {} hash check for '{}': {}", instanceId, filename, fileHashMatches ? "matched" : "mismatched"); if (!fileHashMatches) { LOG.debug("File hash mismatch for remediation {} in {}; searching changed source content", instanceId, filename); - String contextText = getRequiredElementText(change, "Context"); + Element contextElement = getRequiredElement(change, "Context"); + String contextText = contextElement.getTextContent(); List contextLine = Arrays.asList(contextText.split("\\r?\\n")); int contextLineFrom = fuzzySearchContext(instanceId, filename, originalLines, contextLine); if (contextLineFrom == -1) { @@ -320,7 +323,10 @@ private String applyChange(String instanceId, String filename, String fileHash, String originalCodeText = getRequiredElementText(change, "OriginalCode"); List originalCodeLine = Arrays.asList(originalCodeText.split("\\r?\\n")); - int[] lineFromTo = fuzzySearchOriginalCode(instanceId, filename, originalLines, originalCodeLine, contextLineFrom); + int contextBefore = parseRequiredContextAttribute(contextElement, "before"); + int contextAfter = parseRequiredContextAttribute(contextElement, "after"); + int[] lineFromTo = fuzzySearchOriginalCode(instanceId, filename, originalLines, originalCodeLine, + contextLineFrom, contextLine.size(), contextBefore, contextAfter); if (lineFromTo[0] == -1 || lineFromTo[1] == -1) { LOG.debug("Original code search failed for remediation {} in {}; context line={}, original code lines={}, source lines={}", instanceId, filename, contextLineFrom + 1, originalCodeLine.size(), originalLines.size()); @@ -387,7 +393,15 @@ private void rollbackRemediationWrites(String instanceId, List originalLines, List contextLine) { try { - return FuzzyContextSearcher.fuzzySearchContext(originalLines, contextLine, 0); + List matches = FuzzyContextSearcher.fuzzySearchContextMatches(originalLines, contextLine, 0); + if (matches.size() > 1) { + String candidateLines = matches.stream() + .map(line -> String.valueOf(line + 1)) + .collect(Collectors.joining(", ")); + throw new SkipRemediationException(SkipReason.SOURCE_CONTEXT_AMBIGUOUS, + "Source context matched multiple locations in file '" + filename + "'; candidate lines: " + candidateLines); + } + return matches.isEmpty() ? -1 : matches.get(0); } catch (IOException e) { throw new SkipRemediationException(SkipReason.SOURCE_CONTEXT_NOT_FOUND, "Error searching source context for remediation '" + instanceId + "' in file '" + filename + "'", e); @@ -395,8 +409,19 @@ private int fuzzySearchContext(String instanceId, String filename, List } private int[] fuzzySearchOriginalCode(String instanceId, String filename, List originalLines, List originalCodeLine, - int contextLineFrom) { - return FuzzyContextSearcher.fuzzySearchOriginalCode(originalLines, originalCodeLine, 0, contextLineFrom); + int contextLineFrom, int contextLineCount, int contextBefore, int contextAfter) { + int contextStart = contextLineFrom + contextBefore; + int contextEnd = contextLineFrom + contextLineCount - contextAfter; + if (contextStart < 0 || contextStart >= contextEnd || contextEnd > originalLines.size()) { + return new int[] {-1, -1}; + } + + int[] lineFromTo = FuzzyContextSearcher.fuzzySearchOriginalCode( + originalLines.subList(contextStart, contextEnd), originalCodeLine, 0, 0); + if (lineFromTo[0] == -1 || lineFromTo[1] == -1) { + return lineFromTo; + } + return new int[] {lineFromTo[0] + contextStart, lineFromTo[1] + contextStart}; } private boolean isFilePresent(Path path) { @@ -447,12 +472,34 @@ private byte[] encodeSourceFile(String content, Charset charset, String filename } private String getRequiredElementText(Element parent, String elementName) { + return getRequiredElement(parent, elementName).getTextContent(); + } + + private Element getRequiredElement(Element parent, String elementName) { NodeList nodes = parent.getElementsByTagNameNS(NAMESPACE_URI, elementName); if (nodes.getLength() == 0 || nodes.item(0) == null) { throw new SkipRemediationException(SkipReason.REMEDIATION_DATA_INVALID, "Missing required remediation element '" + elementName + "'"); } - return nodes.item(0).getTextContent(); + return (Element) nodes.item(0); + } + + private int parseRequiredContextAttribute(Element context, String attributeName) { + String value = context.getAttribute(attributeName); + if (value == null || value.isBlank()) { + throw new SkipRemediationException(SkipReason.REMEDIATION_DATA_INVALID, + "Missing required remediation context attribute '" + attributeName + "'"); + } + try { + int parsedValue = Integer.parseInt(value); + if (parsedValue < 0) { + throw new NumberFormatException("negative value"); + } + return parsedValue; + } catch (NumberFormatException e) { + throw new SkipRemediationException(SkipReason.REMEDIATION_DATA_INVALID, + "Invalid remediation context attribute '" + attributeName + "': " + value, e); + } } private int parseRequiredInt(Element parent, String elementName) { @@ -517,8 +564,12 @@ private String calculateHashBase64(String content, String algorithm) { } } - private void recordSkipped(Map skippedByReason, SkipReason reason) { - skippedByReason.merge(reason.displayName, 1, Integer::sum); + private void recordSkipped(Map skippedByReason, String reason) { + skippedByReason.merge(reason, 1, Integer::sum); + } + + private String skipReasonLabel(SkipRemediationException exception) { + return exception.reason.displayName; } private String formatSkippedReasons(Map skippedByReason) { diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/Constants.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/Constants.java index 756789a088..00613cd242 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/Constants.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/Constants.java @@ -12,6 +12,9 @@ */ package com.fortify.cli.aviator.util; +import java.util.Locale; +import java.util.Set; + public class Constants { // Audit Result Values @@ -45,8 +48,19 @@ public class Constants { public static final String AUDITOR_STATUS_TAG_ID = "ACB05E55-E74D-468C-8501-52E1FDC27D71"; public static final String FOD_TAG_ID = "604f0fbe-b5fe-47cd-a9cb-587ad8ebe93a"; - // User Names + // User Names written into audit.xml TagHistory / comments public static final String USER_NAME = "Fortify Remediation Aviator"; + public static final String USER_NAME_LEGACY_FORTIFY_AVIATOR = "Fortify Aviator"; + public static final String USER_NAME_LEGACY_CORE_SAST_AVIATOR = "Core SAST Aviator"; + private static final Set AVIATOR_AUDIT_USERNAMES = Set.of( + USER_NAME.toLowerCase(Locale.ROOT), + USER_NAME_LEGACY_FORTIFY_AVIATOR.toLowerCase(Locale.ROOT), + USER_NAME_LEGACY_CORE_SAST_AVIATOR.toLowerCase(Locale.ROOT) + ); + + public static boolean isAviatorAuditUsername(String username) { + return username != null && AVIATOR_AUDIT_USERNAMES.contains(username.trim().toLowerCase(Locale.ROOT)); + } // Other Constants public static final String AUDIT_NAMESPACE_URI = "xmlns://www.fortify.com/schema/audit"; diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FuzzyContextSearcher.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FuzzyContextSearcher.java index 5b3ff71736..4a4b8e6dd5 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FuzzyContextSearcher.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FuzzyContextSearcher.java @@ -28,58 +28,68 @@ public class FuzzyContextSearcher { */ public static int fuzzySearchContext(List sourceLines, List contextLines, int maxMismatches) throws IOException { + List matches = fuzzySearchContextMatches(sourceLines, contextLines, maxMismatches); + return matches.isEmpty() ? -1 : matches.get(0); + } + + public static List fuzzySearchContextMatches(List sourceLines, List contextLines, + int maxMismatches) throws IOException { List normalizedSource = normalizeLines(sourceLines); List normalizedContext = normalizeLines(contextLines); + List matches = new ArrayList<>(); + boolean contextStartsWithBlank = !normalizedContext.isEmpty() && normalizedContext.get(0).isEmpty(); for (int i = 0; i < normalizedSource.size(); i++) { - int mismatchCount = 0; - int sourceIndex = i; - int contextIndex = 0; - boolean similar = false; + if (isUnusableContextStart(normalizedSource, i, contextStartsWithBlank)) { + continue; + } + Integer matchStart = findContextMatchStart(normalizedSource, normalizedContext, maxMismatches, i); + if (matchStart != null && !matches.contains(matchStart)) { + matches.add(matchStart); + } + } - while (contextIndex < normalizedContext.size() && sourceIndex < normalizedSource.size()) { - String contextLine = normalizedContext.get(contextIndex).trim(); + return List.copyOf(matches); + } - if (contextLine.isEmpty()) { - contextIndex++; // Skip empty context lines - continue; - } + private static boolean isUnusableContextStart(List normalizedSource, int index, boolean contextStartsWithBlank) { + boolean sourceStartsWithBlank = normalizedSource.get(index).isEmpty(); + if (!contextStartsWithBlank) { + return sourceStartsWithBlank; + } + return !sourceStartsWithBlank || (index > 0 && normalizedSource.get(index - 1).isEmpty()); + } - // Skip empty source lines too - String sourceLine = normalizedSource.get(sourceIndex).trim(); - while (sourceLine.isEmpty()) { - sourceIndex++; - if (sourceIndex >= normalizedSource.size()) { - break; - } - sourceLine = normalizedSource.get(sourceIndex).trim(); - if(!similar) - i = sourceIndex; - } + private static Integer findContextMatchStart(List normalizedSource, List normalizedContext, + int maxMismatches, int startIndex) { + int mismatchCount = 0; + int sourceIndex = startIndex; + int contextIndex = 0; - if (sourceIndex >= normalizedSource.size()) { - break; // No more source lines to match - } + while (contextIndex < normalizedContext.size() && sourceIndex < normalizedSource.size()) { + String contextLine = normalizedContext.get(contextIndex); + if (contextLine.isEmpty()) { + contextIndex++; + continue; + } - similar = linesSimilar(sourceLine, contextLine); + sourceIndex = skipEmptySourceLines(normalizedSource, sourceIndex); + if (sourceIndex >= normalizedSource.size()) { + break; + } - if (!similar) { - mismatchCount++; - if (mismatchCount > maxMismatches) { - break; - } + if (!linesSimilar(normalizedSource.get(sourceIndex), contextLine)) { + mismatchCount++; + if (mismatchCount > maxMismatches) { + break; } - - sourceIndex++; - contextIndex++; } - if (contextIndex == normalizedContext.size() && mismatchCount <= maxMismatches) { - return i; // Found approximate match starting at i (ignoring blanks) - } + sourceIndex++; + contextIndex++; } - return -1; // Not found + return contextIndex == normalizedContext.size() && mismatchCount <= maxMismatches ? startIndex : null; } public static int[] fuzzySearchOriginalCode(List sourceLines, List originalCodeLine, int maxMismatches, int startIndex) { @@ -143,7 +153,7 @@ private static int skipEmptySourceLines(List normalizedSource, int sourc private static List normalizeLines(List lines) { List result = new ArrayList<>(); for (String line : lines) { - result.add(line.trim().replaceAll("\\s+", " ")); + result.add(line.trim().replaceAll("\\s+", " ")); } return result; } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/StringUtil.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/StringUtil.java index adf83ec46e..e92f131f91 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/StringUtil.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/StringUtil.java @@ -23,6 +23,17 @@ public static boolean isEmpty(String test) { return test == null || test.length() == 0; } + public static boolean isPendingReviewValue(String value) { + if (value == null) { + return true; + } + String normalizedValue = value.trim(); + return normalizedValue.isEmpty() + || "Pending Review".equalsIgnoreCase(normalizedValue) + || "Not Set".equalsIgnoreCase(normalizedValue) + || Constants.PENDING_REVIEW.equalsIgnoreCase(normalizedValue); + } + /** * Strips HTML-like tags from a string to clean it up for display. * diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/IssueAuditorTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/IssueAuditorTest.java index 023d2d5bc5..145dc6bddf 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/IssueAuditorTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/IssueAuditorTest.java @@ -25,6 +25,8 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentLinkedDeque; import java.util.stream.Collectors; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -33,19 +35,31 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import com.fortify.cli.aviator.audit.model.AuditFprOptions; import com.fortify.cli.aviator.audit.model.FilterSelection; +import com.fortify.cli.aviator.audit.model.UserPrompt; import com.fortify.cli.aviator.config.IAviatorLogger; import com.fortify.cli.aviator.fpr.Vulnerability; import com.fortify.cli.aviator.fpr.filter.Filter; import com.fortify.cli.aviator.fpr.filter.FilterSet; import com.fortify.cli.aviator.fpr.filter.FilterTemplate; +import com.fortify.cli.aviator.fpr.model.AuditIssue; import com.fortify.cli.aviator.fpr.model.FPRInfo; import com.fortify.cli.aviator.fpr.model.FVDLMetadata; +import com.fortify.cli.aviator.util.Constants; import com.fortify.cli.aviator.util.FprHandle; class IssueAuditorTest { + private static final String TEST_ISSUE_ID = "PROCESSED_ISSUE"; + private static final IAviatorLogger NO_OP_LOGGER = new IAviatorLogger() { + @Override public void progress(String format, Object... args) {} + @Override public void info(String format, Object... args) {} + @Override public void warn(String format, Object... args) {} + @Override public void error(String format, Object... args) {} + }; + private Path tempFprFile; private FprHandle fprHandle; @@ -113,6 +127,202 @@ void testFprInfoMissingBuildIdDefaultsToEmptyString() throws Exception { assertEquals("", fprInfo.getBuildId()); } + @Test + void skipsPreviouslyProcessedAviatorIssueWithoutForceReaudit() throws Exception { + IssueAuditor auditor = createIssueAuditor(false, false, + Map.of(Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR)); + + assertTrue(prepareIssueIds(auditor).isEmpty()); + } + + @Test + void forceReauditIncludesProcessedAviatorIssueAndIgnoresAviatorOutcomeTag() throws Exception { + IssueAuditor auditor = createIssueAuditor(true, false, Map.of( + Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR, + Constants.AVIATOR_EXPECTED_OUTCOME_TAG_ID, Constants.EXPLOITABLE)); + + assertEquals(List.of(TEST_ISSUE_ID), prepareIssueIds(auditor)); + } + + @Test + void forceReauditIncludesProcessedAviatorIssueWithAnalysisTag() throws Exception { + IssueAuditor auditor = createIssueAuditor(true, false, Map.of( + Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR, + Constants.ANALYSIS_TAG_ID, Constants.EXPLOITABLE)); + + assertEquals(List.of(TEST_ISSUE_ID), prepareIssueIds(auditor)); + } + + @Test + void forceReauditStillSkipsSuppressedIssue() throws Exception { + IssueAuditor auditor = createIssueAuditor(true, true, + Map.of(Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR)); + + assertTrue(prepareIssueIds(auditor).isEmpty()); + } + + @Test + void forceReauditStillSkipsHumanTriagedIssue() throws Exception { + IssueAuditor auditor = createIssueAuditor(true, false, Map.of( + Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR, + Constants.FOD_TAG_ID, Constants.EXPLOITABLE)); + + assertTrue(prepareIssueIds(auditor).isEmpty()); + } + + @Test + void forceReauditIncludesIssueWithPendingReviewState() throws Exception { + IssueAuditor auditor = createIssueAuditor(true, false, Map.of( + Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR, + Constants.FOD_TAG_ID, "Pending Review", + Constants.AUDITOR_STATUS_TAG_ID, Constants.PENDING_REVIEW)); + + assertEquals(List.of(TEST_ISSUE_ID), prepareIssueIds(auditor)); + } + + @Test + void treatsAllPendingAnalysisValuesAsUnaudited() throws Exception { + for (String pendingValue : List.of("Pending Review", "Not Set", "Pending Review/Not Set", " pending review ")) { + IssueAuditor auditor = createIssueAuditor(false, false, Map.of(Constants.ANALYSIS_TAG_ID, pendingValue)); + + assertEquals(List.of(TEST_ISSUE_ID), prepareIssueIds(auditor), pendingValue); + } + } + + @Test + void forceReauditStillSkipsIssueWithManualAuditorStatus() throws Exception { + IssueAuditor auditor = createIssueAuditor(true, false, Map.of( + Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR, + Constants.AUDITOR_STATUS_TAG_ID, Constants.EXPLOITABLE), + Map.of(Constants.AUDITOR_STATUS_TAG_ID, "analyst.user")); + + assertTrue(prepareIssueIds(auditor).isEmpty()); + } + + @Test + void forceReauditIncludesAviatorWrittenAuditorStatus() throws Exception { + IssueAuditor auditor = createIssueAuditor(true, false, Map.of( + Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR, + Constants.AUDITOR_STATUS_TAG_ID, Constants.EXPLOITABLE), + Map.of(Constants.AUDITOR_STATUS_TAG_ID, Constants.USER_NAME)); + + assertEquals(List.of(TEST_ISSUE_ID), prepareIssueIds(auditor)); + } + + @Test + void forceReauditSkipsHumanAnalysisOverrideAfterAviator() throws Exception { + IssueAuditor auditor = createIssueAuditor(true, false, Map.of( + Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR, + Constants.ANALYSIS_TAG_ID, Constants.EXPLOITABLE), + Map.of(Constants.ANALYSIS_TAG_ID, "analyst.user")); + + assertTrue(prepareIssueIds(auditor).isEmpty()); + } + + @Test + void forceReauditIncludesLegacyFortifyAviatorUsername() throws Exception { + IssueAuditor auditor = createIssueAuditor(true, false, Map.of( + Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR, + Constants.ANALYSIS_TAG_ID, Constants.EXPLOITABLE), + Map.of(Constants.ANALYSIS_TAG_ID, Constants.USER_NAME_LEGACY_FORTIFY_AVIATOR)); + + assertEquals(List.of(TEST_ISSUE_ID), prepareIssueIds(auditor)); + } + + @Test + void forceReauditIncludesMappedTagWrittenByAviatorWithoutStatusTag() throws Exception { + Path mappingFile = writeMappedTagFile(); + try { + IssueAuditor auditor = createIssueAuditor(true, false, + Map.of("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", Constants.EXPLOITABLE), + Map.of("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", Constants.USER_NAME), + mappingFile.toString()); + assertEquals(List.of(TEST_ISSUE_ID), prepareIssueIds(auditor)); + } finally { + Files.deleteIfExists(mappingFile); + } + } + + @Test + void forceReauditSkipsHumanWriterOnMappedTag() throws Exception { + Path mappingFile = writeMappedTagFile(); + try { + IssueAuditor auditor = createIssueAuditor(true, false, + Map.of("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", Constants.EXPLOITABLE), + Map.of("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "analyst.user"), + mappingFile.toString()); + assertTrue(prepareIssueIds(auditor).isEmpty()); + } finally { + Files.deleteIfExists(mappingFile); + } + } + + private Path writeMappedTagFile() throws IOException { + Path mappingFile = Files.createTempFile("tag-mapping", ".yaml"); + Files.writeString(mappingFile, "tag_id: \"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\"\n"); + return mappingFile; + } + + private IssueAuditor createIssueAuditor(boolean forceReaudit, boolean suppressed, Map tags) { + return createIssueAuditor(forceReaudit, suppressed, tags, Map.of(), null); + } + + private IssueAuditor createIssueAuditor(boolean forceReaudit, boolean suppressed, Map tags, + Map lastTagUsernames) { + return createIssueAuditor(forceReaudit, suppressed, tags, lastTagUsernames, null); + } + + private IssueAuditor createIssueAuditor(boolean forceReaudit, boolean suppressed, Map tags, + Map lastTagUsernames, String tagMappingPath) { + FPRInfo fprInfo = new FPRInfo(fprHandle); + FilterTemplate filterTemplate = new FilterTemplate(); + filterTemplate.setTagDefinitions(new ArrayList<>()); + fprInfo.setFilterTemplate(filterTemplate); + + Vulnerability vulnerability = new Vulnerability(); + vulnerability.setInstanceID(TEST_ISSUE_ID); + AuditIssue auditIssue = AuditIssue.builder() + .instanceId(TEST_ISSUE_ID) + .suppressed(suppressed) + .tags(new HashMap<>(tags)) + .lastTagUsernames(new HashMap<>(lastTagUsernames)) + .build(); + + return IssueAuditor.builder() + .vulnerabilities(List.of(vulnerability)) + .auditIssueMap(Map.of(TEST_ISSUE_ID, auditIssue)) + .fprInfo(fprInfo) + .filterSelection(new FilterSelection(null, null)) + .sourceLanguageResolver(new SourceLanguageResolver(new FVDLMetadata())) + .options(auditOptions(forceReaudit, NO_OP_LOGGER, tagMappingPath)) + .build(); + } + + private AuditFprOptions auditOptions(boolean forceReaudit, IAviatorLogger logger) { + return auditOptions(forceReaudit, logger, null); + } + + private AuditFprOptions auditOptions(boolean forceReaudit, IAviatorLogger logger, String tagMappingPath) { + return AuditFprOptions.builder() + .fprHandle(fprHandle) + .logger(logger) + .sscAppName("TestApp") + .sscAppVersion("1.0") + .tagMappingPath(tagMappingPath) + .forceReaudit(forceReaudit) + .build(); + } + + @SuppressWarnings("unchecked") + private List prepareIssueIds(IssueAuditor auditor) throws Exception { + Method prepareMethod = IssueAuditor.class.getDeclaredMethod("prepareAndFilterPrompts"); + prepareMethod.setAccessible(true); + ConcurrentLinkedDeque prompts = (ConcurrentLinkedDeque) prepareMethod.invoke(auditor); + return prompts.stream() + .map(prompt -> prompt.getIssueData().getInstanceID()) + .collect(Collectors.toList()); + } + @Test void testFilterVulnerabilities_LegacySyntaxWithSpaces() throws Exception { @@ -159,11 +369,14 @@ void testFilterVulnerabilities_LegacySyntaxWithSpaces() throws Exception { List inputList = Arrays.asList(targetVuln, hiddenVuln); - IssueAuditor auditor = new IssueAuditor( - inputList, null, new HashMap<>(), fprInfo, - "TestApp", "1.0", selection, dummyLogger, null, - new SourceLanguageResolver(new FVDLMetadata()) - ); + IssueAuditor auditor = IssueAuditor.builder() + .vulnerabilities(inputList) + .auditIssueMap(new HashMap<>()) + .fprInfo(fprInfo) + .filterSelection(selection) + .sourceLanguageResolver(new SourceLanguageResolver(new FVDLMetadata())) + .options(auditOptions(false, dummyLogger)) + .build(); Method filterMethod = IssueAuditor.class.getDeclaredMethod("filterVulnerabilities", List.class, FilterSet.class); filterMethod.setAccessible(true); @@ -172,8 +385,8 @@ void testFilterVulnerabilities_LegacySyntaxWithSpaces() throws Exception { List results = (List) filterMethod.invoke(auditor, inputList, filterSet); List remainingIds = results.stream() - .map(Vulnerability::getInstanceID) - .collect(Collectors.toList()); + .map(Vulnerability::getInstanceID) + .collect(Collectors.toList()); assertEquals(1, remainingIds.size(), "Should verify that exactly one issue remains"); assertTrue(remainingIds.contains("TARGET_ISSUE"), diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/FPRProcessorTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/FPRProcessorTest.java index 7b7a94f007..61b5734f54 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/FPRProcessorTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/FPRProcessorTest.java @@ -128,6 +128,20 @@ void testProcessDoesNotMarkPendingReviewDefaultAuditorStatusAsAudited() throws E assertFalse(vulnerability.isAudited()); } + @Test + void testProcessTreatsAllPendingAnalysisValuesAsUnaudited() throws Exception { + createTestFpr(minimalAuditFvdl()); + + for (String pendingValue : List.of("Pending Review", "Not Set", "Pending Review/Not Set", " pending review ")) { + AuditIssue auditIssue = AuditIssue.builder() + .instanceId("instance-1") + .tags(Map.of(Constants.ANALYSIS_TAG_ID, pendingValue)) + .build(); + + assertFalse(processSingleVulnerability(auditIssue).isAudited(), pendingValue); + } + } + private String minimalAuditFvdl() { return """ diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/AuditProcessorLastTagUsernamesTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/AuditProcessorLastTagUsernamesTest.java new file mode 100644 index 0000000000..53a830524c --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/AuditProcessorLastTagUsernamesTest.java @@ -0,0 +1,129 @@ +/* + * 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.fpr.processor; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.fortify.cli.aviator.fpr.model.AuditIssue; +import com.fortify.cli.aviator.util.Constants; +import com.fortify.cli.aviator.util.FprHandle; + +class AuditProcessorLastTagUsernamesTest { + private static final String INSTANCE_ID = "ISSUE-1"; + + @TempDir + Path tempDir; + + private FprHandle fprHandle; + + @AfterEach + void tearDown() throws Exception { + if (fprHandle != null) { + fprHandle.close(); + } + } + + @Test + void lastDocumentOrderTagHistoryWinsPerTag() throws Exception { + fprHandle = new FprHandle(createTestFpr(""" + + + + + + Exploitable + + + Exploitable + + + + Not an Issue + + %s + + + + Exploitable + + analyst.user + + + + Exploitable + + %s + + + + + """.formatted( + Constants.ANALYSIS_TAG_ID, Constants.AUDITOR_STATUS_TAG_ID, + Constants.ANALYSIS_TAG_ID, Constants.USER_NAME, + Constants.ANALYSIS_TAG_ID, + Constants.AUDITOR_STATUS_TAG_ID, Constants.USER_NAME_LEGACY_FORTIFY_AVIATOR))); + + Map issues = new AuditProcessor(fprHandle).processAuditXML(); + Map lastTagUsernames = issues.get(INSTANCE_ID).getLastTagUsernames(); + + assertEquals("analyst.user", lastTagUsernames.get(Constants.ANALYSIS_TAG_ID)); + assertEquals(Constants.USER_NAME_LEGACY_FORTIFY_AVIATOR, lastTagUsernames.get(Constants.AUDITOR_STATUS_TAG_ID)); + } + + @Test + void missingUsernameIsStoredAsEmptyString() throws Exception { + fprHandle = new FprHandle(createTestFpr(""" + + + + + + + Exploitable + + + + + + """.formatted(Constants.ANALYSIS_TAG_ID))); + + Map issues = new AuditProcessor(fprHandle).processAuditXML(); + + assertEquals("", issues.get(INSTANCE_ID).getLastTagUsernames().get(Constants.ANALYSIS_TAG_ID)); + } + + private Path createTestFpr(String auditXml) throws Exception { + Path fprPath = Files.createTempFile(tempDir, "audit-processor", ".fpr"); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(fprPath))) { + zipOutputStream.putNextEntry(new ZipEntry("audit.xml")); + zipOutputStream.write(auditXml.getBytes(StandardCharsets.UTF_8)); + zipOutputStream.closeEntry(); + + zipOutputStream.putNextEntry(new ZipEntry("src-archive/index.xml")); + zipOutputStream.write("".getBytes(StandardCharsets.UTF_8)); + zipOutputStream.closeEntry(); + } + return fprPath; + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorTest.java new file mode 100644 index 0000000000..fe171bb4b7 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorTest.java @@ -0,0 +1,145 @@ +/* + * 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.fpr.processor; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.fortify.cli.aviator.util.FprHandle; + +class RemediationProcessorTest { + private static final String REMEDIATIONS_NAMESPACE = "xmlns://www.fortify.com/schema/remediations"; + + @TempDir + Path tempDir; + + @Test + void skipsAmbiguousContextWithoutChangingSource() throws Exception { + String originalSource = "before\nTARGET\nafter\nbefore\nTARGET\nafter\n"; + Path sourceFile = writeSourceFile(originalSource); + Path fprPath = createRemediationFpr(2, 2, 1, 1, "before\ntarget\nafter", "TARGET", "REPLACED"); + + RemediationProcessor.RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(1, metric.totalRemediations()); + assertEquals(0, metric.appliedRemediations()); + assertEquals(1, metric.skippedRemediations()); + assertEquals(Map.of( + "Source context matched multiple locations", 1), + metric.skippedByReason()); + assertEquals(originalSource, Files.readString(sourceFile)); + } + + @Test + void appliesRemediationWhenContextMatchesOnce() throws Exception { + Path sourceFile = writeSourceFile("before\nTARGET\nafter\n"); + Path fprPath = createRemediationFpr(2, 2, 1, 1, "before\ntarget\nafter", "TARGET", "REPLACED"); + + RemediationProcessor.RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(1, metric.appliedRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals("before\nREPLACED\nafter\n", Files.readString(sourceFile)); + } + + @Test + void appliesOriginalCodeAfterLeadingContextLines() throws Exception { + Path sourceFile = writeSourceFile("TARGET\nkeep\nTARGET\nafter\n"); + Path fprPath = createRemediationFpr(3, 3, 2, 1, "TARGET\nkeep\nTARGET\nafter", "TARGET", "REPLACED"); + + RemediationProcessor.RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(1, metric.appliedRemediations()); + assertEquals("TARGET\nkeep\nREPLACED\nafter\n", Files.readString(sourceFile)); + } + + @Test + void appliesRemediationWhenContextStartsWithBlankLine() throws Exception { + Path sourceFile = writeSourceFile("header\n\nTARGET\nafter\n"); + Path fprPath = createRemediationFpr(3, 3, 1, 1, "\nTARGET\nafter", "TARGET", "REPLACED"); + + RemediationProcessor.RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(1, metric.appliedRemediations()); + assertEquals("header\n\nREPLACED\nafter\n", Files.readString(sourceFile)); + } + + private Path writeSourceFile(String content) throws Exception { + Path sourceFile = tempDir.resolve("Example.java"); + Files.writeString(sourceFile, content, StandardCharsets.UTF_8); + return sourceFile; + } + + private Path createRemediationFpr(String context, String originalCode, String newCode) throws Exception { + return createRemediationFpr(2, 2, 1, 1, context, originalCode, newCode); + } + + private Path createRemediationFpr(int lineFrom, int lineTo, int contextBefore, int contextAfter, + String context, String originalCode, String newCode) throws Exception { + Path fprPath = tempDir.resolve("remediation.fpr"); + String remediationXml = """ + + + + test + 2026-08-26T00:00:00Z + + + + test + + Example.java + not-the-source-hash + + %d + %d + %s + %s + %s + + + + + + """.formatted(REMEDIATIONS_NAMESPACE, lineFrom, lineTo, contextBefore, contextAfter, + context, originalCode, newCode); + + try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(fprPath))) { + zipOutputStream.putNextEntry(new ZipEntry("remediations.xml")); + zipOutputStream.write(remediationXml.getBytes(StandardCharsets.UTF_8)); + zipOutputStream.closeEntry(); + } + return fprPath; + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/util/FuzzyContextSearcherTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/util/FuzzyContextSearcherTest.java index 22d662833b..5e8cc165eb 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/util/FuzzyContextSearcherTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/util/FuzzyContextSearcherTest.java @@ -13,6 +13,7 @@ package com.fortify.cli.aviator.util; import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; import java.util.List; @@ -41,4 +42,54 @@ void shouldMatchOriginalCodeAcrossBlankSourceLines() { assertArrayEquals(new int[] {0, 3}, lineFromTo); } + + @Test + void shouldReturnAllMatchingContextStartLines() throws Exception { + List matches = FuzzyContextSearcher.fuzzySearchContextMatches( + List.of("before", "target", "after", "target", "after"), + List.of("target", "after"), + 0); + + assertEquals(List.of(1, 3), matches); + } + + @Test + void shouldReturnOneMatchingContextStartLine() throws Exception { + List matches = FuzzyContextSearcher.fuzzySearchContextMatches( + List.of("before", "target", "after"), + List.of("target", "after"), + 0); + + assertEquals(List.of(1), matches); + } + + @Test + void shouldReturnNoMatchingContextStartLines() throws Exception { + List matches = FuzzyContextSearcher.fuzzySearchContextMatches( + List.of("before", "after"), + List.of("target", "after"), + 0); + + assertEquals(List.of(), matches); + } + + @Test + void shouldKeepPhysicalStartWhenContextBeginsWithBlankLine() throws Exception { + List matches = FuzzyContextSearcher.fuzzySearchContextMatches( + List.of("header", "", "target", "after"), + List.of("", "target", "after"), + 0); + + assertEquals(List.of(1), matches); + } + + @Test + void shouldMatchContextAcrossBlankSourceLines() throws Exception { + List matches = FuzzyContextSearcher.fuzzySearchContextMatches( + List.of("header", "target", "", "", "after", "target", "", "after"), + List.of("target", "after"), + 0); + + assertEquals(List.of(1, 5), matches); + } } \ 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 5a0c876ef9..f0c56048b1 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 @@ -80,6 +80,7 @@ public class AviatorSSCAuditCommand extends AbstractSSCJsonNodeOutputCommand imp @ArgGroup(exclusive = true, multiplicity = "0..1") private QuotaHandlingArgGroup quotaHandlingArgGroup = new QuotaHandlingArgGroup(); @Option(names = {"--test-exceeding-quota"}) private boolean testExceedingQuota; @Option(names = {"--default-quota-fallback"}) private boolean defaultQuotaFallback; + @Option(names = {"--force-reaudit"}) private boolean forceReaudit; @Mixin private SourceEncodingsMixin sourceEncodingsMixin; private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCAuditCommand.class); private Long checkedQuotaBefore; @@ -102,7 +103,8 @@ public JsonNode getJsonNode(UnirestInstance unirest) { refreshMetricsIfNeeded(unirest, av, logger); - long auditableIssueCount = AviatorSSCAuditHelper.getAuditableIssueCount(unirest, av, logger, isNoFilterSet(), getFilterSetTitleOrId(), folderNames); + long auditableIssueCount = AviatorSSCAuditHelper.getAuditableIssueCount( + unirest, av, logger, isNoFilterSet(), getFilterSetTitleOrId(), folderNames, forceReaudit); if (auditableIssueCount == 0) { logger.progress("Audit skipped - no auditable issues found matching the specified filters."); ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "SKIPPED"); @@ -238,7 +240,7 @@ private JsonNode evaluateQuota(UnirestInstance unirest, SSCAppVersionDescriptor logger.progress("Warning: Could not retrieve quota for '%s', proceeding with audit.", effectiveAppName); } else if (availableQuota >= 0 && auditableIssueCount > availableQuota) { checkedQuotaBefore = availableQuota; - var topCategories = AviatorSSCAuditHelper.getTopUnauditedCategories(unirest, av, logger, 10); + var topCategories = AviatorSSCAuditHelper.getTopUnauditedCategories(unirest, av, logger, 10, forceReaudit); String detailedMessage = AviatorSSCAuditHelper.formatQuotaExceededMessage( av, auditableIssueCount, availableQuota, topCategories); LOG.info(detailedMessage); @@ -277,6 +279,7 @@ private JsonNode processFpr(UnirestInstance unirest, SSCAppVersionDescriptor av, .noFilterSet(isNoFilterSet()) .folderNames(folderNames) .folderPriorityOrder(getFolderPriorityOrder()) + .forceReaudit(forceReaudit) .sourceDecoder(sourceEncodingsMixin.getSourceDecoder()) .build()); } catch (Exception e) { 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 e9ce322887..bf0be50613 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 @@ -213,69 +213,75 @@ public static String getProgressMessage(FPRAuditResult auditResult) { } /** - * Queries SSC to get the number of auditable issues for a given application version. + * Coarse SSC download gate. Force re-audit counts Aviator-processed issues even when + * SSC marks them audited; suppressed issues and human-audited issues Aviator never + * processed stay excluded. Last TagHistory writer is applied later from the FPR. */ - public static long getAuditableIssueCount(UnirestInstance unirest, SSCAppVersionDescriptor av, AviatorLoggerImpl logger, boolean noFilterSet, String filterSetTitleOrId, List folderNames) { + public static long getAuditableIssueCount(UnirestInstance unirest, SSCAppVersionDescriptor av, AviatorLoggerImpl logger, + boolean noFilterSet, String filterSetTitleOrId, List folderNames, boolean forceReaudit) { logger.progress("Status: Checking for auditable issues..."); + LOG.debug("Starting auditable issue count for SSC version {} (application='{}', version='{}') with pageLimit={}, noFilterSet={}, forceReaudit={}", + av.getVersionId(), av.getApplicationName(), av.getVersionName(), PAGE_LIMIT, noFilterSet, forceReaudit); - LOG.debug("Starting auditable issue count for SSC version {} (application='{}', version='{}') with pageLimit={}, noFilterSet={}", - av.getVersionId(), av.getApplicationName(), av.getVersionName(), PAGE_LIMIT, noFilterSet); + AuditableIssueCountQuery query = resolveAuditableIssueCountQuery( + unirest, av, logger, noFilterSet, filterSetTitleOrId, folderNames, forceReaudit); + return countEligibleIssues(query, logger); + } + + private record AuditableIssueCountQuery(UnirestInstance unirest, SSCAppVersionDescriptor av, boolean noFilterSet, + String filterSetGuid, String folderFilter, boolean forceReaudit) {} + private static AuditableIssueCountQuery resolveAuditableIssueCountQuery(UnirestInstance unirest, SSCAppVersionDescriptor av, + AviatorLoggerImpl logger, boolean noFilterSet, String filterSetTitleOrId, List folderNames, + boolean forceReaudit) { String effectiveFilterSetTitleOrId = filterSetTitleOrId != null && !filterSetTitleOrId.isBlank() ? filterSetTitleOrId : null; boolean effectiveNoFilterSet = noFilterSet && effectiveFilterSetTitleOrId == null; + SSCIssueFilterSetDescriptor filterSetDescriptor = resolveFilterSet( + unirest, av, logger, effectiveNoFilterSet, effectiveFilterSetTitleOrId); + String filterSetGuid = filterSetDescriptor != null ? filterSetDescriptor.getGuid() : null; + String folderFilter = resolveFolderFilter(logger, av, effectiveNoFilterSet, filterSetDescriptor, folderNames); + return new AuditableIssueCountQuery(unirest, av, effectiveNoFilterSet, filterSetGuid, folderFilter, forceReaudit); + } - // Apply filter set if specified - SSCIssueFilterSetDescriptor filterSetDescriptor = null; - String filterSetGuid = null; - if (!effectiveNoFilterSet) { - SSCIssueFilterSetHelper filterSetHelper = new SSCIssueFilterSetHelper(unirest, av.getVersionId()); - filterSetDescriptor = filterSetHelper.getDescriptorByTitleOrId(effectiveFilterSetTitleOrId, false); - if (filterSetDescriptor != null) { - logger.progress("Status: Applying filter set '%s' for issue count check", filterSetDescriptor.getTitle()); - filterSetGuid = filterSetDescriptor.getGuid(); - LOG.debug("Applied SSC filter set '{}' with guid {} while counting auditable issues for version {}", - filterSetDescriptor.getTitle(), filterSetGuid, av.getVersionId()); - } else { - LOG.debug("No SSC filter set resolved from options while counting auditable issues for version {}", - av.getVersionId()); - } + private static SSCIssueFilterSetDescriptor resolveFilterSet(UnirestInstance unirest, SSCAppVersionDescriptor av, + AviatorLoggerImpl logger, boolean noFilterSet, String filterSetTitleOrId) { + if (noFilterSet) { + return null; } - - // Apply folder filter if specified - String folderFilter = null; - if (folderNames != null && !folderNames.isEmpty()) { - folderFilter = getFolderFilter(effectiveNoFilterSet, filterSetDescriptor, folderNames); - logger.progress("Status: Applying folder filter for: %s", String.join(", ", folderNames)); - LOG.debug("Applied folder filter '{}' for folders {} while counting auditable issues for version {}", - folderFilter, folderNames, av.getVersionId()); + SSCIssueFilterSetHelper filterSetHelper = new SSCIssueFilterSetHelper(unirest, av.getVersionId()); + SSCIssueFilterSetDescriptor filterSetDescriptor = filterSetHelper.getDescriptorByTitleOrId(filterSetTitleOrId, false); + if (filterSetDescriptor != null) { + logger.progress("Status: Applying filter set '%s' for issue count check", filterSetDescriptor.getTitle()); + LOG.debug("Applied SSC filter set '{}' with guid {} while counting auditable issues for version {}", + filterSetDescriptor.getTitle(), filterSetDescriptor.getGuid(), av.getVersionId()); + } else { + LOG.debug("No SSC filter set resolved from options while counting auditable issues for version {}", + av.getVersionId()); } + return filterSetDescriptor; + } - long totalAuditableCount = 0; - int start = 0; - long totalFromServer = -1; + private static String resolveFolderFilter(AviatorLoggerImpl logger, SSCAppVersionDescriptor av, boolean noFilterSet, + SSCIssueFilterSetDescriptor filterSetDescriptor, List folderNames) { + if (folderNames == null || folderNames.isEmpty()) { + return null; + } + String folderFilter = getFolderFilter(noFilterSet, filterSetDescriptor, folderNames); + logger.progress("Status: Applying folder filter for: %s", String.join(", ", folderNames)); + LOG.debug("Applied folder filter '{}' for folders {} while counting auditable issues for version {}", + folderFilter, folderNames, av.getVersionId()); + return folderFilter; + } + private static long countEligibleIssues(AuditableIssueCountQuery query, AviatorLoggerImpl logger) { try { + long totalAuditableCount = 0; + int start = 0; + long totalFromServer = -1; do { - GetRequest request = unirest.get(SSCUrls.PROJECT_VERSION_ISSUES(av.getVersionId())) - .queryString("limit", PAGE_LIMIT) - .queryString("embed", "auditValues") - .queryString("qm", "issues") - .queryString("q", "audited:false") - .queryString("start", start); - if (effectiveNoFilterSet) { - request.queryString("showhidden", "true"); - } - if (filterSetGuid != null) { - request.queryString("filterset", filterSetGuid); - } - if (folderFilter != null) { - request.queryString("filter", folderFilter); - } - LOG.debug("Requesting SSC issues page for version {} with start={} and limit={}", - av.getVersionId(), start, PAGE_LIMIT); - JsonNode response = request.asObject(JsonNode.class).getBody(); + JsonNode response = fetchAuditableIssuesPage(query, start); if (response == null || !response.has("data")) { LOG.warn("Invalid response received from issue check; proceeding with FPR download."); logger.progress("WARN: Invalid response from issue check. Proceeding with FPR download."); @@ -284,27 +290,23 @@ public static long getAuditableIssueCount(UnirestInstance unirest, SSCAppVersion if (totalFromServer == -1) { totalFromServer = response.get("count").asLong(0); LOG.debug("SSC reported {} total issues matching the initial auditable count query for version {}", - totalFromServer, av.getVersionId()); + totalFromServer, query.av().getVersionId()); } - ArrayNode issues = (ArrayNode) response.get("data"); - if (issues != null && !issues.isEmpty()) { - long auditableOnPage = JsonHelper.stream(issues) - .filter(issue -> !isProcessedByAviator(issue)) - .count(); - totalAuditableCount += auditableOnPage; - LOG.debug("Processed SSC issues page for version {}: pageStart={}, pageSize={}, auditableOnPage={}, cumulativeAuditableCount={}, totalFromServer={}", - av.getVersionId(), start, issues.size(), auditableOnPage, totalAuditableCount, totalFromServer); - start += issues.size(); - } else { + if (issues == null || issues.isEmpty()) { LOG.debug("SSC returned no more issues for version {} at start={}; stopping pagination with cumulativeAuditableCount={} and totalFromServer={}", - av.getVersionId(), start, totalAuditableCount, totalFromServer); - break; // No more issues + query.av().getVersionId(), start, totalAuditableCount, totalFromServer); + break; } + long auditableOnPage = countEligibleOnPage(issues, query.forceReaudit()); + totalAuditableCount += auditableOnPage; + LOG.debug("Processed SSC issues page for version {}: pageStart={}, pageSize={}, auditableOnPage={}, cumulativeAuditableCount={}, totalFromServer={}", + query.av().getVersionId(), start, issues.size(), auditableOnPage, totalAuditableCount, totalFromServer); + start += issues.size(); } while (start < totalFromServer); LOG.debug("Completed auditable issue count for version {}: totalAuditableCount={}, totalFromServer={}", - av.getVersionId(), totalAuditableCount, totalFromServer); + query.av().getVersionId(), totalAuditableCount, totalFromServer); logger.progress("Status: Found %d auditable issues.", totalAuditableCount); return totalAuditableCount; } catch (UnexpectedHttpResponseException e) { @@ -314,6 +316,52 @@ public static long getAuditableIssueCount(UnirestInstance unirest, SSCAppVersion } } + private static JsonNode fetchAuditableIssuesPage(AuditableIssueCountQuery query, int start) { + GetRequest request = query.unirest().get(SSCUrls.PROJECT_VERSION_ISSUES(query.av().getVersionId())) + .queryString("limit", PAGE_LIMIT) + .queryString("embed", "auditValues") + .queryString("qm", "issues") + .queryString("start", start); + // Normal audit only needs unaudited issues. Force re-audit must also + // retrieve Aviator-processed issues, which SSC marks audited after Analysis is set. + if (!query.forceReaudit()) { + request.queryString("q", "audited:false"); + } + if (query.noFilterSet()) { + request.queryString("showhidden", "true"); + } + if (query.filterSetGuid() != null) { + request.queryString("filterset", query.filterSetGuid()); + } + if (query.folderFilter() != null) { + request.queryString("filter", query.folderFilter()); + } + LOG.debug("Requesting SSC issues page for version {} with start={} and limit={}", + query.av().getVersionId(), start, PAGE_LIMIT); + return request.asObject(JsonNode.class).getBody(); + } + + private static long countEligibleOnPage(ArrayNode issues, boolean forceReaudit) { + return JsonHelper.stream(issues) + .filter(issue -> isEligibleForRequestedAudit(issue, forceReaudit)) + .count(); + } + + /** + * Force re-audit includes previously processed Aviator issues even when SSC + * already marks them audited. Suppressed issues and human-audited issues that + * Aviator never processed stay out. Normal audit still excludes processed issues. + */ + private static boolean isEligibleForRequestedAudit(JsonNode issue, boolean forceReaudit) { + if (issue.path("suppressed").asBoolean(false)) { + return false; + } + if (isProcessedByAviator(issue)) { + return forceReaudit; + } + return !issue.path("audited").asBoolean(false); + } + /** * Checks if an issue has the 'Aviator status' custom tag set. */ @@ -425,18 +473,17 @@ public static long getDefaultQuota(String aviatorUrl, String aviatorToken, } /** - * Returns the top N SAST categories ordered by truly-unaudited issue count (descending). - * Uses the SSC issueGroups API with: - * - groupingtype = dynamically resolved "Category" GUID - * - filter = dynamically resolved "Aviator status:Not Set" technical filter - * Each entry contains "categoryName" and "unauditedCount". + * Returns the top N SAST categories for the requested audit mode. + * Normal audit ranks by unaudited issues with Aviator status not set. + * Force re-audit ranks by visible issues, including Aviator-processed ones. + * Each entry contains "categoryName" and "unauditedCount" (eligible count for that mode). */ public static List> getTopUnauditedCategories( UnirestInstance unirest, SSCAppVersionDescriptor av, - AviatorLoggerImpl logger, int topN) { + AviatorLoggerImpl logger, int topN, boolean forceReaudit) { try { - return getTopUnauditedCategoriesInternal(unirest, av, logger, topN); + return getTopUnauditedCategoriesInternal(unirest, av, logger, topN, forceReaudit); } catch (Exception e) { LOG.warn("Failed to retrieve top unaudited categories for {}:{}: {}", av.getApplicationName(), av.getVersionName(), e.getMessage()); @@ -447,7 +494,7 @@ public static List> getTopUnauditedCategories( private static List> getTopUnauditedCategoriesInternal( UnirestInstance unirest, SSCAppVersionDescriptor av, - AviatorLoggerImpl logger, int topN) { + AviatorLoggerImpl logger, int topN, boolean forceReaudit) { String versionId = av.getVersionId(); @@ -458,12 +505,14 @@ private static List> getTopUnauditedCategoriesInternal( // Resolve "Aviator status:Not Set" filter dynamically String aviatorStatusFilter = null; - try { - SSCIssueFilterHelper filterHelper = new SSCIssueFilterHelper(unirest, versionId); - aviatorStatusFilter = filterHelper.getFilter("Aviator status:Not Set"); - } catch (FcliSimpleException e) { - // Tag doesn't exist on this version — all issues are unprocessed - LOG.debug("Aviator status tag not found for version {}. All issues considered unprocessed.", versionId); + if (!forceReaudit) { + try { + SSCIssueFilterHelper filterHelper = new SSCIssueFilterHelper(unirest, versionId); + aviatorStatusFilter = filterHelper.getFilter("Aviator status:Not Set"); + } catch (FcliSimpleException e) { + // Tag doesn't exist on this version — all issues are unprocessed + LOG.debug("Aviator status tag not found for version {}. All issues considered unprocessed.", versionId); + } } // Call issueGroups API @@ -479,20 +528,22 @@ private static List> getTopUnauditedCategoriesInternal( JsonNode response = request.asObject(JsonNode.class).getBody(); ArrayNode groups = (ArrayNode) response.get("data"); - // Calculate truly-unaudited count per category and sort descending + // Rank categories by issues eligible for the requested audit mode. List> categories = new ArrayList<>(); if (groups != null) { for (JsonNode group : groups) { String name = group.path("id").asText("Unknown"); long visibleCount = group.path("visibleCount").asLong(0); long auditedCount = group.path("auditedCount").asLong(0); - long unaudited = visibleCount - auditedCount; - if (unaudited > 0) { + // Force re-audit sends Aviator-processed (already audited) issues, so + // visible count is the quota-relevant size for the category. + long eligibleCount = forceReaudit ? visibleCount : visibleCount - auditedCount; + if (eligibleCount > 0) { Map entry = new LinkedHashMap<>(); entry.put("categoryName", name); entry.put("totalIssues", visibleCount); entry.put("auditedIssues", auditedCount); - entry.put("unauditedCount", unaudited); + entry.put("unauditedCount", eligibleCount); categories.add(entry); } } 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 7a2c64f68e..907990c022 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 @@ -128,6 +128,7 @@ fcli.aviator.ssc.audit.folder = Filter issues by a comma-separated list of speci fcli.aviator.ssc.audit.skip-if-exceeding-quota = Skip audit if the number of open issues exceeds the available Fortify Remediation Aviator quota. When skipped, a summary with top unaudited categories is shown. fcli.aviator.ssc.audit.test-exceeding-quota = Check whether the number of open issues exceeds the available Fortify Remediation Aviator quota and report the result without performing an audit. fcli.aviator.ssc.audit.default-quota-fallback = (Internal) When the Fortify Aviator application does not exist, use the tenant default quota instead of reporting app not found. Used by bulk audit. +fcli.aviator.ssc.audit.force-reaudit = Re-audit issues previously processed by Aviator, while preserving suppressed and manually triaged issues. fcli.aviator.ssc.audit.folder-priority-order = Custom priority order for folder-based filtering when quota is exceeded (comma-separated, highest priority first). Example: Critical,High,Medium,Low. If not specified, uses default priority order. fcli.aviator.source-encodings = Comma-separated source encoding candidates to try in order when decoding source files. Use FPR to try the source encoding recorded in audit.fvdl. When writing remediated files, the accepted encoding is used. Default value: ${DEFAULT-VALUE}. fcli.aviator.ssc.audit.refresh = By default, this command will refresh the source application version's metrics when copying from it. \ diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommandTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommandTest.java index d8988e8a86..c1041fd5a7 100644 --- a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommandTest.java +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommandTest.java @@ -17,6 +17,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Collections; import java.util.Map; @@ -52,6 +53,14 @@ void testAllowsNoFilterSetOption() { assertNull(cmd.getFilterSetTitleOrId()); } + @Test + void testAllowsForceReauditOption() throws Exception { + var cmd = parse("--force-reaudit"); + Field field = AviatorSSCAuditCommand.class.getDeclaredField("forceReaudit"); + field.setAccessible(true); + assertTrue((boolean) field.get(cmd)); + } + @Test void reportsDecodeSkippedIssuesAsNotSubmitted() { ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelperTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelperTest.java new file mode 100644 index 0000000000..c1a3f0f612 --- /dev/null +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelperTest.java @@ -0,0 +1,252 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.helper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.aviator.config.AviatorLoggerImpl; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.progress.helper.IProgressWriter; +import com.fortify.cli.common.rest.unirest.UnirestHelper; +import com.fortify.cli.common.rest.unirest.config.UnirestJsonHeaderConfigurer; +import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +import kong.unirest.UnirestInstance; + +class AviatorSSCAuditHelperTest { + + @Test + void excludesPreviouslyProcessedIssuesFromNormalPreflightCount() throws Exception { + try (var server = new TestSscServer(processedIssue(), unprocessedIssue()); + var unirest = newUnirest(server)) { + assertEquals(1, AviatorSSCAuditHelper.getAuditableIssueCount( + unirest, appVersion(), logger(), true, null, null, false)); + assertTrue(server.getLastIssueQuery().contains("q=audited:false")); + } + } + + @Test + void forceReauditPreflightIncludesPreviouslyProcessedIssues() throws Exception { + try (var server = new TestSscServer(processedIssue(), unprocessedIssue()); + var unirest = newUnirest(server)) { + assertEquals(2, AviatorSSCAuditHelper.getAuditableIssueCount( + unirest, appVersion(), logger(), true, null, null, true)); + assertFalse(server.getLastIssueQuery().contains("q=audited:false")); + } + } + + @Test + void forceReauditPreflightDoesNotSkipAnAllProcessedApplicationVersion() throws Exception { + try (var server = new TestSscServer(processedIssue()); + var unirest = newUnirest(server)) { + assertEquals(1, AviatorSSCAuditHelper.getAuditableIssueCount( + unirest, appVersion(), logger(), true, null, null, true)); + assertFalse(server.getLastIssueQuery().contains("q=audited:false")); + } + } + + @Test + void forceReauditPreflightExcludesHumanAuditedIssuesThatAviatorNeverProcessed() throws Exception { + try (var server = new TestSscServer(processedIssue(), unprocessedIssue(), humanAuditedIssue()); + var unirest = newUnirest(server)) { + assertEquals(2, AviatorSSCAuditHelper.getAuditableIssueCount( + unirest, appVersion(), logger(), true, null, null, true)); + assertFalse(server.getLastIssueQuery().contains("q=audited:false")); + } + } + + @Test + void forceReauditPreflightExcludesSuppressedAviatorIssues() throws Exception { + try (var server = new TestSscServer(processedIssue(), suppressedProcessedIssue()); + var unirest = newUnirest(server)) { + assertEquals(1, AviatorSSCAuditHelper.getAuditableIssueCount( + unirest, appVersion(), logger(), true, null, null, true)); + } + } + + @Test + void forceReauditCategoryBreakdownIncludesFullyAuditedCategories() throws Exception { + try (var server = new TestSscServer(processedIssue()); + var unirest = newUnirest(server)) { + List> categories = AviatorSSCAuditHelper.getTopUnauditedCategories( + unirest, appVersion(), logger(), 10, true); + + assertEquals(1, categories.size()); + assertEquals("Category A", categories.get(0).get("categoryName")); + assertEquals(1L, categories.get(0).get("unauditedCount")); + assertEquals(1, server.getSelectorSetRequestCount()); + } + } + + private static SSCAppVersionDescriptor appVersion() { + var descriptor = new SSCAppVersionDescriptor(); + descriptor.setVersionId("42"); + descriptor.setApplicationName("TestApp"); + descriptor.setVersionName("1.0"); + return descriptor; + } + + private static AviatorLoggerImpl logger() { + return new AviatorLoggerImpl(new NoOpProgressWriter()); + } + + private static UnirestInstance newUnirest(TestSscServer server) { + return UnirestHelper.createUnirestInstance(unirest -> { + UnirestJsonHeaderConfigurer.configure(unirest); + unirest.config().defaultBaseUrl(server.getBaseUrl()); + }); + } + + private static ObjectNode processedIssue() { + ObjectNode issue = JsonHelper.getObjectMapper().createObjectNode(); + issue.put("audited", true); + ArrayNode auditValues = issue.putObject("_embed").putArray("auditValues"); + auditValues.addObject() + .put("customTagGuid", AviatorSSCTagDefs.AVIATOR_STATUS_TAG.getGuid()) + .put("customTagIndex", 0); + return issue; + } + + private static ObjectNode unprocessedIssue() { + ObjectNode issue = JsonHelper.getObjectMapper().createObjectNode(); + issue.put("audited", false); + issue.putObject("_embed").putArray("auditValues"); + return issue; + } + + private static ObjectNode humanAuditedIssue() { + ObjectNode issue = JsonHelper.getObjectMapper().createObjectNode(); + issue.put("audited", true); + issue.putObject("_embed").putArray("auditValues"); + return issue; + } + + private static ObjectNode suppressedProcessedIssue() { + ObjectNode issue = processedIssue(); + issue.put("suppressed", true); + return issue; + } + + private static final class TestSscServer implements AutoCloseable { + private final HttpServer server; + private final ArrayNode issues = JsonHelper.getObjectMapper().createArrayNode(); + private int selectorSetRequestCount; + private String lastIssueQuery = ""; + + private TestSscServer(ObjectNode... issueNodes) throws IOException { + for (ObjectNode issue : issueNodes) { + issues.add(issue); + } + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/api/v1/projectVersions/42/issues", this::handleIssues); + server.createContext("/api/v1/projectVersions/42/issueSelectorSet", this::handleSelectorSet); + server.createContext("/api/v1/projectVersions/42/issueGroups", this::handleIssueGroups); + server.start(); + } + + private String getBaseUrl() { + return "http://localhost:" + server.getAddress().getPort(); + } + + private void handleIssues(HttpExchange exchange) throws IOException { + lastIssueQuery = URLDecoder.decode(exchange.getRequestURI().getRawQuery(), StandardCharsets.UTF_8); + ArrayNode matchingIssues = JsonHelper.getObjectMapper().createArrayNode(); + boolean unauditedOnly = lastIssueQuery.contains("q=audited:false"); + for (JsonNode issue : issues) { + if (unauditedOnly && issue.path("audited").asBoolean(false)) { + continue; + } + matchingIssues.add(issue); + } + byte[] response = JsonHelper.getObjectMapper().createObjectNode() + .put("count", matchingIssues.size()) + .set("data", matchingIssues) + .toString() + .getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, response.length); + try (var outputStream = exchange.getResponseBody()) { + outputStream.write(response); + } + } + + private void handleSelectorSet(HttpExchange exchange) throws IOException { + selectorSetRequestCount++; + ObjectNode data = JsonHelper.getObjectMapper().createObjectNode(); + data.putArray("filterBySet"); + data.putArray("groupBySet").addObject() + .put("guid", "category-guid") + .put("displayName", "Category"); + writeJson(exchange, JsonHelper.getObjectMapper().createObjectNode().set("data", data)); + } + + private void handleIssueGroups(HttpExchange exchange) throws IOException { + ObjectNode group = JsonHelper.getObjectMapper().createObjectNode() + .put("id", "Category A") + .put("visibleCount", 1) + .put("auditedCount", 1); + ArrayNode data = JsonHelper.getObjectMapper().createArrayNode().add(group); + writeJson(exchange, JsonHelper.getObjectMapper().createObjectNode().set("data", data)); + } + + private void writeJson(HttpExchange exchange, ObjectNode responseNode) throws IOException { + byte[] response = responseNode.toString().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, response.length); + try (var outputStream = exchange.getResponseBody()) { + outputStream.write(response); + } + } + + private String getLastIssueQuery() { + return lastIssueQuery; + } + + private int getSelectorSetRequestCount() { + return selectorSetRequestCount; + } + + @Override + public void close() { + server.stop(0); + } + } + + private static final class NoOpProgressWriter implements IProgressWriter { + @Override public boolean isMultiLineSupported() { return false; } + @Override public void writeProgress(String message, Object... args) {} + @Override public void writeInfo(String message, Object... args) {} + @Override public void writeInfoWithException(String message, Throwable cause, Object... args) {} + @Override public void writeWarning(String message, Object... args) {} + @Override public void writeWarningWithException(String message, Throwable cause, Object... args) {} + @Override public void clearProgress() {} + @Override public void close() {} + @Override public String type() { return "test"; } + } +}