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 @@ -32,6 +32,9 @@
import com.fortify.aviator.application.ApplicationResponseMessage;
import com.fortify.aviator.application.ApplicationServiceGrpc;
import com.fortify.aviator.application.CreateApplicationRequest;
import com.fortify.aviator.application.GetApplicationByTokenRequest;
import com.fortify.aviator.application.GetDefaultQuotaRequest;
import com.fortify.aviator.application.GetDefaultQuotaResponse;
import com.fortify.aviator.application.UpdateApplicationRequest;
import com.fortify.aviator.dastentitlement.DastEntitlement;
import com.fortify.aviator.dastentitlement.DastEntitlementServiceGrpc;
Expand Down Expand Up @@ -188,6 +191,24 @@ public Application getApplication(String projectId, String signature, String mes
return GrpcUtil.executeGrpcCall(blockingStub, ApplicationServiceGrpc.ApplicationServiceBlockingStub::getApplication, request, Constants.OP_GET_APP);
}

public Application getApplicationByToken(String token, String appName) {
GetApplicationByTokenRequest request = GetApplicationByTokenRequest.newBuilder()
.setToken(token)
.setAppName(appName)
.build();
return GrpcUtil.executeGrpcCall(blockingStub, ApplicationServiceGrpc.ApplicationServiceBlockingStub::getApplicationByToken, request, Constants.OP_GET_APP_BY_TOKEN);
}

public long getDefaultQuota(String token) {
GetDefaultQuotaRequest request = GetDefaultQuotaRequest.newBuilder()
.setToken(token)
.build();
GetDefaultQuotaResponse response = GrpcUtil.executeGrpcCall(blockingStub,
ApplicationServiceGrpc.ApplicationServiceBlockingStub::getDefaultQuota,
request, Constants.OP_GET_DEFAULT_QUOTA);
return response.getDefaultQuota();
}

