diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/util/FoDEnums.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/util/FoDEnums.java index 4b0ef1da5f..668f883fee 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/util/FoDEnums.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/util/FoDEnums.java @@ -456,7 +456,9 @@ public enum AttributeTypes { Application(1), Vulnerability(2), Microservice(3), - Release(4); + Release(4), + Issue(5), + Scan(6); private final int _val; @@ -470,8 +472,6 @@ public int getValue() { public String toString() { switch (this._val) { - case 0: - return "All"; case 1: return "Application"; case 2: @@ -479,8 +479,13 @@ public String toString() { case 3: return "Microservice"; case 4: - default: return "Release"; + case 5: + return "Issue"; + case 6: + return "Scan"; + default: + return "All"; } } @@ -493,7 +498,7 @@ public static AttributeTypes fromInt(int val) { case 2: return Vulnerability; case 3: - return Microservice; + return Microservice; case 4: default: return Release; @@ -552,6 +557,36 @@ public enum DeveloperStatusType { public String getValue() { return this.value; } + + /** + * Resolve an input string which may be either the enum constant name (e.g. "InRemediation") + * or the user-facing value (e.g. "In Remediation") to the canonical user-facing value. + * Comparison for the enum name is case-insensitive. If no match is found an empty + * Optional is returned. + */ + public static java.util.Optional resolveValue(String input) { + if (input == null) return java.util.Optional.empty(); + var trimmed = input.trim(); + if (trimmed.isEmpty()) return java.util.Optional.empty(); + // First try matching enum constant name (case-insensitive) + for (DeveloperStatusType t : values()) { + if (t.name().equalsIgnoreCase(trimmed)) { + return java.util.Optional.of(t.getValue()); + } + } + // Then try matching the display value exactly (case-sensitive and case-insensitive fallback) + for (DeveloperStatusType t : values()) { + if (t.getValue().equals(trimmed)) { + return java.util.Optional.of(t.getValue()); + } + } + for (DeveloperStatusType t : values()) { + if (t.getValue().equalsIgnoreCase(trimmed)) { + return java.util.Optional.of(t.getValue()); + } + } + return java.util.Optional.empty(); + } } public enum AuditorStatusType { @@ -564,7 +599,7 @@ public enum AuditorStatusType { //Suspicious("Suspicious"), //ProposedNotAnIssue("Proposed Not an Issue"), RiskAccepted("Risk Accepted"), - NotAnIssue("Not an Issues"); + NotAnIssue("Not an Issue"); public final String value; @@ -575,6 +610,35 @@ public enum AuditorStatusType { public String getValue() { return this.value; } + + /** + * Resolve an input string which may be either the enum constant name (e.g. "PendingReview") + * or the user-facing value (e.g. "Pending Review") to the canonical user-facing value. + * Comparison for the enum name is case-insensitive. Returns an empty Optional when no match. + */ + public static java.util.Optional resolveValue(String input) { + if (input == null) return java.util.Optional.empty(); + var trimmed = input.trim(); + if (trimmed.isEmpty()) return java.util.Optional.empty(); + // Try matching enum constant name first + for (AuditorStatusType t : values()) { + if (t.name().equalsIgnoreCase(trimmed)) { + return java.util.Optional.of(t.getValue()); + } + } + // Then match display value + for (AuditorStatusType t : values()) { + if (t.getValue().equals(trimmed)) { + return java.util.Optional.of(t.getValue()); + } + } + for (AuditorStatusType t : values()) { + if (t.getValue().equalsIgnoreCase(trimmed)) { + return java.util.Optional.of(t.getValue()); + } + } + return java.util.Optional.empty(); + } } } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueUpdateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueUpdateCommand.java index 55bc687315..94937bca03 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueUpdateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueUpdateCommand.java @@ -13,19 +13,20 @@ package com.fortify.cli.fod.issue.cli.cmd; import java.util.ArrayList; +import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.fortify.cli.common.mcp.MCPInclude; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; import com.fortify.cli.fod._common.cli.mixin.FoDDelimiterMixin; import com.fortify.cli.fod._common.output.cli.cmd.AbstractFoDJsonNodeOutputCommand; -import com.fortify.cli.fod._common.util.FoDEnums.AuditorStatusType; -import com.fortify.cli.fod._common.util.FoDEnums.DeveloperStatusType; import com.fortify.cli.fod._common.util.FoDEnums.VulnerabilitySeverityType; +import com.fortify.cli.fod.attribute.cli.mixin.FoDAttributeUpdateOptions; import com.fortify.cli.fod.issue.helper.FoDBulkIssueUpdateRequest; import com.fortify.cli.fod.issue.helper.FoDBulkIssueUpdateResponse; import com.fortify.cli.fod.issue.helper.FoDIssueHelper; @@ -42,16 +43,23 @@ @Command(name = OutputHelperMixins.Update.CMD_NAME) public class FoDIssueUpdateCommand extends AbstractFoDJsonNodeOutputCommand implements IActionCommandResultSupplier { private static final Logger LOG = LoggerFactory.getLogger(FoDIssueUpdateCommand.class); + // Fields to hold last-run counts so getActionCommandResult() can report status + private int lastTotalCount = 0; + private int lastSkippedCount = 0; + private long lastErrorCount = 0; + private int lastUpdateCount = 0; @Getter @Mixin private OutputHelperMixins.Update outputHelper; @Mixin private FoDDelimiterMixin delimiterMixin; // Is automatically injected in resolver mixins @Mixin private FoDReleaseByQualifiedNameOrIdResolverMixin.RequiredOption releaseResolver; + @Mixin private FoDAttributeUpdateOptions.OptionalAttrOption issueAttrsUpdate; + private final ObjectMapper objectMapper = new ObjectMapper(); @Option(names = {"--user"}, required = true) protected String user; @Option(names = {"--dev-status"}, required = false) - protected DeveloperStatusType developerStatus; + protected String developerStatus; @Option(names = {"--auditor-status"}, required = false) - protected AuditorStatusType auditorStatus; + protected String auditorStatus; @Option(names = {"--severity"}, required = false) protected VulnerabilitySeverityType severity; @Option(names = {"--comment"}, required = false) @@ -59,36 +67,91 @@ public class FoDIssueUpdateCommand extends AbstractFoDJsonNodeOutputCommand impl @Option(names = {"--vuln-ids"}, required = true, split=",") protected ArrayList vulnIds; - private long errorCount = 0; - @Override public JsonNode getJsonNode(UnirestInstance unirest) { FoDReleaseDescriptor releaseDescriptor = releaseResolver.getReleaseDescriptor(unirest); - FoDBulkIssueUpdateRequest issueUpdateRequest = FoDBulkIssueUpdateRequest.builder() + // If vulnIds are provided, filter them against the release vulnerabilities using a helper. + int issueUpdateCount = 0; + int totalCount = 0; + int skippedCount = 0; + if ( vulnIds != null && !vulnIds.isEmpty() ) { + var vulnFilterResult = FoDIssueHelper.filterRequestedVulnIds(unirest, releaseDescriptor.getReleaseId(), vulnIds); + totalCount = vulnFilterResult.totalCount(); + issueUpdateCount = vulnFilterResult.kept().size(); + skippedCount = vulnFilterResult.skipped().size(); + vulnIds = new ArrayList<>(vulnFilterResult.kept()); + if (!vulnFilterResult.skipped().isEmpty()) { + LOG.debug("Skipped vulnerabilities: {}", vulnFilterResult.skipped()); + vulnFilterResult.skipped().forEach(vid -> LOG.warn("Vulnerability {} not found in release {}, skipping", vid, releaseDescriptor.getReleaseId())); + } + } + + Map attributeUpdates = issueAttrsUpdate.getAttributes(); + JsonNode jsonAttrs = FoDIssueHelper.buildIssueAttributesNode(unirest, attributeUpdates); + + // Validate auditor and developer status values against attribute picklists + ResolvedStatuses resolvedStatuses = resolveStatuses(unirest); + + FoDBulkIssueUpdateRequest issueUpdateRequest = buildIssueUpdateRequest(unirest, resolvedStatuses.developerStatusValue(), resolvedStatuses.auditorStatusValue(), jsonAttrs); + FoDBulkIssueUpdateResponse resp = performUpdate(unirest, releaseDescriptor.getReleaseId(), issueUpdateRequest, totalCount, skippedCount, issueUpdateCount); + return resp.asObjectNode() + .put("totalCount", totalCount) + .put("skippedCount", skippedCount) + .put("errorCount", lastErrorCount) + .put("updateCount", issueUpdateCount); + } + + private record ResolvedStatuses(String developerStatusValue, String auditorStatusValue) {} + + private ResolvedStatuses resolveStatuses(UnirestInstance unirest) { + String auditorStatusValue = null; + if ( auditorStatus != null && !auditorStatus.isBlank() ) { + auditorStatusValue = FoDIssueHelper.resolveStatusValue(unirest, auditorStatus, new String[]{ + "Auditor Status (Non suppressed)", "Auditor Status (Suppressed)" + }, "auditor-status"); + } + String developerStatusValue = null; + if ( developerStatus != null && !developerStatus.isBlank() ) { + developerStatusValue = FoDIssueHelper.resolveStatusValue(unirest, developerStatus, new String[]{ + "Developer Status (Open)", "Developer Status (Closed)" + }, "developer-status"); + } + return new ResolvedStatuses(developerStatusValue, auditorStatusValue); + } + + private FoDBulkIssueUpdateRequest buildIssueUpdateRequest(UnirestInstance unirest, String developerStatusValue, String auditorStatusValue, JsonNode jsonAttrs) { + return FoDBulkIssueUpdateRequest.builder() .user(unirest, user) - .developerStatus(developerStatus != null ? developerStatus.getValue() : null) - .auditorStatus(auditorStatus != null ? auditorStatus.getValue() : null) + .developerStatus(developerStatusValue) + .auditorStatus(auditorStatusValue) .severity(severity != null ? severity.toString() : null) .comment(comment) .vulnerabilityIds(vulnIds) + .attributes(jsonAttrs) .build().validate(); + } - LOG.debug("Updating issues: {}", vulnIds.toString()); - FoDBulkIssueUpdateResponse resp = FoDIssueHelper.updateIssues(unirest, releaseDescriptor.getReleaseId(), issueUpdateRequest); - errorCount = resp.getResults() - .stream() - .filter(r -> r.getErrorCode() != 0) - .count(); + private FoDBulkIssueUpdateResponse performUpdate(UnirestInstance unirest, String releaseId, FoDBulkIssueUpdateRequest request, int totalCount, int skippedCount, int issueUpdateCount) { + LOG.debug("Updating issues: {}", vulnIds); + FoDBulkIssueUpdateResponse resp = FoDIssueHelper.updateIssues(unirest, releaseId, request); + long errorCount = resp.getResults().stream().filter(r -> r.getErrorCode() != 0).count(); resp.setIssueCount(resp.getResults().size()); resp.setErrorCount(errorCount); - LOG.debug("Response: {}", resp.getResults().toString()); - return resp.asObjectNode().put("issueCount", resp.getResults().size()).put("errorCount", errorCount); + // Store last-run counts for action result reporting + lastTotalCount = totalCount; + lastSkippedCount = skippedCount; + lastErrorCount = errorCount; + lastUpdateCount = issueUpdateCount; + LOG.debug("Response: {}", resp.getResults()); + return resp; } @Override public String getActionCommandResult() { - return "ISSUES_UPDATED"; + if ( lastErrorCount>0 ) { return "UPDATED_WITH_ERRORS"; } + if ( lastSkippedCount>0 ) { return "UPDATED_WITH_SKIPPED"; } + return "UPDATED"; } @Override @@ -96,4 +159,4 @@ public boolean isSingular() { return true; } -} \ No newline at end of file +} diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDBulkIssueUpdateRequest.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDBulkIssueUpdateRequest.java index 931b2108f4..93d6326c69 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDBulkIssueUpdateRequest.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDBulkIssueUpdateRequest.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; import com.formkiq.graalvm.annotations.Reflectable; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.fod.access_control.helper.FoDUserHelper; @@ -43,6 +44,7 @@ public class FoDBulkIssueUpdateRequest { private String severity; private String comment; private ArrayList vulnerabilityIds; + private JsonNode attributes; @JsonIgnore public final FoDBulkIssueUpdateRequest validate(Consumer> validationMessageConsumer) { @@ -58,7 +60,7 @@ public final FoDBulkIssueUpdateRequest validate(Consumer> validatio public final FoDBulkIssueUpdateRequest validate() { return validate(messages->{throw new FcliSimpleException("Unable to update issues:\n\t"+String.join("\n\t", messages)); }); } - + @JsonIgnore private final void validateRequired(List messages, Object obj, String message) { if ( obj==null || (obj instanceof String && StringUtils.isBlank((String)obj)) ) { @@ -78,4 +80,4 @@ public FoDBulkIssueUpdateRequestBuilder user(UnirestInstance unirest, String use return userId(userId); } } -} \ No newline at end of file +} diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueHelper.java index c0f816c058..06cf154974 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueHelper.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/helper/FoDIssueHelper.java @@ -21,6 +21,9 @@ import java.util.Map; import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -28,16 +31,100 @@ import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.json.transform.fields.RenameFieldsTransformer; import com.fortify.cli.fod._common.rest.FoDUrls; +import com.fortify.cli.fod._common.rest.helper.FoDInputTransformer; import com.fortify.cli.fod._common.rest.helper.FoDProductHelper; +import com.fortify.cli.fod._common.util.FoDEnums; +import com.fortify.cli.fod.attribute.helper.FoDAttributeDescriptor; +import com.fortify.cli.fod.attribute.helper.FoDAttributeHelper; +import kong.unirest.HttpResponse; import kong.unirest.UnirestInstance; import lombok.Builder; import lombok.Data; import lombok.Getter; public class FoDIssueHelper { + private static final Logger LOG = LoggerFactory.getLogger(FoDIssueHelper.class); @Getter private static ObjectMapper objectMapper = new ObjectMapper(); + // Local cache for attribute descriptors used during bulk issue updates. Populated by loadAllAttributes(). + private static final java.util.concurrent.ConcurrentHashMap ATTR_CACHE_BY_NAME = new java.util.concurrent.ConcurrentHashMap<>(); + private static final java.util.concurrent.ConcurrentHashMap ATTR_CACHE_BY_ID = new java.util.concurrent.ConcurrentHashMap<>(); + private static volatile boolean attributesPrefetched = false; + + /** + * Prefetch all attributes from FoD and populate the local cache. Safe to call multiple times; will perform + * the fetch only once per JVM unless clearAttributesCache() is called. + */ + public static synchronized void loadAllAttributes(UnirestInstance unirest) { + if ( attributesPrefetched ) return; + // Use local temporary maps to build the cache so a failed fetch/parse doesn't leave partial data + var tmpById = new java.util.HashMap(); + var tmpByName = new java.util.HashMap(); + try { + var request = unirest.get(FoDUrls.ATTRIBUTES); + var body = request.asObject(ObjectNode.class).getBody(); + var items = body.get("items"); + if ( items!=null && items.isArray() ) { + for (var item : items) { + FoDAttributeDescriptor desc = JsonHelper.treeToValue(item, FoDAttributeDescriptor.class); + if ( desc!=null ) { + tmpById.putIfAbsent(desc.getId(), desc); + if ( desc.getName()!=null ) { + tmpByName.putIfAbsent(desc.getName(), desc); + tmpByName.putIfAbsent(desc.getName().trim(), desc); + } + } + } + } + // Merge into the concurrent caches; preserve any existing descriptors using putIfAbsent + for ( var e : tmpById.entrySet() ) { + ATTR_CACHE_BY_ID.putIfAbsent(e.getKey(), e.getValue()); + } + for ( var e : tmpByName.entrySet() ) { + ATTR_CACHE_BY_NAME.putIfAbsent(e.getKey(), e.getValue()); + } + attributesPrefetched = true; // set only after successful population + } catch (kong.unirest.UnirestException e) { + throw new com.fortify.cli.common.exception.FcliTechnicalException("Error loading attribute descriptors", e); + } catch (Exception e) { + throw new com.fortify.cli.common.exception.FcliTechnicalException("Error processing attribute descriptors", e); + } + } + + public static void clearAttributesCache() { + ATTR_CACHE_BY_ID.clear(); + ATTR_CACHE_BY_NAME.clear(); + attributesPrefetched = false; + } + + /** + * Resolve an attribute descriptor from the local cache. If not prefetched yet, will call loadAllAttributes. + */ + public static FoDAttributeDescriptor getAttributeDescriptorFromCache(UnirestInstance unirest, String nameOrId, boolean failIfNotFound) { + if ( !attributesPrefetched ) { + loadAllAttributes(unirest); + } + if ( nameOrId==null ) { + if ( failIfNotFound ) throw new com.fortify.cli.common.exception.FcliSimpleException("No attribute found for name or id: null"); + return null; + } + try { + int id = Integer.parseInt(nameOrId); + var desc = ATTR_CACHE_BY_ID.get(id); + if ( desc==null && failIfNotFound ) throw new com.fortify.cli.common.exception.FcliSimpleException("No attribute found for name or id: " + nameOrId); + return desc; + } catch (NumberFormatException nfe) { + var desc = ATTR_CACHE_BY_NAME.get(nameOrId); + if ( desc==null ) { + // try trimmed + desc = ATTR_CACHE_BY_NAME.get(nameOrId.trim()); + } + if ( desc==null && failIfNotFound ) throw new com.fortify.cli.common.exception.FcliSimpleException("No attribute found for name or id: " + nameOrId); + return desc; + } + } + public static final JsonNode transformRecord(JsonNode record) { return new RenameFieldsTransformer(new String[]{}).transform(record); } @@ -89,10 +176,10 @@ public String getReleaseIdsString() { public String getIdsString() { return asString(ids); } - + private String asString(Set values) { return values==null || values.isEmpty() - ? "N/A" + ? "N/A" : String.join(", ", values); } } @@ -127,7 +214,6 @@ public static final FoDBulkIssueUpdateResponse updateIssues(UnirestInstance unir return getResponse(result); } - public static final ArrayNode mergeReleaseIssues(ArrayNode issues) { Map merged = new HashMap<>(); Map> releaseNamesByInstance = new HashMap<>(); @@ -194,4 +280,220 @@ private static String getBaseUrl(UnirestInstance unirest) { return FoDProductHelper.INSTANCE.getBrowserUrl(unirest.config().getDefaultBaseUrl()); } -} \ No newline at end of file + /** + * Check whether an issue (vulnerability) exists in the given release. + * Uses the vulnerabilities/{vulnId}/summary endpoint which returns 200 when present and 404 when not. + * Returns true when the issue exists, false when a 404 is returned. Other HTTP errors are wrapped + * in a FcliTechnicalException. + */ + public static boolean issueExists(UnirestInstance unirest, String releaseId, String vulnId) { + try { + var response = unirest.get("/api/v3/releases/{releaseId}/vulnerabilities/{vulnId}/summary") + .routeParam("releaseId", releaseId) + .routeParam("vulnId", vulnId) + .asObject(JsonNode.class); + int status = response.getStatus(); + if ( status==200 ) { + return true; + } else if ( status==404 ) { + return false; + } else { + throw new com.fortify.cli.common.exception.FcliTechnicalException(String.format("Unexpected response checking issue existence: HTTP %d", status)); + } + } catch (kong.unirest.UnirestException e) { + throw new com.fortify.cli.common.exception.FcliTechnicalException("Error checking issue existence", e); + } + } + + /** + * Bulk retrieval of vulnerability ids for a release. Returns a set containing the 'id' and 'vulnId' + * string values for vulnerabilities in the release. If {@code requestedIds} is non-null and non-empty, + * this method will stop paging early once all requested ids have been found (matching either id or vulnId). + * + * @param unirest Unirest instance + * @param releaseId Release id + * @param requestedIds Optional set of requested ids (either internal id or vulnId) to look for; may be null to fetch all + * @return Set of found id/vulnId strings (trimmed) + */ + public static java.util.Set getVulnIdsForRelease(UnirestInstance unirest, String releaseId, java.util.Set requestedIds) { + var result = new java.util.HashSet(); + // If a requested set is provided, track remaining items to allow early exit + java.util.Set remaining = null; + if ( requestedIds!=null && !requestedIds.isEmpty() ) { + remaining = new java.util.HashSet<>(); + for ( var s : requestedIds ) { if ( s!=null ) remaining.add(s.trim()); } + // If after trimming nothing remains, treat as null + if ( remaining.isEmpty() ) remaining = null; + } + try { + var request = unirest.get(FoDUrls.VULNERABILITIES) + .routeParam("relId", releaseId) + .queryString("fields", "id,vulnId") + .queryString("includeFixed", "true") + .queryString("includeSuppressed", "true"); + var stream = com.fortify.cli.fod._common.rest.helper.FoDPagingHelper.pagedRequest(request).stream() + .map(HttpResponse::getBody) + .map(FoDInputTransformer::getItems) + .filter(items -> items!=null && items.isArray()) + .map(ArrayNode.class::cast) + .flatMap(JsonHelper::stream); + + for ( JsonNode item : (Iterable)stream::iterator ) { + if ( item.has("id") && !item.get("id").isNull() ) { + String id = item.get("id").asText().trim(); + result.add(id); + if ( remaining!=null ) remaining.remove(id); + } + if ( item.has("vulnId") && !item.get("vulnId").isNull() ) { + String vid = item.get("vulnId").asText().trim(); + result.add(vid); + if ( remaining!=null ) remaining.remove(vid); + } + if ( remaining!=null && remaining.isEmpty() ) { + // Found all requested ids; stop paging early + break; + } + } + } catch (Exception e) { + throw new com.fortify.cli.common.exception.FcliTechnicalException("Error retrieving vulnerabilities for release", e); + } + return result; + } + + /** + * Resolve a status value (developer/auditor) against one or more FoD attribute picklists. + * Returns the canonical picklist name when found, or throws a FcliSimpleException listing allowed values. + */ + public static String resolveStatusValue(UnirestInstance unirest, String providedValue, String[] attributeNames, String optionName) { + if ( providedValue==null || providedValue.isBlank() ) { return null; } + // Preserve original for error messages + String originalProvided = providedValue; + // Allow legacy enum-style inputs (camel-case names) for developer/auditor statuses by mapping + // them to their user-facing display values when possible. The calling code passes optionName + // as either "developer-status" or "auditor-status" so use that to determine which enum to try. + String candidate = providedValue.trim(); + try { + if ( optionName!=null && optionName.toLowerCase().contains("developer") ) { + var resolved = FoDEnums.DeveloperStatusType.resolveValue(candidate); + if ( resolved.isPresent() ) { candidate = resolved.get(); } + } else if ( optionName!=null && optionName.toLowerCase().contains("auditor") ) { + var resolved = FoDEnums.AuditorStatusType.resolveValue(candidate); + if ( resolved.isPresent() ) { candidate = resolved.get(); } + } + } catch (Exception e) { + // Ignore resolution errors and continue with original candidate + LOG.debug("Error resolving enum-style status value for {}: {}", optionName, e.getMessage()); + } + // Try each candidate attribute name until we find matching picklist values + for (String attrName: attributeNames) { + var desc = FoDAttributeHelper.getAttributeDescriptor(unirest, attrName, false); + if ( desc==null ) continue; + var picklist = desc.getPicklistValues(); + if ( picklist==null || picklist.isEmpty() ) continue; + for (var pv : picklist) { + if ( pv.getName()!=null && pv.getName().equalsIgnoreCase(candidate) ) { + return pv.getName(); + } + } + // if provided value looks like an id, try matching by id + try { + int providedId = Integer.parseInt(candidate); + for (var pv : picklist) { + if ( java.util.Objects.equals(pv.getId(), providedId) ) { + return pv.getName(); + } + } + } catch (NumberFormatException ignored) {} + } + // Not found — collect allowed values to show in error + var allowed = new ArrayList(); + for (String attrName: attributeNames) { + var desc = FoDAttributeHelper.getAttributeDescriptor(unirest, attrName, false); + if ( desc==null ) continue; + var picklist = desc.getPicklistValues(); + if ( picklist==null ) continue; + for (var pv: picklist) { + allowed.add(pv.getName()); + } + } + throw new com.fortify.cli.common.exception.FcliSimpleException(String.format("Invalid %s '%s'. Allowed values: %s", optionName, originalProvided, String.join(", ", allowed))); + } + + /** + * Result carrier for vuln filtering: kept (normalized ids to update), skipped (original values skipped), totalCount + */ + public static record VulnFilterResult(java.util.List kept, java.util.List skipped, int totalCount) {} + + /** + * Filter a list of requested vuln identifiers (either internal 'id' or 'vulnId') against the release's + * vulnerabilities. Preserves original order in the returned 'kept' list. Uses server-side paging and + * early-exit when possible. + */ + public static VulnFilterResult filterRequestedVulnIds(UnirestInstance unirest, String releaseId, java.util.List requested) { + int totalCount = requested==null ? 0 : requested.size(); + if ( requested==null || requested.isEmpty() ) { + return new VulnFilterResult(new ArrayList<>(), new ArrayList<>(), totalCount); + } + // Build normalized requested set for lookup/early-exit + var requestedSet = new java.util.HashSet(); + for ( String vid : requested ) { + String normalized = normalizeVulnId(vid); + if ( normalized!=null ) { requestedSet.add(normalized); } + } + // Fetch ids (early-exit aware) + var found = getVulnIdsForRelease(unirest, releaseId, requestedSet); + var kept = new ArrayList(); + var skipped = new ArrayList(); + for ( String vid : requested ) { + String normalized = normalizeVulnId(vid); + if ( normalized!=null && found.contains(normalized) ) { + kept.add(normalized); + } else { + skipped.add(vid); + } + } + return new VulnFilterResult(kept, skipped, totalCount); + } + + /** + * Normalize a vulnerability identifier supplied by the user: trim and strip surrounding single or double quotes. + */ + private static String normalizeVulnId(String id) { + if ( id==null ) return null; + String v = id.trim(); + if ( v.length() >= 2 ) { + char f = v.charAt(0); + char l = v.charAt(v.length()-1); + if ((f=='\'' && l=='\'') || (f=='"' && l=='"')) { + v = v.substring(1, v.length()-1).trim(); + } + } + return v.isEmpty() ? null : v; + } + + /** + * Build an ArrayNode of attribute objects (id/value) for Issue attribute updates using the localized + * attribute cache. Will prefetch attributes if necessary. + */ + public static ArrayNode buildIssueAttributesNode(UnirestInstance unirest, Map attributeUpdates) { + ArrayNode attrArray = JsonHelper.getObjectMapper().createArrayNode(); + if ( attributeUpdates==null || attributeUpdates.isEmpty() ) return attrArray; + // Ensure local cache populated + loadAllAttributes(unirest); + for ( Map.Entry e : attributeUpdates.entrySet() ) { + String attrName = e.getKey(); + String value = e.getValue(); + FoDAttributeDescriptor attributeDescriptor = getAttributeDescriptorFromCache(unirest, attrName, true); + // Only include attributes that are Issue-scoped (AttributeTypes.Issue) + if ( attributeDescriptor!=null && attributeDescriptor.getAttributeTypeId() == FoDEnums.AttributeTypes.Issue.getValue() ) { + var obj = JsonHelper.getObjectMapper().createObjectNode(); + obj.put("id", attributeDescriptor.getId()); + obj.put("value", value); + attrArray.add(obj); + } else { + LOG.debug("Skipping attribute '{}' as it is not an Issue attribute", attributeDescriptor.getName()); + } + } + return attrArray; + } +} diff --git a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties index c83fe29b08..107e94e051 100644 --- a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties +++ b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties @@ -113,7 +113,7 @@ fcli.fod.session.login.usage.description.2 = %nTo avoid having to remember the v consider whether anyone else may be able to see the environment variable values. If you frequently connect to \ different FoD URLs or tenants, you can for example define PROD_FOD_URL, PROD_FOD_TENANT, DEV_FOD_URL, and \ DEV_FOD_TENANT environment variables, and then use the --env-prefix=PROD or --env-prefix=DEV option to \ - select from which environment variables the default values should be retrieved. + select from which environment variables the default values should be retrieved. fcli.fod.session.login.url = FoD URL, for example https://emea.fortify.com/. fcli.fod.session.login.tenant = FoD tenant; required when authenticating with user credentials, ignored for client credentials. fcli.fod.session.login.user = FoD user. @@ -418,7 +418,7 @@ fcli.fod.release.wait-for.usage.description.0 = Although this command offers a l release in 'suspended' state until copying is completed, during which time scan requests and other \ operations may be rejected. \ %n%nNote that contrary to other fcli wait-for commands, any options related to unknown or failure state \ - handling are not applicable to this wait-for command and will be ignored. + handling are not applicable to this wait-for command and will be ignored. fcli.fod.release.wait-for.until = Wait until either any or all releases match. If neither --until or --while are specified, default is to wait until all releases match. fcli.fod.release.wait-for.while = Wait while either any or all releases match. fcli.fod.release.wait-for.suspended = Suspended state against which to match the given releases; may be `false` (default) or `true`. @@ -560,10 +560,10 @@ fcli.fod.sast-scan.setup.language-level = The language level of the technology s fcli.fod.sast-scan.setup.oss = Perform Open Source Analysis scan. fcli.fod.sast-scan.setup.audit-preference = Audit preference, e.g. Manual or Automated fcli.fod.sast-scan.setup.include-third-party-libs = (LEGACY) Indicates if third party libraries should be included. -fcli.fod.sast-scan.setup.use-source-control = (LEGACY) Indicates if source control should be used. +fcli.fod.sast-scan.setup.use-source-control = (LEGACY) Indicates if source control should be used. fcli.fod.sast-scan.setup.skip-if-exists = Skip setup if a scan has already been set up. If not specified, any existing scan \ setup will be replaced based on the given setup options. -fcli.fod.sast-scan.setup.use-aviator = Use Fortify Aviator to audit results and provide enhanced remediation guidance. +fcli.fod.sast-scan.setup.use-aviator = Use Fortify Aviator to audit results and provide enhanced remediation guidance. fcli.fod.sast-scan.import.usage.header = Import existing SAST scan results (from an FPR file). fcli.fod.sast-scan.import.usage.description = As FoD doesn't return a scan id for imported scans, the output of this command cannot be used with commands that expect a scan id, like the wait-for command. fcli.fod.sast-scan.import.file = FPR file containing existing SAST scan results to be imported. @@ -847,9 +847,9 @@ fcli.fod.oss-scan.import-debricked.debricked-password = Password for the given d fcli.fod.oss-scan.import-debricked.debricked-access-token = Debricked long-lived access token. fcli.fod.oss-scan.import-debricked.repository = Debricked repository name or id. fcli.fod.oss-scan.import-debricked.branch = Branch in the given repository for which to retrieve the SBOM. -fcli.fod.oss-scan.import-debricked.insecure = Disable SSL checks when connecting to Debricked. -fcli.fod.oss-scan.import-debricked.connect-timeout = Debricked connection timeout, for example 30s (30 seconds), 5m (5 minutes). Default value: ${default-connect-timeout}. -fcli.fod.oss-scan.import-debricked.socket-timeout = Debricked socket timeout, for example 30s (30 seconds), 5m (5 minutes). Default value: ${default-socket-timeout}. +fcli.fod.oss-scan.import-debricked.insecure = Disable SSL checks when connecting to Debricked. +fcli.fod.oss-scan.import-debricked.connect-timeout = Debricked connection timeout, for example 30s (30 seconds), 5m (5 minutes). Default value: ${default-connect-timeout}. +fcli.fod.oss-scan.import-debricked.socket-timeout = Debricked socket timeout, for example 30s (30 seconds), 5m (5 minutes). Default value: ${default-socket-timeout}. fcli.fod.oss-scan.start.usage.header = (PREVIEW) Start a new OSS scan. fcli.fod.oss-scan.start.usage.description = This command is not fully implemented and is intended for preview only. \ Command name, options and behavior may change at any time, even between patch or minor releases, potentially affecting \ @@ -874,7 +874,9 @@ fcli.fod.oss-scan.download-latest.file = File path and name where to save the SB fcli.fod.issue.usage.header = Manage FoD issues (vulnerabilities) and related entities. fcli.fod.issue.output.table.header.vulnId = Vuln Id fcli.fod.issue.output.table.header.errorCode = Error Code -fcli.fod.issue.output.table.header.issueCount = Issues +fcli.fod.issue.output.table.header.totalCount = Total Issues +fcli.fod.issue.output.table.header.updateCount = Issues Updated +fcli.fod.issue.output.table.header.skippedCount = Issues Skipped fcli.fod.issue.output.table.header.errorCount = Errors fcli.fod.issue.list.usage.header = List vulnerabilities. fcli.fod.issue.list.usage.description = This command allows for listing FoD vulnerability data \ @@ -911,14 +913,17 @@ fcli.fod.issue.list.includeIssue = By default, only visible issues will be retur fcli.fod.issue.list.aggregate = Include aggregation data. fcli.fod.issue.update.usage.header = Bulk update vulnerabilities. fcli.fod.issue.update.usage.description = This command allows for updating the audit information \ - for multiple vulnerabilities. Note: the "id" here refers to the "vulnId" field which is not displayed \ - in the FoD UI but is retrieved using the `fcli fod issue ls` command. + for multiple vulnerabilities. Note: for "vuln-ids" you can use either the numeric Id as shown in the FOD UI, \ + or the "vulnId" UUID field that is retrieved using the `fcli fod issue ls` command. fcli.fod.issue.update.user = The username or user id of the user the update will be recorded as. -fcli.fod.issue.update.dev-status = The Developer Status to set for the vulnerabilities. Allowed values: ${COMPLETION-CANDIDATES}. -fcli.fod.issue.update.auditor-status = The Auditor Status to set for the vulnerabilities. Allowed values: ${COMPLETION-CANDIDATES}. +fcli.fod.issue.update.dev-status = The Developer Status to set for the vulnerabilities, see the FoD UI for valid values. +fcli.fod.issue.update.auditor-status = The Auditor Status to set for the vulnerabilities, see the FoD UI for valid values. fcli.fod.issue.update.severity = The Severity to set for the vulnerabilities. Allowed values: ${COMPLETION-CANDIDATES}. -fcli.fod.issue.update.comment = A comment to apply to all the vulnerabilities that are updated. -fcli.fod.issue.update.vuln-ids = Comma separate list of the vulnerability ids to be updated. +fcli.fod.issue.update.comment = A comment to apply to all of the vulnerabilities that are updated. +fcli.fod.issue.update.vuln-ids = Comma separated list of the vulnerability ids to be updated. +fcli.fod.issue.update.attributes = A comma separated list of "attributeName=attributeValue" pairs to set on the vulnerabilities. \ + It is recommended to provide attribute names as they appear in the FoD UI. For example: "Business Criticality=High,Attribute 2=Value". \ + You can also use multiple --attributes options to provide more "attributeName=attributeValue" pairs. # fcli fod report fcli.fod.report.usage.header = Manage FoD reports. @@ -1041,6 +1046,6 @@ fcli.fod.rest.lookup.output.table.args = group,text,value fcli.fod.report.output.table.args = reportId,reportName,reportStatusType,reportType fcli.fod.report.report-template.output.table.args = value,text,group fcli.fod.issue.list.output.table.args = instanceId,visibilityMarker,severityString,category,location,foundInReleasesString -fcli.fod.issue.update.output.table.args = issueCount,errorCount +fcli.fod.issue.update.output.table.args = totalCount,updateCount,skippedCount,errorCount fcli.fod.attribute.output.table.args = id,name,attributeType,attributeDataType,isRequired,isRestricted