From 99a7a5335953e4d30e910ee139e3058af77c9426 Mon Sep 17 00:00:00 2001 From: Sangamesh Vijaykumar Date: Tue, 28 Apr 2026 18:38:38 +0530 Subject: [PATCH 1/5] feat: add fcli fpr module for local FPR file operations --- fcli-core/fcli-app/build.gradle.kts | 4 +- .../app/_main/cli/cmd/FCLIRootCommands.java | 2 + .../cli/aviator/fpr/model/AuditIssue.java | 13 +- .../aviator/fpr/processor/AuditProcessor.java | 157 ++++++++++-- fcli-core/fcli-fpr/build.gradle.kts | 6 + .../fpr/_common/cli/mixin/FPRFileMixin.java | 40 ++++ .../cli/fpr/_common/helper/FPRHelper.java | 223 ++++++++++++++++++ .../cli/fpr/_main/cli/cmd/FPRCommands.java | 33 +++ .../issue/cli/cmd/FPRIssueAuditCommand.java | 155 ++++++++++++ .../fpr/issue/cli/cmd/FPRIssueCommands.java | 28 +++ .../issue/cli/cmd/FPRIssueCountCommand.java | 89 +++++++ .../fpr/issue/cli/cmd/FPRIssueGetCommand.java | 87 +++++++ .../issue/cli/cmd/FPRIssueListCommand.java | 56 +++++ .../cli/cmd/FPRApplyRemediationsCommand.java | 65 +++++ .../cli/cmd/FPRRemediationCommands.java | 25 ++ .../cli/fpr/i18n/FPRMessages.properties | 37 +++ gradle.properties | 3 +- 17 files changed, 1003 insertions(+), 20 deletions(-) create mode 100644 fcli-core/fcli-fpr/build.gradle.kts create mode 100644 fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_common/cli/mixin/FPRFileMixin.java create mode 100644 fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_common/helper/FPRHelper.java create mode 100644 fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_main/cli/cmd/FPRCommands.java create mode 100644 fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueAuditCommand.java create mode 100644 fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueCommands.java create mode 100644 fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueCountCommand.java create mode 100644 fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueGetCommand.java create mode 100644 fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueListCommand.java create mode 100644 fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/remediation/cli/cmd/FPRApplyRemediationsCommand.java create mode 100644 fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/remediation/cli/cmd/FPRRemediationCommands.java create mode 100644 fcli-core/fcli-fpr/src/main/resources/com/fortify/cli/fpr/i18n/FPRMessages.properties diff --git a/fcli-core/fcli-app/build.gradle.kts b/fcli-core/fcli-app/build.gradle.kts index 92328e390ad..0119cb05c54 100644 --- a/fcli-core/fcli-app/build.gradle.kts +++ b/fcli-core/fcli-app/build.gradle.kts @@ -7,7 +7,7 @@ plugins { // Inter-project dependencies val refs = listOf( - "fcliCommonRef","fcliActionRef","fcliAviatorRef","fcliConfigRef","fcliFoDRef","fcliSSCRef","fcliSCSastRef","fcliSCDastRef","fcliToolRef","fcliLicenseRef","fcliUtilRef" + "fcliCommonRef","fcliActionRef","fcliAviatorRef","fcliConfigRef","fcliFoDRef","fcliSSCRef","fcliSCSastRef","fcliSCDastRef","fcliToolRef","fcliLicenseRef","fcliUtilRef","fcliFPRRef" ) references@ for (r in refs) { val p = project.findProperty(r) as String? ?: continue@references @@ -128,4 +128,4 @@ tasks.register("dist") { into(rootProject.layout.buildDirectory.dir("dist/release-assets")) inputs.file(layout.buildDirectory.file("libs/fcli.jar")) outputs.dir(rootProject.layout.buildDirectory.dir("dist/release-assets")) -} \ No newline at end of file +} diff --git a/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/_main/cli/cmd/FCLIRootCommands.java b/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/_main/cli/cmd/FCLIRootCommands.java index 3d319940d45..70463982f5b 100644 --- a/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/_main/cli/cmd/FCLIRootCommands.java +++ b/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/_main/cli/cmd/FCLIRootCommands.java @@ -19,6 +19,7 @@ import com.fortify.cli.common.util.DisableTest.TestType; import com.fortify.cli.config._main.cli.cmd.ConfigCommands; import com.fortify.cli.fod._main.cli.cmd.FoDCommands; +import com.fortify.cli.fpr._main.cli.cmd.FPRCommands; import com.fortify.cli.generic_action._main.cli.cmd.GenericActionCommands; import com.fortify.cli.license._main.cli.cmd.LicenseCommands; import com.fortify.cli.sc_dast._main.cli.cmd.SCDastCommands; @@ -54,6 +55,7 @@ SSCCommands.class, ToolCommands.class, LicenseCommands.class, + FPRCommands.class, UtilCommands.class } ) diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/model/AuditIssue.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/model/AuditIssue.java index 75c616be25c..8e6c2bc34c8 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/model/AuditIssue.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/model/AuditIssue.java @@ -31,6 +31,7 @@ public class AuditIssue { private int revision; @Builder.Default private Map tags = new HashMap<>(); @Builder.Default private List threadedComments = new ArrayList<>(); + @Builder.Default private List tagHistory = new ArrayList<>(); public void addTag(String tagId, String tagValue) { if (tagId != null) { @@ -52,4 +53,14 @@ public static class Comment { private String username; private String timestamp; } -} \ No newline at end of file + + @Getter + @Builder + @Reflectable + public static class TagHistoryEntry { + private String tagId; + private String tagValue; + private String editTime; + private String username; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/AuditProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/AuditProcessor.java index 17773d1de42..12a79ab6160 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/AuditProcessor.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/AuditProcessor.java @@ -12,6 +12,7 @@ */ package com.fortify.cli.aviator.fpr.processor; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; @@ -93,20 +94,21 @@ public Map processAuditXML() throws AviatorTechnicalExceptio if (!Files.exists(auditPath)) { logger.debug("audit.xml not found. Creating a default audit.xml."); auditDoc = createDefaultAuditXml(); - } - - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); - factory.setFeature("http://xml.org/sax/features/external-general-entities", false); - factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); - factory.setXIncludeAware(false); - factory.setExpandEntityReferences(false); - factory.setNamespaceAware(true); - DocumentBuilder builder = factory.newDocumentBuilder(); + } else { - try (InputStream auditStream = Files.newInputStream(auditPath)) { - auditDoc = builder.parse(auditStream); + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + factory.setNamespaceAware(true); + DocumentBuilder builder = factory.newDocumentBuilder(); + + try (InputStream auditStream = Files.newInputStream(auditPath)) { + auditDoc = builder.parse(auditStream); + } } NodeList issueNodes = auditDoc.getElementsByTagNameNS(AUDIT_NAMESPACE_URI, "Issue"); for (int i = 0; i < issueNodes.getLength(); i++) { @@ -211,6 +213,31 @@ private AuditIssue processAuditIssue(Element issueElement) { } auditIssueBuilder.threadedComments(threadedComments); + List tagHistoryEntries = new ArrayList<>(); + NodeList clientAuditTrailNodes = issueElement.getElementsByTagNameNS(AUDIT_NAMESPACE_URI, "ClientAuditTrail"); + if (clientAuditTrailNodes.getLength() > 0) { + Element catElement = (Element) clientAuditTrailNodes.item(0); + NodeList thNodes = catElement.getElementsByTagNameNS(AUDIT_NAMESPACE_URI, "TagHistory"); + for (int j = 0; j < thNodes.getLength(); j++) { + Element thElement = (Element) thNodes.item(j); + String thTagId = ""; + String thTagValue = ""; + NodeList thTagNodes = thElement.getElementsByTagNameNS(AUDIT_NAMESPACE_URI, "Tag"); + if (thTagNodes.getLength() > 0) { + Element thTagElement = (Element) thTagNodes.item(0); + thTagId = thTagElement.getAttribute("id"); + thTagValue = Optional.ofNullable(getTagValue(thTagElement)).orElse(""); + } + tagHistoryEntries.add(AuditIssue.TagHistoryEntry.builder() + .tagId(thTagId) + .tagValue(thTagValue) + .editTime(Optional.ofNullable(getFirstElementContentNS(thElement, "EditTime")).orElse("")) + .username(Optional.ofNullable(getFirstElementContentNS(thElement, "Username")).orElse("")) + .build()); + } + } + auditIssueBuilder.tagHistory(tagHistoryEntries); + return auditIssueBuilder.build(); } @@ -247,6 +274,100 @@ public void updateIssueTag(AuditIssue auditIssue, String tagId, String tagValue) updateOrAddTag(issueElement, tagId, tagValue); } + /** + * Performs a simple audit on a single issue: sets the given tag value, + * optionally adds a comment, and optionally suppresses the issue. + * The tag and comment are recorded in ClientAuditTrail for history. + */ + public boolean auditIssue(String instanceId, String tagId, String tagValue, + String comment, String username, boolean suppress) { + if (username == null || username.isBlank()) { + throw new IllegalArgumentException("username must be provided"); + } + Element issueElement = findIssueElement(instanceId); + boolean issueCreated = false; + if (issueElement == null) { + issueElement = createSimpleIssueElement(instanceId); + issueCreated = true; + } + String currentTagValue = getCurrentTagValue(issueElement, tagId); + boolean tagChanged = !java.util.Objects.equals(tagValue, currentTagValue); + boolean suppressChanged = suppress && !"true".equalsIgnoreCase(issueElement.getAttribute("suppressed")); + boolean commentAdded = comment != null && !comment.isBlank(); + + if (!tagChanged && !suppressChanged && !commentAdded) { + return false; + } + + int revision = Optional.ofNullable(issueElement.getAttribute("revision")) + .filter(s -> !s.isEmpty()) + .map(Integer::parseInt) + .orElse(0); + issueElement.setAttribute("revision", String.valueOf(revision + 1)); + + if (tagChanged) { + updateOrAddTag(issueElement, tagId, tagValue); + Element clientAuditTrail = getClientAuditTrailElement(issueElement); + addTagHistory(clientAuditTrail, tagId, tagValue, username); + } + if (commentAdded) { + addCommentToIssueElement(issueElement, comment, username); + } + if (suppressChanged) { + issueElement.setAttribute("suppressed", "true"); + } + return tagChanged || suppressChanged || commentAdded || issueCreated; + } + + private String getCurrentTagValue(Element issueElement, String tagId) { + NodeList tagNodes = issueElement.getElementsByTagNameNS(AUDIT_NAMESPACE_URI, "Tag"); + for (int i = 0; i < tagNodes.getLength(); i++) { + Element tag = (Element) tagNodes.item(i); + if (tag.getAttribute("id").equalsIgnoreCase(tagId)) { + NodeList valueNodes = tag.getElementsByTagNameNS(AUDIT_NAMESPACE_URI, "Value"); + if (valueNodes.getLength() > 0) { + return valueNodes.item(0).getTextContent(); + } + } + } + return null; + } + + private Element createSimpleIssueElement(String instanceId) { + Element issueList = (Element) auditDoc.getElementsByTagNameNS(AUDIT_NAMESPACE_URI, "IssueList").item(0); + if (issueList == null) { + issueList = auditDoc.createElementNS(AUDIT_NAMESPACE_URI, "IssueList"); + auditDoc.getDocumentElement().appendChild(issueList); + } + Element newIssue = auditDoc.createElementNS(AUDIT_NAMESPACE_URI, "Issue"); + newIssue.setAttribute("instanceId", instanceId); + newIssue.setAttribute("revision", "0"); + newIssue.setAttribute("suppressed", "false"); + issueList.appendChild(newIssue); + return newIssue; + } + + /** + * Writes the in-memory audit.xml back into the FPR file. + */ + public void saveAuditXml() { + // Serialize to memory first; only write to FPR if serialization succeeds, + // to avoid leaving a corrupted/truncated audit.xml inside the FPR file. + byte[] serialized; + try { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + transformDomToStream(auditDoc, buffer); + serialized = buffer.toByteArray(); + } catch (Exception e) { + throw new AviatorTechnicalException("Failed to serialize audit.xml", e); + } + try (OutputStream os = Files.newOutputStream(fprHandle.getPath("/audit.xml"))) { + os.write(serialized); + } catch (IOException e) { + throw new AviatorTechnicalException("Failed to write audit.xml back into the FPR file", e); + } + } + private Map updateAuditXml(Map auditResponses, TagMappingConfig tagMappingConfig) throws AviatorTechnicalException { Map remediationCommentTimestamps = new HashMap<>(); for (Map.Entry entry : auditResponses.entrySet()) { @@ -391,6 +512,10 @@ private Element getClientAuditTrailElement(Element issueElement) { } private void addTagHistory(Element clientAuditTrail, String tagId, String tagValue) { + addTagHistory(clientAuditTrail, tagId, tagValue, Constants.USER_NAME); + } + + private void addTagHistory(Element clientAuditTrail, String tagId, String tagValue, String username) { Element tagHistory = auditDoc.createElementNS(AUDIT_NAMESPACE_URI, "TagHistory"); Element tag = auditDoc.createElementNS(AUDIT_NAMESPACE_URI, "Tag"); @@ -405,9 +530,9 @@ private void addTagHistory(Element clientAuditTrail, String tagId, String tagVal editTime.setTextContent(dateFormat.format(new Date())); tagHistory.appendChild(editTime); - Element username = auditDoc.createElementNS(AUDIT_NAMESPACE_URI, "Username"); - username.setTextContent("Fortify Aviator"); - tagHistory.appendChild(username); + Element usernameElement = auditDoc.createElementNS(AUDIT_NAMESPACE_URI, "Username"); + usernameElement.setTextContent(username); + tagHistory.appendChild(usernameElement); clientAuditTrail.appendChild(tagHistory); } diff --git a/fcli-core/fcli-fpr/build.gradle.kts b/fcli-core/fcli-fpr/build.gradle.kts new file mode 100644 index 00000000000..5dfa7f42fc5 --- /dev/null +++ b/fcli-core/fcli-fpr/build.gradle.kts @@ -0,0 +1,6 @@ +plugins { id("fcli.module-conventions") } + +dependencies { + val aviatorCommonRef = project.findProperty("fcliAviatorCommonRef") as String + implementation(project(aviatorCommonRef)) +} diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_common/cli/mixin/FPRFileMixin.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_common/cli/mixin/FPRFileMixin.java new file mode 100644 index 00000000000..a4140034b5d --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_common/cli/mixin/FPRFileMixin.java @@ -0,0 +1,40 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.fpr._common.cli.mixin; + +import java.nio.file.Files; +import java.nio.file.Path; + +import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.common.exception.FcliSimpleException; + +import picocli.CommandLine.Option; + +/** + * Shared mixin providing the {@code --fpr} option for specifying a local FPR file path. + * Creates and returns a validated {@link FprHandle} for accessing the FPR contents. + */ +public class FPRFileMixin { + @Option(names = {"--fpr"}, required = true, order = 1) + private Path fprPath; + + public FprHandle createFprHandle() { + if (fprPath == null || !Files.exists(fprPath)) { + throw new FcliSimpleException("FPR file not found: " + fprPath); + } + if (!fprPath.toString().toLowerCase().endsWith(".fpr")) { + throw new FcliSimpleException("File does not have .fpr extension: " + fprPath); + } + return new FprHandle(fprPath); + } +} diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_common/helper/FPRHelper.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_common/helper/FPRHelper.java new file mode 100644 index 00000000000..78d331769fb --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_common/helper/FPRHelper.java @@ -0,0 +1,223 @@ +/* + * 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.fpr._common.helper; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.aviator.fpr.FPRProcessor; +import com.fortify.cli.aviator.fpr.Vulnerability; +import com.fortify.cli.aviator.fpr.model.AuditIssue; +import com.fortify.cli.aviator.fpr.processor.AuditProcessor; +import com.fortify.cli.aviator.fpr.processor.StreamingFVDLProcessor; +import com.fortify.cli.aviator.util.FprHandle; + +/** + * Helper class for loading and converting FPR vulnerability data + * into the fcli output framework's {@link ObjectNode} format. + */ +public final class FPRHelper { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private FPRHelper() {} + + public record FprLoadResult(List vulnerabilities, Map auditIssueMap) {} + + /** + * Parses an FPR file and returns the list of vulnerabilities. + */ + public static List loadVulnerabilities(FprHandle fprHandle) { + return loadVulnerabilitiesWithAudit(fprHandle).vulnerabilities(); + } + + /** + * Parses an FPR file and returns both the vulnerabilities and the audit issue map. + */ + public static FprLoadResult loadVulnerabilitiesWithAudit(FprHandle fprHandle) { + var auditProcessor = new AuditProcessor(fprHandle); + Map auditIssueMap = auditProcessor.processAuditXML(); + var fprProcessor = new FPRProcessor(fprHandle, auditIssueMap, auditProcessor); + var streamingProcessor = new StreamingFVDLProcessor(fprHandle); + var vulnerabilities = fprProcessor.process(streamingProcessor); + return new FprLoadResult(vulnerabilities, auditIssueMap); + } + + /** + * Converts a {@link Vulnerability} to an {@link ObjectNode} for the output framework. + * Field names are chosen to align with SSC issue field names where possible. + */ + public static ObjectNode toObjectNode(Vulnerability vuln) { + var node = MAPPER.createObjectNode(); + node.put("instanceId", vuln.getInstanceID()); + node.put("category", vuln.getCategory()); + node.put("kingdom", vuln.getKingdom()); + node.put("type", vuln.getType()); + node.put("subtype", vuln.getSubType()); + node.put("analyzerName", vuln.getAnalyzerName()); + node.put("severity", vuln.getInstanceSeverity()); + node.put("confidence", vuln.getConfidence()); + node.put("priority", vuln.getPriority()); + node.put("classId", vuln.getClassID()); + node.put("audited", vuln.isAudited()); + node.put("suppressed", vuln.isSuppressed()); + node.put("issueStatus", vuln.getIssueStatus()); + + if (vuln.getAccuracy() != null) { node.put("accuracy", vuln.getAccuracy()); } + if (vuln.getImpact() != null) { node.put("impact", vuln.getImpact()); } + if (vuln.getProbability() != null) { node.put("probability", vuln.getProbability()); } + + node.put("packageName", vuln.getPackageName()); + node.put("className", vuln.getClassName()); + node.put("functionName", vuln.getFunctionName()); + node.put("sourceFunction", vuln.getSourceFunction()); + node.put("sinkFunction", vuln.getSinkFunction()); + + if (vuln.getSource() != null) { + node.put("primaryFile", vuln.getSource().getFilename()); + node.put("primaryLine", vuln.getSource().getLine()); + } else if (vuln.getSink() != null) { + node.put("primaryFile", vuln.getSink().getFilename()); + node.put("primaryLine", vuln.getSink().getLine()); + } else if (!vuln.getFiles().isEmpty()) { + var firstFile = vuln.getFiles().get(0); + node.put("primaryFile", firstFile.getName()); + } + + node.put("shortDescription", vuln.getShortDescription()); + node.put("lastComment", vuln.getLastComment()); + + if (!vuln.getTaintFlags().isEmpty()) { + ArrayNode flags = MAPPER.createArrayNode(); + vuln.getTaintFlags().forEach(flags::add); + node.set("taintFlags", flags); + } + + return node; + } + + /** + * Converts a {@link Vulnerability} to a detailed {@link ObjectNode} with all + * available fields, including explanation, source/sink context, stack traces, + * knowledge metadata, and DAST fields. + */ + public static ObjectNode toDetailObjectNode(Vulnerability vuln) { + var node = toObjectNode(vuln); + + node.put("subcategory", vuln.getSubcategory()); + node.put("explanation", vuln.getExplanation()); + node.put("defaultSeverity", vuln.getDefaultSeverity()); + if (vuln.getLikelihood() != null) { node.put("likelihood", vuln.getLikelihood()); } + node.put("analysisType", vuln.getAnalysisType()); + node.put("buildId", vuln.getBuildId()); + + if (vuln.getSource() != null) { + var src = MAPPER.createObjectNode(); + src.put("file", vuln.getSource().getFilename()); + src.put("line", vuln.getSource().getLine()); + src.put("code", vuln.getSource().getCode()); + node.set("source", src); + } + if (vuln.getSink() != null) { + var snk = MAPPER.createObjectNode(); + snk.put("file", vuln.getSink().getFilename()); + snk.put("line", vuln.getSink().getLine()); + snk.put("code", vuln.getSink().getCode()); + node.set("sink", snk); + } + + node.put("sourceContext", vuln.getSourceContext()); + node.put("sinkContext", vuln.getSinkContext()); + node.put("commentUsers", vuln.getCommentUsers()); + + if (!vuln.getStackTrace().isEmpty()) { + ArrayNode traces = MAPPER.createArrayNode(); + for (var trace : vuln.getStackTrace()) { + ArrayNode traceArray = MAPPER.createArrayNode(); + for (var element : trace) { + var elem = MAPPER.createObjectNode(); + elem.put("file", element.getFilename()); + elem.put("line", element.getLine()); + elem.put("code", element.getCode()); + traceArray.add(elem); + } + traces.add(traceArray); + } + node.set("traces", traces); + } + + if (!vuln.getKnowledge().isEmpty()) { + var knowledgeNode = MAPPER.createObjectNode(); + vuln.getKnowledge().forEach((k, v) -> { + if (k != null && v != null) { knowledgeNode.put(k, v); } + }); + node.set("knowledge", knowledgeNode); + } + + if (vuln.getRequestMethod() != null) { node.put("requestMethod", vuln.getRequestMethod()); } + if (vuln.getRequestHeaders() != null) { node.put("requestHeaders", vuln.getRequestHeaders()); } + if (vuln.getRequestParameters() != null) { node.put("requestParameters", vuln.getRequestParameters()); } + if (vuln.getRequestBody() != null) { node.put("requestBody", vuln.getRequestBody()); } + if (vuln.getAttackPayload() != null) { node.put("attackPayload", vuln.getAttackPayload()); } + if (vuln.getAttackType() != null) { node.put("attackType", vuln.getAttackType()); } + if (vuln.getResponse() != null) { node.put("response", vuln.getResponse()); } + if (vuln.getVulnerableParameter() != null) { node.put("vulnerableParameter", vuln.getVulnerableParameter()); } + + return node; + } + + /** + * Embeds audit history into a detail {@link ObjectNode}: the full comment thread, + * all current tag values, and the tag change history from ClientAuditTrail. + */ + public static void embedAuditHistory(ObjectNode node, AuditIssue auditIssue) { + if (auditIssue == null) { return; } + + node.put("revision", auditIssue.getRevision()); + + if (!auditIssue.getTags().isEmpty()) { + var tagsNode = MAPPER.createObjectNode(); + auditIssue.getTags().forEach((k, v) -> { + if (k != null) { tagsNode.put(k, v != null ? v : ""); } + }); + node.set("auditTags", tagsNode); + } + + if (!auditIssue.getThreadedComments().isEmpty()) { + ArrayNode commentsArray = MAPPER.createArrayNode(); + for (var comment : auditIssue.getThreadedComments()) { + var c = MAPPER.createObjectNode(); + c.put("content", comment.getContent()); + c.put("username", comment.getUsername()); + c.put("timestamp", comment.getTimestamp()); + commentsArray.add(c); + } + node.set("comments", commentsArray); + } + + if (!auditIssue.getTagHistory().isEmpty()) { + ArrayNode historyArray = MAPPER.createArrayNode(); + for (var entry : auditIssue.getTagHistory()) { + var h = MAPPER.createObjectNode(); + h.put("tagId", entry.getTagId()); + h.put("tagValue", entry.getTagValue()); + h.put("editTime", entry.getEditTime()); + h.put("username", entry.getUsername()); + historyArray.add(h); + } + node.set("tagHistory", historyArray); + } + } +} diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_main/cli/cmd/FPRCommands.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_main/cli/cmd/FPRCommands.java new file mode 100644 index 00000000000..65dd0640a49 --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_main/cli/cmd/FPRCommands.java @@ -0,0 +1,33 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.fpr._main.cli.cmd; + +import static com.fortify.cli.common.cli.util.FcliModuleCategories.UTIL; + +import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; +import com.fortify.cli.common.cli.util.FcliModuleCategory; +import com.fortify.cli.fpr.issue.cli.cmd.FPRIssueCommands; +import com.fortify.cli.fpr.remediation.cli.cmd.FPRRemediationCommands; + +import picocli.CommandLine.Command; + +@FcliModuleCategory(UTIL) +@Command( + name = "fpr", + resourceBundle = "com.fortify.cli.fpr.i18n.FPRMessages", + subcommands = { + FPRIssueCommands.class, + FPRRemediationCommands.class + } +) +public class FPRCommands extends AbstractContainerCommand {} diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueAuditCommand.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueAuditCommand.java new file mode 100644 index 00000000000..4cb1c6e2b6b --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueAuditCommand.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.fpr.issue.cli.cmd; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.aviator.fpr.processor.AuditProcessor; +import com.fortify.cli.aviator.util.Constants; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.exception.FcliTechnicalException; +import com.fortify.cli.common.json.producer.IObjectNodeProducer; +import com.fortify.cli.common.json.producer.ObjectNodeProducerApplyFrom; +import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; +import com.fortify.cli.common.util.DisableTest; +import com.fortify.cli.common.util.DisableTest.TestType; +import com.fortify.cli.fpr._common.cli.mixin.FPRFileMixin; + +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command(name = "audit") +public class FPRIssueAuditCommand extends AbstractOutputCommand { + private static final ObjectMapper MAPPER = new ObjectMapper(); + // Canonical SSC analysis tag values; lookup is case-insensitive. + private static final Map VALID_ANALYSIS_VALUES; + static { + VALID_ANALYSIS_VALUES = new LinkedHashMap<>(); + for (var v : new String[] { + Constants.NOT_AN_ISSUE, + Constants.EXPLOITABLE, + Constants.SUSPICIOUS, + Constants.RELIABILITY_ISSUE, + Constants.FALSE_POSITIVE, + Constants.BAD_PRACTICE + }) { + VALID_ANALYSIS_VALUES.put(v.toLowerCase(), v); + } + } + + @Getter @Mixin private OutputHelperMixins.Update outputHelper; + @Mixin private FPRFileMixin fprFileMixin; + + @DisableTest(TestType.MULTI_OPT_PLURAL_NAME) + @Option(names = {"--instance-ids"}, required = true, split = ",", order = 2) + private List instanceIds; + + @Option(names = {"--analysis"}, required = true, order = 3) + private String analysis; + + @Option(names = {"--comment"}, order = 4) + private String comment; + + @Option(names = {"--suppress"}, order = 5) + private boolean suppress; + + @Option(names = {"--user"}, order = 6) + private String user; + + @Override + protected IObjectNodeProducer getObjectNodeProducer() { + var canonicalAnalysis = validateAnalysis(analysis); + var username = resolveUsername(); + var uniqueIds = dedupePreservingOrder(instanceIds); + var results = applyAudits(uniqueIds, canonicalAnalysis, username); + return streamingObjectNodeProducerBuilder(ObjectNodeProducerApplyFrom.SPEC) + .streamSupplier(results::stream) + .build(); + } + + private List applyAudits(List ids, String canonicalAnalysis, String username) { + try (var fprHandle = fprFileMixin.createFprHandle()) { + var auditProcessor = new AuditProcessor(fprHandle); + auditProcessor.processAuditXML(); + var results = new ArrayList(ids.size()); + boolean anyChanged = false; + for (var id : ids) { + boolean changed = auditProcessor.auditIssue(id, Constants.ANALYSIS_TAG_ID, + canonicalAnalysis, comment, username, suppress); + anyChanged |= changed; + results.add(buildResultRow(id, canonicalAnalysis, username, changed)); + } + if (anyChanged) { + auditProcessor.saveAuditXml(); + } + return results; + } catch (IOException e) { + throw new FcliTechnicalException("Error processing FPR file", e); + } + } + + private ObjectNode buildResultRow(String id, String canonicalAnalysis, String username, boolean changed) { + var row = MAPPER.createObjectNode(); + row.put("instanceId", id); + row.put("analysis", canonicalAnalysis); + row.put("comment", comment != null ? comment : ""); + row.put("suppressed", suppress); + row.put("user", username); + row.put("__action__", changed ? "AUDITED" : "UNCHANGED"); + return row; + } + + private static List dedupePreservingOrder(List ids) { + var seen = new LinkedHashSet(); + for (var id : ids) { + if (id != null && !id.isBlank()) { + seen.add(id.trim()); + } + } + if (seen.isEmpty()) { + throw new FcliSimpleException("--instance-ids must contain at least one non-blank value"); + } + return new ArrayList<>(seen); + } + + private String validateAnalysis(String value) { + if (value == null) { return null; } + var canonical = VALID_ANALYSIS_VALUES.get(value.toLowerCase()); + if (canonical == null) { + throw new FcliSimpleException("Invalid --analysis value '" + value + + "'; valid values: " + String.join(", ", VALID_ANALYSIS_VALUES.values())); + } + return canonical; + } + + private String resolveUsername() { + if (user != null && !user.isBlank()) { return user; } + var sysUser = System.getProperty("user.name"); + return (sysUser != null && !sysUser.isBlank()) ? sysUser : "fcli"; + } + + @Override + public boolean isSingular() { + return false; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueCommands.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueCommands.java new file mode 100644 index 00000000000..0fd2fc6dde8 --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueCommands.java @@ -0,0 +1,28 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.fpr.issue.cli.cmd; + +import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; + +import picocli.CommandLine.Command; + +@Command( + name = "issue", + subcommands = { + FPRIssueGetCommand.class, + FPRIssueListCommand.class, + FPRIssueCountCommand.class, + FPRIssueAuditCommand.class + } +) +public class FPRIssueCommands extends AbstractContainerCommand {} diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueCountCommand.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueCountCommand.java new file mode 100644 index 00000000000..4e509323bb3 --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueCountCommand.java @@ -0,0 +1,89 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.fpr.issue.cli.cmd; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.aviator.fpr.Vulnerability; +import com.fortify.cli.common.exception.FcliTechnicalException; +import com.fortify.cli.common.json.producer.IObjectNodeProducer; +import com.fortify.cli.common.json.producer.ObjectNodeProducerApplyFrom; +import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; +import com.fortify.cli.fpr._common.cli.mixin.FPRFileMixin; +import com.fortify.cli.fpr._common.helper.FPRHelper; + +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +@Command(name = "count") +public class FPRIssueCountCommand extends AbstractOutputCommand { + private static final ObjectMapper MAPPER = new ObjectMapper(); + @Getter @Mixin private OutputHelperMixins.TableNoQuery outputHelper; + @Mixin private FPRFileMixin fprFileMixin; + + @Override + protected IObjectNodeProducer getObjectNodeProducer() { + List vulnerabilities = loadVulnerabilities(); + Map counts = new LinkedHashMap<>(); + for (var vuln : vulnerabilities) { + var category = vuln.getCategory() != null ? vuln.getCategory() : "Unknown"; + counts.computeIfAbsent(category, k -> new long[3]); + long[] c = counts.get(category); + c[0]++; + if (vuln.isAudited()) { c[1]++; } + if (vuln.isSuppressed()) { c[2]++; } + } + int total = vulnerabilities.size(); + return streamingObjectNodeProducerBuilder(ObjectNodeProducerApplyFrom.SPEC) + .streamSupplier(() -> toStream(counts, total)) + .build(); + } + + private List loadVulnerabilities() { + try (var fprHandle = fprFileMixin.createFprHandle()) { + return FPRHelper.loadVulnerabilities(fprHandle); + } catch (IOException e) { + throw new FcliTechnicalException("Error processing FPR file", e); + } + } + + private Stream toStream(Map counts, int total) { + var summary = counts.entrySet().stream().map(e -> { + var node = MAPPER.createObjectNode(); + node.put("category", e.getKey()); + node.put("total", e.getValue()[0]); + node.put("audited", e.getValue()[1]); + node.put("suppressed", e.getValue()[2]); + return node; + }); + var totalNode = MAPPER.createObjectNode(); + totalNode.put("category", "TOTAL"); + totalNode.put("total", total); + totalNode.put("audited", counts.values().stream().mapToLong(c -> c[1]).sum()); + totalNode.put("suppressed", counts.values().stream().mapToLong(c -> c[2]).sum()); + return Stream.concat(summary, Stream.of(totalNode)); + } + + @Override + public boolean isSingular() { + return false; + } +} diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueGetCommand.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueGetCommand.java new file mode 100644 index 00000000000..65f8648379d --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueGetCommand.java @@ -0,0 +1,87 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.fpr.issue.cli.cmd; + +import java.io.IOException; +import java.util.Set; + +import com.fasterxml.jackson.databind.node.ObjectNode; +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; +import com.fortify.cli.common.util.DisableTest; +import com.fortify.cli.common.util.DisableTest.TestType; +import com.fortify.cli.fpr._common.cli.mixin.FPRFileMixin; +import com.fortify.cli.fpr._common.helper.FPRHelper; + +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command(name = OutputHelperMixins.Get.CMD_NAME) +public class FPRIssueGetCommand extends AbstractOutputCommand implements IJsonNodeSupplier { + private static final Set VALID_EMBEDS = Set.of("history"); + + @Getter @Mixin private OutputHelperMixins.Get outputHelper; + @Mixin private FPRFileMixin fprFileMixin; + + @Option(names = {"--instance-id"}, required = true, order = 2) + private String instanceId; + + @DisableTest(TestType.MULTI_OPT_PLURAL_NAME) + @Option(names = {"--embed"}, split = ",", order = 3) + private Set embed; + + @Override + public ObjectNode getJsonNode() { + validateEmbed(); + try (var fprHandle = fprFileMixin.createFprHandle()) { + var result = FPRHelper.loadVulnerabilitiesWithAudit(fprHandle); + var vuln = result.vulnerabilities().stream() + .filter(v -> instanceId.equals(v.getInstanceID())) + .findFirst() + .orElseThrow(() -> new FcliSimpleException( + "Issue with instanceId '" + instanceId + "' not found in FPR file")); + var node = FPRHelper.toDetailObjectNode(vuln); + if (hasEmbed("history")) { + FPRHelper.embedAuditHistory(node, result.auditIssueMap().get(instanceId)); + } + return node; + } catch (IOException e) { + throw new FcliTechnicalException("Error processing FPR file", e); + } + } + + private boolean hasEmbed(String name) { + return embed != null && embed.contains(name); + } + + private void validateEmbed() { + if (embed != null) { + for (var e : embed) { + if (!VALID_EMBEDS.contains(e)) { + throw new FcliSimpleException("Invalid --embed value '" + e + + "'; valid values: " + String.join(", ", VALID_EMBEDS)); + } + } + } + } + + @Override + public boolean isSingular() { + return true; + } +} diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueListCommand.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueListCommand.java new file mode 100644 index 00000000000..0cba5566746 --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueListCommand.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.fpr.issue.cli.cmd; + +import java.io.IOException; +import java.util.List; + +import com.fortify.cli.aviator.fpr.Vulnerability; +import com.fortify.cli.common.exception.FcliTechnicalException; +import com.fortify.cli.common.json.producer.IObjectNodeProducer; +import com.fortify.cli.common.json.producer.ObjectNodeProducerApplyFrom; +import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; +import com.fortify.cli.fpr._common.cli.mixin.FPRFileMixin; +import com.fortify.cli.fpr._common.helper.FPRHelper; + +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +@Command(name = OutputHelperMixins.List.CMD_NAME) +public class FPRIssueListCommand extends AbstractOutputCommand { + @Getter @Mixin private OutputHelperMixins.List outputHelper; + @Mixin private FPRFileMixin fprFileMixin; + + @Override + protected IObjectNodeProducer getObjectNodeProducer() { + List vulnerabilities = loadVulnerabilities(); + return streamingObjectNodeProducerBuilder(ObjectNodeProducerApplyFrom.SPEC) + .streamSupplier(() -> vulnerabilities.stream().map(FPRHelper::toObjectNode)) + .build(); + } + + private List loadVulnerabilities() { + try (var fprHandle = fprFileMixin.createFprHandle()) { + return FPRHelper.loadVulnerabilities(fprHandle); + } catch (IOException e) { + throw new FcliTechnicalException("Error processing FPR file", e); + } + } + + @Override + public boolean isSingular() { + return false; + } +} diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/remediation/cli/cmd/FPRApplyRemediationsCommand.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/remediation/cli/cmd/FPRApplyRemediationsCommand.java new file mode 100644 index 00000000000..3eb90a6ce01 --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/remediation/cli/cmd/FPRApplyRemediationsCommand.java @@ -0,0 +1,65 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.fpr.remediation.cli.cmd; + +import java.io.IOException; +import java.nio.file.Path; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.aviator.fpr.processor.RemediationProcessor; +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; +import com.fortify.cli.fpr._common.cli.mixin.FPRFileMixin; + +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command(name = "apply-remediations") +public class FPRApplyRemediationsCommand extends AbstractOutputCommand implements IJsonNodeSupplier { + private static final ObjectMapper MAPPER = new ObjectMapper(); + @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; + @Mixin private FPRFileMixin fprFileMixin; + + @Option(names = {"--source-root"}, required = true, order = 2) + private Path sourceRoot; + + @Override + public ObjectNode getJsonNode() { + try (var fprHandle = fprFileMixin.createFprHandle()) { + if (!fprHandle.hasRemediations()) { + throw new FcliSimpleException("FPR does not contain remediations.xml; no remediations to apply"); + } + var processor = new RemediationProcessor(fprHandle, sourceRoot.toAbsolutePath().toString()); + var metric = processor.processRemediationXML(); + var result = MAPPER.createObjectNode(); + result.put("totalRemediations", metric.totalRemediations()); + result.put("appliedRemediations", metric.appliedRemediations()); + result.put("skippedRemediations", metric.skippedRemediations()); + result.put("__action__", metric.appliedRemediations() > 0 ? "APPLIED" : "SKIPPED"); + return result; + } catch (IOException e) { + throw new FcliTechnicalException("Error processing FPR file", e); + } + } + + @Override + public boolean isSingular() { + return true; + } +} diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/remediation/cli/cmd/FPRRemediationCommands.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/remediation/cli/cmd/FPRRemediationCommands.java new file mode 100644 index 00000000000..083bc25d644 --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/remediation/cli/cmd/FPRRemediationCommands.java @@ -0,0 +1,25 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.fpr.remediation.cli.cmd; + +import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; + +import picocli.CommandLine.Command; + +@Command( + name = "remediation", + subcommands = { + FPRApplyRemediationsCommand.class + } +) +public class FPRRemediationCommands extends AbstractContainerCommand {} diff --git a/fcli-core/fcli-fpr/src/main/resources/com/fortify/cli/fpr/i18n/FPRMessages.properties b/fcli-core/fcli-fpr/src/main/resources/com/fortify/cli/fpr/i18n/FPRMessages.properties new file mode 100644 index 00000000000..c32ccb19be6 --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/resources/com/fortify/cli/fpr/i18n/FPRMessages.properties @@ -0,0 +1,37 @@ +# FPR Module Messages + +# Top-level command +fcli.fpr.usage.header = Commands for working with local FPR (Fortify Project Result) files. + +# Issue commands +fcli.fpr.issue.usage.header = Commands for listing and analyzing issues from a local FPR file. +fcli.fpr.issue.list.usage.header = List all issues/vulnerabilities from an FPR file. +fcli.fpr.issue.get.usage.header = Get detailed information about a specific issue from an FPR file. +fcli.fpr.issue.count.usage.header = Count issues by category from an FPR file. +fcli.fpr.issue.audit.usage.header = Audit a single issue in an FPR file by setting its analysis tag value. + +# Remediation commands +fcli.fpr.remediation.usage.header = Commands for applying remediations from an FPR file. +fcli.fpr.remediation.apply-remediations.usage.header = Apply auto-remediations from an FPR file to source code. + +# Shared options +fcli.fpr.issue.list.fpr = Path to the local FPR file to read. +fcli.fpr.issue.get.fpr = Path to the local FPR file to read. +fcli.fpr.issue.get.instance-id = The instanceId of the issue to retrieve. +fcli.fpr.issue.get.embed = Comma-separated list of extra data to embed. Valid values: history. +fcli.fpr.issue.count.fpr = Path to the local FPR file to read. +fcli.fpr.issue.audit.fpr = Path to the local FPR file to audit. +fcli.fpr.issue.audit.instance-ids = Comma-separated list of one or more issue instanceIds to audit. +fcli.fpr.issue.audit.analysis = The analysis value to set. Valid values (case-insensitive): 'Not an Issue', 'Exploitable', 'Suspicious', 'Reliability Issue', 'False Positive', 'Bad Practice'. +fcli.fpr.issue.audit.comment = Optional comment to add to the issue audit trail. +fcli.fpr.issue.audit.suppress = Suppress the issue in the FPR file. +fcli.fpr.issue.audit.user = Username to record in the audit trail. Defaults to the current operating system user. +fcli.fpr.remediation.apply-remediations.fpr = Path to the local FPR file containing remediations. +fcli.fpr.remediation.apply-remediations.source-root = Root directory of the source code to apply remediations to. + +# Default output columns +fcli.fpr.issue.list.output.table.args = instanceId,category,priority,analyzerName,primaryFile,primaryLine,audited,suppressed +fcli.fpr.issue.get.output.table.args = instanceId,category,kingdom,type,subtype,analyzerName,priority,primaryFile,primaryLine,audited,suppressed,issueStatus,shortDescription +fcli.fpr.issue.count.output.table.args = category,total,audited,suppressed +fcli.fpr.issue.audit.output.table.args = instanceId,analysis,comment,suppressed,user,__action__ +fcli.fpr.remediation.apply-remediations.output.table.args = totalRemediations,appliedRemediations,skippedRemediations,__action__ diff --git a/gradle.properties b/gradle.properties index 4adf83d5896..b1e547ec46f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,7 +15,8 @@ fcliSCSastRef=:fcli-core:fcli-sc-sast fcliSSCRef=:fcli-core:fcli-ssc fcliToolRef=:fcli-core:fcli-tool fcliLicenseRef=:fcli-core:fcli-license -fcliUtilRef=:fcli-core:fcli-util +fcliUtilRef=:fcli-core:fcli-util +fcliFPRRef=:fcli-core:fcli-fpr fcliBomRef=:fcli-other:fcli-bom fcliFunctionalTestRef=:fcli-other:fcli-functional-test From 34bd7408c8879ab72a4dbc82d2ee6caa967407f9 Mon Sep 17 00:00:00 2001 From: Sangamesh Vijaykumar Date: Tue, 28 Apr 2026 19:27:23 +0530 Subject: [PATCH 2/5] feat: support assigning a user to issues via --assign-user and updating custom tags via --custom-tags in fcli fpr issue audit --- .../cli/aviator/fpr/model/AuditIssue.java | 1 + .../aviator/fpr/processor/AuditProcessor.java | 69 ++++++++++++++-- .../cli/fpr/_common/helper/FPRHelper.java | 63 ++++++++++++++- .../issue/cli/cmd/FPRIssueAuditCommand.java | 79 ++++++++++++++++--- .../cli/fpr/i18n/FPRMessages.properties | 6 +- 5 files changed, 198 insertions(+), 20 deletions(-) diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/model/AuditIssue.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/model/AuditIssue.java index 8e6c2bc34c8..2259565957e 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/model/AuditIssue.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/model/AuditIssue.java @@ -32,6 +32,7 @@ public class AuditIssue { @Builder.Default private Map tags = new HashMap<>(); @Builder.Default private List threadedComments = new ArrayList<>(); @Builder.Default private List tagHistory = new ArrayList<>(); + private String assignedUser; public void addTag(String tagId, String tagValue) { if (tagId != null) { diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/AuditProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/AuditProcessor.java index 12a79ab6160..1e5eaaf6aa6 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/AuditProcessor.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/AuditProcessor.java @@ -185,6 +185,9 @@ private AuditIssue processAuditIssue(Element issueElement) { AuditIssue.AuditIssueBuilder auditIssueBuilder = AuditIssue.builder(); auditIssueBuilder.instanceId(issueElement.getAttribute("instanceId")); + if (issueElement.hasAttribute("assignedUser")) { + auditIssueBuilder.assignedUser(issueElement.getAttribute("assignedUser")); + } auditIssueBuilder.suppressed(Boolean.parseBoolean(issueElement.getAttribute("suppressed"))); String revisionStr = issueElement.getAttribute("revision"); @@ -281,21 +284,64 @@ public void updateIssueTag(AuditIssue auditIssue, String tagId, String tagValue) */ public boolean auditIssue(String instanceId, String tagId, String tagValue, String comment, String username, boolean suppress) { + return auditIssueMulti(instanceId, java.util.Map.of(tagId, tagValue), comment, username, suppress, null); + } + + /** + * Backwards-compatible overload without an assigned user. + */ + public boolean auditIssueMulti(String instanceId, java.util.Map tagIdToValue, + String comment, String username, boolean suppress) { + return auditIssueMulti(instanceId, tagIdToValue, comment, username, suppress, null); + } + + /** + * Audits a single issue, applying multiple tag changes atomically: bumps revision once, + * writes only the tags whose value actually changes (and a TagHistory entry for each), + * appends an optional comment once, optionally suppresses the issue, and optionally + * assigns the issue to a user (stored as the {@code assignedUser} attribute on the + * {@code } element). Returns true if anything changed, false if the call was a no-op. + */ + public boolean auditIssueMulti(String instanceId, java.util.Map tagIdToValue, + String comment, String username, boolean suppress, + String assignedUser) { if (username == null || username.isBlank()) { throw new IllegalArgumentException("username must be provided"); } + if (tagIdToValue == null) { tagIdToValue = java.util.Map.of(); } + Element issueElement = findIssueElement(instanceId); boolean issueCreated = false; if (issueElement == null) { issueElement = createSimpleIssueElement(instanceId); issueCreated = true; } - String currentTagValue = getCurrentTagValue(issueElement, tagId); - boolean tagChanged = !java.util.Objects.equals(tagValue, currentTagValue); + + java.util.Map changedTags = new java.util.LinkedHashMap<>(); + for (var entry : tagIdToValue.entrySet()) { + String tagId = entry.getKey(); + String tagValue = entry.getValue(); + if (tagId == null || tagId.isBlank() || tagValue == null) { continue; } + String currentTagValue = getCurrentTagValue(issueElement, tagId); + if (!java.util.Objects.equals(tagValue, currentTagValue)) { + changedTags.put(tagId, tagValue); + } + } + boolean suppressChanged = suppress && !"true".equalsIgnoreCase(issueElement.getAttribute("suppressed")); boolean commentAdded = comment != null && !comment.isBlank(); - if (!tagChanged && !suppressChanged && !commentAdded) { + boolean assignChanged = false; + if (assignedUser != null) { + String currentAssigned = issueElement.hasAttribute("assignedUser") + ? issueElement.getAttribute("assignedUser") : ""; + // Empty string clears the assignment. + if (!assignedUser.equals(currentAssigned)) { + assignChanged = true; + } + } + + if (changedTags.isEmpty() && !suppressChanged && !commentAdded && !assignChanged) { return false; } @@ -305,10 +351,12 @@ public boolean auditIssue(String instanceId, String tagId, String tagValue, .orElse(0); issueElement.setAttribute("revision", String.valueOf(revision + 1)); - if (tagChanged) { - updateOrAddTag(issueElement, tagId, tagValue); + if (!changedTags.isEmpty()) { Element clientAuditTrail = getClientAuditTrailElement(issueElement); - addTagHistory(clientAuditTrail, tagId, tagValue, username); + for (var ct : changedTags.entrySet()) { + updateOrAddTag(issueElement, ct.getKey(), ct.getValue()); + addTagHistory(clientAuditTrail, ct.getKey(), ct.getValue(), username); + } } if (commentAdded) { addCommentToIssueElement(issueElement, comment, username); @@ -316,7 +364,14 @@ public boolean auditIssue(String instanceId, String tagId, String tagValue, if (suppressChanged) { issueElement.setAttribute("suppressed", "true"); } - return tagChanged || suppressChanged || commentAdded || issueCreated; + if (assignChanged) { + if (assignedUser.isEmpty()) { + issueElement.removeAttribute("assignedUser"); + } else { + issueElement.setAttribute("assignedUser", assignedUser); + } + } + return !changedTags.isEmpty() || suppressChanged || commentAdded || assignChanged || issueCreated; } private String getCurrentTagValue(Element issueElement, String tagId) { diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_common/helper/FPRHelper.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_common/helper/FPRHelper.java index 78d331769fb..fc518d8007e 100644 --- a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_common/helper/FPRHelper.java +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_common/helper/FPRHelper.java @@ -20,8 +20,12 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.fortify.cli.aviator.fpr.FPRProcessor; import com.fortify.cli.aviator.fpr.Vulnerability; +import com.fortify.cli.aviator.fpr.filter.FilterTemplate; +import com.fortify.cli.aviator.fpr.filter.TagDefinition; +import com.fortify.cli.aviator.fpr.filter.TagValue; import com.fortify.cli.aviator.fpr.model.AuditIssue; import com.fortify.cli.aviator.fpr.processor.AuditProcessor; +import com.fortify.cli.aviator.fpr.processor.FilterTemplateParser; import com.fortify.cli.aviator.fpr.processor.StreamingFVDLProcessor; import com.fortify.cli.aviator.util.FprHandle; @@ -186,6 +190,9 @@ public static void embedAuditHistory(ObjectNode node, AuditIssue auditIssue) { if (auditIssue == null) { return; } node.put("revision", auditIssue.getRevision()); + if (auditIssue.getAssignedUser() != null && !auditIssue.getAssignedUser().isBlank()) { + node.put("assignedUser", auditIssue.getAssignedUser()); + } if (!auditIssue.getTags().isEmpty()) { var tagsNode = MAPPER.createObjectNode(); @@ -220,4 +227,58 @@ public static void embedAuditHistory(ObjectNode node, AuditIssue auditIssue) { node.set("tagHistory", historyArray); } } -} + /** + * Loads the FPR's filter template (if present), exposing tag definitions + * for resolving custom-tag names and their valid values. Returns an empty + * Optional if the FPR has no filtertemplate.xml. + */ + public static java.util.Optional loadFilterTemplate(FprHandle fprHandle) { + var auditProcessor = new com.fortify.cli.aviator.fpr.processor.AuditProcessor(fprHandle); + auditProcessor.processAuditXML(); + return new FilterTemplateParser(fprHandle, auditProcessor).parseFilterTemplate(); + } + + /** + * Resolves a user-supplied tag name (or GUID) and value to the canonical + * tagId / tagValue pair for use with AuditProcessor. Tag and value lookups + * are case-insensitive. If the tag is not found in the filter template, + * the input is treated as a raw GUID. If the value is not in the tag's + * defined values and the tag is not extensible, throws IllegalArgumentException. + */ + public static java.util.Map.Entry resolveCustomTag( + FilterTemplate filterTemplate, String tagNameOrId, String value) { + if (tagNameOrId == null || tagNameOrId.isBlank()) { + throw new IllegalArgumentException("Tag name/id must not be blank"); + } + if (value == null) { + throw new IllegalArgumentException("Tag value must not be null for tag '" + tagNameOrId + "'"); + } + if (filterTemplate == null || filterTemplate.getTagDefinitions() == null) { + return java.util.Map.entry(tagNameOrId, value); + } + TagDefinition match = null; + for (var def : filterTemplate.getTagDefinitions()) { + if (tagNameOrId.equalsIgnoreCase(def.getName()) || tagNameOrId.equalsIgnoreCase(def.getId())) { + match = def; + break; + } + } + if (match == null) { + return java.util.Map.entry(tagNameOrId, value); + } + if (match.getValues() != null) { + for (TagValue tv : match.getValues()) { + if (tv.getValue() != null && tv.getValue().equalsIgnoreCase(value)) { + return java.util.Map.entry(match.getId(), tv.getValue()); + } + } + } + if (!match.isExtensible()) { + var allowed = match.getValues() == null ? java.util.List.of() + : match.getValues().stream().map(TagValue::getValue).toList(); + throw new IllegalArgumentException("Invalid value '" + value + "' for tag '" + + match.getName() + "'; valid values: " + String.join(", ", allowed)); + } + return java.util.Map.entry(match.getId(), value); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueAuditCommand.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueAuditCommand.java index 4cb1c6e2b6b..542090d26cc 100644 --- a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueAuditCommand.java +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueAuditCommand.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.aviator.fpr.filter.FilterTemplate; import com.fortify.cli.aviator.fpr.processor.AuditProcessor; import com.fortify.cli.aviator.util.Constants; import com.fortify.cli.common.exception.FcliSimpleException; @@ -32,6 +33,7 @@ import com.fortify.cli.common.util.DisableTest; import com.fortify.cli.common.util.DisableTest.TestType; import com.fortify.cli.fpr._common.cli.mixin.FPRFileMixin; +import com.fortify.cli.fpr._common.helper.FPRHelper; import lombok.Getter; import picocli.CommandLine.Command; @@ -64,20 +66,28 @@ public class FPRIssueAuditCommand extends AbstractOutputCommand { @Option(names = {"--instance-ids"}, required = true, split = ",", order = 2) private List instanceIds; - @Option(names = {"--analysis"}, required = true, order = 3) + @Option(names = {"--analysis"}, order = 3) private String analysis; - @Option(names = {"--comment"}, order = 4) + @DisableTest(TestType.MULTI_OPT_PLURAL_NAME) + @Option(names = {"--custom-tags", "-t"}, split = ",", paramLabel = "TAG=VALUE", order = 4) + private Map customTags; + + @Option(names = {"--comment"}, order = 5) private String comment; - @Option(names = {"--suppress"}, order = 5) + @Option(names = {"--suppress"}, order = 6) private boolean suppress; - @Option(names = {"--user"}, order = 6) + @Option(names = {"--user"}, order = 7) private String user; + @Option(names = {"--assign-user"}, order = 8) + private String assignUser; + @Override protected IObjectNodeProducer getObjectNodeProducer() { + validateAtLeastOneAction(); var canonicalAnalysis = validateAnalysis(analysis); var username = resolveUsername(); var uniqueIds = dedupePreservingOrder(instanceIds); @@ -91,14 +101,18 @@ private List applyAudits(List ids, String canonicalAnalysis, try (var fprHandle = fprFileMixin.createFprHandle()) { var auditProcessor = new AuditProcessor(fprHandle); auditProcessor.processAuditXML(); + + // Resolve all custom-tag inputs to canonical (tagId, tagValue) pairs once. + Map resolvedTags = resolveTagsForFpr(fprHandle, canonicalAnalysis); + var results = new ArrayList(ids.size()); boolean anyChanged = false; for (var id : ids) { - boolean changed = auditProcessor.auditIssue(id, Constants.ANALYSIS_TAG_ID, - canonicalAnalysis, comment, username, suppress); + boolean changed = auditProcessor.auditIssueMulti(id, resolvedTags, comment, username, suppress, assignUser); anyChanged |= changed; - results.add(buildResultRow(id, canonicalAnalysis, username, changed)); + results.add(buildResultRow(id, canonicalAnalysis, resolvedTags, username, changed)); } + // (assignedUser already included by buildResultRow when set) if (anyChanged) { auditProcessor.saveAuditXml(); } @@ -108,17 +122,62 @@ private List applyAudits(List ids, String canonicalAnalysis, } } - private ObjectNode buildResultRow(String id, String canonicalAnalysis, String username, boolean changed) { + private Map resolveTagsForFpr(com.fortify.cli.aviator.util.FprHandle fprHandle, + String canonicalAnalysis) { + var tags = new LinkedHashMap(); + if (canonicalAnalysis != null) { + tags.put(Constants.ANALYSIS_TAG_ID, canonicalAnalysis); + } + if (customTags == null || customTags.isEmpty()) { + return tags; + } + FilterTemplate filterTemplate = FPRHelper.loadFilterTemplate(fprHandle).orElse(null); + for (var entry : customTags.entrySet()) { + try { + var resolved = FPRHelper.resolveCustomTag(filterTemplate, entry.getKey(), entry.getValue()); + tags.put(resolved.getKey(), resolved.getValue()); + } catch (IllegalArgumentException e) { + throw new FcliSimpleException(e.getMessage()); + } + } + return tags; + } + + private ObjectNode buildResultRow(String id, String canonicalAnalysis, Map resolvedTags, + String username, boolean changed) { var row = MAPPER.createObjectNode(); row.put("instanceId", id); - row.put("analysis", canonicalAnalysis); + row.put("analysis", canonicalAnalysis != null ? canonicalAnalysis : ""); + if (customTags != null && !customTags.isEmpty()) { + var tagsNode = MAPPER.createObjectNode(); + for (var entry : resolvedTags.entrySet()) { + if (!Constants.ANALYSIS_TAG_ID.equals(entry.getKey())) { + tagsNode.put(entry.getKey(), entry.getValue()); + } + } + row.set("customTags", tagsNode); + } row.put("comment", comment != null ? comment : ""); row.put("suppressed", suppress); + if (assignUser != null) { + row.put("assignedUser", assignUser); + } row.put("user", username); row.put("__action__", changed ? "AUDITED" : "UNCHANGED"); return row; } + private void validateAtLeastOneAction() { + boolean hasAnalysis = analysis != null && !analysis.isBlank(); + boolean hasTags = customTags != null && !customTags.isEmpty(); + boolean hasComment = comment != null && !comment.isBlank(); + boolean hasAssign = assignUser != null; + if (!hasAnalysis && !hasTags && !hasComment && !suppress && !hasAssign) { + throw new FcliSimpleException( + "At least one of --analysis, --custom-tags, --comment, --suppress, or --assign-user must be provided"); + } + } + private static List dedupePreservingOrder(List ids) { var seen = new LinkedHashSet(); for (var id : ids) { @@ -133,7 +192,7 @@ private static List dedupePreservingOrder(List ids) { } private String validateAnalysis(String value) { - if (value == null) { return null; } + if (value == null || value.isBlank()) { return null; } var canonical = VALID_ANALYSIS_VALUES.get(value.toLowerCase()); if (canonical == null) { throw new FcliSimpleException("Invalid --analysis value '" + value diff --git a/fcli-core/fcli-fpr/src/main/resources/com/fortify/cli/fpr/i18n/FPRMessages.properties b/fcli-core/fcli-fpr/src/main/resources/com/fortify/cli/fpr/i18n/FPRMessages.properties index c32ccb19be6..7e21c22aa4c 100644 --- a/fcli-core/fcli-fpr/src/main/resources/com/fortify/cli/fpr/i18n/FPRMessages.properties +++ b/fcli-core/fcli-fpr/src/main/resources/com/fortify/cli/fpr/i18n/FPRMessages.properties @@ -22,10 +22,12 @@ fcli.fpr.issue.get.embed = Comma-separated list of extra data to embed. Valid va fcli.fpr.issue.count.fpr = Path to the local FPR file to read. fcli.fpr.issue.audit.fpr = Path to the local FPR file to audit. fcli.fpr.issue.audit.instance-ids = Comma-separated list of one or more issue instanceIds to audit. -fcli.fpr.issue.audit.analysis = The analysis value to set. Valid values (case-insensitive): 'Not an Issue', 'Exploitable', 'Suspicious', 'Reliability Issue', 'False Positive', 'Bad Practice'. +fcli.fpr.issue.audit.analysis = Optional analysis value to set. Valid values (case-insensitive): 'Not an Issue', 'Exploitable', 'Suspicious', 'Reliability Issue', 'False Positive', 'Bad Practice'. +fcli.fpr.issue.audit.custom-tags = Comma-separated TAG=VALUE pairs for custom tags. TAG can be the tag name or its GUID; lookups are case-insensitive. Example: "--custom-tags 'Auditor Status=Reviewed,Severity=High'". fcli.fpr.issue.audit.comment = Optional comment to add to the issue audit trail. fcli.fpr.issue.audit.suppress = Suppress the issue in the FPR file. fcli.fpr.issue.audit.user = Username to record in the audit trail. Defaults to the current operating system user. +fcli.fpr.issue.audit.assign-user = Assign the issue to the specified user. Stored as the assignedUser attribute on the Issue element. Pass an empty string to clear the assignment. fcli.fpr.remediation.apply-remediations.fpr = Path to the local FPR file containing remediations. fcli.fpr.remediation.apply-remediations.source-root = Root directory of the source code to apply remediations to. @@ -33,5 +35,5 @@ fcli.fpr.remediation.apply-remediations.source-root = Root directory of the sour fcli.fpr.issue.list.output.table.args = instanceId,category,priority,analyzerName,primaryFile,primaryLine,audited,suppressed fcli.fpr.issue.get.output.table.args = instanceId,category,kingdom,type,subtype,analyzerName,priority,primaryFile,primaryLine,audited,suppressed,issueStatus,shortDescription fcli.fpr.issue.count.output.table.args = category,total,audited,suppressed -fcli.fpr.issue.audit.output.table.args = instanceId,analysis,comment,suppressed,user,__action__ +fcli.fpr.issue.audit.output.table.args = instanceId,analysis,customTags,comment,suppressed,assignedUser,user,__action__ fcli.fpr.remediation.apply-remediations.output.table.args = totalRemediations,appliedRemediations,skippedRemediations,__action__ From 34387ded97209c5fa50704c25dbbd0d28b8af1d5 Mon Sep 17 00:00:00 2001 From: Sangamesh Vijaykumar Date: Wed, 29 Apr 2026 12:08:00 +0530 Subject: [PATCH 3/5] chore: removed remediations related changes and renamed `issue audit` to `issue update` --- .../cli/fpr/_main/cli/cmd/FPRCommands.java | 4 +- .../fpr/issue/cli/cmd/FPRIssueCommands.java | 2 +- ...ommand.java => FPRIssueUpdateCommand.java} | 7 +- .../cli/cmd/FPRApplyRemediationsCommand.java | 65 ------------------- .../cli/cmd/FPRRemediationCommands.java | 25 ------- .../cli/fpr/i18n/FPRMessages.properties | 27 +++----- 6 files changed, 15 insertions(+), 115 deletions(-) rename fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/{FPRIssueAuditCommand.java => FPRIssueUpdateCommand.java} (98%) delete mode 100644 fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/remediation/cli/cmd/FPRApplyRemediationsCommand.java delete mode 100644 fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/remediation/cli/cmd/FPRRemediationCommands.java diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_main/cli/cmd/FPRCommands.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_main/cli/cmd/FPRCommands.java index 65dd0640a49..a2bc1c01371 100644 --- a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_main/cli/cmd/FPRCommands.java +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_main/cli/cmd/FPRCommands.java @@ -17,7 +17,6 @@ import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; import com.fortify.cli.common.cli.util.FcliModuleCategory; import com.fortify.cli.fpr.issue.cli.cmd.FPRIssueCommands; -import com.fortify.cli.fpr.remediation.cli.cmd.FPRRemediationCommands; import picocli.CommandLine.Command; @@ -26,8 +25,7 @@ name = "fpr", resourceBundle = "com.fortify.cli.fpr.i18n.FPRMessages", subcommands = { - FPRIssueCommands.class, - FPRRemediationCommands.class + FPRIssueCommands.class } ) public class FPRCommands extends AbstractContainerCommand {} diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueCommands.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueCommands.java index 0fd2fc6dde8..28586d59e58 100644 --- a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueCommands.java +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueCommands.java @@ -22,7 +22,7 @@ FPRIssueGetCommand.class, FPRIssueListCommand.class, FPRIssueCountCommand.class, - FPRIssueAuditCommand.class + FPRIssueUpdateCommand.class } ) public class FPRIssueCommands extends AbstractContainerCommand {} diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueAuditCommand.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueUpdateCommand.java similarity index 98% rename from fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueAuditCommand.java rename to fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueUpdateCommand.java index 542090d26cc..fc2dff76b8c 100644 --- a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueAuditCommand.java +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueUpdateCommand.java @@ -40,8 +40,8 @@ import picocli.CommandLine.Mixin; import picocli.CommandLine.Option; -@Command(name = "audit") -public class FPRIssueAuditCommand extends AbstractOutputCommand { +@Command(name = "update") +public class FPRIssueUpdateCommand extends AbstractOutputCommand { private static final ObjectMapper MAPPER = new ObjectMapper(); // Canonical SSC analysis tag values; lookup is case-insensitive. private static final Map VALID_ANALYSIS_VALUES; @@ -112,7 +112,6 @@ private List applyAudits(List ids, String canonicalAnalysis, anyChanged |= changed; results.add(buildResultRow(id, canonicalAnalysis, resolvedTags, username, changed)); } - // (assignedUser already included by buildResultRow when set) if (anyChanged) { auditProcessor.saveAuditXml(); } @@ -211,4 +210,4 @@ private String resolveUsername() { public boolean isSingular() { return false; } -} \ No newline at end of file +} diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/remediation/cli/cmd/FPRApplyRemediationsCommand.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/remediation/cli/cmd/FPRApplyRemediationsCommand.java deleted file mode 100644 index 3eb90a6ce01..00000000000 --- a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/remediation/cli/cmd/FPRApplyRemediationsCommand.java +++ /dev/null @@ -1,65 +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.fpr.remediation.cli.cmd; - -import java.io.IOException; -import java.nio.file.Path; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fortify.cli.aviator.fpr.processor.RemediationProcessor; -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; -import com.fortify.cli.fpr._common.cli.mixin.FPRFileMixin; - -import lombok.Getter; -import picocli.CommandLine.Command; -import picocli.CommandLine.Mixin; -import picocli.CommandLine.Option; - -@Command(name = "apply-remediations") -public class FPRApplyRemediationsCommand extends AbstractOutputCommand implements IJsonNodeSupplier { - private static final ObjectMapper MAPPER = new ObjectMapper(); - @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; - @Mixin private FPRFileMixin fprFileMixin; - - @Option(names = {"--source-root"}, required = true, order = 2) - private Path sourceRoot; - - @Override - public ObjectNode getJsonNode() { - try (var fprHandle = fprFileMixin.createFprHandle()) { - if (!fprHandle.hasRemediations()) { - throw new FcliSimpleException("FPR does not contain remediations.xml; no remediations to apply"); - } - var processor = new RemediationProcessor(fprHandle, sourceRoot.toAbsolutePath().toString()); - var metric = processor.processRemediationXML(); - var result = MAPPER.createObjectNode(); - result.put("totalRemediations", metric.totalRemediations()); - result.put("appliedRemediations", metric.appliedRemediations()); - result.put("skippedRemediations", metric.skippedRemediations()); - result.put("__action__", metric.appliedRemediations() > 0 ? "APPLIED" : "SKIPPED"); - return result; - } catch (IOException e) { - throw new FcliTechnicalException("Error processing FPR file", e); - } - } - - @Override - public boolean isSingular() { - return true; - } -} diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/remediation/cli/cmd/FPRRemediationCommands.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/remediation/cli/cmd/FPRRemediationCommands.java deleted file mode 100644 index 083bc25d644..00000000000 --- a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/remediation/cli/cmd/FPRRemediationCommands.java +++ /dev/null @@ -1,25 +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.fpr.remediation.cli.cmd; - -import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; - -import picocli.CommandLine.Command; - -@Command( - name = "remediation", - subcommands = { - FPRApplyRemediationsCommand.class - } -) -public class FPRRemediationCommands extends AbstractContainerCommand {} diff --git a/fcli-core/fcli-fpr/src/main/resources/com/fortify/cli/fpr/i18n/FPRMessages.properties b/fcli-core/fcli-fpr/src/main/resources/com/fortify/cli/fpr/i18n/FPRMessages.properties index 7e21c22aa4c..87458bd03a8 100644 --- a/fcli-core/fcli-fpr/src/main/resources/com/fortify/cli/fpr/i18n/FPRMessages.properties +++ b/fcli-core/fcli-fpr/src/main/resources/com/fortify/cli/fpr/i18n/FPRMessages.properties @@ -8,11 +8,7 @@ fcli.fpr.issue.usage.header = Commands for listing and analyzing issues from a l fcli.fpr.issue.list.usage.header = List all issues/vulnerabilities from an FPR file. fcli.fpr.issue.get.usage.header = Get detailed information about a specific issue from an FPR file. fcli.fpr.issue.count.usage.header = Count issues by category from an FPR file. -fcli.fpr.issue.audit.usage.header = Audit a single issue in an FPR file by setting its analysis tag value. - -# Remediation commands -fcli.fpr.remediation.usage.header = Commands for applying remediations from an FPR file. -fcli.fpr.remediation.apply-remediations.usage.header = Apply auto-remediations from an FPR file to source code. +fcli.fpr.issue.update.usage.header = Update an issue in an FPR file by setting its analysis tag, custom tags, or other audit attributes. # Shared options fcli.fpr.issue.list.fpr = Path to the local FPR file to read. @@ -20,20 +16,17 @@ fcli.fpr.issue.get.fpr = Path to the local FPR file to read. fcli.fpr.issue.get.instance-id = The instanceId of the issue to retrieve. fcli.fpr.issue.get.embed = Comma-separated list of extra data to embed. Valid values: history. fcli.fpr.issue.count.fpr = Path to the local FPR file to read. -fcli.fpr.issue.audit.fpr = Path to the local FPR file to audit. -fcli.fpr.issue.audit.instance-ids = Comma-separated list of one or more issue instanceIds to audit. -fcli.fpr.issue.audit.analysis = Optional analysis value to set. Valid values (case-insensitive): 'Not an Issue', 'Exploitable', 'Suspicious', 'Reliability Issue', 'False Positive', 'Bad Practice'. -fcli.fpr.issue.audit.custom-tags = Comma-separated TAG=VALUE pairs for custom tags. TAG can be the tag name or its GUID; lookups are case-insensitive. Example: "--custom-tags 'Auditor Status=Reviewed,Severity=High'". -fcli.fpr.issue.audit.comment = Optional comment to add to the issue audit trail. -fcli.fpr.issue.audit.suppress = Suppress the issue in the FPR file. -fcli.fpr.issue.audit.user = Username to record in the audit trail. Defaults to the current operating system user. -fcli.fpr.issue.audit.assign-user = Assign the issue to the specified user. Stored as the assignedUser attribute on the Issue element. Pass an empty string to clear the assignment. -fcli.fpr.remediation.apply-remediations.fpr = Path to the local FPR file containing remediations. -fcli.fpr.remediation.apply-remediations.source-root = Root directory of the source code to apply remediations to. +fcli.fpr.issue.update.fpr = Path to the local FPR file to update. +fcli.fpr.issue.update.instance-ids = Comma-separated list of one or more issue instanceIds to update. +fcli.fpr.issue.update.analysis = Optional analysis value to set. Valid values (case-insensitive): 'Not an Issue', 'Exploitable', 'Suspicious', 'Reliability Issue', 'False Positive', 'Bad Practice'. +fcli.fpr.issue.update.custom-tags = Comma-separated TAG=VALUE pairs for custom tags. TAG can be the tag name or its GUID; lookups are case-insensitive. Example: "--custom-tags 'Auditor Status=Reviewed,Severity=High'". +fcli.fpr.issue.update.comment = Optional comment to add to the issue audit trail. +fcli.fpr.issue.update.suppress = Suppress the issue in the FPR file. +fcli.fpr.issue.update.user = Username to record in the audit trail. Defaults to the current operating system user. +fcli.fpr.issue.update.assign-user = Assign the issue to the specified user. Stored as the assignedUser attribute on the Issue element. Pass an empty string to clear the assignment. # Default output columns fcli.fpr.issue.list.output.table.args = instanceId,category,priority,analyzerName,primaryFile,primaryLine,audited,suppressed fcli.fpr.issue.get.output.table.args = instanceId,category,kingdom,type,subtype,analyzerName,priority,primaryFile,primaryLine,audited,suppressed,issueStatus,shortDescription fcli.fpr.issue.count.output.table.args = category,total,audited,suppressed -fcli.fpr.issue.audit.output.table.args = instanceId,analysis,customTags,comment,suppressed,assignedUser,user,__action__ -fcli.fpr.remediation.apply-remediations.output.table.args = totalRemediations,appliedRemediations,skippedRemediations,__action__ +fcli.fpr.issue.update.output.table.args = instanceId,analysis,customTags,comment,suppressed,assignedUser,user,__action__ \ No newline at end of file From 6b8310a187761212f799c5b85a9ac59d0d5ab64f Mon Sep 17 00:00:00 2001 From: Sangamesh Vijaykumar Date: Wed, 29 Apr 2026 17:20:31 +0530 Subject: [PATCH 4/5] feat: Add features of fprutility into fcli --- .../fpr/_common/helper/FVDLInfoParser.java | 316 ++++++++++++++++++ .../cli/fpr/_main/cli/cmd/FPRCommands.java | 13 +- .../fpr/error/cli/cmd/FPRErrorsCommand.java | 69 ++++ .../cli/cmd/FPRInformationCommands.java | 40 +++ .../issue/cli/cmd/FPRIssueCountCommand.java | 32 +- .../cli/fpr/loc/cli/cmd/FPRLocCommand.java | 88 +++++ .../fpr/merge/cli/cmd/FPRMergeCommand.java | 228 +++++++++++++ .../cli/cmd/FPRSignatureCommand.java | 91 +++++ .../cli/cmd/FPRSourceExtractCommand.java | 123 +++++++ .../source/cli/cmd/FPRSourceMergeCommand.java | 166 +++++++++ .../summary/cli/cmd/FPRSummaryCommand.java | 104 ++++++ .../cli/fpr/trim/cli/cmd/FPRTrimCommand.java | 135 ++++++++ .../cli/fpr/i18n/FPRMessages.properties | 59 +++- 13 files changed, 1453 insertions(+), 11 deletions(-) create mode 100644 fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_common/helper/FVDLInfoParser.java create mode 100644 fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/error/cli/cmd/FPRErrorsCommand.java create mode 100644 fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/information/cli/cmd/FPRInformationCommands.java create mode 100644 fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/loc/cli/cmd/FPRLocCommand.java create mode 100644 fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/merge/cli/cmd/FPRMergeCommand.java create mode 100644 fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/signature/cli/cmd/FPRSignatureCommand.java create mode 100644 fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/source/cli/cmd/FPRSourceExtractCommand.java create mode 100644 fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/source/cli/cmd/FPRSourceMergeCommand.java create mode 100644 fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/summary/cli/cmd/FPRSummaryCommand.java create mode 100644 fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/trim/cli/cmd/FPRTrimCommand.java diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_common/helper/FVDLInfoParser.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_common/helper/FVDLInfoParser.java new file mode 100644 index 00000000000..a15bd991042 --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_common/helper/FVDLInfoParser.java @@ -0,0 +1,316 @@ +/* + * 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.fpr._common.helper; + +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.List; + +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamConstants; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamReader; + +import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.common.exception.FcliTechnicalException; + +/** + * Lightweight StAX-based parser that extracts Build and EngineData metadata + * from the FVDL file without parsing the (potentially large) Vulnerabilities section. + */ +public final class FVDLInfoParser { + + private FVDLInfoParser() {} + + // ── Records ────────────────────────────────────────────────────── + + public record FVDLInfo(BuildInfo build, EngineInfo engine) {} + + public record BuildInfo( + String project, String version, String buildID, + Integer numberFiles, List totalLoc, + String sourceBasePath, Integer scanTimeSeconds, + Integer buildDuration, List sourceFiles + ) {} + + public record LocEntry(String type, int value) {} + + public record SourceFileInfo( + String name, String type, String size, + String encoding, Integer loc, List locDetails + ) {} + + public record EngineInfo( + String engineVersion, MachineInfo machineInfo, + List commandLine, List errors, + List rulePacks + ) {} + + public record MachineInfo(String hostname, String username, String platform) {} + + public record ErrorEntry(String code, String message) {} + + public record RulePackInfo(String id, String name, String version, String sku) {} + + // ── Public API ─────────────────────────────────────────────────── + + public static FVDLInfo parse(FprHandle fprHandle) { + Path fvdlPath = fprHandle.getPath("/audit.fvdl"); + if (!Files.exists(fvdlPath)) { + throw new FcliTechnicalException("audit.fvdl not found in FPR file"); + } + var xmlInputFactory = XMLInputFactory.newInstance(); + xmlInputFactory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); + xmlInputFactory.setProperty(XMLInputFactory.SUPPORT_DTD, false); + + try (InputStream is = Files.newInputStream(fvdlPath)) { + XMLStreamReader reader = xmlInputFactory.createXMLStreamReader(is); + try { + return doParse(reader); + } finally { + reader.close(); + } + } catch (IOException | XMLStreamException e) { + throw new FcliTechnicalException("Failed to parse FVDL metadata", e); + } + } + + // ── Top-level dispatcher ───────────────────────────────────────── + + private static FVDLInfo doParse(XMLStreamReader reader) throws XMLStreamException { + BuildInfo buildInfo = null; + EngineInfo engineInfo = null; + + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.START_ELEMENT) { + switch (reader.getLocalName()) { + case "Build" -> buildInfo = parseBuild(reader); + case "EngineData" -> engineInfo = parseEngineData(reader); + case "Vulnerabilities" -> skipSection(reader, "Vulnerabilities"); + default -> { /* other top-level: UnifiedNodePool, etc. — skip implicitly */ } + } + } + } + return new FVDLInfo( + buildInfo != null ? buildInfo : new BuildInfo(null, null, null, null, List.of(), null, null, null, List.of()), + engineInfo != null ? engineInfo : new EngineInfo(null, null, List.of(), List.of(), List.of()) + ); + } + + // ── Build section ──────────────────────────────────────────────── + + private static BuildInfo parseBuild(XMLStreamReader reader) throws XMLStreamException { + String project = null, version = null, buildID = null, sourceBasePath = null; + Integer numberFiles = null, scanTimeSeconds = null, buildDuration = null; + var totalLoc = new ArrayList(); + var sourceFiles = new ArrayList(); + + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.START_ELEMENT) { + switch (reader.getLocalName()) { + case "Project" -> project = readText(reader); + case "Version" -> version = readText(reader); + case "BuildID" -> buildID = readText(reader); + case "NumberFiles" -> numberFiles = Integer.parseInt(readText(reader).trim()); + case "BuildDuration" -> buildDuration = Integer.parseInt(readText(reader).trim()); + case "LOC" -> totalLoc.add(parseLoc(reader)); + case "SourceBasePath" -> sourceBasePath = readText(reader); + case "SourceFiles" -> parseSourceFiles(reader, sourceFiles); + case "ScanTime" -> { + var val = reader.getAttributeValue(null, "value"); + if (val != null) { scanTimeSeconds = Integer.parseInt(val.trim()); } + skipSection(reader, "ScanTime"); + } + default -> { /* JavaClasspath, Libdirs, Label — not needed */ } + } + } else if (event == XMLStreamConstants.END_ELEMENT && "Build".equals(reader.getLocalName())) { + break; + } + } + return new BuildInfo(project, version, buildID, numberFiles, totalLoc, + sourceBasePath, scanTimeSeconds, buildDuration, sourceFiles); + } + + private static LocEntry parseLoc(XMLStreamReader reader) throws XMLStreamException { + String type = reader.getAttributeValue(null, "type"); + String text = readText(reader); + return new LocEntry(type, Integer.parseInt(text.trim())); + } + + private static void parseSourceFiles(XMLStreamReader reader, List result) throws XMLStreamException { + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.START_ELEMENT && "File".equals(reader.getLocalName())) { + result.add(parseSourceFile(reader)); + } else if (event == XMLStreamConstants.END_ELEMENT && "SourceFiles".equals(reader.getLocalName())) { + return; + } + } + } + + private static SourceFileInfo parseSourceFile(XMLStreamReader reader) throws XMLStreamException { + String type = reader.getAttributeValue(null, "type"); + String size = reader.getAttributeValue(null, "size"); + String encoding = reader.getAttributeValue(null, "encoding"); + String locAttr = reader.getAttributeValue(null, "loc"); + Integer loc = locAttr != null ? Integer.parseInt(locAttr.trim()) : null; + + String name = null; + var locDetails = new ArrayList(); + + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.START_ELEMENT) { + switch (reader.getLocalName()) { + case "Name" -> name = readText(reader); + case "LOC" -> locDetails.add(parseLoc(reader)); + default -> skipSection(reader, reader.getLocalName()); + } + } else if (event == XMLStreamConstants.END_ELEMENT && "File".equals(reader.getLocalName())) { + break; + } + } + return new SourceFileInfo(name, type, size, encoding, loc, locDetails); + } + + // ── EngineData section ─────────────────────────────────────────── + + private static EngineInfo parseEngineData(XMLStreamReader reader) throws XMLStreamException { + String engineVersion = null; + MachineInfo machineInfo = null; + var commandLine = new ArrayList(); + var errors = new ArrayList(); + var rulePacks = new ArrayList(); + + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.START_ELEMENT) { + switch (reader.getLocalName()) { + case "EngineVersion" -> engineVersion = readText(reader); + case "MachineInfo" -> machineInfo = parseMachineInfo(reader); + case "CommandLine" -> parseCommandLine(reader, commandLine); + case "Errors" -> parseErrors(reader, errors); + case "RulePacks" -> parseRulePacks(reader, rulePacks); + default -> skipSection(reader, reader.getLocalName()); + } + } else if (event == XMLStreamConstants.END_ELEMENT && "EngineData".equals(reader.getLocalName())) { + break; + } + } + return new EngineInfo(engineVersion, machineInfo, commandLine, errors, rulePacks); + } + + private static MachineInfo parseMachineInfo(XMLStreamReader reader) throws XMLStreamException { + String hostname = null, username = null, platform = null; + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.START_ELEMENT) { + switch (reader.getLocalName()) { + case "Hostname" -> hostname = readText(reader); + case "Username" -> username = readText(reader); + case "Platform" -> platform = readText(reader); + default -> skipSection(reader, reader.getLocalName()); + } + } else if (event == XMLStreamConstants.END_ELEMENT && "MachineInfo".equals(reader.getLocalName())) { + break; + } + } + return new MachineInfo(hostname, username, platform); + } + + private static void parseCommandLine(XMLStreamReader reader, List result) throws XMLStreamException { + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.START_ELEMENT && "Argument".equals(reader.getLocalName())) { + result.add(readText(reader)); + } else if (event == XMLStreamConstants.END_ELEMENT && "CommandLine".equals(reader.getLocalName())) { + return; + } + } + } + + private static void parseErrors(XMLStreamReader reader, List result) throws XMLStreamException { + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.START_ELEMENT && "Error".equals(reader.getLocalName())) { + String code = reader.getAttributeValue(null, "code"); + String message = readText(reader); + result.add(new ErrorEntry(code, message)); + } else if (event == XMLStreamConstants.END_ELEMENT && "Errors".equals(reader.getLocalName())) { + return; + } + } + } + + private static void parseRulePacks(XMLStreamReader reader, List result) throws XMLStreamException { + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.START_ELEMENT && "RulePack".equals(reader.getLocalName())) { + result.add(parseRulePack(reader)); + } else if (event == XMLStreamConstants.END_ELEMENT && "RulePacks".equals(reader.getLocalName())) { + return; + } + } + } + + private static RulePackInfo parseRulePack(XMLStreamReader reader) throws XMLStreamException { + String id = null, name = null, version = null, sku = null; + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.START_ELEMENT) { + switch (reader.getLocalName()) { + case "RulePackID" -> id = readText(reader); + case "Name" -> name = readText(reader); + case "Version" -> version = readText(reader); + case "SKU" -> sku = readText(reader); + default -> skipSection(reader, reader.getLocalName()); + } + } else if (event == XMLStreamConstants.END_ELEMENT && "RulePack".equals(reader.getLocalName())) { + break; + } + } + return new RulePackInfo(id, name, version, sku); + } + + // ── XML utilities ──────────────────────────────────────────────── + + private static String readText(XMLStreamReader reader) throws XMLStreamException { + var sb = new StringBuilder(); + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.CHARACTERS || event == XMLStreamConstants.CDATA) { + sb.append(reader.getText()); + } else if (event == XMLStreamConstants.END_ELEMENT) { + break; + } + } + return sb.toString().trim(); + } + + private static void skipSection(XMLStreamReader reader, String sectionName) throws XMLStreamException { + int depth = 1; + while (reader.hasNext() && depth > 0) { + int event = reader.next(); + if (event == XMLStreamConstants.START_ELEMENT) { + depth++; + } else if (event == XMLStreamConstants.END_ELEMENT) { + depth--; + } + } + } +} diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_main/cli/cmd/FPRCommands.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_main/cli/cmd/FPRCommands.java index a2bc1c01371..17e338e076a 100644 --- a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_main/cli/cmd/FPRCommands.java +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_main/cli/cmd/FPRCommands.java @@ -16,6 +16,7 @@ import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; import com.fortify.cli.common.cli.util.FcliModuleCategory; +import com.fortify.cli.fpr.information.cli.cmd.FPRInformationCommands; import com.fortify.cli.fpr.issue.cli.cmd.FPRIssueCommands; import picocli.CommandLine.Command; @@ -25,7 +26,15 @@ name = "fpr", resourceBundle = "com.fortify.cli.fpr.i18n.FPRMessages", subcommands = { - FPRIssueCommands.class + FPRIssueCommands.class, + + + + + + + + FPRInformationCommands.class } ) -public class FPRCommands extends AbstractContainerCommand {} +public class FPRCommands extends AbstractContainerCommand {} \ No newline at end of file diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/error/cli/cmd/FPRErrorsCommand.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/error/cli/cmd/FPRErrorsCommand.java new file mode 100644 index 00000000000..c814585568f --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/error/cli/cmd/FPRErrorsCommand.java @@ -0,0 +1,69 @@ +/* + * 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.fpr.error.cli.cmd; + +import java.io.IOException; +import java.util.List; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.common.exception.FcliTechnicalException; +import com.fortify.cli.common.json.producer.IObjectNodeProducer; +import com.fortify.cli.common.json.producer.ObjectNodeProducerApplyFrom; +import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; +import com.fortify.cli.fpr._common.cli.mixin.FPRFileMixin; +import com.fortify.cli.fpr._common.helper.FVDLInfoParser; + +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +@Command(name = "list-errors") +public class FPRErrorsCommand extends AbstractOutputCommand { + private static final ObjectMapper MAPPER = new ObjectMapper(); + @Getter @Mixin private OutputHelperMixins.TableNoQuery outputHelper; + @Mixin private FPRFileMixin fprFileMixin; + + @Override + protected IObjectNodeProducer getObjectNodeProducer() { + try (var fprHandle = fprFileMixin.createFprHandle()) { + var info = FVDLInfoParser.parse(fprHandle); + List errors = info.engine().errors(); + + if (errors.isEmpty()) { + var row = MAPPER.createObjectNode(); + row.put("code", ""); + row.put("message", "No errors found in the FPR scan results."); + return streamingObjectNodeProducerBuilder(ObjectNodeProducerApplyFrom.SPEC) + .streamSupplier(() -> java.util.stream.Stream.of(row)) + .build(); + } + + return streamingObjectNodeProducerBuilder(ObjectNodeProducerApplyFrom.SPEC) + .streamSupplier(() -> errors.stream().map(err -> { + var node = MAPPER.createObjectNode(); + node.put("code", err.code()); + node.put("message", err.message()); + return node; + })) + .build(); + } catch (IOException e) { + throw new FcliTechnicalException("Error reading FPR file", e); + } + } + + @Override + public boolean isSingular() { + return false; + } +} diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/information/cli/cmd/FPRInformationCommands.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/information/cli/cmd/FPRInformationCommands.java new file mode 100644 index 00000000000..53026760765 --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/information/cli/cmd/FPRInformationCommands.java @@ -0,0 +1,40 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.fpr.information.cli.cmd; + +import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; +import com.fortify.cli.fpr.error.cli.cmd.FPRErrorsCommand; +import com.fortify.cli.fpr.loc.cli.cmd.FPRLocCommand; +import com.fortify.cli.fpr.merge.cli.cmd.FPRMergeCommand; +import com.fortify.cli.fpr.signature.cli.cmd.FPRSignatureCommand; +import com.fortify.cli.fpr.source.cli.cmd.FPRSourceExtractCommand; +import com.fortify.cli.fpr.source.cli.cmd.FPRSourceMergeCommand; +import com.fortify.cli.fpr.summary.cli.cmd.FPRSummaryCommand; +import com.fortify.cli.fpr.trim.cli.cmd.FPRTrimCommand; + +import picocli.CommandLine.Command; + +@Command( + name = "information", + subcommands = { + FPRSummaryCommand.class, + FPRLocCommand.class, + FPRErrorsCommand.class, + FPRSignatureCommand.class, + FPRMergeCommand.class, + FPRSourceExtractCommand.class, + FPRSourceMergeCommand.class, + FPRTrimCommand.class + } +) +public class FPRInformationCommands extends AbstractContainerCommand {} diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueCountCommand.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueCountCommand.java index 4e509323bb3..7c55fc7b47e 100644 --- a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueCountCommand.java +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueCountCommand.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fortify.cli.aviator.fpr.Vulnerability; +import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.exception.FcliTechnicalException; import com.fortify.cli.common.json.producer.IObjectNodeProducer; import com.fortify.cli.common.json.producer.ObjectNodeProducerApplyFrom; @@ -32,6 +33,7 @@ import lombok.Getter; import picocli.CommandLine.Command; import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; @Command(name = "count") public class FPRIssueCountCommand extends AbstractOutputCommand { @@ -39,14 +41,18 @@ public class FPRIssueCountCommand extends AbstractOutputCommand { @Getter @Mixin private OutputHelperMixins.TableNoQuery outputHelper; @Mixin private FPRFileMixin fprFileMixin; + @Option(names = {"--by"}, defaultValue = "category", order = 2) + private String groupBy; + @Override protected IObjectNodeProducer getObjectNodeProducer() { + validateGroupBy(); List vulnerabilities = loadVulnerabilities(); Map counts = new LinkedHashMap<>(); for (var vuln : vulnerabilities) { - var category = vuln.getCategory() != null ? vuln.getCategory() : "Unknown"; - counts.computeIfAbsent(category, k -> new long[3]); - long[] c = counts.get(category); + var key = resolveGroupKey(vuln); + counts.computeIfAbsent(key, k -> new long[3]); + long[] c = counts.get(key); c[0]++; if (vuln.isAudited()) { c[1]++; } if (vuln.isSuppressed()) { c[2]++; } @@ -57,6 +63,19 @@ protected IObjectNodeProducer getObjectNodeProducer() { .build(); } + private void validateGroupBy() { + if (!"category".equalsIgnoreCase(groupBy) && !"analyzer".equalsIgnoreCase(groupBy)) { + throw new FcliSimpleException("Invalid --by value '" + groupBy + "'; valid values: category, analyzer"); + } + } + + private String resolveGroupKey(Vulnerability vuln) { + if ("analyzer".equalsIgnoreCase(groupBy)) { + return vuln.getAnalyzerName() != null ? vuln.getAnalyzerName() : "Unknown"; + } + return vuln.getCategory() != null ? vuln.getCategory() : "Unknown"; + } + private List loadVulnerabilities() { try (var fprHandle = fprFileMixin.createFprHandle()) { return FPRHelper.loadVulnerabilities(fprHandle); @@ -66,16 +85,17 @@ private List loadVulnerabilities() { } private Stream toStream(Map counts, int total) { + String label = "group"; var summary = counts.entrySet().stream().map(e -> { var node = MAPPER.createObjectNode(); - node.put("category", e.getKey()); + node.put(label, e.getKey()); node.put("total", e.getValue()[0]); node.put("audited", e.getValue()[1]); node.put("suppressed", e.getValue()[2]); return node; }); var totalNode = MAPPER.createObjectNode(); - totalNode.put("category", "TOTAL"); + totalNode.put(label, "TOTAL"); totalNode.put("total", total); totalNode.put("audited", counts.values().stream().mapToLong(c -> c[1]).sum()); totalNode.put("suppressed", counts.values().stream().mapToLong(c -> c[2]).sum()); @@ -86,4 +106,4 @@ private Stream toStream(Map counts, int total) { public boolean isSingular() { return false; } -} +} \ No newline at end of file diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/loc/cli/cmd/FPRLocCommand.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/loc/cli/cmd/FPRLocCommand.java new file mode 100644 index 00000000000..4864ea75574 --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/loc/cli/cmd/FPRLocCommand.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.fpr.loc.cli.cmd; + +import java.io.IOException; +import java.util.ArrayList; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.exception.FcliTechnicalException; +import com.fortify.cli.common.json.producer.IObjectNodeProducer; +import com.fortify.cli.common.json.producer.ObjectNodeProducerApplyFrom; +import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; +import com.fortify.cli.fpr._common.cli.mixin.FPRFileMixin; +import com.fortify.cli.fpr._common.helper.FVDLInfoParser; + +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +@Command(name = "list-source-files") +public class FPRLocCommand extends AbstractOutputCommand { + private static final ObjectMapper MAPPER = new ObjectMapper(); + @Getter @Mixin private OutputHelperMixins.TableNoQuery outputHelper; + @Mixin private FPRFileMixin fprFileMixin; + + @Override + protected IObjectNodeProducer getObjectNodeProducer() { + try (var fprHandle = fprFileMixin.createFprHandle()) { + var info = FVDLInfoParser.parse(fprHandle); + var build = info.build(); + var rows = new ArrayList(); + + for (var file : build.sourceFiles()) { + var node = MAPPER.createObjectNode(); + node.put("file", file.name()); + node.put("type", file.type()); + node.put("size", file.size()); + node.put("encoding", file.encoding()); + node.put("loc", file.loc() != null ? file.loc() : 0); + for (var locDetail : file.locDetails()) { + if (locDetail.type() != null) { + node.put("loc_" + locDetail.type().replace(" ", "_"), locDetail.value()); + } + } + rows.add(node); + } + + // Summary row with totals + var totalRow = MAPPER.createObjectNode(); + totalRow.put("file", "TOTAL (" + build.sourceFiles().size() + " files)"); + totalRow.put("type", ""); + totalRow.put("size", ""); + totalRow.put("encoding", ""); + int totalLoc = build.sourceFiles().stream() + .mapToInt(f -> f.loc() != null ? f.loc() : 0).sum(); + totalRow.put("loc", totalLoc); + for (var loc : build.totalLoc()) { + if (loc.type() != null) { + totalRow.put("loc_" + loc.type().replace(" ", "_"), loc.value()); + } + } + rows.add(totalRow); + + return streamingObjectNodeProducerBuilder(ObjectNodeProducerApplyFrom.SPEC) + .streamSupplier(() -> rows.stream().map(n -> (ObjectNode) n)) + .build(); + } catch (IOException e) { + throw new FcliTechnicalException("Error reading FPR file", e); + } + } + + @Override + public boolean isSingular() { + return false; + } +} diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/merge/cli/cmd/FPRMergeCommand.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/merge/cli/cmd/FPRMergeCommand.java new file mode 100644 index 00000000000..c7889a6aecd --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/merge/cli/cmd/FPRMergeCommand.java @@ -0,0 +1,228 @@ +/* + * 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.fpr.merge.cli.cmd; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; + +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +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; + +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +/** + * Merges audit data (tags, comments, suppression) from a source FPR into the + * primary FPR. The primary FPR's audit data takes precedence for conflicting + * entries. The primary FPR's FVDL (scan results) is preserved unchanged. + * + *

This provides the audit-merge functionality of FPRUtility's {@code -merge} + * option. Full FVDL merge (combining scan results with instance-id migration) + * is not supported; use FPRUtility directly for that scenario. + */ +@Command(name = "merge") +public class FPRMergeCommand extends AbstractOutputCommand implements IJsonNodeSupplier { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String AUDIT_NS = "xmlns://www.fortify.com/schema/audit"; + + @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; + + @Option(names = {"--project"}, required = true, order = 1) + private Path projectPath; + + @Option(names = {"--source"}, required = true, order = 2) + private Path sourcePath; + + @Option(names = {"-f", "--output-file"}, order = 3) + private Path outputPath; + + @Override + public JsonNode getJsonNode() { + validateInputs(); + if (outputPath == null) { outputPath = projectPath; } + + try { + int merged = mergeAuditData(); + var node = MAPPER.createObjectNode(); + node.put("project", projectPath.toString()); + node.put("source", sourcePath.toString()); + node.put("output", outputPath.toString()); + node.put("mergedIssues", merged); + node.put("__action__", merged > 0 ? "MERGED" : "NO_CHANGES"); + return node; + } catch (IOException e) { + throw new FcliTechnicalException("Error merging FPR files", e); + } + } + + private void validateInputs() { + if (!Files.exists(projectPath)) { + throw new FcliSimpleException("Primary project file not found: " + projectPath); + } + if (!Files.exists(sourcePath)) { + throw new FcliSimpleException("Source project file not found: " + sourcePath); + } + } + + private int mergeAuditData() throws IOException { + // Parse audit.xml from source FPR + Document sourceAuditDoc = readAuditXml(sourcePath); + if (sourceAuditDoc == null) { return 0; } + + // Parse audit.xml from primary FPR + Document primaryAuditDoc = readAuditXml(projectPath); + + // Build map of source issues by instanceId + var sourceIssues = extractIssueElements(sourceAuditDoc); + if (sourceIssues.isEmpty()) { return 0; } + + // Merge: add source issues that are not in primary + if (primaryAuditDoc == null) { + primaryAuditDoc = createEmptyAuditDoc(); + } + var primaryIssues = extractIssueElements(primaryAuditDoc); + var primaryIds = new HashSet<>(primaryIssues.keySet()); + + Element projectRoot = (Element) primaryAuditDoc.getElementsByTagNameNS(AUDIT_NS, "ProjectVersionAudit").item(0); + if (projectRoot == null) { + projectRoot = (Element) primaryAuditDoc.getDocumentElement(); + } + + int mergedCount = 0; + for (var entry : sourceIssues.entrySet()) { + if (!primaryIds.contains(entry.getKey())) { + var imported = primaryAuditDoc.importNode(entry.getValue(), true); + projectRoot.appendChild(imported); + mergedCount++; + } + } + + // Write updated FPR + writeUpdatedFpr(primaryAuditDoc, mergedCount > 0); + return mergedCount; + } + + private Document readAuditXml(Path fprPath) throws IOException { + try (var zipFile = new ZipFile(fprPath.toFile())) { + ZipEntry auditEntry = zipFile.getEntry("audit.xml"); + if (auditEntry == null) { return null; } + try (InputStream is = zipFile.getInputStream(auditEntry)) { + var dbf = DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(true); + return dbf.newDocumentBuilder().parse(is); + } catch (Exception e) { + throw new FcliTechnicalException("Failed to parse audit.xml from " + fprPath, e); + } + } + } + + private Map extractIssueElements(Document doc) { + var map = new java.util.LinkedHashMap(); + NodeList issues = doc.getElementsByTagNameNS(AUDIT_NS, "Issue"); + for (int i = 0; i < issues.getLength(); i++) { + var elem = (Element) issues.item(i); + String instanceId = elem.getAttribute("instanceId"); + if (instanceId != null && !instanceId.isBlank()) { + map.put(instanceId, elem); + } + } + return map; + } + + private Document createEmptyAuditDoc() { + try { + var dbf = DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(true); + var doc = dbf.newDocumentBuilder().newDocument(); + var root = doc.createElementNS(AUDIT_NS, "ProjectVersionAudit"); + doc.appendChild(root); + return doc; + } catch (Exception e) { + throw new FcliTechnicalException("Failed to create audit document", e); + } + } + + private void writeUpdatedFpr(Document auditDoc, boolean changed) throws IOException { + if (!changed) { + if (!outputPath.equals(projectPath)) { + Files.copy(projectPath, outputPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + return; + } + + Path tempFpr = Files.createTempFile("fcli-merge-", ".fpr"); + try { + try (var zipIn = new ZipFile(projectPath.toFile()); + OutputStream fos = Files.newOutputStream(tempFpr); + var zipOut = new ZipOutputStream(fos)) { + + Enumeration entries = zipIn.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if ("audit.xml".equals(entry.getName())) { + continue; // replaced below + } + zipOut.putNextEntry(new ZipEntry(entry.getName())); + try (InputStream is = zipIn.getInputStream(entry)) { + is.transferTo(zipOut); + } + zipOut.closeEntry(); + } + + // Write merged audit.xml + zipOut.putNextEntry(new ZipEntry("audit.xml")); + var transformer = TransformerFactory.newInstance().newTransformer(); + transformer.setOutputProperty(OutputKeys.INDENT, "yes"); + transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); + transformer.transform(new DOMSource(auditDoc), new StreamResult(zipOut)); + zipOut.closeEntry(); + } + + Files.move(tempFpr, outputPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } catch (Exception e) { + Files.deleteIfExists(tempFpr); + throw new FcliTechnicalException("Failed to write merged FPR", e); + } + } + + @Override + public boolean isSingular() { + return true; + } +} diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/signature/cli/cmd/FPRSignatureCommand.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/signature/cli/cmd/FPRSignatureCommand.java new file mode 100644 index 00000000000..a2531bab51f --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/signature/cli/cmd/FPRSignatureCommand.java @@ -0,0 +1,91 @@ +/* + * 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.fpr.signature.cli.cmd; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +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; +import com.fortify.cli.fpr._common.cli.mixin.FPRFileMixin; +import com.fortify.cli.fpr._common.helper.FVDLInfoParser; + +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +@Command(name = "show-signature") +public class FPRSignatureCommand extends AbstractOutputCommand implements IJsonNodeSupplier { + private static final ObjectMapper MAPPER = new ObjectMapper(); + @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; + @Mixin private FPRFileMixin fprFileMixin; + + @Override + public JsonNode getJsonNode() { + try (var fprHandle = fprFileMixin.createFprHandle()) { + var node = MAPPER.createObjectNode(); + + // Read VERSION file + Path versionPath = fprHandle.getPath("/VERSION"); + if (Files.exists(versionPath)) { + node.put("fprVersion", Files.readString(versionPath).trim()); + } + + // Read MAC file + Path macPath = fprHandle.getPath("/audit.fvdl.mac"); + if (Files.exists(macPath)) { + byte[] macBytes = Files.readAllBytes(macPath); + node.put("mac", bytesToHex(macBytes)); + node.put("signed", true); + } else { + node.put("signed", false); + } + + // Engine version from FVDL + var info = FVDLInfoParser.parse(fprHandle); + if (info.engine().engineVersion() != null) { + node.put("engineVersion", info.engine().engineVersion()); + } + if (info.build().buildID() != null) { + node.put("buildID", info.build().buildID()); + } + if (info.engine().machineInfo() != null) { + node.put("hostname", info.engine().machineInfo().hostname()); + node.put("username", info.engine().machineInfo().username()); + node.put("platform", info.engine().machineInfo().platform()); + } + + return node; + } catch (IOException e) { + throw new FcliTechnicalException("Error reading FPR file", e); + } + } + + private static String bytesToHex(byte[] bytes) { + var sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + @Override + public boolean isSingular() { + return true; + } +} diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/source/cli/cmd/FPRSourceExtractCommand.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/source/cli/cmd/FPRSourceExtractCommand.java new file mode 100644 index 00000000000..152c734067c --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/source/cli/cmd/FPRSourceExtractCommand.java @@ -0,0 +1,123 @@ +/* + * 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.fpr.source.cli.cmd; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.LinkedHashMap; +import java.util.Map; + +import javax.xml.parsers.DocumentBuilderFactory; + +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +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; +import com.fortify.cli.fpr._common.cli.mixin.FPRFileMixin; + +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command(name = "extract-source") +public class FPRSourceExtractCommand extends AbstractOutputCommand implements IJsonNodeSupplier { + private static final ObjectMapper MAPPER = new ObjectMapper(); + @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; + @Mixin private FPRFileMixin fprFileMixin; + + @Option(names = {"-f", "--output-dir"}, required = true, order = 2) + private Path outputDir; + + @Override + public JsonNode getJsonNode() { + try (var fprHandle = fprFileMixin.createFprHandle()) { + // Read src-archive/index.xml to get file mappings + Path indexPath = fprHandle.getPath("/src-archive/index.xml"); + if (!Files.exists(indexPath)) { + throw new FcliSimpleException("FPR does not contain a source archive (src-archive/index.xml not found)"); + } + + Map fileMap = parseSourceIndex(indexPath); + Files.createDirectories(outputDir); + + int extracted = 0; + for (var entry : fileMap.entrySet()) { + String filePath = entry.getKey(); + String archivePath = entry.getValue(); + + Path srcEntry = fprHandle.getPath("/" + archivePath); + if (Files.exists(srcEntry)) { + Path target = outputDir.resolve(filePath); + // Zip-slip protection + if (!target.normalize().startsWith(outputDir.normalize())) { + continue; + } + Files.createDirectories(target.getParent()); + Files.copy(srcEntry, target, StandardCopyOption.REPLACE_EXISTING); + extracted++; + } + } + + var node = MAPPER.createObjectNode(); + node.put("outputDir", outputDir.toString()); + node.put("totalFiles", fileMap.size()); + node.put("extractedFiles", extracted); + node.put("__action__", extracted > 0 ? "EXTRACTED" : "NO_FILES"); + return node; + } catch (IOException e) { + throw new FcliTechnicalException("Error extracting source archive", e); + } + } + + private Map parseSourceIndex(Path indexPath) throws IOException { + var map = new LinkedHashMap(); + try (InputStream is = Files.newInputStream(indexPath)) { + var dbf = DocumentBuilderFactory.newInstance(); + dbf.setFeature("http://xml.org/sax/features/external-general-entities", false); + dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + dbf.setXIncludeAware(false); + dbf.setExpandEntityReferences(false); + var doc = dbf.newDocumentBuilder().parse(is); + NodeList entries = doc.getElementsByTagName("entry"); + for (int i = 0; i < entries.getLength(); i++) { + var elem = (Element) entries.item(i); + String id = elem.getAttribute("key"); + String path = elem.getTextContent().trim(); + if (id != null && !id.isBlank() && !path.isBlank()) { + map.put(id, path); + } + } + } catch (IOException e) { + throw e; + } catch (Exception e) { + throw new FcliTechnicalException("Failed to parse src-archive/index.xml", e); + } + return map; + } + + @Override + public boolean isSingular() { + return true; + } +} diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/source/cli/cmd/FPRSourceMergeCommand.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/source/cli/cmd/FPRSourceMergeCommand.java new file mode 100644 index 00000000000..0980562ddcf --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/source/cli/cmd/FPRSourceMergeCommand.java @@ -0,0 +1,166 @@ +/* + * 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.fpr.source.cli.cmd; + +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.Enumeration; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; + +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +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; + +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +/** + * Merges a source directory into an FPR file as a source archive. + * Creates or replaces the {@code src-archive/} entries with a + * generated {@code index.xml} and numbered archive entries. + */ +@Command(name = "merge-source") +public class FPRSourceMergeCommand extends AbstractOutputCommand implements IJsonNodeSupplier { + private static final ObjectMapper MAPPER = new ObjectMapper(); + @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; + + @Option(names = {"--fpr"}, required = true, order = 1) + private Path fprPath; + + @Option(names = {"--source-dir"}, required = true, order = 2) + private Path sourceDir; + + @Option(names = {"-f", "--output-file"}, order = 3) + private Path outputPath; + + @Override + public JsonNode getJsonNode() { + validateInputs(); + if (outputPath == null) { outputPath = fprPath; } + + try { + int added = mergeSourceArchive(); + var node = MAPPER.createObjectNode(); + node.put("fpr", fprPath.toString()); + node.put("sourceDir", sourceDir.toString()); + node.put("output", outputPath.toString()); + node.put("filesAdded", added); + node.put("__action__", added > 0 ? "MERGED" : "NO_FILES"); + return node; + } catch (IOException e) { + throw new FcliTechnicalException("Error merging source archive", e); + } + } + + private void validateInputs() { + if (!Files.exists(fprPath)) { + throw new FcliSimpleException("FPR file not found: " + fprPath); + } + if (!Files.isDirectory(sourceDir)) { + throw new FcliSimpleException("Source directory not found: " + sourceDir); + } + } + + private int mergeSourceArchive() throws IOException { + // Collect source files + var sourceFiles = new java.util.ArrayList(); + try (var walk = Files.walk(sourceDir)) { + walk.filter(Files::isRegularFile).forEach(sourceFiles::add); + } + if (sourceFiles.isEmpty()) { return 0; } + + Path tempFpr = Files.createTempFile("fcli-source-merge-", ".fpr"); + try { + try (var zipIn = new ZipFile(fprPath.toFile()); + OutputStream fos = Files.newOutputStream(tempFpr); + var zipOut = new ZipOutputStream(fos)) { + + // Copy all existing entries except src-archive/* + Enumeration entries = zipIn.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (entry.getName().startsWith("src-archive/")) { + continue; + } + zipOut.putNextEntry(new ZipEntry(entry.getName())); + try (InputStream is = zipIn.getInputStream(entry)) { + is.transferTo(zipOut); + } + zipOut.closeEntry(); + } + + // Generate index.xml and add source files + var dbf = DocumentBuilderFactory.newInstance(); + var doc = dbf.newDocumentBuilder().newDocument(); + var root = doc.createElement("index"); + doc.appendChild(root); + + int id = 0; + for (var file : sourceFiles) { + String relativePath = sourceDir.relativize(file).toString().replace('\\', '/'); + + // Add to index + var entryElem = doc.createElement("entry"); + entryElem.setAttribute("id", String.valueOf(id)); + entryElem.setTextContent(relativePath); + root.appendChild(entryElem); + + // Add file content + zipOut.putNextEntry(new ZipEntry("src-archive/" + id)); + Files.copy(file, zipOut); + zipOut.closeEntry(); + + id++; + } + + // Write index.xml + zipOut.putNextEntry(new ZipEntry("src-archive/index.xml")); + var transformer = TransformerFactory.newInstance().newTransformer(); + transformer.setOutputProperty(OutputKeys.INDENT, "yes"); + transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); + transformer.transform(new DOMSource(doc), new StreamResult(zipOut)); + zipOut.closeEntry(); + } + + Files.move(tempFpr, outputPath, StandardCopyOption.REPLACE_EXISTING); + return sourceFiles.size(); + } catch (Exception e) { + Files.deleteIfExists(tempFpr); + if (e instanceof IOException ioe) { throw ioe; } + throw new FcliTechnicalException("Failed to merge source archive", e); + } + } + + @Override + public boolean isSingular() { + return true; + } +} diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/summary/cli/cmd/FPRSummaryCommand.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/summary/cli/cmd/FPRSummaryCommand.java new file mode 100644 index 00000000000..b5e769c9c9f --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/summary/cli/cmd/FPRSummaryCommand.java @@ -0,0 +1,104 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.fpr.summary.cli.cmd; + +import java.io.IOException; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +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; +import com.fortify.cli.fpr._common.cli.mixin.FPRFileMixin; +import com.fortify.cli.fpr._common.helper.FVDLInfoParser; + +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +@Command(name = "show-summary") +public class FPRSummaryCommand extends AbstractOutputCommand implements IJsonNodeSupplier { + private static final ObjectMapper MAPPER = new ObjectMapper(); + @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; + @Mixin private FPRFileMixin fprFileMixin; + + @Override + public JsonNode getJsonNode() { + try (var fprHandle = fprFileMixin.createFprHandle()) { + var info = FVDLInfoParser.parse(fprHandle); + var build = info.build(); + var engine = info.engine(); + var node = MAPPER.createObjectNode(); + + node.put("project", build.project()); + node.put("version", build.version()); + node.put("buildID", build.buildID()); + node.put("numberFiles", build.numberFiles()); + node.put("sourceBasePath", build.sourceBasePath()); + if (build.scanTimeSeconds() != null) { + node.put("scanTimeSeconds", build.scanTimeSeconds()); + } + if (build.buildDuration() != null) { + node.put("buildDurationSeconds", build.buildDuration()); + } + + if (!build.totalLoc().isEmpty()) { + var locNode = MAPPER.createObjectNode(); + for (var loc : build.totalLoc()) { + locNode.put(loc.type() != null ? loc.type() : "total", loc.value()); + } + node.set("loc", locNode); + } + + node.put("engineVersion", engine.engineVersion()); + + if (engine.machineInfo() != null) { + var mi = MAPPER.createObjectNode(); + mi.put("hostname", engine.machineInfo().hostname()); + mi.put("username", engine.machineInfo().username()); + mi.put("platform", engine.machineInfo().platform()); + node.set("machineInfo", mi); + } + + if (!engine.rulePacks().isEmpty()) { + ArrayNode rp = MAPPER.createArrayNode(); + for (var pack : engine.rulePacks()) { + var p = MAPPER.createObjectNode(); + p.put("name", pack.name()); + p.put("version", pack.version()); + p.put("id", pack.id()); + if (pack.sku() != null) { p.put("sku", pack.sku()); } + rp.add(p); + } + node.set("rulePacks", rp); + } + + if (!engine.commandLine().isEmpty()) { + ArrayNode cmdNode = MAPPER.createArrayNode(); + engine.commandLine().forEach(cmdNode::add); + node.set("commandLine", cmdNode); + } + + return node; + } catch (IOException e) { + throw new FcliTechnicalException("Error reading FPR file", e); + } + } + + @Override + public boolean isSingular() { + return true; + } +} diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/trim/cli/cmd/FPRTrimCommand.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/trim/cli/cmd/FPRTrimCommand.java new file mode 100644 index 00000000000..4b0f1fe8666 --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/trim/cli/cmd/FPRTrimCommand.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.fpr.trim.cli.cmd; + +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.Enumeration; +import java.util.HashSet; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +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; + +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +/** + * Trims an FPR to only the latest scan by removing any previous scan FVDL + * files. In a merged FPR, additional scan data is stored as numbered + * {@code audit_N.fvdl} entries. This command removes those older scans, + * keeping only the primary {@code audit.fvdl}. + */ +@Command(name = "trim") +public class FPRTrimCommand extends AbstractOutputCommand implements IJsonNodeSupplier { + private static final ObjectMapper MAPPER = new ObjectMapper(); + @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; + + @Option(names = {"--fpr"}, required = true, order = 1) + private Path fprPath; + + @Option(names = {"-f", "--output-file"}, order = 2) + private Path outputPath; + + @Override + public JsonNode getJsonNode() { + if (!Files.exists(fprPath)) { + throw new FcliSimpleException("FPR file not found: " + fprPath); + } + if (outputPath == null) { outputPath = fprPath; } + + try { + var result = trimToLastScan(); + var node = MAPPER.createObjectNode(); + node.put("fpr", fprPath.toString()); + node.put("output", outputPath.toString()); + node.put("removedEntries", result.removedCount); + node.put("removedEntryNames", String.join(", ", result.removedNames)); + node.put("__action__", result.removedCount > 0 ? "TRIMMED" : "UNCHANGED"); + return node; + } catch (IOException e) { + throw new FcliTechnicalException("Error trimming FPR file", e); + } + } + + private record TrimResult(int removedCount, Set removedNames) {} + + private TrimResult trimToLastScan() throws IOException { + Set toRemove = new HashSet<>(); + + // Identify entries to remove: older scan FVDLs and their MACs + try (var zipFile = new ZipFile(fprPath.toFile())) { + Enumeration entries = zipFile.entries(); + while (entries.hasMoreElements()) { + String name = entries.nextElement().getName(); + // Older scans are stored as audit_N.fvdl, audit_N.fvdl.mac + if (name.matches("audit_\\d+\\.fvdl(\\.mac)?")) { + toRemove.add(name); + } + } + } + + if (toRemove.isEmpty()) { + if (!outputPath.equals(fprPath)) { + Files.copy(fprPath, outputPath, StandardCopyOption.REPLACE_EXISTING); + } + return new TrimResult(0, toRemove); + } + + Path tempFpr = Files.createTempFile("fcli-trim-", ".fpr"); + try { + try (var zipIn = new ZipFile(fprPath.toFile()); + OutputStream fos = Files.newOutputStream(tempFpr); + var zipOut = new ZipOutputStream(fos)) { + + Enumeration entries = zipIn.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (toRemove.contains(entry.getName())) { + continue; + } + zipOut.putNextEntry(new ZipEntry(entry.getName())); + try (InputStream is = zipIn.getInputStream(entry)) { + is.transferTo(zipOut); + } + zipOut.closeEntry(); + } + } + + Files.move(tempFpr, outputPath, StandardCopyOption.REPLACE_EXISTING); + return new TrimResult(toRemove.size(), toRemove); + } catch (Exception e) { + Files.deleteIfExists(tempFpr); + throw new FcliTechnicalException("Failed to write trimmed FPR", e); + } + } + + @Override + public boolean isSingular() { + return true; + } +} diff --git a/fcli-core/fcli-fpr/src/main/resources/com/fortify/cli/fpr/i18n/FPRMessages.properties b/fcli-core/fcli-fpr/src/main/resources/com/fortify/cli/fpr/i18n/FPRMessages.properties index 87458bd03a8..c779e79d9f5 100644 --- a/fcli-core/fcli-fpr/src/main/resources/com/fortify/cli/fpr/i18n/FPRMessages.properties +++ b/fcli-core/fcli-fpr/src/main/resources/com/fortify/cli/fpr/i18n/FPRMessages.properties @@ -7,15 +7,41 @@ fcli.fpr.usage.header = Commands for working with local FPR (Fortify Project Res fcli.fpr.issue.usage.header = Commands for listing and analyzing issues from a local FPR file. fcli.fpr.issue.list.usage.header = List all issues/vulnerabilities from an FPR file. fcli.fpr.issue.get.usage.header = Get detailed information about a specific issue from an FPR file. -fcli.fpr.issue.count.usage.header = Count issues by category from an FPR file. +fcli.fpr.issue.count.usage.header = Count issues by category or analyzer from an FPR file. fcli.fpr.issue.update.usage.header = Update an issue in an FPR file by setting its analysis tag, custom tags, or other audit attributes. +# Information commands +fcli.fpr.information.usage.header = Commands for retrieving information, signatures, and performing operations on FPR files. + +# Summary command +fcli.fpr.information.show-summary.usage.header = Display project summary information from an FPR file (build info, engine version, LOC totals, machine info, rule packs). + +# LOC command +fcli.fpr.information.list-source-files.usage.header = List source files and their lines-of-code counts from an FPR file. + +# Errors command +fcli.fpr.information.list-errors.usage.header = List scan errors recorded during analysis from an FPR file. + +# Signature command +fcli.fpr.information.show-signature.usage.header = Display FPR signature information including version, MAC, engine version, and build metadata. + +# Merge command +fcli.fpr.information.merge.usage.header = Merge audit data from a source FPR into the primary project FPR. Primary audit data takes precedence for conflicts. + +# Source archive commands +fcli.fpr.information.extract-source.usage.header = Extract the source archive from an FPR file to a local directory. +fcli.fpr.information.merge-source.usage.header = Merge a local source directory into an FPR file as a source archive. + +# Trim command +fcli.fpr.information.trim.usage.header = Remove previous scan data from a merged FPR, keeping only the latest scan. + # Shared options fcli.fpr.issue.list.fpr = Path to the local FPR file to read. fcli.fpr.issue.get.fpr = Path to the local FPR file to read. fcli.fpr.issue.get.instance-id = The instanceId of the issue to retrieve. fcli.fpr.issue.get.embed = Comma-separated list of extra data to embed. Valid values: history. fcli.fpr.issue.count.fpr = Path to the local FPR file to read. +fcli.fpr.issue.count.by = Group counts by 'category' (default) or 'analyzer'. fcli.fpr.issue.update.fpr = Path to the local FPR file to update. fcli.fpr.issue.update.instance-ids = Comma-separated list of one or more issue instanceIds to update. fcli.fpr.issue.update.analysis = Optional analysis value to set. Valid values (case-insensitive): 'Not an Issue', 'Exploitable', 'Suspicious', 'Reliability Issue', 'False Positive', 'Bad Practice'. @@ -25,8 +51,35 @@ fcli.fpr.issue.update.suppress = Suppress the issue in the FPR file. fcli.fpr.issue.update.user = Username to record in the audit trail. Defaults to the current operating system user. fcli.fpr.issue.update.assign-user = Assign the issue to the specified user. Stored as the assignedUser attribute on the Issue element. Pass an empty string to clear the assignment. +fcli.fpr.information.show-summary.fpr = Path to the local FPR file to read. +fcli.fpr.information.list-source-files.fpr = Path to the local FPR file to read. +fcli.fpr.information.list-errors.fpr = Path to the local FPR file to read. +fcli.fpr.information.show-signature.fpr = Path to the local FPR file to read. + +fcli.fpr.information.merge.project = Path to the primary FPR file. Audit data from this file takes precedence in conflicts. +fcli.fpr.information.merge.source = Path to the secondary FPR file whose audit data will be merged in. +fcli.fpr.information.merge.output-file = Output FPR file path. Defaults to the primary project path if not specified. + +fcli.fpr.information.extract-source.fpr = Path to the FPR file to extract the source archive from. +fcli.fpr.information.extract-source.output-dir = Directory to extract source files into. + +fcli.fpr.information.merge-source.fpr = Path to the FPR file to merge the source archive into. +fcli.fpr.information.merge-source.source-dir = Directory containing source files to add to the FPR. +fcli.fpr.information.merge-source.output-file = Output FPR file path. Defaults to the input FPR path if not specified. + +fcli.fpr.information.trim.fpr = Path to the FPR file to trim. +fcli.fpr.information.trim.output-file = Output FPR file path. Defaults to the input FPR path if not specified. + # Default output columns fcli.fpr.issue.list.output.table.args = instanceId,category,priority,analyzerName,primaryFile,primaryLine,audited,suppressed fcli.fpr.issue.get.output.table.args = instanceId,category,kingdom,type,subtype,analyzerName,priority,primaryFile,primaryLine,audited,suppressed,issueStatus,shortDescription -fcli.fpr.issue.count.output.table.args = category,total,audited,suppressed -fcli.fpr.issue.update.output.table.args = instanceId,analysis,customTags,comment,suppressed,assignedUser,user,__action__ \ No newline at end of file +fcli.fpr.issue.count.output.table.args = group,total,audited,suppressed +fcli.fpr.issue.update.output.table.args = instanceId,analysis,customTags,comment,suppressed,assignedUser,user,__action__ +fcli.fpr.information.show-summary.output.table.args = project,version,buildID,numberFiles,engineVersion +fcli.fpr.information.list-source-files.output.table.args = file,type,loc +fcli.fpr.information.list-errors.output.table.args = code,message +fcli.fpr.information.show-signature.output.table.args = fprVersion,signed,mac,engineVersion,buildID,hostname,platform +fcli.fpr.information.merge.output.table.args = project,source,output,mergedIssues,__action__ +fcli.fpr.information.extract-source.output.table.args = outputDir,totalFiles,extractedFiles,__action__ +fcli.fpr.information.merge-source.output.table.args = fpr,sourceDir,output,filesAdded,__action__ +fcli.fpr.information.trim.output.table.args = fpr,output,removedEntries,__action__ \ No newline at end of file From cb2202cdfeb9f9d8063322934ec4d429ccb88b38 Mon Sep 17 00:00:00 2001 From: Sangamesh Vijaykumar Date: Tue, 5 May 2026 11:18:13 +0530 Subject: [PATCH 5/5] fix: harden XML parsing against XXE, centralize secure XML utils, and add command aliases --- .../cli/aviator/fpr/utils/XmlUtils.java | 69 +++++++++++++++++++ .../fpr/error/cli/cmd/FPRErrorsCommand.java | 2 +- .../cli/cmd/FPRInformationCommands.java | 2 +- .../cli/fpr/loc/cli/cmd/FPRLocCommand.java | 2 +- .../fpr/merge/cli/cmd/FPRMergeCommand.java | 13 ++-- .../cli/cmd/FPRSignatureCommand.java | 2 +- .../cli/cmd/FPRSourceExtractCommand.java | 13 +--- .../source/cli/cmd/FPRSourceMergeCommand.java | 10 ++- .../summary/cli/cmd/FPRSummaryCommand.java | 2 +- .../cli/fpr/i18n/FPRMessages.properties | 30 ++++---- 10 files changed, 100 insertions(+), 45 deletions(-) diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/XmlUtils.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/XmlUtils.java index 1bb306c0ca2..1e9ab10b7ea 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/XmlUtils.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/XmlUtils.java @@ -14,6 +14,13 @@ import java.math.BigDecimal; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.TransformerConfigurationException; +import javax.xml.transform.TransformerFactory; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -139,4 +146,66 @@ public static String[] getMetaInfoFromRule(EngineData.RuleInfo.Rule ruleElement) } return metaInfo; } + + /** + * Creates a {@link DocumentBuilderFactory} pre-configured to prevent XXE attacks. + * Disables external general/parameter entities, external DTD loading, and XInclude, + * and enables {@code FEATURE_SECURE_PROCESSING}. + * + * @param namespaceAware whether the factory should be namespace-aware + * @return a hardened {@link DocumentBuilderFactory} + * @throws IllegalStateException if the JDK does not support the required security features + */ + public static DocumentBuilderFactory secureDocumentBuilderFactory(boolean namespaceAware) { + try { + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + dbf.setFeature("http://xml.org/sax/features/external-general-entities", false); + dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + dbf.setXIncludeAware(false); + dbf.setExpandEntityReferences(false); + dbf.setNamespaceAware(namespaceAware); + return dbf; + } catch (ParserConfigurationException e) { + throw new IllegalStateException("Failed to configure secure XML DocumentBuilderFactory", e); + } + } + + /** + * Creates a {@link DocumentBuilder} pre-configured to prevent XXE attacks. + * Convenience method combining {@link #secureDocumentBuilderFactory(boolean)} + * and {@link DocumentBuilderFactory#newDocumentBuilder()}. + * + * @param namespaceAware whether the builder should be namespace-aware + * @return a hardened {@link DocumentBuilder} + * @throws IllegalStateException if the JDK does not support the required security features + */ + public static DocumentBuilder secureDocumentBuilder(boolean namespaceAware) { + try { + return secureDocumentBuilderFactory(namespaceAware).newDocumentBuilder(); + } catch (ParserConfigurationException e) { + throw new IllegalStateException("Failed to create secure XML DocumentBuilder", e); + } + } + + /** + * Creates a {@link TransformerFactory} pre-configured to prevent XXE attacks. + * Restricts access to external DTDs and stylesheets and enables {@code FEATURE_SECURE_PROCESSING}. + * + * @return a hardened {@link TransformerFactory} + * @throws IllegalStateException if the JDK does not support the required security features + */ + public static TransformerFactory secureTransformerFactory() { + try { + TransformerFactory tf = TransformerFactory.newInstance(); + tf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + tf.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + tf.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, ""); + return tf; + } catch (TransformerConfigurationException e) { + throw new IllegalStateException("Failed to configure secure XML TransformerFactory", e); + } + } } \ No newline at end of file diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/error/cli/cmd/FPRErrorsCommand.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/error/cli/cmd/FPRErrorsCommand.java index c814585568f..11facc11507 100644 --- a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/error/cli/cmd/FPRErrorsCommand.java +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/error/cli/cmd/FPRErrorsCommand.java @@ -28,7 +28,7 @@ import picocli.CommandLine.Command; import picocli.CommandLine.Mixin; -@Command(name = "list-errors") +@Command(name = "list-errors", aliases = {"errors", "le"}) public class FPRErrorsCommand extends AbstractOutputCommand { private static final ObjectMapper MAPPER = new ObjectMapper(); @Getter @Mixin private OutputHelperMixins.TableNoQuery outputHelper; diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/information/cli/cmd/FPRInformationCommands.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/information/cli/cmd/FPRInformationCommands.java index 53026760765..f55e025623b 100644 --- a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/information/cli/cmd/FPRInformationCommands.java +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/information/cli/cmd/FPRInformationCommands.java @@ -25,7 +25,7 @@ import picocli.CommandLine.Command; @Command( - name = "information", + name = "information", aliases = {"info"}, subcommands = { FPRSummaryCommand.class, FPRLocCommand.class, diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/loc/cli/cmd/FPRLocCommand.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/loc/cli/cmd/FPRLocCommand.java index 4864ea75574..4f0e37b9d62 100644 --- a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/loc/cli/cmd/FPRLocCommand.java +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/loc/cli/cmd/FPRLocCommand.java @@ -29,7 +29,7 @@ import picocli.CommandLine.Command; import picocli.CommandLine.Mixin; -@Command(name = "list-source-files") +@Command(name = "list-source-files", aliases = {"loc", "lsf"}) public class FPRLocCommand extends AbstractOutputCommand { private static final ObjectMapper MAPPER = new ObjectMapper(); @Getter @Mixin private OutputHelperMixins.TableNoQuery outputHelper; diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/merge/cli/cmd/FPRMergeCommand.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/merge/cli/cmd/FPRMergeCommand.java index c7889a6aecd..7b114e93167 100644 --- a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/merge/cli/cmd/FPRMergeCommand.java +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/merge/cli/cmd/FPRMergeCommand.java @@ -24,9 +24,7 @@ import java.util.zip.ZipFile; import java.util.zip.ZipOutputStream; -import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.transform.OutputKeys; -import javax.xml.transform.TransformerFactory; import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; @@ -36,6 +34,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.aviator.fpr.utils.XmlUtils; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.exception.FcliTechnicalException; import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; @@ -143,9 +142,7 @@ private Document readAuditXml(Path fprPath) throws IOException { ZipEntry auditEntry = zipFile.getEntry("audit.xml"); if (auditEntry == null) { return null; } try (InputStream is = zipFile.getInputStream(auditEntry)) { - var dbf = DocumentBuilderFactory.newInstance(); - dbf.setNamespaceAware(true); - return dbf.newDocumentBuilder().parse(is); + return XmlUtils.secureDocumentBuilder(true).parse(is); } catch (Exception e) { throw new FcliTechnicalException("Failed to parse audit.xml from " + fprPath, e); } @@ -167,9 +164,7 @@ private Map extractIssueElements(Document doc) { private Document createEmptyAuditDoc() { try { - var dbf = DocumentBuilderFactory.newInstance(); - dbf.setNamespaceAware(true); - var doc = dbf.newDocumentBuilder().newDocument(); + var doc = XmlUtils.secureDocumentBuilder(true).newDocument(); var root = doc.createElementNS(AUDIT_NS, "ProjectVersionAudit"); doc.appendChild(root); return doc; @@ -207,7 +202,7 @@ private void writeUpdatedFpr(Document auditDoc, boolean changed) throws IOExcept // Write merged audit.xml zipOut.putNextEntry(new ZipEntry("audit.xml")); - var transformer = TransformerFactory.newInstance().newTransformer(); + var transformer = XmlUtils.secureTransformerFactory().newTransformer(); transformer.setOutputProperty(OutputKeys.INDENT, "yes"); transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); transformer.transform(new DOMSource(auditDoc), new StreamResult(zipOut)); diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/signature/cli/cmd/FPRSignatureCommand.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/signature/cli/cmd/FPRSignatureCommand.java index a2531bab51f..fe86e279642 100644 --- a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/signature/cli/cmd/FPRSignatureCommand.java +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/signature/cli/cmd/FPRSignatureCommand.java @@ -29,7 +29,7 @@ import picocli.CommandLine.Command; import picocli.CommandLine.Mixin; -@Command(name = "show-signature") +@Command(name = "show-signature", aliases = {"signature", "sign"}) public class FPRSignatureCommand extends AbstractOutputCommand implements IJsonNodeSupplier { private static final ObjectMapper MAPPER = new ObjectMapper(); @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/source/cli/cmd/FPRSourceExtractCommand.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/source/cli/cmd/FPRSourceExtractCommand.java index 152c734067c..904d103416e 100644 --- a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/source/cli/cmd/FPRSourceExtractCommand.java +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/source/cli/cmd/FPRSourceExtractCommand.java @@ -20,13 +20,12 @@ import java.util.LinkedHashMap; import java.util.Map; -import javax.xml.parsers.DocumentBuilderFactory; - import org.w3c.dom.Element; import org.w3c.dom.NodeList; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.aviator.fpr.utils.XmlUtils; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.exception.FcliTechnicalException; import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; @@ -39,7 +38,7 @@ import picocli.CommandLine.Mixin; import picocli.CommandLine.Option; -@Command(name = "extract-source") +@Command(name = "extract-source", aliases = {"source", "es"}) public class FPRSourceExtractCommand extends AbstractOutputCommand implements IJsonNodeSupplier { private static final ObjectMapper MAPPER = new ObjectMapper(); @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; @@ -92,13 +91,7 @@ public JsonNode getJsonNode() { private Map parseSourceIndex(Path indexPath) throws IOException { var map = new LinkedHashMap(); try (InputStream is = Files.newInputStream(indexPath)) { - var dbf = DocumentBuilderFactory.newInstance(); - dbf.setFeature("http://xml.org/sax/features/external-general-entities", false); - dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); - dbf.setXIncludeAware(false); - dbf.setExpandEntityReferences(false); - var doc = dbf.newDocumentBuilder().parse(is); + var doc = XmlUtils.secureDocumentBuilder(false).parse(is); NodeList entries = doc.getElementsByTagName("entry"); for (int i = 0; i < entries.getLength(); i++) { var elem = (Element) entries.item(i); diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/source/cli/cmd/FPRSourceMergeCommand.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/source/cli/cmd/FPRSourceMergeCommand.java index 0980562ddcf..388cb061099 100644 --- a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/source/cli/cmd/FPRSourceMergeCommand.java +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/source/cli/cmd/FPRSourceMergeCommand.java @@ -23,14 +23,13 @@ import java.util.zip.ZipFile; import java.util.zip.ZipOutputStream; -import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.transform.OutputKeys; -import javax.xml.transform.TransformerFactory; import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.aviator.fpr.utils.XmlUtils; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.exception.FcliTechnicalException; import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; @@ -47,7 +46,7 @@ * Creates or replaces the {@code src-archive/} entries with a * generated {@code index.xml} and numbered archive entries. */ -@Command(name = "merge-source") +@Command(name = "merge-source", aliases = {"ms"}) public class FPRSourceMergeCommand extends AbstractOutputCommand implements IJsonNodeSupplier { private static final ObjectMapper MAPPER = new ObjectMapper(); @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; @@ -118,8 +117,7 @@ private int mergeSourceArchive() throws IOException { } // Generate index.xml and add source files - var dbf = DocumentBuilderFactory.newInstance(); - var doc = dbf.newDocumentBuilder().newDocument(); + var doc = XmlUtils.secureDocumentBuilder(false).newDocument(); var root = doc.createElement("index"); doc.appendChild(root); @@ -143,7 +141,7 @@ private int mergeSourceArchive() throws IOException { // Write index.xml zipOut.putNextEntry(new ZipEntry("src-archive/index.xml")); - var transformer = TransformerFactory.newInstance().newTransformer(); + var transformer = XmlUtils.secureTransformerFactory().newTransformer(); transformer.setOutputProperty(OutputKeys.INDENT, "yes"); transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); transformer.transform(new DOMSource(doc), new StreamResult(zipOut)); diff --git a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/summary/cli/cmd/FPRSummaryCommand.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/summary/cli/cmd/FPRSummaryCommand.java index b5e769c9c9f..67ee3aa2861 100644 --- a/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/summary/cli/cmd/FPRSummaryCommand.java +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/summary/cli/cmd/FPRSummaryCommand.java @@ -28,7 +28,7 @@ import picocli.CommandLine.Command; import picocli.CommandLine.Mixin; -@Command(name = "show-summary") +@Command(name = "show-summary", aliases = {"summary", "sum"}) public class FPRSummaryCommand extends AbstractOutputCommand implements IJsonNodeSupplier { private static final ObjectMapper MAPPER = new ObjectMapper(); @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; diff --git a/fcli-core/fcli-fpr/src/main/resources/com/fortify/cli/fpr/i18n/FPRMessages.properties b/fcli-core/fcli-fpr/src/main/resources/com/fortify/cli/fpr/i18n/FPRMessages.properties index c779e79d9f5..a7b873fb3b8 100644 --- a/fcli-core/fcli-fpr/src/main/resources/com/fortify/cli/fpr/i18n/FPRMessages.properties +++ b/fcli-core/fcli-fpr/src/main/resources/com/fortify/cli/fpr/i18n/FPRMessages.properties @@ -1,39 +1,39 @@ # FPR Module Messages # Top-level command -fcli.fpr.usage.header = Commands for working with local FPR (Fortify Project Result) files. +fcli.fpr.usage.header = Commands for working with FPR (Fortify Project Result) files. # Issue commands -fcli.fpr.issue.usage.header = Commands for listing and analyzing issues from a local FPR file. -fcli.fpr.issue.list.usage.header = List all issues/vulnerabilities from an FPR file. -fcli.fpr.issue.get.usage.header = Get detailed information about a specific issue from an FPR file. -fcli.fpr.issue.count.usage.header = Count issues by category or analyzer from an FPR file. -fcli.fpr.issue.update.usage.header = Update an issue in an FPR file by setting its analysis tag, custom tags, or other audit attributes. +fcli.fpr.issue.usage.header = Commands for listing and analyzing issues. +fcli.fpr.issue.list.usage.header = List all issues/vulnerabilities. +fcli.fpr.issue.get.usage.header = Get detailed information about a specific issue. +fcli.fpr.issue.count.usage.header = Count issues by category or analyzer. +fcli.fpr.issue.update.usage.header = Update issue analysis tag, custom tags, or other audit attributes. # Information commands -fcli.fpr.information.usage.header = Commands for retrieving information, signatures, and performing operations on FPR files. +fcli.fpr.information.usage.header = Commands for retrieving information and performing file operations. # Summary command -fcli.fpr.information.show-summary.usage.header = Display project summary information from an FPR file (build info, engine version, LOC totals, machine info, rule packs). +fcli.fpr.information.show-summary.usage.header = Display project summary (build info, engine version, LOC totals, machine info, rule packs). # LOC command -fcli.fpr.information.list-source-files.usage.header = List source files and their lines-of-code counts from an FPR file. +fcli.fpr.information.list-source-files.usage.header = List source files and their lines-of-code counts. # Errors command -fcli.fpr.information.list-errors.usage.header = List scan errors recorded during analysis from an FPR file. +fcli.fpr.information.list-errors.usage.header = List scan errors recorded during analysis. # Signature command -fcli.fpr.information.show-signature.usage.header = Display FPR signature information including version, MAC, engine version, and build metadata. +fcli.fpr.information.show-signature.usage.header = Display signature information including version, MAC, engine version, and build metadata. # Merge command -fcli.fpr.information.merge.usage.header = Merge audit data from a source FPR into the primary project FPR. Primary audit data takes precedence for conflicts. +fcli.fpr.information.merge.usage.header = Merge audit data from a source FPR into the primary FPR; primary data takes precedence for conflicts. # Source archive commands -fcli.fpr.information.extract-source.usage.header = Extract the source archive from an FPR file to a local directory. -fcli.fpr.information.merge-source.usage.header = Merge a local source directory into an FPR file as a source archive. +fcli.fpr.information.extract-source.usage.header = Extract the source archive to a local directory. +fcli.fpr.information.merge-source.usage.header = Merge a local source directory as a source archive. # Trim command -fcli.fpr.information.trim.usage.header = Remove previous scan data from a merged FPR, keeping only the latest scan. +fcli.fpr.information.trim.usage.header = Remove older scan data, keeping only the latest scan. # Shared options fcli.fpr.issue.list.fpr = Path to the local FPR file to read.