public List<Application> listApplication(String tenantName, String signature, String message) {
ApplicationByTenantName request = ApplicationByTenantName.newBuilder().setName(tenantName).setSignature(signature).setMessage(message).build();
ApplicationList applicationList = GrpcUtil.executeGrpcCall(blockingStub, ApplicationServiceGrpc.ApplicationServiceBlockingStub::listApplications, request, Constants.OP_LIST_APPS);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ public class Constants {
public static final String OP_VALIDATE_USER_TOKEN = "validating user token";
public static final String OP_LIST_ENTITLEMENTS = "listing entitlements";
public static final String OP_LIST_DAST_ENTITLEMENTS = "listing DAST entitlements";
public static final String OP_GET_APP_BY_TOKEN = "retrieving application by token";
public static final String OP_GET_DEFAULT_QUOTA = "retrieving default quota";

public static final long DEFAULT_PING_INTERVAL_SECONDS = 30;
public static final int DEFAULT_TIMEOUT_SECONDS = 30;
Expand Down
15 changes: 15 additions & 0 deletions fcli-core/fcli-aviator-common/src/main/proto/Application.proto
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,26 @@ message ApplicationResponseMessage {
string responseMessage = 1;
}

message GetApplicationByTokenRequest {
string token = 1;
string app_name = 2;
}

message GetDefaultQuotaRequest {
string token = 1;
}

message GetDefaultQuotaResponse {
int64 default_quota = 1;
}

service ApplicationService {
rpc CreateApplication(CreateApplicationRequest) returns (Application) {}
rpc GetApplication(ApplicationById) returns (Application) {}
rpc UpdateApplication(UpdateApplicationRequest) returns (Application) {}
rpc DeleteApplication(ApplicationById) returns (ApplicationResponseMessage) {}
rpc ListApplications(ApplicationByTenantName) returns (ApplicationList) {}
rpc ListApplicationsByEntitlement(ApplicationById) returns (ApplicationList) {}
rpc GetApplicationByToken(GetApplicationByTokenRequest) returns (Application) {}
rpc GetDefaultQuota(GetDefaultQuotaRequest) returns (GetDefaultQuotaResponse) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ public class AviatorSSCAuditCommand extends AbstractSSCJsonNodeOutputCommand imp
@Option(names = {"--tag-mapping"}) private String tagMapping;
@Option(names = {"--no-filterset"}) private boolean noFilterSet;
@Option(names = {"--folder"}, split = ",") @DisableTest(DisableTest.TestType.MULTI_OPT_PLURAL_NAME) private List<String> folderNames;
@Option(names = {"--skip-if-exceeding-quota"}) private boolean skipIfExceedingQuota;
Copy link
Contributor

Choose a reason for hiding this comment

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

Quite a long option name; nothing better comes to mind right now but maybe worth giving this another thought, to see whether some creativity can result in shorter option names?

@Option(names = {"--test-exceeding-quota"}) private boolean testExceedingQuota;
Copy link
Contributor

Choose a reason for hiding this comment

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

PR description states 'dry run'; does this do anything other than checking quota? Maybe we should just have --dry-run or similar, if that's clear enough and properly describes the intent? Can this option be used on its own, or does it also require --skip-if-exceeding-quota to be specified? Just wondering, would it make sense to have this as a separate command (together with --default-quota-fallback)?

@Option(names = {"--default-quota-fallback"}, hidden = true) private boolean defaultQuotaFallback;
Copy link
Contributor

Choose a reason for hiding this comment

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

We (almost) never use hidden options in fcli, and given that this is explicitly used by bulkaudit.yaml, maybe we should just make this a non-hidden option?

@Option(names = {"--folder-priority-order"}, split = ",",
description = "Custom priority order by folder (comma-separated, highest first). Example: Critical,High,Medium,Low")
@DisableTest(DisableTest.TestType.MULTI_OPT_PLURAL_NAME)
Expand Down Expand Up @@ -99,6 +102,66 @@ public JsonNode getJsonNode(UnirestInstance unirest) {
return AviatorSSCAuditHelper.buildResultNode(av, "N/A", "SKIPPED (no auditable issues)");
}

// Quota check: only when --skip-if-exceeding-quota or --test-exceeding-quota is active
Copy link
Contributor

Choose a reason for hiding this comment

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

Not sure how long the original method was, but for sure the method becomes much longer with this change. Can we please split into multiple methods?

if (skipIfExceedingQuota || testExceedingQuota) {
String effectiveAppName = appName != null ? appName : av.getApplicationName();
long availableQuota = AviatorSSCAuditHelper.getAvailableQuota(
sessionDescriptor.getAviatorUrl(), sessionDescriptor.getAviatorToken(),
effectiveAppName, logger);

// App not found — behavior depends on --default-quota-fallback
if (availableQuota == AviatorSSCAuditHelper.QUOTA_APP_NOT_FOUND) {
if (defaultQuotaFallback) {
// Bulk audit mode: use default quota for non-existing apps
logger.progress("Application '%s' not found, using default quota for new applications.", effectiveAppName);
availableQuota = AviatorSSCAuditHelper.getDefaultQuota(
sessionDescriptor.getAviatorUrl(), sessionDescriptor.getAviatorToken(), logger);
if (availableQuota == AviatorSSCAuditHelper.QUOTA_UNKNOWN) {
if (testExceedingQuota) {
return AviatorSSCAuditHelper.buildResultNode(av, "N/A",
"QUOTA UNKNOWN (application '" + effectiveAppName + "' not found, could not retrieve default quota)");
}
logger.progress("Warning: Could not retrieve default quota, proceeding with audit.");
}
// Fall through to normal quota comparison below with the default quota value
} else {
// Individual audit mode: report app not found and stop
logger.progress("Application '%s' does not exist in Aviator.", effectiveAppName);
return AviatorSSCAuditHelper.buildResultNode(av, "N/A",
"SKIPPED (application '" + effectiveAppName + "' not found in Aviator)");
}
}

// If quota retrieval failed for other reasons, handle accordingly
if (availableQuota == AviatorSSCAuditHelper.QUOTA_UNKNOWN) {
if (testExceedingQuota) {
return AviatorSSCAuditHelper.buildResultNode(av, "N/A",
"QUOTA UNKNOWN (could not retrieve quota for application '" + effectiveAppName + "')");
}
// --skip-if-exceeding-quota with unknown quota — fall through to normal audit
logger.progress("Warning: Could not retrieve quota for '%s', proceeding with audit.", effectiveAppName);
} else if (availableQuota >= 0 && auditableIssueCount > availableQuota) {
// Exceeding quota — gather top categories
var topCategories = AviatorSSCAuditHelper.getTopUnauditedCategories(unirest, av, logger, 10);
String detailedMessage = AviatorSSCAuditHelper.formatQuotaExceededMessage(
av, auditableIssueCount, availableQuota, topCategories);
LOG.info(detailedMessage);
logger.progress("Quota exceeded for %s:%s -- Open issues: %d, Available quota: %d. Audit skipped.",
av.getApplicationName(), av.getVersionName(), auditableIssueCount, availableQuota);
return AviatorSSCAuditHelper.buildQuotaExceededResultNode(
av, auditableIssueCount, availableQuota, topCategories);
} else if (testExceedingQuota) {
// Test mode but NOT exceeding quota — report pass, no audit
logger.progress("Quota check passed for %s:%s -- Open issues: %d, Available quota: %s",
av.getApplicationName(), av.getVersionName(), auditableIssueCount,
availableQuota < 0 ? "unlimited" : String.valueOf(availableQuota));
return AviatorSSCAuditHelper.buildResultNode(av, "N/A",
String.format("QUOTA OK (issues: %d, quota: %s)", auditableIssueCount,
availableQuota < 0 ? "unlimited" : String.valueOf(availableQuota)));
}
// --skip-if-exceeding-quota with quota OK: fall through to normal audit
}

downloadedFprPath = downloadFpr(unirest, av, logger);
if (downloadedFprPath == null) {
return AviatorSSCAuditHelper.buildResultNode(av, "N/A", "SKIPPED (no FPR available to audit)");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
*/
package com.fortify.cli.aviator.ssc.helper;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
Expand All @@ -23,17 +25,24 @@
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fortify.cli.aviator._common.exception.AviatorSimpleException;
import com.fortify.cli.aviator.audit.model.FPRAuditResult;
import com.fortify.cli.aviator.config.AviatorLoggerImpl;
import com.fortify.cli.aviator.grpc.AviatorGrpcClient;
import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper;
import com.fortify.cli.aviator.util.Constants;
import com.fortify.cli.common.exception.FcliSimpleException;
import com.fortify.cli.common.json.JsonHelper;
import com.fortify.cli.common.output.transform.IActionCommandResultSupplier;
import com.fortify.cli.common.rest.unirest.UnexpectedHttpResponseException;
import com.fortify.cli.ssc._common.rest.ssc.SSCUrls;
import com.fortify.cli.ssc.appversion.helper.SSCAppVersionDescriptor;
import com.fortify.cli.ssc.issue.cli.mixin.SSCIssueFilterSetOptionMixin;
import com.fortify.cli.ssc.issue.helper.SSCIssueFilterHelper;
import com.fortify.cli.ssc.issue.helper.SSCIssueFilterSetDescriptor;
import com.fortify.cli.ssc.issue.helper.SSCIssueFilterSetHelper;
import com.fortify.cli.ssc.issue.helper.SSCIssueGroupDescriptor;
import com.fortify.cli.ssc.issue.helper.SSCIssueGroupHelper;

import kong.unirest.GetRequest;
import kong.unirest.UnirestInstance;
Expand Down Expand Up @@ -228,4 +237,180 @@ private static String getFolderFilter(boolean noFilterSet, SSCIssueFilterSetDesc
})
.collect(Collectors.joining(" OR "));
}

/**
* Sentinel value indicating quota retrieval failed.
*/
public static final long QUOTA_UNKNOWN = Long.MIN_VALUE;

/**
* Sentinel value indicating the application was not found on the server.
*/
public static final long QUOTA_APP_NOT_FOUND = Long.MIN_VALUE + 1;

/**
* Retrieves the available quota for the given Aviator application using the developer token.
* Returns the quota value from the server, {@link #QUOTA_APP_NOT_FOUND} if the app doesn't exist,
* or {@link #QUOTA_UNKNOWN} if retrieval fails for other reasons.
* A server-returned value of -1 means unlimited quota.
*/
public static long getAvailableQuota(String aviatorUrl, String aviatorToken, String appName,
AviatorLoggerImpl logger) {
try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(aviatorUrl, logger,
Constants.DEFAULT_PING_INTERVAL_SECONDS)) {
com.fortify.aviator.application.Application app = client.getApplicationByToken(aviatorToken, appName);
long quota = app.getQuota();
logger.progress("Status: Available Aviator quota for app '%s': %s", appName,
quota < 0 ? "unlimited" : String.valueOf(quota));
return quota;
} catch (AviatorSimpleException e) {
if (e.getMessage() != null && e.getMessage().contains("not found")) {
LOG.info("Application '{}' not found on Aviator server.", appName);
return QUOTA_APP_NOT_FOUND;
}
LOG.warn("Failed to retrieve quota for application '{}': {}", appName, e.getMessage());
logger.progress("WARN: Could not retrieve Aviator quota for app '%s'. Proceeding without quota check.", appName);
return QUOTA_UNKNOWN;
} catch (Exception e) {
LOG.warn("Failed to retrieve quota for application '{}': {}", appName, e.getMessage());
logger.progress("WARN: Could not retrieve Aviator quota for app '%s'. Proceeding without quota check.", appName);
return QUOTA_UNKNOWN;
}
}

/**
* Retrieves the default initial quota for new applications from the tenant.
* Returns the default quota, or {@link #QUOTA_UNKNOWN} if retrieval fails.
*/
public static long getDefaultQuota(String aviatorUrl, String aviatorToken,
AviatorLoggerImpl logger) {
try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(aviatorUrl, logger,
Constants.DEFAULT_PING_INTERVAL_SECONDS)) {
long quota = client.getDefaultQuota(aviatorToken);
logger.progress("Status: Default Aviator quota for new applications: %s",
quota < 0 ? "unlimited" : String.valueOf(quota));
return quota;
} catch (Exception e) {
LOG.warn("Failed to retrieve default quota: {}", e.getMessage());
logger.progress("WARN: Could not retrieve default Aviator quota.");
return QUOTA_UNKNOWN;
}
}

