diff --git a/fcli-core/fcli-app/build.gradle.kts b/fcli-core/fcli-app/build.gradle.kts index 92328e390a..0119cb05c5 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 3d319940d4..70463982f5 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 75c616be25..2259565957 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,8 @@ 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<>(); + private String assignedUser; public void addTag(String tagId, String tagValue) { if (tagId != null) { @@ -52,4 +54,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 17773d1de4..1e5eaaf6aa 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++) { @@ -183,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"); @@ -211,6 +216,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 +277,152 @@ 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) { + 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; + } + + 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(); + + 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; + } + + int revision = Optional.ofNullable(issueElement.getAttribute("revision")) + .filter(s -> !s.isEmpty()) + .map(Integer::parseInt) + .orElse(0); + issueElement.setAttribute("revision", String.valueOf(revision + 1)); + + if (!changedTags.isEmpty()) { + Element clientAuditTrail = getClientAuditTrailElement(issueElement); + for (var ct : changedTags.entrySet()) { + updateOrAddTag(issueElement, ct.getKey(), ct.getValue()); + addTagHistory(clientAuditTrail, ct.getKey(), ct.getValue(), username); + } + } + if (commentAdded) { + addCommentToIssueElement(issueElement, comment, username); + } + if (suppressChanged) { + issueElement.setAttribute("suppressed", "true"); + } + 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) { + 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 +567,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 +585,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-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 1bb306c0ca..1e9ab10b7e 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/build.gradle.kts b/fcli-core/fcli-fpr/build.gradle.kts new file mode 100644 index 0000000000..5dfa7f42fc --- /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 0000000000..a4140034b5 --- /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 0000000000..fc518d8007 --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_common/helper/FPRHelper.java @@ -0,0 +1,284 @@ +/* + * 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.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; + +/** + * 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.getAssignedUser() != null && !auditIssue.getAssignedUser().isBlank()) { + node.put("assignedUser", auditIssue.getAssignedUser()); + } + + 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); + } + } + /** + * 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/_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 0000000000..a15bd99104 --- /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 new file mode 100644 index 0000000000..17e338e076 --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_main/cli/cmd/FPRCommands.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._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.information.cli.cmd.FPRInformationCommands; +import com.fortify.cli.fpr.issue.cli.cmd.FPRIssueCommands; + +import picocli.CommandLine.Command; + +@FcliModuleCategory(UTIL) +@Command( + name = "fpr", + resourceBundle = "com.fortify.cli.fpr.i18n.FPRMessages", + subcommands = { + FPRIssueCommands.class, + + + + + + + + FPRInformationCommands.class + } +) +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 0000000000..11facc1150 --- /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", aliases = {"errors", "le"}) +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 0000000000..f55e025623 --- /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", aliases = {"info"}, + 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/FPRIssueCommands.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueCommands.java new file mode 100644 index 0000000000..28586d59e5 --- /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, + FPRIssueUpdateCommand.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 0000000000..7c55fc7b47 --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueCountCommand.java @@ -0,0 +1,109 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.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.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.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 = "count") +public class FPRIssueCountCommand extends AbstractOutputCommand { + private static final ObjectMapper MAPPER = new ObjectMapper(); + @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 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]++; } + } + int total = vulnerabilities.size(); + return streamingObjectNodeProducerBuilder(ObjectNodeProducerApplyFrom.SPEC) + .streamSupplier(() -> toStream(counts, total)) + .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); + } catch (IOException e) { + throw new FcliTechnicalException("Error processing FPR file", e); + } + } + + private Stream toStream(Map counts, int total) { + String label = "group"; + var summary = counts.entrySet().stream().map(e -> { + var node = MAPPER.createObjectNode(); + 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(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()); + return Stream.concat(summary, Stream.of(totalNode)); + } + + @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/FPRIssueGetCommand.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueGetCommand.java new file mode 100644 index 0000000000..65f8648379 --- /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 0000000000..0cba556674 --- /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/issue/cli/cmd/FPRIssueUpdateCommand.java b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueUpdateCommand.java new file mode 100644 index 0000000000..fc2dff76b8 --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueUpdateCommand.java @@ -0,0 +1,213 @@ +/* + * 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.filter.FilterTemplate; +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 com.fortify.cli.fpr._common.helper.FPRHelper; + +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@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; + 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"}, order = 3) + private String analysis; + + @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 = 6) + private boolean suppress; + + @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); + 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(); + + // 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.auditIssueMulti(id, resolvedTags, comment, username, suppress, assignUser); + anyChanged |= changed; + results.add(buildResultRow(id, canonicalAnalysis, resolvedTags, username, changed)); + } + if (anyChanged) { + auditProcessor.saveAuditXml(); + } + return results; + } catch (IOException e) { + throw new FcliTechnicalException("Error processing FPR file", e); + } + } + + 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 != 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) { + 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 || value.isBlank()) { 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; + } +} 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 0000000000..4f0e37b9d6 --- /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", aliases = {"loc", "lsf"}) +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 0000000000..7b114e9316 --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/merge/cli/cmd/FPRMergeCommand.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.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.transform.OutputKeys; +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.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; +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)) { + return XmlUtils.secureDocumentBuilder(true).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 doc = XmlUtils.secureDocumentBuilder(true).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 = XmlUtils.secureTransformerFactory().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 0000000000..fe86e27964 --- /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", aliases = {"signature", "sign"}) +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 0000000000..904d103416 --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/source/cli/cmd/FPRSourceExtractCommand.java @@ -0,0 +1,116 @@ +/* + * 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 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; +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", aliases = {"source", "es"}) +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 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); + 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 0000000000..388cb06109 --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/source/cli/cmd/FPRSourceMergeCommand.java @@ -0,0 +1,164 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.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.transform.OutputKeys; +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; +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", aliases = {"ms"}) +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 doc = XmlUtils.secureDocumentBuilder(false).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 = XmlUtils.secureTransformerFactory().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 0000000000..67ee3aa286 --- /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", aliases = {"summary", "sum"}) +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 0000000000..4b0f1fe866 --- /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 new file mode 100644 index 0000000000..a7b873fb3b --- /dev/null +++ b/fcli-core/fcli-fpr/src/main/resources/com/fortify/cli/fpr/i18n/FPRMessages.properties @@ -0,0 +1,85 @@ +# FPR Module Messages + +# Top-level command +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. +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 and performing file operations. + +# Summary command +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. + +# Errors command +fcli.fpr.information.list-errors.usage.header = List scan errors recorded during analysis. + +# Signature command +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 FPR; primary data takes precedence for conflicts. + +# Source archive commands +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 older scan data, 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'. +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. + +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 = 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 diff --git a/gradle.properties b/gradle.properties index 4adf83d589..b1e547ec46 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