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 8a1c339eb0d..fa3fa4e7f0a 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 @@ -600,7 +600,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":"setSuppression_exclusions","parameterTypes":["java.util.List"] }, {"name":"setTag_id","parameterTypes":["java.lang.String"] }] + "methods":[{"name":"","parameterTypes":[] }, {"name":"setDast","parameterTypes":["com.fortify.cli.aviator.config.TagMappingConfig$ProductMapping"] }, {"name":"setMapping","parameterTypes":["com.fortify.cli.aviator.config.TagMappingConfig$Mapping"] }, {"name":"setSast","parameterTypes":["com.fortify.cli.aviator.config.TagMappingConfig$ProductMapping"] }, {"name":"setSuppression_exclusions","parameterTypes":["java.util.List"] }, {"name":"setTag_id","parameterTypes":["java.lang.String"] }] }, { "name":"com.fortify.cli.aviator.config.TagMappingConfig$Mapping", @@ -631,6 +631,13 @@ { "name":"com.fortify.cli.aviator.config.TagMappingConfig$MappingCustomizer" }, + { + "name":"com.fortify.cli.aviator.config.TagMappingConfig$ProductMapping", + "allDeclaredConstructors":true, + "allDeclaredFields":true, + "queryAllPublicMethods":true, + "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$Result", "allDeclaredConstructors": 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 d42d5a366a6..421d583441c 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 @@ -43,7 +43,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":"setSuppression_exclusions","parameterTypes":["java.util.List"] }, {"name":"setTag_id","parameterTypes":["java.lang.String"] }] + "methods":[{"name":"","parameterTypes":[] }, {"name":"setDast","parameterTypes":["com.fortify.cli.aviator.config.TagMappingConfig$ProductMapping"] }, {"name":"setMapping","parameterTypes":["com.fortify.cli.aviator.config.TagMappingConfig$Mapping"] }, {"name":"setSast","parameterTypes":["com.fortify.cli.aviator.config.TagMappingConfig$ProductMapping"] }, {"name":"setSuppression_exclusions","parameterTypes":["java.util.List"] }, {"name":"setTag_id","parameterTypes":["java.lang.String"] }] }, { "name":"com.fortify.cli.aviator.config.TagMappingConfig$Mapping", @@ -74,6 +74,13 @@ { "name":"com.fortify.cli.aviator.config.TagMappingConfig$MappingCustomizer" }, + { + "name":"com.fortify.cli.aviator.config.TagMappingConfig$ProductMapping", + "allDeclaredConstructors":true, + "allDeclaredFields":true, + "queryAllPublicMethods":true, + "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$Result", "allDeclaredConstructors": true, diff --git a/fcli-core/fcli-app/src/test/java/com/fortify/cli/NativeReflectConfigTest.java b/fcli-core/fcli-app/src/test/java/com/fortify/cli/NativeReflectConfigTest.java index 671e032646f..c61a672e005 100644 --- a/fcli-core/fcli-app/src/test/java/com/fortify/cli/NativeReflectConfigTest.java +++ b/fcli-core/fcli-app/src/test/java/com/fortify/cli/NativeReflectConfigTest.java @@ -38,6 +38,7 @@ class NativeReflectConfigTest { 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$ProductMapping", "com.fortify.cli.aviator.config.TagMappingConfig$Tier", "com.fortify.cli.aviator.config.TagMappingConfig$Result"); diff --git a/fcli-core/fcli-aviator-common/build.gradle.kts b/fcli-core/fcli-aviator-common/build.gradle.kts index f68eabca1cb..5049e10a888 100644 --- a/fcli-core/fcli-aviator-common/build.gradle.kts +++ b/fcli-core/fcli-aviator-common/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("fcli.module-conventions") id("java-library") + id("java-test-fixtures") id("com.google.protobuf") } @@ -13,6 +14,7 @@ tasks.withType().configureEach { dependsOn("generateProto") } dependencies { implementation(project(":fcli-core:fcli-common-core")) + testFixturesImplementation(project(":fcli-core:fcli-common-core")) implementation("org.yaml:snakeyaml:2.3") // JAXB for XML object marshalling (used in FVDLProcessor legacy parser) diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/cli/mixin/AbstractApplyRemediationsOptionsMixin.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/cli/mixin/AbstractApplyRemediationsOptionsMixin.java new file mode 100644 index 00000000000..ed230b8f17f --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/cli/mixin/AbstractApplyRemediationsOptionsMixin.java @@ -0,0 +1,93 @@ +/* + * 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._common.cli.mixin; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.apache.commons.lang3.StringUtils; + +import com.fortify.cli.aviator._common.remediations_cache.IApplyRemediationsOptions; +import com.fortify.cli.aviator.fpr.remediation.RemediationExecutionMode; +import com.fortify.cli.aviator.fpr.utils.ISourceDecoder; +import com.fortify.cli.common.exception.FcliSimpleException; + +import lombok.Getter; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +/** + * Abstract base for apply-remediations options. Provides shared CLI options and validation template method. + * Product-specific subclasses declare source-selection mixins and implement validation hooks. + */ +@Getter +public abstract class AbstractApplyRemediationsOptionsMixin implements IApplyRemediationsOptions { + @Mixin + private SourceEncodingsMixin sourceEncodingsMixin = new SourceEncodingsMixin(); + + @Option(names = {"--source-dir"}) + private String sourceCodeDirectory = System.getProperty("user.dir"); + + @Option(names = {"--issue-ids"}, split = ",") + private List issueIds; + + @Option(names = {"--preview"}) + private boolean previewMode = false; + + public RemediationExecutionMode executionMode() { + return previewMode ? RemediationExecutionMode.PREVIEW : RemediationExecutionMode.APPLY; + } + + @Override + public ISourceDecoder getSourceDecoder() { + return sourceEncodingsMixin.getSourceDecoder(); + } + + /** + * Validates all options by calling validation hooks in order. + * Template method: ensures consistent validation sequence across SSC and FoD. + */ + @Override + public final void validate() { + validateSourceSelection(); + validateSourceDir(); + validateIssueIdsConstraints(); + } + + /** Hook for product-specific source selection validation (--from-cache vs online selection). */ + protected abstract void validateSourceSelection(); + + /** Hook to determine if --from-cache is selected (needed for --issue-ids constraint validation). */ + protected abstract boolean isCacheMode(); + + private void validateSourceDir() { + FcliSimpleException.throwIf( + StringUtils.isBlank(sourceCodeDirectory), + "--source-dir must specify a valid directory path"); + Path path = Path.of(sourceCodeDirectory); + FcliSimpleException.throwIf( + !Files.exists(path) || !Files.isDirectory(path), + "--source-dir path does not exist or is not a directory: %s", sourceCodeDirectory); + FcliSimpleException.throwIf( + !Files.isReadable(path), + "--source-dir path is not accessible: %s", sourceCodeDirectory); + } + + private void validateIssueIdsConstraints() { + FcliSimpleException.throwIf( + issueIds != null && !issueIds.isEmpty() && !isCacheMode(), + "--issue-ids can only be used with --from-cache; " + + "create a cache with download-remediations-cache and rerun with --from-cache"); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/config/AviatorConfigManager.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/config/AviatorConfigManager.java index 6787f8a4d0f..e231af243f4 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/config/AviatorConfigManager.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/config/AviatorConfigManager.java @@ -91,4 +91,8 @@ public TagMappingConfig getDefaultTagMappingConfig() { } return defaultTagMappingConfig; } + + public TagMappingConfig getDefaultDastTagMappingConfig() { + return getDefaultTagMappingConfig(); + } } \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/exception/UnsupportedAviatorUrlSchemeException.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/exception/UnsupportedAviatorUrlSchemeException.java new file mode 100644 index 00000000000..07cb8e97da8 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/exception/UnsupportedAviatorUrlSchemeException.java @@ -0,0 +1,42 @@ +/* + * 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._common.exception; + +/** + * User-facing rejection of a non-{@code https} Aviator target scheme. + *

+ * Typed so diagnose can map endpoint UX without parsing exception message text. + */ +public class UnsupportedAviatorUrlSchemeException extends AviatorSimpleException { + private static final long serialVersionUID = 1L; + + public static final String STAGE_SUMMARY = "Unsupported URL scheme"; + public static final String STAGE_GUIDANCE = "Use a supported Aviator URL (https://host[:port])"; + + private final String scheme; + private final String providedUrl; + + public UnsupportedAviatorUrlSchemeException(String scheme, String providedUrl) { + super(STAGE_SUMMARY+" '"+scheme+"'. "+STAGE_GUIDANCE+". Provided URL: "+providedUrl); + this.scheme = scheme; + this.providedUrl = providedUrl; + } + + public String getScheme() { + return scheme; + } + + public String getProvidedUrl() { + return providedUrl; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/output/cli/cmd/AbstractAviatorApplyRemediationsCommand.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/output/cli/cmd/AbstractAviatorApplyRemediationsCommand.java new file mode 100644 index 00000000000..d866daf1f43 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/output/cli/cmd/AbstractAviatorApplyRemediationsCommand.java @@ -0,0 +1,66 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.output.cli.cmd; + +import java.util.Set; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.aviator._common.cli.mixin.AbstractApplyRemediationsOptionsMixin; +import com.fortify.cli.aviator._common.remediations_cache.IRemediationsFprSource; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsApplyHelper; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsApplyHelper.ApplyResult; +import com.fortify.cli.aviator._common.util.AviatorIssueIdFilterUtils; +import com.fortify.cli.aviator.config.AviatorLoggerImpl; +import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; +import com.fortify.cli.common.output.cli.cmd.IJsonNodeSupplier; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; +import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; +import com.fortify.cli.common.progress.helper.IProgressWriter; + +import lombok.Getter; +import picocli.CommandLine.Mixin; + +/** + * Abstract base command for applying remediations. Orchestrates validation, FPR source acquisition, + * and remediation application. Product-specific subclasses provide options mixin and implement hooks. + */ +public abstract class AbstractAviatorApplyRemediationsCommand extends AbstractOutputCommand + implements IJsonNodeSupplier { + + @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; + @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; + + /** Subclasses declare their product-specific options mixin (SSC or FoD). */ + protected abstract AbstractApplyRemediationsOptionsMixin getApplyOptions(); + + @Override + public final JsonNode getJsonNode() { + AbstractApplyRemediationsOptionsMixin applyOptions = getApplyOptions(); + applyOptions.validate(); + Set issueIdFilter = AviatorIssueIdFilterUtils.normalizeIssueIds(applyOptions.getIssueIds()); + try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { + AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); + try (IRemediationsFprSource fprSource = openFprSource(logger, progressWriter)) { + ApplyResult result = RemediationsApplyHelper.apply(fprSource, applyOptions, issueIdFilter, logger); + return buildResultNode(fprSource, result, issueIdFilter); + } + } + } + + protected abstract IRemediationsFprSource openFprSource(AviatorLoggerImpl logger, IProgressWriter progressWriter); + + protected abstract JsonNode buildResultNode(IRemediationsFprSource fprSource, ApplyResult result, Set issueIdFilter); + + @Override + public final boolean isSingular() { return true; } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/CacheRemediationsFprSource.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/CacheRemediationsFprSource.java new file mode 100644 index 00000000000..e6b90a040ff --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/CacheRemediationsFprSource.java @@ -0,0 +1,82 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.remediations_cache; + +import java.nio.file.Path; +import java.util.List; + +import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheReader.ResolvedFpr; +import com.fortify.cli.common.exception.FcliBugException; + +/** + * Offline remediations source: ordered FPR paths from a remediations cache zip. + * Product identity (and thus artifact vs release id) comes from the validated entry model. + */ +public final class CacheRemediationsFprSource implements IRemediationsFprSource { + private final RemediationsCacheReader reader; + + private CacheRemediationsFprSource(RemediationsCacheReader reader) { + this.reader = reader; + } + + public static CacheRemediationsFprSource open(Path cacheZip, String expectedProduct) { + RemediationsCacheReader reader = RemediationsCacheReader.open(cacheZip); + try { + reader.requireProduct(expectedProduct); + return new CacheRemediationsFprSource(reader); + } catch (RuntimeException e) { + reader.close(); + throw e; + } + } + + public RemediationsCacheReader reader() { + return reader; + } + + @Override + public void forEachEntry(EntryAction action) { + List units = reader.getOrderedResolvedFprs(); + int total = units.size(); + for (int i = 0; i < total; i++) { + ResolvedFpr unit = units.get(i); + RemediationsCacheEntry entry = unit.entry(); + String label = entry.getPath() != null + ? entry.getPath() + : unit.fprPath().getFileName().toString(); + if (!action.accept(unit.fprPath(), label, productId(entry), i + 1, total)) { + break; + } + } + } + + /** + * Id for progress/result lists: artifactId (SSC) or releaseId (FoD). + * Entries are validated before resolve, so exactly one product block is present. + */ + private static String productId(RemediationsCacheEntry entry) { + if (entry.getSscData() != null) { + return entry.getSscData().getArtifactId(); + } + if (entry.getFodData() != null) { + return entry.getFodData().getReleaseId(); + } + throw new FcliBugException( + "Remediations cache entry missing product data after validation: " + entry.getPath()); + } + + @Override + public void close() { + reader.close(); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/IApplyRemediationsOptions.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/IApplyRemediationsOptions.java new file mode 100644 index 00000000000..1fccbfb1a0d --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/IApplyRemediationsOptions.java @@ -0,0 +1,27 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.remediations_cache; + +import java.util.List; + +import com.fortify.cli.aviator.fpr.utils.ISourceDecoder; + +/** Abstraction over the shared apply-remediations CLI options, allowing RemediationsApplyHelper + * to remain independent of concrete Picocli types. */ +public interface IApplyRemediationsOptions { + String getSourceCodeDirectory(); + List getIssueIds(); + ISourceDecoder getSourceDecoder(); + boolean isPreviewMode(); + void validate(); +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/IRemediationsFprSource.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/IRemediationsFprSource.java new file mode 100644 index 00000000000..150c8cfdcea --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/IRemediationsFprSource.java @@ -0,0 +1,47 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.remediations_cache; + +import java.nio.file.Path; + +/** + * Source of audited FPR files for apply-remediations (cache zip or online download). + * Implementations own any resources (zip FS, temp files) until {@link #close()}. + * + *

Paths passed to {@link EntryAction#accept} are valid only for the duration of that call + * (online sources may download to a temp file and delete it afterward). + */ +public interface IRemediationsFprSource extends AutoCloseable { + + /** + * Invokes {@code action} for each FPR in order. Returning {@code false} from the action + * stops iteration early (for example when an issue-id filter is exhausted). + */ + void forEachEntry(EntryAction action); + + @Override + void close(); + + @FunctionalInterface + interface EntryAction { + /** + * @param fprPath host-readable FPR path (zip entry or temp file) + * @param label human-readable label for progress/logs + * @param id artifact id, release id, or empty + * @param index 1-based index + * @param total total entries + * @return {@code false} to stop processing further entries + */ + boolean accept(Path fprPath, String label, String id, int index, int total); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsApplyHelper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsApplyHelper.java new file mode 100644 index 00000000000..7b590e69001 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsApplyHelper.java @@ -0,0 +1,128 @@ +/* + * 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._common.remediations_cache; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; +import com.fortify.cli.aviator._common.util.AviatorRemediationMetricsHelper; +import com.fortify.cli.aviator.applyRemediation.ApplyAutoRemediationOnSource; +import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.aviator.fpr.remediation.RemediationExecutionMode; +import com.fortify.cli.aviator.fpr.remediation.RemediationProcessingOptions; +import com.fortify.cli.aviator.fpr.remediation.model.RemediationMetric; +import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.common.exception.FcliTechnicalException; + +import lombok.extern.slf4j.Slf4j; + +/** + * Single apply-remediations loop for any {@link IRemediationsFprSource} + * (cache zip entries or online downloads). Soft-skips on {@link AviatorSimpleException}. + */ +@Slf4j +public final class RemediationsApplyHelper { + private RemediationsApplyHelper() {} + + public record ApplyResult( + List processedEntries, + List processedIds, + int skipped, + List metrics) {} + + /** + * Applies or previews remediations for each FPR source entry until done or the issue-id filter is exhausted. + * Caller owns {@code fprSource} lifecycle (try-with-resources). + */ + public static ApplyResult apply( + IRemediationsFprSource fprSource, + IApplyRemediationsOptions options, + Set issueIdFilter, + IAviatorLogger logger) { + Accumulator acc = new Accumulator(issueIdFilter); + fprSource.forEachEntry((fprPath, label, id, index, total) -> { + if (acc.remaining != null && acc.remaining.isEmpty()) { + return false; + } + RemediationMetric metric = applyOne( + fprPath, label, index, total, options, logger, acc.remaining); + if (metric == null) { + acc.skipped++; + } else { + acc.metrics.add(metric); + acc.processedEntries.add(label); + acc.processedIds.add(id != null ? id : ""); + acc.remaining = AviatorRemediationMetricsHelper.getRemainingIssueIds(acc.remaining, metric); + } + return true; + }); + return acc.toResult(); + } + + /** @see AviatorRemediationMetricsHelper#actionLabel(RemediationMetric) */ + public static String actionLabel(RemediationMetric metric) { + return AviatorRemediationMetricsHelper.actionLabel(metric); + } + + private static RemediationMetric applyOne( + Path fprPath, + String entryLabel, + int index, + int total, + IApplyRemediationsOptions options, + IAviatorLogger logger, + Set issueFilter) { + boolean previewMode = options.isPreviewMode(); + logger.progress("Processing FPR " + index + "/" + total + " (" + entryLabel + ")"); + logger.progress("Status: Processing FPR with Aviator for " + (previewMode ? "Previewing" : "Applying") + " Auto Remediations"); + try (FprHandle fprHandle = new FprHandle(fprPath)) { + RemediationProcessingOptions processingOptions = new RemediationProcessingOptions( + issueFilter, + previewMode ? RemediationExecutionMode.PREVIEW : RemediationExecutionMode.APPLY, + options.getSourceDecoder()); + return ApplyAutoRemediationOnSource.applyRemediations( + fprHandle, options.getSourceCodeDirectory(), logger, processingOptions); + } catch (AviatorSimpleException e) { + log.warn("Skipping entry {} as {}", entryLabel, e.getMessage()); + return null; + } catch (IOException e) { + throw new FcliTechnicalException("Failed to close FPR handle for entry " + entryLabel, e); + } + } + + /** Sequential-loop state (not concurrent — avoids Atomic* only to satisfy lambda capture rules). */ + private static final class Accumulator { + private final List metrics = new ArrayList<>(); + private final List processedEntries = new ArrayList<>(); + private final List processedIds = new ArrayList<>(); + private int skipped; + private Set remaining; + + private Accumulator(Set issueIdFilter) { + this.remaining = issueIdFilter == null ? null : new LinkedHashSet<>(issueIdFilter); + } + + private ApplyResult toResult() { + return new ApplyResult( + List.copyOf(processedEntries), + List.copyOf(processedIds), + skipped, + List.copyOf(metrics)); + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheConstants.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheConstants.java new file mode 100644 index 00000000000..26a4293de3f --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheConstants.java @@ -0,0 +1,24 @@ +/* + * 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._common.remediations_cache; + +public final class RemediationsCacheConstants { + public static final int SCHEMA_VERSION = 1; + public static final String KIND = "aviator-remediations-cache"; + public static final String MANIFEST_ENTRY = "manifest.json"; + public static final String FPRS_DIR = "fprs"; + public static final String PRODUCT_SSC = "ssc"; + public static final String PRODUCT_FOD = "fod"; + + private RemediationsCacheConstants() {} +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheEntry.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheEntry.java new file mode 100644 index 00000000000..2a5d1f5d5b9 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheEntry.java @@ -0,0 +1,151 @@ +/* + * 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._common.remediations_cache; + +import java.nio.file.Path; + +import org.apache.commons.lang3.StringUtils; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.exception.FcliSimpleException; + +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Wire model for one FPR in a remediations cache manifest. + * Product identity is nested ({@link #sscData} or {@link #fodData}), not sibling + * optional ids on this type. Prefer {@link #forSsc} / {@link #forFod} with a product model + * from {@link SSCData#of} / {@link FoDData#of}; do not add {@code @Builder} on this + * {@code @Reflectable} class. + */ +@Reflectable +@Data +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class RemediationsCacheEntry { + private int order; + private String path; + private String sha256; + private SSCData sscData; + private FoDData fodData; + + /** + * Factory for SSC cache writers (not Jackson). Assigns shared fields and the provided + * SSC product block; product field construction belongs on {@link SSCData#of}. + */ + public static RemediationsCacheEntry forSsc(int order, String path, String sha256, SSCData sscData) { + FcliSimpleException.throwIf(sscData == null, "sscData is required for an SSC remediations cache entry"); + RemediationsCacheEntry entry = new RemediationsCacheEntry(); + entry.setOrder(order); + entry.setPath(path); + entry.setSha256(sha256); + entry.setSscData(sscData); + return entry; + } + + /** + * Factory for FoD cache writers (not Jackson). Assigns shared fields and the provided + * FoD product block; product field construction belongs on {@link FoDData#of}. + */ + public static RemediationsCacheEntry forFod(int order, String path, String sha256, FoDData fodData) { + FcliSimpleException.throwIf(fodData == null, "fodData is required for a FoD remediations cache entry"); + RemediationsCacheEntry entry = new RemediationsCacheEntry(); + entry.setOrder(order); + entry.setPath(path); + entry.setSha256(sha256); + entry.setFodData(fodData); + return entry; + } + + /** Validates structural invariants (no cache path context). */ + public void validate() { + validate(null); + } + + /** + * Validates structural invariants of this entry (path, checksum, exactly one product block). + * Product-vs-manifest consistency is checked by {@link RemediationsCacheManifest#validate(Path)}. + * + * @param cacheZip optional cache path included in error messages for diagnostics + */ + public void validate(Path cacheZip) { + String where = where(cacheZip); + FcliSimpleException.throwIf(StringUtils.isBlank(path), + "Remediations cache entry is missing path%s", where); + FcliSimpleException.throwIf(StringUtils.isBlank(sha256), + "Remediations cache entry is missing sha256: %s%s", path, where); + boolean hasSsc = sscData != null; + boolean hasFod = fodData != null; + FcliSimpleException.throwIf(hasSsc == hasFod, + "Remediations cache entry must have exactly one of sscData or fodData: %s%s", path, where); + if (hasSsc) { + sscData.validate(path, where); + } else { + fodData.validate(path, where); + } + } + + private static String where(Path cacheZip) { + return cacheZip != null ? " (" + cacheZip + ")" : ""; + } + + /** SSC-specific fields for one cache entry. */ + @Reflectable + @Data + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class SSCData { + private String artifactId; + private String uploadDate; + + /** Factory for writers (not Jackson). */ + public static SSCData of(String artifactId, String uploadDate) { + SSCData ssc = new SSCData(); + ssc.setArtifactId(artifactId); + ssc.setUploadDate(uploadDate); + return ssc; + } + + void validate(String entryPath, String where) { + FcliSimpleException.throwIf(StringUtils.isBlank(artifactId), + "Remediations cache SSC entry is missing artifactId: %s%s", entryPath, where); + } + } + + /** FoD-specific fields for one cache entry. */ + @Reflectable + @Data + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class FoDData { + private String releaseId; + + /** Factory for writers (not Jackson). */ + public static FoDData of(String releaseId) { + FoDData fod = new FoDData(); + fod.setReleaseId(releaseId); + return fod; + } + + void validate(String entryPath, String where) { + FcliSimpleException.throwIf(StringUtils.isBlank(releaseId), + "Remediations cache FoD entry is missing releaseId: %s%s", entryPath, where); + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheManifest.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheManifest.java new file mode 100644 index 00000000000..8f6164d82ea --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheManifest.java @@ -0,0 +1,87 @@ +/* + * 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._common.remediations_cache; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.exception.FcliSimpleException; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Reflectable +@Data +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class RemediationsCacheManifest { + private int schemaVersion; + private String kind; + private String product; + private String createdAt; + private Map selection = new LinkedHashMap<>(); + private List entries = new ArrayList<>(); + + /** Validates schema, product, and every entry (no cache path context). */ + public void validate() { + validate(null); + } + + /** + * Validates schema, product, and every entry (including product-block consistency). + * Call after Jackson load and before publishing a written cache. + * + * @param cacheZip optional cache path included in error messages for diagnostics + */ + public void validate(Path cacheZip) { + String where = cacheZip != null ? ": " + cacheZip : ""; + FcliSimpleException.throwIf(schemaVersion != RemediationsCacheConstants.SCHEMA_VERSION, + "Unsupported remediations cache schemaVersion %s (expected %s)%s", + schemaVersion, RemediationsCacheConstants.SCHEMA_VERSION, where); + FcliSimpleException.throwIf(!RemediationsCacheConstants.KIND.equals(kind), + "Invalid remediations cache kind '%s' (expected %s)%s", + kind, RemediationsCacheConstants.KIND, where); + FcliSimpleException.throwIf(StringUtils.isBlank(product), + "Remediations cache manifest is missing product%s", where); + boolean sscProduct = RemediationsCacheConstants.PRODUCT_SSC.equals(product); + boolean fodProduct = RemediationsCacheConstants.PRODUCT_FOD.equals(product); + FcliSimpleException.throwIf(!sscProduct && !fodProduct, + "Unsupported remediations cache product '%s' (expected %s or %s)%s", + product, RemediationsCacheConstants.PRODUCT_SSC, RemediationsCacheConstants.PRODUCT_FOD, where); + FcliSimpleException.throwIf(entries == null || entries.isEmpty(), + "Remediations cache has no entries%s", where); + for (RemediationsCacheEntry entry : entries) { + FcliSimpleException.throwIf(entry == null, + "Remediations cache contains a null entry%s", where); + entry.validate(cacheZip); + if (sscProduct) { + FcliSimpleException.throwIf(entry.getSscData() == null, + "SSC remediations cache entry is missing sscData: %s%s", + entry.getPath(), where); + } else { + FcliSimpleException.throwIf(entry.getFodData() == null, + "FoD remediations cache entry is missing fodData: %s%s", + entry.getPath(), where); + } + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheReader.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheReader.java new file mode 100644 index 00000000000..d577f8c4e85 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheReader.java @@ -0,0 +1,198 @@ +/* + * 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._common.remediations_cache; + +import java.io.IOException; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.cli.common.exception.AbstractFcliException; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.exception.FcliTechnicalException; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.util.ZipHelper; + +import lombok.Getter; + +/** + * Opens a remediations cache zip as a {@link FileSystem} and exposes manifest data and + * ordered FPR paths. Construction only validates the zip path and opens the filesystem; + * manifest/entry validation happens lazily via getters so a single try-with-resources on + * this reader owns {@code cacheFs} cleanup. + */ +public final class RemediationsCacheReader implements AutoCloseable { + private static final Logger logger = LoggerFactory.getLogger(RemediationsCacheReader.class); + + private final Path cacheZip; + private final FileSystem cacheFs; + + @Getter(lazy = true) + private final RemediationsCacheManifest manifest = loadAndValidateManifest(); + + @Getter(lazy = true) + private final List orderedEntries = loadOrderedEntries(); + + @Getter(lazy = true) + private final List orderedResolvedFprs = loadOrderedResolvedFprs(); + + @Getter(lazy = true) + private final List orderedFprPaths = loadOrderedFprPaths(); + + /** Manifest entry paired with its validated ZipFS path (same order as apply). */ + public record ResolvedFpr(RemediationsCacheEntry entry, Path fprPath) {} + + /** + * Validates {@code cacheZip} and opens it as a zip file system. Prefer use via + * try-with-resources so {@link #close()} always runs. + */ + public RemediationsCacheReader(Path cacheZip) { + Path validated = validateCacheZip(cacheZip); + FileSystem opened; + try { + opened = ZipHelper.openZipFileSystem(validated); + } catch (AbstractFcliException e) { + throw e; + } catch (RuntimeException e) { + throw new FcliTechnicalException("Failed to open remediations cache: " + validated, e); + } + this.cacheZip = validated; + this.cacheFs = opened; + } + + /** Factory alias for call sites that prefer a static open style. */ + public static RemediationsCacheReader open(Path cacheZip) { + return new RemediationsCacheReader(cacheZip); + } + + public Path getCacheZip() { + return cacheZip; + } + + public List getOrderedEntryPaths() { + return getOrderedEntries().stream().map(RemediationsCacheEntry::getPath).toList(); + } + + /** + * Ensures the cache was produced for the expected product ({@code ssc} or {@code fod}). + */ + public void requireProduct(String expectedProduct) { + String actual = getManifest().getProduct(); + FcliSimpleException.throwIf(!expectedProduct.equals(actual), + "Remediations cache product is '%s' but this command expects '%s': %s", + actual, expectedProduct, cacheZip); + } + + @Override + public void close() { + if (cacheFs == null || !cacheFs.isOpen()) { + return; + } + try { + cacheFs.close(); + } catch (IOException e) { + logger.warn("Failed to close cache filesystem", e); + } + } + + private static Path validateCacheZip(Path cacheZip) { + FcliSimpleException.throwIf(cacheZip == null, + "--from-cache must specify a remediations cache zip path"); + FcliSimpleException.throwIf(!Files.exists(cacheZip), + "Remediations cache file does not exist: %s", cacheZip); + FcliSimpleException.throwIf(!Files.isRegularFile(cacheZip), + "Remediations cache path is not a regular file: %s", cacheZip); + FcliSimpleException.throwIf(!Files.isReadable(cacheZip), + "Remediations cache file is not readable: %s", cacheZip); + return cacheZip; + } + + private RemediationsCacheManifest loadAndValidateManifest() { + Path manifestPath = cacheFs.getPath(RemediationsCacheConstants.MANIFEST_ENTRY); + FcliSimpleException.throwIf(!Files.isRegularFile(manifestPath), + "Remediations cache is missing %s: %s", + RemediationsCacheConstants.MANIFEST_ENTRY, cacheZip); + try (var manifestInputStream = Files.newInputStream(manifestPath)) { + RemediationsCacheManifest manifest = JsonHelper.getObjectMapper() + .readValue(manifestInputStream, RemediationsCacheManifest.class); + FcliSimpleException.throwIf(manifest == null, + "Remediations cache manifest is empty: %s", cacheZip); + // Model owns field invariants; Reader only loads and delegates. + manifest.validate(cacheZip); + return manifest; + } catch (AbstractFcliException e) { + throw e; + } catch (IOException e) { + throw new FcliTechnicalException("Failed to read remediations cache manifest: " + cacheZip, e); + } catch (RuntimeException e) { + throw new FcliTechnicalException("Failed to parse remediations cache manifest: " + cacheZip, e); + } + } + + private List loadOrderedEntries() { + List entries = new ArrayList<>(getManifest().getEntries()); + entries.sort(Comparator.comparingInt(RemediationsCacheEntry::getOrder)); + return List.copyOf(entries); + } + + private List loadOrderedResolvedFprs() { + List resolved = new ArrayList<>(); + for (RemediationsCacheEntry entry : getOrderedEntries()) { + Path fprPath = resolveEntryPath(entry.getPath()); + FcliSimpleException.throwIf(!Files.isRegularFile(fprPath), + "Remediations cache entry path not found in zip: %s", entry.getPath()); + String actualSha = RemediationsCacheSha256.hashFile(fprPath); + FcliSimpleException.throwIf(!actualSha.equalsIgnoreCase(entry.getSha256()), + "SHA-256 mismatch for cache entry %s (expected %s, actual %s)", + entry.getPath(), entry.getSha256(), actualSha); + resolved.add(new ResolvedFpr(entry, fprPath)); + } + FcliSimpleException.throwIf(resolved.isEmpty(), + "Remediations cache contains no FPR entries: %s", cacheZip); + return List.copyOf(resolved); + } + + private List loadOrderedFprPaths() { + return getOrderedResolvedFprs().stream().map(ResolvedFpr::fprPath).toList(); + } + + /** + * Resolves a manifest entry path inside the zip FS. Rejects empty, absolute, and parent-escape paths. + * ZipFS keeps paths in-archive (not host zip-slip), but untrusted manifests must still stay relative. + */ + private Path resolveEntryPath(String entryPath) { + FcliSimpleException.throwIf(StringUtils.isBlank(entryPath), + "Remediations cache entry path is blank: %s", cacheZip); + String normalized = entryPath.replace('\\', '/').trim(); + while (normalized.startsWith("./")) { + normalized = normalized.substring(2); + } + FcliSimpleException.throwIf(normalized.startsWith("/") || normalized.matches("^[A-Za-z]:.*"), + "Remediations cache contains absolute path: %s", entryPath); + FcliSimpleException.throwIf(normalized.contains(".."), + "Remediations cache contains unsafe path: %s", entryPath); + Path resolved = cacheFs.getPath(normalized).normalize(); + // After normalize, parent segments must not reappear. + String resolvedStr = resolved.toString().replace('\\', '/'); + FcliSimpleException.throwIf(resolvedStr.contains("..") || resolvedStr.startsWith("/"), + "Remediations cache contains unsafe path: %s", entryPath); + return resolved; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheSha256.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheSha256.java new file mode 100644 index 00000000000..9fcf331b880 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheSha256.java @@ -0,0 +1,60 @@ +/* + * 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._common.remediations_cache; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import com.fortify.cli.common.exception.FcliTechnicalException; + +public final class RemediationsCacheSha256 { + private RemediationsCacheSha256() {} + + public static String hashFile(Path path) { + try (InputStream in = Files.newInputStream(path)) { + return hashStream(in); + } catch (IOException e) { + throw new FcliTechnicalException("Failed to compute SHA-256 for " + path, e); + } + } + + public static String hashStream(InputStream in) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (DigestInputStream dis = new DigestInputStream(in, digest)) { + byte[] buffer = new byte[8192]; + while (dis.read(buffer) != -1) { + // Digest is updated by DigestInputStream + } + } + return toHex(digest.digest()); + } catch (NoSuchAlgorithmException e) { + throw new FcliTechnicalException("SHA-256 algorithm not available", e); + } catch (IOException e) { + throw new FcliTechnicalException("Failed to compute SHA-256", e); + } + } + + private static String toHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheWriter.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheWriter.java new file mode 100644 index 00000000000..99b5bf940df --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheWriter.java @@ -0,0 +1,333 @@ +/* + * 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._common.remediations_cache; + +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.cli.common.exception.AbstractFcliException; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.exception.FcliTechnicalException; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.util.ZipHelper; + +/** + * Builds a remediations cache zip at a destination path. FPR content is written + * directly into a ZipFS (no per-FPR temp staging). The ZipFS is opened on a sibling + * {@code *.partial} work file; on successful {@link #commit()}, the work file is moved + * onto {@code destination} (atomic when the filesystem supports it). + * + *

Use with try-with-resources. Call {@link #commit()} after all entries have been written + * successfully. {@link #close()} closes ZipFS and discards any uncommitted work file. + */ +public final class RemediationsCacheWriter implements AutoCloseable { + private static final Logger logger = LoggerFactory.getLogger(RemediationsCacheWriter.class); + private static final String PARTIAL_SUFFIX = ".partial"; + + private final Path destination; + private final Path workPath; + private final FileSystem zipFs; + private final RemediationsCacheManifest manifest; + private int nextOrder = 1; + private boolean committed; + + /** + * Opens ZipFS on a sibling work file. Exceptions from open are thrown immediately. + * Entry parent dirs (including {@code fprs/}) are created lazily in {@link #prepareEntryPath}. + */ + private RemediationsCacheWriter(Path destination, String product, Map selection) { + this.destination = destination; + this.workPath = workPathFor(destination); + this.manifest = newManifest(product, selection); + this.zipFs = ZipHelper.createZipFileSystem(workPath); + } + + /** + * Opens a work zip next to {@code destination}. Use with try-with-resources. + */ + public static RemediationsCacheWriter create(Path destination, String product, Map selection) { + FcliSimpleException.throwIf(destination == null, + "-f/--file must specify a remediations cache zip path"); + return new RemediationsCacheWriter(destination, product, selection); + } + + /** + * Convenience for callers that already have FPR files on disk (for example unit tests). + * Publishes after all entries have been written successfully. + */ + public static RemediationsCacheManifest write( + Path destination, + String product, + Map selection, + List fprSources) { + FcliSimpleException.throwIf(fprSources == null || fprSources.isEmpty(), + "Cannot create remediations cache: no FPR files to include"); + try (RemediationsCacheWriter writer = create(destination, product, selection)) { + for (LocalFpr source : fprSources) { + if (source instanceof SSCFpr ssc) { + writer.addFprFromFile(ssc); + } else if (source instanceof FoDFpr fod) { + writer.addFprFromFile(fod); + } else { + throw new FcliTechnicalException("Unsupported local FPR type: " + source.getClass().getName()); + } + } + writer.commit(); + return writer.getManifest(); + } + } + + /** In-memory manifest (complete after successful commit with entries). */ + public RemediationsCacheManifest getManifest() { + return manifest; + } + + /** + * Writes an SSC artifact FPR into the next zip entry (for streaming downloads). + */ + public void addSscFpr(String artifactId, String uploadDate, Consumer contentWriter) { + int order = nextOrder++; + writeAndRecord( + sscEntryPath(order, artifactId), + contentWriter, + (entryPath, sha256) -> RemediationsCacheEntry.forSsc( + order, entryPath, sha256, RemediationsCacheEntry.SSCData.of(artifactId, uploadDate))); + } + + /** + * Writes a FoD release FPR into the next zip entry (for streaming downloads). + */ + public void addFodFpr(String releaseId, Consumer contentWriter) { + int order = nextOrder++; + writeAndRecord( + fodEntryPath(order, releaseId), + contentWriter, + (entryPath, sha256) -> RemediationsCacheEntry.forFod( + order, entryPath, sha256, RemediationsCacheEntry.FoDData.of(releaseId))); + } + + @FunctionalInterface + private interface EntryBuilder { + RemediationsCacheEntry build(String entryPath, String sha256); + } + + private void writeAndRecord(String entryPath, Consumer contentWriter, EntryBuilder entryBuilder) { + try { + Path target = prepareEntryPath(entryPath); + writeEntryContent(target, entryPath, contentWriter); + RemediationsCacheEntry entry = entryBuilder.build(entryPath, RemediationsCacheSha256.hashFile(target)); + entry.validate(destination); + manifest.getEntries().add(entry); + } catch (AbstractFcliException e) { + throw e; + } catch (IOException e) { + throw new FcliTechnicalException("Failed to add remediations cache entry " + entryPath, e); + } catch (RuntimeException e) { + throw new FcliTechnicalException("Failed to add remediations cache entry " + entryPath, e); + } + } + + /** Copies an existing SSC FPR file into the cache zip and records its checksum. */ + public void addFprFromFile(SSCFpr source) { + validateLocalPath(source.path()); + addSscFpr(source.artifactId(), source.uploadDate(), copyFrom(source.path())); + } + + /** Copies an existing FoD FPR file into the cache zip and records its checksum. */ + public void addFprFromFile(FoDFpr source) { + validateLocalPath(source.path()); + addFodFpr(source.releaseId(), copyFrom(source.path())); + } + + private Path prepareEntryPath(String entryPath) throws IOException { + Path target = zipFs.getPath(entryPath); + Path parent = target.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + return target; + } + + private static void writeEntryContent(Path target, String entryPath, Consumer contentWriter) { + contentWriter.accept(target); + FcliSimpleException.throwIf(!Files.isRegularFile(target), + "Cache entry was not written: %s", entryPath); + } + + /** Completes the cache and publishes it after all entries have been written successfully. */ + public void commit() { + if (committed) { + return; + } + try { + if (zipFs.isOpen()) { + if (!manifest.getEntries().isEmpty()) { + writeManifest(); + } + zipFs.close(); + } + } catch (AbstractFcliException e) { + deleteQuietly(workPath); + throw e; + } catch (IOException e) { + deleteQuietly(workPath); + throw new FcliTechnicalException("Failed to finalize remediations cache zip: " + destination, e); + } catch (RuntimeException e) { + deleteQuietly(workPath); + throw new FcliTechnicalException("Failed to finalize remediations cache zip: " + destination, e); + } + // Guard with workPath existence so a second commit is a no-op after publish/delete. + if (!Files.exists(workPath)) { + committed = true; + return; + } + if (!manifest.getEntries().isEmpty()) { + publishWorkFile(); + } else { + deleteQuietly(workPath); + } + committed = true; + } + + /** Discards an uncommitted work file when the try-with-resources body fails. */ + @Override + public void close() { + if (committed) { + return; + } + try { + if (zipFs.isOpen()) { + zipFs.close(); + } + } catch (IOException e) { + throw new FcliTechnicalException("Failed to close remediations cache zip: " + destination, e); + } finally { + deleteQuietly(workPath); + } + } + + private void writeManifest() { + try { + manifest.validate(destination); + byte[] manifestBytes = JsonHelper.getObjectMapper().writerWithDefaultPrettyPrinter() + .writeValueAsBytes(manifest); + Files.write(zipFs.getPath(RemediationsCacheConstants.MANIFEST_ENTRY), manifestBytes); + } catch (AbstractFcliException e) { + throw e; + } catch (IOException e) { + throw new FcliTechnicalException("Failed to write remediations cache manifest to " + destination, e); + } catch (RuntimeException e) { + throw new FcliTechnicalException("Failed to write remediations cache manifest to " + destination, e); + } + } + + private void publishWorkFile() { + try { + try { + Files.move(workPath, destination, + StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(workPath, destination, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + deleteQuietly(workPath); + throw new FcliTechnicalException( + "Failed to publish remediations cache zip to " + destination, e); + } + } + + private static void validateLocalPath(Path path) { + FcliSimpleException.throwIf(path == null, "FPR source path is required"); + FcliSimpleException.throwIf(!Files.isRegularFile(path), + "FPR source is not a readable regular file: %s", path); + } + + private static Consumer copyFrom(Path source) { + return target -> { + try { + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + throw new FcliTechnicalException("Failed to copy FPR into cache: " + source, e); + } + }; + } + + private static Path workPathFor(Path destination) { + Path fileName = destination.getFileName(); + String name = fileName != null ? fileName.toString() : "remediations-cache.zip"; + Path parent = destination.toAbsolutePath().getParent(); + Path workName = Path.of(name + PARTIAL_SUFFIX); + return parent != null ? parent.resolve(workName) : workName; + } + + private static RemediationsCacheManifest newManifest(String product, Map selection) { + RemediationsCacheManifest manifest = new RemediationsCacheManifest(); + manifest.setSchemaVersion(RemediationsCacheConstants.SCHEMA_VERSION); + manifest.setKind(RemediationsCacheConstants.KIND); + manifest.setProduct(product); + manifest.setCreatedAt(Instant.now().toString()); + if (selection != null) { + manifest.getSelection().putAll(selection); + } + return manifest; + } + + private static String sscEntryPath(int order, String artifactId) { + return String.format("%s/%03d_artifact_%s.fpr", + RemediationsCacheConstants.FPRS_DIR, order, sanitizePathSegment(artifactId)); + } + + private static String fodEntryPath(int order, String releaseId) { + return String.format("%s/%03d_release_%s.fpr", + RemediationsCacheConstants.FPRS_DIR, order, sanitizePathSegment(releaseId)); + } + + private static String sanitizePathSegment(String id) { + FcliSimpleException.throwIf(id == null || id.isBlank(), "Entry id is required for cache path"); + String cleaned = id.replaceAll("[^A-Za-z0-9_-]", "_"); + return cleaned.isEmpty() ? "id" : cleaned; + } + + private static void deleteQuietly(Path path) { + if (path == null) { + return; + } + try { + Files.deleteIfExists(path); + } catch (IOException e) { + logger.warn("Failed to delete incomplete remediations cache work file: {}", path, e); + } + } + + /** Local on-disk FPR to copy into the cache (product-specific; no shared null fields). */ + public sealed interface LocalFpr permits SSCFpr, FoDFpr { + Path path(); + } + + /** SSC artifact FPR for cache write helpers/tests. */ + public record SSCFpr(Path path, String artifactId, String uploadDate) implements LocalFpr {} + + /** FoD release FPR for cache write helpers/tests. */ + public record FoDFpr(Path path, String releaseId) implements LocalFpr {} +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorIssueIdFilterUtils.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorIssueIdFilterUtils.java new file mode 100644 index 00000000000..dbd1d50df51 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorIssueIdFilterUtils.java @@ -0,0 +1,39 @@ +/* + * 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._common.util; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.apache.commons.lang3.StringUtils; + +import com.fortify.cli.common.exception.FcliSimpleException; + +public final class AviatorIssueIdFilterUtils { + private AviatorIssueIdFilterUtils() {} + + public static Set normalizeIssueIds(List issueIds) { + if (issueIds == null) { + return null; + } + Set normalizedIssueIds = issueIds.stream() + .map(StringUtils::trimToNull) + .filter(StringUtils::isNotBlank) + .collect(LinkedHashSet::new, Set::add, Set::addAll); + if (normalizedIssueIds.isEmpty()) { + throw new FcliSimpleException("--issue-ids must contain at least one non-blank issue ID"); + } + return normalizedIssueIds; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorLocalFprHelper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorLocalFprHelper.java new file mode 100644 index 00000000000..8c1164507b9 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorLocalFprHelper.java @@ -0,0 +1,59 @@ +/* + * 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._common.util; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.common.exception.AbstractFcliException; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.exception.FcliTechnicalException; + +public final class AviatorLocalFprHelper { + private AviatorLocalFprHelper() {} + + public static void validateLocalFprs(List fprPaths) { + validateLocalFprs(fprPaths, "FPR file"); + } + + public static void validateLocalFprs(List fprPaths, String sourceLabel) { + FcliSimpleException.throwIf(fprPaths == null || fprPaths.isEmpty(), + "%s list must contain at least one FPR file", sourceLabel); + for (Path fprPath : fprPaths) { + validateLocalFpr(fprPath, sourceLabel); + } + } + + private static void validateLocalFpr(Path fprPath, String sourceLabel) { + FcliSimpleException.throwIf(fprPath == null, + "%s path must be a valid FPR file path", sourceLabel); + FcliSimpleException.throwIf(!Files.exists(fprPath), + "%s does not exist: %s", sourceLabel, fprPath); + FcliSimpleException.throwIf(!Files.isRegularFile(fprPath), + "%s is not a regular file: %s", sourceLabel, fprPath); + FcliSimpleException.throwIf(!Files.isReadable(fprPath), + "%s is not readable: %s", sourceLabel, fprPath); + try (FprHandle fprHandle = new FprHandle(fprPath)) { + fprHandle.validate(); + } catch (AbstractFcliException e) { + throw e; + } catch (RuntimeException e) { + throw new FcliSimpleException(sourceLabel + " is not a valid audited SAST FPR: " + fprPath, e); + } catch (IOException e) { + throw new FcliTechnicalException("Failed to close " + sourceLabel + ": " + fprPath, e); + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelper.java new file mode 100644 index 00000000000..ae37ea496e4 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelper.java @@ -0,0 +1,258 @@ +/* + * 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._common.util; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.aviator.fpr.remediation.RemediationExecutionMode; +import com.fortify.cli.aviator.fpr.remediation.model.RemediationMetric; +import com.fortify.cli.aviator.fpr.remediation.preview.PreviewDetail; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; + +/** + * Shared metric aggregation and result-field helpers for SSC/FoD apply-remediations flows. + */ +public final class AviatorRemediationMetricsHelper { + private static final String REQUESTED_ISSUE_NOT_FOUND = "Requested issue not found in remediations"; + + private AviatorRemediationMetricsHelper() {} + + /** + * Aggregates per-FPR metrics. {@code requestedIssueIds == null} selects unfiltered + * aggregation (XML totals); non-null selects filtered aggregation (requested IDs). + * An empty metric list is apply mode. + */ + public static RemediationMetric aggregateMetrics(Set requestedIssueIds, Collection metrics) { + return aggregateMetrics(requestedIssueIds, metrics, RemediationExecutionMode.APPLY); + } + + /** + * Same aggregation, with the command's requested mode kept when every FPR was skipped + * before a metric existed. A preview metric still forces preview. + */ + public static RemediationMetric aggregateMetrics( + Set requestedIssueIds, + Collection metrics, + RemediationExecutionMode requestedMode) { + Collection safeMetrics = metrics == null ? List.of() : metrics; + RemediationExecutionMode mode = requestedMode == null ? RemediationExecutionMode.APPLY : requestedMode; + return requestedIssueIds == null + ? aggregateUnfiltered(safeMetrics, mode) + : aggregateFiltered(requestedIssueIds, safeMetrics, mode); + } + + private static RemediationMetric aggregateUnfiltered( + Collection metrics, RemediationExecutionMode requestedMode) { + RemediationMetric.RemediationMetricBuilder builder = RemediationMetric.builder() + .executionMode(requestedMode); + for (RemediationMetric metric : metrics) { + builder.add(metric); + } + return builder.build(); + } + + private static RemediationMetric aggregateFiltered( + Set requestedIssueIds, + Collection metrics, + RemediationExecutionMode requestedMode) { + Set seenIssueIds = new LinkedHashSet<>(); + Set satisfiedIssueIds = new LinkedHashSet<>(); + Set appliedIssueIds = new LinkedHashSet<>(); + Set identicalIssueIds = new LinkedHashSet<>(); + Set supersededIssueIds = new LinkedHashSet<>(); + Set possiblyRemediatedIssueIds = new LinkedHashSet<>(); + Set modifiedFiles = new LinkedHashSet<>(); + Map issueSkipReasons = new LinkedHashMap<>(); + Map previewDetailsByIssue = new LinkedHashMap<>(); + boolean previewMode = requestedMode == RemediationExecutionMode.PREVIEW; + for (RemediationMetric metric : metrics) { + seenIssueIds.addAll(metric.seenIssueIds()); + satisfiedIssueIds.addAll(metric.satisfiedIssueIds()); + appliedIssueIds.addAll(metric.appliedIssueIds()); + identicalIssueIds.addAll(metric.identicalIssueIds()); + supersededIssueIds.addAll(metric.supersededIssueIds()); + possiblyRemediatedIssueIds.addAll(metric.possiblyRemediatedIssueIds()); + modifiedFiles.addAll(metric.modifiedFiles()); + issueSkipReasons.putAll(metric.issueSkipReasons()); + previewMode |= metric.isPreview(); + for (PreviewDetail detail : metric.previewDetails()) { + PreviewDetail existing = previewDetailsByIssue.get(detail.issueId()); + if (existing == null || detail.isAvailable() || !existing.isAvailable()) { + previewDetailsByIssue.put(detail.issueId(), detail); + } + } + } + + issueSkipReasons.keySet().removeAll(satisfiedIssueIds); + possiblyRemediatedIssueIds.removeAll(satisfiedIssueIds); + for (String requestedIssueId : requestedIssueIds) { + if (!satisfiedIssueIds.contains(requestedIssueId) && !seenIssueIds.contains(requestedIssueId)) { + issueSkipReasons.put(requestedIssueId, REQUESTED_ISSUE_NOT_FOUND); + if (previewMode) { + previewDetailsByIssue.put(requestedIssueId, + PreviewDetail.skipped(requestedIssueId, null)); + } + } + } + + Map skippedByReason = new LinkedHashMap<>(); + issueSkipReasons.values().forEach(reason -> skippedByReason.merge(reason, 1, Integer::sum)); + int skippedRemediations = requestedIssueIds.size() - satisfiedIssueIds.size(); + return RemediationMetric.builder() + .totalRemediations(requestedIssueIds.size()) + .appliedRemediations(appliedIssueIds.size()) + .identicalRemediations(identicalIssueIds.size()) + .supersededRemediations(supersededIssueIds.size()) + .possiblyRemediatedRemediations(possiblyRemediatedIssueIds.size()) + .skippedRemediations(skippedRemediations) + .modifiedFiles(modifiedFiles) + .skippedByReason(skippedByReason) + .executionMode(previewMode ? RemediationExecutionMode.PREVIEW : RemediationExecutionMode.APPLY) + .requestedIssueIds(requestedIssueIds) + .seenIssueIds(seenIssueIds) + .satisfiedIssueIds(satisfiedIssueIds) + .appliedIssueIds(appliedIssueIds) + .identicalIssueIds(identicalIssueIds) + .supersededIssueIds(supersededIssueIds) + .possiblyRemediatedIssueIds(possiblyRemediatedIssueIds) + .issueSkipReasons(issueSkipReasons) + .previewDetails(new ArrayList<>(previewDetailsByIssue.values())) + .build(); + } + + public static Set getRemainingIssueIds(Set requestedIssueIds, RemediationMetric metric) { + if (requestedIssueIds == null || requestedIssueIds.isEmpty()) { + return requestedIssueIds; + } + Set remainingIssueIds = new LinkedHashSet<>(requestedIssueIds); + remainingIssueIds.removeAll(metric.satisfiedIssueIds()); + return remainingIssueIds; + } + + public static void mergeSkippedByReason(Map target, Map source) { + if (source == null || source.isEmpty()) { + return; + } + source.forEach((reason, count) -> target.merge(reason, count, Integer::sum)); + } + + /** Compact table-friendly summary: {@code reason=count, ...}. */ + public static String formatSkippedReasons(Map skippedByReason) { + if (skippedByReason == null || skippedByReason.isEmpty()) { + return ""; + } + List parts = new ArrayList<>(); + skippedByReason.forEach((reason, count) -> parts.add(reason + "=" + count)); + return String.join(", ", parts); + } + + public static String actionLabel(RemediationMetric metric) { + boolean previewMode = metric != null && metric.isPreview(); + if (metric != null && metric.appliedRemediations() > 0) { + return previewMode ? "Remediation-Previewed" : "Remediation-Applied"; + } else { + return previewMode ? "No-Remediation-Previewed" : "No-Remediation-Applied"; + } + } + + public static String na(String value) { + return value != null ? value : "N/A"; + } + + /** + * Writes always-present remediation metric fields onto a result node + * (totals, skip reasons, modified files). Does not set {@code __action__}. + */ + public static void putRemediationMetricFields(ObjectNode result, RemediationMetric metric) { + int total = metric == null ? 0 : metric.totalRemediations(); + int applied = metric == null ? 0 : metric.appliedRemediations(); + int skipped = metric == null ? 0 : metric.skippedRemediations(); + String appliedFieldName = metric != null && metric.isPreview() ? "availableRemediation" : "appliedRemediation"; + Map skippedByReason = metric == null ? Map.of() : metric.skippedByReason(); + Set modifiedFiles = metric == null ? Set.of() : metric.modifiedFiles(); + + result.put("totalRemediation", total); + result.put(appliedFieldName, applied); + result.put("identicalRemediation", metric == null ? 0 : metric.identicalRemediations()); + result.put("supersededRemediation", metric == null ? 0 : metric.supersededRemediations()); + result.put("possiblyRemediatedRemediation", metric == null ? 0 : metric.possiblyRemediatedRemediations()); + result.put("skippedRemediation", skipped); + result.put("skippedReasons", formatSkippedReasons(skippedByReason)); + result.set("skippedByReason", toObjectNode(skippedByReason)); + result.set("modifiedFiles", toArrayNode(modifiedFiles)); + } + + /** Metric fields plus {@code __action__} and, for preview results, preview details (shared by SSC/FoD result builders). */ + public static void putMetricAndAction(ObjectNode result, RemediationMetric metric) { + putRemediationMetricFields(result, metric); + result.put(IActionCommandResultSupplier.actionFieldName, actionLabel(metric)); + + if (metric != null && metric.isPreview()) { + result.set("previewDetails", toPreviewDetailsArray(metric.previewDetails())); + } + } + + private static ArrayNode toPreviewDetailsArray(List previewDetails) { + ArrayNode array = JsonHelper.getObjectMapper().createArrayNode(); + if (previewDetails != null) { + previewDetails.forEach(detail -> array.add(JsonHelper.getObjectMapper().valueToTree(detail))); + } + return array; + } + + /** + * Cache-mode extras shared by SSC/FoD: file path, entry list, and product id list field. + * + * @param idArrayField {@code artifactIds} (SSC) or {@code releaseIds} (FoD) + */ + public static void putCacheExtras( + ObjectNode result, Path cacheZip, List entryPaths, String idArrayField, List ids) { + result.put("file", cacheZip.toString()); + result.set("entries", toStringArrayNode(entryPaths)); + result.set(idArrayField, toStringArrayNode(ids)); + } + + public static ObjectNode toObjectNode(Map skippedByReason) { + ObjectNode object = JsonHelper.getObjectMapper().createObjectNode(); + if (skippedByReason != null) { + skippedByReason.forEach(object::put); + } + return object; + } + + public static ArrayNode toArrayNode(Set files) { + ArrayNode array = JsonHelper.getObjectMapper().createArrayNode(); + if (files != null) { + files.forEach(array::add); + } + return array; + } + + public static ArrayNode toStringArrayNode(List values) { + ArrayNode array = JsonHelper.getObjectMapper().createArrayNode(); + if (values != null) { + values.forEach(array::add); + } + return array; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorTempFprFile.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorTempFprFile.java new file mode 100644 index 00000000000..bfc3de2f495 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorTempFprFile.java @@ -0,0 +1,74 @@ +/* + * 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._common.util; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.cli.common.exception.FcliTechnicalException; + +/** + * Host-filesystem temporary FPR used only when a real {@link Path} is required + * (for example online downloads before {@code FprHandle} can open a nested zip FS). + * Prefer ZipFS entry paths from {@code RemediationsCacheReader}/{@code RemediationsCacheWriter} + * whenever content already lives in a durable cache zip. + * + *

Owns cleanup via {@link AutoCloseable}; use try-with-resources so interrupt/exception + * paths still delete the file. + */ +public final class AviatorTempFprFile implements AutoCloseable { + private static final Logger logger = LoggerFactory.getLogger(AviatorTempFprFile.class); + + private final Path path; + + private AviatorTempFprFile(Path path) { + this.path = path; + } + + /** + * @param nameHint short label (artifact/release id); sanitized into the temp file prefix + */ + public static AviatorTempFprFile create(String nameHint) { + String safe = sanitize(nameHint); + try { + return new AviatorTempFprFile(Files.createTempFile("aviator-" + safe + "-", ".fpr")); + } catch (IOException e) { + throw new FcliTechnicalException("Failed to create temporary FPR file for " + safe, e); + } + } + + public Path path() { + return path; + } + + @Override + public void close() { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + logger.warn("Failed to delete temporary FPR file: {}", path, e); + } + } + + private static String sanitize(String nameHint) { + if (nameHint == null || nameHint.isBlank()) { + return "fpr"; + } + String cleaned = nameHint.replaceAll("[^A-Za-z0-9._-]", "_"); + return cleaned.length() > 40 ? cleaned.substring(0, 40) : cleaned; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/applyRemediation/ApplyAutoRemediationOnSource.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/applyRemediation/ApplyAutoRemediationOnSource.java index 89ce88c2b97..3e9afcbbf43 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/applyRemediation/ApplyAutoRemediationOnSource.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/applyRemediation/ApplyAutoRemediationOnSource.java @@ -13,19 +13,22 @@ package com.fortify.cli.aviator.applyRemediation; import java.util.Objects; +import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.fortify.cli.aviator._common.exception.AviatorSimpleException; import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; +import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.aviator.fpr.remediation.RemediationExecutionMode; +import com.fortify.cli.aviator.fpr.remediation.RemediationProcessingOptions; import com.fortify.cli.aviator.fpr.remediation.RemediationProcessor; import com.fortify.cli.aviator.fpr.remediation.model.RemediationMetric; import com.fortify.cli.aviator.fpr.utils.ISourceDecoder; import com.fortify.cli.aviator.fpr.utils.SourceDecoders; import com.fortify.cli.aviator.util.FprHandle; - public class ApplyAutoRemediationOnSource { private static final Logger LOG = LoggerFactory.getLogger(ApplyAutoRemediationOnSource.class); @@ -37,17 +40,42 @@ public static RemediationMetric applyRemediations(FprHandle fprHandle, String so public static RemediationMetric applyRemediations(FprHandle fprHandle, String sourceCodeDirectory, ISourceDecoder sourceDecoder) throws AviatorSimpleException, AviatorTechnicalException { + return applyRemediations(fprHandle, sourceCodeDirectory, null, + new RemediationProcessingOptions(Set.of(), RemediationExecutionMode.APPLY, sourceDecoder)); + } - LOG.info("Starting apply auto-remediation process for file: {}", fprHandle.getFprPath()); + public static RemediationMetric applyRemediations(FprHandle fprHandle, String sourceCodeDirectory, IAviatorLogger logger, + Set issueIdFilter) + throws AviatorSimpleException, AviatorTechnicalException { + return applyRemediations(fprHandle, sourceCodeDirectory, logger, issueIdFilter, false, SourceDecoders.defaults()); + } + public static RemediationMetric applyRemediations(FprHandle fprHandle, String sourceCodeDirectory, IAviatorLogger logger, + Set issueIdFilter, boolean previewMode) + throws AviatorSimpleException, AviatorTechnicalException { + return applyRemediations(fprHandle, sourceCodeDirectory, logger, issueIdFilter, previewMode, SourceDecoders.defaults()); + } + + public static RemediationMetric applyRemediations(FprHandle fprHandle, String sourceCodeDirectory, IAviatorLogger logger, + Set issueIdFilter, boolean previewMode, ISourceDecoder sourceDecoder) + throws AviatorSimpleException, AviatorTechnicalException { + RemediationExecutionMode executionMode = previewMode ? RemediationExecutionMode.PREVIEW : RemediationExecutionMode.APPLY; + return applyRemediations(fprHandle, sourceCodeDirectory, logger, + new RemediationProcessingOptions(issueIdFilter, executionMode, sourceDecoder)); + } + + public static RemediationMetric applyRemediations(FprHandle fprHandle, String sourceCodeDirectory, IAviatorLogger logger, + RemediationProcessingOptions options) + throws AviatorSimpleException, AviatorTechnicalException { + Objects.requireNonNull(options, "options"); + LOG.info("Starting {} process for file: {}", + options.isPreview() ? "preview" : "apply auto-remediation", fprHandle.getFprPath()); if (!fprHandle.hasRemediations()) { - //LOG.error("FPR file does not contain remediations.xml file: {}", fprHandle.getFprPath()); throw new AviatorSimpleException("FPR file does not contain remediations.xml file."); } LOG.info("FPR validation successful"); - RemediationProcessor remediationProcessor = new RemediationProcessor(fprHandle, sourceCodeDirectory, - Objects.requireNonNull(sourceDecoder, "sourceDecoder")); + RemediationProcessor remediationProcessor = new RemediationProcessor(fprHandle, sourceCodeDirectory, options); return remediationProcessor.processRemediationXML(); } } 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 7eff9f7a473..95edd424942 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 @@ -108,8 +108,7 @@ private static TagMappingConfig loadTagMappingConfig(String tagMappingFilePath) tagMappingConfig = AviatorConfigManager.getInstance().getDefaultTagMappingConfig(); } - tagMappingConfig.validate(); - return tagMappingConfig; + return tagMappingConfig.resolveForSast(); } private static Map buildIssueCategoryLookup(List vulnerabilities) { diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditDecisionMapper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditDecisionMapper.java new file mode 100644 index 00000000000..25132f1cc51 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditDecisionMapper.java @@ -0,0 +1,61 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.audit; + +import com.fortify.cli.aviator.audit.model.AuditResponse; +import com.fortify.cli.aviator.audit.model.AuditResult; +import com.fortify.cli.aviator.audit.model.AuditTier; +import com.fortify.cli.aviator.grpc.DastAuditResult; +import com.fortify.cli.aviator.util.Constants; + +/** + * Converts structured DAST decisions to conservative FCLI audit results. + */ +public final class DastAuditDecisionMapper { + private DastAuditDecisionMapper() {} + + public static AuditResponse toAuditResponse(DastAuditResult result) { + if (!(result instanceof DastAuditResult.Success success)) { + return AuditResponse.builder() + .issueId(result.issueId()) + .status(result.status()) + .statusMessage(result.statusMessage()) + .build(); + } + + AuditTier tier = success.tier(); + boolean tierOne = tier == AuditTier.GOLD; + String tagValue; + String prediction; + if (success.truePositive()) { + tagValue = Constants.EXPLOITABLE; + prediction = tierOne ? Constants.AVIATOR_REMEDIATION_REQUIRED : Constants.AVIATOR_LIKELY_TP; + } else { + tagValue = Constants.NOT_AN_ISSUE; + prediction = tierOne ? Constants.AVIATOR_NOT_AN_ISSUE : Constants.AVIATOR_LIKELY_FP; + } + + String comment = success.finalComment() != null && !success.finalComment().isBlank() + ? success.finalComment() + : success.reasoning(); + return AuditResponse.builder() + .issueId(success.issueId()) + .status("SUCCESS") + .tier(tier.name()) + .aviatorPredictionTag(prediction) + .isAviatorProcessed(true) + .auditResult(AuditResult.builder().tagValue(tagValue).comment(comment).build()) + .build(); + } + +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditFPR.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditFPR.java new file mode 100644 index 00000000000..ebc66874c38 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditFPR.java @@ -0,0 +1,319 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.audit; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; +import com.fortify.cli.aviator.audit.model.AuditResponse; +import com.fortify.cli.aviator.audit.model.AuditTier; +import com.fortify.cli.aviator.config.TagMappingConfig; +import com.fortify.cli.aviator.dast.DastSession; +import com.fortify.cli.aviator.dast.StreamingWebInspectParser; +import com.fortify.cli.aviator.fpr.model.AuditIssue; +import com.fortify.cli.aviator.fpr.processor.AuditProcessor; +import com.fortify.cli.aviator.grpc.DastAuditResult; +import com.fortify.cli.aviator.grpc.DastAuditStreamConfig; +import com.fortify.cli.aviator.grpc.DastAuditStreamResult; +import com.fortify.cli.aviator.grpc.DastAuditWorkItem; +import com.fortify.cli.aviator.util.Constants; +import com.fortify.cli.aviator.util.FprHandle; + +import lombok.Builder; + +/** + * Coordinates parsing, filtering, server auditing, and DAST audit.xml updates. + */ +public final class DastAuditFPR { + private static final Logger LOG = LoggerFactory.getLogger(DastAuditFPR.class); + + private DastAuditFPR() {} + + @Builder + private record EligibilityResult( + List workItems, + int missingId, + int duplicate, + int suppressed, + int alreadyProcessed, + int humanAudited) { + private static class EligibilityResultBuilder { + private List workItems = new ArrayList<>(); + + private EligibilityResultBuilder addWorkItem(DastAuditWorkItem workItem) { + workItems.add(workItem); + return this; + } + + private EligibilityResultBuilder incrementMissingId() { + missingId++; + return this; + } + + private EligibilityResultBuilder incrementDuplicate() { + duplicate++; + return this; + } + + private EligibilityResultBuilder incrementSuppressed() { + suppressed++; + return this; + } + + private EligibilityResultBuilder incrementAlreadyProcessed() { + alreadyProcessed++; + return this; + } + + private EligibilityResultBuilder incrementHumanAudited() { + humanAudited++; + return this; + } + } + } + + private record ResponseSummary( + Map successfulResponses, + int truePositives, + int falsePositivesSuppressed, + int likelyFalsePositives, + int serverSkipped, + int failed, + int missingResponses) {} + + @FunctionalInterface + public interface StreamRunner { + CompletableFuture run( + DastAuditStreamConfig config, List workItems, int totalReportedIssues); + } + + public static DastAuditFprResult audit( + FprHandle fprHandle, + DastAuditStreamConfig config, + TagMappingConfig tagMappingConfig, + StreamRunner streamRunner) { + tagMappingConfig.validateForDast(); + var auditProcessor = new AuditProcessor(fprHandle); + Map auditIssues = auditProcessor.processAuditXML(); + var sessions = new StreamingWebInspectParser(fprHandle).parseSessions(); + EligibilityResult eligibility = eligibleWorkItems(sessions, auditIssues); + List workItems = eligibility.workItems(); + int totalReported = sessions.stream().mapToInt(session -> session.getIssues().size()).sum(); + int locallySkipped = totalReported - workItems.size(); + LOG.info("DAST audit eligibility: reported={}, eligible={}, skipped={} " + + "(missingId={}, duplicate={}, suppressed={}, alreadyProcessed={}, humanAudited={})", + totalReported, workItems.size(), locallySkipped, eligibility.missingId(), + eligibility.duplicate(), eligibility.suppressed(), eligibility.alreadyProcessed(), + eligibility.humanAudited()); + + if (workItems.isEmpty()) { + LOG.info("DAST audit skipped because no eligible findings remain"); + return emptyResult(totalReported, locallySkipped); + } + + CompletableFuture streamFuture = streamRunner.run(config, workItems, totalReported); + if (streamFuture == null) { + throw new AviatorTechnicalException("DAST audit stream did not return a completion future"); + } + DastAuditStreamResult streamResult = streamFuture.join(); + if (streamResult == null) { + throw new AviatorTechnicalException("DAST audit stream completed without a result"); + } + ResponseSummary summary = summarizeResponses(streamResult, workItems, tagMappingConfig); + var updatedFile = summary.successfulResponses().isEmpty() + ? null + : auditProcessor.updateAndSaveDastAuditXml(summary.successfulResponses(), tagMappingConfig); + return buildResult(streamResult, summary, updatedFile, totalReported, locallySkipped, workItems.size()); + } + + private static ResponseSummary summarizeResponses( + DastAuditStreamResult streamResult, + List workItems, + TagMappingConfig tagMappingConfig) { + Map successfulResponses = new LinkedHashMap<>(); + int truePositives = 0; + int falsePositivesSuppressed = 0; + int likelyFalsePositives = 0; + int failed = 0; + int serverSkipped = 0; + Set respondedIssueIds = new HashSet<>(); + + for (var result : streamResult.results()) { + respondedIssueIds.add(result.issueId()); + AuditResponse response = DastAuditDecisionMapper.toAuditResponse(result); + if ("SUCCESS".equalsIgnoreCase(response.getStatus()) && response.getAuditResult() != null) { + successfulResponses.put(result.issueId(), response); + var success = (DastAuditResult.Success) result; + LOG.debug("DAST issue {} audited successfully: confidence={}, tier={}, result={}", + result.issueId(), success.confidence(), response.getTier(), response.getAuditResult().getTagValue()); + if (Constants.EXPLOITABLE.equals(response.getAuditResult().getTagValue())) { + truePositives++; + } else if (isSuppressedFalsePositive(response, tagMappingConfig)) { + falsePositivesSuppressed++; + } else { + likelyFalsePositives++; + } + } else if ("SKIPPED".equalsIgnoreCase(result.status())) { + serverSkipped++; + LOG.debug("DAST issue {} skipped by server: statusMessage={}", + result.issueId(), result.statusMessage()); + } else { + failed++; + LOG.warn("DAST issue {} failed: status={}, statusMessage={}", + result.issueId(), result.status(), result.statusMessage()); + } + } + int missingResponses = countMissingResponses(workItems, respondedIssueIds); + return new ResponseSummary(successfulResponses, truePositives, falsePositivesSuppressed, + likelyFalsePositives, serverSkipped, failed + missingResponses, missingResponses); + } + + private static int countMissingResponses( + List workItems, + Set respondedIssueIds) { + int missingResponses = 0; + for (DastAuditWorkItem workItem : workItems) { + if (!respondedIssueIds.contains(workItem.issue().getId())) { + missingResponses++; + LOG.warn("DAST issue {} received no terminal server response", workItem.issue().getId()); + } + } + return missingResponses; + } + + private static DastAuditFprResult buildResult( + DastAuditStreamResult streamResult, + ResponseSummary summary, + File updatedFile, + int totalReported, + int locallySkipped, + int submitted) { + int succeeded = summary.successfulResponses().size(); + LOG.info("DAST audit responses: submitted={}, succeeded={}, serverSkipped={}, failed={}, missingResponses={}", + submitted, succeeded, summary.serverSkipped(), summary.failed(), summary.missingResponses()); + DastAuditFprStatus status = succeeded == submitted ? DastAuditFprStatus.AUDITED + : succeeded > 0 ? DastAuditFprStatus.PARTIALLY_AUDITED : DastAuditFprStatus.FAILED; + String message = succeeded == 0 ? "No DAST audit responses were successfully processed" : null; + return DastAuditFprResult.builder() + .updatedFile(updatedFile) + .status(status) + .message(message) + .totalReported(totalReported) + .eligible(submitted) + .submitted(submitted) + .succeeded(succeeded) + .truePositives(summary.truePositives()) + .falsePositivesSuppressed(summary.falsePositivesSuppressed()) + .likelyFalsePositives(summary.likelyFalsePositives()) + .skipped(locallySkipped + summary.serverSkipped()) + .failed(summary.failed()) + .reservedQuota(streamResult.reservedQuota()) + .exceededCount(streamResult.exceededCount()) + .unlimitedQuota(streamResult.unlimitedQuota()) + .quotaLastUpdated(streamResult.quotaLastUpdated()) + .nextQuotaUpdateMessage(streamResult.nextQuotaUpdateMessage()) + .build(); + } + + private static boolean isSuppressedFalsePositive(AuditResponse response, TagMappingConfig tagMappingConfig) { + boolean tierOne = AuditTier.fromServerValue(response.getTier()) == AuditTier.GOLD; + return Boolean.TRUE.equals(tagMappingConfig.getResult( + tierOne, TagMappingConfig.ResultType.FP).getSuppress()); + } + + private static EligibilityResult eligibleWorkItems( + List sessions, + Map auditIssues) { + var result = EligibilityResult.builder(); + var seenIssueIds = new HashSet(); + for (var session : sessions) { + for (var issue : session.getIssues()) { + String issueId = issue.getId(); + if (issueId == null || issueId.isBlank()) { + result.incrementMissingId(); + LOG.debug("Skipping DAST finding without an issue ID in session {}", session.getRequestId()); + continue; + } + if (!seenIssueIds.add(issueId)) { + result.incrementDuplicate(); + LOG.debug("Skipping duplicate DAST issue {} in session {}", issueId, session.getRequestId()); + continue; + } + AuditIssue auditIssue = auditIssues.get(issueId); + if (auditIssue != null && auditIssue.isSuppressed()) { + result.incrementSuppressed(); + LOG.debug("Skipping DAST issue {} because it is already suppressed", issueId); + continue; + } + if (auditIssue != null && isProcessedByAviator(auditIssue)) { + result.incrementAlreadyProcessed(); + LOG.debug("Skipping DAST issue {} because it is already processed by Aviator", issueId); + continue; + } + if (auditIssue != null && isHumanAudited(auditIssue)) { + result.incrementHumanAudited(); + LOG.debug("Skipping DAST issue {} because it is already audited by a human", issueId); + continue; + } + result.addWorkItem(new DastAuditWorkItem(session, issue)); + } + } + return result.build(); + } + + private static boolean isProcessedByAviator(AuditIssue auditIssue) { + Map tags = getTags(auditIssue); + return Constants.PROCESSED_BY_AVIATOR.equalsIgnoreCase(tags.get(Constants.AVIATOR_STATUS_TAG_ID)) + || tags.containsKey(Constants.AVIATOR_EXPECTED_OUTCOME_TAG_ID); + } + + private static boolean isHumanAudited(AuditIssue auditIssue) { + Map tags = getTags(auditIssue); + return isAuditDecision(tags.get(Constants.AUDITOR_STATUS_TAG_ID)) + || isAuditDecision(tags.get(Constants.FOD_TAG_ID)) + || isAnalysisDecision(tags.get(Constants.ANALYSIS_TAG_ID)); + } + + private static Map getTags(AuditIssue auditIssue) { + return auditIssue.getTags() == null ? Map.of() : auditIssue.getTags(); + } + + private static boolean isAuditDecision(String value) { + return value != null && !value.isBlank() + && !Constants.PENDING_REVIEW.equalsIgnoreCase(value) + && !"Pending Review".equalsIgnoreCase(value); + } + + private static boolean isAnalysisDecision(String value) { + return isAuditDecision(value) && !"Not Set".equalsIgnoreCase(value); + } + + private static DastAuditFprResult emptyResult(int totalReported, int skipped) { + return DastAuditFprResult.builder() + .status(DastAuditFprStatus.SKIPPED) + .message("No eligible DAST findings to audit") + .totalReported(totalReported) + .skipped(skipped) + .build(); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditFprResult.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditFprResult.java new file mode 100644 index 00000000000..005932f3059 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditFprResult.java @@ -0,0 +1,41 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.audit; + +import java.io.File; + +import lombok.Builder; + +/** + * Summary of processing one DAST FPR. + */ +@Builder +public record DastAuditFprResult( + File updatedFile, + DastAuditFprStatus status, + String message, + int totalReported, + int eligible, + int submitted, + int succeeded, + int truePositives, + int falsePositivesSuppressed, + int likelyFalsePositives, + int skipped, + int failed, + int reservedQuota, + int exceededCount, + boolean unlimitedQuota, + String quotaLastUpdated, + String nextQuotaUpdateMessage +) {} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditFprStatus.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditFprStatus.java new file mode 100644 index 00000000000..09c9e385754 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditFprStatus.java @@ -0,0 +1,21 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.audit; + +/** Outcome of processing a DAST FPR. */ +public enum DastAuditFprStatus { + AUDITED, + PARTIALLY_AUDITED, + SKIPPED, + FAILED +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/model/AuditTier.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/model/AuditTier.java new file mode 100644 index 00000000000..be63d1100f4 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/model/AuditTier.java @@ -0,0 +1,29 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.audit.model; + +/** Supported Aviator audit decision tiers. */ +public enum AuditTier { + GOLD, + SILVER; + + /** + * Parses a server-provided tier, defaulting unknown values to the conservative tier. + * + * @param value server-provided tier value + * @return parsed tier, or {@link #SILVER} when missing or unknown + */ + public static AuditTier fromServerValue(String value) { + return GOLD.name().equalsIgnoreCase(value) ? GOLD : SILVER; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/config/AviatorLoggerImpl.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/config/AviatorLoggerImpl.java index e918a691660..f996a0fcc30 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/config/AviatorLoggerImpl.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/config/AviatorLoggerImpl.java @@ -37,11 +37,17 @@ public void info(String format, Object... args) { logger.info(format, args); } + /** + * Writes one warning for the user. + * The progress writer prints the line. It is recorded at debug level rather than warning + * level, because the JAR also prints warning-level log output to standard error and the + * same line would appear twice. + */ @Override public void warn(String format, Object... args) { String message = String.format(format, args); - progressWriter.writeWarning(message); // Console (stderr) - logger.warn(message); + progressWriter.writeWarning(message); + logger.debug(message); } @Override diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/config/TagMappingConfig.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/config/TagMappingConfig.java index 2f21bf4c364..f5f033707c8 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 @@ -34,6 +34,8 @@ public class TagMappingConfig { private String tag_id = "87f2364f-dcd4-49e6-861d-f8d3f351686b"; private List suppression_exclusions = new ArrayList<>(); private Mapping mapping; + private ProductMapping sast; + private ProductMapping dast; public void setSuppression_exclusions(List suppression_exclusions) { this.suppression_exclusions = suppression_exclusions == null ? new ArrayList<>() : suppression_exclusions; @@ -61,6 +63,46 @@ public void validate() { } } + public void validateForDast() { + validate(); + if (hasSuppressionExclusions()) { + throw new AviatorSimpleException( + "Invalid DAST tag mapping configuration: suppression_exclusions are not supported"); + } + } + + public TagMappingConfig resolveForSast() { + TagMappingConfig resolved = resolve(sast); + resolved.validate(); + return resolved; + } + + public TagMappingConfig resolveForDast() { + TagMappingConfig resolved = resolve(dast); + resolved.validateForDast(); + return resolved; + } + + private TagMappingConfig resolve(ProductMapping productMapping) { + TagMappingConfig resolved = new TagMappingConfig(); + resolved.setTag_id(productMapping != null && productMapping.getTag_id() != null + ? productMapping.getTag_id() + : tag_id); + resolved.setMapping(productMapping != null && productMapping.getMapping() != null + ? productMapping.getMapping() + : mapping); + List productExclusions = productMapping == null + ? null + : productMapping.getSuppression_exclusions(); + List effectiveExclusions = productExclusions != null + ? productExclusions + : suppression_exclusions; + resolved.setSuppression_exclusions(effectiveExclusions == null + ? Collections.emptyList() + : new ArrayList<>(effectiveExclusions)); + return resolved; + } + public boolean hasSuppressionExclusions() { return suppression_exclusions != null && suppression_exclusions.stream() @@ -84,6 +126,40 @@ public boolean isSuppressionExcluded(SuppressionExclusionContext context) { .anyMatch(exclusion -> exclusion.matches(context)); } + public Result getResult(boolean tierOne, ResultType resultType) { + Tier tier = tierOne ? mapping.getTier_1() : mapping.getTier_2(); + return switch (resultType) { + case FP -> tier.getFp(); + case TP -> tier.getTp(); + case UNSURE -> tier.getUnsure(); + }; + } + + public Set getMappedValues() { + if (mapping == null) { + return Collections.emptySet(); + } + LinkedHashSet values = new LinkedHashSet<>(); + addMappedValues(values, mapping.getTier_1()); + addMappedValues(values, mapping.getTier_2()); + return Collections.unmodifiableSet(values); + } + + private void addMappedValues(Set values, Tier tier) { + if (tier == null) { + return; + } + addMappedValue(values, tier.getFp()); + addMappedValue(values, tier.getTp()); + addMappedValue(values, tier.getUnsure()); + } + + private void addMappedValue(Set values, Result result) { + if (result != null && result.getValue() != null && !result.getValue().isBlank()) { + values.add(result.getValue()); + } + } + private void validateTier(List errors, String path, Tier tier) { validateRequired(errors, path, tier); if (tier != null) { @@ -259,6 +335,19 @@ public static class Mapping { private Tier tier_2; } + @Data @Reflectable + public static class ProductMapping { + private String tag_id; + private List suppression_exclusions; + private Mapping mapping; + } + + public enum ResultType { + FP, + TP, + UNSURE + } + @Data @Reflectable public static class Tier { private Result fp; diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastIssue.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastIssue.java index cdb564c258f..5b174eb209f 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastIssue.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastIssue.java @@ -13,8 +13,8 @@ package com.fortify.cli.aviator.dast; import java.util.ArrayList; -import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -37,6 +37,7 @@ public class DastIssue { private String cweDescription; // Full CWE description text private String sessionUrl; // URL of the session containing this issue private List reproStepUrls = new ArrayList<>(); + private List reproSteps = new ArrayList<>(); // ReportSections for audit context private String summary; // Summary from ReportSection @@ -46,7 +47,7 @@ public class DastIssue { private String referenceInfo; // Reference Info from ReportSection // Additional classifications - private Map classifications = new HashMap<>(); // kind -> value + private Map classifications = new LinkedHashMap<>(); // kind -> value // Audit status private boolean suppressed = false; diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastReproStep.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastReproStep.java new file mode 100644 index 00000000000..bcf4898d25a --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastReproStep.java @@ -0,0 +1,25 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.dast; + +import lombok.Data; + +/** + * Represents one ordered navigation or attack step reported by WebInspect. + */ +@Data +public class DastReproStep { + private String source; + private String url; + private String postParams; +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/StreamingWebInspectParser.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/StreamingWebInspectParser.java index c3fd3994dd2..01dee42b73f 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/StreamingWebInspectParser.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/StreamingWebInspectParser.java @@ -32,7 +32,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.common.util.SecureXmlParserFactory; /** * Streaming (StAX-based) parser for WebInspect XML files contained in DAST FPR files. @@ -47,9 +49,7 @@ public class StreamingWebInspectParser { public StreamingWebInspectParser(FprHandle fprHandle) { this.fprHandle = fprHandle; - this.xmlInputFactory = XMLInputFactory.newInstance(); - xmlInputFactory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); - xmlInputFactory.setProperty(XMLInputFactory.SUPPORT_DTD, false); + this.xmlInputFactory = SecureXmlParserFactory.newXmlInputFactory(); } /** @@ -64,7 +64,7 @@ public List parse() { Path webInspectPath = fprHandle.getPath("/webinspect.xml"); if (!Files.exists(webInspectPath)) { - throw new RuntimeException("webinspect.xml not found in DAST FPR"); + throw new AviatorTechnicalException("webinspect.xml not found in DAST FPR"); } try (InputStream inputStream = Files.newInputStream(webInspectPath)) { @@ -86,9 +86,9 @@ public List parse() { logger.info("Parsed {} DAST issues from webinspect.xml (streaming)", issues.size()); } catch (XMLStreamException e) { - throw new RuntimeException("Failed to parse webinspect.xml: " + e.getMessage(), e); + throw new AviatorTechnicalException("Failed to parse webinspect.xml", e); } catch (IOException e) { - throw new RuntimeException("Failed to read webinspect.xml: " + e.getMessage(), e); + throw new AviatorTechnicalException("Failed to read webinspect.xml", e); } return issues; @@ -106,7 +106,7 @@ public List parseSessions() { Path webInspectPath = fprHandle.getPath("/webinspect.xml"); if (!Files.exists(webInspectPath)) { - throw new RuntimeException("webinspect.xml not found in DAST FPR"); + throw new AviatorTechnicalException("webinspect.xml not found in DAST FPR"); } try (InputStream inputStream = Files.newInputStream(webInspectPath)) { @@ -133,9 +133,9 @@ public List parseSessions() { sessions.size(), totalIssues); } catch (XMLStreamException e) { - throw new RuntimeException("Failed to parse webinspect.xml: " + e.getMessage(), e); + throw new AviatorTechnicalException("Failed to parse webinspect.xml", e); } catch (IOException e) { - throw new RuntimeException("Failed to read webinspect.xml: " + e.getMessage(), e); + throw new AviatorTechnicalException("Failed to read webinspect.xml", e); } return sessions; @@ -429,12 +429,12 @@ private void parseReproSteps(XMLStreamReader reader, DastIssue issue) while (reader.hasNext()) { int event = reader.next(); - if (event == XMLStreamConstants.START_ELEMENT) { - if ("Url".equals(reader.getLocalName())) { - String url = readElementText(reader); - if (url != null && !url.isEmpty()) { - issue.getReproStepUrls().add(url); - } + if (event == XMLStreamConstants.START_ELEMENT + && "ReproStep".equals(reader.getLocalName())) { + DastReproStep step = parseReproStep(reader); + if (step.getUrl() != null && !step.getUrl().isEmpty()) { + issue.getReproStepUrls().add(step.getUrl()); + issue.getReproSteps().add(step); } } else if (event == XMLStreamConstants.END_ELEMENT && "ReproSteps".equals(reader.getLocalName())) { @@ -443,6 +443,25 @@ private void parseReproSteps(XMLStreamReader reader, DastIssue issue) } } + private DastReproStep parseReproStep(XMLStreamReader reader) throws XMLStreamException { + var step = new DastReproStep(); + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.START_ELEMENT) { + switch (reader.getLocalName()) { + case "Source" -> step.setSource(readElementText(reader)); + case "Url" -> step.setUrl(readElementText(reader)); + case "PostParams" -> step.setPostParams(readElementText(reader)); + default -> { } + } + } else if (event == XMLStreamConstants.END_ELEMENT + && "ReproStep".equals(reader.getLocalName())) { + return step; + } + } + return step; + } + // ========================================================================= // ReportSection parsing // ========================================================================= diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/WebInspectParser.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/WebInspectParser.java index d41a998a813..f3627a7e598 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/WebInspectParser.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/WebInspectParser.java @@ -21,9 +21,7 @@ import java.util.Base64; import java.util.List; -import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import org.slf4j.Logger; @@ -34,6 +32,7 @@ import org.xml.sax.SAXException; import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.common.util.SecureXmlParserFactory; /** * Parser for WebInspect XML files contained in DAST FPR files. @@ -62,7 +61,7 @@ public List parse() { } try (InputStream inputStream = Files.newInputStream(webInspectPath)) { - DocumentBuilderFactory factory = createSecureDocumentBuilderFactory(); + var factory = SecureXmlParserFactory.newDocumentBuilderFactory(false); DocumentBuilder builder = factory.newDocumentBuilder(); Document document = builder.parse(inputStream); @@ -114,7 +113,7 @@ public List parseSessions() { } try (InputStream inputStream = Files.newInputStream(webInspectPath)) { - DocumentBuilderFactory factory = createSecureDocumentBuilderFactory(); + var factory = SecureXmlParserFactory.newDocumentBuilderFactory(false); DocumentBuilder builder = factory.newDocumentBuilder(); Document document = builder.parse(inputStream); @@ -432,14 +431,4 @@ private String stripHtmlTags(String html) { .trim(); } - private DocumentBuilderFactory createSecureDocumentBuilderFactory() throws ParserConfigurationException { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); - factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); - factory.setFeature("http://xml.org/sax/features/external-general-entities", false); - factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - factory.setXIncludeAware(false); - factory.setExpandEntityReferences(false); - return factory; - } } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorConnectionDiagnostics.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorConnectionDiagnostics.java new file mode 100644 index 00000000000..820839f2d8f --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorConnectionDiagnostics.java @@ -0,0 +1,340 @@ +/* + * 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.diagnose; + +import java.io.IOException; +import java.net.InetAddress; +import java.util.Arrays; +import java.util.List; +import java.util.StringJoiner; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.aviator._common.exception.AviatorBugException; +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; +import com.fortify.cli.aviator._common.exception.UnsupportedAviatorUrlSchemeException; +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper.AviatorConnectionPlan; +import com.fortify.cli.common.json.JsonHelper; + +public class AviatorConnectionDiagnostics { + private static final List TRANSPORT_STAGES = List.of( + AviatorDiagnosticStage.DNS, + AviatorDiagnosticStage.TCP, + AviatorDiagnosticStage.PROXY, + AviatorDiagnosticStage.TLS, + AviatorDiagnosticStage.GRPC); + + private final IAviatorDiagnosticProbe probe; + + public AviatorConnectionDiagnostics() { + this(new AviatorDefaultDiagnosticProbe()); + } + + /** Visible for tests and product helpers that inject a probe. */ + public AviatorConnectionDiagnostics(IAviatorDiagnosticProbe probe) { + this.probe = probe; + } + + public AviatorDiagnosticReport diagnose(String url, int timeoutSeconds, String sourceType) { + var report = new AviatorDiagnosticReport(); + report.begin(AviatorDiagnosticStage.ENDPOINT); + try { + return diagnoseValidated(report, AviatorGrpcClientHelper.createConnectionPlan(url), timeoutSeconds, + sourceType); + } catch (AviatorSimpleException e) { + failEndpoint(report, e); + skipAfter(report, null, AviatorDiagnosticStage.ENDPOINT, "endpoint validation failed"); + return report; + } + } + + /** + * Run diagnostics for a pre-built connection plan (tests inject plans to avoid ambient proxy env). + */ + public AviatorDiagnosticReport diagnose(AviatorConnectionPlan connectionPlan, int timeoutSeconds, String sourceType) { + var report = new AviatorDiagnosticReport(); + report.begin(AviatorDiagnosticStage.ENDPOINT); + return diagnoseValidated(report, connectionPlan, timeoutSeconds, sourceType); + } + + private AviatorDiagnosticReport diagnoseValidated(AviatorDiagnosticReport report, + AviatorConnectionPlan connectionPlan, int timeoutSeconds, String sourceType) { + report.pass(AviatorDiagnosticStage.ENDPOINT, + "Endpoint is valid: "+connectionPlan.normalizedUrl(), "No action required", + endpointEvidence(connectionPlan, sourceType)); + + if (!runDns(report, connectionPlan)) { + skipAfter(report, connectionPlan, AviatorDiagnosticStage.DNS, "DNS resolution failed"); + return report; + } + if (!runTcp(report, connectionPlan, timeoutSeconds)) { + skipAfter(report, connectionPlan, AviatorDiagnosticStage.TCP, "TCP connectivity failed"); + return report; + } + if (!runTunnelStages(report, connectionPlan, timeoutSeconds)) { + return report; + } + runGrpc(report, connectionPlan, timeoutSeconds); + return report; + } + + private static void failEndpoint(AviatorDiagnosticReport report, AviatorSimpleException e) { + var evidence = AviatorDiagnosticEvidence.errorEvidence(e); + if (e instanceof UnsupportedAviatorUrlSchemeException schemeFailure) { + evidence.put("scheme", schemeFailure.getScheme()); + evidence.put("providedUrl", schemeFailure.getProvidedUrl()); + report.fail(AviatorDiagnosticStage.ENDPOINT, + UnsupportedAviatorUrlSchemeException.STAGE_SUMMARY, + UnsupportedAviatorUrlSchemeException.STAGE_GUIDANCE, + evidence); + return; + } + report.fail(AviatorDiagnosticStage.ENDPOINT, + "Endpoint is invalid", "Use a valid Aviator host name and optional port", evidence); + } + + private boolean runDns(AviatorDiagnosticReport report, AviatorConnectionPlan connectionPlan) { + report.begin(AviatorDiagnosticStage.DNS); + try { + var evidence = JsonHelper.getObjectMapper().createObjectNode(); + addAddresses(evidence, "resolvedAddresses", probe.resolve(connectionPlan.target().host())); + if (connectionPlan.proxyDescriptor().isPresent()) { + var proxy = connectionPlan.proxyDescriptor().get(); + addAddresses(evidence, "proxyResolvedAddresses", probe.resolve(proxy.getProxyHost())); + } + report.pass(AviatorDiagnosticStage.DNS, + "Host name resolved: "+addresses(evidence, "resolvedAddresses"), "No action required", evidence); + return true; + } catch (IOException e) { + report.fail(AviatorDiagnosticStage.DNS, + "DNS resolution failed", "Check the Aviator host name, DNS, VPN, or proxy settings", + AviatorDiagnosticEvidence.errorEvidence(e)); + return false; + } + } + + /** + * Probes TCP to the next hop (proxy or Aviator host) and closes the socket. + *

+ * Intentional second open: {@link #runTunnelStages} opens a new connection for + * CONNECT/TLS so a pure next-hop TCP failure stays distinct from proxy CONNECT or TLS + * handshake failure. Do not fold TCP into the tunnel solely to avoid a double connect. + */ + private boolean runTcp(AviatorDiagnosticReport report, AviatorConnectionPlan connectionPlan, int timeoutSeconds) { + report.begin(AviatorDiagnosticStage.TCP); + var proxyDescriptor = connectionPlan.proxyDescriptor(); + var nextHopHost = proxyDescriptor.map(proxy -> proxy.getProxyHost()).orElse(connectionPlan.target().host()); + var nextHopPort = proxyDescriptor.map(proxy -> proxy.getProxyPort()).orElse(connectionPlan.effectivePort()); + var evidence = nextHopEvidence(nextHopHost, nextHopPort, proxyDescriptor.isPresent()); + try { + probe.connect(nextHopHost, nextHopPort, timeoutSeconds); + report.pass(AviatorDiagnosticStage.TCP, + "TCP connection opened to "+nextHopHost+":"+nextHopPort, "No action required", evidence); + return true; + } catch (Exception e) { + putError(evidence, e); + var guidance = proxyDescriptor.isPresent() + ? "Check proxy host, port, credentials, and firewall access" + : "Check firewall, VPN, proxy, and port 443 access"; + report.fail(AviatorDiagnosticStage.TCP, "TCP connection failed", guidance, evidence); + return false; + } + } + + /** + * PROXY + TLS from one tunnel session (single CONNECT when proxy is configured). + * Runs after the TCP stage, which already opened and closed a next-hop probe socket + * so stage failures remain isolated (see {@link #runTcp}). + * + * @return true if TLS stage continued the pipeline (pass or alpn warn) + */ + private boolean runTunnelStages(AviatorDiagnosticReport report, AviatorConnectionPlan connectionPlan, + int timeoutSeconds) { + var hasProxy = connectionPlan.proxyDescriptor().isPresent(); + // First stage that will be recorded from this shared tunnel session. + report.begin(hasProxy ? AviatorDiagnosticStage.PROXY : AviatorDiagnosticStage.TLS); + var tunnel = probe.probeTunnel(connectionPlan, timeoutSeconds); + if (tunnel instanceof AviatorTunnelResult.ProxyConnectFailed failed) { + appendProxyFailure(report, connectionPlan, failed); + skipAfter(report, connectionPlan, AviatorDiagnosticStage.PROXY, "proxy CONNECT failed"); + return false; + } + appendProxyPassIfConfigured(report, connectionPlan, tunnel); + if (hasProxy) { + report.begin(AviatorDiagnosticStage.TLS); + } + if (tunnel instanceof AviatorTunnelResult.TlsFailed failed) { + appendTlsFailure(report, connectionPlan, failed); + skipAfter(report, connectionPlan, AviatorDiagnosticStage.TLS, "TLS handshake failed"); + return false; + } + if (tunnel instanceof AviatorTunnelResult.TlsSucceeded ok) { + appendTlsSuccess(report, ok); + return true; + } + throw new AviatorBugException("Unhandled tunnel result: " + tunnel); + } + + private void appendProxyFailure(AviatorDiagnosticReport report, AviatorConnectionPlan connectionPlan, + AviatorTunnelResult.ProxyConnectFailed failed) { + var evidence = AviatorDiagnosticEvidence.errorEvidence(failed.error()); + putProxyEvidence(evidence, connectionPlan); + report.fail(AviatorDiagnosticStage.PROXY, "Proxy CONNECT failed", + "Check proxy host, port, credentials, CONNECT allow-list, and authentication method", evidence); + } + + private void appendProxyPassIfConfigured(AviatorDiagnosticReport report, + AviatorConnectionPlan connectionPlan, AviatorTunnelResult tunnel) { + if (connectionPlan.proxyDescriptor().isEmpty()) { + return; + } + var evidence = JsonHelper.getObjectMapper().createObjectNode(); + evidence.put("proxyConnectStatus", tunnel.proxyConnectStatus()); + putProxyEvidence(evidence, connectionPlan); + report.pass(AviatorDiagnosticStage.PROXY, + "Proxy CONNECT succeeded through "+proxyEndpoint(evidence), "No action required", evidence); + } + + private void appendTlsSuccess(AviatorDiagnosticReport report, AviatorTunnelResult.TlsSucceeded ok) { + var evidence = JsonHelper.getObjectMapper().createObjectNode(); + evidence.put("tlsProtocol", ok.protocol()); + evidence.put("tlsCipherSuite", ok.cipherSuite()); + evidence.put("tlsPeerSubject", ok.peerSubject()); + evidence.put("tlsAlpnProtocol", ok.applicationProtocol()); + evidence.put("proxyConnectStatus", ok.proxyConnectStatusLine()); + evidence.put("tlsPhase", AviatorTlsPhase.HANDSHAKE.id()); + if (!"h2".equals(ok.applicationProtocol())) { + report.warn(AviatorDiagnosticStage.TLS, + tlsSummary("TLS works, but HTTP/2 was not enabled", ok), + "Allow ALPN h2 through the proxy or gateway to aviator-grpc-server", true, evidence); + } else { + report.pass(AviatorDiagnosticStage.TLS, + tlsSummary("TLS and HTTP/2 are available", ok), "No action required", evidence); + } + } + + private void appendTlsFailure(AviatorDiagnosticReport report, AviatorConnectionPlan connectionPlan, + AviatorTunnelResult.TlsFailed failed) { + var evidence = AviatorDiagnosticEvidence.errorEvidence(failed.error()); + evidence.put("tlsPhase", failed.phase().id()); + if (failed.proxyConnectStatusLine() != null) { + evidence.put("proxyConnectStatus", failed.proxyConnectStatusLine()); + } + putProxyEvidence(evidence, connectionPlan); + if (failed.phase() == AviatorTlsPhase.HANDSHAKE) { + report.fail(AviatorDiagnosticStage.TLS, "TLS handshake failed", + "Check certificate trust, SNI, and TLS inspection settings", evidence); + return; + } + var guidance = connectionPlan.proxyDescriptor().isPresent() + ? "Check proxy host, port, credentials, and firewall access to the proxy" + : "Check firewall, VPN, proxy, and port access to the Aviator host"; + report.fail(AviatorDiagnosticStage.TLS, "Could not open connection for TLS probe", guidance, evidence); + } + + private void runGrpc(AviatorDiagnosticReport report, AviatorConnectionPlan connectionPlan, int timeoutSeconds) { + report.begin(AviatorDiagnosticStage.GRPC); + try { + applyGrpc(report, probe.probeGrpc(connectionPlan.originalUrl(), timeoutSeconds)); + } catch (Exception e) { + applyGrpc(report, AviatorGrpcReachabilityResult.probeError(e)); + } + } + + private void applyGrpc(AviatorDiagnosticReport report, AviatorGrpcReachabilityResult grpc) { + var evidence = JsonHelper.getObjectMapper().createObjectNode(); + grpc.putEvidence(evidence); + if (grpc.pattern() != null) { + evidence.put("pattern", grpc.pattern().wireId()); + } + var summary = grpc.stageSummary(); + if (grpc.stagePass()) { + report.pass(AviatorDiagnosticStage.GRPC, summary, grpc.stageGuidance(), evidence); + } else { + report.fail(AviatorDiagnosticStage.GRPC, summary, grpc.stageGuidance(), evidence); + } + } + + private ObjectNode endpointEvidence(AviatorConnectionPlan connectionPlan, String sourceType) { + var evidence = JsonHelper.getObjectMapper().createObjectNode(); + evidence.put("sourceType", sourceType); + evidence.put("targetHost", connectionPlan.target().host()); + evidence.put("targetPort", connectionPlan.effectivePort()); + evidence.put("normalizedUrl", connectionPlan.normalizedUrl()); + putProxyEvidence(evidence, connectionPlan); + return evidence; + } + + private static ObjectNode nextHopEvidence(String host, int port, boolean proxy) { + var evidence = JsonHelper.getObjectMapper().createObjectNode(); + evidence.put("nextHopHost", host); + evidence.put("nextHopPort", port); + evidence.put("nextHopType", proxy ? "proxy" : "aviator"); + return evidence; + } + + private static void putProxyEvidence(ObjectNode evidence, AviatorConnectionPlan connectionPlan) { + connectionPlan.proxyDescriptor().ifPresent(proxy -> { + evidence.put("proxyHost", proxy.getProxyHost()); + evidence.put("proxyPort", proxy.getProxyPort()); + evidence.put("proxyAuthConfigured", proxy.getProxyUser() != null); + }); + } + + private static void putError(ObjectNode evidence, Exception e) { + AviatorDiagnosticEvidence.merge(evidence, AviatorDiagnosticEvidence.errorEvidence(e)); + } + + /** + * Append WARN skips for every transport stage strictly after {@code failedStage}. + * PROXY is omitted when the plan has no proxy (or plan is null after endpoint failure). + */ + private static void skipAfter(AviatorDiagnosticReport report, AviatorConnectionPlan connectionPlan, + AviatorDiagnosticStage failedStage, String reason) { + var hasProxy = connectionPlan != null && connectionPlan.proxyDescriptor().isPresent(); + var afterFailed = failedStage == AviatorDiagnosticStage.ENDPOINT; + for (var stage : TRANSPORT_STAGES) { + if (!afterFailed) { + if (stage == failedStage) { + afterFailed = true; + } + continue; + } + if (stage == AviatorDiagnosticStage.PROXY && !hasProxy) { + continue; + } + report.skipWarn(stage, reason, + "Resolve the previous failed required stage first", + AviatorDiagnosticEvidence.empty()); + } + } + + private static void addAddresses(ObjectNode evidence, String fieldName, InetAddress[] addresses) { + var array = evidence.putArray(fieldName); + Arrays.stream(addresses).map(InetAddress::getHostAddress).forEach(array::add); + } + + private static String addresses(ObjectNode evidence, String fieldName) { + var result = new StringJoiner(", "); + evidence.withArray(fieldName).forEach(address -> result.add(address.asText())); + return result.toString(); + } + + private static String proxyEndpoint(ObjectNode evidence) { + return evidence.path("proxyHost").asText()+":"+evidence.path("proxyPort").asInt(); + } + + private static String tlsSummary(String summary, AviatorTunnelResult.TlsSucceeded result) { + return summary+": "+result.protocol()+", ALPN "+result.applicationProtocol(); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDefaultDiagnosticProbe.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDefaultDiagnosticProbe.java new file mode 100644 index 00000000000..96ee570dfb7 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDefaultDiagnosticProbe.java @@ -0,0 +1,264 @@ +/* + * 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.diagnose; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStreamWriter; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.cert.X509Certificate; +import java.util.Base64; +import java.util.List; +import java.util.regex.Pattern; + +import javax.net.ssl.SNIHostName; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; + +import com.fortify.cli.aviator._common.exception.AviatorBugException; +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper.AviatorConnectionPlan; +import com.fortify.cli.common.http.ssl.trust.FcliTrustManager; + +import io.grpc.Status; +import io.grpc.StatusRuntimeException; + +public class AviatorDefaultDiagnosticProbe implements IAviatorDiagnosticProbe { + private static final Pattern NON_GRPC_HTTP_RESPONSE_PATTERN = Pattern.compile( + "HTTP status code (\\d+) invalid content-type: ([^\\s;]+(?:;\\s*[^\\s]+)?)", Pattern.CASE_INSENSITIVE); + private static final int MAX_CONNECT_HEADER_BYTES = 64 * 1024; + + @Override + public InetAddress[] resolve(String host) throws IOException { + return InetAddress.getAllByName(host); + } + + @Override + public void connect(String host, int port, int timeoutSeconds) throws IOException { + try (var socket = new Socket()) { + socket.connect(new InetSocketAddress(host, port), toMillis(timeoutSeconds)); + } + } + + @Override + public AviatorTunnelResult probeTunnel(AviatorConnectionPlan connectionPlan, int timeoutSeconds) { + var proxyConfigured = connectionPlan.proxyDescriptor().isPresent(); + try (var rawSocket = new Socket()) { + var opened = openTunnelSocket(rawSocket, connectionPlan, timeoutSeconds, proxyConfigured); + if (opened instanceof OpenFailed failed) { + return failed.result(); + } + if (opened instanceof OpenedSocket openedOk) { + return handshakeTls(rawSocket, connectionPlan, timeoutSeconds, proxyConfigured, + openedOk.proxyConnectStatus()); + } + // Sealed OpenResult exhaustiveness; should be unreachable. + throw new AviatorBugException("Unhandled tunnel open result: " + opened.getClass().getName()); + } catch (Exception e) { + if (e instanceof AviatorBugException bug) { + throw bug; + } + return new AviatorTunnelResult.TlsFailed(proxyConfigured, "not-used", AviatorTlsPhase.CONNECT, e); + } + } + + private OpenResult openTunnelSocket(Socket rawSocket, AviatorConnectionPlan connectionPlan, int timeoutSeconds, + boolean proxyConfigured) { + try { + if (proxyConfigured) { + var proxy = connectionPlan.proxyDescriptor().get(); + rawSocket.connect(new InetSocketAddress(proxy.getProxyHost(), proxy.getProxyPort()), toMillis(timeoutSeconds)); + rawSocket.setSoTimeout(toMillis(timeoutSeconds)); + var status = performProxyConnect(rawSocket, connectionPlan); + return new OpenedSocket(status); + } + rawSocket.connect(new InetSocketAddress(connectionPlan.target().host(), connectionPlan.effectivePort()), + toMillis(timeoutSeconds)); + rawSocket.setSoTimeout(toMillis(timeoutSeconds)); + return new OpenedSocket("not-used"); + } catch (AviatorProxyConnectException e) { + return new OpenFailed(new AviatorTunnelResult.ProxyConnectFailed(e)); + } catch (Exception e) { + return new OpenFailed(new AviatorTunnelResult.TlsFailed(proxyConfigured, "not-used", AviatorTlsPhase.CONNECT, e)); + } + } + + private AviatorTunnelResult handshakeTls(Socket rawSocket, AviatorConnectionPlan connectionPlan, int timeoutSeconds, + boolean proxyConfigured, String proxyConnectStatus) { + try { + var sslSocketFactory = createSslSocketFactory(); + try (var sslSocket = (SSLSocket) sslSocketFactory.createSocket( + rawSocket, connectionPlan.target().host(), connectionPlan.effectivePort(), true)) { + sslSocket.setSoTimeout(toMillis(timeoutSeconds)); + applyTlsParameters(sslSocket, connectionPlan.target().host()); + sslSocket.startHandshake(); + return toTlsSucceeded(sslSocket, proxyConfigured, proxyConnectStatus); + } + } catch (Exception e) { + var phase = AviatorTlsFailureDetector.isTlsFailure(e) ? AviatorTlsPhase.HANDSHAKE : AviatorTlsPhase.CONNECT; + return new AviatorTunnelResult.TlsFailed(proxyConfigured, proxyConnectStatus, phase, e); + } + } + + private static void applyTlsParameters(SSLSocket sslSocket, String host) { + SSLParameters parameters = sslSocket.getSSLParameters(); + parameters.setEndpointIdentificationAlgorithm("HTTPS"); + parameters.setServerNames(List.of(new SNIHostName(host))); + parameters.setApplicationProtocols(new String[] {"h2"}); + sslSocket.setSSLParameters(parameters); + } + + private static AviatorTunnelResult.TlsSucceeded toTlsSucceeded(SSLSocket sslSocket, boolean proxyConfigured, + String proxyConnectStatus) throws Exception { + var session = sslSocket.getSession(); + var certificates = session.getPeerCertificates(); + var peerSubject = certificates.length > 0 && certificates[0] instanceof X509Certificate certificate + ? certificate.getSubjectX500Principal().getName() + : "unknown"; + return new AviatorTunnelResult.TlsSucceeded( + proxyConfigured, proxyConnectStatus, session.getProtocol(), session.getCipherSuite(), + peerSubject, sslSocket.getApplicationProtocol()); + } + + @Override + public AviatorGrpcReachabilityResult probeGrpc(String url, int timeoutSeconds) throws Exception { + try (var client = AviatorGrpcClientHelper.createClient(url)) { + client.probeGetDefaultQuota(timeoutSeconds); + return AviatorGrpcReachabilityResult.responseReceived("OK", "Default quota response received"); + } catch (StatusRuntimeException e) { + var status = e.getStatus(); + var responseReceived = isResponseReceived(status.getCode()); + var description = summarizeDescription(status.getDescription()); + var httpResponse = parseNonGrpcHttpResponse(description); + if (httpResponse != null) { + return AviatorGrpcReachabilityResult.nonGrpcHttp(status.getCode().name(), + httpResponse.statusCode(), httpResponse.contentType(), description); + } + if (responseReceived) { + return AviatorGrpcReachabilityResult.responseReceived(status.getCode().name(), description); + } + var tlsFailure = AviatorTlsFailureDetector.isTlsFailure(e, description) + || AviatorTlsFailureDetector.isTlsFailure(status.getCause(), description); + if (tlsFailure) { + return AviatorGrpcReachabilityResult.tlsFailed(status.getCode().name(), description); + } + return AviatorGrpcReachabilityResult.noResponse(status.getCode().name(), description); + } + } + + private static SSLSocketFactory createSslSocketFactory() throws IOException { + try { + FcliTrustManager.refreshIfChanged(); + var context = SSLContext.getInstance("TLS"); + context.init(null, new TrustManager[] {FcliTrustManager.getInstance()}, null); + return context.getSocketFactory(); + } catch (GeneralSecurityException e) { + throw new IOException("Unable to initialize TLS trust context for Aviator diagnostics", e); + } + } + + static String performProxyConnect(Socket socket, AviatorConnectionPlan connectionPlan) throws IOException { + var proxy = connectionPlan.proxyDescriptor() + .orElseThrow(() -> new AviatorBugException("performProxyConnect requires a proxy on the connection plan")); + var target = connectionPlan.target().host() + ":" + connectionPlan.effectivePort(); + var writer = new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.ISO_8859_1); + writer.write("CONNECT " + target + " HTTP/1.1\r\n"); + writer.write("Host: " + target + "\r\n"); + if (proxy.getProxyUser() != null && proxy.getProxyPasswordAsString() != null) { + var credentials = proxy.getProxyUser() + ":" + proxy.getProxyPasswordAsString(); + var encoded = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + writer.write("Proxy-Authorization: Basic " + encoded + "\r\n"); + } + writer.write("Proxy-Connection: Keep-Alive\r\n"); + writer.write("\r\n"); + writer.flush(); + + var statusLine = readHttpHeaders(socket.getInputStream()); + validateProxyConnectStatusLine(statusLine); + return statusLine; + } + + /** + * Validates the CONNECT status line after headers are read. Package-visible for unit tests. + */ + static void validateProxyConnectStatusLine(String statusLine) throws AviatorProxyConnectException { + if (statusLine == null || !statusLine.startsWith("HTTP/")) { + throw new AviatorProxyConnectException("Proxy did not return an HTTP CONNECT response"); + } + if (!statusLine.matches("HTTP/\\d(?:\\.\\d)? 2\\d\\d.*")) { + throw new AviatorProxyConnectException("Proxy CONNECT failed: " + statusLine); + } + } + + static String readHttpHeaders(InputStream input) throws IOException { + var buffer = new ByteArrayOutputStream(); + var match = 0; + final byte[] end = new byte[] {'\r', '\n', '\r', '\n'}; + while (match < end.length) { + var value = input.read(); + if (value < 0) { + break; + } + buffer.write(value); + if (buffer.size() > MAX_CONNECT_HEADER_BYTES) { + throw new AviatorProxyConnectException( + "Proxy CONNECT response headers exceeded " + MAX_CONNECT_HEADER_BYTES + " bytes"); + } + match = value == end[match] ? match + 1 : (value == end[0] ? 1 : 0); + } + var headers = buffer.toString(StandardCharsets.ISO_8859_1); + var lineEnd = headers.indexOf("\r\n"); + return lineEnd < 0 ? headers.trim() : headers.substring(0, lineEnd); + } + + private static NonGrpcHttpResponse parseNonGrpcHttpResponse(String description) { + if (description == null) { + return null; + } + var matcher = NON_GRPC_HTTP_RESPONSE_PATTERN.matcher(description); + return matcher.find() ? new NonGrpcHttpResponse(matcher.group(1), matcher.group(2)) : null; + } + + private static String summarizeDescription(String description) { + if (description == null) { + return null; + } + var oneLine = description.replace('\r', ' ').replace('\n', ' ').replace('\t', ' ').trim(); + return oneLine.length() > 500 ? oneLine.substring(0, 500) + "..." : oneLine; + } + + private static boolean isResponseReceived(Status.Code code) { + return switch (code) { + case DEADLINE_EXCEEDED, UNAVAILABLE, UNKNOWN, CANCELLED -> false; + default -> true; + }; + } + + private static int toMillis(int timeoutSeconds) { + return Math.max(1, timeoutSeconds) * 1000; + } + + private sealed interface OpenResult {} + private record OpenedSocket(String proxyConnectStatus) implements OpenResult {} + private record OpenFailed(AviatorTunnelResult result) implements OpenResult {} + + private record NonGrpcHttpResponse(String statusCode, String contentType) {} +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticEvidence.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticEvidence.java new file mode 100644 index 00000000000..c4df5c02ab0 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticEvidence.java @@ -0,0 +1,54 @@ +/* + * 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.diagnose; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.json.JsonHelper; + +/** + * Shared helpers for diagnostic evidence JSON (no orchestration dependency). + */ +public final class AviatorDiagnosticEvidence { + private AviatorDiagnosticEvidence() {} + + public static ObjectNode empty() { + return JsonHelper.getObjectMapper().createObjectNode(); + } + + public static ObjectNode errorEvidence(Exception e) { + var evidence = empty(); + if (e == null) { + return evidence; + } + evidence.put("exceptionType", e.getClass().getName()); + evidence.put("exceptionMessage", e.getMessage()); + var cause = e.getCause(); + if (cause != null) { + evidence.put("causeType", cause.getClass().getName()); + evidence.put("causeMessage", cause.getMessage()); + var nested = cause.getCause(); + if (nested != null) { + evidence.put("rootCauseType", nested.getClass().getName()); + evidence.put("rootCauseMessage", nested.getMessage()); + } + } + return evidence; + } + + public static void merge(ObjectNode target, ObjectNode source) { + if (target == null || source == null) { + return; + } + source.fields().forEachRemaining(entry -> target.set(entry.getKey(), entry.getValue())); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticReport.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticReport.java new file mode 100644 index 00000000000..64a3544ba50 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticReport.java @@ -0,0 +1,154 @@ +/* + * 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.diagnose; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.json.JsonHelper; + +/** + * Owns stage order, collects diagnostic rows, and emits support log lines for one diagnose run. + *

+ * Call {@link #begin(AviatorDiagnosticStage)} (or the string overload) before running a stage; + * {@code pass}/{@code fail}/{@code warn}/{@code skip*} record the row and log the outcome. + * Skip APIs take a structured {@code reason} so log formatting never parses summary English. + */ +public final class AviatorDiagnosticReport { + private static final Logger LOG = LoggerFactory.getLogger(AviatorDiagnosticReport.class); + + private final List stages = new ArrayList<>(); + + public int nextOrder() { + return stages.size() + 1; + } + + /** DEBUG start line before a stage is executed (not for pure skip rows). */ + public void begin(AviatorDiagnosticStage stage) { + LOG.debug("Starting {} diagnostic", stage.displayName()); + } + + /** DEBUG start line for product stages (token/admin) that are not transport enums. */ + public void begin(String stageId) { + LOG.debug("Starting {} diagnostic", AviatorDiagnosticStage.displayNameFor(stageId)); + } + + public void add(AviatorDiagnosticStageResult result) { + stages.add(result); + logStageOutcome(result, false, null); + } + + private void addSkip(AviatorDiagnosticStageResult result, String reason) { + stages.add(result); + logStageOutcome(result, true, reason); + } + + private static void logStageOutcome(AviatorDiagnosticStageResult result, boolean skipped, String skipReason) { + var name = AviatorDiagnosticStage.displayNameFor(result.stage()); + if (skipped) { + LOG.info("{} skipped because {}", name, nullToEmpty(skipReason)); + return; + } + switch (result.status()) { + case PASS -> LOG.info("{}: PASS - {}", name, nullToEmpty(result.summary())); + case FAIL -> LOG.error("{}: FAIL - {}", name, failDetail(result)); + case WARN -> LOG.info("{}: WARN - {}", name, nullToEmpty(result.summary())); + } + } + + private static String failDetail(AviatorDiagnosticStageResult result) { + var summary = nullToEmpty(result.summary()); + var evidence = result.evidence(); + if (evidence == null || !evidence.hasNonNull("exceptionMessage")) { + return summary; + } + var exceptionMessage = evidence.get("exceptionMessage").asText(); + if (exceptionMessage == null || exceptionMessage.isBlank()) { + return summary; + } + return summary+" ("+exceptionMessage+")"; + } + + private static String nullToEmpty(String value) { + return value == null ? "" : value; + } + + public void pass(AviatorDiagnosticStage stage, String summary, String guidance, ObjectNode evidence) { + add(AviatorDiagnosticStageResult.pass(nextOrder(), stage, summary, guidance, evidence)); + } + + public void fail(AviatorDiagnosticStage stage, String summary, String guidance, ObjectNode evidence) { + add(AviatorDiagnosticStageResult.fail(nextOrder(), stage, summary, guidance, evidence)); + } + + /** + * Required transport skip WARN. Builds summary {@code Skipped because {reason}} and logs + * as a skip without parsing summary text. + */ + public void skipWarn(AviatorDiagnosticStage stage, String reason, String guidance, ObjectNode evidence) { + addSkip(AviatorDiagnosticStageResult.warn(nextOrder(), stage, skipSummary(reason), guidance, true, evidence), + reason); + } + + public void warn(AviatorDiagnosticStage stage, String summary, String guidance, boolean required, + ObjectNode evidence) { + add(AviatorDiagnosticStageResult.warn(nextOrder(), stage, summary, guidance, required, evidence)); + } + + public void optionalPass(String stage, String description, String summary, String guidance, ObjectNode evidence) { + add(AviatorDiagnosticStageResult.optionalPass(nextOrder(), stage, description, summary, guidance, evidence)); + } + + public void optionalFail(String stage, String description, String summary, String guidance, ObjectNode evidence) { + add(AviatorDiagnosticStageResult.optionalFail(nextOrder(), stage, description, summary, guidance, evidence)); + } + + /** + * Optional product-stage skip WARN. Builds summary {@code Skipped because {reason}}. + */ + public void optionalSkipWarn(String stage, String description, String reason, String guidance, ObjectNode evidence) { + addSkip(AviatorDiagnosticStageResult.warn(nextOrder(), stage, description, skipSummary(reason), guidance, false, + evidence), reason); + } + + private static String skipSummary(String reason) { + return "Skipped because "+reason; + } + + public List stages() { + return Collections.unmodifiableList(stages); + } + + public ArrayNode toArrayNode() { + var array = JsonHelper.getObjectMapper().createArrayNode(); + stages.stream().map(AviatorDiagnosticStageResult::asObjectNode).forEach(array::add); + return array; + } + + public boolean hasRequiredFailure() { + return stages.stream().anyMatch(AviatorDiagnosticStageResult::isRequiredFailure); + } + + /** True when the gRPC stage completed with status PASS (server response received). */ + public boolean hasGrpcStagePass() { + return stages.stream() + .anyMatch(result -> result.isStage(AviatorDiagnosticStage.GRPC) + && AviatorDiagnosticStatus.PASS.equals(result.status())); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticStage.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticStage.java new file mode 100644 index 00000000000..d4cdab1c173 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticStage.java @@ -0,0 +1,65 @@ +/* + * 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.diagnose; + +/** + * Transport diagnostic stage identifiers for report rows and evidence. + *

+ * Pipeline: {@link #ENDPOINT} then DNS → TCP → optional {@link #PROXY} → {@link #TLS} → {@link #GRPC}. + * Product-layer credential stages (token/admin) are not part of this enum; they append + * string stage ids from the connection diagnose helper only. + */ +public enum AviatorDiagnosticStage { + ENDPOINT("endpoint", "Aviator endpoint configuration validation", "Endpoint"), + DNS("dns", "DNS resolution", "DNS"), + TCP("tcp", "TCP connectivity", "TCP"), + PROXY("proxy", "HTTP proxy CONNECT", "Proxy"), + TLS("tls", "TLS handshake", "TLS"), + GRPC("grpc", "gRPC request/response reachability", "gRPC"); + + private final String id; + private final String description; + /** Human-readable label for log lines (e.g. {@code Endpoint}, {@code gRPC}). */ + private final String displayName; + + AviatorDiagnosticStage(String id, String description, String displayName) { + this.id = id; + this.description = description; + this.displayName = displayName; + } + + public String id() { + return id; + } + + public String description() { + return description; + } + + public String displayName() { + return displayName; + } + + /** Resolve wire id to display name; falls back to a simple capitalization of {@code stageId}. */ + public static String displayNameFor(String stageId) { + if (stageId == null || stageId.isBlank()) { + return "Stage"; + } + for (var stage : values()) { + if (stage.id.equals(stageId)) { + return stage.displayName; + } + } + return Character.toUpperCase(stageId.charAt(0)) + stageId.substring(1); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticStageResult.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticStageResult.java new file mode 100644 index 00000000000..4ff1eb37571 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticStageResult.java @@ -0,0 +1,96 @@ +/* + * 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.diagnose; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.json.JsonHelper; + +/** + * One diagnostic stage row. + *

+ * {@code required} only affects process exit when {@code status} is {@link AviatorDiagnosticStatus#FAIL}: + * a required FAIL forces soft exit 1 after the table is written. WARN rows may carry + * {@code required=true} for documentation of skip chains, but WARN never drives exit code. + */ +public record AviatorDiagnosticStageResult( + int order, + String stage, + String description, + AviatorDiagnosticStatus status, + boolean required, + String summary, + String guidance, + ObjectNode evidence) { + + public static AviatorDiagnosticStageResult of(int order, String stage, String description, + AviatorDiagnosticStatus status, boolean required, String summary, String guidance, ObjectNode evidence) { + return new AviatorDiagnosticStageResult(order, stage, description, status, required, summary, guidance, + evidence == null ? JsonHelper.getObjectMapper().createObjectNode() : evidence); + } + + public static AviatorDiagnosticStageResult of(int order, AviatorDiagnosticStage stage, + AviatorDiagnosticStatus status, boolean required, String summary, String guidance, ObjectNode evidence) { + return of(order, stage.id(), stage.description(), status, required, summary, guidance, evidence); + } + + public static AviatorDiagnosticStageResult pass(int order, AviatorDiagnosticStage stage, String summary, + String guidance, ObjectNode evidence) { + return of(order, stage, AviatorDiagnosticStatus.PASS, true, summary, guidance, evidence); + } + + public static AviatorDiagnosticStageResult fail(int order, AviatorDiagnosticStage stage, String summary, + String guidance, ObjectNode evidence) { + return of(order, stage, AviatorDiagnosticStatus.FAIL, true, summary, guidance, evidence); + } + + public static AviatorDiagnosticStageResult warn(int order, AviatorDiagnosticStage stage, String summary, + String guidance, boolean required, ObjectNode evidence) { + return of(order, stage, AviatorDiagnosticStatus.WARN, required, summary, guidance, evidence); + } + + public static AviatorDiagnosticStageResult warn(int order, String stage, String description, String summary, + String guidance, boolean required, ObjectNode evidence) { + return of(order, stage, description, AviatorDiagnosticStatus.WARN, required, summary, guidance, evidence); + } + + public static AviatorDiagnosticStageResult optionalPass(int order, String stage, String description, + String summary, String guidance, ObjectNode evidence) { + return of(order, stage, description, AviatorDiagnosticStatus.PASS, false, summary, guidance, evidence); + } + + public static AviatorDiagnosticStageResult optionalFail(int order, String stage, String description, + String summary, String guidance, ObjectNode evidence) { + return of(order, stage, description, AviatorDiagnosticStatus.FAIL, false, summary, guidance, evidence); + } + + public boolean isRequiredFailure() { + return required && AviatorDiagnosticStatus.FAIL.equals(status); + } + + public boolean isStage(AviatorDiagnosticStage stage) { + return stage != null && stage.id().equals(this.stage); + } + + public ObjectNode asObjectNode() { + var node = JsonHelper.getObjectMapper().createObjectNode(); + node.put("order", order); + node.put("stage", stage); + node.put("description", description); + node.put("status", status.name()); + node.put("required", required); + node.put("summary", summary); + node.put("guidance", guidance); + node.set("evidence", evidence); + return node; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticStatus.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticStatus.java new file mode 100644 index 00000000000..87663cc49b3 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticStatus.java @@ -0,0 +1,19 @@ +/* + * 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.diagnose; + +public enum AviatorDiagnosticStatus { + PASS, + FAIL, + WARN +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorGrpcPattern.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorGrpcPattern.java new file mode 100644 index 00000000000..24a0c64d7a8 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorGrpcPattern.java @@ -0,0 +1,34 @@ +/* + * 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.diagnose; + +/** + * Public gRPC stage evidence {@code pattern} wire ids. Sole automation vocabulary for gRPC failures. + */ +public enum AviatorGrpcPattern { + HTTP_RESPONSE_NOT_GRPC("http-response-not-grpc"), + /** gRPC path got no response (transport TLS may still have passed). */ + GRPC_NO_RESPONSE("grpc-no-response"), + GRPC_TLS_FAILED("grpc-tls-failed"), + GRPC_PROBE_ERROR("grpc-probe-error"); + + private final String wireId; + + AviatorGrpcPattern(String wireId) { + this.wireId = wireId; + } + + public String wireId() { + return wireId; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorGrpcReachabilityResult.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorGrpcReachabilityResult.java new file mode 100644 index 00000000000..550d5e407ff --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorGrpcReachabilityResult.java @@ -0,0 +1,256 @@ +/* + * 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.diagnose; + +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * Sealed gRPC probe outcome: taxonomy, stage summary/guidance, and wire {@code pattern} live here + * (no separate failure-category + classification re-label layer). + */ +public sealed interface AviatorGrpcReachabilityResult { + + String TLS_GUIDANCE = + "Check certificate trust, SNI, TLS inspection, and that the proxy does not break TLS to aviator-grpc-server"; + String NO_RESPONSE_GUIDANCE = + "Allow HTTP/2 gRPC traffic through the proxy, VPN, gateway, or load balancer to aviator-grpc-server"; + + String statusCode(); + + String description(); + + boolean stagePass(); + + String stageSummary(); + + String stageGuidance(); + + /** Public evidence pattern wire id, or {@code null} when the stage passes. */ + AviatorGrpcPattern pattern(); + + void putEvidence(ObjectNode evidence); + + /** Application-level or OK status codes still count as reachability success. */ + static AviatorGrpcReachabilityResult responseReceived(String statusCode, String description) { + return new ResponseReceived(statusCode, description); + } + + static AviatorGrpcReachabilityResult nonGrpcHttp(String statusCode, String httpStatus, String contentType, + String description) { + return new NonGrpcHttp(statusCode, httpStatus, contentType, description); + } + + static AviatorGrpcReachabilityResult tlsFailed(String statusCode, String description) { + return new TlsFailed(statusCode, description); + } + + static AviatorGrpcReachabilityResult noResponse(String statusCode, String description) { + return new NoResponse(statusCode, description); + } + + /** + * Exception thrown outside a classified gRPC status. Always preserves exception/cause evidence; + * TLS vs probe-error pattern is chosen from the exception chain. + */ + static AviatorGrpcReachabilityResult probeError(Exception e) { + return new ProbeError(e); + } + + record ResponseReceived(String statusCode, String description) implements AviatorGrpcReachabilityResult { + @Override + public boolean stagePass() { + return true; + } + + @Override + public String stageSummary() { + return "Aviator gRPC responded"; + } + + @Override + public String stageGuidance() { + return "No action required"; + } + + @Override + public AviatorGrpcPattern pattern() { + return null; + } + + @Override + public void putEvidence(ObjectNode evidence) { + evidence.put("grpcResponseReceived", true); + evidence.put("grpcStatusCode", statusCode); + putDescription(evidence, description); + evidence.put("httpResponseReceived", false); + } + } + + record NonGrpcHttp(String statusCode, String httpStatusCode, String httpContentType, String description) + implements AviatorGrpcReachabilityResult { + @Override + public boolean stagePass() { + return false; + } + + @Override + public String stageSummary() { + return "Received an HTTP page instead of gRPC"; + } + + @Override + public String stageGuidance() { + return "A VPN, proxy, or gateway returned a block, login, or error page; allow direct gRPC/HTTP2 to aviator-grpc-server"; + } + + @Override + public AviatorGrpcPattern pattern() { + return AviatorGrpcPattern.HTTP_RESPONSE_NOT_GRPC; + } + + @Override + public void putEvidence(ObjectNode evidence) { + evidence.put("grpcResponseReceived", false); + evidence.put("grpcStatusCode", statusCode); + putDescription(evidence, description); + evidence.put("httpResponseReceived", true); + if (httpStatusCode != null) { + evidence.put("httpStatusCode", httpStatusCode); + } + if (httpContentType != null) { + evidence.put("httpContentType", httpContentType); + } + } + } + + record TlsFailed(String statusCode, String description) implements AviatorGrpcReachabilityResult { + @Override + public boolean stagePass() { + return false; + } + + @Override + public String stageSummary() { + return "gRPC TLS handshake failed"; + } + + @Override + public String stageGuidance() { + return TLS_GUIDANCE; + } + + @Override + public AviatorGrpcPattern pattern() { + return AviatorGrpcPattern.GRPC_TLS_FAILED; + } + + @Override + public void putEvidence(ObjectNode evidence) { + putNoResponseEvidence(evidence, statusCode, description); + } + } + + record NoResponse(String statusCode, String description) implements AviatorGrpcReachabilityResult { + @Override + public boolean stagePass() { + return false; + } + + @Override + public String stageSummary() { + return "No gRPC response received"; + } + + @Override + public String stageGuidance() { + return NO_RESPONSE_GUIDANCE; + } + + @Override + public AviatorGrpcPattern pattern() { + return AviatorGrpcPattern.GRPC_NO_RESPONSE; + } + + @Override + public void putEvidence(ObjectNode evidence) { + putNoResponseEvidence(evidence, statusCode, description); + } + } + + /** + * Exception path: always includes exception/cause evidence keys; pattern is TLS or probe-error. + */ + record ProbeError(Exception error) implements AviatorGrpcReachabilityResult { + public ProbeError { + if (error == null) { + throw new NullPointerException("error"); + } + } + + private boolean tlsFailure() { + return AviatorTlsFailureDetector.isTlsFailure(error); + } + + @Override + public String statusCode() { + return "EXCEPTION"; + } + + @Override + public String description() { + return error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName(); + } + + @Override + public boolean stagePass() { + return false; + } + + @Override + public String stageSummary() { + return tlsFailure() ? "gRPC TLS handshake failed" : "gRPC probe failed"; + } + + @Override + public String stageGuidance() { + return tlsFailure() ? TLS_GUIDANCE : NO_RESPONSE_GUIDANCE; + } + + @Override + public AviatorGrpcPattern pattern() { + return tlsFailure() ? AviatorGrpcPattern.GRPC_TLS_FAILED : AviatorGrpcPattern.GRPC_PROBE_ERROR; + } + + @Override + public void putEvidence(ObjectNode evidence) { + AviatorDiagnosticEvidence.merge(evidence, AviatorDiagnosticEvidence.errorEvidence(error)); + evidence.put("grpcResponseReceived", false); + evidence.put("grpcStatusCode", statusCode()); + putDescription(evidence, description()); + evidence.put("httpResponseReceived", false); + } + } + + private static void putNoResponseEvidence(ObjectNode evidence, String statusCode, String description) { + evidence.put("grpcResponseReceived", false); + evidence.put("grpcStatusCode", statusCode); + putDescription(evidence, description); + evidence.put("httpResponseReceived", false); + } + + private static void putDescription(ObjectNode evidence, String description) { + if (description != null) { + evidence.put("grpcDescription", description); + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorProxyConnectException.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorProxyConnectException.java new file mode 100644 index 00000000000..ef20c3cb015 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorProxyConnectException.java @@ -0,0 +1,30 @@ +/* + * 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.diagnose; + +import java.io.IOException; + +/** + * Thrown when HTTP CONNECT through a proxy fails (distinct from TLS handshake failures). + */ +public class AviatorProxyConnectException extends IOException { + private static final long serialVersionUID = 1L; + + public AviatorProxyConnectException(String message) { + super(message); + } + + public AviatorProxyConnectException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorTlsFailureDetector.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorTlsFailureDetector.java new file mode 100644 index 00000000000..bf89108f87a --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorTlsFailureDetector.java @@ -0,0 +1,76 @@ +/* + * 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.diagnose; + +import java.security.cert.CertificateException; +import java.util.Locale; + +import javax.net.ssl.SSLException; + +/** + * Detects TLS/trust failures from throwables and status descriptions (cause-chain aware). + */ +public final class AviatorTlsFailureDetector { + private AviatorTlsFailureDetector() {} + + public static boolean isTlsFailure(Throwable throwable, String statusDescription) { + if (isTlsFailureMessage(statusDescription)) { + return true; + } + return isTlsFailure(throwable); + } + + public static boolean isTlsFailure(Throwable throwable) { + for (var current = throwable; current != null; current = current.getCause()) { + if (current instanceof SSLException || current instanceof CertificateException) { + return true; + } + var typeAndMessage = current.getClass().getName() + + " " + + (current.getMessage() == null ? "" : current.getMessage()); + if (isTlsFailureMessage(typeAndMessage)) { + return true; + } + } + return false; + } + + private static final String[] TLS_MESSAGE_MARKERS = { + "sslhandshakeexception", + "sslexception", + "sslengine", + "openssl", + "pkix path", + "unable to find valid certification path", + "certificateexception", + "certpathbuilderexception", + "certpathvalidatorexception", + "certificate_unknown", + "unknown_ca", + "certificate_required", + "handshake_failure" + }; + + static boolean isTlsFailureMessage(String description) { + if (description == null || description.isBlank()) { + return false; + } + var text = description.toLowerCase(Locale.ROOT); + for (var marker : TLS_MESSAGE_MARKERS) { + if (text.contains(marker)) { + return true; + } + } + return false; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorTlsPhase.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorTlsPhase.java new file mode 100644 index 00000000000..c62e906f3cd --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorTlsPhase.java @@ -0,0 +1,28 @@ +/* + * 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.diagnose; + +public enum AviatorTlsPhase { + CONNECT("connect"), + HANDSHAKE("handshake"); + + private final String id; + + AviatorTlsPhase(String id) { + this.id = id; + } + + public String id() { + return id; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorTunnelResult.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorTunnelResult.java new file mode 100644 index 00000000000..9e7ff1f4176 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorTunnelResult.java @@ -0,0 +1,54 @@ +/* + * 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.diagnose; + +/** + * Outcome of a single TCP(+CONNECT)+TLS probe session. CONNECT and TLS share one socket + * so diagnostics do not re-open the tunnel between the PROXY and TLS stages. + */ +public sealed interface AviatorTunnelResult { + + boolean proxyConfigured(); + + /** CONNECT status line or {@code not-used} when no proxy status applies. */ + default String proxyConnectStatus() { + if (this instanceof TlsSucceeded s) { + return s.proxyConnectStatusLine(); + } + if (this instanceof TlsFailed f) { + return f.proxyConnectStatusLine(); + } + return "not-used"; + } + + record ProxyConnectFailed(Exception error) implements AviatorTunnelResult { + @Override + public boolean proxyConfigured() { + return true; + } + } + + record TlsFailed( + boolean proxyConfigured, + String proxyConnectStatusLine, + AviatorTlsPhase phase, + Exception error) implements AviatorTunnelResult {} + + record TlsSucceeded( + boolean proxyConfigured, + String proxyConnectStatusLine, + String protocol, + String cipherSuite, + String peerSubject, + String applicationProtocol) implements AviatorTunnelResult {} +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/IAviatorDiagnosticProbe.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/IAviatorDiagnosticProbe.java new file mode 100644 index 00000000000..68f90f3ebc3 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/IAviatorDiagnosticProbe.java @@ -0,0 +1,37 @@ +/* + * 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.diagnose; + +import java.io.IOException; +import java.net.InetAddress; + +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper.AviatorConnectionPlan; + +public interface IAviatorDiagnosticProbe { + InetAddress[] resolve(String host) throws IOException; + + /** + * Short-lived TCP connect used only for the TCP diagnostic stage (open then close). + * The tunnel probe opens a separate connection afterward on purpose. + */ + void connect(String host, int port, int timeoutSeconds) throws IOException; + + /** + * Single session: TCP to next hop, optional HTTP CONNECT, then TLS. Emits one + * structured result so PROXY and TLS stages share a tunnel (no re-CONNECT between + * those stages). Called after {@link #connect}; the second TCP open is intentional. + */ + AviatorTunnelResult probeTunnel(AviatorConnectionPlan connectionPlan, int timeoutSeconds); + + AviatorGrpcReachabilityResult probeGrpc(String url, int timeoutSeconds) throws Exception; +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/model/FPRInfo.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/model/FPRInfo.java index 3e5816a67be..6c17c387b51 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/model/FPRInfo.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/model/FPRInfo.java @@ -19,7 +19,6 @@ import java.util.Optional; import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.stream.XMLInputFactory; import javax.xml.stream.XMLStreamConstants; import javax.xml.stream.XMLStreamException; @@ -36,6 +35,7 @@ import com.fortify.cli.aviator.fpr.filter.FilterTemplate; import com.fortify.cli.aviator.util.FprHandle; import com.fortify.cli.aviator.util.StringUtil; +import com.fortify.cli.common.util.SecureXmlParserFactory; import lombok.Getter; import lombok.Setter; @@ -80,10 +80,7 @@ private void extractInfoFromAuditFvdlStreaming(FprHandle fprHandle) { throw new AviatorTechnicalException("audit.fvdl not found in FPR: " + fprHandle.getFprPath()); } - XMLInputFactory factory = XMLInputFactory.newInstance(); - // Security: Disable external entity processing - factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); - factory.setProperty(XMLInputFactory.SUPPORT_DTD, false); + XMLInputFactory factory = SecureXmlParserFactory.newXmlInputFactory(); try (InputStream inputStream = Files.newInputStream(auditPath)) { XMLStreamReader reader = factory.createXMLStreamReader(inputStream); @@ -187,13 +184,7 @@ private void extractInfoFromAuditFvdl(FprHandle fprHandle) throws Exception { throw new AviatorTechnicalException("audit.fvdl not found in FPR: " + fprHandle.getFprPath()); } - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setFeature("http://xml.org/sax/features/external-general-entities", false); - factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); - factory.setXIncludeAware(false); - factory.setExpandEntityReferences(false); - factory.setValidating(false); + var factory = SecureXmlParserFactory.newDocumentBuilderFactory(false); DocumentBuilder builder = factory.newDocumentBuilder(); Document auditDoc; diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/model/Node.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/model/Node.java index c9cb6cdc660..9c2c07c8823 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/model/Node.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/model/Node.java @@ -21,9 +21,7 @@ import java.util.Optional; import java.util.regex.Pattern; -import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; import org.w3c.dom.Document; import org.w3c.dom.Element; @@ -33,6 +31,7 @@ import com.fortify.cli.aviator.fpr.filter.FilterTemplate; import com.fortify.cli.aviator.fpr.utils.Searchable; import com.fortify.cli.aviator.util.StringUtil; +import com.fortify.cli.common.util.SecureXmlParserFactory; import lombok.Getter; import lombok.Setter; @@ -320,13 +319,7 @@ private void extractInfoFromAuditFvdl(Path extractedPath) throws Exception { throw new IllegalStateException("audit.fvdl not found in " + extractedPath); } - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); - factory.setFeature("http://xml.org/sax/features/external-general-entities", false); - factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); - factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); - factory.setFeature("http://xml.org/sax/features/validation", false); + var factory = SecureXmlParserFactory.newDocumentBuilderFactory(false); DocumentBuilder builder = factory.newDocumentBuilder(); Document auditDoc = builder.parse(auditPath.toFile()); @@ -365,10 +358,7 @@ private void extractInfoFromAuditFvdlStreaming(Path extractedPath) throws Except throw new IllegalStateException("audit.fvdl not found in " + extractedPath); } - javax.xml.stream.XMLInputFactory factory = javax.xml.stream.XMLInputFactory.newInstance(); - // Security: Disable external entity processing - factory.setProperty(javax.xml.stream.XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); - factory.setProperty(javax.xml.stream.XMLInputFactory.SUPPORT_DTD, false); + var factory = SecureXmlParserFactory.newXmlInputFactory(); try (java.io.InputStream inputStream = Files.newInputStream(auditPath)) { javax.xml.stream.XMLStreamReader reader = factory.createXMLStreamReader(inputStream); 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 5707630dfc7..5183d371834 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 @@ -37,7 +37,6 @@ import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.OutputKeys; import javax.xml.transform.Transformer; @@ -58,6 +57,7 @@ import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; import com.fortify.cli.aviator.audit.model.AuditResponse; +import com.fortify.cli.aviator.audit.model.AuditTier; import com.fortify.cli.aviator.config.TagMappingConfig; import com.fortify.cli.aviator.fpr.model.AuditIssue; import com.fortify.cli.aviator.fpr.model.FPRInfo; @@ -68,6 +68,7 @@ import com.fortify.cli.aviator.util.Constants; import com.fortify.cli.aviator.util.FileUtil; import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.common.util.SecureXmlParserFactory; import lombok.Setter; @@ -148,14 +149,7 @@ public Map processAuditXML() throws AviatorTechnicalExceptio logger.debug("audit.xml not found. Creating a default audit.xml."); auditDoc = createDefaultAuditXml(); } else { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); - factory.setFeature("http://xml.org/sax/features/external-general-entities", false); - factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); - factory.setXIncludeAware(false); - factory.setExpandEntityReferences(false); - factory.setNamespaceAware(true); + var factory = SecureXmlParserFactory.newDocumentBuilderFactory(true); DocumentBuilder builder = factory.newDocumentBuilder(); try (InputStream auditStream = Files.newInputStream(auditPath)) { @@ -183,14 +177,7 @@ public Map processAuditXML() throws AviatorTechnicalExceptio private Document createDefaultAuditXml() throws AviatorTechnicalException { try { - DocumentBuilderFactory docFactory = DocumentBuilderFactory.newInstance(); - docFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); - docFactory.setFeature("http://xml.org/sax/features/external-general-entities", false); - docFactory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - docFactory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); - docFactory.setXIncludeAware(false); - docFactory.setExpandEntityReferences(false); - docFactory.setNamespaceAware(true); + var docFactory = SecureXmlParserFactory.newDocumentBuilderFactory(true); DocumentBuilder docBuilder = docFactory.newDocumentBuilder(); Document doc = docBuilder.newDocument(); @@ -829,19 +816,102 @@ public File updateAndSaveAuditAndRemediationsXml(Map audi return fprHandle.getFprPath().toFile(); } + /** + * Applies DAST audit decisions to audit.xml without creating SAST remediation artifacts. + */ + public File updateAndSaveDastAuditXml(Map auditResponses, + TagMappingConfig tagMappingConfig) { + Set writtenInstanceIds = new HashSet<>(); + for (Map.Entry entry : auditResponses.entrySet()) { + AuditResponse response = entry.getValue(); + if (response == null || !"SUCCESS".equalsIgnoreCase(response.getStatus()) + || response.getAuditResult() == null) { + continue; + } + Element issueElement = findIssueElement(entry.getKey()); + if (issueElement == null) { + issueElement = createDastIssueElement(entry.getKey()); + } else { + int revision = Optional.ofNullable(issueElement.getAttribute("revision")) + .filter(value -> !value.isBlank()) + .map(value -> { + try { return Integer.parseInt(value); } catch (NumberFormatException e) { return 0; } + }) + .orElse(0); + issueElement.setAttribute("revision", String.valueOf(revision + 1)); + } + applyDastAuditResponse(issueElement, response, tagMappingConfig); + writtenInstanceIds.add(entry.getKey()); + } + + AuditXmlIssuePruner.retainOnly(auditDoc, writtenInstanceIds); + + try (OutputStream os = Files.newOutputStream(fprHandle.getPath("/audit.xml"))) { + transformDomToStream(auditDoc, os); + } catch (Exception e) { + throw new AviatorTechnicalException("Failed to write DAST audit data back into the FPR file", e); + } + return fprHandle.getFprPath().toFile(); + } + + private Element createDastIssueElement(String instanceId) { + Element issueList = (Element) auditDoc.getElementsByTagNameNS(AUDIT_NAMESPACE_URI, "IssueList").item(0); + if (issueList == null) { + issueList = auditDoc.createElementNS(AUDIT_NAMESPACE_URI, "IssueList"); + auditDoc.getDocumentElement().appendChild(issueList); + } + Element issueElement = auditDoc.createElementNS(AUDIT_NAMESPACE_URI, "Issue"); + issueElement.setAttribute("instanceId", instanceId); + issueElement.setAttribute("revision", "0"); + issueElement.setAttribute("suppressed", "false"); + issueList.appendChild(issueElement); + return issueElement; + } + + private void applyDastAuditResponse(Element issueElement, AuditResponse response, + TagMappingConfig tagMappingConfig) { + String prediction = response.getAviatorPredictionTag(); + updateOrAddTag(issueElement, Constants.AVIATOR_PREDICTION_TAG_ID, prediction); + TagMappingConfig.Result resultConfig = getDastResultConfig(response, tagMappingConfig); + if (resultConfig.getValue() != null && !resultConfig.getValue().isBlank()) { + updateOrAddTag(issueElement, tagMappingConfig.getTag_id(), resultConfig.getValue()); + } + Boolean suppressedHistoryValue = updateSuppressedState( + issueElement, Boolean.TRUE.equals(resultConfig.getSuppress())); + updateOrAddTag(issueElement, Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR); + if (response.getAuditResult().getComment() != null) { + updateOrAddComment(issueElement, response.getAuditResult().getComment()); + } + Element clientAuditTrail = getClientAuditTrailElement(issueElement); + addTagHistory(clientAuditTrail, Constants.AVIATOR_PREDICTION_TAG_ID, prediction); + if (resultConfig.getValue() != null && !resultConfig.getValue().isBlank()) { + addTagHistory(clientAuditTrail, tagMappingConfig.getTag_id(), resultConfig.getValue()); + } + addTagHistory(clientAuditTrail, Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR); + if (suppressedHistoryValue != null) { + addTagHistory(clientAuditTrail, Constants.SUPPRESSED_TAG_ID, suppressedHistoryValue.toString()); + } + } + + private TagMappingConfig.Result getDastResultConfig(AuditResponse response, + TagMappingConfig tagMappingConfig) { + boolean tierOne = AuditTier.fromServerValue(response.getTier()) == AuditTier.GOLD; + String tagValue = response.getAuditResult().getTagValue(); + if (Constants.NOT_AN_ISSUE.equalsIgnoreCase(tagValue)) { + return tagMappingConfig.getResult(tierOne, TagMappingConfig.ResultType.FP); + } + if (Constants.EXPLOITABLE.equalsIgnoreCase(tagValue)) { + return tagMappingConfig.getResult(tierOne, TagMappingConfig.ResultType.TP); + } + return tagMappingConfig.getResult(tierOne, TagMappingConfig.ResultType.UNSURE); + } + private Document generateRemediationsXml(Map auditResponses, Map remediationCommentTimestamps, FPRInfo fprInfo, FVDLMetadata fvdlMetadata, Map skippedByReason) throws AviatorTechnicalException { try { - DocumentBuilderFactory docFactory = DocumentBuilderFactory.newInstance(); - docFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); - docFactory.setFeature("http://xml.org/sax/features/external-general-entities", false); - docFactory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - docFactory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); - docFactory.setXIncludeAware(false); - docFactory.setExpandEntityReferences(false); - docFactory.setNamespaceAware(true); + var docFactory = SecureXmlParserFactory.newDocumentBuilderFactory(true); DocumentBuilder docBuilder = docFactory.newDocumentBuilder(); Document finalDoc = docBuilder.newDocument(); @@ -1085,14 +1155,7 @@ public void warning(org.xml.sax.SAXParseException e) { private boolean isRemediationElementValid(Element remediationElement, FPRInfo fprInfo) { String instanceId = remediationElement.getAttribute("instanceId"); try { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); - factory.setFeature("http://xml.org/sax/features/external-general-entities", false); - factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); - factory.setXIncludeAware(false); - factory.setExpandEntityReferences(false); - factory.setNamespaceAware(true); + var factory = SecureXmlParserFactory.newDocumentBuilderFactory(true); DocumentBuilder builder = factory.newDocumentBuilder(); Document tempDoc = builder.newDocument(); diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/FVDLProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/FVDLProcessor.java index b1a41b2728c..a875d89b828 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/FVDLProcessor.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/FVDLProcessor.java @@ -45,6 +45,7 @@ import com.fortify.cli.aviator.fpr.utils.XmlUtils; import com.fortify.cli.aviator.util.FprHandle; import com.fortify.cli.aviator.util.StringUtil; +import com.fortify.cli.common.util.SecureXmlParserFactory; import jakarta.xml.bind.JAXBContext; import jakarta.xml.bind.JAXBException; @@ -129,9 +130,7 @@ private FVDL unmarshalFVDL(Path fvdlFilePath) throws JAXBException, IOException try (InputStream fis = Files.newInputStream(fvdlFilePath)) { JAXBContext jaxbContext = JAXBContext.newInstance(FVDL.class); Unmarshaller unmarshaller = jaxbContext.createUnmarshaller(); - javax.xml.stream.XMLInputFactory xmlInputFactory = javax.xml.stream.XMLInputFactory.newInstance(); - xmlInputFactory.setProperty(javax.xml.stream.XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); - xmlInputFactory.setProperty(javax.xml.stream.XMLInputFactory.SUPPORT_DTD, false); + var xmlInputFactory = SecureXmlParserFactory.newXmlInputFactory(); javax.xml.stream.XMLStreamReader xmlStreamReader = xmlInputFactory.createXMLStreamReader(fis); return (FVDL) unmarshaller.unmarshal(xmlStreamReader); } catch (javax.xml.stream.XMLStreamException e) { diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/FilterTemplateParser.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/FilterTemplateParser.java index 07c0d04b716..a6a0a960445 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/FilterTemplateParser.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/FilterTemplateParser.java @@ -25,7 +25,6 @@ import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.transform.OutputKeys; import javax.xml.transform.Transformer; import javax.xml.transform.TransformerFactory; @@ -49,6 +48,7 @@ import com.fortify.cli.aviator.fpr.filter.TagValue; import com.fortify.cli.aviator.util.Constants; import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.common.util.SecureXmlParserFactory; public class FilterTemplateParser { @@ -72,14 +72,7 @@ public Optional parseFilterTemplate() { return Optional.empty(); } - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); - factory.setFeature("http://xml.org/sax/features/external-general-entities", false); - factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); - factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); - factory.setValidating(false); - factory.setNamespaceAware(true); + var factory = SecureXmlParserFactory.newDocumentBuilderFactory(true); DocumentBuilder builder = factory.newDocumentBuilder(); diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/StreamingFVDLProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/StreamingFVDLProcessor.java index 3dace7f6e75..96c535c4228 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/StreamingFVDLProcessor.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/StreamingFVDLProcessor.java @@ -37,6 +37,7 @@ import com.fortify.cli.aviator.fpr.utils.XmlUtils; import com.fortify.cli.aviator.util.FprHandle; import com.fortify.cli.aviator.util.StringUtil; +import com.fortify.cli.common.util.SecureXmlParserFactory; import lombok.AllArgsConstructor; import lombok.Getter; @@ -84,10 +85,7 @@ public StreamingFVDLProcessor(FprHandle fprHandle, ISourceDecoder sourceDecoder) this.vulnFinalizer = new VulnFinalizer(); this.fprHandle = fprHandle; this.sourceFileMap = fprHandle.getSourceFileMap(); - this.xmlInputFactory = XMLInputFactory.newInstance(); - // Security: Disable external entity processing - xmlInputFactory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); - xmlInputFactory.setProperty(XMLInputFactory.SUPPORT_DTD, false); + this.xmlInputFactory = SecureXmlParserFactory.newXmlInputFactory(); this.fvdlMetadata = new FVDLMetadata(); // Same metadata instance FileUtils will see once encodings are registered during parse. this.fileUtils = new FileUtils( @@ -425,7 +423,7 @@ public void parseBuildMetadata(ZipFile zipFile, String entryName) throws Excepti logger.debug("Parsed FVDL build metadata entry '{}'", entryName); } - private void parseBuildMetadata(InputStream inputStream) throws XMLStreamException { + public void parseBuildMetadata(InputStream inputStream) throws XMLStreamException { XMLStreamReader reader = xmlInputFactory.createXMLStreamReader(inputStream); try { diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationExecutionMode.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationExecutionMode.java new file mode 100644 index 00000000000..f9c8647ad61 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationExecutionMode.java @@ -0,0 +1,18 @@ +/* + * 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.remediation; + +public enum RemediationExecutionMode { + APPLY, + PREVIEW +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationProcessingOptions.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationProcessingOptions.java new file mode 100644 index 00000000000..eb617a3bf07 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationProcessingOptions.java @@ -0,0 +1,39 @@ +/* + * 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.remediation; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; + +import com.fortify.cli.aviator.fpr.utils.ISourceDecoder; + +public record RemediationProcessingOptions(Set issueIdFilter, RemediationExecutionMode executionMode, + ISourceDecoder sourceDecoder) { + public RemediationProcessingOptions { + issueIdFilter = issueIdFilter == null + ? Set.of() + : Collections.unmodifiableSet(new LinkedHashSet<>(issueIdFilter)); + executionMode = Objects.requireNonNull(executionMode, "executionMode"); + sourceDecoder = Objects.requireNonNull(sourceDecoder, "sourceDecoder"); + } + + public boolean isFiltered() { + return !issueIdFilter.isEmpty(); + } + + public boolean isPreview() { + return executionMode == RemediationExecutionMode.PREVIEW; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationProcessingState.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationProcessingState.java new file mode 100644 index 00000000000..c609f655c69 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationProcessingState.java @@ -0,0 +1,181 @@ +/* + * 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.remediation; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +import com.fortify.cli.aviator.fpr.remediation.model.RemediationMetric; +import com.fortify.cli.aviator.fpr.remediation.preview.FilePreview; +import com.fortify.cli.aviator.fpr.remediation.preview.PreviewDetail; + +final class RemediationProcessingState { + private static final String POSSIBLY_REMEDIATED_REASON = "Possibly remediated by a sibling fix"; + private static final String REQUESTED_ISSUE_NOT_FOUND = "Requested issue not found in remediations"; + + private final RemediationProcessingOptions options; + private final Set seenIssueIds = new LinkedHashSet<>(); + private final Set satisfiedIssueIds = new LinkedHashSet<>(); + private final Set appliedIssueIds = new LinkedHashSet<>(); + private final Set identicalIssueIds = new LinkedHashSet<>(); + private final Set supersededIssueIds = new LinkedHashSet<>(); + private final Set possiblyRemediatedIssueIds = new LinkedHashSet<>(); + private final Set modifiedFiles = new LinkedHashSet<>(); + private final Map skippedByReason = new LinkedHashMap<>(); + private final Map issueSkipReasons = new LinkedHashMap<>(); + private final Map descriptionsByIssue = new LinkedHashMap<>(); + private final Map previewDetailsByIssue = new LinkedHashMap<>(); + private int xmlEntryCount; + private int appliedRemediations; + private int identicalRemediations; + private int supersededRemediations; + private int possiblyRemediatedRemediations; + + RemediationProcessingState(RemediationProcessingOptions options) { + this.options = options; + } + + void setXmlEntryCount(int xmlEntryCount) { + this.xmlEntryCount = xmlEntryCount; + } + + boolean shouldProcess(String instanceId) { + return !options.isFiltered() || options.issueIdFilter().contains(instanceId); + } + + void recordSeen(String instanceId) { + if (options.isFiltered() && instanceId != null) { + seenIssueIds.add(instanceId); + } + } + + void recordDescription(String instanceId, String description) { + if (instanceId != null && !instanceId.isBlank() && description != null) { + descriptionsByIssue.put(instanceId, description); + } + } + + void recordApplied(String instanceId) { + appliedRemediations++; + recordSatisfied(instanceId); + appliedIssueIds.add(instanceId); + issueSkipReasons.remove(instanceId); + } + + void recordPreviewAvailable(String instanceId, Map files) { + recordApplied(instanceId); + for (FilePreview filePreview : files.values()) { + modifiedFiles.add(filePreview.path()); + } + previewDetailsByIssue.put(instanceId, + PreviewDetail.available(instanceId, descriptionsByIssue.get(instanceId), files)); + } + + void recordIdentical(String instanceId) { + identicalRemediations++; + identicalIssueIds.add(instanceId); + recordSatisfied(instanceId); + } + + void recordSuperseded(String instanceId) { + supersededRemediations++; + supersededIssueIds.add(instanceId); + recordSatisfied(instanceId); + } + + void recordPossiblyRemediated(String instanceId) { + possiblyRemediatedRemediations++; + possiblyRemediatedIssueIds.add(instanceId); + issueSkipReasons.put(instanceId, POSSIBLY_REMEDIATED_REASON); + } + + void recordSkipped(String instanceId, SkipReason reason) { + recordSkipped(instanceId, reason.displayName()); + } + + void recordSkipped(String instanceId, String reason) { + skippedByReason.merge(reason, 1, Integer::sum); + if (instanceId != null && !instanceId.isBlank()) { + issueSkipReasons.put(instanceId, reason); + if (options.isPreview()) { + previewDetailsByIssue.put(instanceId, + PreviewDetail.skipped(instanceId, descriptionsByIssue.get(instanceId))); + } + } + } + + Set modifiedFiles() { + return modifiedFiles; + } + + Map skippedByReason() { + return skippedByReason; + } + + RemediationMetric toMetric() { + recordMissingRequestedIssues(); + int totalRemediations = options.isFiltered() ? options.issueIdFilter().size() : xmlEntryCount; + int applied = options.isFiltered() ? appliedIssueIds.size() : appliedRemediations; + int skipped = options.isFiltered() + ? totalRemediations - satisfiedIssueIds.size() + : totalRemediations - appliedRemediations - identicalRemediations - supersededRemediations + - possiblyRemediatedRemediations; + return RemediationMetric.builder() + .totalRemediations(totalRemediations) + .appliedRemediations(applied) + .identicalRemediations(identicalRemediations) + .supersededRemediations(supersededRemediations) + .possiblyRemediatedRemediations(possiblyRemediatedRemediations) + .skippedRemediations(skipped) + .modifiedFiles(modifiedFiles) + .skippedByReason(skippedByReason) + .executionMode(options.executionMode()) + .requestedIssueIds(options.issueIdFilter()) + .seenIssueIds(seenIssueIds) + .satisfiedIssueIds(satisfiedIssueIds) + .appliedIssueIds(appliedIssueIds) + .identicalIssueIds(identicalIssueIds) + .supersededIssueIds(supersededIssueIds) + .possiblyRemediatedIssueIds(possiblyRemediatedIssueIds) + .issueSkipReasons(issueSkipReasons) + .previewDetails(new ArrayList<>(previewDetailsByIssue.values())) + .build(); + } + + private void recordMissingRequestedIssues() { + if (!options.isFiltered()) { + return; + } + for (String requestedIssueId : options.issueIdFilter()) { + if (seenIssueIds.contains(requestedIssueId) || satisfiedIssueIds.contains(requestedIssueId) + || issueSkipReasons.putIfAbsent(requestedIssueId, REQUESTED_ISSUE_NOT_FOUND) != null) { + continue; + } + skippedByReason.merge(REQUESTED_ISSUE_NOT_FOUND, 1, Integer::sum); + if (options.isPreview()) { + previewDetailsByIssue.put(requestedIssueId, + PreviewDetail.skipped(requestedIssueId, null)); + } + } + } + + private void recordSatisfied(String instanceId) { + if (instanceId != null && !instanceId.isBlank()) { + satisfiedIssueIds.add(instanceId); + issueSkipReasons.remove(instanceId); + } + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationProcessor.java index 24f1e9ee430..eaa2fda3ea9 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationProcessor.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationProcessor.java @@ -12,6 +12,7 @@ */ package com.fortify.cli.aviator.fpr.remediation; +import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -22,11 +23,12 @@ import java.util.Map; import java.util.Objects; import java.util.Set; -import java.util.zip.ZipFile; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; import com.fortify.cli.aviator.fpr.model.FVDLMetadata; @@ -37,11 +39,16 @@ import com.fortify.cli.aviator.fpr.remediation.exception.RemediationCommitException; import com.fortify.cli.aviator.fpr.remediation.exception.RollbackRemediationException; import com.fortify.cli.aviator.fpr.remediation.exception.SkipRemediationException; +import com.fortify.cli.aviator.fpr.remediation.model.FileChange; +import com.fortify.cli.aviator.fpr.remediation.model.Hunk; import com.fortify.cli.aviator.fpr.remediation.model.HunkOutcome; import com.fortify.cli.aviator.fpr.remediation.model.Remediation; import com.fortify.cli.aviator.fpr.remediation.model.RemediationDocument; import com.fortify.cli.aviator.fpr.remediation.model.RemediationKey; import com.fortify.cli.aviator.fpr.remediation.model.RemediationMetric; +import com.fortify.cli.aviator.fpr.remediation.preview.ChangeDetail; +import com.fortify.cli.aviator.fpr.remediation.preview.FilePreview; +import com.fortify.cli.aviator.fpr.remediation.preview.PreviewFileChange; import com.fortify.cli.aviator.fpr.remediation.writer.FileWriteCoordinator; import com.fortify.cli.aviator.fpr.remediation.writer.PendingFileWrite; import com.fortify.cli.aviator.fpr.remediation.writer.PreparedFileChanges; @@ -55,8 +62,8 @@ * Orchestrator. {@link #processRemediationXML()} runs the three phases in sequence: parse * XML into a DOM {@link Document} ({@link RemediationXmlReader}), map the document into the * domain model with zero business logic ({@link RemediationDocumentMapper}), then classify - * and apply each remediation ({@link #classifyAndApply}). Public API (constructors, method - * signature) is unchanged from the original single-class implementation. + * and apply each remediation ({@link #classifyAndApply}). Preview lists declared remediations.xml + * fields without running the applier. * *

Per-FPR isolation: Each processor instance handles exactly one FPR. In multi-FPR * scenarios (e.g., --all-open-issues), each artifact gets its own processor with a fresh @@ -67,10 +74,12 @@ * so later hunks apply only where their OriginalCode still literally matches. */ public class RemediationProcessor { + private static final String NAMESPACE_URI = "xmlns://www.fortify.com/schema/remediations"; private static final Logger LOG = LoggerFactory.getLogger(RemediationProcessor.class); private final FprHandle fprHandle; private final String sourceCodeDirectory; + private final RemediationProcessingOptions options; private final ISourceDecoder sourceDecoder; private final RemediationXmlReader xmlReader = new RemediationXmlReader(); @@ -84,23 +93,35 @@ public RemediationProcessor(FprHandle fprHandle, String sourceCodeDirectory) { } public RemediationProcessor(FprHandle fprHandle, String sourceCodeDirectory, ISourceDecoder sourceDecoder) { + this(fprHandle, sourceCodeDirectory, + new RemediationProcessingOptions(Set.of(), RemediationExecutionMode.APPLY, sourceDecoder)); + } + + public RemediationProcessor(FprHandle fprHandle, String sourceCodeDirectory, RemediationProcessingOptions options) { this.fprHandle = fprHandle; this.sourceCodeDirectory = sourceCodeDirectory; - this.sourceDecoder = Objects.requireNonNull(sourceDecoder, "sourceDecoder"); + this.options = Objects.requireNonNull(options, "options"); + this.sourceDecoder = options.sourceDecoder(); this.fileWriteCoordinator = new FileWriteCoordinator(sourceDecoder, remediationApplier); } public RemediationMetric processRemediationXML() { Path remediationPath = fprHandle.getPath("/remediations.xml"); Path sourceBasePath = resolveSourceBasePath(); - LOG.debug("Applying remediations from {} to source directory {}", remediationPath, sourceBasePath); + LOG.debug("{} remediations from {} to source directory {}", + options.isPreview() ? "Previewing" : "Applying", remediationPath, sourceBasePath); FVDLMetadata fvdlMetadata = loadFvdlMetadata(); try { Document remediationDoc = xmlReader.read(remediationPath); RemediationDocument remediations = documentMapper.map(remediationDoc); + RemediationProcessingState state = new RemediationProcessingState(options); + recordDescriptions(remediationDoc, state); + if (options.isPreview()) { + return previewRemediations(remediations, sourceBasePath, fvdlMetadata, state); + } AppliedChangeLedger ledger = new AppliedChangeLedger(); - return classifyAndApply(remediations, sourceBasePath, fvdlMetadata, ledger); + return classifyAndApply(remediations, sourceBasePath, fvdlMetadata, ledger, state); } catch (AviatorTechnicalException e) { throw e; } catch (Exception e) { @@ -119,10 +140,84 @@ private Path resolveSourceBasePath() { return Paths.get(trimmedSourceDir).toAbsolutePath().normalize(); } + private RemediationMetric previewRemediations(RemediationDocument remediationDocument, Path sourceBasePath, + FVDLMetadata fvdlMetadata, RemediationProcessingState state) { + List remediations = remediationDocument.remediations(); + state.setXmlEntryCount(remediations.size()); + LOG.debug("Previewing {} remediation entries from remediations.xml", remediations.size()); + for (Remediation remediation : remediations) { + String instanceId = remediation.instanceId(); + if (!state.shouldProcess(instanceId)) { + continue; + } + state.recordSeen(instanceId); + try { + Map files = xmlFilePreviews(remediation, sourceBasePath, fvdlMetadata); + state.recordPreviewAvailable(instanceId, files); + } catch (SkipRemediationException e) { + state.recordSkipped(instanceId, skipReasonLabel(e)); + LOG.warn("Skipping remediation {}: {}", instanceId, e.getMessage()); + LOG.debug("Skip reason for remediation {}: {}", instanceId, e.getReason().displayName(), e); + } catch (Exception e) { + state.recordSkipped(instanceId, SkipReason.UNEXPECTED_ERROR); + LOG.warn("Skipping remediation {} due to an unexpected processing error", instanceId); + LOG.debug("Unexpected error while previewing remediation {}", instanceId, e); + } + } + return finish(state); + } + + private Map xmlFilePreviews(Remediation remediation, Path sourceBasePath, FVDLMetadata fvdlMetadata) { + List fileChanges = remediation.fileChanges(); + if (fileChanges.isEmpty()) { + throw new SkipRemediationException(SkipReason.NO_CHANGES, "No file changes found"); + } + Map files = new LinkedHashMap<>(); + for (FileChange fileChange : fileChanges) { + String filename = fileChange.requiredFilename(); + Path filePath = fileChange.resolve(sourceBasePath); + if (!filePath.startsWith(sourceBasePath)) { + throw new SkipRemediationException(SkipReason.SOURCE_FILE_OUTSIDE_SOURCE_DIR, + "Source file resolves outside source directory: " + filename); + } + if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) { + throw new SkipRemediationException(SkipReason.SOURCE_FILE_MISSING, + "Source code file not present at: " + filePath); + } + List hunks = fileChange.hunks(); + if (hunks.isEmpty()) { + throw new SkipRemediationException(SkipReason.NO_CHANGES, "No changes found for file: " + filename); + } + String encoding = fileWriteCoordinator.encodingFor(filePath, filename, fvdlMetadata).name(); + List changes = new ArrayList<>(); + FilePreview existing = files.get(filename); + if (existing != null) { + changes.addAll(existing.changes()); + } + int changeIndex = changes.size(); + for (Hunk hunk : hunks) { + changes.add(ChangeDetail.builder() + .changeIndex(++changeIndex) + .lineFrom(hunk.lineFrom()) + .lineTo(hunk.lineTo()) + .originalCode(hunk.requiredOriginalCode()) + .newCode(hunk.requiredNewCode()) + .contextLinesBefore(hunk.contextBeforeOrZero()) + .contextLinesAfter(hunk.contextAfterOrZero()) + .contextContent(hunk.contextTextOrEmpty()) + .build() + .toPreviewFileChange()); + } + files.put(filename, new FilePreview(filename, encoding, List.copyOf(changes))); + } + return files; + } + private RemediationMetric classifyAndApply(RemediationDocument remediationDocument, Path sourceBasePath, FVDLMetadata fvdlMetadata, - AppliedChangeLedger ledger) { + AppliedChangeLedger ledger, RemediationProcessingState state) { List orderedRemediations = new ArrayList<>(remediationDocument.remediations()); int totalRemediations = orderedRemediations.size(); + state.setXmlEntryCount(totalRemediations); LOG.debug("Loaded {} remediation entries", totalRemediations); try { // Widest-first ordering: broader fixes land first so narrower nested ones classify as SUPERSEDED. @@ -130,21 +225,19 @@ private RemediationMetric classifyAndApply(RemediationDocument remediationDocume } catch (SkipRemediationException e) { LOG.debug("Unable to sort remediations by width; proceeding with original order", e); } - int appliedRemediations = 0; - int identicalRemediations = 0; - int supersededRemediations = 0; - int possiblyRemediatedRemediations = 0; - Set modifiedFiles = new LinkedHashSet<>(); - Map skippedByReason = new LinkedHashMap<>(); Map remediationLookup = new LinkedHashMap<>(); for (Remediation remediation : orderedRemediations) { String instanceId = remediation.instanceId(); + if (!state.shouldProcess(instanceId)) { + continue; + } + state.recordSeen(instanceId); List remediationKeys; try { remediationKeys = remediation.createRemediationKeys(sourceBasePath); } catch (SkipRemediationException e) { - recordSkipped(skippedByReason, skipReasonLabel(e)); + state.recordSkipped(instanceId, skipReasonLabel(e)); LOG.warn("Skipping remediation {}: {}", instanceId, e.getMessage()); LOG.debug("Skip reason for remediation {}: {}", instanceId, e.getReason().displayName(), e); continue; @@ -166,7 +259,7 @@ private RemediationMetric classifyAndApply(RemediationDocument remediationDocume // Fully identical: every hunk was already applied by an earlier remediation with same content. if (!remediationKeys.isEmpty() && toApplyKeys.isEmpty()) { - identicalRemediations++; + state.recordIdentical(instanceId); LOG.info("Remediation {} is fully identical to prior remediation(s) {}; {} hunk(s) already applied", instanceId, satisfiedByInstances, satisfiedKeys.size()); continue; @@ -182,7 +275,7 @@ private RemediationMetric classifyAndApply(RemediationDocument remediationDocume && preClass.stream().anyMatch(o -> o == HunkOutcome.POSSIBLY_REMEDIATED); if (!anyApplyCandidate && allSuperseded) { - supersededRemediations++; + state.recordSuperseded(instanceId); LOG.info("Remediation {} is superseded by a broader prior fix for all {} hunk(s); no write needed", instanceId, preClass.size()); continue; @@ -191,13 +284,13 @@ private RemediationMetric classifyAndApply(RemediationDocument remediationDocume // cannot be fully/correctly applied, so reject it now rather than let the applier's // best-effort offset/fuzzy-anchor recovery (meant for non-conflicting shifts) decide. if (anyConflicts) { - recordSkipped(skippedByReason, SkipReason.CONFLICTS_WITH_ANOTHER_FIX.displayName()); + state.recordSkipped(instanceId, SkipReason.CONFLICTS_WITH_ANOTHER_FIX); LOG.info("Remediation {} conflicts with prior fix(es) on {} of {} hunk(s); skipping", instanceId, preClass.stream().filter(o -> o == HunkOutcome.CONFLICTS).count(), preClass.size()); continue; } if (!anyApplyCandidate && allPossiblyRemediated) { - possiblyRemediatedRemediations++; + state.recordPossiblyRemediated(instanceId); LOG.info("Remediation {} possibly remediated by a sibling fix with different content for all {} hunk(s)", instanceId, preClass.size()); continue; @@ -228,39 +321,32 @@ private RemediationMetric classifyAndApply(RemediationDocument remediationDocume } Set filter = (satisfiedKeys.isEmpty() && !classifierNarrowed) ? null : toApplyKeys; - Set applied = processRemediation(remediation, sourceBasePath, fvdlMetadata, modifiedFiles, skippedByReason, filter, ledger); - if (!applied.isEmpty()) { - appliedRemediations++; - for (RemediationKey key : applied) { + PreparedFileChanges prepared = processRemediation(remediation, sourceBasePath, fvdlMetadata, state, filter, ledger); + if (prepared != null && !prepared.appliedKeys().isEmpty()) { + state.recordApplied(instanceId); + for (RemediationKey key : prepared.appliedKeys()) { LOG.debug("putting {}", instanceId); remediationLookup.put(key, instanceId); } } } - int skippedRemediations = totalRemediations - appliedRemediations - identicalRemediations - supersededRemediations - - possiblyRemediatedRemediations; + return finish(state); + } + + private RemediationMetric finish(RemediationProcessingState state) { + RemediationMetric metric = state.toMetric(); LOG.info("Auto-remediation summary: total={}, applied={}, identical={}, superseded={}, possiblyRemediated={}, skipped={}", - totalRemediations, appliedRemediations, identicalRemediations, supersededRemediations, possiblyRemediatedRemediations, - skippedRemediations); - if (!skippedByReason.isEmpty()) { - LOG.info("Skipped remediations by reason: {}", formatSkippedReasons(skippedByReason)); + metric.totalRemediations(), metric.appliedRemediations(), metric.identicalRemediations(), metric.supersededRemediations(), + metric.possiblyRemediatedRemediations(), metric.skippedRemediations()); + if (!metric.skippedByReason().isEmpty()) { + LOG.info("Skipped remediations by reason: {}", formatSkippedReasons(metric.skippedByReason())); } - return RemediationMetric.builder() - .totalRemediations(totalRemediations) - .appliedRemediations(appliedRemediations) - .identicalRemediations(identicalRemediations) - .supersededRemediations(supersededRemediations) - .possiblyRemediatedRemediations(possiblyRemediatedRemediations) - .skippedRemediations(skippedRemediations) - .modifiedFiles(modifiedFiles) - .skippedByReason(skippedByReason) - .build(); + return metric; } - private Set processRemediation(Remediation remediation, Path sourceBasePath, FVDLMetadata fvdlMetadata, - Set modifiedFiles, Map skippedByReason, Set keysToApply, - AppliedChangeLedger ledger) { + private PreparedFileChanges processRemediation(Remediation remediation, Path sourceBasePath, FVDLMetadata fvdlMetadata, + RemediationProcessingState state, Set keysToApply, AppliedChangeLedger ledger) { String instanceId = remediation.instanceId(); ledger.discardStaged(); try { @@ -268,14 +354,13 @@ private Set processRemediation(Remediation remediation, Path sou Map pendingWrites = prepared.pendingWrites(); if (pendingWrites.isEmpty()) { - recordSkipped(skippedByReason, SkipReason.NO_CHANGES.displayName()); - return Set.of(); + state.recordSkipped(instanceId, SkipReason.NO_CHANGES); + return null; } try { - fileWriteCoordinator.commitRemediationWrites(instanceId, pendingWrites, modifiedFiles); - // Only on successful commit do the staged hunks enter the per-run offset map. + fileWriteCoordinator.commitRemediationWrites(instanceId, pendingWrites, state.modifiedFiles()); ledger.commitStaged(); - return prepared.appliedKeys(); + return prepared; } catch (RemediationCommitException e) { ledger.discardStaged(); fileWriteCoordinator.rollbackRemediationWrites(instanceId, e.getRollbacks()); @@ -283,25 +368,21 @@ private Set processRemediation(Remediation remediation, Path sou } } catch (SkipRemediationException e) { ledger.discardStaged(); - recordSkipped(skippedByReason, skipReasonLabel(e)); + state.recordSkipped(instanceId, skipReasonLabel(e)); LOG.warn("Skipping remediation {}: {}", instanceId, e.getMessage()); LOG.debug("Skip reason for remediation {}: {}", instanceId, e.getReason().displayName(), e); - return Set.of(); + return null; } catch (RollbackRemediationException e) { throw e; } catch (Exception e) { ledger.discardStaged(); - recordSkipped(skippedByReason, SkipReason.UNEXPECTED_ERROR.displayName()); + state.recordSkipped(instanceId, SkipReason.UNEXPECTED_ERROR); LOG.warn("Skipping remediation {} due to an unexpected processing error", instanceId); LOG.debug("Unexpected error while processing remediation {}", instanceId, e); - return Set.of(); + return null; } } - private void recordSkipped(Map skippedByReason, String reason) { - skippedByReason.merge(reason, 1, Integer::sum); - } - private String skipReasonLabel(SkipRemediationException exception) { return exception.getReason().displayName(); } @@ -312,6 +393,20 @@ private String formatSkippedReasons(Map skippedByReason) { return String.join(", ", parts); } + private void recordDescriptions(Document remediationDoc, RemediationProcessingState state) { + if (!options.isPreview()) { + return; + } + NodeList remediationNodes = remediationDoc.getElementsByTagNameNS(NAMESPACE_URI, "Remediation"); + for (int i = 0; i < remediationNodes.getLength(); i++) { + Element remediation = (Element) remediationNodes.item(i); + String instanceId = remediation.getAttribute("instanceId"); + NodeList comments = remediation.getElementsByTagNameNS(NAMESPACE_URI, "AuditComment"); + String description = comments.getLength() == 0 ? null : comments.item(0).getTextContent(); + state.recordDescription(instanceId, description); + } + } + /** Nullable: missing/unreadable FVDL means FPR encoding candidate is skipped. */ private FVDLMetadata loadFvdlMetadata() { if (!Files.exists(fprHandle.getPath("/audit.fvdl"))) { @@ -319,11 +414,11 @@ private FVDLMetadata loadFvdlMetadata() { return null; } - try (ZipFile zipFile = new ZipFile(fprHandle.getFprPath().toFile())) { + try (InputStream inputStream = Files.newInputStream(fprHandle.getPath("/audit.fvdl"))) { LOG.debug("Loading FVDL build metadata from '{}' to resolve source encodings", fprHandle.getFprPath()); // Decoder unused for metadata-only parse; ctor requires one for FileUtils wiring. StreamingFVDLProcessor processor = new StreamingFVDLProcessor(fprHandle, sourceDecoder); - processor.parseBuildMetadata(zipFile, "audit.fvdl"); + processor.parseBuildMetadata(inputStream); LOG.debug("Loaded FVDL build metadata from '{}'", fprHandle.getFprPath()); return processor.getFvdlMetadata(); } catch (Exception e) { diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/AppliedChangeLedger.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/AppliedChangeLedger.java index 64475688129..05fe0e949e8 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/AppliedChangeLedger.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/AppliedChangeLedger.java @@ -84,6 +84,8 @@ private static AppliedChange fromPending(PendingAppliedChange pac) { return AppliedChange.builder() .originalLineFrom(pac.lineFrom()) .originalLineTo(pac.lineTo()) + .declaredLineFrom(pac.declaredLineFrom()) + .declaredLineTo(pac.declaredLineTo()) .deltaLines(pac.deltaLines()) .comparisonCode(pac.comparisonCode()) .lineNormalizedCode(pac.lineNormalizedCode()) diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/HunkClassifier.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/HunkClassifier.java index d9eb1754729..24f0a32f300 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/HunkClassifier.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/HunkClassifier.java @@ -80,13 +80,13 @@ public List classifyRemediationHunks(Remediation remediation, Path */ private HunkOutcome classifyRange(int lineFrom, int lineTo, List applied, String candidateComparisonCode) { for (AppliedChange ac : applied) { - if (ac.coversFully(lineFrom, lineTo)) { - if (ac.contentCovers(candidateComparisonCode, lineFrom, lineTo)) { + if (ac.coversDeclaredRange(lineFrom, lineTo)){ + if (ac.contentCovers(candidateComparisonCode, lineFrom, lineTo)) { return HunkOutcome.SUPERSEDED; } return HunkOutcome.POSSIBLY_REMEDIATED; } - if (ac.overlapsPartially(lineFrom, lineTo)) { + if (ac.overlapsDeclaredRangePartially(lineFrom, lineTo)) { return HunkOutcome.CONFLICTS; } } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/PendingAppliedChange.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/PendingAppliedChange.java index b714e621f85..9fd2a171671 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/PendingAppliedChange.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/PendingAppliedChange.java @@ -14,6 +14,16 @@ import java.nio.file.Path; -public record PendingAppliedChange(Path filePath, int lineFrom, int lineTo, int deltaLines, - String comparisonCode, String lineNormalizedCode) { +import lombok.Builder; + +@Builder +public record PendingAppliedChange( + Path filePath, + int lineFrom, + int lineTo, + int declaredLineFrom, + int declaredLineTo, + int deltaLines, + String comparisonCode, + String lineNormalizedCode) { } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/AppliedChange.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/AppliedChange.java index f37376b2fcc..89e5b71bde6 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/AppliedChange.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/AppliedChange.java @@ -26,18 +26,24 @@ public final class AppliedChange { private final int originalLineFrom; private final int originalLineTo; + private final int declaredLineFrom; + private final int declaredLineTo; private final int deltaLines; private final String comparisonCode; private final String[] lineNormalizedContent; public AppliedChange(int originalLineFrom, int originalLineTo, int deltaLines, String comparisonCode) { - this(originalLineFrom, originalLineTo, deltaLines, comparisonCode, null); + this(originalLineFrom, originalLineTo, originalLineFrom, originalLineTo, deltaLines, comparisonCode, null); + } @Builder - public AppliedChange(int originalLineFrom, int originalLineTo, int deltaLines, String comparisonCode, String lineNormalizedCode) { + public AppliedChange(int originalLineFrom, int originalLineTo, int declaredLineFrom, int declaredLineTo, int deltaLines, String comparisonCode, String lineNormalizedCode) + { this.originalLineFrom = originalLineFrom; this.originalLineTo = originalLineTo; + this.declaredLineFrom = declaredLineFrom; + this.declaredLineTo = declaredLineTo; this.deltaLines = deltaLines; this.comparisonCode = comparisonCode; // Store line-by-line normalized content for offset-anchored comparison (newlines preserved, each line normalized) @@ -63,10 +69,25 @@ public boolean coversFully(int lineFrom, int lineTo) { /** True if [lineFrom, lineTo] overlaps this change's original range without either side fully containing the other. */ public boolean overlapsPartially(int lineFrom, int lineTo) { - boolean disjoint = lineTo < originalLineFrom || lineFrom > originalLineTo; - return !disjoint; + boolean overlaps = lineFrom <= originalLineTo && originalLineFrom <= lineTo; + boolean candidateContainsThis = lineFrom <= originalLineFrom && originalLineTo <= lineTo; + return overlaps && !coversFully(lineFrom, lineTo) && !candidateContainsThis; + } + + + /** True if this change's declared range fully contains [lineFrom, lineTo]. */ + public boolean coversDeclaredRange(int lineFrom, int lineTo) { + return declaredLineFrom <= lineFrom && lineTo <= declaredLineTo; } + /** True if [lineFrom, lineTo] overlaps this change's declared range without either side fully containing the other. */ + public boolean overlapsDeclaredRangePartially(int lineFrom, int lineTo) { + boolean overlaps = lineFrom <= declaredLineTo && declaredLineFrom <= lineTo; + boolean candidateContainsThis = lineFrom <= declaredLineFrom && declaredLineTo <= lineTo; + return overlaps && !coversDeclaredRange(lineFrom, lineTo) && !candidateContainsThis; + } + + /** * Below this length a normalized comparison code (e.g. {@code "return;"}) is too short and * generic to prove that a substring hit inside a broader fix's content is the same fix, diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/Hunk.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/Hunk.java index baf41ab29ad..33791a8af51 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/Hunk.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/Hunk.java @@ -55,6 +55,10 @@ public String requiredContextText() { return RequiredFields.requireText(contextTextRaw, "Context"); } + public String contextTextOrEmpty() { + return contextTextRaw == null ? "" : contextTextRaw; + } + public int contextBefore() { return RequiredFields.requireContextAttribute(contextBeforeRaw, "before"); } @@ -63,6 +67,14 @@ public int contextAfter() { return RequiredFields.requireContextAttribute(contextAfterRaw, "after"); } + public int contextBeforeOrZero() { + return parseContextAttributeOrZero(contextBeforeRaw); + } + + public int contextAfterOrZero() { + return parseContextAttributeOrZero(contextAfterRaw); + } + public String requiredOriginalCode() { return RequiredFields.requireText(originalCodeRaw, "OriginalCode"); } @@ -71,6 +83,17 @@ public String requiredNewCode() { return RequiredFields.requireText(newCodeRaw, "NewCode"); } + private static int parseContextAttributeOrZero(String value) { + if (value == null || value.isBlank()) { + return 0; + } + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return 0; + } + } + public String comparisonCode(String filename) { String normalizedCode = normalizeProposedCode(requiredNewCode(), filename); diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationMetric.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationMetric.java index f4fd6cda755..5e44927d5c9 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationMetric.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationMetric.java @@ -12,18 +12,23 @@ */ package com.fortify.cli.aviator.fpr.remediation.model; -import java.util.HashMap; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; import java.util.Set; +import com.fortify.cli.aviator.fpr.remediation.RemediationExecutionMode; +import com.fortify.cli.aviator.fpr.remediation.preview.PreviewDetail; + import lombok.Builder; import lombok.Getter; import lombok.experimental.Accessors; @Getter @Accessors(fluent = true) -@Builder public final class RemediationMetric { private final int totalRemediations; private final int appliedRemediations; @@ -33,11 +38,90 @@ public final class RemediationMetric { private final int skippedRemediations; private final Set modifiedFiles; private final Map skippedByReason; + private final RemediationExecutionMode executionMode; + private final Set requestedIssueIds; + private final Set seenIssueIds; + private final Set satisfiedIssueIds; + private final Set appliedIssueIds; + private final Set identicalIssueIds; + private final Set supersededIssueIds; + private final Set possiblyRemediatedIssueIds; + private final Map issueSkipReasons; + private final List previewDetails; + + @Builder + private RemediationMetric( + int totalRemediations, + int appliedRemediations, + int identicalRemediations, + int supersededRemediations, + int possiblyRemediatedRemediations, + int skippedRemediations, + Set modifiedFiles, + Map skippedByReason, + RemediationExecutionMode executionMode, + Set requestedIssueIds, + Set seenIssueIds, + Set satisfiedIssueIds, + Set appliedIssueIds, + Set identicalIssueIds, + Set supersededIssueIds, + Set possiblyRemediatedIssueIds, + Map issueSkipReasons, + List previewDetails) { + this.totalRemediations = totalRemediations; + this.appliedRemediations = appliedRemediations; + this.identicalRemediations = identicalRemediations; + this.supersededRemediations = supersededRemediations; + this.possiblyRemediatedRemediations = possiblyRemediatedRemediations; + this.skippedRemediations = skippedRemediations; + this.modifiedFiles = immutableSet(modifiedFiles); + this.skippedByReason = immutableMap(skippedByReason); + this.executionMode = executionMode == null ? RemediationExecutionMode.APPLY : executionMode; + this.requestedIssueIds = immutableSet(requestedIssueIds); + this.seenIssueIds = immutableSet(seenIssueIds); + this.satisfiedIssueIds = immutableSet(satisfiedIssueIds); + this.appliedIssueIds = immutableSet(appliedIssueIds); + this.identicalIssueIds = immutableSet(identicalIssueIds); + this.supersededIssueIds = immutableSet(supersededIssueIds); + this.possiblyRemediatedIssueIds = immutableSet(possiblyRemediatedIssueIds); + this.issueSkipReasons = immutableStringMap(issueSkipReasons); + this.previewDetails = previewDetails == null ? List.of() : List.copyOf(previewDetails); + } + + public boolean isPreview() { + return executionMode == RemediationExecutionMode.PREVIEW; + } + + public boolean isFiltered() { + return !requestedIssueIds.isEmpty(); + } + + private static Set immutableSet(Set values) { + return values == null ? Set.of() : Collections.unmodifiableSet(new LinkedHashSet<>(values)); + } + + private static Map immutableMap(Map values) { + return values == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(values)); + } + + private static Map immutableStringMap(Map values) { + return values == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(values)); + } public static final class RemediationMetricBuilder { public RemediationMetricBuilder() { this.modifiedFiles = new LinkedHashSet<>(); - this.skippedByReason = new HashMap<>(); + this.skippedByReason = new LinkedHashMap<>(); + this.requestedIssueIds = new LinkedHashSet<>(); + this.seenIssueIds = new LinkedHashSet<>(); + this.satisfiedIssueIds = new LinkedHashSet<>(); + this.appliedIssueIds = new LinkedHashSet<>(); + this.identicalIssueIds = new LinkedHashSet<>(); + this.supersededIssueIds = new LinkedHashSet<>(); + this.possiblyRemediatedIssueIds = new LinkedHashSet<>(); + this.issueSkipReasons = new LinkedHashMap<>(); + this.previewDetails = new ArrayList<>(); } public RemediationMetricBuilder add(RemediationMetric metric) { @@ -50,6 +134,18 @@ public RemediationMetricBuilder add(RemediationMetric metric) { this.modifiedFiles.addAll(metric.modifiedFiles()); metric.skippedByReason().forEach((reason, count) -> this.skippedByReason.merge(reason, count, Integer::sum)); + this.requestedIssueIds.addAll(metric.requestedIssueIds()); + this.seenIssueIds.addAll(metric.seenIssueIds()); + this.satisfiedIssueIds.addAll(metric.satisfiedIssueIds()); + this.appliedIssueIds.addAll(metric.appliedIssueIds()); + this.identicalIssueIds.addAll(metric.identicalIssueIds()); + this.supersededIssueIds.addAll(metric.supersededIssueIds()); + this.possiblyRemediatedIssueIds.addAll(metric.possiblyRemediatedIssueIds()); + this.issueSkipReasons.putAll(metric.issueSkipReasons()); + this.previewDetails.addAll(metric.previewDetails()); + if (metric.isPreview()) { + this.executionMode = RemediationExecutionMode.PREVIEW; + } return this; } } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/ChangeDetail.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/ChangeDetail.java new file mode 100644 index 00000000000..58fe17dfaf9 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/ChangeDetail.java @@ -0,0 +1,47 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.preview; + +import com.formkiq.graalvm.annotations.Reflectable; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Reflectable +@Builder +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class ChangeDetail { + private final int changeIndex; + private final int lineFrom; + private final int lineTo; + private final String originalCode; + private final String newCode; + private final int contextLinesBefore; + private final int contextLinesAfter; + private final String contextContent; + + public PreviewFileChange toPreviewFileChange() { + ContextMetadata context = new ContextMetadata(contextLinesBefore, contextLinesAfter, contextContent); + return PreviewFileChange.builder() + .changeIndex(changeIndex) + .lineFrom(lineFrom) + .lineTo(lineTo) + .originalCode(originalCode) + .newCode(newCode) + .context(context) + .build(); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/ContextMetadata.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/ContextMetadata.java new file mode 100644 index 00000000000..7bda8c28b8b --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/ContextMetadata.java @@ -0,0 +1,31 @@ +/* + * 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.remediation.preview; + +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.aviator._common.exception.AviatorBugException; + +@Reflectable +@JsonPropertyOrder({"linesBefore", "linesAfter", "content"}) +public record ContextMetadata(int linesBefore, int linesAfter, String content) { + public ContextMetadata { + if (linesBefore < 0) { + throw new AviatorBugException("ContextMetadata linesBefore must be non-negative"); + } + if (linesAfter < 0) { + throw new AviatorBugException("ContextMetadata linesAfter must be non-negative"); + } + content = content == null ? "" : content; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/FilePreview.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/FilePreview.java new file mode 100644 index 00000000000..251bed62d82 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/FilePreview.java @@ -0,0 +1,35 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.preview; + +import java.util.Collections; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.aviator._common.exception.AviatorBugException; + +@Reflectable +@JsonPropertyOrder({"path", "encoding", "changes"}) +public record FilePreview(String path, String encoding, List changes) { + public FilePreview { + if (path == null || path.isBlank()) { + throw new AviatorBugException("FilePreview path is required"); + } + changes = changes == null ? List.of() : Collections.unmodifiableList(List.copyOf(changes)); + } + + public int totalChanges() { + return changes.size(); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/PreviewDetail.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/PreviewDetail.java new file mode 100644 index 00000000000..fc7602d8626 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/PreviewDetail.java @@ -0,0 +1,54 @@ +/* + * 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.remediation.preview; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.aviator._common.exception.AviatorBugException; + +@Reflectable +@JsonPropertyOrder({"issueId", "status", "description", "files"}) +public record PreviewDetail(String issueId, String status, String description, Map files) { + public PreviewDetail { + if (issueId == null || issueId.isBlank()) { + throw new AviatorBugException("PreviewDetail issueId is required"); + } + if (status == null || status.isBlank()) { + throw new AviatorBugException("PreviewDetail status is required"); + } + files = files == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(files)); + } + + public static PreviewDetail available(String issueId, String description, Map files) { + return new PreviewDetail(issueId, "available", description, files); + } + + public static PreviewDetail skipped(String issueId, String description) { + return new PreviewDetail(issueId, "skipped", description, Map.of()); + } + + @JsonIgnore + public boolean isAvailable() { + return "available".equals(status); + } + + @JsonIgnore + public boolean isSkipped() { + return "skipped".equals(status); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/PreviewFileChange.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/PreviewFileChange.java new file mode 100644 index 00000000000..03708149852 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/PreviewFileChange.java @@ -0,0 +1,42 @@ +/* + * 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.remediation.preview; + +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.aviator._common.exception.AviatorBugException; + +import lombok.Builder; + +@Reflectable +@Builder +@JsonPropertyOrder({"changeIndex", "lineFrom", "lineTo", "originalCode", "newCode", "context"}) +public record PreviewFileChange( + int changeIndex, + int lineFrom, + int lineTo, + String originalCode, + String newCode, + ContextMetadata context) { + public PreviewFileChange { + if (changeIndex < 1) { + throw new AviatorBugException("PreviewFileChange changeIndex must be positive"); + } + if (lineFrom < 1 || lineTo < lineFrom) { + throw new AviatorBugException("PreviewFileChange invalid line range: " + lineFrom + "-" + lineTo); + } + if (context == null) { + throw new AviatorBugException("PreviewFileChange context is required"); + } + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/FileWriteCoordinator.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/FileWriteCoordinator.java index 1bc85884176..2a7f5cefec9 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/FileWriteCoordinator.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/FileWriteCoordinator.java @@ -74,6 +74,10 @@ public PreparedFileChanges prepareFileChanges(Remediation remediation, Path sour return new PreparedFileChanges(pendingWrites, appliedKeys); } + public Charset encodingFor(Path filePath, String filename, FVDLMetadata fvdlMetadata) { + return readSourceFile(filePath, filename, fvdlMetadata).charset(); + } + private void processFileChanges(Remediation remediation, FileChange fileChange, Path sourceBasePath, FVDLMetadata fvdlMetadata, Map pendingWrites, Set keysToApply, Set appliedKeysOut, AppliedChangeLedger ledger) { @@ -130,7 +134,16 @@ private void processFileChanges(Remediation remediation, FileChange fileChange, int linesAfterChange = updatedContent.split("\n", -1).length; int delta = linesAfterChange - linesBeforeChange; String lineNormalizedCode = hunk.lineNormalizedCode(filename); - ledger.stage(new PendingAppliedChange(filePath, actualLineFrom, actualLineTo, delta, comparisonCode, lineNormalizedCode)); + ledger.stage(PendingAppliedChange.builder() + .filePath(filePath) + .lineFrom(actualLineFrom) + .lineTo(actualLineTo) + .declaredLineFrom(declaredLineFrom) + .declaredLineTo(declaredLineTo) + .deltaLines(delta) + .comparisonCode(comparisonCode) + .lineNormalizedCode(lineNormalizedCode) + .build()); appliedKeysOut.add(key); appliedInThisFile++; } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/xmlprocessor/RemediationXmlReader.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/xmlprocessor/RemediationXmlReader.java index b81cf5ba87c..d4502fc87b3 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/xmlprocessor/RemediationXmlReader.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/xmlprocessor/RemediationXmlReader.java @@ -18,7 +18,6 @@ import java.nio.file.Path; import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import org.slf4j.Logger; @@ -27,6 +26,7 @@ import org.xml.sax.SAXException; import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; +import com.fortify.cli.common.util.SecureXmlParserFactory; /** Phase 1: parses {@code remediations.xml} into a DOM {@link Document}. No mapping, no business logic. */ public final class RemediationXmlReader { @@ -34,13 +34,7 @@ public final class RemediationXmlReader { public Document read(Path remediationPath) { try (InputStream remediationStream = Files.newInputStream(remediationPath)) { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setNamespaceAware(true); - factory.setFeature("http://xml.org/sax/features/external-general-entities", false); - factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); - factory.setXIncludeAware(false); - factory.setExpandEntityReferences(false); + var factory = SecureXmlParserFactory.newDocumentBuilderFactory(true); DocumentBuilder builder = factory.newDocumentBuilder(); return builder.parse(remediationStream); } catch (ParserConfigurationException | SAXException | IOException e) { diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java index e3766515ecd..9903b9a72b6 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java @@ -37,6 +37,7 @@ import com.fortify.aviator.application.GetDefaultQuotaResponse; import com.fortify.aviator.application.UpdateApplicationRequest; import com.fortify.aviator.application.ValidateAdminSessionRequest; +import com.fortify.aviator.dastaudit.DastAuditServiceGrpc; import com.fortify.aviator.dastentitlement.DastEntitlement; import com.fortify.aviator.dastentitlement.DastEntitlementServiceGrpc; import com.fortify.aviator.dastentitlement.ListDastEntitlementsByTenantRequest; @@ -86,6 +87,7 @@ public class AviatorGrpcClient implements AutoCloseable { private final EntitlementServiceGrpc.EntitlementServiceBlockingStub entitlementServiceBlockingStub; private final DastEntitlementServiceGrpc.DastEntitlementServiceBlockingStub dastEntitlementServiceBlockingStub; private final CorrelationServiceGrpc.CorrelationServiceStub correlationAsyncStub; + private final DastAuditServiceGrpc.DastAuditServiceStub dastAuditAsyncStub; private final long defaultTimeoutSeconds; private final java.util.concurrent.ExecutorService processingExecutor; private final long pingIntervalSeconds; @@ -93,7 +95,7 @@ public class AviatorGrpcClient implements AutoCloseable { final AtomicBoolean isShutdown = new AtomicBoolean(false); public AviatorGrpcClient(ManagedChannel channel, long defaultTimeoutSeconds, IAviatorLogger logger, long pingIntervalSeconds) { - LOG.info("Initializing AviatorGrpcClient with ManagedChannel"); + LOG.debug("Initializing AviatorGrpcClient with ManagedChannel"); this.logger = logger; this.channel = channel; this.asyncStub = AuditorServiceGrpc.newStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); @@ -102,6 +104,11 @@ public AviatorGrpcClient(ManagedChannel channel, long defaultTimeoutSeconds, IAv this.entitlementServiceBlockingStub = EntitlementServiceGrpc.newBlockingStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); this.dastEntitlementServiceBlockingStub = DastEntitlementServiceGrpc.newBlockingStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); this.correlationAsyncStub = CorrelationServiceGrpc.newStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); + this.dastAuditAsyncStub = DastAuditServiceGrpc.newStub(channel) + .withCompression("gzip") + .withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE) + .withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE) + .withWaitForReady(); this.defaultTimeoutSeconds = defaultTimeoutSeconds; this.processingExecutor = Executors.newFixedThreadPool(4, r -> { Thread t = new Thread(r, "aviator-client-processing-" + r.hashCode()); @@ -118,7 +125,7 @@ public AviatorGrpcClient(ManagedChannel channel, long defaultTimeoutSeconds, IAv public AviatorGrpcClient(String host, int port, long defaultTimeoutSeconds, IAviatorLogger logger, long pingIntervalSeconds) { this(ManagedChannelBuilder.forAddress(host, port).useTransportSecurity().maxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).keepAliveTime(30, TimeUnit.SECONDS).keepAliveTimeout(10, TimeUnit.SECONDS).keepAliveWithoutCalls(true).enableRetry().compressorRegistry(CompressorRegistry.getDefaultInstance()).decompressorRegistry(DecompressorRegistry.getDefaultInstance()).build(), defaultTimeoutSeconds, logger, pingIntervalSeconds); - LOG.info("Initialized AviatorGrpcClient - Host: {}, Port: {}", host, port); + LOG.debug("Initialized AviatorGrpcClient - Host: {}, Port: {}", host, port); } public AviatorGrpcClient(ManagedChannel channel, long defaultTimeoutSeconds, IAviatorLogger logger) { @@ -177,7 +184,7 @@ public void close() { } } - LOG.info("Client closed"); + LOG.debug("Client closed"); } public Application createApplication(String name, String tenantName, String signature, String message) { @@ -223,6 +230,11 @@ public long getDefaultQuota(String token) { return response.getDefaultQuota(); } + public void probeGetDefaultQuota(long timeoutSeconds) { + blockingStub.withDeadlineAfter(timeoutSeconds, TimeUnit.SECONDS) + .getDefaultQuota(GetDefaultQuotaRequest.getDefaultInstance()); + } + public void validateAdminSession(String tenantName, String signature, String message) { ValidateAdminSessionRequest request = ValidateAdminSessionRequest.newBuilder() .setTenantName(tenantName) @@ -297,6 +309,10 @@ public CorrelationServiceGrpc.CorrelationServiceStub getCorrelationAsyncStub() { return correlationAsyncStub; } + public DastAuditServiceGrpc.DastAuditServiceStub getDastAuditAsyncStub() { + return dastAuditAsyncStub; + } + public java.util.concurrent.ScheduledExecutorService getPingScheduler() { return pingScheduler; } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelper.java index 8ad633818c4..d01c9d04586 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelper.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelper.java @@ -15,22 +15,30 @@ import java.net.InetSocketAddress; import java.net.SocketAddress; import java.net.URI; +import java.util.Optional; import java.util.concurrent.TimeUnit; +import javax.net.ssl.SSLException; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.fortify.cli.aviator._common.exception.AviatorSimpleException; +import com.fortify.cli.aviator._common.exception.UnsupportedAviatorUrlSchemeException; import com.fortify.cli.aviator.config.IAviatorLogger; import com.fortify.cli.aviator.util.Constants; +import com.fortify.cli.common.http.UrlSchemes; import com.fortify.cli.common.http.proxy.helper.ProxyDescriptor; import com.fortify.cli.common.http.proxy.helper.ProxyHelper; +import com.fortify.cli.common.http.ssl.trust.FcliTrustManager; import io.grpc.CompressorRegistry; import io.grpc.DecompressorRegistry; import io.grpc.HttpConnectProxiedSocketAddress; import io.grpc.ProxiedSocketAddress; +import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; +import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext; public class AviatorGrpcClientHelper { private static final Logger LOG = LoggerFactory.getLogger(AviatorGrpcClientHelper.class); @@ -41,8 +49,9 @@ public static AviatorGrpcClient createClient(String url, IAviatorLogger logger, throw new AviatorSimpleException("Aviator URL cannot be null or empty."); } - var proxyDescriptor = ProxyHelper.getProxyDescriptorOrEnv(AVIATOR_MODULE, url); - var target = parseTarget(url); + var connectionPlan = createConnectionPlan(url); + var proxyDescriptor = connectionPlan.proxyDescriptor(); + var target = connectionPlan.target(); var builder = target.port() == null ? NettyChannelBuilder.forTarget(target.host()) : NettyChannelBuilder.forAddress(target.host(), target.port()); @@ -57,7 +66,18 @@ public static AviatorGrpcClient createClient(String url, IAviatorLogger logger, return new AviatorGrpcClient(channel, Constants.DEFAULT_TIMEOUT_SECONDS, logger, pingIntervalSeconds); } - static ParsedTarget parseTarget(String url) { + public static AviatorConnectionPlan createConnectionPlan(String url) { + if (url == null || url.trim().isEmpty()) { + throw new AviatorSimpleException("Aviator URL cannot be null or empty."); + } + var normalizedUrl = normalizeUrl(url); + var target = parseTarget(url); + var proxyDescriptor = ProxyHelper.getProxyDescriptorOrEnv(AVIATOR_MODULE, normalizedUrl); + var effectivePort = target.port() == null ? 443 : target.port(); + return new AviatorConnectionPlan(url, normalizedUrl, target, effectivePort, proxyDescriptor); + } + + public static ParsedTarget parseTarget(String url) { var normalizedUrl = normalizeUrl(url); URI uri; try { @@ -85,17 +105,34 @@ static ParsedTarget parseTarget(String url) { } } - private static String normalizeUrl(String url) { + /** + * Normalizes an Aviator target to an https URL. + *

    + *
  • Scheme-less input ({@code host} or {@code host:port}) gets {@code https://} prepended.
  • + *
  • {@code https} (any casing) is accepted and canonicalized to lowercase {@code https}.
  • + *
  • Any other scheme ({@code http}, {@code ftp}, typos like {@code mttp}, …) is rejected.
  • + *
+ */ + public static String normalizeUrl(String url) { var trimmed = url.trim(); - if ( trimmed.matches("^[a-zA-Z][a-zA-Z0-9+\\-.]*://.*$") ) { + if ( !UrlSchemes.hasScheme(trimmed) ) { + return "https://"+trimmed; + } + var schemeSeparator = trimmed.indexOf("://"); + var scheme = trimmed.substring(0, schemeSeparator); + if ( !"https".equalsIgnoreCase(scheme) ) { + throw new UnsupportedAviatorUrlSchemeException(scheme, url); + } + if ( "https".equals(scheme) ) { return trimmed; } - return "https://"+trimmed; + // Canonicalize scheme casing (HTTPS://host → https://host) + return "https"+trimmed.substring(schemeSeparator); } - private static NettyChannelBuilder configureBuilder(NettyChannelBuilder builder, java.util.Optional proxyDescriptor) { + private static NettyChannelBuilder configureBuilder(NettyChannelBuilder builder, Optional proxyDescriptor) { var configuredBuilder = builder - .useTransportSecurity() + .sslContext(createSslContext()) .maxInboundMessageSize(16 * 1024 * 1024) .keepAliveTime(30, TimeUnit.SECONDS) .keepAliveTimeout(10, TimeUnit.SECONDS) @@ -108,6 +145,17 @@ private static NettyChannelBuilder configureBuilder(NettyChannelBuilder builder, return configuredBuilder; } + private static SslContext createSslContext() { + try { + FcliTrustManager.refreshIfChanged(); + return GrpcSslContexts.forClient() + .trustManager(FcliTrustManager.getInstance()) + .build(); + } catch (SSLException e) { + throw new AviatorSimpleException("Unable to initialize Aviator gRPC TLS context", e); + } + } + static ProxiedSocketAddress toProxiedSocketAddress(SocketAddress targetAddress, ProxyDescriptor proxyDescriptor) { if ( !(targetAddress instanceof InetSocketAddress inetSocketAddress) ) { return null; @@ -127,5 +175,12 @@ public static AviatorGrpcClient createClient(String url) throws AviatorSimpleExc return createClient(url, null, Constants.DEFAULT_PING_INTERVAL_SECONDS); } - record ParsedTarget(String host, Integer port) {} + public record AviatorConnectionPlan( + String originalUrl, + String normalizedUrl, + ParsedTarget target, + int effectivePort, + Optional proxyDescriptor) {} + + public record ParsedTarget(String host, Integer port) {} } \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationResult.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationResult.java index df66621f213..3b3cdb1bf81 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationResult.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationResult.java @@ -14,17 +14,35 @@ import java.util.List; +import lombok.Builder; + /** * Holds the outcome of a full correlation stream run — both confirmed - * and rejected SAST–DAST pairs, plus the count of correlation requests - * that received a successful response from the server. + * and rejected SAST–DAST pairs, plus request statistics for the correlation phase. * * @param confirmedPairs pairs where Phase 2 validation returned confirmed=true * @param rejectedPairs pairs where Phase 2 validation returned confirmed=false - * @param receivedCorrelationResponses number of Phase 1 correlation requests that received a response + * @param submittedCorrelationRequests number of Phase 1 correlation requests sent to the server + * @param successfulCorrelationResponses number of Phase 1 requests with a successful server response + * @param skippedCorrelationResponses number of Phase 1 requests skipped by the server + * @param failedCorrelationResponses number of Phase 1 requests that failed */ +@Builder public record CorrelationResult( List confirmedPairs, List rejectedPairs, - int receivedCorrelationResponses -) {} + int submittedCorrelationRequests, + int successfulCorrelationResponses, + int skippedCorrelationResponses, + int failedCorrelationResponses +) { + public CorrelationResult { + confirmedPairs = confirmedPairs == null ? List.of() : List.copyOf(confirmedPairs); + rejectedPairs = rejectedPairs == null ? List.of() : List.copyOf(rejectedPairs); + } + + public static CorrelationResult empty() { + return CorrelationResult.builder() + .build(); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessor.java index 5c4effc8c6f..4b2b27fb3e5 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessor.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessor.java @@ -20,6 +20,7 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -94,16 +95,15 @@ public class CorrelationStreamProcessor implements AutoCloseable { // Input data retained for building validation requests private List correlationWorkItems; private Map> urlToDastIssues; - private final java.util.concurrent.ConcurrentHashMap validationRequestToDastId = - new java.util.concurrent.ConcurrentHashMap<>(); + private final Set pendingCorrelationRequestIds = ConcurrentHashMap.newKeySet(); + private final ConcurrentHashMap validationRequestToDastId = new ConcurrentHashMap<>(); private volatile CompletableFuture resultFuture; /** - * Keys of SAST–DAST pairs that were confirmed in a previous run and should - * be skipped during both Phase 1 (correlation) and Phase 2 (validation). + * Keys of SAST–DAST pairs that were confirmed or rejected in a previous run. * Each key is formatted as {@code "sastInstanceId::dastIssueId"}. */ - private Set previouslyCorrelatedPairKeys = Set.of(); + private Set previouslyTriedPairKeys = Set.of(); public CorrelationStreamProcessor( AviatorGrpcClient client, @@ -122,23 +122,25 @@ public CorrelationStreamProcessor( /** * Entry point: run correlation on the provided mixed-category buckets. - * Previously confirmed pairs (from prior runs) are not re-processed. + * Previously tried pairs (from prior runs) are not re-processed. * * @param config stream init configuration (token, app name, etc.) * @param mixedBuckets category buckets containing both SAST and DAST findings * @param scanGuid SAST scan UUID for building CorrelatedPair results - * @param previouslyCorrelatedPairKeys keys of already-confirmed pairs to skip, each formatted as - * {@code "sastInstanceId::dastIssueId"}; may be {@code null} + * @param previouslyTriedPairKeys keys of already-tried pairs to skip, each formatted as + * {@code "sastInstanceId::dastIssueId"}; may be {@code null} * @return future that completes with the list of confirmed correlated pairs */ public CompletableFuture processCorrelation( CorrelationStreamConfig config, List mixedBuckets, String scanGuid, - Set previouslyCorrelatedPairKeys) { + Set previouslyTriedPairKeys) { - this.previouslyCorrelatedPairKeys = - previouslyCorrelatedPairKeys != null ? previouslyCorrelatedPairKeys : Set.of(); + this.previouslyTriedPairKeys = + previouslyTriedPairKeys != null ? previouslyTriedPairKeys : Set.of(); + pendingCorrelationRequestIds.clear(); + validationRequestToDastId.clear(); // Build URL→DAST map first; needed to evaluate Phase 1 skip eligibility this.urlToDastIssues = buildUrlToDastMap(mixedBuckets); @@ -149,7 +151,7 @@ public CompletableFuture processCorrelation( urlToDastIssues.forEach((k,v)->LOG.debug(" For url {} no. of dast issues {}", k, v.size())); if (workItems.isEmpty()) { LOG.info("No SAST findings in mixed buckets; skipping correlation stream."); - return CompletableFuture.completedFuture(new CorrelationResult(List.of(), List.of(), 0)); + return CompletableFuture.completedFuture(CorrelationResult.empty()); } String streamId = UUID.randomUUID().toString(); @@ -167,6 +169,17 @@ public CompletableFuture processCorrelation( return resultFuture; } + private CorrelationResult createResultSnapshot() { + return CorrelationResult.builder() + .confirmedPairs(new ArrayList<>(state.confirmedPairs)) + .rejectedPairs(new ArrayList<>(state.rejectedPairs)) + .submittedCorrelationRequests(state.totalCorrelationRequests) + .successfulCorrelationResponses(state.successfulCorrelations.get()) + .skippedCorrelationResponses(state.skippedCorrelations.get()) + .failedCorrelationResponses(state.failedCorrelations.get()) + .build(); + } + /** * Convenience overload for callers that have no previously confirmed pairs to skip. */ @@ -220,6 +233,7 @@ private void sendCorrelationRequests() { for (var item : correlationWorkItems) { var req = buildCorrelationRequest(state.streamId, item); + pendingCorrelationRequestIds.add(req.getRequestId()); requestHandler.sendRequest( CorrelationClientMessage.newBuilder().setCorrelation(req).build() ); @@ -238,11 +252,7 @@ private void transitionToValidation(String scanGuid) { if (validationItems.isEmpty()) { logger.info("No candidates to validate. Completing stream."); if (!resultFuture.isDone()) { - resultFuture.complete(new CorrelationResult( - new ArrayList<>(state.confirmedPairs), - new ArrayList<>(state.rejectedPairs), - state.successfulCorrelations.get() - )); + resultFuture.complete(createResultSnapshot()); } requestHandler.complete(); streamLatch.countDown(); @@ -312,11 +322,7 @@ public void onCompleted() { state.currentPhase = CorrelationStreamState.Phase.COMPLETE; logger.info("Correlation stream completed — " + state.confirmedPairs.size() + " confirmed pairs"); if (!resultFuture.isDone()) { - resultFuture.complete(new CorrelationResult( - new ArrayList<>(state.confirmedPairs), - new ArrayList<>(state.rejectedPairs), - state.successfulCorrelations.get() - )); + resultFuture.complete(createResultSnapshot()); } streamLatch.countDown(); } @@ -340,6 +346,10 @@ private void handleInitResponse(CorrelationInitResponse resp) { } private void handleCorrelationResponse(CorrelationResponse resp) { + if (!pendingCorrelationRequestIds.remove(resp.getRequestId())) { + LOG.warn("Ignoring correlation response for unknown or completed request {}", resp.getRequestId()); + return; + } int received = state.receivedCorrelations.incrementAndGet(); LOG.debug("Correlation response {}/{} for SAST {}: status={}", received, state.totalCorrelationRequests, resp.getSastId(), resp.getStatus()); @@ -356,7 +366,11 @@ private void handleCorrelationResponse(CorrelationResponse resp) { match.getRationale() )); } + } else if ("SKIPPED".equalsIgnoreCase(resp.getStatus())) { + state.skippedCorrelations.incrementAndGet(); + LOG.debug("Skipped correlation for SAST {}: {}", resp.getSastId(), resp.getNoCorrelationReason()); } else { + state.failedCorrelations.incrementAndGet(); LOG.debug("Non-OK correlation for SAST {}: {} — {}", resp.getSastId(), resp.getStatus(), resp.getNoCorrelationReason()); } @@ -408,11 +422,7 @@ private void handleValidationResponse(CorrelationValidationResponse resp, String state.confirmedPairs.size() + " confirmed pairs, " + state.rejectedPairs.size() + " rejected pairs."); if (!resultFuture.isDone()) { - resultFuture.complete(new CorrelationResult( - new ArrayList<>(state.confirmedPairs), - new ArrayList<>(state.rejectedPairs), - state.successfulCorrelations.get() - )); + resultFuture.complete(createResultSnapshot()); } requestHandler.complete(); streamLatch.countDown(); @@ -568,10 +578,10 @@ private List buildCorrelationWorkItems(List urlList = new ArrayList<>(dastUrls); for (Vulnerability vuln : data.sastFindings()) { - // Strip URLs where every mapped DAST issue is already confirmed with this SAST finding + // Strip URLs where every mapped DAST issue was already tried with this SAST finding. List newUrls = filterNewUrls(vuln.getInstanceID(), urlList); if (newUrls.isEmpty()) { - LOG.debug("Skipping SAST finding {} from Phase 1 — all reachable DAST issues already confirmed", + LOG.debug("Skipping SAST finding {} from Phase 1 — all reachable DAST issues already tried", vuln.getInstanceID()); continue; } @@ -585,16 +595,16 @@ private List buildCorrelationWorkItems(List *
  • URLs with no mapped DAST issues are kept (the server may resolve them).
  • *
  • URLs where every mapped DAST issue is already in - * {@link #previouslyCorrelatedPairKeys} are excluded — they add no new work.
  • + * {@link #previouslyTriedPairKeys} are excluded — they add no new work. * */ private List filterNewUrls(String sastInstanceId, List urls) { - if (previouslyCorrelatedPairKeys.isEmpty()) return urls; // fast path: nothing confirmed yet + if (previouslyTriedPairKeys.isEmpty()) return urls; List result = new ArrayList<>(); for (String url : urls) { List issues = urlToDastIssues.getOrDefault(url, List.of()); @@ -604,7 +614,7 @@ private List filterNewUrls(String sastInstanceId, List urls) { } boolean hasUncorrelated = issues.stream() .filter(d -> d.getId() != null && !d.getId().isEmpty()) - .anyMatch(d -> !previouslyCorrelatedPairKeys.contains(sastInstanceId + "::" + d.getId())); + .anyMatch(d -> !previouslyTriedPairKeys.contains(sastInstanceId + "::" + d.getId())); if (hasUncorrelated) { result.add(url); } @@ -645,8 +655,8 @@ private List buildValidationWorkItems() { for (DastIssue dastIssue : issues) { String pairKey = match.sastInstanceId() + "::" + dastIssue.getId(); - if (previouslyCorrelatedPairKeys.contains(pairKey)) { - LOG.debug("Skipping already confirmed pair sast={} dast={} from Phase 2 validation", + if (previouslyTriedPairKeys.contains(pairKey)) { + LOG.debug("Skipping already-tried pair sast={} dast={} from Phase 2 validation", match.sastInstanceId(), dastIssue.getId()); continue; } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamState.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamState.java index d8ec3e2b199..b6ccdce3f2c 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamState.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamState.java @@ -39,6 +39,8 @@ enum Phase { INIT, CORRELATING, VALIDATING, COMPLETE } final AtomicInteger sentCorrelations = new AtomicInteger(0); final AtomicInteger receivedCorrelations = new AtomicInteger(0); final AtomicInteger successfulCorrelations = new AtomicInteger(0); + final AtomicInteger skippedCorrelations = new AtomicInteger(0); + final AtomicInteger failedCorrelations = new AtomicInteger(0); // Validation phase counters int totalValidationRequests; diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditRequestMapper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditRequestMapper.java new file mode 100644 index 00000000000..56ca5765eb1 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditRequestMapper.java @@ -0,0 +1,66 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import com.fortify.aviator.dastaudit.DastClassification; +import com.fortify.aviator.dastaudit.DastFindingContext; +import com.fortify.aviator.dastaudit.DastReproStep; +import com.fortify.cli.aviator.dast.DastIssue; +import com.fortify.cli.aviator.dast.DastSession; + +/** + * Maps parsed WebInspect data to the DAST audit wire contract. + */ +public final class DastAuditRequestMapper { + private DastAuditRequestMapper() {} + + public static DastFindingContext toFindingContext(DastSession session, DastIssue issue) { + var builder = DastFindingContext.newBuilder() + .setIssueId(value(issue.getId())) + .setCheckTypeId(value(issue.getCheckTypeId())) + .setEngineType(value(issue.getEngineType())) + .setVulnerabilityId(value(issue.getVulnerabilityId())) + .setSeverity(issue.getSeverity()) + .setName(value(issue.getName())) + .setCategory(value(issue.getCategory())) + .setCweId(value(issue.getCweId())) + .setCweDescription(value(issue.getCweDescription())) + .setSessionUrl(value(session.getUrl() != null ? session.getUrl() : issue.getSessionUrl())) + .setSummary(value(issue.getSummary())) + .setImplication(value(issue.getImplication())) + .setExecution(value(issue.getExecution())) + .setFix(value(issue.getFix())) + .setReferenceInfo(value(issue.getReferenceInfo())) + .setRequestId(value(session.getRequestId())) + .setScheme(value(session.getScheme())) + .setHost(value(session.getHost())) + .setPort(session.getPort()) + .setAttackParamDescriptor(value(session.getAttackParamDescriptor())) + .setRawRequest(value(session.getRawRequest())) + .setRawResponse(value(session.getRawResponse())); + + issue.getClassifications().forEach((kind, classificationValue) -> builder.addClassifications( + DastClassification.newBuilder().setKind(value(kind)).setValue(value(classificationValue)).build())); + builder.addAllReproStepUrls(issue.getReproStepUrls()); + issue.getReproSteps().forEach(step -> builder.addReproSteps(DastReproStep.newBuilder() + .setSource(value(step.getSource())) + .setUrl(value(step.getUrl())) + .setPostParams(value(step.getPostParams())) + .build())); + return builder.build(); + } + + private static String value(String value) { + return value != null ? value : ""; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditResponseMapper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditResponseMapper.java new file mode 100644 index 00000000000..27f06ddda9c --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditResponseMapper.java @@ -0,0 +1,68 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import com.fortify.aviator.dastaudit.DastAuditResponse; +import com.fortify.cli.aviator.audit.model.AuditTier; + +/** + * Maps a DAST audit response to the issue associated with its request ID. + */ +final class DastAuditResponseMapper { + private DastAuditResponseMapper() {} + + static DastAuditResult map(DastAuditResponse response, String expectedIssueId) { + String responseIssueId = response.getDastIssueId(); + if (!responseIssueId.isBlank() && !expectedIssueId.equals(responseIssueId)) { + return DastAuditResult.Failure.builder() + .issueId(expectedIssueId) + .status("FAILED") + .statusMessage("DAST audit response issue ID mismatch: expected '" + expectedIssueId + + "' but received '" + responseIssueId + "'") + .build(); + } + + if ("SKIPPED".equalsIgnoreCase(response.getStatus())) { + return DastAuditResult.Skipped.builder() + .issueId(expectedIssueId) + .statusMessage(response.getStatusMessage()) + .build(); + } + if (!"SUCCESS".equalsIgnoreCase(response.getStatus())) { + return DastAuditResult.Failure.builder() + .issueId(expectedIssueId) + .status(response.getStatus()) + .statusMessage(response.getStatusMessage()) + .build(); + } + if (!response.hasDecision()) { + return DastAuditResult.Failure.builder() + .issueId(expectedIssueId) + .status("FAILED") + .statusMessage("Successful DAST audit response did not contain a decision") + .build(); + } + + var decision = response.getDecision(); + return DastAuditResult.Success.builder() + .issueId(expectedIssueId) + .truePositive(decision.getTruePositive()) + .confidence(decision.getConfidence()) + .reasoning(decision.getReasoning()) + .remediationAdvice(decision.getRemediationAdvice()) + .finalComment(decision.getFinalComment()) + .tagValue(decision.getTagValue()) + .tier(AuditTier.fromServerValue(decision.getTier())) + .build(); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditResult.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditResult.java new file mode 100644 index 00000000000..0c36e052e2f --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditResult.java @@ -0,0 +1,61 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import com.fortify.cli.aviator.audit.model.AuditTier; + +import lombok.Builder; + +/** Domain representation of one terminal DAST audit response. */ +public sealed interface DastAuditResult permits DastAuditResult.Success, DastAuditResult.Skipped, DastAuditResult.Failure { + String issueId(); + String status(); + String statusMessage(); + + @Builder + record Success( + String issueId, + boolean truePositive, + String confidence, + String reasoning, + String remediationAdvice, + String finalComment, + String tagValue, + AuditTier tier + ) implements DastAuditResult { + public Success { + tier = tier == null ? AuditTier.SILVER : tier; + } + + @Override + public String status() { + return "SUCCESS"; + } + + @Override + public String statusMessage() { + return ""; + } + } + + @Builder + record Skipped(String issueId, String statusMessage) implements DastAuditResult { + @Override + public String status() { + return "SKIPPED"; + } + } + + @Builder + record Failure(String issueId, String status, String statusMessage) implements DastAuditResult {} +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamConfig.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamConfig.java new file mode 100644 index 00000000000..83c21da10d4 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamConfig.java @@ -0,0 +1,38 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; + +import lombok.Builder; + +/** + * Configuration for one DAST audit gRPC stream. + */ +@Builder +public record DastAuditStreamConfig( + String token, + String applicationName, + String sscApplicationName, + String sscApplicationVersion, + String fprBuildId +) { + public DastAuditStreamConfig { + if (token == null || token.isBlank()) { + throw new AviatorSimpleException("Aviator token must be specified for DAST audit"); + } + if (applicationName == null || applicationName.isBlank()) { + throw new AviatorSimpleException("Aviator application name must be specified for DAST audit"); + } + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessor.java new file mode 100644 index 00000000000..c830ce7949c --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessor.java @@ -0,0 +1,493 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.aviator.dastaudit.DastAuditClientMessage; +import com.fortify.aviator.dastaudit.DastAuditErrorResponse; +import com.fortify.aviator.dastaudit.DastAuditInitResponse; +import com.fortify.aviator.dastaudit.DastAuditPingRequest; +import com.fortify.aviator.dastaudit.DastAuditPongResponse; +import com.fortify.aviator.dastaudit.DastAuditRequest; +import com.fortify.aviator.dastaudit.DastAuditResponse; +import com.fortify.aviator.dastaudit.DastAuditServerMessage; +import com.fortify.aviator.dastaudit.DastAuditServiceGrpc; +import com.fortify.aviator.dastaudit.DastAuditStreamInitRequest; +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; +import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; +import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.aviator.util.Constants; + +import io.grpc.Status; +import io.grpc.stub.ClientCallStreamObserver; +import io.grpc.stub.ClientResponseObserver; +import io.grpc.stub.StreamObserver; + +/** + * Processes DAST findings through the Aviator DAST audit bidirectional stream. + */ +public class DastAuditStreamProcessor implements AutoCloseable { + private static final Logger LOG = LoggerFactory.getLogger(DastAuditStreamProcessor.class); + + private final IAviatorLogger logger; + private final DastAuditServiceGrpc.DastAuditServiceStub asyncStub; + private final ScheduledExecutorService pingScheduler; + private final long pingIntervalSeconds; + private final AtomicBoolean isPinging = new AtomicBoolean(false); + private final AtomicBoolean isClosed = new AtomicBoolean(false); + private final AtomicBoolean isRpcCompleted = new AtomicBoolean(false); + private final AtomicBoolean isRetryScheduled = new AtomicBoolean(false); + private final AtomicInteger streamRetryCount = new AtomicInteger(); + private final AtomicInteger stagnantRetryCount = new AtomicInteger(); + private final AtomicLong streamGeneration = new AtomicLong(); + + private RequestHandler requestHandler; + private ScheduledFuture pingTask; + private ScheduledFuture retryTask; + private ClientCallStreamObserver activeRequestStream; + private String streamId; + private DastAuditStreamConfig config; + private List workItems; + private int totalReportedIssues; + private int lastRetryCompletedCount; + private CompletableFuture resultFuture; + private final List results = Collections.synchronizedList(new ArrayList<>()); + private final Map requestIssueIds = new ConcurrentHashMap<>(); + private final Map requestIdsByIssue = new ConcurrentHashMap<>(); + private final Set completedRequestIds = ConcurrentHashMap.newKeySet(); + private final Set pendingIssueIds = ConcurrentHashMap.newKeySet(); + private int reservedQuota; + private int exceededCount; + private boolean unlimitedQuota; + private boolean quotaMetadataInitialized; + private String quotaLastUpdated; + private String nextQuotaUpdateMessage; + + public DastAuditStreamProcessor( + IAviatorLogger logger, + DastAuditServiceGrpc.DastAuditServiceStub asyncStub, + ScheduledExecutorService pingScheduler, + long pingIntervalSeconds) { + this.logger = logger; + this.asyncStub = asyncStub; + this.pingScheduler = pingScheduler; + this.pingIntervalSeconds = pingIntervalSeconds; + } + + public CompletableFuture process( + DastAuditStreamConfig config, + List workItems, + int totalReportedIssues) { + this.config = config; + this.totalReportedIssues = totalReportedIssues; + this.resultFuture = new CompletableFuture<>(); + this.requestIdsByIssue.clear(); + this.completedRequestIds.clear(); + this.results.clear(); + this.streamRetryCount.set(0); + this.stagnantRetryCount.set(0); + this.lastRetryCompletedCount = 0; + this.isClosed.set(false); + this.quotaMetadataInitialized = false; + initializeWorkItems(workItems); + startStream(); + return resultFuture; + } + + void initializeWorkItems(List items) { + this.workItems = List.copyOf(items); + this.pendingIssueIds.clear(); + items.forEach(item -> pendingIssueIds.add(item.issue().getId())); + } + + private void startStream() { + if (resultFuture.isDone() || isClosed.get()) return; + this.streamId = UUID.randomUUID().toString(); + this.requestIssueIds.clear(); + this.requestHandler = new RequestHandler<>(streamId); + this.activeRequestStream = null; + this.isRpcCompleted.set(false); + this.isRetryScheduled.set(false); + long generation = streamGeneration.incrementAndGet(); + LOG.debug("Starting DAST audit stream {} with {} pending findings and {} reported findings", + streamId, pendingIssueIds.size(), totalReportedIssues); + + try { + StreamObserver requestObserver = + asyncStub.processDastAuditStream(new ResponseObserver(generation)); + requestHandler.initialize(requestObserver); + startPingPong(); + sendInit(); + } catch (RuntimeException exception) { + handleStreamError(exception); + } + } + + private void sendInit() { + var init = DastAuditStreamInitRequest.newBuilder() + .setToken(value(config.token())) + .setApplicationName(value(config.applicationName())) + .setStreamId(streamId) + .setRequestId(UUID.randomUUID().toString()) + .setTotalReportedIssues(totalReportedIssues) + .setTotalIssuesToAudit(pendingIssueIds.size()); + if (config.fprBuildId() != null) init.setFprBuildId(config.fprBuildId()); + if (config.sscApplicationName() != null) init.setSscApplicationName(config.sscApplicationName()); + if (config.sscApplicationVersion() != null) init.setSscApplicationVersion(config.sscApplicationVersion()); + sendRequest(DastAuditClientMessage.newBuilder().setInit(init).build()); + } + + private void handleInit(DastAuditInitResponse response) { + if (!isSuccess(response.getStatus())) { + fail(new AviatorSimpleException("DAST audit initialization failed: " + response.getStatusMessage())); + return; + } + if (!quotaMetadataInitialized) { + reservedQuota = response.getReservedQuota(); + exceededCount = response.getExceededCount(); + unlimitedQuota = response.getUnlimitedQuota(); + quotaLastUpdated = response.hasQuotaLastUpdated() ? response.getQuotaLastUpdated() : null; + nextQuotaUpdateMessage = response.hasNextQuotaUpdateMessage() ? response.getNextQuotaUpdateMessage() : null; + quotaMetadataInitialized = true; + } + List remainingWorkItems = remainingWorkItems(); + logger.info("DAST audit stream initialized; submitting " + remainingWorkItems.size() + " findings"); + for (DastAuditClientMessage request : prepareAuditRequests(remainingWorkItems, streamId)) { + sendRequest(request); + } + } + + List prepareAuditRequests(List items, String requestStreamId) { + List requests = new ArrayList<>(items.size()); + for (DastAuditWorkItem item : items) { + String issueId = item.issue().getId(); + String requestId = requestIdsByIssue.computeIfAbsent(issueId, ignored -> UUID.randomUUID().toString()); + var request = DastAuditRequest.newBuilder() + .setRequestId(requestId) + .setStreamId(requestStreamId) + .setFinding(DastAuditRequestMapper.toFindingContext(item.session(), item.issue())) + .build(); + pendingIssueIds.add(issueId); + requestIssueIds.put(requestId, issueId); + LOG.debug("Submitting DAST issue {} with request {} on stream {}", + issueId, requestId, requestStreamId); + requests.add(DastAuditClientMessage.newBuilder().setAudit(request).build()); + } + return List.copyOf(requests); + } + + int pendingRequestCount() { + return pendingIssueIds.size(); + } + + List remainingWorkItems() { + return workItems.stream() + .filter(item -> pendingIssueIds.contains(item.issue().getId())) + .toList(); + } + + String completeRequest(String requestId) { + String issueId = requestIssueIds.remove(requestId); + if (issueId != null) { + pendingIssueIds.remove(issueId); + completedRequestIds.add(requestId); + } + return issueId; + } + + private void handleAudit(DastAuditResponse response) { + String issueId = completeRequest(response.getRequestId()); + if (issueId == null) { + LOG.warn("Ignoring DAST audit response for unknown or completed request {}", response.getRequestId()); + return; + } + var decision = response.hasDecision() ? response.getDecision() : null; + LOG.debug("Received DAST audit response: issueId={}, requestId={}, status={}, confidence={}, tier={}, hasDecision={}", + issueId, response.getRequestId(), response.getStatus(), + decision != null ? decision.getConfidence() : null, + decision != null ? decision.getTier() : null, decision != null); + results.add(DastAuditResponseMapper.map(response, issueId)); + logger.progress("Audited %d of %d DAST findings", results.size(), workItems.size()); + completeRequestsIfDone(); + } + + private void handleError(DastAuditErrorResponse response) { + String issueId = completeRequest(response.getRequestId()); + if (issueId != null) { + LOG.debug("Received DAST audit error response: issueId={}, requestId={}, status={}, statusMessage={}", + issueId, response.getRequestId(), response.getStatus(), response.getStatusMessage()); + DastAuditResult result = "SKIPPED".equalsIgnoreCase(response.getStatus()) + ? DastAuditResult.Skipped.builder() + .issueId(issueId) + .statusMessage(response.getStatusMessage()) + .build() + : DastAuditResult.Failure.builder() + .issueId(issueId) + .status(response.getStatus()) + .statusMessage(response.getStatusMessage()) + .build(); + results.add(result); + logger.progress("Audited %d of %d DAST findings", results.size(), workItems.size()); + completeRequestsIfDone(); + } else { + if (completedRequestIds.contains(response.getRequestId())) { + LOG.debug("Ignoring duplicate DAST audit error for completed request {}", response.getRequestId()); + return; + } + fail(new AviatorSimpleException("DAST audit error: " + response.getStatusMessage())); + } + } + + private void handlePong(DastAuditPongResponse response) { + LOG.debug("DAST audit pong received in {} ms", System.currentTimeMillis() - response.getClientTimestamp()); + } + + private void completeRequestsIfDone() { + if (pendingIssueIds.isEmpty() && requestHandler != null && !requestHandler.isCompleted()) { + requestHandler.complete(); + } + } + + private class ResponseObserver implements ClientResponseObserver { + private final long generation; + + private ResponseObserver(long generation) { + this.generation = generation; + } + + private boolean isCurrent() { + return generation == streamGeneration.get(); + } + + @Override + public void beforeStart(ClientCallStreamObserver requestStream) { + if (isCurrent()) activeRequestStream = requestStream; + } + + @Override + public void onNext(DastAuditServerMessage message) { + if (!isCurrent()) return; + switch (message.getResponseTypeCase()) { + case INIT -> handleInit(message.getInit()); + case AUDIT -> handleAudit(message.getAudit()); + case ERROR -> handleError(message.getError()); + case PONG -> handlePong(message.getPong()); + default -> LOG.warn("Unknown DAST audit response type: {}", message.getResponseTypeCase()); + } + } + + @Override + public void onError(Throwable throwable) { + if (!isCurrent()) return; + isRpcCompleted.set(true); + handleStreamError(throwable); + } + + @Override + public void onCompleted() { + if (!isCurrent()) return; + isRpcCompleted.set(true); + stopPingPong(); + LOG.debug("DAST audit stream {} completed with {} terminal responses for {} submitted findings", + streamId, results.size(), workItems.size()); + if (resultFuture.isDone()) return; + if (!pendingIssueIds.isEmpty()) { + handleStreamError(Status.UNAVAILABLE + .withDescription("DAST audit stream completed before all findings received terminal responses") + .asRuntimeException()); + return; + } + completeSuccessfully(); + } + } + + private void startPingPong() { + if (pingScheduler == null || pingIntervalSeconds <= 0) return; + pingTask = pingScheduler.scheduleAtFixedRate(() -> { + if (isPinging.compareAndSet(false, true)) { + try { + if (requestHandler != null && requestHandler.isReady()) { + var ping = DastAuditPingRequest.newBuilder() + .setStreamId(streamId) + .setTimestamp(System.currentTimeMillis()) + .build(); + sendRequest(DastAuditClientMessage.newBuilder().setPing(ping).build()); + } + } finally { + isPinging.set(false); + } + } + }, pingIntervalSeconds, pingIntervalSeconds, TimeUnit.SECONDS); + } + + private void handleStreamError(Throwable throwable) { + stopPingPong(); + if (resultFuture.isDone() || isClosed.get() || isRetryScheduled.get()) return; + + if (pendingIssueIds.isEmpty()) { + completeSuccessfully(); + return; + } + + if (isRetryableError(throwable) && canRetry(throwable)) { + if (!isRetryScheduled.compareAndSet(false, true)) return; + int retryAttempt = streamRetryCount.incrementAndGet(); + long delay = calculateStreamRetryDelay(retryAttempt); + String maxAttempts = isInfiniteRetryError(throwable) + ? "infinite" : String.valueOf(Constants.MAX_STREAM_RETRIES); + logger.info("Retrying DAST audit stream (attempt %d/%s) after %d ms; %d findings remain", + retryAttempt, maxAttempts, delay, pendingIssueIds.size()); + scheduleRetry(delay); + return; + } + + Status status = Status.fromThrowable(throwable); + completeExceptionally(new AviatorTechnicalException( + "DAST audit stream failed: " + status.getDescription(), throwable)); + } + + private boolean canRetry(Throwable throwable) { + int completedCount = workItems.size() - pendingIssueIds.size(); + if (completedCount == lastRetryCompletedCount) { + if (stagnantRetryCount.incrementAndGet() >= 3) { + LOG.error("DAST audit stream made no progress after multiple retries"); + return false; + } + } else { + stagnantRetryCount.set(0); + } + lastRetryCompletedCount = completedCount; + return isInfiniteRetryError(throwable) || streamRetryCount.get() < Constants.MAX_STREAM_RETRIES; + } + + private void scheduleRetry(long delay) { + Runnable retry = () -> { + if (!resultFuture.isDone() && !isClosed.get()) { + isRetryScheduled.set(false); + startStream(); + } + }; + if (!isRpcCompleted.get() && activeRequestStream != null) { + activeRequestStream.cancel("Retrying DAST audit stream", null); + } + if (pingScheduler == null) { + CompletableFuture.delayedExecutor(delay, TimeUnit.MILLISECONDS).execute(retry); + } else { + retryTask = pingScheduler.schedule(retry, delay, TimeUnit.MILLISECONDS); + } + } + + static boolean isRetryableError(Throwable throwable) { + Status status = Status.fromThrowable(throwable); + String description = status.getDescription(); + return status.getCode() == Status.Code.UNAVAILABLE || + status.getCode() == Status.Code.INTERNAL && description != null && + (description.contains("RST_STREAM") || description.contains("PROTOCOL_ERROR")); + } + + static boolean isInfiniteRetryError(Throwable throwable) { + Status status = Status.fromThrowable(throwable); + String description = status.getDescription(); + return status.getCode() == Status.Code.INTERNAL && description != null && + description.contains("PROTOCOL_ERROR"); + } + + static long calculateStreamRetryDelay(int retryCount) { + long delay = (long) (Constants.STREAM_RETRY_BASE_DELAY_MS * Math.pow(2, retryCount - 1)); + return Math.min(delay, Constants.STREAM_RETRY_MAX_DELAY_MS) + + ThreadLocalRandom.current().nextLong(1000); + } + + private void sendRequest(DastAuditClientMessage request) { + RequestHandler currentHandler = requestHandler; + currentHandler.sendRequest(request).whenComplete((sent, throwable) -> { + if (currentHandler != requestHandler || resultFuture.isDone() || isClosed.get()) return; + if (throwable != null || !Boolean.TRUE.equals(sent)) { + Throwable cause = throwable != null ? throwable : Status.UNAVAILABLE + .withDescription("Unable to send DAST audit stream request") + .asRuntimeException(); + handleStreamError(cause); + } + }); + } + + private void fail(RuntimeException exception) { + stopPingPong(); + if (requestHandler != null && !requestHandler.isCompleted()) { + requestHandler.sendError(exception); + } + completeExceptionally(exception); + } + + private void completeExceptionally(RuntimeException exception) { + if (!resultFuture.isDone()) resultFuture.completeExceptionally(exception); + } + + private void completeSuccessfully() { + if (!resultFuture.isDone()) { + resultFuture.complete(DastAuditStreamResult.builder() + .results(List.copyOf(results)) + .reservedQuota(reservedQuota) + .exceededCount(exceededCount) + .unlimitedQuota(unlimitedQuota) + .quotaLastUpdated(quotaLastUpdated) + .nextQuotaUpdateMessage(nextQuotaUpdateMessage) + .build()); + } + } + + private void stopPingPong() { + if (pingTask != null) pingTask.cancel(false); + } + + private boolean isSuccess(String status) { + return "SUCCESS".equalsIgnoreCase(status) || "OK".equalsIgnoreCase(status); + } + + private String value(String value) { + return value != null ? value : ""; + } + + @Override + public void close() { + isClosed.set(true); + stopPingPong(); + if (retryTask != null) retryTask.cancel(false); + if (!isRpcCompleted.get() && activeRequestStream != null) { + activeRequestStream.cancel("DAST audit stream processor closed", null); + } else if (requestHandler != null && !requestHandler.isCompleted()) { + requestHandler.sendError(new AviatorTechnicalException("DAST audit stream processor closed")); + } + if (resultFuture != null && !resultFuture.isDone()) { + completeExceptionally(new AviatorTechnicalException("DAST audit stream processor closed")); + } + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamResult.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamResult.java new file mode 100644 index 00000000000..f114a495cb9 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamResult.java @@ -0,0 +1,34 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import java.util.List; + +import lombok.Builder; + +/** + * Results and quota metadata returned by one DAST audit stream. + */ +@Builder +public record DastAuditStreamResult( + List results, + int reservedQuota, + int exceededCount, + boolean unlimitedQuota, + String quotaLastUpdated, + String nextQuotaUpdateMessage +) { + public DastAuditStreamResult { + results = results == null ? List.of() : List.copyOf(results); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditWorkItem.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditWorkItem.java new file mode 100644 index 00000000000..1aca385b831 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditWorkItem.java @@ -0,0 +1,21 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import com.fortify.cli.aviator.dast.DastIssue; +import com.fortify.cli.aviator.dast.DastSession; + +/** + * Associates a DAST finding with the WebInspect session that contains its HTTP evidence. + */ +public record DastAuditWorkItem(DastSession session, DastIssue issue) {} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/RequestHandler.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/RequestHandler.java index 12dcd9550a7..62f1231eea4 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/RequestHandler.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/RequestHandler.java @@ -134,15 +134,25 @@ public boolean flush() { * Complete the stream and send any remaining requests. */ public CompletableFuture complete() { - if (!isCompleted.compareAndSet(false, true)) { + if (isCompleted.get()) { return CompletableFuture.completedFuture(null); } return CompletableFuture.runAsync(() -> { sendLock.lock(); try { - // Flush any remaining requests - flush(); + if (!isCompleted.compareAndSet(false, true)) { + return; + } + + T request; + while ((request = requestQueue.poll()) != null) { + if (requestObserver != null) { + requestObserver.onNext(request); + totalSent++; + pendingRequests.decrementAndGet(); + } + } // Complete the stream if (requestObserver != null) { diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FprHandle.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FprHandle.java index 4b10fcab4b7..f6d09f98d48 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FprHandle.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FprHandle.java @@ -24,9 +24,7 @@ import java.util.regex.Pattern; import java.util.stream.Stream; -import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import org.slf4j.Logger; @@ -38,6 +36,7 @@ import com.fortify.cli.aviator._common.exception.AviatorSimpleException; import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; +import com.fortify.cli.common.util.SecureXmlParserFactory; import lombok.Getter; @@ -195,17 +194,7 @@ private Map loadSourceFileMap() { private void loadEntryXmlFormat(byte[] indexBytes, Map map) throws ParserConfigurationException, IOException, SAXException { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); - factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); - factory.setFeature("http://xml.org/sax/features/validation", false); - factory.setFeature("http://xml.org/sax/features/external-general-entities", false); - factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); - factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); - factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); - factory.setXIncludeAware(false); - factory.setExpandEntityReferences(false); + var factory = SecureXmlParserFactory.newDocumentBuilderFactoryWithoutDoctype(false); DocumentBuilder builder = factory.newDocumentBuilder(); Document indexDoc = builder.parse(new ByteArrayInputStream(indexBytes)); diff --git a/fcli-core/fcli-aviator-common/src/main/proto/dast_audit.proto b/fcli-core/fcli-aviator-common/src/main/proto/dast_audit.proto new file mode 100644 index 00000000000..724d0f470e4 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/proto/dast_audit.proto @@ -0,0 +1,136 @@ +syntax = "proto3"; + +option java_multiple_files = true; +option java_package = "com.fortify.aviator.dastaudit"; + +package dastaudit; + +service DastAuditService { + rpc ProcessDastAuditStream(stream DastAuditClientMessage) returns (stream DastAuditServerMessage); +} + +message DastAuditStreamInitRequest { + string token = 1; + string applicationName = 2; + string streamId = 3; + string requestId = 4; + int32 totalReportedIssues = 5; + int32 totalIssuesToAudit = 6; + optional string fprBuildId = 7; + optional string sscApplicationName = 8; + optional string sscApplicationVersion = 9; +} + +message DastAuditClientMessage { + oneof request_type { + DastAuditStreamInitRequest init = 1; + DastAuditRequest audit = 2; + DastAuditPingRequest ping = 3; + } +} + +message DastAuditServerMessage { + oneof response_type { + DastAuditInitResponse init = 1; + DastAuditResponse audit = 2; + DastAuditErrorResponse error = 3; + DastAuditPongResponse pong = 4; + } +} + +message DastAuditInitResponse { + string requestId = 1; + string clientStreamId = 2; + string serverStreamId = 3; + string status = 4; + string statusMessage = 5; + int32 reservedQuota = 6; + int32 exceededCount = 7; + bool unlimitedQuota = 8; + optional string quotaLastUpdated = 9; + optional string nextQuotaUpdateMessage = 10; + optional string reassignedEntitlementId = 11; +} + +message DastAuditRequest { + string requestId = 1; + string streamId = 2; + DastFindingContext finding = 3; +} + +message DastFindingContext { + string issueId = 1; + string checkTypeId = 2; + string engineType = 3; + string vulnerabilityId = 4; + int32 severity = 5; + string name = 6; + string category = 7; + string cweId = 8; + string cweDescription = 9; + string sessionUrl = 10; + repeated string reproStepUrls = 11; + string summary = 12; + string implication = 13; + string execution = 14; + string fix = 15; + string referenceInfo = 16; + repeated DastClassification classifications = 17; + string requestId = 18; + string scheme = 19; + string host = 20; + int32 port = 21; + string attackParamDescriptor = 22; + string rawRequest = 23; + string rawResponse = 24; + repeated DastReproStep reproSteps = 25; +} + +message DastClassification { + string kind = 1; + string value = 2; +} + +message DastReproStep { + string source = 1; + string url = 2; + string postParams = 3; +} + +message DastAuditResponse { + string requestId = 1; + string streamId = 2; + string dastIssueId = 3; + string status = 4; + string statusMessage = 5; + DastAuditDecision decision = 6; +} + +message DastAuditDecision { + bool truePositive = 1; + string confidence = 2; + string reasoning = 3; + string remediationAdvice = 4; + string finalComment = 5; + string tagValue = 6; + string tier = 7; +} + +message DastAuditErrorResponse { + string requestId = 1; + string clientStreamId = 2; + string serverStreamId = 3; + string status = 4; + string statusMessage = 5; +} + +message DastAuditPingRequest { + string streamId = 1; + int64 timestamp = 2; +} + +message DastAuditPongResponse { + string streamId = 1; + int64 serverTimestamp = 2; + int64 clientTimestamp = 3; +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/resources/default_tag_mapping.yaml b/fcli-core/fcli-aviator-common/src/main/resources/default_tag_mapping.yaml index cbd39912eb4..320b96d6d63 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 @@ -10,6 +10,15 @@ tag_id: "87f2364f-dcd4-49e6-861d-f8d3f351686b" # suppression_exclusions: # - categories: # - "Privacy Violation" +# Optional sast and dast sections may override tag_id, mapping, or +# suppression_exclusions for each product. Existing flat files remain valid. +# sast: +# suppression_exclusions: +# - categories: +# - "Privacy Violation" +# dast: +# tag_id: "dast-specific-tag-id" +# mapping: ... mapping: tier_1: fp: diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheRoundTripTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheRoundTripTest.java new file mode 100644 index 00000000000..be89754108e --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheRoundTripTest.java @@ -0,0 +1,294 @@ +/* + * 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._common.remediations_cache; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.exception.FcliTechnicalException; + +class RemediationsCacheRoundTripTest { + @TempDir Path tempDir; + + @Test + void writeAndReadRoundTripPreservesOrderAndHashes() throws Exception { + Path fpr1 = tempDir.resolve("one.fpr"); + Path fpr2 = tempDir.resolve("two.fpr"); + Files.writeString(fpr1, "fpr-content-1"); + Files.writeString(fpr2, "fpr-content-2"); + Path zip = tempDir.resolve("cache.zip"); + + RemediationsCacheWriter.write( + zip, + RemediationsCacheConstants.PRODUCT_SSC, + Map.of("mode", "all", "appVersionId", "10001"), + List.of( + new RemediationsCacheWriter.SSCFpr(fpr1, "123", "2026-07-10T08:00:00Z"), + new RemediationsCacheWriter.SSCFpr(fpr2, "456", "2026-07-11T09:30:00Z"))); + + try (RemediationsCacheReader reader = RemediationsCacheReader.open(zip)) { + assertEquals(RemediationsCacheConstants.PRODUCT_SSC, reader.getManifest().getProduct()); + assertEquals(2, reader.getOrderedFprPaths().size()); + assertEquals("fpr-content-1", Files.readString(reader.getOrderedFprPaths().get(0))); + assertEquals("fpr-content-2", Files.readString(reader.getOrderedFprPaths().get(1))); + assertEquals("123", reader.getManifest().getEntries().get(0).getSscData().getArtifactId()); + assertEquals("456", reader.getManifest().getEntries().get(1).getSscData().getArtifactId()); + assertEquals(null, reader.getManifest().getEntries().get(0).getFodData()); + } + } + + @Test + void streamAddFprWritesReadableCacheForFoD() throws Exception { + Path zip = tempDir.resolve("direct.zip"); + try (RemediationsCacheWriter writer = RemediationsCacheWriter.create( + zip, RemediationsCacheConstants.PRODUCT_FOD, Map.of("mode", "release"))) { + writer.addFodFpr("rel-1", path -> { + try { + Files.writeString(path, "streamed-fpr"); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + writer.commit(); + } + try (RemediationsCacheReader reader = RemediationsCacheReader.open(zip)) { + reader.requireProduct(RemediationsCacheConstants.PRODUCT_FOD); + assertEquals("streamed-fpr", Files.readString(reader.getOrderedFprPaths().get(0))); + assertEquals("rel-1", reader.getOrderedEntries().get(0).getFodData().getReleaseId()); + } + } + + @Test + void emptySourcesRejected() { + Path zip = tempDir.resolve("empty.zip"); + assertThrows(FcliSimpleException.class, () -> + RemediationsCacheWriter.write(zip, RemediationsCacheConstants.PRODUCT_SSC, Map.of(), List.of())); + } + + @Test + void closeWithoutEntriesDoesNotReplaceExistingDestination() throws Exception { + Path zip = tempDir.resolve("existing.zip"); + Files.writeString(zip, "prior-cache-bytes"); + // open + close with no addFpr: work file discarded; destination untouched. + try (RemediationsCacheWriter writer = RemediationsCacheWriter.create( + zip, RemediationsCacheConstants.PRODUCT_SSC, Map.of("mode", "all"))) { + // intentionally empty + } + assertEquals("prior-cache-bytes", Files.readString(zip)); + assertTrue(Files.notExists(zip.resolveSibling("existing.zip.partial"))); + } + + @Test + void closeWithEntriesPublishesAndReplacesDestination() throws Exception { + Path zip = tempDir.resolve("existing.zip"); + Files.writeString(zip, "prior-cache-bytes"); + try (RemediationsCacheWriter writer = RemediationsCacheWriter.create( + zip, RemediationsCacheConstants.PRODUCT_SSC, Map.of("mode", "all"))) { + writer.addSscFpr("1", null, path -> { + try { + Files.writeString(path, "new-fpr"); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + writer.commit(); + } + try (RemediationsCacheReader reader = RemediationsCacheReader.open(zip)) { + assertEquals("new-fpr", Files.readString(reader.getOrderedFprPaths().get(0))); + } + assertTrue(Files.notExists(zip.resolveSibling("existing.zip.partial"))); + } + + @Test + void failedEntryDoesNotPublishPartialCache() throws Exception { + Path zip = tempDir.resolve("failed.zip"); + Files.writeString(zip, "prior-cache-bytes"); + + assertThrows(FcliTechnicalException.class, () -> { + try (RemediationsCacheWriter writer = RemediationsCacheWriter.create( + zip, RemediationsCacheConstants.PRODUCT_SSC, Map.of("mode", "all"))) { + writer.addSscFpr("1", null, path -> { + try { + Files.writeString(path, "first-fpr"); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + writer.addSscFpr("2", null, path -> { + throw new RuntimeException("download failed"); + }); + writer.commit(); + } + }); + + assertEquals("prior-cache-bytes", Files.readString(zip)); + assertTrue(Files.notExists(zip.resolveSibling("failed.zip.partial"))); + } + + @Test + void cachedFprCanBeOpenedAsNestedZipFileSystem() throws Exception { + Path fpr = tempDir.resolve("nested.fpr"); + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(fpr))) { + zos.putNextEntry(new ZipEntry("audit.fvdl")); + zos.write("".getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + } + Path zip = tempDir.resolve("cache.zip"); + RemediationsCacheWriter.write( + zip, + RemediationsCacheConstants.PRODUCT_SSC, + Map.of("mode", "artifact-id"), + List.of(new RemediationsCacheWriter.SSCFpr(fpr, "1", null))); + + try (RemediationsCacheReader reader = RemediationsCacheReader.open(zip); + FprHandle fprHandle = new FprHandle(reader.getOrderedFprPaths().get(0))) { + assertTrue(Files.exists(fprHandle.getPath("/audit.fvdl"))); + } + } + + @Test + void badSha256RejectedOnLazyPathLoad() throws Exception { + Path fpr = tempDir.resolve("one.fpr"); + Files.writeString(fpr, "original"); + Path zip = tempDir.resolve("cache.zip"); + RemediationsCacheWriter.write( + zip, + RemediationsCacheConstants.PRODUCT_SSC, + Map.of("mode", "artifact-id"), + List.of(new RemediationsCacheWriter.SSCFpr(fpr, "1", null))); + + Path corruptZip = tempDir.resolve("corrupt.zip"); + try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(zip)); + ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(corruptZip))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + zos.putNextEntry(new ZipEntry(entry.getName())); + if (entry.getName().endsWith(".fpr")) { + zos.write("corrupted".getBytes(StandardCharsets.UTF_8)); + } else { + zis.transferTo(zos); + } + zos.closeEntry(); + } + } + + assertThrows(FcliSimpleException.class, () -> { + try (RemediationsCacheReader reader = RemediationsCacheReader.open(corruptZip)) { + reader.getOrderedFprPaths(); + } + }); + } + + @Test + void missingManifestRejectedOnLazyManifestLoad() throws Exception { + Path zip = tempDir.resolve("no-manifest.zip"); + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zip))) { + zos.putNextEntry(new ZipEntry("fprs/001.fpr")); + zos.write("x".getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + } + FcliSimpleException ex = assertThrows(FcliSimpleException.class, () -> { + try (RemediationsCacheReader reader = RemediationsCacheReader.open(zip)) { + reader.getManifest(); + } + }); + assertTrue(ex.getMessage().contains("manifest.json")); + } + + @Test + void requireProductRejectsMismatch() throws Exception { + Path fpr = tempDir.resolve("one.fpr"); + Files.writeString(fpr, "x"); + Path zip = tempDir.resolve("cache.zip"); + RemediationsCacheWriter.write( + zip, + RemediationsCacheConstants.PRODUCT_SSC, + Map.of("mode", "artifact-id"), + List.of(new RemediationsCacheWriter.SSCFpr(fpr, "1", null))); + + assertThrows(FcliSimpleException.class, () -> { + try (RemediationsCacheReader reader = RemediationsCacheReader.open(zip)) { + reader.requireProduct(RemediationsCacheConstants.PRODUCT_FOD); + } + }); + } + + @Test + void entryValidateRejectsMissingProductBlockAndDualProduct() { + RemediationsCacheEntry bare = new RemediationsCacheEntry(); + bare.setOrder(1); + bare.setPath("fprs/001.fpr"); + bare.setSha256("abc"); + assertThrows(FcliSimpleException.class, bare::validate); + + RemediationsCacheEntry dual = RemediationsCacheEntry.forSsc( + 1, "fprs/001.fpr", "abc", RemediationsCacheEntry.SSCData.of("1", null)); + dual.setFodData(RemediationsCacheEntry.FoDData.of("r1")); + assertThrows(FcliSimpleException.class, dual::validate); + + RemediationsCacheEntry ssc = RemediationsCacheEntry.forSsc( + 1, "fprs/001.fpr", "abc", RemediationsCacheEntry.SSCData.of("1", null)); + ssc.validate(); + } + + @Test + void manifestValidateRejectsProductEntryMismatch() { + RemediationsCacheManifest manifest = new RemediationsCacheManifest(); + manifest.setSchemaVersion(RemediationsCacheConstants.SCHEMA_VERSION); + manifest.setKind(RemediationsCacheConstants.KIND); + manifest.setProduct(RemediationsCacheConstants.PRODUCT_SSC); + manifest.getEntries().add(RemediationsCacheEntry.forFod( + 1, "fprs/001.fpr", "abc", RemediationsCacheEntry.FoDData.of("rel-1"))); + assertThrows(FcliSimpleException.class, manifest::validate); + } + + @Test + void entryPathSanitizesUnsafeIdCharacters() throws Exception { + Path fpr = tempDir.resolve("one.fpr"); + Files.writeString(fpr, "content"); + Path zip = tempDir.resolve("cache.zip"); + RemediationsCacheWriter.write( + zip, + RemediationsCacheConstants.PRODUCT_SSC, + Map.of("mode", "artifact-id"), + List.of(new RemediationsCacheWriter.SSCFpr(fpr, "12/../evil", null))); + + try (RemediationsCacheReader reader = RemediationsCacheReader.open(zip)) { + var resolved = reader.getOrderedResolvedFprs(); + assertEquals(1, resolved.size()); + // Manifest keeps the original id; zip entry path must not contain path separators or "..". + assertEquals("12/../evil", resolved.get(0).entry().getSscData().getArtifactId()); + String entryPath = resolved.get(0).entry().getPath(); + assertTrue(entryPath.startsWith(RemediationsCacheConstants.FPRS_DIR + "/")); + assertTrue(!entryPath.contains("..")); + assertTrue(!entryPath.contains("/evil")); + assertEquals("content", Files.readString(resolved.get(0).fprPath())); + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorIssueIdFilterUtilsTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorIssueIdFilterUtilsTest.java new file mode 100644 index 00000000000..dc44a2b077c --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorIssueIdFilterUtilsTest.java @@ -0,0 +1,39 @@ +/* + * 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._common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.common.exception.FcliSimpleException; + +class AviatorIssueIdFilterUtilsTest { + + @Test + void normalizeTrimsAndDeduplicates() { + assertEquals( + Set.of("ISSUE-1", "ISSUE-2"), + AviatorIssueIdFilterUtils.normalizeIssueIds(List.of(" ISSUE-1 ", "", "ISSUE-2", "ISSUE-1"))); + } + + @Test + void normalizeRejectsOnlyBlankValues() { + assertThrows(FcliSimpleException.class, + () -> AviatorIssueIdFilterUtils.normalizeIssueIds(List.of(" ", "", " "))); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorLocalFprHelperTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorLocalFprHelperTest.java new file mode 100644 index 00000000000..d33f2469d55 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorLocalFprHelperTest.java @@ -0,0 +1,53 @@ +/* + * 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._common.util; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.fortify.cli.common.exception.FcliSimpleException; + +class AviatorLocalFprHelperTest { + @TempDir private Path tempDir; + + @Test + void testMissingFprThrowsException() { + Path missingFpr = tempDir.resolve("missing.fpr"); + + assertThrows(FcliSimpleException.class, () -> AviatorLocalFprHelper.validateLocalFprs(List.of(missingFpr))); + } + + @Test + void testDirectoryFprThrowsException() { + assertThrows(FcliSimpleException.class, () -> AviatorLocalFprHelper.validateLocalFprs(List.of(tempDir))); + } + + @Test + void testEmptyFprListThrowsException() { + assertThrows(FcliSimpleException.class, () -> AviatorLocalFprHelper.validateLocalFprs(List.of())); + } + + @Test + void testInvalidFprThrowsException() throws Exception { + Path invalidFpr = tempDir.resolve("invalid.fpr"); + Files.writeString(invalidFpr, "not a zip"); + + assertThrows(FcliSimpleException.class, () -> AviatorLocalFprHelper.validateLocalFprs(List.of(invalidFpr))); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelperTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelperTest.java new file mode 100644 index 00000000000..4aec3beb464 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelperTest.java @@ -0,0 +1,150 @@ +/* + * 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._common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator.fpr.remediation.RemediationExecutionMode; +import com.fortify.cli.aviator.fpr.remediation.model.RemediationMetric; +import com.fortify.cli.aviator.fpr.remediation.preview.PreviewDetail; + +class AviatorRemediationMetricsHelperTest { + + @Test + void aggregateFilteredMetricsDeduplicatesIssueIdsAcrossEntries() { + RemediationMetric metricOne = filteredMetric(Set.of("ISSUE-1", "ISSUE-2"), Set.of("ISSUE-1"), Set.of("A.java")); + RemediationMetric metricTwo = filteredMetric(Set.of("ISSUE-1", "ISSUE-2"), Set.of("ISSUE-2"), Set.of("B.java")); + + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + Set.of("ISSUE-1", "ISSUE-2"), List.of(metricOne, metricTwo)); + + assertTrue(aggregated.isFiltered()); + assertEquals(2, aggregated.totalRemediations()); + assertEquals(2, aggregated.appliedRemediations()); + assertEquals(0, aggregated.skippedRemediations()); + assertEquals(Set.of("ISSUE-1", "ISSUE-2"), aggregated.appliedIssueIds()); + assertEquals(Set.of("A.java", "B.java"), aggregated.modifiedFiles()); + } + + @Test + void aggregateUnfilteredMergesSkippedByReason() { + Map reasonsOne = new LinkedHashMap<>(); + reasonsOne.put("Source file missing", 1); + Map reasonsTwo = new LinkedHashMap<>(); + reasonsTwo.put("Source file missing", 1); + reasonsTwo.put("No file changes found", 1); + RemediationMetric metricOne = unfilteredMetric(2, 1, Set.of("A.java"), reasonsOne); + RemediationMetric metricTwo = unfilteredMetric(1, 0, Set.of(), reasonsTwo); + + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + null, List.of(metricOne, metricTwo)); + + assertFalse(aggregated.isFiltered()); + assertEquals(3, aggregated.totalRemediations()); + assertEquals(1, aggregated.appliedRemediations()); + assertEquals(2, aggregated.skippedRemediations()); + assertEquals(2, aggregated.skippedByReason().get("Source file missing")); + assertEquals(1, aggregated.skippedByReason().get("No file changes found")); + assertEquals("Source file missing=2, No file changes found=1", + AviatorRemediationMetricsHelper.formatSkippedReasons(aggregated.skippedByReason())); + } + + @Test + void remainingIssueIdsDropsAlreadyApplied() { + RemediationMetric metric = filteredMetric(Set.of("ISSUE-1", "ISSUE-2"), Set.of("ISSUE-1"), Set.of("A.java")); + + assertEquals( + Set.of("ISSUE-2"), + AviatorRemediationMetricsHelper.getRemainingIssueIds(Set.of("ISSUE-1", "ISSUE-2"), metric)); + } + + @Test + void aggregatingAnyPreviewMetricYieldsPreviewResultWithMergedDetails() { + RemediationMetric applied = unfilteredMetric(1, 1, Set.of("A.java"), Map.of()); + RemediationMetric preview = RemediationMetric.builder() + .totalRemediations(1) + .skippedRemediations(1) + .executionMode(RemediationExecutionMode.PREVIEW) + .previewDetails(List.of(PreviewDetail.skipped("ISSUE-2", null))) + .build(); + + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + null, List.of(applied, preview)); + + assertTrue(aggregated.isPreview()); + assertEquals(1, aggregated.previewDetails().size()); + } + + @Test + void skippedPreviewRunWithNoMetricsStaysPreview() { + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + null, List.of(), RemediationExecutionMode.PREVIEW); + + assertTrue(aggregated.isPreview()); + assertEquals(0, aggregated.appliedRemediations()); + assertEquals("No-Remediation-Previewed", AviatorRemediationMetricsHelper.actionLabel(aggregated)); + } + + @Test + void skippedApplyRunWithNoMetricsStaysApply() { + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + null, List.of(), RemediationExecutionMode.APPLY); + + assertFalse(aggregated.isPreview()); + assertEquals("No-Remediation-Applied", AviatorRemediationMetricsHelper.actionLabel(aggregated)); + } + + @Test + void aggregatingOnlyAppliedMetricsYieldsApplyResult() { + RemediationMetric metricOne = unfilteredMetric(1, 1, Set.of("A.java"), Map.of()); + RemediationMetric metricTwo = unfilteredMetric(1, 0, Set.of(), Map.of()); + + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + null, List.of(metricOne, metricTwo)); + + assertFalse(aggregated.isPreview()); + } + + private RemediationMetric filteredMetric(Set requestedIds, Set appliedIds, Set modifiedFiles) { + return RemediationMetric.builder() + .totalRemediations(requestedIds.size()) + .appliedRemediations(appliedIds.size()) + .skippedRemediations(requestedIds.size() - appliedIds.size()) + .requestedIssueIds(requestedIds) + .seenIssueIds(appliedIds) + .satisfiedIssueIds(appliedIds) + .appliedIssueIds(appliedIds) + .modifiedFiles(modifiedFiles) + .build(); + } + + private RemediationMetric unfilteredMetric(int total, int applied, Set modifiedFiles, + Map skippedByReason) { + return RemediationMetric.builder() + .totalRemediations(total) + .appliedRemediations(applied) + .skippedRemediations(total - applied) + .modifiedFiles(modifiedFiles) + .skippedByReason(skippedByReason) + .build(); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/DastAuditDecisionMapperTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/DastAuditDecisionMapperTest.java new file mode 100644 index 00000000000..424612fb8b9 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/DastAuditDecisionMapperTest.java @@ -0,0 +1,82 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.audit; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator.audit.model.AuditTier; +import com.fortify.cli.aviator.grpc.DastAuditResult; +import com.fortify.cli.aviator.util.Constants; + +class DastAuditDecisionMapperTest { + @Test + void serverGoldTierControlsFalsePositiveMappingRegardlessOfConfidence() { + var result = DastAuditResult.Success.builder() + .issueId("DAST-1") + .confidence("UNKNOWN") + .reasoning("reason") + .finalComment("comment") + .tagValue("bad") + .tier(AuditTier.GOLD) + .build(); + + var response = DastAuditDecisionMapper.toAuditResponse(result); + + assertEquals("GOLD", response.getTier()); + assertEquals(Constants.AVIATOR_NOT_AN_ISSUE, response.getAviatorPredictionTag()); + } + + @Test + void missingServerTierDefaultsToSilverRegardlessOfConfidence() { + var result = DastAuditResult.Success.builder() + .issueId("DAST-1") + .confidence("HIGH") + .reasoning("reason") + .finalComment("comment") + .build(); + + var response = DastAuditDecisionMapper.toAuditResponse(result); + + assertEquals("SILVER", response.getTier()); + assertEquals(Constants.AVIATOR_LIKELY_FP, response.getAviatorPredictionTag()); + } + + @Test + void missingDomainTierDefaultsToSilver() { + var result = DastAuditResult.Success.builder() + .issueId("DAST-1") + .build(); + + var response = DastAuditDecisionMapper.toAuditResponse(result); + + assertEquals("SILVER", response.getTier()); + assertEquals(Constants.AVIATOR_LIKELY_FP, response.getAviatorPredictionTag()); + } + + @Test + void serverSilverTierControlsTruePositivePrediction() { + var result = DastAuditResult.Success.builder() + .issueId("DAST-1") + .truePositive(true) + .confidence("HIGH") + .tier(AuditTier.SILVER) + .build(); + + var response = DastAuditDecisionMapper.toAuditResponse(result); + + assertEquals("SILVER", response.getTier()); + assertEquals(Constants.AVIATOR_LIKELY_TP, response.getAviatorPredictionTag()); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/DastAuditFPRTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/DastAuditFPRTest.java new file mode 100644 index 00000000000..5349cd79774 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/DastAuditFPRTest.java @@ -0,0 +1,294 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.audit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.fortify.cli.aviator._common.config.AviatorConfigManager; +import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; +import com.fortify.cli.aviator.audit.model.AuditTier; +import com.fortify.cli.aviator.config.TagMappingConfig; +import com.fortify.cli.aviator.grpc.DastAuditResult; +import com.fortify.cli.aviator.grpc.DastAuditStreamConfig; +import com.fortify.cli.aviator.grpc.DastAuditStreamResult; +import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.aviator.util.ResourceUtil; + +class DastAuditFPRTest { + @TempDir Path tempDir; + + @Test + void auditsEligibleFindingAndWritesConservativeXml() throws Exception { + Path fpr = createFpr(); + var config = streamConfig(); + + DastAuditFprResult result; + try (FprHandle handle = new FprHandle(fpr)) { + result = DastAuditFPR.audit(handle, config, defaultTagMapping(), (ignoredConfig, items, total) -> + CompletableFuture.completedFuture(DastAuditStreamResult.builder() + .results(List.of(successResult(false, "HIGH", AuditTier.GOLD))) + .reservedQuota(1) + .build())); + } + + assertEquals(DastAuditFprStatus.AUDITED, result.status()); + assertEquals(1, result.falsePositivesSuppressed()); + try (FileSystem zip = FileSystems.newFileSystem(fpr)) { + String auditXml = Files.readString(zip.getPath("/audit.xml")); + assertTrue(auditXml.contains("instanceId=\"DAST-1\"")); + assertTrue(auditXml.contains("suppressed=\"true\"")); + assertTrue(auditXml.contains("PROCESSED_BY_AVIATOR")); + assertFalse(Files.exists(zip.getPath("/remediations.xml"))); + } + } + + @Test + void customTagMappingControlsFinalTagAndSuppression() throws Exception { + Path fpr = createFpr(); + Path tagMapping = tempDir.resolve("dast-tag-mapping.yaml"); + Files.writeString(tagMapping, """ + tag_id: "custom-analysis-tag" + mapping: + tier_1: + fp: { value: "Confirmed FP", suppress: false } + tp: { value: "Confirmed TP", suppress: false } + unsure: { suppress: false } + tier_2: + fp: { value: "Review FP", suppress: true } + tp: { value: "Review TP", suppress: false } + unsure: { suppress: false } + """); + var config = streamConfig(); + + DastAuditFprResult result; + try (FprHandle handle = new FprHandle(fpr)) { + result = DastAuditFPR.audit(handle, config, + ResourceUtil.loadYamlFile(tagMapping.toFile(), TagMappingConfig.class), + (ignoredConfig, items, total) -> + CompletableFuture.completedFuture(DastAuditStreamResult.builder() + .results(List.of(successResult(false, "MEDIUM", AuditTier.SILVER))) + .reservedQuota(1) + .build())); + } + + assertEquals(1, result.falsePositivesSuppressed()); + try (FileSystem zip = FileSystems.newFileSystem(fpr)) { + String auditXml = Files.readString(zip.getPath("/audit.xml")); + assertTrue(auditXml.contains("suppressed=\"true\"")); + assertTrue(auditXml.contains("id=\"custom-analysis-tag\"")); + assertTrue(auditXml.contains(">Review FP<")); + } + } + + @Test + void writesOnlyIssuesUpdatedByCurrentAudit() throws Exception { + Path fpr = createFpr(); + try (FileSystem zip = FileSystems.newFileSystem(fpr)) { + Files.writeString(zip.getPath("/audit.xml"), """ + + + + + """); + } + var config = streamConfig(); + + try (FprHandle handle = new FprHandle(fpr)) { + DastAuditFPR.audit(handle, config, defaultTagMapping(), (ignoredConfig, items, total) -> + CompletableFuture.completedFuture(DastAuditStreamResult.builder() + .results(List.of(successResult(true, "HIGH", AuditTier.GOLD))) + .reservedQuota(1) + .build())); + } + + try (FileSystem zip = FileSystems.newFileSystem(fpr)) { + String auditXml = Files.readString(zip.getPath("/audit.xml")); + assertTrue(auditXml.contains("instanceId=\"DAST-1\"")); + assertFalse(auditXml.contains("instanceId=\"DAST-2\"")); + } + } + + @Test + void missingTerminalResponseIsCountedAsFailure() throws Exception { + Path fpr = createFpr(); + var config = streamConfig(); + + try (FprHandle handle = new FprHandle(fpr)) { + DastAuditFprResult result = DastAuditFPR.audit( + handle, config, defaultTagMapping(), (ignoredConfig, items, total) -> + CompletableFuture.completedFuture(DastAuditStreamResult.builder() + .results(List.of()) + .reservedQuota(1) + .build())); + + assertEquals(DastAuditFprStatus.FAILED, result.status()); + assertEquals(1, result.failed()); + } + } + + @Test + void excludesSuppressedAviatorProcessedAndHumanAuditedFindings() throws Exception { + Path fpr = createEligibilityFpr(); + + try (FprHandle handle = new FprHandle(fpr)) { + DastAuditFprResult result = DastAuditFPR.audit( + handle, streamConfig(), defaultTagMapping(), (ignoredConfig, items, total) -> { + assertEquals(List.of("DAST-5"), items.stream().map(item -> item.issue().getId()).toList()); + return CompletableFuture.completedFuture(DastAuditStreamResult.builder() + .results(List.of(successResult("DAST-5", true, "HIGH", AuditTier.GOLD))) + .reservedQuota(1) + .build()); + }); + + assertEquals(5, result.totalReported()); + assertEquals(1, result.submitted()); + assertEquals(4, result.skipped()); + } + } + + @Test + void allExcludedFindingsReturnSkippedWithoutStartingStream() throws Exception { + Path fpr = createFpr(); + try (FileSystem zip = FileSystems.newFileSystem(fpr)) { + Files.writeString(zip.getPath("/audit.xml"), """ + + + + """); + } + + try (FprHandle handle = new FprHandle(fpr)) { + DastAuditFprResult result = DastAuditFPR.audit( + handle, streamConfig(), defaultTagMapping(), (ignoredConfig, items, total) -> { + throw new AssertionError("Stream must not start when all findings are excluded"); + }); + + assertEquals(DastAuditFprStatus.SKIPPED, result.status()); + assertEquals(1, result.totalReported()); + assertEquals(1, result.skipped()); + assertEquals(0, result.submitted()); + } + } + + @Test + void missingStreamFutureProducesTechnicalError() throws Exception { + Path fpr = createFpr(); + + try (FprHandle handle = new FprHandle(fpr)) { + AviatorTechnicalException exception = assertThrows(AviatorTechnicalException.class, + () -> DastAuditFPR.audit(handle, streamConfig(), defaultTagMapping(), + (ignoredConfig, items, total) -> null)); + + assertEquals("DAST audit stream did not return a completion future", exception.getMessage()); + } + } + + @Test + void missingStreamResultProducesTechnicalError() throws Exception { + Path fpr = createFpr(); + + try (FprHandle handle = new FprHandle(fpr)) { + AviatorTechnicalException exception = assertThrows(AviatorTechnicalException.class, + () -> DastAuditFPR.audit(handle, streamConfig(), defaultTagMapping(), + (ignoredConfig, items, total) -> CompletableFuture.completedFuture(null))); + + assertEquals("DAST audit stream completed without a result", exception.getMessage()); + } + } + + private DastAuditResult.Success successResult(boolean truePositive, String confidence, AuditTier tier) { + return successResult("DAST-1", truePositive, confidence, tier); + } + + private DastAuditResult.Success successResult( + String issueId, boolean truePositive, String confidence, AuditTier tier) { + return DastAuditResult.Success.builder() + .issueId(issueId) + .truePositive(truePositive) + .confidence(confidence) + .tier(tier) + .reasoning("reason") + .finalComment("comment") + .build(); + } + + private TagMappingConfig defaultTagMapping() { + return AviatorConfigManager.getInstance().getDefaultDastTagMappingConfig(); + } + + private DastAuditStreamConfig streamConfig() { + return DastAuditStreamConfig.builder() + .token("token") + .applicationName("app") + .sscApplicationName("ssc") + .sscApplicationVersion("1") + .build(); + } + + private Path createFpr() throws Exception { + Path fpr = tempDir.resolve("dast.fpr"); + try (FileSystem zip = FileSystems.newFileSystem(fpr, Map.of("create", "true"))) { + Files.writeString(zip.getPath("/webinspect.xml"), """ + https://example.test + SQL Injection4 + + """, StandardCharsets.UTF_8); + } + return fpr; + } + + private Path createEligibilityFpr() throws Exception { + Path fpr = tempDir.resolve("dast-eligibility.fpr"); + try (FileSystem zip = FileSystems.newFileSystem(fpr, Map.of("create", "true"))) { + Files.writeString(zip.getPath("/webinspect.xml"), """ + https://example.test + Suppressed4 + Aviator status4 + Legacy Aviator outcome4 + Human audited4 + Eligible4 + + """, StandardCharsets.UTF_8); + Files.writeString(zip.getPath("/audit.xml"), """ + + + + PROCESSED_BY_AVIATOR + + + Not an Issue + + + Exploitable + + + """, StandardCharsets.UTF_8); + } + return fpr; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/IssueAuditorTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/IssueAuditorTest.java index 145dc6bddfd..cf6184971c3 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/IssueAuditorTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/IssueAuditorTest.java @@ -229,6 +229,24 @@ void forceReauditIncludesLegacyFortifyAviatorUsername() throws Exception { assertEquals(List.of(TEST_ISSUE_ID), prepareIssueIds(auditor)); } + @Test + void forceReauditIncludesLegacyAnalysisTagWrittenByAviatorWithoutStatusTag() throws Exception { + IssueAuditor auditor = createIssueAuditor(true, false, + Map.of(Constants.ANALYSIS_TAG_ID, Constants.EXPLOITABLE), + Map.of(Constants.ANALYSIS_TAG_ID, Constants.USER_NAME_LEGACY_FORTIFY_AVIATOR)); + + assertEquals(List.of(TEST_ISSUE_ID), prepareIssueIds(auditor)); + } + + @Test + void forceReauditSkipsLegacyAnalysisTagWrittenByHumanWithoutStatusTag() throws Exception { + IssueAuditor auditor = createIssueAuditor(true, false, + Map.of(Constants.ANALYSIS_TAG_ID, Constants.EXPLOITABLE), + Map.of(Constants.ANALYSIS_TAG_ID, "analyst.user")); + + assertTrue(prepareIssueIds(auditor).isEmpty()); + } + @Test void forceReauditIncludesMappedTagWrittenByAviatorWithoutStatusTag() throws Exception { Path mappingFile = writeMappedTagFile(); diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/config/TagMappingConfigTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/config/TagMappingConfigTest.java index 43aeb69ab9b..f68cd65f334 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/config/TagMappingConfigTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/config/TagMappingConfigTest.java @@ -21,6 +21,7 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Set; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -135,6 +136,100 @@ void testLoadYamlFileBindsSuppressionExclusionsAcrossEntries() throws Exception assertTrue(config.isSuppressionExcluded(new TagMappingConfig.SuppressionExclusionContext("privacy violation"))); } + @Test + void testCombinedYamlResolvesProductOverridesAndSharedDefaults() throws Exception { + Path yamlFile = tempDir.resolve("combined-tag-mapping.yaml"); + Files.writeString(yamlFile, """ + tag_id: "shared-tag" + mapping: + tier_1: + fp: { value: "Shared FP", suppress: true } + tp: { value: "Shared TP", suppress: false } + unsure: { suppress: false } + tier_2: + fp: { value: "Shared FP", suppress: false } + tp: { value: "Shared TP", suppress: false } + unsure: { suppress: false } + sast: + suppression_exclusions: + - categories: ["Privacy Violation"] + dast: + tag_id: "dast-tag" + mapping: + tier_1: + fp: { value: "DAST FP", suppress: true } + tp: { value: "DAST TP", suppress: false } + unsure: { suppress: false } + tier_2: + fp: { value: "DAST FP", suppress: false } + tp: { value: "DAST TP", suppress: false } + unsure: { suppress: false } + """); + + TagMappingConfig config = ResourceUtil.loadYamlFile(yamlFile.toFile(), TagMappingConfig.class); + TagMappingConfig sastConfig = config.resolveForSast(); + TagMappingConfig dastConfig = config.resolveForDast(); + + assertEquals("shared-tag", sastConfig.getTag_id()); + assertEquals(Set.of("Shared FP", "Shared TP"), sastConfig.getMappedValues()); + assertTrue(sastConfig.isSuppressionExcluded( + new TagMappingConfig.SuppressionExclusionContext("Privacy Violation"))); + assertEquals("dast-tag", dastConfig.getTag_id()); + assertEquals(Set.of("DAST FP", "DAST TP"), dastConfig.getMappedValues()); + assertFalse(dastConfig.hasSuppressionExclusions()); + } + + @Test + void testLegacyFlatConfigResolvesForBothProducts() { + TagMappingConfig config = createValidConfig(); + + assertEquals(config.getMapping(), config.resolveForSast().getMapping()); + assertEquals(config.getMapping(), config.resolveForDast().getMapping()); + } + + @Test + void testNullSuppressionExclusionsResolveAsEmpty() throws Exception { + Path yamlFile = tempDir.resolve("null-exclusions-tag-mapping.yaml"); + Files.writeString(yamlFile, """ + suppression_exclusions: null + mapping: + tier_1: + fp: { suppress: true } + tp: { suppress: false } + unsure: { suppress: false } + tier_2: + fp: { suppress: false } + tp: { suppress: false } + unsure: { suppress: false } + """); + + TagMappingConfig config = ResourceUtil.loadYamlFile(yamlFile.toFile(), TagMappingConfig.class); + + assertFalse(config.resolveForSast().hasSuppressionExclusions()); + assertFalse(config.resolveForDast().hasSuppressionExclusions()); + } + + @Test + void testResolvesResultsAndMappedValues() { + TagMappingConfig config = createValidConfig(); + + assertTrue(config.getResult(true, TagMappingConfig.ResultType.FP).getSuppress()); + assertFalse(config.getResult(false, TagMappingConfig.ResultType.FP).getSuppress()); + assertEquals(Set.of("Not an Issue", "Exploitable"), config.getMappedValues()); + } + + @Test + void testDastValidationRejectsSuppressionExclusions() { + TagMappingConfig config = createValidConfig(); + config.setSuppression_exclusions(new ArrayList<>(List.of(createSuppressionExclusion("Privacy Violation")))); + + AviatorSimpleException exception = assertThrows(AviatorSimpleException.class, config::validateForDast); + + assertEquals( + "Invalid DAST tag mapping configuration: suppression_exclusions are not supported", + exception.getMessage()); + } + private TagMappingConfig createValidConfig() { TagMappingConfig config = new TagMappingConfig(); TagMappingConfig.Mapping mapping = new TagMappingConfig.Mapping(); diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/dast/StreamingWebInspectParserTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/dast/StreamingWebInspectParserTest.java new file mode 100644 index 00000000000..59a08131021 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/dast/StreamingWebInspectParserTest.java @@ -0,0 +1,122 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.dast; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; +import java.util.Map; + +import javax.xml.stream.XMLStreamException; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; +import com.fortify.cli.aviator.util.FprHandle; + +class StreamingWebInspectParserTest { + @TempDir + Path tempDir; + + @Test + void parseSessionsPreservesCompleteAuditContext() throws Exception { + Path fpr = createFpr(); + + try (FprHandle handle = new FprHandle(fpr)) { + var sessions = new StreamingWebInspectParser(handle).parseSessions(); + + assertEquals(1, sessions.size()); + DastSession session = sessions.get(0); + assertEquals("POST /login HTTP/1.1", session.getRawRequest()); + assertEquals("HTTP/1.1 200 OK", session.getRawResponse()); + assertEquals("parameter=username", session.getAttackParamDescriptor()); + + DastIssue issue = session.getIssues().get(0); + assertEquals("Injection", issue.getCategory()); + assertEquals("Improper Neutralization", issue.getCweDescription()); + assertEquals("Summary text", issue.getSummary()); + assertEquals("Fix text", issue.getFix()); + assertEquals(2, issue.getReproSteps().size()); + assertEquals("Macro", issue.getReproSteps().get(0).getSource()); + assertEquals("Attack", issue.getReproSteps().get(1).getSource()); + assertEquals("username=test%27", issue.getReproSteps().get(1).getPostParams()); + assertEquals(issue.getReproSteps().stream().map(DastReproStep::getUrl).toList(), issue.getReproStepUrls()); + } + } + + @Test + void domAndStreamingParsersDoNotResolveExternalEntities() throws Exception { + Path secret = tempDir.resolve("secret.txt"); + Files.writeString(secret, "PRIVATE-SENTINEL"); + Path fpr = createFpr(); + + try (FprHandle handle = new FprHandle(fpr)) { + String maliciousXml = webInspectXml() + .replace("", "]>") + .replace("https://example.test/login", "&xxe;"); + Files.writeString(handle.getPath("/webinspect.xml"), maliciousXml); + + assertNotEquals("PRIVATE-SENTINEL", new WebInspectParser(handle).parseSessions().get(0).getUrl()); + AviatorTechnicalException exception = assertThrows(AviatorTechnicalException.class, + () -> new StreamingWebInspectParser(handle).parseSessions()); + assertInstanceOf(XMLStreamException.class, exception.getCause()); + } + } + + private Path createFpr() throws Exception { + Path fpr = tempDir.resolve("dast.fpr"); + try (FileSystem zip = FileSystems.newFileSystem(fpr, Map.of("create", "true"))) { + Files.writeString(zip.getPath("/webinspect.xml"), webInspectXml(), StandardCharsets.UTF_8); + } + return fpr; + } + + private String webInspectXml() { + String request = Base64.getEncoder().encodeToString("POST /login HTTP/1.1".getBytes(StandardCharsets.UTF_8)); + String response = Base64.getEncoder().encodeToString("HTTP/1.1 200 OK".getBytes(StandardCharsets.UTF_8)); + return """ + + + https://example.test/login + httpsexample.test443 + parameter=username + %s%s + + 1001WebInspect + WI-10014SQL Injection + + Injection + Improper Neutralization + + + Macrohttps://example.test/login + Attackhttps://example.test/login?user=test%%27 + username=test%%27 + + SummarySummary text

    ]]>
    + FixFix text

    ]]>
    +
    +
    +
    + """.formatted(request, response); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/diagnose/AviatorConnectionDiagnosticsTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/diagnose/AviatorConnectionDiagnosticsTest.java new file mode 100644 index 00000000000..50fb7bdf946 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/diagnose/AviatorConnectionDiagnosticsTest.java @@ -0,0 +1,265 @@ +/* + * 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.diagnose; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.UnknownHostException; + +import javax.net.ssl.SSLHandshakeException; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator._common.exception.UnsupportedAviatorUrlSchemeException; +import com.fortify.cli.aviator.diagnose.support.ConfigurableDiagnosticProbe; +import com.fortify.cli.aviator.diagnose.support.OfflineConnectionPlan; + +class AviatorConnectionDiagnosticsTest { + @Test + void shouldSkipDependentStagesWhenEndpointIsInvalid() { + var probe = new ConfigurableDiagnosticProbe(); + var report = new AviatorConnectionDiagnostics(probe).diagnose(" ", 5, "url"); + var results = report.stages(); + + assertEquals(5, results.size()); + assertStage(results.get(0), AviatorDiagnosticStage.ENDPOINT, AviatorDiagnosticStatus.FAIL); + assertEquals("Endpoint is invalid", results.get(0).summary()); + assertStage(results.get(1), AviatorDiagnosticStage.DNS, AviatorDiagnosticStatus.WARN); + assertTrue(results.get(1).summary().contains("endpoint validation failed")); + assertStage(results.get(2), AviatorDiagnosticStage.TCP, AviatorDiagnosticStatus.WARN); + assertStage(results.get(3), AviatorDiagnosticStage.TLS, AviatorDiagnosticStatus.WARN); + assertStage(results.get(4), AviatorDiagnosticStage.GRPC, AviatorDiagnosticStatus.WARN); + assertTrue(report.hasRequiredFailure()); + assertFalse(probe.tunnelCalled); + } + + @Test + void shouldFailEndpointForUnsupportedUrlScheme() { + var probe = new ConfigurableDiagnosticProbe(); + var report = new AviatorConnectionDiagnostics(probe).diagnose("mttp://vacant", 5, "url"); + var results = report.stages(); + + assertEquals(5, results.size()); + assertStage(results.get(0), AviatorDiagnosticStage.ENDPOINT, AviatorDiagnosticStatus.FAIL); + assertEquals(UnsupportedAviatorUrlSchemeException.STAGE_SUMMARY, results.get(0).summary()); + assertEquals(UnsupportedAviatorUrlSchemeException.STAGE_GUIDANCE, results.get(0).guidance()); + assertEquals("mttp", results.get(0).evidence().path("scheme").asText()); + assertEquals("mttp://vacant", results.get(0).evidence().path("providedUrl").asText()); + assertStage(results.get(1), AviatorDiagnosticStage.DNS, AviatorDiagnosticStatus.WARN); + assertEquals("Skipped because endpoint validation failed", results.get(1).summary()); + assertStage(results.get(2), AviatorDiagnosticStage.TCP, AviatorDiagnosticStatus.WARN); + assertStage(results.get(3), AviatorDiagnosticStage.TLS, AviatorDiagnosticStatus.WARN); + assertStage(results.get(4), AviatorDiagnosticStage.GRPC, AviatorDiagnosticStatus.WARN); + assertTrue(report.hasRequiredFailure()); + assertFalse(probe.tunnelCalled); + assertFalse(probe.grpcCalled); + } + + @Test + void shouldFailEndpointForFtpSchemeEvenWithValidLookingHost() { + var probe = new ConfigurableDiagnosticProbe(); + var report = new AviatorConnectionDiagnostics(probe) + .diagnose("ftp://aviator-qa01.example.com", 5, "url"); + + assertStage(report.stages().get(0), AviatorDiagnosticStage.ENDPOINT, AviatorDiagnosticStatus.FAIL); + assertEquals(UnsupportedAviatorUrlSchemeException.STAGE_SUMMARY, report.stages().get(0).summary()); + assertEquals("ftp", report.stages().get(0).evidence().path("scheme").asText()); + assertTrue(report.hasRequiredFailure()); + assertFalse(probe.tunnelCalled); + } + + @Test + void shouldFailEndpointForHttpScheme() { + var probe = new ConfigurableDiagnosticProbe(); + var report = new AviatorConnectionDiagnostics(probe).diagnose("http://aviator.invalid", 5, "url"); + + assertStage(report.stages().get(0), AviatorDiagnosticStage.ENDPOINT, AviatorDiagnosticStatus.FAIL); + assertEquals(UnsupportedAviatorUrlSchemeException.STAGE_SUMMARY, report.stages().get(0).summary()); + assertEquals("http", report.stages().get(0).evidence().path("scheme").asText()); + assertFalse(probe.tunnelCalled); + } + + @Test + void shouldSkipTransportStagesWhenDnsFails() { + var probe = new ConfigurableDiagnosticProbe(); + probe.resolveException = new UnknownHostException("host not found"); + var report = new AviatorConnectionDiagnostics(probe).diagnose(OfflineConnectionPlan.noProxy(), 5, "url"); + var results = report.stages(); + + assertEquals(5, results.size()); + assertStage(results.get(1), AviatorDiagnosticStage.DNS, AviatorDiagnosticStatus.FAIL); + assertStage(results.get(2), AviatorDiagnosticStage.TCP, AviatorDiagnosticStatus.WARN); + assertStage(results.get(3), AviatorDiagnosticStage.TLS, AviatorDiagnosticStatus.WARN); + assertStage(results.get(4), AviatorDiagnosticStage.GRPC, AviatorDiagnosticStatus.WARN); + assertFalse(probe.tunnelCalled); + assertFalse(probe.grpcCalled); + } + + @Test + void shouldReportGrpcNoResponsePattern() { + var probe = new ConfigurableDiagnosticProbe( + AviatorGrpcReachabilityResult.noResponse("DEADLINE_EXCEEDED", "deadline exceeded")); + var report = new AviatorConnectionDiagnostics(probe).diagnose(OfflineConnectionPlan.noProxy(), 5, "url"); + var results = report.stages(); + + assertStage(results.get(3), AviatorDiagnosticStage.TLS, AviatorDiagnosticStatus.PASS); + assertStage(results.get(4), AviatorDiagnosticStage.GRPC, AviatorDiagnosticStatus.FAIL); + assertEquals(AviatorGrpcPattern.GRPC_NO_RESPONSE.wireId(), + results.get(4).evidence().path("pattern").asText()); + assertFalse(report.hasGrpcStagePass()); + } + + @Test + void shouldContinueAfterTlsAlpnWarningWhenGrpcResponds() { + var probe = new ConfigurableDiagnosticProbe( + AviatorGrpcReachabilityResult.responseReceived("UNAUTHENTICATED", "token required")); + probe.tunnelResult = new AviatorTunnelResult.TlsSucceeded(false, "not-used", + "TLSv1.3", "TLS_AES_128_GCM_SHA256", "CN=aviator.invalid", ""); + var report = new AviatorConnectionDiagnostics(probe).diagnose(OfflineConnectionPlan.noProxy(), 5, "url"); + var results = report.stages(); + + assertStage(results.get(3), AviatorDiagnosticStage.TLS, AviatorDiagnosticStatus.WARN); + assertStage(results.get(4), AviatorDiagnosticStage.GRPC, AviatorDiagnosticStatus.PASS); + assertTrue(report.hasGrpcStagePass()); + } + + @Test + void shouldReportNonGrpcHttpResponsePattern() { + var probe = new ConfigurableDiagnosticProbe(AviatorGrpcReachabilityResult.nonGrpcHttp( + "UNAVAILABLE", "503", "text/html", "HTTP status code 503 invalid content-type: text/html")); + var results = new AviatorConnectionDiagnostics(probe) + .diagnose(OfflineConnectionPlan.noProxy(), 5, "url").stages(); + + assertEquals(AviatorGrpcPattern.HTTP_RESPONSE_NOT_GRPC.wireId(), + results.get(4).evidence().path("pattern").asText()); + assertEquals("503", results.get(4).evidence().path("httpStatusCode").asText()); + } + + @Test + void shouldPassAllTransportStagesWithoutProxy() { + var probe = new ConfigurableDiagnosticProbe(); + var report = new AviatorConnectionDiagnostics(probe).diagnose(OfflineConnectionPlan.noProxy(), 5, "url"); + + assertEquals(5, report.stages().size()); + assertStage(report.stages().get(0), AviatorDiagnosticStage.ENDPOINT, AviatorDiagnosticStatus.PASS); + assertStage(report.stages().get(4), AviatorDiagnosticStage.GRPC, AviatorDiagnosticStatus.PASS); + assertTrue(probe.tunnelCalled); + assertFalse(report.hasRequiredFailure()); + } + + @Test + void shouldSkipTlsAndGrpcWhenTcpFails() { + var probe = new ConfigurableDiagnosticProbe(); + probe.connectException = new IOException("Connection refused"); + var results = new AviatorConnectionDiagnostics(probe) + .diagnose(OfflineConnectionPlan.noProxy("127.0.0.1", 1), 3, "url").stages(); + + assertStage(results.get(2), AviatorDiagnosticStage.TCP, AviatorDiagnosticStatus.FAIL); + assertEquals("aviator", results.get(2).evidence().path("nextHopType").asText()); + assertStage(results.get(3), AviatorDiagnosticStage.TLS, AviatorDiagnosticStatus.WARN); + assertStage(results.get(4), AviatorDiagnosticStage.GRPC, AviatorDiagnosticStatus.WARN); + assertFalse(probe.tunnelCalled); + } + + @Test + void shouldSkipGrpcWhenTlsFails() { + var probe = new ConfigurableDiagnosticProbe(); + probe.tunnelResult = new AviatorTunnelResult.TlsFailed(false, "not-used", AviatorTlsPhase.HANDSHAKE, + new SSLHandshakeException("PKIX path building failed")); + var results = new AviatorConnectionDiagnostics(probe) + .diagnose(OfflineConnectionPlan.noProxy(), 5, "url").stages(); + + assertStage(results.get(3), AviatorDiagnosticStage.TLS, AviatorDiagnosticStatus.FAIL); + assertEquals(AviatorTlsPhase.HANDSHAKE.id(), results.get(3).evidence().path("tlsPhase").asText()); + assertStage(results.get(4), AviatorDiagnosticStage.GRPC, AviatorDiagnosticStatus.WARN); + assertFalse(probe.grpcCalled); + } + + @Test + void shouldLabelConnectPhaseWhenOpenFails() { + var probe = new ConfigurableDiagnosticProbe(); + probe.tunnelResult = new AviatorTunnelResult.TlsFailed(false, "not-used", AviatorTlsPhase.CONNECT, + new IOException("Connection refused")); + var results = new AviatorConnectionDiagnostics(probe) + .diagnose(OfflineConnectionPlan.noProxy(), 5, "url").stages(); + + assertEquals(AviatorTlsPhase.CONNECT.id(), results.get(3).evidence().path("tlsPhase").asText()); + assertTrue(results.get(3).summary().toLowerCase().contains("connection")); + } + + @Test + void shouldIncludeProxyStageFromSingleTunnelSession() { + var probe = new ConfigurableDiagnosticProbe(); + probe.tunnelResult = new AviatorTunnelResult.TlsSucceeded(true, "HTTP/1.1 200 Connection established", + "TLSv1.3", "TLS_AES_128_GCM_SHA256", "CN=aviator.invalid", "h2"); + var results = new AviatorConnectionDiagnostics(probe) + .diagnose(OfflineConnectionPlan.withProxy(), 5, "url").stages(); + + assertEquals(6, results.size()); + assertStage(results.get(2), AviatorDiagnosticStage.TCP, AviatorDiagnosticStatus.PASS); + assertEquals("proxy", results.get(2).evidence().path("nextHopType").asText()); + assertStage(results.get(3), AviatorDiagnosticStage.PROXY, AviatorDiagnosticStatus.PASS); + assertStage(results.get(4), AviatorDiagnosticStage.TLS, AviatorDiagnosticStatus.PASS); + assertStage(results.get(5), AviatorDiagnosticStage.GRPC, AviatorDiagnosticStatus.PASS); + assertEquals(1, probe.tunnelCallCount); + } + + @Test + void shouldSkipTlsAndGrpcWhenProxyConnectFailsInTunnel() { + var probe = new ConfigurableDiagnosticProbe(); + probe.tunnelResult = new AviatorTunnelResult.ProxyConnectFailed( + new AviatorProxyConnectException("Proxy CONNECT failed: HTTP/1.1 403")); + var results = new AviatorConnectionDiagnostics(probe) + .diagnose(OfflineConnectionPlan.withProxy(), 5, "url").stages(); + + assertEquals(6, results.size()); + assertStage(results.get(3), AviatorDiagnosticStage.PROXY, AviatorDiagnosticStatus.FAIL); + assertStage(results.get(4), AviatorDiagnosticStage.TLS, AviatorDiagnosticStatus.WARN); + assertStage(results.get(5), AviatorDiagnosticStage.GRPC, AviatorDiagnosticStatus.WARN); + assertFalse(probe.grpcCalled); + assertEquals(1, probe.tunnelCallCount); + } + + @Test + void shouldSkipProxyWhenTcpToProxyFails() { + var probe = new ConfigurableDiagnosticProbe(); + probe.connectException = new IOException("Connection refused"); + var results = new AviatorConnectionDiagnostics(probe) + .diagnose(OfflineConnectionPlan.withProxy(), 3, "url").stages(); + + assertEquals(6, results.size()); + assertStage(results.get(2), AviatorDiagnosticStage.TCP, AviatorDiagnosticStatus.FAIL); + assertEquals("proxy", results.get(2).evidence().path("nextHopType").asText()); + assertStage(results.get(3), AviatorDiagnosticStage.PROXY, AviatorDiagnosticStatus.WARN); + assertFalse(probe.tunnelCalled); + } + + @Test + void shouldTreatApplicationGrpcErrorAsResponseReceived() { + var probe = new ConfigurableDiagnosticProbe( + AviatorGrpcReachabilityResult.responseReceived("INVALID_ARGUMENT", "invalid argument")); + var report = new AviatorConnectionDiagnostics(probe).diagnose(OfflineConnectionPlan.noProxy(), 5, "url"); + + assertStage(report.stages().get(4), AviatorDiagnosticStage.GRPC, AviatorDiagnosticStatus.PASS); + assertTrue(report.hasGrpcStagePass()); + } + + private static void assertStage(AviatorDiagnosticStageResult result, AviatorDiagnosticStage stage, + AviatorDiagnosticStatus status) { + assertTrue(result.isStage(stage), "expected stage " + stage.id() + " but was " + result.stage()); + assertEquals(status, result.status()); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/diagnose/AviatorDefaultDiagnosticProbeConnectTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/diagnose/AviatorDefaultDiagnosticProbeConnectTest.java new file mode 100644 index 00000000000..64a3aed811a --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/diagnose/AviatorDefaultDiagnosticProbeConnectTest.java @@ -0,0 +1,64 @@ +/* + * 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.diagnose; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +class AviatorDefaultDiagnosticProbeConnectTest { + @Test + void shouldReadOnlyStatusLineFromConnectHeaders() throws IOException { + var payload = "HTTP/1.1 200 Connection established\r\nProxy-Agent: test\r\n\r\nEXTRA"; + var status = AviatorDefaultDiagnosticProbe.readHttpHeaders( + new ByteArrayInputStream(payload.getBytes(StandardCharsets.ISO_8859_1))); + + assertEquals("HTTP/1.1 200 Connection established", status); + } + + @Test + void shouldPreserveBytesAfterHeadersForTls() throws IOException { + var payload = "HTTP/1.1 200 Connection established\r\n\r\nTLS-BYTES"; + var stream = new ByteArrayInputStream(payload.getBytes(StandardCharsets.ISO_8859_1)); + AviatorDefaultDiagnosticProbe.readHttpHeaders(stream); + + var remaining = new String(stream.readAllBytes(), StandardCharsets.ISO_8859_1); + assertEquals("TLS-BYTES", remaining); + } + + @Test + void shouldRejectNonHttpConnectStatusLine() { + assertThrows(AviatorProxyConnectException.class, + () -> AviatorDefaultDiagnosticProbe.validateProxyConnectStatusLine("Not an HTTP response")); + assertThrows(AviatorProxyConnectException.class, + () -> AviatorDefaultDiagnosticProbe.validateProxyConnectStatusLine(null)); + } + + @Test + void shouldRejectNon2xxConnectStatusLine() { + assertThrows(AviatorProxyConnectException.class, + () -> AviatorDefaultDiagnosticProbe.validateProxyConnectStatusLine( + "HTTP/1.1 407 Proxy Authentication Required")); + } + + @Test + void shouldAccept2xxConnectStatusLine() throws AviatorProxyConnectException { + AviatorDefaultDiagnosticProbe.validateProxyConnectStatusLine("HTTP/1.1 200 Connection established"); + AviatorDefaultDiagnosticProbe.validateProxyConnectStatusLine("HTTP/1.0 200 OK"); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/diagnose/AviatorGrpcReachabilityResultTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/diagnose/AviatorGrpcReachabilityResultTest.java new file mode 100644 index 00000000000..41a8b6b8b36 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/diagnose/AviatorGrpcReachabilityResultTest.java @@ -0,0 +1,89 @@ +/* + * 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.diagnose; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import javax.net.ssl.SSLHandshakeException; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.common.json.JsonHelper; + +/** + * Focused on non-obvious sealed-result behavior: pattern choice + evidence shape on + * exception paths. Pipeline wiring of patterns is covered by {@link AviatorConnectionDiagnosticsTest}. + */ +class AviatorGrpcReachabilityResultTest { + @Test + void putEvidenceShapesAndPatternsForStatusVariants() { + var response = JsonHelper.getObjectMapper().createObjectNode(); + var ok = AviatorGrpcReachabilityResult.responseReceived("OK", "ok"); + assertTrue(ok.stagePass()); + assertNull(ok.pattern()); + ok.putEvidence(response); + assertTrue(response.path("grpcResponseReceived").asBoolean()); + assertEquals("OK", response.path("grpcStatusCode").asText()); + assertFalse(response.path("httpResponseReceived").asBoolean()); + + var http = JsonHelper.getObjectMapper().createObjectNode(); + var nonGrpc = AviatorGrpcReachabilityResult.nonGrpcHttp("UNAVAILABLE", "503", "text/html", "page"); + assertEquals(AviatorGrpcPattern.HTTP_RESPONSE_NOT_GRPC, nonGrpc.pattern()); + nonGrpc.putEvidence(http); + assertFalse(http.path("grpcResponseReceived").asBoolean()); + assertTrue(http.path("httpResponseReceived").asBoolean()); + assertEquals("503", http.path("httpStatusCode").asText()); + + var tls = JsonHelper.getObjectMapper().createObjectNode(); + var tlsFailed = AviatorGrpcReachabilityResult.tlsFailed("UNAVAILABLE", "ssl"); + assertEquals(AviatorGrpcPattern.GRPC_TLS_FAILED, tlsFailed.pattern()); + tlsFailed.putEvidence(tls); + assertFalse(tls.has("exceptionType")); + + var noResponse = JsonHelper.getObjectMapper().createObjectNode(); + var none = AviatorGrpcReachabilityResult.noResponse("DEADLINE_EXCEEDED", "deadline"); + assertEquals(AviatorGrpcPattern.GRPC_NO_RESPONSE, none.pattern()); + none.putEvidence(noResponse); + assertEquals("DEADLINE_EXCEEDED", noResponse.path("grpcStatusCode").asText()); + } + + @Test + void probeErrorTlsKeepsExceptionCauseEvidence() { + var wrapped = new Exception("io exception", new SSLHandshakeException("PKIX path building failed")); + var result = AviatorGrpcReachabilityResult.probeError(wrapped); + + assertEquals(AviatorGrpcPattern.GRPC_TLS_FAILED, result.pattern()); + assertEquals("gRPC TLS handshake failed", result.stageSummary()); + + var evidence = JsonHelper.getObjectMapper().createObjectNode(); + result.putEvidence(evidence); + assertEquals("java.lang.Exception", evidence.path("exceptionType").asText()); + assertEquals(SSLHandshakeException.class.getName(), evidence.path("causeType").asText()); + assertFalse(evidence.path("grpcResponseReceived").asBoolean()); + assertEquals("EXCEPTION", evidence.path("grpcStatusCode").asText()); + } + + @Test + void probeErrorGenericKeepsExceptionEvidence() { + var result = AviatorGrpcReachabilityResult.probeError(new RuntimeException("boom")); + + assertEquals(AviatorGrpcPattern.GRPC_PROBE_ERROR, result.pattern()); + var evidence = JsonHelper.getObjectMapper().createObjectNode(); + result.putEvidence(evidence); + assertEquals("java.lang.RuntimeException", evidence.path("exceptionType").asText()); + assertEquals("boom", evidence.path("exceptionMessage").asText()); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/model/FPRInfoTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/model/FPRInfoTest.java index 0c08d872306..0c00ae9271f 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/model/FPRInfoTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/model/FPRInfoTest.java @@ -12,6 +12,8 @@ */ package com.fortify.cli.aviator.fpr.model; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -22,6 +24,8 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; +import javax.xml.stream.XMLStreamException; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -46,6 +50,38 @@ void throwsTechnicalExceptionWhenAuditFvdlIsMissing() throws Exception { } } + @Test + void allowsHarmlessDoctypeInAuditFvdl() throws Exception { + Path fprPath = createFpr(""" + + safe-uuidsafe-build + 712 + """); + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + var metadata = new FPRInfo(fprHandle); + assertEquals("safe-uuid", metadata.getUuid()); + assertEquals("safe-build", metadata.getBuildId()); + assertEquals(7, metadata.getNumberOfFiles()); + assertEquals(12, metadata.getScanTime()); + } + } + + @Test + void doesNotResolveExternalEntityInAuditFvdl() throws Exception { + Path secret = tempDir.resolve("secret.txt"); + Files.writeString(secret, "PRIVATE-SENTINEL"); + Path fprPath = createFpr(""" + ]> + &xxe; + """.formatted(secret.toUri())); + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + AviatorTechnicalException exception = assertThrows(AviatorTechnicalException.class, () -> new FPRInfo(fprHandle)); + assertInstanceOf(XMLStreamException.class, exception.getCause()); + } + } + private Path createFpr(String auditFvdlContent) throws IOException { Path fprPath = tempDir.resolve("test.fpr"); try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(fprPath))) { diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/AuditProcessorAuditIsolationTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/AuditProcessorAuditIsolationTest.java index 21f1bf0a985..5f3137c29bd 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/AuditProcessorAuditIsolationTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/AuditProcessorAuditIsolationTest.java @@ -89,6 +89,25 @@ void testPrunesUntouchedIssuesAndKeepsAuditedIssueState() throws Exception { assertTrue(hasTagHistory(audited)); } + @Test + void doesNotResolveExternalEntityInAuditXml() throws Exception { + Path secret = Files.createTempFile("audit-xxe-sentinel", ".txt"); + try { + Files.writeString(secret, "PRIVATE-SENTINEL"); + createTestFpr(""" + ]> + + &xxe; + + """.formatted(secret.toUri())); + + var issue = new AuditProcessor(fprHandle).processAuditXML().get("issue-1"); + assertEquals("", issue.getThreadedComments().get(0).getContent()); + } finally { + Files.deleteIfExists(secret); + } + } + @Test void testPartialSuccessRetainsOnlySuccessfulIssue() throws Exception { createTestFpr(multiIssueAuditXml("instance-A", "instance-D")); diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/FilterTemplateParserTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/FilterTemplateParserTest.java index c608f289ead..038892f5e24 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/FilterTemplateParserTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/FilterTemplateParserTest.java @@ -12,6 +12,7 @@ */ package com.fortify.cli.aviator.fpr.processor; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -47,6 +48,41 @@ void throwsTechnicalExceptionOnMalformedFilterTemplateXml() throws Exception { } } + @Test + void allowsHarmlessDoctypeWithoutLoadingExternalDtd() throws Exception { + String xml = """ + + Safe template + """; + Path fprPath = createFpr(xml); + try (FprHandle fprHandle = new FprHandle(fprPath)) { + var parser = new FilterTemplateParser(fprHandle, new AuditProcessor(fprHandle)); + var template = parser.parseFilterTemplate().orElseThrow(); + assertEquals("Safe template", template.getName()); + assertEquals("template", template.getId()); + assertTrue(!template.getTagDefinitions().isEmpty()); + assertEquals(xml, Files.readString(fprHandle.getPath("/filtertemplate.xml"))); + } + } + + @Test + void doesNotDiscloseExternalEntityInFilterTemplate() throws Exception { + Path secret = tempDir.resolve("secret.txt"); + Files.writeString(secret, "PRIVATE-SENTINEL"); + String xml = """ + ]> + &xxe; + """.formatted(secret.toUri()); + Path fprPath = createFpr(xml); + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + FilterTemplateParser parser = new FilterTemplateParser(fprHandle, new AuditProcessor(fprHandle)); + var template = parser.parseFilterTemplate().orElseThrow(); + assertEquals("", template.getName()); + assertEquals(xml, Files.readString(fprHandle.getPath("/filtertemplate.xml"))); + } + } + private Path createFpr(String filterTemplateXml) throws IOException { Path fprPath = tempDir.resolve("test.fpr"); try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(fprPath))) { diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorEdgeCasesTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorEdgeCasesTest.java new file mode 100644 index 00000000000..0b9e6c2c916 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorEdgeCasesTest.java @@ -0,0 +1,130 @@ +/* + * 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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator.fpr.remediation.RemediationExecutionMode; +import com.fortify.cli.aviator.fpr.remediation.model.RemediationMetric; + +/** + * Tests for edge cases in RemediationProcessor and RemediationMetric. + */ +class RemediationProcessorEdgeCasesTest { + + @Test + void emptyRemediationsReturnsEmptyPreviewDetails() { + RemediationMetric metric = RemediationMetric.builder() + .executionMode(RemediationExecutionMode.PREVIEW) + .build(); + + assertNotNull(metric); + assertEquals(0, metric.totalRemediations()); + assertEquals(0, metric.appliedRemediations()); + assertTrue(metric.isPreview()); + assertEquals(0, metric.previewDetails().size()); + } + + @Test + void nonExistentIssueIdIsTrackedAsRequested() { + Set requestedIds = Set.of("ISSUE-1", "NONEXISTENT-123"); + Set appliedIds = Set.of("ISSUE-1"); + + RemediationMetric metric = filteredMetric(requestedIds, appliedIds, Set.of("file.java")); + + assertNotNull(metric); + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(1, metric.skippedRemediations()); + assertTrue(metric.requestedIssueIds().contains("NONEXISTENT-123")); + } + + @Test + void filteredMetricWithAllIdsAppliedHasNoSkips() { + Set requestedIds = Set.of("ISSUE-1", "ISSUE-2"); + Set appliedIds = Set.of("ISSUE-1", "ISSUE-2"); + + RemediationMetric metric = filteredMetric(requestedIds, appliedIds, Set.of("file.java")); + + assertEquals(2, metric.totalRemediations()); + assertEquals(2, metric.appliedRemediations()); + assertEquals(0, metric.skippedRemediations()); + } + + @Test + void unfilteredMetricWithNoRemediationsHasZeroTotals() { + RemediationMetric metric = unfilteredMetric(0, 0, Set.of()); + + assertEquals(0, metric.totalRemediations()); + assertEquals(0, metric.appliedRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals(0, metric.modifiedFiles().size()); + } + + @Test + void filteredModeDoesNotIncludeUnfilteredFields() { + Set requestedIds = Set.of("ISSUE-1"); + Set appliedIds = Set.of("ISSUE-1"); + + RemediationMetric metric = filteredMetric(requestedIds, appliedIds, Set.of()); + + assertTrue(metric.isFiltered()); + assertNotNull(metric.requestedIssueIds()); + assertNotNull(metric.appliedIssueIds()); + } + + @Test + void unfilteredModeHasEmptyIssueIdSets() { + RemediationMetric metric = unfilteredMetric(5, 3, Set.of("file.java")); + + assertEquals(false, metric.isFiltered()); + assertEquals(0, metric.requestedIssueIds().size()); + assertEquals(0, metric.appliedIssueIds().size()); + } + + @Test + void unfilteredMetricIsApplyModeWithNoPreviewData() { + RemediationMetric metric = unfilteredMetric(5, 3, Set.of()); + + assertEquals(RemediationExecutionMode.APPLY, metric.executionMode()); + assertTrue(metric.previewDetails().isEmpty()); + } + + private RemediationMetric filteredMetric(Set requestedIds, Set appliedIds, Set modifiedFiles) { + return RemediationMetric.builder() + .totalRemediations(requestedIds.size()) + .appliedRemediations(appliedIds.size()) + .skippedRemediations(requestedIds.size() - appliedIds.size()) + .requestedIssueIds(requestedIds) + .seenIssueIds(appliedIds) + .satisfiedIssueIds(appliedIds) + .appliedIssueIds(appliedIds) + .modifiedFiles(modifiedFiles) + .build(); + } + + private RemediationMetric unfilteredMetric(int total, int applied, Set modifiedFiles) { + return RemediationMetric.builder() + .totalRemediations(total) + .appliedRemediations(applied) + .skippedRemediations(total - applied) + .modifiedFiles(modifiedFiles) + .build(); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorTest.java index 0d13730ad3a..18424ba44f3 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorTest.java @@ -13,13 +13,21 @@ package com.fortify.cli.aviator.fpr.processor; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.IOException; +import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; import java.security.MessageDigest; import java.util.Base64; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -31,8 +39,12 @@ import org.junit.jupiter.api.condition.OS; import org.junit.jupiter.api.io.TempDir; +import com.fortify.cli.aviator.applyRemediation.ApplyAutoRemediationOnSource; +import com.fortify.cli.aviator.fpr.remediation.RemediationExecutionMode; +import com.fortify.cli.aviator.fpr.remediation.RemediationProcessingOptions; import com.fortify.cli.aviator.fpr.remediation.RemediationProcessor; -import com.fortify.cli.aviator.fpr.remediation.model.*; +import com.fortify.cli.aviator.fpr.remediation.model.RemediationMetric; +import com.fortify.cli.aviator.fpr.utils.SourceDecoders; import com.fortify.cli.aviator.util.FileUtil; import com.fortify.cli.aviator.util.FprHandle; @@ -42,6 +54,207 @@ class RemediationProcessorTest { @TempDir Path tempDir; + @Test + void testIssueIdFilterAppliesOnlyRequestedRemediations() throws Exception { + Path sourceDir = Files.createDirectory(tempDir.resolve("src")); + Path sourceFile = sourceDir.resolve("Example.java"); + String originalContent = String.join("\n", + "class Example {", + " void run() {", + " oldOne();", + " oldTwo();", + " }", + "}", + ""); + Files.writeString(sourceFile, originalContent, StandardCharsets.UTF_8); + + String hash = TestHashUtil.sha256Base64Unix(originalContent); + Path fprPath = createFpr(remediationsXml(hash)); + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + var processor = new RemediationProcessor(fprHandle, sourceDir.toString(), + options(Set.of("ISSUE-2", "ISSUE-404"), RemediationExecutionMode.APPLY)); + var metric = processor.processRemediationXML(); + + assertTrue(metric.isFiltered()); + assertEquals(RemediationExecutionMode.APPLY, metric.executionMode()); + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(1, metric.skippedRemediations()); + assertEquals(Set.of("ISSUE-2"), metric.appliedIssueIds()); + assertEquals(Set.of("Example.java"), metric.modifiedFiles()); + assertEquals(1, metric.skippedByReason().get("Requested issue not found in remediations")); + String updatedContent = Files.readString(sourceFile, StandardCharsets.UTF_8).replace("\r\n", "\n"); + assertTrue(updatedContent.contains(" oldOne();")); + assertTrue(updatedContent.contains(" newTwo();")); + assertFalse(updatedContent.contains(" newOne();")); + } + } + + @Test + void testIssueIdFilterWithNoMatchesCountsRequestedIdsAsSkipped() throws Exception { + Path sourceDir = Files.createDirectory(tempDir.resolve("src-no-match")); + Path sourceFile = sourceDir.resolve("Example.java"); + String originalContent = String.join("\n", + "class Example {", + " void run() {", + " oldOne();", + " }", + "}", + ""); + Files.writeString(sourceFile, originalContent, StandardCharsets.UTF_8); + + String hash = TestHashUtil.sha256Base64Unix(originalContent); + Path fprPath = createFpr(singleRemediationXml(hash)); + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + var processor = new RemediationProcessor(fprHandle, sourceDir.toString(), + options(new LinkedHashSet<>(Set.of("ISSUE-404", "ISSUE-405")), RemediationExecutionMode.APPLY)); + var metric = processor.processRemediationXML(); + + assertTrue(metric.isFiltered()); + assertEquals(2, metric.totalRemediations()); + assertEquals(0, metric.appliedRemediations()); + assertEquals(2, metric.skippedRemediations()); + assertEquals(Set.of(), metric.appliedIssueIds()); + assertEquals(Set.of(), metric.modifiedFiles()); + assertEquals(2, metric.skippedByReason().get("Requested issue not found in remediations")); + assertEquals(originalContent, Files.readString(sourceFile, StandardCharsets.UTF_8)); + } + } + + @Test + void testUnfilteredPathTraversalCandidateIsSkippedWithoutAborting() throws Exception { + Path sourceDir = Files.createDirectory(tempDir.resolve("src-path-traversal")); + Path sourceFile = sourceDir.resolve("Example.java"); + String originalContent = String.join("\n", + "class Example {", + " void run() {", + " oldOne();", + " }", + "}", + ""); + Files.writeString(sourceFile, originalContent, StandardCharsets.UTF_8); + + String hash = TestHashUtil.sha256Base64Unix(originalContent); + Path fprPath = createFpr(pathTraversalAndValidRemediationsXml(hash)); + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + var processor = new RemediationProcessor(fprHandle, sourceDir.toString()); + var metric = processor.processRemediationXML(); + + assertFalse(metric.isFiltered()); + assertEquals(RemediationExecutionMode.APPLY, metric.executionMode()); + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(1, metric.skippedRemediations()); + assertEquals(1, metric.skippedByReason().get("Source file outside source directory")); + String updatedContent = Files.readString(sourceFile, StandardCharsets.UTF_8).replace("\r\n", "\n"); + assertTrue(updatedContent.contains(" newOne();")); + } + } + + @Test + void testLoadsFvdlMetadataFromZipBackedFprPath() throws Exception { + Path sourceDir = Files.createDirectory(tempDir.resolve("src-zip-backed")); + Path sourceFile = sourceDir.resolve("Example.java"); + String originalContent = String.join("\n", + "class Example {", + " void run() {", + " oldOne();", + " }", + "}", + ""); + Files.writeString(sourceFile, originalContent, StandardCharsets.UTF_8); + + Path fprPath = createFpr(singleRemediationXml(TestHashUtil.sha256Base64Unix(originalContent))); + Path cachePath = tempDir.resolve("remediations-cache.zip"); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(cachePath))) { + zipOutputStream.putNextEntry(new ZipEntry("fprs/001.fpr")); + Files.copy(fprPath, zipOutputStream); + zipOutputStream.closeEntry(); + } + + try (FileSystem cacheFileSystem = FileSystems.newFileSystem(cachePath, (ClassLoader) null); + FprHandle fprHandle = new FprHandle(cacheFileSystem.getPath("/fprs/001.fpr"))) { + var metric = new RemediationProcessor(fprHandle, sourceDir.toString()).processRemediationXML(); + + assertEquals(1, metric.appliedRemediations()); + assertEquals(Set.of("Example.java"), metric.modifiedFiles()); + assertTrue(Files.readString(sourceFile, StandardCharsets.UTF_8).contains(" newOne();")); + } + } + + @Test + void testLoadsDeclaredEncodingFromZipBackedFprPath() throws Exception { + Charset sourceCharset = Charset.forName("windows-1252"); + String originalLine = "String price = \"€100\";"; + String replacementLine = "String price = \"EUR100\";"; + String originalContent = originalLine + "\n"; + Path sourceDir = Files.createDirectory(tempDir.resolve("src-zip-encoding")); + Path sourceFile = sourceDir.resolve("Example.java"); + Files.write(sourceFile, originalContent.getBytes(sourceCharset)); + + String remediationsXml = """ + + + + + Example.java + %s + + 1 + 1 + %s + %s + %s + + + + + """.formatted(TestHashUtil.sha256Base64Unix(originalContent), originalLine, originalLine, replacementLine); + Path fprPath = createFpr(remediationsXml, sourceCharset.name()); + Path cachePath = tempDir.resolve("remediations-encoding-cache.zip"); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(cachePath))) { + zipOutputStream.putNextEntry(new ZipEntry("fprs/001.fpr")); + Files.copy(fprPath, zipOutputStream); + zipOutputStream.closeEntry(); + } + + try (FileSystem cacheFileSystem = FileSystems.newFileSystem(cachePath, (ClassLoader) null); + FprHandle fprHandle = new FprHandle(cacheFileSystem.getPath("/fprs/001.fpr"))) { + var metric = new RemediationProcessor(fprHandle, sourceDir.toString()).processRemediationXML(); + + assertEquals(1, metric.appliedRemediations()); + assertEquals(replacementLine + "\n", new String(Files.readAllBytes(sourceFile), sourceCharset)); + } + } + + private Path createFpr(String remediationsXml) throws IOException { + return createFpr(remediationsXml, "UTF-8"); + } + + private Path createFpr(String remediationsXml, String sourceEncoding) throws IOException { + Path fprPath = tempDir.resolve("test.fpr"); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(fprPath))) { + // Encoding metadata is required by RemediationProcessor (FVDL Build/SourceFiles). + writeEntry(zipOutputStream, "audit.fvdl", """ + + + + + + Example.java + + + + + """.formatted(sourceEncoding)); + writeEntry(zipOutputStream, "remediations.xml", remediationsXml); + } + return fprPath; + } + /** * Fix #2 (exact-line disambiguation): two identical context blocks are ambiguous on their * own, but the declared/projected LineFrom lands exactly on the first occurrence, so it @@ -342,6 +555,33 @@ void supersededRemediationWithMatchingContentIsNotReapplied() throws Exception { assertEquals("before\nREPLACED\nafter\n", Files.readString(sourceFile)); } + /** + * A nested multi-line candidate whose NewCode matches the wide fix still does not prove + * SUPERSEDED: the classifier passes {@code comparisonCode} (all whitespace removed), and + * {@code contentCovers} equals that against a newline-preserving slice. The narrow hunk is + * POSSIBLY_REMEDIATED and is not re-applied. + */ + @Test + void multilineNestedMatchingContentIsPossiblyRemediatedNotReapplied() throws Exception { + Path sourceFile = writeSourceFile("before\nline2\nline3\nafter\n"); + Path fprPath = createRemediationFpr(List.of( + new RemediationSpec("wide-fix", 1, 4, 0, 0, "before\nline2\nline3\nafter", + "before\nline2\nline3\nafter", "before\nREPLACED2\nREPLACED3\nafter"), + new RemediationSpec("narrow-fix", 2, 3, 1, 1, "before\nline2\nline3\nafter", + "line2\nline3", "REPLACED2\nREPLACED3"))); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(0, metric.supersededRemediations()); + assertEquals(1, metric.possiblyRemediatedRemediations()); + assertEquals("before\nREPLACED2\nREPLACED3\nafter\n", Files.readString(sourceFile)); + } + /** * CONFLICTS: two remediations with a partial, non-nested line overlap (neither range contains * the other) is genuinely ambiguous coverage. Fix #2's "no heuristic guessing" philosophy @@ -809,6 +1049,42 @@ void secondFprDoesNotApplyOverAFixTheFirstFprAlreadyRewrote() throws Exception { assertEquals(1, second.skippedRemediations()); assertEquals(afterFirst, Files.readString(sourceFile), "the second FPR must leave the file untouched"); } + /** + * Regression test for coordinate-space mismatch bug: HunkClassifier must compare declared + * (pristine-file) line numbers against declared ranges, not actual (post-shift) ranges, when + * pre-classifying narrower hunks nested inside a broader fix that shifted them. The broader + * fix (wide-deletes) declares lines 1-5 and deletes them, replacing with 2 lines (delta -3). + * The narrower fix (narrow-inside) declares line 3, which sits inside 1-5's declared range. + * Before the fix, classifyRange would compare declared-3 against the broader hunk's ACTUAL + * range (which is now at a shifted position), missing the nesting; after the fix, it compares + * declared-against-declared and correctly classifies as POSSIBLY_REMEDIATED. + */ + @Test + void narrowerRemediationInsideBroaderDeleteIsClassifiedAsPossiblyRemediated() throws Exception { + String originalSource = "line1\nline2\nline3\nline4\nline5\nline6\n"; + writeSourceFile("Example.java", originalSource); + Path fprPath = buildFpr(List.of( + new MultiHunkRemediationSpec("wide-deletes", List.of(new FileSpec("Example.java", List.of( + new HunkSpec(1, 5, 0, 0, + "line1\nline2\nline3\nline4\nline5", + "line1\nline2\nline3\nline4\nline5", + "replacement1\nreplacement2"))))), + new MultiHunkRemediationSpec("narrow-inside", List.of(new FileSpec("Example.java", List.of( + new HunkSpec(3, 3, 1, 1, "line2\nline3\nline4", + "line3", "line3-modified"))))))); + + RemediationMetric metric = apply(fprPath); + + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations(), "the broader fix must be applied"); + assertEquals(1, metric.possiblyRemediatedRemediations(), + "the narrower fix's declared line 3 sits inside the broader fix's declared 1-5, so it must be " + + "pre-classified as POSSIBLY_REMEDIATED even though its actual position shifted"); + assertEquals(0, metric.skippedRemediations(), + "no remediation should be skipped; the narrower one must not fail with ANCHOR_DOES_NOT_MATCH"); + } + + private record HunkSpec(int lineFrom, int lineTo, int contextBefore, int contextAfter, String context, String originalCode, String newCode) {} @@ -877,6 +1153,370 @@ private Path buildFpr(String fprName, List specs) thro return fprPath; } + private void writeEntry(ZipOutputStream zipOutputStream, String entryName, String content) throws IOException { + zipOutputStream.putNextEntry(new ZipEntry(entryName)); + zipOutputStream.write(content.getBytes(StandardCharsets.UTF_8)); + zipOutputStream.closeEntry(); + } + + private String remediationsXml(String hash) { + return """ + + + + + Example.java + %s + + 3 + 3 + void run() {\n oldOne();\n oldTwo(); + oldOne(); + newOne(); + + + + + + Example.java + %s + + 4 + 4 + oldOne();\n oldTwo();\n } + oldTwo(); + newTwo(); + + + + + """.formatted(hash, hash); + } + + private String singleRemediationXml(String hash) { + return """ + + + + + Example.java + %s + + 3 + 3 + void run() {\n oldOne();\n } + oldOne(); + newOne(); + + + + + """.formatted(hash); + } + + private String pathTraversalAndValidRemediationsXml(String hash) { + return """ + + + + + ../outside.java + %s + + 3 + 3 + void run() {\n oldOne();\n } + oldOne(); + ignored(); + + + + + + Example.java + %s + + 3 + 3 + void run() {\n oldOne();\n } + oldOne(); + newOne(); + + + + + """.formatted(hash, hash); + } + + @Test + void previewModeDoesNotModifySourceFiles() throws Exception { + Path sourceDir = Files.createDirectory(tempDir.resolve("src-preview-unchanged")); + Path sourceFile = sourceDir.resolve("Example.java"); + String originalContent = String.join("\n", + "class Example {", + " void run() {", + " oldOne();", + " oldTwo();", + " }", + "}", + ""); + Files.writeString(sourceFile, originalContent, StandardCharsets.UTF_8); + + String hash = TestHashUtil.sha256Base64Unix(originalContent); + Path fprPath = createFpr(remediationsXml(hash)); + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + var processor = new RemediationProcessor(fprHandle, sourceDir.toString(), + options(Set.of(), RemediationExecutionMode.PREVIEW)); + var metric = processor.processRemediationXML(); + + assertEquals(2, metric.totalRemediations()); + assertEquals(2, metric.appliedRemediations()); + assertTrue(metric.isPreview()); + + String actualContent = Files.readString(sourceFile, StandardCharsets.UTF_8).replace("\r\n", "\n"); + assertEquals(originalContent, actualContent); + assertTrue(actualContent.contains(" oldOne();")); + assertTrue(actualContent.contains(" oldTwo();")); + assertFalse(actualContent.contains("newOne")); + assertFalse(actualContent.contains("newTwo")); + } + } + + @Test + void publicPreviewAdapterDoesNotModifySourceFiles() throws Exception { + Path sourceDir = Files.createDirectory(tempDir.resolve("src-preview-adapter")); + Path sourceFile = sourceDir.resolve("Example.java"); + Files.writeString(sourceFile, "before\nTARGET\nafter\n", StandardCharsets.UTF_8); + String originalContent = Files.readString(sourceFile, StandardCharsets.UTF_8); + Path fprPath = createRemediationFpr(2, 2, 1, 1, "before\nTARGET\nafter", "TARGET", "REPLACED"); + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + RemediationMetric metric = ApplyAutoRemediationOnSource.applyRemediations( + fprHandle, sourceDir.toString(), null, Set.of(), true); + + assertTrue(metric.isPreview()); + assertEquals(1, metric.appliedRemediations()); + } + + assertEquals(originalContent, Files.readString(sourceFile, StandardCharsets.UTF_8)); + } + + @Test + void previewModePopulatesPreviewDetailsWithChanges() throws Exception { + Path sourceDir = Files.createDirectory(tempDir.resolve("src-preview-details")); + Path sourceFile = sourceDir.resolve("Example.java"); + String originalContent = String.join("\n", + "class Example {", + " void run() {", + " oldOne();", + " }", + "}", + ""); + Files.writeString(sourceFile, originalContent, StandardCharsets.UTF_8); + + String hash = TestHashUtil.sha256Base64Unix(originalContent); + Path fprPath = createFpr(singleRemediationXml(hash)); + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + var processor = new RemediationProcessor(fprHandle, sourceDir.toString(), + options(Set.of(), RemediationExecutionMode.PREVIEW)); + var metric = processor.processRemediationXML(); + + assertTrue(metric.isPreview()); + assertEquals(1, metric.previewDetails().size()); + + var detail = metric.previewDetails().get(0); + assertEquals("ISSUE-1", detail.issueId()); + assertEquals("available", detail.status()); + assertNotNull(detail.files()); + assertEquals(1, detail.files().size()); + + var filePreview = detail.files().get("Example.java"); + assertNotNull(filePreview); + assertEquals("UTF-8", filePreview.encoding()); + assertEquals(1, filePreview.changes().size()); + + var change = filePreview.changes().get(0); + assertEquals(1, change.changeIndex()); + assertEquals(3, change.lineFrom()); + assertEquals(3, change.lineTo()); + assertTrue(change.originalCode().contains("oldOne")); + assertTrue(change.newCode().contains("newOne")); + } + } + + @Test + void previewShowsDeclaredXmlFieldsWhenSourceHashDoesNotMatch() throws Exception { + Path sourceDir = Files.createDirectory(tempDir.resolve("src-preview-xml-only")); + Path sourceFile = sourceDir.resolve("Example.java"); + Files.writeString(sourceFile, "class Example {\n void run() {\n drifted();\n }\n}\n", + StandardCharsets.UTF_8); + + String xmlHash = TestHashUtil.sha256Base64Unix("class Example {\n void run() {\n oldOne();\n }\n}\n"); + Path fprPath = createFpr(singleRemediationXml(xmlHash)); + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + var metric = new RemediationProcessor(fprHandle, sourceDir.toString(), + options(Set.of(), RemediationExecutionMode.PREVIEW)).processRemediationXML(); + + assertTrue(metric.isPreview()); + assertEquals(1, metric.appliedRemediations()); + var change = metric.previewDetails().get(0).files().get("Example.java").changes().get(0); + assertEquals(3, change.lineFrom()); + assertEquals(3, change.lineTo()); + assertTrue(change.originalCode().contains("oldOne")); + assertTrue(change.newCode().contains("newOne")); + } + assertTrue(Files.readString(sourceFile, StandardCharsets.UTF_8).contains("drifted();")); + } + + @Test + void previewModeCapturesSkipReasonsInPreviewDetails() throws Exception { + Path sourceDir = Files.createDirectory(tempDir.resolve("src-preview-skip")); + // Create file for one remediation, but not the other + Path validFile = sourceDir.resolve("Valid.java"); + Files.writeString(validFile, "class Valid { void run() { old(); } }", StandardCharsets.UTF_8); + + String validHash = TestHashUtil.sha256Base64Unix("class Valid { void run() { old(); } }"); + String missingHash = "dGVzdGhhc2g="; // arbitrary hash for missing file + + String xml = """ + + + + + Valid.java + %s + + 1 + 1 + class Valid { void run() { old(); } } + old(); + new(); + + + + + + Missing.java + %s + + 1 + 1 + ignored + ignored + ignored + + + + + """.formatted(validHash, missingHash); + + Path fprPath = tempDir.resolve("test-skip.fpr"); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(fprPath))) { + writeEntry(zipOutputStream, "audit.fvdl", """ + + + + + + Valid.java + + + Missing.java + + + + + """); + writeEntry(zipOutputStream, "remediations.xml", xml); + } + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + var processor = new RemediationProcessor(fprHandle, sourceDir.toString(), + options(Set.of(), RemediationExecutionMode.PREVIEW)); + var metric = processor.processRemediationXML(); + + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(1, metric.skippedRemediations()); + + assertTrue(metric.isPreview()); + assertEquals(2, metric.previewDetails().size()); + + var available = metric.previewDetails().stream() + .filter(d -> "available".equals(d.status())) + .findFirst().orElseThrow(); + assertEquals("ISSUE-VALID", available.issueId()); + assertNotNull(available.files().get("Valid.java")); + + var skipped = metric.previewDetails().stream() + .filter(d -> "skipped".equals(d.status())) + .findFirst().orElseThrow(); + assertEquals("ISSUE-MISSING", skipped.issueId()); + assertTrue(skipped.files().isEmpty()); + } + } + + @Test + void previewModeWithEmptyRemediationsXmlReturnsEmptyList() throws Exception { + Path sourceDir = Files.createDirectory(tempDir.resolve("src-preview-empty")); + Path sourceFile = sourceDir.resolve("Example.java"); + Files.writeString(sourceFile, "class Example { }", StandardCharsets.UTF_8); + + String emptyXml = """ + + + + """; + + Path fprPath = tempDir.resolve("test-empty.fpr"); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(fprPath))) { + writeEntry(zipOutputStream, "audit.fvdl", """ + + + + + + Example.java + + + + + """); + writeEntry(zipOutputStream, "remediations.xml", emptyXml); + } + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + var processor = new RemediationProcessor(fprHandle, sourceDir.toString(), + options(Set.of(), RemediationExecutionMode.PREVIEW)); + var metric = processor.processRemediationXML(); + + assertEquals(0, metric.totalRemediations()); + assertEquals(0, metric.appliedRemediations()); + + assertTrue(metric.isPreview()); + assertEquals(List.of(), metric.previewDetails()); + } + } + + private RemediationProcessingOptions options(Set issueIds, RemediationExecutionMode executionMode) { + return new RemediationProcessingOptions(issueIds, executionMode, SourceDecoders.defaults()); + } + + private static final class TestHashUtil { + private static String sha256Base64Unix(String content) { + try { + java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(content.replace("\r\n", "\n").getBytes(StandardCharsets.UTF_8)); + return java.util.Base64.getEncoder().encodeToString(digest); + } catch (java.security.NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + } + private static String sha256Base64(byte[] bytes) throws Exception { MessageDigest digest = MessageDigest.getInstance("SHA-256"); return Base64.getEncoder().encodeToString(digest.digest(bytes)); @@ -892,10 +1532,6 @@ private Path writeSourceFile(String filename, String content) throws Exception { return sourceFile; } - private Path createRemediationFpr(String context, String originalCode, String newCode) throws Exception { - return createRemediationFpr(2, 2, 1, 1, context, originalCode, newCode); - } - private record RemediationSpec(String instanceId, int lineFrom, int lineTo, int contextBefore, int contextAfter, String context, String originalCode, String newCode) {} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/StreamingFVDLProcessorTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/StreamingFVDLProcessorTest.java index acd403c8c49..5e6f905b57e 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/StreamingFVDLProcessorTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/StreamingFVDLProcessorTest.java @@ -13,9 +13,13 @@ package com.fortify.cli.aviator.fpr.processor; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -23,6 +27,8 @@ import java.util.zip.ZipFile; import java.util.zip.ZipOutputStream; +import javax.xml.stream.XMLStreamException; + import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -75,6 +81,27 @@ void testParseUsesRunEngineNameAndIgnoresEngineDataPropertiesForAnalysisType() t assertEquals("PTA", processor.getFvdlMetadata().getAnalysisType()); } + @Test + void testParseDoesNotResolveExternalEntity() throws Exception { + Path secret = Files.createTempFile("fvdl-xxe-sentinel", ".txt"); + try { + Files.writeString(secret, "PRIVATE-SENTINEL"); + createTestFpr(""" + ]> + &xxe; + """.formatted(secret.toUri())); + + StreamingFVDLProcessor processor = new StreamingFVDLProcessor(fprHandle); + try (ZipFile zipFile = new ZipFile(tempFprFile.toFile())) { + IOException exception = assertThrows(IOException.class, () -> processor.parse(zipFile, "audit.fvdl")); + assertInstanceOf(XMLStreamException.class, exception.getCause()); + assertTrue(!exception.getMessage().contains("PRIVATE-SENTINEL")); + } + } finally { + Files.deleteIfExists(secret); + } + } + @Test void testParsePreservesRuleMetadataWithoutTracesAndKeepsDefaultAnalysisType() throws Exception { String xml = """ diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/remediation/model/AppliedChangeTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/remediation/model/AppliedChangeTest.java index cc902959a23..71cb8f40249 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/remediation/model/AppliedChangeTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/remediation/model/AppliedChangeTest.java @@ -29,9 +29,24 @@ class AppliedChangeTest { */ @Test void unavailableComparisonCodeIsNotTreatedAsProvenCoverage() { - assertFalse(new AppliedChange(1, 3, 0, "W1W2W3").contentCovers(null, 2, 2), + assertFalse( + AppliedChange.builder() + .originalLineFrom(1) + .originalLineTo(3) + .deltaLines(0) + .comparisonCode("W1W2W3") + .build() + .contentCovers(null, 2, 2), "a candidate whose content could not be computed has not been proven covered"); - assertFalse(new AppliedChange(1, 3, 0, null).contentCovers("M2", 2, 2), + + assertFalse( + AppliedChange.builder() + .originalLineFrom(1) + .originalLineTo(3) + .deltaLines(0) + .comparisonCode(null) + .build() + .contentCovers("M2", 2, 2), "an applied change whose content is unknown cannot prove it covers anything"); } } diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationMetricTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationMetricTest.java new file mode 100644 index 00000000000..842e7aeeda0 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationMetricTest.java @@ -0,0 +1,67 @@ +/* + * 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.remediation.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator.fpr.remediation.RemediationExecutionMode; +import com.fortify.cli.aviator.fpr.remediation.preview.PreviewDetail; + +class RemediationMetricTest { + @Test + void builderDefaultsToApplyModeAndEmptyCollections() { + RemediationMetric metric = RemediationMetric.builder().build(); + + assertFalse(metric.isPreview()); + assertFalse(metric.isFiltered()); + assertEquals(RemediationExecutionMode.APPLY, metric.executionMode()); + assertEquals(Set.of(), metric.modifiedFiles()); + assertEquals(List.of(), metric.previewDetails()); + } + + @Test + void filteredMetricPreservesRequestedAndAppliedIssueIds() { + RemediationMetric metric = RemediationMetric.builder() + .totalRemediations(2) + .appliedRemediations(1) + .skippedRemediations(1) + .requestedIssueIds(Set.of("ISSUE-1", "ISSUE-404")) + .appliedIssueIds(Set.of("ISSUE-1")) + .modifiedFiles(Set.of("Example.java")) + .build(); + + assertTrue(metric.isFiltered()); + assertEquals(Set.of("ISSUE-1", "ISSUE-404"), metric.requestedIssueIds()); + assertEquals(Set.of("ISSUE-1"), metric.appliedIssueIds()); + assertEquals(1, metric.skippedRemediations()); + } + + @Test + void previewMetricCarriesTopLevelPreviewDetails() { + PreviewDetail detail = PreviewDetail.skipped("ISSUE-1", null); + RemediationMetric metric = RemediationMetric.builder() + .executionMode(RemediationExecutionMode.PREVIEW) + .previewDetails(List.of(detail)) + .build(); + + assertTrue(metric.isPreview()); + assertEquals(List.of(detail), metric.previewDetails()); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/remediation/preview/PreviewDtoTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/remediation/preview/PreviewDtoTest.java new file mode 100644 index 00000000000..a580c0d4e6a --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/remediation/preview/PreviewDtoTest.java @@ -0,0 +1,113 @@ +/* + * 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.remediation.preview; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator._common.exception.AviatorBugException; + +class PreviewDtoTest { + @Test + void contextMetadataValidatesCountsAndNormalizesNullContent() { + ContextMetadata metadata = new ContextMetadata(2, 3, "context content"); + assertEquals(2, metadata.linesBefore()); + assertEquals(3, metadata.linesAfter()); + assertEquals("context content", metadata.content()); + assertEquals("", new ContextMetadata(0, 0, null).content()); + assertThrows(AviatorBugException.class, () -> new ContextMetadata(-1, 0, "context")); + assertThrows(AviatorBugException.class, () -> new ContextMetadata(0, -1, "context")); + } + + @Test + void previewFileChangeValidatesIdentityRangeAndContext() { + ContextMetadata context = new ContextMetadata(1, 1, "context"); + + assertThrows(AviatorBugException.class, + () -> new PreviewFileChange(0, 10, 12, "old", "new", context)); + assertThrows(AviatorBugException.class, + () -> new PreviewFileChange(1, 0, 12, "old", "new", context)); + assertThrows(AviatorBugException.class, + () -> new PreviewFileChange(1, 12, 10, "old", "new", context)); + assertThrows(AviatorBugException.class, + () -> new PreviewFileChange(1, 10, 12, "old", "new", null)); + assertEquals(10, new PreviewFileChange(1, 10, 10, "old", "new", context).lineTo()); + } + + @Test + void previewFileChangeBuilderPreservesValues() { + ContextMetadata context = new ContextMetadata(2, 2, "context line"); + PreviewFileChange change = PreviewFileChange.builder() + .changeIndex(1) + .lineFrom(10) + .lineTo(12) + .originalCode("old code") + .newCode("new code") + .context(context) + .build(); + + assertEquals(1, change.changeIndex()); + assertEquals(10, change.lineFrom()); + assertEquals(12, change.lineTo()); + assertEquals("old code", change.originalCode()); + assertEquals("new code", change.newCode()); + assertEquals(context, change.context()); + } + + @Test + void filePreviewNormalizesAndProtectsChanges() { + assertThrows(AviatorBugException.class, () -> new FilePreview(null, "UTF-8", List.of())); + assertThrows(AviatorBugException.class, () -> new FilePreview(" ", "UTF-8", List.of())); + FilePreview empty = new FilePreview("Example.java", "UTF-8", null); + assertEquals("Example.java", empty.path()); + assertEquals("UTF-8", empty.encoding()); + assertEquals(List.of(), empty.changes()); + + PreviewFileChange change = new PreviewFileChange(1, 1, 1, "old", "new", + new ContextMetadata(0, 0, "old")); + FilePreview preview = new FilePreview("Example.java", "UTF-8", List.of(change)); + assertEquals(1, preview.totalChanges()); + assertThrows(UnsupportedOperationException.class, () -> preview.changes().add(change)); + } + + @Test + void previewDetailFactoriesAndFilesAreStable() { + Map files = new LinkedHashMap<>(); + files.put("Example.java", new FilePreview("Example.java", "UTF-8", List.of())); + + PreviewDetail available = PreviewDetail.available("ISSUE-1", "description", files); + PreviewDetail skipped = PreviewDetail.skipped("ISSUE-2", null); + + assertEquals("ISSUE-1", available.issueId()); + assertEquals("available", available.status()); + assertEquals("description", available.description()); + assertTrue(available.isAvailable()); + assertEquals("skipped", skipped.status()); + assertTrue(skipped.isSkipped()); + assertEquals(Map.of(), skipped.files()); + assertEquals(0, new PreviewDetail("ISSUE-1", "available", null, null).files().size()); + assertThrows(UnsupportedOperationException.class, + () -> available.files().put("Other.java", new FilePreview("Other.java", "UTF-8", List.of()))); + assertThrows(AviatorBugException.class, () -> new PreviewDetail(null, "available", null, Map.of())); + assertThrows(AviatorBugException.class, () -> new PreviewDetail("", "available", null, Map.of())); + assertThrows(AviatorBugException.class, () -> new PreviewDetail("ISSUE-1", null, null, Map.of())); + assertThrows(AviatorBugException.class, () -> new PreviewDetail("ISSUE-1", " ", null, Map.of())); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/remediation/xmlprocessor/RemediationXmlReaderTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/remediation/xmlprocessor/RemediationXmlReaderTest.java new file mode 100644 index 00000000000..ffd06462977 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/remediation/xmlprocessor/RemediationXmlReaderTest.java @@ -0,0 +1,52 @@ +/* + * 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.remediation.xmlprocessor; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class RemediationXmlReaderTest { + @TempDir + Path tempDir; + + @Test + void doesNotResolveExternalEntity() throws Exception { + Path secret = tempDir.resolve("secret.txt"); + Files.writeString(secret, "PRIVATE-SENTINEL"); + Path remediations = tempDir.resolve("remediations.xml"); + Files.writeString(remediations, """ + ]> + &xxe; + """.formatted(secret.toUri())); + + var document = new RemediationXmlReader().read(remediations); + assertEquals("", document.getElementsByTagName("Comment").item(0).getTextContent()); + } + + @Test + void allowsDoctypeWithoutLoadingExternalDtd() throws Exception { + Path remediations = tempDir.resolve("remediations.xml"); + Files.writeString(remediations, """ + + expected + """); + + var document = new RemediationXmlReader().read(remediations); + assertEquals("expected", document.getElementsByTagName("Comment").item(0).getTextContent()); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelperTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelperTest.java index 638b73fdb4b..91ceb215f87 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelperTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelperTest.java @@ -15,12 +15,17 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.net.InetSocketAddress; import java.net.SocketAddress; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import com.fortify.cli.aviator._common.exception.UnsupportedAviatorUrlSchemeException; import com.fortify.cli.common.http.proxy.helper.ProxyDescriptor; import io.grpc.HttpConnectProxiedSocketAddress; @@ -28,20 +33,62 @@ class AviatorGrpcClientHelperTest { @Test void shouldParseSchemeLessHostAndPort() { - var parsed = AviatorGrpcClientHelper.parseTarget("aviator.example.com:443"); + var parsed = AviatorGrpcClientHelper.parseTarget("aviator.invalid:443"); - assertEquals("aviator.example.com", parsed.host()); + assertEquals("aviator.invalid", parsed.host()); assertEquals(443, parsed.port()); } @Test void shouldParseTargetWithoutPort() { - var parsed = AviatorGrpcClientHelper.parseTarget("aviator.example.com"); + var parsed = AviatorGrpcClientHelper.parseTarget("aviator.invalid"); - assertEquals("aviator.example.com", parsed.host()); + assertEquals("aviator.invalid", parsed.host()); assertNull(parsed.port()); } + @Test + void shouldCreateConnectionPlanWithDefaultPortAndNormalizedUrl() { + var plan = AviatorGrpcClientHelper.createConnectionPlan("aviator.invalid"); + + assertEquals("aviator.invalid", plan.originalUrl()); + assertEquals("https://aviator.invalid", plan.normalizedUrl()); + assertEquals("aviator.invalid", plan.target().host()); + assertNull(plan.target().port()); + assertEquals(443, plan.effectivePort()); + } + + @Test + void shouldCreateConnectionPlanWithExplicitPort() { + var plan = AviatorGrpcClientHelper.createConnectionPlan("https://aviator.invalid:8443/"); + + assertEquals("https://aviator.invalid:8443/", plan.originalUrl()); + assertEquals("https://aviator.invalid:8443/", plan.normalizedUrl()); + assertEquals("aviator.invalid", plan.target().host()); + assertEquals(8443, plan.target().port()); + assertEquals(8443, plan.effectivePort()); + } + + @Test + void shouldCanonicalizeHttpsSchemeCasing() { + assertEquals("https://aviator.invalid", AviatorGrpcClientHelper.normalizeUrl("HTTPS://aviator.invalid")); + } + + @ParameterizedTest + @ValueSource(strings = { + "mttp://vacant", + "ftp://aviator.invalid", + "http://aviator.invalid", + "grpc://aviator.invalid" + }) + void shouldRejectNonHttpsSchemes(String url) { + var ex = assertThrows(UnsupportedAviatorUrlSchemeException.class, + () -> AviatorGrpcClientHelper.createConnectionPlan(url)); + assertEquals(url.substring(0, url.indexOf("://")), ex.getScheme()); + assertEquals(url, ex.getProvidedUrl()); + assertTrue(ex.getMessage().contains(UnsupportedAviatorUrlSchemeException.STAGE_SUMMARY)); + } + @Test void shouldBuildHttpConnectProxyAddressWithCredentials() { var proxy = ProxyDescriptor.builder() @@ -50,7 +97,7 @@ void shouldBuildHttpConnectProxyAddressWithCredentials() { .proxyUser("user") .proxyPassword("pwd".toCharArray()) .build(); - var target = new InetSocketAddress("aviator.example.com", 443); + var target = new InetSocketAddress("aviator.invalid", 443); var proxied = AviatorGrpcClientHelper.toProxiedSocketAddress(target, proxy); @@ -64,7 +111,7 @@ void shouldBuildHttpConnectProxyAddressWithCredentials() { @Test void shouldIgnoreUnsupportedSocketAddressTypes() { - var proxy = ProxyDescriptor.builder().proxyHost("proxy.example.com").proxyPort(8443).build(); + var proxy = ProxyDescriptor.builder().proxyHost("proxy.invalid").proxyPort(8443).build(); SocketAddress unsupportedTarget = new SocketAddress() { private static final long serialVersionUID = 1L; }; var proxied = AviatorGrpcClientHelper.toProxiedSocketAddress(unsupportedTarget, proxy); diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessorTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessorTest.java index b0207c6e577..00ca9dc474b 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessorTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessorTest.java @@ -15,9 +15,11 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.List; import java.util.Map; +import java.util.Set; import org.junit.jupiter.api.Test; @@ -59,6 +61,25 @@ void buildUrlToDastMapPreservesUrlEncounterOrder() throws Exception { ); } + @Test + void buildCorrelationWorkItemsSkipsFindingWhenAllPairsWereTried() throws Exception { + CorrelationStreamProcessor processor = new CorrelationStreamProcessor(null, null, null, null, 0, 0); + CorrelationStreamProcessor.CorrelationBucketData bucket = createBucket( + "SQL Injection", + List.of("https://example.com/login", "https://example.com/admin") + ); + Map> urlMap = invokeBuildUrlToDastMap(processor, List.of(bucket)); + setField(processor, "urlToDastIssues", urlMap); + setField(processor, "previouslyTriedPairKeys", Set.of( + "SAST-1::https://example.com/login", + "SAST-1::https://example.com/admin" + )); + + List items = invokeBuildCorrelationWorkItems(processor, List.of(bucket)); + + assertEquals(0, items.size()); + } + private CorrelationStreamProcessor.CorrelationBucketData createBucket(String category, List sessionUrls) { List dastIssues = sessionUrls.stream() .map(this::createDastIssue) @@ -96,4 +117,10 @@ private List getDastUrls(Object workItem) throws Exception { method.setAccessible(true); return (List) method.invoke(workItem); } + + private void setField(CorrelationStreamProcessor processor, String fieldName, Object value) throws Exception { + Field field = CorrelationStreamProcessor.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(processor, value); + } } \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditRequestMapperTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditRequestMapperTest.java new file mode 100644 index 00000000000..8f37a595127 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditRequestMapperTest.java @@ -0,0 +1,50 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator.dast.DastIssue; +import com.fortify.cli.aviator.dast.DastReproStep; +import com.fortify.cli.aviator.dast.DastSession; + +class DastAuditRequestMapperTest { + @Test + void mapsCompleteFindingContext() { + var session = new DastSession(); + session.setRequestId("request-1"); + session.setUrl("https://example.test"); + session.setRawRequest("GET / HTTP/1.1"); + session.setRawResponse("HTTP/1.1 200 OK"); + var issue = new DastIssue(); + issue.setId("DAST-1"); + issue.setName("SQL Injection"); + issue.getClassifications().put("CWE", "Improper Neutralization"); + var step = new DastReproStep(); + step.setSource("Attack"); + step.setUrl("https://example.test?id=1"); + step.setPostParams("id=1"); + issue.getReproSteps().add(step); + issue.getReproStepUrls().add(step.getUrl()); + + var context = DastAuditRequestMapper.toFindingContext(session, issue); + + assertEquals("DAST-1", context.getIssueId()); + assertEquals("GET / HTTP/1.1", context.getRawRequest()); + assertEquals("CWE", context.getClassifications(0).getKind()); + assertEquals("Attack", context.getReproSteps(0).getSource()); + assertEquals("id=1", context.getReproSteps(0).getPostParams()); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditResponseMapperTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditResponseMapperTest.java new file mode 100644 index 00000000000..54bc40c9591 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditResponseMapperTest.java @@ -0,0 +1,88 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +import org.junit.jupiter.api.Test; + +import com.fortify.aviator.dastaudit.DastAuditDecision; +import com.fortify.aviator.dastaudit.DastAuditResponse; +import com.fortify.cli.aviator.audit.model.AuditTier; + +class DastAuditResponseMapperTest { + @Test + void usesIssueIdAssociatedWithRequest() { + var response = DastAuditResponse.newBuilder() + .setRequestId("request-1") + .setDastIssueId("DAST-1") + .setStatus("SUCCESS") + .setDecision(DastAuditDecision.newBuilder().setTruePositive(true).setConfidence("HIGH")) + .build(); + + DastAuditResult result = DastAuditResponseMapper.map(response, "DAST-1"); + + var success = assertInstanceOf(DastAuditResult.Success.class, result); + assertEquals("DAST-1", result.issueId()); + assertEquals("SUCCESS", result.status()); + assertEquals(true, success.truePositive()); + assertEquals(AuditTier.SILVER, success.tier()); + } + + @Test + void parsesKnownTierAndDefaultsUnknownTierToSilver() { + assertEquals(AuditTier.GOLD, mapTier("gold")); + assertEquals(AuditTier.SILVER, mapTier("BRONZE")); + } + + @Test + void rejectsMismatchedServerIssueId() { + var response = DastAuditResponse.newBuilder() + .setRequestId("request-1") + .setDastIssueId("DAST-WRONG") + .setStatus("SUCCESS") + .build(); + + DastAuditResult result = DastAuditResponseMapper.map(response, "DAST-1"); + + assertInstanceOf(DastAuditResult.Failure.class, result); + assertEquals("DAST-1", result.issueId()); + assertEquals("FAILED", result.status()); + } + + @Test + void preservesSkippedResponseAsDistinctVariant() { + var response = DastAuditResponse.newBuilder() + .setRequestId("request-1") + .setDastIssueId("DAST-1") + .setStatus("SKIPPED") + .setStatusMessage("Quota exceeded") + .build(); + + DastAuditResult result = DastAuditResponseMapper.map(response, "DAST-1"); + + assertInstanceOf(DastAuditResult.Skipped.class, result); + assertEquals("SKIPPED", result.status()); + assertEquals("Quota exceeded", result.statusMessage()); + } + + private AuditTier mapTier(String tier) { + var response = DastAuditResponse.newBuilder() + .setDastIssueId("DAST-1") + .setStatus("SUCCESS") + .setDecision(DastAuditDecision.newBuilder().setTier(tier)) + .build(); + return ((DastAuditResult.Success) DastAuditResponseMapper.map(response, "DAST-1")).tier(); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditStreamConfigTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditStreamConfigTest.java new file mode 100644 index 00000000000..b3eefdb47f6 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditStreamConfigTest.java @@ -0,0 +1,41 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; + +class DastAuditStreamConfigTest { + @Test + void rejectsBlankToken() { + assertThrows(AviatorSimpleException.class, + () -> validConfigBuilder().token(" ").build()); + } + + @Test + void rejectsBlankApplicationName() { + assertThrows(AviatorSimpleException.class, + () -> validConfigBuilder().applicationName(" ").build()); + } + + private DastAuditStreamConfig.DastAuditStreamConfigBuilder validConfigBuilder() { + return DastAuditStreamConfig.builder() + .token("token") + .applicationName("app") + .sscApplicationName("ssc") + .sscApplicationVersion("1"); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessorTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessorTest.java new file mode 100644 index 00000000000..b0a8952b977 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessorTest.java @@ -0,0 +1,75 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.fortify.aviator.dastaudit.DastAuditClientMessage; +import com.fortify.cli.aviator.dast.DastIssue; +import com.fortify.cli.aviator.dast.DastSession; + +import io.grpc.Status; + +class DastAuditStreamProcessorTest { + @Test + void registersAllRequestsBeforeReturningMessagesForSending() { + var processor = new DastAuditStreamProcessor(null, null, null, 0); + + List requests = processor.prepareAuditRequests( + List.of(workItem("DAST-1"), workItem("DAST-2")), "stream-1"); + + assertEquals(List.of("DAST-1", "DAST-2"), requests.stream() + .map(request -> request.getAudit().getFinding().getIssueId()) + .toList()); + assertEquals(2, processor.pendingRequestCount()); + } + + @Test + void reusesRequestIdsAndRequeuesOnlyUnfinishedWork() { + var processor = new DastAuditStreamProcessor(null, null, null, 0); + List workItems = List.of(workItem("DAST-1"), workItem("DAST-2")); + processor.initializeWorkItems(workItems); + List firstAttempt = processor.prepareAuditRequests(workItems, "stream-1"); + + processor.completeRequest(firstAttempt.get(0).getAudit().getRequestId()); + List retry = processor.prepareAuditRequests( + processor.remainingWorkItems(), "stream-2"); + + assertEquals(1, retry.size()); + assertEquals("DAST-2", retry.get(0).getAudit().getFinding().getIssueId()); + assertEquals(firstAttempt.get(1).getAudit().getRequestId(), retry.get(0).getAudit().getRequestId()); + assertEquals("stream-2", retry.get(0).getAudit().getStreamId()); + } + + @Test + void retriesOnlyTransportDisconnections() { + assertTrue(DastAuditStreamProcessor.isRetryableError(Status.UNAVAILABLE.asRuntimeException())); + assertTrue(DastAuditStreamProcessor.isRetryableError( + Status.INTERNAL.withDescription("RST_STREAM closed").asRuntimeException())); + assertTrue(DastAuditStreamProcessor.isInfiniteRetryError( + Status.INTERNAL.withDescription("PROTOCOL_ERROR").asRuntimeException())); + assertFalse(DastAuditStreamProcessor.isRetryableError(Status.INVALID_ARGUMENT.asRuntimeException())); + } + + private DastAuditWorkItem workItem(String issueId) { + var issue = new DastIssue(); + issue.setId(issueId); + return new DastAuditWorkItem(new DastSession(), issue); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditStreamResultTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditStreamResultTest.java new file mode 100644 index 00000000000..c5e88e4414b --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditStreamResultTest.java @@ -0,0 +1,42 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +class DastAuditStreamResultTest { + @Test + void defensivelyCopiesResults() { + var source = new ArrayList(); + DastAuditStreamResult result = DastAuditStreamResult.builder().results(source).build(); + + source.add(DastAuditResult.Skipped.builder().issueId("DAST-1").build()); + + assertEquals(List.of(), result.results()); + assertThrows(UnsupportedOperationException.class, + () -> result.results().add(DastAuditResult.Skipped.builder().issueId("DAST-2").build())); + } + + @Test + void normalizesNullResultsToEmptyList() { + DastAuditStreamResult result = DastAuditStreamResult.builder().build(); + + assertEquals(List.of(), result.results()); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/RequestHandlerTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/RequestHandlerTest.java new file mode 100644 index 00000000000..d720d653aed --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/RequestHandlerTest.java @@ -0,0 +1,47 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import io.grpc.stub.StreamObserver; + +class RequestHandlerTest { + @Test + void completeFlushesQueuedRequestsBeforeHalfClose() { + var events = new ArrayList(); + var handler = new RequestHandler("stream"); + handler.sendRequest("one"); + handler.sendRequest("two"); + handler.initialize(observer(events)); + + handler.complete().join(); + + assertEquals(List.of("one", "two", "completed"), events); + assertTrue(handler.isCompleted()); + } + + private StreamObserver observer(List events) { + return new StreamObserver<>() { + @Override public void onNext(String value) { events.add(value); } + @Override public void onError(Throwable throwable) { events.add("error"); } + @Override public void onCompleted() { events.add("completed"); } + }; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/testFixtures/java/com/fortify/cli/aviator/diagnose/support/ConfigurableDiagnosticProbe.java b/fcli-core/fcli-aviator-common/src/testFixtures/java/com/fortify/cli/aviator/diagnose/support/ConfigurableDiagnosticProbe.java new file mode 100644 index 00000000000..7f98a2fd499 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/testFixtures/java/com/fortify/cli/aviator/diagnose/support/ConfigurableDiagnosticProbe.java @@ -0,0 +1,71 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.diagnose.support; + +import java.io.IOException; +import java.net.InetAddress; + +import com.fortify.cli.aviator.diagnose.AviatorGrpcReachabilityResult; +import com.fortify.cli.aviator.diagnose.AviatorTunnelResult; +import com.fortify.cli.aviator.diagnose.IAviatorDiagnosticProbe; +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper.AviatorConnectionPlan; + +/** + * Shared offline probe for diagnose unit tests. Defaults to all-transport-pass + * (DNS, TCP, TLS/h2, gRPC response). Mutate fields to force failures. + */ +public final class ConfigurableDiagnosticProbe implements IAviatorDiagnosticProbe { + public IOException resolveException; + public IOException connectException; + public AviatorTunnelResult tunnelResult = new AviatorTunnelResult.TlsSucceeded(false, "not-used", + "TLSv1.3", "TLS_AES_128_GCM_SHA256", "CN=aviator.invalid", "h2"); + public AviatorGrpcReachabilityResult grpcResult = + AviatorGrpcReachabilityResult.responseReceived("OK", "response received"); + public boolean tunnelCalled; + public int tunnelCallCount; + public boolean grpcCalled; + + public ConfigurableDiagnosticProbe() {} + + public ConfigurableDiagnosticProbe(AviatorGrpcReachabilityResult grpcResult) { + this.grpcResult = grpcResult; + } + + @Override + public InetAddress[] resolve(String host) throws IOException { + if (resolveException != null) { + throw resolveException; + } + return new InetAddress[] {InetAddress.getByName("127.0.0.1")}; + } + + @Override + public void connect(String host, int port, int timeoutSeconds) throws IOException { + if (connectException != null) { + throw connectException; + } + } + + @Override + public AviatorTunnelResult probeTunnel(AviatorConnectionPlan connectionPlan, int timeoutSeconds) { + tunnelCalled = true; + tunnelCallCount++; + return tunnelResult; + } + + @Override + public AviatorGrpcReachabilityResult probeGrpc(String url, int timeoutSeconds) { + grpcCalled = true; + return grpcResult; + } +} diff --git a/fcli-core/fcli-aviator-common/src/testFixtures/java/com/fortify/cli/aviator/diagnose/support/OfflineConnectionPlan.java b/fcli-core/fcli-aviator-common/src/testFixtures/java/com/fortify/cli/aviator/diagnose/support/OfflineConnectionPlan.java new file mode 100644 index 00000000000..b0dfd028347 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/testFixtures/java/com/fortify/cli/aviator/diagnose/support/OfflineConnectionPlan.java @@ -0,0 +1,48 @@ +/* + * 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.diagnose.support; + +import java.util.Optional; + +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper.AviatorConnectionPlan; +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper.ParsedTarget; +import com.fortify.cli.common.http.proxy.helper.ProxyDescriptor; + +/** + * Fixed connection plans that avoid ambient proxy/DNS policy in unit tests. + */ +public final class OfflineConnectionPlan { + private OfflineConnectionPlan() {} + + public static AviatorConnectionPlan noProxy() { + return noProxy("aviator.invalid", 443); + } + + public static AviatorConnectionPlan noProxy(String host, int port) { + Integer targetPort = port == 443 ? null : port; + return new AviatorConnectionPlan(host, "https://" + host, + new ParsedTarget(host, targetPort), port, Optional.empty()); + } + + /** Plan whose URL fields match the diagnose input string (helper offline path). */ + public static AviatorConnectionPlan fixedUrl(String url) { + return new AviatorConnectionPlan(url, url, + new ParsedTarget("aviator.invalid", null), 443, Optional.empty()); + } + + public static AviatorConnectionPlan withProxy() { + var proxy = ProxyDescriptor.builder().proxyHost("proxy.invalid").proxyPort(8080).build(); + return new AviatorConnectionPlan("aviator.invalid", "https://aviator.invalid", + new ParsedTarget("aviator.invalid", null), 443, Optional.of(proxy)); + } +} diff --git a/fcli-core/fcli-aviator/build.gradle.kts b/fcli-core/fcli-aviator/build.gradle.kts index f56ef5f7216..93f896633f4 100644 --- a/fcli-core/fcli-aviator/build.gradle.kts +++ b/fcli-core/fcli-aviator/build.gradle.kts @@ -7,4 +7,5 @@ dependencies { implementation(project(aviatorCommonRef)) implementation(project(sscRef)) implementation(project(fodRef)) + testImplementation(testFixtures(project(aviatorCommonRef))) } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/cli/cmd/AviatorUserSessionLoginCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/cli/cmd/AviatorUserSessionLoginCommand.java index 3c69bb7424d..42f93eb2c10 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/cli/cmd/AviatorUserSessionLoginCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/cli/cmd/AviatorUserSessionLoginCommand.java @@ -25,11 +25,8 @@ import com.fortify.cli.aviator._common.session.user.helper.AviatorUserSessionDescriptor; import com.fortify.cli.aviator._common.session.user.helper.AviatorUserSessionHelper; import com.fortify.cli.aviator._common.util.AviatorJwtUtils; -import com.fortify.cli.aviator.grpc.AviatorGrpcClient; -import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.session.cli.cmd.AbstractSessionLoginCommand; -import com.fortify.grpc.token.TokenValidationResponse; import io.grpc.Status; import io.grpc.StatusRuntimeException; @@ -56,17 +53,18 @@ protected void logoutBeforeNewLogin(String sessionName, AviatorUserSessionDescri protected AviatorUserSessionDescriptor login(String sessionName) { String resolvedToken = tokenResolver.getToken(); Date expiryDate = AviatorJwtUtils.extractExpiryDateFromToken(resolvedToken); - String tenantName = AviatorJwtUtils.extractTenantNameFromToken(resolvedToken); - LOG.info("Default Aviator admin configuration found. Attempting to validate user token..."); - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(sessionLoginOptions.getAviatorUrl())) { - - TokenValidationResponse validationResponse = client.validateUserToken(resolvedToken, tenantName); + LOG.info("Validating Aviator user token with the server..."); + try { + var validationResponse = sessionHelper.validateToken(sessionLoginOptions.getAviatorUrl(), resolvedToken).response(); if (!validationResponse.getValid()) { String errorMsg = validationResponse.getErrorMessage(); String fullError = "Aviator user token validation failed: " + - (errorMsg == null || errorMsg.isBlank() ? "The token is invalid. Please verify the token is correct and try again." : errorMsg); throw new AviatorSimpleException(fullError); + (errorMsg == null || errorMsg.isBlank() + ? "The token is invalid. Please verify the token is correct and try again." + : errorMsg); + throw new AviatorSimpleException(fullError); } LOG.info("Aviator user token validated successfully with the Aviator server."); } catch (AviatorTechnicalException e) { diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/cli/mixin/AviatorUserTokenResolverMixin.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/cli/mixin/AviatorUserTokenResolverMixin.java index 093156cabed..2f1ac3fcbee 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/cli/mixin/AviatorUserTokenResolverMixin.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/cli/mixin/AviatorUserTokenResolverMixin.java @@ -12,17 +12,15 @@ */ package com.fortify.cli.aviator._common.session.user.cli.mixin; -import org.apache.commons.lang3.StringUtils; - +import com.fortify.cli.aviator._common.session.user.helper.AviatorUserTokenTextResolver; import com.fortify.cli.common.cli.mixin.CommonOptionMixins.AbstractTextResolverMixin; -import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.log.LogSensitivityLevel; import com.fortify.cli.common.log.MaskValue; import picocli.CommandLine.Option; /** - * Mixin for resolving an Aviator user token from various sources (direct string, file, URL, environment variable). + * Mixin for resolving an Aviator user token from various sources (direct string, file, environment variable). */ @MaskValue(sensitivity = LogSensitivityLevel.high, description = "AVIATOR TOKEN") public class AviatorUserTokenResolverMixin extends AbstractTextResolverMixin { @@ -35,19 +33,9 @@ public String getTextSource() { } /** - * Returns the resolved token text. - * This method calls the underlying resolution logic from AbstractTextResolverMixin. - * @return The resolved Aviator user token string, or null if not provided or resolved. + * @return the resolved Aviator user token string */ public String getToken() { - String source = getTextSource(); - if (source != null && source.toLowerCase().startsWith("url:")) { - throw new FcliSimpleException("Providing Aviator tokens via URL ('url:' prefix) is not supported"); - } - String resolvedToken = super.getText(); - if (StringUtils.isBlank(resolvedToken)) { - throw new FcliSimpleException("Resolved token value for --token option is blank or empty."); - } - return super.getText(); + return AviatorUserTokenTextResolver.resolveRequired(getTextSource(), this::getText); } -} \ No newline at end of file +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/helper/AviatorUserSessionHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/helper/AviatorUserSessionHelper.java index efb8e7f4ed2..dbed50341e3 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/helper/AviatorUserSessionHelper.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/helper/AviatorUserSessionHelper.java @@ -12,7 +12,10 @@ */ package com.fortify.cli.aviator._common.session.user.helper; +import com.fortify.cli.aviator._common.util.AviatorJwtUtils; +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.common.session.helper.AbstractSessionHelper; +import com.fortify.grpc.token.TokenValidationResponse; public class AviatorUserSessionHelper extends AbstractSessionHelper { private static final AviatorUserSessionHelper INSTANCE = new AviatorUserSessionHelper(); @@ -33,8 +36,22 @@ protected String getLoginCmd() { protected Class getSessionDescriptorType() { return AviatorUserSessionDescriptor.class; } + + public AviatorUserTokenValidationResult validateToken(AviatorUserSessionDescriptor sessionDescriptor) { + return validateToken(sessionDescriptor.getAviatorUrl(), sessionDescriptor.getAviatorToken()); + } + + public AviatorUserTokenValidationResult validateToken(String aviatorUrl, String token) { + var tenantName = AviatorJwtUtils.extractTenantNameFromToken(token); + try (var client = AviatorGrpcClientHelper.createClient(aviatorUrl)) { + var response = client.validateUserToken(token, tenantName); + return new AviatorUserTokenValidationResult(tenantName, response); + } + } public static final AviatorUserSessionHelper instance() { return INSTANCE; } + + public record AviatorUserTokenValidationResult(String tenantName, TokenValidationResponse response) {} } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/helper/AviatorUserTokenTextResolver.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/helper/AviatorUserTokenTextResolver.java new file mode 100644 index 00000000000..bbd46ed2032 --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_common/session/user/helper/AviatorUserTokenTextResolver.java @@ -0,0 +1,60 @@ +/* + * 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._common.session.user.helper; + +import java.util.Locale; +import java.util.function.Supplier; + +import org.apache.commons.lang3.StringUtils; + +import com.fortify.cli.common.exception.FcliSimpleException; + +/** + * Shared validation for Aviator user token option sources ({@code file:}/{@code string:}/{@code env:}). + * Used by required (session login) and optional (connection diagnose) token mixins. + */ +public final class AviatorUserTokenTextResolver { + private AviatorUserTokenTextResolver() {} + + /** + * Resolve a required token. {@code resolvedTokenSupplier} is invoked after the source is present. + * + * @param textSource raw option value (e.g. {@code env:MY_TOKEN}) + * @param resolvedTokenSupplier typically {@code AbstractTextResolverMixin#getText} + * @return non-blank token text + */ + public static String resolveRequired(String textSource, Supplier resolvedTokenSupplier) { + rejectUrlPrefix(textSource); + var resolvedToken = resolvedTokenSupplier.get(); + if (StringUtils.isBlank(resolvedToken)) { + throw new FcliSimpleException("Resolved token value for --token option is blank or empty."); + } + return resolvedToken; + } + + /** + * Resolve an optional token. Returns {@code null} when {@code textSource} was not provided. + */ + public static String resolveOptional(String textSource, Supplier resolvedTokenSupplier) { + if (StringUtils.isBlank(textSource)) { + return null; + } + return resolveRequired(textSource, resolvedTokenSupplier); + } + + private static void rejectUrlPrefix(String textSource) { + if (textSource != null && textSource.toLowerCase(Locale.ROOT).startsWith("url:")) { + throw new FcliSimpleException("Providing Aviator tokens via URL ('url:' prefix) is not supported"); + } + } +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_main/cli/cmd/AviatorCommands.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_main/cli/cmd/AviatorCommands.java index 780fd2fc0b1..6cb511ff2d4 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_main/cli/cmd/AviatorCommands.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_main/cli/cmd/AviatorCommands.java @@ -18,6 +18,7 @@ import com.fortify.cli.aviator._common.config.admin.cli.cmd.AviatorAdminConfigCommands; import com.fortify.cli.aviator._common.session.user.cli.cmd.AviatorUserSessionCommands; import com.fortify.cli.aviator.app.cli.cmd.AviatorAppCommands; +import com.fortify.cli.aviator.connection.cli.cmd.AviatorConnectionCommands; import com.fortify.cli.aviator.entitlement.cli.cmd.AviatorEntitlementCommands; import com.fortify.cli.aviator.ssc.cli.cmd.AviatorSSCCommands; import com.fortify.cli.aviator.token.cli.cmd.AviatorTokenCommands; @@ -44,6 +45,7 @@ AviatorAdminConfigCommands.class, AviatorUserSessionCommands.class, AviatorAppCommands.class, + AviatorConnectionCommands.class, AviatorEntitlementCommands.class, AviatorSSCCommands.class, AviatorTokenCommands.class, diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/connection/cli/cmd/AviatorConnectionCommands.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/connection/cli/cmd/AviatorConnectionCommands.java new file mode 100644 index 00000000000..4d49789f2ce --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/connection/cli/cmd/AviatorConnectionCommands.java @@ -0,0 +1,25 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.connection.cli.cmd; + +import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; + +import picocli.CommandLine.Command; + +@Command( + name = "connection", + subcommands = { + AviatorConnectionDiagnoseCommand.class + } +) +public class AviatorConnectionCommands extends AbstractContainerCommand {} \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/connection/cli/cmd/AviatorConnectionDiagnoseCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/connection/cli/cmd/AviatorConnectionDiagnoseCommand.java new file mode 100644 index 00000000000..bb1c10ecf1e --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/connection/cli/cmd/AviatorConnectionDiagnoseCommand.java @@ -0,0 +1,84 @@ +/* + * 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.connection.cli.cmd; + +import com.fortify.cli.aviator._common.config.admin.helper.AviatorAdminConfigHelper; +import com.fortify.cli.aviator._common.session.user.helper.AviatorUserSessionHelper; +import com.fortify.cli.aviator.connection.cli.mixin.AviatorConnectionDiagnoseSourceArgGroup; +import com.fortify.cli.aviator.connection.helper.AviatorConnectionDiagnoseHelper; +import com.fortify.cli.aviator.connection.helper.AviatorConnectionDiagnoseSource; +import com.fortify.cli.common.exception.FcliBugException; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.json.producer.IObjectNodeProducer; +import com.fortify.cli.common.json.producer.ObjectNodeProducerApplyFrom; +import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; + +import lombok.Getter; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command(name = "diagnose") +public class AviatorConnectionDiagnoseCommand extends AbstractOutputCommand { + @Getter @Mixin private OutputHelperMixins.TableNoQuery outputHelper; + + @ArgGroup(exclusive = true, multiplicity = "1", headingKey = "aviator.connection.diagnose.source.arggroup") + private AviatorConnectionDiagnoseSourceArgGroup sourceArgGroup; + + @Option(names = "--timeout", defaultValue = "30", paramLabel = "") + private int timeoutSeconds; + + /** + * Runs diagnostics and returns a producer that writes the stage table and + * reports exit code 1 when a required stage failed (soft exit after output). + */ + @Override + protected IObjectNodeProducer getObjectNodeProducer() { + if (timeoutSeconds <= 0) { + throw new FcliSimpleException("--timeout must be greater than 0"); + } + var runResult = new AviatorConnectionDiagnoseHelper().diagnose(resolveSource(), timeoutSeconds); + return simpleObjectNodeProducerBuilder(ObjectNodeProducerApplyFrom.SPEC) + .source(runResult.json()) + .exitCode(runResult.requiredFailure() ? 1 : 0) + .build(); + } + + private AviatorConnectionDiagnoseSource resolveSource() { + var urlSource = sourceArgGroup.getUrlSource(); + if (urlSource != null) { + var token = urlSource.getTokenOrNull(); + return token != null + ? AviatorConnectionDiagnoseSource.fromUrlAndToken(urlSource.getUrl(), token) + : AviatorConnectionDiagnoseSource.fromUrl(urlSource.getUrl()); + } + var sessionName = sourceArgGroup.getAviatorSession(); + if (sessionName != null) { + return AviatorConnectionDiagnoseSource.fromUserSession( + AviatorUserSessionHelper.instance().get(sessionName, true)); + } + var adminName = sourceArgGroup.getAdminConfig(); + if (adminName != null) { + return AviatorConnectionDiagnoseSource.fromAdminConfig( + AviatorAdminConfigHelper.instance().get(adminName, true)); + } + throw new FcliBugException("No diagnose source selected; exclusive ArgGroup invariant was violated"); + } + + @Override + public boolean isSingular() { + return false; + } +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/connection/cli/mixin/AviatorConnectionDiagnoseSourceArgGroup.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/connection/cli/mixin/AviatorConnectionDiagnoseSourceArgGroup.java new file mode 100644 index 00000000000..490b1571d0a --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/connection/cli/mixin/AviatorConnectionDiagnoseSourceArgGroup.java @@ -0,0 +1,32 @@ +/* + * 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.connection.cli.mixin; + +import lombok.Getter; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Option; + +/** + * Exclusive diagnose source: URL (optional token), saved user session, or admin config. + */ +@Getter +public class AviatorConnectionDiagnoseSourceArgGroup { + @ArgGroup(exclusive = false, multiplicity = "0..1", order = 1) + private AviatorConnectionDiagnoseUrlSourceArgGroup urlSource; + + @Option(names = {"--aviator-session", "--av-session"}, order = 2) + private String aviatorSession; + + @Option(names = {"--admin-config"}, order = 3) + private String adminConfig; +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/connection/cli/mixin/AviatorConnectionDiagnoseUrlSourceArgGroup.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/connection/cli/mixin/AviatorConnectionDiagnoseUrlSourceArgGroup.java new file mode 100644 index 00000000000..2c88968a30b --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/connection/cli/mixin/AviatorConnectionDiagnoseUrlSourceArgGroup.java @@ -0,0 +1,63 @@ +/* + * 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.connection.cli.mixin; + +import com.fortify.cli.aviator._common.session.user.helper.AviatorUserTokenTextResolver; +import com.fortify.cli.common.cli.mixin.CommonOptionMixins.AbstractTextResolverMixin; +import com.fortify.cli.common.log.LogSensitivityLevel; +import com.fortify.cli.common.log.MaskValue; + +import lombok.Getter; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Option; + +/** + * URL-based diagnose source: required {@code --url}, optional {@code --token} for direct token validation. + *

    + * Token resolution is a nested {@link AbstractTextResolverMixin} so class-level + * {@link MaskValue} applies only to the resolved token (not the URL host). + */ +@Getter +public class AviatorConnectionDiagnoseUrlSourceArgGroup { + @Option(names = {"--url"}, required = true, order = 1) + @MaskValue(sensitivity = LogSensitivityLevel.low, description = "AVIATOR HOST NAME", pattern = MaskValue.URL_HOSTNAME_PATTERN) + private String url; + + /** Present only when {@code --token} is supplied (optional ArgGroup). */ + @ArgGroup(exclusive = false, multiplicity = "0..1", order = 2) + private TokenSource tokenSource; + + public String getTokenOrNull() { + return tokenSource == null ? null : tokenSource.getTokenOrNull(); + } + + /** + * Optional token text source. Class-level {@link MaskValue} registers the resolved token + * for log masking ({@link AbstractTextResolverMixin#getText()}); field-level masks the raw option. + */ + @MaskValue(sensitivity = LogSensitivityLevel.high, description = "AVIATOR TOKEN") + public static final class TokenSource extends AbstractTextResolverMixin { + @Option(names = {"--token", "-t"}, paramLabel = "source", required = true, order = 2) + @MaskValue(sensitivity = LogSensitivityLevel.high, description = "AVIATOR TOKEN") + private String textSource; + + @Override + public String getTextSource() { + return textSource; + } + + String getTokenOrNull() { + return AviatorUserTokenTextResolver.resolveOptional(getTextSource(), this::getText); + } + } +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/connection/helper/AviatorConnectionDiagnoseHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/connection/helper/AviatorConnectionDiagnoseHelper.java new file mode 100644 index 00000000000..0f66987d9d5 --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/connection/helper/AviatorConnectionDiagnoseHelper.java @@ -0,0 +1,158 @@ +/* + * 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.connection.helper; + +import java.util.List; + +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fortify.cli.aviator._common.config.admin.helper.AviatorAdminConfigDescriptor; +import com.fortify.cli.aviator._common.config.admin.helper.AviatorAdminConfigHelper; +import com.fortify.cli.aviator._common.session.user.helper.AviatorUserSessionHelper; +import com.fortify.cli.aviator._common.session.user.helper.AviatorUserSessionHelper.AviatorUserTokenValidationResult; +import com.fortify.cli.aviator.connection.helper.AviatorConnectionDiagnoseSource.CredentialRequest; +import com.fortify.cli.aviator.diagnose.AviatorConnectionDiagnostics; +import com.fortify.cli.aviator.diagnose.AviatorDiagnosticEvidence; +import com.fortify.cli.aviator.diagnose.AviatorDiagnosticReport; +import com.fortify.cli.aviator.diagnose.AviatorDiagnosticStageResult; +import com.fortify.cli.common.exception.FcliBugException; + +import lombok.RequiredArgsConstructor; + +/** + * Orchestrates transport diagnostics then an optional credential stage. + *

    + * Soft-exit policy lives in the command: credential failures are optional + * ({@code required=false}) so they never alone force a non-zero process exit. + * Product credential stage ids ({@code token}/{@code admin}) are not transport stages. + */ +@RequiredArgsConstructor +public class AviatorConnectionDiagnoseHelper { + public static final String STAGE_TOKEN = "token"; + public static final String STAGE_TOKEN_DESCRIPTION = "Aviator token validation"; + public static final String STAGE_ADMIN = "admin"; + public static final String STAGE_ADMIN_DESCRIPTION = "Aviator admin credential validation"; + + private final AviatorConnectionDiagnostics diagnostics; + private final TokenValidator tokenValidator; + private final AdminValidator adminValidator; + + public AviatorConnectionDiagnoseHelper() { + this(new AviatorConnectionDiagnostics(), + (url, token) -> AviatorUserSessionHelper.instance().validateToken(url, token), + descriptor -> AviatorAdminConfigHelper.instance().validateConfig(descriptor)); + } + + public DiagnoseRunResult diagnose(AviatorConnectionDiagnoseSource source, int timeoutSeconds) { + var report = diagnostics.diagnose(source.url(), timeoutSeconds, source.sourceTypeId()); + appendCredentialStage(report, source); + return new DiagnoseRunResult(report.stages(), report.toArrayNode(), report.hasRequiredFailure()); + } + + private void appendCredentialStage(AviatorDiagnosticReport report, AviatorConnectionDiagnoseSource source) { + source.credentialRequest().ifPresent(cred -> cred.accept(new CredentialRequest.Visitor() { + @Override + public void visitToken(CredentialRequest.Token token) { + runCredentialStage(report, STAGE_TOKEN, STAGE_TOKEN_DESCRIPTION, + () -> validateUserToken(report, token.url(), token.token())); + } + + @Override + public void visitAdmin(CredentialRequest.Admin admin) { + runCredentialStage(report, STAGE_ADMIN, STAGE_ADMIN_DESCRIPTION, + () -> validateAdminConfig(report, admin.descriptor())); + } + })); + } + + /** Skip when gRPC did not respond; otherwise run {@code validate}. */ + private void runCredentialStage(AviatorDiagnosticReport report, String stage, String description, + Runnable validate) { + report.begin(stage); + if (!report.hasGrpcStagePass()) { + report.optionalSkipWarn(stage, description, + "gRPC did not respond", + "Fix the gRPC connection first", AviatorDiagnosticEvidence.empty()); + return; + } + validate.run(); + } + + private void validateUserToken(AviatorDiagnosticReport report, String aviatorUrl, String token) { + if (token == null || token.isBlank()) { + report.optionalFail(STAGE_TOKEN, STAGE_TOKEN_DESCRIPTION, + "Aviator token is missing", + "Create or update the session with a valid user token", AviatorDiagnosticEvidence.empty()); + return; + } + try { + var validationResult = tokenValidator.validate(aviatorUrl, token); + var evidence = AviatorDiagnosticEvidence.empty(); + evidence.put("tenantNamePresent", validationResult.tenantName() != null); + if (validationResult.response().getValid()) { + report.optionalPass(STAGE_TOKEN, STAGE_TOKEN_DESCRIPTION, + "Aviator token is valid for the requested tenant", "No action required", evidence); + return; + } + var errorMessage = validationResult.response().getErrorMessage(); + if (errorMessage != null && !errorMessage.isBlank()) { + evidence.put("tokenValidationMessage", errorMessage); + } + report.optionalFail(STAGE_TOKEN, STAGE_TOKEN_DESCRIPTION, + "Aviator token is not valid", + "Use a current token for the expected tenant", evidence); + } catch (RuntimeException e) { + rethrowIfBug(e); + report.optionalFail(STAGE_TOKEN, STAGE_TOKEN_DESCRIPTION, + "Aviator token check failed", + "Use a current token for the expected tenant", + AviatorDiagnosticEvidence.errorEvidence(e)); + } + } + + private void validateAdminConfig(AviatorDiagnosticReport report, AviatorAdminConfigDescriptor configDescriptor) { + try { + adminValidator.validate(configDescriptor); + var evidence = AviatorDiagnosticEvidence.empty(); + evidence.put("tenant", configDescriptor.getTenant()); + report.optionalPass(STAGE_ADMIN, STAGE_ADMIN_DESCRIPTION, + "Aviator admin credentials are valid for tenant "+configDescriptor.getTenant(), "No action required", evidence); + } catch (RuntimeException e) { + rethrowIfBug(e); + report.optionalFail(STAGE_ADMIN, STAGE_ADMIN_DESCRIPTION, + "Admin credentials are not valid", + "Check the tenant, public key, and private key", AviatorDiagnosticEvidence.errorEvidence(e)); + } + } + + /** + * Credential stages soft-fail expected validation problems into the report, but must not + * swallow product defects ({@link FcliBugException} / {@code AviatorBugException}). + */ + private static void rethrowIfBug(RuntimeException e) { + if (e instanceof FcliBugException bug) { + throw bug; + } + } + + @FunctionalInterface + public interface TokenValidator { + AviatorUserTokenValidationResult validate(String aviatorUrl, String token); + } + + @FunctionalInterface + public interface AdminValidator { + void validate(AviatorAdminConfigDescriptor configDescriptor); + } + + public record DiagnoseRunResult(List stages, ArrayNode json, boolean requiredFailure) {} +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/connection/helper/AviatorConnectionDiagnoseSource.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/connection/helper/AviatorConnectionDiagnoseSource.java new file mode 100644 index 00000000000..5a89673465e --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/connection/helper/AviatorConnectionDiagnoseSource.java @@ -0,0 +1,173 @@ +/* + * 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.connection.helper; + +import java.util.Objects; +import java.util.Optional; + +import com.fortify.cli.aviator._common.config.admin.helper.AviatorAdminConfigDescriptor; +import com.fortify.cli.aviator._common.session.user.helper.AviatorUserSessionDescriptor; + +/** + * Domain source for Aviator connection diagnostics (no Picocli types). + *

    + * Sealed variants make exclusive modes unrepresentable as dual-null bags: + * bare URL, URL+token, saved user session, or admin config. + */ +public sealed interface AviatorConnectionDiagnoseSource { + + String url(); + + /** Machine-readable source id for endpoint evidence ({@code sourceType}). */ + String sourceTypeId(); + + /** + * Credential check to run after transport stages, if any. + * Bare URL returns empty (no credential stage). + */ + Optional credentialRequest(); + + static AviatorConnectionDiagnoseSource fromUrl(String url) { + return new UrlOnly(url); + } + + static AviatorConnectionDiagnoseSource fromUrlAndToken(String url, String token) { + return new UrlAndToken(url, token); + } + + static AviatorConnectionDiagnoseSource fromUserSession(AviatorUserSessionDescriptor descriptor) { + return new UserSession(descriptor); + } + + static AviatorConnectionDiagnoseSource fromAdminConfig(AviatorAdminConfigDescriptor descriptor) { + return new AdminConfig(descriptor); + } + + /** Product credential stage (not transport stages). */ + sealed interface CredentialRequest { + void accept(Visitor visitor); + + /** + * Exhaustive dispatch over credential modes (Java 17-friendly; no pattern switch). + */ + interface Visitor { + void visitToken(Token token); + + void visitAdmin(Admin admin); + } + + /** + * User token validation. {@code token} is non-null for {@code --url --token}; + * session-sourced tokens may be null/blank (corrupted store) and fail optionally. + */ + record Token(String url, String token) implements CredentialRequest { + public Token { + Objects.requireNonNull(url, "url"); + } + + @Override + public void accept(Visitor visitor) { + visitor.visitToken(this); + } + } + + record Admin(AviatorAdminConfigDescriptor descriptor) implements CredentialRequest { + public Admin { + Objects.requireNonNull(descriptor, "descriptor"); + } + + @Override + public void accept(Visitor visitor) { + visitor.visitAdmin(this); + } + } + } + + record UrlOnly(String url) implements AviatorConnectionDiagnoseSource { + public UrlOnly { + Objects.requireNonNull(url, "url"); + } + + @Override + public String sourceTypeId() { + return "url"; + } + + @Override + public Optional credentialRequest() { + return Optional.empty(); + } + } + + record UrlAndToken(String url, String token) implements AviatorConnectionDiagnoseSource { + public UrlAndToken { + Objects.requireNonNull(url, "url"); + Objects.requireNonNull(token, "token"); + if (token.isBlank()) { + throw new IllegalArgumentException("token must not be blank"); + } + } + + @Override + public String sourceTypeId() { + return "url-token"; + } + + @Override + public Optional credentialRequest() { + return Optional.of(new CredentialRequest.Token(url, token)); + } + } + + record UserSession(AviatorUserSessionDescriptor descriptor) implements AviatorConnectionDiagnoseSource { + public UserSession { + Objects.requireNonNull(descriptor, "descriptor"); + } + + @Override + public String url() { + return descriptor.getAviatorUrl(); + } + + @Override + public String sourceTypeId() { + return "user-session"; + } + + @Override + public Optional credentialRequest() { + return Optional.of(new CredentialRequest.Token(url(), descriptor.getAviatorToken())); + } + } + + record AdminConfig(AviatorAdminConfigDescriptor descriptor) implements AviatorConnectionDiagnoseSource { + public AdminConfig { + Objects.requireNonNull(descriptor, "descriptor"); + } + + @Override + public String url() { + return descriptor.getAviatorUrl(); + } + + @Override + public String sourceTypeId() { + return "admin-config"; + } + + @Override + public Optional credentialRequest() { + return Optional.of(new CredentialRequest.Admin(descriptor)); + } + } +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AbstractAviatorSSCSastAuditCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AbstractAviatorSSCSastAuditCommand.java new file mode 100644 index 00000000000..d800c9fbed8 --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AbstractAviatorSSCSastAuditCommand.java @@ -0,0 +1,381 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.cli.cmd; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.aviator._common.cli.mixin.SourceEncodingsMixin; +import com.fortify.cli.aviator._common.config.AviatorConfigManager; +import com.fortify.cli.aviator._common.session.user.cli.mixin.AviatorUserSessionDescriptorSupplier; +import com.fortify.cli.aviator._common.session.user.helper.AviatorUserSessionDescriptor; +import com.fortify.cli.aviator.audit.AuditFPR; +import com.fortify.cli.aviator.audit.model.AuditFprOptions; +import com.fortify.cli.aviator.audit.model.FPRAuditResult; +import com.fortify.cli.aviator.config.AviatorLoggerImpl; +import com.fortify.cli.aviator.config.TagMappingConfig; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCAuditHelper; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCRefreshHelper; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCTagValidator; +import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.aviator.util.ResourceUtil; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; +import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; +import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; +import com.fortify.cli.common.progress.helper.IProgressWriter; +import com.fortify.cli.common.rest.unirest.UnexpectedHttpResponseException; +import com.fortify.cli.common.util.DisableTest; +import com.fortify.cli.ssc._common.output.cli.cmd.AbstractSSCJsonNodeOutputCommand; +import com.fortify.cli.ssc._common.rest.ssc.SSCUrls; +import com.fortify.cli.ssc._common.rest.ssc.transfer.SSCFileTransferHelper; +import com.fortify.cli.ssc.appversion.cli.mixin.SSCAppVersionRefreshOptions; +import com.fortify.cli.ssc.appversion.cli.mixin.SSCAppVersionResolverMixin; +import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; +import com.fortify.cli.ssc.issue.cli.mixin.SSCIssueFilterSetOptionMixin; + +import kong.unirest.UnirestInstance; +import lombok.Getter; +import lombok.SneakyThrows; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +abstract class AbstractAviatorSSCSastAuditCommand extends AbstractSSCJsonNodeOutputCommand + implements IActionCommandResultSupplier { + @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; + @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; + @Mixin private SSCAppVersionResolverMixin.RequiredOption appVersionResolver; + @Mixin private AviatorUserSessionDescriptorSupplier sessionDescriptorSupplier; + @Mixin private SSCAppVersionRefreshOptions refreshOptions; + @Mixin private SSCIssueFilterSetOptionMixin filterSetOptions; + @Option(names = {"--app"}) private String appName; + @Option(names = {"--tag-mapping"}) private String tagMapping; + @Option(names = {"--no-filterset"}) private boolean noFilterSet; + @Option(names = {"--folder"}, split = ",") @DisableTest(DisableTest.TestType.MULTI_OPT_PLURAL_NAME) private List folderNames; + @ArgGroup(exclusive = true, multiplicity = "0..1") private QuotaHandlingArgGroup quotaHandlingArgGroup = new QuotaHandlingArgGroup(); + @Option(names = {"--test-exceeding-quota"}) private boolean testExceedingQuota; + @Option(names = {"--default-quota-fallback"}) private boolean defaultQuotaFallback; + @Option(names = {"--force-reaudit"}) private boolean forceReaudit; + @Mixin private SourceEncodingsMixin sourceEncodingsMixin; + private static final Logger LOG = LoggerFactory.getLogger(AbstractAviatorSSCSastAuditCommand.class); + private Long checkedQuotaBefore; + + private static final class QuotaHandlingArgGroup { + @Option(names = {"--skip-if-exceeding-quota"}) private boolean skipIfExceedingQuota; + @Option(names = {"--folder-priority-order"}, split = ",") + @DisableTest(DisableTest.TestType.MULTI_OPT_PLURAL_NAME) + private List folderPriorityOrder; + } + + @Override + @SneakyThrows + public JsonNode getJsonNode(UnirestInstance unirest) { + var sessionDescriptor = sessionDescriptorSupplier.getSessionDescriptor(); + Path downloadedFprPath = null; + try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { + AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); + SSCAppVersionDescriptor av = appVersionResolver.getAppVersionDescriptor(unirest); + + AviatorSSCRefreshHelper.refreshMetricsIfNeeded( + unirest, av, refreshOptions.isRefresh(), refreshOptions.getRefreshTimeout(), logger); + + long auditableIssueCount = AviatorSSCAuditHelper.getAuditableIssueCount( + unirest, av, logger, isNoFilterSet(), getFilterSetTitleOrId(), folderNames, forceReaudit); + if (auditableIssueCount == 0) { + logger.progress("Audit skipped - no auditable issues found matching the specified filters."); + ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "SKIPPED"); + AviatorSSCAuditHelper.setOperationMessage(result, "No auditable issues found matching the specified filters"); + return result; + } + + JsonNode quotaResult = checkQuota(unirest, av, sessionDescriptor, auditableIssueCount, logger); + if (quotaResult != null) { + return quotaResult; + } + + downloadedFprPath = downloadFpr(unirest, av, logger); + if (downloadedFprPath == null) { + ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "SKIPPED"); + AviatorSSCAuditHelper.setOperationMessage(result, "No FPR available to audit"); + return result; + } + + ObjectNode result = (ObjectNode) processFpr(unirest, av, sessionDescriptor.getAviatorToken(), sessionDescriptor.getAviatorUrl(), logger, downloadedFprPath); + if (checkedQuotaBefore != null) { + AviatorSSCAuditHelper.setAvailableQuotaBefore(result, checkedQuotaBefore); + } + return result; + } finally { + if (downloadedFprPath != null) { + Files.deleteIfExists(downloadedFprPath); + } + } + } + + String getFilterSetTitleOrId() { + return filterSetOptions.getFilterSetTitleOrId(); + } + + boolean isNoFilterSet() { + return noFilterSet; + } + + private boolean isSkipIfExceedingQuota() { + return quotaHandlingArgGroup.skipIfExceedingQuota; + } + + private List getFolderPriorityOrder() { + return quotaHandlingArgGroup.folderPriorityOrder; + } + + /** + * Checks quota constraints when --skip-if-exceeding-quota or --test-exceeding-quota is active. + * @return a result JsonNode if the audit should be skipped/reported, or null if the audit should proceed. + */ + private JsonNode checkQuota(UnirestInstance unirest, SSCAppVersionDescriptor av, + AviatorUserSessionDescriptor sessionDescriptor, + long auditableIssueCount, AviatorLoggerImpl logger) { + if (!isSkipIfExceedingQuota() && !testExceedingQuota) { + return null; + } + + String effectiveAppName = appName != null ? appName : av.getApplicationName(); + long availableQuota = AviatorSSCAuditHelper.getAvailableQuota( + sessionDescriptor.getAviatorUrl(), sessionDescriptor.getAviatorToken(), + effectiveAppName, logger); + + // App not found — behavior depends on --default-quota-fallback + if (availableQuota == AviatorSSCAuditHelper.QUOTA_APP_NOT_FOUND) { + availableQuota = handleAppNotFound(sessionDescriptor, effectiveAppName, logger); + if (availableQuota == AviatorSSCAuditHelper.QUOTA_APP_NOT_FOUND) { + ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "SKIPPED"); + AviatorSSCAuditHelper.setOperationMessage(result, "Application '" + effectiveAppName + "' not found in Aviator"); + return result; + } + } + + // If auditable issue count is unknown (-1), fail closed when quota protection was requested. + if (auditableIssueCount < 0) { + if (isSkipIfExceedingQuota()) { + LOG.warn("Auditable issue count unknown; cannot honor --skip-if-exceeding-quota for {}:{}. Audit skipped.", + av.getApplicationName(), av.getVersionName()); + ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "SKIPPED"); + AviatorSSCAuditHelper.setOperationMessage(result, + "Cannot determine issue count; audit skipped per --skip-if-exceeding-quota"); + return result; + } + LOG.info("Auditable issue count unknown; skipping quota evaluation for {}:{}.", + av.getApplicationName(), av.getVersionName()); + return null; + } + + return evaluateQuota(unirest, av, effectiveAppName, auditableIssueCount, availableQuota, logger); + } + + /** + * Handles the case where the application is not found in Aviator. + * @return the resolved quota (possibly from default), or QUOTA_APP_NOT_FOUND if audit should be skipped. + */ + private long handleAppNotFound(AviatorUserSessionDescriptor sessionDescriptor, + String effectiveAppName, AviatorLoggerImpl logger) { + if (defaultQuotaFallback) { + logger.progress("Application '%s' not found, using default quota for new applications.", effectiveAppName); + long defaultQuota = AviatorSSCAuditHelper.getDefaultQuota( + sessionDescriptor.getAviatorUrl(), sessionDescriptor.getAviatorToken(), logger); + if (defaultQuota == AviatorSSCAuditHelper.QUOTA_UNKNOWN) { + if (testExceedingQuota) { + // Caller will need to handle this — we return QUOTA_UNKNOWN to signal + return AviatorSSCAuditHelper.QUOTA_UNKNOWN; + } + if (isSkipIfExceedingQuota()) { + LOG.warn("Could not retrieve default quota; cannot honor --skip-if-exceeding-quota. Audit will be skipped."); + return AviatorSSCAuditHelper.QUOTA_APP_NOT_FOUND; + } + logger.progress("Warning: Could not retrieve default quota, proceeding with audit."); + return AviatorSSCAuditHelper.QUOTA_UNKNOWN; + } + return defaultQuota; + } else { + logger.progress("Application '%s' does not exist in Aviator.", effectiveAppName); + return AviatorSSCAuditHelper.QUOTA_APP_NOT_FOUND; + } + } + + /** + * Evaluates the resolved quota against the auditable issue count and returns + * a result node if audit should be skipped, or null to proceed with the audit. + */ + private JsonNode evaluateQuota(UnirestInstance unirest, SSCAppVersionDescriptor av, + String effectiveAppName, long auditableIssueCount, long availableQuota, + AviatorLoggerImpl logger) { + if (availableQuota == AviatorSSCAuditHelper.QUOTA_UNKNOWN) { + if (testExceedingQuota) { + ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "QUOTA_UNKNOWN"); + AviatorSSCAuditHelper.setOperationMessage(result, "Could not retrieve quota for application '" + effectiveAppName + "'"); + return result; + } + if (isSkipIfExceedingQuota()) { + LOG.warn("Could not retrieve quota; cannot honor --skip-if-exceeding-quota for {}:{}. Audit skipped.", + av.getApplicationName(), av.getVersionName()); + ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "SKIPPED"); + AviatorSSCAuditHelper.setOperationMessage(result, + "Could not retrieve quota; audit skipped per --skip-if-exceeding-quota"); + return result; + } + logger.progress("Warning: Could not retrieve quota for '%s', proceeding with audit.", effectiveAppName); + } else if (availableQuota >= 0 && auditableIssueCount > availableQuota) { + checkedQuotaBefore = availableQuota; + var topCategories = AviatorSSCAuditHelper.getTopUnauditedCategories(unirest, av, logger, 10, forceReaudit); + String detailedMessage = AviatorSSCAuditHelper.formatQuotaExceededMessage( + av, auditableIssueCount, availableQuota, topCategories); + LOG.info(detailedMessage); + logger.progress("Quota exceeded for %s:%s -- Open issues: %d, Available quota: %d. Audit skipped.", + av.getApplicationName(), av.getVersionName(), auditableIssueCount, availableQuota); + return AviatorSSCAuditHelper.buildQuotaExceededResultNode( + av, auditableIssueCount, availableQuota, topCategories); + } else if (testExceedingQuota) { + logger.progress("Quota check passed for %s:%s -- Open issues: %d, Available quota: %s", + av.getApplicationName(), av.getVersionName(), auditableIssueCount, + availableQuota < 0 ? "unlimited" : String.valueOf(availableQuota)); + ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "QUOTA_OK"); + AviatorSSCAuditHelper.setOperationMessage(result, String.format("Quota check passed: %d issues, %s quota available", + auditableIssueCount, availableQuota < 0 ? "unlimited" : String.valueOf(availableQuota))); + AviatorSSCAuditHelper.setAvailableQuotaBefore(result, availableQuota); + return result; + } + // Quota was checked and audit is proceeding — capture the value for the final output + checkedQuotaBefore = availableQuota >= 0 ? availableQuota : null; + return null; + } + + @SneakyThrows + private JsonNode processFpr(UnirestInstance unirest, SSCAppVersionDescriptor av, String token, String url, AviatorLoggerImpl logger, Path downloadedFprPath) { + FPRAuditResult auditResult; + + try (FprHandle fprHandle = new FprHandle(downloadedFprPath)) { + auditResult = AuditFPR.auditFPR(AuditFprOptions.builder() + .fprHandle(fprHandle).token(token).url(url) + .appVersion(appName) + .sscAppName(av.getApplicationName()) + .sscAppVersion(av.getVersionName()) + .logger(logger) + .tagMappingPath(tagMapping) + .filterSetNameOrId(getFilterSetTitleOrId()) + .noFilterSet(isNoFilterSet()) + .folderNames(folderNames) + .folderPriorityOrder(getFolderPriorityOrder()) + .forceReaudit(forceReaudit) + .sourceDecoder(sourceEncodingsMixin.getSourceDecoder()) + .build()); + } catch (Exception e) { + LOG.error("FPR audit failed for {}:{}: {}", av.getApplicationName(), av.getVersionName(), e.getMessage(), e); + ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "FAILED"); + AviatorSSCAuditHelper.setOperationMessage(result, "Audit failed: " + e.getMessage()); + return result; + } + + String action = auditResult.getStatus(); + logger.progress(AviatorSSCAuditHelper.getProgressMessage(auditResult)); + + String artifactId = null; + if (auditResult.getUpdatedFile() != null && !"SKIPPED".equals(action) && !"FAILED".equals(action)) { + validateSSCTagsBeforeUpload(unirest, av, logger); + try { + artifactId = uploadAuditedFprToSSC(unirest, auditResult.getUpdatedFile(), av); + } catch (Exception e) { + LOG.error("Failed to upload audited FPR for {}:{}: {}", av.getApplicationName(), av.getVersionName(), e.getMessage(), e); + logger.progress("WARN: Upload of audited FPR to SSC failed: %s", e.getMessage()); + } + } + + ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, artifactId, action); + AviatorSSCAuditHelper.setAuditStats(result, auditResult); + return result; + } + + /** + * Validates that SSC has the required custom tags and Analysis tag values + * before uploading the audited FPR. Emits warnings for any missing tags or + * values so the user can take corrective action. + */ + private void validateSSCTagsBeforeUpload(UnirestInstance unirest, SSCAppVersionDescriptor av, + AviatorLoggerImpl logger) { + LOG.info("Starting SSC tag validation before FPR upload for app version id={}.", av.getVersionId()); + TagMappingConfig tagMappingConfig = loadTagMappingForValidation(); + LOG.debug("Tag mapping config loaded: tag_id='{}', mapping={}", tagMappingConfig.getTag_id(), tagMappingConfig.getMapping()); + Set analysisTagValues = tagMappingConfig.getMappedValues(); + LOG.info("Analysis tag values to validate: {}", analysisTagValues); + List warnings = AviatorSSCTagValidator.validatePreUpload( + unirest, av.getVersionId(), tagMappingConfig.getTag_id(), analysisTagValues, logger); + LOG.info("Tag validation complete. {} warning(s) found.", warnings.size()); + } + + private TagMappingConfig loadTagMappingForValidation() { + TagMappingConfig tagMappingConfig = tagMapping != null && !tagMapping.isBlank() + ? ResourceUtil.loadYamlFile(new java.io.File(tagMapping), TagMappingConfig.class) + : AviatorConfigManager.getInstance().getDefaultTagMappingConfig(); + return tagMappingConfig.resolveForSast(); + } + + private Path downloadFpr(UnirestInstance unirest, SSCAppVersionDescriptor av, AviatorLoggerImpl logger) throws IOException { + logger.progress("Status: Downloading FPR from SSC for app version: %s:%s (id: %s)", av.getApplicationName(), av.getVersionName(), av.getVersionId()); + + String prefix = String.format("aviator_%s_%s_", av.getApplicationName().replaceAll("[^a-zA-Z0-9.-]", "_"), av.getVersionName().replaceAll("[^a-zA-Z0-9.-]", "_")); + Path tempFpr = Files.createTempFile(prefix, ".fpr"); + + try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { + SSCFileTransferHelper.download( + unirest, + SSCUrls.DOWNLOAD_CURRENT_FPR(av.getVersionId(), true), + tempFpr.toFile(), + SSCFileTransferHelper.ISSCAddDownloadTokenFunction.ROUTEPARAM_DOWNLOADTOKEN, + progressWriter); + return tempFpr; + } catch (UnexpectedHttpResponseException e) { + Files.deleteIfExists(tempFpr); + if (e.getStatus() == 400) { + logger.progress("Audit skipped - no FPR available to audit in SSC for app version %s:%s.", av.getApplicationName(), av.getVersionName()); + LOG.info("SSC returned HTTP 400 when downloading FPR for app version id {}. Assuming no FPR is available.", av.getVersionId()); + return null; + } + throw e; + } + } + + @SneakyThrows + private String uploadAuditedFprToSSC(UnirestInstance unirest, File auditedFpr, SSCAppVersionDescriptor av) { + try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { + JsonNode uploadResponse = SSCFileTransferHelper.restUpload(unirest, SSCUrls.PROJECT_VERSION_ARTIFACTS(av.getVersionId()), auditedFpr, JsonNode.class, progressWriter); + return uploadResponse.path("data").path("id").asText("UPLOAD_FAILED"); + } + } + + @Override + public String getActionCommandResult() { + return "AUDITED"; + } + + @Override + public boolean isSingular() { + return true; + } +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommand.java index 5d177bf686e..181af189547 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommand.java @@ -12,193 +12,73 @@ */ package com.fortify.cli.aviator.ssc.cli.cmd; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; import java.time.OffsetDateTime; -import java.util.List; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import java.util.Set; import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.aviator._common.cli.mixin.SourceEncodingsMixin; -import com.fortify.cli.aviator._common.exception.AviatorSimpleException; -import com.fortify.cli.aviator.applyRemediation.ApplyAutoRemediationOnSource; +import com.fortify.cli.aviator._common.output.cli.cmd.AbstractAviatorApplyRemediationsCommand; +import com.fortify.cli.aviator._common.remediations_cache.CacheRemediationsFprSource; +import com.fortify.cli.aviator._common.remediations_cache.IRemediationsFprSource; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsApplyHelper.ApplyResult; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheConstants; import com.fortify.cli.aviator.config.AviatorLoggerImpl; -import com.fortify.cli.aviator.fpr.remediation.model.RemediationMetric; -import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCApplyRemediationsArtifactSelectorMixin; +import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCApplyRemediationsOptionsMixin; +import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCRemediationsSelectorArgGroups.OnlineSelectionArgGroup.ResolvedOnlineArtifacts; import com.fortify.cli.aviator.ssc.helper.AviatorSSCApplyRemediationsHelper; +import com.fortify.cli.aviator.ssc.helper.SSCOnlineRemediationsFprSource; import com.fortify.cli.aviator.ssc.helper.SinceOptionHelper; -import com.fortify.cli.aviator.util.FprHandle; -import com.fortify.cli.common.exception.FcliSimpleException; -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; -import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; import com.fortify.cli.common.progress.helper.IProgressWriter; -import com.fortify.cli.ssc._common.output.cli.cmd.AbstractSSCJsonNodeOutputCommand; -import com.fortify.cli.ssc._common.rest.ssc.SSCUrls; -import com.fortify.cli.ssc._common.rest.ssc.transfer.SSCFileTransferHelper; -import com.fortify.cli.ssc.artifact.helper.SSCArtifactDescriptor; -import com.fortify.cli.ssc.artifact.helper.SSCArtifactHelper; +import com.fortify.cli.ssc._common.rest.ssc.cli.mixin.SSCUnirestInstanceSupplierMixin; import kong.unirest.UnirestInstance; +import lombok.AccessLevel; import lombok.Getter; -import lombok.RequiredArgsConstructor; -import lombok.SneakyThrows; import picocli.CommandLine.Command; import picocli.CommandLine.Mixin; -import picocli.CommandLine.Option; @Command(name = "apply-remediations") -public class AviatorSSCApplyRemediationsCommand extends AbstractSSCJsonNodeOutputCommand - implements IRecordTransformer, IActionCommandResultSupplier { - @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; - @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; - @Mixin private AviatorSSCApplyRemediationsArtifactSelectorMixin artifactSelector; - - private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCApplyRemediationsCommand.class); - @Option(names = {"--source-dir"}, descriptionKey = "fcli.aviator.ssc.apply-remediations.source-dir") - private String sourceCodeDirectory = System.getProperty("user.dir"); - @Mixin private SourceEncodingsMixin sourceEncodingsMixin; +public class AviatorSSCApplyRemediationsCommand extends AbstractAviatorApplyRemediationsCommand { + @Getter(AccessLevel.PROTECTED) @Mixin private AviatorSSCApplyRemediationsOptionsMixin applyOptions; + @Mixin private SSCUnirestInstanceSupplierMixin unirestInstanceSupplier; @Override - @SneakyThrows - public JsonNode getJsonNode(UnirestInstance unirest) { - artifactSelector.validate(); - validateSourceCodeDirectory(); - OffsetDateTime sinceDate = SinceOptionHelper.parse(artifactSelector.getSince()); - try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { - AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); - ArtifactProcessor processor = new ArtifactProcessor(unirest, logger, progressWriter); - - if (artifactSelector.isAllOpenIssuesSelected()) { - return processor.processAllAviatorArtifacts(sinceDate); - } - SSCArtifactDescriptor ad = resolveArtifactDescriptor(unirest, sinceDate); - return processor.processFprRemediations(ad); - } - } - - private SSCArtifactDescriptor resolveArtifactDescriptor(UnirestInstance unirest, OffsetDateTime sinceDate) { - if (artifactSelector.isLatestSelected()) { - return getLatestAviatorArtifact(unirest, sinceDate); - } else { - return SSCArtifactHelper.getArtifactDescriptor(unirest, artifactSelector.getArtifactId()); - } + protected IRemediationsFprSource openFprSource(AviatorLoggerImpl logger, IProgressWriter progressWriter) { + return applyOptions.isFromCacheSelected() + ? openCacheFprSource() + : openOnlineFprSource(logger, progressWriter); } - private SSCArtifactDescriptor getLatestAviatorArtifact(UnirestInstance unirest, OffsetDateTime sinceDate) { - String appVersionId = artifactSelector.getAppVersionId(unirest); - return SSCArtifactHelper.getLatestAviatorArtifact(unirest, appVersionId, sinceDate); + @Override + protected JsonNode buildResultNode(IRemediationsFprSource fprSource, ApplyResult result, Set issueIdFilter) { + return applyOptions.isFromCacheSelected() + ? buildCacheResultNode(fprSource, result, issueIdFilter) + : buildOnlineResultNode(fprSource, result, issueIdFilter); } - private void validateSourceCodeDirectory() { - if (sourceCodeDirectory == null || sourceCodeDirectory.isBlank()) { - throw new FcliSimpleException("--source-dir must specify a valid directory path"); - } + private IRemediationsFprSource openCacheFprSource() { + return CacheRemediationsFprSource.open( + applyOptions.getFromCache(), + RemediationsCacheConstants.PRODUCT_SSC); } - /** - * Inner class to encapsulate artifact processing logic, avoiding the need to pass - * unirest, logger, and progressWriter through multiple method calls. - */ - @RequiredArgsConstructor - private class ArtifactProcessor { - private final UnirestInstance unirest; - private final AviatorLoggerImpl logger; - private final IProgressWriter progressWriter; - - @SneakyThrows - JsonNode processAllAviatorArtifacts(OffsetDateTime sinceDate) { - String appVersionId = artifactSelector.getAppVersionId(unirest); - List artifacts = SSCArtifactHelper.getAllAviatorArtifacts(unirest, appVersionId, sinceDate); - // Process newest-first: the best-matching artifact (most recent scan of the checkout) applies first - // with clean hash matches, and older artifacts then fail anchor checks and are correctly skipped. - java.util.Collections.reverse(artifacts); - - var aggregatedMetrics = RemediationMetric.builder(); - int artifactsProcessed = 0, artifactsSkipped = 0; - - for (SSCArtifactDescriptor ad : artifacts) { - int artifactIndex = artifactsProcessed + artifactsSkipped + 1; - logger.progress("Processing artifact " + artifactIndex + "/" + artifacts.size() + " (id=" + ad.getId() + ")"); - Path fprPath = null; - try { - fprPath = downloadArtifactFpr(ad); - try (FprHandle fprHandle = new FprHandle(fprPath)) { - var metric = ApplyAutoRemediationOnSource.applyRemediations(fprHandle, sourceCodeDirectory, - sourceEncodingsMixin.getSourceDecoder()); - aggregatedMetrics.add(metric); - artifactsProcessed++; - } - } catch (AviatorSimpleException e) { - LOG.warn("Skipping artifact {} as {}", ad.getId(), e.getMessage()); - artifactsSkipped++; - } finally { - if (fprPath != null) { - try { - Files.deleteIfExists(fprPath); - } catch (IOException e) { - LOG.warn("Failed to delete temporary FPR file: {}", fprPath, e); - } - } - } - } - - RemediationMetric aggregated = aggregatedMetrics.build(); - String action = aggregated.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; - return AviatorSSCApplyRemediationsHelper.buildAggregatedResultNode( - appVersionId, artifactsProcessed, artifactsSkipped, aggregated, action); - } - - @SneakyThrows - private Path downloadArtifactFpr(SSCArtifactDescriptor ad) { - Path fprPath = Files.createTempFile("aviator_" + ad.getId() + "_", ".fpr"); - logger.progress("Status: Downloading Audited FPR from SSC (artifact id=" + ad.getId() + ")"); - SSCFileTransferHelper.download( - unirest, - SSCUrls.DOWNLOAD_ARTIFACT(ad.getId(), true), - fprPath.toFile(), - SSCFileTransferHelper.ISSCAddDownloadTokenFunction.ROUTEPARAM_DOWNLOADTOKEN, - progressWriter); - return fprPath; - } - - @SneakyThrows - JsonNode processFprRemediations(SSCArtifactDescriptor ad) { - Path fprPath = downloadArtifactFpr(ad); - try { - logger.progress("Status: Processing FPR with Aviator for Applying Auto Remediations"); - try (FprHandle fprHandle = new FprHandle(fprPath)) { - var remediationMetric = ApplyAutoRemediationOnSource.applyRemediations(fprHandle, sourceCodeDirectory, - sourceEncodingsMixin.getSourceDecoder()); - String status = remediationMetric.appliedRemediations() > 0 - ? "Remediation-Applied" - : "No-Remediation-Applied"; - return AviatorSSCApplyRemediationsHelper.buildResultNode(ad, remediationMetric, status); - } - } finally { - try { - Files.deleteIfExists(fprPath); - } catch (IOException e) { - LOG.warn("Failed to delete temporary downloaded FPR file: {}", fprPath, e); - } - } - } + private IRemediationsFprSource openOnlineFprSource(AviatorLoggerImpl logger, IProgressWriter progressWriter) { + UnirestInstance unirest = unirestInstanceSupplier.getUnirestInstance(); + OffsetDateTime sinceDate = SinceOptionHelper.parse(applyOptions.getOnline().getSince()); + ResolvedOnlineArtifacts resolvedOnline = applyOptions.getOnline().resolveArtifacts(unirest, sinceDate); + return new SSCOnlineRemediationsFprSource(unirest, logger, progressWriter, resolvedOnline); } - @Override - public boolean isSingular() { return true; } - - @Override - public String getActionCommandResult() { - return "Remediations Applied"; + private JsonNode buildCacheResultNode(IRemediationsFprSource fprSource, ApplyResult result, Set issueIdFilter) { + return AviatorSSCApplyRemediationsHelper.buildCacheResultNode( + applyOptions.getFromCache(), result, issueIdFilter, + ((CacheRemediationsFprSource) fprSource).reader().getManifest().getSelection(), + applyOptions.executionMode()); } - @Override - public JsonNode transformRecord(JsonNode record) { - return record; + private JsonNode buildOnlineResultNode(IRemediationsFprSource fprSource, ApplyResult result, Set issueIdFilter) { + ResolvedOnlineArtifacts resolvedOnline = ((SSCOnlineRemediationsFprSource) fprSource).getResolvedOnline(); + return AviatorSSCApplyRemediationsHelper.buildOnlineResultNode( + resolvedOnline.artifacts(), resolvedOnline.appVersionId(), result, issueIdFilter, + applyOptions.executionMode()); } } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommand.java index 2f54cf21b4e..faf43ae58f7 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommand.java @@ -12,407 +12,14 @@ */ package com.fortify.cli.aviator.ssc.cli.cmd; -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fortify.cli.aviator._common.cli.mixin.SourceEncodingsMixin; -import com.fortify.cli.aviator._common.config.AviatorConfigManager; -import com.fortify.cli.aviator._common.session.user.cli.mixin.AviatorUserSessionDescriptorSupplier; -import com.fortify.cli.aviator._common.session.user.helper.AviatorUserSessionDescriptor; -import com.fortify.cli.aviator.audit.AuditFPR; -import com.fortify.cli.aviator.audit.model.AuditFprOptions; -import com.fortify.cli.aviator.audit.model.FPRAuditResult; -import com.fortify.cli.aviator.config.AviatorLoggerImpl; -import com.fortify.cli.aviator.config.TagMappingConfig; -import com.fortify.cli.aviator.ssc.helper.AviatorSSCAuditHelper; -import com.fortify.cli.aviator.ssc.helper.AviatorSSCTagValidator; -import com.fortify.cli.aviator.util.FprHandle; -import com.fortify.cli.aviator.util.ResourceUtil; -import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; -import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; -import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; -import com.fortify.cli.common.progress.helper.IProgressWriter; -import com.fortify.cli.common.rest.unirest.UnexpectedHttpResponseException; -import com.fortify.cli.common.util.DisableTest; import com.fortify.cli.common.variable.DefaultVariablePropertyName; -import com.fortify.cli.ssc._common.output.cli.cmd.AbstractSSCJsonNodeOutputCommand; -import com.fortify.cli.ssc._common.rest.ssc.SSCUrls; -import com.fortify.cli.ssc._common.rest.ssc.transfer.SSCFileTransferHelper; -import com.fortify.cli.ssc.appversion.cli.mixin.SSCAppVersionRefreshOptions; -import com.fortify.cli.ssc.appversion.cli.mixin.SSCAppVersionResolverMixin; -import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; -import com.fortify.cli.ssc.appversion.helper.SSCAppVersionHelper; -import com.fortify.cli.ssc.issue.cli.mixin.SSCIssueFilterSetOptionMixin; -import com.fortify.cli.ssc.system_state.helper.SSCJobDescriptor; -import com.fortify.cli.ssc.system_state.helper.SSCJobHelper; -import kong.unirest.UnirestInstance; -import lombok.Getter; -import lombok.SneakyThrows; -import picocli.CommandLine.ArgGroup; import picocli.CommandLine.Command; -import picocli.CommandLine.Mixin; -import picocli.CommandLine.Option; +/** + * Deprecated SAST audit command retained for backward compatibility. + * Use {@code fcli aviator ssc audit-sast} instead. + */ @Command(name = "audit") @DefaultVariablePropertyName("artifactId") -public class AviatorSSCAuditCommand extends AbstractSSCJsonNodeOutputCommand implements IActionCommandResultSupplier { - @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; - @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; - @Mixin private SSCAppVersionResolverMixin.RequiredOption appVersionResolver; - @Mixin private AviatorUserSessionDescriptorSupplier sessionDescriptorSupplier; - @Mixin private SSCAppVersionRefreshOptions refreshOptions; - @Mixin private SSCIssueFilterSetOptionMixin filterSetOptions; - @Option(names = {"--app"}) private String appName; - @Option(names = {"--tag-mapping"}) private String tagMapping; - @Option(names = {"--no-filterset"}) private boolean noFilterSet; - @Option(names = {"--folder"}, split = ",") @DisableTest(DisableTest.TestType.MULTI_OPT_PLURAL_NAME) private List folderNames; - @ArgGroup(exclusive = true, multiplicity = "0..1") private QuotaHandlingArgGroup quotaHandlingArgGroup = new QuotaHandlingArgGroup(); - @Option(names = {"--test-exceeding-quota"}) private boolean testExceedingQuota; - @Option(names = {"--default-quota-fallback"}) private boolean defaultQuotaFallback; - @Option(names = {"--force-reaudit"}) private boolean forceReaudit; - @Mixin private SourceEncodingsMixin sourceEncodingsMixin; - private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCAuditCommand.class); - private Long checkedQuotaBefore; - - private static final class QuotaHandlingArgGroup { - @Option(names = {"--skip-if-exceeding-quota"}) private boolean skipIfExceedingQuota; - @Option(names = {"--folder-priority-order"}, split = ",") - @DisableTest(DisableTest.TestType.MULTI_OPT_PLURAL_NAME) - private List folderPriorityOrder; - } - - @Override - @SneakyThrows - public JsonNode getJsonNode(UnirestInstance unirest) { - var sessionDescriptor = sessionDescriptorSupplier.getSessionDescriptor(); - Path downloadedFprPath = null; - try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { - AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); - SSCAppVersionDescriptor av = appVersionResolver.getAppVersionDescriptor(unirest); - - refreshMetricsIfNeeded(unirest, av, logger); - - long auditableIssueCount = AviatorSSCAuditHelper.getAuditableIssueCount( - unirest, av, logger, isNoFilterSet(), getFilterSetTitleOrId(), folderNames, forceReaudit); - if (auditableIssueCount == 0) { - logger.progress("Audit skipped - no auditable issues found matching the specified filters."); - ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "SKIPPED"); - AviatorSSCAuditHelper.setOperationMessage(result, "No auditable issues found matching the specified filters"); - return result; - } - - JsonNode quotaResult = checkQuota(unirest, av, sessionDescriptor, auditableIssueCount, logger); - if (quotaResult != null) { - return quotaResult; - } - - downloadedFprPath = downloadFpr(unirest, av, logger); - if (downloadedFprPath == null) { - ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "SKIPPED"); - AviatorSSCAuditHelper.setOperationMessage(result, "No FPR available to audit"); - return result; - } - - ObjectNode result = (ObjectNode) processFpr(unirest, av, sessionDescriptor.getAviatorToken(), sessionDescriptor.getAviatorUrl(), logger, downloadedFprPath); - if (checkedQuotaBefore != null) { - AviatorSSCAuditHelper.setAvailableQuotaBefore(result, checkedQuotaBefore); - } - return result; - } finally { - if (downloadedFprPath != null) { - Files.deleteIfExists(downloadedFprPath); - } - } - } - - String getFilterSetTitleOrId() { - return filterSetOptions.getFilterSetTitleOrId(); - } - - boolean isNoFilterSet() { - return noFilterSet; - } - - private void refreshMetricsIfNeeded(UnirestInstance unirest, SSCAppVersionDescriptor av, AviatorLoggerImpl logger) { - if (refreshOptions.isRefresh() && av.isRefreshRequired()) { - logger.progress("Status: Metrics for application version %s:%s are out of date, starting refresh...", av.getApplicationName(), av.getVersionName()); - SSCJobDescriptor refreshJobDesc = SSCAppVersionHelper.refreshMetrics(unirest, av); - if (refreshJobDesc != null) { - SSCJobHelper.waitForJob(unirest, refreshJobDesc, refreshOptions.getRefreshTimeout()); - logger.progress("Status: Metrics refreshed successfully."); - } - } - } - - private boolean isSkipIfExceedingQuota() { - return quotaHandlingArgGroup.skipIfExceedingQuota; - } - - private List getFolderPriorityOrder() { - return quotaHandlingArgGroup.folderPriorityOrder; - } - - /** - * Checks quota constraints when --skip-if-exceeding-quota or --test-exceeding-quota is active. - * Fails closed when quota cannot be reliably determined and user requested quota protection. - * @return a result JsonNode if the audit should be skipped/reported, or null if the audit should proceed. - */ - private JsonNode checkQuota(UnirestInstance unirest, SSCAppVersionDescriptor av, - AviatorUserSessionDescriptor sessionDescriptor, - long auditableIssueCount, AviatorLoggerImpl logger) { - if (!isSkipIfExceedingQuota() && !testExceedingQuota) { - return null; - } - - String effectiveAppName = appName != null ? appName : av.getApplicationName(); - long availableQuota = AviatorSSCAuditHelper.getAvailableQuota( - sessionDescriptor.getAviatorUrl(), sessionDescriptor.getAviatorToken(), - effectiveAppName, logger); - - // App not found — behavior depends on --default-quota-fallback - if (availableQuota == AviatorSSCAuditHelper.QUOTA_APP_NOT_FOUND) { - availableQuota = handleAppNotFound(sessionDescriptor, effectiveAppName, logger); - if (availableQuota == AviatorSSCAuditHelper.QUOTA_APP_NOT_FOUND) { - ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "SKIPPED"); - AviatorSSCAuditHelper.setOperationMessage(result, "Application '" + effectiveAppName + "' not found in Aviator"); - return result; - } - } - - // If auditable issue count is unknown (-1), fail closed when user requested quota protection - if (auditableIssueCount < 0) { - if (isSkipIfExceedingQuota()) { - LOG.warn("Auditable issue count unknown; cannot honor --skip-if-exceeding-quota for {}:{}. Audit skipped.", - av.getApplicationName(), av.getVersionName()); - ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "SKIPPED"); - AviatorSSCAuditHelper.setOperationMessage(result, - "Cannot determine issue count; audit skipped per --skip-if-exceeding-quota"); - return result; - } - LOG.info("Auditable issue count unknown; skipping quota evaluation for {}:{}.", - av.getApplicationName(), av.getVersionName()); - return null; - } - - return evaluateQuota(unirest, av, effectiveAppName, auditableIssueCount, availableQuota, logger); - } - - /** - * Handles the case where the application is not found in Aviator. - * Fails closed when default quota cannot be determined and quota protection is enabled. - * @return the resolved quota (possibly from default), or QUOTA_APP_NOT_FOUND if audit should be skipped. - */ - private long handleAppNotFound(AviatorUserSessionDescriptor sessionDescriptor, - String effectiveAppName, AviatorLoggerImpl logger) { - if (defaultQuotaFallback) { - logger.progress("Application '%s' not found, using default quota for new applications.", effectiveAppName); - long defaultQuota = AviatorSSCAuditHelper.getDefaultQuota( - sessionDescriptor.getAviatorUrl(), sessionDescriptor.getAviatorToken(), logger); - if (defaultQuota == AviatorSSCAuditHelper.QUOTA_UNKNOWN) { - if (testExceedingQuota) { - // Caller will need to handle this — we return QUOTA_UNKNOWN to signal - return AviatorSSCAuditHelper.QUOTA_UNKNOWN; - } - // Fail closed when user requested quota protection but cannot determine default quota - if (isSkipIfExceedingQuota()) { - LOG.warn("Could not retrieve default quota; cannot honor --skip-if-exceeding-quota. Audit will be skipped."); - return AviatorSSCAuditHelper.QUOTA_APP_NOT_FOUND; - } - logger.progress("Warning: Could not retrieve default quota, proceeding with audit."); - return AviatorSSCAuditHelper.QUOTA_UNKNOWN; - } - return defaultQuota; - } else { - logger.progress("Application '%s' does not exist in Aviator.", effectiveAppName); - return AviatorSSCAuditHelper.QUOTA_APP_NOT_FOUND; - } - } - - /** - * Evaluates the resolved quota against the auditable issue count and returns - * a result node if audit should be skipped/reported, or null to proceed with the audit. - * Fails closed when quota cannot be reliably determined and user requested quota protection. - */ - private JsonNode evaluateQuota(UnirestInstance unirest, SSCAppVersionDescriptor av, - String effectiveAppName, long auditableIssueCount, long availableQuota, - AviatorLoggerImpl logger) { - if (availableQuota == AviatorSSCAuditHelper.QUOTA_UNKNOWN) { - if (testExceedingQuota) { - ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "QUOTA_UNKNOWN"); - AviatorSSCAuditHelper.setOperationMessage(result, "Could not retrieve quota for application '" + effectiveAppName + "'"); - return result; - } - // Fail closed when user requested quota protection but cannot determine quota - if (isSkipIfExceedingQuota()) { - LOG.warn("Could not retrieve quota; cannot honor --skip-if-exceeding-quota for {}:{}. Audit skipped.", - av.getApplicationName(), av.getVersionName()); - ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "SKIPPED"); - AviatorSSCAuditHelper.setOperationMessage(result, - "Could not retrieve quota; audit skipped per --skip-if-exceeding-quota"); - return result; - } - logger.progress("Warning: Could not retrieve quota for '%s', proceeding with audit.", effectiveAppName); - } else if (availableQuota >= 0 && auditableIssueCount > availableQuota) { - checkedQuotaBefore = availableQuota; - var topCategories = AviatorSSCAuditHelper.getTopUnauditedCategories(unirest, av, logger, 10, forceReaudit); - String detailedMessage = AviatorSSCAuditHelper.formatQuotaExceededMessage( - av, auditableIssueCount, availableQuota, topCategories); - LOG.info(detailedMessage); - logger.progress("Quota exceeded for %s:%s -- Open issues: %d, Available quota: %d. Audit skipped.", - av.getApplicationName(), av.getVersionName(), auditableIssueCount, availableQuota); - return AviatorSSCAuditHelper.buildQuotaExceededResultNode( - av, auditableIssueCount, availableQuota, topCategories); - } else if (testExceedingQuota) { - logger.progress("Quota check passed for %s:%s -- Open issues: %d, Available quota: %s", - av.getApplicationName(), av.getVersionName(), auditableIssueCount, - availableQuota < 0 ? "unlimited" : String.valueOf(availableQuota)); - ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "QUOTA_OK"); - AviatorSSCAuditHelper.setOperationMessage(result, String.format("Quota check passed: %d issues, %s quota available", - auditableIssueCount, availableQuota < 0 ? "unlimited" : String.valueOf(availableQuota))); - AviatorSSCAuditHelper.setAvailableQuotaBefore(result, availableQuota); - return result; - } - // Quota was checked and audit is proceeding — capture the value for the final output - checkedQuotaBefore = availableQuota >= 0 ? availableQuota : null; - return null; - } - - @SneakyThrows - private JsonNode processFpr(UnirestInstance unirest, SSCAppVersionDescriptor av, String token, String url, AviatorLoggerImpl logger, Path downloadedFprPath) { - FPRAuditResult auditResult; - - try (FprHandle fprHandle = new FprHandle(downloadedFprPath)) { - auditResult = AuditFPR.auditFPR(AuditFprOptions.builder() - .fprHandle(fprHandle).token(token).url(url) - .appVersion(appName) - .sscAppName(av.getApplicationName()) - .sscAppVersion(av.getVersionName()) - .logger(logger) - .tagMappingPath(tagMapping) - .filterSetNameOrId(getFilterSetTitleOrId()) - .noFilterSet(isNoFilterSet()) - .folderNames(folderNames) - .folderPriorityOrder(getFolderPriorityOrder()) - .forceReaudit(forceReaudit) - .sourceDecoder(sourceEncodingsMixin.getSourceDecoder()) - .build()); - } catch (Exception e) { - LOG.error("FPR audit failed for {}:{}: {}", av.getApplicationName(), av.getVersionName(), e.getMessage(), e); - ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "FAILED"); - AviatorSSCAuditHelper.setOperationMessage(result, "Audit failed: " + e.getMessage()); - return result; - } - - String action = auditResult.getStatus(); - logger.progress(AviatorSSCAuditHelper.getProgressMessage(auditResult)); - - String artifactId = null; - if (auditResult.getUpdatedFile() != null && !"SKIPPED".equals(action) && !"FAILED".equals(action)) { - validateSSCTagsBeforeUpload(unirest, av, logger); - try { - artifactId = uploadAuditedFprToSSC(unirest, auditResult.getUpdatedFile(), av); - } catch (Exception e) { - LOG.error("Failed to upload audited FPR for {}:{}: {}", av.getApplicationName(), av.getVersionName(), e.getMessage(), e); - logger.progress("WARN: Upload of audited FPR to SSC failed: %s", e.getMessage()); - } - } - - ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, artifactId, action); - AviatorSSCAuditHelper.setAuditStats(result, auditResult); - return result; - } - - /** - * Validates that SSC has the required custom tags and Analysis tag values - * before uploading the audited FPR. Emits warnings for any missing tags or - * values so the user can take corrective action. - */ - private void validateSSCTagsBeforeUpload(UnirestInstance unirest, SSCAppVersionDescriptor av, - AviatorLoggerImpl logger) { - LOG.info("Starting SSC tag validation before FPR upload for app version id={}.", av.getVersionId()); - TagMappingConfig tagMappingConfig = loadTagMappingForValidation(); - LOG.debug("Tag mapping config loaded: tag_id='{}', mapping={}", tagMappingConfig.getTag_id(), tagMappingConfig.getMapping()); - Set analysisTagValues = extractAnalysisTagValues(tagMappingConfig); - LOG.info("Analysis tag values to validate: {}", analysisTagValues); - List warnings = AviatorSSCTagValidator.validatePreUpload( - unirest, av.getVersionId(), tagMappingConfig.getTag_id(), analysisTagValues, logger); - LOG.info("Tag validation complete. {} warning(s) found.", warnings.size()); - } - - private TagMappingConfig loadTagMappingForValidation() { - if (tagMapping != null && !tagMapping.isBlank()) { - return ResourceUtil.loadYamlFile(new java.io.File(tagMapping), TagMappingConfig.class); - } - return AviatorConfigManager.getInstance().getDefaultTagMappingConfig(); - } - - private Set extractAnalysisTagValues(TagMappingConfig config) { - Set values = new LinkedHashSet<>(); - if (config.getMapping() != null) { - addTierValues(values, config.getMapping().getTier_1()); - addTierValues(values, config.getMapping().getTier_2()); - } - return values; - } - - private void addTierValues(Set values, TagMappingConfig.Tier tier) { - if (tier == null) return; - if (tier.getFp() != null && tier.getFp().getValue() != null) values.add(tier.getFp().getValue()); - if (tier.getTp() != null && tier.getTp().getValue() != null) values.add(tier.getTp().getValue()); - if (tier.getUnsure() != null && tier.getUnsure().getValue() != null) values.add(tier.getUnsure().getValue()); - } - - private Path downloadFpr(UnirestInstance unirest, SSCAppVersionDescriptor av, AviatorLoggerImpl logger) throws IOException { - logger.progress("Status: Downloading FPR from SSC for app version: %s:%s (id: %s)", av.getApplicationName(), av.getVersionName(), av.getVersionId()); - - String prefix = String.format("aviator_%s_%s_", av.getApplicationName().replaceAll("[^a-zA-Z0-9.-]", "_"), av.getVersionName().replaceAll("[^a-zA-Z0-9.-]", "_")); - Path tempFpr = Files.createTempFile(prefix, ".fpr"); - - try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { - SSCFileTransferHelper.download( - unirest, - SSCUrls.DOWNLOAD_CURRENT_FPR(av.getVersionId(), true), - tempFpr.toFile(), - SSCFileTransferHelper.ISSCAddDownloadTokenFunction.ROUTEPARAM_DOWNLOADTOKEN, - progressWriter); - return tempFpr; - } catch (UnexpectedHttpResponseException e) { - Files.deleteIfExists(tempFpr); - if (e.getStatus() == 400) { - logger.progress("Audit skipped - no FPR available to audit in SSC for app version %s:%s.", av.getApplicationName(), av.getVersionName()); - LOG.info("SSC returned HTTP 400 when downloading FPR for app version id {}. Assuming no FPR is available.", av.getVersionId()); - return null; - } - throw e; - } - } - - @SneakyThrows - private String uploadAuditedFprToSSC(UnirestInstance unirest, File auditedFpr, SSCAppVersionDescriptor av) { - try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { - JsonNode uploadResponse = SSCFileTransferHelper.restUpload(unirest, SSCUrls.PROJECT_VERSION_ARTIFACTS(av.getVersionId()), auditedFpr, JsonNode.class, progressWriter); - return uploadResponse.path("data").path("id").asText("UPLOAD_FAILED"); - } - } - - @Override - public String getActionCommandResult() { - return "AUDITED"; - } - - @Override - public boolean isSingular() { - return true; - } -} +public class AviatorSSCAuditCommand extends AbstractAviatorSSCSastAuditCommand {} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCommands.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCommands.java index 79125d5c3fe..9e41052853e 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCommands.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCommands.java @@ -20,8 +20,11 @@ name = "ssc", subcommands = { AviatorSSCAuditCommand.class, + AviatorSSCSastAuditCommand.class, + AviatorSSCDastAuditCommand.class, AviatorSSCPrepareCommand.class, AviatorSSCApplyRemediationsCommand.class, + AviatorSSCDownloadRemediationsCacheCommand.class, AviatorSSCCorrelateSastDastCommand.class } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCorrelateSastDastCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCorrelateSastDastCommand.java index 4411e1c2386..bdfca5b68e5 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCorrelateSastDastCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCorrelateSastDastCommand.java @@ -15,6 +15,8 @@ import static com.fortify.cli.ssc.artifact.helper.SSCArtifactHelper.getLatestDASTArtifact; import static com.fortify.cli.ssc.artifact.helper.SSCArtifactHelper.getLatestSASTArtifact; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.HashSet; import java.util.List; @@ -33,15 +35,16 @@ import com.fortify.cli.aviator.fpr.Vulnerability; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; -import com.fortify.cli.aviator.grpc.CorrelatedPair; import com.fortify.cli.aviator.grpc.CorrelationResult; import com.fortify.cli.aviator.grpc.CorrelationStreamConfig; import com.fortify.cli.aviator.grpc.CorrelationStreamProcessor; -import com.fortify.cli.aviator.ssc.helper.AviatorSSCCorrelateDownloadHelper; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCAttributeHelper; import com.fortify.cli.aviator.ssc.helper.AviatorSSCCorrelateFprParser; import com.fortify.cli.aviator.ssc.helper.AviatorSSCCorrelateFprParser.ParseResult; import com.fortify.cli.aviator.ssc.helper.AviatorSSCCorrelateHelper; -import com.fortify.cli.aviator.ssc.helper.AviatorSSCCorrelationAttributeHelper; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCCorrelateOutput; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCFprTransferHelper; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCRefreshHelper; import com.fortify.cli.aviator.ssc.helper.CategoryBucket; import com.fortify.cli.aviator.ssc.helper.CategoryGrouper; import com.fortify.cli.aviator.ssc.helper.DastFprCorrelationEnricher; @@ -52,9 +55,9 @@ import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; import com.fortify.cli.common.progress.helper.IProgressWriter; import com.fortify.cli.ssc._common.output.cli.cmd.AbstractSSCJsonNodeOutputCommand; +import com.fortify.cli.ssc.appversion.cli.mixin.SSCAppVersionRefreshOptions; import com.fortify.cli.ssc.appversion.cli.mixin.SSCAppVersionResolverMixin; import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; -import com.fortify.cli.ssc.artifact.helper.SSCArtifactDescriptor; import kong.unirest.UnirestInstance; import lombok.Getter; @@ -69,10 +72,12 @@ public class AviatorSSCCorrelateSastDastCommand extends AbstractSSCJsonNodeOutpu @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; @Mixin private SSCAppVersionResolverMixin.RequiredOption appVersionResolver; @Mixin private AviatorUserSessionDescriptorSupplier sessionDescriptorSupplier; + @Mixin private SSCAppVersionRefreshOptions refreshOptions; @Option(names = {"--app"}) private String appName; private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCCorrelateSastDastCommand.class); private String actionResult = "CORRELATED"; + private String actionMessage; @Override public JsonNode getJsonNode(UnirestInstance unirest) { @@ -97,46 +102,94 @@ private class CorrelationProcessor { private final SSCAppVersionDescriptor av; private final AviatorUserSessionDescriptor sessionDescriptor; - private record DownloadedFprs(Path sastPath, Path dastPath, SSCArtifactDescriptor adDast) {} + private record CorrelationFiles(Path statePath, Path historyPath, boolean unchangedSinceCorrelation) + implements AutoCloseable { + @Override + public void close() { + deleteTemporaryFile(statePath); + deleteTemporaryFile(historyPath); + } + } JsonNode run() { logger.progress("Status: Starting SAST-DAST correlation for %s:%s", av.getApplicationName(), av.getVersionName()); - var fprs = downloadFprs(); - var sastResult = parseFpr(fprs.sastPath, "SAST"); - var dastResult = parseFpr(fprs.dastPath, "DAST"); + AviatorSSCRefreshHelper.refreshMetricsIfNeeded( + unirest, av, refreshOptions.isRefresh(), refreshOptions.getRefreshTimeout(), logger); + try (var files = downloadCorrelationFiles()) { + return correlate(files); + } + } + + private JsonNode correlate(CorrelationFiles files) { + var sastResult = parseFpr(files.statePath(), "SAST"); + var dastResult = parseFpr(files.statePath(), "DAST"); var unsuppressedSast = filterUnsuppressedSast(sastResult); var unsuppressedDast = filterUnsuppressedDast(dastResult); - var alreadyTriedKeys = buildAlreadyTriedKeys(unsuppressedDast, fprs.sastPath, sastResult, dastResult); + var alreadyTriedKeys = buildAlreadyTriedKeys( + unsuppressedDast, files.statePath(), files.historyPath(), sastResult, dastResult); + + if (files.unchangedSinceCorrelation() && !alreadyTriedKeys.isEmpty()) { + actionResult = "SKIPPED"; + actionMessage = "No newer SAST or DAST scan found"; + logger.progress("Status: No newer SAST or DAST scan found — skipping correlation and FPR upload."); + return buildOutputJson(null, CorrelationResult.empty()); + } var mixedBuckets = groupByCategory(unsuppressedSast, unsuppressedDast); - int submitted = countNewSastFindings(mixedBuckets, alreadyTriedKeys); - var grpcResult = correlateViaGrpc(mixedBuckets, alreadyTriedKeys, submitted, sastResult); - String uploadedArtifactId = uploadCorrelatedFprs(fprs, grpcResult); + var grpcResult = correlateViaGrpc(mixedBuckets, alreadyTriedKeys, sastResult); + String uploadedArtifactId = uploadCorrelationResults(files.statePath(), grpcResult); logger.progress("Status: Correlation process complete for %s:%s — result: %s", av.getApplicationName(), av.getVersionName(), actionResult); - return AviatorSSCCorrelateHelper.buildOutputJson( - av, uploadedArtifactId, submitted, grpcResult.succeeded, grpcResult.confirmed, actionResult); + return buildOutputJson(uploadedArtifactId, grpcResult); } - private DownloadedFprs downloadFprs() { + private JsonNode buildOutputJson(String artifactId, CorrelationResult correlationResult) { + return AviatorSSCCorrelateOutput.builder() + .appVersion(av) + .artifactId(artifactId) + .correlationResult(correlationResult) + .actionResult(actionResult) + .message(actionMessage) + .build() + .toJsonNode(); + } + + private CorrelationFiles downloadCorrelationFiles() { + Path statePath = null; + Path historyPath = null; try { - logger.progress("Status: Downloading SAST FPR from SSC for %s:%s", av.getApplicationName(), av.getVersionName()); - var adSast = getLatestSASTArtifact(unirest, av.getVersionId()); - var sastPath = AviatorSSCCorrelateDownloadHelper.downloadArtifactFpr(unirest, adSast, logger, progressWriter); - - logger.progress("Status: Downloading DAST FPR from SSC for %s:%s", av.getApplicationName(), av.getVersionName()); - var adDast = getLatestDASTArtifact(unirest, av.getVersionId()); - var dastPath = AviatorSSCCorrelateDownloadHelper.downloadArtifactFpr(unirest, adDast, logger, progressWriter); - - AviatorSSCCorrelateHelper.validateDownloadedFpr(sastPath, "SAST"); - AviatorSSCCorrelateHelper.validateDownloadedFpr(dastPath, "DAST"); - return new DownloadedFprs(sastPath, dastPath, adDast); - } catch (java.io.IOException e) { + statePath = AviatorSSCFprTransferHelper.downloadCurrentStateFpr( + unirest, av, logger, progressWriter); + var sastArtifact = getLatestSASTArtifact(unirest, av.getVersionId()); + var historyArtifact = getLatestDASTArtifact(unirest, av.getVersionId()); + historyPath = AviatorSSCFprTransferHelper.downloadArtifactFpr( + unirest, historyArtifact, logger, progressWriter); + AviatorSSCCorrelateHelper.validateDownloadedFpr(statePath, "merged"); + AviatorSSCCorrelateHelper.validateDownloadedFpr(historyPath, "correlation history"); + boolean unchangedSinceCorrelation = AviatorSSCCorrelateHelper.isUnchangedSinceCorrelation( + sastArtifact, historyArtifact); + return new CorrelationFiles(statePath, historyPath, unchangedSinceCorrelation); + } catch (IOException e) { + deleteTemporaryFile(statePath); + deleteTemporaryFile(historyPath); throw new FcliSimpleException("Failed to download FPR from SSC: " + e.getMessage(), e); + } catch (RuntimeException e) { + deleteTemporaryFile(statePath); + deleteTemporaryFile(historyPath); + throw e; + } + } + + private static void deleteTemporaryFile(Path path) { + if (path == null) return; + try { + Files.deleteIfExists(path); + } catch (IOException e) { + LOG.warn("Failed to delete temporary correlation FPR {}", path, e); } } @@ -159,17 +212,20 @@ private List filterUnsuppressedDast(ParseResult dastResult) { .collect(Collectors.toList()); } - private Set buildAlreadyTriedKeys(List unsuppressedDast, Path sastFprPath, + private Set buildAlreadyTriedKeys(List unsuppressedDast, Path statePath, Path historyPath, ParseResult sastResult, ParseResult dastResult) { Set confirmedPairKeys = buildPreviouslyCorrelatedPairKeys(unsuppressedDast); - Set rejectedPairKeys = SastFprCorrelationRecorder.readTriedPairKeys(sastFprPath); + Set statePairKeys = SastFprCorrelationRecorder.readTriedPairKeys(statePath); + Set historyPairKeys = SastFprCorrelationRecorder.readTriedPairKeys(historyPath); Set alreadyTriedKeys = new HashSet<>(confirmedPairKeys); - alreadyTriedKeys.addAll(rejectedPairKeys); + alreadyTriedKeys.addAll(statePairKeys); + alreadyTriedKeys.addAll(historyPairKeys); LOG.info("Total SAST issues {}", sastResult.vulnerabilities.size()); LOG.info("Total DAST issues {}", dastResult.dastIssues.size()); LOG.info("Confirmed pairs (from ExternalFindings): {}", confirmedPairKeys.size()); - LOG.info("Pairs from DAST_CORRELATION_STATUS tag: {}", rejectedPairKeys.size()); + LOG.info("Pairs from current-state DAST_CORRELATION_STATUS tags: {}", statePairKeys.size()); + LOG.info("Pairs from latest successful DAST artifact tags: {}", historyPairKeys.size()); LOG.info("Total already-tried pairs (will be skipped): {}", alreadyTriedKeys.size()); return alreadyTriedKeys; } @@ -183,18 +239,17 @@ private List groupByCategory(List sast, List confirmed, List rejected, int succeeded) {} - - private GrpcResult correlateViaGrpc(List mixedBuckets, Set alreadyTriedKeys, - int submitted, ParseResult sastResult) { + private CorrelationResult correlateViaGrpc(List mixedBuckets, + Set alreadyTriedKeys, + ParseResult sastResult) { if (mixedBuckets.isEmpty()) { actionResult = "SKIPPED"; + actionMessage = "No issues present for correlation"; logger.progress("Status: No mixed categories found — skipping correlation."); - return new GrpcResult(List.of(), List.of(), 0); + return CorrelationResult.empty(); } - logger.progress("Status: Found %d mixed category bucket(s) with %d SAST findings to correlate", - mixedBuckets.size(), submitted); + logger.progress("Status: Found %d mixed category bucket(s) to correlate", mixedBuckets.size()); var bucketData = mixedBuckets.stream() .map(b -> new CorrelationStreamProcessor.CorrelationBucketData( @@ -206,61 +261,58 @@ private GrpcResult correlateViaGrpc(List mixedBuckets, Set confirmed; - List rejected; - int succeeded; + CorrelationResult result; try (var grpcClient = AviatorGrpcClientHelper.createClient(sessionDescriptor.getAviatorUrl(), logger, 30)) { - var result = performCorrelation(grpcClient, config, bucketData, sastResult.scanGuid, alreadyTriedKeys); - confirmed = result.confirmedPairs(); - rejected = result.rejectedPairs(); - succeeded = result.receivedCorrelationResponses(); + result = performCorrelation(grpcClient, config, bucketData, sastResult.scanGuid, alreadyTriedKeys); } - logger.progress("Status: Correlation complete — %d of %d SAST findings confirmed as correlated", - confirmed.size(), submitted); - actionResult = succeeded == 0 ? "SKIPPED" : succeeded < submitted ? "PARTIALLY_CORRELATED" : "CORRELATED"; - return new GrpcResult(confirmed, rejected, succeeded); - } - - private String uploadCorrelatedFprs(DownloadedFprs fprs, GrpcResult grpcResult) { - String uploadedArtifactId = null; - if (!grpcResult.confirmed.isEmpty()) { - uploadedArtifactId = uploadEnrichedDastFpr(fprs, grpcResult.confirmed); - } else { - logger.progress("Status: No correlated pairs found — skipping DAST FPR upload."); - } - - if (!grpcResult.confirmed.isEmpty() || !grpcResult.rejected.isEmpty()) { - uploadTaggedSastFpr(fprs.sastPath, grpcResult.confirmed, grpcResult.rejected); - writeLastCorrelationTimestamp(); + logger.progress("Status: Correlation complete — %d pairs confirmed from %d submitted SAST findings", + result.confirmedPairs().size(), result.submittedCorrelationRequests()); + actionResult = getActionResult(result); + if ("SKIPPED".equals(actionResult)) { + actionMessage = "No issues present for correlation"; } - return uploadedArtifactId; + return result; } - private String uploadEnrichedDastFpr(DownloadedFprs fprs, List confirmed) { - logger.progress("Status: Injecting correlation data into DAST FPR (%d correlated pair(s))...", confirmed.size()); - new DastFprCorrelationEnricher().injectAndRepackage(fprs.dastPath, confirmed); - - logger.progress("Status: Uploading correlated DAST FPR to SSC..."); - AviatorSSCCorrelateDownloadHelper.uploadEnrichedDastFpr(unirest, av, fprs.dastPath, progressWriter); - String artifactId = fprs.adDast.getId(); - logger.progress("Status: Correlated DAST FPR uploaded successfully (artifact id=%s)", artifactId); - return artifactId; + private String getActionResult(CorrelationResult result) { + int submitted = result.submittedCorrelationRequests(); + int succeeded = result.successfulCorrelationResponses(); + int skipped = result.skippedCorrelationResponses(); + int failed = result.failedCorrelationResponses(); + return submitted == 0 ? "SKIPPED" + : failed == submitted ? "FAILED" + : succeeded == 0 ? "SKIPPED" + : failed > 0 || skipped > 0 ? "PARTIALLY_CORRELATED" : "CORRELATED"; } - private void uploadTaggedSastFpr(Path sastPath, List confirmed, List rejected) { - logger.progress("Status: Writing correlation status tags to SAST FPR (%d confirmed, %d rejected)...", - confirmed.size(), rejected.size()); - SastFprCorrelationRecorder.writeCorrelationTags(sastPath, confirmed, rejected); + private String uploadCorrelationResults(Path statePath, CorrelationResult result) { + if (result.confirmedPairs().isEmpty() && result.rejectedPairs().isEmpty()) { + logger.progress("Status: No correlation results found — skipping FPR upload."); + return null; + } - logger.progress("Status: Uploading updated SAST FPR to SSC..."); - AviatorSSCCorrelateDownloadHelper.uploadEnrichedSastFpr(unirest, av, sastPath, progressWriter); - logger.progress("Status: Updated SAST FPR uploaded successfully."); + if (!result.confirmedPairs().isEmpty()) { + logger.progress("Status: Injecting correlation data into merged FPR (%d correlated pair(s))...", + result.confirmedPairs().size()); + new DastFprCorrelationEnricher().injectAndRepackage(statePath, result.confirmedPairs()); + } + logger.progress("Status: Writing correlation status tags to merged FPR (%d confirmed, %d rejected)...", + result.confirmedPairs().size(), result.rejectedPairs().size()); + SastFprCorrelationRecorder.writeCorrelationTags( + statePath, result.confirmedPairs(), result.rejectedPairs()); + + logger.progress("Status: Uploading correlated merged FPR to SSC..."); + String artifactId = AviatorSSCFprTransferHelper.uploadFpr( + unirest, av, statePath, progressWriter); + logger.progress("Status: Correlated merged FPR uploaded (artifact id=%s)", artifactId); + writeLastCorrelationTimestamp(); + return artifactId; } private void writeLastCorrelationTimestamp() { logger.progress("Status: Writing last_correlation timestamp to app version..."); - AviatorSSCCorrelationAttributeHelper.writeLastCorrelationTimestamp(unirest, av.getVersionId()); + AviatorSSCAttributeHelper.writeLastCorrelationTimestamp(unirest, av.getVersionId()); logger.progress("Status: last_correlation timestamp written successfully."); } @@ -295,21 +347,6 @@ private Set buildPreviouslyCorrelatedPairKeys(List dastIssues return keys; } - private int countNewSastFindings(List buckets, Set alreadyTriedKeys) { - if (alreadyTriedKeys.isEmpty()) { - return buckets.stream().mapToInt(CategoryBucket::getSastCount).sum(); - } - int count = 0; - for (CategoryBucket bucket : buckets) { - for (Vulnerability sast : bucket.getSastFindings()) { - boolean hasNewPairing = bucket.getDastFindings().stream() - .anyMatch(dast -> !alreadyTriedKeys.contains(sast.getInstanceID() + "::" + dast.getId())); - if (hasNewPairing) count++; - } - } - return count; - } - @Override public String getActionCommandResult() { return actionResult; diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDastAuditCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDastAuditCommand.java new file mode 100644 index 00000000000..ce9368798ab --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDastAuditCommand.java @@ -0,0 +1,185 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.cli.cmd; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.aviator._common.config.AviatorConfigManager; +import com.fortify.cli.aviator._common.session.user.cli.mixin.AviatorUserSessionDescriptorSupplier; +import com.fortify.cli.aviator._common.session.user.helper.AviatorUserSessionDescriptor; +import com.fortify.cli.aviator.audit.DastAuditFPR; +import com.fortify.cli.aviator.audit.DastAuditFprResult; +import com.fortify.cli.aviator.audit.DastAuditFprStatus; +import com.fortify.cli.aviator.config.AviatorLoggerImpl; +import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.aviator.config.TagMappingConfig; +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; +import com.fortify.cli.aviator.grpc.DastAuditStreamConfig; +import com.fortify.cli.aviator.grpc.DastAuditStreamProcessor; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCAttributeHelper; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCAuditHelper; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCFprTransferHelper; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCRefreshHelper; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCTagValidator; +import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.aviator.util.ResourceUtil; +import com.fortify.cli.common.exception.FcliTechnicalException; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; +import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; +import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; +import com.fortify.cli.common.progress.helper.IProgressWriter; +import com.fortify.cli.ssc._common.output.cli.cmd.AbstractSSCJsonNodeOutputCommand; +import com.fortify.cli.ssc.appversion.cli.mixin.SSCAppVersionRefreshOptions; +import com.fortify.cli.ssc.appversion.cli.mixin.SSCAppVersionResolverMixin; +import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; + +import kong.unirest.UnirestInstance; +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command(name = "audit-dast") +public class AviatorSSCDastAuditCommand extends AbstractSSCJsonNodeOutputCommand implements IActionCommandResultSupplier { + private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCDastAuditCommand.class); + + @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; + @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; + @Mixin private SSCAppVersionResolverMixin.RequiredOption appVersionResolver; + @Mixin private AviatorUserSessionDescriptorSupplier sessionDescriptorSupplier; + @Mixin private SSCAppVersionRefreshOptions refreshOptions; + @Option(names = {"--app"}) private String appName; + @Option(names = {"--tag-mapping"}) private String tagMapping; + + private String actionResult = "SKIPPED"; + + @Override + public JsonNode getJsonNode(UnirestInstance unirest) { + Path downloadedFpr = null; + try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { + var logger = new AviatorLoggerImpl(progressWriter); + var appVersion = appVersionResolver.getAppVersionDescriptor(unirest); + var session = sessionDescriptorSupplier.getSessionDescriptor(); + TagMappingConfig tagMappingConfig = loadTagMappingConfig(); + + AviatorSSCRefreshHelper.refreshMetricsIfNeeded( + unirest, appVersion, refreshOptions.isRefresh(), refreshOptions.getRefreshTimeout(), logger); + + downloadedFpr = AviatorSSCFprTransferHelper.downloadCurrentStateFpr( + unirest, appVersion, logger, progressWriter); + + DastAuditFprResult result = auditFpr( + downloadedFpr, appVersion, session, logger, tagMappingConfig); + actionResult = result.status().name(); + String artifactId = null; + if (result.updatedFile() != null && result.succeeded() > 0) { + validateSSCTagsBeforeUpload(unirest, appVersion, logger, tagMappingConfig); + logger.progress("Status: Uploading audited DAST FPR to SSC"); + artifactId = AviatorSSCFprTransferHelper.uploadFpr( + unirest, appVersion, downloadedFpr, progressWriter); + } + if (result.status() == DastAuditFprStatus.AUDITED + || result.status() == DastAuditFprStatus.SKIPPED) { + AviatorSSCAttributeHelper.writeLastDastAuditTimestamp( + unirest, appVersion.getVersionId()); + } + return buildOutput(appVersion, result, artifactId); + } catch (RuntimeException e) { + actionResult = "FAILED"; + throw e; + } catch (Exception e) { + actionResult = "FAILED"; + throw new FcliTechnicalException("DAST audit failed", e); + } finally { + if (downloadedFpr != null) { + try { + Files.deleteIfExists(downloadedFpr); + } catch (Exception e) { + LOG.warn("Failed to delete temporary DAST FPR {}", downloadedFpr, e); + } + } + } + } + + private DastAuditFprResult auditFpr( + Path fprPath, + SSCAppVersionDescriptor appVersion, + AviatorUserSessionDescriptor session, + IAviatorLogger logger, + TagMappingConfig tagMappingConfig) throws Exception { + String effectiveAppName = appName != null ? appName : appVersion.getApplicationName(); + var config = DastAuditStreamConfig.builder() + .token(session.getAviatorToken()) + .applicationName(effectiveAppName) + .sscApplicationName(appVersion.getApplicationName()) + .sscApplicationVersion(appVersion.getVersionName()) + .build(); + try (var grpcClient = AviatorGrpcClientHelper.createClient(session.getAviatorUrl(), logger, 30); + var streamProcessor = new DastAuditStreamProcessor( + logger, grpcClient.getDastAuditAsyncStub(), grpcClient.getPingScheduler(), + grpcClient.getPingIntervalSeconds()); + var fprHandle = new FprHandle(fprPath)) { + long timeout = Math.max(grpcClient.getDefaultTimeoutSeconds(), 300); + return DastAuditFPR.audit(fprHandle, config, tagMappingConfig, (streamConfig, workItems, totalReported) -> + streamProcessor.process(streamConfig, workItems, totalReported) + .orTimeout(timeout, TimeUnit.SECONDS)); + } catch (CompletionException e) { + throw e.getCause() instanceof Exception exception ? exception : e; + } + } + + private ObjectNode buildOutput( + SSCAppVersionDescriptor appVersion, + DastAuditFprResult audit, + String artifactId) { + ObjectNode result = AviatorSSCAuditHelper.buildResultNode(appVersion, artifactId, audit.status().name()); + AviatorSSCAuditHelper.setDastAuditStats(result, audit); + return result; + } + + private TagMappingConfig loadTagMappingConfig() { + TagMappingConfig tagMappingConfig = tagMapping == null || tagMapping.isBlank() + ? AviatorConfigManager.getInstance().getDefaultDastTagMappingConfig() + : ResourceUtil.loadYamlFile(new File(tagMapping), TagMappingConfig.class); + return tagMappingConfig.resolveForDast(); + } + + private void validateSSCTagsBeforeUpload(UnirestInstance unirest, + SSCAppVersionDescriptor appVersion, IAviatorLogger logger, + TagMappingConfig tagMappingConfig) { + List warnings = AviatorSSCTagValidator.validatePreUpload( + unirest, appVersion.getVersionId(), tagMappingConfig.getTag_id(), + tagMappingConfig.getMappedValues(), logger); + LOG.info("DAST tag validation complete. {} warning(s) found.", warnings.size()); + } + + @Override + public String getActionCommandResult() { + return actionResult; + } + + @Override + public boolean isSingular() { + return true; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsCacheCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsCacheCommand.java new file mode 100644 index 00000000000..fe21574cb78 --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsCacheCommand.java @@ -0,0 +1,137 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.cli.cmd; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.OffsetDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheConstants; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheManifest; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheWriter; +import com.fortify.cli.aviator.config.AviatorLoggerImpl; +import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCRemediationsCacheDownloadSelectorMixin; +import com.fortify.cli.aviator.ssc.cli.mixin.SSCRemediationsSelectionMode; +import com.fortify.cli.aviator.ssc.helper.SinceOptionHelper; +import com.fortify.cli.common.cli.mixin.CommonOptionMixins; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; +import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; +import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; +import com.fortify.cli.common.progress.helper.IProgressWriter; +import com.fortify.cli.ssc._common.output.cli.cmd.AbstractSSCJsonNodeOutputCommand; +import com.fortify.cli.ssc._common.rest.ssc.SSCUrls; +import com.fortify.cli.ssc._common.rest.ssc.transfer.SSCFileTransferHelper; +import com.fortify.cli.ssc.artifact.helper.SSCArtifactDescriptor; + +import kong.unirest.UnirestInstance; +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command(name = "download-remediations-cache", aliases = "drc") +public class AviatorSSCDownloadRemediationsCacheCommand extends AbstractSSCJsonNodeOutputCommand implements IActionCommandResultSupplier { + @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; + @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; + @Mixin private AviatorSSCRemediationsCacheDownloadSelectorMixin artifactSelector; + @Mixin private CommonOptionMixins.RequireConfirmation requireConfirmation; + + @Option(names = {"-f", "--file"}, required = true, paramLabel = "") + private File outputFile; + + @Override + public JsonNode getJsonNode(UnirestInstance unirest) { + artifactSelector.getOnlineSelection().validate(); + Path destination = outputFile.toPath(); + if (Files.exists(destination)) { + requireConfirmation.checkConfirmed(destination); + } + + OffsetDateTime sinceDate = SinceOptionHelper.parse(artifactSelector.getOnlineSelection().getSince()); + // One resolve: artifacts + appVersionId (shared with apply-remediations). + var resolved = artifactSelector.getOnlineSelection().resolveArtifacts(unirest, sinceDate); + Map selection = buildSelectionMetadata(resolved.appVersionId(), sinceDate); + + try (IProgressWriter progressWriter = progressWriterFactoryMixin.create(); + RemediationsCacheWriter cacheWriter = RemediationsCacheWriter.create( + destination, RemediationsCacheConstants.PRODUCT_SSC, selection)) { + AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); + for (SSCArtifactDescriptor artifact : resolved.artifacts()) { + cacheWriter.addSscFpr(artifact.getId(), artifact.getUploadDate(), entryPath -> + downloadArtifact(unirest, artifact, entryPath, logger, progressWriter)); + } + cacheWriter.commit(); + RemediationsCacheManifest manifest = cacheWriter.getManifest(); + return buildResultNode(destination, manifest); + } + } + + private Map buildSelectionMetadata(String appVersionId, OffsetDateTime sinceDate) { + Map selection = new LinkedHashMap<>(); + var online = artifactSelector.getOnlineSelection(); + SSCRemediationsSelectionMode mode = online.getSelectionMode(); + if (mode != null) { + selection.put("mode", mode.wireValue()); + } + if (online.isArtifactIdSelected()) { + selection.put("artifactId", online.getArtifactId()); + } else if (appVersionId != null) { + selection.put("appVersionId", appVersionId); + } + if (sinceDate != null) { + selection.put("since", sinceDate.toString()); + } + return selection; + } + + private void downloadArtifact(UnirestInstance unirest, SSCArtifactDescriptor artifact, Path destination, + AviatorLoggerImpl logger, IProgressWriter progressWriter) { + logger.progress("Status: Downloading Audited FPR from SSC (artifact id=" + artifact.getId() + ")"); + SSCFileTransferHelper.download( + unirest, + SSCUrls.DOWNLOAD_ARTIFACT(artifact.getId(), true), + destination, + SSCFileTransferHelper.ISSCAddDownloadTokenFunction.ROUTEPARAM_DOWNLOADTOKEN, + progressWriter); + } + + private ObjectNode buildResultNode(Path destination, RemediationsCacheManifest manifest) { + ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); + result.put("file", destination.toString()); + result.put("artifactsDownloaded", manifest.getEntries().size()); + ArrayNode artifactIds = result.putArray("artifactIds"); + for (var entry : manifest.getEntries()) { + if (entry.getSscData() != null && entry.getSscData().getArtifactId() != null) { + artifactIds.add(entry.getSscData().getArtifactId()); + } + } + return result; + } + + @Override + public String getActionCommandResult() { + return "REMEDIATIONS_CACHE_DOWNLOADED"; + } + + @Override + public boolean isSingular() { + return true; + } +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCSastAuditCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCSastAuditCommand.java new file mode 100644 index 00000000000..b6997932ba9 --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCSastAuditCommand.java @@ -0,0 +1,21 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.cli.cmd; + +import com.fortify.cli.common.variable.DefaultVariablePropertyName; + +import picocli.CommandLine.Command; + +@Command(name = "audit-sast") +@DefaultVariablePropertyName("artifactId") +public class AviatorSSCSastAuditCommand extends AbstractAviatorSSCSastAuditCommand {} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCApplyRemediationsArtifactSelectorMixin.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCApplyRemediationsArtifactSelectorMixin.java deleted file mode 100644 index 622e1f295f5..00000000000 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCApplyRemediationsArtifactSelectorMixin.java +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright 2021-2026 Open Text. - * - * The only warranties for products and services of Open Text - * and its affiliates and licensors ("Open Text") are as may - * be set forth in the express warranty statements accompanying - * such products and services. Nothing herein should be construed - * as constituting an additional warranty. Open Text shall not be - * liable for technical or editorial errors or omissions contained - * herein. The information contained herein is subject to change - * without notice. - */ -package com.fortify.cli.aviator.ssc.cli.mixin; - -import org.apache.commons.lang3.StringUtils; - -import com.fortify.cli.common.exception.FcliSimpleException; -import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; -import com.fortify.cli.ssc.appversion.helper.SSCAppVersionHelper; - -import kong.unirest.UnirestInstance; -import lombok.Getter; -import picocli.CommandLine.ArgGroup; -import picocli.CommandLine.Option; - -/** - * Mixin for selecting which artifact(s) to process for apply-remediations command. - * Uses Picocli ArgGroups to enforce mutually exclusive options. - */ -@Getter -public class AviatorSSCApplyRemediationsArtifactSelectorMixin { - - @ArgGroup(exclusive = true, multiplicity = "1") - private ArtifactSelectionArgGroup artifactSelection; - - @Option(names = {"--since"}, descriptionKey = "fcli.aviator.ssc.apply-remediations.since") - private String since; - - // Options needed by --latest and --all-open-issues (not by --artifact-id) - @Option(names = {"--appversion", "--av"}, descriptionKey = "fcli.ssc.appversion.resolver.nameOrId") - private String appVersionNameOrId; - - @Option(names = {"--delim"}, defaultValue = ":") - private String delimiter; - - @Getter - public static class ArtifactSelectionArgGroup { - @Option(names = {"--artifact-id"}, required = true, descriptionKey = "fcli.aviator.ssc.apply-remediations.artifact-id") - private String artifactId; - - @Option(names = {"--latest"}, required = true, descriptionKey = "fcli.aviator.ssc.apply-remediations.latest") - private boolean latest; - - @Option(names = {"--all"}, required = true, descriptionKey = "fcli.aviator.ssc.apply-remediations.all") - private boolean allOpenIssues; - } - - public boolean isArtifactIdSelected() { - return artifactSelection != null && StringUtils.isNotBlank(artifactSelection.artifactId); - } - - public boolean isLatestSelected() { - return artifactSelection != null && artifactSelection.latest; - } - - public boolean isAllOpenIssuesSelected() { - return artifactSelection != null && artifactSelection.allOpenIssues; - } - - public String getArtifactId() { - return isArtifactIdSelected() ? artifactSelection.artifactId : null; - } - - public String getAppVersionNameOrId() { - return appVersionNameOrId; - } - - public String getAppVersionId(UnirestInstance unirest) { - if (StringUtils.isBlank(appVersionNameOrId)) { - return null; - } - SSCAppVersionDescriptor descriptor = SSCAppVersionHelper.getRequiredAppVersion( - unirest, appVersionNameOrId, delimiter, "id"); - return descriptor.getVersionId(); - } - - public void validate() { - // Validate --since is only used with --latest or --all-open-issues - if (since != null && !since.isBlank() && isArtifactIdSelected()) { - throw new FcliSimpleException( - "--since cannot be used with --artifact-id; use --latest or --all-open-issues"); - } - - // Validate --av is required with --latest or --all-open-issues - if ((isLatestSelected() || isAllOpenIssuesSelected()) && StringUtils.isBlank(appVersionNameOrId)) { - throw new FcliSimpleException( - "--av/--appversion is required when using --latest or --all-open-issues"); - } - - // Validate --av is not used with --artifact-id - if (isArtifactIdSelected() && StringUtils.isNotBlank(appVersionNameOrId)) { - throw new FcliSimpleException( - "--av/--appversion cannot be used with --artifact-id"); - } - } -} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCApplyRemediationsOptionsMixin.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCApplyRemediationsOptionsMixin.java new file mode 100644 index 00000000000..5d923d6b39f --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCApplyRemediationsOptionsMixin.java @@ -0,0 +1,77 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.cli.mixin; + +import java.nio.file.Path; + +import com.fortify.cli.aviator._common.cli.mixin.AbstractApplyRemediationsOptionsMixin; +import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCRemediationsSelectorArgGroups.OnlineSelectionArgGroup; +import com.fortify.cli.common.exception.FcliSimpleException; + +import lombok.Getter; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Option; + +/** + * SSC-specific apply-remediations options mixin. Combines source selection (online or cache) + * with shared options and provides SSC-specific validation logic. + */ +@Getter +public final class AviatorSSCApplyRemediationsOptionsMixin extends AbstractApplyRemediationsOptionsMixin { + + @ArgGroup(exclusive = true, multiplicity = "1") + private SourceArgGroup source; + + @Getter + public static class SourceArgGroup { + @ArgGroup(exclusive = false) + private OnlineSelectionArgGroup online; + + /** Shared/arg-group option: keep descriptionKey (default picocli key uses FQCN). */ + @Option(names = {"--from-cache"}, required = true, paramLabel = "", + descriptionKey = "fcli.aviator.ssc.apply-remediations.from-cache") + private Path fromCache; + } + + public boolean isFromCacheSelected() { + return source != null && source.fromCache != null; + } + + public boolean isOnlineSelected() { + return source != null && source.online != null; + } + + public Path getFromCache() { + return isFromCacheSelected() ? source.fromCache : null; + } + + /** Online ArgGroup when online mode is selected; null for --from-cache. */ + public OnlineSelectionArgGroup getOnline() { + return isOnlineSelected() ? source.online : null; + } + + @Override + protected void validateSourceSelection() { + if (isFromCacheSelected()) { + return; + } + FcliSimpleException.throwIf(!isOnlineSelected(), + "Exactly one of --from-cache or online selection (--artifact-id, --latest, --all) must be specified"); + source.online.validate(); + } + + @Override + protected boolean isCacheMode() { + return isFromCacheSelected(); + } +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsCacheDownloadSelectorMixin.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsCacheDownloadSelectorMixin.java new file mode 100644 index 00000000000..dfe0e113e8e --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsCacheDownloadSelectorMixin.java @@ -0,0 +1,27 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.cli.mixin; + +import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCRemediationsSelectorArgGroups.OnlineSelectionArgGroup; + +import lombok.Getter; +import picocli.CommandLine.ArgGroup; + +/** + * Download-remediations-cache selector: thin wrapper over shared {@link OnlineSelectionArgGroup}. + */ +@Getter +public class AviatorSSCRemediationsCacheDownloadSelectorMixin { + @ArgGroup(exclusive = false, multiplicity = "1") + private OnlineSelectionArgGroup onlineSelection; +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsSelectorArgGroups.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsSelectorArgGroups.java new file mode 100644 index 00000000000..14a8d5421cd --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsSelectorArgGroups.java @@ -0,0 +1,148 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.cli.mixin; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.apache.commons.lang3.StringUtils; + +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; +import com.fortify.cli.ssc.appversion.helper.SSCAppVersionHelper; +import com.fortify.cli.ssc.artifact.helper.SSCArtifactDescriptor; +import com.fortify.cli.ssc.artifact.helper.SSCArtifactHelper; + +import kong.unirest.UnirestInstance; +import lombok.Getter; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Option; + +public final class AviatorSSCRemediationsSelectorArgGroups { + private AviatorSSCRemediationsSelectorArgGroups() {} + + @Getter + public static class OnlineSelectionArgGroup { + @ArgGroup(exclusive = true, multiplicity = "1") + private OnlineModeArgGroup mode; + + @Option(names = {"--since"}, descriptionKey = "fcli.aviator.ssc.remediations-cache.since") + private String since; + + @Option(names = {"--appversion", "--av"}, descriptionKey = "fcli.ssc.appversion.resolver.nameOrId") + private String appVersionNameOrId; + + /** Shared description from module Messages.properties ({@code delim=}). */ + @Option(names = {"--delim"}, defaultValue = ":", descriptionKey = "delim") + private String delimiter; + + public boolean isArtifactIdSelected() { + return mode != null && StringUtils.isNotBlank(mode.artifactId); + } + + public boolean isLatestSelected() { + return mode != null && mode.latest; + } + + public boolean isAllSelected() { + return mode != null && mode.all; + } + + public String getArtifactId() { + return isArtifactIdSelected() ? mode.artifactId : null; + } + + public String getAppVersionId(UnirestInstance unirest) { + if (StringUtils.isBlank(appVersionNameOrId)) { + return null; + } + SSCAppVersionDescriptor descriptor = SSCAppVersionHelper.getRequiredAppVersion( + unirest, appVersionNameOrId, getDelimiter(), "id"); + return descriptor.getVersionId(); + } + + /** + * Selected online mode, or {@code null} if none (should not occur after validate / + * exclusive ArgGroup). Used for manifest {@code selection.mode} wire values. + */ + public SSCRemediationsSelectionMode getSelectionMode() { + if (isArtifactIdSelected()) { + return SSCRemediationsSelectionMode.ARTIFACT_ID; + } + if (isLatestSelected()) { + return SSCRemediationsSelectionMode.LATEST; + } + if (isAllSelected()) { + return SSCRemediationsSelectionMode.ALL; + } + return null; + } + + /** + * Resolves online selection once: artifacts plus appVersionId for {@code --latest}/{@code --all}. + * Shared by download-remediations-cache and apply-remediations so selection behavior stays aligned. + * {@code --artifact-id} is validated with {@link SSCArtifactHelper#requireAviatorArtifact}; + * appVersionId is null in that mode (callers may read it from the artifact JSON if needed). + */ + public ResolvedOnlineArtifacts resolveArtifacts(UnirestInstance unirest, OffsetDateTime sinceDate) { + if (isAllSelected()) { + String appVersionId = getAppVersionId(unirest); + List artifacts = new ArrayList<>( + SSCArtifactHelper.getAllAviatorArtifacts(unirest, appVersionId, sinceDate)); + Collections.reverse(artifacts); + return new ResolvedOnlineArtifacts( + artifacts, + appVersionId); + } + if (isLatestSelected()) { + String appVersionId = getAppVersionId(unirest); + return new ResolvedOnlineArtifacts( + List.of(SSCArtifactHelper.getLatestAviatorArtifact(unirest, appVersionId, sinceDate)), + appVersionId); + } + FcliSimpleException.throwIf(!isArtifactIdSelected(), + "Exactly one of --artifact-id, --latest, or --all must be specified"); + return new ResolvedOnlineArtifacts( + List.of(SSCArtifactHelper.requireAviatorArtifact( + SSCArtifactHelper.getArtifactDescriptor(unirest, getArtifactId()))), + null); + } + + /** Result of {@link #resolveArtifacts}: one REST resolve of app version when applicable. */ + public record ResolvedOnlineArtifacts(List artifacts, String appVersionId) {} + + public void validate() { + FcliSimpleException.throwIf(StringUtils.isNotBlank(since) && isArtifactIdSelected(), + "--since cannot be used with --artifact-id; use --latest or --all"); + FcliSimpleException.throwIf( + (isLatestSelected() || isAllSelected()) && StringUtils.isBlank(appVersionNameOrId), + "--av/--appversion is required when using --latest or --all"); + FcliSimpleException.throwIf(isArtifactIdSelected() && StringUtils.isNotBlank(appVersionNameOrId), + "--av/--appversion cannot be used with --artifact-id"); + } + } + + @Getter + public static class OnlineModeArgGroup { + @Option(names = {"--artifact-id"}, required = true, descriptionKey = "fcli.aviator.ssc.remediations-cache.artifact-id") + private String artifactId; + + @Option(names = {"--latest"}, required = true, descriptionKey = "fcli.aviator.ssc.remediations-cache.latest") + private boolean latest; + + @Option(names = {"--all"}, required = true, descriptionKey = "fcli.aviator.ssc.remediations-cache.all") + private boolean all; + } +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/SSCRemediationsSelectionMode.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/SSCRemediationsSelectionMode.java new file mode 100644 index 00000000000..724a9926076 --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/SSCRemediationsSelectionMode.java @@ -0,0 +1,34 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.cli.mixin; + +/** + * SSC remediations online selection mode. Wire value is stored in cache manifest + * {@code selection.mode} and must remain stable for existing/future caches. + */ +public enum SSCRemediationsSelectionMode { + ARTIFACT_ID("artifact-id"), + LATEST("latest"), + ALL("all"); + + private final String wireValue; + + SSCRemediationsSelectionMode(String wireValue) { + this.wireValue = wireValue; + } + + /** Value written to remediations cache manifest selection metadata. */ + public String wireValue() { + return wireValue; + } +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCApplyRemediationsHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCApplyRemediationsHelper.java index 43b161e75a7..863f943d439 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCApplyRemediationsHelper.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCApplyRemediationsHelper.java @@ -12,106 +12,85 @@ */ package com.fortify.cli.aviator.ssc.helper; -import java.util.ArrayList; +import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.Set; -import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsApplyHelper.ApplyResult; +import com.fortify.cli.aviator._common.util.AviatorRemediationMetricsHelper; +import com.fortify.cli.aviator.fpr.remediation.RemediationExecutionMode; import com.fortify.cli.aviator.fpr.remediation.model.RemediationMetric; import com.fortify.cli.common.json.JsonHelper; -import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; import com.fortify.cli.ssc.artifact.helper.SSCArtifactDescriptor; /** - * Helper class for the AviatorSSCAuditCommand to encapsulate - * result message formatting and JSON output construction. + * SSC apply-remediations result JSON: product identity fields only; + * metrics/action/cache extras come from {@link AviatorRemediationMetricsHelper}. */ public final class AviatorSSCApplyRemediationsHelper { private AviatorSSCApplyRemediationsHelper() {} - /** - * Builds the unified JSON result node for a single-artifact remediation (--artifact-id or --latest). - * Uses the same output shape as buildAggregatedResultNode for consistent table columns. - * @param ad The SSCArtifactDescriptor; its projectVersionId is used as appVersionId. - * @param metric The remediation metric for this artifact. - * @param action Final action. - * @return An ObjectNode representing the result. - */ - public static ObjectNode buildResultNode(SSCArtifactDescriptor ad, RemediationMetric metric, String action) { - ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); - result.put("appVersionId", ad.asObjectNode().path("projectVersionId").asText("N/A")); - result.put("artifactId", ad.getId()); - result.put("artifactsProcessed", 1); - result.put("artifactsSkipped", 0); - result.put("totalRemediation", metric.totalRemediations()); - result.put("appliedRemediation", metric.appliedRemediations()); - result.put("identicalRemediation", metric.identicalRemediations()); - result.put("supersededRemediation", metric.supersededRemediations()); - result.put("possiblyRemediatedRemediation", metric.possiblyRemediatedRemediations()); - result.put("skippedRemediation", metric.skippedRemediations()); - result.put("skippedReasons", formatSkippedReasons(metric.skippedByReason())); - result.set("skippedByReason", toObjectNode(metric.skippedByReason())); - result.set("modifiedFiles", toArrayNode(metric.modifiedFiles())); - result.put(IActionCommandResultSupplier.actionFieldName, action); + public static ObjectNode buildOnlineResultNode( + List artifacts, + String appVersionId, + ApplyResult applyResult, + Set issueIdFilter, + RemediationExecutionMode executionMode) { + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + issueIdFilter, applyResult.metrics(), executionMode); + return buildCommonNode( + resolveAppVersionId(artifacts, appVersionId), + resolveSingleArtifactId(artifacts, applyResult), + applyResult, + aggregated); + } + + public static ObjectNode buildCacheResultNode( + Path cacheZip, + ApplyResult applyResult, + Set issueIdFilter, + Map selection, + RemediationExecutionMode executionMode) { + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + issueIdFilter, applyResult.metrics(), executionMode); + String appVersionId = selection != null ? selection.get("appVersionId") : null; + ObjectNode result = buildCommonNode(appVersionId, null, applyResult, aggregated); + AviatorRemediationMetricsHelper.putCacheExtras( + result, cacheZip, applyResult.processedEntries(), "artifactIds", applyResult.processedIds()); return result; } - /** - * Builds the unified JSON result node for --all-open-issues, aggregating across all artifacts. - * Uses the same output shape as buildResultNode for consistent table columns. - * @param appVersionId The application version ID processed. - * @param artifactsProcessed Number of artifacts successfully processed. - * @param artifactsSkipped Number of artifacts skipped (e.g. no remediations.xml). - * @param totalRemediation Total remediations across all artifacts. - * @param appliedRemediation Total applied remediations across all artifacts. - * @param skippedRemediation Total skipped remediations across all artifacts. - * @param action Final action result. - * @return An ObjectNode representing the aggregated result. - */ - public static ObjectNode buildAggregatedResultNode(String appVersionId, int artifactsProcessed, int artifactsSkipped, - RemediationMetric aggregatedMetric, String action) { + private static ObjectNode buildCommonNode( + String appVersionId, + String artifactId, + ApplyResult applyResult, + RemediationMetric aggregated) { ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); - result.put("appVersionId", appVersionId); - result.put("artifactId", "N/A"); - result.put("artifactsProcessed", artifactsProcessed); - result.put("artifactsSkipped", artifactsSkipped); - result.put("totalRemediation", aggregatedMetric.totalRemediations()); - result.put("appliedRemediation", aggregatedMetric.appliedRemediations()); - result.put("identicalRemediation", aggregatedMetric.identicalRemediations()); - result.put("supersededRemediation", aggregatedMetric.supersededRemediations()); - result.put("possiblyRemediatedRemediation", aggregatedMetric.possiblyRemediatedRemediations()); - result.put("skippedRemediation", aggregatedMetric.skippedRemediations()); - result.put("skippedReasons", formatSkippedReasons(aggregatedMetric.skippedByReason())); - result.set("skippedByReason", toObjectNode(aggregatedMetric.skippedByReason())); - result.set("modifiedFiles", toArrayNode(aggregatedMetric.modifiedFiles())); - result.put(IActionCommandResultSupplier.actionFieldName, action); + result.put("appVersionId", AviatorRemediationMetricsHelper.na(appVersionId)); + result.put("artifactId", AviatorRemediationMetricsHelper.na(artifactId)); + result.put("artifactsProcessed", applyResult.metrics().size()); + result.put("artifactsSkipped", applyResult.skipped()); + result.put("previewMode", aggregated.isPreview()); + AviatorRemediationMetricsHelper.putMetricAndAction(result, aggregated); return result; } - private static ArrayNode toArrayNode(Set files) { - ArrayNode array = JsonHelper.getObjectMapper().createArrayNode(); - if (files != null) { - files.forEach(array::add); + private static String resolveAppVersionId(List artifacts, String appVersionId) { + if (appVersionId != null || artifacts == null || artifacts.size() != 1) { + return appVersionId; } - return array; + return artifacts.get(0).asObjectNode().path("projectVersionId").asText(null); } - private static ObjectNode toObjectNode(Map skippedByReason) { - ObjectNode object = JsonHelper.getObjectMapper().createObjectNode(); - if (skippedByReason != null) { - skippedByReason.forEach(object::put); + private static String resolveSingleArtifactId(List artifacts, ApplyResult applyResult) { + if (artifacts == null || artifacts.size() != 1) { + return null; } - return object; - } - - private static String formatSkippedReasons(Map skippedByReason) { - if (skippedByReason == null || skippedByReason.isEmpty()) { - return ""; + if (applyResult.metrics().size() == 1 && applyResult.skipped() == 0) { + return artifacts.get(0).getId(); } - List parts = new ArrayList<>(); - skippedByReason.forEach((reason, count) -> parts.add(reason + "=" + count)); - return String.join(", ", parts); + return null; } } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelationAttributeDefs.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAttributeDefinitions.java similarity index 63% rename from fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelationAttributeDefs.java rename to fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAttributeDefinitions.java index 7573dbace09..8476df99189 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelationAttributeDefs.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAttributeDefinitions.java @@ -13,27 +13,26 @@ package com.fortify.cli.aviator.ssc.helper; /** - * Attribute definitions used by the SAST-DAST correlation feature. + * SSC application-version attribute definitions used by Aviator workflows. * *

    These are SSC application-version attributes (not per-issue custom tags). * The definition is created by the {@code aviator ssc prepare} command and - * the value is written by {@code aviator ssc correlate-sast-dast}. + * the values are written by {@code aviator ssc correlate-sast-dast} and + * {@code aviator ssc audit-dast}. */ -public final class AviatorSSCCorrelationAttributeDefs { +public final class AviatorSSCAttributeDefinitions { - private AviatorSSCCorrelationAttributeDefs() {} + private AviatorSSCAttributeDefinitions() {} /** * Descriptor for a custom SSC attribute definition managed by the Aviator module. * - * @param guid Fixed GUID — must never change once deployed to an SSC instance. * @param name Attribute name as it appears in SSC (used for lookup and write). * @param category SSC attribute category (e.g. {@code "TECHNICAL"}). * @param type SSC attribute type string (e.g. {@code "TEXT"}, {@code "DATE"}). * @param description Human-readable description stored in SSC. */ public record AttributeDefinition( - String guid, String name, String category, String type, @@ -53,10 +52,29 @@ public record AttributeDefinition( * comparison with artifact {@code lastScanDate} values. */ public static final AttributeDefinition LAST_CORRELATION_ATTR = new AttributeDefinition( - "B2C3D4E5-F6A7-8901-BCDE-F12345678901", "last_correlation", "TECHNICAL", "TEXT", "Timestamp of the last successful SAST-DAST correlation run (ISO-8601 UTC). Written by fcli aviator ssc correlate-sast-dast." ); + + /** + * Free-text attribute written after a DAST audit evaluation completes successfully, + * including a run that finds no eligible findings. + * + *

    Value is an ISO-8601 UTC timestamp produced by {@code Instant.now().toString()}, + * the same kind of value written to {@code last_correlation}. Bulk DAST audit reads + * it and selects a version only when the newest processed WebInspect scan date is + * later than this timestamp. + * + *

    TEXT type is used rather than DATE because SSC's DATE type only accepts + * {@code yyyy-MM-dd}, which loses the time-of-day precision required for reliable + * comparison with artifact {@code lastScanDate} values. + */ + public static final AttributeDefinition LAST_DAST_AUDIT_ATTR = new AttributeDefinition( + "last_dast_audit", + "TECHNICAL", + "TEXT", + "Timestamp of the last successful DAST audit evaluation (ISO-8601 UTC). Written by fcli aviator ssc audit-dast." + ); } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelationAttributeHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAttributeHelper.java similarity index 70% rename from fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelationAttributeHelper.java rename to fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAttributeHelper.java index 1972b73b6aa..33bfe5b44a4 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelationAttributeHelper.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAttributeHelper.java @@ -21,32 +21,33 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fortify.cli.aviator.ssc.helper.AviatorSSCCorrelationAttributeDefs.AttributeDefinition; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCAttributeDefinitions.AttributeDefinition; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.rest.unirest.UnexpectedHttpResponseException; import com.fortify.cli.ssc._common.rest.ssc.SSCUrls; import com.fortify.cli.ssc.attribute.helper.SSCAttributeUpdateBuilder; +import kong.unirest.UnirestException; import kong.unirest.UnirestInstance; import lombok.RequiredArgsConstructor; /** - * Manages the SSC attribute definitions used by the SAST-DAST correlation feature. + * Manages SSC application-version attributes used by Aviator workflows. * *

    The attribute definition is created by {@code aviator ssc prepare} (admin-only). * The attribute value is written per application version by - * {@code aviator ssc correlate-sast-dast} (non-admin). + * {@code aviator ssc correlate-sast-dast} and {@code aviator ssc audit-dast} (non-admin). * *

    This is distinct from the generic SSC attribute helpers in the SSC module * ({@code SSCAttributeHelper}, {@code SSCAttributeDefinitionHelper}) which * handle reading/updating existing attributes. This class also handles - * creating attribute definitions specific to correlation. + * creating attribute definitions specific to Aviator workflows. */ @RequiredArgsConstructor -public class AviatorSSCCorrelationAttributeHelper { +public class AviatorSSCAttributeHelper { - private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCCorrelationAttributeHelper.class); + private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCAttributeHelper.class); private final UnirestInstance unirest; private final AttributeDefinition attrDef; @@ -63,7 +64,7 @@ public class AviatorSSCCorrelationAttributeHelper { */ public void synchronize(AviatorSSCPrepareHelper.PrepareResult result) { try { - LOG.debug("Searching for attribute definition '{}' (GUID: {})", attrDef.name(), attrDef.guid()); + LOG.debug("Searching for SSC attribute definition '{}'", attrDef.name()); if (findDefinition() != null) { LOG.info("Attribute definition '{}' is already present.", attrDef.name()); result.addEntry("Attribute Definition", "VERIFIED", @@ -82,30 +83,38 @@ public void synchronize(AviatorSSCPrepareHelper.PrepareResult result) { } } + /** Writes the current UTC timestamp to the {@code last_correlation} attribute. */ + public static void writeLastCorrelationTimestamp(UnirestInstance unirest, String versionId) { + writeTimestamp(unirest, versionId, AviatorSSCAttributeDefinitions.LAST_CORRELATION_ATTR); + } + /** - * Writes the current UTC timestamp to the {@code last_correlation} attribute on - * the given application version. - * - *

    This method assumes the attribute definition already exists — it must have - * been created by a prior {@code aviator ssc prepare} run. If the definition - * does not exist, SSC will reject the update and an error is thrown. + * Writes the current UTC timestamp to the {@code last_dast_audit} attribute. + * Same value shape as {@link #writeLastCorrelationTimestamp}: {@code Instant.now()}. + */ + public static void writeLastDastAuditTimestamp(UnirestInstance unirest, String versionId) { + writeTimestamp(unirest, versionId, AviatorSSCAttributeDefinitions.LAST_DAST_AUDIT_ATTR); + } + + /** + * Writes the current UTC timestamp to the given attribute on the application version. * - * @param unirest active SSC session - * @param versionId SSC project version ID + *

    A rejected update is logged and swallowed so the completed audit or correlation + * stays successful and bulk selection can retry the version. */ - public static void writeLastCorrelationTimestamp(UnirestInstance unirest, String versionId) { + private static void writeTimestamp( + UnirestInstance unirest, String versionId, AttributeDefinition attributeDefinition) { String timestamp = Instant.now().toString(); - LOG.debug("Writing last_correlation timestamp '{}' to app version {}", timestamp, versionId); - + LOG.debug("Writing {} timestamp to app version {}", attributeDefinition.name(), versionId); try { new SSCAttributeUpdateBuilder(unirest) - .add(Map.of(AviatorSSCCorrelationAttributeDefs.LAST_CORRELATION_ATTR.name(), timestamp)) + .add(Map.of(attributeDefinition.category() + ":" + attributeDefinition.name(), timestamp)) .buildRequest(versionId) .asObject(JsonNode.class); - - LOG.info("last_correlation timestamp '{}' written to app version {}", timestamp, versionId); - } catch (FcliSimpleException e) { - LOG.warn("WARN: Could not write last_correlation timestamp. Run 'fcli aviator ssc prepare' to create the attribute definition."); + LOG.info("{} timestamp written to app version {}", attributeDefinition.name(), versionId); + } catch (FcliSimpleException | UnirestException e) { + LOG.warn("Could not write {} timestamp; the audit result remains successful but bulk selection may retry this version. " + + "Run 'fcli aviator ssc prepare' if the attribute definition is missing.", attributeDefinition.name()); } } @@ -122,7 +131,9 @@ private JsonNode findDefinition() { JsonNode data = responseBody.get("data"); if (data == null || !data.isArray()) return null; return JsonHelper.stream((ArrayNode) data) - .filter(n -> attrDef.name().equals(n.path("name").asText())) + .filter(n -> attrDef.name().equals(n.path("name").asText()) + && attrDef.category().equals(n.path("category").asText()) + && attrDef.type().equals(n.path("type").asText())) .findFirst().orElse(null); } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java index bf0be506133..49e44fdba1a 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java @@ -27,6 +27,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.fortify.aviator.application.Application; import com.fortify.cli.aviator._common.exception.AviatorSimpleException; +import com.fortify.cli.aviator.audit.DastAuditFprResult; import com.fortify.cli.aviator.audit.model.FPRAuditResult; import com.fortify.cli.aviator.config.AviatorLoggerImpl; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; @@ -163,6 +164,35 @@ private static String formatSkippedReasons(Map skippedByReason) return String.join(", ", parts); } + /** + * Populates the standard audit output envelope with DAST-specific statistics. + * + * @param result The result node created by {@link #buildResultNode}. + * @param auditResult The DAST FPR audit result. + */ + public static void setDastAuditStats(ObjectNode result, DastAuditFprResult auditResult) { + ObjectNode audit = JsonHelper.getObjectMapper().createObjectNode(); + audit.put("message", getDastAuditMessage(auditResult)); + audit.put("submitted", auditResult.submitted()); + audit.put("succeeded", auditResult.succeeded()); + audit.put("skipped", auditResult.skipped()); + audit.put("failed", auditResult.failed()); + ((ObjectNode) result.get("operation")).set("audit", audit); + result.remove("state"); + } + + private static String getDastAuditMessage(DastAuditFprResult auditResult) { + return switch (auditResult.status()) { + case AUDITED -> "DAST audit completed successfully"; + case PARTIALLY_AUDITED -> auditResult.message() != null + ? auditResult.message() : "DAST audit partially completed"; + case SKIPPED -> auditResult.message() != null + ? auditResult.message() : "No DAST findings to audit"; + case FAILED -> auditResult.message() != null + ? auditResult.message() : "DAST audit failed"; + }; + } + /** * Sets only the {@code operation.audit.message} field without audit stats. * Used for code paths that don't perform an actual audit (SKIPPED, FAILED, QUOTA_EXCEEDED, etc.). @@ -213,9 +243,10 @@ public static String getProgressMessage(FPRAuditResult auditResult) { } /** - * Coarse SSC download gate. Force re-audit counts Aviator-processed issues even when - * SSC marks them audited; suppressed issues and human-audited issues Aviator never - * processed stay excluded. Last TagHistory writer is applied later from the FPR. + * Coarse SSC download gate. Force re-audit counts Aviator-processed issues and + * legacy Analysis-tag candidates even when SSC marks them audited. The Analysis tag + * is only a compatibility signal; final ownership and human-triage decisions are + * applied from TagHistory in the FPR. */ public static long getAuditableIssueCount(UnirestInstance unirest, SSCAppVersionDescriptor av, AviatorLoggerImpl logger, boolean noFilterSet, String filterSetTitleOrId, List folderNames, boolean forceReaudit) { @@ -348,9 +379,9 @@ private static long countEligibleOnPage(ArrayNode issues, boolean forceReaudit) } /** - * Force re-audit includes previously processed Aviator issues even when SSC - * already marks them audited. Suppressed issues and human-audited issues that - * Aviator never processed stay out. Normal audit still excludes processed issues. + * Force re-audit includes previously processed Aviator issues and legacy + * Analysis-tag candidates even when SSC already marks them audited. Normal audit + * still excludes processed issues. */ private static boolean isEligibleForRequestedAudit(JsonNode issue, boolean forceReaudit) { if (issue.path("suppressed").asBoolean(false)) { @@ -359,7 +390,10 @@ private static boolean isEligibleForRequestedAudit(JsonNode issue, boolean force if (isProcessedByAviator(issue)) { return forceReaudit; } - return !issue.path("audited").asBoolean(false); + if (!issue.path("audited").asBoolean(false)) { + return true; + } + return forceReaudit && hasAnalysisTag(issue); } /** @@ -378,6 +412,21 @@ private static boolean isProcessedByAviator(JsonNode issue) { ); } + /** + * Legacy Aviator versions wrote audit results to the Analysis tag without the + * explicit Aviator status tag. This only identifies an FPR download candidate; + * TagHistory is required to determine who wrote the result. + */ + private static boolean hasAnalysisTag(JsonNode issue) { + JsonNode auditValues = issue.path("_embed").path("auditValues"); + if (!auditValues.isArray()) { + return false; + } + return JsonHelper.stream((ArrayNode) auditValues) + .anyMatch(tagValue -> Constants.ANALYSIS_TAG_ID.equalsIgnoreCase( + tagValue.path("customTagGuid").asText())); + } + /** * Helper method to construct the folder filter string for the API request. */ diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateDownloadHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateDownloadHelper.java deleted file mode 100644 index 2b47c4833c1..00000000000 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateDownloadHelper.java +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright 2021-2026 Open Text. - * - * The only warranties for products and services of Open Text - * and its affiliates and licensors ("Open Text") are as may - * be set forth in the express warranty statements accompanying - * such products and services. Nothing herein should be construed - * as constituting an additional warranty. Open Text shall not be - * liable for technical or editorial errors or omissions contained - * herein. The information contained herein is subject to change - * without notice. - */ -package com.fortify.cli.aviator.ssc.helper; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.aviator.config.AviatorLoggerImpl; -import com.fortify.cli.common.progress.helper.IProgressWriter; -import com.fortify.cli.ssc._common.rest.ssc.SSCUrls; -import com.fortify.cli.ssc._common.rest.ssc.transfer.SSCFileTransferHelper; -import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; -import com.fortify.cli.ssc.artifact.helper.SSCArtifactDescriptor; - -import kong.unirest.UnirestInstance; - -/** - * Handles FPR download and upload operations against SSC for the correlate-sast-dast command. - */ -public final class AviatorSSCCorrelateDownloadHelper { - - private AviatorSSCCorrelateDownloadHelper() {} - - /** - * Downloads the FPR artifact for a single artifact ID. Used for DAST FPR download - * (individual artifact is needed so its webinspect.xml can be enriched and re-uploaded). - */ - public static Path downloadArtifactFpr(UnirestInstance unirest, SSCArtifactDescriptor ad, - AviatorLoggerImpl logger, IProgressWriter progressWriter) throws IOException { - Path fprPath = Files.createTempFile("aviator_" + ad.getId() + "_", ".fpr"); - logger.progress("Status: Downloading FPR from SSC (artifact id=" + ad.getId() + ")"); - SSCFileTransferHelper.download( - unirest, - SSCUrls.DOWNLOAD_ARTIFACT(ad.getId(), true), - fprPath.toFile(), - SSCFileTransferHelper.ISSCAddDownloadTokenFunction.ROUTEPARAM_DOWNLOADTOKEN, - progressWriter); - return fprPath; - } - - /** - * Downloads the current merged SAST FPR for an application version. - * This merged FPR is safe to re-upload to SSC after adding audit tags because its - * internal FVDL contains only audit state (no raw scan results), which SSC processes - * as an audit-only update instead of a new scan submission. - * Using DOWNLOAD_ARTIFACT for the SAST FPR and re-uploading it causes SSC to treat - * it as a duplicate scan and puts the artifact into an error state. - */ - public static Path downloadCurrentSastFpr(UnirestInstance unirest, SSCAppVersionDescriptor av, - AviatorLoggerImpl logger, IProgressWriter progressWriter) throws IOException { - Path fprPath = Files.createTempFile("aviator_sast_merged_", ".fpr"); - logger.progress("Status: Downloading current merged SAST FPR from SSC for %s:%s", - av.getApplicationName(), av.getVersionName()); - SSCFileTransferHelper.download( - unirest, - SSCUrls.DOWNLOAD_CURRENT_FPR(av.getVersionId(), false), - fprPath.toFile(), - SSCFileTransferHelper.ISSCAddDownloadTokenFunction.ROUTEPARAM_DOWNLOADTOKEN, - progressWriter); - return fprPath; - } - - /** - * Uploads an enriched DAST FPR to SSC using the HTML upload endpoint. - */ - public static void uploadEnrichedDastFpr(UnirestInstance unirest, SSCAppVersionDescriptor av, - Path enrichedDastFpr, IProgressWriter progressWriter) { - SSCFileTransferHelper.htmlUpload( - unirest, - SSCUrls.UPLOAD_RESULT_FILE(av.getVersionId()), - enrichedDastFpr.toFile(), - SSCFileTransferHelper.ISSCAddUploadTokenFunction.ROUTEPARAM_UPLOADTOKEN, - String.class, - progressWriter - ); - } - - /** - * Uploads an enriched SAST FPR (with updated DAST_CORRELATION_STATUS tags) to SSC. - * Uses the REST artifacts endpoint — same as the audit command — so SSC merges only - * the audit.xml changes without treating the upload as a new scan result. - * Using UPLOAD_RESULT_FILE would cause SSC to reject it as a duplicate scan (error state). - */ - public static void uploadEnrichedSastFpr(UnirestInstance unirest, SSCAppVersionDescriptor av, - Path enrichedSastFpr, IProgressWriter progressWriter) { - SSCFileTransferHelper.restUpload( - unirest, - SSCUrls.PROJECT_VERSION_ARTIFACTS(av.getVersionId()), - enrichedSastFpr.toFile(), - JsonNode.class, - progressWriter - ); - } -} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateHelper.java index 0443067165f..1262c926a60 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateHelper.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateHelper.java @@ -14,74 +14,26 @@ import java.nio.file.Files; import java.nio.file.Path; -import java.util.List; import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.fasterxml.jackson.databind.node.ObjectNode; import com.fortify.cli.aviator.fpr.Vulnerability; import com.fortify.cli.aviator.fpr.model.AuditIssue; -import com.fortify.cli.aviator.grpc.CorrelatedPair; import com.fortify.cli.common.exception.FcliSimpleException; -import com.fortify.cli.common.json.JsonHelper; -import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; -import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; +import com.fortify.cli.ssc.artifact.helper.SSCArtifactDescriptor; +import com.fortify.cli.ssc.artifact.helper.SSCArtifactHelper; /** * Stateless utility helpers for the correlate-sast-dast command: - * output JSON construction, suppression check, and FPR path validation. + * suppression checks and FPR path validation. */ public final class AviatorSSCCorrelateHelper { private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCCorrelateHelper.class); private AviatorSSCCorrelateHelper() {} - /** - * Builds the final JSON output node for the correlate-sast-dast command. - */ - public static ObjectNode buildOutputJson(SSCAppVersionDescriptor av, - String artifactId, - int submitted, - int succeeded, - List newPairs, - String actionResult) { - int correlated = newPairs.size(); - int skipped = submitted - succeeded; - - ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); - result.put("id", av.getVersionId()); - result.put("applicationName", av.getApplicationName()); - result.put("versionName", av.getVersionName()); - if (artifactId != null) { - result.put("artifactId", artifactId); - } else { - result.putNull("artifactId"); - } - result.put(IActionCommandResultSupplier.actionFieldName, actionResult); - - ObjectNode operation = result.putObject("operation"); - ObjectNode correlate = operation.putObject("correlate"); - - if (submitted > 0) { - String message = String.format("%d SAST findings submitted, %d correlated pairs confirmed", - submitted, correlated); - correlate.put("message", message); - correlate.put("submitted", submitted); - correlate.put("succeeded", succeeded); - correlate.put("skipped", skipped); - } else { - correlate.putNull("message"); - correlate.putNull("submitted"); - correlate.putNull("succeeded"); - correlate.putNull("skipped"); - } - correlate.put("correlated", correlated); - - return result; - } - /** * Returns true if the given vulnerability is marked as suppressed in the audit map. */ @@ -93,6 +45,17 @@ public static boolean isVulnerabilitySuppressed(Vulnerability vuln, Map 0) { + correlate.put("message", String.format( + "%d SAST findings submitted: %d succeeded, %d skipped, %d failed; %d correlated pairs confirmed", + submitted, succeeded, skipped, failed, correlated)); + } else { + correlate.putNull("message"); + } + } + + private static void addStatistics(ObjectNode correlate, int submitted, int succeeded, int skipped, int failed) { + if (submitted > 0) { + correlate.put("submitted", submitted); + correlate.put("succeeded", succeeded); + correlate.put("skipped", skipped); + correlate.put("failed", failed); + } else { + correlate.putNull("submitted"); + correlate.putNull("succeeded"); + correlate.putNull("skipped"); + correlate.putNull("failed"); + } + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCFprTransferHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCFprTransferHelper.java new file mode 100644 index 00000000000..53ed93291f1 --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCFprTransferHelper.java @@ -0,0 +1,112 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.helper; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.common.exception.FcliTechnicalException; +import com.fortify.cli.common.progress.helper.IProgressWriter; +import com.fortify.cli.ssc._common.rest.ssc.SSCUrls; +import com.fortify.cli.ssc._common.rest.ssc.transfer.SSCFileTransferHelper; +import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; +import com.fortify.cli.ssc.artifact.helper.SSCArtifactDescriptor; + +import kong.unirest.UnirestInstance; + +/** + * Shared SSC transfer operations for Aviator FPR workflows. + */ +public final class AviatorSSCFprTransferHelper { + private AviatorSSCFprTransferHelper() {} + + public static Path downloadCurrentStateFpr( + UnirestInstance unirest, + SSCAppVersionDescriptor appVersion, + IAviatorLogger logger, + IProgressWriter progressWriter) throws IOException { + return downloadCurrentStateFpr(unirest, appVersion, logger, progressWriter, true); + } + + public static Path downloadCurrentStateFpr( + UnirestInstance unirest, + SSCAppVersionDescriptor appVersion, + IAviatorLogger logger, + IProgressWriter progressWriter, + boolean includeSource) throws IOException { + logger.progress("Status: Downloading current FPR state from SSC for app version %s:%s (id=%s)", + appVersion.getApplicationName(), appVersion.getVersionName(), appVersion.getVersionId()); + return downloadFpr(unirest, "aviator_" + appVersion.getVersionId() + "_", + SSCUrls.DOWNLOAD_CURRENT_FPR(appVersion.getVersionId(), includeSource), progressWriter); + } + + public static Path downloadArtifactFpr( + UnirestInstance unirest, + SSCArtifactDescriptor artifact, + IAviatorLogger logger, + IProgressWriter progressWriter) throws IOException { + logger.progress("Status: Downloading FPR from SSC (artifact id=%s)", artifact.getId()); + return downloadFpr(unirest, "aviator_" + artifact.getId() + "_", + SSCUrls.DOWNLOAD_ARTIFACT(artifact.getId(), true), progressWriter); + } + + private static Path downloadFpr( + UnirestInstance unirest, + String filePrefix, + String downloadUrl, + IProgressWriter progressWriter) throws IOException { + Path fprPath = Files.createTempFile(filePrefix, ".fpr"); + try { + SSCFileTransferHelper.download( + unirest, + downloadUrl, + fprPath.toFile(), + SSCFileTransferHelper.ISSCAddDownloadTokenFunction.ROUTEPARAM_DOWNLOADTOKEN, + progressWriter); + return fprPath; + } catch (RuntimeException e) { + try { + Files.deleteIfExists(fprPath); + } catch (IOException cleanupException) { + e.addSuppressed(cleanupException); + } + throw e; + } + } + + public static String uploadFpr( + UnirestInstance unirest, + SSCAppVersionDescriptor appVersion, + Path fprPath, + IProgressWriter progressWriter) { + JsonNode uploadResponse = SSCFileTransferHelper.restUpload( + unirest, + SSCUrls.PROJECT_VERSION_ARTIFACTS(appVersion.getVersionId()), + fprPath.toFile(), + JsonNode.class, + progressWriter); + return getUploadedArtifactId(uploadResponse); + } + + static String getUploadedArtifactId(JsonNode uploadResponse) { + String artifactId = uploadResponse == null + ? null : uploadResponse.path("data").path("id").asText(null); + if (artifactId == null || artifactId.isBlank()) { + throw new FcliTechnicalException("SSC FPR upload response did not contain an artifact ID"); + } + return artifactId; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCPrepareHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCPrepareHelper.java index 56f96f09e03..93073fbef58 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCPrepareHelper.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCPrepareHelper.java @@ -126,7 +126,9 @@ private void addOptionalTagWarnings(TagSynchronizationResults tagResults, Prepar /** Synchronizes Aviator custom attributes. */ private void synchronizeAttributes(PrepareResult result, IProgressWriter progress) { progress.writeProgress("Synchronizing Aviator custom attributes..."); - new AviatorSSCCorrelationAttributeHelper(unirest, AviatorSSCCorrelationAttributeDefs.LAST_CORRELATION_ATTR) + new AviatorSSCAttributeHelper(unirest, AviatorSSCAttributeDefinitions.LAST_CORRELATION_ATTR) + .synchronize(result); + new AviatorSSCAttributeHelper(unirest, AviatorSSCAttributeDefinitions.LAST_DAST_AUDIT_ATTR) .synchronize(result); } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCRefreshHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCRefreshHelper.java new file mode 100644 index 00000000000..16f2707babf --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCRefreshHelper.java @@ -0,0 +1,46 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.helper; + +import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; +import com.fortify.cli.ssc.appversion.helper.SSCAppVersionHelper; +import com.fortify.cli.ssc.system_state.helper.SSCJobHelper; + +import kong.unirest.UnirestInstance; + +/** + * Refreshes stale SSC application-version metrics before workflows read current state. + */ +public final class AviatorSSCRefreshHelper { + private AviatorSSCRefreshHelper() {} + + public static void refreshMetricsIfNeeded( + UnirestInstance unirest, + SSCAppVersionDescriptor appVersion, + boolean refreshEnabled, + String refreshTimeout, + IAviatorLogger logger) { + if (!refreshEnabled || !appVersion.isRefreshRequired()) { + return; + } + + logger.progress("Status: Metrics for application version %s:%s are out of date, starting refresh...", + appVersion.getApplicationName(), appVersion.getVersionName()); + var refreshJob = SSCAppVersionHelper.refreshMetrics(unirest, appVersion); + if (refreshJob != null) { + SSCJobHelper.waitForJob(unirest, refreshJob, refreshTimeout); + logger.progress("Status: Metrics refreshed successfully."); + } + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCTagValidator.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCTagValidator.java index 8130310d1d5..ffb6b3e259c 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCTagValidator.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCTagValidator.java @@ -23,44 +23,49 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCTagDefs.TagDefinition; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.ssc._common.rest.ssc.SSCUrls; +import com.fortify.cli.ssc.appversion.helper.SSCAppVersionHelper; +import kong.unirest.GetRequest; import kong.unirest.UnirestInstance; /** - * Validates that the SSC instance has the required custom tags and tag values - * before uploading an audited FPR. This prevents SSC from silently dropping - * audit results and provides actionable warnings to the user. + * Checks SSC custom tags before an audited FPR is uploaded. * - *

    Validates two categories of tags: - *

      - *
    • Aviator custom tags (Aviator prediction, Aviator status) — - * created by {@code aviator ssc prepare}.
    • - *
    • Analysis tag values — the standard Analysis tag - * ({@code 87f2364f-dcd4-49e6-861d-f8d3f351686b}) must contain the values - * that the tag mapping config writes (e.g., "Not an Issue", "Exploitable").
    • - *
    + *

    One {@code includeall=true} response supplies the tags. An assigned tag has type + * {@code CUSTOM}. A built-in Aviator tag has type {@code AVIATOR}. Either one makes + * Aviator prediction and Aviator status available. The Analysis tag, when the audit + * writes to it, must be in that response and must already contain the mapped values. + * Each problem is reported as a warning. Validation does not stop the upload. */ public final class AviatorSSCTagValidator { private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCTagValidator.class); + private static final String AVIATOR_TAG_TYPE = "AVIATOR"; + private static final List AVIATOR_TAGS = List.of( + AviatorSSCTagDefs.AVIATOR_PREDICTION_TAG, + AviatorSSCTagDefs.AVIATOR_STATUS_TAG); + private static final String ENABLE_AVIATOR = + "Enable Aviator in SSC under Administration -> Configuration -> AI Assistant -> Aviator."; + private static final String RUN_PREPARE = "Run 'fcli aviator ssc prepare' to resolve this."; private AviatorSSCTagValidator() {} /** - * Validates that the SSC instance has the required Aviator custom tags and - * that the Analysis tag contains the values needed for the audit results. + * Checks Aviator tags and Analysis values for one application version. * - *

    Logs warnings to both the progress writer (visible on stdout) and the - * log file. Does not throw exceptions — validation failures are advisory. + *

    Warnings are written to {@code logger} and returned. An empty list means every + * required tag and value is present. A failure to read tags from SSC is reported as + * a warning and is not thrown. * - * @param unirest active SSC session - * @param versionId SSC application version ID to validate tags for - * @param analysisTagId the tag ID used for writing audit results (from TagMappingConfig) - * @param analysisTagValues the set of values the audit may write to the Analysis tag - * @param logger logger for progress/warnings visible to the user - * @return list of warning messages (empty if all validations pass) + * @param unirest active SSC session + * @param versionId application version whose tags are checked + * @param analysisTagId GUID of the tag that receives audit results, or blank when that check is not required + * @param analysisTagValues values the audit may write to {@code analysisTagId} + * @param logger receives progress messages and warnings shown to the user + * @return warnings, empty when validation passes */ public static List validatePreUpload(UnirestInstance unirest, String versionId, String analysisTagId, Set analysisTagValues, IAviatorLogger logger) { @@ -69,32 +74,30 @@ public static List validatePreUpload(UnirestInstance unirest, String ver logger.progress("Status: Validating SSC custom tags for app version before uploading audited FPR..."); List warnings = new ArrayList<>(); try { - ArrayNode versionCustomTags = fetchVersionCustomTags(unirest, versionId); - if (versionCustomTags == null) { - String msg = "WARN: Could not retrieve custom tags for this application version from SSC. " - + "Tag validation skipped — audit results may be silently dropped if 'fcli aviator ssc prepare' has not been run."; - LOG.warn(msg); - warnings.add(msg); + ArrayNode tags = fetchCustomTags(unirest, versionId); + if (tags == null) { + warnings.add("WARN: Could not retrieve custom tags for this application version from SSC. " + + "Tag validation skipped — audit results may be silently dropped if 'fcli aviator ssc prepare' has not been run."); emitWarnings(warnings, logger); return warnings; } - LOG.info("Fetched {} custom tags for app version id={} from SSC.", versionCustomTags.size(), versionId); - LOG.debug("Version custom tags: {}", versionCustomTags); + LOG.info("Fetched {} custom tags for app version id={} from SSC.", tags.size(), versionId); + LOG.debug("Version custom tags: {}", tags); - validateAviatorCustomTags(versionCustomTags, warnings); - validateAnalysisTagValues(versionCustomTags, unirest, analysisTagId, analysisTagValues, warnings); + validateAviatorTags(tags, unirest, warnings); + validateAnalysisTag(tags, unirest, analysisTagId, analysisTagValues, warnings); if (warnings.isEmpty()) { LOG.info("Pre-upload tag validation passed — all required tags and values are present on this app version."); logger.progress("Status: SSC custom tag validation passed."); } else { - LOG.warn("Pre-upload tag validation found {} issue(s).", warnings.size()); + LOG.debug("Pre-upload tag validation found {} issue(s).", warnings.size()); emitWarnings(warnings, logger); } } catch (Exception e) { String msg = "WARN: Pre-upload tag validation failed: " + e.getMessage() + ". Proceeding with upload — audit results may be silently dropped by SSC."; - LOG.warn(msg, e); + LOG.debug(msg, e); warnings.add(msg); emitWarnings(warnings, logger); } @@ -102,8 +105,7 @@ public static List validatePreUpload(UnirestInstance unirest, String ver } /** - * Emits each warning via the progress writer so they are visible on stdout. - * Each warning is emitted as a separate progress message. + * Writes each collected warning once. */ private static void emitWarnings(List warnings, IAviatorLogger logger) { for (String warning : warnings) { @@ -111,139 +113,196 @@ private static void emitWarnings(List warnings, IAviatorLogger logger) { } } - private static ArrayNode fetchVersionCustomTags(UnirestInstance unirest, String versionId) { - String url = SSCUrls.PROJECT_VERSION_CUSTOM_TAGS(versionId); - LOG.debug("Fetching custom tags for app version from SSC via {}", url); - JsonNode body = unirest.get(url) - .queryString("limit", "-1") - .asObject(JsonNode.class).getBody(); + /** + * Returns the application version's custom tags, including built-in tags. + * Uses {@link SSCAppVersionHelper#getCustomTagsRequest} with {@code includeall=true}. + * That response contains assigned {@code CUSTOM} tags and internal tags such as Aviator. + * + * @return the tag array, or {@code null} when the response has no data array + */ + private static ArrayNode fetchCustomTags(UnirestInstance unirest, String versionId) { + LOG.debug("Fetching custom tags for app version id={} includeall=true", versionId); + GetRequest request = SSCAppVersionHelper.getCustomTagsRequest(unirest, versionId) + .queryString("includeall", "true"); + JsonNode body = request.asObject(JsonNode.class).getBody(); LOG.debug("SSC version custom tags response body: {}", body); - JsonNode data = body.get("data"); - if (data == null || !data.isArray()) { - LOG.warn("SSC version custom tags response has no 'data' array. body={}", body); - return null; + ArrayNode tags = dataArray(body); + if (tags == null) { + LOG.debug("SSC version custom tags response has no data array."); } - return (ArrayNode) data; + return tags; } /** - * Checks that both Aviator custom tags (Aviator prediction, Aviator status) - * exist on the SSC instance. These are created by {@code aviator ssc prepare}. + * Adds a warning for each Aviator tag missing from {@code tags}. + * A {@code CUSTOM} entry is assigned to the version. An {@code AVIATOR} entry is built-in. + * Either one is enough. A missing tag tells the user to enable Aviator when SSC publishes + * that tag as built-in, and to run prepare otherwise. */ - private static void validateAviatorCustomTags(ArrayNode allCustomTags, List warnings) { - validateTagExists(allCustomTags, AviatorSSCTagDefs.AVIATOR_PREDICTION_TAG, warnings); - validateTagExists(allCustomTags, AviatorSSCTagDefs.AVIATOR_STATUS_TAG, warnings); + private static void validateAviatorTags(ArrayNode tags, UnirestInstance unirest, List warnings) { + List missing = new ArrayList<>(); + for (TagDefinition tag : AVIATOR_TAGS) { + if (containsGuid(tags, tag.getGuid())) { + LOG.info("Custom tag '{}' (GUID: {}) is available on this app version — OK.", + tag.getName(), tag.getGuid()); + } else { + missing.add(tag); + } + } + if (missing.isEmpty()) { + return; + } + String resolution = publishesBuiltInAviatorTags(unirest) ? ENABLE_AVIATOR : RUN_PREPARE; + for (TagDefinition tag : missing) { + warnings.add(missingTagWarning(tag, resolution)); + } } - private static void validateTagExists(ArrayNode versionCustomTags, AviatorSSCTagDefs.TagDefinition tagDef, - List warnings) { - LOG.debug("Looking for custom tag '{}' with GUID '{}' among {} app version tags", - tagDef.getName(), tagDef.getGuid(), versionCustomTags.size()); - - boolean found = JsonHelper.stream(versionCustomTags) - .anyMatch(tag -> { - String tagGuid = tag.path("guid").asText(); - LOG.trace("Comparing version tag guid='{}' with expected guid='{}'", tagGuid, tagDef.getGuid()); - return tagDef.getGuid().equals(tagGuid); - }); - - if (found) { - LOG.info("Custom tag '{}' (GUID: {}) is associated with this app version — OK.", tagDef.getName(), tagDef.getGuid()); - } else { - String msg = String.format( - "WARN: Custom tag '%s' (GUID: %s) is not associated with this application version. " - + "Audit results for this tag will not be visible in SSC. " - + "Run 'fcli aviator ssc prepare' to resolve this.", - tagDef.getName(), tagDef.getGuid()); - LOG.warn(msg); - warnings.add(msg); + /** + * Returns whether SSC publishes Aviator prediction or Aviator status as a built-in tag. + * Older SSC exposes the same catalog without those tags, so the catalog alone is not enough. + * Returns {@code false} when the request fails, which keeps the prepare guidance. + */ + private static boolean publishesBuiltInAviatorTags(UnirestInstance unirest) { + try { + LOG.debug("Checking /internalCustomTags for built-in Aviator tags"); + JsonNode body = unirest.get(SSCUrls.INTERNAL_CUSTOM_TAGS).asObject(JsonNode.class).getBody(); + ArrayNode tags = dataArray(body); + boolean present = tags != null && JsonHelper.stream(tags).anyMatch(AviatorSSCTagValidator::isBuiltInAviatorTag); + LOG.debug("SSC /internalCustomTags has built-in Aviator tags={}", present); + return present; + } catch (Exception e) { + LOG.debug("Could not query /internalCustomTags: {}", e.getMessage()); + return false; } } /** - * Checks that the Analysis tag (or custom tag used for audit results) exists - * on the SSC application version and contains all required values. If the - * Analysis tag is a LIST type, missing values mean SSC will silently drop - * those audit decisions. + * Returns whether {@code tag} is Aviator prediction or Aviator status with custom tag type {@code AVIATOR}. + */ + private static boolean isBuiltInAviatorTag(JsonNode tag) { + return AVIATOR_TAGS.stream().anyMatch(tagDef -> isBuiltInTag(tag, tagDef)); + } + + /** + * Returns whether {@code tag} is {@code tagDef} and its custom tag type is {@code AVIATOR}. + */ + private static boolean isBuiltInTag(JsonNode tag, TagDefinition tagDef) { + return tagDef.getGuid().equalsIgnoreCase(tag.path("guid").asText()) + && AVIATOR_TAG_TYPE.equalsIgnoreCase(tag.path("customTagType").asText()); + } + + /** + * Returns the warning for an Aviator tag that is not available on the application version. + * + * @param resolution guidance that tells the user how to make the tag available + */ + private static String missingTagWarning(TagDefinition tagDef, String resolution) { + return String.format( + "WARN: Custom tag '%s' (GUID: %s) is not associated with this application version. " + + "Audit results for this tag will not be visible in SSC. %s", + tagDef.getName(), tagDef.getGuid(), resolution); + } + + /** + * Adds a warning when the Analysis tag is missing, or when a list-valued Analysis tag + * does not contain the values the audit will write. Does nothing when no Analysis tag + * or values were requested. An Analysis tag in {@code tags} is assigned to the version. */ - private static void validateAnalysisTagValues(ArrayNode versionCustomTags, UnirestInstance unirest, + private static void validateAnalysisTag(ArrayNode tags, UnirestInstance unirest, String analysisTagId, Set requiredValues, List warnings) { LOG.debug("Validating Analysis tag values. analysisTagId='{}', requiredValues={}", analysisTagId, requiredValues); - if (analysisTagId == null || analysisTagId.isBlank() || requiredValues == null || requiredValues.isEmpty()) { LOG.debug("Skipping Analysis tag validation: analysisTagId={}, requiredValues={}", analysisTagId, requiredValues); return; } - // Find the Analysis tag by GUID in the version's custom tags - JsonNode analysisTag = JsonHelper.stream(versionCustomTags) - .filter(tag -> analysisTagId.equalsIgnoreCase(tag.path("guid").asText())) - .findFirst().orElse(null); - + JsonNode analysisTag = findByGuid(tags, analysisTagId); if (analysisTag == null) { - LOG.debug("Analysis tag with GUID '{}' not found in app version custom tags. " - + "Available GUIDs: {}", analysisTagId, - JsonHelper.stream(versionCustomTags).map(t -> t.path("guid").asText()).toList()); - String msg = String.format( + LOG.debug("Analysis tag with GUID '{}' not found in app version custom tags. Available GUIDs: {}", + analysisTagId, JsonHelper.stream(tags).map(tag -> tag.path("guid").asText()).toList()); + warnings.add(String.format( "WARN: Analysis tag (GUID: %s) is not associated with this application version. " + "Audit results written to this tag will not be visible in SSC.", - analysisTagId); - LOG.warn(msg); - warnings.add(msg); + analysisTagId)); return; } - LOG.debug("Found Analysis tag: id={}, name='{}', valueType='{}'", - analysisTag.path("id").asText(), analysisTag.path("name").asText(), - analysisTag.path("valueType").asText()); - - // Only validate values for LIST type tags String valueType = analysisTag.path("valueType").asText(""); + LOG.debug("Found Analysis tag: id={}, name='{}', valueType='{}'", + analysisTag.path("id").asText(), analysisTag.path("name").asText(), valueType); if (!"LIST".equalsIgnoreCase(valueType)) { LOG.debug("Analysis tag valueType='{}' is not LIST — skipping value validation.", valueType); return; } + warnForMissingAnalysisValues(unirest, analysisTag, analysisTagId, requiredValues, warnings); + } - // Fetch full tag details to get valueList + /** + * Adds a warning when the Analysis value list is missing or does not contain {@code requiredValues}. + */ + private static void warnForMissingAnalysisValues(UnirestInstance unirest, JsonNode analysisTag, + String analysisTagId, Set requiredValues, List warnings) { String tagId = analysisTag.path("id").asText(); LOG.debug("Fetching full tag details for Analysis tag id={}", tagId); - JsonNode fullTagDetails = unirest.get(SSCUrls.CUSTOM_TAG(tagId)) - .asObject(JsonNode.class).getBody().path("data"); - LOG.debug("Full Analysis tag details: {}", fullTagDetails); + JsonNode tagDetails = unirest.get(SSCUrls.CUSTOM_TAG(tagId)).asObject(JsonNode.class).getBody().path("data"); + LOG.debug("Full Analysis tag details: {}", tagDetails); - JsonNode valueListNode = fullTagDetails.get("valueList"); - if (valueListNode == null || !valueListNode.isArray()) { - String msg = String.format( + JsonNode valueList = tagDetails.get("valueList"); + if (valueList == null || !valueList.isArray()) { + warnings.add(String.format( "WARN: Analysis tag '%s' has no value list configured. " + "Audit results written to this tag may be silently dropped by SSC.", - fullTagDetails.path("name").asText("Analysis")); - LOG.warn(msg); - warnings.add(msg); + tagDetails.path("name").asText("Analysis"))); return; } - Set existingValues = JsonHelper.stream((ArrayNode) valueListNode) - .map(v -> v.path("lookupValue").asText()) + Set existingValues = JsonHelper.stream((ArrayNode) valueList) + .map(value -> value.path("lookupValue").asText()) .collect(Collectors.toSet()); LOG.debug("Analysis tag existing values: {}", existingValues); LOG.debug("Required values: {}", requiredValues); List missingValues = requiredValues.stream() - .filter(v -> v != null && !v.isBlank()) - .filter(v -> !existingValues.contains(v)) + .filter(value -> value != null && !value.isBlank()) + .filter(value -> !existingValues.contains(value)) .toList(); + if (missingValues.isEmpty()) { + LOG.info("Analysis tag '{}' has all required values — OK.", tagDetails.path("name").asText("Analysis")); + return; + } + warnings.add(String.format( + "WARN: Analysis tag '%s' (GUID: %s) is missing the following values: %s. " + + "These audit results will not be reflected in SSC. " + + "Verify the tag configuration or use --tag-mapping to customize value mapping.", + tagDetails.path("name").asText("Analysis"), analysisTagId, missingValues)); + } - if (!missingValues.isEmpty()) { - String tagName = fullTagDetails.path("name").asText("Analysis"); - String msg = String.format( - "WARN: Analysis tag '%s' (GUID: %s) is missing the following values: %s. " - + "These audit results will not be reflected in SSC. " - + "Verify the tag configuration or use --tag-mapping to customize value mapping.", - tagName, analysisTagId, missingValues); - LOG.warn(msg); - warnings.add(msg); - } else { - LOG.info("Analysis tag '{}' has all required values — OK.", fullTagDetails.path("name").asText("Analysis")); + /** + * Returns whether {@code tags} contains {@code guid}. + */ + private static boolean containsGuid(ArrayNode tags, String guid) { + return findByGuid(tags, guid) != null; + } + + /** + * Returns the first tag whose GUID matches {@code guid}, or {@code null} when none does. + */ + private static JsonNode findByGuid(ArrayNode tags, String guid) { + if (tags == null || guid == null) { + return null; } + return JsonHelper.stream(tags) + .filter(tag -> guid.equalsIgnoreCase(tag.path("guid").asText())) + .findFirst() + .orElse(null); + } + + /** + * Returns the {@code data} array from an SSC response, or {@code null} when that array is absent. + */ + private static ArrayNode dataArray(JsonNode body) { + JsonNode data = body == null ? null : body.get("data"); + return data != null && data.isArray() ? (ArrayNode) data : null; } } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/DastFprCorrelationEnricher.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/DastFprCorrelationEnricher.java index 7594d29d4e1..bac2e5c5d55 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/DastFprCorrelationEnricher.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/DastFprCorrelationEnricher.java @@ -12,6 +12,7 @@ */ package com.fortify.cli.aviator.ssc.helper; +import java.io.InputStream; import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; @@ -19,12 +20,14 @@ import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; -import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.XMLConstants; import javax.xml.transform.OutputKeys; import javax.xml.transform.TransformerFactory; import javax.xml.transform.dom.DOMSource; @@ -38,6 +41,7 @@ import com.fortify.cli.aviator.grpc.CorrelatedPair; import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.common.util.SecureXmlParserFactory; import lombok.SneakyThrows; @@ -104,13 +108,12 @@ private int injectAll(Document doc, Map> pairsByDas List pairs = pairsByDastId.get(issueId); if (pairs == null || pairs.isEmpty()) continue; - // Remove any existing ExternalFindings to avoid duplicates on re-run - removeExistingExternalFindings(issue); - - Element externalFindings = doc.createElement("ExternalFindings"); + Element externalFindings = getOrCreateExternalFindings(doc, issue); + Set existingSastIds = getExistingSastIds(externalFindings); String timestamp = OffsetDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); for (CorrelatedPair pair : pairs) { + if (!existingSastIds.add(pair.sastInstanceId())) continue; Element ef = doc.createElement("ExternalFinding"); ef.setAttribute("Origin", "SCA"); @@ -120,26 +123,34 @@ private int injectAll(Document doc, Map> pairsByDas externalFindings.appendChild(ef); } - - issue.appendChild(externalFindings); injectedCount++; } return injectedCount; } - private void removeExistingExternalFindings(Element issue) { + private Element getOrCreateExternalFindings(Document doc, Element issue) { NodeList existing = issue.getElementsByTagName("ExternalFindings"); - // Collect first, then remove (to avoid ConcurrentModificationException) - List toRemove = new ArrayList<>(); for (int i = 0; i < existing.getLength(); i++) { - if (existing.item(i).getParentNode() == issue) { - toRemove.add(existing.item(i)); + if (existing.item(i).getParentNode() == issue && existing.item(i) instanceof Element element) { + return element; } } - for (org.w3c.dom.Node node : toRemove) { - issue.removeChild(node); + Element externalFindings = doc.createElement("ExternalFindings"); + issue.appendChild(externalFindings); + return externalFindings; + } + + private Set getExistingSastIds(Element externalFindings) { + Set result = new HashSet<>(); + NodeList originFindingIds = externalFindings.getElementsByTagName("OriginFindingID"); + for (int i = 0; i < originFindingIds.getLength(); i++) { + String sastId = originFindingIds.item(i).getTextContent(); + if (sastId != null && !sastId.isBlank()) { + result.add(sastId.trim()); + } } + return result; } private void appendChildElement(Document doc, Element parent, String name, String value) { @@ -158,25 +169,19 @@ private Map> groupByDastId(List pai @SneakyThrows private Document parseXml(Path path) { - var factory = DocumentBuilderFactory.newInstance(); - // Disable DOCTYPE declarations and external entity processing to prevent XXE attacks - try { - factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); - factory.setFeature("http://xml.org/sax/features/external-general-entities", false); - factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - factory.setXIncludeAware(false); - factory.setExpandEntityReferences(false); - } catch (Exception e) { - LOG.warn("Could not configure XXE protection for DocumentBuilderFactory; some protections may be unavailable: {}", - e.getMessage()); + var factory = SecureXmlParserFactory.newDocumentBuilderFactory(false); + try (InputStream input = Files.newInputStream(path)) { + return factory.newDocumentBuilder().parse(input); } - factory.setNamespaceAware(false); - return factory.newDocumentBuilder().parse(Files.newInputStream(path)); } @SneakyThrows private void writeXml(Document doc, Path path) { - var transformer = TransformerFactory.newInstance().newTransformer(); + var factory = TransformerFactory.newInstance(); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, ""); + var transformer = factory.newTransformer(); transformer.setOutputProperty(OutputKeys.INDENT, "yes"); transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2"); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SSCOnlineRemediationsFprSource.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SSCOnlineRemediationsFprSource.java new file mode 100644 index 00000000000..26fd1168079 --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SSCOnlineRemediationsFprSource.java @@ -0,0 +1,83 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.helper; + + +import com.fortify.cli.aviator._common.remediations_cache.IRemediationsFprSource; +import com.fortify.cli.aviator._common.util.AviatorTempFprFile; +import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCRemediationsSelectorArgGroups.OnlineSelectionArgGroup.ResolvedOnlineArtifacts; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.progress.helper.IProgressWriter; +import com.fortify.cli.ssc._common.rest.ssc.SSCUrls; +import com.fortify.cli.ssc._common.rest.ssc.transfer.SSCFileTransferHelper; +import com.fortify.cli.ssc.artifact.helper.SSCArtifactDescriptor; + +import kong.unirest.UnirestInstance; +import lombok.Getter; + +/** + * Online SSC remediations source: downloads each artifact FPR to a managed temp path + * for the duration of {@link EntryAction#accept}, then deletes it. + * + *

    {@link #close()} is a no-op: temps are owned per entry inside {@link #forEachEntry}. + * The type implements {@link AutoCloseable} so callers can use one try-with-resources + * pattern for all {@link IRemediationsFprSource} implementations. + */ +@Getter +public final class SSCOnlineRemediationsFprSource implements IRemediationsFprSource { + private final UnirestInstance unirest; + private final IAviatorLogger logger; + private final IProgressWriter progressWriter; + private final ResolvedOnlineArtifacts resolvedOnline; + + public SSCOnlineRemediationsFprSource( + UnirestInstance unirest, + IAviatorLogger logger, + IProgressWriter progressWriter, + ResolvedOnlineArtifacts resolvedOnline) { + FcliSimpleException.throwIf(resolvedOnline == null || resolvedOnline.artifacts() == null || resolvedOnline.artifacts().isEmpty(), + "No SSC artifacts to apply remediations from"); + this.unirest = unirest; + this.logger = logger; + this.progressWriter = progressWriter; + this.resolvedOnline = resolvedOnline; + } + + @Override + public void forEachEntry(EntryAction action) { + int total = resolvedOnline.artifacts().size(); + for (int i = 0; i < total; i++) { + SSCArtifactDescriptor artifact = resolvedOnline.artifacts().get(i); + String id = artifact.getId(); + String label = "artifact id=" + id; + try (AviatorTempFprFile tempFpr = AviatorTempFprFile.create(id)) { + logger.progress("Status: Downloading Audited FPR from SSC (artifact id=" + id + ")"); + SSCFileTransferHelper.download( + unirest, + SSCUrls.DOWNLOAD_ARTIFACT(id, true), + tempFpr.path(), + SSCFileTransferHelper.ISSCAddDownloadTokenFunction.ROUTEPARAM_DOWNLOADTOKEN, + progressWriter); + if (!action.accept(tempFpr.path(), label, id, i + 1, total)) { + break; + } + } + } + } + + @Override + public void close() { + // Per-entry temps are closed in forEachEntry; nothing retained on this instance. + } +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SastFprCorrelationRecorder.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SastFprCorrelationRecorder.java index 6d6364457b3..a704e1815e8 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SastFprCorrelationRecorder.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SastFprCorrelationRecorder.java @@ -12,6 +12,7 @@ */ package com.fortify.cli.aviator.ssc.helper; +import java.io.InputStream; import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; @@ -23,7 +24,7 @@ import java.util.Set; import java.util.stream.Collectors; -import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.XMLConstants; import javax.xml.transform.OutputKeys; import javax.xml.transform.TransformerFactory; import javax.xml.transform.dom.DOMSource; @@ -37,6 +38,7 @@ import com.fortify.cli.aviator.grpc.CorrelatedPair; import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.common.util.SecureXmlParserFactory; import lombok.SneakyThrows; @@ -369,14 +371,19 @@ private static void upsertCorrelationTag(Document doc, Element issue, String val @SneakyThrows private static Document parseXml(Path path) { - var factory = DocumentBuilderFactory.newInstance(); - factory.setNamespaceAware(true); // must be true to find ns0:Issue / ns0:Tag by local name - return factory.newDocumentBuilder().parse(Files.newInputStream(path)); + var factory = SecureXmlParserFactory.newDocumentBuilderFactory(true); + try (InputStream input = Files.newInputStream(path)) { + return factory.newDocumentBuilder().parse(input); + } } @SneakyThrows private static void writeXml(Document doc, Path path) { - var transformer = TransformerFactory.newInstance().newTransformer(); + var factory = TransformerFactory.newInstance(); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, ""); + var transformer = factory.newTransformer(); transformer.setOutputProperty(OutputKeys.INDENT, "yes"); transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2"); diff --git a/fcli-core/fcli-aviator/src/main/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties b/fcli-core/fcli-aviator/src/main/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties index adb447c737a..53db9c72176 100644 --- a/fcli-core/fcli-aviator/src/main/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties +++ b/fcli-core/fcli-aviator/src/main/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties @@ -73,6 +73,26 @@ fcli.aviator.app.update.usage.description = Updates an existing Fortify Aviator fcli.aviator.app.update.applicationId = ID of the application to update. fcli.aviator.app.update.name = The new name for the application. +# fcli aviator connection +fcli.aviator.connection.usage.header = Diagnose Fortify Aviator connectivity. +fcli.aviator.connection.usage.description = Commands for diagnosing Fortify Aviator endpoint and credential connectivity. +fcli.aviator.connection.diagnose.usage.header = Run Fortify Aviator connectivity diagnostics. +fcli.aviator.connection.diagnose.usage.description = Runs ordered connectivity checks for Fortify Aviator endpoint configuration, DNS, TCP, optional HTTP proxy CONNECT (when a proxy is configured), TLS, gRPC reachability, and optional credential validation. Use --url with optional --token, or a saved --aviator-session / --admin-config. +aviator.connection.diagnose.source.arggroup = Fortify Aviator diagnostics source options%n +fcli.aviator.connection.diagnose.url = Fortify Aviator URL to diagnose. Optionally pair with --token to validate a user token without a saved session. +fcli.aviator.connection.diagnose.token = Fortify Aviator user access token. Can be specified as one of:\ + \n file:\ + \n string:\ + \n env:\ + \n If no prefix is given, is assumed.\ + \n The url: prefix is not supported for this option.\ + \n For security reasons, avoid specifying sensitive tokens directly\ + \n on the command line (using string:). Prefer using files or env:. +fcli.aviator.connection.diagnose.aviator-session = Name of the Fortify Aviator user session to diagnose and validate. +fcli.aviator.connection.diagnose.av-session = Name of the Fortify Aviator user session to diagnose and validate. +fcli.aviator.connection.diagnose.admin-config = Name of the Fortify Aviator administrator configuration to diagnose and validate. +fcli.aviator.connection.diagnose.timeout = Timeout in seconds for TCP, proxy CONNECT, TLS, and gRPC diagnostic stages. Default value: ${DEFAULT-VALUE}. + # fcli aviator token fcli.aviator.token.usage.header = Manage Fortify Aviator access tokens. fcli.aviator.token.usage.description = Commands for creating, deleting, revoking, listing, and validating Fortify Aviator access tokens. These commands require an administrator configuration. @@ -115,13 +135,20 @@ fcli.aviator.entitlement.list-dast.usage.description = Retrieves a list of Forti # fcli aviator ssc fcli.aviator.ssc.usage.header = Use Fortify Remediation Aviator with SSC. -fcli.aviator.ssc.audit.usage.header = Audit an SSC application version using Fortify Remediation Aviator. -fcli.aviator.ssc.audit.usage.description = Downloads the FPR from an SSC application version, audits it with Fortify Remediation Aviator, and uploads the result back to SSC. \ +fcli.aviator.ssc.audit.usage.header = (DEPRECATED) Audit SAST findings in an SSC application version using Fortify Remediation Aviator. +fcli.aviator.ssc.audit.usage.description = This command is deprecated, please use 'fcli aviator ssc audit-sast' instead. \ + Downloads the FPR from an SSC application version, audits it with Fortify Remediation Aviator, and uploads the result back to SSC. \ + This command requires an active user session. Use 'fcli aviator session login' to create a session. \ + This command doesn't wait for SSC to finish processing the audited FPR file; please use the 'fcli ssc artifact wait-for' \ + command to wait until the audited FPR file has been processed by SSC. +fcli.aviator.ssc.audit-sast.usage.header = Audit SAST findings in an SSC application version using Fortify Remediation Aviator. +fcli.aviator.ssc.audit-sast.usage.description = Downloads the FPR from an SSC application version, audits it with Fortify Remediation Aviator, and uploads the result back to SSC. \ This command requires an active user session. Use 'fcli aviator session login' to create a session. \ This command doesn't wait for SSC to finish processing the audited FPR file; please use the 'fcli ssc artifact wait-for' \ command to wait until the audited FPR file has been processed by SSC. fcli.aviator.ssc.audit.app = Fortify Aviator application name to associate with the audit. If not provided, the SAST/FPR Build ID of the SSC application is used. fcli.aviator.ssc.audit.tag-mapping = Custom tag mapping for audit results. +fcli.aviator.ssc.audit-dast.tag-mapping = Custom tag mapping for DAST audit results. fcli.aviator.ssc.audit.filterset = Name or ID of the FilterSet to apply. fcli.aviator.ssc.audit.no-filterset = Do not apply any filter sets, including the default enabled filter set from the FPR. fcli.aviator.ssc.audit.folder = Filter issues by a comma-separated list of specific folder names from the selected FilterSet (e.g., 'Hot,Critical'). This option requires a FilterSet to be active. @@ -135,11 +162,39 @@ fcli.aviator.ssc.audit.refresh = By default, this command will refresh the sour Note that for large applications this can lead to an error if the timeout expires. fcli.aviator.ssc.audit.refresh-timeout = Time-out, for example 30s (30 seconds), 5m (5 minutes), 1h (1 hour). Default value: ${DEFAULT-VALUE} +fcli.aviator.ssc.audit-sast.app = Fortify Aviator application name to associate with the audit. If not provided, the SAST/FPR Build ID of the SSC application is used. +fcli.aviator.ssc.audit-sast.tag-mapping = Custom tag mapping for audit results. +fcli.aviator.ssc.audit-sast.filterset = Name or ID of the FilterSet to apply. +fcli.aviator.ssc.audit-sast.no-filterset = Do not apply any filter sets, including the default enabled filter set from the FPR. +fcli.aviator.ssc.audit-sast.folder = Filter issues by a comma-separated list of specific folder names from the selected FilterSet (e.g., 'Hot,Critical'). This option requires a FilterSet to be active. +fcli.aviator.ssc.audit-sast.skip-if-exceeding-quota = Skip audit if the number of open issues exceeds the available Fortify Remediation Aviator quota. When skipped, a summary with top unaudited categories is shown. +fcli.aviator.ssc.audit-sast.test-exceeding-quota = Check whether the number of open issues exceeds the available Fortify Remediation Aviator quota and report the result without performing an audit. +fcli.aviator.ssc.audit-sast.default-quota-fallback = (Internal) When the Fortify Aviator application does not exist, use the tenant default quota instead of reporting app not found. Used by bulk audit. +fcli.aviator.ssc.audit-sast.force-reaudit = Re-audit issues previously processed by Aviator, while preserving suppressed and manually triaged issues. +fcli.aviator.ssc.audit-sast.folder-priority-order = Custom priority order for folder-based filtering when quota is exceeded (comma-separated, highest priority first). Example: Critical,High,Medium,Low. If not specified, uses default priority order. +fcli.aviator.ssc.audit-sast.refresh = By default, this command will refresh the source application version's metrics when copying from it. \ + Note that for large applications this can lead to an error if the timeout expires. +fcli.aviator.ssc.audit-sast.refresh-timeout = Time-out, for example 30s (30 seconds), 5m (5 minutes), 1h (1 hour). Default value: ${DEFAULT-VALUE} + +fcli.ssc.appversion.create.refresh = Refresh out-of-date application version metrics before downloading the FPR. \ + Note that for large applications this can lead to an error if the timeout expires. +fcli.ssc.appversion.create.refresh-timeout = Time-out for refreshing application version metrics, for example 30s (30 seconds), \ + 5m (5 minutes), 1h (1 hour). Default value: ${DEFAULT-VALUE} + +fcli.aviator.ssc.audit-dast.usage.header = Audit DAST findings in an SSC application version using Fortify Aviator. +fcli.aviator.ssc.audit-dast.usage.description = Downloads the current application version FPR from SSC, audits eligible WebInspect findings, \ + writes decisions to audit.xml, and uploads the changed DAST FPR. This command requires an active Fortify Aviator user session. +fcli.aviator.ssc.audit-dast.app = Fortify Aviator application name to associate with the DAST audit. \ + If omitted, the SSC application name is used. + fcli.aviator.ssc.apply-remediations.usage.header = Apply auto-remediations from a Fortify Remediation Aviator-processed artifact to source code. -fcli.aviator.ssc.apply-remediations.usage.description = Downloads FPR artifact(s) and applies Fortify Remediation Aviator-generated remediations to the specified source directory. \ - Exactly one of --artifact-id, --latest, or --all must be specified. \ - This command requires an active user session. Use 'fcli aviator session login' to create a session. \ +fcli.aviator.ssc.apply-remediations.usage.description = Downloads FPR artifact(s) from SSC, or reads a local remediations cache zip, and applies Fortify Remediation Aviator-generated remediations to the specified source directory. \ + Exactly one of --from-cache, --artifact-id, --latest, or --all must be specified. Online selection requires an active SSC session; --from-cache does not. \ %n%nExamples: \ + %n%n Apply remediations from a remediations cache zip: \ + %n fcli aviator ssc apply-remediations --from-cache ./remediations.zip \ + %n%n Apply selected issue IDs from a cache zip: \ + %n fcli aviator ssc apply-remediations --from-cache ./remediations.zip --issue-ids id1,id2 \ %n%n Apply remediations from a known artifact by ID: \ %n fcli aviator ssc apply-remediations --artifact-id 12345 \ %n%n Apply remediations from the latest Fortify Remediation Aviator-processed artifact for an application version: \ @@ -152,15 +207,24 @@ fcli.aviator.ssc.apply-remediations.usage.description = Downloads FPR artifact(s %n fcli aviator ssc apply-remediations --av "MyApp:1.0" --all --since 2w \ %n%n Apply remediations from the latest artifact using a custom source directory: \ %n fcli aviator ssc apply-remediations --av "MyApp:1.0" --latest --source-dir /path/to/src -fcli.aviator.ssc.apply-remediations.artifact-id = Specific artifact ID to process. Mutually exclusive with --latest and --all. -fcli.aviator.ssc.apply-remediations.latest = Automatically select the most recent Fortify Remediation Aviator-processed artifact. Requires --av/--appversion. Mutually exclusive with --artifact-id and --all. -fcli.aviator.ssc.apply-remediations.all = Apply remediations from all Fortify Remediation Aviator-processed artifacts for the given application version, \ - in chronological order. Aggregates remediation statistics across all artifacts. \ - Requires --av/--appversion. Mutually exclusive with --artifact-id and --latest. +fcli.aviator.ssc.apply-remediations.from-cache = Local remediations cache zip produced by download-remediations-cache. Mutually exclusive with online selection options. Does not require an SSC session. fcli.aviator.ssc.apply-remediations.source-dir = Source code directory where remediations will be applied. Defaults to current directory. -fcli.aviator.ssc.apply-remediations.since = Filter artifacts by upload date. Supports relative durations (e.g. 7d, 2w, 1M, 90d) \ - or absolute dates (e.g. 2025-01-01, 2025-01-01T10:30:00, 2025-01-01T10:30:00Z). \ - Can only be used with --latest or --all; not compatible with --artifact-id. +fcli.aviator.ssc.apply-remediations.preview = Shows available remediations and their proposed changes without modifying source files. \ + Does not validate whether the proposed changes apply cleanly to the current source. +fcli.aviator.ssc.apply-remediations.issue-ids = Comma-separated list of issue IDs to apply. Matches requested values against remediations.xml \ + instanceId values. Requires --from-cache so integrations can download once via download-remediations-cache and apply selected remediations without repeated SSC downloads. +# Shared by download-remediations-cache and apply-remediations online selection (AviatorSSCRemediationsSelectorArgGroups). +fcli.aviator.ssc.remediations-cache.artifact-id = Specific artifact ID to process. Mutually exclusive with --latest and --all. +fcli.aviator.ssc.remediations-cache.latest = Select the most recent Fortify Remediation Aviator-processed artifact. Requires --av/--appversion. Mutually exclusive with --artifact-id and --all. +fcli.aviator.ssc.remediations-cache.all = Select all Fortify Remediation Aviator-processed artifacts for the given application version, newest upload first. Requires --av/--appversion. Mutually exclusive with --artifact-id and --latest. +fcli.aviator.ssc.remediations-cache.since = Filter artifacts by upload date. Supports relative durations (e.g. 7d, 2w, 1M, 90d) or absolute dates. Can only be used with --latest or --all. + +fcli.aviator.ssc.download-remediations-cache.usage.header = Download a remediations cache zip containing Fortify Remediation Aviator audited FPR(s) from SSC. +fcli.aviator.ssc.download-remediations-cache.usage.description = Downloads audited FPR file(s) from SSC into a single remediations cache zip (manifest.json + ordered FPR entries) for use with apply-remediations --from-cache. \ + Exactly one of --artifact-id, --latest, or --all must be specified. Requires -f/--file. Overwriting an existing file requires confirmation (-y/--confirm). +fcli.aviator.ssc.download-remediations-cache.file = Destination remediations cache zip path. Required. Existing files require confirmation (-y/--confirm) before overwrite. +fcli.aviator.ssc.download-remediations-cache.confirm = Confirm overwriting existing remediations cache file. +fcli.aviator.ssc.download-remediations-cache.confirmPrompt = Overwrite existing remediations cache file %s? fcli.aviator.ssc.prepare.usage.header = (PREVIEW) Prepare an SSC instance for Fortify Remediation Aviator integration. fcli.aviator.ssc.prepare.usage.description = This command ensures that the Fortify Remediation Aviator-specific custom tags ('Aviator prediction', 'Aviator status') \ @@ -186,15 +250,6 @@ fcli.aviator.ssc.correlate-sast-dast.usage.description = Downloads the latest SA and uploads it back to SSC. Requires an active SSC session and Fortify Aviator user session. fcli.aviator.ssc.correlate-sast-dast.app = Fortify Aviator application name to associate with the correlation. If not provided, the SAST/FPR Build ID of the SSC application is used. -#fcli aviator fod -fcli.aviator.fod.usage.header = Use Fortify Remediation Aviator with FoD. -fcli.aviator.fod.apply-remediations.usage.header = Apply Fortify Remediation Aviator remediations to source code. -fcli.aviator.fod.apply-remediations.usage.description = Downloads the FPR from an FoD Release ID and applies the remediations proposed by Fortify Remediation Aviator on the user's source code directory.\ - This command requires an active user session. Use 'fcli aviator session login' to create a session. \ -#fcli.aviator.fod.apply-remediations.releaseId = Downloads the FPR based on the release ID -fcli.fod.release.resolver.name-or-id = Release id or [:]: name. -fcli.aviator.fod.apply-remediations.source-dir = Path to the directory containing the source code to remediate. Remediations are applied to files in this directory. Default: current working directory. - ################################################################################################################# # The following are technical properties that shouldn't be internationalized #################################### ################################################################################################################# @@ -207,12 +262,15 @@ fcli.env.default.prefix=FCLI_DEFAULT fcli.aviator.session.output.table.args = name,type,url,created,expires,expired fcli.aviator.admin-config.output.table.args = name,type,url,created fcli.aviator.ssc.audit.output.table.args = id,application.name,name,artifactId,action,operation.audit.skippedReasons +fcli.aviator.ssc.audit-sast.output.table.args = id,application.name,name,artifactId,action,operation.audit.skippedReasons +fcli.aviator.ssc.audit-dast.output.table.args = id,application.name,name,artifactId,action fcli.aviator.app.create.output.table.args = id,name,entitlement_id,disclaimer,quota_last_updated,quota fcli.aviator.app.add-entitlement.output.table.args = id,name,entitlement_id,entitlement_multiplier,entitlements_consumed,quota_last_updated,quota fcli.aviator.app.delete.output.table.args = message fcli.aviator.app.get.output.table.args = id,name,entitlement_id,entitlement_multiplier,entitlements_consumed,updated_at,quota_last_updated,quota fcli.aviator.app.list.output.table.args = id,name,entitlement_id,entitlement_multiplier,entitlements_consumed,created_at,quota_last_updated,quota fcli.aviator.app.update.output.table.args = id,name,updated_at,quota_last_updated,quota +fcli.aviator.connection.diagnose.output.table.args = order,stage,status,required,summary,guidance fcli.aviator.token.create.output.table.args = token_name,start_date,expiry_date,token fcli.aviator.token.delete.output.table.args = message fcli.aviator.token.list.output.table.args = token_name,start_date,expiryDate,revoked,developer_email @@ -221,7 +279,7 @@ fcli.aviator.token.validate.output.table.args = message fcli.aviator.entitlement.list.output.table.args = id,tenant_name,start_date,end_date,number_of_applications,number_of_developers,contract_id,currently_linked_applications,is_valid fcli.aviator.entitlement.list-sast.output.table.args = id,tenant_name,start_date,end_date,number_of_applications,number_of_developers,contract_id,currently_linked_applications,is_valid fcli.aviator.entitlement.list-dast.output.table.args = id,tenant_name,start_date,end_date,number_of_units,credits_consumed,credits_reserved,credit_adjustments,credits_remaining,contract_id,is_valid -fcli.aviator.ssc.apply-remediations.output.table.args = appVersionId,artifactId,artifactsProcessed,artifactsSkipped,totalRemediation,appliedRemediation,supersededRemediation,possiblyRemediatedRemediation,skippedRemediation,skippedReasons +fcli.aviator.ssc.apply-remediations.output.table.args = appVersionId,artifactId,artifactsProcessed,artifactsSkipped,totalRemediation,appliedRemediation,identicalRemediation,supersededRemediation,possiblyRemediatedRemediation,skippedRemediation,skippedReasons +fcli.aviator.ssc.download-remediations-cache.output.table.args = file,artifactsDownloaded,artifactIds,__action__ fcli.aviator.ssc.correlate-sast-dast.output.table.args = applicationName,versionName,sastUnsuppressedCount,dastUnsuppressedCount,mixedCategories,correlatedPairs,__action__ fcli.aviator.ssc.prepare.output.table.args = status,entity,details -fcli.aviator.fod.apply-remediations.output.table.args = releaseId,totalRemediation,appliedRemediation,supersededRemediation,possiblyRemediatedRemediation,skippedRemediation,skippedReasons diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/_common/session/user/helper/AviatorUserTokenTextResolverTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/_common/session/user/helper/AviatorUserTokenTextResolverTest.java new file mode 100644 index 00000000000..0fa72a00467 --- /dev/null +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/_common/session/user/helper/AviatorUserTokenTextResolverTest.java @@ -0,0 +1,57 @@ +/* + * 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._common.session.user.helper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.common.exception.FcliSimpleException; + +class AviatorUserTokenTextResolverTest { + + @Test + void resolveOptionalReturnsNullWhenSourceBlank() { + assertNull(AviatorUserTokenTextResolver.resolveOptional(null, () -> "x")); + assertNull(AviatorUserTokenTextResolver.resolveOptional(" ", () -> "x")); + } + + @Test + void resolveRequiredRejectsUrlPrefix() { + assertThrows(FcliSimpleException.class, + () -> AviatorUserTokenTextResolver.resolveRequired("url:http://evil", () -> "token")); + assertThrows(FcliSimpleException.class, + () -> AviatorUserTokenTextResolver.resolveRequired("URL:http://evil", () -> "token")); + } + + @Test + void resolveOptionalRejectsUrlPrefix() { + assertThrows(FcliSimpleException.class, + () -> AviatorUserTokenTextResolver.resolveOptional("url:http://evil", () -> "token")); + assertThrows(FcliSimpleException.class, + () -> AviatorUserTokenTextResolver.resolveOptional("URL:http://evil", () -> "token")); + } + + @Test + void resolveRequiredRejectsBlankToken() { + assertThrows(FcliSimpleException.class, + () -> AviatorUserTokenTextResolver.resolveRequired("string: ", () -> " ")); + } + + @Test + void resolveRequiredReturnsToken() { + assertEquals("abc", AviatorUserTokenTextResolver.resolveRequired("string:abc", () -> "abc")); + } +} diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/connection/cli/cmd/AviatorConnectionDiagnoseCommandTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/connection/cli/cmd/AviatorConnectionDiagnoseCommandTest.java new file mode 100644 index 00000000000..48b271be250 --- /dev/null +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/connection/cli/cmd/AviatorConnectionDiagnoseCommandTest.java @@ -0,0 +1,83 @@ +/* + * 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.connection.cli.cmd; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.lang.reflect.Field; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator.connection.cli.mixin.AviatorConnectionDiagnoseSourceArgGroup; + +import picocli.CommandLine; +import picocli.CommandLine.ParameterException; + +class AviatorConnectionDiagnoseCommandTest { + @Test + void parseAllowsUrlWithOptionalToken() throws ReflectiveOperationException { + var cmd = parse("--url", "aviator.invalid", "--token", "string:abc"); + var sourceArgGroup = getSourceArgGroup(cmd); + + assertNotNull(sourceArgGroup.getUrlSource()); + assertEquals("aviator.invalid", sourceArgGroup.getUrlSource().getUrl()); + assertNotNull(sourceArgGroup.getUrlSource().getTokenSource()); + assertEquals("string:abc", sourceArgGroup.getUrlSource().getTokenSource().getTextSource()); + assertEquals("abc", sourceArgGroup.getUrlSource().getTokenOrNull()); + assertNull(sourceArgGroup.getAviatorSession()); + assertNull(sourceArgGroup.getAdminConfig()); + } + + @Test + void parseAllowsUrlWithoutToken() throws ReflectiveOperationException { + var cmd = parse("--url", "aviator.invalid"); + var sourceArgGroup = getSourceArgGroup(cmd); + + assertNotNull(sourceArgGroup.getUrlSource()); + assertEquals("aviator.invalid", sourceArgGroup.getUrlSource().getUrl()); + assertNull(sourceArgGroup.getUrlSource().getTokenSource()); + assertNull(sourceArgGroup.getUrlSource().getTokenOrNull()); + } + + @Test + void parseAllowsAviatorSessionAlias() throws ReflectiveOperationException { + var cmd = parse("--av-session", "default"); + var sourceArgGroup = getSourceArgGroup(cmd); + + assertEquals("default", sourceArgGroup.getAviatorSession()); + assertNull(sourceArgGroup.getUrlSource()); + assertNull(sourceArgGroup.getAdminConfig()); + } + + @Test + void parseRejectsMultipleSourceModes() { + assertThrows(ParameterException.class, + () -> parse("--url", "aviator.invalid", "--aviator-session", "default")); + } + + private static AviatorConnectionDiagnoseCommand parse(String... args) { + var cmd = new AviatorConnectionDiagnoseCommand(); + new CommandLine(cmd).parseArgs(args); + return cmd; + } + + private static AviatorConnectionDiagnoseSourceArgGroup getSourceArgGroup(AviatorConnectionDiagnoseCommand cmd) + throws ReflectiveOperationException { + Field field = AviatorConnectionDiagnoseCommand.class.getDeclaredField("sourceArgGroup"); + field.setAccessible(true); + return (AviatorConnectionDiagnoseSourceArgGroup) field.get(cmd); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/connection/helper/AviatorConnectionDiagnoseHelperTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/connection/helper/AviatorConnectionDiagnoseHelperTest.java new file mode 100644 index 00000000000..5c7add6c7e9 --- /dev/null +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/connection/helper/AviatorConnectionDiagnoseHelperTest.java @@ -0,0 +1,245 @@ +/* + * 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.connection.helper; + +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.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import com.fortify.cli.aviator._common.config.admin.helper.AviatorAdminConfigDescriptor; +import com.fortify.cli.aviator._common.session.user.helper.AviatorUserSessionDescriptor; +import com.fortify.cli.aviator._common.session.user.helper.AviatorUserSessionHelper.AviatorUserTokenValidationResult; +import com.fortify.cli.aviator.connection.helper.AviatorConnectionDiagnoseHelper.AdminValidator; +import com.fortify.cli.aviator.connection.helper.AviatorConnectionDiagnoseHelper.TokenValidator; +import com.fortify.cli.aviator.diagnose.AviatorConnectionDiagnostics; +import com.fortify.cli.aviator.diagnose.AviatorDiagnosticReport; +import com.fortify.cli.aviator.diagnose.AviatorDiagnosticStageResult; +import com.fortify.cli.aviator.diagnose.AviatorDiagnosticStatus; +import com.fortify.cli.aviator.diagnose.AviatorGrpcReachabilityResult; +import com.fortify.cli.aviator.diagnose.IAviatorDiagnosticProbe; +import com.fortify.cli.aviator.diagnose.support.ConfigurableDiagnosticProbe; +import com.fortify.cli.aviator.diagnose.support.OfflineConnectionPlan; +import com.fortify.cli.common.exception.FcliBugException; +import com.fortify.grpc.token.TokenValidationResponse; + +/** + * Product-layer credential policy: omit / skip / optional pass-fail + sourceType matrix. + * Transport skip chains live in {@code AviatorConnectionDiagnosticsTest}. + */ +class AviatorConnectionDiagnoseHelperTest { + + @ParameterizedTest(name = "{0}") + @MethodSource("sourceTypes") + void endpointSourceType(String label, AviatorConnectionDiagnoseSource source, String expectedSourceType) { + var result = helperWithGrpc(AviatorGrpcReachabilityResult.responseReceived("OK", "ok")) + .diagnose(source, 5); + assertEquals(expectedSourceType, result.stages().get(0).evidence().path("sourceType").asText()); + } + + static Stream sourceTypes() { + return Stream.of( + Arguments.of("url", AviatorConnectionDiagnoseSource.fromUrl("https://aviator.invalid"), "url"), + Arguments.of("url-token", + AviatorConnectionDiagnoseSource.fromUrlAndToken("https://aviator.invalid", "tok"), "url-token"), + Arguments.of("user-session", + AviatorConnectionDiagnoseSource.fromUserSession(session("session-tok")), "user-session"), + Arguments.of("admin-config", + AviatorConnectionDiagnoseSource.fromAdminConfig(admin()), "admin-config")); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("credentialSources") + void credentialSkippedWhenGrpcDidNotRespond(String label, AviatorConnectionDiagnoseSource source, + String expectedStage) { + var result = helperWithGrpc(AviatorGrpcReachabilityResult.noResponse("DEADLINE_EXCEEDED", "deadline")) + .diagnose(source, 5); + var cred = lastStage(result.stages()); + assertEquals(expectedStage, cred.stage()); + assertEquals(AviatorDiagnosticStatus.WARN, cred.status()); + assertFalse(cred.required()); + assertTrue(cred.summary().toLowerCase().contains("skipped")); + } + + static Stream credentialSources() { + return Stream.of( + Arguments.of("url-token", + AviatorConnectionDiagnoseSource.fromUrlAndToken("https://aviator.invalid", "tok"), + AviatorConnectionDiagnoseHelper.STAGE_TOKEN), + Arguments.of("user-session", + AviatorConnectionDiagnoseSource.fromUserSession(session("session-tok")), + AviatorConnectionDiagnoseHelper.STAGE_TOKEN), + Arguments.of("admin-config", + AviatorConnectionDiagnoseSource.fromAdminConfig(admin()), + AviatorConnectionDiagnoseHelper.STAGE_ADMIN)); + } + + @Test + void bareUrlOmitsCredentialStage() { + var result = helperWithGrpc(AviatorGrpcReachabilityResult.responseReceived("OK", "ok")) + .diagnose(AviatorConnectionDiagnoseSource.fromUrl("https://aviator.invalid"), 5); + + assertTrue(result.stages().stream().noneMatch(s -> + AviatorConnectionDiagnoseHelper.STAGE_TOKEN.equals(s.stage()) + || AviatorConnectionDiagnoseHelper.STAGE_ADMIN.equals(s.stage()))); + assertFalse(result.requiredFailure()); + } + + @Test + void tokenPassAndOptionalFailDoNotForceRequiredFailure() { + TokenValidator tokenOk = (url, token) -> + new AviatorUserTokenValidationResult("tenant", TokenValidationResponse.newBuilder().setValid(true).build()); + var pass = helperWithGrpcAndValidators( + AviatorGrpcReachabilityResult.responseReceived("OK", "ok"), tokenOk, d -> {}) + .diagnose(AviatorConnectionDiagnoseSource.fromUrlAndToken("https://aviator.invalid", "tok"), 5); + assertEquals(AviatorDiagnosticStatus.PASS, lastStage(pass.stages()).status()); + assertFalse(lastStage(pass.stages()).required()); + assertFalse(pass.requiredFailure()); + + TokenValidator tokenBad = (url, token) -> + new AviatorUserTokenValidationResult("tenant", + TokenValidationResponse.newBuilder().setValid(false).setErrorMessage("expired").build()); + var fail = helperWithGrpcAndValidators( + AviatorGrpcReachabilityResult.responseReceived("OK", "ok"), tokenBad, d -> {}) + .diagnose(AviatorConnectionDiagnoseSource.fromUrlAndToken("https://aviator.invalid", "tok"), 5); + assertEquals(AviatorDiagnosticStatus.FAIL, lastStage(fail.stages()).status()); + assertFalse(lastStage(fail.stages()).required()); + assertFalse(fail.requiredFailure()); + assertEquals("expired", lastStage(fail.stages()).evidence().path("tokenValidationMessage").asText()); + } + + @Test + void tokenValidatorExceptionIsOptionalFailWithExceptionEvidence() { + TokenValidator tokenBad = (url, token) -> { + throw new IllegalStateException("validator error"); + }; + var result = helperWithGrpcAndValidators( + AviatorGrpcReachabilityResult.responseReceived("OK", "ok"), tokenBad, d -> {}) + .diagnose(AviatorConnectionDiagnoseSource.fromUrlAndToken("https://aviator.invalid", "tok"), 5); + + var cred = lastStage(result.stages()); + assertEquals(AviatorDiagnosticStatus.FAIL, cred.status()); + assertFalse(cred.required()); + assertFalse(result.requiredFailure()); + assertEquals("java.lang.IllegalStateException", cred.evidence().path("exceptionType").asText()); + } + + @Test + void tokenValidatorBugExceptionIsNotSwallowed() { + TokenValidator tokenBug = (url, token) -> { + throw new FcliBugException("internal invariant broken"); + }; + assertThrows(FcliBugException.class, () -> helperWithGrpcAndValidators( + AviatorGrpcReachabilityResult.responseReceived("OK", "ok"), tokenBug, d -> {}) + .diagnose(AviatorConnectionDiagnoseSource.fromUrlAndToken("https://aviator.invalid", "tok"), 5)); + } + + @Test + void adminValidatorBugExceptionIsNotSwallowed() { + AdminValidator adminBug = d -> { + throw new FcliBugException("admin path bug"); + }; + assertThrows(FcliBugException.class, () -> helperWithGrpcAndValidators( + AviatorGrpcReachabilityResult.responseReceived("OK", "ok"), + (u, t) -> { + throw new IllegalStateException("token path should not run"); + }, + adminBug).diagnose(AviatorConnectionDiagnoseSource.fromAdminConfig(admin()), 5)); + } + + @Test + void adminPassUsesAdminStageAndValidator() { + var adminCalled = new AtomicBoolean(); + AdminValidator adminOk = d -> adminCalled.set(true); + var result = helperWithGrpcAndValidators( + AviatorGrpcReachabilityResult.responseReceived("OK", "ok"), + (u, t) -> { + throw new IllegalStateException("token path should not run"); + }, + adminOk).diagnose(AviatorConnectionDiagnoseSource.fromAdminConfig(admin()), 5); + + assertTrue(adminCalled.get()); + assertEquals(AviatorConnectionDiagnoseHelper.STAGE_ADMIN, lastStage(result.stages()).stage()); + assertEquals(AviatorDiagnosticStatus.PASS, lastStage(result.stages()).status()); + assertFalse(result.requiredFailure()); + } + + @Test + void userSessionWithMissingTokenOptionalFailsWithoutNpe() { + var result = helperWithGrpc(AviatorGrpcReachabilityResult.responseReceived("OK", "ok")) + .diagnose(AviatorConnectionDiagnoseSource.fromUserSession(session(null)), 5); + + var cred = lastStage(result.stages()); + assertEquals(AviatorConnectionDiagnoseHelper.STAGE_TOKEN, cred.stage()); + assertEquals(AviatorDiagnosticStatus.FAIL, cred.status()); + assertFalse(cred.required()); + assertFalse(result.requiredFailure()); + assertTrue(cred.summary().toLowerCase().contains("missing")); + } + + private static AviatorUserSessionDescriptor session(String token) { + return AviatorUserSessionDescriptor.builder() + .aviatorUrl("https://aviator.invalid") + .aviatorToken(token) + .build(); + } + + private static AviatorAdminConfigDescriptor admin() { + return AviatorAdminConfigDescriptor.builder() + .aviatorUrl("https://aviator.invalid") + .tenant("demo") + .build(); + } + + private static AviatorDiagnosticStageResult lastStage(List stages) { + return stages.get(stages.size() - 1); + } + + private static AviatorConnectionDiagnoseHelper helperWithGrpc(AviatorGrpcReachabilityResult grpc) { + return helperWithGrpcAndValidators(grpc, + (u, t) -> { + throw new IllegalStateException("token validator not expected"); + }, + d -> { + throw new IllegalStateException("admin validator not expected"); + }); + } + + private static AviatorConnectionDiagnoseHelper helperWithGrpcAndValidators( + AviatorGrpcReachabilityResult grpc, + TokenValidator tokenValidator, + AdminValidator adminValidator) { + return new AviatorConnectionDiagnoseHelper( + new OfflineDiagnostics(new ConfigurableDiagnosticProbe(grpc)), tokenValidator, adminValidator); + } + + private static final class OfflineDiagnostics extends AviatorConnectionDiagnostics { + OfflineDiagnostics(IAviatorDiagnosticProbe probe) { + super(probe); + } + + @Override + public AviatorDiagnosticReport diagnose(String url, int timeoutSeconds, String sourceType) { + return diagnose(OfflineConnectionPlan.fixedUrl(url), timeoutSeconds, sourceType); + } + } +} diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandOrderTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandOrderTest.java index aeca7c62b81..216b500f72f 100644 --- a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandOrderTest.java +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandOrderTest.java @@ -33,12 +33,17 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fortify.cli.aviator._common.cli.mixin.SourceEncodingsMixin; +import com.fortify.cli.aviator._common.remediations_cache.IApplyRemediationsOptions; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsApplyHelper; +import com.fortify.cli.aviator._common.util.AviatorRemediationMetricsHelper; +import com.fortify.cli.aviator.config.AviatorLoggerImpl; +import com.fortify.cli.aviator.fpr.utils.ISourceDecoder; import com.fortify.cli.aviator.fpr.utils.SourceDecoders; -import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCApplyRemediationsArtifactSelectorMixin; +import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCRemediationsSelectorArgGroups.OnlineModeArgGroup; +import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCRemediationsSelectorArgGroups.OnlineSelectionArgGroup; +import com.fortify.cli.aviator.ssc.helper.SSCOnlineRemediationsFprSource; import com.fortify.cli.aviator.util.FileUtil; import com.fortify.cli.common.cli.mixin.CommandHelperMixin; import com.fortify.cli.common.json.JsonHelper; @@ -54,25 +59,13 @@ import picocli.CommandLine.Model.CommandSpec; /** - * The order in which {@code apply-remediations --all} walks an application version's artifacts is a - * yield decision, and the current order is the losing one. + * {@code --all} walks newest upload first for both download-remediations-cache and online apply. * - *

    {@code SSCArtifactHelper.getAllAviatorArtifacts} fetches {@code uploadDate DESC} and then - * reverses the list "to maintain ascending order contract", so - * {@code AviatorSSCApplyRemediationsCommand} applies the OLDEST scan first. The oldest scan is the - * one whose line numbers are the most stale relative to the customer's current checkout: letting it - * go first rewrites the file and destroys the newest scan's chance of a clean hash match, after - * which the newest scan has to fall back on fuzzy anchoring and can lose its hunk to an ambiguous - * match. Newest-first applies the best-matching artifact while the file is still pristine, and the - * older artifacts then simply fail their own anchor checks, which is the correct outcome. - * - *

    This is NOT a correctness test. Whatever gets written is written in the right place in either - * order - that is guaranteed by anchor verification, not by ordering. What the order costs us is - * fixes we could have landed. {@code getAllAviatorArtifacts} has exactly one caller, this command, - * so the fix can be a local reverse in the {@code --all} loop or a change to the helper's ordering - * contract; this test only cares that the newest artifact is applied first. - * - *

    Currently: red, {@code appliedRemediation} is 1. Turns green when the order is reversed. + *

    SSC returns {@code uploadDate DESC}. {@code getAllAviatorArtifacts} reverses that to oldest-first. + * The shared selector reverses again, so the latest artifact is processed first. That newer hunk matches + * the pristine file. The older hunk is then applied at its own lines, which the newer edit did not move, + * so both fixes land. Oldest-first applies the stale scan first, shifts the file, and the newer hunk then + * misses its hash and loses an ambiguous fuzzy match. */ class AviatorSSCApplyRemediationsCommandOrderTest { private static final String APP_VERSION_ID = "42"; @@ -102,14 +95,24 @@ void allOpenIssuesAppliesNewestArtifactFirstSoFewerRemediationsAreLost() throws try (TestSscServer server = new TestSscServer(Map.of("1", olderFpr, "2", newerFpr)); UnirestInstance unirest = newUnirest(server)) { - JsonNode result = newAllOpenIssuesCommand().getJsonNode(unirest); - - assertEquals(2, result.path("artifactsProcessed").asInt()); - assertEquals(2, result.path("appliedRemediation").asInt(), - "newest-first lands both fixes; oldest-first loses the newer artifact's hunk to an " - + "ambiguous fuzzy match after the older artifact has shifted the file"); - assertEquals("head\nOLD1\nOLD2\nctx\nDUP\nctx2\nfiller\nctx\nNEWFIX\nctx2\ntail\n", - Files.readString(sourceFile)); + var resolved = allArtifactsSelector().resolveArtifacts(unirest, null); + assertEquals(List.of("2", "1"), resolved.artifacts().stream().map(artifact -> artifact.getId()).toList()); + + var progressWriterFactory = silentProgressWriterFactory(); + try (var progressWriter = progressWriterFactory.create(); + var source = new SSCOnlineRemediationsFprSource( + unirest, new AviatorLoggerImpl(progressWriter), progressWriter, resolved)) { + var applyResult = RemediationsApplyHelper.apply( + source, applyOptions(), null, new AviatorLoggerImpl(progressWriter)); + var result = AviatorRemediationMetricsHelper.aggregateMetrics(null, applyResult.metrics()); + + assertEquals(2, applyResult.metrics().size()); + assertEquals(2, result.appliedRemediations(), + "newest-first lands both fixes; oldest-first loses the newer artifact's hunk to an " + + "ambiguous fuzzy match after the older artifact has shifted the file"); + assertEquals("head\nOLD1\nOLD2\nctx\nDUP\nctx2\nfiller\nctx\nNEWFIX\nctx2\ntail\n", + Files.readString(sourceFile)); + } } } @@ -118,31 +121,24 @@ void allOpenIssuesAppliesNewestArtifactFirstSoFewerRemediationsAreLost() throws // the command's fields are picocli mixins that are otherwise only populated by a real parse. // ------------------------------------------------------------------------------------------ - private AviatorSSCApplyRemediationsCommand newAllOpenIssuesCommand() throws Exception { - AviatorSSCApplyRemediationsCommand command = new AviatorSSCApplyRemediationsCommand(); - set(command, "sourceCodeDirectory", tempDir.toString()); - set(command, "artifactSelector", allOpenIssuesSelector()); - set(command, "progressWriterFactoryMixin", silentProgressWriterFactory()); - set(command, "sourceEncodingsMixin", defaultSourceEncodings()); - return command; - } - - private static AviatorSSCApplyRemediationsArtifactSelectorMixin allOpenIssuesSelector() throws Exception { - var selector = new AviatorSSCApplyRemediationsArtifactSelectorMixin(); - var argGroup = AviatorSSCApplyRemediationsArtifactSelectorMixin.ArtifactSelectionArgGroup.class - .getDeclaredConstructor().newInstance(); - set(argGroup, "allOpenIssues", true); - set(selector, "artifactSelection", argGroup); + private static OnlineSelectionArgGroup allArtifactsSelector() throws Exception { + var selector = new OnlineSelectionArgGroup(); + var mode = new OnlineModeArgGroup(); + set(mode, "all", true); + set(selector, "mode", mode); set(selector, "appVersionNameOrId", APP_VERSION_ID); set(selector, "delimiter", ":"); return selector; } - /** picocli normally applies the --source-encodings default; set it explicitly here. */ - private static SourceEncodingsMixin defaultSourceEncodings() throws Exception { - var mixin = new SourceEncodingsMixin(); - set(mixin, "sourceDecoders", List.of(SourceDecoders.defaults())); - return mixin; + private IApplyRemediationsOptions applyOptions() { + return new IApplyRemediationsOptions() { + @Override public String getSourceCodeDirectory() { return tempDir.toString(); } + @Override public List getIssueIds() { return List.of(); } + @Override public ISourceDecoder getSourceDecoder() { return SourceDecoders.defaults(); } + @Override public boolean isPreviewMode() { return false; } + @Override public void validate() {} + }; } private static ProgressWriterFactoryMixin silentProgressWriterFactory() throws Exception { diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java new file mode 100644 index 00000000000..7023986d02f --- /dev/null +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java @@ -0,0 +1,84 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.cli.cmd; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Field; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCApplyRemediationsOptionsMixin; +import com.fortify.cli.common.exception.FcliSimpleException; + +import picocli.CommandLine; + +/** + * CLI wiring and product rules for apply-remediations (not metric/util logic). + */ +class AviatorSSCApplyRemediationsCommandTest { + + @Test + void fromCacheParsesPath() throws Exception { + AviatorSSCApplyRemediationsCommand command = parse("--from-cache", "remediations.zip"); + assertEquals(Path.of("remediations.zip"), getApplyOptions(command).getFromCache()); + assertTrue(getApplyOptions(command).isFromCacheSelected()); + } + + @Test + void fromCacheCannotBeCombinedWithArtifactId() { + assertThrows(CommandLine.ParameterException.class, + () -> parse("--artifact-id", "1", "--from-cache", "cache.zip")); + } + + @Test + void issueIdsRequireFromCache() { + AviatorSSCApplyRemediationsCommand command = parse("--artifact-id", "1", "--issue-ids", "ISSUE-1"); + assertThrows(FcliSimpleException.class, command::getJsonNode); + } + + @Test + void previewFlagParsedCorrectly() throws Exception { + AviatorSSCApplyRemediationsCommand command = parse("--from-cache", "remediations.zip", "--preview"); + assertTrue(getApplyOptions(command).isPreviewMode()); + } + + @Test + void previewWorksWithIssueIds() throws Exception { + AviatorSSCApplyRemediationsCommand command = parse("--from-cache", "cache.zip", "--preview", "--issue-ids", "ISSUE-1,ISSUE-2"); + assertTrue(getApplyOptions(command).isPreviewMode()); + assertEquals(2, getApplyOptions(command).getIssueIds().size()); + } + + @Test + void previewWorksWithOnlineSelection() throws Exception { + AviatorSSCApplyRemediationsCommand command = parse("--artifact-id", "123", "--preview"); + assertTrue(getApplyOptions(command).isPreviewMode()); + } + + private static AviatorSSCApplyRemediationsCommand parse(String... args) { + AviatorSSCApplyRemediationsCommand command = new AviatorSSCApplyRemediationsCommand(); + new CommandLine(command).parseArgs(args); + return command; + } + + private static AviatorSSCApplyRemediationsOptionsMixin getApplyOptions(AviatorSSCApplyRemediationsCommand command) + throws Exception { + Field field = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("applyOptions"); + field.setAccessible(true); + return (AviatorSSCApplyRemediationsOptionsMixin) field.get(command); + } +} diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommandTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommandTest.java index c1041fd5a75..ede906eb6c1 100644 --- a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommandTest.java +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommandTest.java @@ -21,6 +21,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Map; +import java.util.ResourceBundle; import org.junit.jupiter.api.Test; @@ -32,6 +33,36 @@ import picocli.CommandLine; class AviatorSSCAuditCommandTest { + @Test + void registersCanonicalAndDeprecatedCommandNames() { + assertEquals("audit-sast", new CommandLine(new AviatorSSCSastAuditCommand()).getCommandName()); + assertEquals("audit", new CommandLine(new AviatorSSCAuditCommand()).getCommandName()); + } + + @Test + void canonicalAndDeprecatedCommandsShareUnannotatedBase() { + assertEquals(AbstractAviatorSSCSastAuditCommand.class, AviatorSSCSastAuditCommand.class.getSuperclass()); + assertEquals(AbstractAviatorSSCSastAuditCommand.class, AviatorSSCAuditCommand.class.getSuperclass()); + assertFalse(AbstractAviatorSSCSastAuditCommand.class.isAnnotationPresent(CommandLine.Command.class)); + } + + @Test + void deprecatedCommandHelpPointsToCanonicalCommand() { + var messages = ResourceBundle.getBundle("com.fortify.cli.aviator.i18n.AviatorMessages"); + String header = messages.getString("fcli.aviator.ssc.audit.usage.header"); + String description = messages.getString("fcli.aviator.ssc.audit.usage.description"); + + assertTrue(header.contains("(DEPRECATED)")); + assertTrue(description.contains("fcli aviator ssc audit-sast")); + } + + @Test + void canonicalCommandAllowsFilterOptions() { + var cmd = parseCanonical("--filterset", "Security Auditor View", "--no-filterset"); + assertEquals("Security Auditor View", cmd.getFilterSetTitleOrId()); + assertTrue(cmd.isNoFilterSet()); + } + @Test void testAllowsCombinedFilterOptions() { var cmd = parse("--filterset", "Security Auditor View", "--no-filterset"); @@ -56,11 +87,17 @@ void testAllowsNoFilterSetOption() { @Test void testAllowsForceReauditOption() throws Exception { var cmd = parse("--force-reaudit"); - Field field = AviatorSSCAuditCommand.class.getDeclaredField("forceReaudit"); + Field field = AbstractAviatorSSCSastAuditCommand.class.getDeclaredField("forceReaudit"); field.setAccessible(true); assertTrue((boolean) field.get(cmd)); } + @Test + void canonicalAndDeprecatedCommandsAllowSourceEncodings() { + parse("--source-encodings", "UTF-8"); + parseCanonical("--source-encodings", "UTF-8"); + } + @Test void reportsDecodeSkippedIssuesAsNotSubmitted() { ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); @@ -105,10 +142,20 @@ void excludesOnlyDecodeSkippedIssuesFromSubmittedCount() { private static AviatorSSCAuditCommand parse(String... args) { var cmd = new AviatorSSCAuditCommand(); + parse(cmd, args); + return cmd; + } + + private static AviatorSSCSastAuditCommand parseCanonical(String... args) { + var cmd = new AviatorSSCSastAuditCommand(); + parse(cmd, args); + return cmd; + } + + private static void parse(AbstractAviatorSSCSastAuditCommand cmd, String... args) { var fullArgs = new ArrayList(); Collections.addAll(fullArgs, "--av", "test:1.0"); Collections.addAll(fullArgs, args); new CommandLine(cmd).parseArgs(fullArgs.toArray(String[]::new)); - return cmd; } } \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCorrelateSastDastCommandTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCorrelateSastDastCommandTest.java new file mode 100644 index 00000000000..60128a2acf5 --- /dev/null +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCCorrelateSastDastCommandTest.java @@ -0,0 +1,33 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.cli.cmd; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import org.junit.jupiter.api.Test; + +import picocli.CommandLine; + +class AviatorSSCCorrelateSastDastCommandTest { + @Test + void acceptsSscRefreshOptions() { + var commandLine = new CommandLine(new AviatorSSCCorrelateSastDastCommand()); + + var parseResult = commandLine.parseArgs( + "--av", "test:1.0", "--no-refresh", "--refresh-timeout", "2m"); + + assertFalse(parseResult.matchedOptionValue("--refresh", true)); + assertEquals("2m", parseResult.matchedOptionValue("--refresh-timeout", null)); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDastAuditCommandTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDastAuditCommandTest.java new file mode 100644 index 00000000000..3860a7701d1 --- /dev/null +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDastAuditCommandTest.java @@ -0,0 +1,104 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.cli.cmd; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator.audit.DastAuditFprResult; +import com.fortify.cli.aviator.audit.DastAuditFprStatus; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCAuditHelper; +import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; + +import picocli.CommandLine; + +class AviatorSSCDastAuditCommandTest { + @Test + void rejectsClientSideToneImprovementOptions() { + assertThrows(CommandLine.ParameterException.class, () -> parse("--improve-tone")); + assertThrows(CommandLine.ParameterException.class, () -> parse("--no-improve-tone")); + } + + @Test + void acceptsCustomTagMapping() { + var commandLine = new CommandLine(new AviatorSSCDastAuditCommand()); + + var parseResult = commandLine.parseArgs("--av", "test:1.0", "--tag-mapping", "dast-tags.yaml"); + + assertEquals("dast-tags.yaml", parseResult.matchedOptionValue("--tag-mapping", null)); + } + + @Test + void acceptsSscRefreshOptions() { + var commandLine = new CommandLine(new AviatorSSCDastAuditCommand()); + + var parseResult = commandLine.parseArgs( + "--av", "test:1.0", "--no-refresh", "--refresh-timeout", "2m"); + + assertFalse(parseResult.matchedOptionValue("--refresh", true)); + assertEquals("2m", parseResult.matchedOptionValue("--refresh-timeout", null)); + } + + @Test + void dastAuditStatsUseSastAuditOutputEnvelope() { + var appVersion = new SSCAppVersionDescriptor(); + appVersion.setVersionId("42"); + appVersion.setApplicationName("WebGoat"); + appVersion.setVersionName("1.0"); + var auditResult = DastAuditFprResult.builder() + .status(DastAuditFprStatus.PARTIALLY_AUDITED) + .totalReported(8) + .eligible(6) + .submitted(6) + .succeeded(4) + .truePositives(2) + .falsePositivesSuppressed(1) + .likelyFalsePositives(1) + .skipped(2) + .reservedQuota(6) + .exceededCount(2) + .build(); + + var result = AviatorSSCAuditHelper.buildResultNode(appVersion, "2786", auditResult.status().name()); + AviatorSSCAuditHelper.setDastAuditStats(result, auditResult); + + assertEquals("42", result.path("id").asText()); + assertEquals("WebGoat", result.path("applicationName").asText()); + assertEquals("2786", result.path("artifactId").asText()); + assertEquals("PARTIALLY_AUDITED", result.path("__action__").asText()); + assertEquals(6, result.path("operation").path("audit").path("submitted").asInt()); + assertEquals(4, result.path("operation").path("audit").path("succeeded").asInt()); + assertEquals(2, result.path("operation").path("audit").path("skipped").asInt()); + assertEquals(0, result.path("operation").path("audit").path("failed").asInt()); + assertFalse(result.path("operation").path("audit").has("truePositives")); + assertFalse(result.path("operation").path("audit").has("falsePositivesSuppressed")); + assertFalse(result.path("operation").path("audit").has("likelyFalsePositives")); + assertFalse(result.has("state")); + assertFalse(result.has("submitted")); + } + + private static AviatorSSCDastAuditCommand parse(String... args) { + var command = new AviatorSSCDastAuditCommand(); + var fullArgs = new ArrayList(); + Collections.addAll(fullArgs, "--av", "test:1.0"); + Collections.addAll(fullArgs, args); + new CommandLine(command).parseArgs(fullArgs.toArray(String[]::new)); + return command; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsCacheCommandTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsCacheCommandTest.java new file mode 100644 index 00000000000..9d14f52efb3 --- /dev/null +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsCacheCommandTest.java @@ -0,0 +1,48 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.cli.cmd; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.common.exception.FcliSimpleException; + +import picocli.CommandLine; + +class AviatorSSCDownloadRemediationsCacheCommandTest { + @Test + void testArtifactIdAndLatestAreMutuallyExclusive() { + assertThrows(CommandLine.ParameterException.class, + () -> parse("--artifact-id", "1", "--latest", "-f", "cache.zip")); + } + + @Test + void testFileIsRequired() { + assertThrows(CommandLine.ParameterException.class, + () -> parse("--artifact-id", "1")); + } + + @Test + void testArtifactIdRejectsAppVersion() { + AviatorSSCDownloadRemediationsCacheCommand command = parse("--artifact-id", "1", "--av", "2", "-f", "cache.zip"); + + assertThrows(FcliSimpleException.class, () -> command.getJsonNode(null)); + } + + private static AviatorSSCDownloadRemediationsCacheCommand parse(String... args) { + AviatorSSCDownloadRemediationsCacheCommand command = new AviatorSSCDownloadRemediationsCacheCommand(); + new CommandLine(command).parseArgs(args); + return command; + } +} diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAttributeHelperTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAttributeHelperTest.java new file mode 100644 index 00000000000..4d5cd6b387e --- /dev/null +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAttributeHelperTest.java @@ -0,0 +1,125 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.helper; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.rest.unirest.UnirestHelper; +import com.fortify.cli.common.rest.unirest.config.UnirestJsonHeaderConfigurer; +import com.fortify.cli.common.rest.unirest.config.UnirestUnexpectedHttpResponseConfigurer; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +import kong.unirest.UnirestInstance; + +class AviatorSSCAttributeHelperTest { + @Test + void writesLastDastAuditTimestampAsTextAttribute() throws Exception { + try (var server = new TestSscServer(); var unirest = newUnirest(server)) { + AviatorSSCAttributeHelper.writeLastDastAuditTimestamp(unirest, "42"); + + JsonNode update = server.getLastUpdate(); + assertEquals("42", update.get(0).path("attributeDefinitionId").asText()); + assertTrue(update.get(0).path("value").asText().matches("\\d{4}-\\d{2}-\\d{2}T.*Z")); + } + } + + @Test + void exposesStableDastAuditAttributeDefinition() { + var definition = AviatorSSCAttributeDefinitions.LAST_DAST_AUDIT_ATTR; + + assertEquals("last_dast_audit", definition.name()); + assertEquals("TECHNICAL", definition.category()); + assertEquals("TEXT", definition.type()); + } + + @Test + void markerWriteFailureIsReportedWithoutThrowing() throws Exception { + try (var server = new TestSscServer().withUpdateStatus(500); var unirest = newUnirest(server)) { + assertDoesNotThrow(() -> AviatorSSCAttributeHelper.writeLastDastAuditTimestamp(unirest, "42")); + } + } + + private static UnirestInstance newUnirest(TestSscServer server) { + return UnirestHelper.createUnirestInstance(unirest -> { + UnirestJsonHeaderConfigurer.configure(unirest); + UnirestUnexpectedHttpResponseConfigurer.configure(unirest); + unirest.config().defaultBaseUrl(server.getBaseUrl()); + }); + } + + private static final class TestSscServer implements AutoCloseable { + private final HttpServer server; + private JsonNode lastUpdate; + private int updateStatus = 200; + + private TestSscServer() throws IOException { + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/api/v1/attributeDefinitions", this::handleDefinitions); + server.createContext("/api/v1/projectVersions/42/attributes", this::handleAttributes); + server.start(); + } + + private String getBaseUrl() { + return "http://localhost:" + server.getAddress().getPort(); + } + + private JsonNode getLastUpdate() { + return lastUpdate; + } + + private TestSscServer withUpdateStatus(int status) { + updateStatus = status; + return this; + } + + private void handleDefinitions(HttpExchange exchange) throws IOException { + respond(exchange, 200, """ + {"data":[{"id":"42","guid":"C3D4E5F6-A7B8-9012-BCDE-F12345678902","name":"last_dast_audit","category":"TECHNICAL","type":"TEXT","required":false,"hasDefault":false,"options":[]}]} + """); + } + + private void handleAttributes(HttpExchange exchange) throws IOException { + if (!"PUT".equals(exchange.getRequestMethod())) { + respond(exchange, 405, "{}"); + return; + } + lastUpdate = JsonHelper.getObjectMapper().readTree(exchange.getRequestBody()); + respond(exchange, updateStatus, "{}"); + } + + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] response = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, response.length); + try (var output = exchange.getResponseBody()) { + output.write(response); + } + } + + @Override + public void close() { + server.stop(0); + } + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelperTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelperTest.java index c1a3f0f6122..ccf25a5dbfa 100644 --- a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelperTest.java +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelperTest.java @@ -29,6 +29,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fortify.cli.aviator.config.AviatorLoggerImpl; +import com.fortify.cli.aviator.util.Constants; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.progress.helper.IProgressWriter; import com.fortify.cli.common.rest.unirest.UnirestHelper; @@ -43,7 +44,7 @@ class AviatorSSCAuditHelperTest { @Test void excludesPreviouslyProcessedIssuesFromNormalPreflightCount() throws Exception { - try (var server = new TestSscServer(processedIssue(), unprocessedIssue()); + try (var server = new TestSscServer(processedIssue(), legacyAnalysisIssue(), unprocessedIssue()); var unirest = newUnirest(server)) { assertEquals(1, AviatorSSCAuditHelper.getAuditableIssueCount( unirest, appVersion(), logger(), true, null, null, false)); @@ -61,6 +62,15 @@ void forceReauditPreflightIncludesPreviouslyProcessedIssues() throws Exception { } } + @Test + void forceReauditPreflightIncludesLegacyAnalysisTagCandidates() throws Exception { + try (var server = new TestSscServer(legacyAnalysisIssue()); + var unirest = newUnirest(server)) { + assertEquals(1, AviatorSSCAuditHelper.getAuditableIssueCount( + unirest, appVersion(), logger(), true, null, null, true)); + } + } + @Test void forceReauditPreflightDoesNotSkipAnAllProcessedApplicationVersion() throws Exception { try (var server = new TestSscServer(processedIssue()); @@ -72,7 +82,7 @@ void forceReauditPreflightDoesNotSkipAnAllProcessedApplicationVersion() throws E } @Test - void forceReauditPreflightExcludesHumanAuditedIssuesThatAviatorNeverProcessed() throws Exception { + void forceReauditPreflightExcludesAuditedIssuesWithoutAviatorOrLegacyResultTag() throws Exception { try (var server = new TestSscServer(processedIssue(), unprocessedIssue(), humanAuditedIssue()); var unirest = newUnirest(server)) { assertEquals(2, AviatorSSCAuditHelper.getAuditableIssueCount( @@ -83,7 +93,7 @@ void forceReauditPreflightExcludesHumanAuditedIssuesThatAviatorNeverProcessed() @Test void forceReauditPreflightExcludesSuppressedAviatorIssues() throws Exception { - try (var server = new TestSscServer(processedIssue(), suppressedProcessedIssue()); + try (var server = new TestSscServer(processedIssue(), suppressedProcessedIssue(), suppressedLegacyAnalysisIssue()); var unirest = newUnirest(server)) { assertEquals(1, AviatorSSCAuditHelper.getAuditableIssueCount( unirest, appVersion(), logger(), true, null, null, true)); @@ -140,6 +150,16 @@ private static ObjectNode unprocessedIssue() { return issue; } + private static ObjectNode legacyAnalysisIssue() { + ObjectNode issue = JsonHelper.getObjectMapper().createObjectNode(); + issue.put("audited", true); + issue.putObject("_embed").putArray("auditValues") + .addObject() + .put("customTagGuid", Constants.ANALYSIS_TAG_ID) + .put("customTagIndex", 4); + return issue; + } + private static ObjectNode humanAuditedIssue() { ObjectNode issue = JsonHelper.getObjectMapper().createObjectNode(); issue.put("audited", true); @@ -153,6 +173,12 @@ private static ObjectNode suppressedProcessedIssue() { return issue; } + private static ObjectNode suppressedLegacyAnalysisIssue() { + ObjectNode issue = legacyAnalysisIssue(); + issue.put("suppressed", true); + return issue; + } + private static final class TestSscServer implements AutoCloseable { private final HttpServer server; private final ArrayNode issues = JsonHelper.getObjectMapper().createArrayNode(); diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateFprParserTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateFprParserTest.java index a3c6966fbc6..26013338d2d 100644 --- a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateFprParserTest.java +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateFprParserTest.java @@ -66,6 +66,26 @@ void parseDastFprPreservesNullCategoryWhen7pkCategoryIsMissing() throws Exceptio assertEquals("SQL Injection", result.dastIssues.get(0).getName()); } + @Test + void parseDastFprMapsSuppressionFromAuditState() throws Exception { + Path fprPath = createMinimalDastFpr(true, true); + + AviatorSSCCorrelateFprParser.ParseResult result = AviatorSSCCorrelateFprParser.parseDastFpr(fprPath); + + assertEquals(1, result.dastIssues.size()); + assertTrue(result.dastIssues.get(0).isSuppressed()); + } + + @Test + void parseSastFprMapsSuppressionFromAuditState() throws Exception { + Path fprPath = createMinimalSastFpr(true); + + AviatorSSCCorrelateFprParser.ParseResult result = AviatorSSCCorrelateFprParser.parseSastFpr(fprPath); + + assertEquals(1, result.vulnerabilities.size()); + assertTrue(result.vulnerabilities.get(0).isSuppressed()); + } + private Path createMinimalSastFpr(boolean includeAuditXml) throws Exception { Path fprPath = tempDir.resolve(includeAuditXml ? "sast-with-audit.fpr" : "sast-no-audit.fpr"); if (Files.exists(fprPath)) { @@ -133,7 +153,10 @@ private String minimalAuditXml() { TestProject - + + + + """; } diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateHelperTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateHelperTest.java index 31382a25263..87ae0fd2116 100644 --- a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateHelperTest.java +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCCorrelateHelperTest.java @@ -31,15 +31,17 @@ import com.fortify.cli.aviator.fpr.Vulnerability; import com.fortify.cli.aviator.fpr.model.AuditIssue; import com.fortify.cli.aviator.grpc.CorrelatedPair; +import com.fortify.cli.aviator.grpc.CorrelationResult; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; +import com.fortify.cli.ssc.artifact.helper.SSCArtifactDescriptor; class AviatorSSCCorrelateHelperTest { @TempDir Path tempDir; - // ── buildOutputJson ────────────────────────────────────────────────── + // ── correlation output ─────────────────────────────────────────────── @Test void testBuildOutputJson_withCorrelatedPairs() { @@ -49,7 +51,19 @@ void testBuildOutputJson_withCorrelatedPairs() { new CorrelatedPair("SAST-2", "DAST-2", "scan-guid", "MEDIUM", "match") ); - var result = AviatorSSCCorrelateHelper.buildOutputJson(av, "artifact-123", 5, 4, pairs, "CORRELATED"); + var correlationResult = CorrelationResult.builder() + .confirmedPairs(pairs) + .submittedCorrelationRequests(5) + .successfulCorrelationResponses(4) + .skippedCorrelationResponses(1) + .build(); + var result = AviatorSSCCorrelateOutput.builder() + .appVersion(av) + .artifactId("artifact-123") + .correlationResult(correlationResult) + .actionResult("CORRELATED") + .build() + .toJsonNode(); assertEquals("37", result.get("id").asText()); assertEquals("MyApp", result.get("applicationName").asText()); @@ -61,25 +75,81 @@ void testBuildOutputJson_withCorrelatedPairs() { assertEquals(5, correlate.get("submitted").asInt()); assertEquals(4, correlate.get("succeeded").asInt()); assertEquals(1, correlate.get("skipped").asInt()); + assertEquals(0, correlate.get("failed").asInt()); assertEquals(2, correlate.get("correlated").asInt()); assertEquals( - "5 SAST findings submitted, 2 correlated pairs confirmed", + "5 SAST findings submitted: 4 succeeded, 1 skipped, 0 failed; 2 correlated pairs confirmed", + correlate.get("message").asText()); + } + + @Test + void testBuildOutputJson_moreSuccessfulResponsesThanSubmitted() { + var av = createAppVersionDescriptor("95", "APPHANDLE", "2"); + + var correlationResult = CorrelationResult.builder() + .submittedCorrelationRequests(40) + .successfulCorrelationResponses(46) + .build(); + var result = AviatorSSCCorrelateOutput.builder() + .appVersion(av) + .artifactId("4168") + .correlationResult(correlationResult) + .actionResult("CORRELATED") + .build() + .toJsonNode(); + + JsonNode correlate = result.get("operation").get("correlate"); + assertEquals(40, correlate.get("submitted").asInt()); + assertEquals(40, correlate.get("succeeded").asInt()); + assertEquals(0, correlate.get("skipped").asInt()); + assertEquals(0, correlate.get("failed").asInt()); + } + + @Test + void testBuildOutputJson_failedResponsesAreNotReportedAsSkipped() { + var av = createAppVersionDescriptor("194", "cor1", "1.0"); + + var correlationResult = CorrelationResult.builder() + .submittedCorrelationRequests(53) + .failedCorrelationResponses(53) + .build(); + var result = AviatorSSCCorrelateOutput.builder() + .appVersion(av) + .correlationResult(correlationResult) + .actionResult("FAILED") + .build() + .toJsonNode(); + + JsonNode correlate = result.get("operation").get("correlate"); + assertEquals(53, correlate.get("submitted").asInt()); + assertEquals(0, correlate.get("succeeded").asInt()); + assertEquals(0, correlate.get("skipped").asInt()); + assertEquals(53, correlate.get("failed").asInt()); + assertEquals( + "53 SAST findings submitted: 0 succeeded, 0 skipped, 53 failed; 0 correlated pairs confirmed", correlate.get("message").asText()); } @Test void testBuildOutputJson_noPairsSubmitted() { var av = createAppVersionDescriptor("42", "TestApp", "2.0"); - var result = AviatorSSCCorrelateHelper.buildOutputJson(av, null, 0, 0, List.of(), "SKIPPED"); + var result = AviatorSSCCorrelateOutput.builder() + .appVersion(av) + .correlationResult(CorrelationResult.empty()) + .actionResult("SKIPPED") + .message("No issues present for correlation") + .build() + .toJsonNode(); assertTrue(result.get("artifactId").isNull()); assertEquals("SKIPPED", result.get(IActionCommandResultSupplier.actionFieldName).asText()); JsonNode correlate = result.get("operation").get("correlate"); - assertTrue(correlate.get("message").isNull()); + assertEquals("No issues present for correlation", correlate.get("message").asText()); assertTrue(correlate.get("submitted").isNull()); assertTrue(correlate.get("succeeded").isNull()); assertTrue(correlate.get("skipped").isNull()); + assertTrue(correlate.get("failed").isNull()); assertEquals(0, correlate.get("correlated").asInt()); } @@ -124,6 +194,32 @@ void testIsVulnerabilitySuppressed_nullInstanceId() { assertFalse(AviatorSSCCorrelateHelper.isVulnerabilitySuppressed(vuln, auditMap)); } + // ── isUnchangedSinceCorrelation ───────────────────────────────────── + + @Test + void identifiesUnchangedAviatorCorrelationArtifact() { + var sastArtifact = createArtifact("100", "aviator_42_state.fpr"); + var dastArtifact = createArtifact("100", "aviator_42_state.fpr"); + + assertTrue(AviatorSSCCorrelateHelper.isUnchangedSinceCorrelation(sastArtifact, dastArtifact)); + } + + @Test + void treatsNewUserMixedArtifactAsChanged() { + var sastArtifact = createArtifact("101", "mixed-scan.fpr"); + var dastArtifact = createArtifact("101", "mixed-scan.fpr"); + + assertFalse(AviatorSSCCorrelateHelper.isUnchangedSinceCorrelation(sastArtifact, dastArtifact)); + } + + @Test + void treatsDifferentLatestArtifactsAsChanged() { + var sastArtifact = createArtifact("101", "sast.fpr"); + var dastArtifact = createArtifact("102", "dast.fpr"); + + assertFalse(AviatorSSCCorrelateHelper.isUnchangedSinceCorrelation(sastArtifact, dastArtifact)); + } + // ── validateDownloadedFpr ──────────────────────────────────────────── @Test @@ -166,4 +262,11 @@ private SSCAppVersionDescriptor createAppVersionDescriptor(String id, String app descriptor.setVersionName(versionName); return descriptor; } + + private SSCArtifactDescriptor createArtifact(String id, String originalFileName) { + var artifact = new SSCArtifactDescriptor(); + artifact.setId(id); + artifact.asObjectNode().put("originalFileName", originalFileName); + return artifact; + } } diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCFprTransferHelperTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCFprTransferHelperTest.java new file mode 100644 index 00000000000..509b7e2578b --- /dev/null +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCFprTransferHelperTest.java @@ -0,0 +1,40 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.helper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.common.exception.FcliTechnicalException; +import com.fortify.cli.common.json.JsonHelper; + +class AviatorSSCFprTransferHelperTest { + @Test + void returnsUploadedArtifactId() throws Exception { + var response = JsonHelper.getObjectMapper().readTree("{\"data\":{\"id\":2786}}"); + + assertEquals("2786", AviatorSSCFprTransferHelper.getUploadedArtifactId(response)); + } + + @Test + void rejectsMissingArtifactId() throws Exception { + var response = JsonHelper.getObjectMapper().readTree("{\"data\":{}}"); + + assertThrows(FcliTechnicalException.class, + () -> AviatorSSCFprTransferHelper.getUploadedArtifactId(response)); + assertThrows(FcliTechnicalException.class, + () -> AviatorSSCFprTransferHelper.getUploadedArtifactId(null)); + } +} diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCTagValidatorTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCTagValidatorTest.java new file mode 100644 index 00000000000..c1333ad9285 --- /dev/null +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCTagValidatorTest.java @@ -0,0 +1,331 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.helper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.rest.unirest.UnirestHelper; +import com.fortify.cli.common.rest.unirest.config.UnirestJsonHeaderConfigurer; +import com.fortify.cli.common.rest.unirest.config.UnirestUnexpectedHttpResponseConfigurer; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +import kong.unirest.UnirestInstance; + +/** + * Pre-upload checks for Aviator prediction, Aviator status, and Analysis values. + * One {@code includeall=true} response supplies assigned {@code CUSTOM} tags and built-in + * {@code AVIATOR} tags. A missing tag uses the internal catalog to choose enable-Aviator + * or prepare guidance. + */ +class AviatorSSCTagValidatorTest { + + private static final String VERSION_ID = "15"; + private static final String ANALYSIS_GUID = "87f2364f-dcd4-49e6-861d-f8d3f351686b"; + + private TestSscServer server; + private UnirestInstance unirest; + + @AfterEach + void tearDown() { + if (unirest != null) { + unirest.close(); + } + if (server != null) { + server.close(); + } + } + + @Test + @DisplayName("assigned CUSTOM Aviator tags in the includeall response pass") + void assignedCustomTagsDoNotWarn() throws IOException { + server = new TestSscServer() + .withTags(customTag(AviatorSSCTagDefs.AVIATOR_PREDICTION_TAG.getGuid(), "Aviator prediction"), + customTag(AviatorSSCTagDefs.AVIATOR_STATUS_TAG.getGuid(), "Aviator status")); + unirest = newUnirest(server); + + List warnings = validate(); + + assertTrue(warnings.isEmpty()); + assertTrue(server.includeAllRequests == 1); + assertTrue(server.plainRequests == 0); + assertTrue(server.internalCatalogRequests == 0); + } + + @Test + @DisplayName("built-in AVIATOR tags in the includeall response do not warn") + void builtInAviatorTagsDoNotWarn() throws IOException { + server = new TestSscServer() + .withTags( + customTag(ANALYSIS_GUID, "Analysis"), + aviatorTag(AviatorSSCTagDefs.AVIATOR_PREDICTION_TAG.getGuid(), "Aviator prediction"), + aviatorTag(AviatorSSCTagDefs.AVIATOR_STATUS_TAG.getGuid(), "Aviator status")); + unirest = newUnirest(server); + + List warnings = validate(); + + assertTrue(warnings.isEmpty(), warnings.toString()); + assertTrue(server.includeAllRequests == 1); + assertTrue(server.plainRequests == 0); + assertTrue(server.internalCatalogRequests == 0); + } + + @Test + @DisplayName("SSC 26.2+ missing Aviator tags say to enable Aviator") + void ssc26MissingAviatorTagsSayEnableAviator() throws IOException { + server = new TestSscServer() + .withBuiltInAviatorCatalog() + .withTags(customTag(ANALYSIS_GUID, "Analysis")); + unirest = newUnirest(server); + + List warnings = validate(); + + assertTrue(warnings.stream().anyMatch(w -> w.contains("Aviator prediction"))); + assertTrue(warnings.stream().anyMatch(w -> w.contains("Aviator status"))); + assertTrue(warnings.stream().allMatch(w -> w.contains("Enable Aviator in SSC"))); + assertFalse(warnings.stream().anyMatch(w -> w.contains("fcli aviator ssc prepare"))); + assertTrue(server.includeAllRequests == 1); + assertTrue(server.plainRequests == 0); + } + + @Test + @DisplayName("internal catalog without Aviator tags keeps the prepare warning") + void internalCatalogWithoutAviatorTagsKeepsPrepare() throws IOException { + server = new TestSscServer() + .withInternalCatalog(customTag(ANALYSIS_GUID, "Analysis")) + .withTags(customTag(ANALYSIS_GUID, "Analysis")); + unirest = newUnirest(server); + + List warnings = validate(); + + assertTrue(warnings.stream().anyMatch(w -> w.contains("Aviator prediction"))); + assertTrue(warnings.stream().anyMatch(w -> w.contains("Aviator status"))); + assertTrue(warnings.stream().allMatch(w -> w.contains("fcli aviator ssc prepare"))); + assertFalse(warnings.stream().anyMatch(w -> w.contains("Enable Aviator"))); + assertTrue(server.plainRequests == 0); + } + + @Test + @DisplayName("Analysis in the includeall response is accepted") + void analysisTagInIncludeAllDoesNotWarn() throws IOException { + server = new TestSscServer() + .withTags( + customTag(ANALYSIS_GUID, "Analysis"), + aviatorTag(AviatorSSCTagDefs.AVIATOR_PREDICTION_TAG.getGuid(), "Aviator prediction"), + aviatorTag(AviatorSSCTagDefs.AVIATOR_STATUS_TAG.getGuid(), "Aviator status")); + unirest = newUnirest(server); + + List warnings = validate(ANALYSIS_GUID, Set.of("Not an Issue")); + + assertTrue(warnings.isEmpty(), warnings.toString()); + assertTrue(server.includeAllRequests == 1); + assertTrue(server.plainRequests == 0); + } + + @Test + @DisplayName("missing Analysis tag warns while built-in Aviator tags pass") + void missingAnalysisTagWarns() throws IOException { + server = new TestSscServer() + .withTags( + aviatorTag(AviatorSSCTagDefs.AVIATOR_PREDICTION_TAG.getGuid(), "Aviator prediction"), + aviatorTag(AviatorSSCTagDefs.AVIATOR_STATUS_TAG.getGuid(), "Aviator status")); + unirest = newUnirest(server); + + List warnings = validate(ANALYSIS_GUID, Set.of("Not an Issue")); + + assertTrue(warnings.stream().anyMatch(w -> w.contains("Analysis tag"))); + assertFalse(warnings.stream().anyMatch(w -> w.contains("Aviator prediction"))); + assertFalse(warnings.stream().anyMatch(w -> w.contains("Aviator status"))); + assertTrue(server.plainRequests == 0); + } + + @Test + @DisplayName("includeall failure reports that validation failed") + void includeAllFailureReportsValidationFailed() throws IOException { + server = new TestSscServer().failIncludeAll(); + unirest = newUnirest(server); + + List warnings = validate(); + + assertTrue(warnings.stream().anyMatch(w -> w.contains("Pre-upload tag validation failed"))); + assertFalse(warnings.stream().anyMatch(w -> w.contains("fcli aviator ssc prepare"))); + assertFalse(warnings.stream().anyMatch(w -> w.contains("Enable Aviator"))); + assertTrue(server.includeAllRequests == 1); + assertTrue(server.plainRequests == 0); + } + + private List validate() { + return validate(null, Set.of()); + } + + private List validate(String analysisTagId, Set analysisTagValues) { + CollectingLogger logger = new CollectingLogger(); + List warnings = AviatorSSCTagValidator.validatePreUpload( + unirest, VERSION_ID, analysisTagId, analysisTagValues, logger); + assertEquals(warnings, logger.warnings); + return warnings; + } + + private static UnirestInstance newUnirest(TestSscServer server) { + return UnirestHelper.createUnirestInstance(unirest -> { + UnirestJsonHeaderConfigurer.configure(unirest); + UnirestUnexpectedHttpResponseConfigurer.configure(unirest); + unirest.config().defaultBaseUrl(server.getBaseUrl()); + }); + } + + private static ObjectNode customTag(String guid, String name) { + return tag(guid, name, "CUSTOM"); + } + + private static ObjectNode aviatorTag(String guid, String name) { + return tag(guid, name, "AVIATOR"); + } + + private static ObjectNode tag(String guid, String name, String customTagType) { + return JsonHelper.getObjectMapper().createObjectNode() + .put("guid", guid) + .put("name", name) + .put("customTagType", customTagType); + } + + private static final class CollectingLogger implements IAviatorLogger { + private final List warnings = new ArrayList<>(); + + @Override public void progress(String format, Object... args) {} + @Override public void info(String format, Object... args) {} + @Override public void warn(String format, Object... args) { + warnings.add(String.format(format, args)); + } + @Override public void error(String format, Object... args) {} + } + + private static final class TestSscServer implements AutoCloseable { + private final HttpServer server; + private final ArrayNode tags = JsonHelper.getObjectMapper().createArrayNode(); + private final ArrayNode internalCatalog = JsonHelper.getObjectMapper().createArrayNode(); + private boolean failIncludeAll; + private boolean internalCatalogPresent; + private int includeAllRequests; + private int plainRequests; + private int internalCatalogRequests; + + private TestSscServer() throws IOException { + this.server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/api/v1/projectVersions/" + VERSION_ID + "/customTags", this::handleCustomTags); + server.createContext("/api/v1/internalCustomTags", this::handleInternalCustomTags); + server.start(); + } + + private TestSscServer withBuiltInAviatorCatalog() { + return withInternalCatalog( + aviatorTag(AviatorSSCTagDefs.AVIATOR_PREDICTION_TAG.getGuid(), "Aviator prediction"), + aviatorTag(AviatorSSCTagDefs.AVIATOR_STATUS_TAG.getGuid(), "Aviator status")); + } + + private TestSscServer withInternalCatalog(ObjectNode... tags) { + this.internalCatalogPresent = true; + internalCatalog.removeAll(); + for (ObjectNode tag : tags) { + internalCatalog.add(tag); + } + return this; + } + + private String getBaseUrl() { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + private TestSscServer withTags(ObjectNode... versionTags) { + tags.removeAll(); + for (ObjectNode tag : versionTags) { + tags.add(tag); + } + return this; + } + + private TestSscServer failIncludeAll() { + this.failIncludeAll = true; + return this; + } + + private void handleInternalCustomTags(HttpExchange exchange) throws IOException { + internalCatalogRequests++; + if (!internalCatalogPresent) { + byte[] body = "{}".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(404, body.length); + try (OutputStream outputStream = exchange.getResponseBody()) { + outputStream.write(body); + } + return; + } + writeJson(exchange, internalCatalog); + } + + private void handleCustomTags(HttpExchange exchange) throws IOException { + String query = exchange.getRequestURI().getRawQuery(); + boolean includeAllRequest = query != null && query.contains("includeall=true"); + if (includeAllRequest) { + includeAllRequests++; + if (failIncludeAll) { + byte[] body = "{}".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(500, body.length); + try (OutputStream outputStream = exchange.getResponseBody()) { + outputStream.write(body); + } + return; + } + writeJson(exchange, tags); + return; + } + plainRequests++; + writeJson(exchange, JsonHelper.getObjectMapper().createArrayNode()); + } + + private void writeJson(HttpExchange exchange, ArrayNode data) throws IOException { + ObjectNode wrapper = JsonHelper.getObjectMapper().createObjectNode(); + wrapper.set("data", data); + byte[] response = JsonHelper.getObjectMapper() + .writeValueAsString(wrapper) + .getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, response.length); + try (OutputStream outputStream = exchange.getResponseBody()) { + outputStream.write(response); + } + } + + @Override + public void close() { + server.stop(0); + } + } +} diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/DastFprCorrelationEnricherTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/DastFprCorrelationEnricherTest.java new file mode 100644 index 00000000000..8970cf2aa7f --- /dev/null +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/DastFprCorrelationEnricherTest.java @@ -0,0 +1,122 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.helper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import javax.xml.parsers.DocumentBuilderFactory; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import com.fortify.cli.aviator.grpc.CorrelatedPair; +import com.fortify.cli.aviator.util.FprHandle; + +class DastFprCorrelationEnricherTest { + @TempDir Path tempDir; + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + void preservesExistingExternalFindingsAndAvoidsDuplicates(boolean withDoctype) throws Exception { + Path fprPath = createFpr(); + if (withDoctype) { + try (FprHandle fprHandle = new FprHandle(fprPath)) { + Path xmlPath = fprHandle.getPath("/webinspect.xml"); + Files.writeString(xmlPath, "" + + Files.readString(xmlPath)); + } + } + var pairs = List.of( + new CorrelatedPair("SAST-1", "DAST-1", "scan-1", "HIGH", "existing"), + new CorrelatedPair("SAST-2", "DAST-1", "scan-2", "HIGH", "new") + ); + + new DastFprCorrelationEnricher().injectAndRepackage(fprPath, pairs); + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + var factory = DocumentBuilderFactory.newInstance(); + try (var inputStream = Files.newInputStream(fprHandle.getPath("/webinspect.xml"))) { + var document = factory.newDocumentBuilder().parse(inputStream); + var originFindingIds = document.getElementsByTagName("OriginFindingID"); + assertEquals(2, originFindingIds.getLength()); + assertEquals("SAST-1", originFindingIds.item(0).getTextContent()); + assertEquals("SAST-2", originFindingIds.item(1).getTextContent()); + } + } + } + + @Test + void doesNotDiscloseExternalEntityInWebInspectXml() throws Exception { + Path secret = tempDir.resolve("secret.txt"); + Files.writeString(secret, "PRIVATE-SENTINEL"); + Path fprPath = createFpr(); + String maliciousXml = """ + ]> + &xxe; + """.formatted(secret.toUri()); + try (FprHandle fprHandle = new FprHandle(fprPath)) { + Files.writeString(fprHandle.getPath("/webinspect.xml"), maliciousXml); + } + + new DastFprCorrelationEnricher().injectAndRepackage(fprPath, + List.of(new CorrelatedPair("SAST-1", "DAST-1", "scan-1", "HIGH", "match"))); + try (FprHandle fprHandle = new FprHandle(fprPath)) { + String output = Files.readString(fprHandle.getPath("/webinspect.xml")); + assertFalse(output.contains("PRIVATE-SENTINEL")); + try (var input = Files.newInputStream(fprHandle.getPath("/webinspect.xml"))) { + var document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(input); + var originFindingIds = document.getElementsByTagName("OriginFindingID"); + assertEquals(2, originFindingIds.getLength()); + assertEquals("", originFindingIds.item(0).getTextContent()); + assertEquals("SAST-1", originFindingIds.item(1).getTextContent()); + assertTrue(output.contains("AI_CORRELATION_METADATA")); + } + } + } + + private Path createFpr() throws Exception { + Path fprPath = tempDir.resolve("merged.fpr"); + try (FileSystem zipFs = FileSystems.newFileSystem(fprPath, Map.of("create", "true"))) { + Files.writeString(zipFs.getPath("/webinspect.xml"), """ + + + + + + + scan-1 + SAST-1 + 2026-01-01T00:00:00Z + + + + + + + """, StandardCharsets.UTF_8); + } + return fprPath; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/SastFprCorrelationRecorderTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/SastFprCorrelationRecorderTest.java index 619ab9d3863..e382c895882 100644 --- a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/SastFprCorrelationRecorderTest.java +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/helper/SastFprCorrelationRecorderTest.java @@ -27,6 +27,7 @@ import org.junit.jupiter.api.io.TempDir; import com.fortify.cli.aviator.grpc.CorrelatedPair; +import com.fortify.cli.aviator.util.FprHandle; class SastFprCorrelationRecorderTest { @@ -181,8 +182,67 @@ void testReadTriedPairKeys_noAuditXml() throws Exception { assertTrue(keys.isEmpty()); } + @Test + void testReadTriedPairKeys_doesNotResolveExternalEntity() throws Exception { + Path secret = tempDir.resolve("secret.txt"); + Files.writeString(secret, "PRIVATE-SENTINEL"); + Path fprPath = createFprWithAuditXml(""" + + ]> + + CORRELATED::&xxe; + + """.formatted(secret.toUri(), SastFprCorrelationRecorder.DAST_CORRELATION_TAG_ID)); + + assertTrue(SastFprCorrelationRecorder.readTriedPairKeys(fprPath).isEmpty()); + SastFprCorrelationRecorder.writeCorrelationTags(fprPath, + List.of(new CorrelatedPair("SAST-1", "DAST-A", SCAN_GUID, "HIGH", "match")), List.of()); + try (FileSystem zipFs = FileSystems.newFileSystem(fprPath)) { + String output = Files.readString(zipFs.getPath("/audit.xml")); + assertTrue(output.contains("CORRELATED::DAST-A")); + assertTrue(!output.contains("PRIVATE-SENTINEL")); + } + } + + @Test + void testReadTriedPairKeys_allowsHarmlessDoctype() throws Exception { + Path fprPath = createFprWithAuditXml(""" + + + + CORRELATED::DAST-A + + """.formatted(SastFprCorrelationRecorder.DAST_CORRELATION_TAG_ID)); + + try (FprHandle handle = new FprHandle(fprPath)) { + Files.createDirectories(handle.getPath("/src-archive")); + Files.writeString(handle.getPath("/src-archive/index.xml"), """ + + + src-archive/Test.java + """); + Files.writeString(handle.getPath("/src-archive/Test.java"), "class Test {}"); + assertEquals("src-archive/Test.java", handle.getSourceFileMap().get("Test.java")); + } + + SastFprCorrelationRecorder.writeCorrelationTags(fprPath, + List.of(new CorrelatedPair("SAST-1", "DAST-B", SCAN_GUID, "HIGH", "match")), List.of()); + assertEquals(Set.of("SAST-1::DAST-A", "SAST-1::DAST-B"), SastFprCorrelationRecorder.readTriedPairKeys(fprPath)); + try (FprHandle handle = new FprHandle(fprPath)) { + assertEquals("src-archive/Test.java", handle.getSourceFileMap().get("Test.java")); + } + } + // ── helpers ────────────────────────────────────────────────────────── + private Path createFprWithAuditXml(String auditXml) throws Exception { + Path fprPath = tempDir.resolve("custom-audit.fpr"); + try (FileSystem zipFs = FileSystems.newFileSystem(fprPath, Map.of("create", "true"))) { + Files.writeString(zipFs.getPath("/audit.xml"), auditXml); + } + return fprPath; + } + private Path createMinimalSastFpr() throws Exception { Path fprPath = tempDir.resolve("test-sast.fpr"); if (Files.exists(fprPath)) { diff --git a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/exception/FcliSimpleException.java b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/exception/FcliSimpleException.java index c8f087d305e..4a6f50280f1 100644 --- a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/exception/FcliSimpleException.java +++ b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/exception/FcliSimpleException.java @@ -56,6 +56,20 @@ public FcliSimpleException(Throwable cause) { public FcliSimpleException(String message, Throwable cause) { super(message, cause); } + + /** + * Throws a new {@link FcliSimpleException} when {@code condition} is {@code true}. + * Useful for compact validation guards. + * + * @param condition when true, an exception is thrown + * @param fmt {@link String#format(String, Object...)} message pattern + * @param args format arguments + */ + public static void throwIf(boolean condition, String fmt, Object... args) { + if (condition) { + throw new FcliSimpleException(fmt, args); + } + } public String getStackTraceString() { return String.format("%s%s", getSummary(this), getCauseAsString()); diff --git a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/http/UrlSchemes.java b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/http/UrlSchemes.java new file mode 100644 index 00000000000..e1db79769fc --- /dev/null +++ b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/http/UrlSchemes.java @@ -0,0 +1,28 @@ +/* + * 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.common.http; + +/** + * Shared URL scheme detection (RFC 3986 scheme production, lenient for CLI inputs). + * Policy after detection (prepend https, reject non-https, …) stays with the caller. + */ +public final class UrlSchemes { + private static final String SCHEME_PREFIX = "^[a-zA-Z][a-zA-Z0-9+\\-.]*://.*$"; + + private UrlSchemes() {} + + /** True when {@code url} starts with a scheme and {@code ://}. */ + public static boolean hasScheme(String url) { + return url != null && url.matches(SCHEME_PREFIX); + } +} diff --git a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/http/proxy/helper/ProxyHelper.java b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/http/proxy/helper/ProxyHelper.java index 79e8515e085..d7bd1a3390e 100644 --- a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/http/proxy/helper/ProxyHelper.java +++ b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/http/proxy/helper/ProxyHelper.java @@ -26,6 +26,7 @@ import org.slf4j.LoggerFactory; import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.http.UrlSchemes; import com.fortify.cli.common.util.FcliDataHelper; import kong.unirest.UnirestInstance; @@ -75,16 +76,12 @@ static URI parseTargetUri(String targetUrlString) { private static String normalizeTargetUrl(String targetUrlString) { var trimmed = StringUtils.trimToEmpty(targetUrlString); - if ( !hasScheme(trimmed) ) { + if ( !UrlSchemes.hasScheme(trimmed) ) { return "https://"+trimmed; } return trimmed; } - private static boolean hasScheme(String url) { - return url.matches("^[a-zA-Z][a-zA-Z0-9+\\-.]*://.*$"); - } - static Optional getProxyEnvVarName(String targetScheme, Map env) { return getProxyEnvVarCandidates(targetScheme).stream() .filter(envVarName->StringUtils.isNotBlank(env.get(envVarName))) @@ -108,7 +105,7 @@ private static ProxyDescriptor getProxyDescriptorFromEnvVar(String envVarName, S private static String normalizeProxyUri(String envVarName, String proxyString) { var trimmed = StringUtils.trimToEmpty(proxyString); - if ( hasScheme(trimmed) ) { + if ( UrlSchemes.hasScheme(trimmed) ) { return trimmed; } if ( envVarName.toLowerCase(Locale.ROOT).startsWith("https_") ) { diff --git a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/util/SecureXmlParserFactory.java b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/util/SecureXmlParserFactory.java new file mode 100644 index 00000000000..436016c17dd --- /dev/null +++ b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/util/SecureXmlParserFactory.java @@ -0,0 +1,56 @@ +/* + * 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.common.util; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.stream.XMLInputFactory; + +public final class SecureXmlParserFactory { + private SecureXmlParserFactory() {} + + /** Creates a fresh DOM factory that permits DOCTYPE declarations without resolving external resources. */ + public static DocumentBuilderFactory newDocumentBuilderFactory(boolean namespaceAware) + throws ParserConfigurationException { + return newDocumentBuilderFactory(namespaceAware, false); + } + + /** Creates a fresh DOM factory that rejects all DOCTYPE declarations. */ + public static DocumentBuilderFactory newDocumentBuilderFactoryWithoutDoctype(boolean namespaceAware) + throws ParserConfigurationException { + return newDocumentBuilderFactory(namespaceAware, true); + } + + private static DocumentBuilderFactory newDocumentBuilderFactory(boolean namespaceAware, boolean disallowDoctype) + throws ParserConfigurationException { + var factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", disallowDoctype); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + factory.setNamespaceAware(namespaceAware); + return factory; + } + + /** Creates a fresh streaming factory with DTD processing and external entity support disabled. */ + public static XMLInputFactory newXmlInputFactory() { + var factory = XMLInputFactory.newInstance(); + factory.setProperty(XMLInputFactory.SUPPORT_DTD, false); + factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); + return factory; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/util/ZipHelper.java b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/util/ZipHelper.java index 5c890c6ee48..74421ccdb8a 100644 --- a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/util/ZipHelper.java +++ b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/util/ZipHelper.java @@ -14,15 +14,59 @@ import java.io.IOException; import java.io.InputStream; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; import java.util.function.Supplier; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.exception.FcliTechnicalException; import lombok.SneakyThrows; public class ZipHelper { + /** + * Opens an existing zip file as a {@link FileSystem}. Callers are responsible for + * closing the returned file system (for example via try-with-resources). + * + * @param zipFile path to an existing zip file + * @return a zip file system for reading (and optionally writing) zip entries + * @throws FcliTechnicalException if the zip file cannot be opened + */ + public static final FileSystem openZipFileSystem(Path zipFile) { + try { + return FileSystems.newFileSystem(zipFile, (ClassLoader)null); + } catch (IOException e) { + throw new FcliTechnicalException("Error opening zip file " + zipFile, e); + } + } + + /** + * Creates a new zip file as a {@link FileSystem}. Parent directories are created + * if needed. If {@code zipFile} already exists, it is deleted first. Callers are + * responsible for closing the returned file system (for example via try-with-resources). + * + * @param zipFile path of the zip file to create + * @return a zip file system opened with {@code create=true} + * @throws FcliTechnicalException if the zip file cannot be created or opened + */ + public static final FileSystem createZipFileSystem(Path zipFile) { + try { + var parent = zipFile.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.deleteIfExists(zipFile); + return FileSystems.newFileSystem(zipFile, Map.of("create", "true")); + } catch (IOException e) { + throw new FcliTechnicalException("Error creating zip file " + zipFile, e); + } + } + /** * Helper method to process individual zip entries from a zip file * loaded from the given zipFileInputStream, calling the diff --git a/fcli-core/fcli-common-core/src/test/java/com/fortify/cli/common/util/SecureXmlParserFactoryTest.java b/fcli-core/fcli-common-core/src/test/java/com/fortify/cli/common/util/SecureXmlParserFactoryTest.java new file mode 100644 index 00000000000..ba1a89c39ed --- /dev/null +++ b/fcli-core/fcli-common-core/src/test/java/com/fortify/cli/common/util/SecureXmlParserFactoryTest.java @@ -0,0 +1,126 @@ +/* + * 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.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.StringReader; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamException; + +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; + +class SecureXmlParserFactoryTest { + @Test + void domUsesSecurePolicyAndFreshFactories() throws Exception { + var factory = SecureXmlParserFactory.newDocumentBuilderFactory(true); + assertNotSame(factory, SecureXmlParserFactory.newDocumentBuilderFactory(true)); + assertFalse(factory.getFeature("http://apache.org/xml/features/disallow-doctype-decl")); + assertFalse(factory.getFeature("http://xml.org/sax/features/external-general-entities")); + assertFalse(factory.getFeature("http://xml.org/sax/features/external-parameter-entities")); + assertFalse(factory.getFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd")); + assertTrue(factory.getFeature(XMLConstants.FEATURE_SECURE_PROCESSING)); + assertFalse(factory.isXIncludeAware()); + assertFalse(factory.isExpandEntityReferences()); + assertTrue(factory.isNamespaceAware()); + } + + @Test + void domAllowsHarmlessDoctypeAndPreservesNamespaceChoice() throws Exception { + var xml = "safe"; + var aware = parse(SecureXmlParserFactory.newDocumentBuilderFactory(true), xml); + assertEquals("urn:test", aware.getDocumentElement().getNamespaceURI()); + assertEquals("safe", aware.getDocumentElement().getTextContent()); + var unaware = parse(SecureXmlParserFactory.newDocumentBuilderFactory(false), xml); + assertNull(unaware.getDocumentElement().getNamespaceURI()); + } + + @Test + void domDoesNotResolveExternalEntitiesOrDtd() throws Exception { + for (var declaration : new String[] { + "", + "]>", + "%external;]>" }) { + var body = declaration.contains("" + body + ""); + assertEquals(body.equals("safe") ? "safe" : "", document.getDocumentElement().getTextContent()); + } + } + + @Test + void strictDomRejectsDoctype() throws Exception { + var factory = SecureXmlParserFactory.newDocumentBuilderFactoryWithoutDoctype(false); + assertThrows(SAXException.class, () -> parse(factory, "")); + } + + @Test + void staxDoesNotResolveExternalDtdAndPreservesNamespaces() throws Exception { + var factory = SecureXmlParserFactory.newXmlInputFactory(); + assertNotSame(factory, SecureXmlParserFactory.newXmlInputFactory()); + assertEquals(false, factory.getProperty(XMLInputFactory.SUPPORT_DTD)); + assertEquals(false, factory.getProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES)); + factory.setXMLResolver((publicId, systemId, baseUri, namespace) -> { + throw new AssertionError("External XML resource requested"); + }); + var reader = factory.createXMLStreamReader(new StringReader( + "safe")); + try { + while (reader.hasNext() && !reader.isStartElement()) { + reader.next(); + } + assertEquals("urn:test", reader.getNamespaceURI()); + assertEquals("safe", reader.getElementText()); + } finally { + reader.close(); + } + } + + @Test + void staxDoesNotResolveExternalEntity() throws Exception { + var factory = SecureXmlParserFactory.newXmlInputFactory(); + factory.setXMLResolver((publicId, systemId, baseUri, namespace) -> { + throw new AssertionError("External XML resource requested"); + }); + var reader = factory.createXMLStreamReader(new StringReader( + "]>&external;")); + try { + assertThrows(XMLStreamException.class, () -> { + while (reader.hasNext()) { + reader.next(); + } + }); + } finally { + reader.close(); + } + } + + private Document parse(DocumentBuilderFactory factory, String xml) throws Exception { + var builder = factory.newDocumentBuilder(); + builder.setEntityResolver((publicId, systemId) -> { + throw new AssertionError("External XML resource requested"); + }); + return builder.parse(new InputSource(new StringReader(xml))); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/oauth/FoDOAuthHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/oauth/FoDOAuthHelper.java index 90ed7e0f0e4..24d98476184 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/oauth/FoDOAuthHelper.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/oauth/FoDOAuthHelper.java @@ -28,7 +28,7 @@ // TODO Consider moving all classes in this package to a more appropriate package, // for example as a sub-package of the 'rest' package. public class FoDOAuthHelper { - + public static final FoDTokenCreateResponse createToken(IUrlConfig urlConfig, IFoDUserCredentials uc, String... scopes) { Map formData = generateTokenRequest(uc, null, scopes); try ( var unirest = UnirestHelper.createUnirestInstance() ) { diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cli/mixin/FoDAviatorApplyRemediationsOptionsMixin.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cli/mixin/FoDAviatorApplyRemediationsOptionsMixin.java new file mode 100644 index 00000000000..73e12408a68 --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cli/mixin/FoDAviatorApplyRemediationsOptionsMixin.java @@ -0,0 +1,80 @@ +/* + * 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.fod.aviator.cli.mixin; + +import java.nio.file.Path; + +import com.fortify.cli.aviator._common.cli.mixin.AbstractApplyRemediationsOptionsMixin; +import com.fortify.cli.fod._common.cli.mixin.FoDDelimiterMixin; +import com.fortify.cli.fod._common.cli.mixin.IFoDDelimiterMixinAware; +import com.fortify.cli.fod.release.cli.mixin.FoDReleaseByQualifiedNameOrIdResolverMixin; +import com.fortify.cli.fod.release.helper.FoDReleaseDescriptor; + +import kong.unirest.UnirestInstance; +import lombok.Getter; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Option; + +/** + * FoD-specific apply-remediations options mixin. Combines source selection (online or cache) + * with shared options and provides FoD-specific validation logic. Implements delimiter injection + * for release selection. + */ +@Getter +public final class FoDAviatorApplyRemediationsOptionsMixin extends AbstractApplyRemediationsOptionsMixin + implements IFoDDelimiterMixinAware { + + @ArgGroup(exclusive = true, multiplicity = "1") + private SourceArgGroup source = new SourceArgGroup(); + + @Override + public void setDelimiterMixin(FoDDelimiterMixin delimiterMixin) { + if (source != null && source.online != null) { + source.online.setDelimiterMixin(delimiterMixin); + } + } + + public boolean isFromCacheSelected() { + return source != null && source.fromCache != null; + } + + public Path getFromCache() { + return isFromCacheSelected() ? source.fromCache : null; + } + + public FoDReleaseDescriptor getReleaseDescriptor(UnirestInstance unirest) { + return source.online.getReleaseDescriptor(unirest); + } + + @Override + protected void validateSourceSelection() { + // FoD ArgGroup has multiplicity="1" validation; no additional checks needed + } + + @Override + protected boolean isCacheMode() { + return isFromCacheSelected(); + } + + @Getter + static class SourceArgGroup { + @ArgGroup(exclusive = false, multiplicity = "1") + private FoDReleaseByQualifiedNameOrIdResolverMixin.RequiredOption online = + new FoDReleaseByQualifiedNameOrIdResolverMixin.RequiredOption(); + + /** Shared description key: command-local option on an ArgGroup (default picocli key would use FQCN). */ + @Option(names = {"--from-cache"}, required = true, paramLabel = "", + descriptionKey = "fcli.fod.aviator.apply-remediations.from-cache") + private Path fromCache; + } +} diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorApplyRemediationsCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorApplyRemediationsCommand.java index c0790420e1f..ea5af3142b0 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorApplyRemediationsCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorApplyRemediationsCommand.java @@ -12,137 +12,69 @@ */ package com.fortify.cli.fod.aviator.cmd; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Duration; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import java.util.Set; import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.aviator._common.cli.mixin.SourceEncodingsMixin; -import com.fortify.cli.aviator.applyRemediation.ApplyAutoRemediationOnSource; +import com.fortify.cli.aviator._common.output.cli.cmd.AbstractAviatorApplyRemediationsCommand; +import com.fortify.cli.aviator._common.remediations_cache.CacheRemediationsFprSource; +import com.fortify.cli.aviator._common.remediations_cache.IRemediationsFprSource; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsApplyHelper.ApplyResult; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheConstants; import com.fortify.cli.aviator.config.AviatorLoggerImpl; -import com.fortify.cli.aviator.util.FprHandle; -import com.fortify.cli.common.exception.FcliSimpleException; -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; -import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; import com.fortify.cli.common.progress.helper.IProgressWriter; -import com.fortify.cli.common.rest.unirest.HttpHeader; -import com.fortify.cli.common.rest.unirest.RestResponseBodyHelper; import com.fortify.cli.fod._common.cli.mixin.FoDDelimiterMixin; -import com.fortify.cli.fod._common.output.cli.cmd.AbstractFoDJsonNodeOutputCommand; -import com.fortify.cli.fod._common.scan.helper.FoDScanDescriptor; -import com.fortify.cli.fod._common.scan.helper.FoDScanHelper; -import com.fortify.cli.fod._common.scan.helper.FoDScanType; +import com.fortify.cli.fod._common.session.cli.mixin.FoDUnirestInstanceSupplierMixin; +import com.fortify.cli.fod.aviator.cli.mixin.FoDAviatorApplyRemediationsOptionsMixin; import com.fortify.cli.fod.aviator.helper.AviatorFoDApplyRemediationsHelper; -import com.fortify.cli.fod.release.cli.mixin.FoDReleaseByQualifiedNameOrIdResolverMixin; +import com.fortify.cli.fod.aviator.helper.FoDOnlineRemediationsFprSource; import com.fortify.cli.fod.release.helper.FoDReleaseDescriptor; -import kong.unirest.GetRequest; import kong.unirest.UnirestInstance; +import lombok.AccessLevel; import lombok.Getter; -import lombok.SneakyThrows; import picocli.CommandLine.Command; import picocli.CommandLine.Mixin; -import picocli.CommandLine.Option; @Command(name = "apply-remediations") -public class FoDAviatorApplyRemediationsCommand extends AbstractFoDJsonNodeOutputCommand - implements IRecordTransformer, IActionCommandResultSupplier { - @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; - @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; - @Mixin private FoDDelimiterMixin delimiterMixin; // Is automatically injected in resolver mixins - @Mixin private FoDReleaseByQualifiedNameOrIdResolverMixin.RequiredOption releaseResolver; - private static final Logger LOG = LoggerFactory.getLogger(FoDAviatorApplyRemediationsCommand.class); - @Option(names = {"--source-dir"}) private String sourceCodeDirectory = System.getProperty("user.dir"); - @Mixin private SourceEncodingsMixin sourceEncodingsMixin; - - @Override @SneakyThrows - public JsonNode getJsonNode(UnirestInstance unirest) { - validateSourceCodeDirectory(); - try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { - AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); - FoDReleaseDescriptor rd = releaseResolver.getReleaseDescriptor(unirest); - return processFprRemediations(unirest, rd, logger); - } - } - - private void validateSourceCodeDirectory() { - if (sourceCodeDirectory == null || sourceCodeDirectory.isBlank()) { - throw new FcliSimpleException("--source-dir must specify a valid directory path"); - } - } - - @SneakyThrows - private JsonNode processFprRemediations(UnirestInstance unirest, FoDReleaseDescriptor rd, AviatorLoggerImpl logger) { - Path downloadedFprPath = null; - try { - logger.progress("Status: Downloading Audited FPR from FOD"); - downloadedFprPath = downloadFprFromFod(unirest, rd); - - logger.progress("Status: Processing FPR with Aviator for Applying Auto Remediations"); - try (FprHandle fprHandle = new FprHandle(downloadedFprPath)) { - var remediationMetric = ApplyAutoRemediationOnSource.applyRemediations(fprHandle, sourceCodeDirectory, - sourceEncodingsMixin.getSourceDecoder()); - LOG.info("Applied remediation {}", remediationMetric.appliedRemediations()); - LOG.info("Total remediation {}", remediationMetric.totalRemediations()); - String status = remediationMetric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; - return AviatorFoDApplyRemediationsHelper.buildResultNode(rd, remediationMetric, status); - } - } finally { - if (downloadedFprPath != null) { - try { - Files.deleteIfExists(downloadedFprPath); - } catch (IndexOutOfBoundsException e) { - LOG.warn("WARN: Failed to delete temporary downloaded FPR file: {}", downloadedFprPath, e); - } - } - } - } +public class FoDAviatorApplyRemediationsCommand extends AbstractAviatorApplyRemediationsCommand { + @Mixin private FoDDelimiterMixin delimiterMixin; // Injected into applyOptions + @Getter(AccessLevel.PROTECTED) @Mixin private FoDAviatorApplyRemediationsOptionsMixin applyOptions; + @Mixin private FoDUnirestInstanceSupplierMixin unirestInstanceSupplier; - @SneakyThrows - private Path downloadFprFromFod(UnirestInstance unirest, FoDReleaseDescriptor releaseDescriptor) { - Path fprPath = Files.createTempFile("aviator_" + releaseDescriptor.getReleaseId() + "_", ".fpr"); - FoDScanDescriptor scanDescriptor = FoDScanHelper.getLatestScanDescriptor(unirest, releaseDescriptor.getReleaseId(), - getScanType(), false); - FoDScanHelper.validateScanDate(scanDescriptor, FoDScanHelper.MAX_RETENTION_PERIOD); - var file = fprPath.toString(); - GetRequest request = getDownloadRequest(unirest, releaseDescriptor, scanDescriptor); - RestResponseBodyHelper.saveToFileWhenReady(request, Path.of(file), null, Duration.ofSeconds(30), Duration.ZERO); - return fprPath; + @Override + protected IRemediationsFprSource openFprSource(AviatorLoggerImpl logger, IProgressWriter progressWriter) { + return applyOptions.isFromCacheSelected() + ? openCacheFprSource() + : openOnlineFprSource(logger); } - - - protected FoDScanType getScanType() { - return FoDScanType.Static; + @Override + protected JsonNode buildResultNode(IRemediationsFprSource fprSource, ApplyResult result, Set issueIdFilter) { + return applyOptions.isFromCacheSelected() + ? buildCacheResultNode(result, issueIdFilter) + : buildOnlineResultNode(fprSource, result); } - protected GetRequest getDownloadRequest(UnirestInstance unirest, FoDReleaseDescriptor releaseDescriptor, - FoDScanDescriptor scanDescriptor) { - return unirest.get("/api/v3/releases/{releaseId}/fpr") - .routeParam("releaseId", releaseDescriptor.getReleaseId()) - // Use headerReplace to replace rather than add the Accept header (avoid duplicates with defaults) - .headerReplace(HttpHeader.ACCEPT, "application/octet-stream") - .queryString("scanType", scanDescriptor.getScanType()); + private IRemediationsFprSource openCacheFprSource() { + return CacheRemediationsFprSource.open( + applyOptions.getFromCache(), + RemediationsCacheConstants.PRODUCT_FOD); } - @Override - public boolean isSingular() { - return true; + private IRemediationsFprSource openOnlineFprSource(AviatorLoggerImpl logger) { + UnirestInstance unirest = unirestInstanceSupplier.getUnirestInstance(); + FoDReleaseDescriptor releaseDescriptor = applyOptions.getReleaseDescriptor(unirest); + return new FoDOnlineRemediationsFprSource(unirest, logger, releaseDescriptor); } - - @Override - public String getActionCommandResult() { - return "Remediation-Applied"; + private JsonNode buildCacheResultNode(ApplyResult result, Set issueIdFilter) { + return AviatorFoDApplyRemediationsHelper.buildCacheResultNode( + applyOptions.getFromCache(), result, issueIdFilter, applyOptions.executionMode()); } - @Override - public JsonNode transformRecord(JsonNode record) { - return record; + private JsonNode buildOnlineResultNode(IRemediationsFprSource fprSource, ApplyResult result) { + FoDReleaseDescriptor releaseDescriptor = ((FoDOnlineRemediationsFprSource) fprSource).getReleaseDescriptor(); + return AviatorFoDApplyRemediationsHelper.buildOnlineResultNode( + releaseDescriptor, result, applyOptions.executionMode()); } } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorCommands.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorCommands.java index 17ace17f0d1..5564d943ed3 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorCommands.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorCommands.java @@ -19,7 +19,8 @@ @CommandLine.Command( name = "aviator", subcommands = { - FoDAviatorApplyRemediationsCommand.class + FoDAviatorApplyRemediationsCommand.class, + FoDAviatorDownloadRemediationsCacheCommand.class } ) diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorDownloadRemediationsCacheCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorDownloadRemediationsCacheCommand.java new file mode 100644 index 00000000000..dc347679e3c --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorDownloadRemediationsCacheCommand.java @@ -0,0 +1,100 @@ +/* + * 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.fod.aviator.cmd; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheConstants; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheManifest; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheWriter; +import com.fortify.cli.aviator.config.AviatorLoggerImpl; +import com.fortify.cli.common.cli.mixin.CommonOptionMixins; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; +import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; +import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; +import com.fortify.cli.common.progress.helper.IProgressWriter; +import com.fortify.cli.fod._common.cli.mixin.FoDDelimiterMixin; +import com.fortify.cli.fod._common.output.cli.cmd.AbstractFoDJsonNodeOutputCommand; +import com.fortify.cli.fod.aviator.helper.FoDRemediationsFprDownloadHelper; +import com.fortify.cli.fod.release.cli.mixin.FoDReleaseByQualifiedNameOrIdResolverMixin; +import com.fortify.cli.fod.release.helper.FoDReleaseDescriptor; + +import kong.unirest.UnirestInstance; +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command(name = "download-remediations-cache", aliases = "drc") +public class FoDAviatorDownloadRemediationsCacheCommand extends AbstractFoDJsonNodeOutputCommand implements IActionCommandResultSupplier { + @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; + @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; + @Mixin private FoDDelimiterMixin delimiterMixin; // Injected into releaseResolver + @Mixin private FoDReleaseByQualifiedNameOrIdResolverMixin.RequiredOption releaseResolver; + @Mixin private CommonOptionMixins.RequireConfirmation requireConfirmation; + + @Option(names = {"-f", "--file"}, required = true, paramLabel = "") + private File outputFile; + + @Override + public JsonNode getJsonNode(UnirestInstance unirest) { + Path destination = outputFile.toPath(); + if (Files.exists(destination)) { + requireConfirmation.checkConfirmed(destination); + } + + FoDReleaseDescriptor releaseDescriptor = releaseResolver.getReleaseDescriptor(unirest); + Map selection = new LinkedHashMap<>(); + selection.put("mode", "release"); + selection.put("releaseId", releaseDescriptor.getReleaseId()); + + try (IProgressWriter progressWriter = progressWriterFactoryMixin.create(); + RemediationsCacheWriter cacheWriter = RemediationsCacheWriter.create( + destination, RemediationsCacheConstants.PRODUCT_FOD, selection)) { + AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); + logger.progress("Status: Downloading Audited FPR from FoD (release id=" + + releaseDescriptor.getReleaseId() + ")"); + cacheWriter.addFodFpr(releaseDescriptor.getReleaseId(), entryPath -> + FoDRemediationsFprDownloadHelper.downloadStaticRemediationsFpr(unirest, releaseDescriptor, entryPath)); + logger.progress("Status: Writing remediations cache to " + destination); + cacheWriter.commit(); + RemediationsCacheManifest manifest = cacheWriter.getManifest(); + return buildResultNode(destination, releaseDescriptor, manifest); + } + } + + private ObjectNode buildResultNode(Path destination, FoDReleaseDescriptor releaseDescriptor, RemediationsCacheManifest manifest) { + ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); + result.put("file", destination.toString()); + result.put("releasesDownloaded", manifest.getEntries().size()); + result.putArray("releaseIds").add(releaseDescriptor.getReleaseId()); + return result; + } + + @Override + public String getActionCommandResult() { + return "REMEDIATIONS_CACHE_DOWNLOADED"; + } + + @Override + public boolean isSingular() { + return true; + } +} diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/AviatorFoDApplyRemediationsHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/AviatorFoDApplyRemediationsHelper.java index 45972568ee3..b5ec857e4fb 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/AviatorFoDApplyRemediationsHelper.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/AviatorFoDApplyRemediationsHelper.java @@ -12,68 +12,59 @@ */ package com.fortify.cli.fod.aviator.helper; -import java.util.ArrayList; +import java.nio.file.Path; import java.util.List; -import java.util.Map; import java.util.Set; -import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsApplyHelper.ApplyResult; +import com.fortify.cli.aviator._common.util.AviatorRemediationMetricsHelper; +import com.fortify.cli.aviator.fpr.remediation.RemediationExecutionMode; import com.fortify.cli.aviator.fpr.remediation.model.RemediationMetric; import com.fortify.cli.common.json.JsonHelper; -import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; import com.fortify.cli.fod.release.helper.FoDReleaseDescriptor; -public class AviatorFoDApplyRemediationsHelper { - public AviatorFoDApplyRemediationsHelper() {} - - /** - * Builds the final JSON result node for the command output. - * @param rd The FoDReleaseDescriptor. - * @param metric The remediation metric for this release. - * @param action Final action. - * @return An ObjectNode representing the result. - */ - public static ObjectNode buildResultNode(FoDReleaseDescriptor rd, RemediationMetric metric, String action) { - ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); - result.put("releaseId", rd.getReleaseId()); - result.put("applicationName", rd.getApplicationName()); - result.put("releaseName", rd.getReleaseName()); - result.put("totalRemediation", metric.totalRemediations()); - result.put("appliedRemediation", metric.appliedRemediations()); - result.put("identicalRemediation", metric.identicalRemediations()); - result.put("supersededRemediation", metric.supersededRemediations()); - result.put("possiblyRemediatedRemediation", metric.possiblyRemediatedRemediations()); - result.put("skippedRemediation", metric.skippedRemediations()); - result.put("skippedReasons", formatSkippedReasons(metric.skippedByReason())); - result.set("skippedByReason", toObjectNode(metric.skippedByReason())); - result.set("modifiedFiles", toArrayNode(metric.modifiedFiles())); - result.put(IActionCommandResultSupplier.actionFieldName, action); - return result; - } +/** + * FoD apply-remediations result JSON: product identity fields only; + * metrics/action/cache extras come from {@link AviatorRemediationMetricsHelper}. + */ +public final class AviatorFoDApplyRemediationsHelper { + private AviatorFoDApplyRemediationsHelper() {} - private static ArrayNode toArrayNode(Set files) { - ArrayNode array = JsonHelper.getObjectMapper().createArrayNode(); - if (files != null) { - files.forEach(array::add); - } - return array; + public static ObjectNode buildOnlineResultNode( + FoDReleaseDescriptor releaseDescriptor, ApplyResult applyResult, RemediationExecutionMode executionMode) { + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + null, applyResult.metrics(), executionMode); + return buildCommonNode( + releaseDescriptor.getReleaseId(), + releaseDescriptor.getApplicationName(), + releaseDescriptor.getReleaseName(), + aggregated); } - private static ObjectNode toObjectNode(Map skippedByReason) { - ObjectNode object = JsonHelper.getObjectMapper().createObjectNode(); - if (skippedByReason != null) { - skippedByReason.forEach(object::put); - } - return object; + public static ObjectNode buildCacheResultNode( + Path cacheZip, ApplyResult applyResult, Set issueIdFilter, RemediationExecutionMode executionMode) { + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + issueIdFilter, applyResult.metrics(), executionMode); + List releaseIds = applyResult.processedIds(); + String releaseId = releaseIds != null && !releaseIds.isEmpty() ? releaseIds.get(0) : null; + ObjectNode result = buildCommonNode(releaseId, null, null, aggregated); + AviatorRemediationMetricsHelper.putCacheExtras( + result, cacheZip, applyResult.processedEntries(), "releaseIds", releaseIds); + return result; } - private static String formatSkippedReasons(Map skippedByReason) { - if (skippedByReason == null || skippedByReason.isEmpty()) { - return ""; - } - List parts = new ArrayList<>(); - skippedByReason.forEach((reason, count) -> parts.add(reason + "=" + count)); - return String.join(", ", parts); + private static ObjectNode buildCommonNode( + String releaseId, + String applicationName, + String releaseName, + RemediationMetric aggregated) { + ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); + result.put("releaseId", AviatorRemediationMetricsHelper.na(releaseId)); + result.put("applicationName", AviatorRemediationMetricsHelper.na(applicationName)); + result.put("releaseName", AviatorRemediationMetricsHelper.na(releaseName)); + result.put("previewMode", aggregated.isPreview()); + AviatorRemediationMetricsHelper.putMetricAndAction(result, aggregated); + return result; } } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/FoDOnlineRemediationsFprSource.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/FoDOnlineRemediationsFprSource.java new file mode 100644 index 00000000000..db39cbfa0ba --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/FoDOnlineRemediationsFprSource.java @@ -0,0 +1,64 @@ +/* + * 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.fod.aviator.helper; + +import com.fortify.cli.aviator._common.remediations_cache.IRemediationsFprSource; +import com.fortify.cli.aviator._common.util.AviatorTempFprFile; +import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.fod.release.helper.FoDReleaseDescriptor; + +import kong.unirest.UnirestInstance; +import lombok.Getter; + +/** + * Online FoD remediations source: downloads the release FPR to a managed temp path + * for the duration of {@link EntryAction#accept}, then deletes it. + * + *

    {@link #close()} is a no-op: temps are owned per entry inside {@link #forEachEntry}. + * Implements {@link AutoCloseable} so callers share one try-with-resources pattern with + * cache sources. + */ +@Getter +public final class FoDOnlineRemediationsFprSource implements IRemediationsFprSource { + private final UnirestInstance unirest; + private final IAviatorLogger logger; + private final FoDReleaseDescriptor releaseDescriptor; + + public FoDOnlineRemediationsFprSource( + UnirestInstance unirest, + IAviatorLogger logger, + FoDReleaseDescriptor releaseDescriptor) { + this.unirest = unirest; + this.logger = logger; + this.releaseDescriptor = releaseDescriptor; + } + + @Override + public void forEachEntry(EntryAction action) { + String id = releaseDescriptor.getReleaseId(); + String label = "release id=" + id; + try (AviatorTempFprFile tempFpr = AviatorTempFprFile.create(id)) { + logger.progress("Status: Downloading Audited FPR from FOD"); + FoDRemediationsFprDownloadHelper.downloadStaticRemediationsFpr( + unirest, releaseDescriptor, tempFpr.path()); + if (!action.accept(tempFpr.path(), label, id, 1, 1)) { + return; + } + } + } + + @Override + public void close() { + // Per-entry temps are closed in forEachEntry; nothing retained on this instance. + } +} diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/FoDRemediationsFprDownloadHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/FoDRemediationsFprDownloadHelper.java new file mode 100644 index 00000000000..f304b4e75e2 --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/FoDRemediationsFprDownloadHelper.java @@ -0,0 +1,135 @@ +/* + * 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.fod.aviator.helper; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.cli.common.exception.AbstractFcliException; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.exception.FcliTechnicalException; +import com.fortify.cli.common.rest.unirest.HttpHeader; +import com.fortify.cli.fod._common.scan.helper.FoDScanDescriptor; +import com.fortify.cli.fod._common.scan.helper.FoDScanHelper; +import com.fortify.cli.fod._common.scan.helper.FoDScanType; +import com.fortify.cli.fod.release.helper.FoDReleaseDescriptor; + +import kong.unirest.GetRequest; +import kong.unirest.HttpResponse; +import kong.unirest.RawResponse; +import kong.unirest.UnirestInstance; + +/** + * Shared FoD remediations FPR download with 202-retry handling. Supports any {@link Path}, + * including zip filesystem entry paths. Response body is written only when the download is + * ready (successful non-202 status). + */ +public final class FoDRemediationsFprDownloadHelper { + private static final Logger logger = LoggerFactory.getLogger(FoDRemediationsFprDownloadHelper.class); + private static final int MAX_RETRIES = 10; + private static final long RETRY_SLEEP_MS = 30_000L; + + private FoDRemediationsFprDownloadHelper() {} + + public static void downloadStaticRemediationsFpr(UnirestInstance unirest, FoDReleaseDescriptor releaseDescriptor, + Path destination) { + boolean completed = false; + try { + FoDScanDescriptor scanDescriptor = FoDScanHelper.getLatestScanDescriptor(unirest, releaseDescriptor.getReleaseId(), + FoDScanType.Static, false); + FoDScanHelper.validateScanDate(scanDescriptor, FoDScanHelper.MAX_RETENTION_PERIOD); + GetRequest request = unirest.get("/api/v3/releases/{releaseId}/fpr") + .routeParam("releaseId", releaseDescriptor.getReleaseId()) + .headerReplace(HttpHeader.ACCEPT, "application/octet-stream") + .queryString("scanType", scanDescriptor.getScanType()); + + int status = 202; + int retries = 0; + while (status == 202 && retries < MAX_RETRIES) { + HttpResponse response = request.asObject(raw -> copyBodyIfReady(raw, destination)); + status = response.getBody() != null ? response.getBody() : response.getStatus(); + if (status == 202) { + retries++; + sleepBeforeRetry(releaseDescriptor.getReleaseId()); + } + } + if (status == 202) { + throw new FcliSimpleException("Timed out waiting for FoD remediations FPR download to complete after " + + MAX_RETRIES + " retries"); + } + if (status < 200 || status >= 300) { + throw new FcliSimpleException("FoD remediations FPR download failed with HTTP status " + status + + " for release " + releaseDescriptor.getReleaseId()); + } + completed = true; + } catch (AbstractFcliException e) { + if (!completed) { + deleteQuietly(destination); + } + throw e; + } catch (RuntimeException e) { + if (!completed) { + deleteQuietly(destination); + } + throw new FcliTechnicalException("Error downloading FoD remediations FPR for release " + + releaseDescriptor.getReleaseId() + " to " + destination, e); + } + } + + /** Status first; write body only for ready 2xx (not 202). Drain otherwise so the connection can close. */ + private static int copyBodyIfReady(RawResponse raw, Path destination) { + int status = raw.getStatus(); + try (InputStream in = raw.getContent()) { + if (status < 200 || status >= 300 || status == 202) { + in.transferTo(OutputStream.nullOutputStream()); + return status; + } + Path parent = destination.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.copy(in, destination, StandardCopyOption.REPLACE_EXISTING); + return status; + } catch (IOException e) { + throw new FcliTechnicalException("Error handling FoD download response for " + destination, e); + } + } + + private static void sleepBeforeRetry(String releaseId) { + try { + Thread.sleep(RETRY_SLEEP_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new FcliTechnicalException( + "Interrupted while waiting for FoD remediations FPR download for release " + releaseId, e); + } + } + + private static void deleteQuietly(Path destination) { + if (destination == null) { + return; + } + try { + Files.deleteIfExists(destination); + } catch (IOException e) { + logger.warn("Failed to delete incomplete FoD FPR download: {}", destination, e); + } + } +} 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 3f840e0369f..41623829197 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 @@ -1050,7 +1050,19 @@ fcli.fod.attribute.update.values = List of picklist values (only for Picklist da # fcli fod aviator fcli.fod.aviator.usage.header = Use Fortify Remediation Aviator with FoD. fcli.fod.aviator.apply-remediations.usage.header = Apply Fortify Remediation Aviator auto-remediations to source code. +fcli.fod.aviator.apply-remediations.usage.description = Downloads the FPR from a FoD release, or reads a local remediations cache zip, and applies Fortify Remediation Aviator remediations to the source directory. \ + Exactly one of --release/--rel or --from-cache must be specified. Online selection requires an FoD session; --from-cache does not. fcli.fod.aviator.apply-remediations.source-dir = Directory containing source code to apply remediations to. Default value: ${DEFAULT-VALUE}. +fcli.fod.aviator.apply-remediations.from-cache = Local remediations cache zip produced by download-remediations-cache. Mutually exclusive with --release/--rel. Does not require an FoD session. +fcli.fod.aviator.apply-remediations.preview = Shows available remediations and their proposed changes without modifying source files. \ + Does not validate whether the proposed changes apply cleanly to the current source. +fcli.fod.aviator.apply-remediations.issue-ids = Comma-separated list of issue IDs to apply. Matches requested values against remediations.xml \ + instanceId entries. Requires --from-cache so integrations can download once via download-remediations-cache and apply selected remediations without repeated FoD downloads. +fcli.fod.aviator.download-remediations-cache.usage.header = Download a remediations cache zip containing Fortify Remediation Aviator remediations from FoD. +fcli.fod.aviator.download-remediations-cache.usage.description = Downloads the latest static audited FPR for a FoD release into a remediations cache zip for use with apply-remediations --from-cache. Requires -f/--file. Overwriting an existing file requires confirmation (-y/--confirm). +fcli.fod.aviator.download-remediations-cache.file = Destination remediations cache zip path. Required. Existing files require confirmation (-y/--confirm) before overwrite. +fcli.fod.aviator.download-remediations-cache.confirm = Confirm overwriting existing remediations cache file. +fcli.fod.aviator.download-remediations-cache.confirmPrompt = Overwrite existing remediations cache file %s? fcli.aviator.source-encodings = Comma-separated source encoding candidates to try in order when decoding source files. Use FPR to try the source encoding recorded in audit.fvdl. When writing remediated files, the accepted encoding is used. Default value: ${DEFAULT-VALUE}. @@ -1104,5 +1116,6 @@ fcli.fod.issue.list.output.table.args = instanceId,visibilityMarker,severityStri fcli.fod.issue.get.output.table.args = instanceId,severityString,category,primaryLocation,releaseName fcli.fod.issue.update.output.table.args = totalCount,updateCount,skippedCount,errorCount fcli.fod.attribute.output.table.args = id,name,attributeType,attributeDataType,isRequired,isRestricted -fcli.fod.aviator.apply-remediations.output.table.args = releaseId,totalRemediation,appliedRemediation,supersededRemediation,possiblyRemediatedRemediation,skippedRemediation,skippedReasons,__action__ +fcli.fod.aviator.apply-remediations.output.table.args = releaseId,totalRemediation,appliedRemediation,identicalRemediation,supersededRemediation,possiblyRemediatedRemediation,skippedRemediation,skippedReasons,__action__ +fcli.fod.aviator.download-remediations-cache.output.table.args = file,releasesDownloaded,releaseIds,__action__ diff --git a/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorApplyRemediationsCommandTest.java b/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorApplyRemediationsCommandTest.java index f05c0100094..d707c35802d 100644 --- a/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorApplyRemediationsCommandTest.java +++ b/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorApplyRemediationsCommandTest.java @@ -12,56 +12,86 @@ */ package com.fortify.cli.fod.aviator; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.lang.reflect.Field; +import java.nio.file.Path; import org.junit.jupiter.api.Test; import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.fod.aviator.cli.mixin.FoDAviatorApplyRemediationsOptionsMixin; import com.fortify.cli.fod.aviator.cmd.FoDAviatorApplyRemediationsCommand; -class FoDAviatorApplyRemediationsCommandTest { - @Test - void testSourceCodeDirectoryHasDefaultValue() throws Exception { - FoDAviatorApplyRemediationsCommand command = new FoDAviatorApplyRemediationsCommand(); +import picocli.CommandLine; - Field field = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("sourceCodeDirectory"); - field.setAccessible(true); - String fieldValue = (String) field.get(command); - - assertNotNull(fieldValue, - "sourceCodeDirectory must have default value to prevent NPE when --source-dir not specified"); +/** + * CLI wiring and product rules for FoD apply-remediations (not default-field or util tests). + */ +class FoDAviatorApplyRemediationsCommandTest { - assertEquals(System.getProperty("user.dir"), fieldValue, - "sourceCodeDirectory default should be current working directory"); + @Test + void fromCacheParsesPath() throws Exception { + FoDAviatorApplyRemediationsCommand command = parse("--from-cache", "remediations.zip"); + FoDAviatorApplyRemediationsOptionsMixin applyOptions = getApplyOptions(command); + assertEquals(Path.of("remediations.zip"), applyOptions.getFromCache()); + assertTrue(applyOptions.isFromCacheSelected()); } @Test - void testSourceCodeDirectoryCanBeOverridden() throws Exception { - FoDAviatorApplyRemediationsCommand command = new FoDAviatorApplyRemediationsCommand(); + void releaseAndFromCacheAreExclusive() { + assertThrows(CommandLine.ParameterException.class, + () -> parse("--release", "1", "--from-cache", "local.zip")); + } - Field field = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("sourceCodeDirectory"); - field.setAccessible(true); + @Test + void issueIdsRequireFromCache() { + FoDAviatorApplyRemediationsCommand command = parse("--release", "1", "--issue-ids", "ISSUE-1"); + assertThrows(FcliSimpleException.class, command::getJsonNode); + } - String customPath = "/custom/source/directory"; - field.set(command, customPath); + @Test + void blankSourceDirIsRejected() throws Exception { + FoDAviatorApplyRemediationsCommand command = parse("--from-cache", "cache.zip"); + FoDAviatorApplyRemediationsOptionsMixin applyOptions = getApplyOptions(command); + Field sourceDirField = FoDAviatorApplyRemediationsOptionsMixin.class + .getSuperclass().getDeclaredField("sourceCodeDirectory"); + sourceDirField.setAccessible(true); + sourceDirField.set(applyOptions, ""); + assertThrows(FcliSimpleException.class, command::getJsonNode); + } - String fieldValue = (String) field.get(command); + @Test + void previewFlagParsedCorrectly() throws Exception { + FoDAviatorApplyRemediationsCommand command = parse("--from-cache", "remediations.zip", "--preview"); + assertTrue(getApplyOptions(command).isPreviewMode()); + } - assertEquals(customPath, fieldValue, - "sourceCodeDirectory should be overridable when --source-dir option is provided"); + @Test + void previewWorksWithIssueIds() throws Exception { + FoDAviatorApplyRemediationsCommand command = parse("--from-cache", "cache.zip", "--preview", "--issue-ids", "ISSUE-1,ISSUE-2"); + assertTrue(getApplyOptions(command).isPreviewMode()); + assertEquals(2, getApplyOptions(command).getIssueIds().size()); } @Test - void testBlankSourceCodeDirectoryThrowsException() throws Exception { + void previewWorksWithOnlineSelection() throws Exception { + FoDAviatorApplyRemediationsCommand command = parse("--release", "123", "--preview"); + assertTrue(getApplyOptions(command).isPreviewMode()); + } + + private static FoDAviatorApplyRemediationsCommand parse(String... args) { FoDAviatorApplyRemediationsCommand command = new FoDAviatorApplyRemediationsCommand(); + new CommandLine(command).parseArgs(args); + return command; + } - Field field = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("sourceCodeDirectory"); + private static FoDAviatorApplyRemediationsOptionsMixin getApplyOptions(FoDAviatorApplyRemediationsCommand command) + throws Exception { + Field field = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("applyOptions"); field.setAccessible(true); - field.set(command, ""); - - assertThrows(FcliSimpleException.class, () -> command.getJsonNode(null), - "Blank sourceCodeDirectory should throw FcliSimpleException"); + return (FoDAviatorApplyRemediationsOptionsMixin) field.get(command); } } diff --git a/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorDownloadRemediationsCacheCommandTest.java b/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorDownloadRemediationsCacheCommandTest.java new file mode 100644 index 00000000000..37b9dde1028 --- /dev/null +++ b/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorDownloadRemediationsCacheCommandTest.java @@ -0,0 +1,39 @@ +/* + * 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.fod.aviator; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.fod.aviator.cmd.FoDAviatorDownloadRemediationsCacheCommand; + +import picocli.CommandLine; + +class FoDAviatorDownloadRemediationsCacheCommandTest { + @Test + void releaseIsRequired() { + assertThrows(CommandLine.ParameterException.class, + () -> parse("-f", "cache.zip")); + } + + @Test + void fileIsRequired() { + assertThrows(CommandLine.ParameterException.class, + () -> parse("--release", "1")); + } + + private static void parse(String... args) { + new CommandLine(new FoDAviatorDownloadRemediationsCacheCommand()).parseArgs(args); + } +} diff --git a/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/_common/rest/ssc/transfer/SSCFileTransferHelper.java b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/_common/rest/ssc/transfer/SSCFileTransferHelper.java index 21d39afc98a..da58fd47401 100644 --- a/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/_common/rest/ssc/transfer/SSCFileTransferHelper.java +++ b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/_common/rest/ssc/transfer/SSCFileTransferHelper.java @@ -13,12 +13,24 @@ package com.fortify.cli.ssc._common.rest.ssc.transfer; import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.function.BiFunction; import java.util.function.Supplier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import com.fortify.cli.common.exception.AbstractFcliException; import com.fortify.cli.common.exception.FcliBugException; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.exception.FcliTechnicalException; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.progress.helper.IProgressWriter; import com.fortify.cli.common.rest.unirest.HttpHeader; @@ -27,13 +39,16 @@ import kong.unirest.GetRequest; import kong.unirest.HttpRequest; import kong.unirest.HttpRequestWithBody; +import kong.unirest.HttpResponse; import kong.unirest.ProgressMonitor; +import kong.unirest.RawResponse; import kong.unirest.UnirestInstance; import kong.unirest.jackson.JacksonObjectMapper; import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; public class SSCFileTransferHelper { + private static final Logger LOG = LoggerFactory.getLogger(SSCFileTransferHelper.class); private static final JacksonObjectMapper XMLMAPPER = new JacksonObjectMapper(new XmlMapper()); @SneakyThrows @@ -52,6 +67,80 @@ public static final File download(UnirestInstance unirest, String endpoint, File return download(unirest, endpoint, downloadPath, SSCFileTransferTokenType.DOWNLOAD, addTokenFunction, progressWriter); } + /** + * Downloads to any {@link Path}, including zip filesystem entry paths used by remediations cache. + * Writes the body only after a successful non-202 status. + */ + public static final void download(UnirestInstance unirest, String endpoint, Path downloadPath, + ISSCAddDownloadTokenFunction addTokenFunction, IProgressWriter progressWriter) { + download(unirest, endpoint, downloadPath, SSCFileTransferTokenType.DOWNLOAD, addTokenFunction, progressWriter); + } + + public static final void download(UnirestInstance unirest, String endpoint, Path downloadPath, + SSCFileTransferTokenType tokenType, ISSCAddDownloadTokenFunction addTokenFunction, IProgressWriter progressWriter) { + boolean completed = false; + try ( SSCFileTransferTokenSupplier tokenSupplier = new SSCFileTransferTokenSupplier(unirest, tokenType); + SSCProgressMonitor downloadMonitor = new SSCProgressMonitor(progressWriter, "Download") ) { + HttpResponse response = addTokenFunction.apply(tokenSupplier.get(), unirest.get(endpoint)) + .downloadMonitor(downloadMonitor) + .asObject(raw -> copyBodyIfReady(raw, downloadPath)); + int status = response.getBody() != null ? response.getBody() : response.getStatus(); + if (status < 200 || status >= 300 || status == 202) { + throw new FcliSimpleException("Download failed with HTTP status " + status + " for " + endpoint); + } + completed = true; + } catch (AbstractFcliException e) { + if (!completed) { + deleteQuietly(downloadPath); + } + throw e; + } catch (RuntimeException e) { + if (!completed) { + deleteQuietly(downloadPath); + } + throw new FcliTechnicalException("Error downloading " + endpoint + " to " + downloadPath, e); + } catch (Exception e) { + // AutoCloseable close can surface checked exceptions; preserve interrupt flag. + if (!completed) { + deleteQuietly(downloadPath); + } + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + throw new FcliTechnicalException("Error downloading " + endpoint + " to " + downloadPath, e); + } + } + + /** Status first; write body only for ready 2xx (not 202). Drain otherwise so the connection can close. */ + private static int copyBodyIfReady(RawResponse raw, Path destination) { + int status = raw.getStatus(); + try (InputStream in = raw.getContent()) { + if (status < 200 || status >= 300 || status == 202) { + in.transferTo(OutputStream.nullOutputStream()); + return status; + } + Path parent = destination.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.copy(in, destination, StandardCopyOption.REPLACE_EXISTING); + return status; + } catch (IOException e) { + throw new FcliTechnicalException("Error handling download response for " + destination, e); + } + } + + private static void deleteQuietly(Path path) { + if (path == null) { + return; + } + try { + Files.deleteIfExists(path); + } catch (IOException e) { + LOG.warn("Failed to delete incomplete download: {}", path, e); + } + } + @SneakyThrows public static final T htmlUpload(UnirestInstance unirest, String endpoint, File filePath, ISSCAddUploadTokenFunction addTokenFunction, Class returnType, IProgressWriter progressWriter) { if ( !isHtmlEndpoint(endpoint) ) { diff --git a/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/artifact/helper/SSCArtifactHelper.java b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/artifact/helper/SSCArtifactHelper.java index ee2719d0030..289806aaa02 100644 --- a/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/artifact/helper/SSCArtifactHelper.java +++ b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/artifact/helper/SSCArtifactHelper.java @@ -156,6 +156,19 @@ private static boolean shouldStopProcessing(JsonNode artifact, OffsetDateTime si /** * Check if artifact is Aviator-processed based on filename prefix. */ + public static boolean isAviatorArtifact(SSCArtifactDescriptor artifact) { + return artifact != null && isAviatorArtifact(artifact.asJsonNode()); + } + + public static SSCArtifactDescriptor requireAviatorArtifact(SSCArtifactDescriptor artifact) { + if (!isAviatorArtifact(artifact)) { + String artifactId = artifact == null ? "" : artifact.getId(); + throw new FcliSimpleException("Artifact " + artifactId + + " is not a Fortify Remediation Aviator-processed artifact; expected originalFileName to start with aviator_"); + } + return artifact; + } + private static boolean isAviatorArtifact(JsonNode artifact) { String originalFileName = artifact.path("originalFileName").asText(""); return originalFileName.startsWith("aviator_"); @@ -243,7 +256,8 @@ private static SSCArtifactDescriptor getLatestArtifactByScanType(UnirestInstance if (data == null || !data.isArray() || data.isEmpty()) { break; } for (JsonNode artifact : data) { - if (hasEmbeddedScanType(artifact, scanType)) { + if ("PROCESS_COMPLETE".equalsIgnoreCase(artifact.path("status").asText()) + && hasEmbeddedScanType(artifact, scanType)) { return getDescriptor(artifact); } } diff --git a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/bulkaudit-dast.yaml b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/bulkaudit-dast.yaml new file mode 100644 index 00000000000..041d8837ed9 --- /dev/null +++ b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/bulkaudit-dast.yaml @@ -0,0 +1,536 @@ +# yaml-language-server: $schema=https://fortify.github.io/fcli/schemas/action/fcli-action-schema-dev-2.x.json + +author: Fortify +usage: + header: (PREVIEW) Perform Fortify DAST Aviator audits of SSC application versions in bulk. + description: | + This action identifies SSC application versions with a processed DAST FPR, + determines whether that scan is newer than the last successful DAST audit, and + runs `fcli aviator ssc audit-dast` for the selected versions. + + Versions without a processed WebInspect result are skipped before any attribute + lookup is performed. For versions that have one, the action compares the most + recent DAST scan date with the SSC `last_dast_audit` attribute written by the + audit command, using the same scan-date rule as bulk SAST-DAST correlation. + + Before each DAST audit, the action runs `fcli aviator ssc prepare` for the selected + application version so the Aviator tags and the `last_dast_audit` + application-version attribute are available in SSC. The action requires an active + SSC session, an Aviator user session for DAST auditing, and an Aviator admin + configuration for listing and creating Aviator applications. Unless nested command + defaults are overridden, these use the `default` SSC session, Aviator user session, + and admin configuration. + + For application versions that don't already exist in Fortify Aviator, the action + automatically creates them before submitting the DAST audit. By default + (--aviator-app-mapping=app), SSC project versions map to one Fortify Aviator + application per SSC project. Set --aviator-app-mapping=version to map each SSC + project version to its own Fortify Aviator application. + + If Aviator entitlement is exhausted while creating applications, further creation + attempts are suppressed and versions with existing applications continue. + +config: + output: immediate + rest.target.default: ssc + run.fcli.status.log.default: true + run.fcli.status.check.default: false + mcp: exclude # Not suitable for MCP tool invocation + +cli.options: + max-audits: + names: --max-audits, -m + description: "Maximum number of application versions to audit. Default: -1 (unlimited)" + required: false + default: -1 + type: int + aviator-app-mapping: + names: --aviator-app-mapping + description: "Controls how SSC project versions map to Fortify Aviator applications. 'app' (default) maps one application per SSC project; 'version' maps one application per project version." + required: false + default: app + filter: + names: --filter, -f + description: "Optional filter to restrict the SSC application versions considered. Example: 'Languages:java'. Default: no filtering" + required: false + default: "" + exclude-filter: + names: --exclude-filter, -e + description: "Optional filter to exclude SSC application versions. Example: 'Languages:c#'. Default: no exclusion" + required: false + default: "" + dry-run: + names: --dry-run, -n + description: "Show application versions and Aviator commands that would be processed without creating applications or running audits. Default: false" + required: false + default: false + type: boolean + tag-mapping: + names: --tag-mapping, -t + description: "Optional path to a custom DAST tag mapping YAML file. If omitted, the default DAST mapping is used" + required: false + refresh: + names: --refresh + description: "Refresh out-of-date application version metrics before downloading the FPR. Note that for large applications this can lead to an error if the timeout expires. Default: true" + required: false + type: boolean + default: true + no-refresh: + names: --no-refresh + description: "Do not refresh out-of-date application version metrics before downloading the FPR. Overrides --refresh." + required: false + type: boolean + default: false + refresh-timeout: + names: --refresh-timeout + description: "Time-out for refreshing application version metrics, for example 30s (30 seconds), 5m (5 minutes), 1h (1 hour). Default value: 60s" + required: false + default: "60s" + +functions: + runDastAudit: + description: Run a DAST audit and return the command result + export: false + args: + appVersion: { required: true } + aviatorAppName: { required: true } + tagMapping: { required: true } + refresh: { required: true, type: boolean } + refreshTimeout: { required: true } + return: ${run_audit} + steps: + - run.fcli: + run_audit: + cmd: >- + aviator ssc audit-dast --av "${args.appVersion}" --app "${args.aviatorAppName}" + --log-level=INFO${#isBlank(args.tagMapping) ? '' : ' --tag-mapping="' + args.tagMapping + '"'} + --refresh=${args.refresh} --refresh-timeout="${args.refreshTimeout}" + status.check: false + records.collect: true + stdout: show + stderr: collect + +steps: + - var.set: + module: ssc + refresh_metrics: ${cli.refresh && !cli['no-refresh']} + stats.missing_dast_skipped: 0 + stats.unchanged_skipped: 0 + stats.create_attempts: 0 + stats.create_successes: 0 + stats.create_failures: 0 + stats.prepare_failures: 0 + stats.audit_attempts: 0 + stats.audit_failures: 0 + stats.failures: 0 + stats.selection_failures: 0 + stats.entitlement_exhausted: false + stats.create_skipped_after_failure: 0 + stats.would_create_count: 0 + + - if: ${cli['max-audits'] < -1} + throw: "Invalid --max-audits value '${cli['max-audits']}'. Use -1 for unlimited or a non-negative number" + + - if: ${!(cli['aviator-app-mapping'] matches 'app|version')} + throw: "Invalid --aviator-app-mapping value '${cli['aviator-app-mapping']}'. Valid values are: app, version" + + - log.progress: "Using Fortify Aviator app mapping: ${cli['aviator-app-mapping']}" + + - log.progress: Retrieving existing Fortify Aviator applications... + - run.fcli: + aviator_apps: + cmd: aviator app ls -o json + status.check: true + records.collect: true + + - var.set: + aviator_app_names: null + - records.for-each: + from: ${aviator_apps.records} + record.var-name: aviator_app + do: + - var.set: + aviator_app_names..: ${aviator_app.name} + + - var.set: + last_dast_audit_guid: null + last_dast_audit_id: null + - rest.call: + dast_audit_attribute_definitions: + uri: /api/v1/attributeDefinitions + type: paged + query: + limit: -1 + records.for-each: + record.var-name: attribute_definition + breakIf: ${last_dast_audit_guid != null || last_dast_audit_id != null} + if: ${attribute_definition.name == 'last_dast_audit' && attribute_definition.category == 'TECHNICAL' && attribute_definition.type == 'TEXT'} + do: + - var.set: + last_dast_audit_guid: ${attribute_definition.guid} + last_dast_audit_id: ${attribute_definition.id} + - if: ${last_dast_audit_guid == null && last_dast_audit_id == null} + log.warn: >- + SSC attribute definition 'last_dast_audit' was not found. The action will attempt to create it + while preparing each selected application version before audit + + - log.progress: Querying SSC application versions... + - var.set: + candidate_versions: null + + - if: ${cli.filter == ''} + rest.call: + app_versions: + uri: /api/v1/projectVersions + type: paged + query: + limit: -1 + records.for-each: + record.var-name: version + do: + - var.set: + current_project_name: ${version.project.name} + current_version_name: ${version.name} + current_aviator_app_name: ${version.project.name.replaceAll('"', '')} + - if: ${'version'.equals(cli['aviator-app-mapping'])} + var.set: + current_aviator_app_name: ${(current_project_name + '__' + current_version_name).replaceAll('"', '')} + - var.set: + project_exists_in_aviator: ${aviator_app_names != null && aviator_app_names.contains(current_aviator_app_name)} + - var.set: + candidate_versions..: {fmt: candidate_version} + + - if: ${cli.filter != ''} + do: + - log.progress: Using issue aging filter scope for candidate discovery... + - rest.call: + app_versions: + uri: /api/v1/issueaging + type: paged + query: + limit: -1 + filterby: ${cli.filter} + records.for-each: + record.var-name: version + embed: + project_details: + uri: /api/v1/projectVersions/${version.id} + do: + - var.set: + current_project_name: ${version.project_details.project.name} + current_version_name: ${version.project_details.name} + current_aviator_app_name: ${version.project_details.project.name.replaceAll('"', '')} + - if: ${'version'.equals(cli['aviator-app-mapping'])} + var.set: + current_aviator_app_name: ${(current_project_name + '__' + current_version_name).replaceAll('"', '')} + - var.set: + project_exists_in_aviator: ${aviator_app_names != null && aviator_app_names.contains(current_aviator_app_name)} + - var.set: + candidate_versions..: {fmt: candidate_version} + + - if: ${candidate_versions != null} + log.progress: Found ${candidate_versions.size()} SSC application versions in initial scope + - if: ${candidate_versions == null} + log.progress: Found 0 SSC application versions in initial scope + + - var.set: + scoped_versions: ${candidate_versions} + + - if: ${candidate_versions != null && candidate_versions.size() > 0 && cli['exclude-filter'] != ''} + do: + - log.progress: Resolving exclusion filter scope... + - var.set: + excluded_version_lookup: null + - rest.call: + excluded_versions: + uri: /api/v1/issueaging + type: paged + query: + limit: -1 + filterby: ${cli['exclude-filter']} + records.for-each: + record.var-name: excluded_version + do: + - var.set: + excluded_version_lookup.${excluded_version.id}: true + + - var.set: + filtered_versions: null + excluded_count: 0 + + - records.for-each: + from: ${candidate_versions} + record.var-name: candidate + do: + - var.set: + should_exclude: false + - if: ${excluded_version_lookup != null && excluded_version_lookup[candidate.id.toString()] != null} + var.set: + should_exclude: true + - if: ${should_exclude} + var.set: + excluded_count: ${excluded_count + 1} + - if: ${!should_exclude} + var.set: + filtered_versions..: ${candidate} + + - var.set: + scoped_versions: ${filtered_versions} + - if: ${scoped_versions != null} + log.progress: Excluded ${excluded_count} application versions, ${scoped_versions.size()} remaining + - if: ${scoped_versions == null} + log.progress: Excluded ${excluded_count} application versions, 0 remaining + + - if: ${candidate_versions == null} + log.progress: 0 application versions remain after filtering + - if: ${candidate_versions != null && cli['exclude-filter'] == ''} + log.progress: ${scoped_versions.size()} application versions remain after filtering + + - if: ${scoped_versions != null && scoped_versions.size() > 0} + do: + - log.progress: Evaluating application versions for bulk DAST audit... + - var.set: + selected_versions: null + - records.for-each: + from: ${scoped_versions} + record.var-name: candidate + do: + - var.set: + latest_dast_scan_date: null + last_dast_audit_value: null + dast_scan_after_last_audit: false + candidate_selection_failed: false + - rest.call: + candidate_artifacts: + uri: /api/v1/projectVersions/${candidate.id}/artifacts + type: paged + query: + limit: -1 + embed: scans + orderby: uploadDate DESC + records.for-each: + record.var-name: artifact + breakIf: ${latest_dast_scan_date != null} + do: + - var.set: + artifact_scan_date: ${#ifBlank(artifact.lastScanDate, artifact.uploadDate)} + - if: ${latest_dast_scan_date == null && artifact.status == 'PROCESS_COMPLETE' && !#isBlank(artifact_scan_date) && artifact._embed.scans?.^[type=='WEBINSPECT'] != null} + var.set: + latest_dast_scan_date: ${artifact_scan_date} + on.fail: + - var.set: + candidate_selection_failed: true + stats.selection_failures: ${stats.selection_failures + 1} + stats.failures: ${stats.failures + 1} + - log.warn: "Unable to inspect DAST artifacts for ${candidate.project_name}:${candidate.version_name}; skipping this version and continuing" + - if: ${!candidate_selection_failed && latest_dast_scan_date == null} + do: + - var.set: + stats.missing_dast_skipped: ${stats.missing_dast_skipped + 1} + - if: ${!candidate_selection_failed && latest_dast_scan_date != null} + do: + - rest.call: + candidate_attributes: + uri: /api/v1/projectVersions/${candidate.id}/attributes + records.for-each: + record.var-name: attribute + breakIf: ${last_dast_audit_value != null} + if: "${last_dast_audit_guid != null && attribute.guid == last_dast_audit_guid || last_dast_audit_id != null && attribute.attributeDefinitionId != null && attribute.attributeDefinitionId.toString() == last_dast_audit_id.toString()}" + do: + - var.set: + last_dast_audit_value: ${attribute.value} + on.fail: + - var.set: + candidate_selection_failed: true + stats.selection_failures: ${stats.selection_failures + 1} + stats.failures: ${stats.failures + 1} + - log.warn: "Unable to inspect DAST audit state for ${candidate.project_name}:${candidate.version_name}; skipping this version and continuing" + - if: ${!candidate_selection_failed && !#isBlank(last_dast_audit_value)} + var.set: + dast_scan_after_last_audit: ${#date(latest_dast_scan_date).isAfter(#date(last_dast_audit_value))} + on.fail: + - var.set: + candidate_selection_failed: true + stats.selection_failures: ${stats.selection_failures + 1} + stats.failures: ${stats.failures + 1} + - log.warn: "Unable to compare DAST scan date '${latest_dast_scan_date}' with last_dast_audit '${last_dast_audit_value}' for ${candidate.project_name}:${candidate.version_name}; skipping this version and continuing" + - if: ${!candidate_selection_failed && !#isBlank(last_dast_audit_value) && !dast_scan_after_last_audit} + var.set: + stats.unchanged_skipped: ${stats.unchanged_skipped + 1} + - if: ${!candidate_selection_failed && (#isBlank(last_dast_audit_value) || dast_scan_after_last_audit)} + var.set: + selected_versions..: {fmt: selected_version} + + - if: ${selected_versions != null} + log.progress: Found ${selected_versions.size()} application versions requiring DAST audit before limit + - if: ${selected_versions == null} + log.progress: Found 0 application versions requiring DAST audit before limit + + - if: ${selected_versions != null && selected_versions.size() > 0} + do: + - var.set: + audit_candidates: ${selected_versions} + - if: ${cli['max-audits'] != -1 && selected_versions.size() > cli['max-audits']} + do: + - log.progress: Limiting bulk DAST audit run to ${cli['max-audits']} application versions + - var.set: + audit_candidates: null + audit_counter: 0 + - records.for-each: + from: ${selected_versions} + record.var-name: selected_version + breakIf: ${audit_counter >= cli['max-audits']} + do: + - var.set: + audit_candidates..: ${selected_version} + audit_counter: ${audit_counter + 1} + + - if: ${audit_candidates != null} + log.progress: Processing ${audit_candidates.size()} application versions for DAST audit + - if: ${audit_candidates == null} + log.progress: Processing 0 application versions for DAST audit + + - var.set: + known_aviator_app_names: ${aviator_app_names} + + - if: ${audit_candidates != null && audit_candidates.size() > 0} + do: + - records.for-each: + from: ${audit_candidates} + record.var-name: project + do: + - var.set: + app_known_in_aviator: ${known_aviator_app_names != null && known_aviator_app_names.contains(project.aviator_app_name)} + - if: ${cli['dry-run']} + do: + - if: ${!app_known_in_aviator} + do: + - log.info: Would create app ${project.aviator_app_name} + - var.set: + stats.would_create_count: ${stats.would_create_count + 1} + known_aviator_app_names..: ${project.aviator_app_name} + - log.info: >- + Would prepare Fortify Aviator tags and attributes for + ${project.project_name}:${project.version_name} + - log.info: Would audit ${project.project_name}:${project.version_name} + - if: ${!cli['dry-run']} + do: + - var.set: + app_ready: ${app_known_in_aviator} + app_needs_creation: ${!app_known_in_aviator} + - if: ${app_needs_creation && stats.entitlement_exhausted} + do: + - var.set: + stats.create_skipped_after_failure: ${stats.create_skipped_after_failure + 1} + - log.warn: Skipping ${project.project_name}:${project.version_name} because Aviator entitlement or quota is exhausted + - if: ${app_needs_creation && !stats.entitlement_exhausted} + do: + - var.set: + stats.create_attempts: ${stats.create_attempts + 1} + create_app.stdout: "" + create_app.stderr: "" + - run.fcli: + create_app: + cmd: aviator app create "${project.aviator_app_name}" + status.check: false + stdout: collect + stderr: collect + - if: ${!#isBlank(create_app.stdout)} + log.info: >- + App creation stdout for ${project.aviator_app_name}: + ${create_app.stdout} + - if: ${!#isBlank(create_app.stderr)} + log.warn: >- + App creation stderr for ${project.aviator_app_name}: + ${create_app.stderr} + - if: ${create_app.exitCode == 0} + var.set: + app_ready: true + stats.create_successes: ${stats.create_successes + 1} + known_aviator_app_names..: ${project.aviator_app_name} + - if: ${create_app.exitCode != 0} + do: + - var.set: + create_app_error_text: "${(create_app.stderr == null ? '' : create_app.stderr) + ' ' + (create_app.stdout == null ? '' : create_app.stdout)}" + create_app_already_exists: ${create_app_error_text.toLowerCase().contains('already exists')} + create_app_entitlement_or_quota_error: ${create_app_error_text.toLowerCase().contains('entitlement') || create_app_error_text.toLowerCase().contains('quota')} + - if: ${create_app_already_exists} + do: + - var.set: + app_ready: true + known_aviator_app_names..: ${project.aviator_app_name} + - log.warn: App ${project.aviator_app_name} already exists; continuing with audit + - if: ${!create_app_already_exists} + do: + - var.set: + stats.create_failures: ${stats.create_failures + 1} + stats.failures: ${stats.failures + 1} + - if: ${create_app_entitlement_or_quota_error && !stats.entitlement_exhausted} + do: + - log.warn: App creation failed due to entitlement or quota; suppressing further create attempts + - var.set: + stats.entitlement_exhausted: true + - if: ${!create_app_entitlement_or_quota_error} + log.warn: App creation failed for ${project.aviator_app_name}; continuing with remaining candidates + - if: ${app_ready} + do: + - log.progress: >- + Preparing Fortify Aviator tags and attributes for + ${project.project_name}:${project.version_name} + - run.fcli: + prepare_version: + cmd: aviator ssc prepare --av "${project.app_version}" + status.check: false + - if: ${prepare_version.exitCode != 0} + do: + - var.set: + stats.prepare_failures: ${stats.prepare_failures + 1} + - log.warn: >- + Fortify Aviator preparation failed for + ${project.project_name}:${project.version_name} + - var.set: + stats.audit_attempts: ${stats.audit_attempts + 1} + - var.set: + run_audit: >- + ${#fn.call('runDastAudit', project.app_version, project.aviator_app_name, + #ifBlank(cli['tag-mapping'], ''), refresh_metrics, cli['refresh-timeout'])} + - if: "${run_audit.exitCode != 0 || (run_audit.records != null && run_audit.records.size() > 0 && (run_audit.records[0].__action__ == 'FAILED' || run_audit.records[0].__action__ == 'PARTIALLY_AUDITED'))}" + do: + - var.set: + stats.audit_failures: ${stats.audit_failures + 1} + stats.failures: ${stats.failures + 1} + - log.warn: DAST audit failed for ${project.project_name}:${project.version_name}; continuing with remaining candidates + + - if: ${cli['dry-run']} + log.info: "Dry-run complete (mapping: ${cli['aviator-app-mapping']}) - would process ${audit_candidates == null ? 0 : audit_candidates.size()} versions, missing DAST artifact skipped ${stats.missing_dast_skipped}, up-to-date last_dast_audit skipped ${stats.unchanged_skipped}, selection failures ${stats.selection_failures}, create ${stats.would_create_count} apps" + + - if: ${!cli['dry-run']} + do: + - log.info: >- + Complete (mapping: ${cli['aviator-app-mapping']}) - Apps created + ${stats.create_successes}/${stats.create_attempts}, Audits attempted ${stats.audit_attempts}, + Failures ${stats.failures} (create ${stats.create_failures}, audit ${stats.audit_failures}, + selection ${stats.selection_failures}), Prepare warnings ${stats.prepare_failures}, app creation + failures ${stats.create_failures}, creations skipped after blocking failure ${stats.create_skipped_after_failure}, missing DAST artifact skipped + ${stats.missing_dast_skipped}, up-to-date last_dast_audit skipped ${stats.unchanged_skipped} + - if: ${stats.entitlement_exhausted} + log.info: Note - Aviator entitlement was exhausted; some application creations and audits were skipped + +formatters: + candidate_version: + id: ${version.id} + project_name: ${current_project_name} + version_name: ${current_version_name} + app_version: ${current_project_name + ':' + current_version_name} + aviator_app_name: ${current_aviator_app_name} + exists_in_aviator: ${project_exists_in_aviator} + + selected_version: + id: ${candidate.id} + project_name: ${candidate.project_name} + version_name: ${candidate.version_name} + app_version: ${candidate.app_version} + aviator_app_name: ${candidate.aviator_app_name} + exists_in_aviator: ${candidate.exists_in_aviator} + latest_dast_scan_date: ${latest_dast_scan_date} + last_dast_audit: ${last_dast_audit_value} \ No newline at end of file diff --git a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/bulkaudit-sast.yaml b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/bulkaudit-sast.yaml new file mode 100644 index 00000000000..d28a21f5fd4 --- /dev/null +++ b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/bulkaudit-sast.yaml @@ -0,0 +1,504 @@ +# yaml-language-server: $schema=https://fortify.github.io/fcli/schemas/action/fcli-action-schema-dev-2.x.json + +author: Fortify +usage: + header: (PREVIEW) Perform Fortify Remediation Aviator SAST audits of SSC application versions in bulk. + description: | + This action identifies SSC application versions with pending SAST audit issues and + automatically submits them for auditing by Fortify Remediation Aviator. The action + intelligently filters to only process versions with actual pending audit issues, + while ignoring versions for which audit information needs to be refreshed. + + For application versions that don't already exist in Fortify Aviator, the action will + automatically create them before submitting for audit. + + By default (--aviator-app-mapping=app), SSC project versions map to a single + Fortify Aviator application per SSC project. Set --aviator-app-mapping=version to map + each SSC project version to its own Fortify Aviator application (project_name__version_name). + + When quota limits are exceeded for any application, issues are prioritized by + folder using either the default order (Critical > High > Medium > Low) or a + custom priority order specified via --folder-priority-order. The same priority + order is applied to all audited versions in the bulk operation. + + Either --tag-mapping or --add-aviator-tags must be specified. The default tag + mapping leaves unsure issues unaudited, causing indefinite reselection. Use + a custom mapping file or 'fcli ssc aviator prepare' to configure Fortify Remediation Aviator-specific + issue templates. + + This action assumes active sessions with SSC, Fortify Remediation Aviator (user role for auditing), + and Fortify Aviator (admin role for application listing and creation). + +config: + output: immediate + rest.target.default: ssc + run.fcli.status.log.default: true + run.fcli.status.check.default: false + mcp: exclude # Not suitable for MCP tool invocation + +cli.options: + max-audits: + names: --max-audits, -m + description: "Maximum number of project versions to audit. Default: -1 (unlimited)" + required: false + default: -1 + type: int + aviator-app-mapping: + names: --aviator-app-mapping + description: "Controls how SSC project versions map to Fortify Aviator applications. 'app' (default) maps one Fortify Aviator app per SSC project; 'version' maps one Fortify Aviator app per SSC project version (using project_name__version_name)." + required: false + default: app + filter: + names: --filter, -f + description: "Optional filter to apply when querying SSC for projects. Example: 'Languages:java'. Default: no filtering" + required: false + default: "" + exclude-filter: + names: --exclude-filter, -e + description: "Optional inverse filter to exclude projects from audit. Projects matching this filter will be skipped. Example: 'Languages:c#' to exclude .NET projects. Can be combined with --filter. Default: no exclusion" + required: false + default: "" + dry-run: + names: --dry-run, -n + description: "Show what aviator commands would be executed without actually running them. Default: false" + required: false + default: false + type: boolean + tag-mapping: + names: --tag-mapping, -t + description: "Path to tag mapping YAML file. This custom mapping ensures that Fortify Remediation Aviator 'Unsure' audit results are properly handled. Required unless --add-aviator-tags is specified." + required: false + add-aviator-tags: + names: --add-aviator-tags + description: "If specified, runs 'fcli aviator ssc prepare' for the application before audit. Required unless --tag-mapping is specified." + required: false + type: boolean + default: false + refresh: + names: --refresh + description: "Refresh application version metrics before audit. For large applications this can lead to timeout errors. Default: true" + required: false + type: boolean + default: true + refresh-timeout: + names: --refresh-timeout + description: "Timeout period for metric refresh, e.g., 30s (30 seconds), 5m (5 minutes), 1h (1 hour). Default: 60s" + required: false + default: "60s" + skip-if-exceeding-quota: + names: --skip-if-exceeding-quota + description: "Skip audit if the number of open issues exceeds the available Fortify Remediation Aviator quota. When skipped, a summary with top unaudited categories is shown. Default: false" + required: false + default: false + type: boolean + test-exceeding-quota: + names: --test-exceeding-quota + description: "Check whether the number of open issues exceeds the available Fortify Remediation Aviator quota and report the result without performing an audit. Default: false" + required: false + default: false + type: boolean + folder-priority-order: + names: --folder-priority-order + description: "Custom priority order for folder-based filtering when quota is exceeded (comma-separated, highest priority first). Example: Critical,High,Medium,Low. Applied to all audited versions. Default: uses server default (Critical > High > Medium > Low)" + required: false + default: "" + +functions: + runSastAudit: + description: Run a SAST audit and return the command result + export: false + args: + projectId: { required: true } + aviatorAppName: { required: true } + tagMapping: { required: true } + refresh: { required: true, type: boolean } + refreshTimeout: { required: true } + quotaFlags: { required: true } + folderPriorityOrder: { required: true } + return: ${run_audit} + steps: + - run.fcli: + run_audit: + cmd: >- + aviator ssc audit-sast --av "${args.projectId}" --app "${args.aviatorAppName}" + --log-level=INFO${#isBlank(args.tagMapping) ? '' : ' --tag-mapping="' + args.tagMapping + '"'} + --refresh=${args.refresh} --refresh-timeout="${args.refreshTimeout}"${args.quotaFlags} + ${#isBlank(args.folderPriorityOrder) ? '' : ' --folder-priority-order="' + args.folderPriorityOrder + '"'} + status.check: false + records.collect: true + stdout: show + +steps: + # Configure module + - var.set: + module: ssc + + # Validate --aviator-app-mapping value + - if: ${!(cli['aviator-app-mapping'] matches 'app|version')} + throw: "Invalid --aviator-app-mapping value '${cli['aviator-app-mapping']}'. Valid values are: app, version" + + # Validate that at least one of --tag-mapping or --add-aviator-tags is specified + - if: ${(cli['tag-mapping'] == null || cli['tag-mapping'] == '') && !cli['add-aviator-tags']} + throw: "Either --tag-mapping or --add-aviator-tags must be specified." + + # Validate that --dry-run and --test-exceeding-quota are not used together + - if: ${cli['dry-run'] && cli['test-exceeding-quota']} + do: + - log.progress: "ERROR: --dry-run and --test-exceeding-quota cannot be used together. Use --test-exceeding-quota alone to check quota without auditing." + - throw: "--dry-run and --test-exceeding-quota cannot be used together. Use --test-exceeding-quota alone to check quota without auditing." + + # Validate that --dry-run and --skip-if-exceeding-quota are not used together + - if: ${cli['dry-run'] && cli['skip-if-exceeding-quota']} + do: + - log.progress: "ERROR: --dry-run and --skip-if-exceeding-quota cannot be used together. --dry-run prevents server interaction, but --skip-if-exceeding-quota requires quota retrieval." + - throw: "--dry-run and --skip-if-exceeding-quota cannot be used together. --dry-run prevents server interaction, but --skip-if-exceeding-quota requires quota retrieval." + + # Validate that --skip-if-exceeding-quota and --folder-priority-order are not used together + - if: ${cli['skip-if-exceeding-quota'] && cli['folder-priority-order'] != null && cli['folder-priority-order'] != ''} + do: + - log.progress: "ERROR: --skip-if-exceeding-quota and --folder-priority-order cannot be used together. --skip-if-exceeding-quota skips the audit when quota is insufficient, while --folder-priority-order only applies when auditing within quota constraints." + - throw: "--skip-if-exceeding-quota and --folder-priority-order cannot be used together. --skip-if-exceeding-quota skips the audit when quota is insufficient, while --folder-priority-order only applies when auditing within quota constraints." + + - log.progress: "Using Fortify Aviator app mapping: ${cli['aviator-app-mapping']}" + + # Get existing Fortify Aviator applications + - log.progress: Retrieving existing Fortify Aviator applications... + - run.fcli: + aviator_apps: + cmd: aviator app ls -o json + records.collect: true + + # Build Aviator app name set for fast membership checks + - var.set: + aviator_app_names: null + - records.for-each: + from: ${aviator_apps.records} + record.var-name: aviator_app + do: + - var.set: + aviator_app_names..: ${aviator_app.name} + + # Query SSC for projects needing audit + - log.progress: Querying SSC for projects with pending audit issues... + + - var.set: + enriched_versions: null + + - rest.call: + projects_needing_audit: + uri: /api/v1/issueaging + type: paged + query: + limit: -1 + filterby: ${cli.filter} + records.for-each: + record.var-name: version + if: ${version.issuesPendingReview > 0 && !version.snapshotOutOfDate} + embed: + project_details: + uri: /api/v1/projectVersions/${version.id} + do: + - var.set: + current_project_name: ${version.project_details.project.name} + current_version_name: ${version.project_details.name} + current_aviator_app_name: ${version.project_details.project.name.replaceAll('"', '')} + - if: ${'version'.equals(cli['aviator-app-mapping'])} + var.set: + current_aviator_app_name: ${(current_project_name + '__' + current_version_name).replaceAll('"', '')} + - var.set: + project_exists_in_aviator: ${aviator_app_names != null && aviator_app_names.contains(current_aviator_app_name)} + - var.set: + enriched_versions..: {fmt: enriched_project} + + - if: ${enriched_versions != null} + log.progress: Found ${enriched_versions.size()} application versions with pending audit issues + - if: ${enriched_versions == null} + log.progress: Found 0 application versions with pending audit issues + + # Apply exclusion filter if specified + - if: ${enriched_versions != null && enriched_versions.size() > 0 && cli['exclude-filter'] != ""} + do: + - log.progress: Applying exclusion filter... + - rest.call: + exclusion_list: + uri: /api/v1/issueaging + type: paged + query: + limit: -1 + filterby: ${cli['exclude-filter']} + records.for-each: + record.var-name: excluded_version + if: ${excluded_version.issuesPendingReview > 0 && !excluded_version.snapshotOutOfDate} + do: + - var.set: + excluded_ids..: ${excluded_version.id} + + - var.set: + filtered_versions: null + excluded_count: 0 + + - records.for-each: + from: ${enriched_versions} + record.var-name: candidate + do: + - var.set: + should_exclude: false + - if: ${excluded_ids != null && excluded_ids.contains(candidate.id)} + var.set: + should_exclude: true + - if: ${should_exclude} + var.set: + excluded_count: ${excluded_count + 1} + - if: ${!should_exclude} + var.set: + filtered_versions..: ${candidate} + + - var.set: + enriched_versions: ${filtered_versions} + - if: ${enriched_versions != null} + log.progress: Excluded ${excluded_count} application versions, ${enriched_versions.size()} remaining + - if: ${enriched_versions == null} + log.progress: Excluded ${excluded_count} application versions, 0 remaining + + # Early exit if no candidates + - if: ${enriched_versions == null || enriched_versions.size() == 0} + do: + - log.info: No application versions found that require audit + - var.set: + audit_candidates: null + + # Apply max-audits limit if we have candidates + - if: ${enriched_versions != null && enriched_versions.size() > 0} + do: + - var.set: + audit_candidates: ${enriched_versions} + - if: ${cli['max-audits'] != -1 && enriched_versions.size() > cli['max-audits']} + do: + - var.set: + audit_counter: 0 + audit_candidates: null + - records.for-each: + from: ${enriched_versions} + record.var-name: candidate + breakIf: ${audit_counter >= cli['max-audits']} + do: + - var.set: + audit_candidates..: ${candidate} + audit_counter: ${audit_counter + 1} + + - if: ${audit_candidates != null} + log.progress: Processing ${audit_candidates.size()} application versions + - if: ${audit_candidates == null} + log.progress: Processing 0 application versions + + # Process audit candidates + - if: ${audit_candidates != null && audit_candidates.size() > 0} + do: + # Initialize execution tracking + - var.set: + stats.create_attempts: 0 + stats.create_successes: 0 + stats.create_failures: 0 + stats.audit_attempts: 0 + stats.audit_failures: 0 + stats.quota_skipped: 0 + stats.entitlement_exhausted: false + stats.create_skipped_due_to_entitlement: 0 + stats.would_create_count: 0 + known_aviator_app_names: ${aviator_app_names} + + # Process each candidate + - records.for-each: + from: ${audit_candidates} + record.var-name: project + do: + - var.set: + app_known_in_aviator: ${known_aviator_app_names != null && known_aviator_app_names.contains(project.aviator_app_name)} + + - if: ${cli['dry-run']} + do: + - if: ${!app_known_in_aviator} + do: + - log.info: Would create app ${project.aviator_app_name} + - var.set: + stats.would_create_count: ${stats.would_create_count + 1} + known_aviator_app_names..: ${project.aviator_app_name} + - if: ${cli['add-aviator-tags']} + do: + - log.info: Would prepare tags for ${project.project_name}:${project.version_name} + + - if: "${cli['folder-priority-order'] != null && cli['folder-priority-order'] != ''}" + do: + - log.info: "Would audit ${project.project_name}:${project.version_name} with custom priority order: ${cli['folder-priority-order']}" + - if: "${cli['folder-priority-order'] == null || cli['folder-priority-order'] == ''}" + do: + - log.info: Would audit ${project.project_name}:${project.version_name} + - if: ${!cli['dry-run']} + do: + - var.set: + app_ready: ${app_known_in_aviator} + skip_this_version: false + + # --- TEST-EXCEEDING-QUOTA MODE --- + # In test mode we only check quota and report; no app creation, no audit. + # For non-existing apps, --default-quota-fallback tells the audit command + # to use the tenant's default quota instead of reporting "app not found". + - if: ${cli['test-exceeding-quota']} + do: + - var.set: + stats.audit_attempts: ${stats.audit_attempts + 1} + quota_flags: " --test-exceeding-quota" + - if: ${!app_known_in_aviator} + var.set: + quota_flags: " --test-exceeding-quota --default-quota-fallback" + + - var.set: + run_audit: >- + ${#fn.call('runSastAudit', project.id, project.aviator_app_name, + #ifBlank(cli['tag-mapping'], ''), cli.refresh, cli['refresh-timeout'], quota_flags, + #ifBlank(cli['folder-priority-order'], ''))} + + - if: ${run_audit.exitCode == 0 && run_audit.records != null && run_audit.records.size() > 0 && run_audit.records[0].__action__ != null && run_audit.records[0].__action__ == 'QUOTA_EXCEEDED'} + var.set: + stats.quota_skipped: ${stats.quota_skipped + 1} + + - if: ${run_audit.exitCode != 0} + do: + - var.set: + stats.audit_failures: ${stats.audit_failures + 1} + - log.warn: Quota test failed for ${project.aviator_app_name}:${project.version_name} + + - var.set: + skip_this_version: true + + # --- SKIP-IF-EXCEEDING-QUOTA + NON-EXISTING APP --- + # Pre-check default quota before creating the app. If the default quota + # would be exceeded, skip the version entirely (don't create, don't audit). + - if: ${!skip_this_version && cli['skip-if-exceeding-quota'] && !app_known_in_aviator} + do: + - log.progress: Pre-checking default quota for new app ${project.aviator_app_name}... + + - var.set: + quota_precheck: >- + ${#fn.call('runSastAudit', project.id, project.aviator_app_name, + #ifBlank(cli['tag-mapping'], ''), cli.refresh, cli['refresh-timeout'], + ' --test-exceeding-quota --default-quota-fallback', + #ifBlank(cli['folder-priority-order'], ''))} + + # If pre-check shows quota exceeded, skip this version entirely + - if: ${quota_precheck.exitCode == 0 && quota_precheck.records != null && quota_precheck.records.size() > 0 && quota_precheck.records[0].__action__ != null && quota_precheck.records[0].__action__ == 'QUOTA_EXCEEDED'} + do: + - log.progress: Default quota exceeded for ${project.aviator_app_name} - skipping app creation and audit + - var.set: + skip_this_version: true + stats.quota_skipped: ${stats.quota_skipped + 1} + + # --- NORMAL FLOW: create app, prepare tags, run audit --- + - if: ${!skip_this_version} + do: + # Create app if needed + - if: ${!app_known_in_aviator && !stats.entitlement_exhausted} + do: + - var.set: + stats.create_attempts: ${stats.create_attempts + 1} + - run.fcli: + create_app: + cmd: aviator app create "${project.aviator_app_name}" + status.check: false + - if: ${create_app.exitCode == 0} + var.set: + app_ready: true + stats.create_successes: ${stats.create_successes + 1} + known_aviator_app_names..: ${project.aviator_app_name} + - if: ${create_app.exitCode != 0} + do: + - var.set: + create_app_error_text: "${(create_app.stderr == null ? '' : create_app.stderr) + ' ' + (create_app.stdout == null ? '' : create_app.stdout)}" + create_app_already_exists: ${create_app_error_text.toLowerCase().contains('already exists')} + create_app_entitlement_or_quota_error: ${create_app_error_text.toLowerCase().contains('entitlement') || create_app_error_text.toLowerCase().contains('quota')} + - if: ${create_app_already_exists} + var.set: + app_ready: true + known_aviator_app_names..: ${project.aviator_app_name} + - if: ${!create_app_already_exists} + do: + - var.set: + stats.create_failures: ${stats.create_failures + 1} + - if: ${create_app_entitlement_or_quota_error && !stats.entitlement_exhausted} + do: + - log.warn: App creation failed due to entitlement/quota - suppressing further create attempts + - var.set: + stats.entitlement_exhausted: true + - if: ${!create_app_entitlement_or_quota_error} + log.warn: App creation failed for ${project.aviator_app_name}; continuing with remaining candidates + + - if: ${!app_known_in_aviator && stats.entitlement_exhausted} + var.set: + stats.create_skipped_due_to_entitlement: ${stats.create_skipped_due_to_entitlement + 1} + + # Prepare Fortify Remediation Aviator tags if requested + - if: ${cli['add-aviator-tags']} + do: + - log.progress: Preparing Fortify Remediation Aviator tags for ${project.project_name}:${project.version_name} + - run.fcli: + prepare_tags: + cmd: aviator ssc prepare --av "${project.id}" + status.check: false + - if: ${prepare_tags.exitCode != 0} + do: + - log.warn: Fortify Remediation Aviator tag preparation failed for ${project.project_name}:${project.version_name} + + # Run audit if app is ready + - if: ${app_ready} + do: + - var.set: + stats.audit_attempts: ${stats.audit_attempts + 1} + + # Build quota flags string (only --skip-if-exceeding-quota here; + # --test-exceeding-quota is handled in its own block above) + - var.set: + quota_flags: "" + - if: ${cli['skip-if-exceeding-quota']} + var.set: + quota_flags: " --skip-if-exceeding-quota" + + - var.set: + run_audit: >- + ${#fn.call('runSastAudit', project.id, project.aviator_app_name, + #ifBlank(cli['tag-mapping'], ''), cli.refresh, cli['refresh-timeout'], quota_flags, + #ifBlank(cli['folder-priority-order'], ''))} + + # Track quota-skipped results + - if: ${run_audit.exitCode == 0 && run_audit.records != null && run_audit.records.size() > 0 && run_audit.records[0].__action__ != null && run_audit.records[0].__action__ == 'QUOTA_EXCEEDED'} + var.set: + stats.quota_skipped: ${stats.quota_skipped + 1} + + - if: ${run_audit.exitCode != 0} + do: + - var.set: + stats.audit_failures: ${stats.audit_failures + 1} + - log.warn: Audit failed for ${project.aviator_app_name}:${project.version_name} + + # Summary + - if: ${cli['dry-run']} + do: + - log.info: "Dry-run complete (mapping: ${cli['aviator-app-mapping']}) - would process ${audit_candidates.size()} versions and create ${stats.would_create_count} apps" + + - if: ${!cli['dry-run']} + do: + - if: ${stats.quota_skipped > 0} + log.info: "Complete (mapping: ${cli['aviator-app-mapping']}) - Apps created ${stats.create_successes}/${stats.create_attempts}, Audits attempted ${stats.audit_attempts}, Quota-skipped ${stats.quota_skipped}" + - if: ${stats.quota_skipped == 0} + log.info: "Complete (mapping: ${cli['aviator-app-mapping']}) - Apps created ${stats.create_successes}/${stats.create_attempts}, Audits attempted ${stats.audit_attempts}" + - if: ${stats.entitlement_exhausted} + log.info: Note - Entitlement exhausted, some app creations were skipped + +formatters: + enriched_project: + id: ${version.id} + name: ${version.name} + issuesPendingReview: ${version.issuesPendingReview} + project_name: ${version.project_details.project.name} + version_name: ${version.project_details.name} + aviator_app_name: ${current_aviator_app_name} + exists_in_aviator: ${project_exists_in_aviator} diff --git a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/bulkaudit.yaml b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/bulkaudit.yaml index 6bdc82860d6..9a02a3e5b4c 100644 --- a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/bulkaudit.yaml +++ b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/bulkaudit.yaml @@ -2,507 +2,120 @@ author: Fortify usage: - header: (PREVIEW) Perform Fortify Remediation Aviator audits of SSC application versions in bulk. + header: (PREVIEW, DEPRECATED) Perform SAST Aviator audits of SSC application versions in bulk. description: | - This action identifies SSC application versions with pending audit issues and - automatically submits them for auditing by Fortify Remediation Aviator. The action - intelligently filters to only process versions with actual pending audit issues, - while ignoring versions for which audit information needs to be refreshed. + This action is deprecated; use the `bulkaudit-sast` action instead. - For application versions that don't already exist in Fortify Aviator, the action will - automatically create them before submitting for audit. - - By default (--aviator-app-mapping=app), SSC project versions map to a single - Fortify Aviator application per SSC project. Set --aviator-app-mapping=version to map - each SSC project version to its own Fortify Aviator application (project_name__version_name). - - When quota limits are exceeded for any application, issues are prioritized by - folder using either the default order (Critical > High > Medium > Low) or a - custom priority order specified via --folder-priority-order. The same priority - order is applied to all audited versions in the bulk operation. - - Either --tag-mapping or --add-aviator-tags must be specified. The default tag - mapping leaves unsure issues unaudited, causing indefinite reselection. Use - a custom mapping file or 'fcli ssc aviator prepare' to configure Fortify Remediation Aviator-specific - issue templates. - - This action assumes active sessions with SSC, Fortify Remediation Aviator (user role for auditing), - and Fortify Aviator (admin role for application listing and creation). + For backward compatibility, this action accepts the original `bulkaudit` options + and delegates execution to `bulkaudit-sast`. config: output: immediate - rest.target.default: ssc run.fcli.status.log.default: true - run.fcli.status.check.default: false mcp: exclude # Not suitable for MCP tool invocation cli.options: max-audits: + group: bulkaudit-sast-options names: --max-audits, -m description: "Maximum number of project versions to audit. Default: -1 (unlimited)" required: false default: -1 type: int aviator-app-mapping: + group: bulkaudit-sast-options names: --aviator-app-mapping - description: "Controls how SSC project versions map to Fortify Aviator applications. 'app' (default) maps one Fortify Aviator app per SSC project; 'version' maps one Fortify Aviator app per SSC project version (using project_name__version_name)." + description: >- + Controls how SSC project versions map to Fortify Aviator applications. 'app' (default) maps one + Fortify Aviator app per SSC project; 'version' maps one Fortify Aviator app per SSC project version + (using project_name__version_name). required: false default: app filter: + group: bulkaudit-sast-options names: --filter, -f - description: "Optional filter to apply when querying SSC for projects. Example: 'Languages:java'. Default: no filtering" + description: >- + Optional filter to apply when querying SSC for projects. Example: 'Languages:java'. Default: no filtering required: false default: "" exclude-filter: + group: bulkaudit-sast-options names: --exclude-filter, -e - description: "Optional inverse filter to exclude projects from audit. Projects matching this filter will be skipped. Example: 'Languages:c#' to exclude .NET projects. Can be combined with --filter. Default: no exclusion" + description: >- + Optional inverse filter to exclude projects from audit. Projects matching this filter will be skipped. + Example: 'Languages:c#' to exclude .NET projects. Can be combined with --filter. Default: no exclusion required: false default: "" dry-run: + group: bulkaudit-sast-options names: --dry-run, -n description: "Show what aviator commands would be executed without actually running them. Default: false" required: false default: false type: boolean tag-mapping: + group: bulkaudit-sast-options names: --tag-mapping, -t - description: "Path to tag mapping YAML file. This custom mapping ensures that Fortify Remediation Aviator 'Unsure' audit results are properly handled. Required unless --add-aviator-tags is specified." + description: >- + Path to tag mapping YAML file. This custom mapping ensures that Fortify Remediation Aviator 'Unsure' + audit results are properly handled. Required unless --add-aviator-tags is specified. required: false add-aviator-tags: + group: bulkaudit-sast-options names: --add-aviator-tags - description: "If specified, runs 'fcli aviator ssc prepare' for the application before audit. Required unless --tag-mapping is specified." + description: >- + If specified, runs 'fcli aviator ssc prepare' for the application before audit. Required unless + --tag-mapping is specified. required: false type: boolean default: false refresh: + group: bulkaudit-sast-options names: --refresh - description: "Refresh application version metrics before audit. For large applications this can lead to timeout errors. Default: true" + description: >- + Refresh application version metrics before audit. For large applications this can lead to timeout errors. + Default: true required: false type: boolean default: true refresh-timeout: + group: bulkaudit-sast-options names: --refresh-timeout - description: "Timeout period for metric refresh, e.g., 30s (30 seconds), 5m (5 minutes), 1h (1 hour). Default: 60s" + description: >- + Timeout period for metric refresh, e.g., 30s (30 seconds), 5m (5 minutes), 1h (1 hour). Default: 60s required: false default: "60s" skip-if-exceeding-quota: + group: bulkaudit-sast-options names: --skip-if-exceeding-quota - description: "Skip audit if the number of open issues exceeds the available Fortify Remediation Aviator quota. When skipped, a summary with top unaudited categories is shown. Default: false" + description: >- + Skip audit if the number of open issues exceeds the available Fortify Remediation Aviator quota. + When skipped, a summary with top unaudited categories is shown. Default: false required: false default: false type: boolean test-exceeding-quota: + group: bulkaudit-sast-options names: --test-exceeding-quota - description: "Check whether the number of open issues exceeds the available Fortify Remediation Aviator quota and report the result without performing an audit. Default: false" + description: >- + Check whether the number of open issues exceeds the available Fortify Remediation Aviator quota and + report the result without performing an audit. Default: false required: false default: false type: boolean folder-priority-order: + group: bulkaudit-sast-options names: --folder-priority-order - description: "Custom priority order for folder-based filtering when quota is exceeded (comma-separated, highest priority first). Example: Critical,High,Medium,Low. Applied to all audited versions. Default: uses server default (Critical > High > Medium > Low)" + description: >- + Custom priority order for folder-based filtering when quota is exceeded (comma-separated, highest priority + first). Example: Critical,High,Medium,Low. Applied to all audited versions. Default: uses server default + (Critical > High > Medium > Low) required: false default: "" steps: - # Configure module - - var.set: - module: ssc - - # Validate --aviator-app-mapping value - - if: ${!(cli['aviator-app-mapping'] matches 'app|version')} - throw: "Invalid --aviator-app-mapping value '${cli['aviator-app-mapping']}'. Valid values are: app, version" - - # Validate that at least one of --tag-mapping or --add-aviator-tags is specified - - if: ${(cli['tag-mapping'] == null || cli['tag-mapping'] == '') && !cli['add-aviator-tags']} - throw: "Either --tag-mapping or --add-aviator-tags must be specified." - - # Validate that --dry-run and --test-exceeding-quota are not used together - - if: ${cli['dry-run'] && cli['test-exceeding-quota']} - do: - - log.progress: "ERROR: --dry-run and --test-exceeding-quota cannot be used together. Use --test-exceeding-quota alone to check quota without auditing." - - throw: "--dry-run and --test-exceeding-quota cannot be used together. Use --test-exceeding-quota alone to check quota without auditing." - - # Validate that --dry-run and --skip-if-exceeding-quota are not used together - - if: ${cli['dry-run'] && cli['skip-if-exceeding-quota']} - do: - - log.progress: "ERROR: --dry-run and --skip-if-exceeding-quota cannot be used together. --dry-run prevents server interaction, but --skip-if-exceeding-quota requires quota retrieval." - - throw: "--dry-run and --skip-if-exceeding-quota cannot be used together. --dry-run prevents server interaction, but --skip-if-exceeding-quota requires quota retrieval." - - # Validate that --skip-if-exceeding-quota and --folder-priority-order are not used together - - if: ${cli['skip-if-exceeding-quota'] && cli['folder-priority-order'] != null && cli['folder-priority-order'] != ''} - do: - - log.progress: "ERROR: --skip-if-exceeding-quota and --folder-priority-order cannot be used together. --skip-if-exceeding-quota skips the audit when quota is insufficient, while --folder-priority-order only applies when auditing within quota constraints." - - throw: "--skip-if-exceeding-quota and --folder-priority-order cannot be used together. --skip-if-exceeding-quota skips the audit when quota is insufficient, while --folder-priority-order only applies when auditing within quota constraints." - - - log.progress: "Using Fortify Aviator app mapping: ${cli['aviator-app-mapping']}" - - # Get existing Fortify Aviator applications - - log.progress: Retrieving existing Fortify Aviator applications... + - log.warn: "The 'bulkaudit' action is deprecated; use 'bulkaudit-sast' instead." - run.fcli: - aviator_apps: - cmd: aviator app ls -o json - records.collect: true - - # Build Aviator app name set for fast membership checks - - var.set: - aviator_app_names: null - - records.for-each: - from: ${aviator_apps.records} - record.var-name: aviator_app - do: - - var.set: - aviator_app_names..: ${aviator_app.name} - - # Query SSC for projects needing audit - - log.progress: Querying SSC for projects with pending audit issues... - - - var.set: - enriched_versions: null - - - rest.call: - projects_needing_audit: - uri: /api/v1/issueaging - type: paged - query: - limit: -1 - filterby: ${cli.filter} - records.for-each: - record.var-name: version - if: ${version.issuesPendingReview > 0 && !version.snapshotOutOfDate} - embed: - project_details: - uri: /api/v1/projectVersions/${version.id} - do: - - var.set: - current_project_name: ${version.project_details.project.name} - current_version_name: ${version.project_details.name} - current_aviator_app_name: ${version.project_details.project.name.replaceAll('"', '')} - - if: ${'version'.equals(cli['aviator-app-mapping'])} - var.set: - current_aviator_app_name: ${(current_project_name + '__' + current_version_name).replaceAll('"', '')} - - var.set: - project_exists_in_aviator: ${aviator_app_names != null && aviator_app_names.contains(current_aviator_app_name)} - - var.set: - enriched_versions..: {fmt: enriched_project} - - - if: ${enriched_versions != null} - log.progress: Found ${enriched_versions.size()} application versions with pending audit issues - - if: ${enriched_versions == null} - log.progress: Found 0 application versions with pending audit issues - - # Apply exclusion filter if specified - - if: ${enriched_versions != null && enriched_versions.size() > 0 && cli['exclude-filter'] != ""} - do: - - log.progress: Applying exclusion filter... - - rest.call: - exclusion_list: - uri: /api/v1/issueaging - type: paged - query: - limit: -1 - filterby: ${cli['exclude-filter']} - records.for-each: - record.var-name: excluded_version - if: ${excluded_version.issuesPendingReview > 0 && !excluded_version.snapshotOutOfDate} - do: - - var.set: - excluded_ids..: ${excluded_version.id} - - - var.set: - filtered_versions: null - excluded_count: 0 - - - records.for-each: - from: ${enriched_versions} - record.var-name: candidate - do: - - var.set: - should_exclude: false - - if: ${excluded_ids != null && excluded_ids.contains(candidate.id)} - var.set: - should_exclude: true - - if: ${should_exclude} - var.set: - excluded_count: ${excluded_count + 1} - - if: ${!should_exclude} - var.set: - filtered_versions..: ${candidate} - - - var.set: - enriched_versions: ${filtered_versions} - - if: ${enriched_versions != null} - log.progress: Excluded ${excluded_count} application versions, ${enriched_versions.size()} remaining - - if: ${enriched_versions == null} - log.progress: Excluded ${excluded_count} application versions, 0 remaining - - # Early exit if no candidates - - if: ${enriched_versions == null || enriched_versions.size() == 0} - do: - - log.info: No application versions found that require audit - - var.set: - audit_candidates: null - - # Apply max-audits limit if we have candidates - - if: ${enriched_versions != null && enriched_versions.size() > 0} - do: - - var.set: - audit_candidates: ${enriched_versions} - - if: ${cli['max-audits'] != -1 && enriched_versions.size() > cli['max-audits']} - do: - - var.set: - audit_counter: 0 - audit_candidates: null - - records.for-each: - from: ${enriched_versions} - record.var-name: candidate - breakIf: ${audit_counter >= cli['max-audits']} - do: - - var.set: - audit_candidates..: ${candidate} - audit_counter: ${audit_counter + 1} - - - if: ${audit_candidates != null} - log.progress: Processing ${audit_candidates.size()} application versions - - if: ${audit_candidates == null} - log.progress: Processing 0 application versions - - # Process audit candidates - - if: ${audit_candidates != null && audit_candidates.size() > 0} - do: - # Initialize execution tracking - - var.set: - stats.create_attempts: 0 - stats.create_successes: 0 - stats.create_failures: 0 - stats.audit_attempts: 0 - stats.audit_failures: 0 - stats.quota_skipped: 0 - stats.entitlement_exhausted: false - stats.create_skipped_due_to_entitlement: 0 - stats.would_create_count: 0 - known_aviator_app_names: ${aviator_app_names} - - # Process each candidate - - records.for-each: - from: ${audit_candidates} - record.var-name: project - do: - - var.set: - app_known_in_aviator: ${known_aviator_app_names != null && known_aviator_app_names.contains(project.aviator_app_name)} - - - if: ${cli['dry-run']} - do: - - if: ${!app_known_in_aviator} - do: - - log.info: Would create app ${project.aviator_app_name} - - var.set: - stats.would_create_count: ${stats.would_create_count + 1} - known_aviator_app_names..: ${project.aviator_app_name} - - if: ${cli['add-aviator-tags']} - do: - - log.info: Would prepare tags for ${project.project_name}:${project.version_name} - - - if: "${cli['folder-priority-order'] != null && cli['folder-priority-order'] != ''}" - do: - - log.info: "Would audit ${project.project_name}:${project.version_name} with custom priority order: ${cli['folder-priority-order']}" - - if: "${cli['folder-priority-order'] == null || cli['folder-priority-order'] == ''}" - do: - - log.info: Would audit ${project.project_name}:${project.version_name} - - if: ${!cli['dry-run']} - do: - - var.set: - app_ready: ${app_known_in_aviator} - skip_this_version: false - - # --- TEST-EXCEEDING-QUOTA MODE --- - # In test mode we only check quota and report; no app creation, no audit. - # For non-existing apps, --default-quota-fallback tells the audit command - # to use the tenant's default quota instead of reporting "app not found". - - if: ${cli['test-exceeding-quota']} - do: - - var.set: - stats.audit_attempts: ${stats.audit_attempts + 1} - quota_flags: " --test-exceeding-quota" - - if: ${!app_known_in_aviator} - var.set: - quota_flags: " --test-exceeding-quota --default-quota-fallback" - - - if: ${cli['tag-mapping'] != null && cli['tag-mapping'] != ''} - run.fcli: - run_audit: - cmd: "aviator ssc audit --av \"${project.id}\" --app \"${project.aviator_app_name}\" --log-level=INFO --tag-mapping=\"${cli['tag-mapping']}\" --refresh=${cli.refresh} --refresh-timeout=\"${cli['refresh-timeout']}\"${quota_flags}${cli['folder-priority-order'] != null && cli['folder-priority-order'] != '' ? ' --folder-priority-order=\"' + cli['folder-priority-order'] + '\"' : ''}" - status.check: false - records.collect: true - stdout: show - - - if: ${cli['tag-mapping'] == null || cli['tag-mapping'] == ''} - run.fcli: - run_audit: - cmd: "aviator ssc audit --av \"${project.id}\" --app \"${project.aviator_app_name}\" --log-level=INFO --refresh=${cli.refresh} --refresh-timeout=\"${cli['refresh-timeout']}\"${quota_flags}${cli['folder-priority-order'] != null && cli['folder-priority-order'] != '' ? ' --folder-priority-order=\"' + cli['folder-priority-order'] + '\"' : ''}" - status.check: false - records.collect: true - stdout: show - - - if: ${run_audit.exitCode == 0 && run_audit.records != null && run_audit.records.size() > 0 && run_audit.records[0].__action__ != null && run_audit.records[0].__action__ == 'QUOTA_EXCEEDED'} - var.set: - stats.quota_skipped: ${stats.quota_skipped + 1} - - - if: ${run_audit.exitCode != 0} - do: - - var.set: - stats.audit_failures: ${stats.audit_failures + 1} - - log.warn: Quota test failed for ${project.aviator_app_name}:${project.version_name} - - - var.set: - skip_this_version: true - - # --- SKIP-IF-EXCEEDING-QUOTA + NON-EXISTING APP --- - # Pre-check default quota before creating the app. If the default quota - # would be exceeded, skip the version entirely (don't create, don't audit). - - if: ${!skip_this_version && cli['skip-if-exceeding-quota'] && !app_known_in_aviator} - do: - - log.progress: Pre-checking default quota for new app ${project.aviator_app_name}... - - - if: ${cli['tag-mapping'] != null && cli['tag-mapping'] != ''} - run.fcli: - quota_precheck: - cmd: "aviator ssc audit --av \"${project.id}\" --app \"${project.aviator_app_name}\" --log-level=INFO --tag-mapping=\"${cli['tag-mapping']}\" --refresh=${cli.refresh} --refresh-timeout=\"${cli['refresh-timeout']}\" --test-exceeding-quota --default-quota-fallback${cli['folder-priority-order'] != null && cli['folder-priority-order'] != '' ? ' --folder-priority-order=\"' + cli['folder-priority-order'] + '\"' : ''}" - status.check: false - records.collect: true - stdout: show - - - if: ${cli['tag-mapping'] == null || cli['tag-mapping'] == ''} - run.fcli: - quota_precheck: - cmd: "aviator ssc audit --av \"${project.id}\" --app \"${project.aviator_app_name}\" --log-level=INFO --refresh=${cli.refresh} --refresh-timeout=\"${cli['refresh-timeout']}\" --test-exceeding-quota --default-quota-fallback${cli['folder-priority-order'] != null && cli['folder-priority-order'] != '' ? ' --folder-priority-order=\"' + cli['folder-priority-order'] + '\"' : ''}" - status.check: false - records.collect: true - stdout: show - - # If pre-check shows quota exceeded, skip this version entirely - - if: ${quota_precheck.exitCode == 0 && quota_precheck.records != null && quota_precheck.records.size() > 0 && quota_precheck.records[0].__action__ != null && quota_precheck.records[0].__action__ == 'QUOTA_EXCEEDED'} - do: - - log.progress: Default quota exceeded for ${project.aviator_app_name} - skipping app creation and audit - - var.set: - skip_this_version: true - stats.quota_skipped: ${stats.quota_skipped + 1} - - # --- NORMAL FLOW: create app, prepare tags, run audit --- - - if: ${!skip_this_version} - do: - # Create app if needed - - if: ${!app_known_in_aviator && !stats.entitlement_exhausted} - do: - - var.set: - stats.create_attempts: ${stats.create_attempts + 1} - - run.fcli: - create_app: - cmd: aviator app create "${project.aviator_app_name}" - status.check: false - - if: ${create_app.exitCode == 0} - var.set: - app_ready: true - stats.create_successes: ${stats.create_successes + 1} - known_aviator_app_names..: ${project.aviator_app_name} - - if: ${create_app.exitCode != 0} - do: - - var.set: - create_app_error_text: "${(create_app.stderr == null ? '' : create_app.stderr) + ' ' + (create_app.stdout == null ? '' : create_app.stdout)}" - create_app_already_exists: ${create_app_error_text.toLowerCase().contains('already exists')} - create_app_entitlement_or_quota_error: ${create_app_error_text.toLowerCase().contains('entitlement') || create_app_error_text.toLowerCase().contains('quota')} - - if: ${create_app_already_exists} - var.set: - app_ready: true - known_aviator_app_names..: ${project.aviator_app_name} - - if: ${!create_app_already_exists} - do: - - var.set: - stats.create_failures: ${stats.create_failures + 1} - - if: ${create_app_entitlement_or_quota_error && !stats.entitlement_exhausted} - do: - - log.warn: App creation failed due to entitlement/quota - suppressing further create attempts - - var.set: - stats.entitlement_exhausted: true - - if: ${!create_app_entitlement_or_quota_error} - log.warn: App creation failed for ${project.aviator_app_name}; continuing with remaining candidates - - - if: ${!app_known_in_aviator && stats.entitlement_exhausted} - var.set: - stats.create_skipped_due_to_entitlement: ${stats.create_skipped_due_to_entitlement + 1} - - # Prepare Fortify Remediation Aviator tags if requested - - if: ${cli['add-aviator-tags']} - do: - - log.progress: Preparing Fortify Remediation Aviator tags for ${project.project_name}:${project.version_name} - - run.fcli: - prepare_tags: - cmd: aviator ssc prepare --av "${project.id}" - status.check: false - - if: ${prepare_tags.exitCode != 0} - do: - - log.warn: Fortify Remediation Aviator tag preparation failed for ${project.project_name}:${project.version_name} - - # Run audit if app is ready - - if: ${app_ready} - do: - - var.set: - stats.audit_attempts: ${stats.audit_attempts + 1} - - # Build quota flags string (only --skip-if-exceeding-quota here; - # --test-exceeding-quota is handled in its own block above) - - var.set: - quota_flags: "" - - if: ${cli['skip-if-exceeding-quota']} - var.set: - quota_flags: " --skip-if-exceeding-quota" - - - if: ${cli['tag-mapping'] != null && cli['tag-mapping'] != ''} - run.fcli: - run_audit: - cmd: "aviator ssc audit --av \"${project.id}\" --app \"${project.aviator_app_name}\" --log-level=INFO --tag-mapping=\"${cli['tag-mapping']}\" --refresh=${cli.refresh} --refresh-timeout=\"${cli['refresh-timeout']}\"${quota_flags}${cli['folder-priority-order'] != null && cli['folder-priority-order'] != '' ? ' --folder-priority-order=\"' + cli['folder-priority-order'] + '\"' : ''}" - status.check: false - records.collect: true - stdout: show - - - if: ${cli['tag-mapping'] == null || cli['tag-mapping'] == ''} - run.fcli: - run_audit: - cmd: "aviator ssc audit --av \"${project.id}\" --app \"${project.aviator_app_name}\" --log-level=INFO --refresh=${cli.refresh} --refresh-timeout=\"${cli['refresh-timeout']}\"${quota_flags}${cli['folder-priority-order'] != null && cli['folder-priority-order'] != '' ? ' --folder-priority-order=\"' + cli['folder-priority-order'] + '\"' : ''}" - status.check: false - records.collect: true - stdout: show - - # Track quota-skipped results - - if: ${run_audit.exitCode == 0 && run_audit.records != null && run_audit.records.size() > 0 && run_audit.records[0].__action__ != null && run_audit.records[0].__action__ == 'QUOTA_EXCEEDED'} - var.set: - stats.quota_skipped: ${stats.quota_skipped + 1} - - - if: ${run_audit.exitCode != 0} - do: - - var.set: - stats.audit_failures: ${stats.audit_failures + 1} - - log.warn: Audit failed for ${project.aviator_app_name}:${project.version_name} - - # Summary - - if: ${cli['dry-run']} - do: - - log.info: "Dry-run complete (mapping: ${cli['aviator-app-mapping']}) - would process ${audit_candidates.size()} versions and create ${stats.would_create_count} apps" - - - if: ${!cli['dry-run']} - do: - - if: ${stats.quota_skipped > 0} - log.info: "Complete (mapping: ${cli['aviator-app-mapping']}) - Apps created ${stats.create_successes}/${stats.create_attempts}, Audits attempted ${stats.audit_attempts}, Quota-skipped ${stats.quota_skipped}" - - if: ${stats.quota_skipped == 0} - log.info: "Complete (mapping: ${cli['aviator-app-mapping']}) - Apps created ${stats.create_successes}/${stats.create_attempts}, Audits attempted ${stats.audit_attempts}" - - if: ${stats.entitlement_exhausted} - log.info: Note - Entitlement exhausted, some app creations were skipped - -formatters: - enriched_project: - id: ${version.id} - name: ${version.name} - issuesPendingReview: ${version.issuesPendingReview} - project_name: ${version.project_details.project.name} - version_name: ${version.project_details.name} - aviator_app_name: ${current_aviator_app_name} - exists_in_aviator: ${project_exists_in_aviator} + bulkaudit_sast: + cmd: ssc action run bulkaudit-sast ${#action.copyParametersFromGroup('bulkaudit-sast-options')} + status.check: true diff --git a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/bulkcorrelate.yaml b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/bulkcorrelate.yaml index 0d09d2185df..5be480a7127 100644 --- a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/bulkcorrelate.yaml +++ b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/bulkcorrelate.yaml @@ -107,6 +107,26 @@ steps: - var.set: aviator_app_names..: ${aviator_app.name} + - var.set: + last_correlation_guid: null + last_correlation_id: null + - rest.call: + correlation_attribute_definitions: + uri: /api/v1/attributeDefinitions + type: paged + query: + limit: -1 + records.for-each: + record.var-name: attribute_definition + breakIf: ${last_correlation_guid != null || last_correlation_id != null} + if: ${attribute_definition.name == 'last_correlation' && attribute_definition.category == 'TECHNICAL' && attribute_definition.type == 'TEXT'} + do: + - var.set: + last_correlation_guid: ${attribute_definition.guid} + last_correlation_id: ${attribute_definition.id} + - if: ${last_correlation_guid == null && last_correlation_id == null} + log.warn: "SSC attribute definition 'last_correlation' was not found. Run 'fcli aviator ssc prepare' before using bulk correlation" + - log.progress: Querying SSC application versions... - var.set: candidate_versions: null @@ -267,7 +287,7 @@ steps: records.for-each: record.var-name: attribute breakIf: ${last_correlation_value != null} - if: ${attribute.guid == 'B2C3D4E5-F6A7-8901-BCDE-F12345678901'} + if: "${last_correlation_guid != null && attribute.guid == last_correlation_guid || last_correlation_id != null && attribute.attributeDefinitionId != null && attribute.attributeDefinitionId.toString() == last_correlation_id.toString()}" do: - var.set: last_correlation_value: ${attribute.value} diff --git a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/ci.yaml b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/ci.yaml index 248e71804c6..972be879137 100644 --- a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/ci.yaml +++ b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/ci.yaml @@ -148,7 +148,7 @@ steps: cmd: ${#fcliCmd('DAST_SCAN', 'sc-dast scan start')} --settings "${#env('DAST_SETTINGS')}" --name "${dast.scanName}" --store sc_dast_scan skip.if-reason: - ${dast.skipReason} # Skip if DAST scan is skipped - + DEBRICKED_SCAN_ACTION: # --extra-scan-opts is passed through global.debrickedScan.extraOpts variable defined above cmd: > diff --git a/fcli-core/fcli-ssc/src/test/java/com/fortify/cli/ssc/artifact/helper/SSCArtifactHelperTest.java b/fcli-core/fcli-ssc/src/test/java/com/fortify/cli/ssc/artifact/helper/SSCArtifactHelperTest.java new file mode 100644 index 00000000000..cd4bd7857ba --- /dev/null +++ b/fcli-core/fcli-ssc/src/test/java/com/fortify/cli/ssc/artifact/helper/SSCArtifactHelperTest.java @@ -0,0 +1,53 @@ +/* + * 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.ssc.artifact.helper; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.json.JsonHelper; + +class SSCArtifactHelperTest { + @Test + void testIsAviatorArtifactRequiresAviatorPrefix() { + assertTrue(SSCArtifactHelper.isAviatorArtifact(artifact("1", "aviator_app_version.fpr"))); + assertFalse(SSCArtifactHelper.isAviatorArtifact(artifact("2", "regular.fpr"))); + } + + @Test + void testRequireAviatorArtifactReturnsAviatorArtifact() { + SSCArtifactDescriptor artifact = artifact("1", "aviator_app_version.fpr"); + + assertSame(artifact, SSCArtifactHelper.requireAviatorArtifact(artifact)); + } + + @Test + void testRequireAviatorArtifactRejectsNonAviatorArtifact() { + assertThrows(FcliSimpleException.class, + () -> SSCArtifactHelper.requireAviatorArtifact(artifact("2", "regular.fpr"))); + } + + private static SSCArtifactDescriptor artifact(String id, String originalFileName) { + SSCArtifactDescriptor descriptor = new SSCArtifactDescriptor(); + descriptor.setId(id); + descriptor.setJsonNode(JsonHelper.getObjectMapper().createObjectNode() + .put("id", id) + .put("originalFileName", originalFileName)); + return descriptor; + } +} \ No newline at end of file diff --git a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/ssc/SSCAviatorAuditValidationSpec.groovy b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/ssc/SSCAviatorAuditValidationSpec.groovy index cb4e55bc018..779c7df93c3 100644 --- a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/ssc/SSCAviatorAuditValidationSpec.groovy +++ b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/ssc/SSCAviatorAuditValidationSpec.groovy @@ -32,10 +32,11 @@ class SSCAviatorAuditValidationSpec extends FcliBaseSpec { } } - def "ssc bulkaudit action rejects skip-if-exceeding-quota with folder-priority-order"() { + def "ssc #action action rejects skip-if-exceeding-quota with folder-priority-order"() { when: def result = Fcli.run( - "ssc action run bulkaudit --progress=none --on-unsigned=ignore --on-invalid-version=ignore --add-aviator-tags --skip-if-exceeding-quota --folder-priority-order High", + "ssc action run ${action} --progress=none --on-unsigned=ignore --on-invalid-version=ignore " + + "--add-aviator-tags --skip-if-exceeding-quota --folder-priority-order High", { it.expectSuccess(false) }) then: verifyAll(result) { @@ -44,5 +45,35 @@ class SSCAviatorAuditValidationSpec extends FcliBaseSpec { line.contains("--skip-if-exceeding-quota and --folder-priority-order cannot be used together") } } + where: + action << ["bulkaudit-sast", "bulkaudit"] + } + + def "ssc bulkaudit-dast action rejects invalid app mapping"() { + when: + def result = Fcli.run( + "ssc action run bulkaudit-dast --progress=none --aviator-app-mapping invalid", + { it.expectSuccess(false) }) + then: + verifyAll(result) { + nonZeroExitCode + stderr.any { line -> + line.contains("Invalid --aviator-app-mapping value 'invalid'") + } + } + } + + def "ssc bulkaudit-dast action rejects invalid max audits"() { + when: + def result = Fcli.run( + "ssc action run bulkaudit-dast --progress=none --max-audits=-2", + { it.expectSuccess(false) }) + then: + verifyAll(result) { + nonZeroExitCode + stderr.any { line -> + line.contains("Invalid --max-audits value '-2'") + } + } } } \ No newline at end of file