/**
* Returns the top N SAST categories ordered by truly-unaudited issue count (descending).
* Uses the SSC issueGroups API with:
* - groupingtype = dynamically resolved "Category" GUID
* - filter = dynamically resolved "Aviator status:Not Set" technical filter
* Each entry contains "categoryName" and "unauditedCount".
*/
public static List<Map<String, Object>> getTopUnauditedCategories(
UnirestInstance unirest, SSCAppVersionDescriptor av,
AviatorLoggerImpl logger, int topN) {

String versionId = av.getVersionId();

// Resolve "Category" grouping GUID dynamically
SSCIssueGroupHelper groupHelper = new SSCIssueGroupHelper(unirest, versionId);
SSCIssueGroupDescriptor categoryDescriptor = groupHelper.getDescriptorByDisplayNameOrId("Category", true);
String categoryGroupGuid = categoryDescriptor.getGuid();

// Resolve "Aviator status:Not Set" filter dynamically
String aviatorStatusFilter = null;
try {
SSCIssueFilterHelper filterHelper = new SSCIssueFilterHelper(unirest, versionId);
aviatorStatusFilter = filterHelper.getFilter("Aviator status:Not Set");
} catch (FcliSimpleException e) {
// Tag doesn't exist on this version — all issues are unprocessed
LOG.debug("Aviator status tag not found for version {}. All issues considered unprocessed.", versionId);
}

// Call issueGroups API
GetRequest request = unirest.get(SSCUrls.PROJECT_VERSION_ISSUE_GROUPS(versionId))
.queryString("limit", "-1")
.queryString("qm", "issues")
.queryString("groupingtype", categoryGroupGuid);

if (aviatorStatusFilter != null) {
request.queryString("filter", aviatorStatusFilter);
}

JsonNode response = request.asObject(JsonNode.class).getBody();
ArrayNode groups = (ArrayNode) response.get("data");

// Calculate truly-unaudited count per category and sort descending
List<Map<String, Object>> categories = new ArrayList<>();
if (groups != null) {
for (JsonNode group : groups) {
String name = group.path("id").asText("Unknown");
long visibleCount = group.path("visibleCount").asLong(0);
long auditedCount = group.path("auditedCount").asLong(0);
long unaudited = visibleCount - auditedCount;
if (unaudited > 0) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("categoryName", name);
entry.put("unauditedCount", unaudited);
categories.add(entry);
}
}
}

