diff --git a/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/grpc/reflect-config.json b/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/grpc/reflect-config.json index 15669d72f71..b17920fb833 100644 --- a/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/grpc/reflect-config.json +++ b/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/grpc/reflect-config.json @@ -510,7 +510,7 @@ "name":"com.fortify.cli.aviator.config.TagMappingConfig", "allDeclaredFields":true, "queryAllPublicMethods":true, - "methods":[{"name":"","parameterTypes":[] }, {"name":"setMapping","parameterTypes":["com.fortify.cli.aviator.config.TagMappingConfig$Mapping"] }, {"name":"setTag_id","parameterTypes":["java.lang.String"] }] + "methods":[{"name":"","parameterTypes":[] }, {"name":"setMapping","parameterTypes":["com.fortify.cli.aviator.config.TagMappingConfig$Mapping"] }, {"name":"setSuppression_exclusions","parameterTypes":["java.util.List"] }, {"name":"setTag_id","parameterTypes":["java.lang.String"] }] }, { "name":"com.fortify.cli.aviator.config.TagMappingConfig$Mapping", @@ -518,6 +518,23 @@ "queryAllPublicMethods":true, "methods":[{"name":"","parameterTypes":[] }, {"name":"setTier_1","parameterTypes":["com.fortify.cli.aviator.config.TagMappingConfig$Tier"] }, {"name":"setTier_2","parameterTypes":["com.fortify.cli.aviator.config.TagMappingConfig$Tier"] }] }, + { + "name":"com.fortify.cli.aviator.config.TagMappingConfig$SuppressionExclusion", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allPublicFields": true, + "allDeclaredFields":true, + "queryAllPublicMethods":true, + "methods":[{"name":"","parameterTypes":[] }, {"name":"setCategories","parameterTypes":["java.util.List"] }] + }, + { + "name":"com.fortify.cli.aviator.config.TagMappingConfig$SuppressionExclusionBeanInfo" + }, + { + "name":"com.fortify.cli.aviator.config.TagMappingConfig$SuppressionExclusionCustomizer" + }, { "name":"com.fortify.cli.aviator.config.TagMappingConfig$MappingBeanInfo" }, diff --git a/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/yaml/reflect-config.json b/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/yaml/reflect-config.json index ea1f2a7e112..7da7e37c00f 100644 --- a/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/yaml/reflect-config.json +++ b/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/yaml/reflect-config.json @@ -33,7 +33,7 @@ "name":"com.fortify.cli.aviator.config.TagMappingConfig", "allDeclaredFields":true, "queryAllPublicMethods":true, - "methods":[{"name":"","parameterTypes":[] }, {"name":"setMapping","parameterTypes":["com.fortify.cli.aviator.config.TagMappingConfig$Mapping"] }, {"name":"setTag_id","parameterTypes":["java.lang.String"] }] + "methods":[{"name":"","parameterTypes":[] }, {"name":"setMapping","parameterTypes":["com.fortify.cli.aviator.config.TagMappingConfig$Mapping"] }, {"name":"setSuppression_exclusions","parameterTypes":["java.util.List"] }, {"name":"setTag_id","parameterTypes":["java.lang.String"] }] }, { "name":"com.fortify.cli.aviator.config.TagMappingConfig$Mapping", @@ -41,6 +41,23 @@ "queryAllPublicMethods":true, "methods":[{"name":"","parameterTypes":[] }, {"name":"setTier_1","parameterTypes":["com.fortify.cli.aviator.config.TagMappingConfig$Tier"] }, {"name":"setTier_2","parameterTypes":["com.fortify.cli.aviator.config.TagMappingConfig$Tier"] }] }, + { + "name":"com.fortify.cli.aviator.config.TagMappingConfig$SuppressionExclusion", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allPublicFields": true, + "allDeclaredFields":true, + "queryAllPublicMethods":true, + "methods":[{"name":"","parameterTypes":[] }, {"name":"setCategories","parameterTypes":["java.util.List"] }] + }, + { + "name":"com.fortify.cli.aviator.config.TagMappingConfig$SuppressionExclusionBeanInfo" + }, + { + "name":"com.fortify.cli.aviator.config.TagMappingConfig$SuppressionExclusionCustomizer" + }, { "name":"com.fortify.cli.aviator.config.TagMappingConfig$MappingBeanInfo" }, diff --git a/fcli-core/fcli-app/src/test/java/com/fortify/cli/NativeYamlReflectConfigTest.java b/fcli-core/fcli-app/src/test/java/com/fortify/cli/NativeYamlReflectConfigTest.java new file mode 100644 index 00000000000..bcf2dc085ab --- /dev/null +++ b/fcli-core/fcli-app/src/test/java/com/fortify/cli/NativeYamlReflectConfigTest.java @@ -0,0 +1,79 @@ +/* + * 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; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.stream.StreamSupport; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +class NativeYamlReflectConfigTest { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final String TAG_MAPPING_CONFIG_CLASS = "com.fortify.cli.aviator.config.TagMappingConfig"; + private static final List TAG_MAPPING_NESTED_CLASSES = List.of( + "com.fortify.cli.aviator.config.TagMappingConfig$SuppressionExclusion", + "com.fortify.cli.aviator.config.TagMappingConfig$Mapping", + "com.fortify.cli.aviator.config.TagMappingConfig$Tier", + "com.fortify.cli.aviator.config.TagMappingConfig$Result"); + + @ParameterizedTest + @ValueSource(strings = { + "META-INF/native-image/fcli/fcli-app/yaml/reflect-config.json", + "META-INF/native-image/fcli/fcli-app/grpc/reflect-config.json" + }) + void testTagMappingConfigNativeReflectConfigIncludesSuppressionExclusions(String resourcePath) throws Exception { + JsonNode reflectConfig = loadReflectConfig(resourcePath); + JsonNode tagMappingConfigEntry = getReflectConfigEntry(reflectConfig, TAG_MAPPING_CONFIG_CLASS); + + assertTrue(tagMappingConfigEntry.path("allDeclaredFields").asBoolean(), + () -> "Expected allDeclaredFields for " + TAG_MAPPING_CONFIG_CLASS + " in " + resourcePath); + assertTrue(hasMethod(tagMappingConfigEntry, "setSuppression_exclusions"), + () -> "Expected setSuppression_exclusions metadata for " + TAG_MAPPING_CONFIG_CLASS + " in " + resourcePath); + + TAG_MAPPING_NESTED_CLASSES.forEach(className -> assertTrue(hasReflectConfigEntry(reflectConfig, className), + () -> "Expected reflect-config entry for " + className + " in " + resourcePath)); + } + + private JsonNode loadReflectConfig(String resourcePath) throws IOException { + try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(resourcePath)) { + assertNotNull(inputStream, () -> "Missing native reflect-config resource: " + resourcePath); + return OBJECT_MAPPER.readTree(inputStream); + } + } + + private JsonNode getReflectConfigEntry(JsonNode reflectConfig, String className) { + return StreamSupport.stream(reflectConfig.spliterator(), false) + .filter(node -> className.equals(node.path("name").asText())) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing reflect-config entry for " + className)); + } + + private boolean hasReflectConfigEntry(JsonNode reflectConfig, String className) { + return StreamSupport.stream(reflectConfig.spliterator(), false) + .anyMatch(node -> className.equals(node.path("name").asText())); + } + + private boolean hasMethod(JsonNode reflectConfigEntry, String methodName) { + return StreamSupport.stream(reflectConfigEntry.path("methods").spliterator(), false) + .anyMatch(node -> methodName.equals(node.path("name").asText())); + } +} 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 db924e597b3..86d3c2af15f 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 @@ -13,6 +13,7 @@ package com.fortify.cli.aviator.audit; import java.io.File; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -54,6 +55,9 @@ public static FPRAuditResult auditFPR(AuditFprOptions options) // --- STAGE 1: PARSING --- ParsedFprData parsedData = prepareAndParseFpr(options.getFprHandle()); TagMappingConfig tagMappingConfig = loadTagMappingConfig(options.getTagMappingPath()); + Map issueCategoryLookup = tagMappingConfig.requiresCategoryForSuppressionEvaluation() + ? buildIssueCategoryLookup(parsedData.vulnerabilities) + : Map.of(); // --- STAGE 2: FILTER SELECTION (DELEGATED) --- FilterSelection filterSelection = FilterSetSelector.select( @@ -71,7 +75,7 @@ public static FPRAuditResult auditFPR(AuditFprOptions options) // --- STAGE 4: FINALIZATION --- return finalizeFprAudit( auditOutcome, auditResponses, parsedData.auditProcessor, - tagMappingConfig, parsedData.fprInfo + tagMappingConfig, issueCategoryLookup, parsedData.fprInfo ); } @@ -95,13 +99,28 @@ private static ParsedFprData prepareAndParseFpr(FprHandle fprHandle) { } private static TagMappingConfig loadTagMappingConfig(String tagMappingFilePath) { + TagMappingConfig tagMappingConfig; if (tagMappingFilePath != null && !tagMappingFilePath.trim().isEmpty()) { LOG.info("Loading user-provided tag mapping from: {}", tagMappingFilePath); - return ResourceUtil.loadYamlFile(new File(tagMappingFilePath), TagMappingConfig.class); + tagMappingConfig = ResourceUtil.loadYamlFile(new File(tagMappingFilePath), TagMappingConfig.class); } else { LOG.info("Using default tag mapping configuration."); - return AviatorConfigManager.getInstance().getDefaultTagMappingConfig(); + tagMappingConfig = AviatorConfigManager.getInstance().getDefaultTagMappingConfig(); } + + tagMappingConfig.validate(); + return tagMappingConfig; + } + + private static Map buildIssueCategoryLookup(List vulnerabilities) { + Map issueCategoryLookup = new HashMap<>(); + for (Vulnerability vulnerability : vulnerabilities) { + String instanceId = vulnerability.getInstanceID(); + if (instanceId != null && !instanceId.isBlank()) { + issueCategoryLookup.putIfAbsent(instanceId, vulnerability.getCategory()); + } + } + return issueCategoryLookup; } private static AuditOutcome performAviatorAudit( @@ -128,7 +147,7 @@ private static AuditOutcome performAviatorAudit( private static FPRAuditResult finalizeFprAudit( AuditOutcome auditOutcome, Map auditResponses, AuditProcessor auditProcessor, TagMappingConfig tagMappingConfig, - FPRInfo fprInfo) { + Map issueCategoryLookup, FPRInfo fprInfo) { int totalIssuesToAudit = auditOutcome.getTotalIssuesToAudit(); if (auditResponses.isEmpty()) { @@ -168,7 +187,8 @@ private static FPRAuditResult finalizeFprAudit( File updatedFile = null; if (issuesSuccessfullyAudited > 0) { - updatedFile = auditProcessor.updateAndSaveAuditAndRemediationsXml(auditResponses, tagMappingConfig, fprInfo); + updatedFile = auditProcessor.updateAndSaveAuditAndRemediationsXml( + auditResponses, tagMappingConfig, issueCategoryLookup, fprInfo); } LOG.info("FPR audit process completed with status: {}", status); diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/config/TagMappingConfig.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/config/TagMappingConfig.java index 4c4da2b568b..2f21bf4c364 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/config/TagMappingConfig.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/config/TagMappingConfig.java @@ -12,29 +12,261 @@ */ package com.fortify.cli.aviator.config; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; import lombok.Data; @Data @Reflectable public class TagMappingConfig { + public static final String SUPPRESSION_SELECTOR_CATEGORIES = "categories"; + private String tag_id = "87f2364f-dcd4-49e6-861d-f8d3f351686b"; + private List suppression_exclusions = new ArrayList<>(); private Mapping mapping; - @Data + public void setSuppression_exclusions(List suppression_exclusions) { + this.suppression_exclusions = suppression_exclusions == null ? new ArrayList<>() : suppression_exclusions; + } + + public void validate() { + List errors = new ArrayList<>(); + + validateRequired(errors, "mapping", mapping); + if (mapping != null) { + validateTier(errors, "mapping.tier_1", mapping.getTier_1()); + validateTier(errors, "mapping.tier_2", mapping.getTier_2()); + } + + validateSuppressionExclusions(errors); + + if (!errors.isEmpty()) { + throw new AviatorSimpleException("Invalid tag mapping configuration: " + String.join("; ", errors)); + } + + for (SuppressionExclusion exclusion : suppression_exclusions) { + if (exclusion != null) { + exclusion.initNormalized(); + } + } + } + + public boolean hasSuppressionExclusions() { + return suppression_exclusions != null + && suppression_exclusions.stream() + .filter(Objects::nonNull) + .anyMatch(SuppressionExclusion::hasSupportedSelectors); + } + + public boolean requiresCategoryForSuppressionEvaluation() { + return suppression_exclusions != null + && suppression_exclusions.stream() + .filter(Objects::nonNull) + .anyMatch(exclusion -> exclusion.usesSelector(SUPPRESSION_SELECTOR_CATEGORIES)); + } + + public boolean isSuppressionExcluded(SuppressionExclusionContext context) { + if (context == null || suppression_exclusions == null) { + return false; + } + return suppression_exclusions.stream() + .filter(Objects::nonNull) + .anyMatch(exclusion -> exclusion.matches(context)); + } + + private void validateTier(List errors, String path, Tier tier) { + validateRequired(errors, path, tier); + if (tier != null) { + validateRequired(errors, path + ".fp", tier.getFp()); + validateRequired(errors, path + ".tp", tier.getTp()); + validateRequired(errors, path + ".unsure", tier.getUnsure()); + } + } + + private void validateSuppressionExclusions(List errors) { + if (suppression_exclusions == null) { + return; + } + + for (int i = 0; i < suppression_exclusions.size(); i++) { + SuppressionExclusion suppressionExclusion = suppression_exclusions.get(i); + if (suppressionExclusion == null) { + errors.add("suppression_exclusions[" + i + "] must be a non-null object"); + continue; + } + suppressionExclusion.validate(errors, i); + } + } + + private void validateRequired(List errors, String path, Object value) { + if (value == null) { + errors.add(path + " is required"); + } + } + + private static String normalizeSelectorValue(String value) { + return value.trim().toLowerCase(Locale.ROOT); + } + + public record SuppressionExclusionContext(Map> exactMatchSelectorValues) { + public SuppressionExclusionContext { + exactMatchSelectorValues = exactMatchSelectorValues == null + ? Collections.emptyMap() + : exactMatchSelectorValues; + } + + public SuppressionExclusionContext(String category) { + this(builder() + .withExactMatchSelectorValue(SUPPRESSION_SELECTOR_CATEGORIES, category) + .build() + .exactMatchSelectorValues()); + } + + Set getExactMatchSelectorValues(String selectorName) { + return exactMatchSelectorValues.getOrDefault(selectorName, Collections.emptySet()); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private final Map> exactMatchSelectorValues = new LinkedHashMap<>(); + + public Builder withExactMatchSelectorValue(String selectorName, String value) { + if (selectorName != null && value != null && !value.isBlank()) { + exactMatchSelectorValues + .computeIfAbsent(selectorName, unused -> new LinkedHashSet<>()) + .add(normalizeSelectorValue(value)); + } + return this; + } + + public SuppressionExclusionContext build() { + if (exactMatchSelectorValues.isEmpty()) { + return new SuppressionExclusionContext(Collections.emptyMap()); + } + + Map> normalizedSelectorValues = new LinkedHashMap<>(); + exactMatchSelectorValues.forEach((selectorName, values) -> normalizedSelectorValues.put( + selectorName, + values.isEmpty() + ? Collections.emptySet() + : Collections.unmodifiableSet(new LinkedHashSet<>(values)))); + return new SuppressionExclusionContext(Collections.unmodifiableMap(normalizedSelectorValues)); + } + } + } + + @Data @Reflectable + public static class SuppressionExclusion { + private List categories = new ArrayList<>(); + private transient Set normalizedCategories; + + public void setCategories(List categories) { + this.categories = categories == null ? new ArrayList<>() : categories; + this.normalizedCategories = null; + } + + void initNormalized() { + normalizedCategories = buildNormalizedCategories(); + } + + boolean hasSupportedSelectors() { + return usesSelector(SUPPRESSION_SELECTOR_CATEGORIES); + } + + boolean usesSelector(String selectorName) { + return SUPPRESSION_SELECTOR_CATEGORIES.equals(selectorName) + && categories != null + && !categories.isEmpty(); + } + + boolean matches(SuppressionExclusionContext context) { + if (!hasSupportedSelectors()) { + return false; + } + return matchesExactMatchSelector(SUPPRESSION_SELECTOR_CATEGORIES, getNormalizedCategories(), context); + } + + private boolean matchesExactMatchSelector(String selectorName, Set configuredValues, + SuppressionExclusionContext context) { + if (!usesSelector(selectorName)) { + return true; + } + + Set contextValues = context.getExactMatchSelectorValues(selectorName); + if (contextValues.isEmpty()) { + return false; + } + return contextValues.stream().anyMatch(configuredValues::contains); + } + + private Set getNormalizedCategories() { + if (normalizedCategories == null) { + normalizedCategories = buildNormalizedCategories(); + } + return normalizedCategories; + } + + private Set buildNormalizedCategories() { + if (categories == null || categories.isEmpty()) { + return Collections.emptySet(); + } + LinkedHashSet result = new LinkedHashSet<>(); + for (String category : categories) { + if (category != null && !category.isBlank()) { + result.add(normalizeSelectorValue(category)); + } + } + return result.isEmpty() ? Collections.emptySet() : Collections.unmodifiableSet(result); + } + + void validate(List errors, int index) { + if (!hasSupportedSelectors()) { + errors.add("suppression_exclusions[" + index + "] must define at least one supported selector (e.g. categories)"); + return; + } + validateCategories(errors, index); + } + + private void validateCategories(List errors, int exclusionIndex) { + if (categories == null || categories.isEmpty()) { + return; + } + for (int j = 0; j < categories.size(); j++) { + String category = categories.get(j); + if (category == null || category.isBlank()) { + errors.add("suppression_exclusions[" + exclusionIndex + "].categories[" + j + "] must be a non-blank string"); + } + } + } + } + + @Data @Reflectable public static class Mapping { private Tier tier_1; private Tier tier_2; } - @Data + @Data @Reflectable public static class Tier { private Result fp; private Result tp; private Result unsure; } - @Data + @Data @Reflectable public static class Result { private String value; private Boolean suppress = false; 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 17773d1de42..32e9a7dd24a 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 @@ -248,6 +248,11 @@ public void updateIssueTag(AuditIssue auditIssue, String tagId, String tagValue) } private Map updateAuditXml(Map auditResponses, TagMappingConfig tagMappingConfig) throws AviatorTechnicalException { + return updateAuditXml(auditResponses, tagMappingConfig, Map.of()); + } + + private Map updateAuditXml(Map auditResponses, + TagMappingConfig tagMappingConfig, Map issueCategoryLookup) throws AviatorTechnicalException { Map remediationCommentTimestamps = new HashMap<>(); for (Map.Entry entry : auditResponses.entrySet()) { String instanceId = entry.getKey(); @@ -268,9 +273,9 @@ private Map updateAuditXml(Map auditRespo if (response.getAuditResult() != null) { if (issueElement != null) { - commentTimestamp = updateIssueElement(issueElement, response, tagMappingConfig); + commentTimestamp = updateIssueElement(issueElement, response, tagMappingConfig, issueCategoryLookup); } else { - commentTimestamp = addNewIssueElement(instanceId, response, tagMappingConfig); + commentTimestamp = addNewIssueElement(instanceId, response, tagMappingConfig, issueCategoryLookup); } if (commentTimestamp != null && response.getAuditResult().getAutoremediation() != null && @@ -295,7 +300,8 @@ public Element findIssueElement(String instanceId) { return null; } - public String updateIssueElement(Element issueElement, AuditResponse response, TagMappingConfig tagMappingConfig) { + public String updateIssueElement(Element issueElement, AuditResponse response, TagMappingConfig tagMappingConfig, + Map issueCategoryLookup) throws AviatorTechnicalException { int revision = Integer.parseInt(issueElement.getAttribute("revision")); issueElement.setAttribute("revision", String.valueOf(++revision)); String commentTimestamp = null; @@ -326,9 +332,7 @@ public String updateIssueElement(Element issueElement, AuditResponse response, T if (resultConfig != null && resultConfig.getValue() != null && !resultConfig.getValue().isEmpty()) { updateOrAddTag(issueElement, tagMappingConfig.getTag_id(), resultConfig.getValue()); } - if (resultConfig != null && resultConfig.getSuppress()) { - issueElement.setAttribute("suppressed", "true"); - } + applySuppressionDecision(issueElement, issueElement.getAttribute("instanceId"), resultConfig, tagMappingConfig, issueCategoryLookup); } updateOrAddTag(issueElement, Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR); @@ -337,12 +341,13 @@ public String updateIssueElement(Element issueElement, AuditResponse response, T commentTimestamp = updateOrAddComment(issueElement, response.getAuditResult().comment); } - updateClientAuditTrail(issueElement, response, tagMappingConfig); + updateClientAuditTrail(issueElement, response, tagMappingConfig, issueCategoryLookup); return commentTimestamp; } - private void updateClientAuditTrail(Element issueElement, AuditResponse response, TagMappingConfig tagMappingConfig) { + private void updateClientAuditTrail(Element issueElement, AuditResponse response, TagMappingConfig tagMappingConfig, + Map issueCategoryLookup) throws AviatorTechnicalException { Element clientAuditTrail = getClientAuditTrailElement(issueElement); if (response != null && response.getAuditResult() != null) { @@ -371,13 +376,44 @@ private void updateClientAuditTrail(Element issueElement, AuditResponse response if (resultConfig != null && resultConfig.getValue() != null && !resultConfig.getValue().isEmpty()) { addTagHistory(clientAuditTrail, tagMappingConfig.getTag_id(), resultConfig.getValue()); } - if (resultConfig != null && resultConfig.getSuppress()) { - issueElement.setAttribute("suppressed", "true"); - } + applySuppressionDecision(issueElement, issueElement.getAttribute("instanceId"), resultConfig, tagMappingConfig, issueCategoryLookup); } addTagHistory(clientAuditTrail, Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR); } + private void applySuppressionDecision(Element issueElement, String instanceId, TagMappingConfig.Result resultConfig, + TagMappingConfig tagMappingConfig, Map issueCategoryLookup) throws AviatorTechnicalException { + if (shouldSuppress(instanceId, resultConfig, tagMappingConfig, issueCategoryLookup)) { + issueElement.setAttribute("suppressed", "true"); + } + } + + private boolean shouldSuppress(String instanceId, TagMappingConfig.Result resultConfig, + TagMappingConfig tagMappingConfig, Map issueCategoryLookup) throws AviatorTechnicalException { + if (resultConfig == null || !Boolean.TRUE.equals(resultConfig.getSuppress())) { + return false; + } + + if (!tagMappingConfig.hasSuppressionExclusions()) { + return true; + } + + String issueCategory = null; + if (tagMappingConfig.requiresCategoryForSuppressionEvaluation()) { + issueCategory = Optional.ofNullable(issueCategoryLookup.get(instanceId)) + .map(String::trim) + .filter(category -> !category.isEmpty()) + .orElseThrow(() -> new AviatorTechnicalException( + "Cannot apply suppression exclusions for issue '" + instanceId + "' because no vulnerability category was available.")); + } + + TagMappingConfig.SuppressionExclusionContext suppressionExclusionContext = TagMappingConfig.SuppressionExclusionContext + .builder() + .withExactMatchSelectorValue(TagMappingConfig.SUPPRESSION_SELECTOR_CATEGORIES, issueCategory) + .build(); + return !tagMappingConfig.isSuppressionExcluded(suppressionExclusionContext); + } + private Element getClientAuditTrailElement(Element issueElement) { NodeList clientAuditTrailNodes = issueElement.getElementsByTagNameNS(AUDIT_NAMESPACE_URI, "ClientAuditTrail"); Element clientAuditTrail; @@ -473,7 +509,8 @@ private String updateOrAddComment(Element issueElement, String commentText) { return timestamp; } - public String addNewIssueElement(String instanceId, AuditResponse response, TagMappingConfig tagMappingConfig) { + public String addNewIssueElement(String instanceId, AuditResponse response, TagMappingConfig tagMappingConfig, + Map issueCategoryLookup) throws AviatorTechnicalException { Element issueList = (Element) auditDoc.getElementsByTagNameNS(AUDIT_NAMESPACE_URI, "IssueList").item(0); if (issueList == null) { issueList = auditDoc.createElementNS(AUDIT_NAMESPACE_URI, "IssueList"); @@ -511,9 +548,7 @@ public String addNewIssueElement(String instanceId, AuditResponse response, TagM if (resultConfig != null && resultConfig.getValue() != null && !resultConfig.getValue().isEmpty()) { updateOrAddTag(newIssue, tagMappingConfig.getTag_id(), resultConfig.getValue()); } - if (resultConfig != null && resultConfig.getSuppress()) { - newIssue.setAttribute("suppressed", "true"); - } + applySuppressionDecision(newIssue, instanceId, resultConfig, tagMappingConfig, issueCategoryLookup); } updateOrAddTag(newIssue, Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR); @@ -522,7 +557,7 @@ public String addNewIssueElement(String instanceId, AuditResponse response, TagM commentTimestamp = updateOrAddComment(newIssue, response.getAuditResult().comment); } - updateClientAuditTrail(newIssue, response, tagMappingConfig); + updateClientAuditTrail(newIssue, response, tagMappingConfig, issueCategoryLookup); issueList.appendChild(newIssue); return commentTimestamp; @@ -638,10 +673,12 @@ private String addCommentToIssueElement(Element issueElement, String commentText } public File updateAndSaveAuditAndRemediationsXml(Map auditResponses, - TagMappingConfig tagMappingConfig, - FPRInfo fprInfo) throws AviatorTechnicalException { + TagMappingConfig tagMappingConfig, Map issueCategoryLookup, + FPRInfo fprInfo) throws AviatorTechnicalException { // Step 1: Update the in-memory audit.xml document. This returns timestamps needed for remediations. - Map remediationCommentTimestamps = updateAuditXml(auditResponses, tagMappingConfig); + Map effectiveIssueCategoryLookup = issueCategoryLookup == null ? Map.of() : issueCategoryLookup; + Map remediationCommentTimestamps = updateAuditXml( + auditResponses, tagMappingConfig, effectiveIssueCategoryLookup); // Step 2: Check if there are any remediations to generate. boolean hasRemediations = auditResponses.values().stream() diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/ResourceUtil.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/ResourceUtil.java index 183f0b92f4a..be6717b8047 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/ResourceUtil.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/ResourceUtil.java @@ -23,10 +23,12 @@ import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.constructor.Constructor; import org.yaml.snakeyaml.error.YAMLException; +import org.yaml.snakeyaml.introspector.BeanAccess; import com.fortify.cli.aviator._common.exception.AviatorBugException; import com.fortify.cli.aviator._common.exception.AviatorSimpleException; import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; +import com.fortify.cli.aviator.config.TagMappingConfig; public class ResourceUtil { private static final Logger LOG = LoggerFactory.getLogger(ResourceUtil.class); @@ -39,6 +41,9 @@ private static T loadYamlInternal(InputStream inputStream, Class configCl options.setAllowDuplicateKeys(false); options.setAllowRecursiveKeys(true); Constructor constructor = new Constructor(configClass, options); + if (TagMappingConfig.class.equals(configClass)) { + constructor.getPropertyUtils().setBeanAccess(BeanAccess.FIELD); + } Yaml yaml = new Yaml(constructor); T loadedConfig = yaml.load(inputStream); if (loadedConfig == null) { diff --git a/fcli-core/fcli-aviator-common/src/main/resources/default_tag_mapping.yaml b/fcli-core/fcli-aviator-common/src/main/resources/default_tag_mapping.yaml index 440fcaced36..cbd39912eb4 100644 --- a/fcli-core/fcli-aviator-common/src/main/resources/default_tag_mapping.yaml +++ b/fcli-core/fcli-aviator-common/src/main/resources/default_tag_mapping.yaml @@ -5,6 +5,11 @@ tag_id: "87f2364f-dcd4-49e6-861d-f8d3f351686b" # that by default are suppressed automatically. “tier_2” are the remaining issues. # “value” is a String attribute that maps to a tag value in SSC. It may be omitted. # “suppress” is a Boolean attribute that defaults to “false” +# Optional: Issues matching these exclusions are never auto-suppressed by Aviator. +# Matching is exact and case-insensitive. +# suppression_exclusions: +# - categories: +# - "Privacy Violation" mapping: tier_1: fp: diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/config/TagMappingConfigTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/config/TagMappingConfigTest.java new file mode 100644 index 00000000000..43aeb69ab9b --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/config/TagMappingConfigTest.java @@ -0,0 +1,167 @@ +/* + * 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.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; +import com.fortify.cli.aviator.util.ResourceUtil; + +class TagMappingConfigTest { + @TempDir Path tempDir; + + @Test + void testValidateRejectsBlankSuppressionExclusionCategories() { + TagMappingConfig config = createValidConfig(); + config.setSuppression_exclusions(new ArrayList<>(List.of(createSuppressionExclusion("Privacy Violation", " ")))); + + AviatorSimpleException exception = assertThrows(AviatorSimpleException.class, config::validate); + + assertEquals( + "Invalid tag mapping configuration: suppression_exclusions[0].categories[1] must be a non-blank string", + exception.getMessage()); + } + + @Test + void testValidateRejectsSuppressionExclusionWithoutCategories() { + TagMappingConfig config = createValidConfig(); + config.setSuppression_exclusions(new ArrayList<>(List.of(new TagMappingConfig.SuppressionExclusion()))); + + AviatorSimpleException exception = assertThrows(AviatorSimpleException.class, config::validate); + + assertEquals( + "Invalid tag mapping configuration: suppression_exclusions[0] must define at least one supported selector (e.g. categories)", + exception.getMessage()); + } + + @Test + void testSuppressionExclusionsMatchingIsCaseInsensitiveAndExact() { + TagMappingConfig config = createValidConfig(); + config.setSuppression_exclusions(new ArrayList<>(List.of(createSuppressionExclusion("Privacy Violation")))); + config.validate(); + + assertTrue(config.hasSuppressionExclusions()); + assertTrue(config.isSuppressionExcluded(new TagMappingConfig.SuppressionExclusionContext("privacy violation"))); + assertTrue(config.isSuppressionExcluded(new TagMappingConfig.SuppressionExclusionContext(" Privacy Violation "))); + assertFalse(config.isSuppressionExcluded(new TagMappingConfig.SuppressionExclusionContext("Privacy"))); + } + + @Test + void testSuppressionExclusionContextBuilderMatchesConfiguredSelectors() { + TagMappingConfig config = createValidConfig(); + config.setSuppression_exclusions(new ArrayList<>(List.of(createSuppressionExclusion("Privacy Violation")))); + config.validate(); + + TagMappingConfig.SuppressionExclusionContext context = TagMappingConfig.SuppressionExclusionContext.builder() + .withExactMatchSelectorValue(TagMappingConfig.SUPPRESSION_SELECTOR_CATEGORIES, " privacy violation ") + .build(); + + assertTrue(config.isSuppressionExcluded(context)); + } + + @Test + void testSuppressionExclusionCategoryCacheIsInvalidatedOnMutation() { + TagMappingConfig config = createValidConfig(); + TagMappingConfig.SuppressionExclusion suppressionExclusion = createSuppressionExclusion("Privacy Violation"); + config.setSuppression_exclusions(new ArrayList<>(List.of(suppressionExclusion))); + config.validate(); + + assertTrue(config.isSuppressionExcluded(new TagMappingConfig.SuppressionExclusionContext("privacy violation"))); + + suppressionExclusion.setCategories(new ArrayList<>(List.of("SQL Injection"))); + + assertFalse(config.isSuppressionExcluded(new TagMappingConfig.SuppressionExclusionContext("privacy violation"))); + assertTrue(config.isSuppressionExcluded(new TagMappingConfig.SuppressionExclusionContext("sql injection"))); + } + + @Test + void testLoadYamlFileBindsSuppressionExclusionsAcrossEntries() throws Exception { + Path yamlFile = tempDir.resolve("tag-mapping.yaml"); + Files.writeString(yamlFile, """ + tag_id: \"87f2364f-dcd4-49e6-861d-f8d3f351686b\" + suppression_exclusions: + - categories: + - \"Privacy Violation\" + - categories: + - \"SQL Injection\" + - \"privacy violation\" + mapping: + tier_1: + fp: + value: \"Not an Issue\" + suppress: true + tp: + value: \"Exploitable\" + suppress: false + unsure: + suppress: false + tier_2: + fp: + value: \"Not an Issue\" + suppress: false + tp: + value: \"Suspicious\" + suppress: false + unsure: + suppress: false + """); + + TagMappingConfig config = ResourceUtil.loadYamlFile(yamlFile.toFile(), TagMappingConfig.class); + config.validate(); + + assertTrue(config.hasSuppressionExclusions()); + assertTrue(config.isSuppressionExcluded(new TagMappingConfig.SuppressionExclusionContext("SQL Injection"))); + assertTrue(config.isSuppressionExcluded(new TagMappingConfig.SuppressionExclusionContext("privacy violation"))); + } + + private TagMappingConfig createValidConfig() { + TagMappingConfig config = new TagMappingConfig(); + TagMappingConfig.Mapping mapping = new TagMappingConfig.Mapping(); + mapping.setTier_1(createTier(true)); + mapping.setTier_2(createTier(false)); + config.setMapping(mapping); + return config; + } + + private TagMappingConfig.Tier createTier(boolean suppressFalsePositives) { + TagMappingConfig.Tier tier = new TagMappingConfig.Tier(); + tier.setFp(createResult("Not an Issue", suppressFalsePositives)); + tier.setTp(createResult("Exploitable", false)); + tier.setUnsure(createResult(null, false)); + return tier; + } + + private TagMappingConfig.Result createResult(String value, boolean suppress) { + TagMappingConfig.Result result = new TagMappingConfig.Result(); + result.setValue(value); + result.setSuppress(suppress); + return result; + } + + private TagMappingConfig.SuppressionExclusion createSuppressionExclusion(String... categories) { + TagMappingConfig.SuppressionExclusion suppressionExclusion = new TagMappingConfig.SuppressionExclusion(); + suppressionExclusion.setCategories(new ArrayList<>(List.of(categories))); + return suppressionExclusion; + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/AuditProcessorSuppressionExclusionsTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/AuditProcessorSuppressionExclusionsTest.java new file mode 100644 index 00000000000..491f90f3d76 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/AuditProcessorSuppressionExclusionsTest.java @@ -0,0 +1,204 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import javax.xml.parsers.DocumentBuilderFactory; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; +import com.fortify.cli.aviator.audit.model.AuditResponse; +import com.fortify.cli.aviator.audit.model.AuditResult; +import com.fortify.cli.aviator.config.TagMappingConfig; +import com.fortify.cli.aviator.fpr.model.FPRInfo; +import com.fortify.cli.aviator.util.Constants; +import com.fortify.cli.aviator.util.FprHandle; + +class AuditProcessorSuppressionExclusionsTest { + private Path tempFprFile; + private FprHandle fprHandle; + + @AfterEach + void tearDown() throws Exception { + if (fprHandle != null) { + fprHandle.close(); + } + if (tempFprFile != null) { + Files.deleteIfExists(tempFprFile); + } + } + + @Test + void testUpdateAndSaveDoesNotSuppressExcludedCategory() throws Exception { + createTestFpr(createAuditXml(false)); + AuditProcessor auditProcessor = new AuditProcessor(fprHandle); + auditProcessor.processAuditXML(); + + auditProcessor.updateAndSaveAuditAndRemediationsXml( + Map.of("instance-1", createFalsePositiveResponse()), + createTagMappingConfig("Privacy Violation"), + Map.of("instance-1", "Privacy Violation"), + new FPRInfo(fprHandle)); + + assertEquals("false", readIssueElement().getAttribute("suppressed")); + } + + @Test + void testUpdateAndSaveSuppressesNonExcludedCategory() throws Exception { + createTestFpr(createAuditXml(false)); + AuditProcessor auditProcessor = new AuditProcessor(fprHandle); + auditProcessor.processAuditXML(); + + auditProcessor.updateAndSaveAuditAndRemediationsXml( + Map.of("instance-1", createFalsePositiveResponse()), + createTagMappingConfig("Privacy Violation"), + Map.of("instance-1", "Cross-Site Scripting"), + new FPRInfo(fprHandle)); + + assertEquals("true", readIssueElement().getAttribute("suppressed")); + } + + @Test + void testUpdateAndSaveThrowsClearErrorWhenCategoryLookupMissing() throws Exception { + createTestFpr(createAuditXml(false)); + AuditProcessor auditProcessor = new AuditProcessor(fprHandle); + auditProcessor.processAuditXML(); + + AviatorTechnicalException exception = assertThrows( + AviatorTechnicalException.class, + () -> auditProcessor.updateAndSaveAuditAndRemediationsXml( + Map.of("instance-1", createFalsePositiveResponse()), + createTagMappingConfig("Privacy Violation"), + Map.of(), + new FPRInfo(fprHandle))); + + assertEquals( + "Cannot apply suppression exclusions for issue 'instance-1' because no vulnerability category was available.", + exception.getMessage()); + } + + private void createTestFpr(String auditXml) throws Exception { + tempFprFile = Files.createTempFile("audit-processor-suppressions", ".fpr"); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(tempFprFile))) { + zipOutputStream.putNextEntry(new ZipEntry("audit.fvdl")); + zipOutputStream.write(minimalAuditFvdl().getBytes(StandardCharsets.UTF_8)); + zipOutputStream.closeEntry(); + + 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(); + } + fprHandle = new FprHandle(tempFprFile); + } + + private Element readIssueElement() throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + Document document; + try (var inputStream = Files.newInputStream(fprHandle.getPath("/audit.xml"))) { + document = factory.newDocumentBuilder().parse(inputStream); + } + return (Element) document.getElementsByTagNameNS("xmlns://www.fortify.com/schema/audit", "Issue").item(0); + } + + private AuditResponse createFalsePositiveResponse() { + return AuditResponse.builder() + .issueId("instance-1") + .status("SUCCESS") + .tier("GOLD") + .auditResult(AuditResult.builder() + .tagValue(Constants.NOT_AN_ISSUE) + .comment("Reviewed by Aviator") + .build()) + .build(); + } + + private TagMappingConfig createTagMappingConfig(String... suppressionExcludedCategories) { + TagMappingConfig config = new TagMappingConfig(); + config.setSuppression_exclusions(new ArrayList<>(List.of(createSuppressionExclusion(suppressionExcludedCategories)))); + + TagMappingConfig.Mapping mapping = new TagMappingConfig.Mapping(); + mapping.setTier_1(createTier(true)); + mapping.setTier_2(createTier(false)); + config.setMapping(mapping); + config.validate(); + + return config; + } + + private TagMappingConfig.Tier createTier(boolean suppressFalsePositives) { + TagMappingConfig.Tier tier = new TagMappingConfig.Tier(); + tier.setFp(createResult("Not an Issue", suppressFalsePositives)); + tier.setTp(createResult("Exploitable", false)); + tier.setUnsure(createResult(null, false)); + return tier; + } + + private TagMappingConfig.Result createResult(String value, boolean suppress) { + TagMappingConfig.Result result = new TagMappingConfig.Result(); + result.setValue(value); + result.setSuppress(suppress); + return result; + } + + private TagMappingConfig.SuppressionExclusion createSuppressionExclusion(String... categories) { + TagMappingConfig.SuppressionExclusion suppressionExclusion = new TagMappingConfig.SuppressionExclusion(); + suppressionExclusion.setCategories(new ArrayList<>(List.of(categories))); + return suppressionExclusion; + } + + private String createAuditXml(boolean suppressed) { + return """ + + + + + + + """.formatted(suppressed); + } + + private String minimalAuditFvdl() { + return """ + + + uuid-1 + + build-1 + . + 1 + 1 + + + """; + } +}