Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions fcli-core/fcli-app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -128,4 +128,4 @@ tasks.register<Copy>("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"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -54,6 +55,7 @@
SSCCommands.class,
ToolCommands.class,
LicenseCommands.class,
FPRCommands.class,
UtilCommands.class
}
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ public class AuditIssue {
private int revision;
@Builder.Default private Map<String, String> tags = new HashMap<>();
@Builder.Default private List<Comment> threadedComments = new ArrayList<>();
@Builder.Default private List<TagHistoryEntry> tagHistory = new ArrayList<>();
private String assignedUser;

public void addTag(String tagId, String tagValue) {
if (tagId != null) {
Expand All @@ -52,4 +54,14 @@ public static class Comment {
private String username;
private String timestamp;
}
}

@Getter
@Builder
@Reflectable
public static class TagHistoryEntry {
private String tagId;
private String tagValue;
private String editTime;
private String username;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -93,20 +94,21 @@ public Map<String, AuditIssue> 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++) {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -211,6 +216,31 @@ private AuditIssue processAuditIssue(Element issueElement) {
}
auditIssueBuilder.threadedComments(threadedComments);

List<AuditIssue.TagHistoryEntry> 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();
}

Expand Down Expand Up @@ -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<String, String> 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 <Issue>} element). Returns true if anything changed, false if the call was a no-op.
*/
public boolean auditIssueMulti(String instanceId, java.util.Map<String, String> 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<String, String> 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<String, String> updateAuditXml(Map<String, AuditResponse> auditResponses, TagMappingConfig tagMappingConfig) throws AviatorTechnicalException {
Map<String, String> remediationCommentTimestamps = new HashMap<>();
for (Map.Entry<String, AuditResponse> entry : auditResponses.entrySet()) {
Expand Down Expand Up @@ -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");
Expand All @@ -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);
}
Expand Down
6 changes: 6 additions & 0 deletions fcli-core/fcli-fpr/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
plugins { id("fcli.module-conventions") }

dependencies {
val aviatorCommonRef = project.findProperty("fcliAviatorCommonRef") as String
implementation(project(aviatorCommonRef))
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading