diff --git a/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/runner/util/FortifyCLIStaticInitializer.java b/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/runner/util/FortifyCLIStaticInitializer.java index 4a9b0878036..b3e07718098 100644 --- a/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/runner/util/FortifyCLIStaticInitializer.java +++ b/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/runner/util/FortifyCLIStaticInitializer.java @@ -12,10 +12,26 @@ */ package com.fortify.cli.app.runner.util; +import java.net.Socket; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import java.util.Locale; +import java.util.Objects; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509ExtendedTrustManager; +import javax.net.ssl.X509TrustManager; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; @@ -25,6 +41,7 @@ import com.fortify.cli.common.http.ssl.truststore.helper.TrustStoreConfigDescriptor; import com.fortify.cli.common.http.ssl.truststore.helper.TrustStoreConfigHelper; import com.fortify.cli.common.i18n.helper.LanguageHelper; +import com.fortify.cli.common.util.EnvHelper; import com.fortify.cli.fod.action.helper.FoDActionProductContextProvider; import com.fortify.cli.ssc.action.helper.SSCActionProductContextProvider; import com.fortify.cli.tool._common.helper.ToolUninstaller; @@ -77,6 +94,83 @@ private void initializeTrustStore() { trustStorePasswordPropertyKey); } log.debug("INFO: Trust store file: " + System.getProperty(trustStorePropertyKey, "NONE")); + + // Merge OS platform trust store (e.g. Windows Certificate Store / macOS Keychain) + // with the configured trust store so enterprise CAs are trusted automatically. + initializePlatformTrustStore(descriptor); + } + + private void initializePlatformTrustStore(TrustStoreConfigDescriptor descriptor) { + if (isOsTrustStoreDisabled(descriptor)) { + log.debug("OS trust store merge disabled"); + return; + } + KeyStore platformKeyStore = loadPlatformKeyStore(); + if (platformKeyStore == null) { + return; // No OS trust store available on this platform or in this runtime + } + + List managers = new ArrayList<>(); + addTrustManagerFromKeyStore(managers, null); // null = use javax.net.ssl.trustStore system props + int managerCountBeforePlatformStore = managers.size(); + addTrustManagerFromKeyStore(managers, platformKeyStore); + + if (managers.size() == managerCountBeforePlatformStore) { + return; // Nothing new to add; skip installing a composite context + } + + try { + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(null, new TrustManager[]{new CompositeX509TrustManager(managers)}, null); + SSLContext.setDefault(ctx); + log.info("Composite SSL context installed: configured trust store + OS trust store"); + } catch (GeneralSecurityException e) { + log.warn("Could not install composite SSL context with OS trust store: " + e.getMessage()); + } + } + + private boolean isOsTrustStoreDisabled(TrustStoreConfigDescriptor descriptor) { + if (EnvHelper.asBoolean(EnvHelper.env("FCLI_DISABLE_OS_TRUSTSTORE"))) { + return true; + } + return descriptor != null && Boolean.FALSE.equals(descriptor.getUseOsTrustStore()); + } + + private void addTrustManagerFromKeyStore(List managers, KeyStore keyStore) { + try { + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(keyStore); // null = uses javax.net.ssl.trustStore system props + for (TrustManager tm : tmf.getTrustManagers()) { + if (tm instanceof X509TrustManager x509tm) { + managers.add(x509tm); + } + } + } catch (GeneralSecurityException e) { + log.debug("Could not load trust manager from key store: " + e.getMessage()); + } + } + + private KeyStore loadPlatformKeyStore() { + String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + String type; + if (os.contains("win")) { + type = "Windows-ROOT"; + } else if (os.contains("mac")) { + type = "KeychainStore"; + } else { + return null; // Linux has no standard Java-accessible OS trust store + } + try { + KeyStore ks = KeyStore.getInstance(type); + ks.load(null, null); + log.debug("Loaded OS trust store: " + type); + return ks; + } catch (Exception | LinkageError e) { + // Provider may be unavailable in GraalVM native images built on a different OS + log.warn("OS trust store unavailable ({}): {}", type, e.getMessage()); + log.debug("OS trust store load failure details", e); + return null; + } } private void initializeTrustStoreFromEnv(String trustStorePropertyKey, String trustStoreTypePropertyKey, @@ -123,4 +217,80 @@ private void initializeTrustStoreFromConfig(TrustStoreConfigDescriptor descripto private void initializeLocale() { Locale.setDefault(LanguageHelper.getConfiguredLanguageDescriptor().getLocale()); } + + private static final class CompositeX509TrustManager extends X509ExtendedTrustManager { + private final List delegates; + + CompositeX509TrustManager(List delegates) { + this.delegates = List.copyOf(delegates); + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { + tryEach(tm -> tm.checkClientTrusted(chain, authType)); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { + tryEach(tm -> tm.checkServerTrusted(chain, authType)); + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket) throws CertificateException { + tryEach(tm -> { + if (tm instanceof X509ExtendedTrustManager ext) { ext.checkClientTrusted(chain, authType, socket); } + else { tm.checkClientTrusted(chain, authType); } + }); + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine) throws CertificateException { + tryEach(tm -> { + if (tm instanceof X509ExtendedTrustManager ext) { ext.checkClientTrusted(chain, authType, engine); } + else { tm.checkClientTrusted(chain, authType); } + }); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket) throws CertificateException { + tryEach(tm -> { + if (tm instanceof X509ExtendedTrustManager ext) { ext.checkServerTrusted(chain, authType, socket); } + else { tm.checkServerTrusted(chain, authType); } + }); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine) throws CertificateException { + tryEach(tm -> { + if (tm instanceof X509ExtendedTrustManager ext) { ext.checkServerTrusted(chain, authType, engine); } + else { tm.checkServerTrusted(chain, authType); } + }); + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return delegates.stream() + .map(X509TrustManager::getAcceptedIssuers) + .filter(Objects::nonNull) + .flatMap(Arrays::stream) + .toArray(X509Certificate[]::new); + } + + private void tryEach(TrustCheckFactory check) throws CertificateException { + CertificateException last = null; + for (X509TrustManager tm : delegates) { + try { check.check(tm); return; } + catch (CertificateException e) { last = e; } + } + if (last != null) { + throw last; + } + throw new CertificateException("No trust manager accepted the certificate chain"); + } + + @FunctionalInterface + private interface TrustCheckFactory { + void check(X509TrustManager tm) throws CertificateException; + } + } } 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/ssl/jni-config.json b/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/ssl/jni-config.json new file mode 100644 index 00000000000..33d45860837 --- /dev/null +++ b/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/ssl/jni-config.json @@ -0,0 +1,29 @@ +[ + { + "name": "sun.security.mscapi.SunMSCAPI", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredFields": true, + "allPublicFields": true + }, + { + "name": "sun.security.mscapi.CKeyStore", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredFields": true, + "allPublicFields": true + }, + { + "name": "sun.security.mscapi.CKeyStore$ROOT", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredFields": true, + "allPublicFields": true + } +] diff --git a/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/ssl/reflect-config.json b/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/ssl/reflect-config.json new file mode 100644 index 00000000000..33d45860837 --- /dev/null +++ b/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/ssl/reflect-config.json @@ -0,0 +1,29 @@ +[ + { + "name": "sun.security.mscapi.SunMSCAPI", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredFields": true, + "allPublicFields": true + }, + { + "name": "sun.security.mscapi.CKeyStore", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredFields": true, + "allPublicFields": true + }, + { + "name": "sun.security.mscapi.CKeyStore$ROOT", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredFields": true, + "allPublicFields": true + } +] 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 + + + """; + } +} diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/http/ssl/truststore/helper/TrustStoreConfigDescriptor.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/http/ssl/truststore/helper/TrustStoreConfigDescriptor.java index 9a85c7178d0..e33debc419e 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/http/ssl/truststore/helper/TrustStoreConfigDescriptor.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/http/ssl/truststore/helper/TrustStoreConfigDescriptor.java @@ -27,4 +27,5 @@ public class TrustStoreConfigDescriptor extends JsonNodeHolder { private String path; private String type; private String password; + private Boolean useOsTrustStore; } diff --git a/fcli-core/fcli-config/src/main/java/com/fortify/cli/config/truststore/cli/cmd/TrustStoreSetCommand.java b/fcli-core/fcli-config/src/main/java/com/fortify/cli/config/truststore/cli/cmd/TrustStoreSetCommand.java index 80fc0074584..4fcbe812917 100644 --- a/fcli-core/fcli-config/src/main/java/com/fortify/cli/config/truststore/cli/cmd/TrustStoreSetCommand.java +++ b/fcli-core/fcli-config/src/main/java/com/fortify/cli/config/truststore/cli/cmd/TrustStoreSetCommand.java @@ -43,6 +43,9 @@ public class TrustStoreSetCommand extends AbstractOutputCommand implements IJson @Option(names = {"-t", "--type"}, defaultValue = "jks") private String trustStoreType; + + @Option(names = {"--no-os-truststore"}) + private boolean noOsTrustStore; @Override public JsonNode getJsonNode() { @@ -55,6 +58,7 @@ public JsonNode getJsonNode() { .path(absolutePathString) .type(trustStoreType) .password(trustStorePassword) + .useOsTrustStore(!noOsTrustStore) .build(); TrustStoreConfigHelper.setTrustStoreConfig(descriptor); return descriptor.asJsonNode(); diff --git a/fcli-core/fcli-config/src/main/resources/com/fortify/cli/config/i18n/ConfigMessages.properties b/fcli-core/fcli-config/src/main/resources/com/fortify/cli/config/i18n/ConfigMessages.properties index f83e5d7bbcc..ebd9d7b7b56 100644 --- a/fcli-core/fcli-config/src/main/resources/com/fortify/cli/config/i18n/ConfigMessages.properties +++ b/fcli-core/fcli-config/src/main/resources/com/fortify/cli/config/i18n/ConfigMessages.properties @@ -67,6 +67,7 @@ fcli.config.truststore.set.usage.header = Configure SSL trust store. fcli.config.truststore.set.file = Path to custom SSL trust store file. fcli.config.truststore.set.password = Optional SSL trust store password. fcli.config.truststore.set.type = SSL trust store type (jks, pkcs12). +fcli.config.truststore.set.no-os-truststore = Disable automatic merge with OS trust store certificates. ################################################################################################################# # The following section lists human-readable header names used by table and tree output formats; @@ -87,4 +88,3 @@ fcli.config.language.output.table.args = locale,localName,name,active fcli.config.proxy.output.table.args = name,priority,proxyHost,proxyPort,proxyUser,modules,modulesMatchMode,targetHost fcli.config.public-key.output.table.args = name,fingerprint fcli.config.truststore.output.table.args = path,type - diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/cli/cmd/FoDAppUpdateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/cli/cmd/FoDAppUpdateCommand.java index 928b24dcdcc..aecec5e3595 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/cli/cmd/FoDAppUpdateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/app/cli/cmd/FoDAppUpdateCommand.java @@ -15,12 +15,11 @@ import static com.fortify.cli.common.util.DisableTest.TestType.MULTI_OPT_PLURAL_NAME; import java.util.ArrayList; -import java.util.Map; import org.apache.commons.lang3.StringUtils; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.common.cli.mixin.CommonOptionMixins; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; import com.fortify.cli.common.output.transform.IRecordTransformer; @@ -33,7 +32,6 @@ import com.fortify.cli.fod.app.helper.FoDAppHelper; import com.fortify.cli.fod.app.helper.FoDAppUpdateRequest; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeUpdateOptions; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; import kong.unirest.UnirestInstance; @@ -46,7 +44,7 @@ public class FoDAppUpdateCommand extends AbstractFoDJsonNodeOutputCommand implements IRecordTransformer, IActionCommandResultSupplier { @Getter @Mixin private OutputHelperMixins.Update outputHelper; @Mixin private FoDAppResolverMixin.PositionalParameter appResolver; - private final ObjectMapper objectMapper = new ObjectMapper(); + @Mixin private CommonOptionMixins.AutoRequiredAttrsOption autoRequiredAttrsOption; @Option(names = {"--name", "-n"}) private String applicationNameUpdate; @@ -60,21 +58,10 @@ public class FoDAppUpdateCommand extends AbstractFoDJsonNodeOutputCommand implem @Override public JsonNode getJsonNode(UnirestInstance unirest) { - - // current values of app being updated FoDAppDescriptor appDescriptor = FoDAppHelper.getAppDescriptor(unirest, appResolver.getAppNameOrId(), true); - ArrayList appAttrsCurrent = appDescriptor.getAttributes(); - - // new values to replace FoDCriticalityTypeOptions.FoDCriticalityType appCriticalityNew = criticalityTypeUpdate.getCriticalityType(); - Map attributeUpdates = appAttrsUpdate.getAttributes(); - JsonNode jsonAttrs = objectMapper.createArrayNode(); - if (attributeUpdates != null && !attributeUpdates.isEmpty()) { - jsonAttrs = FoDAttributeHelper.mergeAttributesNode(unirest, FoDEnums.AttributeTypes.Application, appAttrsCurrent, - attributeUpdates); - } else { - jsonAttrs = FoDAttributeHelper.getAttributesNode(FoDEnums.AttributeTypes.Application, appAttrsCurrent); - } + JsonNode jsonAttrs = FoDAttributeHelper.getAttributesNodeForUpdate(unirest, FoDEnums.AttributeTypes.Application, + appDescriptor.getAttributes(), appAttrsUpdate.getAttributes(), autoRequiredAttrsOption.isAutoRequiredAttrs()); String appEmailListNew = FoDAppHelper.getEmailList(notificationsUpdate); FoDAppUpdateRequest appUpdateRequest = FoDAppUpdateRequest.builder() @@ -102,4 +89,4 @@ public boolean isSingular() { return true; } -} +} \ No newline at end of file diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeHelper.java index 5204883417b..7b9c3cbba9a 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeHelper.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/attribute/helper/FoDAttributeHelper.java @@ -131,18 +131,16 @@ public static JsonNode getAttributesNode(FoDEnums.AttributeTypes attrType, Array return attrArray; } - public static JsonNode getAttributesNode(UnirestInstance unirest, FoDEnums.AttributeTypes attrType, - Map attributesMap, boolean autoReqdAttributes) { - Map combinedAttributesMap = new LinkedHashMap<>(); - if (autoReqdAttributes) { - // find any required attributes - combinedAttributesMap.putAll(getRequiredAttributesDefaultValues(unirest, attrType)); - } - if ( attributesMap!=null && !attributesMap.isEmpty() ) { - combinedAttributesMap.putAll(attributesMap); - } + /** + * For create commands: amends user-provided attribute values with server-side defaults + * for any required attributes not already specified by the user. + * Resolves attribute names to IDs and filters by attrType. + */ + public static JsonNode getAttributesNode(UnirestInstance unirest, FoDEnums.AttributeTypes attrType, + Map attributesMap, boolean autoReqdAttributes) { + var effectiveMap = buildEffectiveAttributeUpdates(unirest, attrType, null, attributesMap, autoReqdAttributes); ArrayNode attrArray = JsonHelper.getObjectMapper().createArrayNode(); - for (Map.Entry attr : combinedAttributesMap.entrySet()) { + for (Map.Entry attr : effectiveMap.entrySet()) { ObjectNode attrObj = getObjectMapper().createObjectNode(); FoDAttributeDescriptor attributeDescriptor = FoDAttributeHelper.getAttributeDescriptor(unirest, attr.getKey(), true); // filter out any attributes that aren't valid for the entity we are working on, e.g. Application or Release @@ -152,12 +150,56 @@ public static JsonNode getAttributesNode(UnirestInstance unirest, FoDEnums.Attri attrArray.add(attrObj); } else { LOG.debug("Skipping attribute '"+attributeDescriptor.getName()+"' as it is not a "+attrType.toString()+" attribute"); - - } + } } return attrArray; } + /** + * For update commands: merges user-supplied attribute values with the entity's existing + * attribute values, then amends with server-side defaults for any required attributes + * not already covered by either the current values or user-supplied updates. + */ + public static JsonNode getAttributesNodeForUpdate(UnirestInstance unirest, FoDEnums.AttributeTypes attrType, + ArrayList currentAttributes, Map userSuppliedUpdates, + boolean autoReqdAttributes) { + var effectiveUpdates = buildEffectiveAttributeUpdates( + unirest, attrType, currentAttributes, userSuppliedUpdates, autoReqdAttributes); + return effectiveUpdates.isEmpty() + ? getAttributesNode(attrType, currentAttributes) + : mergeAttributesNode(unirest, attrType, currentAttributes, effectiveUpdates); + } + + /** + * Computes the effective attribute updates to apply. For required attributes not already + * covered by current entity values or user-supplied updates, server-side defaults are added. + * User-supplied updates always take highest priority. + * Pass {@code currentAttributes=null} for create scenarios. + */ + private static Map buildEffectiveAttributeUpdates(UnirestInstance unirest, + FoDEnums.AttributeTypes attrType, ArrayList currentAttributes, + Map userSuppliedUpdates, boolean autoReqdAttributes) { + var effective = new LinkedHashMap(); + if (autoReqdAttributes) { + Set covered = new HashSet<>(); + if (currentAttributes != null) { + currentAttributes.stream() + .filter(a -> StringUtils.isNotBlank(a.getValue())) + .map(FoDAttributeDescriptor::getName) + .forEach(covered::add); + } + if (userSuppliedUpdates != null) { + covered.addAll(userSuppliedUpdates.keySet()); + } + getRequiredAttributesDefaultValues(unirest, attrType) + .forEach((k, v) -> { if (!covered.contains(k)) effective.put(k, v); }); + } + if (userSuppliedUpdates != null) { + effective.putAll(userSuppliedUpdates); + } + return effective; + } + public static FoDAttributeDescriptor createAttribute(UnirestInstance unirest, FoDAttributeCreateRequest request) { var response = unirest.post(FoDUrls.ATTRIBUTES) // Use headerReplace to replace rather than add the Content-Type header (avoid duplicates with defaults) diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueUpdateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueUpdateCommand.java index 55075a320a6..ea9f25ddeaa 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueUpdateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueUpdateCommand.java @@ -37,6 +37,7 @@ import kong.unirest.UnirestInstance; import lombok.Getter; +import picocli.CommandLine.ArgGroup; import picocli.CommandLine.Command; import picocli.CommandLine.Mixin; import picocli.CommandLine.Option; @@ -66,41 +67,67 @@ public class FoDIssueUpdateCommand extends AbstractFoDJsonNodeOutputCommand impl protected VulnerabilitySeverityType severity; @Option(names = {"--comment"}, required = false) protected String comment; - @Option(names = {"--vuln-ids"}, required = true, split=",") - protected ArrayList vulnIds; + @ArgGroup(exclusive = true, multiplicity = "1") + private VulnSelectionArgs vulnSelection; + + static class VulnSelectionArgs { + @Option(names = {"--vuln-ids", "--issue-ids"}, required = true, split=",") + ArrayList vulnIds; + @Option(names = {"--include-all", "--all"}, required = true) + boolean includeAllVulnerabilities; + } @Override public JsonNode getJsonNode(UnirestInstance unirest) { FoDReleaseDescriptor releaseDescriptor = releaseResolver.getReleaseDescriptor(unirest); - // If vulnIds are provided, filter them against the release vulnerabilities using a helper. - int issueUpdateCount = 0; - int totalCount = 0; - int skippedCount = 0; - if ( vulnIds != null && !vulnIds.isEmpty() ) { - var vulnFilterResult = FoDIssueHelper.filterRequestedVulnIds(unirest, releaseDescriptor.getReleaseId(), vulnIds); - totalCount = vulnFilterResult.totalCount(); - issueUpdateCount = vulnFilterResult.kept().size(); - skippedCount = vulnFilterResult.skipped().size(); - vulnIds = new ArrayList<>(vulnFilterResult.kept()); + Map attributeUpdates = issueAttrsUpdate.getAttributes(); + JsonNode jsonAttrs = FoDIssueHelper.buildIssueAttributesNode(unirest, attributeUpdates); + ResolvedStatuses resolvedStatuses = resolveStatuses(unirest); + + if (vulnSelection.includeAllVulnerabilities) { + FoDBulkIssueUpdateRequest issueUpdateRequest = buildIssueUpdateRequest(unirest, resolvedStatuses.developerStatusValue(), resolvedStatuses.auditorStatusValue(), jsonAttrs, null, true); + FoDBulkIssueUpdateResponse resp = performUpdate(unirest, releaseDescriptor.getReleaseId(), issueUpdateRequest, 0, 0, 0, null); + int updateCount = (int) resp.getIssueCount(); + lastTotalCount = updateCount; + lastUpdateCount = updateCount; + return resp.asObjectNode() + .put("totalCount", updateCount) + .put("skippedCount", 0) + .put("errorCount", lastErrorCount) + .put("updateCount", updateCount); + } else { + var vulnFilterResult = FoDIssueHelper.filterRequestedVulnIds(unirest, releaseDescriptor.getReleaseId(), vulnSelection.vulnIds); + int totalCount = vulnFilterResult.totalCount(); + int issueUpdateCount = vulnFilterResult.kept().size(); + int skippedCount = vulnFilterResult.skipped().size(); + ArrayList effectiveVulnIds = new ArrayList<>(vulnFilterResult.kept()); if (!vulnFilterResult.skipped().isEmpty()) { LOG.debug("Skipped vulnerabilities: {}", vulnFilterResult.skipped()); vulnFilterResult.skipped().forEach(vid -> LOG.warn("Vulnerability {} not found in release {}, skipping", vid, releaseDescriptor.getReleaseId())); } + if (effectiveVulnIds.isEmpty()) { + return createNoOpResponse(totalCount, skippedCount, issueUpdateCount); + } + FoDBulkIssueUpdateRequest issueUpdateRequest = buildIssueUpdateRequest(unirest, resolvedStatuses.developerStatusValue(), resolvedStatuses.auditorStatusValue(), jsonAttrs, effectiveVulnIds, false); + FoDBulkIssueUpdateResponse resp = performUpdate(unirest, releaseDescriptor.getReleaseId(), issueUpdateRequest, totalCount, skippedCount, issueUpdateCount, effectiveVulnIds); + return resp.asObjectNode() + .put("totalCount", totalCount) + .put("skippedCount", skippedCount) + .put("errorCount", lastErrorCount) + .put("updateCount", issueUpdateCount); } + } - Map attributeUpdates = issueAttrsUpdate.getAttributes(); - JsonNode jsonAttrs = FoDIssueHelper.buildIssueAttributesNode(unirest, attributeUpdates); - - // Validate auditor and developer status values against attribute picklists - ResolvedStatuses resolvedStatuses = resolveStatuses(unirest); - - FoDBulkIssueUpdateRequest issueUpdateRequest = buildIssueUpdateRequest(unirest, resolvedStatuses.developerStatusValue(), resolvedStatuses.auditorStatusValue(), jsonAttrs); - FoDBulkIssueUpdateResponse resp = performUpdate(unirest, releaseDescriptor.getReleaseId(), issueUpdateRequest, totalCount, skippedCount, issueUpdateCount); - return resp.asObjectNode() + private JsonNode createNoOpResponse(int totalCount, int skippedCount, int issueUpdateCount) { + lastTotalCount = totalCount; + lastSkippedCount = skippedCount; + lastErrorCount = 0; + lastUpdateCount = issueUpdateCount; + return objectMapper.createObjectNode() .put("totalCount", totalCount) .put("skippedCount", skippedCount) - .put("errorCount", lastErrorCount) + .put("errorCount", 0) .put("updateCount", issueUpdateCount); } @@ -122,20 +149,21 @@ private ResolvedStatuses resolveStatuses(UnirestInstance unirest) { return new ResolvedStatuses(developerStatusValue, auditorStatusValue); } - private FoDBulkIssueUpdateRequest buildIssueUpdateRequest(UnirestInstance unirest, String developerStatusValue, String auditorStatusValue, JsonNode jsonAttrs) { + private FoDBulkIssueUpdateRequest buildIssueUpdateRequest(UnirestInstance unirest, String developerStatusValue, String auditorStatusValue, JsonNode jsonAttrs, ArrayList effectiveVulnIds, boolean includeAllVulnerabilities) { return FoDBulkIssueUpdateRequest.builder() .user(unirest, user) .developerStatus(developerStatusValue) .auditorStatus(auditorStatusValue) .severity(severity != null ? severity.toString() : null) .comment(comment) - .vulnerabilityIds(vulnIds) + .vulnerabilityIds(effectiveVulnIds) + .includeAllVulnerabilities(includeAllVulnerabilities ? true : null) .attributes(jsonAttrs) .build().validate(); } - private FoDBulkIssueUpdateResponse performUpdate(UnirestInstance unirest, String releaseId, FoDBulkIssueUpdateRequest request, int totalCount, int skippedCount, int issueUpdateCount) { - LOG.debug("Updating issues: {}", vulnIds); + private FoDBulkIssueUpdateResponse performUpdate(UnirestInstance unirest, String releaseId, FoDBulkIssueUpdateRequest request, int totalCount, int skippedCount, int issueUpdateCount, ArrayList effectiveVulnIds) { + if (effectiveVulnIds != null) { LOG.debug("Updating issues: {}", effectiveVulnIds); } FoDBulkIssueUpdateResponse resp = FoDIssueHelper.updateIssues(unirest, releaseId, request); long errorCount = resp.getResults().stream().filter(r -> r.getErrorCode() != 0).count(); resp.setIssueCount(resp.getResults().size()); diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDBulkIssueUpdateRequest.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDBulkIssueUpdateRequest.java index 93d6326c696..45df3e6fd18 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDBulkIssueUpdateRequest.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDBulkIssueUpdateRequest.java @@ -44,12 +44,15 @@ public class FoDBulkIssueUpdateRequest { private String severity; private String comment; private ArrayList vulnerabilityIds; + private Boolean includeAllVulnerabilities; private JsonNode attributes; @JsonIgnore public final FoDBulkIssueUpdateRequest validate(Consumer> validationMessageConsumer) { var messages = new ArrayList(); - validateRequired(messages, vulnerabilityIds, "Vulnerability Ids not specified"); + if (!Boolean.TRUE.equals(includeAllVulnerabilities)) { + validateRequired(messages, vulnerabilityIds, "Vulnerability Ids not specified"); + } if ( !messages.isEmpty() ) { validationMessageConsumer.accept(messages); } @@ -58,11 +61,11 @@ public final FoDBulkIssueUpdateRequest validate(Consumer> validatio @JsonIgnore public final FoDBulkIssueUpdateRequest validate() { - return validate(messages->{throw new FcliSimpleException("Unable to update issues:\n\t"+String.join("\n\t", messages)); }); + return validate(messages -> { throw new FcliSimpleException("Unable to update issues:\n\t"+String.join("\n\t", messages)); }); } @JsonIgnore - private final void validateRequired(List messages, Object obj, String message) { + private void validateRequired(List messages, Object obj, String message) { if ( obj==null || (obj instanceof String && StringUtils.isBlank((String)obj)) ) { messages.add(message); } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueHelper.java index e6e4a906f8b..24a29cd5de6 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueHelper.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueHelper.java @@ -365,6 +365,38 @@ public static java.util.Set getVulnIdsForRelease(UnirestInstance unirest return result; } + /** + * Returns the numeric {@code id} for every vulnerability in the release, with no duplicates. + * Unlike {@link #getVulnIdsForRelease}, which collects both {@code id} and {@code vulnId} per + * item (needed for user-supplied identifier matching), this method collects only the canonical + * numeric id so that the result count matches the actual vulnerability count and the bulk-edit + * API is not sent duplicate identifiers. + */ + public static List getAllVulnNumericIdsForRelease(UnirestInstance unirest, String releaseId) { + var result = new ArrayList(); + try { + var request = unirest.get(FoDUrls.VULNERABILITIES) + .routeParam("relId", releaseId) + .queryString("fields", "id") + .queryString("includeFixed", "true") + .queryString("includeSuppressed", "true"); + var stream = FoDPagingHelper.pagedRequest(request).stream() + .map(HttpResponse::getBody) + .map(FoDInputTransformer::getItems) + .filter(items -> items != null && items.isArray()) + .map(ArrayNode.class::cast) + .flatMap(JsonHelper::stream); + for ( JsonNode item : (Iterable)stream::iterator ) { + if ( item.has("id") && !item.get("id").isNull() ) { + result.add(item.get("id").asText().trim()); + } + } + } catch (Exception e) { + throw new FcliTechnicalException("Error retrieving vulnerabilities for release", e); + } + return result; + } + /** * Resolve a status value (developer/auditor) against one or more FoD attribute picklists. * Returns the canonical picklist name when found, or throws a FcliSimpleException listing allowed values. diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseUpdateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseUpdateCommand.java index d2560bc1aa0..8649b783452 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseUpdateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/release/cli/cmd/FoDReleaseUpdateCommand.java @@ -12,13 +12,10 @@ */ package com.fortify.cli.fod.release.cli.cmd; -import java.util.ArrayList; -import java.util.Map; - import org.apache.commons.lang3.StringUtils; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.common.cli.mixin.CommonOptionMixins; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; @@ -28,7 +25,6 @@ import com.fortify.cli.fod._common.util.FoDEnums; import com.fortify.cli.fod.app.cli.mixin.FoDSdlcStatusTypeOptions; import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeUpdateOptions; -import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; import com.fortify.cli.fod.release.cli.mixin.FoDReleaseByQualifiedNameOrIdResolverMixin; import com.fortify.cli.fod.release.helper.FoDReleaseDescriptor; @@ -45,9 +41,10 @@ @Command(name = OutputHelperMixins.Update.CMD_NAME) public class FoDReleaseUpdateCommand extends AbstractFoDJsonNodeOutputCommand implements IRecordTransformer, IActionCommandResultSupplier { @Getter @Mixin private OutputHelperMixins.Update outputHelper; - private final ObjectMapper objectMapper = new ObjectMapper(); @Mixin private FoDDelimiterMixin delimiterMixin; // Is automatically injected in resolver mixins @Mixin private FoDReleaseByQualifiedNameOrIdResolverMixin.PositionalParameter releaseResolver; + @Mixin private CommonOptionMixins.AutoRequiredAttrsOption autoRequiredAttrsOption; + @Option(names = {"--name", "-n"}) private String releaseName; @@ -59,22 +56,16 @@ public class FoDReleaseUpdateCommand extends AbstractFoDJsonNodeOutputCommand im @Mixin private FoDSdlcStatusTypeOptions.OptionalOption sdlcStatus; - @Mixin + @Mixin private FoDAttributeUpdateOptions.OptionalAttrOption appAttrsUpdate; @Override public JsonNode getJsonNode(UnirestInstance unirest) { FoDReleaseDescriptor releaseDescriptor = releaseResolver.getReleaseDescriptor(unirest); - ArrayList releaseAttrsCurrent = releaseDescriptor.getAttributes(); FoDSdlcStatusTypeOptions.FoDSdlcStatusType sdlcStatusTypeNew = sdlcStatus.getSdlcStatusType(); - Map attributeUpdates = appAttrsUpdate.getAttributes(); - JsonNode jsonAttrs = objectMapper.createArrayNode(); - if (attributeUpdates != null && !attributeUpdates.isEmpty()) { - jsonAttrs = FoDAttributeHelper.mergeAttributesNode(unirest, FoDEnums.AttributeTypes.Release, - releaseAttrsCurrent, attributeUpdates); - } else { - jsonAttrs = FoDAttributeHelper.getAttributesNode(FoDEnums.AttributeTypes.Release, releaseAttrsCurrent); - } + JsonNode jsonAttrs = FoDAttributeHelper.getAttributesNodeForUpdate(unirest, FoDEnums.AttributeTypes.Release, + releaseDescriptor.getAttributes(), appAttrsUpdate.getAttributes(), autoRequiredAttrsOption.isAutoRequiredAttrs()); + FoDReleaseUpdateRequest appRelUpdateRequest = FoDReleaseUpdateRequest.builder() .releaseName(StringUtils.isNotBlank(releaseName) ? getUnqualifiedReleaseName(releaseName, releaseDescriptor) : releaseDescriptor.getReleaseName()) .releaseDescription(StringUtils.isNotBlank(description) ? description : releaseDescriptor.getReleaseDescription()) @@ -85,7 +76,7 @@ public JsonNode getJsonNode(UnirestInstance unirest) { return FoDReleaseHelper.updateRelease(unirest, releaseDescriptor.getReleaseId(), appRelUpdateRequest).asJsonNode(); } - + private String getUnqualifiedReleaseName(String potentialQualifiedName, FoDReleaseDescriptor descriptor) { if ( StringUtils.isBlank(potentialQualifiedName) ) { return null; } var delim = delimiterMixin.getDelimiter(); @@ -113,4 +104,4 @@ public String getActionCommandResult() { public boolean isSingular() { return true; } -} +} \ No newline at end of file diff --git a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties index b494725f02c..411e3cfbd51 100644 --- a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties +++ b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties @@ -325,6 +325,8 @@ fcli.fod.app.update.criticality = The business criticality of the application. fcli.fod.app.update.attrs = Set of application attribute id's or names and their values to set. \ Please note for user attributes only the userId is currently supported. Release attributes \ may be provided but will be ignored. +fcli.fod.app.update.auto-required-attrs = For each mandatory application attribute that does not already \ + have a value, read the default value from the server and set it automatically. fcli.fod.app.list-scans.usage.header = List scans for a given application. # fcli fod microservice @@ -408,6 +410,8 @@ fcli.fod.release.update.status = SDLC lifecycle status of the release. Valid val fcli.fod.release.update.attrs = Set of release attribute id's or names and their values to set on the release. \ Please note for user attributes only the userId is currently supported. Application attributes may be provided \ but will be ignored. +fcli.fod.release.update.auto-required-attrs = For each mandatory release attribute that does not already \ + have a value, read the default value from the server and set it automatically. fcli.fod.release.list-assessment-types.usage.header = List assessment types for a given release. fcli.fod.release.list-assessment-types.scan-types = Comma-separated list of scan types for which to list assessment types. Default value: ${DEFAULT-VALUE}. Valid values: ${COMPLETION-CANDIDATES}. fcli.fod.release.list-scans.usage.header = List scans for a given release. @@ -885,8 +889,8 @@ fcli.fod.issue.output.table.header.totalCount = Total Issues fcli.fod.issue.output.table.header.updateCount = Issues Updated fcli.fod.issue.output.table.header.skippedCount = Issues Skipped fcli.fod.issue.output.table.header.errorCount = Errors -fcli.fod.issue.list.usage.header = List vulnerabilities. -fcli.fod.issue.list.usage.description = This command allows for listing FoD vulnerability data \ +fcli.fod.issue.list.usage.header = List issues (vulnerabilities). +fcli.fod.issue.list.usage.description = This command allows for listing FoD issue data \ for a given application or release. By default, only visible issues will be returned; the --include option can \ be used to (also) include suppressed or fixed issues. If any such issues are included, the \ default table output will show (S) and/or (F) for respectively suppressed and fixed issues. \ @@ -918,17 +922,20 @@ fcli.fod.issue.list.includeIssue = By default, only visible issues will be retur for example `--include visible,fixed` (to return both visible and fixed issues) or `--include \ fixed` (to return only fixed issues). Allowed values: ${COMPLETION-CANDIDATES}. fcli.fod.issue.list.aggregate = Include aggregation data. -fcli.fod.issue.update.usage.header = Bulk update vulnerabilities. +fcli.fod.issue.update.usage.header = Bulk update issues (vulnerabilities). fcli.fod.issue.update.usage.description = This command allows for updating the audit information \ - for multiple vulnerabilities. Note: for "vuln-ids" you can use either the numeric Id as shown in the FOD UI, \ - or the "vulnId" UUID field that is retrieved using the `fcli fod issue ls` command. -fcli.fod.issue.update.user = The username or user id of the user the update will be recorded as. -fcli.fod.issue.update.dev-status = The Developer Status to set for the vulnerabilities, see the FoD UI for valid values. -fcli.fod.issue.update.auditor-status = The Auditor Status to set for the vulnerabilities, see the FoD UI for valid values. -fcli.fod.issue.update.severity = The Severity to set for the vulnerabilities. Allowed values: ${COMPLETION-CANDIDATES}. -fcli.fod.issue.update.comment = A comment to apply to all of the vulnerabilities that are updated. -fcli.fod.issue.update.vuln-ids = Comma separated list of the vulnerability ids to be updated. -fcli.fod.issue.update.attributes = A comma separated list of "attributeName=attributeValue" pairs to set on the vulnerabilities. \ + for multiple issues (vulnerabilities). Note: for --vuln-ids/--issue-ids, you can use either the numeric Id as shown in the FoD UI, \ + or the "vulnId" UUID field that is retrieved using the `fcli fod issue ls` command. Alternatively, \ + use --include-all/--all to update all issues from the specified release without providing issue ids. +fcli.fod.issue.update.user = The username or user id of the user to assign the issue to. +fcli.fod.issue.update.dev-status = The Developer Status to set for the issues, see the FoD UI for valid values. +fcli.fod.issue.update.auditor-status = The Auditor Status to set for the issues, see the FoD UI for valid values. +fcli.fod.issue.update.severity = The Severity to set for the issues. Allowed values: ${COMPLETION-CANDIDATES}. +fcli.fod.issue.update.comment = A comment to apply to all of the issues that are updated. +fcli.fod.issue.update.vuln-ids = Comma separated list of the issue ids to be updated. +fcli.fod.issue.update.include-all = Include all the issues from the specified release in the update. \ + %nWARNING: This will update all issues in the specified release regardless of their current status, so use with caution. +fcli.fod.issue.update.attributes = A comma separated list of "attributeName=attributeValue" pairs to set on the issues. \ It is recommended to provide attribute names as they appear in the FoD UI. For example: "Business Criticality=High,Attribute 2=Value". \ You can also use multiple --attributes options to provide more "attributeName=attributeValue" pairs. diff --git a/fcli-other/fcli-doc/src/docs/asciidoc/versioned/index.adoc b/fcli-other/fcli-doc/src/docs/asciidoc/versioned/index.adoc index 6eaea9abeb7..35231a96ca9 100644 --- a/fcli-other/fcli-doc/src/docs/asciidoc/versioned/index.adoc +++ b/fcli-other/fcli-doc/src/docs/asciidoc/versioned/index.adoc @@ -591,6 +591,10 @@ Fcli allows for configuring trust store settings through the `fcli config trusts * `FCLI_TRUSTSTORE`: Absolute path to the trust store file to use * `FCLI_TRUSTSTORE_TYPE`: Type of the trust store, usually JKS, which is the default type if not specified * `FCLI_TRUSTSTORE_PWD`: Trust store password +* `FCLI_DISABLE_OS_TRUSTSTORE`: Set to `true` or `1` to disable automatic merge with OS trust store certificates + +When configuring trust stores through `fcli config truststore set`, use `--no-os-truststore` to persistently disable automatic +OS trust store certificate merge for that configured trust store. === Fcli User Home Folder diff --git a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/config/ConfigTrustStoreSpec.groovy b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/config/ConfigTrustStoreSpec.groovy index d1ff88d559d..45d56e17da4 100644 --- a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/config/ConfigTrustStoreSpec.groovy +++ b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/config/ConfigTrustStoreSpec.groovy @@ -37,7 +37,7 @@ class ConfigTrustStoreSpec extends FcliBaseSpec { } def "set"() { - def args = "config truststore set -f ${dummyStore}" + def args = "config truststore set -f ${dummyStore} --no-os-truststore" when: def result = Fcli.run(args) then: @@ -52,7 +52,7 @@ class ConfigTrustStoreSpec extends FcliBaseSpec { def result = Fcli.run(args) then: verifyAll(result.stdout) { - // TODO + it.any { it == "useOsTrustStore: false" } } } @@ -75,4 +75,4 @@ class ConfigTrustStoreSpec extends FcliBaseSpec { // TODO } } -} \ No newline at end of file +}