Skip to content
Merged
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
18 changes: 18 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,29 @@ message ApplicationResponseMessage {
string responseMessage = 1;
}

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

message GetDefaultQuotaRequest {
optional string token = 1;
optional string tenant_name = 2;
optional string signature = 3;
optional string message = 4;
}

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 @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -69,11 +69,15 @@ 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"}) 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<String> folderPriorityOrder;
private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCAuditCommand.class);
private Long checkedQuotaBefore;

@Override
@SneakyThrows
Expand All @@ -84,34 +88,151 @@ 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;
}

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);
}
}
}

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;
Expand All @@ -129,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 {
Expand Down Expand Up @@ -175,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";
Expand All @@ -199,13 +317,4 @@ public String getActionCommandResult() {
public boolean isSingular() {
return true;
}

private List<String> resolvePriorityOrder() {
if (folderPriorityOrder != null && !folderPriorityOrder.isEmpty()) {
return folderPriorityOrder;
}


return null;
}
}
Loading