From e7389722660d546f3f06ef8b580edca21b03f065 Mon Sep 17 00:00:00 2001 From: Ankit Rathod Date: Thu, 26 Feb 2026 14:24:53 +0530 Subject: [PATCH 1/5] feat: Implement `--skip-if-exceeding-quota` and `--test-exceeding-quota` flags for audit command - Added new gRPC RPC `GetApplicationByToken` to retrieve application quota. - Introduced `--skip-if-exceeding-quota` to skip audits if open issues exceed quota. - Introduced `--test-exceeding-quota` for dry-run mode to report potential skips without auditing. - Enhanced `AviatorSSCAuditHelper` with methods to get available quota and top unaudited categories. - Updated FCLI client and server-side implementations to support new features. - Modified bulk audit YAML to include new options and track quota-skipped statistics. - Added comprehensive error handling and logging for quota checks. - Updated documentation and messages for new command options. --- .../cli/aviator/grpc/AviatorGrpcClient.java | 21 ++ .../fortify/cli/aviator/util/Constants.java | 2 + .../src/main/proto/Application.proto | 15 ++ .../ssc/cli/cmd/AviatorSSCAuditCommand.java | 63 ++++++ .../ssc/helper/AviatorSSCAuditHelper.java | 185 ++++++++++++++++ .../aviator/i18n/AviatorMessages.properties | 3 + .../cli/ssc/actions/zip/bulkaudit.yaml | 208 ++++++++++++++---- 7 files changed, 454 insertions(+), 43 deletions(-) diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java index d5302e6e423..aa797e97639 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java @@ -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.entitlement.Entitlement; import com.fortify.aviator.entitlement.EntitlementServiceGrpc; @@ -181,6 +184,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 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); diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/Constants.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/Constants.java index 98810954215..79e9da7b2cf 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/Constants.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/Constants.java @@ -72,6 +72,8 @@ public class Constants { public static final String OP_VALIDATE_TOKEN = "validating token"; 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_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; diff --git a/fcli-core/fcli-aviator-common/src/main/proto/Application.proto b/fcli-core/fcli-aviator-common/src/main/proto/Application.proto index d4849f01aef..e949dcccc25 100644 --- a/fcli-core/fcli-aviator-common/src/main/proto/Application.proto +++ b/fcli-core/fcli-aviator-common/src/main/proto/Application.proto @@ -57,6 +57,19 @@ 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) {} @@ -64,4 +77,6 @@ service ApplicationService { 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) {} } \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommand.java index 201f01b16fa..e571ad22247 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommand.java @@ -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 folderNames; + @Option(names = {"--skip-if-exceeding-quota"}) private boolean skipIfExceedingQuota; + @Option(names = {"--test-exceeding-quota"}) private boolean testExceedingQuota; + @Option(names = {"--default-quota-fallback"}, hidden = true) private boolean defaultQuotaFallback; private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCAuditCommand.class); @Override @@ -95,6 +98,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 + 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)"); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java index 0d91c7831a5..9d4cfc52c5d 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java @@ -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; @@ -23,8 +25,12 @@ 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; @@ -32,8 +38,11 @@ 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; @@ -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> 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> 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 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> 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 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 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> 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 cat : topCategories) { + sb.append(String.format(" %d. %s (%d issues)%n", + rank++, cat.get("categoryName"), cat.get("unauditedCount"))); + } + return sb.toString(); + } } \ No newline at end of file diff --git a/fcli-core/fcli-aviator/src/main/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties b/fcli-core/fcli-aviator/src/main/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties index 876006b50d3..eda9e62ea4d 100644 --- a/fcli-core/fcli-aviator/src/main/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties +++ b/fcli-core/fcli-aviator/src/main/resources/com/fortify/cli/aviator/i18n/AviatorMessages.properties @@ -114,6 +114,9 @@ fcli.aviator.ssc.audit.tag-mapping = Custom tag mapping for audit results. fcli.aviator.ssc.audit.filterset = Name or ID of the FilterSet to apply. fcli.aviator.ssc.audit.no-filterset = Do not apply any filter sets, including the default enabled filter set from the FPR. fcli.aviator.ssc.audit.folder = Filter issues by a comma-separated list of specific folder names from the selected FilterSet (e.g., 'Hot,Critical'). This option requires a FilterSet to be active. +fcli.aviator.ssc.audit.skip-if-exceeding-quota = Skip audit if the number of open issues exceeds the available Aviator quota. When skipped, a summary with top unaudited categories is shown. +fcli.aviator.ssc.audit.test-exceeding-quota = Check whether the number of open issues exceeds the available Aviator quota and report the result without performing an audit. +fcli.aviator.ssc.audit.default-quota-fallback = (Internal) When the Aviator application does not exist, use the tenant default quota instead of reporting app not found. Used by bulk audit. fcli.aviator.ssc.audit.refresh = By default, this command will refresh the source application version's metrics when copying from it. \ Note that for large applications this can lead to an error if the timeout expires. fcli.aviator.ssc.audit.refresh-timeout = Time-out, for example 30s (30 seconds), 5m (5 minutes), 1h (1 hour). Default value: ${DEFAULT-VALUE} diff --git a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/bulkaudit.yaml b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/bulkaudit.yaml index e287854d417..ec001ce326c 100644 --- a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/bulkaudit.yaml +++ b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/bulkaudit.yaml @@ -71,6 +71,18 @@ cli.options: description: "Timeout period for metric refresh, e.g., 30s (30 seconds), 5m (5 minutes), 1h (1 hour). Default: 60s" required: false default: "60s" + skip-if-exceeding-quota: + names: --skip-if-exceeding-quota + description: "Skip audit if the number of open issues exceeds the available Aviator quota. When skipped, a summary with top unaudited categories is shown. Default: false" + required: false + default: false + type: boolean + test-exceeding-quota: + names: --test-exceeding-quota + description: "Check whether the number of open issues exceeds the available Aviator quota and report the result without performing an audit. Default: false" + required: false + default: false + type: boolean steps: # Configure module @@ -83,6 +95,18 @@ steps: - log.progress: "ERROR: Either --tag-mapping or --add-aviator-tags must be specified." - throw: "Either --tag-mapping or --add-aviator-tags must be specified." + # Validate that --dry-run and --test-exceeding-quota are not used together + - if: ${cli['dry-run'] && cli['test-exceeding-quota']} + steps: + - log.progress: "ERROR: --dry-run and --test-exceeding-quota cannot be used together. Use --test-exceeding-quota alone to check quota without auditing." + - throw: "--dry-run and --test-exceeding-quota cannot be used together. Use --test-exceeding-quota alone to check quota without auditing." + + # Validate that --dry-run and --skip-if-exceeding-quota are not used together + - if: ${cli['dry-run'] && cli['skip-if-exceeding-quota']} + steps: + - log.progress: "ERROR: --dry-run and --skip-if-exceeding-quota cannot be used together. --dry-run prevents server interaction, but --skip-if-exceeding-quota requires quota retrieval." + - throw: "--dry-run and --skip-if-exceeding-quota cannot be used together. --dry-run prevents server interaction, but --skip-if-exceeding-quota requires quota retrieval." + # Get existing Aviator applications - log.progress: Retrieving existing Aviator applications... - run.fcli: @@ -217,6 +241,7 @@ steps: stats.create_failures: 0 stats.audit_attempts: 0 stats.audit_failures: 0 + stats.quota_skipped: 0 stats.entitlement_exhausted: false stats.create_skipped_due_to_entitlement: 0 @@ -236,69 +261,163 @@ steps: steps: - var.set: app_ready: ${project.exists_in_aviator} + skip_this_version: false - # Create app if needed - - if: ${!project.exists_in_aviator && !stats.entitlement_exhausted} + # --- TEST-EXCEEDING-QUOTA MODE --- + # In test mode we only check quota and report; no app creation, no audit. + # For non-existing apps, --default-quota-fallback tells the audit command + # to use the tenant's default quota instead of reporting "app not found". + - if: ${cli['test-exceeding-quota']} steps: - var.set: - stats.create_attempts: ${stats.create_attempts + 1} - - run.fcli: - create_app: - cmd: aviator app create "${project.project_name}" - status.check: false - - if: ${create_app.exitCode == 0} + stats.audit_attempts: ${stats.audit_attempts + 1} + quota_flags: " --test-exceeding-quota" + - if: ${!project.exists_in_aviator} var.set: - app_ready: true - stats.create_successes: ${stats.create_successes + 1} - - if: ${create_app.exitCode != 0} - steps: - - var.set: - stats.create_failures: ${stats.create_failures + 1} - - if: ${!stats.entitlement_exhausted} - steps: - - log.warn: App creation failed - suppressing further create attempts - - var.set: - stats.entitlement_exhausted: true + quota_flags: " --test-exceeding-quota --default-quota-fallback" - - if: ${!project.exists_in_aviator && stats.entitlement_exhausted} - var.set: - stats.create_skipped_due_to_entitlement: ${stats.create_skipped_due_to_entitlement + 1} + - if: ${cli['tag-mapping'] != null && cli['tag-mapping'] != ''} + run.fcli: + run_audit: + cmd: 'aviator ssc audit --av "${project.project_name}:${project.version_name}" --app "${project.project_name}" --log-level=INFO --tag-mapping="${cli[''tag-mapping'']}" --refresh=${cli.refresh} --refresh-timeout="${cli[''refresh-timeout'']}"${quota_flags}' + status.check: false + records.collect: true + stdout: show - # Prepare Aviator tags if requested - - if: ${cli['add-aviator-tags']} - steps: - - log.progress: Preparing Aviator tags for ${project.project_name} - - run.fcli: - prepare_tags: - cmd: aviator ssc prepare --av "${project.project_name}:${project.version_name}" + - if: ${cli['tag-mapping'] == null || cli['tag-mapping'] == ''} + run.fcli: + run_audit: + cmd: 'aviator ssc audit --av "${project.project_name}:${project.version_name}" --app "${project.project_name}" --log-level=INFO --refresh=${cli.refresh} --refresh-timeout="${cli[''refresh-timeout'']}"${quota_flags}' status.check: false - - if: ${prepare_tags.exitCode != 0} + records.collect: true + stdout: show + + - if: ${run_audit.exitCode == 0 && run_audit.records != null && run_audit.records.size() > 0 && run_audit.records[0].__action__ != null && run_audit.records[0].__action__.contains('QUOTA EXCEEDED')} + var.set: + stats.quota_skipped: ${stats.quota_skipped + 1} + + - if: ${run_audit.exitCode != 0} steps: - - log.warn: Aviator tag preparation failed for ${project.project_name} + - var.set: + stats.audit_failures: ${stats.audit_failures + 1} + - log.warn: Quota test failed for ${project.project_name}:${project.version_name} - # Run audit if app is ready - - if: ${app_ready} - steps: - var.set: - stats.audit_attempts: ${stats.audit_attempts + 1} + skip_this_version: true + + # --- SKIP-IF-EXCEEDING-QUOTA + NON-EXISTING APP --- + # Pre-check default quota before creating the app. If the default quota + # would be exceeded, skip the version entirely (don't create, don't audit). + - if: ${!skip_this_version && cli['skip-if-exceeding-quota'] && !project.exists_in_aviator} + steps: + - log.progress: Pre-checking default quota for new app ${project.project_name}... - if: ${cli['tag-mapping'] != null && cli['tag-mapping'] != ''} run.fcli: - run_audit: - cmd: 'aviator ssc audit --av "${project.project_name}:${project.version_name}" --app "${project.project_name}" --log-level=INFO --tag-mapping="${cli[''tag-mapping'']}" --refresh=${cli.refresh} --refresh-timeout="${cli[''refresh-timeout'']}"' + quota_precheck: + cmd: 'aviator ssc audit --av "${project.project_name}:${project.version_name}" --app "${project.project_name}" --log-level=INFO --tag-mapping="${cli[''tag-mapping'']}" --refresh=${cli.refresh} --refresh-timeout="${cli[''refresh-timeout'']}" --test-exceeding-quota --default-quota-fallback' status.check: false + records.collect: true + stdout: show - if: ${cli['tag-mapping'] == null || cli['tag-mapping'] == ''} run.fcli: - run_audit: - cmd: 'aviator ssc audit --av "${project.project_name}:${project.version_name}" --app "${project.project_name}" --log-level=INFO --refresh=${cli.refresh} --refresh-timeout="${cli[''refresh-timeout'']}"' + quota_precheck: + cmd: 'aviator ssc audit --av "${project.project_name}:${project.version_name}" --app "${project.project_name}" --log-level=INFO --refresh=${cli.refresh} --refresh-timeout="${cli[''refresh-timeout'']}" --test-exceeding-quota --default-quota-fallback' status.check: false + records.collect: true + stdout: show - - if: ${run_audit.exitCode != 0} + # If pre-check shows quota exceeded, skip this version entirely + - if: ${quota_precheck.exitCode == 0 && quota_precheck.records != null && quota_precheck.records.size() > 0 && quota_precheck.records[0].__action__ != null && quota_precheck.records[0].__action__.contains('QUOTA EXCEEDED')} steps: + - log.progress: Default quota exceeded for ${project.project_name} - skipping app creation and audit - var.set: - stats.audit_failures: ${stats.audit_failures + 1} - - log.warn: Audit failed for ${project.project_name}:${project.version_name} + skip_this_version: true + stats.quota_skipped: ${stats.quota_skipped + 1} + + # --- NORMAL FLOW: create app, prepare tags, run audit --- + - if: ${!skip_this_version} + steps: + # Create app if needed + - if: ${!project.exists_in_aviator && !stats.entitlement_exhausted} + steps: + - var.set: + stats.create_attempts: ${stats.create_attempts + 1} + - run.fcli: + create_app: + cmd: aviator app create "${project.project_name}" + status.check: false + - if: ${create_app.exitCode == 0} + var.set: + app_ready: true + stats.create_successes: ${stats.create_successes + 1} + - if: ${create_app.exitCode != 0} + steps: + - var.set: + stats.create_failures: ${stats.create_failures + 1} + - if: ${!stats.entitlement_exhausted} + steps: + - log.warn: App creation failed - suppressing further create attempts + - var.set: + stats.entitlement_exhausted: true + + - if: ${!project.exists_in_aviator && stats.entitlement_exhausted} + var.set: + stats.create_skipped_due_to_entitlement: ${stats.create_skipped_due_to_entitlement + 1} + + # Prepare Aviator tags if requested + - if: ${cli['add-aviator-tags']} + steps: + - log.progress: Preparing Aviator tags for ${project.project_name} + - run.fcli: + prepare_tags: + cmd: aviator ssc prepare --av "${project.project_name}:${project.version_name}" + status.check: false + - if: ${prepare_tags.exitCode != 0} + steps: + - log.warn: Aviator tag preparation failed for ${project.project_name} + + # Run audit if app is ready + - if: ${app_ready} + steps: + - var.set: + stats.audit_attempts: ${stats.audit_attempts + 1} + + # Build quota flags string (only --skip-if-exceeding-quota here; + # --test-exceeding-quota is handled in its own block above) + - var.set: + quota_flags: "" + - if: ${cli['skip-if-exceeding-quota']} + var.set: + quota_flags: " --skip-if-exceeding-quota" + + - if: ${cli['tag-mapping'] != null && cli['tag-mapping'] != ''} + run.fcli: + run_audit: + cmd: 'aviator ssc audit --av "${project.project_name}:${project.version_name}" --app "${project.project_name}" --log-level=INFO --tag-mapping="${cli[''tag-mapping'']}" --refresh=${cli.refresh} --refresh-timeout="${cli[''refresh-timeout'']}"${quota_flags}' + status.check: false + records.collect: true + stdout: show + + - if: ${cli['tag-mapping'] == null || cli['tag-mapping'] == ''} + run.fcli: + run_audit: + cmd: 'aviator ssc audit --av "${project.project_name}:${project.version_name}" --app "${project.project_name}" --log-level=INFO --refresh=${cli.refresh} --refresh-timeout="${cli[''refresh-timeout'']}"${quota_flags}' + status.check: false + records.collect: true + stdout: show + + # Track quota-skipped results (__action__ field contains QUOTA EXCEEDED) + - if: ${run_audit.exitCode == 0 && run_audit.records != null && run_audit.records.size() > 0 && run_audit.records[0].__action__ != null && run_audit.records[0].__action__.contains('QUOTA EXCEEDED')} + var.set: + stats.quota_skipped: ${stats.quota_skipped + 1} + + - if: ${run_audit.exitCode != 0} + steps: + - var.set: + stats.audit_failures: ${stats.audit_failures + 1} + - log.warn: Audit failed for ${project.project_name}:${project.version_name} # Summary - if: ${cli['dry-run']} @@ -316,7 +435,10 @@ steps: - if: ${!cli['dry-run']} steps: - - log.progress: Complete - Apps created ${stats.create_successes}/${stats.create_attempts}, Audits attempted ${stats.audit_attempts} + - if: ${stats.quota_skipped > 0} + log.progress: Complete - Apps created ${stats.create_successes}/${stats.create_attempts}, Audits attempted ${stats.audit_attempts}, Quota-skipped ${stats.quota_skipped} + - if: ${stats.quota_skipped == 0} + log.progress: Complete - Apps created ${stats.create_successes}/${stats.create_attempts}, Audits attempted ${stats.audit_attempts} - if: ${stats.entitlement_exhausted} log.progress: Note - Entitlement exhausted, some app creations were skipped From fc3524036c337ec088a4d4f8c899fe7756f708e4 Mon Sep 17 00:00:00 2001 From: Ankit Rathod Date: Fri, 27 Feb 2026 13:07:33 +0530 Subject: [PATCH 2/5] feat: Update GetDefaultQuotaRequest to use optional fields for token, tenant_name, signature, and message --- .../fcli-aviator-common/src/main/proto/Application.proto | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/fcli-core/fcli-aviator-common/src/main/proto/Application.proto b/fcli-core/fcli-aviator-common/src/main/proto/Application.proto index e949dcccc25..a2e332ec8e4 100644 --- a/fcli-core/fcli-aviator-common/src/main/proto/Application.proto +++ b/fcli-core/fcli-aviator-common/src/main/proto/Application.proto @@ -63,7 +63,10 @@ message GetApplicationByTokenRequest { } message GetDefaultQuotaRequest { - string token = 1; + optional string token = 1; + optional string tenant_name = 2; + optional string signature = 3; + optional string message = 4; } message GetDefaultQuotaResponse { From 0d66c01880fd3cd8b851326223ad6b376f383ee9 Mon Sep 17 00:00:00 2001 From: "Kevin A. Lee" <4321872+kadraman@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:39:12 +0000 Subject: [PATCH 3/5] chore: DAST Automated fixes and missing option. (#937) fix: `fcli fod dast-scan start`: Fix DAST scan not starting first time when using fcli (fixes #917) fix: `fcli fod microservice create`: Disallow microservice creation on non-microservice application (fixes #873) feat: `fcli fod dast-scan start`: Add `--vpn` option to select Fortify Connect network name chore: tidied up DAST scan messages --- .../dast/FoDScanDastAutomatedHelper.java | 21 +++++++++----- .../cmd/FoDDastAutomatedScanStartCommand.java | 29 +++++++++++++++++-- .../cli/cmd/FoDMicroserviceCreateCommand.java | 9 ++++-- .../cli/fod/i18n/FoDMessages.properties | 1 + 4 files changed, 48 insertions(+), 12 deletions(-) diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/scan/helper/dast/FoDScanDastAutomatedHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/scan/helper/dast/FoDScanDastAutomatedHelper.java index d00ecde7581..4fec80bfe0d 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/scan/helper/dast/FoDScanDastAutomatedHelper.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/scan/helper/dast/FoDScanDastAutomatedHelper.java @@ -80,13 +80,17 @@ public static final FoDScanDescriptor handleInProgressScan(UnirestInstance unire .queryString("fields", "scanId,scanType,analysisStatusType") .asObject(JsonNode.class).getBody(); JsonNode itemsNode = response.path("items"); - if (!itemsNode.isArray() || itemsNode.isEmpty()) continue; + if (!itemsNode.isArray() || itemsNode.isEmpty()) { + // No scans exist for this release yet; nothing to handle + progressWriter.writeProgress("Status: No previous scans found"); + return null; + } boolean foundActive = false; for (JsonNode node : itemsNode) { if (!"Dynamic".equals(node.path("scanType").asText())) continue; String status = node.path("analysisStatusType").asText(); - + if (isActiveStatus(status)) { foundActive = true; FoDScanDescriptor result = handleActiveScan( @@ -107,10 +111,13 @@ public static final FoDScanDescriptor handleInProgressScan(UnirestInstance unire throw new FcliSimpleException("Unable to start Dynamic scan after " + maxAttempts + " attempts. Please check the UI and try again."); } - public static final FoDScanDescriptor startScan(UnirestInstance unirest, FoDReleaseDescriptor releaseDescriptor) { - JsonNode response = unirest.post(FoDUrls.DAST_AUTOMATED_SCANS + "/start-scan") - .routeParam("relId", releaseDescriptor.getReleaseId()) - .asObject(JsonNode.class).getBody(); + public static final FoDScanDescriptor startScan(UnirestInstance unirest, String networkName, FoDReleaseDescriptor releaseDescriptor) { + var request = unirest.post(FoDUrls.DAST_AUTOMATED_SCANS + "/start-scan") + .routeParam("relId", releaseDescriptor.getReleaseId()); + if ( networkName != null && !networkName.isBlank() ) { + request = request.queryString("networkName", networkName); + } + JsonNode response = request.asObject(JsonNode.class).getBody(); FoDStartScanResponse startScanResponse = JsonHelper.treeToValue(response, FoDStartScanResponse.class); if (startScanResponse == null || startScanResponse.getScanId() <= 0) { throw new FcliSimpleException("Unable to retrieve scan id from response when starting Dynamic scan."); @@ -151,7 +158,7 @@ private static FoDScanDescriptor handleActiveScan( Thread.sleep(waitMillis); break; case DoNotStartScan: - progressWriter.writeProgress("Status: A scan is running %s, no new scan will be started", scanId); + progressWriter.writeProgress("Status: A scan with id %s is %s, no new scan will be started", scanId, status); JsonNode scan = objectMapper.createObjectNode() .put("scanId", scanId) .put("scanType", FoDScanType.Dynamic.name()) diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/dast_scan/cli/cmd/FoDDastAutomatedScanStartCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/dast_scan/cli/cmd/FoDDastAutomatedScanStartCommand.java index c68709a4f1e..1f83fd50b51 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/dast_scan/cli/cmd/FoDDastAutomatedScanStartCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/dast_scan/cli/cmd/FoDDastAutomatedScanStartCommand.java @@ -12,8 +12,10 @@ */ package com.fortify.cli.fod.dast_scan.cli.cmd; +import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; +import com.fortify.cli.common.rest.unirest.UnexpectedHttpResponseException; import com.fortify.cli.fod._common.scan.cli.cmd.AbstractFoDScanStartCommand; import com.fortify.cli.fod._common.scan.cli.mixin.FoDInProgressScanActionTypeMixins; import com.fortify.cli.fod._common.scan.helper.FoDScanDescriptor; @@ -36,6 +38,8 @@ public class FoDDastAutomatedScanStartCommand extends AbstractFoDScanStartComman private Integer waitInterval; @Option(names="--max-attempts", descriptionKey = "fcli.fod.scan.max-attempts", defaultValue = "30", required = false) private Integer maxAttempts; + @Option(names = {"--vpn"}) + private String fodConnectNetwork; @Mixin private ProgressWriterFactoryMixin progressWriterFactory; @@ -48,13 +52,17 @@ protected FoDScanDescriptor startScan(UnirestInstance unirest, FoDReleaseDescrip try (var progressWriter = progressWriterFactory.create()) { // get current setup to ensure the scan has been configured - FoDScanDastAutomatedHelper.getSetupDescriptor(unirest, relId); + var setup = FoDScanDastAutomatedHelper.getSetupDescriptor(unirest, relId); + if (setup == null) { + throw new FcliSimpleException("DAST Automated scan is not configured for release '" + releaseDescriptor.getReleaseName() + "'. Please run one of the 'fod dast-scan setup-xxx' commands to configure the scan before starting."); + } - // check if scan is already in progress + // handle any in-progress or active scans according to the configured action FoDScanDescriptor scan = FoDScanDastAutomatedHelper.handleInProgressScan(unirest, releaseDescriptor, inProgressScanActionType.getInProgressScanActionType(), progressWriter, maxAttempts, waitInterval); + // if the action was to not start a new scan, return the in progress scan descriptor if (scan != null && scan.getAnalysisStatusType().equals("In_Progress")) { if (inProgressScanActionType.getInProgressScanActionType() == FoDEnums.InProgressScanActionType.DoNotStartScan) { scanAction = "NOT_STARTED_SCAN_IN_PROGRESS"; @@ -62,7 +70,21 @@ protected FoDScanDescriptor startScan(UnirestInstance unirest, FoDReleaseDescrip } } - return FoDScanDastAutomatedHelper.startScan(unirest, releaseDescriptor); + try { + return FoDScanDastAutomatedHelper.startScan(unirest, fodConnectNetwork, releaseDescriptor); + } catch (UnexpectedHttpResponseException e) { + // If FoD rejects start because a dynamic scan is in progress, try to fetch that scan and return it + if (e.getStatus() == 422 && (e.getMessage().contains("dynamic scan is currently in progress") || e.getMessage().contains("errorCode: -10"))) { + FoDScanDescriptor running = FoDScanDastAutomatedHelper.handleInProgressScan(unirest, releaseDescriptor, + FoDEnums.InProgressScanActionType.DoNotStartScan, progressWriter, maxAttempts, + waitInterval); + if (running != null) { + scanAction = "NOT_STARTED_SCAN_IN_PROGRESS"; + return running; + } + } + throw e; + } } } @@ -70,4 +92,5 @@ protected FoDScanDescriptor startScan(UnirestInstance unirest, FoDReleaseDescrip public final String getActionCommandResult() { return scanAction; } + } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceCreateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceCreateCommand.java index 6ce2013820e..0d6dc640b5b 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceCreateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceCreateCommand.java @@ -14,6 +14,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fortify.cli.common.cli.mixin.CommonOptionMixins; +import com.fortify.cli.common.exception.FcliSimpleException; 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; @@ -46,12 +47,16 @@ public class FoDMicroserviceCreateCommand extends AbstractFoDJsonNodeOutputComma @Override public JsonNode getJsonNode(UnirestInstance unirest) { + FoDAppDescriptor appDescriptor = qualifiedMicroserviceNameResolver.getAppDescriptor(unirest, true); + FoDQualifiedMicroserviceNameDescriptor qualifiedMicroserviceNameDescriptor = qualifiedMicroserviceNameResolver.getQualifiedMicroserviceNameDescriptor(); + // throw an exception if the application is not microservice-enabled + if (!appDescriptor.isHasMicroservices()) { + throw new FcliSimpleException("Cannot create microservice for non-microservice application "+appDescriptor.getApplicationName()); + } if (skipIfExistsOption.isSkipIfExists()) { FoDMicroserviceDescriptor descriptor = qualifiedMicroserviceNameResolver.getMicroserviceDescriptor(unirest, false); if (descriptor != null) { return descriptor.asObjectNode().put("__action__", "SKIPPED_EXISTING"); } } - FoDAppDescriptor appDescriptor = qualifiedMicroserviceNameResolver.getAppDescriptor(unirest, true); - FoDQualifiedMicroserviceNameDescriptor qualifiedMicroserviceNameDescriptor = qualifiedMicroserviceNameResolver.getQualifiedMicroserviceNameDescriptor(); FoDMicroserviceUpdateRequest msCreateRequest = FoDMicroserviceUpdateRequest.builder() .microserviceName(qualifiedMicroserviceNameDescriptor.getMicroserviceName()) .attributes(FoDAttributeHelper.getAttributesNode(unirest, FoDEnums.AttributeTypes.Microservice, 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 107e94e051f..9ac34fc4906 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 @@ -613,6 +613,7 @@ fcli.fod.dast-scan.start.usage.description.0 = This command is intended for DAST fcli.fod.dast-scan.start.usage.description.1 = The scan will need to have been previously setup using the FoD UI or one of the \ 'fod dast-scan setup-xxx' commands. fcli.fod.dast-scan.start.validate-entitlement = Validate if an entitlement has been set and is still valid. +fcli.fod.dast-scan.start.vpn = Fortify Connect network name to use for site-to-site VPN. fcli.fod.dast-scan.start-legacy.usage.header = (LEGACY) Start a new DAST scan. fcli.fod.dast-scan.start-legacy.usage.description.0 = This command is not fully implemented and is intended for legacy DAST scanning (not DAST Automated). \ It can only be used for starting a configured Dynamic scan and does not support file uploads (i.e. API definitions \ From dcea406899f02ce15b7fc1fd1c554fc2f66f3db1 Mon Sep 17 00:00:00 2001 From: Ankit Rathod Date: Fri, 6 Mar 2026 18:00:59 +0530 Subject: [PATCH 4/5] Addressed Review comments Updated the output format of audit command to be consistent and extensible. Made --default-quota-fallback unhidden. Refactored getJsonNode method in Aviatorsscauditcommand class to be shorter and readable by splitting. --- .../ssc/cli/cmd/AviatorSSCAuditCommand.java | 250 +++++++++++------- .../ssc/helper/AviatorSSCAuditHelper.java | 182 +++++++++---- .../cli/ssc/actions/zip/bulkaudit.yaml | 6 +- 3 files changed, 287 insertions(+), 151 deletions(-) diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommand.java index 571ef0b85f8..a8f668050b2 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCAuditCommand.java @@ -24,6 +24,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fortify.cli.aviator._common.session.user.cli.mixin.AviatorUserSessionDescriptorSupplier; +import com.fortify.cli.aviator._common.session.user.helper.AviatorUserSessionDescriptor; import com.fortify.cli.aviator.audit.AuditFPR; import com.fortify.cli.aviator.audit.model.AuditFprOptions; import com.fortify.cli.aviator.audit.model.FPRAuditResult; @@ -32,7 +33,6 @@ import com.fortify.cli.aviator.util.FprHandle; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; -import com.fortify.cli.common.output.transform.IRecordTransformer; import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; import com.fortify.cli.common.progress.helper.IProgressWriter; import com.fortify.cli.common.rest.unirest.UnexpectedHttpResponseException; @@ -58,8 +58,8 @@ @Command(name = "audit") @DefaultVariablePropertyName("artifactId") -public class AviatorSSCAuditCommand extends AbstractSSCJsonNodeOutputCommand implements IRecordTransformer, IActionCommandResultSupplier { - @Getter @Mixin private OutputHelperMixins.TableNoQuery outputHelper; +public class AviatorSSCAuditCommand extends AbstractSSCJsonNodeOutputCommand implements IActionCommandResultSupplier { + @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; @Mixin private SSCAppVersionResolverMixin.RequiredOption appVersionResolver; @Mixin private AviatorUserSessionDescriptorSupplier sessionDescriptorSupplier; @@ -71,12 +71,13 @@ public class AviatorSSCAuditCommand extends AbstractSSCJsonNodeOutputCommand imp @Option(names = {"--folder"}, split = ",") @DisableTest(DisableTest.TestType.MULTI_OPT_PLURAL_NAME) private List folderNames; @Option(names = {"--skip-if-exceeding-quota"}) private boolean skipIfExceedingQuota; @Option(names = {"--test-exceeding-quota"}) private boolean testExceedingQuota; - @Option(names = {"--default-quota-fallback"}, hidden = true) private boolean defaultQuotaFallback; + @Option(names = {"--default-quota-fallback"}) private boolean defaultQuotaFallback; @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) private List folderPriorityOrder; private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCAuditCommand.class); + private Long checkedQuotaBefore; @Override @SneakyThrows @@ -87,87 +88,33 @@ public JsonNode getJsonNode(UnirestInstance unirest) { AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); SSCAppVersionDescriptor av = appVersionResolver.getAppVersionDescriptor(unirest); - if (refreshOptions.isRefresh() && av.isRefreshRequired()) { - logger.progress("Status: Metrics for application version %s:%s are out of date, starting refresh...", av.getApplicationName(), av.getVersionName()); - SSCJobDescriptor refreshJobDesc = SSCAppVersionHelper.refreshMetrics(unirest, av); - if (refreshJobDesc != null) { - SSCJobHelper.waitForJob(unirest, refreshJobDesc, refreshOptions.getRefreshTimeout()); - logger.progress("Status: Metrics refreshed successfully."); - } - } + refreshMetricsIfNeeded(unirest, av, logger); long auditableIssueCount = AviatorSSCAuditHelper.getAuditableIssueCount(unirest, av, logger, noFilterSet, filterSetOptions, folderNames); if (auditableIssueCount == 0) { logger.progress("Audit skipped - no auditable issues found matching the specified filters."); - return AviatorSSCAuditHelper.buildResultNode(av, "N/A", "SKIPPED (no auditable issues)"); + ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "SKIPPED"); + AviatorSSCAuditHelper.setOperationMessage(result, "No auditable issues found matching the specified filters"); + return result; } - // Quota check: only when --skip-if-exceeding-quota or --test-exceeding-quota is active - 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 + JsonNode quotaResult = checkQuota(unirest, av, sessionDescriptor, auditableIssueCount, logger); + if (quotaResult != null) { + return quotaResult; } downloadedFprPath = downloadFpr(unirest, av, logger); if (downloadedFprPath == null) { - return AviatorSSCAuditHelper.buildResultNode(av, "N/A", "SKIPPED (no FPR available to audit)"); + ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "SKIPPED"); + AviatorSSCAuditHelper.setOperationMessage(result, "No FPR available to audit"); + return result; } - return processFpr(unirest, av, sessionDescriptor.getAviatorToken(), sessionDescriptor.getAviatorUrl(), logger, downloadedFprPath); + ObjectNode result = (ObjectNode) processFpr(unirest, av, sessionDescriptor.getAviatorToken(), sessionDescriptor.getAviatorUrl(), logger, downloadedFprPath); + if (checkedQuotaBefore != null) { + AviatorSSCAuditHelper.setAvailableQuotaBefore(result, checkedQuotaBefore); + } + return result; } finally { if (downloadedFprPath != null) { Files.deleteIfExists(downloadedFprPath); @@ -175,6 +122,117 @@ public JsonNode getJsonNode(UnirestInstance unirest) { } } + private void refreshMetricsIfNeeded(UnirestInstance unirest, SSCAppVersionDescriptor av, AviatorLoggerImpl logger) { + if (refreshOptions.isRefresh() && av.isRefreshRequired()) { + logger.progress("Status: Metrics for application version %s:%s are out of date, starting refresh...", av.getApplicationName(), av.getVersionName()); + SSCJobDescriptor refreshJobDesc = SSCAppVersionHelper.refreshMetrics(unirest, av); + if (refreshJobDesc != null) { + SSCJobHelper.waitForJob(unirest, refreshJobDesc, refreshOptions.getRefreshTimeout()); + logger.progress("Status: Metrics refreshed successfully."); + } + } + } + + /** + * Checks quota constraints when --skip-if-exceeding-quota or --test-exceeding-quota is active. + * @return a result JsonNode if the audit should be skipped/reported, or null if the audit should proceed. + */ + private JsonNode checkQuota(UnirestInstance unirest, SSCAppVersionDescriptor av, + AviatorUserSessionDescriptor sessionDescriptor, + long auditableIssueCount, AviatorLoggerImpl logger) { + if (!skipIfExceedingQuota && !testExceedingQuota) { + return null; + } + + 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) { + availableQuota = handleAppNotFound(sessionDescriptor, effectiveAppName, logger); + if (availableQuota == AviatorSSCAuditHelper.QUOTA_APP_NOT_FOUND) { + ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "SKIPPED"); + AviatorSSCAuditHelper.setOperationMessage(result, "Application '" + effectiveAppName + "' not found in Aviator"); + return result; + } + } + + // If auditable issue count is unknown (-1), skip quota comparison and proceed with audit + if (auditableIssueCount < 0) { + LOG.info("Auditable issue count unknown; skipping quota evaluation for {}:{}.", + av.getApplicationName(), av.getVersionName()); + return null; + } + + return evaluateQuota(unirest, av, effectiveAppName, auditableIssueCount, availableQuota, logger); + } + + /** + * Handles the case where the application is not found in Aviator. + * @return the resolved quota (possibly from default), or QUOTA_APP_NOT_FOUND if audit should be skipped. + */ + private long handleAppNotFound(AviatorUserSessionDescriptor sessionDescriptor, + String effectiveAppName, AviatorLoggerImpl logger) { + if (defaultQuotaFallback) { + logger.progress("Application '%s' not found, using default quota for new applications.", effectiveAppName); + long defaultQuota = AviatorSSCAuditHelper.getDefaultQuota( + sessionDescriptor.getAviatorUrl(), sessionDescriptor.getAviatorToken(), logger); + if (defaultQuota == AviatorSSCAuditHelper.QUOTA_UNKNOWN) { + if (testExceedingQuota) { + // Caller will need to handle this — we return QUOTA_UNKNOWN to signal + return AviatorSSCAuditHelper.QUOTA_UNKNOWN; + } + logger.progress("Warning: Could not retrieve default quota, proceeding with audit."); + return AviatorSSCAuditHelper.QUOTA_UNKNOWN; + } + return defaultQuota; + } else { + logger.progress("Application '%s' does not exist in Aviator.", effectiveAppName); + return AviatorSSCAuditHelper.QUOTA_APP_NOT_FOUND; + } + } + + /** + * Evaluates the resolved quota against the auditable issue count and returns + * a result node if audit should be skipped, or null to proceed with the audit. + */ + private JsonNode evaluateQuota(UnirestInstance unirest, SSCAppVersionDescriptor av, + String effectiveAppName, long auditableIssueCount, long availableQuota, + AviatorLoggerImpl logger) { + if (availableQuota == AviatorSSCAuditHelper.QUOTA_UNKNOWN) { + if (testExceedingQuota) { + ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "QUOTA_UNKNOWN"); + AviatorSSCAuditHelper.setOperationMessage(result, "Could not retrieve quota for application '" + effectiveAppName + "'"); + return result; + } + logger.progress("Warning: Could not retrieve quota for '%s', proceeding with audit.", effectiveAppName); + } else if (availableQuota >= 0 && auditableIssueCount > availableQuota) { + checkedQuotaBefore = availableQuota; + 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) { + 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)); + ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "QUOTA_OK"); + AviatorSSCAuditHelper.setOperationMessage(result, String.format("Quota check passed: %d issues, %s quota available", + auditableIssueCount, availableQuota < 0 ? "unlimited" : String.valueOf(availableQuota))); + AviatorSSCAuditHelper.setAvailableQuotaBefore(result, availableQuota); + return result; + } + // Quota was checked and audit is proceeding — capture the value for the final output + checkedQuotaBefore = availableQuota >= 0 ? availableQuota : null; + return null; + } + @SneakyThrows private JsonNode processFpr(UnirestInstance unirest, SSCAppVersionDescriptor av, String token, String url, AviatorLoggerImpl logger, Path downloadedFprPath) { FPRAuditResult auditResult; @@ -192,17 +250,29 @@ private JsonNode processFpr(UnirestInstance unirest, SSCAppVersionDescriptor av, .folderNames(folderNames) .folderPriorityOrder(folderPriorityOrder) .build()); + } catch (Exception e) { + LOG.error("FPR audit failed for {}:{}: {}", av.getApplicationName(), av.getVersionName(), e.getMessage(), e); + ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, null, "FAILED"); + AviatorSSCAuditHelper.setOperationMessage(result, "Audit failed: " + e.getMessage()); + return result; } - String action = AviatorSSCAuditHelper.getDetailedAction(auditResult); + String action = auditResult.getStatus(); logger.progress(AviatorSSCAuditHelper.getProgressMessage(auditResult)); - String artifactId = "UPLOAD_SKIPPED"; - if (auditResult.getUpdatedFile() != null && !"SKIPPED".equals(auditResult.getStatus()) && !"FAILED".equals(auditResult.getStatus())) { - artifactId = uploadAuditedFprToSSC(unirest, auditResult.getUpdatedFile(), av); + String artifactId = null; + if (auditResult.getUpdatedFile() != null && !"SKIPPED".equals(action) && !"FAILED".equals(action)) { + try { + artifactId = uploadAuditedFprToSSC(unirest, auditResult.getUpdatedFile(), av); + } catch (Exception e) { + LOG.error("Failed to upload audited FPR for {}:{}: {}", av.getApplicationName(), av.getVersionName(), e.getMessage(), e); + logger.progress("WARN: Upload of audited FPR to SSC failed: %s", e.getMessage()); + } } - return AviatorSSCAuditHelper.buildResultNode(av, artifactId, action); + ObjectNode result = AviatorSSCAuditHelper.buildResultNode(av, artifactId, action); + AviatorSSCAuditHelper.setAuditStats(result, auditResult); + return result; } private Path downloadFpr(UnirestInstance unirest, SSCAppVersionDescriptor av, AviatorLoggerImpl logger) throws IOException { @@ -238,21 +308,6 @@ private String uploadAuditedFprToSSC(UnirestInstance unirest, File auditedFpr, S } } - @Override - public JsonNode transformRecord(JsonNode record) { - ObjectNode transformed = record.deepCopy(); - if (transformed.has("versionId")) { - transformed.set("Id", transformed.remove("versionId")); - } - if (transformed.has("applicationName")) { - transformed.set("Application name", transformed.remove("applicationName")); - } - if (transformed.has("versionName")) { - transformed.set("Name", transformed.remove("versionName")); - } - return SSCAppVersionHelper.renameFields(transformed); - } - @Override public String getActionCommandResult() { return "AUDITED"; @@ -262,13 +317,4 @@ public String getActionCommandResult() { public boolean isSingular() { return true; } - - private List resolvePriorityOrder() { - if (folderPriorityOrder != null && !folderPriorityOrder.isEmpty()) { - return folderPriorityOrder; - } - - - return null; - } } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java index 9d4cfc52c5d..93fe7278e24 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/helper/AviatorSSCAuditHelper.java @@ -25,6 +25,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.aviator.application.Application; import com.fortify.cli.aviator._common.exception.AviatorSimpleException; import com.fortify.cli.aviator.audit.model.FPRAuditResult; import com.fortify.cli.aviator.config.AviatorLoggerImpl; @@ -58,44 +59,113 @@ private AviatorSSCAuditHelper() {} /** * Builds the final JSON result node for the command output. + * All fields are always present to ensure a stable, predictable output structure. + * Fields not applicable to the current action are set to null. + * + *

