From 2c1ce22184f4f2f42ebcf937b398ee3a4044a09c Mon Sep 17 00:00:00 2001 From: kireetivar Date: Thu, 2 Jul 2026 14:22:18 +0530 Subject: [PATCH 01/18] feat: add --issue-ids filtering to Aviator SSC/FoD apply-remediations --- .../util/AviatorIssueIdFilterUtils.java | 39 ++ .../ApplyAutoRemediationOnSource.java | 10 +- .../fpr/processor/RemediationProcessor.java | 338 +++++++++++------- .../processor/RemediationProcessorTest.java | 242 +++++++++++++ .../AviatorSSCApplyRemediationsCommand.java | 152 ++++++-- .../aviator/i18n/AviatorMessages.properties | 5 + ...viatorSSCApplyRemediationsCommandTest.java | 78 ++++ .../FoDAviatorApplyRemediationsCommand.java | 22 +- .../cli/fod/i18n/FoDMessages.properties | 2 + ...oDAviatorApplyRemediationsCommandTest.java | 46 ++- 10 files changed, 770 insertions(+), 164 deletions(-) create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorIssueIdFilterUtils.java create mode 100644 fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorTest.java create mode 100644 fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java 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/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/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..30473d34075 --- /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..7e447e26068 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,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.OffsetDateTime; +import java.util.Collection; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; @@ -25,8 +26,10 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fortify.cli.aviator._common.exception.AviatorSimpleException; +import com.fortify.cli.aviator._common.util.AviatorIssueIdFilterUtils; 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.ssc.cli.mixin.AviatorSSCApplyRemediationsArtifactSelectorMixin; import com.fortify.cli.aviator.ssc.helper.AviatorSSCApplyRemediationsHelper; import com.fortify.cli.aviator.ssc.helper.SinceOptionHelper; @@ -60,16 +63,19 @@ public class AviatorSSCApplyRemediationsCommand extends AbstractSSCJsonNodeOutpu 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(); validateSourceCodeDirectory(); + Set issueIdFilter = getIssueIdFilter(); OffsetDateTime sinceDate = SinceOptionHelper.parse(artifactSelector.getSince()); try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); - ArtifactProcessor processor = new ArtifactProcessor(unirest, logger, progressWriter); + ArtifactProcessor processor = new ArtifactProcessor(unirest, logger, progressWriter, issueIdFilter); if (artifactSelector.isAllOpenIssuesSelected()) { return processor.processAllAviatorArtifacts(sinceDate); @@ -98,6 +104,39 @@ private void validateSourceCodeDirectory() { } } + private Set getIssueIdFilter() { + return AviatorIssueIdFilterUtils.normalizeIssueIds(issueIds); + } + + 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; + } + /** * Inner class to encapsulate artifact processing logic, avoiding the need to pass * unirest, logger, and progressWriter through multiple method calls. @@ -107,48 +146,76 @@ 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); List artifacts = SSCArtifactHelper.getAllAviatorArtifacts(unirest, appVersionId, sinceDate); - - int totalRemediations = 0, appliedRemediations = 0, skippedRemediations = 0; - int artifactsProcessed = 0, artifactsSkipped = 0; - Set allModifiedFiles = new LinkedHashSet<>(); - - for (SSCArtifactDescriptor ad : artifacts) { - int artifactIndex = artifactsProcessed + artifactsSkipped + 1; - logger.progress("Processing artifact " + artifactIndex + "/" + artifacts.size() + " (id=" + ad.getId() + ")"); - Path fprPath = null; + + 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); + } + + 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 (shouldStopProcessing(remaining)) { + 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); + } + + @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 { + cleanupFprFile(fprPath); + } + } + + private void cleanupFprFile(Path fprPath) { + if (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); - } - } + Files.deleteIfExists(fprPath); + } catch (IOException e) { + LOG.warn("Failed to delete temporary FPR file: {}", fprPath, e); } } - - String action = appliedRemediations > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; - return AviatorSSCApplyRemediationsHelper.buildAggregatedResultNode( - appVersionId, artifactsProcessed, artifactsSkipped, - totalRemediations, appliedRemediations, skippedRemediations, allModifiedFiles, action); + } + + private boolean shouldStopProcessing(Set remaining) { + return remaining != null && remaining.isEmpty(); } @SneakyThrows @@ -170,7 +237,7 @@ 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); } @@ -196,4 +263,17 @@ 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/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..9e6099bb036 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 @@ -156,6 +156,8 @@ fcli.aviator.ssc.apply-remediations.all = Apply remediations from all Fortify Re in chronological order. Aggregates remediation statistics across all artifacts. \ Requires --av/--appversion. Mutually exclusive with --artifact-id and --latest. fcli.aviator.ssc.apply-remediations.source-dir = Source code directory where remediations will be applied. Defaults to current directory. +fcli.aviator.ssc.apply-remediations.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. 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. @@ -188,10 +190,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 #################################### 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..728cc1f9254 --- /dev/null +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java @@ -0,0 +1,78 @@ +/* + * 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 java.lang.reflect.Field; +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.common.exception.FcliSimpleException; + +import picocli.CommandLine; + +class AviatorSSCApplyRemediationsCommandTest { + @Test + void testNormalizeIssueIdsTrimsAndDeduplicates() throws Exception { + AviatorSSCApplyRemediationsCommand command = parse("--artifact-id", "1", "--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("--artifact-id", "1", "--issue-ids", " , , "); + assertThrows(FcliSimpleException.class, () -> AviatorIssueIdFilterUtils.normalizeIssueIds(getIssueIds(command))); + } + + @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); + } +} \ No newline at end of file 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..0e192381b9e 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,14 +12,18 @@ */ 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.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.util.AviatorIssueIdFilterUtils; import com.fortify.cli.aviator.applyRemediation.ApplyAutoRemediationOnSource; import com.fortify.cli.aviator.config.AviatorLoggerImpl; import com.fortify.cli.aviator.util.FprHandle; @@ -55,14 +59,17 @@ public class FoDAviatorApplyRemediationsCommand extends AbstractFoDJsonNodeOutpu @Mixin private FoDReleaseByQualifiedNameOrIdResolverMixin.RequiredOption releaseResolver; private static final Logger LOG = LoggerFactory.getLogger(FoDAviatorApplyRemediationsCommand.class); @Option(names = {"--source-dir"}) private String sourceCodeDirectory = System.getProperty("user.dir"); + @Option(names = {"--issue-ids"}, split = ",") + private List issueIds; @Override @SneakyThrows public JsonNode getJsonNode(UnirestInstance unirest) { validateSourceCodeDirectory(); + Set issueIdFilter = getIssueIdFilter(); try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); FoDReleaseDescriptor rd = releaseResolver.getReleaseDescriptor(unirest); - return processFprRemediations(unirest, rd, logger); + return processFprRemediations(unirest, rd, logger, issueIdFilter); } } @@ -72,8 +79,13 @@ private void validateSourceCodeDirectory() { } } + private Set getIssueIdFilter() { + return AviatorIssueIdFilterUtils.normalizeIssueIds(issueIds); + } + @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,7 +93,7 @@ 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"; @@ -91,8 +103,8 @@ private JsonNode processFprRemediations(UnirestInstance unirest, FoDReleaseDescr 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); } } } 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..5257d7288fa 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 @@ -1038,6 +1038,8 @@ fcli.fod.attribute.update.values = List of picklist values (only for Picklist da 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.source-dir = Directory containing source code to apply remediations to. Default value: ${DEFAULT-VALUE}. +fcli.fod.aviator.apply-remediations.issue-ids = Comma-separated list of issue IDs to apply. Matches requested values against remediations.xml \ + instanceId entries. Only matching remediations are applied; unmatched requested IDs are counted as skipped. # various messages displayed during execution 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..15bcf127eed 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,22 @@ */ 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 java.lang.reflect.Field; +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 { @@ -64,4 +71,41 @@ void testBlankSourceCodeDirectoryThrowsException() throws Exception { assertThrows(FcliSimpleException.class, () -> command.getJsonNode(null), "Blank sourceCodeDirectory should throw FcliSimpleException"); } + + @Test + void testNormalizeIssueIdsTrimsAndDeduplicates() throws Exception { + FoDAviatorApplyRemediationsCommand command = parse("--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("--issue-ids", " , , "); + assertThrows(FcliSimpleException.class, () -> AviatorIssueIdFilterUtils.normalizeIssueIds(getIssueIds(command))); + } + + @Test + void testIssueIdsOptionParsesIntoField() throws Exception { + FoDAviatorApplyRemediationsCommand command = parse("--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)); + } + + private static FoDAviatorApplyRemediationsCommand parse(String... args) { + FoDAviatorApplyRemediationsCommand command = new FoDAviatorApplyRemediationsCommand(); + var fullArgs = new java.util.ArrayList(); + fullArgs.add("--release"); + fullArgs.add("1"); + java.util.Collections.addAll(fullArgs, args); + new CommandLine(command).parseArgs(fullArgs.toArray(String[]::new)); + 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); + } } From a5a051c19ac0466ae773888c0feee328e3ed9e9a Mon Sep 17 00:00:00 2001 From: kireetivar Date: Mon, 6 Jul 2026 13:43:18 +0530 Subject: [PATCH 02/18] chore: correct file path syntax in remediation test --- .../cli/aviator/fpr/processor/RemediationProcessorTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorTest.java index 30473d34075..d10ae0727d6 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorTest.java @@ -200,7 +200,7 @@ private String pathTraversalAndValidRemediationsXml(String hash) { - ..\\outside.java + ../outside.java %s 3 From 64d2d7f1125fb0bce0813a608728dd11b97480e6 Mon Sep 17 00:00:00 2001 From: kireetivar Date: Mon, 13 Jul 2026 15:55:08 +0530 Subject: [PATCH 03/18] feat: add SSC/FoD remediations FPR download and local apply flows --- .../_common/util/AviatorLocalFprHelper.java | 57 +++++++ .../util/AviatorLocalFprHelperTest.java | 53 ++++++ .../AviatorSSCApplyRemediationsCommand.java | 47 +++++- .../ssc/cli/cmd/AviatorSSCCommands.java | 1 + ...atorSSCDownloadRemediationsFprCommand.java | 155 ++++++++++++++++++ ...pplyRemediationsArtifactSelectorMixin.java | 29 ++++ ...CRemediationsFprDownloadSelectorMixin.java | 88 ++++++++++ .../AviatorSSCApplyRemediationsHelper.java | 35 ++++ .../aviator/i18n/AviatorMessages.properties | 22 ++- ...viatorSSCApplyRemediationsCommandTest.java | 28 ++++ ...SSCDownloadRemediationsFprCommandTest.java | 56 +++++++ .../FoDAviatorApplyRemediationsCommand.java | 86 +++++++++- .../fod/aviator/cmd/FoDAviatorCommands.java | 3 +- ...AviatorDownloadRemediationsFprCommand.java | 109 ++++++++++++ .../AviatorFoDApplyRemediationsHelper.java | 25 +++ .../cli/fod/i18n/FoDMessages.properties | 7 +- ...oDAviatorApplyRemediationsCommandTest.java | 30 ++++ ...torDownloadRemediationsFprCommandTest.java | 37 +++++ .../artifact/helper/SSCArtifactHelper.java | 13 ++ .../helper/SSCArtifactHelperTest.java | 53 ++++++ 20 files changed, 927 insertions(+), 7 deletions(-) create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorLocalFprHelper.java create mode 100644 fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorLocalFprHelperTest.java create mode 100644 fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsFprCommand.java create mode 100644 fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsFprDownloadSelectorMixin.java create mode 100644 fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsFprCommandTest.java create mode 100644 fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorDownloadRemediationsFprCommand.java create mode 100644 fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorDownloadRemediationsFprCommandTest.java create mode 100644 fcli-core/fcli-ssc/src/test/java/com/fortify/cli/ssc/artifact/helper/SSCArtifactHelperTest.java 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..42c41880c29 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorLocalFprHelper.java @@ -0,0 +1,57 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.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) { + if (fprPaths == null || fprPaths.isEmpty()) { + throw new FcliSimpleException("--fpr must specify at least one FPR file"); + } + for (Path fprPath : fprPaths) { + validateLocalFpr(fprPath); + } + } + + private static void validateLocalFpr(Path fprPath) { + if (fprPath == null) { + throw new FcliSimpleException("--fpr must specify a valid FPR file path"); + } + if (!Files.exists(fprPath)) { + throw new FcliSimpleException("FPR file specified by --fpr does not exist: " + fprPath); + } + if (!Files.isRegularFile(fprPath)) { + throw new FcliSimpleException("FPR file specified by --fpr is not a regular file: " + fprPath); + } + if (!Files.isReadable(fprPath)) { + throw new FcliSimpleException("FPR file specified by --fpr is not readable: " + fprPath); + } + try (FprHandle fprHandle = new FprHandle(fprPath)) { + fprHandle.validate(); + } catch (FcliSimpleException e) { + throw e; + } catch (RuntimeException e) { + throw new FcliSimpleException("FPR file specified by --fpr is not a valid audited SAST FPR: " + fprPath, e); + } catch (java.io.IOException e) { + throw new FcliSimpleException("Failed to close FPR file specified by --fpr: " + fprPath, e); + } + } +} \ No newline at end of file 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/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 7e447e26068..7b789d7450c 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 @@ -27,6 +27,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fortify.cli.aviator._common.exception.AviatorSimpleException; 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; @@ -72,11 +73,15 @@ public JsonNode getJsonNode(UnirestInstance unirest) { artifactSelector.validate(); validateSourceCodeDirectory(); Set issueIdFilter = getIssueIdFilter(); - OffsetDateTime sinceDate = SinceOptionHelper.parse(artifactSelector.getSince()); + validateIssueIdFilterMode(); try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); ArtifactProcessor processor = new ArtifactProcessor(unirest, logger, progressWriter, issueIdFilter); + if (artifactSelector.isLocalFprSelected()) { + return processor.processLocalFprRemediations(artifactSelector.getFprPaths()); + } + OffsetDateTime sinceDate = SinceOptionHelper.parse(artifactSelector.getSince()); if (artifactSelector.isAllOpenIssuesSelected()) { return processor.processAllAviatorArtifacts(sinceDate); } @@ -108,6 +113,12 @@ private Set getIssueIdFilter() { return AviatorIssueIdFilterUtils.normalizeIssueIds(issueIds); } + private void validateIssueIdFilterMode() { + if (issueIds != null && !issueIds.isEmpty() && !artifactSelector.isLocalFprSelected()) { + throw new FcliSimpleException("--issue-ids can only be used with --fpr; download the FPR once with download-remediations-fpr and rerun with --fpr"); + } + } + static RemediationMetric aggregateMetrics(Set requestedIssueIds, Collection metrics) { Set modifiedFiles = new LinkedHashSet<>(); if (requestedIssueIds == null) { @@ -218,6 +229,40 @@ private boolean shouldStopProcessing(Set remaining) { return remaining != null && remaining.isEmpty(); } + @SneakyThrows + JsonNode processLocalFprRemediations(List fprPaths) { + AviatorLocalFprHelper.validateLocalFprs(fprPaths); + List metrics = new java.util.ArrayList<>(); + Set remaining = issueIdFilter == null ? null : new LinkedHashSet<>(issueIdFilter); + + for (int i = 0; i < fprPaths.size(); i++) { + if (shouldStopProcessing(remaining)) { + break; + } + Path fprPath = fprPaths.get(i); + logger.progress("Processing FPR " + (i + 1) + "/" + fprPaths.size() + " (" + fprPath + ")"); + 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); + remaining = getRemainingIssueIds(remaining, metric); + } + } + + RemediationMetric aggregatedMetric = aggregateMetrics(issueIdFilter, metrics); + String action = aggregatedMetric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; + return AviatorSSCApplyRemediationsHelper.buildLocalFprResultNode( + new AviatorSSCApplyRemediationsHelper.LocalFprResultData( + fprPaths, + metrics.size(), + 0, + aggregatedMetric.totalRemediations(), + aggregatedMetric.appliedRemediations(), + aggregatedMetric.skippedRemediations(), + aggregatedMetric.modifiedFiles(), + action)); + } + @SneakyThrows private Path downloadArtifactFpr(SSCArtifactDescriptor ad) { Path fprPath = Files.createTempFile("aviator_" + ad.getId() + "_", ".fpr"); 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..8d968bf5e6e 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, + AviatorSSCDownloadRemediationsFprCommand.class, AviatorSSCCorrelateSastDastCommand.class } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsFprCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsFprCommand.java new file mode 100644 index 00000000000..10159886d5f --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsFprCommand.java @@ -0,0 +1,155 @@ +/* + * 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.List; + +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.config.AviatorLoggerImpl; +import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCRemediationsFprDownloadSelectorMixin; +import com.fortify.cli.aviator.ssc.helper.SinceOptionHelper; +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.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-fpr") +public class AviatorSSCDownloadRemediationsFprCommand extends AbstractSSCJsonNodeOutputCommand implements IActionCommandResultSupplier { + @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; + @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; + @Mixin private AviatorSSCRemediationsFprDownloadSelectorMixin artifactSelector; + @Option(names = {"-f", "--file"}, paramLabel = "", descriptionKey = "fcli.aviator.ssc.download-remediations-fpr.file") + private File outputFile; + @Option(names = {"--dir"}, paramLabel = "", descriptionKey = "fcli.aviator.ssc.download-remediations-fpr.dir") + private Path outputDir; + + @Override + @SneakyThrows + public JsonNode getJsonNode(UnirestInstance unirest) { + artifactSelector.validate(); + validateDestinationOptions(); + OffsetDateTime sinceDate = SinceOptionHelper.parse(artifactSelector.getSince()); + try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { + AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); + if (artifactSelector.isAllOpenIssuesSelected()) { + return downloadAll(unirest, sinceDate, logger, progressWriter); + } + SSCArtifactDescriptor artifact = resolveArtifactDescriptor(unirest, sinceDate); + Path destination = getSingleDestination(artifact); + downloadArtifact(unirest, artifact, destination, logger, progressWriter); + return buildSingleResultNode(artifact, destination); + } + } + + private SSCArtifactDescriptor resolveArtifactDescriptor(UnirestInstance unirest, OffsetDateTime sinceDate) { + if (artifactSelector.isLatestSelected()) { + String appVersionId = artifactSelector.getAppVersionId(unirest); + return SSCArtifactHelper.getLatestAviatorArtifact(unirest, appVersionId, sinceDate); + } + return SSCArtifactHelper.requireAviatorArtifact( + SSCArtifactHelper.getArtifactDescriptor(unirest, artifactSelector.getArtifactId())); + } + + @SneakyThrows + private JsonNode downloadAll(UnirestInstance unirest, OffsetDateTime sinceDate, AviatorLoggerImpl logger, IProgressWriter progressWriter) { + Files.createDirectories(outputDir); + if (!Files.isDirectory(outputDir)) { + throw new FcliSimpleException("--dir must specify a directory: " + outputDir); + } + + String appVersionId = artifactSelector.getAppVersionId(unirest); + List artifacts = SSCArtifactHelper.getAllAviatorArtifacts(unirest, appVersionId, sinceDate); + ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); + result.put("appVersionId", appVersionId); + result.put("artifactsDownloaded", artifacts.size()); + ArrayNode files = result.putArray("files"); + ArrayNode artifactIds = result.putArray("artifactIds"); + for (int i = 0; i < artifacts.size(); i++) { + SSCArtifactDescriptor artifact = artifacts.get(i); + Path destination = outputDir.resolve(String.format("%03d_remediations_artifact_%s.fpr", i + 1, artifact.getId())); + downloadArtifact(unirest, artifact, destination, logger, progressWriter); + files.add(destination.toString()); + artifactIds.add(artifact.getId()); + } + result.put(IActionCommandResultSupplier.actionFieldName, getActionCommandResult()); + return result; + } + + private Path getSingleDestination(SSCArtifactDescriptor artifact) { + return outputFile == null ? Path.of(String.format("remediations_artifact_%s.fpr", artifact.getId())) : outputFile.toPath(); + } + + private void validateDestinationOptions() { + if (artifactSelector.isAllOpenIssuesSelected()) { + if (outputFile != null) { + throw new FcliSimpleException("-f/--file cannot be used with --all; use --dir"); + } + if (outputDir == null) { + throw new FcliSimpleException("--dir must be specified when using --all"); + } + } else if (outputDir != null) { + throw new FcliSimpleException("--dir can only be used with --all; use -f/--file for a single FPR download"); + } + } + + 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 buildSingleResultNode(SSCArtifactDescriptor artifact, Path destination) { + ObjectNode result = artifact.asObjectNode(); + result.put("artifactsDownloaded", 1); + result.putArray("artifactIds").add(artifact.getId()); + result.putArray("files").add(destination.toString()); + result.put("file", destination.toString()); + result.put(IActionCommandResultSupplier.actionFieldName, getActionCommandResult()); + return result; + } + + @Override + public String getActionCommandResult() { + return "REMEDIATIONS_FPR_DOWNLOADED"; + } + + @Override + public boolean isSingular() { + return true; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCApplyRemediationsArtifactSelectorMixin.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCApplyRemediationsArtifactSelectorMixin.java index 622e1f295f5..b4838ed19c0 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/AviatorSSCApplyRemediationsArtifactSelectorMixin.java @@ -12,9 +12,15 @@ */ package com.fortify.cli.aviator.ssc.cli.mixin; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; + import org.apache.commons.lang3.StringUtils; import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.util.DisableTest; +import com.fortify.cli.common.util.DisableTest.TestType; import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; import com.fortify.cli.ssc.appversion.helper.SSCAppVersionHelper; @@ -53,6 +59,11 @@ public static class ArtifactSelectionArgGroup { @Option(names = {"--all"}, required = true, descriptionKey = "fcli.aviator.ssc.apply-remediations.all") private boolean allOpenIssues; + + @Option(names = {"--fpr"}, required = true, arity = "1..*", paramLabel = "", + descriptionKey = "fcli.aviator.ssc.apply-remediations.fpr") + @DisableTest({TestType.MULTI_OPT_SPLIT, TestType.MULTI_OPT_PLURAL_NAME, TestType.OPT_ARITY_VARIABLE}) + private List fprPaths; } public boolean isArtifactIdSelected() { @@ -67,10 +78,18 @@ public boolean isAllOpenIssuesSelected() { return artifactSelection != null && artifactSelection.allOpenIssues; } + public boolean isLocalFprSelected() { + return artifactSelection != null && artifactSelection.fprPaths != null && !artifactSelection.fprPaths.isEmpty(); + } + public String getArtifactId() { return isArtifactIdSelected() ? artifactSelection.artifactId : null; } + public List getFprPaths() { + return isLocalFprSelected() ? artifactSelection.fprPaths : Collections.emptyList(); + } + public String getAppVersionNameOrId() { return appVersionNameOrId; } @@ -85,6 +104,16 @@ public String getAppVersionId(UnirestInstance unirest) { } public void validate() { + if (isLocalFprSelected()) { + if (StringUtils.isNotBlank(since)) { + throw new FcliSimpleException("--since cannot be used with --fpr"); + } + if (StringUtils.isNotBlank(appVersionNameOrId)) { + throw new FcliSimpleException("--av/--appversion cannot be used with --fpr"); + } + return; + } + // Validate --since is only used with --latest or --all-open-issues if (since != null && !since.isBlank() && isArtifactIdSelected()) { throw new FcliSimpleException( diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsFprDownloadSelectorMixin.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsFprDownloadSelectorMixin.java new file mode 100644 index 00000000000..905c55dffef --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsFprDownloadSelectorMixin.java @@ -0,0 +1,88 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.cli.mixin; + +import org.apache.commons.lang3.StringUtils; + +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; +import com.fortify.cli.ssc.appversion.helper.SSCAppVersionHelper; + +import kong.unirest.UnirestInstance; +import lombok.Getter; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Option; + +@Getter +public class AviatorSSCRemediationsFprDownloadSelectorMixin { + @ArgGroup(exclusive = true, multiplicity = "1") + private ArtifactSelectionArgGroup artifactSelection; + + @Option(names = {"--since"}, descriptionKey = "fcli.aviator.ssc.download-remediations-fpr.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 ArtifactSelectionArgGroup { + @Option(names = {"--artifact-id"}, required = true, descriptionKey = "fcli.aviator.ssc.download-remediations-fpr.artifact-id") + private String artifactId; + + @Option(names = {"--latest"}, required = true, descriptionKey = "fcli.aviator.ssc.download-remediations-fpr.latest") + private boolean latest; + + @Option(names = {"--all"}, required = true, descriptionKey = "fcli.aviator.ssc.download-remediations-fpr.all") + private boolean allOpenIssues; + } + + public boolean isArtifactIdSelected() { + return artifactSelection != null && StringUtils.isNotBlank(artifactSelection.artifactId); + } + + public boolean isLatestSelected() { + return artifactSelection != null && artifactSelection.latest; + } + + public boolean isAllOpenIssuesSelected() { + return artifactSelection != null && artifactSelection.allOpenIssues; + } + + public String getArtifactId() { + return isArtifactIdSelected() ? artifactSelection.artifactId : null; + } + + public String getAppVersionId(UnirestInstance unirest) { + if (StringUtils.isBlank(appVersionNameOrId)) { + return null; + } + SSCAppVersionDescriptor descriptor = SSCAppVersionHelper.getRequiredAppVersion( + unirest, appVersionNameOrId, delimiter, "id"); + return descriptor.getVersionId(); + } + + public void validate() { + if (StringUtils.isNotBlank(since) && isArtifactIdSelected()) { + throw new FcliSimpleException("--since cannot be used with --artifact-id; use --latest or --all"); + } + if ((isLatestSelected() || isAllOpenIssuesSelected()) && 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"); + } + } +} \ No newline at end of file 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..e088ae6762f 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; @@ -27,6 +29,16 @@ public final class AviatorSSCApplyRemediationsHelper { private AviatorSSCApplyRemediationsHelper() {} + public record LocalFprResultData( + List fprPaths, + int fprsProcessed, + int fprsSkipped, + int totalRemediation, + int appliedRemediation, + int skippedRemediation, + Set modifiedFiles, + String action) {} + /** * 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. @@ -78,6 +90,21 @@ public static ObjectNode buildAggregatedResultNode(String appVersionId, int arti return result; } + public static ObjectNode buildLocalFprResultNode(LocalFprResultData resultData) { + ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); + result.put("appVersionId", "N/A"); + result.put("artifactId", "N/A"); + 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("fprs", toPathArrayNode(resultData.fprPaths())); + result.put(IActionCommandResultSupplier.actionFieldName, resultData.action()); + return result; + } + private static ArrayNode toArrayNode(Set files) { ArrayNode array = JsonHelper.getObjectMapper().createArrayNode(); if (files != null) { @@ -85,4 +112,12 @@ private static ArrayNode toArrayNode(Set files) { } return array; } + + private static ArrayNode toPathArrayNode(List paths) { + ArrayNode array = JsonHelper.getObjectMapper().createArrayNode(); + if (paths != null) { + paths.stream().map(Path::toString).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 9e6099bb036..f2ceef8253a 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,12 @@ 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. \ +fcli.aviator.ssc.apply-remediations.usage.description = Downloads FPR artifact(s), or reads one or more local FPR files, and applies Fortify Remediation Aviator-generated remediations to the specified source directory. \ + Exactly one of --fpr, --artifact-id, --latest, or --all must be specified. Local FPR files supplied with --fpr are processed in command-line order. \ This command requires an active user session. Use 'fcli aviator session login' to create a session. \ %n%nExamples: \ + %n%n Apply remediations from local FPR files: \ + %n fcli aviator ssc apply-remediations --fpr ./001_remediations_artifact_123.fpr ./002_remediations_artifact_456.fpr \ %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: \ @@ -155,13 +157,26 @@ fcli.aviator.ssc.apply-remediations.latest = Automatically select the most recen fcli.aviator.ssc.apply-remediations.all = Apply remediations from all Fortify Remediation Aviator-processed artifacts for the given application version, \ in chronological order. Aggregates remediation statistics across all artifacts. \ Requires --av/--appversion. Mutually exclusive with --artifact-id and --latest. +fcli.aviator.ssc.apply-remediations.fpr = One or more local audited FPR files containing Fortify Remediation Aviator remediations. \ + Files are processed in command-line order. Mutually exclusive with --artifact-id, --latest, and --all. 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. When specified, summary counts reflect requested issue IDs rather than all remediations in the artifact. + instanceId values. Requires --fpr so integrations can download the FPR once 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. +fcli.aviator.ssc.download-remediations-fpr.usage.header = Download audited FPR file(s) containing Fortify Remediation Aviator remediations from SSC. +fcli.aviator.ssc.download-remediations-fpr.usage.description = Downloads reusable audited FPR file(s) from SSC so they can be passed to apply-remediations with --fpr. \ + Existing destination files are overwritten. For --all, files are downloaded in the same order used by apply-remediations --all and generated file names include a numeric order prefix. \ + Exactly one of --artifact-id, --latest, or --all must be specified. +fcli.aviator.ssc.download-remediations-fpr.artifact-id = Specific artifact ID to download. Mutually exclusive with --latest and --all. +fcli.aviator.ssc.download-remediations-fpr.latest = Download the most recent Fortify Remediation Aviator-processed artifact. Requires --av/--appversion. Mutually exclusive with --artifact-id and --all. +fcli.aviator.ssc.download-remediations-fpr.all = Download all Fortify Remediation Aviator-processed artifacts for the given application version. Requires --av/--appversion and --dir. Mutually exclusive with --artifact-id and --latest. +fcli.aviator.ssc.download-remediations-fpr.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-fpr.file = Destination FPR file for a single download. Existing files are overwritten. Cannot be used with --all. +fcli.aviator.ssc.download-remediations-fpr.dir = Destination directory for --all downloads. The directory is created if needed. Existing generated files are overwritten. + 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') \ exist in SSC and are associated with the specified issue templates and/or application versions. \ @@ -225,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-fpr.output.table.args = artifactsDownloaded,artifactIds,files,__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 index 728cc1f9254..df03530fcd2 100644 --- a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java @@ -16,6 +16,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import java.lang.reflect.Field; +import java.nio.file.Path; import java.util.List; import java.util.Set; @@ -40,6 +41,25 @@ void testNormalizeIssueIdsRejectsOnlyBlankValues() throws Exception { assertThrows(FcliSimpleException.class, () -> AviatorIssueIdFilterUtils.normalizeIssueIds(getIssueIds(command))); } + @Test + void testFprOptionParsesOrderedPaths() throws Exception { + AviatorSSCApplyRemediationsCommand command = parse("--fpr", "first.fpr", "second.fpr"); + + assertEquals(List.of(Path.of("first.fpr"), Path.of("second.fpr")), getFprPaths(command)); + } + + @Test + void testFprOptionCannotBeCombinedWithArtifactId() { + assertThrows(CommandLine.ParameterException.class, () -> parse("--artifact-id", "1", "--fpr", "local.fpr")); + } + + @Test + void testIssueIdsWithoutFprThrowsException() { + AviatorSSCApplyRemediationsCommand command = parse("--artifact-id", "1", "--issue-ids", "ISSUE-1"); + + assertThrows(FcliSimpleException.class, () -> command.getJsonNode(null)); + } + @Test void testAggregateMetricsForFilteredAllDeduplicatesAcrossArtifacts() { RemediationMetric metricOne = RemediationMetric.filtered(Set.of("ISSUE-1", "ISSUE-2"), Set.of("ISSUE-1"), Set.of("A.java")); @@ -75,4 +95,12 @@ private static List getIssueIds(AviatorSSCApplyRemediationsCommand comma field.setAccessible(true); return (List) field.get(command); } + + @SuppressWarnings("unchecked") + private static List getFprPaths(AviatorSSCApplyRemediationsCommand command) throws Exception { + Field artifactSelectorField = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("artifactSelector"); + artifactSelectorField.setAccessible(true); + Object artifactSelector = artifactSelectorField.get(command); + return (List) artifactSelector.getClass().getMethod("getFprPaths").invoke(artifactSelector); + } } \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsFprCommandTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsFprCommandTest.java new file mode 100644 index 00000000000..87a2b73d455 --- /dev/null +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsFprCommandTest.java @@ -0,0 +1,56 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.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 AviatorSSCDownloadRemediationsFprCommandTest { + @Test + void testArtifactIdAndLatestAreMutuallyExclusive() { + assertThrows(CommandLine.ParameterException.class, + () -> parse("--artifact-id", "1", "--latest")); + } + + @Test + void testAllRejectsFileOption() { + AviatorSSCDownloadRemediationsFprCommand command = parse("--all", "--av", "1", "-f", "one.fpr"); + + assertThrows(FcliSimpleException.class, () -> command.getJsonNode(null)); + } + + @Test + void testAllRequiresDirectoryOption() { + AviatorSSCDownloadRemediationsFprCommand command = parse("--all", "--av", "1"); + + assertThrows(FcliSimpleException.class, () -> command.getJsonNode(null)); + } + + @Test + void testArtifactIdRejectsAppVersion() { + AviatorSSCDownloadRemediationsFprCommand command = parse("--artifact-id", "1", "--av", "2"); + + assertThrows(FcliSimpleException.class, () -> command.getJsonNode(null)); + } + + private static AviatorSSCDownloadRemediationsFprCommand parse(String... args) { + AviatorSSCDownloadRemediationsFprCommand command = new AviatorSSCDownloadRemediationsFprCommand(); + new CommandLine(command).parseArgs(args); + return command; + } +} \ No newline at end of file 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 0e192381b9e..535895cfa9c 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 @@ -16,6 +16,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.util.Collection; +import java.util.LinkedHashSet; import java.util.List; import java.util.Set; @@ -24,8 +26,10 @@ import com.fasterxml.jackson.databind.JsonNode; 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.mixin.OutputHelperMixins; @@ -34,6 +38,8 @@ import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; import com.fortify.cli.common.progress.helper.IProgressWriter; import com.fortify.cli.common.rest.unirest.HttpHeader; +import com.fortify.cli.common.util.DisableTest; +import com.fortify.cli.common.util.DisableTest.TestType; 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; @@ -56,18 +62,25 @@ public class FoDAviatorApplyRemediationsCommand extends AbstractFoDJsonNodeOutpu @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 FoDReleaseByQualifiedNameOrIdResolverMixin.OptionalOption releaseResolver; private static final Logger LOG = LoggerFactory.getLogger(FoDAviatorApplyRemediationsCommand.class); @Option(names = {"--source-dir"}) private String sourceCodeDirectory = System.getProperty("user.dir"); @Option(names = {"--issue-ids"}, split = ",") private List issueIds; + @Option(names = {"--fpr"}, arity = "1..*", paramLabel = "", descriptionKey = "fcli.fod.aviator.apply-remediations.fpr") + @DisableTest({TestType.MULTI_OPT_SPLIT, TestType.MULTI_OPT_PLURAL_NAME, TestType.OPT_ARITY_VARIABLE}) + private List fprPaths; @Override @SneakyThrows public JsonNode getJsonNode(UnirestInstance unirest) { validateSourceCodeDirectory(); Set issueIdFilter = getIssueIdFilter(); + validateSelection(); try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); + if (isLocalFprSelected()) { + return processLocalFprRemediations(logger, issueIdFilter); + } FoDReleaseDescriptor rd = releaseResolver.getReleaseDescriptor(unirest); return processFprRemediations(unirest, rd, logger, issueIdFilter); } @@ -83,6 +96,77 @@ private Set getIssueIdFilter() { return AviatorIssueIdFilterUtils.normalizeIssueIds(issueIds); } + private void validateSelection() { + boolean releaseSelected = releaseResolver.getQualifiedReleaseNameOrId() != null && !releaseResolver.getQualifiedReleaseNameOrId().isBlank(); + boolean localFprSelected = isLocalFprSelected(); + if (releaseSelected == localFprSelected) { + throw new FcliSimpleException("Exactly one of --release/--rel or --fpr must be specified"); + } + if (issueIds != null && !issueIds.isEmpty() && !localFprSelected) { + throw new FcliSimpleException("--issue-ids can only be used with --fpr; download the FPR once with download-remediations-fpr and rerun with --fpr"); + } + } + + private boolean isLocalFprSelected() { + return fprPaths != null && !fprPaths.isEmpty(); + } + + @SneakyThrows + private JsonNode processLocalFprRemediations(AviatorLoggerImpl logger, Set issueIdFilter) { + AviatorLocalFprHelper.validateLocalFprs(fprPaths); + List metrics = new java.util.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); + logger.progress("Processing FPR " + (i + 1) + "/" + fprPaths.size() + " (" + fprPath + ")"); + 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); + remaining = getRemainingIssueIds(remaining, metric); + } + } + + RemediationMetric aggregatedMetric = aggregateMetrics(issueIdFilter, metrics); + String status = aggregatedMetric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; + return AviatorFoDApplyRemediationsHelper.buildLocalFprResultNode(fprPaths, + 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, Set issueIdFilter) { 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..4513aa9f85a 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, + FoDAviatorDownloadRemediationsFprCommand.class } ) diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorDownloadRemediationsFprCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorDownloadRemediationsFprCommand.java new file mode 100644 index 00000000000..96a46ff8ebe --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorDownloadRemediationsFprCommand.java @@ -0,0 +1,109 @@ +/* + * 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.Path; +import java.nio.file.StandardCopyOption; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; +import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; +import com.fortify.cli.common.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-fpr") +public class FoDAviatorDownloadRemediationsFprCommand 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; + @Option(names = {"-f", "--file"}, paramLabel = "", descriptionKey = "fcli.fod.aviator.download-remediations-fpr.file") + private File outputFile; + + @Override + @SneakyThrows + public JsonNode getJsonNode(UnirestInstance unirest) { + FoDReleaseDescriptor releaseDescriptor = releaseResolver.getReleaseDescriptor(unirest); + FoDScanDescriptor scanDescriptor = FoDScanHelper.getLatestScanDescriptor(unirest, releaseDescriptor.getReleaseId(), + FoDScanType.Static, false); + FoDScanHelper.validateScanDate(scanDescriptor, FoDScanHelper.MAX_RETENTION_PERIOD); + Path destination = getDestination(releaseDescriptor); + 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) { + throw new FcliSimpleException("Timed out waiting for FoD remediations FPR download to complete after " + + MAX_RETRIES + " retries"); + } + return buildResultNode(releaseDescriptor, scanDescriptor, destination); + } + + private Path getDestination(FoDReleaseDescriptor releaseDescriptor) { + return outputFile == null ? Path.of(String.format("remediations_release_%s.fpr", releaseDescriptor.getReleaseId())) : outputFile.toPath(); + } + + 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(FoDReleaseDescriptor releaseDescriptor, FoDScanDescriptor scanDescriptor, Path destination) { + ObjectNode result = scanDescriptor.asObjectNode(); + result.put("releaseId", releaseDescriptor.getReleaseId()); + result.put("applicationName", releaseDescriptor.getApplicationName()); + result.put("releaseName", releaseDescriptor.getReleaseName()); + result.put("file", destination.toString()); + result.put(IActionCommandResultSupplier.actionFieldName, getActionCommandResult()); + return result; + } + + @Override + public String getActionCommandResult() { + return "REMEDIATIONS_FPR_DOWNLOADED"; + } + + @Override + public boolean isSingular() { + return true; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/AviatorFoDApplyRemediationsHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/AviatorFoDApplyRemediationsHelper.java index 06a27646269..c30955a30e8 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; @@ -59,6 +61,21 @@ public static ObjectNode buildResultNode(FoDReleaseDescriptor rd, int totalRemed return result; } + public static ObjectNode buildLocalFprResultNode(List fprPaths, int totalRemediation, int appliedRemediation, + int skippedRemediation, Set modifiedFiles, String action) { + ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); + result.put("releaseId", "N/A"); + result.put("applicationName", "N/A"); + result.put("releaseName", "N/A"); + result.put("totalRemediation", totalRemediation); + result.put("appliedRemediation", appliedRemediation); + result.put("skippedRemediation", skippedRemediation); + result.set("modifiedFiles", toArrayNode(modifiedFiles)); + result.set("fprs", toPathArrayNode(fprPaths)); + result.put(IActionCommandResultSupplier.actionFieldName, action); + return result; + } + private static ArrayNode toArrayNode(Set files) { ArrayNode array = JsonHelper.getObjectMapper().createArrayNode(); if (files != null) { @@ -66,4 +83,12 @@ private static ArrayNode toArrayNode(Set files) { } return array; } + + private static ArrayNode toPathArrayNode(List paths) { + ArrayNode array = JsonHelper.getObjectMapper().createArrayNode(); + if (paths != null) { + paths.stream().map(Path::toString).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 5257d7288fa..3b794dc5100 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 @@ -1038,8 +1038,12 @@ fcli.fod.attribute.update.values = List of picklist values (only for Picklist da 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.source-dir = Directory containing source code to apply remediations to. Default value: ${DEFAULT-VALUE}. +fcli.fod.aviator.apply-remediations.fpr = One or more local audited FPR files containing Fortify Remediation Aviator remediations. Files are processed in command-line order. Mutually exclusive with --release/--rel. fcli.fod.aviator.apply-remediations.issue-ids = Comma-separated list of issue IDs to apply. Matches requested values against remediations.xml \ - instanceId entries. Only matching remediations are applied; unmatched requested IDs are counted as skipped. + instanceId entries. Requires --fpr so integrations can download the FPR once and apply selected remediations without repeated FoD downloads. +fcli.fod.aviator.download-remediations-fpr.usage.header = Download an audited FPR file containing Fortify Remediation Aviator remediations from FoD. +fcli.fod.aviator.download-remediations-fpr.usage.description = Downloads the latest static audited FPR for a FoD release so it can be passed to apply-remediations with --fpr. Existing destination files are overwritten. +fcli.fod.aviator.download-remediations-fpr.file = Destination FPR file. Existing files are overwritten. Defaults to remediations_release_.fpr. # various messages displayed during execution @@ -1092,4 +1096,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-fpr.output.table.args = releaseId,scanId,scanType,file,__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 15bcf127eed..39097e08cf1 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 @@ -17,6 +17,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import java.lang.reflect.Field; +import java.nio.file.Path; import java.util.List; import java.util.Set; @@ -92,6 +93,29 @@ void testIssueIdsOptionParsesIntoField() throws Exception { assertEquals(List.of("ISSUE-1", "ISSUE-2"), field.get(command)); } + @Test + void testFprOptionParsesOrderedPaths() throws Exception { + FoDAviatorApplyRemediationsCommand command = parseRaw("--fpr", "first.fpr", "second.fpr"); + + Field field = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("fprPaths"); + field.setAccessible(true); + assertEquals(List.of(Path.of("first.fpr"), Path.of("second.fpr")), field.get(command)); + } + + @Test + void testReleaseAndFprTogetherThrowsException() { + FoDAviatorApplyRemediationsCommand command = parseRaw("--release", "1", "--fpr", "local.fpr"); + + assertThrows(FcliSimpleException.class, () -> command.getJsonNode(null)); + } + + @Test + void testIssueIdsWithoutFprThrowsException() { + FoDAviatorApplyRemediationsCommand command = parse("--issue-ids", "ISSUE-1"); + + assertThrows(FcliSimpleException.class, () -> command.getJsonNode(null)); + } + private static FoDAviatorApplyRemediationsCommand parse(String... args) { FoDAviatorApplyRemediationsCommand command = new FoDAviatorApplyRemediationsCommand(); var fullArgs = new java.util.ArrayList(); @@ -102,6 +126,12 @@ private static FoDAviatorApplyRemediationsCommand parse(String... args) { return command; } + private static FoDAviatorApplyRemediationsCommand parseRaw(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"); diff --git a/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorDownloadRemediationsFprCommandTest.java b/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorDownloadRemediationsFprCommandTest.java new file mode 100644 index 00000000000..8147651d806 --- /dev/null +++ b/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorDownloadRemediationsFprCommandTest.java @@ -0,0 +1,37 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.fod.aviator; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.fod.aviator.cmd.FoDAviatorDownloadRemediationsFprCommand; + +import picocli.CommandLine; + +class FoDAviatorDownloadRemediationsFprCommandTest { + @Test + void testReleaseIsRequired() { + FoDAviatorDownloadRemediationsFprCommand command = new FoDAviatorDownloadRemediationsFprCommand(); + + assertThrows(CommandLine.ParameterException.class, () -> new CommandLine(command).parseArgs()); + } + + @Test + void testReleaseWithFileOptionParses() { + FoDAviatorDownloadRemediationsFprCommand command = new FoDAviatorDownloadRemediationsFprCommand(); + + new CommandLine(command).parseArgs("--release", "1", "-f", "remediations_release_1.fpr"); + } +} \ No newline at end of file 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 From 30b8b218e47b9f562fc892c9dd86f31b3d0fd189 Mon Sep 17 00:00:00 2001 From: kireetivar Date: Wed, 15 Jul 2026 12:02:48 +0530 Subject: [PATCH 04/18] chore: update output structure for download remediations FPR command --- .../FoDAviatorDownloadRemediationsFprCommand.java | 13 +++++++------ .../com/fortify/cli/fod/i18n/FoDMessages.properties | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorDownloadRemediationsFprCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorDownloadRemediationsFprCommand.java index 96a46ff8ebe..f06c02ebcd7 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorDownloadRemediationsFprCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorDownloadRemediationsFprCommand.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; 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; @@ -73,7 +74,7 @@ public JsonNode getJsonNode(UnirestInstance unirest) { throw new FcliSimpleException("Timed out waiting for FoD remediations FPR download to complete after " + MAX_RETRIES + " retries"); } - return buildResultNode(releaseDescriptor, scanDescriptor, destination); + return buildResultNode(releaseDescriptor, destination); } private Path getDestination(FoDReleaseDescriptor releaseDescriptor) { @@ -87,11 +88,11 @@ private GetRequest getDownloadRequest(UnirestInstance unirest, FoDReleaseDescrip .queryString("scanType", scanDescriptor.getScanType()); } - private ObjectNode buildResultNode(FoDReleaseDescriptor releaseDescriptor, FoDScanDescriptor scanDescriptor, Path destination) { - ObjectNode result = scanDescriptor.asObjectNode(); - result.put("releaseId", releaseDescriptor.getReleaseId()); - result.put("applicationName", releaseDescriptor.getApplicationName()); - result.put("releaseName", releaseDescriptor.getReleaseName()); + private ObjectNode buildResultNode(FoDReleaseDescriptor releaseDescriptor, Path destination) { + ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); + result.put("releasesDownloaded", 1); + result.putArray("releaseIds").add(releaseDescriptor.getReleaseId()); + result.putArray("files").add(destination.toString()); result.put("file", destination.toString()); result.put(IActionCommandResultSupplier.actionFieldName, getActionCommandResult()); return result; 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 3b794dc5100..926a33be2f9 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 @@ -1096,5 +1096,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-fpr.output.table.args = releaseId,scanId,scanType,file,__action__ +fcli.fod.aviator.download-remediations-fpr.output.table.args = releasesDownloaded,releaseIds,files,__action__ From 3225a4c457a56a1c453e03eb2438b329516b4f1a Mon Sep 17 00:00:00 2001 From: kireetivar Date: Fri, 17 Jul 2026 10:42:23 +0530 Subject: [PATCH 05/18] chore: replace remediations FPR download with cache zip workflow --- .../RemediationsCacheConstants.java | 24 ++ .../RemediationsCacheEntry.java | 34 +++ .../RemediationsCacheManifest.java | 39 ++++ .../RemediationsCacheReader.java | 207 ++++++++++++++++ .../RemediationsCacheSha256.java | 60 +++++ .../RemediationsCacheWriter.java | 139 +++++++++++ .../_common/util/AviatorLocalFprHelper.java | 24 +- .../RemediationsCacheRoundTripTest.java | 113 +++++++++ .../AviatorSSCApplyRemediationsCommand.java | 220 ++++++++++-------- .../ssc/cli/cmd/AviatorSSCCommands.java | 2 +- ...orSSCDownloadRemediationsCacheCommand.java | 164 +++++++++++++ ...atorSSCDownloadRemediationsFprCommand.java | 155 ------------ ...pplyRemediationsArtifactSelectorMixin.java | 135 ----------- ...viatorSSCApplyRemediationsSourceMixin.java | 139 +++++++++++ ...mediationsCacheDownloadSelectorMixin.java} | 33 ++- .../AviatorSSCApplyRemediationsHelper.java | 63 +++-- .../aviator/i18n/AviatorMessages.properties | 47 ++-- ...viatorSSCApplyRemediationsCommandTest.java | 35 +-- ...DownloadRemediationsCacheCommandTest.java} | 26 +-- .../FoDAviatorApplyRemediationsCommand.java | 169 +++++++++----- .../fod/aviator/cmd/FoDAviatorCommands.java | 2 +- ...atorDownloadRemediationsCacheCommand.java} | 66 ++++-- .../AviatorFoDApplyRemediationsHelper.java | 45 ++-- .../cli/fod/i18n/FoDMessages.properties | 15 +- ...oDAviatorApplyRemediationsCommandTest.java | 52 ++--- ...DownloadRemediationsCacheCommandTest.java} | 23 +- 26 files changed, 1401 insertions(+), 630 deletions(-) create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheConstants.java create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheEntry.java create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheManifest.java create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheReader.java create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheSha256.java create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheWriter.java create mode 100644 fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheRoundTripTest.java create mode 100644 fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsCacheCommand.java delete mode 100644 fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsFprCommand.java delete mode 100644 fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCApplyRemediationsArtifactSelectorMixin.java create mode 100644 fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCApplyRemediationsSourceMixin.java rename fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/{AviatorSSCRemediationsFprDownloadSelectorMixin.java => AviatorSSCRemediationsCacheDownloadSelectorMixin.java} (80%) rename fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/{AviatorSSCDownloadRemediationsFprCommandTest.java => AviatorSSCDownloadRemediationsCacheCommandTest.java} (56%) rename fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/{FoDAviatorDownloadRemediationsFprCommand.java => FoDAviatorDownloadRemediationsCacheCommand.java} (60%) rename fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/{FoDAviatorDownloadRemediationsFprCommandTest.java => FoDAviatorDownloadRemediationsCacheCommandTest.java} (54%) 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/AviatorLocalFprHelper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorLocalFprHelper.java index 42c41880c29..6fe0871565b 100644 --- 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 @@ -23,35 +23,39 @@ 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("--fpr must specify at least one FPR file"); + throw new FcliSimpleException(sourceLabel + " list must contain at least one FPR file"); } for (Path fprPath : fprPaths) { - validateLocalFpr(fprPath); + validateLocalFpr(fprPath, sourceLabel); } } - private static void validateLocalFpr(Path fprPath) { + private static void validateLocalFpr(Path fprPath, String sourceLabel) { if (fprPath == null) { - throw new FcliSimpleException("--fpr must specify a valid FPR file path"); + throw new FcliSimpleException(sourceLabel + " path must be a valid FPR file path"); } if (!Files.exists(fprPath)) { - throw new FcliSimpleException("FPR file specified by --fpr does not exist: " + fprPath); + throw new FcliSimpleException(sourceLabel + " does not exist: " + fprPath); } if (!Files.isRegularFile(fprPath)) { - throw new FcliSimpleException("FPR file specified by --fpr is not a regular file: " + fprPath); + throw new FcliSimpleException(sourceLabel + " is not a regular file: " + fprPath); } if (!Files.isReadable(fprPath)) { - throw new FcliSimpleException("FPR file specified by --fpr is not readable: " + 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("FPR file specified by --fpr is not a valid audited SAST FPR: " + fprPath, 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 FPR file specified by --fpr: " + fprPath, e); + throw new FcliSimpleException("Failed to close " + sourceLabel + ": " + fprPath, e); } } -} \ No newline at end of file +} 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/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 7b789d7450c..7ba621b35f1 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 @@ -26,23 +26,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.fpr.processor.RemediationProcessor.RemediationMetric; -import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCApplyRemediationsArtifactSelectorMixin; +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; @@ -56,10 +60,12 @@ 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") @@ -69,20 +75,20 @@ public class AviatorSSCApplyRemediationsCommand extends AbstractSSCJsonNodeOutpu @Override @SneakyThrows - public JsonNode getJsonNode(UnirestInstance unirest) { - artifactSelector.validate(); + public JsonNode getJsonNode() { + sourceSelector.validate(); validateSourceCodeDirectory(); Set issueIdFilter = getIssueIdFilter(); validateIssueIdFilterMode(); try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); - ArtifactProcessor processor = new ArtifactProcessor(unirest, logger, progressWriter, issueIdFilter); - - if (artifactSelector.isLocalFprSelected()) { - return processor.processLocalFprRemediations(artifactSelector.getFprPaths()); + if (sourceSelector.isFromCacheSelected()) { + return processFromCache(logger, issueIdFilter); } - OffsetDateTime sinceDate = SinceOptionHelper.parse(artifactSelector.getSince()); - if (artifactSelector.isAllOpenIssuesSelected()) { + 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); @@ -90,17 +96,77 @@ 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 java.util.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); + remaining = getRemainingIssueIds(remaining, metric); + } + } + + RemediationMetric aggregatedMetric = aggregateMetrics(issueIdFilter, metrics); + String action = aggregatedMetric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; + // Only report durable zip-relative paths for entries actually processed this run + List processedEntries = allEntryPaths.subList(0, Math.min(metrics.size(), allEntryPaths.size())); + List processedArtifactIds = allArtifactIds.subList(0, Math.min(metrics.size(), allArtifactIds.size())); + return AviatorSSCApplyRemediationsHelper.buildCacheResultNode( + new AviatorSSCApplyRemediationsHelper.CacheResultData( + cacheZip, + List.copyOf(processedEntries), + List.copyOf(processedArtifactIds), + appVersionId, + metrics.size(), + 0, + 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() { @@ -114,8 +180,9 @@ private Set getIssueIdFilter() { } private void validateIssueIdFilterMode() { - if (issueIds != null && !issueIds.isEmpty() && !artifactSelector.isLocalFprSelected()) { - throw new FcliSimpleException("--issue-ids can only be used with --fpr; download the FPR once with download-remediations-fpr and rerun with --fpr"); + 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"); } } @@ -148,10 +215,6 @@ static Set getRemainingIssueIds(Set requestedIssueIds, Remediati return remainingIssueIds; } - /** - * Inner class to encapsulate artifact processing logic, avoiding the need to pass - * unirest, logger, and progressWriter through multiple method calls. - */ @RequiredArgsConstructor private class ArtifactProcessor { private final UnirestInstance unirest; @@ -161,30 +224,30 @@ private class ArtifactProcessor { @SneakyThrows JsonNode processAllAviatorArtifacts(OffsetDateTime sinceDate) { - String appVersionId = artifactSelector.getAppVersionId(unirest); + String appVersionId = sourceSelector.getAppVersionId(unirest); List artifacts = SSCArtifactHelper.getAllAviatorArtifacts(unirest, appVersionId, sinceDate); - + 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.totalRemediations(), aggregatedMetric.appliedRemediations(), aggregatedMetric.skippedRemediations(), aggregatedMetric.modifiedFiles(), action); } - + 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 (shouldStopProcessing(remaining)) { + 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()); @@ -196,7 +259,7 @@ private ArtifactBatchResult processBatchOfArtifacts(List } return new ArtifactBatchResult(processed, skipped, metrics); } - + @SneakyThrows private ArtifactProcessResult processSingleArtifact(SSCArtifactDescriptor ad, int index, int total, Set issueFilter) { logger.progress("Processing artifact " + index + "/" + total + " (id=" + ad.getId() + ")"); @@ -211,56 +274,14 @@ private ArtifactProcessResult processSingleArtifact(SSCArtifactDescriptor ad, in LOG.warn("Skipping artifact {} as {}", ad.getId(), e.getMessage()); return ArtifactProcessResult.failure(); } finally { - cleanupFprFile(fprPath); - } - } - - private void cleanupFprFile(Path fprPath) { - if (fprPath != null) { - try { - Files.deleteIfExists(fprPath); - } catch (IOException e) { - LOG.warn("Failed to delete temporary FPR file: {}", fprPath, e); - } - } - } - - private boolean shouldStopProcessing(Set remaining) { - return remaining != null && remaining.isEmpty(); - } - - @SneakyThrows - JsonNode processLocalFprRemediations(List fprPaths) { - AviatorLocalFprHelper.validateLocalFprs(fprPaths); - List metrics = new java.util.ArrayList<>(); - Set remaining = issueIdFilter == null ? null : new LinkedHashSet<>(issueIdFilter); - - for (int i = 0; i < fprPaths.size(); i++) { - if (shouldStopProcessing(remaining)) { - break; - } - Path fprPath = fprPaths.get(i); - logger.progress("Processing FPR " + (i + 1) + "/" + fprPaths.size() + " (" + fprPath + ")"); - 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); - remaining = getRemainingIssueIds(remaining, metric); + if (fprPath != null) { + try { + Files.deleteIfExists(fprPath); + } catch (IOException e) { + LOG.warn("Failed to delete temporary FPR file: {}", fprPath, e); + } } } - - RemediationMetric aggregatedMetric = aggregateMetrics(issueIdFilter, metrics); - String action = aggregatedMetric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; - return AviatorSSCApplyRemediationsHelper.buildLocalFprResultNode( - new AviatorSSCApplyRemediationsHelper.LocalFprResultData( - fprPaths, - metrics.size(), - 0, - aggregatedMetric.totalRemediations(), - aggregatedMetric.appliedRemediations(), - aggregatedMetric.skippedRemediations(), - aggregatedMetric.modifiedFiles(), - action)); } @SneakyThrows @@ -284,7 +305,9 @@ JsonNode processFprRemediations(SSCArtifactDescriptor ad) { try (FprHandle fprHandle = new FprHandle(fprPath)) { 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 { @@ -297,7 +320,9 @@ JsonNode processFprRemediations(SSCArtifactDescriptor ad) { } @Override - public boolean isSingular() { return true; } + public boolean isSingular() { + return true; + } @Override public String getActionCommandResult() { @@ -308,17 +333,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(); } - + 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(); } + + 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 8d968bf5e6e..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,7 +22,7 @@ AviatorSSCAuditCommand.class, AviatorSSCPrepareCommand.class, AviatorSSCApplyRemediationsCommand.class, - AviatorSSCDownloadRemediationsFprCommand.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..e91c8cfe071 --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsCacheCommand.java @@ -0,0 +1,164 @@ +/* + * 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 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 { + @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 ignored) { + // best effort + } + } + } + } + + 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/cmd/AviatorSSCDownloadRemediationsFprCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsFprCommand.java deleted file mode 100644 index 10159886d5f..00000000000 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsFprCommand.java +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright 2021-2026 Open Text. - * - * The only warranties for products and services of Open Text - * and its affiliates and licensors ("Open Text") are as may - * be set forth in the express warranty statements accompanying - * such products and services. Nothing herein should be construed - * as constituting an additional warranty. Open Text shall not be - * liable for technical or editorial errors or omissions contained - * herein. The information contained herein is subject to change - * without notice. - */ -package com.fortify.cli.aviator.ssc.cli.cmd; - -import java.io.File; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.OffsetDateTime; -import java.util.List; - -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.config.AviatorLoggerImpl; -import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCRemediationsFprDownloadSelectorMixin; -import com.fortify.cli.aviator.ssc.helper.SinceOptionHelper; -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.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-fpr") -public class AviatorSSCDownloadRemediationsFprCommand extends AbstractSSCJsonNodeOutputCommand implements IActionCommandResultSupplier { - @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; - @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; - @Mixin private AviatorSSCRemediationsFprDownloadSelectorMixin artifactSelector; - @Option(names = {"-f", "--file"}, paramLabel = "", descriptionKey = "fcli.aviator.ssc.download-remediations-fpr.file") - private File outputFile; - @Option(names = {"--dir"}, paramLabel = "", descriptionKey = "fcli.aviator.ssc.download-remediations-fpr.dir") - private Path outputDir; - - @Override - @SneakyThrows - public JsonNode getJsonNode(UnirestInstance unirest) { - artifactSelector.validate(); - validateDestinationOptions(); - OffsetDateTime sinceDate = SinceOptionHelper.parse(artifactSelector.getSince()); - try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { - AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); - if (artifactSelector.isAllOpenIssuesSelected()) { - return downloadAll(unirest, sinceDate, logger, progressWriter); - } - SSCArtifactDescriptor artifact = resolveArtifactDescriptor(unirest, sinceDate); - Path destination = getSingleDestination(artifact); - downloadArtifact(unirest, artifact, destination, logger, progressWriter); - return buildSingleResultNode(artifact, destination); - } - } - - private SSCArtifactDescriptor resolveArtifactDescriptor(UnirestInstance unirest, OffsetDateTime sinceDate) { - if (artifactSelector.isLatestSelected()) { - String appVersionId = artifactSelector.getAppVersionId(unirest); - return SSCArtifactHelper.getLatestAviatorArtifact(unirest, appVersionId, sinceDate); - } - return SSCArtifactHelper.requireAviatorArtifact( - SSCArtifactHelper.getArtifactDescriptor(unirest, artifactSelector.getArtifactId())); - } - - @SneakyThrows - private JsonNode downloadAll(UnirestInstance unirest, OffsetDateTime sinceDate, AviatorLoggerImpl logger, IProgressWriter progressWriter) { - Files.createDirectories(outputDir); - if (!Files.isDirectory(outputDir)) { - throw new FcliSimpleException("--dir must specify a directory: " + outputDir); - } - - String appVersionId = artifactSelector.getAppVersionId(unirest); - List artifacts = SSCArtifactHelper.getAllAviatorArtifacts(unirest, appVersionId, sinceDate); - ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); - result.put("appVersionId", appVersionId); - result.put("artifactsDownloaded", artifacts.size()); - ArrayNode files = result.putArray("files"); - ArrayNode artifactIds = result.putArray("artifactIds"); - for (int i = 0; i < artifacts.size(); i++) { - SSCArtifactDescriptor artifact = artifacts.get(i); - Path destination = outputDir.resolve(String.format("%03d_remediations_artifact_%s.fpr", i + 1, artifact.getId())); - downloadArtifact(unirest, artifact, destination, logger, progressWriter); - files.add(destination.toString()); - artifactIds.add(artifact.getId()); - } - result.put(IActionCommandResultSupplier.actionFieldName, getActionCommandResult()); - return result; - } - - private Path getSingleDestination(SSCArtifactDescriptor artifact) { - return outputFile == null ? Path.of(String.format("remediations_artifact_%s.fpr", artifact.getId())) : outputFile.toPath(); - } - - private void validateDestinationOptions() { - if (artifactSelector.isAllOpenIssuesSelected()) { - if (outputFile != null) { - throw new FcliSimpleException("-f/--file cannot be used with --all; use --dir"); - } - if (outputDir == null) { - throw new FcliSimpleException("--dir must be specified when using --all"); - } - } else if (outputDir != null) { - throw new FcliSimpleException("--dir can only be used with --all; use -f/--file for a single FPR download"); - } - } - - 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 buildSingleResultNode(SSCArtifactDescriptor artifact, Path destination) { - ObjectNode result = artifact.asObjectNode(); - result.put("artifactsDownloaded", 1); - result.putArray("artifactIds").add(artifact.getId()); - result.putArray("files").add(destination.toString()); - result.put("file", destination.toString()); - result.put(IActionCommandResultSupplier.actionFieldName, getActionCommandResult()); - return result; - } - - @Override - public String getActionCommandResult() { - return "REMEDIATIONS_FPR_DOWNLOADED"; - } - - @Override - public boolean isSingular() { - return true; - } -} \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCApplyRemediationsArtifactSelectorMixin.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCApplyRemediationsArtifactSelectorMixin.java deleted file mode 100644 index b4838ed19c0..00000000000 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCApplyRemediationsArtifactSelectorMixin.java +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Copyright 2021-2026 Open Text. - * - * The only warranties for products and services of Open Text - * and its affiliates and licensors ("Open Text") are as may - * be set forth in the express warranty statements accompanying - * such products and services. Nothing herein should be construed - * as constituting an additional warranty. Open Text shall not be - * liable for technical or editorial errors or omissions contained - * herein. The information contained herein is subject to change - * without notice. - */ -package com.fortify.cli.aviator.ssc.cli.mixin; - -import java.nio.file.Path; -import java.util.Collections; -import java.util.List; - -import org.apache.commons.lang3.StringUtils; - -import com.fortify.cli.common.exception.FcliSimpleException; -import com.fortify.cli.common.util.DisableTest; -import com.fortify.cli.common.util.DisableTest.TestType; -import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; -import com.fortify.cli.ssc.appversion.helper.SSCAppVersionHelper; - -import kong.unirest.UnirestInstance; -import lombok.Getter; -import picocli.CommandLine.ArgGroup; -import picocli.CommandLine.Option; - -/** - * Mixin for selecting which artifact(s) to process for apply-remediations command. - * Uses Picocli ArgGroups to enforce mutually exclusive options. - */ -@Getter -public class AviatorSSCApplyRemediationsArtifactSelectorMixin { - - @ArgGroup(exclusive = true, multiplicity = "1") - private ArtifactSelectionArgGroup artifactSelection; - - @Option(names = {"--since"}, descriptionKey = "fcli.aviator.ssc.apply-remediations.since") - private String since; - - // Options needed by --latest and --all-open-issues (not by --artifact-id) - @Option(names = {"--appversion", "--av"}, descriptionKey = "fcli.ssc.appversion.resolver.nameOrId") - private String appVersionNameOrId; - - @Option(names = {"--delim"}, defaultValue = ":") - private String delimiter; - - @Getter - public static class ArtifactSelectionArgGroup { - @Option(names = {"--artifact-id"}, required = true, descriptionKey = "fcli.aviator.ssc.apply-remediations.artifact-id") - private String artifactId; - - @Option(names = {"--latest"}, required = true, descriptionKey = "fcli.aviator.ssc.apply-remediations.latest") - private boolean latest; - - @Option(names = {"--all"}, required = true, descriptionKey = "fcli.aviator.ssc.apply-remediations.all") - private boolean allOpenIssues; - - @Option(names = {"--fpr"}, required = true, arity = "1..*", paramLabel = "", - descriptionKey = "fcli.aviator.ssc.apply-remediations.fpr") - @DisableTest({TestType.MULTI_OPT_SPLIT, TestType.MULTI_OPT_PLURAL_NAME, TestType.OPT_ARITY_VARIABLE}) - private List fprPaths; - } - - public boolean isArtifactIdSelected() { - return artifactSelection != null && StringUtils.isNotBlank(artifactSelection.artifactId); - } - - public boolean isLatestSelected() { - return artifactSelection != null && artifactSelection.latest; - } - - public boolean isAllOpenIssuesSelected() { - return artifactSelection != null && artifactSelection.allOpenIssues; - } - - public boolean isLocalFprSelected() { - return artifactSelection != null && artifactSelection.fprPaths != null && !artifactSelection.fprPaths.isEmpty(); - } - - public String getArtifactId() { - return isArtifactIdSelected() ? artifactSelection.artifactId : null; - } - - public List getFprPaths() { - return isLocalFprSelected() ? artifactSelection.fprPaths : Collections.emptyList(); - } - - public String getAppVersionNameOrId() { - return appVersionNameOrId; - } - - public String getAppVersionId(UnirestInstance unirest) { - if (StringUtils.isBlank(appVersionNameOrId)) { - return null; - } - SSCAppVersionDescriptor descriptor = SSCAppVersionHelper.getRequiredAppVersion( - unirest, appVersionNameOrId, delimiter, "id"); - return descriptor.getVersionId(); - } - - public void validate() { - if (isLocalFprSelected()) { - if (StringUtils.isNotBlank(since)) { - throw new FcliSimpleException("--since cannot be used with --fpr"); - } - if (StringUtils.isNotBlank(appVersionNameOrId)) { - throw new FcliSimpleException("--av/--appversion cannot be used with --fpr"); - } - return; - } - - // Validate --since is only used with --latest or --all-open-issues - if (since != null && !since.isBlank() && isArtifactIdSelected()) { - throw new FcliSimpleException( - "--since cannot be used with --artifact-id; use --latest or --all-open-issues"); - } - - // Validate --av is required with --latest or --all-open-issues - if ((isLatestSelected() || isAllOpenIssuesSelected()) && StringUtils.isBlank(appVersionNameOrId)) { - throw new FcliSimpleException( - "--av/--appversion is required when using --latest or --all-open-issues"); - } - - // Validate --av is not used with --artifact-id - if (isArtifactIdSelected() && StringUtils.isNotBlank(appVersionNameOrId)) { - throw new FcliSimpleException( - "--av/--appversion cannot be used with --artifact-id"); - } - } -} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCApplyRemediationsSourceMixin.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCApplyRemediationsSourceMixin.java new file mode 100644 index 00000000000..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/AviatorSSCRemediationsFprDownloadSelectorMixin.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsCacheDownloadSelectorMixin.java similarity index 80% rename from fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsFprDownloadSelectorMixin.java rename to fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsCacheDownloadSelectorMixin.java index 905c55dffef..d9b5e5b8f2b 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsFprDownloadSelectorMixin.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsCacheDownloadSelectorMixin.java @@ -24,11 +24,11 @@ import picocli.CommandLine.Option; @Getter -public class AviatorSSCRemediationsFprDownloadSelectorMixin { +public class AviatorSSCRemediationsCacheDownloadSelectorMixin { @ArgGroup(exclusive = true, multiplicity = "1") private ArtifactSelectionArgGroup artifactSelection; - @Option(names = {"--since"}, descriptionKey = "fcli.aviator.ssc.download-remediations-fpr.since") + @Option(names = {"--since"}, descriptionKey = "fcli.aviator.ssc.download-remediations-cache.since") private String since; @Option(names = {"--appversion", "--av"}, descriptionKey = "fcli.ssc.appversion.resolver.nameOrId") @@ -39,14 +39,14 @@ public class AviatorSSCRemediationsFprDownloadSelectorMixin { @Getter public static class ArtifactSelectionArgGroup { - @Option(names = {"--artifact-id"}, required = true, descriptionKey = "fcli.aviator.ssc.download-remediations-fpr.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.download-remediations-fpr.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.download-remediations-fpr.all") - private boolean allOpenIssues; + @Option(names = {"--all"}, required = true, descriptionKey = "fcli.aviator.ssc.download-remediations-cache.all") + private boolean all; } public boolean isArtifactIdSelected() { @@ -57,8 +57,8 @@ 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() { @@ -74,15 +74,28 @@ public String getAppVersionId(UnirestInstance unirest) { return descriptor.getVersionId(); } + public String getSelectionMode() { + if (isArtifactIdSelected()) { + return "artifact-id"; + } + if (isLatestSelected()) { + return "latest"; + } + if (isAllSelected()) { + return "all"; + } + return null; + } + public void validate() { if (StringUtils.isNotBlank(since) && isArtifactIdSelected()) { throw new FcliSimpleException("--since cannot be used with --artifact-id; use --latest or --all"); } - if ((isLatestSelected() || isAllOpenIssuesSelected()) && StringUtils.isBlank(appVersionNameOrId)) { + 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"); } } -} \ No newline at end of file +} 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 e088ae6762f..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 @@ -23,33 +23,17 @@ 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() {} - public record LocalFprResultData( - List fprPaths, - int fprsProcessed, - int fprsSkipped, - int totalRemediation, - int appliedRemediation, - int skippedRemediation, - Set modifiedFiles, - String action) {} - /** * 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()); @@ -64,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) { @@ -90,21 +66,40 @@ public static ObjectNode buildAggregatedResultNode(String appVersionId, int arti return result; } - public static ObjectNode buildLocalFprResultNode(LocalFprResultData resultData) { + /** + * 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", "N/A"); + 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("fprs", toPathArrayNode(resultData.fprPaths())); + 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) { @@ -113,10 +108,10 @@ private static ArrayNode toArrayNode(Set files) { return array; } - private static ArrayNode toPathArrayNode(List paths) { + private static ArrayNode toStringArrayNode(List values) { ArrayNode array = JsonHelper.getObjectMapper().createArrayNode(); - if (paths != null) { - paths.stream().map(Path::toString).forEach(array::add); + 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 f2ceef8253a..fde72eca9e2 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,12 +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), or reads one or more local FPR files, and applies Fortify Remediation Aviator-generated remediations to the specified source directory. \ - Exactly one of --fpr, --artifact-id, --latest, or --all must be specified. Local FPR files supplied with --fpr are processed in command-line order. \ - 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 local FPR files: \ - %n fcli aviator ssc apply-remediations --fpr ./001_remediations_artifact_123.fpr ./002_remediations_artifact_456.fpr \ + %n%n Apply remediations from a remediations cache zip: \ + %n fcli aviator ssc apply-remediations --from-cache ./remediations.zip \ + %n%n Apply selected issue IDs from a cache zip: \ + %n fcli aviator ssc apply-remediations --from-cache ./remediations.zip --issue-ids id1,id2 \ %n%n Apply remediations from a known artifact by ID: \ %n fcli aviator ssc apply-remediations --artifact-id 12345 \ %n%n Apply remediations from the latest Fortify Remediation Aviator-processed artifact for an application version: \ @@ -152,30 +153,28 @@ 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. -fcli.aviator.ssc.apply-remediations.fpr = One or more local audited FPR files containing Fortify Remediation Aviator remediations. \ - Files are processed in command-line order. Mutually exclusive with --artifact-id, --latest, and --all. + 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 --fpr so integrations can download the FPR once and apply selected remediations without repeated SSC downloads. + 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. - -fcli.aviator.ssc.download-remediations-fpr.usage.header = Download audited FPR file(s) containing Fortify Remediation Aviator remediations from SSC. -fcli.aviator.ssc.download-remediations-fpr.usage.description = Downloads reusable audited FPR file(s) from SSC so they can be passed to apply-remediations with --fpr. \ - Existing destination files are overwritten. For --all, files are downloaded in the same order used by apply-remediations --all and generated file names include a numeric order prefix. \ - Exactly one of --artifact-id, --latest, or --all must be specified. -fcli.aviator.ssc.download-remediations-fpr.artifact-id = Specific artifact ID to download. Mutually exclusive with --latest and --all. -fcli.aviator.ssc.download-remediations-fpr.latest = Download the most recent Fortify Remediation Aviator-processed artifact. Requires --av/--appversion. Mutually exclusive with --artifact-id and --all. -fcli.aviator.ssc.download-remediations-fpr.all = Download all Fortify Remediation Aviator-processed artifacts for the given application version. Requires --av/--appversion and --dir. Mutually exclusive with --artifact-id and --latest. -fcli.aviator.ssc.download-remediations-fpr.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-fpr.file = Destination FPR file for a single download. Existing files are overwritten. Cannot be used with --all. -fcli.aviator.ssc.download-remediations-fpr.dir = Destination directory for --all downloads. The directory is created if needed. Existing generated files are overwritten. + 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.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') \ @@ -240,7 +239,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-fpr.output.table.args = artifactsDownloaded,artifactIds,files,__action__ +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 index df03530fcd2..d0b51b3a7de 100644 --- a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java @@ -14,6 +14,7 @@ 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; @@ -24,6 +25,7 @@ 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; @@ -31,33 +33,34 @@ class AviatorSSCApplyRemediationsCommandTest { @Test void testNormalizeIssueIdsTrimsAndDeduplicates() throws Exception { - AviatorSSCApplyRemediationsCommand command = parse("--artifact-id", "1", "--issue-ids", " ISSUE-1 , , ISSUE-2,ISSUE-1 "); + 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("--artifact-id", "1", "--issue-ids", " , , "); + AviatorSSCApplyRemediationsCommand command = parse("--from-cache", "cache.zip", "--issue-ids", " , , "); assertThrows(FcliSimpleException.class, () -> AviatorIssueIdFilterUtils.normalizeIssueIds(getIssueIds(command))); } @Test - void testFprOptionParsesOrderedPaths() throws Exception { - AviatorSSCApplyRemediationsCommand command = parse("--fpr", "first.fpr", "second.fpr"); - - assertEquals(List.of(Path.of("first.fpr"), Path.of("second.fpr")), getFprPaths(command)); + 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 testFprOptionCannotBeCombinedWithArtifactId() { - assertThrows(CommandLine.ParameterException.class, () -> parse("--artifact-id", "1", "--fpr", "local.fpr")); + void testFromCacheCannotBeCombinedWithArtifactId() { + assertThrows(CommandLine.ParameterException.class, + () -> parse("--artifact-id", "1", "--from-cache", "cache.zip")); } @Test - void testIssueIdsWithoutFprThrowsException() { + void testIssueIdsWithoutFromCacheThrowsException() { AviatorSSCApplyRemediationsCommand command = parse("--artifact-id", "1", "--issue-ids", "ISSUE-1"); - assertThrows(FcliSimpleException.class, () -> command.getJsonNode(null)); + assertThrows(FcliSimpleException.class, command::getJsonNode); } @Test @@ -96,11 +99,9 @@ private static List getIssueIds(AviatorSSCApplyRemediationsCommand comma return (List) field.get(command); } - @SuppressWarnings("unchecked") - private static List getFprPaths(AviatorSSCApplyRemediationsCommand command) throws Exception { - Field artifactSelectorField = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("artifactSelector"); - artifactSelectorField.setAccessible(true); - Object artifactSelector = artifactSelectorField.get(command); - return (List) artifactSelector.getClass().getMethod("getFprPaths").invoke(artifactSelector); + private static AviatorSSCApplyRemediationsSourceMixin getSourceMixin(AviatorSSCApplyRemediationsCommand command) throws Exception { + Field field = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("sourceSelector"); + field.setAccessible(true); + return (AviatorSSCApplyRemediationsSourceMixin) field.get(command); } -} \ No newline at end of file +} diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsFprCommandTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsCacheCommandTest.java similarity index 56% rename from fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsFprCommandTest.java rename to fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsCacheCommandTest.java index 87a2b73d455..9d14f52efb3 100644 --- a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsFprCommandTest.java +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsCacheCommandTest.java @@ -20,37 +20,29 @@ import picocli.CommandLine; -class AviatorSSCDownloadRemediationsFprCommandTest { +class AviatorSSCDownloadRemediationsCacheCommandTest { @Test void testArtifactIdAndLatestAreMutuallyExclusive() { assertThrows(CommandLine.ParameterException.class, - () -> parse("--artifact-id", "1", "--latest")); + () -> parse("--artifact-id", "1", "--latest", "-f", "cache.zip")); } @Test - void testAllRejectsFileOption() { - AviatorSSCDownloadRemediationsFprCommand command = parse("--all", "--av", "1", "-f", "one.fpr"); - - assertThrows(FcliSimpleException.class, () -> command.getJsonNode(null)); - } - - @Test - void testAllRequiresDirectoryOption() { - AviatorSSCDownloadRemediationsFprCommand command = parse("--all", "--av", "1"); - - assertThrows(FcliSimpleException.class, () -> command.getJsonNode(null)); + void testFileIsRequired() { + assertThrows(CommandLine.ParameterException.class, + () -> parse("--artifact-id", "1")); } @Test void testArtifactIdRejectsAppVersion() { - AviatorSSCDownloadRemediationsFprCommand command = parse("--artifact-id", "1", "--av", "2"); + AviatorSSCDownloadRemediationsCacheCommand command = parse("--artifact-id", "1", "--av", "2", "-f", "cache.zip"); assertThrows(FcliSimpleException.class, () -> command.getJsonNode(null)); } - private static AviatorSSCDownloadRemediationsFprCommand parse(String... args) { - AviatorSSCDownloadRemediationsFprCommand command = new AviatorSSCDownloadRemediationsFprCommand(); + private static AviatorSSCDownloadRemediationsCacheCommand parse(String... args) { + AviatorSSCDownloadRemediationsCacheCommand command = new AviatorSSCDownloadRemediationsCacheCommand(); new CommandLine(command).parseArgs(args); return command; } -} \ No newline at end of file +} 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 535895cfa9c..ce5dae6704f 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 @@ -25,6 +25,7 @@ import org.slf4j.LoggerFactory; import com.fasterxml.jackson.databind.JsonNode; +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; @@ -32,56 +33,81 @@ 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; import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; import com.fortify.cli.common.progress.helper.IProgressWriter; import com.fortify.cli.common.rest.unirest.HttpHeader; -import com.fortify.cli.common.util.DisableTest; -import com.fortify.cli.common.util.DisableTest.TestType; 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.OptionalOption 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"); - @Option(names = {"--issue-ids"}, split = ",") + + @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; - @Option(names = {"--fpr"}, arity = "1..*", paramLabel = "", descriptionKey = "fcli.fod.aviator.apply-remediations.fpr") - @DisableTest({TestType.MULTI_OPT_SPLIT, TestType.MULTI_OPT_PLURAL_NAME, TestType.OPT_ARITY_VARIABLE}) - private List fprPaths; - @Override @SneakyThrows - public JsonNode getJsonNode(UnirestInstance unirest) { + @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); - if (isLocalFprSelected()) { - return processLocalFprRemediations(logger, issueIdFilter); + if (isFromCacheSelected()) { + return processFromCache(logger, issueIdFilter); } - FoDReleaseDescriptor rd = releaseResolver.getReleaseDescriptor(unirest); + UnirestInstance unirest = unirestInstanceSupplier.getUnirestInstance(); + FoDReleaseDescriptor rd = FoDReleaseHelper.getReleaseDescriptor( + unirest, source.online.qualifiedReleaseNameOrId, delimiterMixin.getDelimiter(), true); return processFprRemediations(unirest, rd, logger, issueIdFilter); } } @@ -97,45 +123,63 @@ private Set getIssueIdFilter() { } private void validateSelection() { - boolean releaseSelected = releaseResolver.getQualifiedReleaseNameOrId() != null && !releaseResolver.getQualifiedReleaseNameOrId().isBlank(); - boolean localFprSelected = isLocalFprSelected(); - if (releaseSelected == localFprSelected) { - throw new FcliSimpleException("Exactly one of --release/--rel or --fpr must be specified"); - } - if (issueIds != null && !issueIds.isEmpty() && !localFprSelected) { - throw new FcliSimpleException("--issue-ids can only be used with --fpr; download the FPR once with download-remediations-fpr and rerun with --fpr"); + 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 isLocalFprSelected() { - return fprPaths != null && !fprPaths.isEmpty(); + private boolean isFromCacheSelected() { + return source != null && source.fromCache != null; } @SneakyThrows - private JsonNode processLocalFprRemediations(AviatorLoggerImpl logger, Set issueIdFilter) { - AviatorLocalFprHelper.validateLocalFprs(fprPaths); - List metrics = new java.util.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); - logger.progress("Processing FPR " + (i + 1) + "/" + fprPaths.size() + " (" + fprPath + ")"); - 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); - remaining = getRemainingIssueIds(remaining, metric); + 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 java.util.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); + remaining = getRemainingIssueIds(remaining, metric); + } } - } - RemediationMetric aggregatedMetric = aggregateMetrics(issueIdFilter, metrics); - String status = aggregatedMetric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; - return AviatorFoDApplyRemediationsHelper.buildLocalFprResultNode(fprPaths, - aggregatedMetric.totalRemediations(), aggregatedMetric.appliedRemediations(), - aggregatedMetric.skippedRemediations(), aggregatedMetric.modifiedFiles(), status); + RemediationMetric aggregatedMetric = aggregateMetrics(issueIdFilter, metrics); + String status = aggregatedMetric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; + List processedEntries = allEntryPaths.subList(0, Math.min(metrics.size(), allEntryPaths.size())); + List processedReleaseIds = allReleaseIds.subList(0, Math.min(metrics.size(), allReleaseIds.size())); + return AviatorFoDApplyRemediationsHelper.buildCacheResultNode( + cacheZip, + List.copyOf(processedEntries), + List.copyOf(processedReleaseIds), + aggregatedMetric.totalRemediations(), + aggregatedMetric.appliedRemediations(), + aggregatedMetric.skippedRemediations(), + aggregatedMetric.modifiedFiles(), + status); + } } static RemediationMetric aggregateMetrics(Set requestedIssueIds, Collection metrics) { @@ -181,7 +225,9 @@ private JsonNode processFprRemediations(UnirestInstance unirest, FoDReleaseDescr 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) { @@ -195,7 +241,7 @@ private JsonNode processFprRemediations(UnirestInstance unirest, FoDReleaseDescr } @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); @@ -203,17 +249,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; } @@ -221,7 +280,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()); } @@ -231,7 +289,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 4513aa9f85a..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 @@ -20,7 +20,7 @@ name = "aviator", subcommands = { FoDAviatorApplyRemediationsCommand.class, - FoDAviatorDownloadRemediationsFprCommand.class + FoDAviatorDownloadRemediationsCacheCommand.class } ) diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorDownloadRemediationsFprCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorDownloadRemediationsCacheCommand.java similarity index 60% rename from fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorDownloadRemediationsFprCommand.java rename to fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorDownloadRemediationsCacheCommand.java index f06c02ebcd7..428f2b2750d 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorDownloadRemediationsFprCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorDownloadRemediationsCacheCommand.java @@ -13,11 +13,20 @@ 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; @@ -39,24 +48,50 @@ import picocli.CommandLine.Mixin; import picocli.CommandLine.Option; -@Command(name = "download-remediations-fpr") -public class FoDAviatorDownloadRemediationsFprCommand extends AbstractFoDJsonNodeOutputCommand implements IActionCommandResultSupplier { +@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; - @Option(names = {"-f", "--file"}, paramLabel = "", descriptionKey = "fcli.fod.aviator.download-remediations-fpr.file") + @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); - Path destination = getDestination(releaseDescriptor); GetRequest request = getDownloadRequest(unirest, releaseDescriptor, scanDescriptor); int status = 202; @@ -71,14 +106,15 @@ public JsonNode getJsonNode(UnirestInstance unirest) { } } if (status == 202) { + Files.deleteIfExists(destination); throw new FcliSimpleException("Timed out waiting for FoD remediations FPR download to complete after " + MAX_RETRIES + " retries"); } - return buildResultNode(releaseDescriptor, destination); - } - - private Path getDestination(FoDReleaseDescriptor releaseDescriptor) { - return outputFile == null ? Path.of(String.format("remediations_release_%s.fpr", releaseDescriptor.getReleaseId())) : outputFile.toPath(); + 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) { @@ -88,23 +124,21 @@ private GetRequest getDownloadRequest(UnirestInstance unirest, FoDReleaseDescrip .queryString("scanType", scanDescriptor.getScanType()); } - private ObjectNode buildResultNode(FoDReleaseDescriptor releaseDescriptor, Path destination) { + private ObjectNode buildResultNode(Path destination, FoDReleaseDescriptor releaseDescriptor, RemediationsCacheManifest manifest) { ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); - result.put("releasesDownloaded", 1); - result.putArray("releaseIds").add(releaseDescriptor.getReleaseId()); - result.putArray("files").add(destination.toString()); result.put("file", destination.toString()); - result.put(IActionCommandResultSupplier.actionFieldName, getActionCommandResult()); + result.put("releasesDownloaded", manifest.getEntries().size()); + result.putArray("releaseIds").add(releaseDescriptor.getReleaseId()); return result; } @Override public String getActionCommandResult() { - return "REMEDIATIONS_FPR_DOWNLOADED"; + return "REMEDIATIONS_CACHE_DOWNLOADED"; } @Override public boolean isSingular() { return true; } -} \ No newline at end of file +} diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/AviatorFoDApplyRemediationsHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/AviatorFoDApplyRemediationsHelper.java index c30955a30e8..7a553a02882 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 @@ -22,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()); @@ -48,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()); @@ -61,17 +55,24 @@ public static ObjectNode buildResultNode(FoDReleaseDescriptor rd, int totalRemed return result; } - public static ObjectNode buildLocalFprResultNode(List fprPaths, int totalRemediation, int appliedRemediation, - int skippedRemediation, Set modifiedFiles, String action) { + /** + * 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(Path cacheZip, List entryPaths, List releaseIds, + int totalRemediation, int appliedRemediation, int skippedRemediation, Set modifiedFiles, + String action) { ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); - result.put("releaseId", "N/A"); + result.put("releaseId", releaseIds != null && !releaseIds.isEmpty() ? releaseIds.get(0) : "N/A"); result.put("applicationName", "N/A"); result.put("releaseName", "N/A"); + result.put("file", cacheZip.toString()); result.put("totalRemediation", totalRemediation); result.put("appliedRemediation", appliedRemediation); result.put("skippedRemediation", skippedRemediation); result.set("modifiedFiles", toArrayNode(modifiedFiles)); - result.set("fprs", toPathArrayNode(fprPaths)); + result.set("entries", toStringArrayNode(entryPaths)); + result.set("releaseIds", toStringArrayNode(releaseIds)); result.put(IActionCommandResultSupplier.actionFieldName, action); return result; } @@ -84,10 +85,10 @@ private static ArrayNode toArrayNode(Set files) { return array; } - private static ArrayNode toPathArrayNode(List paths) { + private static ArrayNode toStringArrayNode(List values) { ArrayNode array = JsonHelper.getObjectMapper().createArrayNode(); - if (paths != null) { - paths.stream().map(Path::toString).forEach(array::add); + 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 926a33be2f9..adab6daa64b 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,13 +1037,16 @@ 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.fpr = One or more local audited FPR files containing Fortify Remediation Aviator remediations. Files are processed in command-line order. Mutually exclusive with --release/--rel. +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 --fpr so integrations can download the FPR once and apply selected remediations without repeated FoD downloads. -fcli.fod.aviator.download-remediations-fpr.usage.header = Download an audited FPR file containing Fortify Remediation Aviator remediations from FoD. -fcli.fod.aviator.download-remediations-fpr.usage.description = Downloads the latest static audited FPR for a FoD release so it can be passed to apply-remediations with --fpr. Existing destination files are overwritten. -fcli.fod.aviator.download-remediations-fpr.file = Destination FPR file. Existing files are overwritten. Defaults to remediations_release_.fpr. + 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.confirmPrompt = Overwrite existing remediations cache file %s? # various messages displayed during execution @@ -1096,5 +1099,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-fpr.output.table.args = releasesDownloaded,releaseIds,files,__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 39097e08cf1..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 @@ -15,6 +15,7 @@ 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; @@ -63,70 +64,67 @@ 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("--issue-ids", " ISSUE-1 , , ISSUE-2,ISSUE-1 "); + 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("--issue-ids", " , , "); + FoDAviatorApplyRemediationsCommand command = parse("--from-cache", "cache.zip", "--issue-ids", " , , "); assertThrows(FcliSimpleException.class, () -> AviatorIssueIdFilterUtils.normalizeIssueIds(getIssueIds(command))); } @Test void testIssueIdsOptionParsesIntoField() throws Exception { - FoDAviatorApplyRemediationsCommand command = parse("--issue-ids", "ISSUE-1,ISSUE-2"); + 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 testFprOptionParsesOrderedPaths() throws Exception { - FoDAviatorApplyRemediationsCommand command = parseRaw("--fpr", "first.fpr", "second.fpr"); - - Field field = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("fprPaths"); - field.setAccessible(true); - assertEquals(List.of(Path.of("first.fpr"), Path.of("second.fpr")), field.get(command)); + 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 testReleaseAndFprTogetherThrowsException() { - FoDAviatorApplyRemediationsCommand command = parseRaw("--release", "1", "--fpr", "local.fpr"); - - assertThrows(FcliSimpleException.class, () -> command.getJsonNode(null)); + void testReleaseAndFromCacheTogetherThrowsException() { + assertThrows(CommandLine.ParameterException.class, + () -> parse("--release", "1", "--from-cache", "local.zip")); } @Test - void testIssueIdsWithoutFprThrowsException() { - FoDAviatorApplyRemediationsCommand command = parse("--issue-ids", "ISSUE-1"); + void testIssueIdsWithoutFromCacheThrowsException() { + FoDAviatorApplyRemediationsCommand command = parse("--release", "1", "--issue-ids", "ISSUE-1"); - assertThrows(FcliSimpleException.class, () -> command.getJsonNode(null)); + assertThrows(FcliSimpleException.class, command::getJsonNode); } - private static FoDAviatorApplyRemediationsCommand parse(String... args) { - FoDAviatorApplyRemediationsCommand command = new FoDAviatorApplyRemediationsCommand(); - var fullArgs = new java.util.ArrayList(); - fullArgs.add("--release"); - fullArgs.add("1"); - java.util.Collections.addAll(fullArgs, args); - new CommandLine(command).parseArgs(fullArgs.toArray(String[]::new)); - return command; + @Test + void testOnlineReleaseSelectionParses() { + FoDAviatorApplyRemediationsCommand command = parse("--release", "1"); + assertTrue(command != null); } - private static FoDAviatorApplyRemediationsCommand parseRaw(String... args) { + private static FoDAviatorApplyRemediationsCommand parse(String... args) { FoDAviatorApplyRemediationsCommand command = new FoDAviatorApplyRemediationsCommand(); new CommandLine(command).parseArgs(args); return command; diff --git a/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorDownloadRemediationsFprCommandTest.java b/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorDownloadRemediationsCacheCommandTest.java similarity index 54% rename from fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorDownloadRemediationsFprCommandTest.java rename to fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorDownloadRemediationsCacheCommandTest.java index 8147651d806..e771801a853 100644 --- a/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorDownloadRemediationsFprCommandTest.java +++ b/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorDownloadRemediationsCacheCommandTest.java @@ -16,22 +16,31 @@ import org.junit.jupiter.api.Test; -import com.fortify.cli.fod.aviator.cmd.FoDAviatorDownloadRemediationsFprCommand; +import com.fortify.cli.fod.aviator.cmd.FoDAviatorDownloadRemediationsCacheCommand; import picocli.CommandLine; -class FoDAviatorDownloadRemediationsFprCommandTest { +class FoDAviatorDownloadRemediationsCacheCommandTest { @Test void testReleaseIsRequired() { - FoDAviatorDownloadRemediationsFprCommand command = new FoDAviatorDownloadRemediationsFprCommand(); + FoDAviatorDownloadRemediationsCacheCommand command = new FoDAviatorDownloadRemediationsCacheCommand(); - assertThrows(CommandLine.ParameterException.class, () -> new CommandLine(command).parseArgs()); + 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() { - FoDAviatorDownloadRemediationsFprCommand command = new FoDAviatorDownloadRemediationsFprCommand(); + FoDAviatorDownloadRemediationsCacheCommand command = new FoDAviatorDownloadRemediationsCacheCommand(); - new CommandLine(command).parseArgs("--release", "1", "-f", "remediations_release_1.fpr"); + new CommandLine(command).parseArgs("--release", "1", "-f", "remediations.zip"); } -} \ No newline at end of file +} From 1569055498d4b5f1a5c09436308b89644cfbfe58 Mon Sep 17 00:00:00 2001 From: kireetivar Date: Fri, 17 Jul 2026 12:26:42 +0530 Subject: [PATCH 06/18] chore: add missing descrption key --- .../com/fortify/cli/aviator/i18n/AviatorMessages.properties | 1 + .../resources/com/fortify/cli/fod/i18n/FoDMessages.properties | 1 + 2 files changed, 2 insertions(+) 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 fde72eca9e2..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 @@ -174,6 +174,7 @@ fcli.aviator.ssc.download-remediations-cache.latest = Include the most recent Fo 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. 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 adab6daa64b..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 @@ -1046,6 +1046,7 @@ fcli.fod.aviator.apply-remediations.issue-ids = Comma-separated list of issue ID 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? From 5a0b2d5fda4cbb5d60f2698e44749e4bff8c8309 Mon Sep 17 00:00:00 2001 From: kireetivar Date: Fri, 17 Jul 2026 16:00:08 +0530 Subject: [PATCH 07/18] chore: handle artifacts not containing remediations.xml --- .../cmd/AviatorSSCApplyRemediationsCommand.java | 16 +++++++++++----- .../cmd/FoDAviatorApplyRemediationsCommand.java | 12 +++++++++--- 2 files changed, 20 insertions(+), 8 deletions(-) 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 7ba621b35f1..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,7 @@ 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; @@ -108,7 +109,10 @@ private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdF : null; AviatorLocalFprHelper.validateLocalFprs(fprPaths, "Cache FPR"); - List metrics = new java.util.ArrayList<>(); + 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++) { @@ -122,15 +126,17 @@ private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdF 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"; - // Only report durable zip-relative paths for entries actually processed this run - List processedEntries = allEntryPaths.subList(0, Math.min(metrics.size(), allEntryPaths.size())); - List processedArtifactIds = allArtifactIds.subList(0, Math.min(metrics.size(), allArtifactIds.size())); return AviatorSSCApplyRemediationsHelper.buildCacheResultNode( new AviatorSSCApplyRemediationsHelper.CacheResultData( cacheZip, @@ -138,7 +144,7 @@ private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdF List.copyOf(processedArtifactIds), appVersionId, metrics.size(), - 0, + skipped, aggregatedMetric.totalRemediations(), aggregatedMetric.appliedRemediations(), aggregatedMetric.skippedRemediations(), 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 ce5dae6704f..c5c7d332844 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 @@ -16,6 +16,7 @@ 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; @@ -25,6 +26,7 @@ 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; @@ -148,7 +150,9 @@ private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdF .toList(); AviatorLocalFprHelper.validateLocalFprs(fprPaths, "Cache FPR"); - List metrics = new java.util.ArrayList<>(); + 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++) { @@ -162,14 +166,16 @@ private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdF 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"; - List processedEntries = allEntryPaths.subList(0, Math.min(metrics.size(), allEntryPaths.size())); - List processedReleaseIds = allReleaseIds.subList(0, Math.min(metrics.size(), allReleaseIds.size())); return AviatorFoDApplyRemediationsHelper.buildCacheResultNode( cacheZip, List.copyOf(processedEntries), From ae5417fb360f38908c1e92e37d70c7594771b580 Mon Sep 17 00:00:00 2001 From: kireetivar Date: Fri, 17 Jul 2026 16:22:12 +0530 Subject: [PATCH 08/18] chore: refactor remediations cache handling and improve logging --- ...orSSCDownloadRemediationsCacheCommand.java | 9 ++++-- .../FoDAviatorApplyRemediationsCommand.java | 17 +++++----- .../AviatorFoDApplyRemediationsHelper.java | 32 ++++++++++++------- 3 files changed, 36 insertions(+), 22 deletions(-) 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 index e91c8cfe071..3098febb530 100644 --- 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 @@ -21,6 +21,9 @@ 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; @@ -52,6 +55,8 @@ @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; @@ -91,8 +96,8 @@ public JsonNode getJsonNode(UnirestInstance unirest) { for (Path temp : tempFiles) { try { Files.deleteIfExists(temp); - } catch (Exception ignored) { - // best effort + } catch (Exception e) { + LOG.warn("Failed to delete temp file: " + temp, e); } } } 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 c5c7d332844..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 @@ -177,14 +177,15 @@ private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdF RemediationMetric aggregatedMetric = aggregateMetrics(issueIdFilter, metrics); String status = aggregatedMetric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; return AviatorFoDApplyRemediationsHelper.buildCacheResultNode( - cacheZip, - List.copyOf(processedEntries), - List.copyOf(processedReleaseIds), - aggregatedMetric.totalRemediations(), - aggregatedMetric.appliedRemediations(), - aggregatedMetric.skippedRemediations(), - aggregatedMetric.modifiedFiles(), - status); + new AviatorFoDApplyRemediationsHelper.CacheResultData( + cacheZip, + List.copyOf(processedEntries), + List.copyOf(processedReleaseIds), + aggregatedMetric.totalRemediations(), + aggregatedMetric.appliedRemediations(), + aggregatedMetric.skippedRemediations(), + aggregatedMetric.modifiedFiles(), + status)); } } 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 7a553a02882..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 @@ -59,24 +59,32 @@ public static ObjectNode buildResultNode(FoDReleaseDescriptor rd, int totalRemed * 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(Path cacheZip, List entryPaths, List releaseIds, - int totalRemediation, int appliedRemediation, int skippedRemediation, Set modifiedFiles, - String action) { + public static ObjectNode buildCacheResultNode(CacheResultData resultData) { ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); - result.put("releaseId", releaseIds != null && !releaseIds.isEmpty() ? releaseIds.get(0) : "N/A"); + 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", cacheZip.toString()); - result.put("totalRemediation", totalRemediation); - result.put("appliedRemediation", appliedRemediation); - result.put("skippedRemediation", skippedRemediation); - result.set("modifiedFiles", toArrayNode(modifiedFiles)); - result.set("entries", toStringArrayNode(entryPaths)); - result.set("releaseIds", toStringArrayNode(releaseIds)); - result.put(IActionCommandResultSupplier.actionFieldName, action); + 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) { From 3aa77892febf043054e17ff9bf6dd20878a58ad6 Mon Sep 17 00:00:00 2001 From: kireetivar Date: Mon, 20 Jul 2026 15:22:45 +0530 Subject: [PATCH 09/18] chore: refactor remediations cache selectors and cache zip I/O --- .../RemediationsCacheReader.java | 88 +++++--------- .../RemediationsCacheWriter.java | 56 ++++----- .../RemediationsCacheRoundTripTest.java | 22 ++++ .../AviatorSSCApplyRemediationsCommand.java | 93 ++++++++------ ...orSSCDownloadRemediationsCacheCommand.java | 2 +- ...viatorSSCApplyRemediationsSourceMixin.java | 66 ++-------- ...emediationsCacheDownloadSelectorMixin.java | 72 +++-------- ...iatorSSCRemediationsSelectorArgGroups.java | 105 ++++++++++++++++ .../aviator/i18n/AviatorMessages.properties | 4 + .../fortify/cli/common/util/ZipHelper.java | 26 ++++ .../FoDAviatorApplyRemediationsCommand.java | 114 ++++++++++-------- ...iatorDownloadRemediationsCacheCommand.java | 12 +- ...oDAviatorRemediationSelectorArgGroups.java | 27 +++++ 13 files changed, 400 insertions(+), 287 deletions(-) create mode 100644 fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsSelectorArgGroups.java create mode 100644 fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorRemediationSelectorArgGroups.java 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 index b8f8a6dea0f..f5da09c13d5 100644 --- 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 @@ -13,36 +13,33 @@ package com.fortify.cli.aviator._common.remediations_cache; import java.io.IOException; -import java.io.InputStream; +import java.nio.file.FileSystem; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Comparator; -import java.util.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; +import com.fortify.cli.common.util.ZipHelper; /** * Opens a remediations cache zip, validates the manifest and entry checksums, - * and extracts FPR files to a temp directory for ordered processing. + * and exposes FPR files directly from the zip file system for ordered processing. */ public final class RemediationsCacheReader implements AutoCloseable { private final Path cacheZip; - private final Path extractDir; + private final FileSystem cacheFs; private final RemediationsCacheManifest manifest; private final List orderedFprPaths; - private RemediationsCacheReader(Path cacheZip, Path extractDir, RemediationsCacheManifest manifest, + private RemediationsCacheReader(Path cacheZip, FileSystem cacheFs, RemediationsCacheManifest manifest, List orderedFprPaths) { this.cacheZip = cacheZip; - this.extractDir = extractDir; + this.cacheFs = cacheFs; this.manifest = manifest; this.orderedFprPaths = orderedFprPaths; } @@ -61,23 +58,20 @@ public static RemediationsCacheReader open(Path cacheZip) { throw new FcliSimpleException("Remediations cache file is not readable: " + cacheZip); } - Path extractDir; + FileSystem cacheFs = null; 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) { + cacheFs = ZipHelper.openZipFileSystem(cacheZip); + Path manifestPath = cacheFs.getPath(RemediationsCacheConstants.MANIFEST_ENTRY); + if (!Files.isRegularFile(manifestPath)) { throw new FcliSimpleException("Remediations cache is missing " + RemediationsCacheConstants.MANIFEST_ENTRY + ": " + cacheZip); } - RemediationsCacheManifest manifest = JsonHelper.getObjectMapper() - .readValue(manifestPath.toFile(), RemediationsCacheManifest.class); + RemediationsCacheManifest manifest; + try (var manifestInputStream = Files.newInputStream(manifestPath)) { + manifest = JsonHelper.getObjectMapper() + .readValue(manifestInputStream, RemediationsCacheManifest.class); + } validateManifest(manifest, cacheZip); List entries = new ArrayList<>(manifest.getEntries()); @@ -85,7 +79,7 @@ public static RemediationsCacheReader open(Path cacheZip) { List orderedFprs = new ArrayList<>(); for (RemediationsCacheEntry entry : entries) { - Path fprPath = extracted.get(normalizeZipPath(entry.getPath())); + Path fprPath = getEntryPath(cacheFs, entry.getPath()); if (fprPath == null || !Files.isRegularFile(fprPath)) { throw new FcliSimpleException("Remediations cache entry path not found in zip: " + entry.getPath()); } @@ -101,13 +95,15 @@ public static RemediationsCacheReader open(Path cacheZip) { throw new FcliSimpleException("Remediations cache contains no FPR entries: " + cacheZip); } - return new RemediationsCacheReader(cacheZip, extractDir, manifest, orderedFprs); + FileSystem openCacheFs = cacheFs; + cacheFs = null; + return new RemediationsCacheReader(cacheZip, openCacheFs, manifest, orderedFprs); } catch (FcliSimpleException e) { - deleteRecursivelyQuietly(extractDir); throw e; } catch (IOException e) { - deleteRecursivelyQuietly(extractDir); throw new FcliSimpleException("Failed to read remediations cache: " + cacheZip, e); + } finally { + closeQuietly(cacheFs); } } @@ -125,7 +121,7 @@ public Path getCacheZip() { @Override public void close() { - deleteRecursivelyQuietly(extractDir); + closeQuietly(cacheFs); } private static void validateManifest(RemediationsCacheManifest manifest, Path cacheZip) { @@ -160,46 +156,24 @@ private static void validateManifest(RemediationsCacheManifest manifest, Path ca } } - 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); - } + private static Path getEntryPath(FileSystem cacheFs, String entryPath) { + String normalizedPath = normalizeZipPath(entryPath); + if (normalizedPath.contains("..")) { + throw new FcliSimpleException("Remediations cache contains unsafe path: " + entryPath); } - return extracted; + return cacheFs.getPath(normalizedPath).normalize(); } private static String normalizeZipPath(String path) { return path.replace('\\', '/'); } - private static void deleteRecursivelyQuietly(Path root) { - if (root == null || !Files.exists(root)) { + private static void closeQuietly(FileSystem cacheFs) { + if (cacheFs == null || !cacheFs.isOpen()) { return; } - try (var walk = Files.walk(root)) { - walk.sorted(Comparator.reverseOrder()).forEach(p -> { - try { - Files.deleteIfExists(p); - } catch (IOException ignored) { - // best effort - } - }); + try { + cacheFs.close(); } catch (IOException ignored) { // best effort } 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 index 16aeeaaeb50..73beb09899b 100644 --- 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 @@ -19,11 +19,10 @@ 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; +import com.fortify.cli.common.util.ZipHelper; public final class RemediationsCacheWriter { private RemediationsCacheWriter() {} @@ -69,34 +68,12 @@ public static RemediationsCacheManifest write( 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); + try (var zipFs = ZipHelper.createZipFileSystem(tempZip)) { + Files.createDirectories(zipFs.getPath(RemediationsCacheConstants.FPRS_DIR)); + addFprEntries(zipFs.getPath("/"), manifest, fprSources); byte[] manifestBytes = JsonHelper.getObjectMapper().writerWithDefaultPrettyPrinter() .writeValueAsBytes(manifest); - zos.write(manifestBytes); - zos.closeEntry(); + Files.write(zipFs.getPath(RemediationsCacheConstants.MANIFEST_ENTRY), manifestBytes); } Files.move(tempZip, destination, StandardCopyOption.REPLACE_EXISTING); @@ -115,6 +92,29 @@ public static RemediationsCacheManifest write( } } + private static void addFprEntries(Path zipRoot, RemediationsCacheManifest manifest, List fprSources) throws IOException { + 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()); + + Files.copy(source.path(), zipRoot.resolve(entryPath), StandardCopyOption.REPLACE_EXISTING); + manifest.getEntries().add(toManifestEntry(source, order, entryPath, sha256)); + } + } + + private static RemediationsCacheEntry toManifestEntry(FprSource source, int order, String entryPath, String sha256) { + 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); + return entry; + } + private static String entryPath(FprSource source, int order) { if (source.artifactId() != null && !source.artifactId().isBlank()) { return String.format("%s/%03d_artifact_%s.fpr", 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 index 8eda2500140..ad8d89160b5 100644 --- 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 @@ -27,6 +27,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import com.fortify.cli.aviator.util.FprHandle; import com.fortify.cli.common.exception.FcliSimpleException; class RemediationsCacheRoundTripTest { @@ -65,6 +66,27 @@ void emptySourcesRejected() { RemediationsCacheWriter.write(zip, RemediationsCacheConstants.PRODUCT_SSC, Map.of(), List.of())); } + @Test + void cachedFprCanBeOpenedAsNestedZipFileSystem() throws Exception { + Path fpr = tempDir.resolve("nested.fpr"); + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(fpr))) { + zos.putNextEntry(new ZipEntry("audit.fvdl")); + zos.write("".getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + } + Path zip = tempDir.resolve("cache.zip"); + RemediationsCacheWriter.write( + zip, + RemediationsCacheConstants.PRODUCT_SSC, + Map.of("mode", "artifact-id"), + List.of(RemediationsCacheWriter.FprSource.forSsc(fpr, "1", null))); + + try (RemediationsCacheReader reader = RemediationsCacheReader.open(zip); + FprHandle fprHandle = new FprHandle(reader.getOrderedFprPaths().get(0))) { + assertTrue(Files.exists(fprHandle.getPath("/audit.fvdl"))); + } + } + @Test void badSha256Rejected() throws Exception { Path fpr = tempDir.resolve("one.fpr"); 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 44f14d7cce8..19b232422ba 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 @@ -101,50 +101,20 @@ public JsonNode getJsonNode() { 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); + CacheProcessingResult cacheResult = processCacheEntries(cacheReader, logger, issueIdFilter); + RemediationMetric aggregatedMetric = aggregateMetrics(issueIdFilter, cacheResult.metrics()); String action = aggregatedMetric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; return AviatorSSCApplyRemediationsHelper.buildCacheResultNode( new AviatorSSCApplyRemediationsHelper.CacheResultData( cacheZip, - List.copyOf(processedEntries), - List.copyOf(processedArtifactIds), + cacheResult.processedEntries(), + cacheResult.processedArtifactIds(), appVersionId, - metrics.size(), - skipped, + cacheResult.metrics().size(), + cacheResult.skipped(), aggregatedMetric.totalRemediations(), aggregatedMetric.appliedRemediations(), aggregatedMetric.skippedRemediations(), @@ -153,6 +123,57 @@ private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdF } } + private CacheProcessingResult processCacheEntries(RemediationsCacheReader cacheReader, AviatorLoggerImpl logger, + Set issueIdFilter) { + List fprPaths = cacheReader.getOrderedFprPaths(); + List allEntryPaths = orderedEntryPaths(cacheReader); + List allArtifactIds = orderedArtifactIds(cacheReader); + 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; + } + String entryLabel = i < allEntryPaths.size() ? allEntryPaths.get(i) : fprPaths.get(i).getFileName().toString(); + RemediationMetric metric = processCacheEntry(fprPaths.get(i), entryLabel, i + 1, fprPaths.size(), logger, remaining); + if (metric == null) { + skipped++; + } else { + metrics.add(metric); + processedEntries.add(entryLabel); + processedArtifactIds.add(i < allArtifactIds.size() ? allArtifactIds.get(i) : ""); + remaining = getRemainingIssueIds(remaining, metric); + } + } + return new CacheProcessingResult(List.copyOf(processedEntries), List.copyOf(processedArtifactIds), skipped, metrics); + } + + private RemediationMetric processCacheEntry(Path fprPath, String entryLabel, int index, int total, + AviatorLoggerImpl logger, Set issueFilter) { + logger.progress("Processing FPR " + index + "/" + total + " (" + entryLabel + ")"); + logger.progress("Status: Processing FPR with Aviator for Applying Auto Remediations"); + try (FprHandle fprHandle = new FprHandle(fprPath)) { + return ApplyAutoRemediationOnSource.applyRemediations(fprHandle, sourceCodeDirectory, logger, issueFilter); + } catch (AviatorSimpleException e) { + LOG.warn("Skipping cache entry {} as {}", entryLabel, e.getMessage()); + return null; + } catch (IOException e) { + throw new FcliSimpleException("Failed to close FPR handle for cache entry " + entryLabel, e); + } + } + + private record CacheProcessingResult( + List processedEntries, + List processedArtifactIds, + int skipped, + List metrics) {} + private static List orderedEntryPaths(RemediationsCacheReader cacheReader) { return cacheReader.getManifest().getEntries().stream() .sorted(java.util.Comparator.comparingInt(RemediationsCacheEntry::getOrder)) 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 index 3098febb530..4dbf058e546 100644 --- 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 @@ -53,7 +53,7 @@ import picocli.CommandLine.Mixin; import picocli.CommandLine.Option; -@Command(name = "download-remediations-cache") +@Command(name = "download-remediations-cache", aliases = "drc") public class AviatorSSCDownloadRemediationsCacheCommand extends AbstractSSCJsonNodeOutputCommand implements IActionCommandResultSupplier { private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCDownloadRemediationsCacheCommand.class); 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 index d3c4b1a499f..769fdc56611 100644 --- 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 @@ -14,11 +14,8 @@ import java.nio.file.Path; -import org.apache.commons.lang3.StringUtils; - +import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCRemediationsSelectorArgGroups.OnlineSelectionArgGroup; 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; @@ -37,40 +34,13 @@ public class AviatorSSCApplyRemediationsSourceMixin { @Getter public static class SourceArgGroup { @ArgGroup(exclusive = false) - private OnlineSource online; + private OnlineSelectionArgGroup 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; } @@ -84,39 +54,31 @@ public Path getFromCache() { } public boolean isArtifactIdSelected() { - return isOnlineSelected() && source.online.mode != null - && StringUtils.isNotBlank(source.online.mode.artifactId); + return isOnlineSelected() && source.online.isArtifactIdSelected(); } public boolean isLatestSelected() { - return isOnlineSelected() && source.online.mode != null && source.online.mode.latest; + return isOnlineSelected() && source.online.isLatestSelected(); } public boolean isAllSelected() { - return isOnlineSelected() && source.online.mode != null && source.online.mode.all; + return isOnlineSelected() && source.online.isAllSelected(); } public String getArtifactId() { - return isArtifactIdSelected() ? source.online.mode.artifactId : null; + return isOnlineSelected() ? source.online.getArtifactId() : null; } public String getSince() { - return isOnlineSelected() ? source.online.since : null; + return isOnlineSelected() ? source.online.getSince() : null; } public String getAppVersionNameOrId() { - return isOnlineSelected() ? source.online.appVersionNameOrId : null; + return isOnlineSelected() ? source.online.getAppVersionNameOrId() : 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(); + return isOnlineSelected() ? source.online.getAppVersionId(unirest) : null; } public void validate() { @@ -126,14 +88,6 @@ public void validate() { 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"); - } + source.online.validate(); } } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsCacheDownloadSelectorMixin.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsCacheDownloadSelectorMixin.java index d9b5e5b8f2b..5ee616d7ff6 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsCacheDownloadSelectorMixin.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsCacheDownloadSelectorMixin.java @@ -12,90 +12,46 @@ */ package com.fortify.cli.aviator.ssc.cli.mixin; -import org.apache.commons.lang3.StringUtils; - -import com.fortify.cli.common.exception.FcliSimpleException; -import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; -import com.fortify.cli.ssc.appversion.helper.SSCAppVersionHelper; +import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCRemediationsSelectorArgGroups.OnlineSelectionArgGroup; import kong.unirest.UnirestInstance; import lombok.Getter; import picocli.CommandLine.ArgGroup; -import picocli.CommandLine.Option; @Getter public class AviatorSSCRemediationsCacheDownloadSelectorMixin { - @ArgGroup(exclusive = true, multiplicity = "1") - private ArtifactSelectionArgGroup artifactSelection; - - @Option(names = {"--since"}, descriptionKey = "fcli.aviator.ssc.download-remediations-cache.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 ArtifactSelectionArgGroup { - @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.download-remediations-cache.latest") - private boolean latest; - - @Option(names = {"--all"}, required = true, descriptionKey = "fcli.aviator.ssc.download-remediations-cache.all") - private boolean all; - } + @ArgGroup(exclusive = false, multiplicity = "1") + private OnlineSelectionArgGroup onlineSelection; public boolean isArtifactIdSelected() { - return artifactSelection != null && StringUtils.isNotBlank(artifactSelection.artifactId); + return onlineSelection != null && onlineSelection.isArtifactIdSelected(); } public boolean isLatestSelected() { - return artifactSelection != null && artifactSelection.latest; + return onlineSelection != null && onlineSelection.isLatestSelected(); } public boolean isAllSelected() { - return artifactSelection != null && artifactSelection.all; + return onlineSelection != null && onlineSelection.isAllSelected(); } public String getArtifactId() { - return isArtifactIdSelected() ? artifactSelection.artifactId : null; + return onlineSelection != null ? onlineSelection.getArtifactId() : null; + } + + public String getSince() { + return onlineSelection != null ? onlineSelection.getSince() : null; } public String getAppVersionId(UnirestInstance unirest) { - if (StringUtils.isBlank(appVersionNameOrId)) { - return null; - } - SSCAppVersionDescriptor descriptor = SSCAppVersionHelper.getRequiredAppVersion( - unirest, appVersionNameOrId, delimiter, "id"); - return descriptor.getVersionId(); + return onlineSelection != null ? onlineSelection.getAppVersionId(unirest) : null; } public String getSelectionMode() { - if (isArtifactIdSelected()) { - return "artifact-id"; - } - if (isLatestSelected()) { - return "latest"; - } - if (isAllSelected()) { - return "all"; - } - return null; + return onlineSelection != null ? onlineSelection.getSelectionMode() : null; } 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"); - } + onlineSelection.validate(); } } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsSelectorArgGroups.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsSelectorArgGroups.java new file mode 100644 index 00000000000..e8ad69a9d93 --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsSelectorArgGroups.java @@ -0,0 +1,105 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.cli.mixin; + +import org.apache.commons.lang3.StringUtils; + +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; +import com.fortify.cli.ssc.appversion.helper.SSCAppVersionHelper; + +import kong.unirest.UnirestInstance; +import lombok.Getter; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Option; + +public final class AviatorSSCRemediationsSelectorArgGroups { + private AviatorSSCRemediationsSelectorArgGroups() {} + + @Getter + public static class OnlineSelectionArgGroup { + @ArgGroup(exclusive = true, multiplicity = "1") + private OnlineModeArgGroup mode; + + @Option(names = {"--since"}, descriptionKey = "fcli.aviator.ssc.remediations-cache.since") + private String since; + + @Option(names = {"--appversion", "--av"}, descriptionKey = "fcli.ssc.appversion.resolver.nameOrId") + private String appVersionNameOrId; + + @Option(names = {"--delim"}, defaultValue = ":") + private String delimiter; + + public boolean isArtifactIdSelected() { + return mode != null && StringUtils.isNotBlank(mode.artifactId); + } + + public boolean isLatestSelected() { + return mode != null && mode.latest; + } + + public boolean isAllSelected() { + return mode != null && mode.all; + } + + public String getArtifactId() { + return isArtifactIdSelected() ? mode.artifactId : null; + } + + public String getAppVersionId(UnirestInstance unirest) { + if (StringUtils.isBlank(appVersionNameOrId)) { + return null; + } + SSCAppVersionDescriptor descriptor = SSCAppVersionHelper.getRequiredAppVersion( + unirest, appVersionNameOrId, getDelimiter(), "id"); + return descriptor.getVersionId(); + } + + public String getSelectionMode() { + if (isArtifactIdSelected()) { + return "artifact-id"; + } + if (isLatestSelected()) { + return "latest"; + } + if (isAllSelected()) { + return "all"; + } + return null; + } + + 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"); + } + } + } + + @Getter + public static class OnlineModeArgGroup { + @Option(names = {"--artifact-id"}, required = true, descriptionKey = "fcli.aviator.ssc.remediations-cache.artifact-id") + private String artifactId; + + @Option(names = {"--latest"}, required = true, descriptionKey = "fcli.aviator.ssc.remediations-cache.latest") + private boolean latest; + + @Option(names = {"--all"}, required = true, descriptionKey = "fcli.aviator.ssc.remediations-cache.all") + private boolean all; + } +} \ No newline at end of file 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 ba6c2db50fa..c02b05bde8f 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 @@ -165,6 +165,10 @@ fcli.aviator.ssc.apply-remediations.issue-ids = Comma-separated list of issue ID 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 or --from-cache. +fcli.aviator.ssc.remediations-cache.artifact-id = Specific artifact ID to process. Mutually exclusive with --latest and --all. +fcli.aviator.ssc.remediations-cache.latest = Select the most recent Fortify Remediation Aviator-processed artifact. Requires --av/--appversion. Mutually exclusive with --artifact-id and --all. +fcli.aviator.ssc.remediations-cache.all = Select all Fortify Remediation Aviator-processed artifacts for the given application version, in chronological order. Requires --av/--appversion. Mutually exclusive with --artifact-id and --latest. +fcli.aviator.ssc.remediations-cache.since = Filter artifacts by upload date. Supports relative durations (e.g. 7d, 2w, 1M, 90d) or absolute dates. Can only be used with --latest or --all. fcli.aviator.ssc.download-remediations-cache.usage.header = Download a remediations cache zip containing Fortify Remediation Aviator audited FPR(s) from SSC. fcli.aviator.ssc.download-remediations-cache.usage.description = Downloads audited FPR file(s) from SSC into a single remediations cache zip (manifest.json + ordered FPR entries) for use with apply-remediations --from-cache. \ diff --git a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/util/ZipHelper.java b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/util/ZipHelper.java index 5c890c6ee48..da95f37663e 100644 --- a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/util/ZipHelper.java +++ b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/util/ZipHelper.java @@ -14,6 +14,11 @@ import java.io.IOException; import java.io.InputStream; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; import java.util.function.Supplier; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -23,6 +28,27 @@ import lombok.SneakyThrows; public class ZipHelper { + public static final FileSystem openZipFileSystem(Path zipFile) { + try { + return FileSystems.newFileSystem(zipFile, (ClassLoader)null); + } catch (IOException e) { + throw new FcliSimpleException("Error opening zip file " + zipFile, e); + } + } + + public static final FileSystem createZipFileSystem(Path zipFile) { + try { + var parent = zipFile.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.deleteIfExists(zipFile); + return FileSystems.newFileSystem(zipFile, Map.of("create", "true")); + } catch (IOException e) { + throw new FcliSimpleException("Error creating zip file " + zipFile, e); + } + } + /** * Helper method to process individual zip entries from a zip file * loaded from the given zipFileInputStream, calling the diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorApplyRemediationsCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorApplyRemediationsCommand.java index 6a073f7aeb7..0d342e4beed 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 @@ -82,20 +82,13 @@ public class FoDAviatorApplyRemediationsCommand extends AbstractOutputCommand @Getter static class SourceArgGroup { @ArgGroup(exclusive = false) - private OnlineSource online; + private FoDAviatorRemediationSelectorArgGroups.ReleaseArgGroup 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() { @@ -109,7 +102,7 @@ public JsonNode getJsonNode() { } UnirestInstance unirest = unirestInstanceSupplier.getUnirestInstance(); FoDReleaseDescriptor rd = FoDReleaseHelper.getReleaseDescriptor( - unirest, source.online.qualifiedReleaseNameOrId, delimiterMixin.getDelimiter(), true); + unirest, source.online.getQualifiedReleaseNameOrId(), delimiterMixin.getDelimiter(), true); return processFprRemediations(unirest, rd, logger, issueIdFilter); } } @@ -139,48 +132,14 @@ private boolean isFromCacheSelected() { 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); + CacheProcessingResult cacheResult = processCacheEntries(cacheReader, logger, issueIdFilter); + RemediationMetric aggregatedMetric = aggregateMetrics(issueIdFilter, cacheResult.metrics()); String status = aggregatedMetric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; return AviatorFoDApplyRemediationsHelper.buildCacheResultNode( new AviatorFoDApplyRemediationsHelper.CacheResultData( cacheZip, - List.copyOf(processedEntries), - List.copyOf(processedReleaseIds), + cacheResult.processedEntries(), + cacheResult.processedReleaseIds(), aggregatedMetric.totalRemediations(), aggregatedMetric.appliedRemediations(), aggregatedMetric.skippedRemediations(), @@ -189,6 +148,67 @@ private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdF } } + private CacheProcessingResult processCacheEntries(RemediationsCacheReader cacheReader, AviatorLoggerImpl logger, + Set issueIdFilter) { + List fprPaths = cacheReader.getOrderedFprPaths(); + List allEntryPaths = orderedEntryPaths(cacheReader); + List allReleaseIds = orderedReleaseIds(cacheReader); + 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; + } + String entryLabel = i < allEntryPaths.size() ? allEntryPaths.get(i) : fprPaths.get(i).getFileName().toString(); + RemediationMetric metric = processCacheEntry(fprPaths.get(i), entryLabel, i + 1, fprPaths.size(), logger, remaining); + if (metric != null) { + metrics.add(metric); + processedEntries.add(entryLabel); + processedReleaseIds.add(i < allReleaseIds.size() ? allReleaseIds.get(i) : ""); + remaining = getRemainingIssueIds(remaining, metric); + } + } + return new CacheProcessingResult(List.copyOf(processedEntries), List.copyOf(processedReleaseIds), metrics); + } + + private RemediationMetric processCacheEntry(Path fprPath, String entryLabel, int index, int total, + AviatorLoggerImpl logger, Set issueFilter) { + logger.progress("Processing FPR " + index + "/" + total + " (" + entryLabel + ")"); + logger.progress("Status: Processing FPR with Aviator for Applying Auto Remediations"); + try (FprHandle fprHandle = new FprHandle(fprPath)) { + return ApplyAutoRemediationOnSource.applyRemediations(fprHandle, sourceCodeDirectory, logger, issueFilter); + } catch (AviatorSimpleException e) { + LOG.warn("Skipping cache entry {} as {}", entryLabel, e.getMessage()); + return null; + } catch (IOException e) { + throw new FcliSimpleException("Failed to close FPR handle for cache entry " + entryLabel, e); + } + } + + private static List orderedEntryPaths(RemediationsCacheReader cacheReader) { + return cacheReader.getManifest().getEntries().stream() + .sorted(java.util.Comparator.comparingInt(e -> e.getOrder())) + .map(e -> e.getPath()) + .toList(); + } + + private static List orderedReleaseIds(RemediationsCacheReader cacheReader) { + return cacheReader.getManifest().getEntries().stream() + .sorted(java.util.Comparator.comparingInt(e -> e.getOrder())) + .map(e -> e.getReleaseId() != null ? e.getReleaseId() : "") + .toList(); + } + + private record CacheProcessingResult( + List processedEntries, + List processedReleaseIds, + List metrics) {} + static RemediationMetric aggregateMetrics(Set requestedIssueIds, Collection metrics) { Set modifiedFiles = new LinkedHashSet<>(); if (requestedIssueIds == null) { 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 index 428f2b2750d..712927928ac 100644 --- 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 @@ -37,26 +37,29 @@ 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 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 = "download-remediations-cache") +@Command(name = "download-remediations-cache", aliases = "drc") 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; + @ArgGroup(exclusive = false, multiplicity = "1") + private FoDAviatorRemediationSelectorArgGroups.ReleaseArgGroup releaseSelection; + @Option(names = {"-f", "--file"}, required = true, paramLabel = "", descriptionKey = "fcli.fod.aviator.download-remediations-cache.file") private File outputFile; @@ -69,7 +72,8 @@ public JsonNode getJsonNode(UnirestInstance unirest) { requireConfirmation.checkConfirmed(destination); } - FoDReleaseDescriptor releaseDescriptor = releaseResolver.getReleaseDescriptor(unirest); + FoDReleaseDescriptor releaseDescriptor = FoDReleaseHelper.getReleaseDescriptor( + unirest, releaseSelection.getQualifiedReleaseNameOrId(), delimiterMixin.getDelimiter(), true); Path tempFpr = Files.createTempFile("aviator-cache-" + releaseDescriptor.getReleaseId() + "-", ".fpr"); try { downloadFpr(unirest, releaseDescriptor, tempFpr); diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorRemediationSelectorArgGroups.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorRemediationSelectorArgGroups.java new file mode 100644 index 00000000000..6903c673782 --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorRemediationSelectorArgGroups.java @@ -0,0 +1,27 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.fod.aviator.cmd; + +import lombok.Getter; +import picocli.CommandLine.Option; + +public final class FoDAviatorRemediationSelectorArgGroups { + private FoDAviatorRemediationSelectorArgGroups() {} + + @Getter + public static class ReleaseArgGroup { + @Option(names = {"--release", "--rel"}, required = true, paramLabel = "id|app[:ms]:rel", + descriptionKey = "fcli.fod.release.resolver.name-or-id") + private String qualifiedReleaseNameOrId; + } +} \ No newline at end of file From 06dd250aba8ac5768ae42bd6ce8005820a7dd9d6 Mon Sep 17 00:00:00 2001 From: kireetivar Date: Mon, 20 Jul 2026 20:56:23 +0530 Subject: [PATCH 10/18] chore: clean up remediations cache helpers and shared selection messages --- .../RemediationsCacheReader.java | 49 +++++++++++---- .../RemediationsCacheSha256.java | 8 +-- .../RemediationsCacheWriter.java | 9 ++- .../_common/util/AviatorLocalFprHelper.java | 3 +- .../util/AviatorRemediationMetricsHelper.java | 55 ++++++++++++++++ .../AviatorSSCApplyRemediationsCommand.java | 63 ++++--------------- ...emediationsCacheDownloadSelectorMixin.java | 4 +- ...iatorSSCRemediationsSelectorArgGroups.java | 2 +- .../aviator/i18n/AviatorMessages.properties | 13 +--- ...viatorSSCApplyRemediationsCommandTest.java | 7 ++- .../fortify/cli/common/util/ZipHelper.java | 22 ++++++- .../FoDAviatorApplyRemediationsCommand.java | 57 +++-------------- 12 files changed, 154 insertions(+), 138 deletions(-) create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelper.java 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 index f5da09c13d5..3bdc7724f30 100644 --- 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 @@ -21,8 +21,11 @@ import java.util.List; import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.exception.FcliTechnicalException; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.util.ZipHelper; @@ -31,16 +34,20 @@ * and exposes FPR files directly from the zip file system for ordered processing. */ public final class RemediationsCacheReader implements AutoCloseable { + private static final Logger logger = LoggerFactory.getLogger(RemediationsCacheReader.class); + private final Path cacheZip; private final FileSystem cacheFs; private final RemediationsCacheManifest manifest; + private final List orderedEntries; private final List orderedFprPaths; private RemediationsCacheReader(Path cacheZip, FileSystem cacheFs, RemediationsCacheManifest manifest, - List orderedFprPaths) { + List orderedEntries, List orderedFprPaths) { this.cacheZip = cacheZip; this.cacheFs = cacheFs; this.manifest = manifest; + this.orderedEntries = orderedEntries; this.orderedFprPaths = orderedFprPaths; } @@ -67,11 +74,11 @@ public static RemediationsCacheReader open(Path cacheZip) { + ": " + cacheZip); } - RemediationsCacheManifest manifest; - try (var manifestInputStream = Files.newInputStream(manifestPath)) { + RemediationsCacheManifest manifest; + try (var manifestInputStream = Files.newInputStream(manifestPath)) { manifest = JsonHelper.getObjectMapper() - .readValue(manifestInputStream, RemediationsCacheManifest.class); - } + .readValue(manifestInputStream, RemediationsCacheManifest.class); + } validateManifest(manifest, cacheZip); List entries = new ArrayList<>(manifest.getEntries()); @@ -80,7 +87,7 @@ public static RemediationsCacheReader open(Path cacheZip) { List orderedFprs = new ArrayList<>(); for (RemediationsCacheEntry entry : entries) { Path fprPath = getEntryPath(cacheFs, entry.getPath()); - if (fprPath == null || !Files.isRegularFile(fprPath)) { + if (!Files.isRegularFile(fprPath)) { throw new FcliSimpleException("Remediations cache entry path not found in zip: " + entry.getPath()); } String actualSha = RemediationsCacheSha256.hashFile(fprPath); @@ -97,11 +104,11 @@ public static RemediationsCacheReader open(Path cacheZip) { FileSystem openCacheFs = cacheFs; cacheFs = null; - return new RemediationsCacheReader(cacheZip, openCacheFs, manifest, orderedFprs); - } catch (FcliSimpleException e) { + return new RemediationsCacheReader(cacheZip, openCacheFs, manifest, List.copyOf(entries), orderedFprs); + } catch (FcliSimpleException | FcliTechnicalException e) { throw e; } catch (IOException e) { - throw new FcliSimpleException("Failed to read remediations cache: " + cacheZip, e); + throw new FcliTechnicalException("Failed to read remediations cache: " + cacheZip, e); } finally { closeQuietly(cacheFs); } @@ -111,10 +118,30 @@ public RemediationsCacheManifest getManifest() { return manifest; } + public List getOrderedEntries() { + return orderedEntries; + } + public List getOrderedFprPaths() { return List.copyOf(orderedFprPaths); } + public List getOrderedEntryPaths() { + return orderedEntries.stream().map(RemediationsCacheEntry::getPath).toList(); + } + + public List getOrderedArtifactIds() { + return orderedEntries.stream() + .map(e -> e.getArtifactId() != null ? e.getArtifactId() : "") + .toList(); + } + + public List getOrderedReleaseIds() { + return orderedEntries.stream() + .map(e -> e.getReleaseId() != null ? e.getReleaseId() : "") + .toList(); + } + public Path getCacheZip() { return cacheZip; } @@ -174,8 +201,8 @@ private static void closeQuietly(FileSystem cacheFs) { } try { cacheFs.close(); - } catch (IOException ignored) { - // best effort + } catch (IOException e) { + logger.warn("Failed to close cache filesystem", e); } } } 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 index e8d8d7be23d..9fcf331b880 100644 --- 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 @@ -20,7 +20,7 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.exception.FcliTechnicalException; public final class RemediationsCacheSha256 { private RemediationsCacheSha256() {} @@ -29,7 +29,7 @@ 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); + throw new FcliTechnicalException("Failed to compute SHA-256 for " + path, e); } } @@ -44,9 +44,9 @@ public static String hashStream(InputStream in) { } return toHex(digest.digest()); } catch (NoSuchAlgorithmException e) { - throw new FcliSimpleException("SHA-256 algorithm not available", e); + throw new FcliTechnicalException("SHA-256 algorithm not available", e); } catch (IOException e) { - throw new FcliSimpleException("Failed to compute SHA-256", e); + throw new FcliTechnicalException("Failed to compute SHA-256", e); } } 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 index 73beb09899b..66dd4d8a4de 100644 --- 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 @@ -21,6 +21,7 @@ import java.util.Map; import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.exception.FcliTechnicalException; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.util.ZipHelper; @@ -52,7 +53,7 @@ public static RemediationsCacheManifest write( try { Files.createDirectories(parent); } catch (IOException e) { - throw new FcliSimpleException("Failed to create parent directory for " + destination, e); + throw new FcliTechnicalException("Failed to create parent directory for " + destination, e); } } @@ -79,14 +80,16 @@ public static RemediationsCacheManifest write( Files.move(tempZip, destination, StandardCopyOption.REPLACE_EXISTING); tempZip = null; return manifest; + } catch (FcliSimpleException | FcliTechnicalException e) { + throw e; } catch (IOException e) { - throw new FcliSimpleException("Failed to write remediations cache to " + destination, e); + throw new FcliTechnicalException("Failed to write remediations cache to " + destination, e); } finally { if (tempZip != null) { try { Files.deleteIfExists(tempZip); } catch (IOException ignored) { - // best effort + // best effort cleanup of incomplete temp zip } } } 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 index 6fe0871565b..45cf314bd26 100644 --- 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 @@ -18,6 +18,7 @@ import com.fortify.cli.aviator.util.FprHandle; import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.exception.FcliTechnicalException; public final class AviatorLocalFprHelper { private AviatorLocalFprHelper() {} @@ -55,7 +56,7 @@ private static void validateLocalFpr(Path fprPath, String sourceLabel) { } 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); + throw new FcliTechnicalException("Failed to close " + sourceLabel + ": " + fprPath, e); } } } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelper.java new file mode 100644 index 00000000000..0bed7939f0d --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelper.java @@ -0,0 +1,55 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.util; + +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.Set; + +import com.fortify.cli.aviator.fpr.processor.RemediationProcessor.RemediationMetric; + +/** + * Shared metric aggregation helpers for SSC/FoD apply-remediations flows. + */ +public final class AviatorRemediationMetricsHelper { + private AviatorRemediationMetricsHelper() {} + + public static RemediationMetric aggregateMetrics(Set requestedIssueIds, Collection metrics) { + Set modifiedFiles = new LinkedHashSet<>(); + if (requestedIssueIds == null) { + int totalRemediations = 0; + int appliedRemediations = 0; + for (RemediationMetric metric : metrics) { + totalRemediations += metric.totalRemediations(); + appliedRemediations += metric.appliedRemediations(); + modifiedFiles.addAll(metric.modifiedFiles()); + } + return RemediationMetric.unfiltered(totalRemediations, appliedRemediations, modifiedFiles); + } + Set appliedIssueIds = new LinkedHashSet<>(); + for (RemediationMetric metric : metrics) { + modifiedFiles.addAll(metric.modifiedFiles()); + appliedIssueIds.addAll(metric.appliedIssueIds()); + } + return RemediationMetric.filtered(requestedIssueIds, appliedIssueIds, modifiedFiles); + } + + public static Set getRemainingIssueIds(Set requestedIssueIds, RemediationMetric metric) { + if (requestedIssueIds == null || requestedIssueIds.isEmpty()) { + return requestedIssueIds; + } + Set remainingIssueIds = new LinkedHashSet<>(requestedIssueIds); + remainingIssueIds.removeAll(metric.appliedIssueIds()); + return remainingIssueIds; + } +} diff --git a/fcli-core/fcli-aviator/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 19b232422ba..8269e593a52 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 @@ -17,7 +17,6 @@ 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; @@ -27,10 +26,10 @@ 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._common.util.AviatorRemediationMetricsHelper; import com.fortify.cli.aviator.applyRemediation.ApplyAutoRemediationOnSource; import com.fortify.cli.aviator.config.AviatorLoggerImpl; import com.fortify.cli.aviator.fpr.processor.RemediationProcessor.RemediationMetric; @@ -39,6 +38,7 @@ 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.exception.FcliTechnicalException; 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; @@ -105,7 +105,8 @@ private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdF ? cacheReader.getManifest().getSelection().get("appVersionId") : null; CacheProcessingResult cacheResult = processCacheEntries(cacheReader, logger, issueIdFilter); - RemediationMetric aggregatedMetric = aggregateMetrics(issueIdFilter, cacheResult.metrics()); + RemediationMetric aggregatedMetric = AviatorRemediationMetricsHelper.aggregateMetrics( + issueIdFilter, cacheResult.metrics()); String action = aggregatedMetric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; return AviatorSSCApplyRemediationsHelper.buildCacheResultNode( new AviatorSSCApplyRemediationsHelper.CacheResultData( @@ -126,8 +127,8 @@ private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdF private CacheProcessingResult processCacheEntries(RemediationsCacheReader cacheReader, AviatorLoggerImpl logger, Set issueIdFilter) { List fprPaths = cacheReader.getOrderedFprPaths(); - List allEntryPaths = orderedEntryPaths(cacheReader); - List allArtifactIds = orderedArtifactIds(cacheReader); + List allEntryPaths = cacheReader.getOrderedEntryPaths(); + List allArtifactIds = cacheReader.getOrderedArtifactIds(); AviatorLocalFprHelper.validateLocalFprs(fprPaths, "Cache FPR"); List metrics = new ArrayList<>(); @@ -148,7 +149,7 @@ private CacheProcessingResult processCacheEntries(RemediationsCacheReader cacheR metrics.add(metric); processedEntries.add(entryLabel); processedArtifactIds.add(i < allArtifactIds.size() ? allArtifactIds.get(i) : ""); - remaining = getRemainingIssueIds(remaining, metric); + remaining = AviatorRemediationMetricsHelper.getRemainingIssueIds(remaining, metric); } } return new CacheProcessingResult(List.copyOf(processedEntries), List.copyOf(processedArtifactIds), skipped, metrics); @@ -164,7 +165,7 @@ private RemediationMetric processCacheEntry(Path fprPath, String entryLabel, int LOG.warn("Skipping cache entry {} as {}", entryLabel, e.getMessage()); return null; } catch (IOException e) { - throw new FcliSimpleException("Failed to close FPR handle for cache entry " + entryLabel, e); + throw new FcliTechnicalException("Failed to close FPR handle for cache entry " + entryLabel, e); } } @@ -174,20 +175,6 @@ private record CacheProcessingResult( int skipped, List metrics) {} - 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); @@ -213,35 +200,6 @@ private void validateIssueIdFilterMode() { } } - 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; @@ -255,7 +213,8 @@ JsonNode processAllAviatorArtifacts(OffsetDateTime sinceDate) { List artifacts = SSCArtifactHelper.getAllAviatorArtifacts(unirest, appVersionId, sinceDate); ArtifactBatchResult batchResult = processBatchOfArtifacts(artifacts); - RemediationMetric aggregatedMetric = aggregateMetrics(issueIdFilter, batchResult.metrics()); + RemediationMetric aggregatedMetric = AviatorRemediationMetricsHelper.aggregateMetrics( + issueIdFilter, batchResult.metrics()); String action = aggregatedMetric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; return AviatorSSCApplyRemediationsHelper.buildAggregatedResultNode( appVersionId, batchResult.processed(), batchResult.skipped(), @@ -278,7 +237,7 @@ private ArtifactBatchResult processBatchOfArtifacts(List ArtifactProcessResult result = processSingleArtifact(ad, artifactIndex, artifacts.size(), remaining); if (result.isSuccess()) { metrics.add(result.metric()); - remaining = getRemainingIssueIds(remaining, result.metric()); + remaining = AviatorRemediationMetricsHelper.getRemainingIssueIds(remaining, result.metric()); processed++; } else { skipped++; diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsCacheDownloadSelectorMixin.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsCacheDownloadSelectorMixin.java index 5ee616d7ff6..853f3ecd100 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsCacheDownloadSelectorMixin.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsCacheDownloadSelectorMixin.java @@ -52,6 +52,8 @@ public String getSelectionMode() { } public void validate() { - onlineSelection.validate(); + if (onlineSelection != null) { + onlineSelection.validate(); + } } } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsSelectorArgGroups.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsSelectorArgGroups.java index e8ad69a9d93..a81e61f4d03 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsSelectorArgGroups.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsSelectorArgGroups.java @@ -102,4 +102,4 @@ public static class OnlineModeArgGroup { @Option(names = {"--all"}, required = true, descriptionKey = "fcli.aviator.ssc.remediations-cache.all") private boolean all; } -} \ No newline at end of file +} 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 c02b05bde8f..bd058ad3faa 100644 --- a/fcli-core/fcli-aviator/src/main/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties +++ b/fcli-core/fcli-aviator/src/main/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties @@ -153,18 +153,11 @@ 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, --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, --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 or --from-cache. +# Shared by download-remediations-cache and apply-remediations online selection (AviatorSSCRemediationsSelectorArgGroups). fcli.aviator.ssc.remediations-cache.artifact-id = Specific artifact ID to process. Mutually exclusive with --latest and --all. fcli.aviator.ssc.remediations-cache.latest = Select the most recent Fortify Remediation Aviator-processed artifact. Requires --av/--appversion. Mutually exclusive with --artifact-id and --all. fcli.aviator.ssc.remediations-cache.all = Select all Fortify Remediation Aviator-processed artifacts for the given application version, in chronological order. Requires --av/--appversion. Mutually exclusive with --artifact-id and --latest. @@ -173,10 +166,6 @@ fcli.aviator.ssc.remediations-cache.since = Filter artifacts by upload date. Sup 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? diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java index d0b51b3a7de..038d966db18 100644 --- a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java @@ -24,6 +24,7 @@ import org.junit.jupiter.api.Test; import com.fortify.cli.aviator._common.util.AviatorIssueIdFilterUtils; +import com.fortify.cli.aviator._common.util.AviatorRemediationMetricsHelper; 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; @@ -68,7 +69,8 @@ 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)); + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + Set.of("ISSUE-1", "ISSUE-2"), List.of(metricOne, metricTwo)); assertEquals(2, aggregated.totalRemediations()); assertEquals(2, aggregated.appliedRemediations()); @@ -81,7 +83,8 @@ void testAggregateMetricsForFilteredAllDeduplicatesAcrossArtifacts() { 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); + Set remainingIssueIds = AviatorRemediationMetricsHelper.getRemainingIssueIds( + Set.of("ISSUE-1", "ISSUE-2"), metric); assertEquals(Set.of("ISSUE-2"), remainingIssueIds); } diff --git a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/util/ZipHelper.java b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/util/ZipHelper.java index da95f37663e..74421ccdb8a 100644 --- a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/util/ZipHelper.java +++ b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/util/ZipHelper.java @@ -24,18 +24,36 @@ import java.util.zip.ZipInputStream; import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.exception.FcliTechnicalException; import lombok.SneakyThrows; public class ZipHelper { + /** + * Opens an existing zip file as a {@link FileSystem}. Callers are responsible for + * closing the returned file system (for example via try-with-resources). + * + * @param zipFile path to an existing zip file + * @return a zip file system for reading (and optionally writing) zip entries + * @throws FcliTechnicalException if the zip file cannot be opened + */ public static final FileSystem openZipFileSystem(Path zipFile) { try { return FileSystems.newFileSystem(zipFile, (ClassLoader)null); } catch (IOException e) { - throw new FcliSimpleException("Error opening zip file " + zipFile, e); + throw new FcliTechnicalException("Error opening zip file " + zipFile, e); } } + /** + * Creates a new zip file as a {@link FileSystem}. Parent directories are created + * if needed. If {@code zipFile} already exists, it is deleted first. Callers are + * responsible for closing the returned file system (for example via try-with-resources). + * + * @param zipFile path of the zip file to create + * @return a zip file system opened with {@code create=true} + * @throws FcliTechnicalException if the zip file cannot be created or opened + */ public static final FileSystem createZipFileSystem(Path zipFile) { try { var parent = zipFile.toAbsolutePath().getParent(); @@ -45,7 +63,7 @@ public static final FileSystem createZipFileSystem(Path zipFile) { Files.deleteIfExists(zipFile); return FileSystems.newFileSystem(zipFile, Map.of("create", "true")); } catch (IOException e) { - throw new FcliSimpleException("Error creating zip file " + zipFile, e); + throw new FcliTechnicalException("Error creating zip file " + zipFile, e); } } 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 0d342e4beed..31d2f36bfd0 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 @@ -17,7 +17,6 @@ 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; @@ -30,11 +29,13 @@ 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._common.util.AviatorRemediationMetricsHelper; 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.exception.FcliTechnicalException; 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; @@ -133,7 +134,8 @@ private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdF Path cacheZip = source.fromCache; try (RemediationsCacheReader cacheReader = RemediationsCacheReader.open(cacheZip)) { CacheProcessingResult cacheResult = processCacheEntries(cacheReader, logger, issueIdFilter); - RemediationMetric aggregatedMetric = aggregateMetrics(issueIdFilter, cacheResult.metrics()); + RemediationMetric aggregatedMetric = AviatorRemediationMetricsHelper.aggregateMetrics( + issueIdFilter, cacheResult.metrics()); String status = aggregatedMetric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; return AviatorFoDApplyRemediationsHelper.buildCacheResultNode( new AviatorFoDApplyRemediationsHelper.CacheResultData( @@ -151,8 +153,8 @@ private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdF private CacheProcessingResult processCacheEntries(RemediationsCacheReader cacheReader, AviatorLoggerImpl logger, Set issueIdFilter) { List fprPaths = cacheReader.getOrderedFprPaths(); - List allEntryPaths = orderedEntryPaths(cacheReader); - List allReleaseIds = orderedReleaseIds(cacheReader); + List allEntryPaths = cacheReader.getOrderedEntryPaths(); + List allReleaseIds = cacheReader.getOrderedReleaseIds(); AviatorLocalFprHelper.validateLocalFprs(fprPaths, "Cache FPR"); List metrics = new ArrayList<>(); @@ -170,7 +172,7 @@ private CacheProcessingResult processCacheEntries(RemediationsCacheReader cacheR metrics.add(metric); processedEntries.add(entryLabel); processedReleaseIds.add(i < allReleaseIds.size() ? allReleaseIds.get(i) : ""); - remaining = getRemainingIssueIds(remaining, metric); + remaining = AviatorRemediationMetricsHelper.getRemainingIssueIds(remaining, metric); } } return new CacheProcessingResult(List.copyOf(processedEntries), List.copyOf(processedReleaseIds), metrics); @@ -186,58 +188,15 @@ private RemediationMetric processCacheEntry(Path fprPath, String entryLabel, int LOG.warn("Skipping cache entry {} as {}", entryLabel, e.getMessage()); return null; } catch (IOException e) { - throw new FcliSimpleException("Failed to close FPR handle for cache entry " + entryLabel, e); + throw new FcliTechnicalException("Failed to close FPR handle for cache entry " + entryLabel, e); } } - private static List orderedEntryPaths(RemediationsCacheReader cacheReader) { - return cacheReader.getManifest().getEntries().stream() - .sorted(java.util.Comparator.comparingInt(e -> e.getOrder())) - .map(e -> e.getPath()) - .toList(); - } - - private static List orderedReleaseIds(RemediationsCacheReader cacheReader) { - return cacheReader.getManifest().getEntries().stream() - .sorted(java.util.Comparator.comparingInt(e -> e.getOrder())) - .map(e -> e.getReleaseId() != null ? e.getReleaseId() : "") - .toList(); - } - private record CacheProcessingResult( List processedEntries, List processedReleaseIds, List metrics) {} - 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, Set issueIdFilter) { From 3fa6c5ae1014d054de858ed30d0a122f04112fd9 Mon Sep 17 00:00:00 2001 From: kireetivar Date: Mon, 20 Jul 2026 21:16:38 +0530 Subject: [PATCH 11/18] chore: reuse FoD release resolver for remediations cache commands --- .../FoDAviatorApplyRemediationsCommand.java | 65 ++++++++++++++----- ...iatorDownloadRemediationsCacheCommand.java | 12 ++-- ...oDAviatorRemediationSelectorArgGroups.java | 27 -------- ...oDAviatorApplyRemediationsCommandTest.java | 12 ++-- 4 files changed, 59 insertions(+), 57 deletions(-) delete mode 100644 fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorRemediationSelectorArgGroups.java 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 31d2f36bfd0..8f4a2023de3 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 @@ -45,13 +45,14 @@ 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.cli.mixin.IFoDDelimiterMixinAware; 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; @@ -67,29 +68,66 @@ public class FoDAviatorApplyRemediationsCommand extends AbstractOutputCommand implements IJsonNodeSupplier, IRecordTransformer, IActionCommandResultSupplier { @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; - @Mixin private FoDDelimiterMixin delimiterMixin; + @Mixin private FoDDelimiterMixin delimiterMixin; // Injected into sourceSelector @Mixin private FoDUnirestInstanceSupplierMixin unirestInstanceSupplier; + @Mixin private SourceMixin sourceSelector; private static final Logger LOG = LoggerFactory.getLogger(FoDAviatorApplyRemediationsCommand.class); - @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; + /** + * Exclusive source selection: online FoD release (via standard release resolver) or local cache zip. + * Propagates {@link FoDDelimiterMixin} into the nested release resolver, matching FoD patterns such as + * {@code FoDAppOrReleaseMixin}. + */ + public static final class SourceMixin implements IFoDDelimiterMixinAware { + @ArgGroup(exclusive = true, multiplicity = "1") + private SourceArgGroup source = new SourceArgGroup(); + + @Override + public void setDelimiterMixin(FoDDelimiterMixin delimiterMixin) { + if (source != null && source.online != null) { + source.online.setDelimiterMixin(delimiterMixin); + } + } + + public boolean isFromCacheSelected() { + return source != null && source.fromCache != null; + } + + public Path getFromCache() { + return isFromCacheSelected() ? source.fromCache : null; + } + + public FoDReleaseDescriptor getReleaseDescriptor(UnirestInstance unirest) { + return source.online.getReleaseDescriptor(unirest); + } + } + @Getter static class SourceArgGroup { - @ArgGroup(exclusive = false) - private FoDAviatorRemediationSelectorArgGroups.ReleaseArgGroup online; + @ArgGroup(exclusive = false, multiplicity = "1") + private OnlineReleaseArgGroup online = new OnlineReleaseArgGroup(); @Option(names = {"--from-cache"}, required = true, paramLabel = "", descriptionKey = "fcli.fod.aviator.apply-remediations.from-cache") private Path fromCache; } + /** + * Online release branch reusing the standard FoD release option wiring/resolution. + */ + static class OnlineReleaseArgGroup + extends FoDReleaseByQualifiedNameOrIdResolverMixin.AbstractFoDQualifiedReleaseNameOrIdResolverMixin { + @Option(names = {"--release", "--rel"}, required = true, paramLabel = "id|app[:ms]:rel", + descriptionKey = "fcli.fod.release.resolver.name-or-id") + @Getter private String qualifiedReleaseNameOrId; + } + @Override @SneakyThrows public JsonNode getJsonNode() { @@ -98,12 +136,11 @@ public JsonNode getJsonNode() { validateSelection(); try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); - if (isFromCacheSelected()) { + if (sourceSelector.isFromCacheSelected()) { return processFromCache(logger, issueIdFilter); } UnirestInstance unirest = unirestInstanceSupplier.getUnirestInstance(); - FoDReleaseDescriptor rd = FoDReleaseHelper.getReleaseDescriptor( - unirest, source.online.getQualifiedReleaseNameOrId(), delimiterMixin.getDelimiter(), true); + FoDReleaseDescriptor rd = sourceSelector.getReleaseDescriptor(unirest); return processFprRemediations(unirest, rd, logger, issueIdFilter); } } @@ -119,19 +156,15 @@ private Set getIssueIdFilter() { } private void validateSelection() { - if (issueIds != null && !issueIds.isEmpty() && !isFromCacheSelected()) { + 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"); } } - private boolean isFromCacheSelected() { - return source != null && source.fromCache != null; - } - @SneakyThrows private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdFilter) { - Path cacheZip = source.fromCache; + Path cacheZip = sourceSelector.getFromCache(); try (RemediationsCacheReader cacheReader = RemediationsCacheReader.open(cacheZip)) { CacheProcessingResult cacheResult = processCacheEntries(cacheReader, logger, issueIdFilter); RemediationMetric aggregatedMetric = AviatorRemediationMetricsHelper.aggregateMetrics( 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 index 712927928ac..c257ccb05d0 100644 --- 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 @@ -37,14 +37,13 @@ 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 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; @@ -54,12 +53,10 @@ public class FoDAviatorDownloadRemediationsCacheCommand extends AbstractFoDJsonN private static final int MAX_RETRIES = 10; @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; - @Mixin private FoDDelimiterMixin delimiterMixin; + @Mixin private FoDDelimiterMixin delimiterMixin; // Injected into releaseResolver + @Mixin private FoDReleaseByQualifiedNameOrIdResolverMixin.RequiredOption releaseResolver; @Mixin private CommonOptionMixins.RequireConfirmation requireConfirmation; - @ArgGroup(exclusive = false, multiplicity = "1") - private FoDAviatorRemediationSelectorArgGroups.ReleaseArgGroup releaseSelection; - @Option(names = {"-f", "--file"}, required = true, paramLabel = "", descriptionKey = "fcli.fod.aviator.download-remediations-cache.file") private File outputFile; @@ -72,8 +69,7 @@ public JsonNode getJsonNode(UnirestInstance unirest) { requireConfirmation.checkConfirmed(destination); } - FoDReleaseDescriptor releaseDescriptor = FoDReleaseHelper.getReleaseDescriptor( - unirest, releaseSelection.getQualifiedReleaseNameOrId(), delimiterMixin.getDelimiter(), true); + FoDReleaseDescriptor releaseDescriptor = releaseResolver.getReleaseDescriptor(unirest); Path tempFpr = Files.createTempFile("aviator-cache-" + releaseDescriptor.getReleaseId() + "-", ".fpr"); try { downloadFpr(unirest, releaseDescriptor, tempFpr); diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorRemediationSelectorArgGroups.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorRemediationSelectorArgGroups.java deleted file mode 100644 index 6903c673782..00000000000 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorRemediationSelectorArgGroups.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2021-2026 Open Text. - * - * The only warranties for products and services of Open Text - * and its affiliates and licensors ("Open Text") are as may - * be set forth in the express warranty statements accompanying - * such products and services. Nothing herein should be construed - * as constituting an additional warranty. Open Text shall not be - * liable for technical or editorial errors or omissions contained - * herein. The information contained herein is subject to change - * without notice. - */ -package com.fortify.cli.fod.aviator.cmd; - -import lombok.Getter; -import picocli.CommandLine.Option; - -public final class FoDAviatorRemediationSelectorArgGroups { - private FoDAviatorRemediationSelectorArgGroups() {} - - @Getter - public static class ReleaseArgGroup { - @Option(names = {"--release", "--rel"}, required = true, paramLabel = "id|app[:ms]:rel", - descriptionKey = "fcli.fod.release.resolver.name-or-id") - private String qualifiedReleaseNameOrId; - } -} \ No newline at end of file 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 5e599c3f23c..c6360db2d79 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 @@ -97,12 +97,12 @@ void testIssueIdsOptionParsesIntoField() throws Exception { @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)); + Field sourceSelectorField = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("sourceSelector"); + sourceSelectorField.setAccessible(true); + FoDAviatorApplyRemediationsCommand.SourceMixin sourceSelector = + (FoDAviatorApplyRemediationsCommand.SourceMixin) sourceSelectorField.get(command); + assertEquals(Path.of("remediations.zip"), sourceSelector.getFromCache()); + assertTrue(sourceSelector.isFromCacheSelected()); } @Test From 17a872936167b56cd056b23a3bbb185bec8387c6 Mon Sep 17 00:00:00 2001 From: kireetivar Date: Tue, 21 Jul 2026 20:00:17 +0530 Subject: [PATCH 12/18] chore: stream remediations cache into ZipFS and extract apply helpers --- .../RemediationsCacheApplyHelper.java | 119 ++++++++ .../RemediationsCacheReader.java | 254 ++++++++-------- .../RemediationsCacheWriter.java | 277 ++++++++++++++---- .../_common/util/AviatorLocalFprHelper.java | 6 +- .../_common/util/AviatorTempFprFile.java | 74 +++++ .../RemediationsCacheRoundTripTest.java | 56 +++- .../AviatorSSCApplyRemediationsCommand.java | 264 +++-------------- ...orSSCDownloadRemediationsCacheCommand.java | 38 +-- .../AviatorSSCOnlineRemediationsApplier.java | 152 ++++++++++ .../common/exception/FcliSimpleException.java | 14 + .../FoDAviatorApplyRemediationsCommand.java | 207 +++---------- ...iatorDownloadRemediationsCacheCommand.java | 71 +---- .../AviatorFoDOnlineRemediationsApplier.java | 72 +++++ .../FoDRemediationsFprDownloadHelper.java | 135 +++++++++ .../ssc/transfer/SSCFileTransferHelper.java | 88 ++++++ 15 files changed, 1150 insertions(+), 677 deletions(-) create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheApplyHelper.java create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorTempFprFile.java create mode 100644 fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCOnlineRemediationsApplier.java create mode 100644 fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/AviatorFoDOnlineRemediationsApplier.java create mode 100644 fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/FoDRemediationsFprDownloadHelper.java diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheApplyHelper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheApplyHelper.java new file mode 100644 index 00000000000..db9a138d489 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheApplyHelper.java @@ -0,0 +1,119 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.remediations_cache; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.slf4j.Logger; + +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; +import com.fortify.cli.aviator._common.util.AviatorRemediationMetricsHelper; +import com.fortify.cli.aviator.applyRemediation.ApplyAutoRemediationOnSource; +import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.aviator.fpr.processor.RemediationProcessor.RemediationMetric; +import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.common.exception.FcliTechnicalException; + +/** + * Shared apply-remediations logic for cache zip entries (SSC and FoD). + * SHA-256 is verified by {@link RemediationsCacheReader#getOrderedFprPaths()} (no extra local FPR pass). + */ +public final class RemediationsCacheApplyHelper { + private RemediationsCacheApplyHelper() {} + + public enum EntryIdKind { + ARTIFACT_ID, + RELEASE_ID + } + + public record ApplyResult( + List processedEntries, + List processedIds, + int skipped, + List metrics) {} + + /** + * Applies remediations for each ordered cache FPR until done or issue-id filter is exhausted. + */ + public static ApplyResult applyEntries( + RemediationsCacheReader cacheReader, + String expectedProduct, + String sourceCodeDirectory, + IAviatorLogger logger, + Set issueIdFilter, + EntryIdKind idKind, + Logger skipLog) { + cacheReader.requireProduct(expectedProduct); + + List fprPaths = cacheReader.getOrderedFprPaths(); + List entryPaths = cacheReader.getOrderedEntryPaths(); + List entryIds = idKind == EntryIdKind.ARTIFACT_ID + ? cacheReader.getOrderedArtifactIds() + : cacheReader.getOrderedReleaseIds(); + + List metrics = new ArrayList<>(); + List processedEntries = new ArrayList<>(); + List processedIds = 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; + } + String entryLabel = i < entryPaths.size() ? entryPaths.get(i) : fprPaths.get(i).getFileName().toString(); + RemediationMetric metric = applyOneEntry( + fprPaths.get(i), entryLabel, i + 1, fprPaths.size(), sourceCodeDirectory, logger, remaining, skipLog); + if (metric == null) { + skipped++; + } else { + metrics.add(metric); + processedEntries.add(entryLabel); + processedIds.add(i < entryIds.size() ? entryIds.get(i) : ""); + remaining = AviatorRemediationMetricsHelper.getRemainingIssueIds(remaining, metric); + } + } + return new ApplyResult( + List.copyOf(processedEntries), List.copyOf(processedIds), skipped, List.copyOf(metrics)); + } + + public static String actionLabel(RemediationMetric metric) { + return metric != null && metric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; + } + + private static RemediationMetric applyOneEntry( + Path fprPath, + String entryLabel, + int index, + int total, + String sourceCodeDirectory, + IAviatorLogger logger, + Set issueFilter, + Logger skipLog) { + logger.progress("Processing FPR " + index + "/" + total + " (" + entryLabel + ")"); + logger.progress("Status: Processing FPR with Aviator for Applying Auto Remediations"); + try (FprHandle fprHandle = new FprHandle(fprPath)) { + return ApplyAutoRemediationOnSource.applyRemediations(fprHandle, sourceCodeDirectory, logger, issueFilter); + } catch (AviatorSimpleException e) { + skipLog.warn("Skipping cache entry {} as {}", entryLabel, e.getMessage()); + return null; + } catch (IOException e) { + throw new FcliTechnicalException("Failed to close FPR handle for cache entry " + entryLabel, e); + } + } +} 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 index 3bdc7724f30..1ef912a5379 100644 --- 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 @@ -24,185 +24,181 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.fortify.cli.common.exception.AbstractFcliException; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.exception.FcliTechnicalException; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.util.ZipHelper; +import lombok.Getter; + /** - * Opens a remediations cache zip, validates the manifest and entry checksums, - * and exposes FPR files directly from the zip file system for ordered processing. + * Opens a remediations cache zip as a {@link FileSystem} and exposes manifest data and + * ordered FPR paths. Construction only validates the zip path and opens the filesystem; + * manifest/entry validation happens lazily via getters so a single try-with-resources on + * this reader owns {@code cacheFs} cleanup. */ public final class RemediationsCacheReader implements AutoCloseable { private static final Logger logger = LoggerFactory.getLogger(RemediationsCacheReader.class); private final Path cacheZip; private final FileSystem cacheFs; - private final RemediationsCacheManifest manifest; - private final List orderedEntries; - private final List orderedFprPaths; - - private RemediationsCacheReader(Path cacheZip, FileSystem cacheFs, RemediationsCacheManifest manifest, - List orderedEntries, List orderedFprPaths) { - this.cacheZip = cacheZip; - this.cacheFs = cacheFs; - this.manifest = manifest; - this.orderedEntries = orderedEntries; - 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); - } + @Getter(lazy = true) + private final RemediationsCacheManifest manifest = loadAndValidateManifest(); + + @Getter(lazy = true) + private final List orderedEntries = loadOrderedEntries(); + + @Getter(lazy = true) + private final List orderedFprPaths = loadOrderedFprPaths(); - FileSystem cacheFs = null; + /** + * Validates {@code cacheZip} and opens it as a zip file system. Prefer use via + * try-with-resources so {@link #close()} always runs. + */ + public RemediationsCacheReader(Path cacheZip) { + Path validated = validateCacheZip(cacheZip); + FileSystem opened; try { - cacheFs = ZipHelper.openZipFileSystem(cacheZip); - Path manifestPath = cacheFs.getPath(RemediationsCacheConstants.MANIFEST_ENTRY); - if (!Files.isRegularFile(manifestPath)) { - throw new FcliSimpleException("Remediations cache is missing " + RemediationsCacheConstants.MANIFEST_ENTRY - + ": " + cacheZip); - } - - RemediationsCacheManifest manifest; - try (var manifestInputStream = Files.newInputStream(manifestPath)) { - manifest = JsonHelper.getObjectMapper() - .readValue(manifestInputStream, 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 = getEntryPath(cacheFs, entry.getPath()); - if (!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); - } - - FileSystem openCacheFs = cacheFs; - cacheFs = null; - return new RemediationsCacheReader(cacheZip, openCacheFs, manifest, List.copyOf(entries), orderedFprs); - } catch (FcliSimpleException | FcliTechnicalException e) { + opened = ZipHelper.openZipFileSystem(validated); + } catch (AbstractFcliException e) { throw e; - } catch (IOException e) { - throw new FcliTechnicalException("Failed to read remediations cache: " + cacheZip, e); - } finally { - closeQuietly(cacheFs); + } catch (RuntimeException e) { + throw new FcliTechnicalException("Failed to open remediations cache: " + validated, e); } + this.cacheZip = validated; + this.cacheFs = opened; } - public RemediationsCacheManifest getManifest() { - return manifest; - } - - public List getOrderedEntries() { - return orderedEntries; + /** Factory alias for call sites that prefer a static open style. */ + public static RemediationsCacheReader open(Path cacheZip) { + return new RemediationsCacheReader(cacheZip); } - public List getOrderedFprPaths() { - return List.copyOf(orderedFprPaths); + public Path getCacheZip() { + return cacheZip; } public List getOrderedEntryPaths() { - return orderedEntries.stream().map(RemediationsCacheEntry::getPath).toList(); + return getOrderedEntries().stream().map(RemediationsCacheEntry::getPath).toList(); } public List getOrderedArtifactIds() { - return orderedEntries.stream() + return getOrderedEntries().stream() .map(e -> e.getArtifactId() != null ? e.getArtifactId() : "") .toList(); } public List getOrderedReleaseIds() { - return orderedEntries.stream() + return getOrderedEntries().stream() .map(e -> e.getReleaseId() != null ? e.getReleaseId() : "") .toList(); } - public Path getCacheZip() { - return cacheZip; + /** + * Ensures the cache was produced for the expected product ({@code ssc} or {@code fod}). + */ + public void requireProduct(String expectedProduct) { + String actual = getManifest().getProduct(); + FcliSimpleException.throwIf(!expectedProduct.equals(actual), + "Remediations cache product is '%s' but this command expects '%s': %s", + actual, expectedProduct, cacheZip); } @Override public void close() { - closeQuietly(cacheFs); - } - - 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 (cacheFs == null || !cacheFs.isOpen()) { + return; } - if (manifest.getEntries() == null || manifest.getEntries().isEmpty()) { - throw new FcliSimpleException("Remediations cache has no entries: " + cacheZip); + try { + cacheFs.close(); + } catch (IOException e) { + logger.warn("Failed to close cache filesystem", e); } - 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 Path validateCacheZip(Path cacheZip) { + FcliSimpleException.throwIf(cacheZip == null, + "--from-cache must specify a remediations cache zip path"); + FcliSimpleException.throwIf(!Files.exists(cacheZip), + "Remediations cache file does not exist: %s", cacheZip); + FcliSimpleException.throwIf(!Files.isRegularFile(cacheZip), + "Remediations cache path is not a regular file: %s", cacheZip); + FcliSimpleException.throwIf(!Files.isReadable(cacheZip), + "Remediations cache file is not readable: %s", cacheZip); + return cacheZip; + } + + private RemediationsCacheManifest loadAndValidateManifest() { + Path manifestPath = cacheFs.getPath(RemediationsCacheConstants.MANIFEST_ENTRY); + FcliSimpleException.throwIf(!Files.isRegularFile(manifestPath), + "Remediations cache is missing %s: %s", + RemediationsCacheConstants.MANIFEST_ENTRY, cacheZip); + try (var manifestInputStream = Files.newInputStream(manifestPath)) { + RemediationsCacheManifest manifest = JsonHelper.getObjectMapper() + .readValue(manifestInputStream, RemediationsCacheManifest.class); + validateManifest(manifest); + return manifest; + } catch (AbstractFcliException e) { + throw e; + } catch (IOException e) { + throw new FcliTechnicalException("Failed to read remediations cache manifest: " + cacheZip, e); + } catch (RuntimeException e) { + throw new FcliTechnicalException("Failed to parse remediations cache manifest: " + cacheZip, e); } } - private static Path getEntryPath(FileSystem cacheFs, String entryPath) { - String normalizedPath = normalizeZipPath(entryPath); - if (normalizedPath.contains("..")) { - throw new FcliSimpleException("Remediations cache contains unsafe path: " + entryPath); + private void validateManifest(RemediationsCacheManifest manifest) { + FcliSimpleException.throwIf(manifest == null, + "Remediations cache manifest is empty: %s", cacheZip); + FcliSimpleException.throwIf(manifest.getSchemaVersion() != RemediationsCacheConstants.SCHEMA_VERSION, + "Unsupported remediations cache schemaVersion %s (expected %s): %s", + manifest.getSchemaVersion(), RemediationsCacheConstants.SCHEMA_VERSION, cacheZip); + FcliSimpleException.throwIf(!RemediationsCacheConstants.KIND.equals(manifest.getKind()), + "Invalid remediations cache kind '%s' (expected %s): %s", + manifest.getKind(), RemediationsCacheConstants.KIND, cacheZip); + FcliSimpleException.throwIf(StringUtils.isBlank(manifest.getProduct()), + "Remediations cache manifest is missing product: %s", cacheZip); + FcliSimpleException.throwIf(manifest.getEntries() == null || manifest.getEntries().isEmpty(), + "Remediations cache has no entries: %s", cacheZip); + for (RemediationsCacheEntry entry : manifest.getEntries()) { + FcliSimpleException.throwIf(entry == null, + "Remediations cache contains a null entry: %s", cacheZip); + FcliSimpleException.throwIf(StringUtils.isBlank(entry.getPath()), + "Remediations cache entry is missing path: %s", cacheZip); + FcliSimpleException.throwIf(StringUtils.isBlank(entry.getSha256()), + "Remediations cache entry is missing sha256: %s", entry.getPath()); } - return cacheFs.getPath(normalizedPath).normalize(); } - private static String normalizeZipPath(String path) { - return path.replace('\\', '/'); + private List loadOrderedEntries() { + List entries = new ArrayList<>(getManifest().getEntries()); + entries.sort(Comparator.comparingInt(RemediationsCacheEntry::getOrder)); + return List.copyOf(entries); } - private static void closeQuietly(FileSystem cacheFs) { - if (cacheFs == null || !cacheFs.isOpen()) { - return; - } - try { - cacheFs.close(); - } catch (IOException e) { - logger.warn("Failed to close cache filesystem", e); + private List loadOrderedFprPaths() { + List orderedFprs = new ArrayList<>(); + for (RemediationsCacheEntry entry : getOrderedEntries()) { + Path fprPath = resolveEntryPath(entry.getPath()); + FcliSimpleException.throwIf(!Files.isRegularFile(fprPath), + "Remediations cache entry path not found in zip: %s", entry.getPath()); + String actualSha = RemediationsCacheSha256.hashFile(fprPath); + FcliSimpleException.throwIf(!actualSha.equalsIgnoreCase(entry.getSha256()), + "SHA-256 mismatch for cache entry %s (expected %s, actual %s)", + entry.getPath(), entry.getSha256(), actualSha); + orderedFprs.add(fprPath); } + FcliSimpleException.throwIf(orderedFprs.isEmpty(), + "Remediations cache contains no FPR entries: %s", cacheZip); + return List.copyOf(orderedFprs); + } + + private Path resolveEntryPath(String entryPath) { + String normalizedPath = entryPath.replace('\\', '/'); + FcliSimpleException.throwIf(normalizedPath.contains(".."), + "Remediations cache contains unsafe path: %s", entryPath); + return cacheFs.getPath(normalizedPath).normalize(); } } 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 index 66dd4d8a4de..28c735969da 100644 --- 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 @@ -13,123 +13,278 @@ package com.fortify.cli.aviator._common.remediations_cache; import java.io.IOException; +import java.nio.file.FileSystem; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.time.Instant; import java.util.List; import java.util.Map; +import java.util.function.Consumer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.cli.common.exception.AbstractFcliException; +import com.fortify.cli.common.exception.FcliBugException; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.exception.FcliTechnicalException; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.util.ZipHelper; -public final class RemediationsCacheWriter { - private RemediationsCacheWriter() {} +/** + * Builds a remediations cache zip at a destination path. Prefer opening a writer, writing + * each FPR directly into the zip filesystem (for example via download APIs that accept + * {@link Path}), then {@link #finish()} and close. No intermediate temp zip is used. + * + *

The destination is kept only when {@link #finish()} succeeded and + * {@link #close()} closed the zip filesystem successfully (ZipFS finalizes the archive on close). + */ +public final class RemediationsCacheWriter implements AutoCloseable { + private static final Logger logger = LoggerFactory.getLogger(RemediationsCacheWriter.class); + + private final Path destination; + private final FileSystem zipFs; + private final RemediationsCacheManifest manifest; + private int nextOrder = 1; + /** True after manifest.json has been written into the open ZipFS (not yet durable until close). */ + private boolean manifestWritten; + private boolean closed; + + private RemediationsCacheWriter(Path destination, FileSystem zipFs, RemediationsCacheManifest manifest) { + this.destination = destination; + this.zipFs = zipFs; + this.manifest = manifest; + } /** - * 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 + * Opens (or recreates) the destination zip and prepares an empty manifest. + * Existing destination files are replaced by {@link ZipHelper#createZipFileSystem(Path)}. + */ + public static RemediationsCacheWriter create(Path destination, String product, Map selection) { + FcliSimpleException.throwIf(destination == null, + "-f/--file must specify a remediations cache zip path"); + // ZipHelper.createZipFileSystem creates parent dirs and replaces any existing file. + FileSystem zipFs = ZipHelper.createZipFileSystem(destination); + try { + Files.createDirectories(zipFs.getPath(RemediationsCacheConstants.FPRS_DIR)); + // On success, zipFs ownership transfers to the returned writer (not closed here). + return new RemediationsCacheWriter(destination, zipFs, newManifest(product, selection)); + } catch (AbstractFcliException e) { + abortCreate(zipFs, destination); + throw e; + } catch (IOException e) { + abortCreate(zipFs, destination); + throw new FcliTechnicalException("Failed to initialize remediations cache zip: " + destination, e); + } catch (RuntimeException e) { + abortCreate(zipFs, destination); + throw new FcliTechnicalException("Failed to initialize remediations cache zip: " + destination, e); + } + } + + /** + * Convenience for callers that already have FPR files on disk (for example unit tests). + * Writes directly to {@code destination} (no temp zip). */ 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"); + FcliSimpleException.throwIf(fprSources == null || fprSources.isEmpty(), + "Cannot create remediations cache: no FPR files to include"); + try (RemediationsCacheWriter writer = create(destination, product, selection)) { + for (FprSource source : fprSources) { + writer.addFprFromFile(source); + } + return writer.finish(); } - if (destination == null) { - throw new FcliSimpleException("-f/--file must specify a remediations cache zip path"); + } + + /** + * Allocates the next zip entry path, invokes {@code contentWriter} to populate it + * (for example download into the path), then records SHA-256 and manifest metadata. + */ + public void addFpr(String artifactId, String releaseId, String uploadDate, Consumer contentWriter) { + requireWritable(); + int order = nextOrder++; + String entryPath = entryPath(artifactId, releaseId, order); + try { + Path target = prepareEntryPath(entryPath); + writeEntryContent(target, entryPath, contentWriter); + recordEntry(order, entryPath, artifactId, releaseId, uploadDate, target); + } catch (AbstractFcliException e) { + throw e; + } catch (IOException e) { + throw new FcliTechnicalException("Failed to add remediations cache entry " + entryPath, e); + } catch (RuntimeException e) { + throw new FcliTechnicalException("Failed to add remediations cache entry " + entryPath, e); } + } - Path parent = destination.toAbsolutePath().getParent(); - if (parent != null) { + /** Copies an existing FPR file into the cache zip and records its checksum. */ + public void addFprFromFile(FprSource source) { + requireWritable(); + FcliSimpleException.throwIf(source == null || source.path() == null, + "FPR source path is required"); + FcliSimpleException.throwIf(!Files.isRegularFile(source.path()), + "FPR source is not a readable regular file: %s", source.path()); + addFpr(source.artifactId(), source.releaseId(), source.uploadDate(), target -> { try { - Files.createDirectories(parent); + Files.copy(source.path(), target, StandardCopyOption.REPLACE_EXISTING); } catch (IOException e) { - throw new FcliTechnicalException("Failed to create parent directory for " + destination, e); + throw new FcliTechnicalException("Failed to copy FPR into cache: " + source.path(), 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); + /** Creates parent directories for a zip entry and returns its path. */ + private Path prepareEntryPath(String entryPath) throws IOException { + Path target = zipFs.getPath(entryPath); + Path parent = target.getParent(); + if (parent != null) { + Files.createDirectories(parent); } + return target; + } - Path tempZip = null; - try { - tempZip = Files.createTempFile("remediations-cache-", ".zip"); - try (var zipFs = ZipHelper.createZipFileSystem(tempZip)) { - Files.createDirectories(zipFs.getPath(RemediationsCacheConstants.FPRS_DIR)); - addFprEntries(zipFs.getPath("/"), manifest, fprSources); - byte[] manifestBytes = JsonHelper.getObjectMapper().writerWithDefaultPrettyPrinter() - .writeValueAsBytes(manifest); - Files.write(zipFs.getPath(RemediationsCacheConstants.MANIFEST_ENTRY), manifestBytes); - } + /** Invokes {@code contentWriter} and ensures a regular file was produced at {@code target}. */ + private static void writeEntryContent(Path target, String entryPath, Consumer contentWriter) { + contentWriter.accept(target); + FcliSimpleException.throwIf(!Files.isRegularFile(target), + "Cache entry was not written: %s", entryPath); + } - Files.move(tempZip, destination, StandardCopyOption.REPLACE_EXISTING); - tempZip = null; + /** Hashes the entry and appends it to the in-memory manifest. */ + private void recordEntry( + int order, String entryPath, String artifactId, String releaseId, String uploadDate, Path target) { + String sha256 = RemediationsCacheSha256.hashFile(target); + manifest.getEntries().add(toManifestEntry(order, entryPath, artifactId, releaseId, uploadDate, sha256)); + } + + /** + * Writes {@code manifest.json} into the open zip. Must be called before {@link #close()} + * for a successful cache. The archive is only durable after a successful {@link #close()}. + * Returns the completed manifest. + */ + public RemediationsCacheManifest finish() { + requireWritable(); + FcliSimpleException.throwIf(manifest.getEntries().isEmpty(), + "Cannot create remediations cache: no FPR files to include"); + try { + byte[] manifestBytes = JsonHelper.getObjectMapper().writerWithDefaultPrettyPrinter() + .writeValueAsBytes(manifest); + Files.write(zipFs.getPath(RemediationsCacheConstants.MANIFEST_ENTRY), manifestBytes); + manifestWritten = true; return manifest; - } catch (FcliSimpleException | FcliTechnicalException e) { + } catch (AbstractFcliException e) { throw e; } catch (IOException e) { - throw new FcliTechnicalException("Failed to write remediations cache to " + destination, e); + throw new FcliTechnicalException("Failed to write remediations cache manifest to " + destination, e); + } catch (RuntimeException e) { + throw new FcliTechnicalException("Failed to write remediations cache manifest to " + destination, e); + } + } + + @Override + public void close() { + if (closed) { + return; + } + closed = true; + boolean closeSucceeded = false; + try { + if (zipFs != null && zipFs.isOpen()) { + zipFs.close(); + } + closeSucceeded = true; + } catch (IOException e) { + // ZipFS finalizes the archive on close; failure means destination is not trustworthy. + if (manifestWritten) { + deleteQuietly(destination); + throw new FcliTechnicalException("Failed to finalize remediations cache zip: " + destination, e); + } + logger.warn("Failed to close remediations cache zip: {}", destination, e); } finally { - if (tempZip != null) { - try { - Files.deleteIfExists(tempZip); - } catch (IOException ignored) { - // best effort cleanup of incomplete temp zip - } + // Keep destination only when finish() wrote the manifest and ZipFS closed cleanly. + if (!(manifestWritten && closeSucceeded)) { + deleteQuietly(destination); } } } - private static void addFprEntries(Path zipRoot, RemediationsCacheManifest manifest, List fprSources) throws IOException { - 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()); + private void requireWritable() { + if (closed) { + throw new FcliBugException("RemediationsCacheWriter is already closed: " + destination); + } + if (manifestWritten) { + throw new FcliBugException("RemediationsCacheWriter is already finished: " + destination); + } + } + + private static void abortCreate(FileSystem zipFs, Path destination) { + closeQuietly(zipFs); + deleteQuietly(destination); + } - Files.copy(source.path(), zipRoot.resolve(entryPath), StandardCopyOption.REPLACE_EXISTING); - manifest.getEntries().add(toManifestEntry(source, order, entryPath, sha256)); + private static RemediationsCacheManifest newManifest(String product, Map selection) { + RemediationsCacheManifest manifest = new RemediationsCacheManifest(); + manifest.setSchemaVersion(RemediationsCacheConstants.SCHEMA_VERSION); + manifest.setKind(RemediationsCacheConstants.KIND); + manifest.setProduct(product); + manifest.setCreatedAt(Instant.now().toString()); + if (selection != null) { + manifest.getSelection().putAll(selection); } + return manifest; } - private static RemediationsCacheEntry toManifestEntry(FprSource source, int order, String entryPath, String sha256) { + private static RemediationsCacheEntry toManifestEntry( + int order, String entryPath, String artifactId, String releaseId, String uploadDate, String sha256) { RemediationsCacheEntry entry = new RemediationsCacheEntry(); entry.setOrder(order); - entry.setArtifactId(source.artifactId()); - entry.setReleaseId(source.releaseId()); - entry.setUploadDate(source.uploadDate()); + entry.setArtifactId(artifactId); + entry.setReleaseId(releaseId); + entry.setUploadDate(uploadDate); entry.setPath(entryPath); entry.setSha256(sha256); return entry; } - private static String entryPath(FprSource source, int order) { - if (source.artifactId() != null && !source.artifactId().isBlank()) { + private static String entryPath(String artifactId, String releaseId, int order) { + if (artifactId != null && !artifactId.isBlank()) { return String.format("%s/%03d_artifact_%s.fpr", - RemediationsCacheConstants.FPRS_DIR, order, source.artifactId()); + RemediationsCacheConstants.FPRS_DIR, order, artifactId); } - if (source.releaseId() != null && !source.releaseId().isBlank()) { + if (releaseId != null && !releaseId.isBlank()) { return String.format("%s/%03d_release_%s.fpr", - RemediationsCacheConstants.FPRS_DIR, order, source.releaseId()); + RemediationsCacheConstants.FPRS_DIR, order, releaseId); } return String.format("%s/%03d_remediations.fpr", RemediationsCacheConstants.FPRS_DIR, order); } + private static void closeQuietly(FileSystem zipFs) { + if (zipFs == null || !zipFs.isOpen()) { + return; + } + try { + zipFs.close(); + } catch (IOException e) { + logger.warn("Failed to close zip filesystem during abort", e); + } + } + + private static void deleteQuietly(Path path) { + if (path == null) { + return; + } + try { + Files.deleteIfExists(path); + } catch (IOException e) { + logger.warn("Failed to delete incomplete remediations cache file: {}", path, e); + } + } + 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); 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 index 45cf314bd26..180f536b5af 100644 --- 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 @@ -12,11 +12,13 @@ */ package com.fortify.cli.aviator._common.util; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.common.exception.AbstractFcliException; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.exception.FcliTechnicalException; @@ -51,11 +53,11 @@ private static void validateLocalFpr(Path fprPath, String sourceLabel) { } try (FprHandle fprHandle = new FprHandle(fprPath)) { fprHandle.validate(); - } catch (FcliSimpleException e) { + } catch (AbstractFcliException e) { throw e; } catch (RuntimeException e) { throw new FcliSimpleException(sourceLabel + " is not a valid audited SAST FPR: " + fprPath, e); - } catch (java.io.IOException e) { + } catch (IOException e) { throw new FcliTechnicalException("Failed to close " + sourceLabel + ": " + fprPath, e); } } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorTempFprFile.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorTempFprFile.java new file mode 100644 index 00000000000..bfc3de2f495 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorTempFprFile.java @@ -0,0 +1,74 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.util; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.cli.common.exception.FcliTechnicalException; + +/** + * Host-filesystem temporary FPR used only when a real {@link Path} is required + * (for example online downloads before {@code FprHandle} can open a nested zip FS). + * Prefer ZipFS entry paths from {@code RemediationsCacheReader}/{@code RemediationsCacheWriter} + * whenever content already lives in a durable cache zip. + * + *

Owns cleanup via {@link AutoCloseable}; use try-with-resources so interrupt/exception + * paths still delete the file. + */ +public final class AviatorTempFprFile implements AutoCloseable { + private static final Logger logger = LoggerFactory.getLogger(AviatorTempFprFile.class); + + private final Path path; + + private AviatorTempFprFile(Path path) { + this.path = path; + } + + /** + * @param nameHint short label (artifact/release id); sanitized into the temp file prefix + */ + public static AviatorTempFprFile create(String nameHint) { + String safe = sanitize(nameHint); + try { + return new AviatorTempFprFile(Files.createTempFile("aviator-" + safe + "-", ".fpr")); + } catch (IOException e) { + throw new FcliTechnicalException("Failed to create temporary FPR file for " + safe, e); + } + } + + public Path path() { + return path; + } + + @Override + public void close() { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + logger.warn("Failed to delete temporary FPR file: {}", path, e); + } + } + + private static String sanitize(String nameHint) { + if (nameHint == null || nameHint.isBlank()) { + return "fpr"; + } + String cleaned = nameHint.replaceAll("[^A-Za-z0-9._-]", "_"); + return cleaned.length() > 40 ? cleaned.substring(0, 40) : cleaned; + } +} diff --git a/fcli-core/fcli-aviator-common/src/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 index ad8d89160b5..acd7a51ef9d 100644 --- 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 @@ -16,12 +16,14 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; import org.junit.jupiter.api.Test; @@ -59,6 +61,27 @@ void writeAndReadRoundTripPreservesOrderAndHashes() throws Exception { } } + @Test + void writeDirectlyIntoDestinationZipWithoutTempStaging() throws Exception { + Path zip = tempDir.resolve("direct.zip"); + try (RemediationsCacheWriter writer = RemediationsCacheWriter.create( + zip, RemediationsCacheConstants.PRODUCT_FOD, Map.of("mode", "release"))) { + writer.addFpr(null, "rel-1", null, path -> { + try { + Files.writeString(path, "streamed-fpr"); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + writer.finish(); + } + try (RemediationsCacheReader reader = RemediationsCacheReader.open(zip)) { + reader.requireProduct(RemediationsCacheConstants.PRODUCT_FOD); + assertEquals("streamed-fpr", Files.readString(reader.getOrderedFprPaths().get(0))); + assertEquals("rel-1", reader.getOrderedReleaseIds().get(0)); + } + } + @Test void emptySourcesRejected() { Path zip = tempDir.resolve("empty.zip"); @@ -88,7 +111,7 @@ void cachedFprCanBeOpenedAsNestedZipFileSystem() throws Exception { } @Test - void badSha256Rejected() throws Exception { + void badSha256RejectedOnLazyPathLoad() throws Exception { Path fpr = tempDir.resolve("one.fpr"); Files.writeString(fpr, "original"); Path zip = tempDir.resolve("cache.zip"); @@ -98,9 +121,8 @@ void badSha256Rejected() throws Exception { 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)); + try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(zip)); ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(corruptZip))) { ZipEntry entry; while ((entry = zis.getNextEntry()) != null) { @@ -116,20 +138,42 @@ void badSha256Rejected() throws Exception { assertThrows(FcliSimpleException.class, () -> { try (RemediationsCacheReader reader = RemediationsCacheReader.open(corruptZip)) { - // open validates + reader.getOrderedFprPaths(); } }); } @Test - void missingManifestRejected() throws Exception { + void missingManifestRejectedOnLazyManifestLoad() throws Exception { Path zip = tempDir.resolve("no-manifest.zip"); try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zip))) { zos.putNextEntry(new ZipEntry("fprs/001.fpr")); zos.write("x".getBytes(StandardCharsets.UTF_8)); zos.closeEntry(); } - FcliSimpleException ex = assertThrows(FcliSimpleException.class, () -> RemediationsCacheReader.open(zip)); + FcliSimpleException ex = assertThrows(FcliSimpleException.class, () -> { + try (RemediationsCacheReader reader = RemediationsCacheReader.open(zip)) { + reader.getManifest(); + } + }); assertTrue(ex.getMessage().contains("manifest.json")); } + + @Test + void requireProductRejectsMismatch() throws Exception { + Path fpr = tempDir.resolve("one.fpr"); + Files.writeString(fpr, "x"); + Path zip = tempDir.resolve("cache.zip"); + RemediationsCacheWriter.write( + zip, + RemediationsCacheConstants.PRODUCT_SSC, + Map.of("mode", "artifact-id"), + List.of(RemediationsCacheWriter.FprSource.forSsc(fpr, "1", null))); + + assertThrows(FcliSimpleException.class, () -> { + try (RemediationsCacheReader reader = RemediationsCacheReader.open(zip)) { + reader.requireProduct(RemediationsCacheConstants.PRODUCT_FOD); + } + }); + } } 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 8269e593a52..00235be4c75 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommand.java @@ -12,12 +12,7 @@ */ package com.fortify.cli.aviator.ssc.cli.cmd; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; import java.time.OffsetDateTime; -import java.util.ArrayList; -import java.util.LinkedHashSet; import java.util.List; import java.util.Set; @@ -25,20 +20,19 @@ 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.RemediationsCacheApplyHelper; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheApplyHelper.ApplyResult; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheConstants; 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._common.util.AviatorRemediationMetricsHelper; -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.ssc.cli.mixin.AviatorSSCApplyRemediationsSourceMixin; import com.fortify.cli.aviator.ssc.helper.AviatorSSCApplyRemediationsHelper; +import com.fortify.cli.aviator.ssc.helper.AviatorSSCOnlineRemediationsApplier; 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.exception.FcliTechnicalException; 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; @@ -46,15 +40,12 @@ 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.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; import kong.unirest.UnirestInstance; import lombok.Getter; -import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; import picocli.CommandLine.Command; import picocli.CommandLine.Mixin; @@ -63,12 +54,13 @@ @Command(name = "apply-remediations") public class AviatorSSCApplyRemediationsCommand extends AbstractOutputCommand implements IJsonNodeSupplier, IRecordTransformer, IActionCommandResultSupplier { + private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCApplyRemediationsCommand.class); + @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; @Mixin private 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") @@ -79,106 +71,65 @@ public class AviatorSSCApplyRemediationsCommand extends AbstractOutputCommand public JsonNode getJsonNode() { sourceSelector.validate(); validateSourceCodeDirectory(); - Set issueIdFilter = getIssueIdFilter(); + Set issueIdFilter = AviatorIssueIdFilterUtils.normalizeIssueIds(issueIds); validateIssueIdFilterMode(); + try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); 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); - return processor.processFprRemediations(ad); + return processOnline(logger, progressWriter, issueIdFilter); } } - @SneakyThrows private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdFilter) { - Path cacheZip = sourceSelector.getFromCache(); - try (RemediationsCacheReader cacheReader = RemediationsCacheReader.open(cacheZip)) { + try (RemediationsCacheReader cacheReader = RemediationsCacheReader.open(sourceSelector.getFromCache())) { + ApplyResult applyResult = RemediationsCacheApplyHelper.applyEntries( + cacheReader, + RemediationsCacheConstants.PRODUCT_SSC, + sourceCodeDirectory, + logger, + issueIdFilter, + RemediationsCacheApplyHelper.EntryIdKind.ARTIFACT_ID, + LOG); + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + issueIdFilter, applyResult.metrics()); String appVersionId = cacheReader.getManifest().getSelection() != null ? cacheReader.getManifest().getSelection().get("appVersionId") : null; - CacheProcessingResult cacheResult = processCacheEntries(cacheReader, logger, issueIdFilter); - RemediationMetric aggregatedMetric = AviatorRemediationMetricsHelper.aggregateMetrics( - issueIdFilter, cacheResult.metrics()); - String action = aggregatedMetric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; return AviatorSSCApplyRemediationsHelper.buildCacheResultNode( new AviatorSSCApplyRemediationsHelper.CacheResultData( - cacheZip, - cacheResult.processedEntries(), - cacheResult.processedArtifactIds(), + sourceSelector.getFromCache(), + applyResult.processedEntries(), + applyResult.processedIds(), appVersionId, - cacheResult.metrics().size(), - cacheResult.skipped(), - aggregatedMetric.totalRemediations(), - aggregatedMetric.appliedRemediations(), - aggregatedMetric.skippedRemediations(), - aggregatedMetric.modifiedFiles(), - action)); + applyResult.metrics().size(), + applyResult.skipped(), + aggregated.totalRemediations(), + aggregated.appliedRemediations(), + aggregated.skippedRemediations(), + aggregated.modifiedFiles(), + RemediationsCacheApplyHelper.actionLabel(aggregated))); } } - private CacheProcessingResult processCacheEntries(RemediationsCacheReader cacheReader, AviatorLoggerImpl logger, - Set issueIdFilter) { - List fprPaths = cacheReader.getOrderedFprPaths(); - List allEntryPaths = cacheReader.getOrderedEntryPaths(); - List allArtifactIds = cacheReader.getOrderedArtifactIds(); - 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; - } - String entryLabel = i < allEntryPaths.size() ? allEntryPaths.get(i) : fprPaths.get(i).getFileName().toString(); - RemediationMetric metric = processCacheEntry(fprPaths.get(i), entryLabel, i + 1, fprPaths.size(), logger, remaining); - if (metric == null) { - skipped++; - } else { - metrics.add(metric); - processedEntries.add(entryLabel); - processedArtifactIds.add(i < allArtifactIds.size() ? allArtifactIds.get(i) : ""); - remaining = AviatorRemediationMetricsHelper.getRemainingIssueIds(remaining, metric); - } + private JsonNode processOnline( + AviatorLoggerImpl logger, IProgressWriter progressWriter, Set issueIdFilter) { + UnirestInstance unirest = unirestInstanceSupplier.getUnirestInstance(); + AviatorSSCOnlineRemediationsApplier applier = new AviatorSSCOnlineRemediationsApplier( + unirest, logger, progressWriter, sourceCodeDirectory, issueIdFilter); + OffsetDateTime sinceDate = SinceOptionHelper.parse(sourceSelector.getSince()); + if (sourceSelector.isAllSelected()) { + return applier.applyAll(sourceSelector.getAppVersionId(unirest), sinceDate); } - return new CacheProcessingResult(List.copyOf(processedEntries), List.copyOf(processedArtifactIds), skipped, metrics); + return applier.applyOne(resolveArtifactDescriptor(unirest, sinceDate)); } - private RemediationMetric processCacheEntry(Path fprPath, String entryLabel, int index, int total, - AviatorLoggerImpl logger, Set issueFilter) { - logger.progress("Processing FPR " + index + "/" + total + " (" + entryLabel + ")"); - logger.progress("Status: Processing FPR with Aviator for Applying Auto Remediations"); - try (FprHandle fprHandle = new FprHandle(fprPath)) { - return ApplyAutoRemediationOnSource.applyRemediations(fprHandle, sourceCodeDirectory, logger, issueFilter); - } catch (AviatorSimpleException e) { - LOG.warn("Skipping cache entry {} as {}", entryLabel, e.getMessage()); - return null; - } catch (IOException e) { - throw new FcliTechnicalException("Failed to close FPR handle for cache entry " + entryLabel, e); - } - } - - private record CacheProcessingResult( - List processedEntries, - List processedArtifactIds, - int skipped, - List metrics) {} - private SSCArtifactDescriptor resolveArtifactDescriptor(UnirestInstance unirest, OffsetDateTime sinceDate) { if (sourceSelector.isLatestSelected()) { - String appVersionId = sourceSelector.getAppVersionId(unirest); - return SSCArtifactHelper.getLatestAviatorArtifact(unirest, appVersionId, sinceDate); + return SSCArtifactHelper.getLatestAviatorArtifact( + unirest, sourceSelector.getAppVersionId(unirest), sinceDate); } return SSCArtifactHelper.getArtifactDescriptor(unirest, sourceSelector.getArtifactId()); } @@ -189,10 +140,6 @@ private void validateSourceCodeDirectory() { } } - private Set getIssueIdFilter() { - return AviatorIssueIdFilterUtils.normalizeIssueIds(issueIds); - } - private void validateIssueIdFilterMode() { if (issueIds != null && !issueIds.isEmpty() && !sourceSelector.isFromCacheSelected()) { throw new FcliSimpleException( @@ -200,111 +147,6 @@ private void validateIssueIdFilterMode() { } } - @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 = sourceSelector.getAppVersionId(unirest); - List artifacts = SSCArtifactHelper.getAllAviatorArtifacts(unirest, appVersionId, sinceDate); - - ArtifactBatchResult batchResult = processBatchOfArtifacts(artifacts); - RemediationMetric aggregatedMetric = AviatorRemediationMetricsHelper.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); - } - - 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 = AviatorRemediationMetricsHelper.getRemainingIssueIds(remaining, result.metric()); - processed++; - } else { - skipped++; - } - } - return new ArtifactBatchResult(processed, skipped, metrics); - } - - @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 - private Path downloadArtifactFpr(SSCArtifactDescriptor ad) { - Path fprPath = Files.createTempFile("aviator_" + ad.getId() + "_", ".fpr"); - logger.progress("Status: Downloading Audited FPR from SSC (artifact id=" + ad.getId() + ")"); - SSCFileTransferHelper.download( - unirest, - SSCUrls.DOWNLOAD_ARTIFACT(ad.getId(), true), - fprPath.toFile(), - SSCFileTransferHelper.ISSCAddDownloadTokenFunction.ROUTEPARAM_DOWNLOADTOKEN, - progressWriter); - return fprPath; - } - - @SneakyThrows - JsonNode processFprRemediations(SSCArtifactDescriptor ad) { - Path fprPath = downloadArtifactFpr(ad); - try { - logger.progress("Status: Processing FPR with Aviator for Applying Auto Remediations"); - try (FprHandle fprHandle = new FprHandle(fprPath)) { - var remediationMetric = ApplyAutoRemediationOnSource.applyRemediations(fprHandle, sourceCodeDirectory, logger, issueIdFilter); - String status = remediationMetric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; - return AviatorSSCApplyRemediationsHelper.buildResultNode(ad, remediationMetric.totalRemediations(), - remediationMetric.appliedRemediations(), remediationMetric.skippedRemediations(), - remediationMetric.modifiedFiles(), status); - } - } finally { - try { - Files.deleteIfExists(fprPath); - } catch (IOException e) { - LOG.warn("Failed to delete temporary downloaded FPR file: {}", fprPath, e); - } - } - } - } - @Override public boolean isSingular() { return true; @@ -319,28 +161,4 @@ 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/AviatorSSCDownloadRemediationsCacheCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsCacheCommand.java index 4dbf058e546..1066ec16fca 100644 --- 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 @@ -16,21 +16,16 @@ 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; @@ -55,8 +50,6 @@ @Command(name = "download-remediations-cache", aliases = "drc") 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; @@ -76,30 +69,19 @@ public JsonNode getJsonNode(UnirestInstance unirest) { } OffsetDateTime sinceDate = SinceOptionHelper.parse(artifactSelector.getSince()); - List tempFiles = new ArrayList<>(); - try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { + List artifacts = resolveArtifacts(unirest, sinceDate); + Map selection = buildSelectionMetadata(unirest, sinceDate); + + try (IProgressWriter progressWriter = progressWriterFactoryMixin.create(); + RemediationsCacheWriter cacheWriter = RemediationsCacheWriter.create( + destination, RemediationsCacheConstants.PRODUCT_SSC, selection)) { 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())); + cacheWriter.addFpr(artifact.getId(), null, artifact.getUploadDate(), entryPath -> + downloadArtifact(unirest, artifact, entryPath, logger, progressWriter)); } - - Map selection = buildSelectionMetadata(unirest, sinceDate); - RemediationsCacheManifest manifest = RemediationsCacheWriter.write( - destination, RemediationsCacheConstants.PRODUCT_SSC, selection, sources); + RemediationsCacheManifest manifest = cacheWriter.finish(); 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); - } - } } } @@ -139,7 +121,7 @@ private void downloadArtifact(UnirestInstance unirest, SSCArtifactDescriptor art SSCFileTransferHelper.download( unirest, SSCUrls.DOWNLOAD_ARTIFACT(artifact.getId(), true), - destination.toFile(), + destination, SSCFileTransferHelper.ISSCAddDownloadTokenFunction.ROUTEPARAM_DOWNLOADTOKEN, progressWriter); } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCOnlineRemediationsApplier.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCOnlineRemediationsApplier.java new file mode 100644 index 00000000000..e9d2ccec31f --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCOnlineRemediationsApplier.java @@ -0,0 +1,152 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.helper; + +import java.io.IOException; +import java.nio.file.Path; +import java.time.OffsetDateTime; +import java.util.ArrayList; +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.RemediationsCacheApplyHelper; +import com.fortify.cli.aviator._common.util.AviatorRemediationMetricsHelper; +import com.fortify.cli.aviator._common.util.AviatorTempFprFile; +import com.fortify.cli.aviator.applyRemediation.ApplyAutoRemediationOnSource; +import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.aviator.fpr.processor.RemediationProcessor.RemediationMetric; +import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.common.exception.FcliTechnicalException; +import com.fortify.cli.common.progress.helper.IProgressWriter; +import com.fortify.cli.ssc._common.rest.ssc.SSCUrls; +import com.fortify.cli.ssc._common.rest.ssc.transfer.SSCFileTransferHelper; +import com.fortify.cli.ssc.artifact.helper.SSCArtifactDescriptor; +import com.fortify.cli.ssc.artifact.helper.SSCArtifactHelper; + +import kong.unirest.UnirestInstance; + +/** + * Online (non-cache) SSC apply-remediations: download each audited FPR to a managed host temp + * path (required by {@link FprHandle}), apply remediations, delete the temp file. + */ +public final class AviatorSSCOnlineRemediationsApplier { + private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCOnlineRemediationsApplier.class); + + private final UnirestInstance unirest; + private final IAviatorLogger logger; + private final IProgressWriter progressWriter; + private final String sourceCodeDirectory; + private final Set issueIdFilter; + + public AviatorSSCOnlineRemediationsApplier( + UnirestInstance unirest, + IAviatorLogger logger, + IProgressWriter progressWriter, + String sourceCodeDirectory, + Set issueIdFilter) { + this.unirest = unirest; + this.logger = logger; + this.progressWriter = progressWriter; + this.sourceCodeDirectory = sourceCodeDirectory; + this.issueIdFilter = issueIdFilter; + } + + public JsonNode applyAll(String appVersionId, OffsetDateTime sinceDate) { + List artifacts = + SSCArtifactHelper.getAllAviatorArtifacts(unirest, appVersionId, sinceDate); + BatchResult batch = processBatch(artifacts); + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + issueIdFilter, batch.metrics()); + return AviatorSSCApplyRemediationsHelper.buildAggregatedResultNode( + appVersionId, + batch.processed(), + batch.skipped(), + aggregated.totalRemediations(), + aggregated.appliedRemediations(), + aggregated.skippedRemediations(), + aggregated.modifiedFiles(), + RemediationsCacheApplyHelper.actionLabel(aggregated)); + } + + public JsonNode applyOne(SSCArtifactDescriptor artifact) { + RemediationMetric metric = downloadAndApply(artifact); + return AviatorSSCApplyRemediationsHelper.buildResultNode( + artifact, + metric.totalRemediations(), + metric.appliedRemediations(), + metric.skippedRemediations(), + metric.modifiedFiles(), + RemediationsCacheApplyHelper.actionLabel(metric)); + } + + private BatchResult processBatch(List artifacts) { + int processed = 0; + int skipped = 0; + List metrics = new ArrayList<>(); + Set remaining = issueIdFilter == null ? null : new LinkedHashSet<>(issueIdFilter); + + for (int i = 0; i < artifacts.size(); i++) { + if (remaining != null && remaining.isEmpty()) { + break; + } + SSCArtifactDescriptor ad = artifacts.get(i); + logger.progress("Processing artifact " + (i + 1) + "/" + artifacts.size() + " (id=" + ad.getId() + ")"); + try { + RemediationMetric metric = downloadAndApply(ad, remaining); + metrics.add(metric); + remaining = AviatorRemediationMetricsHelper.getRemainingIssueIds(remaining, metric); + processed++; + } catch (AviatorSimpleException e) { + LOG.warn("Skipping artifact {} as {}", ad.getId(), e.getMessage()); + skipped++; + } + } + return new BatchResult(processed, skipped, metrics); + } + + private RemediationMetric downloadAndApply(SSCArtifactDescriptor artifact) { + return downloadAndApply(artifact, issueIdFilter); + } + + private RemediationMetric downloadAndApply(SSCArtifactDescriptor artifact, Set issueFilter) { + try (AviatorTempFprFile tempFpr = AviatorTempFprFile.create(artifact.getId())) { + downloadArtifact(artifact, tempFpr.path()); + logger.progress("Status: Processing FPR with Aviator for Applying Auto Remediations"); + try (FprHandle fprHandle = new FprHandle(tempFpr.path())) { + return ApplyAutoRemediationOnSource.applyRemediations( + fprHandle, sourceCodeDirectory, logger, issueFilter); + } catch (IOException e) { + throw new FcliTechnicalException( + "Failed to close FPR handle for SSC artifact " + artifact.getId(), e); + } + } + } + + private void downloadArtifact(SSCArtifactDescriptor artifact, Path destination) { + logger.progress("Status: Downloading Audited FPR from SSC (artifact id=" + artifact.getId() + ")"); + SSCFileTransferHelper.download( + unirest, + SSCUrls.DOWNLOAD_ARTIFACT(artifact.getId(), true), + destination, + SSCFileTransferHelper.ISSCAddDownloadTokenFunction.ROUTEPARAM_DOWNLOADTOKEN, + progressWriter); + } + + private record BatchResult(int processed, int skipped, List metrics) {} +} diff --git a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/exception/FcliSimpleException.java b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/exception/FcliSimpleException.java index c8f087d305e..4a6f50280f1 100644 --- a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/exception/FcliSimpleException.java +++ b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/exception/FcliSimpleException.java @@ -56,6 +56,20 @@ public FcliSimpleException(Throwable cause) { public FcliSimpleException(String message, Throwable cause) { super(message, cause); } + + /** + * Throws a new {@link FcliSimpleException} when {@code condition} is {@code true}. + * Useful for compact validation guards. + * + * @param condition when true, an exception is thrown + * @param fmt {@link String#format(String, Object...)} message pattern + * @param args format arguments + */ + public static void throwIf(boolean condition, String fmt, Object... args) { + if (condition) { + throw new FcliSimpleException(fmt, args); + } + } public String getStackTraceString() { return String.format("%s%s", getSummary(this), getCauseAsString()); diff --git a/fcli-core/fcli-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 8f4a2023de3..18e2cbbc7c8 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,12 +12,7 @@ */ 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.LinkedHashSet; import java.util.List; import java.util.Set; @@ -25,17 +20,15 @@ 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.RemediationsCacheApplyHelper; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheApplyHelper.ApplyResult; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheConstants; 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._common.util.AviatorRemediationMetricsHelper; -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.exception.FcliTechnicalException; 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; @@ -43,18 +36,14 @@ import com.fortify.cli.common.output.transform.IRecordTransformer; import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; import com.fortify.cli.common.progress.helper.IProgressWriter; -import com.fortify.cli.common.rest.unirest.HttpHeader; import com.fortify.cli.fod._common.cli.mixin.FoDDelimiterMixin; import com.fortify.cli.fod._common.cli.mixin.IFoDDelimiterMixinAware; -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.aviator.helper.AviatorFoDOnlineRemediationsApplier; 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; @@ -66,14 +55,14 @@ @Command(name = "apply-remediations") public class FoDAviatorApplyRemediationsCommand extends AbstractOutputCommand implements IJsonNodeSupplier, IRecordTransformer, IActionCommandResultSupplier { + private static final Logger LOG = LoggerFactory.getLogger(FoDAviatorApplyRemediationsCommand.class); + @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; @Mixin private FoDDelimiterMixin delimiterMixin; // Injected into sourceSelector @Mixin private FoDUnirestInstanceSupplierMixin unirestInstanceSupplier; @Mixin private SourceMixin sourceSelector; - private static final Logger LOG = LoggerFactory.getLogger(FoDAviatorApplyRemediationsCommand.class); - @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") @@ -118,9 +107,7 @@ static class SourceArgGroup { private Path fromCache; } - /** - * Online release branch reusing the standard FoD release option wiring/resolution. - */ + /** Online release branch reusing the standard FoD release option wiring/resolution. */ static class OnlineReleaseArgGroup extends FoDReleaseByQualifiedNameOrIdResolverMixin.AbstractFoDQualifiedReleaseNameOrIdResolverMixin { @Option(names = {"--release", "--rel"}, required = true, paramLabel = "id|app[:ms]:rel", @@ -132,175 +119,61 @@ static class OnlineReleaseArgGroup @SneakyThrows public JsonNode getJsonNode() { validateSourceCodeDirectory(); - Set issueIdFilter = getIssueIdFilter(); + Set issueIdFilter = AviatorIssueIdFilterUtils.normalizeIssueIds(issueIds); validateSelection(); + try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); if (sourceSelector.isFromCacheSelected()) { return processFromCache(logger, issueIdFilter); } UnirestInstance unirest = unirestInstanceSupplier.getUnirestInstance(); - FoDReleaseDescriptor rd = sourceSelector.getReleaseDescriptor(unirest); - return processFprRemediations(unirest, rd, logger, issueIdFilter); + return AviatorFoDOnlineRemediationsApplier.apply( + unirest, + sourceSelector.getReleaseDescriptor(unirest), + sourceCodeDirectory, + logger, + issueIdFilter); } } - private void validateSourceCodeDirectory() { - if (sourceCodeDirectory == null || sourceCodeDirectory.isBlank()) { - throw new FcliSimpleException("--source-dir must specify a valid directory path"); - } - } - - private Set getIssueIdFilter() { - return AviatorIssueIdFilterUtils.normalizeIssueIds(issueIds); - } - - private void validateSelection() { - 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"); - } - } - - @SneakyThrows private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdFilter) { Path cacheZip = sourceSelector.getFromCache(); try (RemediationsCacheReader cacheReader = RemediationsCacheReader.open(cacheZip)) { - CacheProcessingResult cacheResult = processCacheEntries(cacheReader, logger, issueIdFilter); - RemediationMetric aggregatedMetric = AviatorRemediationMetricsHelper.aggregateMetrics( - issueIdFilter, cacheResult.metrics()); - String status = aggregatedMetric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; + ApplyResult applyResult = RemediationsCacheApplyHelper.applyEntries( + cacheReader, + RemediationsCacheConstants.PRODUCT_FOD, + sourceCodeDirectory, + logger, + issueIdFilter, + RemediationsCacheApplyHelper.EntryIdKind.RELEASE_ID, + LOG); + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + issueIdFilter, applyResult.metrics()); return AviatorFoDApplyRemediationsHelper.buildCacheResultNode( new AviatorFoDApplyRemediationsHelper.CacheResultData( - cacheZip, - cacheResult.processedEntries(), - cacheResult.processedReleaseIds(), - aggregatedMetric.totalRemediations(), - aggregatedMetric.appliedRemediations(), - aggregatedMetric.skippedRemediations(), - aggregatedMetric.modifiedFiles(), - status)); + cacheZip, + applyResult.processedEntries(), + applyResult.processedIds(), + aggregated.totalRemediations(), + aggregated.appliedRemediations(), + aggregated.skippedRemediations(), + aggregated.modifiedFiles(), + RemediationsCacheApplyHelper.actionLabel(aggregated))); } } - private CacheProcessingResult processCacheEntries(RemediationsCacheReader cacheReader, AviatorLoggerImpl logger, - Set issueIdFilter) { - List fprPaths = cacheReader.getOrderedFprPaths(); - List allEntryPaths = cacheReader.getOrderedEntryPaths(); - List allReleaseIds = cacheReader.getOrderedReleaseIds(); - 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; - } - String entryLabel = i < allEntryPaths.size() ? allEntryPaths.get(i) : fprPaths.get(i).getFileName().toString(); - RemediationMetric metric = processCacheEntry(fprPaths.get(i), entryLabel, i + 1, fprPaths.size(), logger, remaining); - if (metric != null) { - metrics.add(metric); - processedEntries.add(entryLabel); - processedReleaseIds.add(i < allReleaseIds.size() ? allReleaseIds.get(i) : ""); - remaining = AviatorRemediationMetricsHelper.getRemainingIssueIds(remaining, metric); - } - } - return new CacheProcessingResult(List.copyOf(processedEntries), List.copyOf(processedReleaseIds), metrics); - } - - private RemediationMetric processCacheEntry(Path fprPath, String entryLabel, int index, int total, - AviatorLoggerImpl logger, Set issueFilter) { - logger.progress("Processing FPR " + index + "/" + total + " (" + entryLabel + ")"); - logger.progress("Status: Processing FPR with Aviator for Applying Auto Remediations"); - try (FprHandle fprHandle = new FprHandle(fprPath)) { - return ApplyAutoRemediationOnSource.applyRemediations(fprHandle, sourceCodeDirectory, logger, issueFilter); - } catch (AviatorSimpleException e) { - LOG.warn("Skipping cache entry {} as {}", entryLabel, e.getMessage()); - return null; - } catch (IOException e) { - throw new FcliTechnicalException("Failed to close FPR handle for cache entry " + entryLabel, e); - } - } - - private record CacheProcessingResult( - List processedEntries, - List processedReleaseIds, - List metrics) {} - - @SneakyThrows - private JsonNode processFprRemediations(UnirestInstance unirest, FoDReleaseDescriptor rd, AviatorLoggerImpl logger, - Set issueIdFilter) { - Path downloadedFprPath = null; - try { - logger.progress("Status: Downloading Audited FPR from FOD"); - downloadedFprPath = downloadFprFromFod(unirest, rd); - - logger.progress("Status: Processing FPR with Aviator for Applying Auto Remediations"); - try (FprHandle fprHandle = new FprHandle(downloadedFprPath)) { - var remediationMetric = ApplyAutoRemediationOnSource.applyRemediations(fprHandle, sourceCodeDirectory, logger, 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); - } - } finally { - if (downloadedFprPath != null) { - try { - Files.deleteIfExists(downloadedFprPath); - } catch (IOException e) { - LOG.warn("Failed to delete temporary downloaded FPR file: {}", downloadedFprPath, e); - } - } + private void validateSourceCodeDirectory() { + if (sourceCodeDirectory == null || sourceCodeDirectory.isBlank()) { + throw new FcliSimpleException("--source-dir must specify a valid directory path"); } } - @SneakyThrows - private Path downloadFprFromFod(UnirestInstance unirest, FoDReleaseDescriptor releaseDescriptor) { - Path fprPath = Files.createTempFile("aviator_" + releaseDescriptor.getReleaseId() + "_", ".fpr"); - FoDScanDescriptor scanDescriptor = FoDScanHelper.getLatestScanDescriptor(unirest, releaseDescriptor.getReleaseId(), - getScanType(), false); - FoDScanHelper.validateScanDate(scanDescriptor, FoDScanHelper.MAX_RETENTION_PERIOD); - var file = fprPath.toString(); - GetRequest request = getDownloadRequest(unirest, releaseDescriptor, scanDescriptor); - int status = 202; - int retries = 0; - final int maxRetries = 10; - while (status == 202 && retries < maxRetries) { - status = request - .asFile(file, StandardCopyOption.REPLACE_EXISTING) - .getStatus(); - 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()); + private void validateSelection() { + 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"); } - return fprPath; - } - - protected FoDScanType getScanType() { - return FoDScanType.Static; - } - - protected 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()); } @Override 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 index c257ccb05d0..8662286baa0 100644 --- 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 @@ -15,9 +15,7 @@ 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; @@ -25,22 +23,16 @@ 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.aviator.helper.FoDRemediationsFprDownloadHelper; 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; @@ -50,8 +42,6 @@ @Command(name = "download-remediations-cache", aliases = "drc") public class FoDAviatorDownloadRemediationsCacheCommand extends AbstractFoDJsonNodeOutputCommand implements IActionCommandResultSupplier { - private static final int MAX_RETRIES = 10; - @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; @Mixin private FoDDelimiterMixin delimiterMixin; // Injected into releaseResolver @Mixin private FoDReleaseByQualifiedNameOrIdResolverMixin.RequiredOption releaseResolver; @@ -70,60 +60,19 @@ public JsonNode getJsonNode(UnirestInstance unirest) { } 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); + Map selection = new LinkedHashMap<>(); + selection.put("mode", "release"); + selection.put("releaseId", releaseDescriptor.getReleaseId()); - 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()); + try (RemediationsCacheWriter cacheWriter = RemediationsCacheWriter.create( + destination, RemediationsCacheConstants.PRODUCT_FOD, selection)) { + cacheWriter.addFpr(null, releaseDescriptor.getReleaseId(), null, entryPath -> + FoDRemediationsFprDownloadHelper.downloadStaticRemediationsFpr(unirest, releaseDescriptor, entryPath)); + RemediationsCacheManifest manifest = cacheWriter.finish(); + return buildResultNode(destination, releaseDescriptor, manifest); } } - 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()); diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/AviatorFoDOnlineRemediationsApplier.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/AviatorFoDOnlineRemediationsApplier.java new file mode 100644 index 00000000000..f3885e8a803 --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/AviatorFoDOnlineRemediationsApplier.java @@ -0,0 +1,72 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.fod.aviator.helper; + +import java.io.IOException; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheApplyHelper; +import com.fortify.cli.aviator._common.util.AviatorTempFprFile; +import com.fortify.cli.aviator.applyRemediation.ApplyAutoRemediationOnSource; +import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.aviator.fpr.processor.RemediationProcessor.RemediationMetric; +import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.common.exception.FcliTechnicalException; +import com.fortify.cli.fod.release.helper.FoDReleaseDescriptor; + +import kong.unirest.UnirestInstance; + +/** + * Online (non-cache) FoD apply-remediations: download audited FPR to a managed host temp path + * (required by {@link FprHandle}), apply remediations, delete the temp file. + */ +public final class AviatorFoDOnlineRemediationsApplier { + private static final Logger LOG = LoggerFactory.getLogger(AviatorFoDOnlineRemediationsApplier.class); + + private AviatorFoDOnlineRemediationsApplier() {} + + public static JsonNode apply( + UnirestInstance unirest, + FoDReleaseDescriptor releaseDescriptor, + String sourceCodeDirectory, + IAviatorLogger logger, + Set issueIdFilter) { + try (AviatorTempFprFile tempFpr = AviatorTempFprFile.create(releaseDescriptor.getReleaseId())) { + logger.progress("Status: Downloading Audited FPR from FOD"); + FoDRemediationsFprDownloadHelper.downloadStaticRemediationsFpr( + unirest, releaseDescriptor, tempFpr.path()); + + logger.progress("Status: Processing FPR with Aviator for Applying Auto Remediations"); + try (FprHandle fprHandle = new FprHandle(tempFpr.path())) { + RemediationMetric metric = ApplyAutoRemediationOnSource.applyRemediations( + fprHandle, sourceCodeDirectory, logger, issueIdFilter); + LOG.info("Applied remediation {}", metric.appliedRemediations()); + LOG.info("Total remediation {}", metric.totalRemediations()); + return AviatorFoDApplyRemediationsHelper.buildResultNode( + releaseDescriptor, + metric.totalRemediations(), + metric.appliedRemediations(), + metric.skippedRemediations(), + metric.modifiedFiles(), + RemediationsCacheApplyHelper.actionLabel(metric)); + } catch (IOException e) { + throw new FcliTechnicalException( + "Failed to close FPR handle for FoD release " + releaseDescriptor.getReleaseId(), e); + } + } + } +} diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/FoDRemediationsFprDownloadHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/FoDRemediationsFprDownloadHelper.java new file mode 100644 index 00000000000..f304b4e75e2 --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/FoDRemediationsFprDownloadHelper.java @@ -0,0 +1,135 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.fod.aviator.helper; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.cli.common.exception.AbstractFcliException; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.exception.FcliTechnicalException; +import com.fortify.cli.common.rest.unirest.HttpHeader; +import com.fortify.cli.fod._common.scan.helper.FoDScanDescriptor; +import com.fortify.cli.fod._common.scan.helper.FoDScanHelper; +import com.fortify.cli.fod._common.scan.helper.FoDScanType; +import com.fortify.cli.fod.release.helper.FoDReleaseDescriptor; + +import kong.unirest.GetRequest; +import kong.unirest.HttpResponse; +import kong.unirest.RawResponse; +import kong.unirest.UnirestInstance; + +/** + * Shared FoD remediations FPR download with 202-retry handling. Supports any {@link Path}, + * including zip filesystem entry paths. Response body is written only when the download is + * ready (successful non-202 status). + */ +public final class FoDRemediationsFprDownloadHelper { + private static final Logger logger = LoggerFactory.getLogger(FoDRemediationsFprDownloadHelper.class); + private static final int MAX_RETRIES = 10; + private static final long RETRY_SLEEP_MS = 30_000L; + + private FoDRemediationsFprDownloadHelper() {} + + public static void downloadStaticRemediationsFpr(UnirestInstance unirest, FoDReleaseDescriptor releaseDescriptor, + Path destination) { + boolean completed = false; + try { + FoDScanDescriptor scanDescriptor = FoDScanHelper.getLatestScanDescriptor(unirest, releaseDescriptor.getReleaseId(), + FoDScanType.Static, false); + FoDScanHelper.validateScanDate(scanDescriptor, FoDScanHelper.MAX_RETENTION_PERIOD); + GetRequest request = unirest.get("/api/v3/releases/{releaseId}/fpr") + .routeParam("releaseId", releaseDescriptor.getReleaseId()) + .headerReplace(HttpHeader.ACCEPT, "application/octet-stream") + .queryString("scanType", scanDescriptor.getScanType()); + + int status = 202; + int retries = 0; + while (status == 202 && retries < MAX_RETRIES) { + HttpResponse response = request.asObject(raw -> copyBodyIfReady(raw, destination)); + status = response.getBody() != null ? response.getBody() : response.getStatus(); + if (status == 202) { + retries++; + sleepBeforeRetry(releaseDescriptor.getReleaseId()); + } + } + if (status == 202) { + throw new FcliSimpleException("Timed out waiting for FoD remediations FPR download to complete after " + + MAX_RETRIES + " retries"); + } + if (status < 200 || status >= 300) { + throw new FcliSimpleException("FoD remediations FPR download failed with HTTP status " + status + + " for release " + releaseDescriptor.getReleaseId()); + } + completed = true; + } catch (AbstractFcliException e) { + if (!completed) { + deleteQuietly(destination); + } + throw e; + } catch (RuntimeException e) { + if (!completed) { + deleteQuietly(destination); + } + throw new FcliTechnicalException("Error downloading FoD remediations FPR for release " + + releaseDescriptor.getReleaseId() + " to " + destination, e); + } + } + + /** Status first; write body only for ready 2xx (not 202). Drain otherwise so the connection can close. */ + private static int copyBodyIfReady(RawResponse raw, Path destination) { + int status = raw.getStatus(); + try (InputStream in = raw.getContent()) { + if (status < 200 || status >= 300 || status == 202) { + in.transferTo(OutputStream.nullOutputStream()); + return status; + } + Path parent = destination.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.copy(in, destination, StandardCopyOption.REPLACE_EXISTING); + return status; + } catch (IOException e) { + throw new FcliTechnicalException("Error handling FoD download response for " + destination, e); + } + } + + private static void sleepBeforeRetry(String releaseId) { + try { + Thread.sleep(RETRY_SLEEP_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new FcliTechnicalException( + "Interrupted while waiting for FoD remediations FPR download for release " + releaseId, e); + } + } + + private static void deleteQuietly(Path destination) { + if (destination == null) { + return; + } + try { + Files.deleteIfExists(destination); + } catch (IOException e) { + logger.warn("Failed to delete incomplete FoD FPR download: {}", destination, e); + } + } +} diff --git a/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/_common/rest/ssc/transfer/SSCFileTransferHelper.java b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/_common/rest/ssc/transfer/SSCFileTransferHelper.java index cf33422295c..e3ed5d07248 100644 --- a/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/_common/rest/ssc/transfer/SSCFileTransferHelper.java +++ b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/_common/rest/ssc/transfer/SSCFileTransferHelper.java @@ -13,13 +13,24 @@ package com.fortify.cli.ssc._common.rest.ssc.transfer; import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.function.BiFunction; import java.util.function.Supplier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import com.fortify.cli.common.exception.AbstractFcliException; import com.fortify.cli.common.exception.FcliBugException; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.exception.FcliTechnicalException; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.progress.helper.IProgressWriter; import com.fortify.cli.common.rest.unirest.HttpHeader; @@ -27,13 +38,16 @@ import kong.unirest.GetRequest; import kong.unirest.HttpRequest; import kong.unirest.HttpRequestWithBody; +import kong.unirest.HttpResponse; import kong.unirest.ProgressMonitor; +import kong.unirest.RawResponse; import kong.unirest.UnirestInstance; import kong.unirest.jackson.JacksonObjectMapper; import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; public class SSCFileTransferHelper { + private static final Logger LOG = LoggerFactory.getLogger(SSCFileTransferHelper.class); private static final JacksonObjectMapper XMLMAPPER = new JacksonObjectMapper(new XmlMapper()); @SneakyThrows @@ -53,6 +67,80 @@ public static final File download(UnirestInstance unirest, String endpoint, File return download(unirest, endpoint, downloadPath, SSCFileTransferTokenType.DOWNLOAD, addTokenFunction, progressWriter); } + /** + * Downloads to any {@link Path}, including zip filesystem entry paths used by remediations cache. + * Writes the body only after a successful non-202 status. + */ + public static final void download(UnirestInstance unirest, String endpoint, Path downloadPath, + ISSCAddDownloadTokenFunction addTokenFunction, IProgressWriter progressWriter) { + download(unirest, endpoint, downloadPath, SSCFileTransferTokenType.DOWNLOAD, addTokenFunction, progressWriter); + } + + public static final void download(UnirestInstance unirest, String endpoint, Path downloadPath, + SSCFileTransferTokenType tokenType, ISSCAddDownloadTokenFunction addTokenFunction, IProgressWriter progressWriter) { + boolean completed = false; + try ( SSCFileTransferTokenSupplier tokenSupplier = new SSCFileTransferTokenSupplier(unirest, tokenType); + SSCProgressMonitor downloadMonitor = new SSCProgressMonitor(progressWriter, "Download") ) { + HttpResponse response = addTokenFunction.apply(tokenSupplier.get(), unirest.get(endpoint)) + .downloadMonitor(downloadMonitor) + .asObject(raw -> copyBodyIfReady(raw, downloadPath)); + int status = response.getBody() != null ? response.getBody() : response.getStatus(); + if (status < 200 || status >= 300 || status == 202) { + throw new FcliSimpleException("Download failed with HTTP status " + status + " for " + endpoint); + } + completed = true; + } catch (AbstractFcliException e) { + if (!completed) { + deleteQuietly(downloadPath); + } + throw e; + } catch (RuntimeException e) { + if (!completed) { + deleteQuietly(downloadPath); + } + throw new FcliTechnicalException("Error downloading " + endpoint + " to " + downloadPath, e); + } catch (Exception e) { + // AutoCloseable close can surface checked exceptions; preserve interrupt flag. + if (!completed) { + deleteQuietly(downloadPath); + } + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + throw new FcliTechnicalException("Error downloading " + endpoint + " to " + downloadPath, e); + } + } + + /** Status first; write body only for ready 2xx (not 202). Drain otherwise so the connection can close. */ + private static int copyBodyIfReady(RawResponse raw, Path destination) { + int status = raw.getStatus(); + try (InputStream in = raw.getContent()) { + if (status < 200 || status >= 300 || status == 202) { + in.transferTo(OutputStream.nullOutputStream()); + return status; + } + Path parent = destination.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.copy(in, destination, StandardCopyOption.REPLACE_EXISTING); + return status; + } catch (IOException e) { + throw new FcliTechnicalException("Error handling download response for " + destination, e); + } + } + + private static void deleteQuietly(Path path) { + if (path == null) { + return; + } + try { + Files.deleteIfExists(path); + } catch (IOException e) { + LOG.warn("Failed to delete incomplete download: {}", path, e); + } + } + @SneakyThrows public static final T htmlUpload(UnirestInstance unirest, String endpoint, File filePath, ISSCAddUploadTokenFunction addTokenFunction, Class returnType, IProgressWriter progressWriter) { if ( !isHtmlEndpoint(endpoint) ) { From 5f0b8bf1b51f7e31fb84112f4c54f5c8b398bc0f Mon Sep 17 00:00:00 2001 From: kireetivar Date: Tue, 21 Jul 2026 20:39:34 +0530 Subject: [PATCH 13/18] chore: add progress messages for FoD download-remediations-cache --- .../FoDAviatorDownloadRemediationsCacheCommand.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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 index 8662286baa0..abe84f5bfce 100644 --- 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 @@ -23,10 +23,13 @@ import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheConstants; import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheManifest; import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheWriter; +import com.fortify.cli.aviator.config.AviatorLoggerImpl; import com.fortify.cli.common.cli.mixin.CommonOptionMixins; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; +import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; +import com.fortify.cli.common.progress.helper.IProgressWriter; import com.fortify.cli.fod._common.cli.mixin.FoDDelimiterMixin; import com.fortify.cli.fod._common.output.cli.cmd.AbstractFoDJsonNodeOutputCommand; import com.fortify.cli.fod.aviator.helper.FoDRemediationsFprDownloadHelper; @@ -43,6 +46,7 @@ @Command(name = "download-remediations-cache", aliases = "drc") public class FoDAviatorDownloadRemediationsCacheCommand extends AbstractFoDJsonNodeOutputCommand implements IActionCommandResultSupplier { @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; + @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; @Mixin private FoDDelimiterMixin delimiterMixin; // Injected into releaseResolver @Mixin private FoDReleaseByQualifiedNameOrIdResolverMixin.RequiredOption releaseResolver; @Mixin private CommonOptionMixins.RequireConfirmation requireConfirmation; @@ -64,10 +68,15 @@ public JsonNode getJsonNode(UnirestInstance unirest) { selection.put("mode", "release"); selection.put("releaseId", releaseDescriptor.getReleaseId()); - try (RemediationsCacheWriter cacheWriter = RemediationsCacheWriter.create( - destination, RemediationsCacheConstants.PRODUCT_FOD, selection)) { + try (IProgressWriter progressWriter = progressWriterFactoryMixin.create(); + RemediationsCacheWriter cacheWriter = RemediationsCacheWriter.create( + destination, RemediationsCacheConstants.PRODUCT_FOD, selection)) { + AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); + logger.progress("Status: Downloading Audited FPR from FoD (release id=" + + releaseDescriptor.getReleaseId() + ")"); cacheWriter.addFpr(null, releaseDescriptor.getReleaseId(), null, entryPath -> FoDRemediationsFprDownloadHelper.downloadStaticRemediationsFpr(unirest, releaseDescriptor, entryPath)); + logger.progress("Status: Writing remediations cache to " + destination); RemediationsCacheManifest manifest = cacheWriter.finish(); return buildResultNode(destination, releaseDescriptor, manifest); } From 1a6b6080e4c3f8f371d9e1e246987f640da67b9f Mon Sep 17 00:00:00 2001 From: kireetivar Date: Wed, 22 Jul 2026 23:46:03 +0530 Subject: [PATCH 14/18] chore: share remediations apply sources and harden cache write/publish --- .../CacheRemediationsFprSource.java | 74 +++++++++ .../IRemediationsFprSource.java | 47 ++++++ ...lper.java => RemediationsApplyHelper.java} | 91 ++++++----- .../RemediationsCacheEntry.java | 16 ++ .../RemediationsCacheReader.java | 42 ++++- .../RemediationsCacheWriter.java | 154 +++++++++++------- .../_common/util/AviatorLocalFprHelper.java | 25 ++- .../RemediationsCacheRoundTripTest.java | 45 ++++- .../util/AviatorIssueIdFilterUtilsTest.java | 39 +++++ .../AviatorRemediationMetricsHelperTest.java | 52 ++++++ .../AviatorSSCApplyRemediationsCommand.java | 89 ++++------ ...orSSCDownloadRemediationsCacheCommand.java | 36 +--- ...viatorSSCApplyRemediationsSourceMixin.java | 15 +- ...emediationsCacheDownloadSelectorMixin.java | 7 + ...iatorSSCRemediationsSelectorArgGroups.java | 52 +++++- .../AviatorSSCApplyRemediationsHelper.java | 126 ++++++++++---- .../AviatorSSCOnlineRemediationsApplier.java | 152 ----------------- .../SscOnlineRemediationsFprSource.java | 81 +++++++++ ...viatorSSCApplyRemediationsCommandTest.java | 61 +------ ...oDAviatorApplyRemediationsSourceMixin.java | 72 ++++++++ .../FoDAviatorApplyRemediationsCommand.java | 130 ++++----------- ...iatorDownloadRemediationsCacheCommand.java | 5 +- .../AviatorFoDApplyRemediationsHelper.java | 92 ++++++++--- .../AviatorFoDOnlineRemediationsApplier.java | 72 -------- .../FodOnlineRemediationsFprSource.java | 62 +++++++ ...oDAviatorApplyRemediationsCommandTest.java | 97 ++--------- ...rDownloadRemediationsCacheCommandTest.java | 19 +-- 27 files changed, 997 insertions(+), 756 deletions(-) create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/CacheRemediationsFprSource.java create mode 100644 fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/IRemediationsFprSource.java rename fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/{RemediationsCacheApplyHelper.java => RemediationsApplyHelper.java} (53%) create mode 100644 fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorIssueIdFilterUtilsTest.java create mode 100644 fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelperTest.java delete mode 100644 fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCOnlineRemediationsApplier.java create mode 100644 fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SscOnlineRemediationsFprSource.java create mode 100644 fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cli/mixin/FoDAviatorApplyRemediationsSourceMixin.java delete mode 100644 fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/AviatorFoDOnlineRemediationsApplier.java create mode 100644 fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/FodOnlineRemediationsFprSource.java diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/CacheRemediationsFprSource.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/CacheRemediationsFprSource.java new file mode 100644 index 00000000000..25345c523f2 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/CacheRemediationsFprSource.java @@ -0,0 +1,74 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.remediations_cache; + +import java.nio.file.Path; +import java.util.List; + +import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheReader.ResolvedFpr; + +/** + * Offline remediations source: ordered FPR paths from a remediations cache zip. + */ +public final class CacheRemediationsFprSource implements IRemediationsFprSource { + public enum IdKind { + ARTIFACT_ID, + RELEASE_ID + } + + private final RemediationsCacheReader reader; + private final IdKind idKind; + + private CacheRemediationsFprSource(RemediationsCacheReader reader, IdKind idKind) { + this.reader = reader; + this.idKind = idKind; + } + + public static CacheRemediationsFprSource open(Path cacheZip, String expectedProduct, IdKind idKind) { + RemediationsCacheReader reader = RemediationsCacheReader.open(cacheZip); + try { + reader.requireProduct(expectedProduct); + return new CacheRemediationsFprSource(reader, idKind); + } catch (RuntimeException e) { + reader.close(); + throw e; + } + } + + public RemediationsCacheReader reader() { + return reader; + } + + @Override + public void forEachEntry(EntryAction action) { + List units = reader.getOrderedResolvedFprs(); + int total = units.size(); + for (int i = 0; i < total; i++) { + ResolvedFpr unit = units.get(i); + RemediationsCacheEntry entry = unit.entry(); + String label = entry.getPath() != null + ? entry.getPath() + : unit.fprPath().getFileName().toString(); + String raw = idKind == IdKind.ARTIFACT_ID ? entry.getArtifactId() : entry.getReleaseId(); + String id = raw != null ? raw : ""; + if (!action.accept(unit.fprPath(), label, id, i + 1, total)) { + break; + } + } + } + + @Override + public void close() { + reader.close(); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/IRemediationsFprSource.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/IRemediationsFprSource.java new file mode 100644 index 00000000000..150c8cfdcea --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/IRemediationsFprSource.java @@ -0,0 +1,47 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.remediations_cache; + +import java.nio.file.Path; + +/** + * Source of audited FPR files for apply-remediations (cache zip or online download). + * Implementations own any resources (zip FS, temp files) until {@link #close()}. + * + *

Paths passed to {@link EntryAction#accept} are valid only for the duration of that call + * (online sources may download to a temp file and delete it afterward). + */ +public interface IRemediationsFprSource extends AutoCloseable { + + /** + * Invokes {@code action} for each FPR in order. Returning {@code false} from the action + * stops iteration early (for example when an issue-id filter is exhausted). + */ + void forEachEntry(EntryAction action); + + @Override + void close(); + + @FunctionalInterface + interface EntryAction { + /** + * @param fprPath host-readable FPR path (zip entry or temp file) + * @param label human-readable label for progress/logs + * @param id artifact id, release id, or empty + * @param index 1-based index + * @param total total entries + * @return {@code false} to stop processing further entries + */ + boolean accept(Path fprPath, String label, String id, int index, int total); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheApplyHelper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsApplyHelper.java similarity index 53% rename from fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheApplyHelper.java rename to fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsApplyHelper.java index db9a138d489..8d8d30f07ba 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheApplyHelper.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsApplyHelper.java @@ -30,16 +30,11 @@ import com.fortify.cli.common.exception.FcliTechnicalException; /** - * Shared apply-remediations logic for cache zip entries (SSC and FoD). - * SHA-256 is verified by {@link RemediationsCacheReader#getOrderedFprPaths()} (no extra local FPR pass). + * Single apply-remediations loop for any {@link IRemediationsFprSource} + * (cache zip entries or online downloads). Soft-skips on {@link AviatorSimpleException}. */ -public final class RemediationsCacheApplyHelper { - private RemediationsCacheApplyHelper() {} - - public enum EntryIdKind { - ARTIFACT_ID, - RELEASE_ID - } +public final class RemediationsApplyHelper { + private RemediationsApplyHelper() {} public record ApplyResult( List processedEntries, @@ -48,55 +43,40 @@ public record ApplyResult( List metrics) {} /** - * Applies remediations for each ordered cache FPR until done or issue-id filter is exhausted. + * Applies remediations for each source entry until done or the issue-id filter is exhausted. + * Caller owns {@code source} lifecycle (try-with-resources). */ - public static ApplyResult applyEntries( - RemediationsCacheReader cacheReader, - String expectedProduct, + public static ApplyResult apply( + IRemediationsFprSource source, String sourceCodeDirectory, IAviatorLogger logger, Set issueIdFilter, - EntryIdKind idKind, Logger skipLog) { - cacheReader.requireProduct(expectedProduct); - - List fprPaths = cacheReader.getOrderedFprPaths(); - List entryPaths = cacheReader.getOrderedEntryPaths(); - List entryIds = idKind == EntryIdKind.ARTIFACT_ID - ? cacheReader.getOrderedArtifactIds() - : cacheReader.getOrderedReleaseIds(); - - List metrics = new ArrayList<>(); - List processedEntries = new ArrayList<>(); - List processedIds = 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; + Accumulator acc = new Accumulator(issueIdFilter); + source.forEachEntry((fprPath, label, id, index, total) -> { + if (acc.remaining != null && acc.remaining.isEmpty()) { + return false; } - String entryLabel = i < entryPaths.size() ? entryPaths.get(i) : fprPaths.get(i).getFileName().toString(); - RemediationMetric metric = applyOneEntry( - fprPaths.get(i), entryLabel, i + 1, fprPaths.size(), sourceCodeDirectory, logger, remaining, skipLog); + RemediationMetric metric = applyOne( + fprPath, label, index, total, sourceCodeDirectory, logger, acc.remaining, skipLog); if (metric == null) { - skipped++; + acc.skipped++; } else { - metrics.add(metric); - processedEntries.add(entryLabel); - processedIds.add(i < entryIds.size() ? entryIds.get(i) : ""); - remaining = AviatorRemediationMetricsHelper.getRemainingIssueIds(remaining, metric); + acc.metrics.add(metric); + acc.processedEntries.add(label); + acc.processedIds.add(id != null ? id : ""); + acc.remaining = AviatorRemediationMetricsHelper.getRemainingIssueIds(acc.remaining, metric); } - } - return new ApplyResult( - List.copyOf(processedEntries), List.copyOf(processedIds), skipped, List.copyOf(metrics)); + return true; + }); + return acc.toResult(); } public static String actionLabel(RemediationMetric metric) { return metric != null && metric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; } - private static RemediationMetric applyOneEntry( + private static RemediationMetric applyOne( Path fprPath, String entryLabel, int index, @@ -110,10 +90,31 @@ private static RemediationMetric applyOneEntry( try (FprHandle fprHandle = new FprHandle(fprPath)) { return ApplyAutoRemediationOnSource.applyRemediations(fprHandle, sourceCodeDirectory, logger, issueFilter); } catch (AviatorSimpleException e) { - skipLog.warn("Skipping cache entry {} as {}", entryLabel, e.getMessage()); + skipLog.warn("Skipping entry {} as {}", entryLabel, e.getMessage()); return null; } catch (IOException e) { - throw new FcliTechnicalException("Failed to close FPR handle for cache entry " + entryLabel, e); + throw new FcliTechnicalException("Failed to close FPR handle for entry " + entryLabel, e); + } + } + + /** Sequential-loop state (not concurrent — avoids Atomic* only to satisfy lambda capture rules). */ + private static final class Accumulator { + private final List metrics = new ArrayList<>(); + private final List processedEntries = new ArrayList<>(); + private final List processedIds = new ArrayList<>(); + private int skipped; + private Set remaining; + + private Accumulator(Set issueIdFilter) { + this.remaining = issueIdFilter == null ? null : new LinkedHashSet<>(issueIdFilter); + } + + private ApplyResult toResult() { + return new ApplyResult( + List.copyOf(processedEntries), + List.copyOf(processedIds), + skipped, + List.copyOf(metrics)); } } } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheEntry.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheEntry.java index addd13d1e18..93a783374bf 100644 --- 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 @@ -31,4 +31,20 @@ public class RemediationsCacheEntry { private String uploadDate; private String path; private String sha256; + + /** + * Factory for writers (not Jackson). Avoids multi-arg same-type call sites on the wire model. + * Do not add {@code @Builder} on this {@code @Reflectable} class. + */ + public static RemediationsCacheEntry of( + int order, String path, String artifactId, String releaseId, String uploadDate, String sha256) { + RemediationsCacheEntry entry = new RemediationsCacheEntry(); + entry.setOrder(order); + entry.setPath(path); + entry.setArtifactId(artifactId); + entry.setReleaseId(releaseId); + entry.setUploadDate(uploadDate); + entry.setSha256(sha256); + return entry; + } } 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 index 1ef912a5379..42574bc32ee 100644 --- 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 @@ -50,9 +50,15 @@ public final class RemediationsCacheReader implements AutoCloseable { @Getter(lazy = true) private final List orderedEntries = loadOrderedEntries(); + @Getter(lazy = true) + private final List orderedResolvedFprs = loadOrderedResolvedFprs(); + @Getter(lazy = true) private final List orderedFprPaths = loadOrderedFprPaths(); + /** Manifest entry paired with its validated ZipFS path (same order as apply). */ + public record ResolvedFpr(RemediationsCacheEntry entry, Path fprPath) {} + /** * Validates {@code cacheZip} and opens it as a zip file system. Prefer use via * try-with-resources so {@link #close()} always runs. @@ -178,8 +184,8 @@ private List loadOrderedEntries() { return List.copyOf(entries); } - private List loadOrderedFprPaths() { - List orderedFprs = new ArrayList<>(); + private List loadOrderedResolvedFprs() { + List resolved = new ArrayList<>(); for (RemediationsCacheEntry entry : getOrderedEntries()) { Path fprPath = resolveEntryPath(entry.getPath()); FcliSimpleException.throwIf(!Files.isRegularFile(fprPath), @@ -188,17 +194,37 @@ private List loadOrderedFprPaths() { FcliSimpleException.throwIf(!actualSha.equalsIgnoreCase(entry.getSha256()), "SHA-256 mismatch for cache entry %s (expected %s, actual %s)", entry.getPath(), entry.getSha256(), actualSha); - orderedFprs.add(fprPath); + resolved.add(new ResolvedFpr(entry, fprPath)); } - FcliSimpleException.throwIf(orderedFprs.isEmpty(), + FcliSimpleException.throwIf(resolved.isEmpty(), "Remediations cache contains no FPR entries: %s", cacheZip); - return List.copyOf(orderedFprs); + return List.copyOf(resolved); } + private List loadOrderedFprPaths() { + return getOrderedResolvedFprs().stream().map(ResolvedFpr::fprPath).toList(); + } + + /** + * Resolves a manifest entry path inside the zip FS. Rejects empty, absolute, and parent-escape paths. + * ZipFS keeps paths in-archive (not host zip-slip), but untrusted manifests must still stay relative. + */ private Path resolveEntryPath(String entryPath) { - String normalizedPath = entryPath.replace('\\', '/'); - FcliSimpleException.throwIf(normalizedPath.contains(".."), + FcliSimpleException.throwIf(StringUtils.isBlank(entryPath), + "Remediations cache entry path is blank: %s", cacheZip); + String normalized = entryPath.replace('\\', '/').trim(); + while (normalized.startsWith("./")) { + normalized = normalized.substring(2); + } + FcliSimpleException.throwIf(normalized.startsWith("/") || normalized.matches("^[A-Za-z]:.*"), + "Remediations cache contains absolute path: %s", entryPath); + FcliSimpleException.throwIf(normalized.contains(".."), + "Remediations cache contains unsafe path: %s", entryPath); + Path resolved = cacheFs.getPath(normalized).normalize(); + // After normalize, parent segments must not reappear. + String resolvedStr = resolved.toString().replace('\\', '/'); + FcliSimpleException.throwIf(resolvedStr.contains("..") || resolvedStr.startsWith("/"), "Remediations cache contains unsafe path: %s", entryPath); - return cacheFs.getPath(normalizedPath).normalize(); + return resolved; } } 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 index 28c735969da..6fbfae9767c 100644 --- 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 @@ -13,6 +13,7 @@ package com.fortify.cli.aviator._common.remediations_cache; import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.FileSystem; import java.nio.file.Files; import java.nio.file.Path; @@ -33,58 +34,77 @@ import com.fortify.cli.common.util.ZipHelper; /** - * Builds a remediations cache zip at a destination path. Prefer opening a writer, writing - * each FPR directly into the zip filesystem (for example via download APIs that accept - * {@link Path}), then {@link #finish()} and close. No intermediate temp zip is used. + * Builds a remediations cache zip at a destination path. FPR content is written + * directly into a ZipFS (no per-FPR temp staging). The ZipFS is opened on a sibling + * {@code *.partial} work file; on successful {@link #finish()} + {@link #close()}, the + * work file is moved onto {@code destination} (atomic when the filesystem supports it), + * so an existing destination is not replaced until the archive is complete. * - *

The destination is kept only when {@link #finish()} succeeded and - * {@link #close()} closed the zip filesystem successfully (ZipFS finalizes the archive on close). + *

{@link #close()} is the sole owner of zip close, work-file cleanup, and publish. */ public final class RemediationsCacheWriter implements AutoCloseable { private static final Logger logger = LoggerFactory.getLogger(RemediationsCacheWriter.class); + private static final String PARTIAL_SUFFIX = ".partial"; private final Path destination; + /** Sibling work path where ZipFS content is written before publish. */ + private final Path workPath; + /** May be null if initialization failed; {@link #close()} still cleans workPath. */ private final FileSystem zipFs; private final RemediationsCacheManifest manifest; + private final Exception initError; private int nextOrder = 1; - /** True after manifest.json has been written into the open ZipFS (not yet durable until close). */ + /** True after manifest.json has been written into the open ZipFS (not yet durable until close+publish). */ private boolean manifestWritten; private boolean closed; - private RemediationsCacheWriter(Path destination, FileSystem zipFs, RemediationsCacheManifest manifest) { + /** + * Always constructs an instance so {@link #close()} remains the only cleanup path. + * Callers must use {@link #create(Path, String, Map)} which closes and rethrows on init failure. + */ + private RemediationsCacheWriter(Path destination, String product, Map selection) { this.destination = destination; - this.zipFs = zipFs; - this.manifest = manifest; + this.workPath = workPathFor(destination); + this.manifest = newManifest(product, selection); + FileSystem fs = null; + Exception error = null; + try { + // ZipFS on sibling work file; existing destination is left intact until publish. + fs = ZipHelper.createZipFileSystem(workPath); + Files.createDirectories(fs.getPath(RemediationsCacheConstants.FPRS_DIR)); + } catch (AbstractFcliException e) { + error = e; + } catch (IOException | RuntimeException e) { + error = e; + } + this.zipFs = fs; + this.initError = error; } /** - * Opens (or recreates) the destination zip and prepares an empty manifest. - * Existing destination files are replaced by {@link ZipHelper#createZipFileSystem(Path)}. + * Opens a work zip next to {@code destination} and prepares an empty manifest. + * On initialization failure, {@link #close()} runs before the exception is rethrown so + * cleanup is not split across factory and AutoCloseable paths. */ public static RemediationsCacheWriter create(Path destination, String product, Map selection) { FcliSimpleException.throwIf(destination == null, "-f/--file must specify a remediations cache zip path"); - // ZipHelper.createZipFileSystem creates parent dirs and replaces any existing file. - FileSystem zipFs = ZipHelper.createZipFileSystem(destination); - try { - Files.createDirectories(zipFs.getPath(RemediationsCacheConstants.FPRS_DIR)); - // On success, zipFs ownership transfers to the returned writer (not closed here). - return new RemediationsCacheWriter(destination, zipFs, newManifest(product, selection)); - } catch (AbstractFcliException e) { - abortCreate(zipFs, destination); - throw e; - } catch (IOException e) { - abortCreate(zipFs, destination); - throw new FcliTechnicalException("Failed to initialize remediations cache zip: " + destination, e); - } catch (RuntimeException e) { - abortCreate(zipFs, destination); - throw new FcliTechnicalException("Failed to initialize remediations cache zip: " + destination, e); + RemediationsCacheWriter writer = new RemediationsCacheWriter(destination, product, selection); + if (writer.initError != null) { + Exception cause = writer.initError; + writer.close(); + if (cause instanceof AbstractFcliException fcliException) { + throw fcliException; + } + throw new FcliTechnicalException( + "Failed to initialize remediations cache zip: " + destination, cause); } + return writer; } /** * Convenience for callers that already have FPR files on disk (for example unit tests). - * Writes directly to {@code destination} (no temp zip). + * Writes via ZipFS into a work file, then publishes to {@code destination}. */ public static RemediationsCacheManifest write( Path destination, @@ -164,7 +184,8 @@ private void recordEntry( /** * Writes {@code manifest.json} into the open zip. Must be called before {@link #close()} - * for a successful cache. The archive is only durable after a successful {@link #close()}. + * for a successful cache. The archive is only published to {@code destination} after a + * successful {@link #close()}. * Returns the completed manifest. */ public RemediationsCacheManifest finish() { @@ -186,6 +207,10 @@ public RemediationsCacheManifest finish() { } } + /** + * Sole owner of zip filesystem close, work-file cleanup, and publish to destination. + * Destination is replaced only when finish() wrote the manifest and ZipFS closed cleanly. + */ @Override public void close() { if (closed) { @@ -199,17 +224,34 @@ public void close() { } closeSucceeded = true; } catch (IOException e) { - // ZipFS finalizes the archive on close; failure means destination is not trustworthy. + // ZipFS finalizes the archive on close; failure means work file is not trustworthy. if (manifestWritten) { - deleteQuietly(destination); + deleteQuietly(workPath); throw new FcliTechnicalException("Failed to finalize remediations cache zip: " + destination, e); } - logger.warn("Failed to close remediations cache zip: {}", destination, e); + logger.warn("Failed to close remediations cache zip work file: {}", workPath, e); } finally { - // Keep destination only when finish() wrote the manifest and ZipFS closed cleanly. - if (!(manifestWritten && closeSucceeded)) { - deleteQuietly(destination); + if (manifestWritten && closeSucceeded) { + publishWorkFile(); + } else { + deleteQuietly(workPath); + } + } + } + + /** Moves the completed work zip onto {@link #destination}, preferring an atomic replace. */ + private void publishWorkFile() { + try { + try { + Files.move(workPath, destination, + StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(workPath, destination, StandardCopyOption.REPLACE_EXISTING); } + } catch (IOException e) { + deleteQuietly(workPath); + throw new FcliTechnicalException( + "Failed to publish remediations cache zip to " + destination, e); } } @@ -217,14 +259,20 @@ private void requireWritable() { if (closed) { throw new FcliBugException("RemediationsCacheWriter is already closed: " + destination); } + if (initError != null) { + throw new FcliBugException("RemediationsCacheWriter failed to initialize: " + destination, initError); + } if (manifestWritten) { throw new FcliBugException("RemediationsCacheWriter is already finished: " + destination); } } - private static void abortCreate(FileSystem zipFs, Path destination) { - closeQuietly(zipFs); - deleteQuietly(destination); + private static Path workPathFor(Path destination) { + Path fileName = destination.getFileName(); + String name = fileName != null ? fileName.toString() : "remediations-cache.zip"; + Path parent = destination.toAbsolutePath().getParent(); + Path workName = Path.of(name + PARTIAL_SUFFIX); + return parent != null ? parent.resolve(workName) : workName; } private static RemediationsCacheManifest newManifest(String product, Map selection) { @@ -241,37 +289,29 @@ private static RemediationsCacheManifest newManifest(String product, Map fprPaths) { } 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"); - } + FcliSimpleException.throwIf(fprPaths == null || fprPaths.isEmpty(), + "%s list must contain at least one FPR file", sourceLabel); for (Path fprPath : fprPaths) { validateLocalFpr(fprPath, sourceLabel); } } private static void validateLocalFpr(Path fprPath, String sourceLabel) { - 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); - } + FcliSimpleException.throwIf(fprPath == null, + "%s path must be a valid FPR file path", sourceLabel); + FcliSimpleException.throwIf(!Files.exists(fprPath), + "%s does not exist: %s", sourceLabel, fprPath); + FcliSimpleException.throwIf(!Files.isRegularFile(fprPath), + "%s is not a regular file: %s", sourceLabel, fprPath); + FcliSimpleException.throwIf(!Files.isReadable(fprPath), + "%s is not readable: %s", sourceLabel, fprPath); try (FprHandle fprHandle = new FprHandle(fprPath)) { fprHandle.validate(); } catch (AbstractFcliException e) { 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 index acd7a51ef9d..6daff9ee95b 100644 --- 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 @@ -62,7 +62,7 @@ void writeAndReadRoundTripPreservesOrderAndHashes() throws Exception { } @Test - void writeDirectlyIntoDestinationZipWithoutTempStaging() throws Exception { + void streamAddFprWritesReadableCacheForFoD() throws Exception { Path zip = tempDir.resolve("direct.zip"); try (RemediationsCacheWriter writer = RemediationsCacheWriter.create( zip, RemediationsCacheConstants.PRODUCT_FOD, Map.of("mode", "release"))) { @@ -89,6 +89,25 @@ void emptySourcesRejected() { RemediationsCacheWriter.write(zip, RemediationsCacheConstants.PRODUCT_SSC, Map.of(), List.of())); } + @Test + void incompleteWriteDoesNotReplaceExistingDestination() throws Exception { + Path zip = tempDir.resolve("existing.zip"); + Files.writeString(zip, "prior-cache-bytes"); + try (RemediationsCacheWriter writer = RemediationsCacheWriter.create( + zip, RemediationsCacheConstants.PRODUCT_SSC, Map.of("mode", "all"))) { + writer.addFpr("1", null, null, path -> { + try { + Files.writeString(path, "partial-fpr"); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + // Close without finish(): work file is discarded; destination stays intact. + } + assertEquals("prior-cache-bytes", Files.readString(zip)); + assertTrue(Files.notExists(zip.resolveSibling("existing.zip.partial"))); + } + @Test void cachedFprCanBeOpenedAsNestedZipFileSystem() throws Exception { Path fpr = tempDir.resolve("nested.fpr"); @@ -176,4 +195,28 @@ void requireProductRejectsMismatch() throws Exception { } }); } + + @Test + void entryPathSanitizesUnsafeIdCharacters() throws Exception { + Path fpr = tempDir.resolve("one.fpr"); + Files.writeString(fpr, "content"); + Path zip = tempDir.resolve("cache.zip"); + RemediationsCacheWriter.write( + zip, + RemediationsCacheConstants.PRODUCT_SSC, + Map.of("mode", "artifact-id"), + List.of(RemediationsCacheWriter.FprSource.forSsc(fpr, "12/../evil", null))); + + try (RemediationsCacheReader reader = RemediationsCacheReader.open(zip)) { + var resolved = reader.getOrderedResolvedFprs(); + assertEquals(1, resolved.size()); + // Manifest keeps the original id; zip entry path must not contain path separators or "..". + assertEquals("12/../evil", resolved.get(0).entry().getArtifactId()); + String entryPath = resolved.get(0).entry().getPath(); + assertTrue(entryPath.startsWith(RemediationsCacheConstants.FPRS_DIR + "/")); + assertTrue(!entryPath.contains("..")); + assertTrue(!entryPath.contains("/evil")); + assertEquals("content", Files.readString(resolved.get(0).fprPath())); + } + } } diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorIssueIdFilterUtilsTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorIssueIdFilterUtilsTest.java new file mode 100644 index 00000000000..dc44a2b077c --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorIssueIdFilterUtilsTest.java @@ -0,0 +1,39 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.common.exception.FcliSimpleException; + +class AviatorIssueIdFilterUtilsTest { + + @Test + void normalizeTrimsAndDeduplicates() { + assertEquals( + Set.of("ISSUE-1", "ISSUE-2"), + AviatorIssueIdFilterUtils.normalizeIssueIds(List.of(" ISSUE-1 ", "", "ISSUE-2", "ISSUE-1"))); + } + + @Test + void normalizeRejectsOnlyBlankValues() { + assertThrows(FcliSimpleException.class, + () -> AviatorIssueIdFilterUtils.normalizeIssueIds(List.of(" ", "", " "))); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelperTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelperTest.java new file mode 100644 index 00000000000..14bb0d9758e --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelperTest.java @@ -0,0 +1,52 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator.fpr.processor.RemediationProcessor.RemediationMetric; + +class AviatorRemediationMetricsHelperTest { + + @Test + void aggregateFilteredMetricsDeduplicatesIssueIdsAcrossEntries() { + RemediationMetric metricOne = RemediationMetric.filtered( + Set.of("ISSUE-1", "ISSUE-2"), Set.of("ISSUE-1"), Set.of("A.java")); + RemediationMetric metricTwo = RemediationMetric.filtered( + Set.of("ISSUE-1", "ISSUE-2"), Set.of("ISSUE-2"), Set.of("B.java")); + + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + Set.of("ISSUE-1", "ISSUE-2"), List.of(metricOne, metricTwo)); + + assertEquals(2, aggregated.totalRemediations()); + assertEquals(2, aggregated.appliedRemediations()); + assertEquals(0, aggregated.skippedRemediations()); + assertEquals(Set.of("ISSUE-1", "ISSUE-2"), aggregated.appliedIssueIds()); + assertEquals(Set.of("A.java", "B.java"), aggregated.modifiedFiles()); + } + + @Test + void remainingIssueIdsDropsAlreadyApplied() { + RemediationMetric metric = RemediationMetric.filtered( + Set.of("ISSUE-1", "ISSUE-2"), Set.of("ISSUE-1"), Set.of("A.java")); + + assertEquals( + Set.of("ISSUE-2"), + AviatorRemediationMetricsHelper.getRemainingIssueIds(Set.of("ISSUE-1", "ISSUE-2"), metric)); + } +} diff --git a/fcli-core/fcli-aviator/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 00235be4c75..0c5b14611fd 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 @@ -20,18 +20,17 @@ import org.slf4j.LoggerFactory; import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheApplyHelper; -import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheApplyHelper.ApplyResult; +import com.fortify.cli.aviator._common.remediations_cache.CacheRemediationsFprSource; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsApplyHelper; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsApplyHelper.ApplyResult; import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheConstants; -import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheReader; import com.fortify.cli.aviator._common.util.AviatorIssueIdFilterUtils; -import com.fortify.cli.aviator._common.util.AviatorRemediationMetricsHelper; import com.fortify.cli.aviator.config.AviatorLoggerImpl; -import com.fortify.cli.aviator.fpr.processor.RemediationProcessor.RemediationMetric; import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCApplyRemediationsSourceMixin; +import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCRemediationsSelectorArgGroups.OnlineSelectionArgGroup.ResolvedOnlineArtifacts; import com.fortify.cli.aviator.ssc.helper.AviatorSSCApplyRemediationsHelper; -import com.fortify.cli.aviator.ssc.helper.AviatorSSCOnlineRemediationsApplier; import com.fortify.cli.aviator.ssc.helper.SinceOptionHelper; +import com.fortify.cli.aviator.ssc.helper.SscOnlineRemediationsFprSource; 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; @@ -41,12 +40,9 @@ import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; import com.fortify.cli.common.progress.helper.IProgressWriter; import com.fortify.cli.ssc._common.rest.ssc.cli.mixin.SSCUnirestInstanceSupplierMixin; -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; @@ -61,13 +57,12 @@ public class AviatorSSCApplyRemediationsCommand extends AbstractOutputCommand @Mixin private AviatorSSCApplyRemediationsSourceMixin sourceSelector; @Mixin private SSCUnirestInstanceSupplierMixin unirestInstanceSupplier; - @Option(names = {"--source-dir"}, descriptionKey = "fcli.aviator.ssc.apply-remediations.source-dir") + @Option(names = {"--source-dir"}) private String sourceCodeDirectory = System.getProperty("user.dir"); - @Option(names = {"--issue-ids"}, split = ",", descriptionKey = "fcli.aviator.ssc.apply-remediations.issue-ids") + @Option(names = {"--issue-ids"}, split = ",") private List issueIds; @Override - @SneakyThrows public JsonNode getJsonNode() { sourceSelector.validate(); validateSourceCodeDirectory(); @@ -84,67 +79,45 @@ public JsonNode getJsonNode() { } private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdFilter) { - try (RemediationsCacheReader cacheReader = RemediationsCacheReader.open(sourceSelector.getFromCache())) { - ApplyResult applyResult = RemediationsCacheApplyHelper.applyEntries( - cacheReader, - RemediationsCacheConstants.PRODUCT_SSC, - sourceCodeDirectory, - logger, - issueIdFilter, - RemediationsCacheApplyHelper.EntryIdKind.ARTIFACT_ID, - LOG); - RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( - issueIdFilter, applyResult.metrics()); - String appVersionId = cacheReader.getManifest().getSelection() != null - ? cacheReader.getManifest().getSelection().get("appVersionId") - : null; + try (CacheRemediationsFprSource source = CacheRemediationsFprSource.open( + sourceSelector.getFromCache(), + RemediationsCacheConstants.PRODUCT_SSC, + CacheRemediationsFprSource.IdKind.ARTIFACT_ID)) { + ApplyResult applyResult = RemediationsApplyHelper.apply( + source, sourceCodeDirectory, logger, issueIdFilter, LOG); return AviatorSSCApplyRemediationsHelper.buildCacheResultNode( - new AviatorSSCApplyRemediationsHelper.CacheResultData( - sourceSelector.getFromCache(), - applyResult.processedEntries(), - applyResult.processedIds(), - appVersionId, - applyResult.metrics().size(), - applyResult.skipped(), - aggregated.totalRemediations(), - aggregated.appliedRemediations(), - aggregated.skippedRemediations(), - aggregated.modifiedFiles(), - RemediationsCacheApplyHelper.actionLabel(aggregated))); + sourceSelector.getFromCache(), + applyResult, + issueIdFilter, + source.reader().getManifest().getSelection()); } } private JsonNode processOnline( AviatorLoggerImpl logger, IProgressWriter progressWriter, Set issueIdFilter) { UnirestInstance unirest = unirestInstanceSupplier.getUnirestInstance(); - AviatorSSCOnlineRemediationsApplier applier = new AviatorSSCOnlineRemediationsApplier( - unirest, logger, progressWriter, sourceCodeDirectory, issueIdFilter); OffsetDateTime sinceDate = SinceOptionHelper.parse(sourceSelector.getSince()); - if (sourceSelector.isAllSelected()) { - return applier.applyAll(sourceSelector.getAppVersionId(unirest), sinceDate); + // One resolve: artifacts + appVersionId (no second getAppVersionId REST call). + ResolvedOnlineArtifacts resolved = sourceSelector.resolveArtifacts(unirest, sinceDate); + try (SscOnlineRemediationsFprSource source = new SscOnlineRemediationsFprSource( + unirest, logger, progressWriter, resolved.artifacts())) { + ApplyResult applyResult = RemediationsApplyHelper.apply( + source, sourceCodeDirectory, logger, issueIdFilter, LOG); + return AviatorSSCApplyRemediationsHelper.buildOnlineResultNode( + resolved.artifacts(), resolved.appVersionId(), applyResult, issueIdFilter); } - return applier.applyOne(resolveArtifactDescriptor(unirest, sinceDate)); - } - - private SSCArtifactDescriptor resolveArtifactDescriptor(UnirestInstance unirest, OffsetDateTime sinceDate) { - if (sourceSelector.isLatestSelected()) { - return SSCArtifactHelper.getLatestAviatorArtifact( - unirest, sourceSelector.getAppVersionId(unirest), sinceDate); - } - return SSCArtifactHelper.getArtifactDescriptor(unirest, sourceSelector.getArtifactId()); } private void validateSourceCodeDirectory() { - if (sourceCodeDirectory == null || sourceCodeDirectory.isBlank()) { - throw new FcliSimpleException("--source-dir must specify a valid directory path"); - } + FcliSimpleException.throwIf(sourceCodeDirectory == null || sourceCodeDirectory.isBlank(), + "--source-dir must specify a valid directory path"); } 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"); - } + FcliSimpleException.throwIf( + issueIds != null && !issueIds.isEmpty() && !sourceSelector.isFromCacheSelected(), + "--issue-ids can only be used with --from-cache; " + + "create a cache with download-remediations-cache and rerun with --from-cache"); } @Override 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 index 1066ec16fca..269d92651a7 100644 --- 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 @@ -17,7 +17,6 @@ import java.nio.file.Path; import java.time.OffsetDateTime; import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; import com.fasterxml.jackson.databind.JsonNode; @@ -39,11 +38,9 @@ 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; @@ -55,12 +52,10 @@ public class AviatorSSCDownloadRemediationsCacheCommand extends AbstractSSCJsonN @Mixin private AviatorSSCRemediationsCacheDownloadSelectorMixin artifactSelector; @Mixin private CommonOptionMixins.RequireConfirmation requireConfirmation; - @Option(names = {"-f", "--file"}, required = true, paramLabel = "", - descriptionKey = "fcli.aviator.ssc.download-remediations-cache.file") + @Option(names = {"-f", "--file"}, required = true, paramLabel = "") private File outputFile; @Override - @SneakyThrows public JsonNode getJsonNode(UnirestInstance unirest) { artifactSelector.validate(); Path destination = outputFile.toPath(); @@ -69,14 +64,15 @@ public JsonNode getJsonNode(UnirestInstance unirest) { } OffsetDateTime sinceDate = SinceOptionHelper.parse(artifactSelector.getSince()); - List artifacts = resolveArtifacts(unirest, sinceDate); - Map selection = buildSelectionMetadata(unirest, sinceDate); + // One resolve: artifacts + appVersionId (shared with apply-remediations). + var resolved = artifactSelector.resolveArtifacts(unirest, sinceDate); + Map selection = buildSelectionMetadata(resolved.appVersionId(), sinceDate); try (IProgressWriter progressWriter = progressWriterFactoryMixin.create(); RemediationsCacheWriter cacheWriter = RemediationsCacheWriter.create( destination, RemediationsCacheConstants.PRODUCT_SSC, selection)) { AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); - for (SSCArtifactDescriptor artifact : artifacts) { + for (SSCArtifactDescriptor artifact : resolved.artifacts()) { cacheWriter.addFpr(artifact.getId(), null, artifact.getUploadDate(), entryPath -> downloadArtifact(unirest, artifact, entryPath, logger, progressWriter)); } @@ -85,29 +81,13 @@ public JsonNode getJsonNode(UnirestInstance unirest) { } } - 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) { + private Map buildSelectionMetadata(String appVersionId, 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); - } + } else if (appVersionId != null) { + selection.put("appVersionId", appVersionId); } if (sinceDate != null) { selection.put("since", sinceDate.toString()); 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 index 769fdc56611..c4b043c1da5 100644 --- 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 @@ -13,6 +13,7 @@ package com.fortify.cli.aviator.ssc.cli.mixin; import java.nio.file.Path; +import java.time.OffsetDateTime; import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCRemediationsSelectorArgGroups.OnlineSelectionArgGroup; import com.fortify.cli.common.exception.FcliSimpleException; @@ -36,6 +37,7 @@ public static class SourceArgGroup { @ArgGroup(exclusive = false) private OnlineSelectionArgGroup online; + /** Shared/arg-group option: keep descriptionKey (default picocli key uses FQCN). */ @Option(names = {"--from-cache"}, required = true, paramLabel = "", descriptionKey = "fcli.aviator.ssc.apply-remediations.from-cache") private Path fromCache; @@ -81,13 +83,20 @@ public String getAppVersionId(UnirestInstance unirest) { return isOnlineSelected() ? source.online.getAppVersionId(unirest) : null; } + /** Online only; same resolution as download-remediations-cache. */ + public OnlineSelectionArgGroup.ResolvedOnlineArtifacts resolveArtifacts( + UnirestInstance unirest, OffsetDateTime sinceDate) { + FcliSimpleException.throwIf(!isOnlineSelected(), + "Online artifact selection is required (not --from-cache)"); + return source.online.resolveArtifacts(unirest, sinceDate); + } + 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"); - } + FcliSimpleException.throwIf(!isOnlineSelected(), + "Exactly one of --from-cache or online selection (--artifact-id, --latest, --all) must be specified"); source.online.validate(); } } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsCacheDownloadSelectorMixin.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsCacheDownloadSelectorMixin.java index 853f3ecd100..b37622794c3 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsCacheDownloadSelectorMixin.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsCacheDownloadSelectorMixin.java @@ -12,6 +12,8 @@ */ package com.fortify.cli.aviator.ssc.cli.mixin; +import java.time.OffsetDateTime; + import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCRemediationsSelectorArgGroups.OnlineSelectionArgGroup; import kong.unirest.UnirestInstance; @@ -51,6 +53,11 @@ public String getSelectionMode() { return onlineSelection != null ? onlineSelection.getSelectionMode() : null; } + public OnlineSelectionArgGroup.ResolvedOnlineArtifacts resolveArtifacts( + UnirestInstance unirest, OffsetDateTime sinceDate) { + return onlineSelection.resolveArtifacts(unirest, sinceDate); + } + public void validate() { if (onlineSelection != null) { onlineSelection.validate(); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsSelectorArgGroups.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsSelectorArgGroups.java index a81e61f4d03..39b6701920a 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsSelectorArgGroups.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsSelectorArgGroups.java @@ -12,11 +12,16 @@ */ package com.fortify.cli.aviator.ssc.cli.mixin; +import java.time.OffsetDateTime; +import java.util.List; + import org.apache.commons.lang3.StringUtils; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor; import com.fortify.cli.ssc.appversion.helper.SSCAppVersionHelper; +import com.fortify.cli.ssc.artifact.helper.SSCArtifactDescriptor; +import com.fortify.cli.ssc.artifact.helper.SSCArtifactHelper; import kong.unirest.UnirestInstance; import lombok.Getter; @@ -37,7 +42,8 @@ public static class OnlineSelectionArgGroup { @Option(names = {"--appversion", "--av"}, descriptionKey = "fcli.ssc.appversion.resolver.nameOrId") private String appVersionNameOrId; - @Option(names = {"--delim"}, defaultValue = ":") + /** Shared description from module Messages.properties ({@code delim=}). */ + @Option(names = {"--delim"}, defaultValue = ":", descriptionKey = "delim") private String delimiter; public boolean isArtifactIdSelected() { @@ -78,16 +84,44 @@ public String getSelectionMode() { return null; } - 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"); + /** + * Resolves online selection once: artifacts plus appVersionId for {@code --latest}/{@code --all}. + * Shared by download-remediations-cache and apply-remediations so selection behavior stays aligned. + * {@code --artifact-id} is validated with {@link SSCArtifactHelper#requireAviatorArtifact}; + * appVersionId is null in that mode (callers may read it from the artifact JSON if needed). + */ + public ResolvedOnlineArtifacts resolveArtifacts(UnirestInstance unirest, OffsetDateTime sinceDate) { + if (isAllSelected()) { + String appVersionId = getAppVersionId(unirest); + return new ResolvedOnlineArtifacts( + SSCArtifactHelper.getAllAviatorArtifacts(unirest, appVersionId, sinceDate), + appVersionId); } - if (isArtifactIdSelected() && StringUtils.isNotBlank(appVersionNameOrId)) { - throw new FcliSimpleException("--av/--appversion cannot be used with --artifact-id"); + if (isLatestSelected()) { + String appVersionId = getAppVersionId(unirest); + return new ResolvedOnlineArtifacts( + List.of(SSCArtifactHelper.getLatestAviatorArtifact(unirest, appVersionId, sinceDate)), + appVersionId); } + FcliSimpleException.throwIf(!isArtifactIdSelected(), + "Exactly one of --artifact-id, --latest, or --all must be specified"); + return new ResolvedOnlineArtifacts( + List.of(SSCArtifactHelper.requireAviatorArtifact( + SSCArtifactHelper.getArtifactDescriptor(unirest, getArtifactId()))), + null); + } + + /** Result of {@link #resolveArtifacts}: one REST resolve of app version when applicable. */ + public record ResolvedOnlineArtifacts(List artifacts, String appVersionId) {} + + public void validate() { + FcliSimpleException.throwIf(StringUtils.isNotBlank(since) && isArtifactIdSelected(), + "--since cannot be used with --artifact-id; use --latest or --all"); + FcliSimpleException.throwIf( + (isLatestSelected() || isAllSelected()) && StringUtils.isBlank(appVersionNameOrId), + "--av/--appversion is required when using --latest or --all"); + FcliSimpleException.throwIf(isArtifactIdSelected() && StringUtils.isNotBlank(appVersionNameOrId), + "--av/--appversion cannot be used with --artifact-id"); } } 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 1b501678cc9..25503fd3d72 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 @@ -14,14 +14,21 @@ import java.nio.file.Path; import java.util.List; +import java.util.Map; import java.util.Set; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsApplyHelper; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsApplyHelper.ApplyResult; +import com.fortify.cli.aviator._common.util.AviatorRemediationMetricsHelper; +import com.fortify.cli.aviator.fpr.processor.RemediationProcessor.RemediationMetric; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; import com.fortify.cli.ssc.artifact.helper.SSCArtifactDescriptor; +import lombok.Builder; + /** * Helper for Aviator SSC apply-remediations result JSON construction. */ @@ -29,48 +36,86 @@ public final class AviatorSSCApplyRemediationsHelper { private AviatorSSCApplyRemediationsHelper() {} /** - * Builds the unified JSON result node for a single-artifact remediation (--artifact-id or --latest). - * Uses the same output shape as buildAggregatedResultNode for consistent table columns. + * Builds a single online-result shape for any apply outcome (one or many artifacts). + * Table columns use the same fields for --artifact-id, --latest, and --all. */ - public static ObjectNode buildResultNode(SSCArtifactDescriptor ad, int totalRemediation, int appliedRemediation, - int skippedRemediation, Set modifiedFiles, String action) { - ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); - result.put("appVersionId", ad.asObjectNode().path("projectVersionId").asText("N/A")); - result.put("artifactId", ad.getId()); - result.put("artifactsProcessed", 1); - result.put("artifactsSkipped", 0); - result.put("totalRemediation", totalRemediation); - result.put("appliedRemediation", appliedRemediation); - result.put("skippedRemediation", skippedRemediation); - result.set("modifiedFiles", toArrayNode(modifiedFiles)); - result.put(IActionCommandResultSupplier.actionFieldName, action); - return result; + public static ObjectNode buildOnlineResultNode( + List artifacts, + String appVersionId, + ApplyResult applyResult, + Set issueIdFilter) { + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + issueIdFilter, applyResult.metrics()); + + String versionId = appVersionId; + String artifactId = "N/A"; + if (artifacts.size() == 1) { + SSCArtifactDescriptor only = artifacts.get(0); + if (versionId == null) { + versionId = only.asObjectNode().path("projectVersionId").asText(null); + } + // Preserve single-artifact id when that one entry was processed successfully. + if (applyResult.metrics().size() == 1 && applyResult.skipped() == 0) { + artifactId = only.getId(); + } + } + + return toOnlineJson(OnlineResultData.builder() + .appVersionId(versionId != null ? versionId : "N/A") + .artifactId(artifactId) + .artifactsProcessed(applyResult.metrics().size()) + .artifactsSkipped(applyResult.skipped()) + .totalRemediation(aggregated.totalRemediations()) + .appliedRemediation(aggregated.appliedRemediations()) + .skippedRemediation(aggregated.skippedRemediations()) + .modifiedFiles(aggregated.modifiedFiles()) + .action(RemediationsApplyHelper.actionLabel(aggregated)) + .build()); } /** - * Builds the unified JSON result node for --all, aggregating across all artifacts. - * Uses the same output shape as buildResultNode for consistent table columns. + * Aggregates metrics and builds the cache apply result JSON (same fields as before). + * + * @param selection manifest selection map; may be null (appVersionId becomes null / "N/A") */ - public static ObjectNode buildAggregatedResultNode(String appVersionId, int artifactsProcessed, int artifactsSkipped, - int totalRemediation, int appliedRemediation, int skippedRemediation, Set modifiedFiles, String action) { + public static ObjectNode buildCacheResultNode( + Path cacheZip, + ApplyResult applyResult, + Set issueIdFilter, + Map selection) { + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + issueIdFilter, applyResult.metrics()); + String appVersionId = selection != null ? selection.get("appVersionId") : null; + return toCacheJson(CacheResultData.builder() + .cacheZip(cacheZip) + .entryPaths(applyResult.processedEntries()) + .artifactIds(applyResult.processedIds()) + .appVersionId(appVersionId) + .fprsProcessed(applyResult.metrics().size()) + .fprsSkipped(applyResult.skipped()) + .totalRemediation(aggregated.totalRemediations()) + .appliedRemediation(aggregated.appliedRemediations()) + .skippedRemediation(aggregated.skippedRemediations()) + .modifiedFiles(aggregated.modifiedFiles()) + .action(RemediationsApplyHelper.actionLabel(aggregated)) + .build()); + } + + private static ObjectNode toOnlineJson(OnlineResultData data) { ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); - result.put("appVersionId", appVersionId); - result.put("artifactId", "N/A"); - result.put("artifactsProcessed", artifactsProcessed); - result.put("artifactsSkipped", artifactsSkipped); - result.put("totalRemediation", totalRemediation); - result.put("appliedRemediation", appliedRemediation); - result.put("skippedRemediation", skippedRemediation); - result.set("modifiedFiles", toArrayNode(modifiedFiles)); - result.put(IActionCommandResultSupplier.actionFieldName, action); + result.put("appVersionId", data.appVersionId() != null ? data.appVersionId() : "N/A"); + result.put("artifactId", data.artifactId() != null ? data.artifactId() : "N/A"); + result.put("artifactsProcessed", data.artifactsProcessed()); + result.put("artifactsSkipped", data.artifactsSkipped()); + result.put("totalRemediation", data.totalRemediation()); + result.put("appliedRemediation", data.appliedRemediation()); + result.put("skippedRemediation", data.skippedRemediation()); + result.set("modifiedFiles", toArrayNode(data.modifiedFiles())); + result.put(IActionCommandResultSupplier.actionFieldName, data.action()); 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) { + private static ObjectNode toCacheJson(CacheResultData resultData) { ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); result.put("appVersionId", resultData.appVersionId() != null ? resultData.appVersionId() : "N/A"); result.put("artifactId", "N/A"); @@ -87,7 +132,20 @@ public static ObjectNode buildCacheResultNode(CacheResultData resultData) { return result; } - public record CacheResultData( + @Builder + private record OnlineResultData( + String appVersionId, + String artifactId, + int artifactsProcessed, + int artifactsSkipped, + int totalRemediation, + int appliedRemediation, + int skippedRemediation, + Set modifiedFiles, + String action) {} + + @Builder + private record CacheResultData( Path cacheZip, List entryPaths, List artifactIds, diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCOnlineRemediationsApplier.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCOnlineRemediationsApplier.java deleted file mode 100644 index e9d2ccec31f..00000000000 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCOnlineRemediationsApplier.java +++ /dev/null @@ -1,152 +0,0 @@ -/* - * Copyright 2021-2026 Open Text. - * - * The only warranties for products and services of Open Text - * and its affiliates and licensors ("Open Text") are as may - * be set forth in the express warranty statements accompanying - * such products and services. Nothing herein should be construed - * as constituting an additional warranty. Open Text shall not be - * liable for technical or editorial errors or omissions contained - * herein. The information contained herein is subject to change - * without notice. - */ -package com.fortify.cli.aviator.ssc.helper; - -import java.io.IOException; -import java.nio.file.Path; -import java.time.OffsetDateTime; -import java.util.ArrayList; -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.RemediationsCacheApplyHelper; -import com.fortify.cli.aviator._common.util.AviatorRemediationMetricsHelper; -import com.fortify.cli.aviator._common.util.AviatorTempFprFile; -import com.fortify.cli.aviator.applyRemediation.ApplyAutoRemediationOnSource; -import com.fortify.cli.aviator.config.IAviatorLogger; -import com.fortify.cli.aviator.fpr.processor.RemediationProcessor.RemediationMetric; -import com.fortify.cli.aviator.util.FprHandle; -import com.fortify.cli.common.exception.FcliTechnicalException; -import com.fortify.cli.common.progress.helper.IProgressWriter; -import com.fortify.cli.ssc._common.rest.ssc.SSCUrls; -import com.fortify.cli.ssc._common.rest.ssc.transfer.SSCFileTransferHelper; -import com.fortify.cli.ssc.artifact.helper.SSCArtifactDescriptor; -import com.fortify.cli.ssc.artifact.helper.SSCArtifactHelper; - -import kong.unirest.UnirestInstance; - -/** - * Online (non-cache) SSC apply-remediations: download each audited FPR to a managed host temp - * path (required by {@link FprHandle}), apply remediations, delete the temp file. - */ -public final class AviatorSSCOnlineRemediationsApplier { - private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCOnlineRemediationsApplier.class); - - private final UnirestInstance unirest; - private final IAviatorLogger logger; - private final IProgressWriter progressWriter; - private final String sourceCodeDirectory; - private final Set issueIdFilter; - - public AviatorSSCOnlineRemediationsApplier( - UnirestInstance unirest, - IAviatorLogger logger, - IProgressWriter progressWriter, - String sourceCodeDirectory, - Set issueIdFilter) { - this.unirest = unirest; - this.logger = logger; - this.progressWriter = progressWriter; - this.sourceCodeDirectory = sourceCodeDirectory; - this.issueIdFilter = issueIdFilter; - } - - public JsonNode applyAll(String appVersionId, OffsetDateTime sinceDate) { - List artifacts = - SSCArtifactHelper.getAllAviatorArtifacts(unirest, appVersionId, sinceDate); - BatchResult batch = processBatch(artifacts); - RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( - issueIdFilter, batch.metrics()); - return AviatorSSCApplyRemediationsHelper.buildAggregatedResultNode( - appVersionId, - batch.processed(), - batch.skipped(), - aggregated.totalRemediations(), - aggregated.appliedRemediations(), - aggregated.skippedRemediations(), - aggregated.modifiedFiles(), - RemediationsCacheApplyHelper.actionLabel(aggregated)); - } - - public JsonNode applyOne(SSCArtifactDescriptor artifact) { - RemediationMetric metric = downloadAndApply(artifact); - return AviatorSSCApplyRemediationsHelper.buildResultNode( - artifact, - metric.totalRemediations(), - metric.appliedRemediations(), - metric.skippedRemediations(), - metric.modifiedFiles(), - RemediationsCacheApplyHelper.actionLabel(metric)); - } - - private BatchResult processBatch(List artifacts) { - int processed = 0; - int skipped = 0; - List metrics = new ArrayList<>(); - Set remaining = issueIdFilter == null ? null : new LinkedHashSet<>(issueIdFilter); - - for (int i = 0; i < artifacts.size(); i++) { - if (remaining != null && remaining.isEmpty()) { - break; - } - SSCArtifactDescriptor ad = artifacts.get(i); - logger.progress("Processing artifact " + (i + 1) + "/" + artifacts.size() + " (id=" + ad.getId() + ")"); - try { - RemediationMetric metric = downloadAndApply(ad, remaining); - metrics.add(metric); - remaining = AviatorRemediationMetricsHelper.getRemainingIssueIds(remaining, metric); - processed++; - } catch (AviatorSimpleException e) { - LOG.warn("Skipping artifact {} as {}", ad.getId(), e.getMessage()); - skipped++; - } - } - return new BatchResult(processed, skipped, metrics); - } - - private RemediationMetric downloadAndApply(SSCArtifactDescriptor artifact) { - return downloadAndApply(artifact, issueIdFilter); - } - - private RemediationMetric downloadAndApply(SSCArtifactDescriptor artifact, Set issueFilter) { - try (AviatorTempFprFile tempFpr = AviatorTempFprFile.create(artifact.getId())) { - downloadArtifact(artifact, tempFpr.path()); - logger.progress("Status: Processing FPR with Aviator for Applying Auto Remediations"); - try (FprHandle fprHandle = new FprHandle(tempFpr.path())) { - return ApplyAutoRemediationOnSource.applyRemediations( - fprHandle, sourceCodeDirectory, logger, issueFilter); - } catch (IOException e) { - throw new FcliTechnicalException( - "Failed to close FPR handle for SSC artifact " + artifact.getId(), e); - } - } - } - - private void downloadArtifact(SSCArtifactDescriptor artifact, Path destination) { - logger.progress("Status: Downloading Audited FPR from SSC (artifact id=" + artifact.getId() + ")"); - SSCFileTransferHelper.download( - unirest, - SSCUrls.DOWNLOAD_ARTIFACT(artifact.getId(), true), - destination, - SSCFileTransferHelper.ISSCAddDownloadTokenFunction.ROUTEPARAM_DOWNLOADTOKEN, - progressWriter); - } - - private record BatchResult(int processed, int skipped, List metrics) {} -} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SscOnlineRemediationsFprSource.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SscOnlineRemediationsFprSource.java new file mode 100644 index 00000000000..f0a5856828c --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SscOnlineRemediationsFprSource.java @@ -0,0 +1,81 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.helper; + +import java.util.List; + +import com.fortify.cli.aviator._common.remediations_cache.IRemediationsFprSource; +import com.fortify.cli.aviator._common.util.AviatorTempFprFile; +import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.progress.helper.IProgressWriter; +import com.fortify.cli.ssc._common.rest.ssc.SSCUrls; +import com.fortify.cli.ssc._common.rest.ssc.transfer.SSCFileTransferHelper; +import com.fortify.cli.ssc.artifact.helper.SSCArtifactDescriptor; + +import kong.unirest.UnirestInstance; + +/** + * Online SSC remediations source: downloads each artifact FPR to a managed temp path + * for the duration of {@link EntryAction#accept}, then deletes it. + * + *

{@link #close()} is a no-op: temps are owned per entry inside {@link #forEachEntry}. + * The type implements {@link AutoCloseable} so callers can use one try-with-resources + * pattern for all {@link IRemediationsFprSource} implementations. + */ +public final class SscOnlineRemediationsFprSource implements IRemediationsFprSource { + private final UnirestInstance unirest; + private final IAviatorLogger logger; + private final IProgressWriter progressWriter; + private final List artifacts; + + public SscOnlineRemediationsFprSource( + UnirestInstance unirest, + IAviatorLogger logger, + IProgressWriter progressWriter, + List artifacts) { + FcliSimpleException.throwIf(artifacts == null || artifacts.isEmpty(), + "No SSC artifacts to apply remediations from"); + this.unirest = unirest; + this.logger = logger; + this.progressWriter = progressWriter; + this.artifacts = List.copyOf(artifacts); + } + + @Override + public void forEachEntry(EntryAction action) { + int total = artifacts.size(); + for (int i = 0; i < total; i++) { + SSCArtifactDescriptor artifact = artifacts.get(i); + String id = artifact.getId(); + String label = "artifact id=" + id; + try (AviatorTempFprFile tempFpr = AviatorTempFprFile.create(id)) { + logger.progress("Status: Downloading Audited FPR from SSC (artifact id=" + id + ")"); + SSCFileTransferHelper.download( + unirest, + SSCUrls.DOWNLOAD_ARTIFACT(id, true), + tempFpr.path(), + SSCFileTransferHelper.ISSCAddDownloadTokenFunction.ROUTEPARAM_DOWNLOADTOKEN, + progressWriter); + if (!action.accept(tempFpr.path(), label, id, i + 1, total)) { + break; + } + } + } + } + + @Override + public void close() { + // Per-entry temps are closed in forEachEntry; nothing retained on this instance. + } +} diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java index 038d966db18..4e2b9608c0b 100644 --- a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java @@ -18,91 +18,46 @@ 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._common.util.AviatorRemediationMetricsHelper; -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; +/** + * CLI wiring and product rules for apply-remediations (not metric/util logic). + */ 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 { + void fromCacheParsesPath() throws Exception { AviatorSSCApplyRemediationsCommand command = parse("--from-cache", "remediations.zip"); assertEquals(Path.of("remediations.zip"), getSourceMixin(command).getFromCache()); assertTrue(getSourceMixin(command).isFromCacheSelected()); } @Test - void testFromCacheCannotBeCombinedWithArtifactId() { + void fromCacheCannotBeCombinedWithArtifactId() { assertThrows(CommandLine.ParameterException.class, () -> parse("--artifact-id", "1", "--from-cache", "cache.zip")); } @Test - void testIssueIdsWithoutFromCacheThrowsException() { + void issueIdsRequireFromCache() { 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 = AviatorRemediationMetricsHelper.aggregateMetrics( - Set.of("ISSUE-1", "ISSUE-2"), List.of(metricOne, metricTwo)); - - assertEquals(2, aggregated.totalRemediations()); - assertEquals(2, aggregated.appliedRemediations()); - assertEquals(0, aggregated.skippedRemediations()); - assertEquals(Set.of("ISSUE-1", "ISSUE-2"), aggregated.appliedIssueIds()); - assertEquals(Set.of("A.java", "B.java"), aggregated.modifiedFiles()); - } - - @Test - void testGetRemainingIssueIdsRemovesAlreadyAppliedIds() { - RemediationMetric metric = RemediationMetric.filtered(Set.of("ISSUE-1", "ISSUE-2"), Set.of("ISSUE-1"), Set.of("A.java")); - - Set remainingIssueIds = AviatorRemediationMetricsHelper.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 { + 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-fod/src/main/java/com/fortify/cli/fod/aviator/cli/mixin/FoDAviatorApplyRemediationsSourceMixin.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cli/mixin/FoDAviatorApplyRemediationsSourceMixin.java new file mode 100644 index 00000000000..55f8b3c34b7 --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cli/mixin/FoDAviatorApplyRemediationsSourceMixin.java @@ -0,0 +1,72 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.fod.aviator.cli.mixin; + +import java.nio.file.Path; + +import com.fortify.cli.fod._common.cli.mixin.FoDDelimiterMixin; +import com.fortify.cli.fod._common.cli.mixin.IFoDDelimiterMixinAware; +import com.fortify.cli.fod.release.cli.mixin.FoDReleaseByQualifiedNameOrIdResolverMixin; +import com.fortify.cli.fod.release.helper.FoDReleaseDescriptor; + +import kong.unirest.UnirestInstance; +import lombok.Getter; +import picocli.CommandLine.ArgGroup; +import picocli.CommandLine.Option; + +/** + * Source selection for FoD apply-remediations: online release or local remediations cache zip. + * Standalone mixin (aligned with SSC apply source mixin layout). + */ +public final class FoDAviatorApplyRemediationsSourceMixin implements IFoDDelimiterMixinAware { + @ArgGroup(exclusive = true, multiplicity = "1") + private SourceArgGroup source = new SourceArgGroup(); + + @Override + public void setDelimiterMixin(FoDDelimiterMixin delimiterMixin) { + if (source != null && source.online != null) { + source.online.setDelimiterMixin(delimiterMixin); + } + } + + public boolean isFromCacheSelected() { + return source != null && source.fromCache != null; + } + + public Path getFromCache() { + return isFromCacheSelected() ? source.fromCache : null; + } + + public FoDReleaseDescriptor getReleaseDescriptor(UnirestInstance unirest) { + return source.online.getReleaseDescriptor(unirest); + } + + @Getter + static class SourceArgGroup { + @ArgGroup(exclusive = false, multiplicity = "1") + private OnlineReleaseArgGroup online = new OnlineReleaseArgGroup(); + + /** Shared description key: command-local option on an ArgGroup (default picocli key would use FQCN). */ + @Option(names = {"--from-cache"}, required = true, paramLabel = "", + descriptionKey = "fcli.fod.aviator.apply-remediations.from-cache") + private Path fromCache; + } + + /** Online release branch reusing the standard FoD release option wiring/resolution. */ + static class OnlineReleaseArgGroup + extends FoDReleaseByQualifiedNameOrIdResolverMixin.AbstractFoDQualifiedReleaseNameOrIdResolverMixin { + @Option(names = {"--release", "--rel"}, required = true, paramLabel = "id|app[:ms]:rel", + descriptionKey = "fcli.fod.release.resolver.name-or-id") + @Getter private String qualifiedReleaseNameOrId; + } +} 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 18e2cbbc7c8..8b4ea859ffe 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,7 +12,6 @@ */ package com.fortify.cli.fod.aviator.cmd; -import java.nio.file.Path; import java.util.List; import java.util.Set; @@ -20,14 +19,12 @@ import org.slf4j.LoggerFactory; import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheApplyHelper; -import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheApplyHelper.ApplyResult; +import com.fortify.cli.aviator._common.remediations_cache.CacheRemediationsFprSource; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsApplyHelper; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsApplyHelper.ApplyResult; import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheConstants; -import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheReader; import com.fortify.cli.aviator._common.util.AviatorIssueIdFilterUtils; -import com.fortify.cli.aviator._common.util.AviatorRemediationMetricsHelper; import com.fortify.cli.aviator.config.AviatorLoggerImpl; -import com.fortify.cli.aviator.fpr.processor.RemediationProcessor.RemediationMetric; 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; @@ -37,17 +34,14 @@ import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; import com.fortify.cli.common.progress.helper.IProgressWriter; import com.fortify.cli.fod._common.cli.mixin.FoDDelimiterMixin; -import com.fortify.cli.fod._common.cli.mixin.IFoDDelimiterMixinAware; import com.fortify.cli.fod._common.session.cli.mixin.FoDUnirestInstanceSupplierMixin; +import com.fortify.cli.fod.aviator.cli.mixin.FoDAviatorApplyRemediationsSourceMixin; import com.fortify.cli.fod.aviator.helper.AviatorFoDApplyRemediationsHelper; -import com.fortify.cli.fod.aviator.helper.AviatorFoDOnlineRemediationsApplier; -import com.fortify.cli.fod.release.cli.mixin.FoDReleaseByQualifiedNameOrIdResolverMixin; +import com.fortify.cli.fod.aviator.helper.FodOnlineRemediationsFprSource; import com.fortify.cli.fod.release.helper.FoDReleaseDescriptor; import kong.unirest.UnirestInstance; import lombok.Getter; -import lombok.SneakyThrows; -import picocli.CommandLine.ArgGroup; import picocli.CommandLine.Command; import picocli.CommandLine.Mixin; import picocli.CommandLine.Option; @@ -61,62 +55,14 @@ public class FoDAviatorApplyRemediationsCommand extends AbstractOutputCommand @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; @Mixin private FoDDelimiterMixin delimiterMixin; // Injected into sourceSelector @Mixin private FoDUnirestInstanceSupplierMixin unirestInstanceSupplier; - @Mixin private SourceMixin sourceSelector; + @Mixin private FoDAviatorApplyRemediationsSourceMixin sourceSelector; - @Option(names = {"--source-dir"}, descriptionKey = "fcli.fod.aviator.apply-remediations.source-dir") + @Option(names = {"--source-dir"}) private String sourceCodeDirectory = System.getProperty("user.dir"); - @Option(names = {"--issue-ids"}, split = ",", descriptionKey = "fcli.fod.aviator.apply-remediations.issue-ids") + @Option(names = {"--issue-ids"}, split = ",") private List issueIds; - /** - * Exclusive source selection: online FoD release (via standard release resolver) or local cache zip. - * Propagates {@link FoDDelimiterMixin} into the nested release resolver, matching FoD patterns such as - * {@code FoDAppOrReleaseMixin}. - */ - public static final class SourceMixin implements IFoDDelimiterMixinAware { - @ArgGroup(exclusive = true, multiplicity = "1") - private SourceArgGroup source = new SourceArgGroup(); - - @Override - public void setDelimiterMixin(FoDDelimiterMixin delimiterMixin) { - if (source != null && source.online != null) { - source.online.setDelimiterMixin(delimiterMixin); - } - } - - public boolean isFromCacheSelected() { - return source != null && source.fromCache != null; - } - - public Path getFromCache() { - return isFromCacheSelected() ? source.fromCache : null; - } - - public FoDReleaseDescriptor getReleaseDescriptor(UnirestInstance unirest) { - return source.online.getReleaseDescriptor(unirest); - } - } - - @Getter - static class SourceArgGroup { - @ArgGroup(exclusive = false, multiplicity = "1") - private OnlineReleaseArgGroup online = new OnlineReleaseArgGroup(); - - @Option(names = {"--from-cache"}, required = true, paramLabel = "", - descriptionKey = "fcli.fod.aviator.apply-remediations.from-cache") - private Path fromCache; - } - - /** Online release branch reusing the standard FoD release option wiring/resolution. */ - static class OnlineReleaseArgGroup - extends FoDReleaseByQualifiedNameOrIdResolverMixin.AbstractFoDQualifiedReleaseNameOrIdResolverMixin { - @Option(names = {"--release", "--rel"}, required = true, paramLabel = "id|app[:ms]:rel", - descriptionKey = "fcli.fod.release.resolver.name-or-id") - @Getter private String qualifiedReleaseNameOrId; - } - @Override - @SneakyThrows public JsonNode getJsonNode() { validateSourceCodeDirectory(); Set issueIdFilter = AviatorIssueIdFilterUtils.normalizeIssueIds(issueIds); @@ -127,53 +73,43 @@ public JsonNode getJsonNode() { if (sourceSelector.isFromCacheSelected()) { return processFromCache(logger, issueIdFilter); } - UnirestInstance unirest = unirestInstanceSupplier.getUnirestInstance(); - return AviatorFoDOnlineRemediationsApplier.apply( - unirest, - sourceSelector.getReleaseDescriptor(unirest), - sourceCodeDirectory, - logger, - issueIdFilter); + return processOnline(logger, issueIdFilter); + } + } + + private JsonNode processOnline(AviatorLoggerImpl logger, Set issueIdFilter) { + UnirestInstance unirest = unirestInstanceSupplier.getUnirestInstance(); + FoDReleaseDescriptor release = sourceSelector.getReleaseDescriptor(unirest); + try (FodOnlineRemediationsFprSource source = + new FodOnlineRemediationsFprSource(unirest, logger, release)) { + ApplyResult applyResult = RemediationsApplyHelper.apply( + source, sourceCodeDirectory, logger, issueIdFilter, LOG); + return AviatorFoDApplyRemediationsHelper.buildOnlineResultNode(release, applyResult); } } private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdFilter) { - Path cacheZip = sourceSelector.getFromCache(); - try (RemediationsCacheReader cacheReader = RemediationsCacheReader.open(cacheZip)) { - ApplyResult applyResult = RemediationsCacheApplyHelper.applyEntries( - cacheReader, - RemediationsCacheConstants.PRODUCT_FOD, - sourceCodeDirectory, - logger, - issueIdFilter, - RemediationsCacheApplyHelper.EntryIdKind.RELEASE_ID, - LOG); - RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( - issueIdFilter, applyResult.metrics()); + try (CacheRemediationsFprSource source = CacheRemediationsFprSource.open( + sourceSelector.getFromCache(), + RemediationsCacheConstants.PRODUCT_FOD, + CacheRemediationsFprSource.IdKind.RELEASE_ID)) { + ApplyResult applyResult = RemediationsApplyHelper.apply( + source, sourceCodeDirectory, logger, issueIdFilter, LOG); return AviatorFoDApplyRemediationsHelper.buildCacheResultNode( - new AviatorFoDApplyRemediationsHelper.CacheResultData( - cacheZip, - applyResult.processedEntries(), - applyResult.processedIds(), - aggregated.totalRemediations(), - aggregated.appliedRemediations(), - aggregated.skippedRemediations(), - aggregated.modifiedFiles(), - RemediationsCacheApplyHelper.actionLabel(aggregated))); + sourceSelector.getFromCache(), applyResult, issueIdFilter); } } private void validateSourceCodeDirectory() { - if (sourceCodeDirectory == null || sourceCodeDirectory.isBlank()) { - throw new FcliSimpleException("--source-dir must specify a valid directory path"); - } + FcliSimpleException.throwIf(sourceCodeDirectory == null || sourceCodeDirectory.isBlank(), + "--source-dir must specify a valid directory path"); } private void validateSelection() { - 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"); - } + FcliSimpleException.throwIf( + issueIds != null && !issueIds.isEmpty() && !sourceSelector.isFromCacheSelected(), + "--issue-ids can only be used with --from-cache; " + + "create a cache with download-remediations-cache and rerun with --from-cache"); } @Override 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 index abe84f5bfce..c43df5b5080 100644 --- 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 @@ -38,7 +38,6 @@ import kong.unirest.UnirestInstance; import lombok.Getter; -import lombok.SneakyThrows; import picocli.CommandLine.Command; import picocli.CommandLine.Mixin; import picocli.CommandLine.Option; @@ -51,12 +50,10 @@ public class FoDAviatorDownloadRemediationsCacheCommand extends AbstractFoDJsonN @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") + @Option(names = {"-f", "--file"}, required = true, paramLabel = "") private File outputFile; @Override - @SneakyThrows public JsonNode getJsonNode(UnirestInstance unirest) { Path destination = outputFile.toPath(); if (Files.exists(destination)) { 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 4d0fa0932d7..e24d65db2c1 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 @@ -18,50 +18,82 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsApplyHelper; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsApplyHelper.ApplyResult; +import com.fortify.cli.aviator._common.util.AviatorRemediationMetricsHelper; +import com.fortify.cli.aviator.fpr.processor.RemediationProcessor.RemediationMetric; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; import com.fortify.cli.fod.release.helper.FoDReleaseDescriptor; +import lombok.Builder; + /** * 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) { - ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); - result.put("releaseId", rd.getReleaseId()); - result.put("applicationName", rd.getApplicationName()); - result.put("releaseName", rd.getReleaseName()); - result.put("totalRemediation", totalRemediation); - result.put("appliedRemediation", appliedRemediation); - result.put("skippedRemediation", skippedRemediation); - result.put(IActionCommandResultSupplier.actionFieldName, action); - return result; + /** Online apply outcome (success or soft-skip) → structured result node. */ + public static ObjectNode buildOnlineResultNode(FoDReleaseDescriptor releaseDescriptor, ApplyResult applyResult) { + if (applyResult.metrics().isEmpty()) { + return toOnlineJson(OnlineResultData.builder() + .releaseDescriptor(releaseDescriptor) + .totalRemediation(0) + .appliedRemediation(0) + .skippedRemediation(0) + .modifiedFiles(Set.of()) + .action(RemediationsApplyHelper.actionLabel(null)) + .build()); + } + RemediationMetric metric = applyResult.metrics().get(0); + return toOnlineJson(OnlineResultData.builder() + .releaseDescriptor(releaseDescriptor) + .totalRemediation(metric.totalRemediations()) + .appliedRemediation(metric.appliedRemediations()) + .skippedRemediation(metric.skippedRemediations()) + .modifiedFiles(metric.modifiedFiles()) + .action(RemediationsApplyHelper.actionLabel(metric)) + .build()); } - public static ObjectNode buildResultNode(FoDReleaseDescriptor rd, int totalRemediation, int appliedRemediation, - int skippedRemediation, Set modifiedFiles, String action) { + /** + * Aggregates metrics and builds the cache apply result JSON (same fields as before). + */ + public static ObjectNode buildCacheResultNode( + Path cacheZip, ApplyResult applyResult, Set issueIdFilter) { + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + issueIdFilter, applyResult.metrics()); + return toCacheJson(CacheResultData.builder() + .cacheZip(cacheZip) + .entryPaths(applyResult.processedEntries()) + .releaseIds(applyResult.processedIds()) + .totalRemediation(aggregated.totalRemediations()) + .appliedRemediation(aggregated.appliedRemediations()) + .skippedRemediation(aggregated.skippedRemediations()) + .modifiedFiles(aggregated.modifiedFiles()) + .action(RemediationsApplyHelper.actionLabel(aggregated)) + .build()); + } + + private static ObjectNode toOnlineJson(OnlineResultData data) { + FoDReleaseDescriptor rd = data.releaseDescriptor(); ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); result.put("releaseId", rd.getReleaseId()); result.put("applicationName", rd.getApplicationName()); result.put("releaseName", rd.getReleaseName()); - result.put("totalRemediation", totalRemediation); - result.put("appliedRemediation", appliedRemediation); - result.put("skippedRemediation", skippedRemediation); - result.set("modifiedFiles", toArrayNode(modifiedFiles)); - result.put(IActionCommandResultSupplier.actionFieldName, action); + result.put("totalRemediation", data.totalRemediation()); + result.put("appliedRemediation", data.appliedRemediation()); + result.put("skippedRemediation", data.skippedRemediation()); + result.set("modifiedFiles", toArrayNode(data.modifiedFiles())); + result.put(IActionCommandResultSupplier.actionFieldName, data.action()); 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) { + private static ObjectNode toCacheJson(CacheResultData resultData) { ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); - result.put("releaseId", resultData.releaseIds() != null && !resultData.releaseIds().isEmpty() ? resultData.releaseIds().get(0) : "N/A"); + 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()); @@ -75,7 +107,17 @@ public static ObjectNode buildCacheResultNode(CacheResultData resultData) { return result; } - public record CacheResultData( + @Builder + private record OnlineResultData( + FoDReleaseDescriptor releaseDescriptor, + int totalRemediation, + int appliedRemediation, + int skippedRemediation, + Set modifiedFiles, + String action) {} + + @Builder + private record CacheResultData( Path cacheZip, List entryPaths, List releaseIds, diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/AviatorFoDOnlineRemediationsApplier.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/AviatorFoDOnlineRemediationsApplier.java deleted file mode 100644 index f3885e8a803..00000000000 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/AviatorFoDOnlineRemediationsApplier.java +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2021-2026 Open Text. - * - * The only warranties for products and services of Open Text - * and its affiliates and licensors ("Open Text") are as may - * be set forth in the express warranty statements accompanying - * such products and services. Nothing herein should be construed - * as constituting an additional warranty. Open Text shall not be - * liable for technical or editorial errors or omissions contained - * herein. The information contained herein is subject to change - * without notice. - */ -package com.fortify.cli.fod.aviator.helper; - -import java.io.IOException; -import java.util.Set; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheApplyHelper; -import com.fortify.cli.aviator._common.util.AviatorTempFprFile; -import com.fortify.cli.aviator.applyRemediation.ApplyAutoRemediationOnSource; -import com.fortify.cli.aviator.config.IAviatorLogger; -import com.fortify.cli.aviator.fpr.processor.RemediationProcessor.RemediationMetric; -import com.fortify.cli.aviator.util.FprHandle; -import com.fortify.cli.common.exception.FcliTechnicalException; -import com.fortify.cli.fod.release.helper.FoDReleaseDescriptor; - -import kong.unirest.UnirestInstance; - -/** - * Online (non-cache) FoD apply-remediations: download audited FPR to a managed host temp path - * (required by {@link FprHandle}), apply remediations, delete the temp file. - */ -public final class AviatorFoDOnlineRemediationsApplier { - private static final Logger LOG = LoggerFactory.getLogger(AviatorFoDOnlineRemediationsApplier.class); - - private AviatorFoDOnlineRemediationsApplier() {} - - public static JsonNode apply( - UnirestInstance unirest, - FoDReleaseDescriptor releaseDescriptor, - String sourceCodeDirectory, - IAviatorLogger logger, - Set issueIdFilter) { - try (AviatorTempFprFile tempFpr = AviatorTempFprFile.create(releaseDescriptor.getReleaseId())) { - logger.progress("Status: Downloading Audited FPR from FOD"); - FoDRemediationsFprDownloadHelper.downloadStaticRemediationsFpr( - unirest, releaseDescriptor, tempFpr.path()); - - logger.progress("Status: Processing FPR with Aviator for Applying Auto Remediations"); - try (FprHandle fprHandle = new FprHandle(tempFpr.path())) { - RemediationMetric metric = ApplyAutoRemediationOnSource.applyRemediations( - fprHandle, sourceCodeDirectory, logger, issueIdFilter); - LOG.info("Applied remediation {}", metric.appliedRemediations()); - LOG.info("Total remediation {}", metric.totalRemediations()); - return AviatorFoDApplyRemediationsHelper.buildResultNode( - releaseDescriptor, - metric.totalRemediations(), - metric.appliedRemediations(), - metric.skippedRemediations(), - metric.modifiedFiles(), - RemediationsCacheApplyHelper.actionLabel(metric)); - } catch (IOException e) { - throw new FcliTechnicalException( - "Failed to close FPR handle for FoD release " + releaseDescriptor.getReleaseId(), e); - } - } - } -} diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/FodOnlineRemediationsFprSource.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/FodOnlineRemediationsFprSource.java new file mode 100644 index 00000000000..1eafaebf9b7 --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/FodOnlineRemediationsFprSource.java @@ -0,0 +1,62 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.fod.aviator.helper; + +import com.fortify.cli.aviator._common.remediations_cache.IRemediationsFprSource; +import com.fortify.cli.aviator._common.util.AviatorTempFprFile; +import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.fod.release.helper.FoDReleaseDescriptor; + +import kong.unirest.UnirestInstance; + +/** + * Online FoD remediations source: downloads the release FPR to a managed temp path + * for the duration of {@link EntryAction#accept}, then deletes it. + * + *

{@link #close()} is a no-op: temps are owned per entry inside {@link #forEachEntry}. + * Implements {@link AutoCloseable} so callers share one try-with-resources pattern with + * cache sources. + */ +public final class FodOnlineRemediationsFprSource implements IRemediationsFprSource { + private final UnirestInstance unirest; + private final IAviatorLogger logger; + private final FoDReleaseDescriptor releaseDescriptor; + + public FodOnlineRemediationsFprSource( + UnirestInstance unirest, + IAviatorLogger logger, + FoDReleaseDescriptor releaseDescriptor) { + this.unirest = unirest; + this.logger = logger; + this.releaseDescriptor = releaseDescriptor; + } + + @Override + public void forEachEntry(EntryAction action) { + String id = releaseDescriptor.getReleaseId(); + String label = "release id=" + id; + try (AviatorTempFprFile tempFpr = AviatorTempFprFile.create(id)) { + logger.progress("Status: Downloading Audited FPR from FOD"); + FoDRemediationsFprDownloadHelper.downloadStaticRemediationsFpr( + unirest, releaseDescriptor, tempFpr.path()); + if (!action.accept(tempFpr.path(), label, id, 1, 1)) { + return; + } + } + } + + @Override + public void close() { + // Per-entry temps are closed in forEachEntry; nothing retained on this instance. + } +} diff --git a/fcli-core/fcli-fod/src/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 c6360db2d79..f1dedd1956e 100644 --- a/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorApplyRemediationsCommandTest.java +++ b/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorApplyRemediationsCommandTest.java @@ -13,115 +13,55 @@ package com.fortify.cli.fod.aviator; 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.cli.mixin.FoDAviatorApplyRemediationsSourceMixin; import com.fortify.cli.fod.aviator.cmd.FoDAviatorApplyRemediationsCommand; import picocli.CommandLine; +/** + * CLI wiring and product rules for FoD apply-remediations (not default-field or util tests). + */ class FoDAviatorApplyRemediationsCommandTest { - @Test - void testSourceCodeDirectoryHasDefaultValue() throws Exception { - FoDAviatorApplyRemediationsCommand command = new FoDAviatorApplyRemediationsCommand(); - - Field field = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("sourceCodeDirectory"); - field.setAccessible(true); - String fieldValue = (String) field.get(command); - - assertNotNull(fieldValue, - "sourceCodeDirectory must have default value to prevent NPE when --source-dir not specified"); - - assertEquals(System.getProperty("user.dir"), fieldValue, - "sourceCodeDirectory default should be current working directory"); - } - - @Test - void testSourceCodeDirectoryCanBeOverridden() throws Exception { - FoDAviatorApplyRemediationsCommand command = new FoDAviatorApplyRemediationsCommand(); - - Field field = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("sourceCodeDirectory"); - field.setAccessible(true); - - String customPath = "/custom/source/directory"; - field.set(command, customPath); - - String fieldValue = (String) field.get(command); - - assertEquals(customPath, fieldValue, - "sourceCodeDirectory should be overridable when --source-dir option is provided"); - } @Test - void testBlankSourceCodeDirectoryThrowsException() throws Exception { - FoDAviatorApplyRemediationsCommand command = parse("--from-cache", "cache.zip"); - - Field field = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("sourceCodeDirectory"); - field.setAccessible(true); - field.set(command, ""); - - assertThrows(FcliSimpleException.class, command::getJsonNode, - "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 { + void fromCacheParsesPath() throws Exception { FoDAviatorApplyRemediationsCommand command = parse("--from-cache", "remediations.zip"); Field sourceSelectorField = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("sourceSelector"); sourceSelectorField.setAccessible(true); - FoDAviatorApplyRemediationsCommand.SourceMixin sourceSelector = - (FoDAviatorApplyRemediationsCommand.SourceMixin) sourceSelectorField.get(command); + FoDAviatorApplyRemediationsSourceMixin sourceSelector = + (FoDAviatorApplyRemediationsSourceMixin) sourceSelectorField.get(command); assertEquals(Path.of("remediations.zip"), sourceSelector.getFromCache()); assertTrue(sourceSelector.isFromCacheSelected()); } @Test - void testReleaseAndFromCacheTogetherThrowsException() { + void releaseAndFromCacheAreExclusive() { assertThrows(CommandLine.ParameterException.class, () -> parse("--release", "1", "--from-cache", "local.zip")); } @Test - void testIssueIdsWithoutFromCacheThrowsException() { + void issueIdsRequireFromCache() { 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); + void blankSourceDirIsRejected() throws Exception { + FoDAviatorApplyRemediationsCommand command = parse("--from-cache", "cache.zip"); + Field field = FoDAviatorApplyRemediationsCommand.class.getDeclaredField("sourceCodeDirectory"); + field.setAccessible(true); + field.set(command, ""); + assertThrows(FcliSimpleException.class, command::getJsonNode); } private static FoDAviatorApplyRemediationsCommand parse(String... args) { @@ -129,11 +69,4 @@ private static FoDAviatorApplyRemediationsCommand parse(String... args) { 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 index e771801a853..37b9dde1028 100644 --- 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 @@ -22,25 +22,18 @@ class FoDAviatorDownloadRemediationsCacheCommandTest { @Test - void testReleaseIsRequired() { - FoDAviatorDownloadRemediationsCacheCommand command = new FoDAviatorDownloadRemediationsCacheCommand(); - + void releaseIsRequired() { assertThrows(CommandLine.ParameterException.class, - () -> new CommandLine(command).parseArgs("-f", "cache.zip")); + () -> parse("-f", "cache.zip")); } @Test - void testFileIsRequired() { - FoDAviatorDownloadRemediationsCacheCommand command = new FoDAviatorDownloadRemediationsCacheCommand(); - + void fileIsRequired() { assertThrows(CommandLine.ParameterException.class, - () -> new CommandLine(command).parseArgs("--release", "1")); + () -> parse("--release", "1")); } - @Test - void testReleaseWithFileOptionParses() { - FoDAviatorDownloadRemediationsCacheCommand command = new FoDAviatorDownloadRemediationsCacheCommand(); - - new CommandLine(command).parseArgs("--release", "1", "-f", "remediations.zip"); + private static void parse(String... args) { + new CommandLine(new FoDAviatorDownloadRemediationsCacheCommand()).parseArgs(args); } } From bbd5676a74ca18606fb09aa38444025472c2c16c Mon Sep 17 00:00:00 2001 From: kireetivar Date: Thu, 23 Jul 2026 11:30:27 +0530 Subject: [PATCH 15/18] chore: refactor source argument group for apply-remediations to use required option mixin --- .../mixin/FoDAviatorApplyRemediationsSourceMixin.java | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cli/mixin/FoDAviatorApplyRemediationsSourceMixin.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cli/mixin/FoDAviatorApplyRemediationsSourceMixin.java index 55f8b3c34b7..d37186bce89 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cli/mixin/FoDAviatorApplyRemediationsSourceMixin.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cli/mixin/FoDAviatorApplyRemediationsSourceMixin.java @@ -54,19 +54,12 @@ public FoDReleaseDescriptor getReleaseDescriptor(UnirestInstance unirest) { @Getter static class SourceArgGroup { @ArgGroup(exclusive = false, multiplicity = "1") - private OnlineReleaseArgGroup online = new OnlineReleaseArgGroup(); + private FoDReleaseByQualifiedNameOrIdResolverMixin.RequiredOption online = + new FoDReleaseByQualifiedNameOrIdResolverMixin.RequiredOption(); /** Shared description key: command-local option on an ArgGroup (default picocli key would use FQCN). */ @Option(names = {"--from-cache"}, required = true, paramLabel = "", descriptionKey = "fcli.fod.aviator.apply-remediations.from-cache") private Path fromCache; } - - /** Online release branch reusing the standard FoD release option wiring/resolution. */ - static class OnlineReleaseArgGroup - extends FoDReleaseByQualifiedNameOrIdResolverMixin.AbstractFoDQualifiedReleaseNameOrIdResolverMixin { - @Option(names = {"--release", "--rel"}, required = true, paramLabel = "id|app[:ms]:rel", - descriptionKey = "fcli.fod.release.resolver.name-or-id") - @Getter private String qualifiedReleaseNameOrId; - } } From 95870a50aedcb1a8e829373823ca0842e7428e20 Mon Sep 17 00:00:00 2001 From: kireetivar Date: Fri, 24 Jul 2026 00:31:20 +0530 Subject: [PATCH 16/18] chore: simplify remediations cache writer and product entry APIs --- .../RemediationsCacheWriter.java | 271 ++++++++---------- .../RemediationsCacheRoundTripTest.java | 40 ++- .../AviatorSSCApplyRemediationsCommand.java | 4 +- ...orSSCDownloadRemediationsCacheCommand.java | 18 +- ...viatorSSCApplyRemediationsSourceMixin.java | 40 +-- ...emediationsCacheDownloadSelectorMixin.java | 45 +-- ...iatorDownloadRemediationsCacheCommand.java | 5 +- 7 files changed, 171 insertions(+), 252 deletions(-) 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 index 6fbfae9767c..afb2704e53b 100644 --- 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 @@ -27,7 +27,6 @@ import org.slf4j.LoggerFactory; import com.fortify.cli.common.exception.AbstractFcliException; -import com.fortify.cli.common.exception.FcliBugException; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.exception.FcliTechnicalException; import com.fortify.cli.common.json.JsonHelper; @@ -36,99 +35,107 @@ /** * Builds a remediations cache zip at a destination path. FPR content is written * directly into a ZipFS (no per-FPR temp staging). The ZipFS is opened on a sibling - * {@code *.partial} work file; on successful {@link #finish()} + {@link #close()}, the - * work file is moved onto {@code destination} (atomic when the filesystem supports it), - * so an existing destination is not replaced until the archive is complete. + * {@code *.partial} work file; on successful {@link #close()}, the work file is moved + * onto {@code destination} (atomic when the filesystem supports it). * - *

{@link #close()} is the sole owner of zip close, work-file cleanup, and publish. + *

Use with try-with-resources. {@link #close()} writes the manifest (when entries + * exist), closes ZipFS, and publishes or discards the work file. */ public final class RemediationsCacheWriter implements AutoCloseable { private static final Logger logger = LoggerFactory.getLogger(RemediationsCacheWriter.class); private static final String PARTIAL_SUFFIX = ".partial"; private final Path destination; - /** Sibling work path where ZipFS content is written before publish. */ private final Path workPath; - /** May be null if initialization failed; {@link #close()} still cleans workPath. */ private final FileSystem zipFs; private final RemediationsCacheManifest manifest; - private final Exception initError; private int nextOrder = 1; - /** True after manifest.json has been written into the open ZipFS (not yet durable until close+publish). */ - private boolean manifestWritten; - private boolean closed; /** - * Always constructs an instance so {@link #close()} remains the only cleanup path. - * Callers must use {@link #create(Path, String, Map)} which closes and rethrows on init failure. + * Opens ZipFS on a sibling work file. Exceptions from open are thrown immediately. + * Entry parent dirs (including {@code fprs/}) are created lazily in {@link #prepareEntryPath}. */ private RemediationsCacheWriter(Path destination, String product, Map selection) { this.destination = destination; this.workPath = workPathFor(destination); this.manifest = newManifest(product, selection); - FileSystem fs = null; - Exception error = null; - try { - // ZipFS on sibling work file; existing destination is left intact until publish. - fs = ZipHelper.createZipFileSystem(workPath); - Files.createDirectories(fs.getPath(RemediationsCacheConstants.FPRS_DIR)); - } catch (AbstractFcliException e) { - error = e; - } catch (IOException | RuntimeException e) { - error = e; - } - this.zipFs = fs; - this.initError = error; + this.zipFs = ZipHelper.createZipFileSystem(workPath); } /** - * Opens a work zip next to {@code destination} and prepares an empty manifest. - * On initialization failure, {@link #close()} runs before the exception is rethrown so - * cleanup is not split across factory and AutoCloseable paths. + * Opens a work zip next to {@code destination}. Use with try-with-resources. */ public static RemediationsCacheWriter create(Path destination, String product, Map selection) { FcliSimpleException.throwIf(destination == null, "-f/--file must specify a remediations cache zip path"); - RemediationsCacheWriter writer = new RemediationsCacheWriter(destination, product, selection); - if (writer.initError != null) { - Exception cause = writer.initError; - writer.close(); - if (cause instanceof AbstractFcliException fcliException) { - throw fcliException; - } - throw new FcliTechnicalException( - "Failed to initialize remediations cache zip: " + destination, cause); - } - return writer; + return new RemediationsCacheWriter(destination, product, selection); } /** * Convenience for callers that already have FPR files on disk (for example unit tests). - * Writes via ZipFS into a work file, then publishes to {@code destination}. + * Publishes on successful try-with-resources close. */ public static RemediationsCacheManifest write( Path destination, String product, Map selection, - List fprSources) { + List fprSources) { FcliSimpleException.throwIf(fprSources == null || fprSources.isEmpty(), "Cannot create remediations cache: no FPR files to include"); try (RemediationsCacheWriter writer = create(destination, product, selection)) { - for (FprSource source : fprSources) { - writer.addFprFromFile(source); + for (LocalFpr source : fprSources) { + if (source instanceof SscFpr ssc) { + writer.addFprFromFile(ssc); + } else if (source instanceof FodFpr fod) { + writer.addFprFromFile(fod); + } else { + throw new FcliTechnicalException("Unsupported local FPR type: " + source.getClass().getName()); + } } - return writer.finish(); + return writer.getManifest(); } } + /** In-memory manifest (complete after successful close with entries). */ + public RemediationsCacheManifest getManifest() { + return manifest; + } + + /** + * Writes an SSC artifact FPR into the next zip entry (for streaming downloads). + */ + public void addSscFpr(String artifactId, String uploadDate, Consumer contentWriter) { + int order = nextOrder++; + addEntry(order, sscEntryPath(order, artifactId), artifactId, null, uploadDate, contentWriter); + } + /** - * Allocates the next zip entry path, invokes {@code contentWriter} to populate it - * (for example download into the path), then records SHA-256 and manifest metadata. + * Writes a FoD release FPR into the next zip entry (for streaming downloads). */ - public void addFpr(String artifactId, String releaseId, String uploadDate, Consumer contentWriter) { - requireWritable(); + public void addFodFpr(String releaseId, Consumer contentWriter) { int order = nextOrder++; - String entryPath = entryPath(artifactId, releaseId, order); + addEntry(order, fodEntryPath(order, releaseId), null, releaseId, null, contentWriter); + } + + /** Copies an existing SSC FPR file into the cache zip and records its checksum. */ + public void addFprFromFile(SscFpr source) { + validateLocalPath(source.path()); + addSscFpr(source.artifactId(), source.uploadDate(), copyFrom(source.path())); + } + + /** Copies an existing FoD FPR file into the cache zip and records its checksum. */ + public void addFprFromFile(FodFpr source) { + validateLocalPath(source.path()); + addFodFpr(source.releaseId(), copyFrom(source.path())); + } + + private void addEntry( + int order, + String entryPath, + String artifactId, + String releaseId, + String uploadDate, + Consumer contentWriter) { try { Path target = prepareEntryPath(entryPath); writeEntryContent(target, entryPath, contentWriter); @@ -142,23 +149,6 @@ public void addFpr(String artifactId, String releaseId, String uploadDate, Consu } } - /** Copies an existing FPR file into the cache zip and records its checksum. */ - public void addFprFromFile(FprSource source) { - requireWritable(); - FcliSimpleException.throwIf(source == null || source.path() == null, - "FPR source path is required"); - FcliSimpleException.throwIf(!Files.isRegularFile(source.path()), - "FPR source is not a readable regular file: %s", source.path()); - addFpr(source.artifactId(), source.releaseId(), source.uploadDate(), target -> { - try { - Files.copy(source.path(), target, StandardCopyOption.REPLACE_EXISTING); - } catch (IOException e) { - throw new FcliTechnicalException("Failed to copy FPR into cache: " + source.path(), e); - } - }); - } - - /** Creates parent directories for a zip entry and returns its path. */ private Path prepareEntryPath(String entryPath) throws IOException { Path target = zipFs.getPath(entryPath); Path parent = target.getParent(); @@ -168,78 +158,67 @@ private Path prepareEntryPath(String entryPath) throws IOException { return target; } - /** Invokes {@code contentWriter} and ensures a regular file was produced at {@code target}. */ private static void writeEntryContent(Path target, String entryPath, Consumer contentWriter) { contentWriter.accept(target); FcliSimpleException.throwIf(!Files.isRegularFile(target), "Cache entry was not written: %s", entryPath); } - /** Hashes the entry and appends it to the in-memory manifest. */ private void recordEntry( int order, String entryPath, String artifactId, String releaseId, String uploadDate, Path target) { String sha256 = RemediationsCacheSha256.hashFile(target); - manifest.getEntries().add(toManifestEntry(order, entryPath, artifactId, releaseId, uploadDate, sha256)); + manifest.getEntries().add(RemediationsCacheEntry.of( + order, entryPath, artifactId, releaseId, uploadDate, sha256)); } /** - * Writes {@code manifest.json} into the open zip. Must be called before {@link #close()} - * for a successful cache. The archive is only published to {@code destination} after a - * successful {@link #close()}. - * Returns the completed manifest. + * Writes manifest when entries exist, closes ZipFS, then publishes the work file + * or deletes it if incomplete. Intended for try-with-resources (single close). */ - public RemediationsCacheManifest finish() { - requireWritable(); - FcliSimpleException.throwIf(manifest.getEntries().isEmpty(), - "Cannot create remediations cache: no FPR files to include"); + @Override + public void close() { try { - byte[] manifestBytes = JsonHelper.getObjectMapper().writerWithDefaultPrettyPrinter() - .writeValueAsBytes(manifest); - Files.write(zipFs.getPath(RemediationsCacheConstants.MANIFEST_ENTRY), manifestBytes); - manifestWritten = true; - return manifest; + if (zipFs.isOpen()) { + if (!manifest.getEntries().isEmpty()) { + writeManifest(); + } + zipFs.close(); + } } catch (AbstractFcliException e) { + deleteQuietly(workPath); throw e; } catch (IOException e) { - throw new FcliTechnicalException("Failed to write remediations cache manifest to " + destination, e); + deleteQuietly(workPath); + throw new FcliTechnicalException("Failed to finalize remediations cache zip: " + destination, e); } catch (RuntimeException e) { - throw new FcliTechnicalException("Failed to write remediations cache manifest to " + destination, e); + deleteQuietly(workPath); + throw new FcliTechnicalException("Failed to finalize remediations cache zip: " + destination, e); } - } - - /** - * Sole owner of zip filesystem close, work-file cleanup, and publish to destination. - * Destination is replaced only when finish() wrote the manifest and ZipFS closed cleanly. - */ - @Override - public void close() { - if (closed) { + // Guard with workPath existence so a second close is a no-op after publish/delete. + if (!Files.exists(workPath)) { return; } - closed = true; - boolean closeSucceeded = false; + if (!manifest.getEntries().isEmpty()) { + publishWorkFile(); + } else { + deleteQuietly(workPath); + } + } + + private void writeManifest() { try { - if (zipFs != null && zipFs.isOpen()) { - zipFs.close(); - } - closeSucceeded = true; + byte[] manifestBytes = JsonHelper.getObjectMapper().writerWithDefaultPrettyPrinter() + .writeValueAsBytes(manifest); + Files.write(zipFs.getPath(RemediationsCacheConstants.MANIFEST_ENTRY), manifestBytes); + } catch (AbstractFcliException e) { + throw e; } catch (IOException e) { - // ZipFS finalizes the archive on close; failure means work file is not trustworthy. - if (manifestWritten) { - deleteQuietly(workPath); - throw new FcliTechnicalException("Failed to finalize remediations cache zip: " + destination, e); - } - logger.warn("Failed to close remediations cache zip work file: {}", workPath, e); - } finally { - if (manifestWritten && closeSucceeded) { - publishWorkFile(); - } else { - deleteQuietly(workPath); - } + throw new FcliTechnicalException("Failed to write remediations cache manifest to " + destination, e); + } catch (RuntimeException e) { + throw new FcliTechnicalException("Failed to write remediations cache manifest to " + destination, e); } } - /** Moves the completed work zip onto {@link #destination}, preferring an atomic replace. */ private void publishWorkFile() { try { try { @@ -255,16 +234,20 @@ private void publishWorkFile() { } } - private void requireWritable() { - if (closed) { - throw new FcliBugException("RemediationsCacheWriter is already closed: " + destination); - } - if (initError != null) { - throw new FcliBugException("RemediationsCacheWriter failed to initialize: " + destination, initError); - } - if (manifestWritten) { - throw new FcliBugException("RemediationsCacheWriter is already finished: " + destination); - } + private static void validateLocalPath(Path path) { + FcliSimpleException.throwIf(path == null, "FPR source path is required"); + FcliSimpleException.throwIf(!Files.isRegularFile(path), + "FPR source is not a readable regular file: %s", path); + } + + private static Consumer copyFrom(Path source) { + return target -> { + try { + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + throw new FcliTechnicalException("Failed to copy FPR into cache: " + source, e); + } + }; } private static Path workPathFor(Path destination) { @@ -287,29 +270,18 @@ private static RemediationsCacheManifest newManifest(String product, Map { + writer.addFodFpr("rel-1", path -> { try { Files.writeString(path, "streamed-fpr"); } catch (IOException e) { throw new RuntimeException(e); } }); - writer.finish(); + // close() writes manifest and publishes } try (RemediationsCacheReader reader = RemediationsCacheReader.open(zip)) { reader.requireProduct(RemediationsCacheConstants.PRODUCT_FOD); @@ -90,21 +90,35 @@ void emptySourcesRejected() { } @Test - void incompleteWriteDoesNotReplaceExistingDestination() throws Exception { + void closeWithoutEntriesDoesNotReplaceExistingDestination() throws Exception { Path zip = tempDir.resolve("existing.zip"); Files.writeString(zip, "prior-cache-bytes"); + // open + close with no addFpr: work file discarded; destination untouched. try (RemediationsCacheWriter writer = RemediationsCacheWriter.create( zip, RemediationsCacheConstants.PRODUCT_SSC, Map.of("mode", "all"))) { - writer.addFpr("1", null, null, path -> { + // intentionally empty + } + assertEquals("prior-cache-bytes", Files.readString(zip)); + assertTrue(Files.notExists(zip.resolveSibling("existing.zip.partial"))); + } + + @Test + void closeWithEntriesPublishesAndReplacesDestination() throws Exception { + Path zip = tempDir.resolve("existing.zip"); + Files.writeString(zip, "prior-cache-bytes"); + try (RemediationsCacheWriter writer = RemediationsCacheWriter.create( + zip, RemediationsCacheConstants.PRODUCT_SSC, Map.of("mode", "all"))) { + writer.addSscFpr("1", null, path -> { try { - Files.writeString(path, "partial-fpr"); + Files.writeString(path, "new-fpr"); } catch (IOException e) { throw new RuntimeException(e); } }); - // Close without finish(): work file is discarded; destination stays intact. } - assertEquals("prior-cache-bytes", Files.readString(zip)); + try (RemediationsCacheReader reader = RemediationsCacheReader.open(zip)) { + assertEquals("new-fpr", Files.readString(reader.getOrderedFprPaths().get(0))); + } assertTrue(Files.notExists(zip.resolveSibling("existing.zip.partial"))); } @@ -121,7 +135,7 @@ void cachedFprCanBeOpenedAsNestedZipFileSystem() throws Exception { zip, RemediationsCacheConstants.PRODUCT_SSC, Map.of("mode", "artifact-id"), - List.of(RemediationsCacheWriter.FprSource.forSsc(fpr, "1", null))); + List.of(new RemediationsCacheWriter.SscFpr(fpr, "1", null))); try (RemediationsCacheReader reader = RemediationsCacheReader.open(zip); FprHandle fprHandle = new FprHandle(reader.getOrderedFprPaths().get(0))) { @@ -138,7 +152,7 @@ void badSha256RejectedOnLazyPathLoad() throws Exception { zip, RemediationsCacheConstants.PRODUCT_SSC, Map.of("mode", "artifact-id"), - List.of(RemediationsCacheWriter.FprSource.forSsc(fpr, "1", null))); + List.of(new RemediationsCacheWriter.SscFpr(fpr, "1", null))); Path corruptZip = tempDir.resolve("corrupt.zip"); try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(zip)); @@ -187,7 +201,7 @@ void requireProductRejectsMismatch() throws Exception { zip, RemediationsCacheConstants.PRODUCT_SSC, Map.of("mode", "artifact-id"), - List.of(RemediationsCacheWriter.FprSource.forSsc(fpr, "1", null))); + List.of(new RemediationsCacheWriter.SscFpr(fpr, "1", null))); assertThrows(FcliSimpleException.class, () -> { try (RemediationsCacheReader reader = RemediationsCacheReader.open(zip)) { @@ -205,7 +219,7 @@ void entryPathSanitizesUnsafeIdCharacters() throws Exception { zip, RemediationsCacheConstants.PRODUCT_SSC, Map.of("mode", "artifact-id"), - List.of(RemediationsCacheWriter.FprSource.forSsc(fpr, "12/../evil", null))); + List.of(new RemediationsCacheWriter.SscFpr(fpr, "12/../evil", null))); try (RemediationsCacheReader reader = RemediationsCacheReader.open(zip)) { var resolved = reader.getOrderedResolvedFprs(); 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 0c5b14611fd..da63c572a32 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 @@ -96,9 +96,9 @@ private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdF private JsonNode processOnline( AviatorLoggerImpl logger, IProgressWriter progressWriter, Set issueIdFilter) { UnirestInstance unirest = unirestInstanceSupplier.getUnirestInstance(); - OffsetDateTime sinceDate = SinceOptionHelper.parse(sourceSelector.getSince()); + OffsetDateTime sinceDate = SinceOptionHelper.parse(sourceSelector.getOnline().getSince()); // One resolve: artifacts + appVersionId (no second getAppVersionId REST call). - ResolvedOnlineArtifacts resolved = sourceSelector.resolveArtifacts(unirest, sinceDate); + ResolvedOnlineArtifacts resolved = sourceSelector.getOnline().resolveArtifacts(unirest, sinceDate); try (SscOnlineRemediationsFprSource source = new SscOnlineRemediationsFprSource( unirest, logger, progressWriter, resolved.artifacts())) { ApplyResult applyResult = RemediationsApplyHelper.apply( 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 index 269d92651a7..f0d63d95127 100644 --- 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 @@ -57,15 +57,15 @@ public class AviatorSSCDownloadRemediationsCacheCommand extends AbstractSSCJsonN @Override public JsonNode getJsonNode(UnirestInstance unirest) { - artifactSelector.validate(); + artifactSelector.getOnlineSelection().validate(); Path destination = outputFile.toPath(); if (Files.exists(destination)) { requireConfirmation.checkConfirmed(destination); } - OffsetDateTime sinceDate = SinceOptionHelper.parse(artifactSelector.getSince()); + OffsetDateTime sinceDate = SinceOptionHelper.parse(artifactSelector.getOnlineSelection().getSince()); // One resolve: artifacts + appVersionId (shared with apply-remediations). - var resolved = artifactSelector.resolveArtifacts(unirest, sinceDate); + var resolved = artifactSelector.getOnlineSelection().resolveArtifacts(unirest, sinceDate); Map selection = buildSelectionMetadata(resolved.appVersionId(), sinceDate); try (IProgressWriter progressWriter = progressWriterFactoryMixin.create(); @@ -73,19 +73,21 @@ public JsonNode getJsonNode(UnirestInstance unirest) { destination, RemediationsCacheConstants.PRODUCT_SSC, selection)) { AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); for (SSCArtifactDescriptor artifact : resolved.artifacts()) { - cacheWriter.addFpr(artifact.getId(), null, artifact.getUploadDate(), entryPath -> + cacheWriter.addSscFpr(artifact.getId(), artifact.getUploadDate(), entryPath -> downloadArtifact(unirest, artifact, entryPath, logger, progressWriter)); } - RemediationsCacheManifest manifest = cacheWriter.finish(); + // close() writes manifest and publishes; capture entry count before close. + RemediationsCacheManifest manifest = cacheWriter.getManifest(); return buildResultNode(destination, manifest); } } private Map buildSelectionMetadata(String appVersionId, OffsetDateTime sinceDate) { Map selection = new LinkedHashMap<>(); - selection.put("mode", artifactSelector.getSelectionMode()); - if (artifactSelector.isArtifactIdSelected()) { - selection.put("artifactId", artifactSelector.getArtifactId()); + var online = artifactSelector.getOnlineSelection(); + selection.put("mode", online.getSelectionMode()); + if (online.isArtifactIdSelected()) { + selection.put("artifactId", online.getArtifactId()); } else if (appVersionId != null) { selection.put("appVersionId", appVersionId); } 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 index c4b043c1da5..bd002c31ee8 100644 --- 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 @@ -13,18 +13,17 @@ package com.fortify.cli.aviator.ssc.cli.mixin; import java.nio.file.Path; -import java.time.OffsetDateTime; import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCRemediationsSelectorArgGroups.OnlineSelectionArgGroup; import com.fortify.cli.common.exception.FcliSimpleException; -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. + * Online selection is the shared {@link OnlineSelectionArgGroup} (no pass-through accessors). */ @Getter public class AviatorSSCApplyRemediationsSourceMixin { @@ -55,40 +54,9 @@ public Path getFromCache() { return isFromCacheSelected() ? source.fromCache : null; } - public boolean isArtifactIdSelected() { - return isOnlineSelected() && source.online.isArtifactIdSelected(); - } - - public boolean isLatestSelected() { - return isOnlineSelected() && source.online.isLatestSelected(); - } - - public boolean isAllSelected() { - return isOnlineSelected() && source.online.isAllSelected(); - } - - public String getArtifactId() { - return isOnlineSelected() ? source.online.getArtifactId() : null; - } - - public String getSince() { - return isOnlineSelected() ? source.online.getSince() : null; - } - - public String getAppVersionNameOrId() { - return isOnlineSelected() ? source.online.getAppVersionNameOrId() : null; - } - - public String getAppVersionId(UnirestInstance unirest) { - return isOnlineSelected() ? source.online.getAppVersionId(unirest) : null; - } - - /** Online only; same resolution as download-remediations-cache. */ - public OnlineSelectionArgGroup.ResolvedOnlineArtifacts resolveArtifacts( - UnirestInstance unirest, OffsetDateTime sinceDate) { - FcliSimpleException.throwIf(!isOnlineSelected(), - "Online artifact selection is required (not --from-cache)"); - return source.online.resolveArtifacts(unirest, sinceDate); + /** Online ArgGroup when online mode is selected; null for --from-cache. */ + public OnlineSelectionArgGroup getOnline() { + return isOnlineSelected() ? source.online : null; } public void validate() { diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsCacheDownloadSelectorMixin.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsCacheDownloadSelectorMixin.java index b37622794c3..dfe0e113e8e 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsCacheDownloadSelectorMixin.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsCacheDownloadSelectorMixin.java @@ -12,55 +12,16 @@ */ package com.fortify.cli.aviator.ssc.cli.mixin; -import java.time.OffsetDateTime; - import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCRemediationsSelectorArgGroups.OnlineSelectionArgGroup; -import kong.unirest.UnirestInstance; import lombok.Getter; import picocli.CommandLine.ArgGroup; +/** + * Download-remediations-cache selector: thin wrapper over shared {@link OnlineSelectionArgGroup}. + */ @Getter public class AviatorSSCRemediationsCacheDownloadSelectorMixin { @ArgGroup(exclusive = false, multiplicity = "1") private OnlineSelectionArgGroup onlineSelection; - - public boolean isArtifactIdSelected() { - return onlineSelection != null && onlineSelection.isArtifactIdSelected(); - } - - public boolean isLatestSelected() { - return onlineSelection != null && onlineSelection.isLatestSelected(); - } - - public boolean isAllSelected() { - return onlineSelection != null && onlineSelection.isAllSelected(); - } - - public String getArtifactId() { - return onlineSelection != null ? onlineSelection.getArtifactId() : null; - } - - public String getSince() { - return onlineSelection != null ? onlineSelection.getSince() : null; - } - - public String getAppVersionId(UnirestInstance unirest) { - return onlineSelection != null ? onlineSelection.getAppVersionId(unirest) : null; - } - - public String getSelectionMode() { - return onlineSelection != null ? onlineSelection.getSelectionMode() : null; - } - - public OnlineSelectionArgGroup.ResolvedOnlineArtifacts resolveArtifacts( - UnirestInstance unirest, OffsetDateTime sinceDate) { - return onlineSelection.resolveArtifacts(unirest, sinceDate); - } - - public void validate() { - if (onlineSelection != null) { - onlineSelection.validate(); - } - } } 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 index c43df5b5080..87c4688d5f0 100644 --- 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 @@ -71,10 +71,11 @@ public JsonNode getJsonNode(UnirestInstance unirest) { AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); logger.progress("Status: Downloading Audited FPR from FoD (release id=" + releaseDescriptor.getReleaseId() + ")"); - cacheWriter.addFpr(null, releaseDescriptor.getReleaseId(), null, entryPath -> + cacheWriter.addFodFpr(releaseDescriptor.getReleaseId(), entryPath -> FoDRemediationsFprDownloadHelper.downloadStaticRemediationsFpr(unirest, releaseDescriptor, entryPath)); logger.progress("Status: Writing remediations cache to " + destination); - RemediationsCacheManifest manifest = cacheWriter.finish(); + // close() writes manifest and publishes. + RemediationsCacheManifest manifest = cacheWriter.getManifest(); return buildResultNode(destination, releaseDescriptor, manifest); } } From c75e7fd46fe830b72651aba172a2d2ed3b3a8402 Mon Sep 17 00:00:00 2001 From: kireetivar Date: Fri, 24 Jul 2026 23:31:05 +0530 Subject: [PATCH 17/18] chore: nest remediations cache product data and simplify apply results --- .../CacheRemediationsFprSource.java | 34 +++-- .../RemediationsCacheEntry.java | 121 ++++++++++++++-- .../RemediationsCacheManifest.java | 48 ++++++ .../RemediationsCacheReader.java | 32 +--- .../RemediationsCacheWriter.java | 67 +++++---- .../fpr/processor/RemediationProcessor.java | 8 +- .../RemediationsCacheRoundTripTest.java | 48 ++++-- .../AviatorSSCApplyRemediationsCommand.java | 7 +- ...orSSCDownloadRemediationsCacheCommand.java | 10 +- ...iatorSSCRemediationsSelectorArgGroups.java | 12 +- .../mixin/SSCRemediationsSelectionMode.java | 34 +++++ .../AviatorSSCApplyRemediationsHelper.java | 123 ++++++++-------- .../SscOnlineRemediationsFprSource.java | 4 +- .../FoDAviatorApplyRemediationsCommand.java | 9 +- .../AviatorFoDApplyRemediationsHelper.java | 137 +++++++++--------- .../FodOnlineRemediationsFprSource.java | 4 +- 16 files changed, 455 insertions(+), 243 deletions(-) create mode 100644 fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/SSCRemediationsSelectionMode.java diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/CacheRemediationsFprSource.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/CacheRemediationsFprSource.java index 25345c523f2..e6b90a040ff 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/CacheRemediationsFprSource.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/CacheRemediationsFprSource.java @@ -16,29 +16,24 @@ import java.util.List; import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheReader.ResolvedFpr; +import com.fortify.cli.common.exception.FcliBugException; /** * Offline remediations source: ordered FPR paths from a remediations cache zip. + * Product identity (and thus artifact vs release id) comes from the validated entry model. */ public final class CacheRemediationsFprSource implements IRemediationsFprSource { - public enum IdKind { - ARTIFACT_ID, - RELEASE_ID - } - private final RemediationsCacheReader reader; - private final IdKind idKind; - private CacheRemediationsFprSource(RemediationsCacheReader reader, IdKind idKind) { + private CacheRemediationsFprSource(RemediationsCacheReader reader) { this.reader = reader; - this.idKind = idKind; } - public static CacheRemediationsFprSource open(Path cacheZip, String expectedProduct, IdKind idKind) { + public static CacheRemediationsFprSource open(Path cacheZip, String expectedProduct) { RemediationsCacheReader reader = RemediationsCacheReader.open(cacheZip); try { reader.requireProduct(expectedProduct); - return new CacheRemediationsFprSource(reader, idKind); + return new CacheRemediationsFprSource(reader); } catch (RuntimeException e) { reader.close(); throw e; @@ -59,14 +54,27 @@ public void forEachEntry(EntryAction action) { String label = entry.getPath() != null ? entry.getPath() : unit.fprPath().getFileName().toString(); - String raw = idKind == IdKind.ARTIFACT_ID ? entry.getArtifactId() : entry.getReleaseId(); - String id = raw != null ? raw : ""; - if (!action.accept(unit.fprPath(), label, id, i + 1, total)) { + if (!action.accept(unit.fprPath(), label, productId(entry), i + 1, total)) { break; } } } + /** + * Id for progress/result lists: artifactId (SSC) or releaseId (FoD). + * Entries are validated before resolve, so exactly one product block is present. + */ + private static String productId(RemediationsCacheEntry entry) { + if (entry.getSscData() != null) { + return entry.getSscData().getArtifactId(); + } + if (entry.getFodData() != null) { + return entry.getFodData().getReleaseId(); + } + throw new FcliBugException( + "Remediations cache entry missing product data after validation: " + entry.getPath()); + } + @Override public void close() { reader.close(); diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheEntry.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheEntry.java index 93a783374bf..2a5d1f5d5b9 100644 --- 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 @@ -12,13 +12,25 @@ */ package com.fortify.cli.aviator._common.remediations_cache; +import java.nio.file.Path; + +import org.apache.commons.lang3.StringUtils; + import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.exception.FcliSimpleException; import lombok.Data; import lombok.NoArgsConstructor; +/** + * Wire model for one FPR in a remediations cache manifest. + * Product identity is nested ({@link #sscData} or {@link #fodData}), not sibling + * optional ids on this type. Prefer {@link #forSsc} / {@link #forFod} with a product model + * from {@link SSCData#of} / {@link FoDData#of}; do not add {@code @Builder} on this + * {@code @Reflectable} class. + */ @Reflectable @Data @NoArgsConstructor @@ -26,25 +38,114 @@ @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; + private SSCData sscData; + private FoDData fodData; + + /** + * Factory for SSC cache writers (not Jackson). Assigns shared fields and the provided + * SSC product block; product field construction belongs on {@link SSCData#of}. + */ + public static RemediationsCacheEntry forSsc(int order, String path, String sha256, SSCData sscData) { + FcliSimpleException.throwIf(sscData == null, "sscData is required for an SSC remediations cache entry"); + RemediationsCacheEntry entry = new RemediationsCacheEntry(); + entry.setOrder(order); + entry.setPath(path); + entry.setSha256(sha256); + entry.setSscData(sscData); + return entry; + } /** - * Factory for writers (not Jackson). Avoids multi-arg same-type call sites on the wire model. - * Do not add {@code @Builder} on this {@code @Reflectable} class. + * Factory for FoD cache writers (not Jackson). Assigns shared fields and the provided + * FoD product block; product field construction belongs on {@link FoDData#of}. */ - public static RemediationsCacheEntry of( - int order, String path, String artifactId, String releaseId, String uploadDate, String sha256) { + public static RemediationsCacheEntry forFod(int order, String path, String sha256, FoDData fodData) { + FcliSimpleException.throwIf(fodData == null, "fodData is required for a FoD remediations cache entry"); RemediationsCacheEntry entry = new RemediationsCacheEntry(); entry.setOrder(order); entry.setPath(path); - entry.setArtifactId(artifactId); - entry.setReleaseId(releaseId); - entry.setUploadDate(uploadDate); entry.setSha256(sha256); + entry.setFodData(fodData); return entry; } + + /** Validates structural invariants (no cache path context). */ + public void validate() { + validate(null); + } + + /** + * Validates structural invariants of this entry (path, checksum, exactly one product block). + * Product-vs-manifest consistency is checked by {@link RemediationsCacheManifest#validate(Path)}. + * + * @param cacheZip optional cache path included in error messages for diagnostics + */ + public void validate(Path cacheZip) { + String where = where(cacheZip); + FcliSimpleException.throwIf(StringUtils.isBlank(path), + "Remediations cache entry is missing path%s", where); + FcliSimpleException.throwIf(StringUtils.isBlank(sha256), + "Remediations cache entry is missing sha256: %s%s", path, where); + boolean hasSsc = sscData != null; + boolean hasFod = fodData != null; + FcliSimpleException.throwIf(hasSsc == hasFod, + "Remediations cache entry must have exactly one of sscData or fodData: %s%s", path, where); + if (hasSsc) { + sscData.validate(path, where); + } else { + fodData.validate(path, where); + } + } + + private static String where(Path cacheZip) { + return cacheZip != null ? " (" + cacheZip + ")" : ""; + } + + /** SSC-specific fields for one cache entry. */ + @Reflectable + @Data + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class SSCData { + private String artifactId; + private String uploadDate; + + /** Factory for writers (not Jackson). */ + public static SSCData of(String artifactId, String uploadDate) { + SSCData ssc = new SSCData(); + ssc.setArtifactId(artifactId); + ssc.setUploadDate(uploadDate); + return ssc; + } + + void validate(String entryPath, String where) { + FcliSimpleException.throwIf(StringUtils.isBlank(artifactId), + "Remediations cache SSC entry is missing artifactId: %s%s", entryPath, where); + } + } + + /** FoD-specific fields for one cache entry. */ + @Reflectable + @Data + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class FoDData { + private String releaseId; + + /** Factory for writers (not Jackson). */ + public static FoDData of(String releaseId) { + FoDData fod = new FoDData(); + fod.setReleaseId(releaseId); + return fod; + } + + void validate(String entryPath, String where) { + FcliSimpleException.throwIf(StringUtils.isBlank(releaseId), + "Remediations cache FoD entry is missing releaseId: %s%s", entryPath, where); + } + } } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheManifest.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheManifest.java index 134ba7b0ee9..8f6164d82ea 100644 --- 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 @@ -12,14 +12,18 @@ */ package com.fortify.cli.aviator._common.remediations_cache; +import java.nio.file.Path; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import org.apache.commons.lang3.StringUtils; + import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.exception.FcliSimpleException; import lombok.Data; import lombok.NoArgsConstructor; @@ -36,4 +40,48 @@ public class RemediationsCacheManifest { private String createdAt; private Map selection = new LinkedHashMap<>(); private List entries = new ArrayList<>(); + + /** Validates schema, product, and every entry (no cache path context). */ + public void validate() { + validate(null); + } + + /** + * Validates schema, product, and every entry (including product-block consistency). + * Call after Jackson load and before publishing a written cache. + * + * @param cacheZip optional cache path included in error messages for diagnostics + */ + public void validate(Path cacheZip) { + String where = cacheZip != null ? ": " + cacheZip : ""; + FcliSimpleException.throwIf(schemaVersion != RemediationsCacheConstants.SCHEMA_VERSION, + "Unsupported remediations cache schemaVersion %s (expected %s)%s", + schemaVersion, RemediationsCacheConstants.SCHEMA_VERSION, where); + FcliSimpleException.throwIf(!RemediationsCacheConstants.KIND.equals(kind), + "Invalid remediations cache kind '%s' (expected %s)%s", + kind, RemediationsCacheConstants.KIND, where); + FcliSimpleException.throwIf(StringUtils.isBlank(product), + "Remediations cache manifest is missing product%s", where); + boolean sscProduct = RemediationsCacheConstants.PRODUCT_SSC.equals(product); + boolean fodProduct = RemediationsCacheConstants.PRODUCT_FOD.equals(product); + FcliSimpleException.throwIf(!sscProduct && !fodProduct, + "Unsupported remediations cache product '%s' (expected %s or %s)%s", + product, RemediationsCacheConstants.PRODUCT_SSC, RemediationsCacheConstants.PRODUCT_FOD, where); + FcliSimpleException.throwIf(entries == null || entries.isEmpty(), + "Remediations cache has no entries%s", where); + for (RemediationsCacheEntry entry : entries) { + FcliSimpleException.throwIf(entry == null, + "Remediations cache contains a null entry%s", where); + entry.validate(cacheZip); + if (sscProduct) { + FcliSimpleException.throwIf(entry.getSscData() == null, + "SSC remediations cache entry is missing sscData: %s%s", + entry.getPath(), where); + } else { + FcliSimpleException.throwIf(entry.getFodData() == null, + "FoD remediations cache entry is missing fodData: %s%s", + entry.getPath(), where); + } + } + } } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheReader.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheReader.java index 42574bc32ee..85365947e51 100644 --- 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 @@ -92,13 +92,13 @@ public List getOrderedEntryPaths() { public List getOrderedArtifactIds() { return getOrderedEntries().stream() - .map(e -> e.getArtifactId() != null ? e.getArtifactId() : "") + .map(e -> e.getSscData() != null ? e.getSscData().getArtifactId() : "") .toList(); } public List getOrderedReleaseIds() { return getOrderedEntries().stream() - .map(e -> e.getReleaseId() != null ? e.getReleaseId() : "") + .map(e -> e.getFodData() != null ? e.getFodData().getReleaseId() : "") .toList(); } @@ -144,7 +144,10 @@ private RemediationsCacheManifest loadAndValidateManifest() { try (var manifestInputStream = Files.newInputStream(manifestPath)) { RemediationsCacheManifest manifest = JsonHelper.getObjectMapper() .readValue(manifestInputStream, RemediationsCacheManifest.class); - validateManifest(manifest); + FcliSimpleException.throwIf(manifest == null, + "Remediations cache manifest is empty: %s", cacheZip); + // Model owns field invariants; Reader only loads and delegates. + manifest.validate(cacheZip); return manifest; } catch (AbstractFcliException e) { throw e; @@ -155,29 +158,6 @@ private RemediationsCacheManifest loadAndValidateManifest() { } } - private void validateManifest(RemediationsCacheManifest manifest) { - FcliSimpleException.throwIf(manifest == null, - "Remediations cache manifest is empty: %s", cacheZip); - FcliSimpleException.throwIf(manifest.getSchemaVersion() != RemediationsCacheConstants.SCHEMA_VERSION, - "Unsupported remediations cache schemaVersion %s (expected %s): %s", - manifest.getSchemaVersion(), RemediationsCacheConstants.SCHEMA_VERSION, cacheZip); - FcliSimpleException.throwIf(!RemediationsCacheConstants.KIND.equals(manifest.getKind()), - "Invalid remediations cache kind '%s' (expected %s): %s", - manifest.getKind(), RemediationsCacheConstants.KIND, cacheZip); - FcliSimpleException.throwIf(StringUtils.isBlank(manifest.getProduct()), - "Remediations cache manifest is missing product: %s", cacheZip); - FcliSimpleException.throwIf(manifest.getEntries() == null || manifest.getEntries().isEmpty(), - "Remediations cache has no entries: %s", cacheZip); - for (RemediationsCacheEntry entry : manifest.getEntries()) { - FcliSimpleException.throwIf(entry == null, - "Remediations cache contains a null entry: %s", cacheZip); - FcliSimpleException.throwIf(StringUtils.isBlank(entry.getPath()), - "Remediations cache entry is missing path: %s", cacheZip); - FcliSimpleException.throwIf(StringUtils.isBlank(entry.getSha256()), - "Remediations cache entry is missing sha256: %s", entry.getPath()); - } - } - private List loadOrderedEntries() { List entries = new ArrayList<>(getManifest().getEntries()); entries.sort(Comparator.comparingInt(RemediationsCacheEntry::getOrder)); 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 index afb2704e53b..8c9dbf73dac 100644 --- 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 @@ -84,9 +84,9 @@ public static RemediationsCacheManifest write( "Cannot create remediations cache: no FPR files to include"); try (RemediationsCacheWriter writer = create(destination, product, selection)) { for (LocalFpr source : fprSources) { - if (source instanceof SscFpr ssc) { + if (source instanceof SSCFpr ssc) { writer.addFprFromFile(ssc); - } else if (source instanceof FodFpr fod) { + } else if (source instanceof FoDFpr fod) { writer.addFprFromFile(fod); } else { throw new FcliTechnicalException("Unsupported local FPR type: " + source.getClass().getName()); @@ -106,7 +106,11 @@ public RemediationsCacheManifest getManifest() { */ public void addSscFpr(String artifactId, String uploadDate, Consumer contentWriter) { int order = nextOrder++; - addEntry(order, sscEntryPath(order, artifactId), artifactId, null, uploadDate, contentWriter); + writeAndRecord( + sscEntryPath(order, artifactId), + contentWriter, + (entryPath, sha256) -> RemediationsCacheEntry.forSsc( + order, entryPath, sha256, RemediationsCacheEntry.SSCData.of(artifactId, uploadDate))); } /** @@ -114,32 +118,25 @@ public void addSscFpr(String artifactId, String uploadDate, Consumer conte */ public void addFodFpr(String releaseId, Consumer contentWriter) { int order = nextOrder++; - addEntry(order, fodEntryPath(order, releaseId), null, releaseId, null, contentWriter); + writeAndRecord( + fodEntryPath(order, releaseId), + contentWriter, + (entryPath, sha256) -> RemediationsCacheEntry.forFod( + order, entryPath, sha256, RemediationsCacheEntry.FoDData.of(releaseId))); } - /** Copies an existing SSC FPR file into the cache zip and records its checksum. */ - public void addFprFromFile(SscFpr source) { - validateLocalPath(source.path()); - addSscFpr(source.artifactId(), source.uploadDate(), copyFrom(source.path())); - } - - /** Copies an existing FoD FPR file into the cache zip and records its checksum. */ - public void addFprFromFile(FodFpr source) { - validateLocalPath(source.path()); - addFodFpr(source.releaseId(), copyFrom(source.path())); + @FunctionalInterface + private interface EntryBuilder { + RemediationsCacheEntry build(String entryPath, String sha256); } - private void addEntry( - int order, - String entryPath, - String artifactId, - String releaseId, - String uploadDate, - Consumer contentWriter) { + private void writeAndRecord(String entryPath, Consumer contentWriter, EntryBuilder entryBuilder) { try { Path target = prepareEntryPath(entryPath); writeEntryContent(target, entryPath, contentWriter); - recordEntry(order, entryPath, artifactId, releaseId, uploadDate, target); + RemediationsCacheEntry entry = entryBuilder.build(entryPath, RemediationsCacheSha256.hashFile(target)); + entry.validate(destination); + manifest.getEntries().add(entry); } catch (AbstractFcliException e) { throw e; } catch (IOException e) { @@ -149,6 +146,18 @@ private void addEntry( } } + /** Copies an existing SSC FPR file into the cache zip and records its checksum. */ + public void addFprFromFile(SSCFpr source) { + validateLocalPath(source.path()); + addSscFpr(source.artifactId(), source.uploadDate(), copyFrom(source.path())); + } + + /** Copies an existing FoD FPR file into the cache zip and records its checksum. */ + public void addFprFromFile(FoDFpr source) { + validateLocalPath(source.path()); + addFodFpr(source.releaseId(), copyFrom(source.path())); + } + private Path prepareEntryPath(String entryPath) throws IOException { Path target = zipFs.getPath(entryPath); Path parent = target.getParent(); @@ -164,13 +173,6 @@ private static void writeEntryContent(Path target, String entryPath, Consumer { try (RemediationsCacheReader reader = RemediationsCacheReader.open(zip)) { @@ -210,6 +211,35 @@ void requireProductRejectsMismatch() throws Exception { }); } + @Test + void entryValidateRejectsMissingProductBlockAndDualProduct() { + RemediationsCacheEntry bare = new RemediationsCacheEntry(); + bare.setOrder(1); + bare.setPath("fprs/001.fpr"); + bare.setSha256("abc"); + assertThrows(FcliSimpleException.class, bare::validate); + + RemediationsCacheEntry dual = RemediationsCacheEntry.forSsc( + 1, "fprs/001.fpr", "abc", RemediationsCacheEntry.SSCData.of("1", null)); + dual.setFodData(RemediationsCacheEntry.FoDData.of("r1")); + assertThrows(FcliSimpleException.class, dual::validate); + + RemediationsCacheEntry ssc = RemediationsCacheEntry.forSsc( + 1, "fprs/001.fpr", "abc", RemediationsCacheEntry.SSCData.of("1", null)); + ssc.validate(); + } + + @Test + void manifestValidateRejectsProductEntryMismatch() { + RemediationsCacheManifest manifest = new RemediationsCacheManifest(); + manifest.setSchemaVersion(RemediationsCacheConstants.SCHEMA_VERSION); + manifest.setKind(RemediationsCacheConstants.KIND); + manifest.setProduct(RemediationsCacheConstants.PRODUCT_SSC); + manifest.getEntries().add(RemediationsCacheEntry.forFod( + 1, "fprs/001.fpr", "abc", RemediationsCacheEntry.FoDData.of("rel-1"))); + assertThrows(FcliSimpleException.class, manifest::validate); + } + @Test void entryPathSanitizesUnsafeIdCharacters() throws Exception { Path fpr = tempDir.resolve("one.fpr"); @@ -219,13 +249,13 @@ void entryPathSanitizesUnsafeIdCharacters() throws Exception { zip, RemediationsCacheConstants.PRODUCT_SSC, Map.of("mode", "artifact-id"), - List.of(new RemediationsCacheWriter.SscFpr(fpr, "12/../evil", null))); + List.of(new RemediationsCacheWriter.SSCFpr(fpr, "12/../evil", null))); try (RemediationsCacheReader reader = RemediationsCacheReader.open(zip)) { var resolved = reader.getOrderedResolvedFprs(); assertEquals(1, resolved.size()); // Manifest keeps the original id; zip entry path must not contain path separators or "..". - assertEquals("12/../evil", resolved.get(0).entry().getArtifactId()); + assertEquals("12/../evil", resolved.get(0).entry().getSscData().getArtifactId()); String entryPath = resolved.get(0).entry().getPath(); assertTrue(entryPath.startsWith(RemediationsCacheConstants.FPRS_DIR + "/")); assertTrue(!entryPath.contains("..")); 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 da63c572a32..b195e70b3e8 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommand.java @@ -29,8 +29,8 @@ import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCApplyRemediationsSourceMixin; import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCRemediationsSelectorArgGroups.OnlineSelectionArgGroup.ResolvedOnlineArtifacts; import com.fortify.cli.aviator.ssc.helper.AviatorSSCApplyRemediationsHelper; +import com.fortify.cli.aviator.ssc.helper.SSCOnlineRemediationsFprSource; import com.fortify.cli.aviator.ssc.helper.SinceOptionHelper; -import com.fortify.cli.aviator.ssc.helper.SscOnlineRemediationsFprSource; 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; @@ -81,8 +81,7 @@ public JsonNode getJsonNode() { private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdFilter) { try (CacheRemediationsFprSource source = CacheRemediationsFprSource.open( sourceSelector.getFromCache(), - RemediationsCacheConstants.PRODUCT_SSC, - CacheRemediationsFprSource.IdKind.ARTIFACT_ID)) { + RemediationsCacheConstants.PRODUCT_SSC)) { ApplyResult applyResult = RemediationsApplyHelper.apply( source, sourceCodeDirectory, logger, issueIdFilter, LOG); return AviatorSSCApplyRemediationsHelper.buildCacheResultNode( @@ -99,7 +98,7 @@ private JsonNode processOnline( OffsetDateTime sinceDate = SinceOptionHelper.parse(sourceSelector.getOnline().getSince()); // One resolve: artifacts + appVersionId (no second getAppVersionId REST call). ResolvedOnlineArtifacts resolved = sourceSelector.getOnline().resolveArtifacts(unirest, sinceDate); - try (SscOnlineRemediationsFprSource source = new SscOnlineRemediationsFprSource( + try (SSCOnlineRemediationsFprSource source = new SSCOnlineRemediationsFprSource( unirest, logger, progressWriter, resolved.artifacts())) { ApplyResult applyResult = RemediationsApplyHelper.apply( source, sourceCodeDirectory, logger, issueIdFilter, LOG); 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 index f0d63d95127..ce625fb26fb 100644 --- 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 @@ -27,6 +27,7 @@ import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheWriter; import com.fortify.cli.aviator.config.AviatorLoggerImpl; import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCRemediationsCacheDownloadSelectorMixin; +import com.fortify.cli.aviator.ssc.cli.mixin.SSCRemediationsSelectionMode; import com.fortify.cli.aviator.ssc.helper.SinceOptionHelper; import com.fortify.cli.common.cli.mixin.CommonOptionMixins; import com.fortify.cli.common.json.JsonHelper; @@ -85,7 +86,10 @@ public JsonNode getJsonNode(UnirestInstance unirest) { private Map buildSelectionMetadata(String appVersionId, OffsetDateTime sinceDate) { Map selection = new LinkedHashMap<>(); var online = artifactSelector.getOnlineSelection(); - selection.put("mode", online.getSelectionMode()); + SSCRemediationsSelectionMode mode = online.getSelectionMode(); + if (mode != null) { + selection.put("mode", mode.wireValue()); + } if (online.isArtifactIdSelected()) { selection.put("artifactId", online.getArtifactId()); } else if (appVersionId != null) { @@ -114,8 +118,8 @@ private ObjectNode buildResultNode(Path destination, RemediationsCacheManifest m result.put("artifactsDownloaded", manifest.getEntries().size()); ArrayNode artifactIds = result.putArray("artifactIds"); for (var entry : manifest.getEntries()) { - if (entry.getArtifactId() != null) { - artifactIds.add(entry.getArtifactId()); + if (entry.getSscData() != null && entry.getSscData().getArtifactId() != null) { + artifactIds.add(entry.getSscData().getArtifactId()); } } return result; diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsSelectorArgGroups.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsSelectorArgGroups.java index 39b6701920a..e1a18e65281 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsSelectorArgGroups.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCRemediationsSelectorArgGroups.java @@ -71,15 +71,19 @@ public String getAppVersionId(UnirestInstance unirest) { return descriptor.getVersionId(); } - public String getSelectionMode() { + /** + * Selected online mode, or {@code null} if none (should not occur after validate / + * exclusive ArgGroup). Used for manifest {@code selection.mode} wire values. + */ + public SSCRemediationsSelectionMode getSelectionMode() { if (isArtifactIdSelected()) { - return "artifact-id"; + return SSCRemediationsSelectionMode.ARTIFACT_ID; } if (isLatestSelected()) { - return "latest"; + return SSCRemediationsSelectionMode.LATEST; } if (isAllSelected()) { - return "all"; + return SSCRemediationsSelectionMode.ALL; } return null; } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/SSCRemediationsSelectionMode.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/SSCRemediationsSelectionMode.java new file mode 100644 index 00000000000..724a9926076 --- /dev/null +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/SSCRemediationsSelectionMode.java @@ -0,0 +1,34 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.ssc.cli.mixin; + +/** + * SSC remediations online selection mode. Wire value is stored in cache manifest + * {@code selection.mode} and must remain stable for existing/future caches. + */ +public enum SSCRemediationsSelectionMode { + ARTIFACT_ID("artifact-id"), + LATEST("latest"), + ALL("all"); + + private final String wireValue; + + SSCRemediationsSelectionMode(String wireValue) { + this.wireValue = wireValue; + } + + /** Value written to remediations cache manifest selection metadata. */ + public String wireValue() { + return wireValue; + } +} diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCApplyRemediationsHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCApplyRemediationsHelper.java index 25503fd3d72..87f9f716ce2 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCApplyRemediationsHelper.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCApplyRemediationsHelper.java @@ -31,13 +31,16 @@ /** * Helper for Aviator SSC apply-remediations result JSON construction. + *

+ * Always-present fields live in {@link CommonApplyResult} (online = common only). + * Cache mode adds mode-specific fields via {@link CacheResultData#toJson()}. */ public final class AviatorSSCApplyRemediationsHelper { private AviatorSSCApplyRemediationsHelper() {} /** - * Builds a single online-result shape for any apply outcome (one or many artifacts). - * Table columns use the same fields for --artifact-id, --latest, and --all. + * Builds online-result JSON (always-present fields only). + * Table columns stay stable for --artifact-id, --latest, and --all. */ public static ObjectNode buildOnlineResultNode( List artifacts, @@ -48,7 +51,7 @@ public static ObjectNode buildOnlineResultNode( issueIdFilter, applyResult.metrics()); String versionId = appVersionId; - String artifactId = "N/A"; + String artifactId = null; if (artifacts.size() == 1) { SSCArtifactDescriptor only = artifacts.get(0); if (versionId == null) { @@ -60,23 +63,14 @@ public static ObjectNode buildOnlineResultNode( } } - return toOnlineJson(OnlineResultData.builder() - .appVersionId(versionId != null ? versionId : "N/A") - .artifactId(artifactId) - .artifactsProcessed(applyResult.metrics().size()) - .artifactsSkipped(applyResult.skipped()) - .totalRemediation(aggregated.totalRemediations()) - .appliedRemediation(aggregated.appliedRemediations()) - .skippedRemediation(aggregated.skippedRemediations()) - .modifiedFiles(aggregated.modifiedFiles()) - .action(RemediationsApplyHelper.actionLabel(aggregated)) - .build()); + return commonFrom(versionId, artifactId, applyResult, aggregated).toJson(); } /** - * Aggregates metrics and builds the cache apply result JSON (same fields as before). + * Aggregates metrics and builds the cache apply result JSON. + * Always-present fields match online table columns; cache-only fields are appended. * - * @param selection manifest selection map; may be null (appVersionId becomes null / "N/A") + * @param selection manifest selection map; may be null (appVersionId becomes "N/A") */ public static ObjectNode buildCacheResultNode( Path cacheZip, @@ -86,54 +80,39 @@ public static ObjectNode buildCacheResultNode( RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( issueIdFilter, applyResult.metrics()); String appVersionId = selection != null ? selection.get("appVersionId") : null; - return toCacheJson(CacheResultData.builder() + return CacheResultData.builder() + .common(commonFrom(appVersionId, null, applyResult, aggregated)) .cacheZip(cacheZip) .entryPaths(applyResult.processedEntries()) .artifactIds(applyResult.processedIds()) + .build() + .toJson(); + } + + private static CommonApplyResult commonFrom( + String appVersionId, + String artifactId, + ApplyResult applyResult, + RemediationMetric aggregated) { + return CommonApplyResult.builder() .appVersionId(appVersionId) - .fprsProcessed(applyResult.metrics().size()) - .fprsSkipped(applyResult.skipped()) + .artifactId(artifactId) + .artifactsProcessed(applyResult.metrics().size()) + .artifactsSkipped(applyResult.skipped()) .totalRemediation(aggregated.totalRemediations()) .appliedRemediation(aggregated.appliedRemediations()) .skippedRemediation(aggregated.skippedRemediations()) .modifiedFiles(aggregated.modifiedFiles()) .action(RemediationsApplyHelper.actionLabel(aggregated)) - .build()); - } - - private static ObjectNode toOnlineJson(OnlineResultData data) { - ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); - result.put("appVersionId", data.appVersionId() != null ? data.appVersionId() : "N/A"); - result.put("artifactId", data.artifactId() != null ? data.artifactId() : "N/A"); - result.put("artifactsProcessed", data.artifactsProcessed()); - result.put("artifactsSkipped", data.artifactsSkipped()); - result.put("totalRemediation", data.totalRemediation()); - result.put("appliedRemediation", data.appliedRemediation()); - result.put("skippedRemediation", data.skippedRemediation()); - result.set("modifiedFiles", toArrayNode(data.modifiedFiles())); - result.put(IActionCommandResultSupplier.actionFieldName, data.action()); - return result; - } - - private static ObjectNode toCacheJson(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; + .build(); } + /** + * Properties always written for SSC apply-remediations, independent of online vs cache. + * Keeps table columns stable across modes. + */ @Builder - private record OnlineResultData( + private record CommonApplyResult( String appVersionId, String artifactId, int artifactsProcessed, @@ -142,21 +121,41 @@ private record OnlineResultData( int appliedRemediation, int skippedRemediation, Set modifiedFiles, - String action) {} + String action) { + ObjectNode toJson() { + ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); + result.put("appVersionId", na(appVersionId)); + result.put("artifactId", na(artifactId)); + result.put("artifactsProcessed", artifactsProcessed); + result.put("artifactsSkipped", artifactsSkipped); + result.put("totalRemediation", totalRemediation); + result.put("appliedRemediation", appliedRemediation); + result.put("skippedRemediation", skippedRemediation); + result.set("modifiedFiles", toArrayNode(modifiedFiles)); + result.put(IActionCommandResultSupplier.actionFieldName, action); + return result; + } + } + /** Cache mode: always-present fields plus cache path, entry paths, and artifact id list. */ @Builder private record CacheResultData( + CommonApplyResult common, Path cacheZip, List entryPaths, - List artifactIds, - String appVersionId, - int fprsProcessed, - int fprsSkipped, - int totalRemediation, - int appliedRemediation, - int skippedRemediation, - Set modifiedFiles, - String action) {} + List artifactIds) { + ObjectNode toJson() { + ObjectNode result = common.toJson(); + result.put("file", cacheZip.toString()); + result.set("entries", toStringArrayNode(entryPaths)); + result.set("artifactIds", toStringArrayNode(artifactIds)); + return result; + } + } + + private static String na(String value) { + return value != null ? value : "N/A"; + } private static ArrayNode toArrayNode(Set files) { ArrayNode array = JsonHelper.getObjectMapper().createArrayNode(); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SscOnlineRemediationsFprSource.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SscOnlineRemediationsFprSource.java index f0a5856828c..470b94ae372 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SscOnlineRemediationsFprSource.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SscOnlineRemediationsFprSource.java @@ -33,13 +33,13 @@ * The type implements {@link AutoCloseable} so callers can use one try-with-resources * pattern for all {@link IRemediationsFprSource} implementations. */ -public final class SscOnlineRemediationsFprSource implements IRemediationsFprSource { +public final class SSCOnlineRemediationsFprSource implements IRemediationsFprSource { private final UnirestInstance unirest; private final IAviatorLogger logger; private final IProgressWriter progressWriter; private final List artifacts; - public SscOnlineRemediationsFprSource( + public SSCOnlineRemediationsFprSource( UnirestInstance unirest, IAviatorLogger logger, IProgressWriter progressWriter, 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 8b4ea859ffe..d51a99f9e46 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorApplyRemediationsCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorApplyRemediationsCommand.java @@ -37,7 +37,7 @@ import com.fortify.cli.fod._common.session.cli.mixin.FoDUnirestInstanceSupplierMixin; import com.fortify.cli.fod.aviator.cli.mixin.FoDAviatorApplyRemediationsSourceMixin; import com.fortify.cli.fod.aviator.helper.AviatorFoDApplyRemediationsHelper; -import com.fortify.cli.fod.aviator.helper.FodOnlineRemediationsFprSource; +import com.fortify.cli.fod.aviator.helper.FoDOnlineRemediationsFprSource; import com.fortify.cli.fod.release.helper.FoDReleaseDescriptor; import kong.unirest.UnirestInstance; @@ -80,8 +80,8 @@ public JsonNode getJsonNode() { private JsonNode processOnline(AviatorLoggerImpl logger, Set issueIdFilter) { UnirestInstance unirest = unirestInstanceSupplier.getUnirestInstance(); FoDReleaseDescriptor release = sourceSelector.getReleaseDescriptor(unirest); - try (FodOnlineRemediationsFprSource source = - new FodOnlineRemediationsFprSource(unirest, logger, release)) { + try (FoDOnlineRemediationsFprSource source = + new FoDOnlineRemediationsFprSource(unirest, logger, release)) { ApplyResult applyResult = RemediationsApplyHelper.apply( source, sourceCodeDirectory, logger, issueIdFilter, LOG); return AviatorFoDApplyRemediationsHelper.buildOnlineResultNode(release, applyResult); @@ -91,8 +91,7 @@ private JsonNode processOnline(AviatorLoggerImpl logger, Set issueIdFilt private JsonNode processFromCache(AviatorLoggerImpl logger, Set issueIdFilter) { try (CacheRemediationsFprSource source = CacheRemediationsFprSource.open( sourceSelector.getFromCache(), - RemediationsCacheConstants.PRODUCT_FOD, - CacheRemediationsFprSource.IdKind.RELEASE_ID)) { + RemediationsCacheConstants.PRODUCT_FOD)) { ApplyResult applyResult = RemediationsApplyHelper.apply( source, sourceCodeDirectory, logger, issueIdFilter, LOG); return AviatorFoDApplyRemediationsHelper.buildCacheResultNode( 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 e24d65db2c1..86c56639c18 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/AviatorFoDApplyRemediationsHelper.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/AviatorFoDApplyRemediationsHelper.java @@ -30,102 +30,103 @@ /** * Helper for FoD apply-remediations result JSON construction. + *

+ * Always-present fields live in {@link CommonApplyResult} (online = common only). + * Cache mode adds path/list properties via {@link CacheResultData#toJson()}. */ public final class AviatorFoDApplyRemediationsHelper { private AviatorFoDApplyRemediationsHelper() {} - /** Online apply outcome (success or soft-skip) → structured result node. */ + /** Online apply outcome (success or soft-skip) → always-present result fields only. */ public static ObjectNode buildOnlineResultNode(FoDReleaseDescriptor releaseDescriptor, ApplyResult applyResult) { - if (applyResult.metrics().isEmpty()) { - return toOnlineJson(OnlineResultData.builder() - .releaseDescriptor(releaseDescriptor) - .totalRemediation(0) - .appliedRemediation(0) - .skippedRemediation(0) - .modifiedFiles(Set.of()) - .action(RemediationsApplyHelper.actionLabel(null)) - .build()); - } - RemediationMetric metric = applyResult.metrics().get(0); - return toOnlineJson(OnlineResultData.builder() - .releaseDescriptor(releaseDescriptor) - .totalRemediation(metric.totalRemediations()) - .appliedRemediation(metric.appliedRemediations()) - .skippedRemediation(metric.skippedRemediations()) - .modifiedFiles(metric.modifiedFiles()) + RemediationMetric metric = applyResult.metrics().isEmpty() ? null : applyResult.metrics().get(0); + return CommonApplyResult.builder() + .releaseId(releaseDescriptor.getReleaseId()) + .applicationName(releaseDescriptor.getApplicationName()) + .releaseName(releaseDescriptor.getReleaseName()) + .totalRemediation(metric == null ? 0 : metric.totalRemediations()) + .appliedRemediation(metric == null ? 0 : metric.appliedRemediations()) + .skippedRemediation(metric == null ? 0 : metric.skippedRemediations()) + .modifiedFiles(metric == null ? Set.of() : metric.modifiedFiles()) .action(RemediationsApplyHelper.actionLabel(metric)) - .build()); + .build() + .toJson(); } /** - * Aggregates metrics and builds the cache apply result JSON (same fields as before). + * Aggregates metrics and builds the cache apply result JSON. + * Always-present fields match online table columns; cache-only fields are appended. */ public static ObjectNode buildCacheResultNode( Path cacheZip, ApplyResult applyResult, Set issueIdFilter) { RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( issueIdFilter, applyResult.metrics()); - return toCacheJson(CacheResultData.builder() + List releaseIds = applyResult.processedIds(); + String releaseId = releaseIds != null && !releaseIds.isEmpty() ? releaseIds.get(0) : null; + return CacheResultData.builder() + .common(CommonApplyResult.builder() + .releaseId(releaseId) + .applicationName(null) + .releaseName(null) + .totalRemediation(aggregated.totalRemediations()) + .appliedRemediation(aggregated.appliedRemediations()) + .skippedRemediation(aggregated.skippedRemediations()) + .modifiedFiles(aggregated.modifiedFiles()) + .action(RemediationsApplyHelper.actionLabel(aggregated)) + .build()) .cacheZip(cacheZip) .entryPaths(applyResult.processedEntries()) - .releaseIds(applyResult.processedIds()) - .totalRemediation(aggregated.totalRemediations()) - .appliedRemediation(aggregated.appliedRemediations()) - .skippedRemediation(aggregated.skippedRemediations()) - .modifiedFiles(aggregated.modifiedFiles()) - .action(RemediationsApplyHelper.actionLabel(aggregated)) - .build()); - } - - private static ObjectNode toOnlineJson(OnlineResultData data) { - FoDReleaseDescriptor rd = data.releaseDescriptor(); - ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); - result.put("releaseId", rd.getReleaseId()); - result.put("applicationName", rd.getApplicationName()); - result.put("releaseName", rd.getReleaseName()); - result.put("totalRemediation", data.totalRemediation()); - result.put("appliedRemediation", data.appliedRemediation()); - result.put("skippedRemediation", data.skippedRemediation()); - result.set("modifiedFiles", toArrayNode(data.modifiedFiles())); - result.put(IActionCommandResultSupplier.actionFieldName, data.action()); - return result; - } - - private static ObjectNode toCacheJson(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; + .releaseIds(releaseIds) + .build() + .toJson(); } + /** + * Properties always written for FoD apply-remediations, independent of online vs cache. + * Keeps table columns stable across modes. + */ @Builder - private record OnlineResultData( - FoDReleaseDescriptor releaseDescriptor, + private record CommonApplyResult( + String releaseId, + String applicationName, + String releaseName, int totalRemediation, int appliedRemediation, int skippedRemediation, Set modifiedFiles, - String action) {} + String action) { + ObjectNode toJson() { + ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); + result.put("releaseId", na(releaseId)); + result.put("applicationName", na(applicationName)); + result.put("releaseName", na(releaseName)); + result.put("totalRemediation", totalRemediation); + result.put("appliedRemediation", appliedRemediation); + result.put("skippedRemediation", skippedRemediation); + result.set("modifiedFiles", toArrayNode(modifiedFiles)); + result.put(IActionCommandResultSupplier.actionFieldName, action); + return result; + } + } @Builder private record CacheResultData( + CommonApplyResult common, Path cacheZip, List entryPaths, - List releaseIds, - int totalRemediation, - int appliedRemediation, - int skippedRemediation, - Set modifiedFiles, - String action) {} + List releaseIds) { + ObjectNode toJson() { + ObjectNode result = common.toJson(); + result.put("file", cacheZip.toString()); + result.set("entries", toStringArrayNode(entryPaths)); + result.set("releaseIds", toStringArrayNode(releaseIds)); + return result; + } + } + + private static String na(String value) { + return value != null ? value : "N/A"; + } private static ArrayNode toArrayNode(Set files) { ArrayNode array = JsonHelper.getObjectMapper().createArrayNode(); diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/FodOnlineRemediationsFprSource.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/FodOnlineRemediationsFprSource.java index 1eafaebf9b7..de18eadae3c 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/FodOnlineRemediationsFprSource.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/FodOnlineRemediationsFprSource.java @@ -27,12 +27,12 @@ * Implements {@link AutoCloseable} so callers share one try-with-resources pattern with * cache sources. */ -public final class FodOnlineRemediationsFprSource implements IRemediationsFprSource { +public final class FoDOnlineRemediationsFprSource implements IRemediationsFprSource { private final UnirestInstance unirest; private final IAviatorLogger logger; private final FoDReleaseDescriptor releaseDescriptor; - public FodOnlineRemediationsFprSource( + public FoDOnlineRemediationsFprSource( UnirestInstance unirest, IAviatorLogger logger, FoDReleaseDescriptor releaseDescriptor) { From 0d8185748436cfce67b7493702183789a62f21c3 Mon Sep 17 00:00:00 2001 From: kireetivar Date: Fri, 24 Jul 2026 23:58:44 +0530 Subject: [PATCH 18/18] chore: fix file names --- ...diationsFprSource.java => SSCOnlineRemediationsFprSource.java} | 0 ...diationsFprSource.java => FoDOnlineRemediationsFprSource.java} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/{SscOnlineRemediationsFprSource.java => SSCOnlineRemediationsFprSource.java} (100%) rename fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/{FodOnlineRemediationsFprSource.java => FoDOnlineRemediationsFprSource.java} (100%) diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SscOnlineRemediationsFprSource.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SSCOnlineRemediationsFprSource.java similarity index 100% rename from fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SscOnlineRemediationsFprSource.java rename to fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/SSCOnlineRemediationsFprSource.java diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/FodOnlineRemediationsFprSource.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/FoDOnlineRemediationsFprSource.java similarity index 100% rename from fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/FodOnlineRemediationsFprSource.java rename to fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/helper/FoDOnlineRemediationsFprSource.java