// Sort descending by unauditedCount
categories.sort((a, b) -> Long.compare(
(long) b.get("unauditedCount"),
(long) a.get("unauditedCount")
));

return categories.subList(0, Math.min(topN, categories.size()));
}

/**
* Builds the JSON result node when a version is skipped due to exceeding quota.
*/
public static ObjectNode buildQuotaExceededResultNode(
SSCAppVersionDescriptor av, long openIssueCount, long availableQuota,
List<Map<String, Object>> topCategories) {
// Build a descriptive action string with all relevant quota information
StringBuilder actionText = new StringBuilder();
actionText.append(String.format("QUOTA EXCEEDED -- Open issues: %d, Available quota: %d.", openIssueCount, availableQuota));
if (topCategories != null && !topCategories.isEmpty()) {
actionText.append("\nTop unaudited categories:");
int rank = 1;
for (Map<String, Object> cat : topCategories) {
actionText.append(String.format("\n %d. %s (%d issues)", rank++, cat.get("categoryName"), cat.get("unauditedCount")));
}
}

ObjectNode result = buildResultNode(av, "N/A", actionText.toString());
result.put("openIssueCount", openIssueCount);
result.put("availableQuota", availableQuota);

ArrayNode categoriesArray = JsonHelper.getObjectMapper().createArrayNode();
for (Map<String, Object> cat : topCategories) {
ObjectNode catNode = JsonHelper.getObjectMapper().createObjectNode();
catNode.put("category", (String) cat.get("categoryName"));
catNode.put("unauditedCount", (long) cat.get("unauditedCount"));
categoriesArray.add(catNode);
}
result.set("topCategories", categoriesArray);
return result;
}

/**
* Formats a human-readable message for quota exceeded scenarios.
*/
public static String formatQuotaExceededMessage(
SSCAppVersionDescriptor av, long openIssueCount, long availableQuota,
List<Map<String, Object>> topCategories) {
StringBuilder sb = new StringBuilder();
sb.append(String.format("Quota exceeded for %s:%s -- Open issues: %d, Available quota: %d%n",
av.getApplicationName(), av.getVersionName(), openIssueCount, availableQuota));
sb.append("Top SAST categories by unaudited issue count:\n");
int rank = 1;
for (Map<String, Object> cat : topCategories) {
sb.append(String.format(" %d. %s (%d issues)%n",
rank++, cat.get("categoryName"), cat.get("unauditedCount")));
}
return sb.toString();
}
}
Loading
Loading