Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
@CommandLine.Command(name = "issue",
subcommands = {
FoDIssueListCommand.class,
FoDIssueGetCommand.class,
FoDIssueUpdateCommand.class,
}
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* 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.fod.issue.cli.cmd;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fortify.cli.common.exception.FcliSimpleException;
import com.fortify.cli.common.json.producer.IObjectNodeProducer;
import com.fortify.cli.common.json.producer.ObjectNodeProducerApplyFrom;
import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins;
import com.fortify.cli.fod._common.cli.mixin.FoDDelimiterMixin;
import com.fortify.cli.fod._common.output.cli.cmd.AbstractFoDOutputCommand;
import com.fortify.cli.fod._common.rest.FoDUrls;
import com.fortify.cli.fod._common.rest.helper.FoDInputTransformer;
import com.fortify.cli.fod.issue.cli.mixin.FoDIssueEmbedMixin;
import com.fortify.cli.fod.issue.helper.FoDIssueHelper;
import com.fortify.cli.fod.issue.helper.FoDIssueHelper.IssueAggregationData;
import com.fortify.cli.fod.release.cli.mixin.FoDReleaseByQualifiedNameOrIdResolverMixin;
import com.fortify.cli.fod.release.helper.FoDReleaseDescriptor;

import kong.unirest.HttpRequest;
import kong.unirest.UnirestInstance;
import lombok.Getter;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Parameters;

@Command(name = OutputHelperMixins.Get.CMD_NAME)
public class FoDIssueGetCommand extends AbstractFoDOutputCommand {
@Getter @Mixin private OutputHelperMixins.Get outputHelper;
@Mixin private FoDDelimiterMixin delimiterMixin; // Is automatically injected in resolver mixins
@Mixin private FoDReleaseByQualifiedNameOrIdResolverMixin.RequiredOption releaseResolver;
@Parameters(index = "0", arity = "1", descriptionKey = "fcli.fod.issue.get.vulnId")
private String vulnId;
@Mixin private FoDIssueEmbedMixin embedMixin;

@Override
protected IObjectNodeProducer getObjectNodeProducer(UnirestInstance unirest) {
FoDReleaseDescriptor releaseDescriptor = releaseResolver.getReleaseDescriptor(unirest);
String releaseId = releaseDescriptor.getReleaseId().toString();
JsonNode issue = findIssue(unirest, releaseId);
if ( issue==null ) {
throw new FcliSimpleException(String.format("No vulnerability found for vulnId '%s' in release '%s'", vulnId, releaseDescriptor.getReleaseName()));
}
if ( issue instanceof ObjectNode issueObject ) {
issueObject.put("releaseId", releaseId);
issueObject.put("releaseName", releaseDescriptor.getReleaseName());
FoDIssueHelper.transformRecord(issueObject, IssueAggregationData.forSingleRelease(issueObject));
}
return simpleObjectNodeProducerBuilder(ObjectNodeProducerApplyFrom.SPEC)
.source(issue)
.build();
}

private JsonNode findIssue(UnirestInstance unirest, String releaseId) {
HttpRequest<?> request = unirest.get(FoDUrls.VULNERABILITIES)
.routeParam("relId", releaseId)
.queryString("filters", "vulnId:" + vulnId)
.queryString("includeFixed", "true")
.queryString("includeSuppressed", "true")
.queryString("limit", "2");
JsonNode body = request.asObject(JsonNode.class).getBody();
JsonNode items = FoDInputTransformer.getItems(body);
if ( items==null || !items.isArray() ) { return null; }
if ( items.size()>1 ) {
throw new FcliSimpleException(String.format("Multiple vulnerabilities found for vulnId '%s'; please check your input", vulnId));
}
return items.isEmpty() ? null : items.get(0);
}

@Override
public boolean isSingular() {
return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public static IssueAggregationData forSingleRelease(ObjectNode issue) {
.releaseNames(Set.of(releaseName))
.releaseIds(Set.of(releaseId))
.ids(Set.of(id))
.vulnIds(Set.of(vulnId))
.vulnIds(vulnId!=null ? Set.of(vulnId) : Collections.emptySet())
.build();
}

Expand All @@ -92,7 +92,7 @@ public String getReleaseIdsString() {
}

public String getIdsString() {
return asString(ids);
return asString(ids);
}

private String asString(Set<String> values) {
Expand All @@ -105,25 +105,29 @@ private String asString(Set<String> values) {
/** Overload adding aggregation fields to an ObjectNode using provided data. */
public static final ObjectNode transformRecord(ObjectNode record, IssueAggregationData data) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand how the code changes in this method are related to the goal of this PR (adding issue get command), and current toJsonNode implementation is wrong. Data types in fcli output must be stable to allow for external processing, i.e., not switch between array or text node depending on the number of available entries. Also, in general, N/A should be modelled as null or empty array, not N/A text node as that's difficult to differentiate from actual values.

So, all the array fields must be either a proper array (which may be empty, single value, or multiple values array), or possibly null, like it was in the original code.

transformRecord(record); // apply generic transformations first (rename etc.)
ArrayNode vulnIdsArray = JsonHelper.getObjectMapper().createArrayNode();
data.getVulnIds().forEach(vulnIdsArray::add);
ArrayNode releaseNamesArray = JsonHelper.getObjectMapper().createArrayNode();
data.getReleaseNames().forEach(releaseNamesArray::add);
ArrayNode releaseIdsArray = JsonHelper.getObjectMapper().createArrayNode();
data.getReleaseIds().forEach(releaseIdsArray::add);
ArrayNode idsArray = JsonHelper.getObjectMapper().createArrayNode();
data.getIds().forEach(idsArray::add);
record.set("vulnIds", vulnIdsArray);
record.set("vulnIds", toJsonNode(data.getVulnIds()));
record.put("vulnIdsString", data.getVulnIdsString());
record.set("foundInReleases", releaseNamesArray);
record.set("foundInReleases", toJsonNode(data.getReleaseNames()));
record.put("foundInReleasesString", data.getReleaseNamesString());
record.set("foundInReleaseIds", releaseIdsArray);
record.set("foundInReleaseIds", toJsonNode(data.getReleaseIds()));
record.put("foundInReleaseIdsString", data.getReleaseIdsString());
record.set("ids", idsArray);
record.set("ids", toJsonNode(data.getIds()));
record.put("idsString", data.getIdsString());
return record;
}

private static JsonNode toJsonNode(Set<String> values) {
if ( values == null || values.isEmpty() ) {
return JsonHelper.getObjectMapper().getNodeFactory().textNode("N/A");
} else if ( values.size() == 1 ) {
return JsonHelper.getObjectMapper().getNodeFactory().textNode(values.iterator().next());
} else {
var array = JsonHelper.getObjectMapper().createArrayNode();
values.forEach(array::add);
return array;
}
}

public static final FoDBulkIssueUpdateResponse updateIssues(UnirestInstance unirest, String releaseId, FoDBulkIssueUpdateRequest issueUpdateRequest) {
ObjectNode body = JsonHelper.getObjectMapper().valueToTree(issueUpdateRequest);
var result = unirest.post(FoDUrls.VULNERABILITIES + "/bulk-edit")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -929,9 +929,9 @@ fcli.fod.issue.list.usage.description = This command allows for listing FoD issu
recommended to use server-side filtering, via use of the --filters-param or --query options. \
For example, if you are only interested in issues with a specific severity, you \
can use a query like --filters-param "severityString:Critical" or --query "severityString='Critical'".
fcli.fod.issue.list.output.table.header.visibilityMarker =
fcli.fod.issue.list.output.table.header.foundInReleases = Releases
fcli.fod.issue.list.output.table.header.foundInReleasesString = Releases
fcli.fod.issue.output.table.header.visibilityMarker =
fcli.fod.issue.output.table.header.foundInReleases = Releases
fcli.fod.issue.output.table.header.foundInReleasesString = Releases
fcli.fod.issue.embed = Embed extra issue data. Due to FoD rate limits, this may significantly \
affect performance. Allowed values: ${COMPLETION-CANDIDATES}. \
Using the --output option, this extra data can be included in the output. Using the --query option, \
Expand All @@ -942,6 +942,9 @@ fcli.fod.issue.list.includeIssue = By default, only visible issues will be retur
for example `--include visible,fixed` (to return both visible and fixed issues) or `--include \
fixed` (to return only fixed issues). Allowed values: ${COMPLETION-CANDIDATES}.
fcli.fod.issue.list.aggregate = Include aggregation data.
fcli.fod.issue.get.usage.header = Get vulnerability details.
fcli.fod.issue.get.usage.description = Get detailed data for a single FoD vulnerability in a given release.
fcli.fod.issue.get.vulnId = Issue vulnerability id.
fcli.fod.issue.update.usage.header = Bulk update issues (vulnerabilities).
fcli.fod.issue.update.usage.description = This command allows for updating the audit information \
for multiple issues (vulnerabilities). Note: for --vuln-ids/--issue-ids, you can use either the numeric Id as shown in the FoD UI, \
Expand Down Expand Up @@ -1087,6 +1090,7 @@ 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.get.output.table.args = instanceId,visibilityMarker,severityString,category,location,foundInReleasesString
fcli.fod.issue.update.output.table.args = totalCount,updateCount,skippedCount,errorCount
fcli.fod.attribute.output.table.args = id,name,attributeType,attributeDataType,isRequired,isRestricted
fcli.fod.aviator.apply-remediations.output.table.args = releaseId,totalRemediation,appliedRemediation,skippedRemediation,__action__
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
SSCIssueGroupGetCommand.class,
SSCIssueGroupListCommand.class,
SSCIssueCountCommand.class,
SSCIssueGetCommand.class,
SSCIssueListCommand.class,
SSCIssueUpdateCommand.class,
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* 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.ssc.issue.cli.cmd;

import java.util.Arrays;
import java.util.stream.Collectors;

import com.fortify.cli.common.cli.util.EnvSuffix;
import com.fortify.cli.common.json.producer.IObjectNodeProducer;
import com.fortify.cli.common.json.producer.ObjectNodeProducerApplyFrom;
import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins;
import com.fortify.cli.common.rest.unirest.IHttpRequestUpdater;
import com.fortify.cli.ssc._common.output.cli.cmd.AbstractSSCOutputCommand;
import com.fortify.cli.ssc._common.rest.ssc.SSCUrls;
import com.fortify.cli.ssc.appversion.cli.mixin.SSCAppVersionResolverMixin;
import com.fortify.cli.ssc.issue.cli.mixin.SSCIssueBulkEmbedMixin;

import kong.unirest.HttpRequest;
import kong.unirest.UnirestInstance;
import lombok.Getter;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Parameters;

@Command(name = OutputHelperMixins.Get.CMD_NAME)
public class SSCIssueGetCommand extends AbstractSSCOutputCommand implements IHttpRequestUpdater {
@Getter @Mixin private OutputHelperMixins.Get outputHelper;
@Mixin private SSCAppVersionResolverMixin.RequiredOption parentResolver;
@EnvSuffix("ISSUE_ID") @Parameters(index = "0", arity = "1", descriptionKey = "fcli.ssc.issue.get.id")
private String id;
@Mixin private SSCIssueBulkEmbedMixin bulkEmbedMixin;

@Override
protected IObjectNodeProducer getObjectNodeProducer(UnirestInstance unirest) {
String appVersionId = parentResolver.getAppVersionId(unirest);
return requestObjectNodeProducerBuilder(ObjectNodeProducerApplyFrom.SPEC)
.baseRequest(getBaseRequest(unirest, appVersionId))
.build();
}

private HttpRequest<?> getBaseRequest(UnirestInstance unirest, String appVersionId) {
return unirest.get(SSCUrls.PROJECT_VERSION_ISSUE(appVersionId, id))
.queryString("showHidden", "true")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the PROJECT_VERSION_ISSUE endpoint doesn't support these query parameters; it will always return the issue that corresponds to the given id, independent of whether that issue is hidden, removed, or suppressed.

.queryString("showRemoved", "true")
.queryString("showSuppressed", "true");
}

@Override
public HttpRequest<?> updateRequest(HttpRequest<?> request) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand this code:

  • The PROJECT_VERSION_ISSUE endpoint used by this command doesn't support the qm query parameter
  • Even if qm was supported, I don't see why embedNames would need to be appended to its value; embed names are fcli-specific identifiers that SSC doesn't have any knowledge about (apart from that they may overlap with SSC endpoint names, but even then still not valid values for qm request parameter).

var embedSuppliers = bulkEmbedMixin.getEmbedSuppliers();
if (embedSuppliers == null || embedSuppliers.length == 0) {
return request.queryString("qm", "issues");
}
var embedNames = Arrays.stream(embedSuppliers).map(Enum::name).collect(Collectors.joining(","));
return request.queryString("qm", "issues," + embedNames);
}

@Override
public boolean isSingular() {
return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@

public class SSCIssueIncludeMixin implements IHttpRequestUpdater, IRecordTransformer {
@DisableTest(TestType.MULTI_OPT_PLURAL_NAME)
@Option(names = {"--include", "-i"}, split = ",", defaultValue = "visible", descriptionKey = "fcli.ssc.issue.list.includeIssue", paramLabel="<status>")
@Option(names = {"--include", "-i"}, split = ",", defaultValue = "visible", descriptionKey = "fcli.ssc.issue.includeIssue", paramLabel="<status>")
private Set<SSCIssueInclude> includes;

public HttpRequest<?> updateRequest(HttpRequest<?> request) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,49 +39,74 @@ public ISSCEntityEmbedder createEntityEmbedder() {
}

private static abstract class AbstractSSCIssueEmbedder implements ISSCEntityEmbedder {
@Override
public void addEmbedRequests(SSCBulkRequestBuilder builder, UnirestInstance unirest, JsonNode record) {
var id = record.get("id").asText();
builder.request(getBaseRequest(unirest)
.routeParam("id", id)
.queryString("limit", "-1"),
response->process((ObjectNode)record, SSCInputTransformer.getDataOrSelf(response)));
}
protected abstract String getEmbedFieldName();

protected abstract HttpRequest<?> getBaseRequest(UnirestInstance unirest);

protected abstract void process(ObjectNode record, JsonNode response);

@Override
public final void addEmbedRequests(SSCBulkRequestBuilder builder, UnirestInstance unirest, JsonNode record) {
// Skip if data is already present in the record (e.g. returned by SSC via qm parameter)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assumption is nonsense; SSC qm parameter (which is not even supported on the endpoint that's used by the issue get command) doesn't control embedding of extra data. Please undo all irrelevant code changes to this class and all related classes (i.e., remove all getEmbedFieldName methods)

if (record.has(getEmbedFieldName()) && !record.get(getEmbedFieldName()).isNull()) {
return;
}
var id = record.get("id").asText();
builder.request(
getBaseRequest(unirest).routeParam("id", id),
response -> process((ObjectNode) record, SSCInputTransformer.getDataOrSelf(response))
);
}
}

private static final class SSCIssueDetailsEmbedder extends AbstractSSCIssueEmbedder {
@Override
protected String getEmbedFieldName() {
return "details";
}

@Override
protected HttpRequest<?> getBaseRequest(UnirestInstance unirest) {
return unirest.get("/api/v1/issueDetails/{id}");
}

@Override
protected void process(ObjectNode record, JsonNode response) {
record.set("details", response);
record.set("details", response);
}
}

private static final class SSCIssueAuditHistoryEmbedder extends AbstractSSCIssueEmbedder {
@Override
protected String getEmbedFieldName() {
return "auditHistory";
}

@Override
protected HttpRequest<?> getBaseRequest(UnirestInstance unirest) {
return unirest.get("/api/v1/issues/{id}/auditHistory?limit=-1");
return unirest.get("/api/v1/issues/{id}/auditHistory").queryString("limit", "-1");
}

@Override
protected void process(ObjectNode record, JsonNode response) {
record.set("auditHistory", response);
record.set("auditHistory", response);
}
}

private static final class SSCIssueCommentsEmbedder extends AbstractSSCIssueEmbedder {
@Override
protected String getEmbedFieldName() {
return "comments";
}

@Override
protected HttpRequest<?> getBaseRequest(UnirestInstance unirest) {
return unirest.get("/api/v1/issues/{id}/comments?limit=-1");
return unirest.get("/api/v1/issues/{id}/comments").queryString("limit", "-1");
}

@Override
protected void process(ObjectNode record, JsonNode response) {
record.set("comments", response);
record.set("comments", response);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -514,13 +514,16 @@ fcli.ssc.issue.list.usage.description = This command allows for listing SSC vuln
more immediate output.
fcli.ssc.issue.list.output.table.header.visibilityMarker =
fcli.ssc.issue.list.output.table.header.friority = Priority
fcli.ssc.issue.get.usage.header = Get vulnerability details.
fcli.ssc.issue.get.usage.description = Get detailed data for a single SSC vulnerability in a given application version.
fcli.ssc.issue.get.id = Issue id.
fcli.ssc.issue.list.filter = Filter issues using the given (friendly or technical) filter. \
See 'fcli ssc issue list-filters' for allowed values.
fcli.ssc.issue.list.embed = Embed extra application version data. Allowed values: ${COMPLETION-CANDIDATES}. \
fcli.ssc.issue.embed = Embed extra application version data. Allowed values: ${COMPLETION-CANDIDATES}. \
Using the --output option, this extra data can be included in the output. Using the --query option, \
this extra data can be queried upon. To get an understanding of the structure and contents of the \
embedded data, use the --output json or --output yaml options.
fcli.ssc.issue.list.includeIssue = By default, only visible issues will be returned. This option \
fcli.ssc.issue.includeIssue = By default, only visible issues will be returned. This option \
accepts a comma-separated list to allow (also) removed, suppressed and/or hidden issues to be returned, \
for example `--include visible,removed` (to return both visible and removed issues) or `--include \
removed` (to return only removed issues). Allowed values: ${COMPLETION-CANDIDATES}.
Expand Down Expand Up @@ -715,6 +718,9 @@ fcli.ssc.attribute.definition.output.table.args = id,category,guid,name,type,req
fcli.ssc.aviator.output.table.args = id,application.name,name,artifactId
fcli.ssc.custom-tag.output.table.args = guid,name,valueType
fcli.ssc.issue.count.output.table.args = cleanName,totalCount,auditedCount
fcli.ssc.issue.get.output.table.args = id,visibilityMarker,friority,location,issueName
fcli.ssc.issue.get.output.table.header.visibilityMarker =
fcli.ssc.issue.get.output.table.header.friority = Priority
fcli.ssc.issue.list.output.table.args = id,visibilityMarker,friority,location,issueName
fcli.ssc.issue.filter-set.output.table.args = guid,title,defaultFilterSet
fcli.ssc.issue.group.output.table.args = guid,displayName,entityType
Expand Down
Loading