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..addd13d1e18 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheEntry.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._common.remediations_cache; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.formkiq.graalvm.annotations.Reflectable; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Reflectable +@Data +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class RemediationsCacheEntry { + private int order; + private String artifactId; + private String releaseId; + private String uploadDate; + private String path; + private String sha256; +} 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..134ba7b0ee9 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheManifest.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.remediations_cache; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.formkiq.graalvm.annotations.Reflectable; + +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<>(); +} 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..b8f8a6dea0f --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheReader.java @@ -0,0 +1,207 @@ +/* + * 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.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import org.apache.commons.lang3.StringUtils; + +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.json.JsonHelper; + +/** + * Opens a remediations cache zip, validates the manifest and entry checksums, + * and extracts FPR files to a temp directory for ordered processing. + */ +public final class RemediationsCacheReader implements AutoCloseable { + private final Path cacheZip; + private final Path extractDir; + private final RemediationsCacheManifest manifest; + private final List orderedFprPaths; + + private RemediationsCacheReader(Path cacheZip, Path extractDir, RemediationsCacheManifest manifest, + List orderedFprPaths) { + this.cacheZip = cacheZip; + this.extractDir = extractDir; + this.manifest = manifest; + this.orderedFprPaths = orderedFprPaths; + } + + public static RemediationsCacheReader open(Path cacheZip) { + if (cacheZip == null) { + throw new FcliSimpleException("--from-cache must specify a remediations cache zip path"); + } + if (!Files.exists(cacheZip)) { + throw new FcliSimpleException("Remediations cache file does not exist: " + cacheZip); + } + if (!Files.isRegularFile(cacheZip)) { + throw new FcliSimpleException("Remediations cache path is not a regular file: " + cacheZip); + } + if (!Files.isReadable(cacheZip)) { + throw new FcliSimpleException("Remediations cache file is not readable: " + cacheZip); + } + + Path extractDir; + try { + extractDir = Files.createTempDirectory("remediations-cache-"); + } catch (IOException e) { + throw new FcliSimpleException("Failed to create temporary directory for remediations cache", e); + } + + try { + Map extracted = extractAll(cacheZip, extractDir); + Path manifestPath = extracted.get(RemediationsCacheConstants.MANIFEST_ENTRY); + if (manifestPath == null) { + throw new FcliSimpleException("Remediations cache is missing " + RemediationsCacheConstants.MANIFEST_ENTRY + + ": " + cacheZip); + } + + RemediationsCacheManifest manifest = JsonHelper.getObjectMapper() + .readValue(manifestPath.toFile(), RemediationsCacheManifest.class); + validateManifest(manifest, cacheZip); + + List entries = new ArrayList<>(manifest.getEntries()); + entries.sort(Comparator.comparingInt(RemediationsCacheEntry::getOrder)); + + List orderedFprs = new ArrayList<>(); + for (RemediationsCacheEntry entry : entries) { + Path fprPath = extracted.get(normalizeZipPath(entry.getPath())); + if (fprPath == null || !Files.isRegularFile(fprPath)) { + throw new FcliSimpleException("Remediations cache entry path not found in zip: " + entry.getPath()); + } + String actualSha = RemediationsCacheSha256.hashFile(fprPath); + if (!actualSha.equalsIgnoreCase(entry.getSha256())) { + throw new FcliSimpleException("SHA-256 mismatch for cache entry " + entry.getPath() + + " (expected " + entry.getSha256() + ", actual " + actualSha + ")"); + } + orderedFprs.add(fprPath); + } + + if (orderedFprs.isEmpty()) { + throw new FcliSimpleException("Remediations cache contains no FPR entries: " + cacheZip); + } + + return new RemediationsCacheReader(cacheZip, extractDir, manifest, orderedFprs); + } catch (FcliSimpleException e) { + deleteRecursivelyQuietly(extractDir); + throw e; + } catch (IOException e) { + deleteRecursivelyQuietly(extractDir); + throw new FcliSimpleException("Failed to read remediations cache: " + cacheZip, e); + } + } + + public RemediationsCacheManifest getManifest() { + return manifest; + } + + public List getOrderedFprPaths() { + return List.copyOf(orderedFprPaths); + } + + public Path getCacheZip() { + return cacheZip; + } + + @Override + public void close() { + deleteRecursivelyQuietly(extractDir); + } + + private static void validateManifest(RemediationsCacheManifest manifest, Path cacheZip) { + if (manifest == null) { + throw new FcliSimpleException("Remediations cache manifest is empty: " + cacheZip); + } + if (manifest.getSchemaVersion() != RemediationsCacheConstants.SCHEMA_VERSION) { + throw new FcliSimpleException("Unsupported remediations cache schemaVersion " + + manifest.getSchemaVersion() + " (expected " + RemediationsCacheConstants.SCHEMA_VERSION + + "): " + cacheZip); + } + if (!RemediationsCacheConstants.KIND.equals(manifest.getKind())) { + throw new FcliSimpleException("Invalid remediations cache kind '" + manifest.getKind() + + "' (expected " + RemediationsCacheConstants.KIND + "): " + cacheZip); + } + if (StringUtils.isBlank(manifest.getProduct())) { + throw new FcliSimpleException("Remediations cache manifest is missing product: " + cacheZip); + } + if (manifest.getEntries() == null || manifest.getEntries().isEmpty()) { + throw new FcliSimpleException("Remediations cache has no entries: " + cacheZip); + } + for (RemediationsCacheEntry entry : manifest.getEntries()) { + if (entry == null) { + throw new FcliSimpleException("Remediations cache contains a null entry: " + cacheZip); + } + if (StringUtils.isBlank(entry.getPath())) { + throw new FcliSimpleException("Remediations cache entry is missing path: " + cacheZip); + } + if (StringUtils.isBlank(entry.getSha256())) { + throw new FcliSimpleException("Remediations cache entry is missing sha256: " + entry.getPath()); + } + } + } + + private static Map extractAll(Path cacheZip, Path extractDir) throws IOException { + Map extracted = new HashMap<>(); + try (InputStream fis = Files.newInputStream(cacheZip); ZipInputStream zis = new ZipInputStream(fis)) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (entry.isDirectory()) { + continue; + } + String name = normalizeZipPath(entry.getName()); + if (name.contains("..")) { + throw new FcliSimpleException("Remediations cache contains unsafe path: " + entry.getName()); + } + Path out = extractDir.resolve(name).normalize(); + if (!out.startsWith(extractDir)) { + throw new FcliSimpleException("Remediations cache contains path outside extract dir: " + entry.getName()); + } + Files.createDirectories(out.getParent()); + Files.copy(zis, out); + extracted.put(name, out); + } + } + return extracted; + } + + private static String normalizeZipPath(String path) { + return path.replace('\\', '/'); + } + + private static void deleteRecursivelyQuietly(Path root) { + if (root == null || !Files.exists(root)) { + return; + } + try (var walk = Files.walk(root)) { + walk.sorted(Comparator.reverseOrder()).forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException ignored) { + // best effort + } + }); + } catch (IOException ignored) { + // best effort + } + } +} 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..e8d8d7be23d --- /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.FcliSimpleException; + +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 FcliSimpleException("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 FcliSimpleException("SHA-256 algorithm not available", e); + } catch (IOException e) { + throw new FcliSimpleException("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..16aeeaaeb50 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheWriter.java @@ -0,0 +1,139 @@ +/* + * 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.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.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.json.JsonHelper; + +public final class RemediationsCacheWriter { + private RemediationsCacheWriter() {} + + /** + * Builds a remediations cache zip at {@code destination} from already-downloaded FPR files. + * + * @param destination cache zip path + * @param product {@link RemediationsCacheConstants#PRODUCT_SSC} or {@link RemediationsCacheConstants#PRODUCT_FOD} + * @param selection informational selection metadata + * @param fprSources ordered FPR sources to pack + */ + public static RemediationsCacheManifest write( + Path destination, + String product, + Map selection, + List fprSources) { + if (fprSources == null || fprSources.isEmpty()) { + throw new FcliSimpleException("Cannot create remediations cache: no FPR files to include"); + } + if (destination == null) { + throw new FcliSimpleException("-f/--file must specify a remediations cache zip path"); + } + + Path parent = destination.toAbsolutePath().getParent(); + if (parent != null) { + try { + Files.createDirectories(parent); + } catch (IOException e) { + throw new FcliSimpleException("Failed to create parent directory for " + destination, e); + } + } + + 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); + } + + Path tempZip = null; + try { + tempZip = Files.createTempFile("remediations-cache-", ".zip"); + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(tempZip))) { + for (int i = 0; i < fprSources.size(); i++) { + FprSource source = fprSources.get(i); + int order = i + 1; + String entryPath = entryPath(source, order); + String sha256 = RemediationsCacheSha256.hashFile(source.path()); + + ZipEntry fprEntry = new ZipEntry(entryPath); + zos.putNextEntry(fprEntry); + Files.copy(source.path(), zos); + zos.closeEntry(); + + RemediationsCacheEntry entry = new RemediationsCacheEntry(); + entry.setOrder(order); + entry.setArtifactId(source.artifactId()); + entry.setReleaseId(source.releaseId()); + entry.setUploadDate(source.uploadDate()); + entry.setPath(entryPath); + entry.setSha256(sha256); + manifest.getEntries().add(entry); + } + + ZipEntry manifestEntry = new ZipEntry(RemediationsCacheConstants.MANIFEST_ENTRY); + zos.putNextEntry(manifestEntry); + byte[] manifestBytes = JsonHelper.getObjectMapper().writerWithDefaultPrettyPrinter() + .writeValueAsBytes(manifest); + zos.write(manifestBytes); + zos.closeEntry(); + } + + Files.move(tempZip, destination, StandardCopyOption.REPLACE_EXISTING); + tempZip = null; + return manifest; + } catch (IOException e) { + throw new FcliSimpleException("Failed to write remediations cache to " + destination, e); + } finally { + if (tempZip != null) { + try { + Files.deleteIfExists(tempZip); + } catch (IOException ignored) { + // best effort + } + } + } + } + + private static String entryPath(FprSource source, int order) { + if (source.artifactId() != null && !source.artifactId().isBlank()) { + return String.format("%s/%03d_artifact_%s.fpr", + RemediationsCacheConstants.FPRS_DIR, order, source.artifactId()); + } + if (source.releaseId() != null && !source.releaseId().isBlank()) { + return String.format("%s/%03d_release_%s.fpr", + RemediationsCacheConstants.FPRS_DIR, order, source.releaseId()); + } + return String.format("%s/%03d_remediations.fpr", RemediationsCacheConstants.FPRS_DIR, order); + } + + public record FprSource(Path path, String artifactId, String releaseId, String uploadDate) { + public static FprSource forSsc(Path path, String artifactId, String uploadDate) { + return new FprSource(path, artifactId, null, uploadDate); + } + + public static FprSource forFod(Path path, String releaseId) { + return new FprSource(path, null, releaseId, null); + } + } +} 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..6fe0871565b --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorLocalFprHelper.java @@ -0,0 +1,61 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.util; + +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.FcliSimpleException; + +public final class AviatorLocalFprHelper { + private AviatorLocalFprHelper() {} + + public static void validateLocalFprs(List fprPaths) { + validateLocalFprs(fprPaths, "FPR file"); + } + + public static void validateLocalFprs(List fprPaths, String sourceLabel) { + if (fprPaths == null || fprPaths.isEmpty()) { + throw new FcliSimpleException(sourceLabel + " list must contain at least one FPR file"); + } + for (Path fprPath : fprPaths) { + validateLocalFpr(fprPath, sourceLabel); + } + } + + private static void validateLocalFpr(Path fprPath, String sourceLabel) { + if (fprPath == null) { + throw new FcliSimpleException(sourceLabel + " path must be a valid FPR file path"); + } + if (!Files.exists(fprPath)) { + throw new FcliSimpleException(sourceLabel + " does not exist: " + fprPath); + } + if (!Files.isRegularFile(fprPath)) { + throw new FcliSimpleException(sourceLabel + " is not a regular file: " + fprPath); + } + if (!Files.isReadable(fprPath)) { + throw new FcliSimpleException(sourceLabel + " is not readable: " + fprPath); + } + try (FprHandle fprHandle = new FprHandle(fprPath)) { + fprHandle.validate(); + } catch (FcliSimpleException e) { + throw e; + } catch (RuntimeException e) { + throw new FcliSimpleException(sourceLabel + " is not a valid audited SAST FPR: " + fprPath, e); + } catch (java.io.IOException e) { + throw new FcliSimpleException("Failed to close " + sourceLabel + ": " + fprPath, e); + } + } +} 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..aead3582ebe 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,58 @@ 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); } - + // Log requested issue IDs that were not found in any remediation entries + for (String notFoundIssueId : processingState.getRequestedButNotApplied()) { + logger.debug("Requested issue ID '{}' was not found in any remediation entries in remediations.xml", notFoundIssueId); + } + 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 +111,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 +263,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..8eda2500140 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheRoundTripTest.java @@ -0,0 +1,113 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._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.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.ZipOutputStream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +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( + RemediationsCacheWriter.FprSource.forSsc(fpr1, "123", "2026-07-10T08:00:00Z"), + RemediationsCacheWriter.FprSource.forSsc(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).getArtifactId()); + assertEquals("456", reader.getManifest().getEntries().get(1).getArtifactId()); + } + } + + @Test + void emptySourcesRejected() { + Path zip = tempDir.resolve("empty.zip"); + assertThrows(FcliSimpleException.class, () -> + RemediationsCacheWriter.write(zip, RemediationsCacheConstants.PRODUCT_SSC, Map.of(), List.of())); + } + + @Test + void badSha256Rejected() 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(RemediationsCacheWriter.FprSource.forSsc(fpr, "1", null))); + + // Rebuild zip with same manifest but corrupted FPR content + Path corruptZip = tempDir.resolve("corrupt.zip"); + try (var zis = new java.util.zip.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)) { + // open validates + } + }); + } + + @Test + void missingManifestRejected() 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, () -> RemediationsCacheReader.open(zip)); + assertTrue(ex.getMessage().contains("manifest.json")); + } +} 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/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..44f14d7cce8 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 @@ -16,6 +16,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Collection; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; @@ -25,20 +27,27 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fortify.cli.aviator._common.exception.AviatorSimpleException; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheEntry; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheReader; +import com.fortify.cli.aviator._common.util.AviatorIssueIdFilterUtils; +import com.fortify.cli.aviator._common.util.AviatorLocalFprHelper; import com.fortify.cli.aviator.applyRemediation.ApplyAutoRemediationOnSource; import com.fortify.cli.aviator.config.AviatorLoggerImpl; -import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCApplyRemediationsArtifactSelectorMixin; +import com.fortify.cli.aviator.fpr.processor.RemediationProcessor.RemediationMetric; +import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCApplyRemediationsSourceMixin; import com.fortify.cli.aviator.ssc.helper.AviatorSSCApplyRemediationsHelper; import com.fortify.cli.aviator.ssc.helper.SinceOptionHelper; import com.fortify.cli.aviator.util.FprHandle; import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.output.cli.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.cli.mixin.SSCUnirestInstanceSupplierMixin; 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; @@ -52,26 +61,35 @@ 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 { @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") private String sourceCodeDirectory = System.getProperty("user.dir"); + @Option(names = {"--issue-ids"}, split = ",", descriptionKey = "fcli.aviator.ssc.apply-remediations.issue-ids") + 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 = getIssueIdFilter(); + validateIssueIdFilterMode(); try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); - ArtifactProcessor processor = new ArtifactProcessor(unirest, logger, progressWriter); - - if (artifactSelector.isAllOpenIssuesSelected()) { + if (sourceSelector.isFromCacheSelected()) { + return processFromCache(logger, issueIdFilter); + } + UnirestInstance unirest = unirestInstanceSupplier.getUnirestInstance(); + ArtifactProcessor processor = new ArtifactProcessor(unirest, logger, progressWriter, issueIdFilter); + OffsetDateTime sinceDate = SinceOptionHelper.parse(sourceSelector.getSince()); + if (sourceSelector.isAllSelected()) { return processor.processAllAviatorArtifacts(sinceDate); } SSCArtifactDescriptor ad = resolveArtifactDescriptor(unirest, sinceDate); @@ -79,17 +97,82 @@ public JsonNode getJsonNode(UnirestInstance unirest) { } } - private SSCArtifactDescriptor resolveArtifactDescriptor(UnirestInstance unirest, OffsetDateTime sinceDate) { - if (artifactSelector.isLatestSelected()) { - return getLatestAviatorArtifact(unirest, sinceDate); - } else { - return SSCArtifactHelper.getArtifactDescriptor(unirest, artifactSelector.getArtifactId()); + @SneakyThrows + private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdFilter) { + Path cacheZip = sourceSelector.getFromCache(); + try (RemediationsCacheReader cacheReader = RemediationsCacheReader.open(cacheZip)) { + List fprPaths = cacheReader.getOrderedFprPaths(); + List allEntryPaths = orderedEntryPaths(cacheReader); + List allArtifactIds = orderedArtifactIds(cacheReader); + String appVersionId = cacheReader.getManifest().getSelection() != null + ? cacheReader.getManifest().getSelection().get("appVersionId") + : null; + + AviatorLocalFprHelper.validateLocalFprs(fprPaths, "Cache FPR"); + List metrics = new ArrayList<>(); + List processedEntries = new ArrayList<>(); + List processedArtifactIds = new ArrayList<>(); + int skipped = 0; + Set remaining = issueIdFilter == null ? null : new LinkedHashSet<>(issueIdFilter); + + for (int i = 0; i < fprPaths.size(); i++) { + if (remaining != null && remaining.isEmpty()) { + break; + } + Path fprPath = fprPaths.get(i); + String entryLabel = i < allEntryPaths.size() ? allEntryPaths.get(i) : fprPath.getFileName().toString(); + logger.progress("Processing FPR " + (i + 1) + "/" + fprPaths.size() + " (" + entryLabel + ")"); + logger.progress("Status: Processing FPR with Aviator for Applying Auto Remediations"); + try (FprHandle fprHandle = new FprHandle(fprPath)) { + RemediationMetric metric = ApplyAutoRemediationOnSource.applyRemediations(fprHandle, sourceCodeDirectory, logger, remaining); + metrics.add(metric); + processedEntries.add(entryLabel); + processedArtifactIds.add(i < allArtifactIds.size() ? allArtifactIds.get(i) : ""); + remaining = getRemainingIssueIds(remaining, metric); + } catch (AviatorSimpleException e) { + LOG.warn("Skipping cache entry {} as {}", entryLabel, e.getMessage()); + skipped++; + } + } + + RemediationMetric aggregatedMetric = aggregateMetrics(issueIdFilter, metrics); + String action = aggregatedMetric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; + return AviatorSSCApplyRemediationsHelper.buildCacheResultNode( + new AviatorSSCApplyRemediationsHelper.CacheResultData( + cacheZip, + List.copyOf(processedEntries), + List.copyOf(processedArtifactIds), + appVersionId, + metrics.size(), + skipped, + aggregatedMetric.totalRemediations(), + aggregatedMetric.appliedRemediations(), + aggregatedMetric.skippedRemediations(), + aggregatedMetric.modifiedFiles(), + action)); } } - private SSCArtifactDescriptor getLatestAviatorArtifact(UnirestInstance unirest, OffsetDateTime sinceDate) { - String appVersionId = artifactSelector.getAppVersionId(unirest); - return SSCArtifactHelper.getLatestAviatorArtifact(unirest, appVersionId, sinceDate); + private static List orderedEntryPaths(RemediationsCacheReader cacheReader) { + return cacheReader.getManifest().getEntries().stream() + .sorted(java.util.Comparator.comparingInt(RemediationsCacheEntry::getOrder)) + .map(RemediationsCacheEntry::getPath) + .toList(); + } + + private static List orderedArtifactIds(RemediationsCacheReader cacheReader) { + return cacheReader.getManifest().getEntries().stream() + .sorted(java.util.Comparator.comparingInt(RemediationsCacheEntry::getOrder)) + .map(e -> e.getArtifactId() != null ? e.getArtifactId() : "") + .toList(); + } + + private SSCArtifactDescriptor resolveArtifactDescriptor(UnirestInstance unirest, OffsetDateTime sinceDate) { + if (sourceSelector.isLatestSelected()) { + String appVersionId = sourceSelector.getAppVersionId(unirest); + return SSCArtifactHelper.getLatestAviatorArtifact(unirest, appVersionId, sinceDate); + } + return SSCArtifactHelper.getArtifactDescriptor(unirest, sourceSelector.getArtifactId()); } private void validateSourceCodeDirectory() { @@ -98,57 +181,113 @@ private void validateSourceCodeDirectory() { } } - /** - * Inner class to encapsulate artifact processing logic, avoiding the need to pass - * unirest, logger, and progressWriter through multiple method calls. - */ + private Set getIssueIdFilter() { + return AviatorIssueIdFilterUtils.normalizeIssueIds(issueIds); + } + + private void validateIssueIdFilterMode() { + if (issueIds != null && !issueIds.isEmpty() && !sourceSelector.isFromCacheSelected()) { + throw new FcliSimpleException( + "--issue-ids can only be used with --from-cache; create a cache with download-remediations-cache and rerun with --from-cache"); + } + } + + 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); + } + + 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; + } + @RequiredArgsConstructor private class ArtifactProcessor { private final UnirestInstance unirest; private final AviatorLoggerImpl logger; private final IProgressWriter progressWriter; + private final Set issueIdFilter; @SneakyThrows JsonNode processAllAviatorArtifacts(OffsetDateTime sinceDate) { - String appVersionId = artifactSelector.getAppVersionId(unirest); + String appVersionId = sourceSelector.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<>(); + ArtifactBatchResult batchResult = processBatchOfArtifacts(artifacts); + RemediationMetric aggregatedMetric = aggregateMetrics(issueIdFilter, batchResult.metrics()); + String action = aggregatedMetric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; + return AviatorSSCApplyRemediationsHelper.buildAggregatedResultNode( + appVersionId, batchResult.processed(), batchResult.skipped(), + aggregatedMetric.totalRemediations(), aggregatedMetric.appliedRemediations(), + aggregatedMetric.skippedRemediations(), aggregatedMetric.modifiedFiles(), action); + } - 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); - } - } + private ArtifactBatchResult processBatchOfArtifacts(List artifacts) { + int processed = 0, skipped = 0; + List metrics = new java.util.ArrayList<>(); + Set remaining = issueIdFilter == null ? null : new LinkedHashSet<>(issueIdFilter); + + for (int i = 0; i < artifacts.size(); i++) { + if (remaining != null && remaining.isEmpty()) { + break; + } + int artifactIndex = i + 1; + SSCArtifactDescriptor ad = artifacts.get(i); + + ArtifactProcessResult result = processSingleArtifact(ad, artifactIndex, artifacts.size(), remaining); + if (result.isSuccess()) { + metrics.add(result.metric()); + remaining = getRemainingIssueIds(remaining, result.metric()); + processed++; + } else { + skipped++; } } + return new ArtifactBatchResult(processed, skipped, metrics); + } - String action = appliedRemediations > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; - return AviatorSSCApplyRemediationsHelper.buildAggregatedResultNode( - appVersionId, artifactsProcessed, artifactsSkipped, - totalRemediations, appliedRemediations, skippedRemediations, allModifiedFiles, action); + @SneakyThrows + private ArtifactProcessResult processSingleArtifact(SSCArtifactDescriptor ad, int index, int total, Set issueFilter) { + logger.progress("Processing artifact " + index + "/" + total + " (id=" + ad.getId() + ")"); + Path fprPath = null; + try { + fprPath = downloadArtifactFpr(ad); + try (FprHandle fprHandle = new FprHandle(fprPath)) { + RemediationMetric metric = ApplyAutoRemediationOnSource.applyRemediations(fprHandle, sourceCodeDirectory, logger, issueFilter); + return ArtifactProcessResult.success(metric); + } + } catch (AviatorSimpleException e) { + LOG.warn("Skipping artifact {} as {}", ad.getId(), e.getMessage()); + return ArtifactProcessResult.failure(); + } finally { + if (fprPath != null) { + try { + Files.deleteIfExists(fprPath); + } catch (IOException e) { + LOG.warn("Failed to delete temporary FPR file: {}", fprPath, e); + } + } + } } @SneakyThrows @@ -170,9 +309,11 @@ JsonNode processFprRemediations(SSCArtifactDescriptor 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); + var remediationMetric = ApplyAutoRemediationOnSource.applyRemediations(fprHandle, sourceCodeDirectory, logger, issueIdFilter); String status = remediationMetric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; - return AviatorSSCApplyRemediationsHelper.buildResultNode(ad, remediationMetric.totalRemediations(), remediationMetric.appliedRemediations(), remediationMetric.skippedRemediations(), remediationMetric.modifiedFiles(), status); + return AviatorSSCApplyRemediationsHelper.buildResultNode(ad, remediationMetric.totalRemediations(), + remediationMetric.appliedRemediations(), remediationMetric.skippedRemediations(), + remediationMetric.modifiedFiles(), status); } } finally { try { @@ -185,7 +326,9 @@ JsonNode processFprRemediations(SSCArtifactDescriptor ad) { } @Override - public boolean isSingular() { return true; } + public boolean isSingular() { + return true; + } @Override public String getActionCommandResult() { @@ -196,4 +339,28 @@ public String getActionCommandResult() { public JsonNode transformRecord(JsonNode record) { return record; } + + private record ArtifactBatchResult(int processed, int skipped, List metrics) {} + + private sealed interface ArtifactProcessResult { + static ArtifactProcessResult success(RemediationMetric metric) { + return new Success(metric); + } + + static ArtifactProcessResult failure() { + return new Failure(); + } + + record Success(RemediationMetric metric) implements ArtifactProcessResult {} + + record Failure() implements ArtifactProcessResult {} + + default boolean isSuccess() { + return this instanceof Success; + } + + default RemediationMetric metric() { + return ((Success) this).metric(); + } + } } 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..3098febb530 --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsCacheCommand.java @@ -0,0 +1,169 @@ +/* + * 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.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +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._common.remediations_cache.RemediationsCacheWriter.FprSource; +import com.fortify.cli.aviator.config.AviatorLoggerImpl; +import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCRemediationsCacheDownloadSelectorMixin; +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 com.fortify.cli.ssc.artifact.helper.SSCArtifactHelper; + +import kong.unirest.UnirestInstance; +import lombok.Getter; +import lombok.SneakyThrows; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command(name = "download-remediations-cache") +public class AviatorSSCDownloadRemediationsCacheCommand extends AbstractSSCJsonNodeOutputCommand implements IActionCommandResultSupplier { + private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCDownloadRemediationsCacheCommand.class); + + @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 = "", + descriptionKey = "fcli.aviator.ssc.download-remediations-cache.file") + private File outputFile; + + @Override + @SneakyThrows + public JsonNode getJsonNode(UnirestInstance unirest) { + artifactSelector.validate(); + Path destination = outputFile.toPath(); + if (Files.exists(destination)) { + requireConfirmation.checkConfirmed(destination); + } + + OffsetDateTime sinceDate = SinceOptionHelper.parse(artifactSelector.getSince()); + List tempFiles = new ArrayList<>(); + try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { + AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); + List artifacts = resolveArtifacts(unirest, sinceDate); + List sources = new ArrayList<>(); + for (SSCArtifactDescriptor artifact : artifacts) { + Path tempFpr = Files.createTempFile("aviator-cache-" + artifact.getId() + "-", ".fpr"); + tempFiles.add(tempFpr); + downloadArtifact(unirest, artifact, tempFpr, logger, progressWriter); + sources.add(FprSource.forSsc(tempFpr, artifact.getId(), artifact.getUploadDate())); + } + + Map selection = buildSelectionMetadata(unirest, sinceDate); + RemediationsCacheManifest manifest = RemediationsCacheWriter.write( + destination, RemediationsCacheConstants.PRODUCT_SSC, selection, sources); + return buildResultNode(destination, manifest); + } finally { + for (Path temp : tempFiles) { + try { + Files.deleteIfExists(temp); + } catch (Exception e) { + LOG.warn("Failed to delete temp file: " + temp, e); + } + } + } + } + + private List resolveArtifacts(UnirestInstance unirest, OffsetDateTime sinceDate) { + if (artifactSelector.isAllSelected()) { + String appVersionId = artifactSelector.getAppVersionId(unirest); + return SSCArtifactHelper.getAllAviatorArtifacts(unirest, appVersionId, sinceDate); + } + if (artifactSelector.isLatestSelected()) { + String appVersionId = artifactSelector.getAppVersionId(unirest); + return List.of(SSCArtifactHelper.getLatestAviatorArtifact(unirest, appVersionId, sinceDate)); + } + return List.of(SSCArtifactHelper.requireAviatorArtifact( + SSCArtifactHelper.getArtifactDescriptor(unirest, artifactSelector.getArtifactId()))); + } + + private Map buildSelectionMetadata(UnirestInstance unirest, OffsetDateTime sinceDate) { + Map selection = new LinkedHashMap<>(); + selection.put("mode", artifactSelector.getSelectionMode()); + if (artifactSelector.isArtifactIdSelected()) { + selection.put("artifactId", artifactSelector.getArtifactId()); + } else { + String appVersionId = artifactSelector.getAppVersionId(unirest); + 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.toFile(), + 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.getArtifactId() != null) { + artifactIds.add(entry.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/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..d3c4b1a499f --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCApplyRemediationsSourceMixin.java @@ -0,0 +1,139 @@ +/* + * 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 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; + +/** + * Source selection for apply-remediations: either online SSC selection or a local remediations cache zip. + */ +@Getter +public class AviatorSSCApplyRemediationsSourceMixin { + + @ArgGroup(exclusive = true, multiplicity = "1") + private SourceArgGroup source; + + @Getter + public static class SourceArgGroup { + @ArgGroup(exclusive = false) + private OnlineSource online; + + @Option(names = {"--from-cache"}, required = true, paramLabel = "", + descriptionKey = "fcli.aviator.ssc.apply-remediations.from-cache") + private Path fromCache; + } + + @Getter + public static class OnlineSource { + @ArgGroup(exclusive = true, multiplicity = "1") + private OnlineModeArgGroup mode; + + @Option(names = {"--since"}, descriptionKey = "fcli.aviator.ssc.apply-remediations.since") + private String since; + + @Option(names = {"--appversion", "--av"}, descriptionKey = "fcli.ssc.appversion.resolver.nameOrId") + private String appVersionNameOrId; + + @Option(names = {"--delim"}, defaultValue = ":") + private String delimiter; + } + + @Getter + public static class OnlineModeArgGroup { + @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 all; + } + + 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; + } + + public boolean isArtifactIdSelected() { + return isOnlineSelected() && source.online.mode != null + && StringUtils.isNotBlank(source.online.mode.artifactId); + } + + public boolean isLatestSelected() { + return isOnlineSelected() && source.online.mode != null && source.online.mode.latest; + } + + public boolean isAllSelected() { + return isOnlineSelected() && source.online.mode != null && source.online.mode.all; + } + + public String getArtifactId() { + return isArtifactIdSelected() ? source.online.mode.artifactId : null; + } + + public String getSince() { + return isOnlineSelected() ? source.online.since : null; + } + + public String getAppVersionNameOrId() { + return isOnlineSelected() ? source.online.appVersionNameOrId : null; + } + + public String getAppVersionId(UnirestInstance unirest) { + String appVersionNameOrId = getAppVersionNameOrId(); + if (StringUtils.isBlank(appVersionNameOrId)) { + return null; + } + String delimiter = source.online.delimiter != null ? source.online.delimiter : ":"; + SSCAppVersionDescriptor descriptor = SSCAppVersionHelper.getRequiredAppVersion( + unirest, appVersionNameOrId, delimiter, "id"); + return descriptor.getVersionId(); + } + + public void validate() { + if (isFromCacheSelected()) { + return; + } + if (!isOnlineSelected()) { + throw new FcliSimpleException("Exactly one of --from-cache or online selection (--artifact-id, --latest, --all) must be specified"); + } + if (StringUtils.isNotBlank(getSince()) && isArtifactIdSelected()) { + throw new FcliSimpleException("--since cannot be used with --artifact-id; use --latest or --all"); + } + if ((isLatestSelected() || isAllSelected()) && StringUtils.isBlank(getAppVersionNameOrId())) { + throw new FcliSimpleException("--av/--appversion is required when using --latest or --all"); + } + if (isArtifactIdSelected() && StringUtils.isNotBlank(getAppVersionNameOrId())) { + 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/AviatorSSCApplyRemediationsArtifactSelectorMixin.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsCacheDownloadSelectorMixin.java similarity index 63% rename from fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCApplyRemediationsArtifactSelectorMixin.java rename to fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsCacheDownloadSelectorMixin.java index 622e1f295f5..d9b5e5b8f2b 100644 --- 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/AviatorSSCRemediationsCacheDownloadSelectorMixin.java @@ -23,20 +23,14 @@ 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 { - +public class AviatorSSCRemediationsCacheDownloadSelectorMixin { @ArgGroup(exclusive = true, multiplicity = "1") private ArtifactSelectionArgGroup artifactSelection; - @Option(names = {"--since"}, descriptionKey = "fcli.aviator.ssc.apply-remediations.since") + @Option(names = {"--since"}, descriptionKey = "fcli.aviator.ssc.download-remediations-cache.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; @@ -45,14 +39,14 @@ public class AviatorSSCApplyRemediationsArtifactSelectorMixin { @Getter public static class ArtifactSelectionArgGroup { - @Option(names = {"--artifact-id"}, required = true, descriptionKey = "fcli.aviator.ssc.apply-remediations.artifact-id") + @Option(names = {"--artifact-id"}, required = true, descriptionKey = "fcli.aviator.ssc.download-remediations-cache.artifact-id") private String artifactId; - @Option(names = {"--latest"}, required = true, descriptionKey = "fcli.aviator.ssc.apply-remediations.latest") + @Option(names = {"--latest"}, required = true, descriptionKey = "fcli.aviator.ssc.download-remediations-cache.latest") private boolean latest; - @Option(names = {"--all"}, required = true, descriptionKey = "fcli.aviator.ssc.apply-remediations.all") - private boolean allOpenIssues; + @Option(names = {"--all"}, required = true, descriptionKey = "fcli.aviator.ssc.download-remediations-cache.all") + private boolean all; } public boolean isArtifactIdSelected() { @@ -63,44 +57,45 @@ public boolean isLatestSelected() { return artifactSelection != null && artifactSelection.latest; } - public boolean isAllOpenIssuesSelected() { - return artifactSelection != null && artifactSelection.allOpenIssues; + public boolean isAllSelected() { + return artifactSelection != null && artifactSelection.all; } 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"); + 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"); + public String getSelectionMode() { + if (isArtifactIdSelected()) { + return "artifact-id"; } - - // 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"); + if (isLatestSelected()) { + return "latest"; } + if (isAllSelected()) { + return "all"; + } + return null; + } - // Validate --av is not used with --artifact-id + public void validate() { + if (StringUtils.isNotBlank(since) && isArtifactIdSelected()) { + throw new FcliSimpleException("--since cannot be used with --artifact-id; use --latest or --all"); + } + if ((isLatestSelected() || isAllSelected()) && StringUtils.isBlank(appVersionNameOrId)) { + throw new FcliSimpleException("--av/--appversion is required when using --latest or --all"); + } if (isArtifactIdSelected() && StringUtils.isNotBlank(appVersionNameOrId)) { - throw new FcliSimpleException( - "--av/--appversion cannot be used with --artifact-id"); + 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/helper/AviatorSSCApplyRemediationsHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCApplyRemediationsHelper.java index f0289c3c139..1b501678cc9 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,6 +12,8 @@ */ package com.fortify.cli.aviator.ssc.helper; +import java.nio.file.Path; +import java.util.List; import java.util.Set; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -21,8 +23,7 @@ import com.fortify.cli.ssc.artifact.helper.SSCArtifactDescriptor; /** - * Helper class for the AviatorSSCAuditCommand to encapsulate - * result message formatting and JSON output construction. + * Helper for Aviator SSC apply-remediations result JSON construction. */ public final class AviatorSSCApplyRemediationsHelper { private AviatorSSCApplyRemediationsHelper() {} @@ -30,14 +31,9 @@ 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. */ - public static ObjectNode buildResultNode(SSCArtifactDescriptor ad, int totalRemediation, int appliedRemediation, int skippedRemediation, Set modifiedFiles, String action) { + 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()); @@ -52,16 +48,8 @@ public static ObjectNode buildResultNode(SSCArtifactDescriptor ad, int totalReme } /** - * Builds the unified JSON result node for --all-open-issues, aggregating across all artifacts. + * Builds the unified JSON result node for --all, aggregating across all artifacts. * Uses the same output shape as buildResultNode for consistent table columns. - * @param appVersionId The application version ID processed. - * @param artifactsProcessed Number of artifacts successfully processed. - * @param artifactsSkipped Number of artifacts skipped (e.g. no remediations.xml). - * @param totalRemediation Total remediations across all artifacts. - * @param appliedRemediation Total applied remediations across all artifacts. - * @param skippedRemediation Total skipped remediations across all artifacts. - * @param action Final action result. - * @return An ObjectNode representing the aggregated result. */ public static ObjectNode buildAggregatedResultNode(String appVersionId, int artifactsProcessed, int artifactsSkipped, int totalRemediation, int appliedRemediation, int skippedRemediation, Set modifiedFiles, String action) { @@ -78,6 +66,40 @@ public static ObjectNode buildAggregatedResultNode(String appVersionId, int arti return result; } + /** + * Result shape for --from-cache: durable cache zip path and zip-relative entry paths only + * (never ephemeral extract-dir absolute paths). + */ + public static ObjectNode buildCacheResultNode(CacheResultData resultData) { + ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); + result.put("appVersionId", resultData.appVersionId() != null ? resultData.appVersionId() : "N/A"); + result.put("artifactId", "N/A"); + result.put("file", resultData.cacheZip().toString()); + result.put("artifactsProcessed", resultData.fprsProcessed()); + result.put("artifactsSkipped", resultData.fprsSkipped()); + result.put("totalRemediation", resultData.totalRemediation()); + result.put("appliedRemediation", resultData.appliedRemediation()); + result.put("skippedRemediation", resultData.skippedRemediation()); + result.set("modifiedFiles", toArrayNode(resultData.modifiedFiles())); + result.set("entries", toStringArrayNode(resultData.entryPaths())); + result.set("artifactIds", toStringArrayNode(resultData.artifactIds())); + result.put(IActionCommandResultSupplier.actionFieldName, resultData.action()); + return result; + } + + public record CacheResultData( + Path cacheZip, + List entryPaths, + List artifactIds, + String appVersionId, + int fprsProcessed, + int fprsSkipped, + int totalRemediation, + int appliedRemediation, + int skippedRemediation, + Set modifiedFiles, + String action) {} + private static ArrayNode toArrayNode(Set files) { ArrayNode array = JsonHelper.getObjectMapper().createArrayNode(); if (files != null) { @@ -85,4 +107,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/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..ba6c2db50fa 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,29 @@ 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.artifact-id = Specific artifact ID to process. Mutually exclusive with --latest, --all, and --from-cache. +fcli.aviator.ssc.apply-remediations.latest = Automatically select the most recent Fortify Remediation Aviator-processed artifact. Requires --av/--appversion. Mutually exclusive with --artifact-id, --all, and --from-cache. 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. + Requires --av/--appversion. Mutually exclusive with --artifact-id, --latest, and --from-cache. +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.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. 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. + Can only be used with --latest or --all; not compatible with --artifact-id or --from-cache. + +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.artifact-id = Specific artifact ID to include in the cache. Mutually exclusive with --latest and --all. +fcli.aviator.ssc.download-remediations-cache.latest = Include the most recent Fortify Remediation Aviator-processed artifact. Requires --av/--appversion. Mutually exclusive with --artifact-id and --all. +fcli.aviator.ssc.download-remediations-cache.all = Include 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.download-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.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 +205,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 +240,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..d0b51b3a7de --- /dev/null +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java @@ -0,0 +1,107 @@ +/* + * 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 java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator._common.util.AviatorIssueIdFilterUtils; +import com.fortify.cli.aviator.fpr.processor.RemediationProcessor.RemediationMetric; +import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCApplyRemediationsSourceMixin; +import com.fortify.cli.common.exception.FcliSimpleException; + +import picocli.CommandLine; + +class AviatorSSCApplyRemediationsCommandTest { + @Test + void testNormalizeIssueIdsTrimsAndDeduplicates() throws Exception { + AviatorSSCApplyRemediationsCommand command = parse("--from-cache", "cache.zip", "--issue-ids", " ISSUE-1 , , ISSUE-2,ISSUE-1 "); + assertEquals(Set.of("ISSUE-1", "ISSUE-2"), AviatorIssueIdFilterUtils.normalizeIssueIds(getIssueIds(command))); + } + + @Test + void testNormalizeIssueIdsRejectsOnlyBlankValues() throws Exception { + AviatorSSCApplyRemediationsCommand command = parse("--from-cache", "cache.zip", "--issue-ids", " , , "); + assertThrows(FcliSimpleException.class, () -> AviatorIssueIdFilterUtils.normalizeIssueIds(getIssueIds(command))); + } + + @Test + void testFromCacheParsesPath() throws Exception { + AviatorSSCApplyRemediationsCommand command = parse("--from-cache", "remediations.zip"); + assertEquals(Path.of("remediations.zip"), getSourceMixin(command).getFromCache()); + assertTrue(getSourceMixin(command).isFromCacheSelected()); + } + + @Test + void testFromCacheCannotBeCombinedWithArtifactId() { + assertThrows(CommandLine.ParameterException.class, + () -> parse("--artifact-id", "1", "--from-cache", "cache.zip")); + } + + @Test + void testIssueIdsWithoutFromCacheThrowsException() { + AviatorSSCApplyRemediationsCommand command = parse("--artifact-id", "1", "--issue-ids", "ISSUE-1"); + + assertThrows(FcliSimpleException.class, command::getJsonNode); + } + + @Test + void testAggregateMetricsForFilteredAllDeduplicatesAcrossArtifacts() { + 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 = AviatorSSCApplyRemediationsCommand.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 testGetRemainingIssueIdsRemovesAlreadyAppliedIds() { + RemediationMetric metric = RemediationMetric.filtered(Set.of("ISSUE-1", "ISSUE-2"), Set.of("ISSUE-1"), Set.of("A.java")); + + Set remainingIssueIds = AviatorSSCApplyRemediationsCommand.getRemainingIssueIds(Set.of("ISSUE-1", "ISSUE-2"), metric); + + assertEquals(Set.of("ISSUE-2"), remainingIssueIds); + } + + private static AviatorSSCApplyRemediationsCommand parse(String... args) { + AviatorSSCApplyRemediationsCommand command = new AviatorSSCApplyRemediationsCommand(); + new CommandLine(command).parseArgs(args); + return command; + } + + @SuppressWarnings("unchecked") + private static List getIssueIds(AviatorSSCApplyRemediationsCommand command) throws Exception { + Field field = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("issueIds"); + field.setAccessible(true); + return (List) field.get(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-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..6a073f7aeb7 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,18 +12,31 @@ */ package com.fortify.cli.fod.aviator.cmd; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheReader; +import com.fortify.cli.aviator._common.util.AviatorIssueIdFilterUtils; +import com.fortify.cli.aviator._common.util.AviatorLocalFprHelper; import com.fortify.cli.aviator.applyRemediation.ApplyAutoRemediationOnSource; import com.fortify.cli.aviator.config.AviatorLoggerImpl; +import com.fortify.cli.aviator.fpr.processor.RemediationProcessor.RemediationMetric; 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; @@ -31,38 +44,73 @@ 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.helper.AviatorFoDApplyRemediationsHelper; -import com.fortify.cli.fod.release.cli.mixin.FoDReleaseByQualifiedNameOrIdResolverMixin; import com.fortify.cli.fod.release.helper.FoDReleaseDescriptor; +import com.fortify.cli.fod.release.helper.FoDReleaseHelper; import kong.unirest.GetRequest; import kong.unirest.UnirestInstance; import lombok.Getter; import lombok.SneakyThrows; +import picocli.CommandLine.ArgGroup; import picocli.CommandLine.Command; import picocli.CommandLine.Mixin; import picocli.CommandLine.Option; @Command(name = "apply-remediations") -public class FoDAviatorApplyRemediationsCommand extends AbstractFoDJsonNodeOutputCommand implements IRecordTransformer, IActionCommandResultSupplier { +public class FoDAviatorApplyRemediationsCommand extends AbstractOutputCommand + implements IJsonNodeSupplier, IRecordTransformer, IActionCommandResultSupplier { @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; - @Mixin private FoDDelimiterMixin delimiterMixin; // Is automatically injected in resolver mixins - @Mixin private FoDReleaseByQualifiedNameOrIdResolverMixin.RequiredOption releaseResolver; + @Mixin private FoDDelimiterMixin delimiterMixin; + @Mixin private FoDUnirestInstanceSupplierMixin unirestInstanceSupplier; + private static final Logger LOG = LoggerFactory.getLogger(FoDAviatorApplyRemediationsCommand.class); - @Option(names = {"--source-dir"}) private String sourceCodeDirectory = System.getProperty("user.dir"); - @Override @SneakyThrows - public JsonNode getJsonNode(UnirestInstance unirest) { + @ArgGroup(exclusive = true, multiplicity = "1") + private SourceArgGroup source; + + @Option(names = {"--source-dir"}, descriptionKey = "fcli.fod.aviator.apply-remediations.source-dir") + private String sourceCodeDirectory = System.getProperty("user.dir"); + @Option(names = {"--issue-ids"}, split = ",", descriptionKey = "fcli.fod.aviator.apply-remediations.issue-ids") + private List issueIds; + + @Getter + static class SourceArgGroup { + @ArgGroup(exclusive = false) + private OnlineSource online; + + @Option(names = {"--from-cache"}, required = true, paramLabel = "", + descriptionKey = "fcli.fod.aviator.apply-remediations.from-cache") + private Path fromCache; + } + + @Getter + static class OnlineSource { + @Option(names = {"--release", "--rel"}, required = true, paramLabel = "id|app[:ms]:rel", + descriptionKey = "fcli.fod.release.resolver.name-or-id") + private String qualifiedReleaseNameOrId; + } + + @Override + @SneakyThrows + public JsonNode getJsonNode() { validateSourceCodeDirectory(); + Set issueIdFilter = getIssueIdFilter(); + validateSelection(); try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); - FoDReleaseDescriptor rd = releaseResolver.getReleaseDescriptor(unirest); - return processFprRemediations(unirest, rd, logger); + if (isFromCacheSelected()) { + return processFromCache(logger, issueIdFilter); + } + UnirestInstance unirest = unirestInstanceSupplier.getUnirestInstance(); + FoDReleaseDescriptor rd = FoDReleaseHelper.getReleaseDescriptor( + unirest, source.online.qualifiedReleaseNameOrId, delimiterMixin.getDelimiter(), true); + return processFprRemediations(unirest, rd, logger, issueIdFilter); } } @@ -72,8 +120,107 @@ private void validateSourceCodeDirectory() { } } + private Set getIssueIdFilter() { + return AviatorIssueIdFilterUtils.normalizeIssueIds(issueIds); + } + + private void validateSelection() { + if (issueIds != null && !issueIds.isEmpty() && !isFromCacheSelected()) { + throw new FcliSimpleException( + "--issue-ids can only be used with --from-cache; create a cache with download-remediations-cache and rerun with --from-cache"); + } + } + + private boolean isFromCacheSelected() { + return source != null && source.fromCache != null; + } + + @SneakyThrows + private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdFilter) { + Path cacheZip = source.fromCache; + try (RemediationsCacheReader cacheReader = RemediationsCacheReader.open(cacheZip)) { + List fprPaths = cacheReader.getOrderedFprPaths(); + List allEntryPaths = cacheReader.getManifest().getEntries().stream() + .sorted(java.util.Comparator.comparingInt(e -> e.getOrder())) + .map(e -> e.getPath()) + .toList(); + List allReleaseIds = cacheReader.getManifest().getEntries().stream() + .sorted(java.util.Comparator.comparingInt(e -> e.getOrder())) + .map(e -> e.getReleaseId() != null ? e.getReleaseId() : "") + .toList(); + + AviatorLocalFprHelper.validateLocalFprs(fprPaths, "Cache FPR"); + List metrics = new ArrayList<>(); + List processedEntries = new ArrayList<>(); + List processedReleaseIds = new ArrayList<>(); + Set remaining = issueIdFilter == null ? null : new LinkedHashSet<>(issueIdFilter); + + for (int i = 0; i < fprPaths.size(); i++) { + if (remaining != null && remaining.isEmpty()) { + break; + } + Path fprPath = fprPaths.get(i); + String entryLabel = i < allEntryPaths.size() ? allEntryPaths.get(i) : fprPath.getFileName().toString(); + logger.progress("Processing FPR " + (i + 1) + "/" + fprPaths.size() + " (" + entryLabel + ")"); + logger.progress("Status: Processing FPR with Aviator for Applying Auto Remediations"); + try (FprHandle fprHandle = new FprHandle(fprPath)) { + RemediationMetric metric = ApplyAutoRemediationOnSource.applyRemediations(fprHandle, sourceCodeDirectory, logger, remaining); + metrics.add(metric); + processedEntries.add(entryLabel); + processedReleaseIds.add(i < allReleaseIds.size() ? allReleaseIds.get(i) : ""); + remaining = getRemainingIssueIds(remaining, metric); + } catch (AviatorSimpleException e) { + LOG.warn("Skipping cache entry {} as {}", entryLabel, e.getMessage()); + } + } + + RemediationMetric aggregatedMetric = aggregateMetrics(issueIdFilter, metrics); + String status = aggregatedMetric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; + return AviatorFoDApplyRemediationsHelper.buildCacheResultNode( + new AviatorFoDApplyRemediationsHelper.CacheResultData( + cacheZip, + List.copyOf(processedEntries), + List.copyOf(processedReleaseIds), + aggregatedMetric.totalRemediations(), + aggregatedMetric.appliedRemediations(), + aggregatedMetric.skippedRemediations(), + aggregatedMetric.modifiedFiles(), + status)); + } + } + + 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); + } + + 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; + } + @SneakyThrows - private JsonNode processFprRemediations(UnirestInstance unirest, FoDReleaseDescriptor rd, AviatorLoggerImpl logger) { + private JsonNode processFprRemediations(UnirestInstance unirest, FoDReleaseDescriptor rd, AviatorLoggerImpl logger, + Set issueIdFilter) { Path downloadedFprPath = null; try { logger.progress("Status: Downloading Audited FPR from FOD"); @@ -81,25 +228,27 @@ private JsonNode processFprRemediations(UnirestInstance unirest, FoDReleaseDescr logger.progress("Status: Processing FPR with Aviator for Applying Auto Remediations"); try (FprHandle fprHandle = new FprHandle(downloadedFprPath)) { - var remediationMetric = ApplyAutoRemediationOnSource.applyRemediations(fprHandle, sourceCodeDirectory, logger); + var remediationMetric = ApplyAutoRemediationOnSource.applyRemediations(fprHandle, sourceCodeDirectory, logger, issueIdFilter); 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); + 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); + } catch (IOException e) { + LOG.warn("Failed to delete temporary downloaded FPR file: {}", downloadedFprPath, e); } } } } @SneakyThrows - private Path downloadFprFromFod(UnirestInstance unirest, FoDReleaseDescriptor releaseDescriptor) { + private Path downloadFprFromFod(UnirestInstance unirest, FoDReleaseDescriptor releaseDescriptor) { Path fprPath = Files.createTempFile("aviator_" + releaseDescriptor.getReleaseId() + "_", ".fpr"); FoDScanDescriptor scanDescriptor = FoDScanHelper.getLatestScanDescriptor(unirest, releaseDescriptor.getReleaseId(), getScanType(), false); @@ -107,17 +256,30 @@ private Path downloadFprFromFod(UnirestInstance unirest, FoDReleaseDescriptor r var file = fprPath.toString(); GetRequest request = getDownloadRequest(unirest, releaseDescriptor, scanDescriptor); int status = 202; - while ( status==202 ) { + int retries = 0; + final int maxRetries = 10; + while (status == 202 && retries < maxRetries) { status = request .asFile(file, StandardCopyOption.REPLACE_EXISTING) .getStatus(); - if ( status==202 ) { Thread.sleep(30000L); } + if (status == 202) { + retries++; + Thread.sleep(30000L); + } + } + if (status == 202) { + Files.deleteIfExists(fprPath); + throw new FcliSimpleException("Timed out waiting for FoD remediations FPR download to complete after " + + maxRetries + " retries"); + } + if (status < 200 || status >= 300) { + Files.deleteIfExists(fprPath); + throw new FcliSimpleException("FoD remediations FPR download failed with HTTP status " + status + + " for release " + releaseDescriptor.getReleaseId()); } return fprPath; } - - protected FoDScanType getScanType() { return FoDScanType.Static; } @@ -125,7 +287,6 @@ protected FoDScanType getScanType() { 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()); } @@ -135,7 +296,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..428f2b2750d --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorDownloadRemediationsCacheCommand.java @@ -0,0 +1,144 @@ +/* + * 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.nio.file.StandardCopyOption; +import java.util.LinkedHashMap; +import java.util.List; +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._common.remediations_cache.RemediationsCacheWriter.FprSource; +import com.fortify.cli.common.cli.mixin.CommonOptionMixins; +import com.fortify.cli.common.exception.FcliSimpleException; +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.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.release.cli.mixin.FoDReleaseByQualifiedNameOrIdResolverMixin; +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 = "download-remediations-cache") +public class FoDAviatorDownloadRemediationsCacheCommand extends AbstractFoDJsonNodeOutputCommand implements IActionCommandResultSupplier { + private static final int MAX_RETRIES = 10; + + @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; + @Mixin private FoDDelimiterMixin delimiterMixin; + @Mixin private FoDReleaseByQualifiedNameOrIdResolverMixin.RequiredOption releaseResolver; + @Mixin private CommonOptionMixins.RequireConfirmation requireConfirmation; + + @Option(names = {"-f", "--file"}, required = true, paramLabel = "", + descriptionKey = "fcli.fod.aviator.download-remediations-cache.file") + private File outputFile; + + @Override + @SneakyThrows + public JsonNode getJsonNode(UnirestInstance unirest) { + Path destination = outputFile.toPath(); + if (Files.exists(destination)) { + requireConfirmation.checkConfirmed(destination); + } + + FoDReleaseDescriptor releaseDescriptor = releaseResolver.getReleaseDescriptor(unirest); + Path tempFpr = Files.createTempFile("aviator-cache-" + releaseDescriptor.getReleaseId() + "-", ".fpr"); + try { + downloadFpr(unirest, releaseDescriptor, tempFpr); + Map selection = new LinkedHashMap<>(); + selection.put("mode", "release"); + selection.put("releaseId", releaseDescriptor.getReleaseId()); + RemediationsCacheManifest manifest = RemediationsCacheWriter.write( + destination, + RemediationsCacheConstants.PRODUCT_FOD, + selection, + List.of(FprSource.forFod(tempFpr, releaseDescriptor.getReleaseId()))); + return buildResultNode(destination, releaseDescriptor, manifest); + } finally { + Files.deleteIfExists(tempFpr); + } + } + + @SneakyThrows + private void downloadFpr(UnirestInstance unirest, FoDReleaseDescriptor releaseDescriptor, Path destination) { + FoDScanDescriptor scanDescriptor = FoDScanHelper.getLatestScanDescriptor(unirest, releaseDescriptor.getReleaseId(), + FoDScanType.Static, false); + FoDScanHelper.validateScanDate(scanDescriptor, FoDScanHelper.MAX_RETENTION_PERIOD); + GetRequest request = getDownloadRequest(unirest, releaseDescriptor, scanDescriptor); + + int status = 202; + int retries = 0; + while (status == 202 && retries < MAX_RETRIES) { + status = request + .asFile(destination.toString(), StandardCopyOption.REPLACE_EXISTING) + .getStatus(); + if (status == 202) { + retries++; + Thread.sleep(30000L); + } + } + if (status == 202) { + Files.deleteIfExists(destination); + throw new FcliSimpleException("Timed out waiting for FoD remediations FPR download to complete after " + + MAX_RETRIES + " retries"); + } + if (status < 200 || status >= 300) { + Files.deleteIfExists(destination); + throw new FcliSimpleException("FoD remediations FPR download failed with HTTP status " + status + + " for release " + releaseDescriptor.getReleaseId()); + } + } + + private GetRequest getDownloadRequest(UnirestInstance unirest, FoDReleaseDescriptor releaseDescriptor, FoDScanDescriptor scanDescriptor) { + return unirest.get("/api/v3/releases/{releaseId}/fpr") + .routeParam("releaseId", releaseDescriptor.getReleaseId()) + .headerReplace(HttpHeader.ACCEPT, "application/octet-stream") + .queryString("scanType", scanDescriptor.getScanType()); + } + + 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..4d0fa0932d7 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,6 +12,8 @@ */ 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; @@ -20,21 +22,14 @@ import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; import com.fortify.cli.fod.release.helper.FoDReleaseDescriptor; -public class AviatorFoDApplyRemediationsHelper { - public AviatorFoDApplyRemediationsHelper(){} - - - /** - * Builds the final JSON result node for the command output. - * @param rd The 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. - */ +/** + * Helper for FoD apply-remediations result JSON construction. + */ +public final class AviatorFoDApplyRemediationsHelper { + private AviatorFoDApplyRemediationsHelper() {} - public static ObjectNode buildResultNode(FoDReleaseDescriptor rd, int totalRemediation, int appliedRemediation, int skippedRemediation, String action) { + 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()); @@ -46,7 +41,8 @@ public static ObjectNode buildResultNode(FoDReleaseDescriptor rd, int totalRemed return result; } - public static ObjectNode buildResultNode(FoDReleaseDescriptor rd, int totalRemediation, int appliedRemediation, int skippedRemediation, Set modifiedFiles, String action) { + 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()); @@ -59,6 +55,36 @@ public static ObjectNode buildResultNode(FoDReleaseDescriptor rd, int totalRemed return result; } + /** + * Result shape for --from-cache: durable cache zip path and zip-relative entry paths only + * (never ephemeral extract-dir absolute paths). + */ + public static ObjectNode buildCacheResultNode(CacheResultData resultData) { + ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); + result.put("releaseId", resultData.releaseIds() != null && !resultData.releaseIds().isEmpty() ? resultData.releaseIds().get(0) : "N/A"); + result.put("applicationName", "N/A"); + result.put("releaseName", "N/A"); + result.put("file", resultData.cacheZip().toString()); + result.put("totalRemediation", resultData.totalRemediation()); + result.put("appliedRemediation", resultData.appliedRemediation()); + result.put("skippedRemediation", resultData.skippedRemediation()); + result.set("modifiedFiles", toArrayNode(resultData.modifiedFiles())); + result.set("entries", toStringArrayNode(resultData.entryPaths())); + result.set("releaseIds", toStringArrayNode(resultData.releaseIds())); + result.put(IActionCommandResultSupplier.actionFieldName, resultData.action()); + return result; + } + + public record CacheResultData( + Path cacheZip, + List entryPaths, + List releaseIds, + int totalRemediation, + int appliedRemediation, + int skippedRemediation, + Set modifiedFiles, + String action) {} + private static ArrayNode toArrayNode(Set files) { ArrayNode array = JsonHelper.getObjectMapper().createArrayNode(); if (files != null) { @@ -66,4 +92,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/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..5e599c3f23c 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,15 +12,24 @@ */ 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.assertNotNull; +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 java.util.List; +import java.util.Set; import org.junit.jupiter.api.Test; +import com.fortify.cli.aviator._common.util.AviatorIssueIdFilterUtils; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.fod.aviator.cmd.FoDAviatorApplyRemediationsCommand; +import picocli.CommandLine; + class FoDAviatorApplyRemediationsCommandTest { @Test void testSourceCodeDirectoryHasDefaultValue() throws Exception { @@ -55,13 +64,76 @@ void testSourceCodeDirectoryCanBeOverridden() throws Exception { @Test void testBlankSourceCodeDirectoryThrowsException() throws Exception { - FoDAviatorApplyRemediationsCommand command = new FoDAviatorApplyRemediationsCommand(); + FoDAviatorApplyRemediationsCommand command = parse("--from-cache", "cache.zip"); Field field = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("sourceCodeDirectory"); field.setAccessible(true); field.set(command, ""); - assertThrows(FcliSimpleException.class, () -> command.getJsonNode(null), + assertThrows(FcliSimpleException.class, command::getJsonNode, "Blank sourceCodeDirectory should throw FcliSimpleException"); } + + @Test + void testNormalizeIssueIdsTrimsAndDeduplicates() throws Exception { + FoDAviatorApplyRemediationsCommand command = parse("--from-cache", "cache.zip", "--issue-ids", " ISSUE-1 , , ISSUE-2,ISSUE-1 "); + assertEquals(Set.of("ISSUE-1", "ISSUE-2"), AviatorIssueIdFilterUtils.normalizeIssueIds(getIssueIds(command))); + } + + @Test + void testNormalizeIssueIdsRejectsOnlyBlankValues() throws Exception { + FoDAviatorApplyRemediationsCommand command = parse("--from-cache", "cache.zip", "--issue-ids", " , , "); + assertThrows(FcliSimpleException.class, () -> AviatorIssueIdFilterUtils.normalizeIssueIds(getIssueIds(command))); + } + + @Test + void testIssueIdsOptionParsesIntoField() throws Exception { + FoDAviatorApplyRemediationsCommand command = parse("--from-cache", "cache.zip", "--issue-ids", "ISSUE-1,ISSUE-2"); + Field field = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("issueIds"); + field.setAccessible(true); + assertEquals(List.of("ISSUE-1", "ISSUE-2"), field.get(command)); + } + + @Test + void testFromCacheParsesPath() throws Exception { + FoDAviatorApplyRemediationsCommand command = parse("--from-cache", "remediations.zip"); + Field sourceField = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("source"); + sourceField.setAccessible(true); + Object source = sourceField.get(command); + Field fromCacheField = source.getClass().getDeclaredField("fromCache"); + fromCacheField.setAccessible(true); + assertEquals(Path.of("remediations.zip"), fromCacheField.get(source)); + } + + @Test + void testReleaseAndFromCacheTogetherThrowsException() { + assertThrows(CommandLine.ParameterException.class, + () -> parse("--release", "1", "--from-cache", "local.zip")); + } + + @Test + void testIssueIdsWithoutFromCacheThrowsException() { + FoDAviatorApplyRemediationsCommand command = parse("--release", "1", "--issue-ids", "ISSUE-1"); + + assertThrows(FcliSimpleException.class, command::getJsonNode); + } + + @Test + void testOnlineReleaseSelectionParses() { + FoDAviatorApplyRemediationsCommand command = parse("--release", "1"); + assertTrue(command != null); + } + + private static FoDAviatorApplyRemediationsCommand parse(String... args) { + FoDAviatorApplyRemediationsCommand command = new FoDAviatorApplyRemediationsCommand(); + new CommandLine(command).parseArgs(args); + return command; + } + + @SuppressWarnings("unchecked") + private static List getIssueIds(FoDAviatorApplyRemediationsCommand command) throws Exception { + Field field = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("issueIds"); + field.setAccessible(true); + return (List) field.get(command); + } } diff --git a/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorDownloadRemediationsCacheCommandTest.java b/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorDownloadRemediationsCacheCommandTest.java new file mode 100644 index 00000000000..e771801a853 --- /dev/null +++ b/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorDownloadRemediationsCacheCommandTest.java @@ -0,0 +1,46 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.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 testReleaseIsRequired() { + FoDAviatorDownloadRemediationsCacheCommand command = new FoDAviatorDownloadRemediationsCacheCommand(); + + assertThrows(CommandLine.ParameterException.class, + () -> new CommandLine(command).parseArgs("-f", "cache.zip")); + } + + @Test + void testFileIsRequired() { + FoDAviatorDownloadRemediationsCacheCommand command = new FoDAviatorDownloadRemediationsCacheCommand(); + + assertThrows(CommandLine.ParameterException.class, + () -> new CommandLine(command).parseArgs("--release", "1")); + } + + @Test + void testReleaseWithFileOptionParses() { + FoDAviatorDownloadRemediationsCacheCommand command = new FoDAviatorDownloadRemediationsCacheCommand(); + + new CommandLine(command).parseArgs("--release", "1", "-f", "remediations.zip"); + } +} 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