Structure: + *

+     * id, applicationName, versionName, artifactId, __action__
+     * operation:
+     *   audit: { message, submitted, succeeded, skipped }
+     * state:
+     *   aviator: { availableQuotaBefore, availableQuotaAfter }
+     *   ssc: { issuesByCategory }
+     * 
+ * * @param av The SSCAppVersionDescriptor. - * @param artifactId The ID of the uploaded artifact, or a status string. + * @param artifactId The ID of the uploaded artifact, or null if not applicable. * @param action The final action string for the output. * @return An ObjectNode representing the result. */ public static ObjectNode buildResultNode(SSCAppVersionDescriptor av, String artifactId, String action) { - ObjectNode result = av.asObjectNode(); + ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); result.put("id", av.getVersionId()); result.put("applicationName", av.getApplicationName()); - result.put("name", av.getVersionName()); - result.put("artifactId", artifactId); + result.put("versionName", av.getVersionName()); + if (artifactId != null) { + result.put("artifactId", artifactId); + } else { + result.putNull("artifactId"); + } result.put(IActionCommandResultSupplier.actionFieldName, action); + + // operation.audit — null by default, populated by setAuditStats or setOperationMessage + ObjectNode operation = JsonHelper.getObjectMapper().createObjectNode(); + operation.putNull("audit"); + result.set("operation", operation); + + // state.aviator and state.ssc — null sub-objects by default + ObjectNode state = JsonHelper.getObjectMapper().createObjectNode(); + ObjectNode aviatorState = JsonHelper.getObjectMapper().createObjectNode(); + aviatorState.putNull("availableQuotaBefore"); + aviatorState.putNull("availableQuotaAfter"); + state.set("aviator", aviatorState); + ObjectNode sscState = JsonHelper.getObjectMapper().createObjectNode(); + sscState.putNull("issuesByCategory"); + state.set("ssc", sscState); + result.set("state", state); + return result; } /** - * Generates a detailed action string based on the FPRAuditResult. - * This is used for the 'action' column in the output. + * Populates the {@code operation.audit} object on the result node with stats from the FPR audit. + * Sets message, submitted, succeeded, and skipped fields. + * + * @param result The result node to update. * @param auditResult The result from the Aviator audit. - * @return A descriptive string of the outcome. */ - public static String getDetailedAction(FPRAuditResult auditResult) { + public static void setAuditStats(ObjectNode result, FPRAuditResult auditResult) { + ObjectNode audit = JsonHelper.getObjectMapper().createObjectNode(); + String message; switch (auditResult.getStatus()) { + case "AUDITED": + message = "Audit completed successfully"; + break; + case "PARTIALLY_AUDITED": + message = auditResult.getMessage() != null ? auditResult.getMessage() : "Audit partially completed"; + break; case "SKIPPED": - String reason = auditResult.getMessage() != null ? auditResult.getMessage() : "Unknown reason"; - return "SKIPPED (" + reason + ")"; + message = auditResult.getMessage() != null ? auditResult.getMessage() : "No issues to audit"; + break; case "FAILED": - String message = auditResult.getMessage() != null ? auditResult.getMessage() : "Unknown error"; - return "FAILED (" + message + ")"; - case "PARTIALLY_AUDITED": - return String.format("PARTIALLY_AUDITED (%d/%d audited)", - auditResult.getIssuesSuccessfullyAudited(), - auditResult.getTotalIssuesToAudit()); - case "AUDITED": - return "AUDITED"; + message = auditResult.getMessage() != null ? auditResult.getMessage() : "Audit failed"; + break; default: - return "UNKNOWN"; + message = auditResult.getMessage() != null ? auditResult.getMessage() : "Unknown audit status"; + break; } + audit.put("message", message); + audit.put("submitted", auditResult.getTotalIssuesToAudit()); + audit.put("succeeded", auditResult.getIssuesSuccessfullyAudited()); + audit.put("skipped", Math.max(0, auditResult.getTotalIssuesToAudit() - auditResult.getIssuesSuccessfullyAudited())); + ((ObjectNode) result.get("operation")).set("audit", audit); + } + + /** + * Sets only the {@code operation.audit.message} field without audit stats. + * Used for code paths that don't perform an actual audit (SKIPPED, FAILED, QUOTA_EXCEEDED, etc.). + * + * @param result The result node to update. + * @param message The descriptive message. + */ + public static void setOperationMessage(ObjectNode result, String message) { + ObjectNode audit = JsonHelper.getObjectMapper().createObjectNode(); + audit.put("message", message); + audit.putNull("submitted"); + audit.putNull("succeeded"); + audit.putNull("skipped"); + ((ObjectNode) result.get("operation")).set("audit", audit); + } + + /** + * Sets the {@code state.aviator.availableQuotaBefore} field. + * + * @param result The result node to update. + * @param quota The quota value. + */ + public static void setAvailableQuotaBefore(ObjectNode result, long quota) { + ((ObjectNode) result.path("state").path("aviator")).put("availableQuotaBefore", quota); } /** @@ -258,7 +328,7 @@ public static long getAvailableQuota(String aviatorUrl, String aviatorToken, Str AviatorLoggerImpl logger) { try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(aviatorUrl, logger, Constants.DEFAULT_PING_INTERVAL_SECONDS)) { - com.fortify.aviator.application.Application app = client.getApplicationByToken(aviatorToken, appName); + 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)); @@ -308,6 +378,20 @@ public static List> getTopUnauditedCategories( UnirestInstance unirest, SSCAppVersionDescriptor av, AviatorLoggerImpl logger, int topN) { + try { + return getTopUnauditedCategoriesInternal(unirest, av, logger, topN); + } catch (Exception e) { + LOG.warn("Failed to retrieve top unaudited categories for {}:{}: {}", + av.getApplicationName(), av.getVersionName(), e.getMessage()); + logger.progress("WARN: Could not retrieve category breakdown from SSC."); + return List.of(); + } + } + + private static List> getTopUnauditedCategoriesInternal( + UnirestInstance unirest, SSCAppVersionDescriptor av, + AviatorLoggerImpl logger, int topN) { + String versionId = av.getVersionId(); // Resolve "Category" grouping GUID dynamically @@ -349,6 +433,8 @@ public static List> getTopUnauditedCategories( if (unaudited > 0) { Map entry = new LinkedHashMap<>(); entry.put("categoryName", name); + entry.put("totalIssues", visibleCount); + entry.put("auditedIssues", auditedCount); entry.put("unauditedCount", unaudited); categories.add(entry); } @@ -366,34 +452,35 @@ public static List> getTopUnauditedCategories( /** * Builds the JSON result node when a version is skipped due to exceeding quota. + * Sets {@code state.aviator.availableQuotaBefore} and populates {@code state.ssc.issuesByCategory} + * with total, audited, and unaudited counts per category. */ public static ObjectNode buildQuotaExceededResultNode( SSCAppVersionDescriptor av, long openIssueCount, long availableQuota, List> 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 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); + ObjectNode result = buildResultNode(av, null, "QUOTA_EXCEEDED"); + setOperationMessage(result, String.format("Open issues (%d) exceed available quota (%d)", openIssueCount, availableQuota)); + setAvailableQuotaBefore(result, availableQuota); + ((ObjectNode) result.path("state").path("ssc")).set("issuesByCategory", buildIssuesByCategoryArray(topCategories)); + return result; + } - ArrayNode categoriesArray = JsonHelper.getObjectMapper().createArrayNode(); - for (Map cat : topCategories) { - ObjectNode catNode = JsonHelper.getObjectMapper().createObjectNode(); - catNode.put("category", (String) cat.get("categoryName")); - catNode.put("unauditedCount", (long) cat.get("unauditedCount")); - categoriesArray.add(catNode); + /** + * Converts the category list into a JSON array for the {@code issuesByCategory} field. + */ + private static ArrayNode buildIssuesByCategoryArray(List> categories) { + ArrayNode array = JsonHelper.getObjectMapper().createArrayNode(); + if (categories != null) { + for (Map cat : categories) { + ObjectNode catNode = JsonHelper.getObjectMapper().createObjectNode(); + catNode.put("category", (String) cat.get("categoryName")); + catNode.put("total", (long) cat.get("totalIssues")); + catNode.put("audited", (long) cat.get("auditedIssues")); + catNode.put("unaudited", (long) cat.get("unauditedCount")); + array.add(catNode); + } } - result.set("topCategories", categoriesArray); - return result; + return array; } /** @@ -406,10 +493,13 @@ public static String formatQuotaExceededMessage( 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 cat : topCategories) { - sb.append(String.format(" %d. %s (%d issues)%n", - rank++, cat.get("categoryName"), cat.get("unauditedCount"))); + if (topCategories != null) { + int rank = 1; + for (Map cat : topCategories) { + sb.append(String.format(" %d. %s (total: %d, audited: %d, unaudited: %d)%n", + rank++, cat.get("categoryName"), cat.get("totalIssues"), + cat.get("auditedIssues"), cat.get("unauditedCount"))); + } } return sb.toString(); } diff --git a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/bulkaudit.yaml b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/bulkaudit.yaml index 677b8c2645e..16d14c197ba 100644 --- a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/bulkaudit.yaml +++ b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/bulkaudit.yaml @@ -304,7 +304,7 @@ steps: records.collect: true stdout: show - - if: ${run_audit.exitCode == 0 && run_audit.records != null && run_audit.records.size() > 0 && run_audit.records[0].__action__ != null && run_audit.records[0].__action__.contains('QUOTA EXCEEDED')} + - if: ${run_audit.exitCode == 0 && run_audit.records != null && run_audit.records.size() > 0 && run_audit.records[0].__action__ != null && run_audit.records[0].__action__ == 'QUOTA_EXCEEDED'} var.set: stats.quota_skipped: ${stats.quota_skipped + 1} @@ -420,8 +420,8 @@ steps: records.collect: true stdout: show - # Track quota-skipped results (__action__ field contains QUOTA EXCEEDED) - - if: ${run_audit.exitCode == 0 && run_audit.records != null && run_audit.records.size() > 0 && run_audit.records[0].__action__ != null && run_audit.records[0].__action__.contains('QUOTA EXCEEDED')} + # Track quota-skipped results + - if: ${run_audit.exitCode == 0 && run_audit.records != null && run_audit.records.size() > 0 && run_audit.records[0].__action__ != null && run_audit.records[0].__action__ == 'QUOTA_EXCEEDED'} var.set: stats.quota_skipped: ${stats.quota_skipped + 1} From 62870dcd70bbfd2e71e06b83c3c47512b906d9e8 Mon Sep 17 00:00:00 2001 From: Ankit Rathod Date: Fri, 6 Mar 2026 19:03:14 +0530 Subject: [PATCH 5/5] Revert "chore: DAST Automated fixes and missing option. (#937)" This reverts commit 0d66c01880fd3cd8b851326223ad6b376f383ee9. --- .../dast/FoDScanDastAutomatedHelper.java | 21 +++++--------- .../cmd/FoDDastAutomatedScanStartCommand.java | 29 ++----------------- .../cli/cmd/FoDMicroserviceCreateCommand.java | 9 ++---- .../cli/fod/i18n/FoDMessages.properties | 1 - 4 files changed, 12 insertions(+), 48 deletions(-) diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/scan/helper/dast/FoDScanDastAutomatedHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/scan/helper/dast/FoDScanDastAutomatedHelper.java index 4fec80bfe0d..d00ecde7581 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/scan/helper/dast/FoDScanDastAutomatedHelper.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/scan/helper/dast/FoDScanDastAutomatedHelper.java @@ -80,17 +80,13 @@ public static final FoDScanDescriptor handleInProgressScan(UnirestInstance unire .queryString("fields", "scanId,scanType,analysisStatusType") .asObject(JsonNode.class).getBody(); JsonNode itemsNode = response.path("items"); - if (!itemsNode.isArray() || itemsNode.isEmpty()) { - // No scans exist for this release yet; nothing to handle - progressWriter.writeProgress("Status: No previous scans found"); - return null; - } + if (!itemsNode.isArray() || itemsNode.isEmpty()) continue; boolean foundActive = false; for (JsonNode node : itemsNode) { if (!"Dynamic".equals(node.path("scanType").asText())) continue; String status = node.path("analysisStatusType").asText(); - + if (isActiveStatus(status)) { foundActive = true; FoDScanDescriptor result = handleActiveScan( @@ -111,13 +107,10 @@ public static final FoDScanDescriptor handleInProgressScan(UnirestInstance unire throw new FcliSimpleException("Unable to start Dynamic scan after " + maxAttempts + " attempts. Please check the UI and try again."); } - public static final FoDScanDescriptor startScan(UnirestInstance unirest, String networkName, FoDReleaseDescriptor releaseDescriptor) { - var request = unirest.post(FoDUrls.DAST_AUTOMATED_SCANS + "/start-scan") - .routeParam("relId", releaseDescriptor.getReleaseId()); - if ( networkName != null && !networkName.isBlank() ) { - request = request.queryString("networkName", networkName); - } - JsonNode response = request.asObject(JsonNode.class).getBody(); + public static final FoDScanDescriptor startScan(UnirestInstance unirest, FoDReleaseDescriptor releaseDescriptor) { + JsonNode response = unirest.post(FoDUrls.DAST_AUTOMATED_SCANS + "/start-scan") + .routeParam("relId", releaseDescriptor.getReleaseId()) + .asObject(JsonNode.class).getBody(); FoDStartScanResponse startScanResponse = JsonHelper.treeToValue(response, FoDStartScanResponse.class); if (startScanResponse == null || startScanResponse.getScanId() <= 0) { throw new FcliSimpleException("Unable to retrieve scan id from response when starting Dynamic scan."); @@ -158,7 +151,7 @@ private static FoDScanDescriptor handleActiveScan( Thread.sleep(waitMillis); break; case DoNotStartScan: - progressWriter.writeProgress("Status: A scan with id %s is %s, no new scan will be started", scanId, status); + progressWriter.writeProgress("Status: A scan is running %s, no new scan will be started", scanId); JsonNode scan = objectMapper.createObjectNode() .put("scanId", scanId) .put("scanType", FoDScanType.Dynamic.name()) diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/dast_scan/cli/cmd/FoDDastAutomatedScanStartCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/dast_scan/cli/cmd/FoDDastAutomatedScanStartCommand.java index 1f83fd50b51..c68709a4f1e 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/dast_scan/cli/cmd/FoDDastAutomatedScanStartCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/dast_scan/cli/cmd/FoDDastAutomatedScanStartCommand.java @@ -12,10 +12,8 @@ */ package com.fortify.cli.fod.dast_scan.cli.cmd; -import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; -import com.fortify.cli.common.rest.unirest.UnexpectedHttpResponseException; import com.fortify.cli.fod._common.scan.cli.cmd.AbstractFoDScanStartCommand; import com.fortify.cli.fod._common.scan.cli.mixin.FoDInProgressScanActionTypeMixins; import com.fortify.cli.fod._common.scan.helper.FoDScanDescriptor; @@ -38,8 +36,6 @@ public class FoDDastAutomatedScanStartCommand extends AbstractFoDScanStartComman private Integer waitInterval; @Option(names="--max-attempts", descriptionKey = "fcli.fod.scan.max-attempts", defaultValue = "30", required = false) private Integer maxAttempts; - @Option(names = {"--vpn"}) - private String fodConnectNetwork; @Mixin private ProgressWriterFactoryMixin progressWriterFactory; @@ -52,17 +48,13 @@ protected FoDScanDescriptor startScan(UnirestInstance unirest, FoDReleaseDescrip try (var progressWriter = progressWriterFactory.create()) { // get current setup to ensure the scan has been configured - var setup = FoDScanDastAutomatedHelper.getSetupDescriptor(unirest, relId); - if (setup == null) { - throw new FcliSimpleException("DAST Automated scan is not configured for release '" + releaseDescriptor.getReleaseName() + "'. Please run one of the 'fod dast-scan setup-xxx' commands to configure the scan before starting."); - } + FoDScanDastAutomatedHelper.getSetupDescriptor(unirest, relId); - // handle any in-progress or active scans according to the configured action + // check if scan is already in progress FoDScanDescriptor scan = FoDScanDastAutomatedHelper.handleInProgressScan(unirest, releaseDescriptor, inProgressScanActionType.getInProgressScanActionType(), progressWriter, maxAttempts, waitInterval); - // if the action was to not start a new scan, return the in progress scan descriptor if (scan != null && scan.getAnalysisStatusType().equals("In_Progress")) { if (inProgressScanActionType.getInProgressScanActionType() == FoDEnums.InProgressScanActionType.DoNotStartScan) { scanAction = "NOT_STARTED_SCAN_IN_PROGRESS"; @@ -70,21 +62,7 @@ protected FoDScanDescriptor startScan(UnirestInstance unirest, FoDReleaseDescrip } } - try { - return FoDScanDastAutomatedHelper.startScan(unirest, fodConnectNetwork, releaseDescriptor); - } catch (UnexpectedHttpResponseException e) { - // If FoD rejects start because a dynamic scan is in progress, try to fetch that scan and return it - if (e.getStatus() == 422 && (e.getMessage().contains("dynamic scan is currently in progress") || e.getMessage().contains("errorCode: -10"))) { - FoDScanDescriptor running = FoDScanDastAutomatedHelper.handleInProgressScan(unirest, releaseDescriptor, - FoDEnums.InProgressScanActionType.DoNotStartScan, progressWriter, maxAttempts, - waitInterval); - if (running != null) { - scanAction = "NOT_STARTED_SCAN_IN_PROGRESS"; - return running; - } - } - throw e; - } + return FoDScanDastAutomatedHelper.startScan(unirest, releaseDescriptor); } } @@ -92,5 +70,4 @@ protected FoDScanDescriptor startScan(UnirestInstance unirest, FoDReleaseDescrip public final String getActionCommandResult() { return scanAction; } - } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceCreateCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceCreateCommand.java index 0d6dc640b5b..6ce2013820e 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceCreateCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/microservice/cli/cmd/FoDMicroserviceCreateCommand.java @@ -14,7 +14,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fortify.cli.common.cli.mixin.CommonOptionMixins; -import com.fortify.cli.common.exception.FcliSimpleException; 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; @@ -47,16 +46,12 @@ public class FoDMicroserviceCreateCommand extends AbstractFoDJsonNodeOutputComma @Override public JsonNode getJsonNode(UnirestInstance unirest) { - FoDAppDescriptor appDescriptor = qualifiedMicroserviceNameResolver.getAppDescriptor(unirest, true); - FoDQualifiedMicroserviceNameDescriptor qualifiedMicroserviceNameDescriptor = qualifiedMicroserviceNameResolver.getQualifiedMicroserviceNameDescriptor(); - // throw an exception if the application is not microservice-enabled - if (!appDescriptor.isHasMicroservices()) { - throw new FcliSimpleException("Cannot create microservice for non-microservice application "+appDescriptor.getApplicationName()); - } if (skipIfExistsOption.isSkipIfExists()) { FoDMicroserviceDescriptor descriptor = qualifiedMicroserviceNameResolver.getMicroserviceDescriptor(unirest, false); if (descriptor != null) { return descriptor.asObjectNode().put("__action__", "SKIPPED_EXISTING"); } } + FoDAppDescriptor appDescriptor = qualifiedMicroserviceNameResolver.getAppDescriptor(unirest, true); + FoDQualifiedMicroserviceNameDescriptor qualifiedMicroserviceNameDescriptor = qualifiedMicroserviceNameResolver.getQualifiedMicroserviceNameDescriptor(); FoDMicroserviceUpdateRequest msCreateRequest = FoDMicroserviceUpdateRequest.builder() .microserviceName(qualifiedMicroserviceNameDescriptor.getMicroserviceName()) .attributes(FoDAttributeHelper.getAttributesNode(unirest, FoDEnums.AttributeTypes.Microservice, 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 9ac34fc4906..107e94e051f 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 @@ -613,7 +613,6 @@ fcli.fod.dast-scan.start.usage.description.0 = This command is intended for DAST fcli.fod.dast-scan.start.usage.description.1 = The scan will need to have been previously setup using the FoD UI or one of the \ 'fod dast-scan setup-xxx' commands. fcli.fod.dast-scan.start.validate-entitlement = Validate if an entitlement has been set and is still valid. -fcli.fod.dast-scan.start.vpn = Fortify Connect network name to use for site-to-site VPN. fcli.fod.dast-scan.start-legacy.usage.header = (LEGACY) Start a new DAST scan. fcli.fod.dast-scan.start-legacy.usage.description.0 = This command is not fully implemented and is intended for legacy DAST scanning (not DAST Automated). \ It can only be used for starting a configured Dynamic scan and does not support file uploads (i.e. API definitions \