Skip to content

Commit 34bd740

Browse files
committed
feat: support assigning a user to issues via --assign-user and updating custom tags via --custom-tags in fcli fpr issue audit
1 parent 99a7a53 commit 34bd740

5 files changed

Lines changed: 198 additions & 20 deletions

File tree

fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/model/AuditIssue.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ public class AuditIssue {
3232
@Builder.Default private Map<String, String> tags = new HashMap<>();
3333
@Builder.Default private List<Comment> threadedComments = new ArrayList<>();
3434
@Builder.Default private List<TagHistoryEntry> tagHistory = new ArrayList<>();
35+
private String assignedUser;
3536

3637
public void addTag(String tagId, String tagValue) {
3738
if (tagId != null) {

fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/AuditProcessor.java

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,9 @@ private AuditIssue processAuditIssue(Element issueElement) {
185185
AuditIssue.AuditIssueBuilder auditIssueBuilder = AuditIssue.builder();
186186

187187
auditIssueBuilder.instanceId(issueElement.getAttribute("instanceId"));
188+
if (issueElement.hasAttribute("assignedUser")) {
189+
auditIssueBuilder.assignedUser(issueElement.getAttribute("assignedUser"));
190+
}
188191
auditIssueBuilder.suppressed(Boolean.parseBoolean(issueElement.getAttribute("suppressed")));
189192

190193
String revisionStr = issueElement.getAttribute("revision");
@@ -281,21 +284,64 @@ public void updateIssueTag(AuditIssue auditIssue, String tagId, String tagValue)
281284
*/
282285
public boolean auditIssue(String instanceId, String tagId, String tagValue,
283286
String comment, String username, boolean suppress) {
287+
return auditIssueMulti(instanceId, java.util.Map.of(tagId, tagValue), comment, username, suppress, null);
288+
}
289+
290+
/**
291+
* Backwards-compatible overload without an assigned user.
292+
*/
293+
public boolean auditIssueMulti(String instanceId, java.util.Map<String, String> tagIdToValue,
294+
String comment, String username, boolean suppress) {
295+
return auditIssueMulti(instanceId, tagIdToValue, comment, username, suppress, null);
296+
}
297+
298+
/**
299+
* Audits a single issue, applying multiple tag changes atomically: bumps revision once,
300+
* writes only the tags whose value actually changes (and a TagHistory entry for each),
301+
* appends an optional comment once, optionally suppresses the issue, and optionally
302+
* assigns the issue to a user (stored as the {@code assignedUser} attribute on the
303+
* {@code <Issue>} element). Returns true if anything changed, false if the call was a no-op.
304+
*/
305+
public boolean auditIssueMulti(String instanceId, java.util.Map<String, String> tagIdToValue,
306+
String comment, String username, boolean suppress,
307+
String assignedUser) {
284308
if (username == null || username.isBlank()) {
285309
throw new IllegalArgumentException("username must be provided");
286310
}
311+
if (tagIdToValue == null) { tagIdToValue = java.util.Map.of(); }
312+
287313
Element issueElement = findIssueElement(instanceId);
288314
boolean issueCreated = false;
289315
if (issueElement == null) {
290316
issueElement = createSimpleIssueElement(instanceId);
291317
issueCreated = true;
292318
}
293-
String currentTagValue = getCurrentTagValue(issueElement, tagId);
294-
boolean tagChanged = !java.util.Objects.equals(tagValue, currentTagValue);
319+
320+
java.util.Map<String, String> changedTags = new java.util.LinkedHashMap<>();
321+
for (var entry : tagIdToValue.entrySet()) {
322+
String tagId = entry.getKey();
323+
String tagValue = entry.getValue();
324+
if (tagId == null || tagId.isBlank() || tagValue == null) { continue; }
325+
String currentTagValue = getCurrentTagValue(issueElement, tagId);
326+
if (!java.util.Objects.equals(tagValue, currentTagValue)) {
327+
changedTags.put(tagId, tagValue);
328+
}
329+
}
330+
295331
boolean suppressChanged = suppress && !"true".equalsIgnoreCase(issueElement.getAttribute("suppressed"));
296332
boolean commentAdded = comment != null && !comment.isBlank();
297333

298-
if (!tagChanged && !suppressChanged && !commentAdded) {
334+
boolean assignChanged = false;
335+
if (assignedUser != null) {
336+
String currentAssigned = issueElement.hasAttribute("assignedUser")
337+
? issueElement.getAttribute("assignedUser") : "";
338+
// Empty string clears the assignment.
339+
if (!assignedUser.equals(currentAssigned)) {
340+
assignChanged = true;
341+
}
342+
}
343+
344+
if (changedTags.isEmpty() && !suppressChanged && !commentAdded && !assignChanged) {
299345
return false;
300346
}
301347

@@ -305,18 +351,27 @@ public boolean auditIssue(String instanceId, String tagId, String tagValue,
305351
.orElse(0);
306352
issueElement.setAttribute("revision", String.valueOf(revision + 1));
307353

308-
if (tagChanged) {
309-
updateOrAddTag(issueElement, tagId, tagValue);
354+
if (!changedTags.isEmpty()) {
310355
Element clientAuditTrail = getClientAuditTrailElement(issueElement);
311-
addTagHistory(clientAuditTrail, tagId, tagValue, username);
356+
for (var ct : changedTags.entrySet()) {
357+
updateOrAddTag(issueElement, ct.getKey(), ct.getValue());
358+
addTagHistory(clientAuditTrail, ct.getKey(), ct.getValue(), username);
359+
}
312360
}
313361
if (commentAdded) {
314362
addCommentToIssueElement(issueElement, comment, username);
315363
}
316364
if (suppressChanged) {
317365
issueElement.setAttribute("suppressed", "true");
318366
}
319-
return tagChanged || suppressChanged || commentAdded || issueCreated;
367+
if (assignChanged) {
368+
if (assignedUser.isEmpty()) {
369+
issueElement.removeAttribute("assignedUser");
370+
} else {
371+
issueElement.setAttribute("assignedUser", assignedUser);
372+
}
373+
}
374+
return !changedTags.isEmpty() || suppressChanged || commentAdded || assignChanged || issueCreated;
320375
}
321376

322377
private String getCurrentTagValue(Element issueElement, String tagId) {

fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/_common/helper/FPRHelper.java

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,12 @@
2020
import com.fasterxml.jackson.databind.node.ObjectNode;
2121
import com.fortify.cli.aviator.fpr.FPRProcessor;
2222
import com.fortify.cli.aviator.fpr.Vulnerability;
23+
import com.fortify.cli.aviator.fpr.filter.FilterTemplate;
24+
import com.fortify.cli.aviator.fpr.filter.TagDefinition;
25+
import com.fortify.cli.aviator.fpr.filter.TagValue;
2326
import com.fortify.cli.aviator.fpr.model.AuditIssue;
2427
import com.fortify.cli.aviator.fpr.processor.AuditProcessor;
28+
import com.fortify.cli.aviator.fpr.processor.FilterTemplateParser;
2529
import com.fortify.cli.aviator.fpr.processor.StreamingFVDLProcessor;
2630
import com.fortify.cli.aviator.util.FprHandle;
2731

@@ -186,6 +190,9 @@ public static void embedAuditHistory(ObjectNode node, AuditIssue auditIssue) {
186190
if (auditIssue == null) { return; }
187191

188192
node.put("revision", auditIssue.getRevision());
193+
if (auditIssue.getAssignedUser() != null && !auditIssue.getAssignedUser().isBlank()) {
194+
node.put("assignedUser", auditIssue.getAssignedUser());
195+
}
189196

190197
if (!auditIssue.getTags().isEmpty()) {
191198
var tagsNode = MAPPER.createObjectNode();
@@ -220,4 +227,58 @@ public static void embedAuditHistory(ObjectNode node, AuditIssue auditIssue) {
220227
node.set("tagHistory", historyArray);
221228
}
222229
}
223-
}
230+
/**
231+
* Loads the FPR's filter template (if present), exposing tag definitions
232+
* for resolving custom-tag names and their valid values. Returns an empty
233+
* Optional if the FPR has no filtertemplate.xml.
234+
*/
235+
public static java.util.Optional<FilterTemplate> loadFilterTemplate(FprHandle fprHandle) {
236+
var auditProcessor = new com.fortify.cli.aviator.fpr.processor.AuditProcessor(fprHandle);
237+
auditProcessor.processAuditXML();
238+
return new FilterTemplateParser(fprHandle, auditProcessor).parseFilterTemplate();
239+
}
240+
241+
/**
242+
* Resolves a user-supplied tag name (or GUID) and value to the canonical
243+
* tagId / tagValue pair for use with AuditProcessor. Tag and value lookups
244+
* are case-insensitive. If the tag is not found in the filter template,
245+
* the input is treated as a raw GUID. If the value is not in the tag's
246+
* defined values and the tag is not extensible, throws IllegalArgumentException.
247+
*/
248+
public static java.util.Map.Entry<String, String> resolveCustomTag(
249+
FilterTemplate filterTemplate, String tagNameOrId, String value) {
250+
if (tagNameOrId == null || tagNameOrId.isBlank()) {
251+
throw new IllegalArgumentException("Tag name/id must not be blank");
252+
}
253+
if (value == null) {
254+
throw new IllegalArgumentException("Tag value must not be null for tag '" + tagNameOrId + "'");
255+
}
256+
if (filterTemplate == null || filterTemplate.getTagDefinitions() == null) {
257+
return java.util.Map.entry(tagNameOrId, value);
258+
}
259+
TagDefinition match = null;
260+
for (var def : filterTemplate.getTagDefinitions()) {
261+
if (tagNameOrId.equalsIgnoreCase(def.getName()) || tagNameOrId.equalsIgnoreCase(def.getId())) {
262+
match = def;
263+
break;
264+
}
265+
}
266+
if (match == null) {
267+
return java.util.Map.entry(tagNameOrId, value);
268+
}
269+
if (match.getValues() != null) {
270+
for (TagValue tv : match.getValues()) {
271+
if (tv.getValue() != null && tv.getValue().equalsIgnoreCase(value)) {
272+
return java.util.Map.entry(match.getId(), tv.getValue());
273+
}
274+
}
275+
}
276+
if (!match.isExtensible()) {
277+
var allowed = match.getValues() == null ? java.util.List.<String>of()
278+
: match.getValues().stream().map(TagValue::getValue).toList();
279+
throw new IllegalArgumentException("Invalid value '" + value + "' for tag '"
280+
+ match.getName() + "'; valid values: " + String.join(", ", allowed));
281+
}
282+
return java.util.Map.entry(match.getId(), value);
283+
}
284+
}

fcli-core/fcli-fpr/src/main/java/com/fortify/cli/fpr/issue/cli/cmd/FPRIssueAuditCommand.java

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
import com.fasterxml.jackson.databind.ObjectMapper;
2323
import com.fasterxml.jackson.databind.node.ObjectNode;
24+
import com.fortify.cli.aviator.fpr.filter.FilterTemplate;
2425
import com.fortify.cli.aviator.fpr.processor.AuditProcessor;
2526
import com.fortify.cli.aviator.util.Constants;
2627
import com.fortify.cli.common.exception.FcliSimpleException;
@@ -32,6 +33,7 @@
3233
import com.fortify.cli.common.util.DisableTest;
3334
import com.fortify.cli.common.util.DisableTest.TestType;
3435
import com.fortify.cli.fpr._common.cli.mixin.FPRFileMixin;
36+
import com.fortify.cli.fpr._common.helper.FPRHelper;
3537

3638
import lombok.Getter;
3739
import picocli.CommandLine.Command;
@@ -64,20 +66,28 @@ public class FPRIssueAuditCommand extends AbstractOutputCommand {
6466
@Option(names = {"--instance-ids"}, required = true, split = ",", order = 2)
6567
private List<String> instanceIds;
6668

67-
@Option(names = {"--analysis"}, required = true, order = 3)
69+
@Option(names = {"--analysis"}, order = 3)
6870
private String analysis;
6971

70-
@Option(names = {"--comment"}, order = 4)
72+
@DisableTest(TestType.MULTI_OPT_PLURAL_NAME)
73+
@Option(names = {"--custom-tags", "-t"}, split = ",", paramLabel = "TAG=VALUE", order = 4)
74+
private Map<String, String> customTags;
75+
76+
@Option(names = {"--comment"}, order = 5)
7177
private String comment;
7278

73-
@Option(names = {"--suppress"}, order = 5)
79+
@Option(names = {"--suppress"}, order = 6)
7480
private boolean suppress;
7581

76-
@Option(names = {"--user"}, order = 6)
82+
@Option(names = {"--user"}, order = 7)
7783
private String user;
7884

85+
@Option(names = {"--assign-user"}, order = 8)
86+
private String assignUser;
87+
7988
@Override
8089
protected IObjectNodeProducer getObjectNodeProducer() {
90+
validateAtLeastOneAction();
8191
var canonicalAnalysis = validateAnalysis(analysis);
8292
var username = resolveUsername();
8393
var uniqueIds = dedupePreservingOrder(instanceIds);
@@ -91,14 +101,18 @@ private List<ObjectNode> applyAudits(List<String> ids, String canonicalAnalysis,
91101
try (var fprHandle = fprFileMixin.createFprHandle()) {
92102
var auditProcessor = new AuditProcessor(fprHandle);
93103
auditProcessor.processAuditXML();
104+
105+
// Resolve all custom-tag inputs to canonical (tagId, tagValue) pairs once.
106+
Map<String, String> resolvedTags = resolveTagsForFpr(fprHandle, canonicalAnalysis);
107+
94108
var results = new ArrayList<ObjectNode>(ids.size());
95109
boolean anyChanged = false;
96110
for (var id : ids) {
97-
boolean changed = auditProcessor.auditIssue(id, Constants.ANALYSIS_TAG_ID,
98-
canonicalAnalysis, comment, username, suppress);
111+
boolean changed = auditProcessor.auditIssueMulti(id, resolvedTags, comment, username, suppress, assignUser);
99112
anyChanged |= changed;
100-
results.add(buildResultRow(id, canonicalAnalysis, username, changed));
113+
results.add(buildResultRow(id, canonicalAnalysis, resolvedTags, username, changed));
101114
}
115+
// (assignedUser already included by buildResultRow when set)
102116
if (anyChanged) {
103117
auditProcessor.saveAuditXml();
104118
}
@@ -108,17 +122,62 @@ private List<ObjectNode> applyAudits(List<String> ids, String canonicalAnalysis,
108122
}
109123
}
110124

111-
private ObjectNode buildResultRow(String id, String canonicalAnalysis, String username, boolean changed) {
125+
private Map<String, String> resolveTagsForFpr(com.fortify.cli.aviator.util.FprHandle fprHandle,
126+
String canonicalAnalysis) {
127+
var tags = new LinkedHashMap<String, String>();
128+
if (canonicalAnalysis != null) {
129+
tags.put(Constants.ANALYSIS_TAG_ID, canonicalAnalysis);
130+
}
131+
if (customTags == null || customTags.isEmpty()) {
132+
return tags;
133+
}
134+
FilterTemplate filterTemplate = FPRHelper.loadFilterTemplate(fprHandle).orElse(null);
135+
for (var entry : customTags.entrySet()) {
136+
try {
137+
var resolved = FPRHelper.resolveCustomTag(filterTemplate, entry.getKey(), entry.getValue());
138+
tags.put(resolved.getKey(), resolved.getValue());
139+
} catch (IllegalArgumentException e) {
140+
throw new FcliSimpleException(e.getMessage());
141+
}
142+
}
143+
return tags;
144+
}
145+
146+
private ObjectNode buildResultRow(String id, String canonicalAnalysis, Map<String, String> resolvedTags,
147+
String username, boolean changed) {
112148
var row = MAPPER.createObjectNode();
113149
row.put("instanceId", id);
114-
row.put("analysis", canonicalAnalysis);
150+
row.put("analysis", canonicalAnalysis != null ? canonicalAnalysis : "");
151+
if (customTags != null && !customTags.isEmpty()) {
152+
var tagsNode = MAPPER.createObjectNode();
153+
for (var entry : resolvedTags.entrySet()) {
154+
if (!Constants.ANALYSIS_TAG_ID.equals(entry.getKey())) {
155+
tagsNode.put(entry.getKey(), entry.getValue());
156+
}
157+
}
158+
row.set("customTags", tagsNode);
159+
}
115160
row.put("comment", comment != null ? comment : "");
116161
row.put("suppressed", suppress);
162+
if (assignUser != null) {
163+
row.put("assignedUser", assignUser);
164+
}
117165
row.put("user", username);
118166
row.put("__action__", changed ? "AUDITED" : "UNCHANGED");
119167
return row;
120168
}
121169

170+
private void validateAtLeastOneAction() {
171+
boolean hasAnalysis = analysis != null && !analysis.isBlank();
172+
boolean hasTags = customTags != null && !customTags.isEmpty();
173+
boolean hasComment = comment != null && !comment.isBlank();
174+
boolean hasAssign = assignUser != null;
175+
if (!hasAnalysis && !hasTags && !hasComment && !suppress && !hasAssign) {
176+
throw new FcliSimpleException(
177+
"At least one of --analysis, --custom-tags, --comment, --suppress, or --assign-user must be provided");
178+
}
179+
}
180+
122181
private static List<String> dedupePreservingOrder(List<String> ids) {
123182
var seen = new LinkedHashSet<String>();
124183
for (var id : ids) {
@@ -133,7 +192,7 @@ private static List<String> dedupePreservingOrder(List<String> ids) {
133192
}
134193

135194
private String validateAnalysis(String value) {
136-
if (value == null) { return null; }
195+
if (value == null || value.isBlank()) { return null; }
137196
var canonical = VALID_ANALYSIS_VALUES.get(value.toLowerCase());
138197
if (canonical == null) {
139198
throw new FcliSimpleException("Invalid --analysis value '" + value

fcli-core/fcli-fpr/src/main/resources/com/fortify/cli/fpr/i18n/FPRMessages.properties

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,18 @@ fcli.fpr.issue.get.embed = Comma-separated list of extra data to embed. Valid va
2222
fcli.fpr.issue.count.fpr = Path to the local FPR file to read.
2323
fcli.fpr.issue.audit.fpr = Path to the local FPR file to audit.
2424
fcli.fpr.issue.audit.instance-ids = Comma-separated list of one or more issue instanceIds to audit.
25-
fcli.fpr.issue.audit.analysis = The analysis value to set. Valid values (case-insensitive): 'Not an Issue', 'Exploitable', 'Suspicious', 'Reliability Issue', 'False Positive', 'Bad Practice'.
25+
fcli.fpr.issue.audit.analysis = Optional analysis value to set. Valid values (case-insensitive): 'Not an Issue', 'Exploitable', 'Suspicious', 'Reliability Issue', 'False Positive', 'Bad Practice'.
26+
fcli.fpr.issue.audit.custom-tags = Comma-separated TAG=VALUE pairs for custom tags. TAG can be the tag name or its GUID; lookups are case-insensitive. Example: "--custom-tags 'Auditor Status=Reviewed,Severity=High'".
2627
fcli.fpr.issue.audit.comment = Optional comment to add to the issue audit trail.
2728
fcli.fpr.issue.audit.suppress = Suppress the issue in the FPR file.
2829
fcli.fpr.issue.audit.user = Username to record in the audit trail. Defaults to the current operating system user.
30+
fcli.fpr.issue.audit.assign-user = Assign the issue to the specified user. Stored as the assignedUser attribute on the Issue element. Pass an empty string to clear the assignment.
2931
fcli.fpr.remediation.apply-remediations.fpr = Path to the local FPR file containing remediations.
3032
fcli.fpr.remediation.apply-remediations.source-root = Root directory of the source code to apply remediations to.
3133

3234
# Default output columns
3335
fcli.fpr.issue.list.output.table.args = instanceId,category,priority,analyzerName,primaryFile,primaryLine,audited,suppressed
3436
fcli.fpr.issue.get.output.table.args = instanceId,category,kingdom,type,subtype,analyzerName,priority,primaryFile,primaryLine,audited,suppressed,issueStatus,shortDescription
3537
fcli.fpr.issue.count.output.table.args = category,total,audited,suppressed
36-
fcli.fpr.issue.audit.output.table.args = instanceId,analysis,comment,suppressed,user,__action__
38+
fcli.fpr.issue.audit.output.table.args = instanceId,analysis,customTags,comment,suppressed,assignedUser,user,__action__
3739
fcli.fpr.remediation.apply-remediations.output.table.args = totalRemediations,appliedRemediations,skippedRemediations,__action__

0 commit comments

Comments
 (0)