From 14e178e6803bdc3f03da668cfe602f26067b974b Mon Sep 17 00:00:00 2001 From: Neeta Meshram Date: Tue, 17 Mar 2026 15:21:13 +0530 Subject: [PATCH] feat(aviator): Improve apply-remediations UX with --latest, --all-open-issues, and --since options This enhancement provides a more flexible and user-friendly experience for applying Aviator auto-remediations by introducing multiple artifact selection modes: - --latest: Automatically select the most recent Aviator-processed artifact - --all-open-issues: Process all artifacts with open issues in bulk - --since: Filter artifacts by upload date (relative: 7d, 2w, 1M; absolute: 2025-01-01) Key Changes: - Replaced required --artifact-id with flexible selection modes - Added SinceOptionHelper for robust date/period parsing - Enhanced SSCArtifactHelper with getLatestAviatorArtifact() and getAllAviatorArtifacts() - Improved command validation with mutual exclusivity checks - Added comprehensive unit tests for all new options - Updated i18n messages with detailed usage descriptions Technical Details: - SinceOptionHelper supports relative periods (d, w, M, y) and absolute ISO-8601 dates - DateTimePeriodHelper integration for consistent period parsing across fcli - Proper UTC timezone handling for date comparisons - Backward compatible - existing --artifact-id usage unchanged Closes: #XXX --- CHANGELOG.md | 5 + .../ApplyAutoRemediationOnSource.java | 2 +- .../_main/cli/cmd/AviatorCommands.java | 9 +- .../AviatorFoDApplyRemediationsCommand.java | 14 +- .../fod/cli/cmd/AviatorFoDCommands.java | 1 - .../AviatorSSCApplyRemediationsCommand.java | 129 +++++++++-- .../AviatorSSCApplyRemediationsHelper.java | 46 +++- .../aviator/ssc/helper/SinceOptionHelper.java | 90 ++++++++ .../aviator/i18n/AviatorMessages.properties | 22 +- ...viatorSSCApplyRemediationsCommandTest.java | 218 ++++++++++++++++++ .../_common/scan/helper/FoDScanHelper.java | 2 +- .../helper/SSCArtifactDescriptor.java | 1 + .../artifact/helper/SSCArtifactHelper.java | 131 +++++++++-- 13 files changed, 615 insertions(+), 55 deletions(-) create mode 100644 fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SinceOptionHelper.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 40f7ace4c4..a7fe12b6cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -990,3 +990,8 @@ ### Miscellaneous Chores * release 1.0.0 ([d983f62](https://www.github.com/fortify-ps/fcli/commit/d983f62c01d38ca5cef8963f9ce98c7a2d19c0ab)) + + + + + 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 089132a0d8..e2db9d777c 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 @@ -32,7 +32,7 @@ public static RemediationMetric applyRemediations(FprHandle fprHandle, String so LOG.info("Starting apply auto-remediation process for file: {}", fprHandle.getFprPath()); if (!fprHandle.hasRemediations()) { - LOG.error("FPR file does not contain remediations.xml file: {}", fprHandle.getFprPath()); + //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"); 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 89b2fac434..aa16ece00e 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 @@ -16,6 +16,7 @@ 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.entitlement.cli.cmd.AviatorEntitlementCommands; +import com.fortify.cli.aviator.fod.cli.cmd.AviatorFoDCommands; import com.fortify.cli.aviator.ssc.cli.cmd.AviatorSSCCommands; import com.fortify.cli.aviator.token.cli.cmd.AviatorTokenCommands; import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; @@ -28,18 +29,18 @@ subcommands = { // This list of product subcommands should be in alphabetical // order, except for: - // - session command (should be the first command, as it is a + // - session command (should be the first command, as it is a // prerequisite for all other commands) // - rest command (should be the last command, as it's a low-level - // command and looks better in the usage command list, as usually - // 'rest' has a different header ('Interact with' compared to most + // command and looks better in the usage command list, as usually + // 'rest' has a different header ('Interact with' compared to most // other commands ('Manage'). AviatorAdminConfigCommands.class, AviatorUserSessionCommands.class, AviatorAppCommands.class, AviatorEntitlementCommands.class, AviatorSSCCommands.class, -// AviatorFoDCommands.class, + AviatorFoDCommands.class, AviatorTokenCommands.class, } ) diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/fod/cli/cmd/AviatorFoDApplyRemediationsCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/fod/cli/cmd/AviatorFoDApplyRemediationsCommand.java index 301a840326..78abd4f2e5 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/fod/cli/cmd/AviatorFoDApplyRemediationsCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/fod/cli/cmd/AviatorFoDApplyRemediationsCommand.java @@ -25,7 +25,7 @@ import com.fortify.cli.aviator.fod.helper.AviatorFoDApplyRemediationsHelper; import com.fortify.cli.aviator.util.FprHandle; import com.fortify.cli.common.exception.FcliSimpleException; -import com.fortify.cli.common.output.cli.mixin.IOutputHelper; +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; @@ -40,6 +40,7 @@ import kong.unirest.GetRequest; import kong.unirest.UnirestInstance; +import lombok.Getter; import lombok.SneakyThrows; import picocli.CommandLine.Command; import picocli.CommandLine.Mixin; @@ -47,6 +48,7 @@ @Command(name = "apply-remediations") public class AviatorFoDApplyRemediationsCommand extends AbstractFoDJsonNodeOutputCommand implements IRecordTransformer, IActionCommandResultSupplier { + @Getter @Mixin private OutputHelperMixins.TableNoQuery outputHelper; @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; @Mixin private FoDDelimiterMixin delimiterMixin; // Is automatically injected in resolver mixins @Mixin private FoDReleaseByQualifiedNameOrIdResolverMixin.RequiredOption releaseResolver; @@ -62,7 +64,7 @@ public JsonNode getJsonNode(UnirestInstance 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"); @@ -79,6 +81,8 @@ private JsonNode processFprRemediations(UnirestInstance unirest, FoDReleaseDescr logger.progress("Status: Processing FPR with Aviator for Applying Auto Remediations"); try (FprHandle fprHandle = new FprHandle(downloadedFprPath)) { var remediationMetric = ApplyAutoRemediationOnSource.applyRemediations(fprHandle, sourceCodeDirectory, logger); + 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.totalRemediations(), remediationMetric.appliedRemediations(), remediationMetric.skippedRemediations(), status); } @@ -129,10 +133,6 @@ public boolean isSingular() { return false; } - @Override - public IOutputHelper getOutputHelper() { - return null; - } @Override public String getActionCommandResult() { @@ -141,6 +141,6 @@ public String getActionCommandResult() { @Override public JsonNode transformRecord(JsonNode record) { - return null; + return record; } } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/fod/cli/cmd/AviatorFoDCommands.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/fod/cli/cmd/AviatorFoDCommands.java index 1422094ca1..a734205b0a 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/fod/cli/cmd/AviatorFoDCommands.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/fod/cli/cmd/AviatorFoDCommands.java @@ -18,7 +18,6 @@ @CommandLine.Command( name = "fod", - hidden = true, subcommands = { AviatorFoDApplyRemediationsCommand.class } 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 d81928afe8..331ac7b7e8 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 @@ -15,14 +15,18 @@ 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 com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; import com.fortify.cli.aviator.applyRemediation.ApplyAutoRemediationOnSource; import com.fortify.cli.aviator.config.AviatorLoggerImpl; import com.fortify.cli.aviator.ssc.helper.AviatorSSCApplyRemediationsHelper; +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; @@ -33,8 +37,9 @@ 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.cli.mixin.SSCArtifactResolverMixin; +import com.fortify.cli.ssc.appversion.cli.mixin.SSCAppVersionResolverMixin; import com.fortify.cli.ssc.artifact.helper.SSCArtifactDescriptor; +import com.fortify.cli.ssc.artifact.helper.SSCArtifactHelper; import kong.unirest.UnirestInstance; import lombok.Getter; @@ -47,22 +52,75 @@ public class AviatorSSCApplyRemediationsCommand extends AbstractSSCJsonNodeOutputCommand implements IRecordTransformer, IActionCommandResultSupplier { @Getter @Mixin private OutputHelperMixins.TableNoQuery outputHelper; @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; - //Downloading of the FPR will be based on artifact and not app version - @Mixin private SSCArtifactResolverMixin.RequiredOption artifactResolver; + @Mixin private SSCAppVersionResolverMixin.OptionalOption appVersionResolver; + + @Option(names = {"--artifact-id"}, descriptionKey = "fcli.aviator.ssc.apply-remediations.artifact-id") + private String artifactId; + + @Option(names = {"--latest"}, descriptionKey = "fcli.aviator.ssc.apply-remediations.latest") + private boolean latest; + + @Option(names = {"--all-open-issues"}, descriptionKey = "fcli.aviator.ssc.apply-remediations.all-open-issues") + private boolean allOpenIssues; + + @Option(names = {"--since"}, descriptionKey = "fcli.aviator.ssc.apply-remediations.since") + private String since; + private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCApplyRemediationsCommand.class); - @Option(names = {"--source-dir"}) private String sourceCodeDirectory = System.getProperty("user.dir"); + @Option(names = {"--source-dir"}, descriptionKey = "fcli.aviator.ssc.apply-remediations.source-dir") + private String sourceCodeDirectory = System.getProperty("user.dir"); @Override @SneakyThrows public JsonNode getJsonNode(UnirestInstance unirest) { + validateOptions(); validateSourceCodeDirectory(); + OffsetDateTime sinceDate = SinceOptionHelper.parse(since); try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); - SSCArtifactDescriptor ad = artifactResolver.getArtifactDescriptor(unirest); + if (allOpenIssues) { + return processAllAviatorArtifacts(unirest, logger, sinceDate); + } + SSCArtifactDescriptor ad = resolveArtifactDescriptor(unirest, sinceDate); return processFprRemediations(unirest, ad, logger); } } - + + private void validateOptions() { + boolean hasArtifactId = artifactId != null && !artifactId.isBlank(); + boolean hasSince = since != null && !since.isBlank(); + int optionCount = (hasArtifactId ? 1 : 0) + (latest ? 1 : 0) + (allOpenIssues ? 1 : 0); + + if (optionCount > 1) { + throw new FcliSimpleException("--artifact-id, --latest, and --all-open-issues are mutually exclusive"); + } + if (optionCount == 0) { + throw new FcliSimpleException("One of --artifact-id, --latest, or --all-open-issues must be specified"); + } + if ((latest || allOpenIssues) && appVersionResolver.getAppVersionNameOrId() == null) { + throw new FcliSimpleException("--av/--appversion is required when using --latest or --all-open-issues"); + } + if (hasSince && hasArtifactId) { + throw new FcliSimpleException("--since cannot be used with --artifact-id; use --latest or --all-open-issues"); + } + if (hasSince && !latest && !allOpenIssues) { + throw new FcliSimpleException("--since can only be used with --latest or --all-open-issues"); + } + } + + private SSCArtifactDescriptor resolveArtifactDescriptor(UnirestInstance unirest, OffsetDateTime sinceDate) { + if (latest) { + return getLatestAviatorArtifact(unirest, sinceDate); + } else { + return SSCArtifactHelper.getArtifactDescriptor(unirest, artifactId); + } + } + + private SSCArtifactDescriptor getLatestAviatorArtifact(UnirestInstance unirest, OffsetDateTime sinceDate) { + String appVersionId = appVersionResolver.getAppVersionId(unirest); + return SSCArtifactHelper.getLatestAviatorArtifact(unirest, appVersionId, sinceDate); + } + private void validateSourceCodeDirectory() { if (sourceCodeDirectory == null || sourceCodeDirectory.isBlank()) { throw new FcliSimpleException("--source-dir must specify a valid directory path"); @@ -70,26 +128,71 @@ private void validateSourceCodeDirectory() { } @SneakyThrows - private JsonNode processFprRemediations(UnirestInstance unirest, SSCArtifactDescriptor ad, AviatorLoggerImpl logger) { + private JsonNode processAllAviatorArtifacts(UnirestInstance unirest, AviatorLoggerImpl logger, OffsetDateTime sinceDate) { + String appVersionId = appVersionResolver.getAppVersionId(unirest); + List artifacts = SSCArtifactHelper.getAllAviatorArtifacts(unirest, appVersionId, sinceDate); + + int totalRemediations = 0, appliedRemediations = 0, skippedRemediations = 0; + 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(unirest, ad, logger); + try (FprHandle fprHandle = new FprHandle(fprPath)) { + var metric = ApplyAutoRemediationOnSource.applyRemediations(fprHandle, sourceCodeDirectory, logger); + totalRemediations += metric.totalRemediations(); + appliedRemediations += metric.appliedRemediations(); + skippedRemediations += metric.skippedRemediations(); + 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); + } + } + } + } + + String action = appliedRemediations > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; + return AviatorSSCApplyRemediationsHelper.buildAggregatedResultNode( + appVersionId, artifactsProcessed, artifactsSkipped, + totalRemediations, appliedRemediations, skippedRemediations, action); + } + + @SneakyThrows + private Path downloadArtifactFpr(UnirestInstance unirest, SSCArtifactDescriptor ad, AviatorLoggerImpl logger) { Path fprPath = Files.createTempFile("aviator_" + ad.getId() + "_", ".fpr"); - try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()){ - logger.progress("Status: Downloading Audited FPR from SSC"); + try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { + 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 + private JsonNode processFprRemediations(UnirestInstance unirest, SSCArtifactDescriptor ad, AviatorLoggerImpl logger) { + Path fprPath = downloadArtifactFpr(unirest, ad, logger); + try { logger.progress("Status: Processing FPR with Aviator for Applying Auto Remediations"); - try (FprHandle fprHandle = new FprHandle(fprPath)) { var remediationMetric = ApplyAutoRemediationOnSource.applyRemediations(fprHandle, sourceCodeDirectory, logger); String status = remediationMetric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; - return AviatorSSCApplyRemediationsHelper.buildResultNode(ad, remediationMetric.totalRemediations(), remediationMetric.appliedRemediations(), remediationMetric.skippedRemediations(), status); } - } finally { try { Files.deleteIfExists(fprPath); @@ -100,7 +203,7 @@ private JsonNode processFprRemediations(UnirestInstance unirest, SSCArtifactDesc } @Override - public boolean isSingular() {return true;} + public boolean isSingular() { return !allOpenIssues; } @Override public String getActionCommandResult() { 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 03d6172571..c60b942eca 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 @@ -13,6 +13,7 @@ package com.fortify.cli.aviator.ssc.helper; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; import com.fortify.cli.ssc.artifact.helper.SSCArtifactDescriptor; @@ -24,18 +25,47 @@ public final class AviatorSSCApplyRemediationsHelper { private AviatorSSCApplyRemediationsHelper() {} /** - * Builds the final JSON result node for the command output. - * @param ad The SSCAppVersionDescriptor. - * @param totalRemediation Total no. of Remediations - * @param appliedRemediation Remediations that has been applied successfully - * @param skippedRemediation Remediations that has been skipped + * 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 totalRemediation Total no. of remediations in the artifact. + * @param appliedRemediation Remediations applied successfully. + * @param skippedRemediation Remediations skipped. * @param action Final action. * @return An ObjectNode representing the result. */ - - public static ObjectNode buildResultNode(SSCArtifactDescriptor ad,int totalRemediation, int appliedRemediation, int skippedRemediation, String action) { - ObjectNode result = ad.asObjectNode(); + public static ObjectNode buildResultNode(SSCArtifactDescriptor ad, int totalRemediation, int appliedRemediation, int skippedRemediation, 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", totalRemediation); + result.put("appliedRemediation", appliedRemediation); + result.put("skippedRemediation", skippedRemediation); + result.put(IActionCommandResultSupplier.actionFieldName, action); + 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, + int totalRemediation, int appliedRemediation, int skippedRemediation, String action) { + 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", totalRemediation); result.put("appliedRemediation", appliedRemediation); result.put("skippedRemediation", skippedRemediation); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SinceOptionHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SinceOptionHelper.java new file mode 100644 index 0000000000..dddef4b5b2 --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SinceOptionHelper.java @@ -0,0 +1,90 @@ +/* + * 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.time.OffsetDateTime; +import java.time.ZoneOffset; + +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.json.JSONDateTimeConverter; +import com.fortify.cli.common.util.DateTimePeriodHelper; +import com.fortify.cli.common.util.DateTimePeriodHelper.Period; + +/** + * Helper for parsing the --since option value into an OffsetDateTime cutoff. + * Supports relative durations (e.g. 7d, 2w, 1m, 90d) and absolute date strings + * (e.g. 2025-01-01, 2025-01-01T10:30:00, 2025-01-01T10:30:00Z). + */ +public final class SinceOptionHelper { + private static final DateTimePeriodHelper PERIOD_HELPER = + DateTimePeriodHelper.byRange(Period.DAYS, Period.YEARS); + + private SinceOptionHelper() {} + + /** + * Parses the given --since value to an OffsetDateTime. + * Returns null if sinceValue is null or blank (meaning no filter). + * Tries relative period parsing first, then falls back to absolute date parsing. + * + * @param sinceValue the raw --since option value + * @return resolved OffsetDateTime cutoff, or null if sinceValue is blank + * @throws FcliSimpleException if the value cannot be parsed + */ + public static OffsetDateTime parse(String sinceValue) { + if (sinceValue == null || sinceValue.isBlank()) { + return null; + } + try { + return PERIOD_HELPER.getCurrentOffsetDateTimeMinusPeriod(sinceValue); + } catch (Exception relativeEx) { + try { + return new JSONDateTimeConverter() + .parseZonedDateTime(sinceValue) + .toOffsetDateTime() + .withOffsetSameInstant(ZoneOffset.UTC); + } catch (Exception absoluteEx) { + throw new FcliSimpleException( + "Invalid --since value: '" + sinceValue + "'. " + + "Use a relative duration (e.g. 7d, 2w, 1m, 90d) or an absolute date " + + "(e.g. 2025-01-01, 2025-01-01T10:30:00, 2025-01-01T10:30:00Z)." + ); + } + } + } + + /** + * Returns true if the given uploadDate string represents a date that is + * on or after the given cutoff. If cutoff is null, always returns true. + * + * @param uploadDateStr the artifact uploadDate string from SSC (ISO 8601) + * @param cutoff the resolved --since cutoff, or null for no filter + * @return true if the artifact should be included + */ + public static boolean isOnOrAfter(String uploadDateStr, OffsetDateTime cutoff) { + if (cutoff == null) { + return true; + } + if (uploadDateStr == null || uploadDateStr.isBlank()) { + return false; + } + try { + OffsetDateTime uploadDate = new JSONDateTimeConverter() + .parseZonedDateTime(uploadDateStr) + .toOffsetDateTime() + .withOffsetSameInstant(ZoneOffset.UTC); + return !uploadDate.isBefore(cutoff); + } catch (Exception e) { + return false; + } + } +} 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 71959d7774..64f0ba806c 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 @@ -129,12 +129,20 @@ 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.apply-remediations.usage.header = Apply remediations on the user source code proposed by artifact. -fcli.aviator.ssc.apply-remediations.usage.description = Downloads the FPR from an SSC artifact ID, apply the remediations proposed by SAST 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.ssc.apply-remediations.artifact-id = Downloads the FPR based on the artifact ID -fcli.ssc.artifact.resolver.id = Artifact id. -fcli.aviator.ssc.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. +fcli.aviator.ssc.apply-remediations.usage.header = Apply auto-remediations from an Aviator-processed artifact to source code. +fcli.aviator.ssc.apply-remediations.usage.description = Downloads FPR artifact(s) and applies Aviator-generated remediations to the specified source directory. \ + Use --artifact-id to specify a specific artifact, --latest to automatically select the most recent Aviator-processed artifact, \ + or --all-open-issues to process all Aviator-processed artifacts for the application version. \ + This command requires an active user session. Use 'fcli aviator session login' to create a session. +fcli.aviator.ssc.apply-remediations.artifact-id = Specific artifact ID to process. Mutually exclusive with --latest and --all-open-issues. +fcli.aviator.ssc.apply-remediations.latest = Automatically select the most recent Aviator-processed artifact. Requires --av/--appversion. Mutually exclusive with --artifact-id and --all-open-issues. +fcli.aviator.ssc.apply-remediations.all-open-issues = Apply remediations from all 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.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-open-issues; not compatible with --artifact-id. fcli.aviator.ssc.prepare.usage.header = (PREVIEW) Prepare an SSC instance for Aviator integration. fcli.aviator.ssc.prepare.usage.description = This command ensures that the Aviator-specific custom tags ('Aviator prediction',`Aviator status`) \ @@ -182,6 +190,6 @@ 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 = artifactId,totalRemediation,appliedRemediation,skippedRemediation +fcli.aviator.ssc.apply-remediations.output.table.args = appVersionId,artifactId,artifactsProcessed,artifactsSkipped,totalRemediation,appliedRemediation,skippedRemediation fcli.aviator.ssc.prepare.output.table.args = status,entity,details fcli.aviator.fod.apply-remediations.output.table.args = releaseId,totalRemediation,appliedRemediation,skippedRemediation 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 index c23564e9b8..72f84d7292 100644 --- 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 @@ -15,12 +15,16 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import org.junit.jupiter.api.Test; import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.ssc.appversion.cli.mixin.SSCAppVersionResolverMixin; class AviatorSSCApplyRemediationsCommandTest { @Test @@ -65,4 +69,218 @@ void testBlankSourceCodeDirectoryThrowsException() throws Exception { assertThrows(FcliSimpleException.class, () -> command.getJsonNode(null), "Blank sourceCodeDirectory should throw FcliSimpleException"); } + + @Test + void testMutualExclusivityBetweenArtifactIdAndLatest() throws Exception { + AviatorSSCApplyRemediationsCommand command = new AviatorSSCApplyRemediationsCommand(); + + Field artifactIdField = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("artifactId"); + artifactIdField.setAccessible(true); + artifactIdField.set(command, "12345"); + + Field latestField = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("latest"); + latestField.setAccessible(true); + latestField.set(command, true); + + Method validateMethod = AviatorSSCApplyRemediationsCommand.class.getDeclaredMethod("validateOptions"); + validateMethod.setAccessible(true); + + FcliSimpleException exception = assertThrows(FcliSimpleException.class, + () -> { + try { + validateMethod.invoke(command); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + }, + "Using both --artifact-id and --latest should throw FcliSimpleException"); + + assertTrue(exception.getMessage().contains("mutually exclusive"), + "Error message should mention mutual exclusivity"); + } + + @Test + void testMutualExclusivityBetweenArtifactIdAndAllOpenIssues() throws Exception { + AviatorSSCApplyRemediationsCommand command = new AviatorSSCApplyRemediationsCommand(); + + Field artifactIdField = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("artifactId"); + artifactIdField.setAccessible(true); + artifactIdField.set(command, "12345"); + + Field allOpenIssuesField = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("allOpenIssues"); + allOpenIssuesField.setAccessible(true); + allOpenIssuesField.set(command, true); + + Method validateMethod = AviatorSSCApplyRemediationsCommand.class.getDeclaredMethod("validateOptions"); + validateMethod.setAccessible(true); + + FcliSimpleException exception = assertThrows(FcliSimpleException.class, + () -> { + try { + validateMethod.invoke(command); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + }, + "Using both --artifact-id and --all-open-issues should throw FcliSimpleException"); + + assertTrue(exception.getMessage().contains("mutually exclusive"), + "Error message should mention mutual exclusivity"); + } + + @Test + void testAllOpenIssuesRequiresAppVersion() throws Exception { + AviatorSSCApplyRemediationsCommand command = new AviatorSSCApplyRemediationsCommand(); + + Field allOpenIssuesField = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("allOpenIssues"); + allOpenIssuesField.setAccessible(true); + allOpenIssuesField.set(command, true); + + Field appVersionResolverField = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("appVersionResolver"); + appVersionResolverField.setAccessible(true); + SSCAppVersionResolverMixin.OptionalOption mockResolver = new SSCAppVersionResolverMixin.OptionalOption() { + @Override + public String getAppVersionNameOrId() { + return null; + } + }; + appVersionResolverField.set(command, mockResolver); + + Method validateMethod = AviatorSSCApplyRemediationsCommand.class.getDeclaredMethod("validateOptions"); + validateMethod.setAccessible(true); + + FcliSimpleException exception = assertThrows(FcliSimpleException.class, + () -> { + try { + validateMethod.invoke(command); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + }, + "Using --all-open-issues without --av should throw FcliSimpleException"); + + assertTrue(exception.getMessage().contains("--av/--appversion is required"), + "Error message should indicate app version is required for --all-open-issues"); + } + + @Test + void testValidationPassesWithAllOpenIssuesAndAppVersion() throws Exception { + AviatorSSCApplyRemediationsCommand command = new AviatorSSCApplyRemediationsCommand(); + + Field allOpenIssuesField = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("allOpenIssues"); + allOpenIssuesField.setAccessible(true); + allOpenIssuesField.set(command, true); + + Field appVersionResolverField = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("appVersionResolver"); + appVersionResolverField.setAccessible(true); + SSCAppVersionResolverMixin.OptionalOption mockResolver = new SSCAppVersionResolverMixin.OptionalOption() { + @Override + public String getAppVersionNameOrId() { + return "MyApp:main"; + } + }; + appVersionResolverField.set(command, mockResolver); + + Method validateMethod = AviatorSSCApplyRemediationsCommand.class.getDeclaredMethod("validateOptions"); + validateMethod.setAccessible(true); + + // Should not throw any exception + validateMethod.invoke(command); + } + + @Test + void testEitherArtifactIdOrLatestRequired() throws Exception { + AviatorSSCApplyRemediationsCommand command = new AviatorSSCApplyRemediationsCommand(); + + Method validateMethod = AviatorSSCApplyRemediationsCommand.class.getDeclaredMethod("validateOptions"); + validateMethod.setAccessible(true); + + FcliSimpleException exception = assertThrows(FcliSimpleException.class, + () -> { + try { + validateMethod.invoke(command); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + }, + "Not providing any option should throw FcliSimpleException"); + + assertTrue(exception.getMessage().contains("One of --artifact-id, --latest, or --all-open-issues must be specified"), + "Error message should indicate one of the options is required"); + } + + @Test + void testLatestRequiresAppVersion() throws Exception { + AviatorSSCApplyRemediationsCommand command = new AviatorSSCApplyRemediationsCommand(); + + Field latestField = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("latest"); + latestField.setAccessible(true); + latestField.set(command, true); + + Field appVersionResolverField = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("appVersionResolver"); + appVersionResolverField.setAccessible(true); + SSCAppVersionResolverMixin.OptionalOption mockResolver = new SSCAppVersionResolverMixin.OptionalOption() { + @Override + public String getAppVersionNameOrId() { + return null; + } + }; + appVersionResolverField.set(command, mockResolver); + + Method validateMethod = AviatorSSCApplyRemediationsCommand.class.getDeclaredMethod("validateOptions"); + validateMethod.setAccessible(true); + + FcliSimpleException exception = assertThrows(FcliSimpleException.class, + () -> { + try { + validateMethod.invoke(command); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + }, + "Using --latest without --av should throw FcliSimpleException"); + + assertTrue(exception.getMessage().contains("--av/--appversion is required"), + "Error message should indicate app version is required for --latest"); + } + + @Test + void testValidationPassesWithArtifactId() throws Exception { + AviatorSSCApplyRemediationsCommand command = new AviatorSSCApplyRemediationsCommand(); + + Field artifactIdField = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("artifactId"); + artifactIdField.setAccessible(true); + artifactIdField.set(command, "12345"); + + Method validateMethod = AviatorSSCApplyRemediationsCommand.class.getDeclaredMethod("validateOptions"); + validateMethod.setAccessible(true); + + // Should not throw any exception + validateMethod.invoke(command); + } + + @Test + void testValidationPassesWithLatestAndAppVersion() throws Exception { + AviatorSSCApplyRemediationsCommand command = new AviatorSSCApplyRemediationsCommand(); + + Field latestField = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("latest"); + latestField.setAccessible(true); + latestField.set(command, true); + + Field appVersionResolverField = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("appVersionResolver"); + appVersionResolverField.setAccessible(true); + SSCAppVersionResolverMixin.OptionalOption mockResolver = new SSCAppVersionResolverMixin.OptionalOption() { + @Override + public String getAppVersionNameOrId() { + return "MyApp:main"; + } + }; + appVersionResolverField.set(command, mockResolver); + + Method validateMethod = AviatorSSCApplyRemediationsCommand.class.getDeclaredMethod("validateOptions"); + validateMethod.setAccessible(true); + + // Should not throw any exception + validateMethod.invoke(command); + } } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/scan/helper/FoDScanHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/scan/helper/FoDScanHelper.java index 9fca985aa6..a51507bff3 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/scan/helper/FoDScanHelper.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/scan/helper/FoDScanHelper.java @@ -146,7 +146,7 @@ public static FoDScanAssessmentTypeDescriptor getEntitlementToUse(UnirestInstanc Integer assessmentTypeId = 0; LOG.info("Finding/Validating entitlement to use."); - var atd = FoDReleaseAssessmentTypeHelper.getAssessmentTypeDescriptor(unirest, relId, scanType, + var atd = FoDReleaseAssessmentTypeHelper.getAssessmentTypeDescriptor(unirest, relId, scanType, entitlementFrequencyType, assessmentType); assessmentTypeId = atd.getAssessmentTypeId(); entitlementIdToUse = atd.getEntitlementId(); diff --git a/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/artifact/helper/SSCArtifactDescriptor.java b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/artifact/helper/SSCArtifactDescriptor.java index d353e94d43..11b8c10a94 100644 --- a/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/artifact/helper/SSCArtifactDescriptor.java +++ b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/artifact/helper/SSCArtifactDescriptor.java @@ -23,4 +23,5 @@ @Data @EqualsAndHashCode(callSuper=true) public class SSCArtifactDescriptor extends JsonNodeHolder { private String id; + private String uploadDate; } 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 77e351415d..83c7f79f0f 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 @@ -14,6 +14,7 @@ import java.time.OffsetDateTime; import java.util.ArrayList; +import java.util.List; import java.util.stream.Collectors; import com.fasterxml.jackson.annotation.JsonFormat; @@ -21,6 +22,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.ssc._common.rest.ssc.SSCUrls; @@ -32,36 +34,139 @@ public final class SSCArtifactHelper { public static final int DEFAULT_POLL_INTERVAL_SECONDS = 1; - + private SSCArtifactHelper() {} - + public static final SSCArtifactDescriptor getArtifactDescriptor(UnirestInstance unirest, String artifactId) { return getDescriptor(getArtifactJsonNode(unirest, artifactId)); } + /** + * Get the latest Aviator-processed artifact for an application version, + * optionally filtered to only consider artifacts uploaded on or after sinceDate. + * + * @param unirest UnirestInstance + * @param appVersionId Application version ID + * @param sinceDate Optional cutoff; only artifacts with uploadDate >= sinceDate are considered. May be null. + * @return SSCArtifactDescriptor of the most recent qualifying Aviator artifact + * @throws FcliSimpleException if no matching Aviator artifacts found + */ + public static final SSCArtifactDescriptor getLatestAviatorArtifact(UnirestInstance unirest, String appVersionId, OffsetDateTime sinceDate) { + // Note: SSC doesn't support filtering by originalFileName in query string, + // so we fetch recent artifacts and filter client-side + JsonNode response = unirest.get(SSCUrls.PROJECT_VERSION_ARTIFACTS(appVersionId)) + .queryString("orderby", "uploadDate DESC") + .queryString("limit", "50") + .queryString("embed", "scans") + .asObject(JsonNode.class) + .getBody(); + + JsonNode data = response.get("data"); + if (data == null || !data.isArray() || data.size() == 0) { + throw new FcliSimpleException( + "No artifacts found for application version ID: " + appVersionId + ); + } + + for (JsonNode artifact : data) { + String originalFileName = artifact.path("originalFileName").asText(""); + if (!originalFileName.startsWith("aviator_")) { continue; } + if (sinceDate != null && !isUploadDateOnOrAfter(artifact, sinceDate)) { continue; } + return getDescriptor(artifact); + } + + throw new FcliSimpleException(buildNoArtifactsMessage(appVersionId, sinceDate)); + } + + /** + * Get all Aviator-processed artifacts for an application version, in chronological order (oldest first), + * optionally filtered to only include artifacts uploaded on or after sinceDate. + * Uses client-side filtering since SSC does not support server-side filtering by originalFileName. + * Supports paging to handle application versions with many artifacts. + * + * @param unirest UnirestInstance + * @param appVersionId Application version ID + * @param sinceDate Optional cutoff; only artifacts with uploadDate >= sinceDate are included. May be null. + * @return List of SSCArtifactDescriptor ordered by uploadDate ascending + * @throws FcliSimpleException if no matching Aviator artifacts found + */ + public static final List getAllAviatorArtifacts(UnirestInstance unirest, String appVersionId, OffsetDateTime sinceDate) { + List result = new ArrayList<>(); + int start = 0; + int pageSize = 50; + + while (true) { + JsonNode response = unirest.get(SSCUrls.PROJECT_VERSION_ARTIFACTS(appVersionId)) + .queryString("orderby", "uploadDate ASC") + .queryString("start", start) + .queryString("limit", pageSize) + .asObject(JsonNode.class) + .getBody(); + + JsonNode data = response.get("data"); + if (data == null || !data.isArray() || data.isEmpty()) { break; } + + for (JsonNode artifact : data) { + String originalFileName = artifact.path("originalFileName").asText(""); + if (!originalFileName.startsWith("aviator_")) { continue; } + if (sinceDate != null && !isUploadDateOnOrAfter(artifact, sinceDate)) { continue; } + result.add(getDescriptor(artifact)); + } + + int totalCount = response.path("count").asInt(0); + start += pageSize; + if (start >= totalCount) { break; } + } + + if (result.isEmpty()) { + throw new FcliSimpleException(buildNoArtifactsMessage(appVersionId, sinceDate)); + } + return result; + } + + private static boolean isUploadDateOnOrAfter(JsonNode artifact, OffsetDateTime cutoff) { + String uploadDateStr = artifact.path("uploadDate").asText(""); + if (uploadDateStr.isBlank()) { return false; } + try { + OffsetDateTime uploadDate = OffsetDateTime.parse(uploadDateStr); + return !uploadDate.isBefore(cutoff); + } catch (Exception e) { + return false; + } + } + + private static String buildNoArtifactsMessage(String appVersionId, OffsetDateTime sinceDate) { + String base = "No Aviator-processed artifacts found for application version ID: " + appVersionId; + if (sinceDate != null) { + return base + + " uploaded on or after " + sinceDate; + } + return base; + } + private static JsonNode getArtifactJsonNode(UnirestInstance unirest, String artifactId) { return unirest.get(SSCUrls.ARTIFACT(artifactId)) .queryString("embed","scans") .asObject(JsonNode.class).getBody().get("data"); } - + public static final SSCArtifactDescriptor delete(UnirestInstance unirest, SSCArtifactDescriptor descriptor) { unirest.delete(SSCUrls.ARTIFACT(descriptor.getId())).asObject(JsonNode.class).getBody(); return descriptor; } - + public static final SSCArtifactDescriptor purge(UnirestInstance unirest, SSCArtifactDescriptor descriptor) { unirest.post(SSCUrls.ARTIFACTS_ACTION_PURGE) .body(new SSCAppVersionArtifactPurgeByIdRequest(new String[] {descriptor.getId()})) .asObject(JsonNode.class).getBody(); return descriptor; } - + public static final JsonNode purge(UnirestInstance unirest, SSCAppVersionArtifactPurgeByDateRequest purgeRequest) { return unirest.post(SSCUrls.PROJECT_VERSIONS_ACTION_PURGE) .body(purgeRequest).asObject(JsonNode.class).getBody(); } - + public static final JsonNode approve(UnirestInstance unirest, String artifactId, String message){ int[] artifactIds = {Integer.parseInt(artifactId)}; @@ -73,7 +178,7 @@ public static final JsonNode approve(UnirestInstance unirest, String artifactId, .body(jsonNode) .asObject(JsonNode.class).getBody(); } - + public static final String getArtifactStatus(UnirestInstance unirest, String artifactId){ return JsonHelper.evaluateSpelExpression( unirest.get(SSCUrls.ARTIFACT(artifactId)).asObject(JsonNode.class).getBody(), @@ -81,21 +186,21 @@ public static final String getArtifactStatus(UnirestInstance unirest, String art String.class ); } - - @Data + + @Data @Reflectable @NoArgsConstructor @AllArgsConstructor private static final class SSCAppVersionArtifactPurgeByIdRequest { private String[] artifactIds; } - + @Data @Builder @Reflectable @NoArgsConstructor @AllArgsConstructor public static final class SSCAppVersionArtifactPurgeByDateRequest { private String[] projectVersionIds; - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSxxx") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSxxx") private OffsetDateTime purgeBefore; } - + private static final SSCArtifactDescriptor getDescriptor(JsonNode scanNode) { return JsonHelper.treeToValue(scanNode, SSCArtifactDescriptor.class); } @@ -108,7 +213,7 @@ public static JsonNode addScanTypes(JsonNode record) { // TODO Can we get rid of unchecked conversion warning? @SuppressWarnings("unchecked") ArrayList scanTypes = JsonHelper.evaluateSpelExpression(_embed, "scans?.![type]", ArrayList.class); - scanTypesString = scanTypes.stream().collect(Collectors.joining(", ")); + scanTypesString = scanTypes.stream().collect(Collectors.joining(", ")); } record = ((ObjectNode)record).put("scanTypes", scanTypesString); }