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/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..8d8d30f07ba --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsApplyHelper.java @@ -0,0 +1,120 @@ +/* + * 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 org.slf4j.Logger; + +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.processor.RemediationProcessor.RemediationMetric; +import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.common.exception.FcliTechnicalException; + +/** + * Single apply-remediations loop for any {@link IRemediationsFprSource} + * (cache zip entries or online downloads). Soft-skips on {@link AviatorSimpleException}. + */ +public final class RemediationsApplyHelper { + private RemediationsApplyHelper() {} + + public record ApplyResult( + List processedEntries, + List processedIds, + int skipped, + List metrics) {} + + /** + * Applies remediations for each source entry until done or the issue-id filter is exhausted. + * Caller owns {@code source} lifecycle (try-with-resources). + */ + public static ApplyResult apply( + IRemediationsFprSource source, + String sourceCodeDirectory, + IAviatorLogger logger, + Set issueIdFilter, + Logger skipLog) { + Accumulator acc = new Accumulator(issueIdFilter); + source.forEachEntry((fprPath, label, id, index, total) -> { + if (acc.remaining != null && acc.remaining.isEmpty()) { + return false; + } + RemediationMetric metric = applyOne( + fprPath, label, index, total, sourceCodeDirectory, logger, acc.remaining, skipLog); + 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(); + } + + public static String actionLabel(RemediationMetric metric) { + return metric != null && metric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; + } + + private static RemediationMetric applyOne( + Path fprPath, + String entryLabel, + int index, + int total, + String sourceCodeDirectory, + IAviatorLogger logger, + Set issueFilter, + Logger skipLog) { + logger.progress("Processing FPR " + index + "/" + total + " (" + entryLabel + ")"); + logger.progress("Status: Processing FPR with Aviator for Applying Auto Remediations"); + try (FprHandle fprHandle = new FprHandle(fprPath)) { + return ApplyAutoRemediationOnSource.applyRemediations(fprHandle, sourceCodeDirectory, logger, issueFilter); + } catch (AviatorSimpleException e) { + skipLog.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..85365947e51 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheReader.java @@ -0,0 +1,210 @@ +/* + * 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(); + } + + public List getOrderedArtifactIds() { + return getOrderedEntries().stream() + .map(e -> e.getSscData() != null ? e.getSscData().getArtifactId() : "") + .toList(); + } + + public List getOrderedReleaseIds() { + return getOrderedEntries().stream() + .map(e -> e.getFodData() != null ? e.getFodData().getReleaseId() : "") + .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..8c9dbf73dac --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheWriter.java @@ -0,0 +1,313 @@ +/* + * 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 #close()}, the work file is moved + * onto {@code destination} (atomic when the filesystem supports it). + * + *

Use with try-with-resources. {@link #close()} writes the manifest (when entries + * exist), closes ZipFS, and publishes or discards the 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; + + /** + * 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 on successful try-with-resources close. + */ + 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()); + } + } + return writer.getManifest(); + } + } + + /** In-memory manifest (complete after successful close 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); + } + + /** + * Writes manifest when entries exist, closes ZipFS, then publishes the work file + * or deletes it if incomplete. Intended for try-with-resources (single close). + */ + @Override + public void close() { + 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 close is a no-op after publish/delete. + if (!Files.exists(workPath)) { + return; + } + if (!manifest.getEntries().isEmpty()) { + publishWorkFile(); + } else { + 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..0bed7939f0d --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelper.java @@ -0,0 +1,55 @@ +/* + * 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.Collection; +import java.util.LinkedHashSet; +import java.util.Set; + +import com.fortify.cli.aviator.fpr.processor.RemediationProcessor.RemediationMetric; + +/** + * Shared metric aggregation helpers for SSC/FoD apply-remediations flows. + */ +public final class AviatorRemediationMetricsHelper { + private AviatorRemediationMetricsHelper() {} + + public static RemediationMetric aggregateMetrics(Set requestedIssueIds, Collection metrics) { + Set modifiedFiles = new LinkedHashSet<>(); + if (requestedIssueIds == null) { + int totalRemediations = 0; + int appliedRemediations = 0; + for (RemediationMetric metric : metrics) { + totalRemediations += metric.totalRemediations(); + appliedRemediations += metric.appliedRemediations(); + modifiedFiles.addAll(metric.modifiedFiles()); + } + return RemediationMetric.unfiltered(totalRemediations, appliedRemediations, modifiedFiles); + } + Set appliedIssueIds = new LinkedHashSet<>(); + for (RemediationMetric metric : metrics) { + modifiedFiles.addAll(metric.modifiedFiles()); + appliedIssueIds.addAll(metric.appliedIssueIds()); + } + return RemediationMetric.filtered(requestedIssueIds, appliedIssueIds, modifiedFiles); + } + + public static Set getRemainingIssueIds(Set requestedIssueIds, RemediationMetric metric) { + if (requestedIssueIds == null || requestedIssueIds.isEmpty()) { + return requestedIssueIds; + } + Set remainingIssueIds = new LinkedHashSet<>(requestedIssueIds); + remainingIssueIds.removeAll(metric.appliedIssueIds()); + return remainingIssueIds; + } +} 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 e2db9d777cb..38476b71b88 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 @@ -12,6 +12,8 @@ */ package com.fortify.cli.aviator.applyRemediation; +import java.util.Set; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -28,6 +30,12 @@ public class ApplyAutoRemediationOnSource { public static RemediationMetric applyRemediations(FprHandle fprHandle, String sourceCodeDirectory, IAviatorLogger logger) throws AviatorSimpleException, AviatorTechnicalException { + return applyRemediations(fprHandle, sourceCodeDirectory, logger, null); + } + + public static RemediationMetric applyRemediations(FprHandle fprHandle, String sourceCodeDirectory, IAviatorLogger logger, + Set issueIdFilter) + throws AviatorSimpleException, AviatorTechnicalException { LOG.info("Starting apply auto-remediation process for file: {}", fprHandle.getFprPath()); @@ -37,7 +45,7 @@ public static RemediationMetric applyRemediations(FprHandle fprHandle, String so } LOG.info("FPR validation successful"); - RemediationProcessor remediationProcessor = new RemediationProcessor(fprHandle, sourceCodeDirectory); + RemediationProcessor remediationProcessor = new RemediationProcessor(fprHandle, sourceCodeDirectory, issueIdFilter); return remediationProcessor.processRemediationXML(); } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessor.java index b16b8947f0c..5c145c65d48 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessor.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessor.java @@ -23,6 +23,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; +import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; @@ -41,6 +42,7 @@ import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; import com.fortify.cli.aviator.util.FprHandle; import com.fortify.cli.aviator.util.FuzzyContextSearcher; + public class RemediationProcessor { Logger logger = LoggerFactory.getLogger(RemediationProcessor.class); private static final String NAMESPACE_URI = "xmlns://www.fortify.com/schema/remediations"; @@ -48,137 +50,60 @@ public class RemediationProcessor { private final FprHandle fprHandle; private final String sourceCodeDirectory; - public record RemediationMetric(int totalRemediations, int appliedRemediations, int skippedRemediations, Set modifiedFiles){} + private final Set issueIdFilter; + + public record RemediationMetric(int totalRemediations, int appliedRemediations, int skippedRemediations, + Set modifiedFiles, Set requestedIssueIds, Set appliedIssueIds) { + public RemediationMetric { + modifiedFiles = immutableCopy(modifiedFiles); + requestedIssueIds = immutableCopy(requestedIssueIds); + appliedIssueIds = immutableCopy(appliedIssueIds); + } + + public static RemediationMetric unfiltered(int totalRemediations, int appliedRemediations, Set modifiedFiles) { + return new RemediationMetric(totalRemediations, appliedRemediations, totalRemediations - appliedRemediations, + modifiedFiles, Set.of(), Set.of()); + } + + public static RemediationMetric filtered(Set requestedIssueIds, Set appliedIssueIds, Set modifiedFiles) { + int totalRemediations = requestedIssueIds.size(); + int appliedRemediations = appliedIssueIds.size(); + return new RemediationMetric(totalRemediations, appliedRemediations, totalRemediations - appliedRemediations, + modifiedFiles, requestedIssueIds, appliedIssueIds); + } + + public boolean isFiltered() { + return !requestedIssueIds.isEmpty(); + } + + private static Set immutableCopy(Set values) { + return values == null ? Set.of() : Collections.unmodifiableSet(new LinkedHashSet<>(values)); + } + } - public RemediationProcessor(FprHandle fprHandle, String sourceCodeDirectory) { + public RemediationProcessor(FprHandle fprHandle, String sourceCodeDirectory, Set issueIdFilter) { this.fprHandle = fprHandle; this.sourceCodeDirectory = sourceCodeDirectory; + this.issueIdFilter = issueIdFilter == null ? null : Collections.unmodifiableSet(new LinkedHashSet<>(issueIdFilter)); } public RemediationMetric processRemediationXML() { Path remediationPath = fprHandle.getPath("/remediations.xml"); - Document remediationDoc; - int totalRemediations; - int appliedRemediations; - Set modifiedFiles = new LinkedHashSet<>(); - - // Sanitize and normalize the base source directory path once. - String trimmedSourceDir = sourceCodeDirectory.trim(); - if (trimmedSourceDir.length() > 1 && - ((trimmedSourceDir.startsWith("\"") && trimmedSourceDir.endsWith("\"")) || - (trimmedSourceDir.startsWith("'") && trimmedSourceDir.endsWith("'")))) { - trimmedSourceDir = trimmedSourceDir.substring(1, trimmedSourceDir.length() - 1); - } - final Path sourceBasePath = Paths.get(trimmedSourceDir).toAbsolutePath().normalize(); - 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); - DocumentBuilder builder = factory.newDocumentBuilder(); - remediationDoc = builder.parse(remediationStream); - + Document remediationDoc = parseRemediationDocument(remediationStream); NodeList remediationNodes = remediationDoc.getElementsByTagNameNS(NAMESPACE_URI, "Remediation"); - totalRemediations = remediationNodes.getLength(); - appliedRemediations=0; + ProcessingState processingState = new ProcessingState(issueIdFilter); + Path sourceBasePath = getSourceBasePath(); for (int i = 0; i < remediationNodes.getLength(); i++) { - Element remediation = (Element) remediationNodes.item(i); - NodeList fileChangesNodes = remediation.getElementsByTagNameNS(NAMESPACE_URI, "FileChanges"); - boolean remediationAppliedOnIssue = false; - for (int j = 0; j < fileChangesNodes.getLength(); j++) { - Element fileChanges = (Element) fileChangesNodes.item(j); - String filename = fileChanges.getElementsByTagNameNS(NAMESPACE_URI, "Filename").item(0).getTextContent(); - - Path filePath = sourceBasePath.resolve(filename).normalize(); - - if (!filePath.startsWith(sourceBasePath)) { - logger.error("Skipping file '{}' as it resolves to a path outside the source directory (potential path traversal attack)", filename); - continue; - } - - if (!isFilePresent(filePath)) { - logger.error("Source code file not present at: {}", filePath.toString()); - throw new AviatorTechnicalException("Source code file not present at: " + filePath.toString()); - } - - String fileHash = fileChanges.getElementsByTagNameNS(NAMESPACE_URI, "Hash").item(0).getTextContent(); - String instanceId = remediation.getAttribute("instanceId"); - - NodeList changesNodes = fileChanges.getElementsByTagNameNS(NAMESPACE_URI, "Change"); - for (int k = 0; k < changesNodes.getLength(); k++) { - Element change = (Element) changesNodes.item(k); - - - String content = Files.readString(filePath, StandardCharsets.UTF_8).replace("\r\n", "\n"); - - List originalLines = Arrays.asList(content.split("\n")); - - int lineFrom = Integer.parseInt(change.getElementsByTagNameNS(NAMESPACE_URI, "LineFrom").item(0).getTextContent()); - int lineTo = Integer.parseInt(change.getElementsByTagNameNS(NAMESPACE_URI, "LineTo").item(0).getTextContent()); - - - if (!calculateHashBase64(content, "SHA-256").equals(fileHash)) { - logger.trace("File hash mismatch for remediation {} in {}; searching changed source content", instanceId, filename); - Element contextElem = (Element) change.getElementsByTagNameNS(NAMESPACE_URI, "Context").item(0); - String contextText = contextElem.getTextContent(); - - //spliting a string into a list of lines, using both Unix (\n) and Windows (\r\n) line endings. - List contextLine = Arrays.asList(contextText.split("\\r?\\n")); - int contextLineFrom = FuzzyContextSearcher.fuzzySearchContext(originalLines, contextLine, 0) ; - if(contextLineFrom==-1) { - logger.trace("Context search failed for remediation {} in {}; context lines={}, source lines={}", instanceId, filename, - contextLine.size(), originalLines.size()); - logger.info("File content has changed. Context Lines not found. Remediation not possible for {}", instanceId); - continue; - } - logger.trace("Context for remediation {} in {} matched at line {}", instanceId, filename, contextLineFrom + 1); - Element OriginalCodeElem = (Element) change.getElementsByTagNameNS(NAMESPACE_URI, "OriginalCode").item(0); - String OriginalCodeText = OriginalCodeElem.getTextContent(); - - //spliting a string into a list of lines, using both Unix (\n) and Windows (\r\n) line endings. - List OriginalCodeLine = Arrays.asList(OriginalCodeText.split("\\r?\\n")); - - int[] lineFromTo = FuzzyContextSearcher.fuzzySearchOriginalCode(originalLines, OriginalCodeLine, 0, contextLineFrom); - if(lineFromTo[0]==-1 || lineFromTo[1]==-1) { - logger.trace("Original code search failed for remediation {} in {}; context line={}, original code lines={}, source lines={}", - instanceId, filename, contextLineFrom + 1, OriginalCodeLine.size(), originalLines.size()); - logger.info("File content has changed. Original Code lines not found. Remediation not possible for {}", instanceId); - continue; - } - lineFrom = lineFromTo[0]+1; //Adding 1 for 1-based indexing - lineTo = lineFromTo[1] + 1; //Adding 1 for 1-based indexing - logger.trace("Original code for remediation {} in {} matched at lines {}-{}", instanceId, filename, lineFrom, lineTo); - } - - - //File hash is matched i,e the file has not been changed - - String newCodeRaw = change.getElementsByTagNameNS(NAMESPACE_URI, "NewCode").item(0).getTextContent(); - - List newCodeLines = Arrays.asList(newCodeRaw.split("\n")); - - - // Replace lines - List updatedLines = new ArrayList<>(); - updatedLines.addAll(originalLines.subList(0, lineFrom - 1)); - updatedLines.addAll(newCodeLines); - updatedLines.addAll(originalLines.subList(lineTo, originalLines.size())); - Files.write(filePath, updatedLines); - modifiedFiles.add(filename); - logger.info("Remediation applied for {} in file {}", instanceId, filename); - if(!remediationAppliedOnIssue) { - remediationAppliedOnIssue = true; - appliedRemediations++; - } - } - - } + processRemediation((Element) remediationNodes.item(i), sourceBasePath, processingState); } - + // Remaining IDs may be absent from remediations.xml or present but not successfully applied. + for (String unappliedIssueId : processingState.getRequestedButNotApplied()) { + logger.debug( + "Requested issue ID '{}' was not successfully applied (missing from remediations.xml or remediation could not be applied)", + unappliedIssueId); + } + return processingState.toMetric(remediationNodes.getLength()); } catch (ParserConfigurationException | SAXException | IOException e) { logger.error("Error parsing remediations.xml file: {}", remediationPath, e); throw new AviatorTechnicalException("Error processing remediation.xml file.", e); @@ -188,7 +113,139 @@ public RemediationMetric processRemediationXML() { logger.error("Unexpected error processing remediation.xml: {}", remediationPath, e); throw new AviatorTechnicalException("Unexpected error processing remediations.xml.", e); } - return new RemediationMetric(totalRemediations, appliedRemediations, totalRemediations-appliedRemediations, modifiedFiles); + } + + private Document parseRemediationDocument(InputStream remediationStream) + throws ParserConfigurationException, SAXException, IOException { + 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); + DocumentBuilder builder = factory.newDocumentBuilder(); + return builder.parse(remediationStream); + } + + private Path getSourceBasePath() { + String trimmedSourceDir = sourceCodeDirectory.trim(); + if (trimmedSourceDir.length() > 1 + && ((trimmedSourceDir.startsWith("\"") && trimmedSourceDir.endsWith("\"")) + || (trimmedSourceDir.startsWith("'") && trimmedSourceDir.endsWith("'")))) { + trimmedSourceDir = trimmedSourceDir.substring(1, trimmedSourceDir.length() - 1); + } + return Paths.get(trimmedSourceDir).toAbsolutePath().normalize(); + } + + private void processRemediation(Element remediation, Path sourceBasePath, ProcessingState processingState) throws IOException { + String instanceId = remediation.getAttribute("instanceId"); + if (!processingState.shouldProcess(instanceId)) { + return; + } + NodeList fileChangesNodes = remediation.getElementsByTagNameNS(NAMESPACE_URI, "FileChanges"); + boolean remediationApplied = false; + for (int j = 0; j < fileChangesNodes.getLength(); j++) { + remediationApplied |= processFileChanges((Element) fileChangesNodes.item(j), instanceId, sourceBasePath, processingState.modifiedFiles); + } + if (remediationApplied) { + processingState.recordApplied(instanceId); + } + } + + private boolean processFileChanges(Element fileChanges, String instanceId, Path sourceBasePath, Set modifiedFiles) throws IOException { + String filename = getRequiredChildText(fileChanges, "Filename"); + Path filePath = resolveSourceFilePath(sourceBasePath, filename); + if (filePath == null) { + return false; + } + String fileHash = getRequiredChildText(fileChanges, "Hash"); + NodeList changesNodes = fileChanges.getElementsByTagNameNS(NAMESPACE_URI, "Change"); + boolean remediationApplied = false; + for (int k = 0; k < changesNodes.getLength(); k++) { + remediationApplied |= applyChange((Element) changesNodes.item(k), filePath, filename, fileHash, instanceId, modifiedFiles); + } + return remediationApplied; + } + + private Path resolveSourceFilePath(Path sourceBasePath, String filename) { + Path filePath = sourceBasePath.resolve(filename).normalize(); + if (!filePath.startsWith(sourceBasePath)) { + logger.error("Skipping file '{}' as it resolves to a path outside the source directory (potential path traversal attack)", filename); + return null; + } + if (!isFilePresent(filePath)) { + logger.error("Source code file not present at: {}", filePath); + throw new AviatorTechnicalException("Source code file not present at: " + filePath); + } + return filePath; + } + + private boolean applyChange(Element change, Path filePath, String filename, String fileHash, String instanceId, Set modifiedFiles) + throws IOException { + String content = Files.readString(filePath, StandardCharsets.UTF_8).replace("\r\n", "\n"); + List originalLines = Arrays.asList(content.split("\n")); + LineRange lineRange = getLineRange(change); + if (!calculateHashBase64(content, "SHA-256").equals(fileHash)) { + lineRange = resolveLineRangeFromContext(change, originalLines, filename, instanceId); + if (lineRange == null) { + return false; + } + } + writeUpdatedContent(change, filePath, originalLines, lineRange, filename, instanceId, modifiedFiles); + return true; + } + + private LineRange getLineRange(Element change) { + int lineFrom = Integer.parseInt(getRequiredChildText(change, "LineFrom")); + int lineTo = Integer.parseInt(getRequiredChildText(change, "LineTo")); + return new LineRange(lineFrom, lineTo); + } + + private LineRange resolveLineRangeFromContext(Element change, List originalLines, String filename, String instanceId) + throws IOException { + logger.trace("File hash mismatch for remediation {} in {}; searching changed source content", instanceId, filename); + List contextLines = splitLines(getRequiredChildText(change, "Context")); + int contextLineFrom = FuzzyContextSearcher.fuzzySearchContext(originalLines, contextLines, 0); + if (contextLineFrom == -1) { + logger.trace("Context search failed for remediation {} in {}; context lines={}, source lines={}", instanceId, filename, + contextLines.size(), originalLines.size()); + logger.info("File content has changed. Context Lines not found. Remediation not possible for {}", instanceId); + return null; + } + logger.trace("Context for remediation {} in {} matched at line {}", instanceId, filename, contextLineFrom + 1); + List originalCodeLines = splitLines(getRequiredChildText(change, "OriginalCode")); + int[] lineFromTo = FuzzyContextSearcher.fuzzySearchOriginalCode(originalLines, originalCodeLines, 0, contextLineFrom); + if (lineFromTo[0] == -1 || lineFromTo[1] == -1) { + logger.trace("Original code search failed for remediation {} in {}; context line={}, original code lines={}, source lines={}", + instanceId, filename, contextLineFrom + 1, originalCodeLines.size(), originalLines.size()); + logger.info("File content has changed. Original Code lines not found. Remediation not possible for {}", instanceId); + return null; + } + int lineFrom = lineFromTo[0] + 1; + int lineTo = lineFromTo[1] + 1; + logger.trace("Original code for remediation {} in {} matched at lines {}-{}", instanceId, filename, lineFrom, lineTo); + return new LineRange(lineFrom, lineTo); + } + + private void writeUpdatedContent(Element change, Path filePath, List originalLines, LineRange lineRange, String filename, + String instanceId, Set modifiedFiles) throws IOException { + List newCodeLines = Arrays.asList(getRequiredChildText(change, "NewCode").split("\n")); + List updatedLines = new ArrayList<>(); + updatedLines.addAll(originalLines.subList(0, lineRange.lineFrom() - 1)); + updatedLines.addAll(newCodeLines); + updatedLines.addAll(originalLines.subList(lineRange.lineTo(), originalLines.size())); + Files.write(filePath, updatedLines); + modifiedFiles.add(filename); + logger.info("Remediation applied for {} in file {}", instanceId, filename); + } + + private List splitLines(String text) { + return Arrays.asList(text.split("\\r?\\n")); + } + + private String getRequiredChildText(Element element, String localName) { + return element.getElementsByTagNameNS(NAMESPACE_URI, localName).item(0).getTextContent(); } private boolean isFilePresent(Path path) { @@ -208,5 +265,46 @@ private String calculateHashBase64(String content, String algorithm) { } } + private record LineRange(int lineFrom, int lineTo) {} + + private static final class ProcessingState { + private final Set requestedIssueIds; + private final Set appliedIssueIds = new LinkedHashSet<>(); + private final Set modifiedFiles = new LinkedHashSet<>(); + private int appliedRemediations; + + private ProcessingState(Set requestedIssueIds) { + this.requestedIssueIds = requestedIssueIds == null ? null : new LinkedHashSet<>(requestedIssueIds); + } + + private boolean shouldProcess(String instanceId) { + return requestedIssueIds == null || requestedIssueIds.contains(instanceId); + } + + private void recordApplied(String instanceId) { + if (requestedIssueIds == null) { + appliedRemediations++; + } else { + appliedIssueIds.add(instanceId); + } + } + + private RemediationMetric toMetric(int totalRemediations) { + if (requestedIssueIds == null) { + return RemediationMetric.unfiltered(totalRemediations, appliedRemediations, modifiedFiles); + } + return RemediationMetric.filtered(requestedIssueIds, appliedIssueIds, modifiedFiles); + } + + private Set getRequestedButNotApplied() { + if (requestedIssueIds == null) { + return Set.of(); + } + Set notApplied = new LinkedHashSet<>(requestedIssueIds); + notApplied.removeAll(appliedIssueIds); + return notApplied; + } + } + } 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..98ff1a16f09 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheRoundTripTest.java @@ -0,0 +1,266 @@ +/* + * 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; + +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); + } + }); + // close() writes manifest and publishes + } + try (RemediationsCacheReader reader = RemediationsCacheReader.open(zip)) { + reader.requireProduct(RemediationsCacheConstants.PRODUCT_FOD); + assertEquals("streamed-fpr", Files.readString(reader.getOrderedFprPaths().get(0))); + assertEquals("rel-1", reader.getOrderedReleaseIds().get(0)); + } + } + + @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); + } + }); + } + 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 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..14bb0d9758e --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelperTest.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._common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator.fpr.processor.RemediationProcessor.RemediationMetric; + +class AviatorRemediationMetricsHelperTest { + + @Test + void aggregateFilteredMetricsDeduplicatesIssueIdsAcrossEntries() { + RemediationMetric metricOne = RemediationMetric.filtered( + Set.of("ISSUE-1", "ISSUE-2"), Set.of("ISSUE-1"), Set.of("A.java")); + RemediationMetric metricTwo = RemediationMetric.filtered( + 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)); + + 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 remainingIssueIdsDropsAlreadyApplied() { + RemediationMetric metric = RemediationMetric.filtered( + 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)); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorTest.java new file mode 100644 index 00000000000..d10ae0727d6 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorTest.java @@ -0,0 +1,242 @@ +/* + * 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.assertFalse; +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.LinkedHashSet; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.fortify.cli.aviator.util.FprHandle; + +class RemediationProcessorTest { + @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(), Set.of("ISSUE-2", "ISSUE-404")); + var metric = processor.processRemediationXML(); + + 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()); + 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(), new LinkedHashSet<>(Set.of("ISSUE-404", "ISSUE-405"))); + var metric = processor.processRemediationXML(); + + assertEquals(2, metric.totalRemediations()); + assertEquals(0, metric.appliedRemediations()); + assertEquals(2, metric.skippedRemediations()); + assertEquals(Set.of(), metric.appliedIssueIds()); + assertEquals(Set.of(), metric.modifiedFiles()); + 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(), null); + var metric = processor.processRemediationXML(); + + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(1, metric.skippedRemediations()); + String updatedContent = Files.readString(sourceFile, StandardCharsets.UTF_8).replace("\r\n", "\n"); + assertTrue(updatedContent.contains(" newOne();")); + } + } + + private Path createFpr(String remediationsXml) throws IOException { + Path fprPath = tempDir.resolve("test.fpr"); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(fprPath))) { + writeEntry(zipOutputStream, "audit.fvdl", ""); + writeEntry(zipOutputStream, "remediations.xml", remediationsXml); + } + 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); + } + + 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); + } + } + } +} \ No newline at end of file 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 53e80d04079..b195e70b3e8 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,11 +12,7 @@ */ 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.LinkedHashSet; import java.util.List; import java.util.Set; @@ -24,168 +20,109 @@ 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._common.remediations_cache.CacheRemediationsFprSource; +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.remediations_cache.RemediationsCacheConstants; +import com.fortify.cli.aviator._common.util.AviatorIssueIdFilterUtils; import com.fortify.cli.aviator.config.AviatorLoggerImpl; -import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCApplyRemediationsArtifactSelectorMixin; +import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCApplyRemediationsSourceMixin; +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.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.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.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 { +public class AviatorSSCApplyRemediationsCommand extends AbstractOutputCommand + implements IJsonNodeSupplier, IRecordTransformer, IActionCommandResultSupplier { + private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCApplyRemediationsCommand.class); + @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; - @Mixin private AviatorSSCApplyRemediationsArtifactSelectorMixin artifactSelector; + @Mixin private AviatorSSCApplyRemediationsSourceMixin sourceSelector; + @Mixin private SSCUnirestInstanceSupplierMixin unirestInstanceSupplier; - private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCApplyRemediationsCommand.class); - @Option(names = {"--source-dir"}, descriptionKey = "fcli.aviator.ssc.apply-remediations.source-dir") + @Option(names = {"--source-dir"}) private String sourceCodeDirectory = System.getProperty("user.dir"); + @Option(names = {"--issue-ids"}, split = ",") + private List issueIds; @Override - @SneakyThrows - public JsonNode getJsonNode(UnirestInstance unirest) { - artifactSelector.validate(); + public JsonNode getJsonNode() { + sourceSelector.validate(); validateSourceCodeDirectory(); - OffsetDateTime sinceDate = SinceOptionHelper.parse(artifactSelector.getSince()); + Set issueIdFilter = AviatorIssueIdFilterUtils.normalizeIssueIds(issueIds); + validateIssueIdFilterMode(); + try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); - ArtifactProcessor processor = new ArtifactProcessor(unirest, logger, progressWriter); - - if (artifactSelector.isAllOpenIssuesSelected()) { - return processor.processAllAviatorArtifacts(sinceDate); + if (sourceSelector.isFromCacheSelected()) { + return processFromCache(logger, issueIdFilter); } - SSCArtifactDescriptor ad = resolveArtifactDescriptor(unirest, sinceDate); - return processor.processFprRemediations(ad); + return processOnline(logger, progressWriter, issueIdFilter); } } - private SSCArtifactDescriptor resolveArtifactDescriptor(UnirestInstance unirest, OffsetDateTime sinceDate) { - if (artifactSelector.isLatestSelected()) { - return getLatestAviatorArtifact(unirest, sinceDate); - } else { - return SSCArtifactHelper.getArtifactDescriptor(unirest, artifactSelector.getArtifactId()); + private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdFilter) { + try (CacheRemediationsFprSource source = CacheRemediationsFprSource.open( + sourceSelector.getFromCache(), + RemediationsCacheConstants.PRODUCT_SSC)) { + ApplyResult applyResult = RemediationsApplyHelper.apply( + source, sourceCodeDirectory, logger, issueIdFilter, LOG); + return AviatorSSCApplyRemediationsHelper.buildCacheResultNode( + sourceSelector.getFromCache(), + applyResult, + issueIdFilter, + source.reader().getManifest().getSelection()); } } - private SSCArtifactDescriptor getLatestAviatorArtifact(UnirestInstance unirest, OffsetDateTime sinceDate) { - String appVersionId = artifactSelector.getAppVersionId(unirest); - return SSCArtifactHelper.getLatestAviatorArtifact(unirest, appVersionId, sinceDate); + private JsonNode processOnline( + AviatorLoggerImpl logger, IProgressWriter progressWriter, Set issueIdFilter) { + UnirestInstance unirest = unirestInstanceSupplier.getUnirestInstance(); + OffsetDateTime sinceDate = SinceOptionHelper.parse(sourceSelector.getOnline().getSince()); + // One resolve: artifacts + appVersionId (no second getAppVersionId REST call). + ResolvedOnlineArtifacts resolved = sourceSelector.getOnline().resolveArtifacts(unirest, sinceDate); + try (SSCOnlineRemediationsFprSource source = new SSCOnlineRemediationsFprSource( + unirest, logger, progressWriter, resolved.artifacts())) { + ApplyResult applyResult = RemediationsApplyHelper.apply( + source, sourceCodeDirectory, logger, issueIdFilter, LOG); + return AviatorSSCApplyRemediationsHelper.buildOnlineResultNode( + resolved.artifacts(), resolved.appVersionId(), applyResult, issueIdFilter); + } } private void validateSourceCodeDirectory() { - if (sourceCodeDirectory == null || sourceCodeDirectory.isBlank()) { - throw new FcliSimpleException("--source-dir must specify a valid directory path"); - } + FcliSimpleException.throwIf(sourceCodeDirectory == null || sourceCodeDirectory.isBlank(), + "--source-dir must specify a valid directory path"); } - /** - * 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); - - int totalRemediations = 0, appliedRemediations = 0, skippedRemediations = 0; - int artifactsProcessed = 0, artifactsSkipped = 0; - Set allModifiedFiles = new LinkedHashSet<>(); - - 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, logger); - totalRemediations += metric.totalRemediations(); - appliedRemediations += metric.appliedRemediations(); - skippedRemediations += metric.skippedRemediations(); - allModifiedFiles.addAll(metric.modifiedFiles()); - 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, allModifiedFiles, 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, logger); - String status = remediationMetric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; - return AviatorSSCApplyRemediationsHelper.buildResultNode(ad, remediationMetric.totalRemediations(), remediationMetric.appliedRemediations(), remediationMetric.skippedRemediations(), remediationMetric.modifiedFiles(), status); - } - } finally { - try { - Files.deleteIfExists(fprPath); - } catch (IOException e) { - LOG.warn("Failed to delete temporary downloaded FPR file: {}", fprPath, e); - } - } - } + private void validateIssueIdFilterMode() { + FcliSimpleException.throwIf( + issueIds != null && !issueIds.isEmpty() && !sourceSelector.isFromCacheSelected(), + "--issue-ids can only be used with --from-cache; " + + "create a cache with download-remediations-cache and rerun with --from-cache"); } @Override - public boolean isSingular() { return true; } + public boolean isSingular() { + return true; + } @Override public String getActionCommandResult() { 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..d7147c455e4 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 @@ -22,6 +22,7 @@ AviatorSSCAuditCommand.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/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..ce625fb26fb --- /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)); + } + // close() writes manifest and publishes; capture entry count before close. + 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/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/AviatorSSCApplyRemediationsSourceMixin.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCApplyRemediationsSourceMixin.java new file mode 100644 index 00000000000..bd002c31ee8 --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCApplyRemediationsSourceMixin.java @@ -0,0 +1,70 @@ +/* + * 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.ssc.cli.mixin.AviatorSSCRemediationsSelectorArgGroups.OnlineSelectionArgGroup; +import com.fortify.cli.common.exception.FcliSimpleException; + +import lombok.Getter; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Option; + +/** + * Source selection for apply-remediations: either online SSC selection or a local remediations cache zip. + * Online selection is the shared {@link OnlineSelectionArgGroup} (no pass-through accessors). + */ +@Getter +public class AviatorSSCApplyRemediationsSourceMixin { + + @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; + } + + public void validate() { + if (isFromCacheSelected()) { + return; + } + FcliSimpleException.throwIf(!isOnlineSelected(), + "Exactly one of --from-cache or online selection (--artifact-id, --latest, --all) must be specified"); + source.online.validate(); + } +} 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..e1a18e65281 --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsSelectorArgGroups.java @@ -0,0 +1,143 @@ +/* + * 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.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); + return new ResolvedOnlineArtifacts( + SSCArtifactHelper.getAllAviatorArtifacts(unirest, appVersionId, sinceDate), + 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 f0289c3c139..87f9f716ce2 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,70 +12,149 @@ */ package com.fortify.cli.aviator.ssc.helper; +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; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsApplyHelper.ApplyResult; +import com.fortify.cli.aviator._common.util.AviatorRemediationMetricsHelper; +import com.fortify.cli.aviator.fpr.processor.RemediationProcessor.RemediationMetric; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; import com.fortify.cli.ssc.artifact.helper.SSCArtifactDescriptor; +import lombok.Builder; + /** - * Helper class for the AviatorSSCAuditCommand to encapsulate - * result message formatting and JSON output construction. + * Helper for Aviator SSC apply-remediations result JSON construction. + *

+ * Always-present fields live in {@link CommonApplyResult} (online = common only). + * Cache mode adds mode-specific fields via {@link CacheResultData#toJson()}. */ 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 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. + * Builds online-result JSON (always-present fields only). + * Table columns stay stable for --artifact-id, --latest, and --all. */ - public static ObjectNode buildResultNode(SSCArtifactDescriptor ad, int totalRemediation, int appliedRemediation, int skippedRemediation, Set modifiedFiles, 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.set("modifiedFiles", toArrayNode(modifiedFiles)); - result.put(IActionCommandResultSupplier.actionFieldName, action); - return result; + public static ObjectNode buildOnlineResultNode( + List artifacts, + String appVersionId, + ApplyResult applyResult, + Set issueIdFilter) { + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + issueIdFilter, applyResult.metrics()); + + String versionId = appVersionId; + String artifactId = null; + if (artifacts.size() == 1) { + SSCArtifactDescriptor only = artifacts.get(0); + if (versionId == null) { + versionId = only.asObjectNode().path("projectVersionId").asText(null); + } + // Preserve single-artifact id when that one entry was processed successfully. + if (applyResult.metrics().size() == 1 && applyResult.skipped() == 0) { + artifactId = only.getId(); + } + } + + return commonFrom(versionId, artifactId, applyResult, aggregated).toJson(); } /** - * 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. + * Aggregates metrics and builds the cache apply result JSON. + * Always-present fields match online table columns; cache-only fields are appended. + * + * @param selection manifest selection map; may be null (appVersionId becomes "N/A") */ - public static ObjectNode buildAggregatedResultNode(String appVersionId, int artifactsProcessed, int artifactsSkipped, - int totalRemediation, int appliedRemediation, int skippedRemediation, Set modifiedFiles, 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); - result.set("modifiedFiles", toArrayNode(modifiedFiles)); - result.put(IActionCommandResultSupplier.actionFieldName, action); - return result; + public static ObjectNode buildCacheResultNode( + Path cacheZip, + ApplyResult applyResult, + Set issueIdFilter, + Map selection) { + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + issueIdFilter, applyResult.metrics()); + String appVersionId = selection != null ? selection.get("appVersionId") : null; + return CacheResultData.builder() + .common(commonFrom(appVersionId, null, applyResult, aggregated)) + .cacheZip(cacheZip) + .entryPaths(applyResult.processedEntries()) + .artifactIds(applyResult.processedIds()) + .build() + .toJson(); + } + + private static CommonApplyResult commonFrom( + String appVersionId, + String artifactId, + ApplyResult applyResult, + RemediationMetric aggregated) { + return CommonApplyResult.builder() + .appVersionId(appVersionId) + .artifactId(artifactId) + .artifactsProcessed(applyResult.metrics().size()) + .artifactsSkipped(applyResult.skipped()) + .totalRemediation(aggregated.totalRemediations()) + .appliedRemediation(aggregated.appliedRemediations()) + .skippedRemediation(aggregated.skippedRemediations()) + .modifiedFiles(aggregated.modifiedFiles()) + .action(RemediationsApplyHelper.actionLabel(aggregated)) + .build(); + } + + /** + * Properties always written for SSC apply-remediations, independent of online vs cache. + * Keeps table columns stable across modes. + */ + @Builder + private record CommonApplyResult( + String appVersionId, + String artifactId, + int artifactsProcessed, + int artifactsSkipped, + int totalRemediation, + int appliedRemediation, + int skippedRemediation, + Set modifiedFiles, + String action) { + ObjectNode toJson() { + ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); + result.put("appVersionId", na(appVersionId)); + result.put("artifactId", na(artifactId)); + result.put("artifactsProcessed", artifactsProcessed); + result.put("artifactsSkipped", artifactsSkipped); + result.put("totalRemediation", totalRemediation); + result.put("appliedRemediation", appliedRemediation); + result.put("skippedRemediation", skippedRemediation); + result.set("modifiedFiles", toArrayNode(modifiedFiles)); + result.put(IActionCommandResultSupplier.actionFieldName, action); + return result; + } + } + + /** Cache mode: always-present fields plus cache path, entry paths, and artifact id list. */ + @Builder + private record CacheResultData( + CommonApplyResult common, + Path cacheZip, + List entryPaths, + List artifactIds) { + ObjectNode toJson() { + ObjectNode result = common.toJson(); + result.put("file", cacheZip.toString()); + result.set("entries", toStringArrayNode(entryPaths)); + result.set("artifactIds", toStringArrayNode(artifactIds)); + return result; + } + } + + private static String na(String value) { + return value != null ? value : "N/A"; } private static ArrayNode toArrayNode(Set files) { @@ -85,4 +164,12 @@ private static ArrayNode toArrayNode(Set files) { } return array; } + + private 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/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..470b94ae372 --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SSCOnlineRemediationsFprSource.java @@ -0,0 +1,81 @@ +/* + * 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.util.List; + +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.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; + +/** + * 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. + */ +public final class SSCOnlineRemediationsFprSource implements IRemediationsFprSource { + private final UnirestInstance unirest; + private final IAviatorLogger logger; + private final IProgressWriter progressWriter; + private final List artifacts; + + public SSCOnlineRemediationsFprSource( + UnirestInstance unirest, + IAviatorLogger logger, + IProgressWriter progressWriter, + List artifacts) { + FcliSimpleException.throwIf(artifacts == null || artifacts.isEmpty(), + "No SSC artifacts to apply remediations from"); + this.unirest = unirest; + this.logger = logger; + this.progressWriter = progressWriter; + this.artifacts = List.copyOf(artifacts); + } + + @Override + public void forEachEntry(EntryAction action) { + int total = artifacts.size(); + for (int i = 0; i < total; i++) { + SSCArtifactDescriptor artifact = 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/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties b/fcli-core/fcli-aviator/src/main/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties index 84f1a0f4ea4..bd058ad3faa 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 @@ -134,10 +134,13 @@ fcli.aviator.ssc.audit.refresh = By default, this command will refresh the sour 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 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: \ @@ -150,15 +153,22 @@ 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.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, in chronological order. 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') \ @@ -188,10 +198,13 @@ fcli.aviator.ssc.correlate-sast-dast.app = Fortify Aviator application name to a 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.\ + When --issue-ids is specified, only remediations whose remediations.xml instanceId matches one of the requested values are eligible. \ 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. +fcli.aviator.fod.apply-remediations.issue-ids = Comma-separated list of issue IDs to apply. Matches requested values against remediations.xml \ + instanceId values. When specified, summary counts reflect requested issue IDs rather than all remediations in the artifact. ################################################################################################################# # The following are technical properties that shouldn't be internationalized #################################### @@ -220,6 +233,7 @@ fcli.aviator.entitlement.list.output.table.args = id,tenant_name,start_date,end_ 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,skippedRemediation +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,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 new file mode 100644 index 00000000000..4e2b9608c0b --- /dev/null +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.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.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.AviatorSSCApplyRemediationsSourceMixin; +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"), getSourceMixin(command).getFromCache()); + assertTrue(getSourceMixin(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); + } + + private static AviatorSSCApplyRemediationsCommand parse(String... args) { + AviatorSSCApplyRemediationsCommand command = new AviatorSSCApplyRemediationsCommand(); + new CommandLine(command).parseArgs(args); + return command; + } + + private static AviatorSSCApplyRemediationsSourceMixin getSourceMixin(AviatorSSCApplyRemediationsCommand command) + throws Exception { + Field field = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("sourceSelector"); + field.setAccessible(true); + return (AviatorSSCApplyRemediationsSourceMixin) field.get(command); + } +} 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-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/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-fod/src/main/java/com/fortify/cli/fod/aviator/cli/mixin/FoDAviatorApplyRemediationsSourceMixin.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cli/mixin/FoDAviatorApplyRemediationsSourceMixin.java new file mode 100644 index 00000000000..d37186bce89 --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cli/mixin/FoDAviatorApplyRemediationsSourceMixin.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.fod.aviator.cli.mixin; + +import java.nio.file.Path; + +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; + +/** + * Source selection for FoD apply-remediations: online release or local remediations cache zip. + * Standalone mixin (aligned with SSC apply source mixin layout). + */ +public final class FoDAviatorApplyRemediationsSourceMixin 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); + } + + @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 fcfe9c1525d..d51a99f9e46 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,122 +12,103 @@ */ package com.fortify.cli.fod.aviator.cmd; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; +import java.util.List; +import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.aviator.applyRemediation.ApplyAutoRemediationOnSource; +import com.fortify.cli.aviator._common.remediations_cache.CacheRemediationsFprSource; +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.remediations_cache.RemediationsCacheConstants; +import com.fortify.cli.aviator._common.util.AviatorIssueIdFilterUtils; 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.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.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.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.FoDAviatorApplyRemediationsSourceMixin; 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.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 { +public class FoDAviatorApplyRemediationsCommand extends AbstractOutputCommand + implements IJsonNodeSupplier, IRecordTransformer, IActionCommandResultSupplier { + private static final Logger LOG = LoggerFactory.getLogger(FoDAviatorApplyRemediationsCommand.class); + @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 FoDDelimiterMixin delimiterMixin; // Injected into sourceSelector + @Mixin private FoDUnirestInstanceSupplierMixin unirestInstanceSupplier; + @Mixin private FoDAviatorApplyRemediationsSourceMixin sourceSelector; - @Override @SneakyThrows - public JsonNode getJsonNode(UnirestInstance unirest) { + @Option(names = {"--source-dir"}) + private String sourceCodeDirectory = System.getProperty("user.dir"); + @Option(names = {"--issue-ids"}, split = ",") + private List issueIds; + + @Override + public JsonNode getJsonNode() { validateSourceCodeDirectory(); + Set issueIdFilter = AviatorIssueIdFilterUtils.normalizeIssueIds(issueIds); + validateSelection(); + 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"); + if (sourceSelector.isFromCacheSelected()) { + return processFromCache(logger, issueIdFilter); + } + return processOnline(logger, issueIdFilter); } } - @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, 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(), remediationMetric.modifiedFiles(), status); - } - } finally { - if (downloadedFprPath != null) { - try { - Files.deleteIfExists(downloadedFprPath); - } catch (IndexOutOfBoundsException e) { - LOG.warn("WARN: Failed to delete temporary downloaded FPR file: {}", downloadedFprPath, e); - } - } + private JsonNode processOnline(AviatorLoggerImpl logger, Set issueIdFilter) { + UnirestInstance unirest = unirestInstanceSupplier.getUnirestInstance(); + FoDReleaseDescriptor release = sourceSelector.getReleaseDescriptor(unirest); + try (FoDOnlineRemediationsFprSource source = + new FoDOnlineRemediationsFprSource(unirest, logger, release)) { + ApplyResult applyResult = RemediationsApplyHelper.apply( + source, sourceCodeDirectory, logger, issueIdFilter, LOG); + return AviatorFoDApplyRemediationsHelper.buildOnlineResultNode(release, applyResult); } } - @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); - int status = 202; - while ( status==202 ) { - status = request - .asFile(file, StandardCopyOption.REPLACE_EXISTING) - .getStatus(); - if ( status==202 ) { Thread.sleep(30000L); } + private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdFilter) { + try (CacheRemediationsFprSource source = CacheRemediationsFprSource.open( + sourceSelector.getFromCache(), + RemediationsCacheConstants.PRODUCT_FOD)) { + ApplyResult applyResult = RemediationsApplyHelper.apply( + source, sourceCodeDirectory, logger, issueIdFilter, LOG); + return AviatorFoDApplyRemediationsHelper.buildCacheResultNode( + sourceSelector.getFromCache(), applyResult, issueIdFilter); } - return fprPath; } - - - protected FoDScanType getScanType() { - return FoDScanType.Static; + private void validateSourceCodeDirectory() { + FcliSimpleException.throwIf(sourceCodeDirectory == null || sourceCodeDirectory.isBlank(), + "--source-dir must specify a valid directory path"); } - 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 void validateSelection() { + FcliSimpleException.throwIf( + issueIds != null && !issueIds.isEmpty() && !sourceSelector.isFromCacheSelected(), + "--issue-ids can only be used with --from-cache; " + + "create a cache with download-remediations-cache and rerun with --from-cache"); } @Override @@ -135,7 +116,6 @@ public boolean isSingular() { return true; } - @Override public String getActionCommandResult() { return "Remediation-Applied"; 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..87c4688d5f0 --- /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); + // close() writes manifest and publishes. + 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 06a27646269..86c56639c18 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,51 +12,120 @@ */ package com.fortify.cli.fod.aviator.helper; +import java.nio.file.Path; +import java.util.List; 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; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsApplyHelper.ApplyResult; +import com.fortify.cli.aviator._common.util.AviatorRemediationMetricsHelper; +import com.fortify.cli.aviator.fpr.processor.RemediationProcessor.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(){} +import lombok.Builder; +/** + * Helper for FoD apply-remediations result JSON construction. + *

+ * Always-present fields live in {@link CommonApplyResult} (online = common only). + * Cache mode adds path/list properties via {@link CacheResultData#toJson()}. + */ +public final class AviatorFoDApplyRemediationsHelper { + private AviatorFoDApplyRemediationsHelper() {} + + /** Online apply outcome (success or soft-skip) → always-present result fields only. */ + public static ObjectNode buildOnlineResultNode(FoDReleaseDescriptor releaseDescriptor, ApplyResult applyResult) { + RemediationMetric metric = applyResult.metrics().isEmpty() ? null : applyResult.metrics().get(0); + return CommonApplyResult.builder() + .releaseId(releaseDescriptor.getReleaseId()) + .applicationName(releaseDescriptor.getApplicationName()) + .releaseName(releaseDescriptor.getReleaseName()) + .totalRemediation(metric == null ? 0 : metric.totalRemediations()) + .appliedRemediation(metric == null ? 0 : metric.appliedRemediations()) + .skippedRemediation(metric == null ? 0 : metric.skippedRemediations()) + .modifiedFiles(metric == null ? Set.of() : metric.modifiedFiles()) + .action(RemediationsApplyHelper.actionLabel(metric)) + .build() + .toJson(); + } /** - * Builds the final JSON result node for the command output. - * @param rd The SSCAppVersionDescriptor. - * @param totalRemediation Total no. of Remediations - * @param appliedRemediation Remediations that has been applied successfully - * @param skippedRemediation Remediations that has been skipped - * @param action Final action. - * @return An ObjectNode representing the result. + * Aggregates metrics and builds the cache apply result JSON. + * Always-present fields match online table columns; cache-only fields are appended. */ + public static ObjectNode buildCacheResultNode( + Path cacheZip, ApplyResult applyResult, Set issueIdFilter) { + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + issueIdFilter, applyResult.metrics()); + List releaseIds = applyResult.processedIds(); + String releaseId = releaseIds != null && !releaseIds.isEmpty() ? releaseIds.get(0) : null; + return CacheResultData.builder() + .common(CommonApplyResult.builder() + .releaseId(releaseId) + .applicationName(null) + .releaseName(null) + .totalRemediation(aggregated.totalRemediations()) + .appliedRemediation(aggregated.appliedRemediations()) + .skippedRemediation(aggregated.skippedRemediations()) + .modifiedFiles(aggregated.modifiedFiles()) + .action(RemediationsApplyHelper.actionLabel(aggregated)) + .build()) + .cacheZip(cacheZip) + .entryPaths(applyResult.processedEntries()) + .releaseIds(releaseIds) + .build() + .toJson(); + } - public static ObjectNode buildResultNode(FoDReleaseDescriptor rd, int totalRemediation, int appliedRemediation, int skippedRemediation, 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", totalRemediation); - result.put("appliedRemediation", appliedRemediation); - result.put("skippedRemediation", skippedRemediation); - result.put(IActionCommandResultSupplier.actionFieldName, action); - return result; + /** + * Properties always written for FoD apply-remediations, independent of online vs cache. + * Keeps table columns stable across modes. + */ + @Builder + private record CommonApplyResult( + String releaseId, + String applicationName, + String releaseName, + int totalRemediation, + int appliedRemediation, + int skippedRemediation, + Set modifiedFiles, + String action) { + ObjectNode toJson() { + ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); + result.put("releaseId", na(releaseId)); + result.put("applicationName", na(applicationName)); + result.put("releaseName", na(releaseName)); + result.put("totalRemediation", totalRemediation); + result.put("appliedRemediation", appliedRemediation); + result.put("skippedRemediation", skippedRemediation); + result.set("modifiedFiles", toArrayNode(modifiedFiles)); + result.put(IActionCommandResultSupplier.actionFieldName, action); + return result; + } } - public static ObjectNode buildResultNode(FoDReleaseDescriptor rd, int totalRemediation, int appliedRemediation, int skippedRemediation, Set modifiedFiles, 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", totalRemediation); - result.put("appliedRemediation", appliedRemediation); - result.put("skippedRemediation", skippedRemediation); - result.set("modifiedFiles", toArrayNode(modifiedFiles)); - result.put(IActionCommandResultSupplier.actionFieldName, action); - return result; + @Builder + private record CacheResultData( + CommonApplyResult common, + Path cacheZip, + List entryPaths, + List releaseIds) { + ObjectNode toJson() { + ObjectNode result = common.toJson(); + result.put("file", cacheZip.toString()); + result.set("entries", toStringArrayNode(entryPaths)); + result.set("releaseIds", toStringArrayNode(releaseIds)); + return result; + } + } + + private static String na(String value) { + return value != null ? value : "N/A"; } private static ArrayNode toArrayNode(Set files) { @@ -66,4 +135,12 @@ private static ArrayNode toArrayNode(Set files) { } return array; } + + private 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-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..de18eadae3c --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/FoDOnlineRemediationsFprSource.java @@ -0,0 +1,62 @@ +/* + * 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; + +/** + * 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. + */ +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 1d1a99b52d0..798ffff3687 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 @@ -1037,7 +1037,17 @@ 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.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? # various messages displayed during execution @@ -1090,4 +1100,5 @@ fcli.fod.issue.list.output.table.args = instanceId,visibilityMarker,severityStri 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,skippedRemediation,__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..f1dedd1956e 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,61 @@ */ 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.FoDAviatorApplyRemediationsSourceMixin; import com.fortify.cli.fod.aviator.cmd.FoDAviatorApplyRemediationsCommand; -class FoDAviatorApplyRemediationsCommandTest { - @Test - void testSourceCodeDirectoryHasDefaultValue() throws Exception { - FoDAviatorApplyRemediationsCommand command = new FoDAviatorApplyRemediationsCommand(); - - Field field = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("sourceCodeDirectory"); - field.setAccessible(true); - String fieldValue = (String) field.get(command); +import picocli.CommandLine; - 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"); + Field sourceSelectorField = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("sourceSelector"); + sourceSelectorField.setAccessible(true); + FoDAviatorApplyRemediationsSourceMixin sourceSelector = + (FoDAviatorApplyRemediationsSourceMixin) sourceSelectorField.get(command); + assertEquals(Path.of("remediations.zip"), sourceSelector.getFromCache()); + assertTrue(sourceSelector.isFromCacheSelected()); } @Test - void testSourceCodeDirectoryCanBeOverridden() throws Exception { - FoDAviatorApplyRemediationsCommand command = new FoDAviatorApplyRemediationsCommand(); - - Field field = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("sourceCodeDirectory"); - field.setAccessible(true); - - String customPath = "/custom/source/directory"; - field.set(command, customPath); - - String fieldValue = (String) field.get(command); - - assertEquals(customPath, fieldValue, - "sourceCodeDirectory should be overridable when --source-dir option is provided"); + void releaseAndFromCacheAreExclusive() { + assertThrows(CommandLine.ParameterException.class, + () -> parse("--release", "1", "--from-cache", "local.zip")); } @Test - void testBlankSourceCodeDirectoryThrowsException() throws Exception { - FoDAviatorApplyRemediationsCommand command = new FoDAviatorApplyRemediationsCommand(); + void issueIdsRequireFromCache() { + FoDAviatorApplyRemediationsCommand command = parse("--release", "1", "--issue-ids", "ISSUE-1"); + assertThrows(FcliSimpleException.class, command::getJsonNode); + } + @Test + void blankSourceDirIsRejected() throws Exception { + FoDAviatorApplyRemediationsCommand command = parse("--from-cache", "cache.zip"); Field field = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("sourceCodeDirectory"); field.setAccessible(true); field.set(command, ""); + assertThrows(FcliSimpleException.class, command::getJsonNode); + } - assertThrows(FcliSimpleException.class, () -> command.getJsonNode(null), - "Blank sourceCodeDirectory should throw FcliSimpleException"); + private static FoDAviatorApplyRemediationsCommand parse(String... args) { + FoDAviatorApplyRemediationsCommand command = new FoDAviatorApplyRemediationsCommand(); + new CommandLine(command).parseArgs(args); + return 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 cf33422295c..e3ed5d07248 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,13 +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 +38,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 @@ -53,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 54dd41c8b4f..bab8bb95030 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_"); 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