diff --git a/apps/api/src/cloud-security/aws-scan-mode.service.ts b/apps/api/src/cloud-security/aws-scan-mode.service.ts
new file mode 100644
index 000000000..d8811eead
--- /dev/null
+++ b/apps/api/src/cloud-security/aws-scan-mode.service.ts
@@ -0,0 +1,104 @@
+import {
+ BadRequestException,
+ ForbiddenException,
+ Injectable,
+ Logger,
+} from '@nestjs/common';
+import { db, Prisma } from '@db';
+import {
+ type AwsScanMode,
+ resolveAwsScanMode,
+} from './aws-scan-mode';
+import { logCloudSecurityActivity } from './cloud-security-audit';
+
+/**
+ * Manages the AWS scan-engine choice on a connection. Lives separately
+ * from CloudSecurityService so the scan-mode concern has one obvious
+ * home — engineers grep for `aws-scan-mode.service` and find every
+ * read / write site at once.
+ */
+@Injectable()
+export class CloudAwsScanModeService {
+ private readonly logger = new Logger(CloudAwsScanModeService.name);
+
+ /**
+ * Update the scan engine on an AWS connection. Validates:
+ * - The connection exists and belongs to the caller's org.
+ * - The connection is an AWS provider (other providers don't have
+ * a scan-mode concept).
+ *
+ * Idempotent — re-applying the same mode is a successful no-op.
+ *
+ * Writes an audit-log entry so a mode change is traceable later.
+ * Reconciliation reads `IntegrationCheckRun.scanMode` per run, so
+ * after this update the next scan automatically uses the new engine
+ * and reconciliation only diffs same-mode runs (see
+ * `reconciliation.service.ts`).
+ */
+ async updateMode(params: {
+ connectionId: string;
+ organizationId: string;
+ userId: string;
+ mode: AwsScanMode;
+ }): Promise<{ mode: AwsScanMode }> {
+ const connection = await db.integrationConnection.findFirst({
+ where: {
+ id: params.connectionId,
+ organizationId: params.organizationId,
+ },
+ select: {
+ id: true,
+ metadata: true,
+ provider: { select: { slug: true } },
+ },
+ });
+
+ if (!connection) {
+ throw new ForbiddenException(
+ 'Connection not found or does not belong to your organization.',
+ );
+ }
+ if (connection.provider?.slug !== 'aws') {
+ throw new BadRequestException(
+ 'Scan engine choice is only available for AWS connections.',
+ );
+ }
+
+ const metadata = (connection.metadata ?? {}) as Record;
+ const previousMode = resolveAwsScanMode(metadata.awsScanMode);
+
+ if (previousMode === params.mode) {
+ // Idempotent no-op — return the current mode without writing.
+ return { mode: params.mode };
+ }
+
+ const nextMetadata: Record = {
+ ...metadata,
+ awsScanMode: params.mode,
+ };
+
+ await db.integrationConnection.update({
+ where: { id: connection.id },
+ data: {
+ metadata: nextMetadata as unknown as Prisma.InputJsonValue,
+ },
+ });
+
+ await logCloudSecurityActivity({
+ organizationId: params.organizationId,
+ userId: params.userId,
+ connectionId: connection.id,
+ action: 'scan_mode_changed',
+ description: `Switched AWS scan engine: ${previousMode} → ${params.mode}`,
+ metadata: {
+ previousMode,
+ newMode: params.mode,
+ },
+ });
+
+ this.logger.log(
+ `Connection ${connection.id} scan mode: ${previousMode} → ${params.mode}`,
+ );
+ return { mode: params.mode };
+ }
+}
diff --git a/apps/api/src/cloud-security/aws-scan-mode.spec.ts b/apps/api/src/cloud-security/aws-scan-mode.spec.ts
new file mode 100644
index 000000000..90670baf5
--- /dev/null
+++ b/apps/api/src/cloud-security/aws-scan-mode.spec.ts
@@ -0,0 +1,77 @@
+import {
+ AWS_SCAN_MODES,
+ DEFAULT_AWS_SCAN_MODE,
+ isSecurityHubMode,
+ resolveAwsScanMode,
+} from './aws-scan-mode';
+
+describe('aws-scan-mode', () => {
+ describe('resolveAwsScanMode', () => {
+ it('returns "security_hub" when the value is exactly that string', () => {
+ expect(resolveAwsScanMode('security_hub')).toBe('security_hub');
+ });
+
+ it('returns the default for "comp_scanners"', () => {
+ expect(resolveAwsScanMode('comp_scanners')).toBe('comp_scanners');
+ });
+
+ it('returns the default for unknown strings', () => {
+ // Defensive — typos / future modes / corrupted JSON variables must
+ // never accidentally activate Security Hub mode.
+ expect(resolveAwsScanMode('SECURITY_HUB')).toBe(DEFAULT_AWS_SCAN_MODE);
+ expect(resolveAwsScanMode('securityhub')).toBe(DEFAULT_AWS_SCAN_MODE);
+ expect(resolveAwsScanMode('xyz')).toBe(DEFAULT_AWS_SCAN_MODE);
+ });
+
+ it('returns the default for missing / non-string values', () => {
+ expect(resolveAwsScanMode(undefined)).toBe(DEFAULT_AWS_SCAN_MODE);
+ expect(resolveAwsScanMode(null)).toBe(DEFAULT_AWS_SCAN_MODE);
+ expect(resolveAwsScanMode(0)).toBe(DEFAULT_AWS_SCAN_MODE);
+ expect(resolveAwsScanMode({})).toBe(DEFAULT_AWS_SCAN_MODE);
+ expect(resolveAwsScanMode([])).toBe(DEFAULT_AWS_SCAN_MODE);
+ });
+ });
+
+ describe('isSecurityHubMode', () => {
+ it('is true only for the exact "security_hub" string', () => {
+ expect(isSecurityHubMode('security_hub')).toBe(true);
+ expect(isSecurityHubMode('comp_scanners')).toBe(false);
+ expect(isSecurityHubMode(undefined)).toBe(false);
+ expect(isSecurityHubMode('SECURITY_HUB')).toBe(false);
+ });
+ });
+
+ describe('DEFAULT_AWS_SCAN_MODE', () => {
+ it('is "comp_scanners" — today\'s behavior is the safe default', () => {
+ // This is intentionally guarded by a test: changing the default
+ // would silently shift production behavior for every existing
+ // connection that does not have an explicit scan mode set.
+ expect(DEFAULT_AWS_SCAN_MODE).toBe('comp_scanners');
+ });
+ });
+
+ describe('AWS_SCAN_MODES', () => {
+ it('lists exactly the two known modes (source of truth for DTOs / validators)', () => {
+ // If a new mode is added, this test must change in lockstep with
+ // the AwsScanMode union — the DTO `@IsIn(AWS_SCAN_MODES)` will
+ // automatically pick up the new value, but reviewers should see
+ // this test fail as a sanity check that the list was updated.
+ expect([...AWS_SCAN_MODES]).toEqual(['comp_scanners', 'security_hub']);
+ });
+
+ it('contains the default mode', () => {
+ // Guards against future refactors that might remove the default
+ // from the list — would break validation for every existing
+ // connection that lacks an explicit mode.
+ expect([...AWS_SCAN_MODES]).toContain(DEFAULT_AWS_SCAN_MODE);
+ });
+
+ it('every entry passes resolveAwsScanMode round-trip (self-consistency)', () => {
+ // Catches drift if someone adds a mode to the array without
+ // updating resolveAwsScanMode.
+ for (const mode of AWS_SCAN_MODES) {
+ expect(resolveAwsScanMode(mode)).toBe(mode);
+ }
+ });
+ });
+});
diff --git a/apps/api/src/cloud-security/aws-scan-mode.ts b/apps/api/src/cloud-security/aws-scan-mode.ts
new file mode 100644
index 000000000..3539c17a7
--- /dev/null
+++ b/apps/api/src/cloud-security/aws-scan-mode.ts
@@ -0,0 +1,54 @@
+/**
+ * Determines which engine performs an AWS security scan for a given
+ * connection. This is the single source of truth for scan-mode strings —
+ * importers never spell the values themselves.
+ *
+ * - 'comp_scanners' — run our service adapters directly against AWS
+ * APIs (today's default; full Fix button support).
+ * - 'security_hub' — call AWS Security Hub's GetFindings API and
+ * surface whatever findings the customer's
+ * Security Hub is configured to evaluate (CIS,
+ * NIST 800-53, PCI, etc.).
+ *
+ * Persisted in two places:
+ * - `IntegrationConnection.metadata.awsScanMode` — the customer's
+ * current choice for the connection. Stored in `metadata` (not
+ * `variables`) because it's a non-secret display field that the
+ * frontend reads via the connection endpoint; mirrors `awsType`,
+ * `roleArn`, `regions` etc.
+ * - `IntegrationCheckRun.scanMode` — which engine produced this run.
+ * Read by reconciliation to diff like-for-like; findingKeys live
+ * in different namespaces across modes, so cross-mode diffs would
+ * produce false resolutions/regressions.
+ */
+export type AwsScanMode = 'comp_scanners' | 'security_hub';
+
+/** Canonical list of valid scan modes. Exported so DTOs, validators,
+ * and tests reference ONE array instead of duplicating the string
+ * literals everywhere. If a new mode is added, only this file changes
+ * and all importers automatically pick it up — that's the
+ * "single source of truth" promise this module makes. */
+export const AWS_SCAN_MODES = [
+ 'comp_scanners',
+ 'security_hub',
+] as const satisfies readonly AwsScanMode[];
+
+/** Default behavior for AWS connections with no scan-mode set (including
+ * every pre-feature connection that already exists in production). */
+export const DEFAULT_AWS_SCAN_MODE: AwsScanMode = 'comp_scanners';
+
+/**
+ * Coerces a value (typically from JSON variables) into a valid
+ * AwsScanMode. Unknown / missing / wrong-typed values fall back to the
+ * default. Use this everywhere instead of comparing strings inline.
+ */
+export function resolveAwsScanMode(value: unknown): AwsScanMode {
+ return value === 'security_hub' ? 'security_hub' : DEFAULT_AWS_SCAN_MODE;
+}
+
+/** True when the value, if persisted, would represent a SecHub-mode
+ * connection. Reads in one place — keeps `=== 'security_hub'` out of
+ * callers. */
+export function isSecurityHubMode(value: unknown): boolean {
+ return resolveAwsScanMode(value) === 'security_hub';
+}
diff --git a/apps/api/src/cloud-security/cloud-security-audit.ts b/apps/api/src/cloud-security/cloud-security-audit.ts
index 9e3f4ea0b..f28e67504 100644
--- a/apps/api/src/cloud-security/cloud-security-audit.ts
+++ b/apps/api/src/cloud-security/cloud-security-audit.ts
@@ -13,7 +13,8 @@ interface CloudSecurityAuditParams {
| 'rollback_failed'
| 'service_toggled'
| 'exception_marked'
- | 'exception_revoked';
+ | 'exception_revoked'
+ | 'scan_mode_changed';
description: string;
metadata?: Record;
}
diff --git a/apps/api/src/cloud-security/cloud-security.controller.ts b/apps/api/src/cloud-security/cloud-security.controller.ts
index 862b9ec64..a0286c4a0 100644
--- a/apps/api/src/cloud-security/cloud-security.controller.ts
+++ b/apps/api/src/cloud-security/cloud-security.controller.ts
@@ -3,6 +3,7 @@ import {
Post,
Get,
Delete,
+ Patch,
Param,
Query,
Body,
@@ -27,8 +28,10 @@ import { CloudSecurityLegacyService } from './cloud-security-legacy.service';
import { CheckDefinitionService } from './check-definition.service';
import { CloudExceptionService } from './exception.service';
import { CloudHistoryService } from './history.service';
+import { CloudAwsScanModeService } from './aws-scan-mode.service';
import { parseExceptionExpiry } from './exception-expiry.utils';
import { MarkExceptionDto } from './dto/mark-exception.dto';
+import { UpdateAwsScanModeDto } from './dto/update-scan-mode.dto';
import { logCloudSecurityActivity } from './cloud-security-audit';
import { CloudSecurityActivityService } from './cloud-security-activity.service';
import {
@@ -52,6 +55,7 @@ export class CloudSecurityController {
private readonly checkDefinitionService: CheckDefinitionService,
private readonly exceptionService: CloudExceptionService,
private readonly historyService: CloudHistoryService,
+ private readonly scanModeService: CloudAwsScanModeService,
) {}
@Get('activity')
@@ -134,6 +138,34 @@ export class CloudSecurityController {
return { data: result };
}
+ @Patch('connections/:connectionId/scan-mode')
+ @UseGuards(HybridAuthGuard, PermissionGuard)
+ @RequirePermission('integration', 'update')
+ @ApiOperation({
+ summary:
+ 'Switch the AWS scan engine for a connection (Comp AI scanners ↔ Security Hub)',
+ })
+ async updateAwsScanMode(
+ @Param('connectionId') connectionId: string,
+ @Body() body: UpdateAwsScanModeDto,
+ @OrganizationId() organizationId: string,
+ @Req() req: { userId?: string },
+ ) {
+ if (!req.userId) {
+ throw new HttpException(
+ 'Switching the scan engine requires session authentication.',
+ HttpStatus.UNAUTHORIZED,
+ );
+ }
+ const result = await this.scanModeService.updateMode({
+ connectionId,
+ organizationId,
+ userId: req.userId,
+ mode: body.mode,
+ });
+ return { data: result };
+ }
+
@Delete('exceptions/:exceptionId')
@UseGuards(HybridAuthGuard, PermissionGuard)
@RequirePermission('integration', 'update')
diff --git a/apps/api/src/cloud-security/cloud-security.module.ts b/apps/api/src/cloud-security/cloud-security.module.ts
index 52618413e..9b5b5497c 100644
--- a/apps/api/src/cloud-security/cloud-security.module.ts
+++ b/apps/api/src/cloud-security/cloud-security.module.ts
@@ -15,6 +15,7 @@ import { AiDescriptionService } from './ai-description.service';
import { CheckDefinitionService } from './check-definition.service';
import { CloudExceptionService } from './exception.service';
import { CloudHistoryService } from './history.service';
+import { CloudAwsScanModeService } from './aws-scan-mode.service';
import { CloudReconciliationService } from './reconciliation.service';
import { CloudSecurityActivityService } from './cloud-security-activity.service';
import { IntegrationPlatformModule } from '../integration-platform/integration-platform.module';
@@ -40,6 +41,7 @@ import { AuthModule } from '../auth/auth.module';
CloudExceptionService,
CloudReconciliationService,
CloudHistoryService,
+ CloudAwsScanModeService,
],
exports: [CloudSecurityService],
})
diff --git a/apps/api/src/cloud-security/cloud-security.service.ts b/apps/api/src/cloud-security/cloud-security.service.ts
index c815a3fab..39d700a76 100644
--- a/apps/api/src/cloud-security/cloud-security.service.ts
+++ b/apps/api/src/cloud-security/cloud-security.service.ts
@@ -9,6 +9,7 @@ import { AWSSecurityService } from './providers/aws-security.service';
import { AzureSecurityService } from './providers/azure-security.service';
import { AWS_SERVICE_TASK_MAPPINGS } from './aws-task-mappings';
import { CloudReconciliationService } from './reconciliation.service';
+import { type AwsScanMode, resolveAwsScanMode } from './aws-scan-mode';
export interface SecurityFinding {
id: string;
@@ -245,6 +246,11 @@ export class CloudSecurityService {
}
}
+ // AWS-only — which engine produced this scan. Persisted on the run
+ // so reconciliation only diffs like-for-like (cross-mode findingKeys
+ // live in different namespaces). Null for GCP / Azure runs.
+ let awsScanMode: AwsScanMode | null = null;
+
switch (providerSlug) {
case 'gcp':
findings = await this.gcpService.scanSecurityFindings(
@@ -253,13 +259,21 @@ export class CloudSecurityService {
enabledServices,
);
break;
- case 'aws':
+ case 'aws': {
+ // AWS scan-mode lives on connection.metadata (non-secret, frontend-
+ // readable); credentials are encrypted blobs intended for the AWS
+ // SDK. Read from metadata so a single source of truth.
+ const metadata =
+ (connection.metadata as Record | null) ?? {};
+ awsScanMode = resolveAwsScanMode(metadata.awsScanMode);
findings = await this.awsService.scanSecurityFindings(
credentials,
variables,
enabledServices,
+ awsScanMode,
);
break;
+ }
case 'azure':
findings = await this.azureService.scanSecurityFindings(
credentials,
@@ -282,6 +296,7 @@ export class CloudSecurityService {
connectionId,
providerSlug,
findings,
+ awsScanMode,
);
// Reconcile against the prior scan to record resolutions and regressions.
@@ -573,6 +588,9 @@ export class CloudSecurityService {
connectionId: string,
provider: string,
findings: SecurityFinding[],
+ // AWS only — which engine produced these findings. Stored on the run so
+ // reconciliation can avoid cross-mode diffs. Null for GCP / Azure runs.
+ awsScanMode: AwsScanMode | null,
): Promise {
const passedCount = findings.filter((f) => f.passed).length;
const failedCount = findings.filter((f) => !f.passed).length;
@@ -607,6 +625,7 @@ export class CloudSecurityService {
passedCount,
failedCount,
scannedServices,
+ scanMode: awsScanMode,
},
});
diff --git a/apps/api/src/cloud-security/dto/update-scan-mode.dto.ts b/apps/api/src/cloud-security/dto/update-scan-mode.dto.ts
new file mode 100644
index 000000000..0b6239597
--- /dev/null
+++ b/apps/api/src/cloud-security/dto/update-scan-mode.dto.ts
@@ -0,0 +1,25 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsIn, IsString } from 'class-validator';
+import { AWS_SCAN_MODES, type AwsScanMode } from '../aws-scan-mode';
+
+/**
+ * Request body for `PATCH /v1/cloud-security/connections/:id/scan-mode`.
+ *
+ * Only AWS connections accept this; the service layer validates the
+ * connection is AWS before applying the change.
+ *
+ * The accepted values + Swagger enum both reference `AWS_SCAN_MODES`
+ * directly so this DTO can't drift from the source of truth — adding a
+ * new mode in `aws-scan-mode.ts` automatically widens what's accepted
+ * here.
+ */
+export class UpdateAwsScanModeDto {
+ @ApiProperty({
+ description: 'Which scan engine to use for this AWS connection.',
+ enum: AWS_SCAN_MODES,
+ example: 'security_hub',
+ })
+ @IsString()
+ @IsIn([...AWS_SCAN_MODES])
+ mode!: AwsScanMode;
+}
diff --git a/apps/api/src/cloud-security/providers/aws-security.service.ts b/apps/api/src/cloud-security/providers/aws-security.service.ts
index bf6c82593..fa0f0a35f 100644
--- a/apps/api/src/cloud-security/providers/aws-security.service.ts
+++ b/apps/api/src/cloud-security/providers/aws-security.service.ts
@@ -64,9 +64,26 @@ import { EventBridgeAdapter } from './aws/eventbridge.adapter';
import { TransferFamilyAdapter } from './aws/transfer-family.adapter';
import { ElasticBeanstalkAdapter } from './aws/elastic-beanstalk.adapter';
import { AppFlowAdapter } from './aws/appflow.adapter';
+import { SecurityHubAdapter } from './aws/security-hub.adapter';
+import {
+ type AwsScanMode,
+ DEFAULT_AWS_SCAN_MODE,
+} from '../aws-scan-mode';
const GOVCLOUD_UNSUPPORTED_SERVICE_IDS = new Set(['cloudfront', 'shield']);
+/**
+ * Pre-computed scan context shared by both scan engines (adapter mode
+ * and Security Hub mode). Produced by `prepareScan`, consumed by
+ * `scanViaAdapters` / `scanViaSecurityHub`.
+ */
+interface AwsScanSetup {
+ awsCredentials: AwsCredentials;
+ configuredRegions: string[];
+ primaryRegion: string;
+ partition: AwsPartition;
+}
+
@Injectable()
export class AWSSecurityService {
private readonly logger = new Logger(AWSSecurityService.name);
@@ -118,11 +135,41 @@ export class AWSSecurityService {
new AppFlowAdapter(),
];
+ /**
+ * Entry point — resolves credentials + regions, then dispatches to the
+ * chosen scan engine. The two engines are mutually exclusive:
+ *
+ * - 'comp_scanners' → runs our ~49 service adapters (default).
+ * - 'security_hub' → runs ONLY the Security Hub adapter, per region.
+ *
+ * Engineers tracing this code can jump straight to `scanViaAdapters`
+ * or `scanViaSecurityHub` to see what each mode does — no nested
+ * branching.
+ */
async scanSecurityFindings(
credentials: Record,
variables: Record,
enabledServices?: string[],
+ mode: AwsScanMode = DEFAULT_AWS_SCAN_MODE,
): Promise {
+ const setup = await this.prepareScan(credentials, variables);
+
+ if (mode === 'security_hub') {
+ return this.scanViaSecurityHub(setup);
+ }
+ return this.scanViaAdapters(setup, enabledServices);
+ }
+
+ /**
+ * Validates credentials, resolves the region list, and (for role
+ * auth) assumes the IAM role. Returns the shared context both scan
+ * engines need. Throws on any precondition failure — both engines
+ * skip out of an unhappy path before they begin.
+ */
+ private async prepareScan(
+ credentials: Record,
+ variables: Record,
+ ): Promise {
const isRoleAuth = Boolean(credentials.roleArn && credentials.externalId);
const isKeyAuth = Boolean(
credentials.access_key_id && credentials.secret_access_key,
@@ -143,6 +190,7 @@ export class AWSSecurityService {
partition,
);
const primaryRegion = configuredRegions[0];
+
const mismatchedRegions = configuredRegions.filter(
(region) => getAwsPartitionForRegion(region) !== partition,
);
@@ -156,31 +204,65 @@ export class AWSSecurityService {
`Scanning ${configuredRegions.length} ${partition} region(s): ${configuredRegions.join(', ')}`,
);
- // Assume role ONCE — IAM is global, credentials work across all regions
- let awsCredentials: AwsCredentials;
- if (isRoleAuth) {
- const partitionErrors = validateAwsPartitionConfig({
- partition,
- roleArn: credentials.roleArn as string,
- regions: configuredRegions,
- });
- if (partitionErrors.length > 0) {
- throw new Error(partitionErrors.join(' '));
- }
+ // Assume role ONCE — IAM is global, credentials work across all regions.
+ const awsCredentials: AwsCredentials = isRoleAuth
+ ? await this.resolveRoleCredentials({
+ roleArn: credentials.roleArn as string,
+ externalId: credentials.externalId as string,
+ partition,
+ regions: configuredRegions,
+ primaryRegion,
+ })
+ : {
+ accessKeyId: credentials.access_key_id as string,
+ secretAccessKey: credentials.secret_access_key as string,
+ };
- awsCredentials = await this.assumeRole({
- roleArn: credentials.roleArn as string,
- externalId: credentials.externalId as string,
- region: primaryRegion,
- partition,
- });
- } else {
- awsCredentials = {
- accessKeyId: credentials.access_key_id as string,
- secretAccessKey: credentials.secret_access_key as string,
- };
+ return {
+ awsCredentials,
+ configuredRegions,
+ primaryRegion,
+ partition,
+ };
+ }
+
+ private async resolveRoleCredentials(params: {
+ roleArn: string;
+ externalId: string;
+ partition: AwsPartition;
+ regions: string[];
+ primaryRegion: string;
+ }): Promise {
+ const partitionErrors = validateAwsPartitionConfig({
+ partition: params.partition,
+ roleArn: params.roleArn,
+ regions: params.regions,
+ });
+ if (partitionErrors.length > 0) {
+ throw new Error(partitionErrors.join(' '));
}
+ return this.assumeRole({
+ roleArn: params.roleArn,
+ externalId: params.externalId,
+ region: params.primaryRegion,
+ partition: params.partition,
+ });
+ }
+
+ /**
+ * Today's default scan engine. Runs each registered service adapter
+ * (global once, regional per region) and aggregates the findings.
+ * Behavior is byte-for-byte identical to the pre-mode implementation —
+ * the SecurityHubAdapter is NOT in `this.adapters`, so it can never
+ * run on this path.
+ */
+ private async scanViaAdapters(
+ setup: AwsScanSetup,
+ enabledServices: string[] | undefined,
+ ): Promise {
+ const { awsCredentials, configuredRegions, primaryRegion, partition } = setup;
+
// undefined = scan all (no detection data), [] = scan nothing (all disabled), [...] = scan specific
const activeAdaptersBeforePartitionFilter =
enabledServices === undefined
@@ -268,6 +350,59 @@ export class AWSSecurityService {
return allFindings;
}
+ /**
+ * Alternative scan engine. Pulls findings from AWS Security Hub
+ * `GetFindings` per region — does NOT touch any of the 49 service
+ * adapters. SecurityHubAdapter handles the "not subscribed" /
+ * "AccessDenied" cases gracefully (returns []).
+ *
+ * Activated by `awsScanMode: 'security_hub'` on the connection.
+ */
+ private async scanViaSecurityHub(
+ setup: AwsScanSetup,
+ ): Promise {
+ const { awsCredentials, configuredRegions } = setup;
+ const adapter = new SecurityHubAdapter();
+
+ this.logger.log(
+ `Scanning Security Hub across ${configuredRegions.length} region(s)`,
+ );
+
+ const allFindings: SecurityFinding[] = [];
+ const successfulRegions = new Set();
+ const failedRegions = new Set();
+
+ for (const region of configuredRegions) {
+ try {
+ const findings = await adapter.scan({
+ credentials: awsCredentials,
+ region,
+ });
+ // Adapter already stamps evidence.serviceId — no need to re-stamp.
+ allFindings.push(...findings);
+ successfulRegions.add(region);
+ this.logger.log(
+ `[security-hub] ${findings.length} findings in ${region}`,
+ );
+ } catch (error) {
+ const msg = error instanceof Error ? error.message : String(error);
+ this.logger.warn(`[security-hub] Error in ${region}: ${msg}`);
+ failedRegions.add(region);
+ }
+ }
+
+ if (successfulRegions.size === 0 && failedRegions.size > 0) {
+ throw new Error(
+ `Security Hub scan failed in all ${failedRegions.size} region(s): ${[...failedRegions].join(', ')}`,
+ );
+ }
+
+ this.logger.log(
+ `Security Hub scan complete: ${allFindings.length} findings from ${successfulRegions.size} region(s)`,
+ );
+ return allFindings;
+ }
+
/**
* Get the list of regions to scan from credentials or variables.
* Always returns at least one region (defaults to us-east-1).
diff --git a/apps/api/src/cloud-security/providers/aws/security-hub.adapter.spec.ts b/apps/api/src/cloud-security/providers/aws/security-hub.adapter.spec.ts
new file mode 100644
index 000000000..34512060e
--- /dev/null
+++ b/apps/api/src/cloud-security/providers/aws/security-hub.adapter.spec.ts
@@ -0,0 +1,290 @@
+import {
+ buildRemediationText,
+ deriveFindingKey,
+ formatRelatedRequirements,
+ mapSecurityHubFinding,
+ SECURITY_HUB_SERVICE_ID,
+ type SecurityHubRawFinding,
+} from './security-hub.adapter';
+
+describe('security-hub.adapter helpers', () => {
+ describe('deriveFindingKey', () => {
+ it('combines standard + control for a foundational best-practices GeneratorId', () => {
+ expect(
+ deriveFindingKey('aws-foundational-security-best-practices/v/1.0.0/EC2.13'),
+ ).toBe('aws-securityhub-aws-foundational-security-best-practices-ec2.13');
+ });
+
+ it('combines standard + control for a CIS GeneratorId', () => {
+ expect(
+ deriveFindingKey('cis-aws-foundations-benchmark/v/1.2.0/1.1'),
+ ).toBe('aws-securityhub-cis-aws-foundations-benchmark-1.1');
+ });
+
+ it('combines standard + control for a NIST GeneratorId', () => {
+ expect(deriveFindingKey('nist-800-53/r/5/AC-2')).toBe(
+ 'aws-securityhub-nist-800-53-ac-2',
+ );
+ });
+
+ it('does NOT collide across standards that share a control id (P1 regression guard)', () => {
+ // The original implementation used only the trailing segment, so
+ // CIS 1.1 and PCI 1.1 produced the same findingKey and would have
+ // merged into one row in the UI — silently corrupting exception
+ // scoping and reconciliation. Cubic caught this on initial review;
+ // this test exists to prevent regression.
+ const cis11 = deriveFindingKey(
+ 'cis-aws-foundations-benchmark/v/1.2.0/1.1',
+ );
+ const pci11 = deriveFindingKey('pci-dss/v/3.2.1/1.1');
+ expect(cis11).not.toBe(pci11);
+ expect(cis11).toContain('cis');
+ expect(pci11).toContain('pci');
+ });
+
+ it('produces the same key for two findings from the same standard + control', () => {
+ // Findings sharing both standard AND control SHOULD merge — that's
+ // the whole point of findingKey: group instances of the same check
+ // across resources.
+ const a = deriveFindingKey('aws-foundational-security-best-practices/v/1.0.0/EC2.13');
+ const b = deriveFindingKey('aws-foundational-security-best-practices/v/1.0.0/EC2.13');
+ expect(a).toBe(b);
+ });
+
+ it('ignores version/revision changes within the same standard + control', () => {
+ // SecHub bumping the standard version shouldn't fragment a finding's
+ // identity. CIS 1.2.0 → 4.0.0 with the same control 1.1 stays one key,
+ // so reconciliation can still diff before/after the version bump.
+ const v12 = deriveFindingKey('cis-aws-foundations-benchmark/v/1.2.0/1.1');
+ const v40 = deriveFindingKey('cis-aws-foundations-benchmark/v/4.0.0/1.1');
+ expect(v12).toBe(v40);
+ });
+
+ it('sanitizes characters not safe for use in identifiers', () => {
+ expect(deriveFindingKey('weird:control id with spaces!')).toMatch(
+ /^aws-securityhub-/,
+ );
+ });
+
+ it('handles single-segment GeneratorIds (no path structure)', () => {
+ expect(deriveFindingKey('SimpleGenerator')).toBe(
+ 'aws-securityhub-simplegenerator',
+ );
+ });
+
+ it('returns a sentinel key rather than throwing when GeneratorId is missing', () => {
+ // We must always produce SOME key — the Fix pipeline gates on
+ // findingKey existence, and silently disabling Fix on findings
+ // without GeneratorId would be a worse UX than an "unknown" key.
+ expect(deriveFindingKey(undefined)).toBe('aws-securityhub-unknown');
+ expect(deriveFindingKey('')).toBe('aws-securityhub-unknown');
+ expect(deriveFindingKey(' ')).toBe('aws-securityhub-unknown');
+ });
+ });
+
+ describe('formatRelatedRequirements', () => {
+ it('returns "" for empty/undefined input', () => {
+ expect(formatRelatedRequirements(undefined)).toBe('');
+ expect(formatRelatedRequirements([])).toBe('');
+ });
+
+ it('emits NIST 800-53 verbatim when no explicit version separator is present', () => {
+ // NIST embeds the revision in the standard name ("NIST.800-53.r5"),
+ // so the structured `standard version (control)` regex doesn't
+ // match — fallback emits the raw string and the parser surfaces it
+ // as a single chip label. Better than fabricating a placeholder.
+ expect(formatRelatedRequirements(['NIST.800-53.r5 AC-2'])).toBe(
+ 'NIST.800-53.r5 AC-2',
+ );
+ });
+
+ it('formats CIS AWS Foundations Benchmark in parser-compatible form (regex must accept lowercase)', () => {
+ // Cubic P2 regression guard — the prior regex `[A-Z][A-Z0-9 .]+?`
+ // could not match "Foundations" / "Benchmark" because of the
+ // lowercase letters, so this format silently fell through.
+ const result = formatRelatedRequirements([
+ 'CIS AWS Foundations Benchmark v1.2.0 1.1',
+ ]);
+ expect(result).toMatch(/^cis .*1\.2\.0 \(1\.1\)$/);
+ });
+
+ it('formats PCI DSS in parser-compatible form', () => {
+ const result = formatRelatedRequirements(['PCI DSS v3.2.1 8.2.3']);
+ expect(result).toBe('pci dss 3.2.1 (8.2.3)');
+ });
+
+ it('formats AWS FSBP in parser-compatible form, handling the slash separator', () => {
+ // Cubic P2 regression guard — `/` between version and control is
+ // unique to AWS Foundational Security Best Practices. Without the
+ // pre-normalization, the regex `\s+` between version and control
+ // would never match.
+ const result = formatRelatedRequirements([
+ 'AWS Foundational Security Best Practices v1.0.0/EC2.2',
+ ]);
+ expect(result).toMatch(/^aws fsbp 1\.0\.0 \(EC2\.2\)$/);
+ });
+
+ it('joins multiple requirements with "; " so the parser splits them correctly', () => {
+ const result = formatRelatedRequirements([
+ 'NIST.800-53.r5 AC-2',
+ 'CIS AWS Foundations Benchmark v1.2.0 1.1',
+ ]);
+ expect(result).toContain('; ');
+ });
+
+ it('keeps unfamiliar requirement strings verbatim rather than dropping them', () => {
+ // We never want to silently lose a compliance reference — if SecHub
+ // adds a new framework format we don't recognize, we still surface
+ // the raw string so the auditor sees something.
+ const weird = 'SomeFutureFramework/CustomFormat#42';
+ const result = formatRelatedRequirements([weird]);
+ expect(result.toLowerCase()).toContain('somefutureframework');
+ });
+ });
+
+ describe('buildRemediationText', () => {
+ it('returns AWS text + reference URL + compliance section when all three are present', () => {
+ const result = buildRemediationText({
+ Remediation: {
+ Recommendation: {
+ Text: 'Enable encryption on the bucket.',
+ Url: 'https://docs.aws.amazon.com/whatever',
+ },
+ },
+ Compliance: { RelatedRequirements: ['NIST.800-53.r5 AC-2'] },
+ });
+ expect(result).toContain('Enable encryption on the bucket.');
+ expect(result).toContain('More info: https://docs.aws.amazon.com/whatever');
+ expect(result).toContain('Compliance:');
+ });
+
+ it('omits the More info section when no URL is present', () => {
+ const result = buildRemediationText({
+ Remediation: { Recommendation: { Text: 'Do the thing.' } },
+ });
+ expect(result).toContain('Do the thing.');
+ expect(result).not.toContain('More info:');
+ });
+
+ it('omits the Compliance section when no related requirements exist', () => {
+ const result = buildRemediationText({
+ Remediation: { Recommendation: { Text: 'Do the thing.' } },
+ Compliance: { RelatedRequirements: [] },
+ });
+ expect(result).not.toContain('Compliance:');
+ });
+
+ it('uses "\\n\\n" as the section separator — must match the parser contract', () => {
+ // `remediation-parser.ts` splits on `\n\n`. If we use any other
+ // separator, the chips disappear from the UI.
+ const result = buildRemediationText({
+ Remediation: {
+ Recommendation: { Text: 'Step one.', Url: 'https://example.com' },
+ },
+ Compliance: { RelatedRequirements: ['NIST.800-53.r5 AC-2'] },
+ });
+ const sections = result.split('\n\n');
+ expect(sections.length).toBeGreaterThanOrEqual(3);
+ });
+
+ it('returns a non-empty fallback when SecHub provides no remediation data', () => {
+ const result = buildRemediationText({});
+ expect(result.length).toBeGreaterThan(0);
+ // The fallback string isn't load-bearing — just make sure we don't
+ // surface an empty string (RemediationSection would hide the whole
+ // section, which is misleading for a SecHub finding).
+ });
+ });
+
+ describe('mapSecurityHubFinding', () => {
+ const baseFinding: SecurityHubRawFinding = {
+ Id: 'arn:aws:securityhub:us-east-1:123:finding/abc',
+ Title: 'EC2 default security group should not allow inbound traffic',
+ Description: 'A default security group allows broad ingress.',
+ Severity: { Label: 'HIGH' },
+ Resources: [{ Type: 'AwsEc2SecurityGroup', Id: 'sg-12345' }],
+ AwsAccountId: '013388577167',
+ Region: 'us-east-1',
+ Compliance: {
+ Status: 'FAILED',
+ RelatedRequirements: ['NIST.800-53.r5 AC-2'],
+ },
+ GeneratorId:
+ 'aws-foundational-security-best-practices/v/1.0.0/EC2.13',
+ Remediation: {
+ Recommendation: {
+ Text: 'Update the security group rules.',
+ Url: 'https://docs.aws.amazon.com/securityhub/EC2.13',
+ },
+ },
+ CreatedAt: '2026-05-18T10:00:00.000Z',
+ UpdatedAt: '2026-05-18T10:00:00.000Z',
+ };
+
+ it('stamps evidence.findingKey so the Fix pipeline picks it up', () => {
+ const mapped = mapSecurityHubFinding(baseFinding, 'us-east-1');
+ expect(mapped.evidence?.findingKey).toBe(
+ 'aws-securityhub-aws-foundational-security-best-practices-ec2.13',
+ );
+ });
+
+ it('stamps evidence.serviceId so the UI can detect SecHub findings', () => {
+ const mapped = mapSecurityHubFinding(baseFinding, 'us-east-1');
+ expect(mapped.evidence?.serviceId).toBe(SECURITY_HUB_SERVICE_ID);
+ });
+
+ it('builds remediation in the GCP-compatible format so the parser handles chips', () => {
+ const mapped = mapSecurityHubFinding(baseFinding, 'us-east-1');
+ expect(mapped.remediation).toContain('Update the security group rules.');
+ expect(mapped.remediation).toContain('More info:');
+ expect(mapped.remediation).toContain('Compliance:');
+ });
+
+ it('uses the finding-supplied region when available, falling back to the scan region', () => {
+ const mapped = mapSecurityHubFinding(
+ { ...baseFinding, Region: undefined },
+ 'eu-west-1',
+ );
+ expect(mapped.evidence?.region).toBe('eu-west-1');
+ });
+
+ it('marks the finding as not-passed for non-PASSED compliance statuses', () => {
+ const mapped = mapSecurityHubFinding(baseFinding, 'us-east-1');
+ expect(mapped.passed).toBe(false);
+ });
+
+ it('marks the finding as passed when SecHub reports PASSED', () => {
+ const passing: SecurityHubRawFinding = {
+ ...baseFinding,
+ Compliance: { ...baseFinding.Compliance, Status: 'PASSED' },
+ };
+ const mapped = mapSecurityHubFinding(passing, 'us-east-1');
+ expect(mapped.passed).toBe(true);
+ });
+
+ it('maps SecHub severity labels to our internal severity levels', () => {
+ const expectations: Array<[string, string]> = [
+ ['INFORMATIONAL', 'info'],
+ ['LOW', 'low'],
+ ['MEDIUM', 'medium'],
+ ['HIGH', 'high'],
+ ['CRITICAL', 'critical'],
+ ];
+ for (const [sechubLabel, internalLevel] of expectations) {
+ const mapped = mapSecurityHubFinding(
+ { ...baseFinding, Severity: { Label: sechubLabel } },
+ 'us-east-1',
+ );
+ expect(mapped.severity).toBe(internalLevel);
+ }
+ });
+
+ it('defaults to medium severity when SecHub omits or returns an unknown label', () => {
+ const mapped = mapSecurityHubFinding(
+ { ...baseFinding, Severity: undefined },
+ 'us-east-1',
+ );
+ expect(mapped.severity).toBe('medium');
+ });
+ });
+});
diff --git a/apps/api/src/cloud-security/providers/aws/security-hub.adapter.ts b/apps/api/src/cloud-security/providers/aws/security-hub.adapter.ts
index 3c082933e..eca2bc256 100644
--- a/apps/api/src/cloud-security/providers/aws/security-hub.adapter.ts
+++ b/apps/api/src/cloud-security/providers/aws/security-hub.adapter.ts
@@ -6,8 +6,33 @@ import {
import type { SecurityFinding } from '../../cloud-security.service';
import type { AwsCredentials, AwsServiceAdapter } from './aws-service-adapter';
+/**
+ * Maximum number of findings we pull from Security Hub per scan, across
+ * all pages. Bounded to keep scan time + payload size predictable.
+ */
+const MAX_FINDINGS_PER_SCAN = 500;
+
+/** Page size for the SecHub GetFindings API. */
+const FINDINGS_PAGE_SIZE = 100;
+
+/** Service ID that this adapter stamps onto each finding's evidence so
+ * downstream code (UI banner, Fix dialog) can recognize SecHub-sourced
+ * findings without inspecting the findingKey format. */
+export const SECURITY_HUB_SERVICE_ID = 'security-hub';
+
+/**
+ * Reads findings from AWS Security Hub and maps them to our internal
+ * SecurityFinding shape so the rest of the system (Fix pipeline,
+ * History tab, compliance chips, UI) treats them like any other
+ * adapter's finding.
+ *
+ * This adapter is only instantiated when a connection's `awsScanMode`
+ * is `'security_hub'` — see `AWSSecurityService.scanViaSecurityHub`.
+ * It is NOT registered in the main adapters array; mode mutual
+ * exclusion is enforced by code structure, not runtime config.
+ */
export class SecurityHubAdapter implements AwsServiceAdapter {
- readonly serviceId = 'security-hub';
+ readonly serviceId = SECURITY_HUB_SERVICE_ID;
readonly isGlobal = false;
async scan(params: {
@@ -15,14 +40,16 @@ export class SecurityHubAdapter implements AwsServiceAdapter {
region: string;
}): Promise {
const { credentials, region } = params;
-
- const securityHub = new SecurityHubClient({ region, credentials });
+ const client = new SecurityHubClient({ region, credentials });
try {
- return await this.fetchFindings(securityHub, region);
+ return await this.fetchFindings(client, region);
} catch (error) {
- const msg = error instanceof Error ? error.message : String(error);
- if (msg.includes('not subscribed') || msg.includes('AccessDenied')) {
+ const message = error instanceof Error ? error.message : String(error);
+ // Returning [] is the agreed graceful path when SecHub isn't subscribed
+ // or the role can't see findings — the cloud-security service surfaces
+ // a clearer onboarding error elsewhere when this happens consistently.
+ if (message.includes('not subscribed') || message.includes('AccessDenied')) {
return [];
}
throw error;
@@ -35,7 +62,7 @@ export class SecurityHubAdapter implements AwsServiceAdapter {
): Promise {
const findings: SecurityFinding[] = [];
- const params: GetFindingsCommandInput = {
+ const baseParams: GetFindingsCommandInput = {
Filters: {
WorkflowStatus: [
{ Value: 'NEW', Comparison: 'EQUALS' },
@@ -43,84 +70,263 @@ export class SecurityHubAdapter implements AwsServiceAdapter {
],
RecordState: [{ Value: 'ACTIVE', Comparison: 'EQUALS' }],
},
- MaxResults: 100,
+ MaxResults: FINDINGS_PAGE_SIZE,
};
- let response = await client.send(new GetFindingsCommand(params));
-
- if (response.Findings) {
- for (const f of response.Findings) {
- findings.push(this.mapFinding(f, region));
- }
- }
-
- let nextToken = response.NextToken;
- while (nextToken && findings.length < 500) {
- response = await client.send(
- new GetFindingsCommand({ ...params, NextToken: nextToken }),
+ let nextToken: string | undefined;
+ do {
+ const response = await client.send(
+ new GetFindingsCommand({ ...baseParams, NextToken: nextToken }),
);
-
- if (response.Findings) {
- for (const f of response.Findings) {
- if (findings.length >= 500) break;
- findings.push(this.mapFinding(f, region));
- }
+ for (const finding of response.Findings ?? []) {
+ if (findings.length >= MAX_FINDINGS_PER_SCAN) break;
+ findings.push(mapSecurityHubFinding(finding, region));
}
-
nextToken = response.NextToken;
- }
+ } while (nextToken && findings.length < MAX_FINDINGS_PER_SCAN);
return findings;
}
+}
+
+/**
+ * Minimal shape we read from the Security Hub API. We don't type the
+ * full AWS response because we only consume a handful of fields and
+ * they're all optional — the AWS SDK types this very loosely anyway.
+ */
+export interface SecurityHubRawFinding {
+ Id?: string;
+ Title?: string;
+ Description?: string;
+ Remediation?: {
+ Recommendation?: { Text?: string; Url?: string };
+ };
+ Severity?: { Label?: string };
+ Resources?: Array<{ Type?: string; Id?: string }>;
+ AwsAccountId?: string;
+ Region?: string;
+ Compliance?: { Status?: string; RelatedRequirements?: string[] };
+ GeneratorId?: string;
+ CreatedAt?: string;
+ UpdatedAt?: string;
+}
+
+const SEVERITY_BY_SECHUB_LABEL: Record = {
+ INFORMATIONAL: 'info',
+ LOW: 'low',
+ MEDIUM: 'medium',
+ HIGH: 'high',
+ CRITICAL: 'critical',
+};
+
+/**
+ * Maps a raw SecHub finding into our internal SecurityFinding shape.
+ * Exported so the unit tests can exercise it directly without a live
+ * AWS client.
+ *
+ * Key design choices:
+ * - `evidence.findingKey` makes the finding visible to the Fix
+ * pipeline (the frontend `canFixFinding` and the API ai-remediation
+ * flow both gate on this). Derived from the SecHub control ID so
+ * it's stable across scans of the same control.
+ * - `evidence.serviceId` lets the UI recognize SecHub-sourced
+ * findings without parsing the findingKey format.
+ * - `remediation` is built in the same `\n\nMore info: \n\n
+ * Compliance: ` format that GCP uses, so the existing
+ * `RemediationSection` + `remediation-parser` render reference link
+ * and compliance chips with zero frontend changes.
+ */
+export function mapSecurityHubFinding(
+ finding: SecurityHubRawFinding,
+ scanRegion: string,
+): SecurityFinding {
+ const region = finding.Region || scanRegion;
+ const passed = finding.Compliance?.Status === 'PASSED';
+ const title = `${finding.Title ?? 'Untitled Finding'} (${region})`;
- private mapFinding(
- finding: {
- Id?: string;
- Title?: string;
- Description?: string;
- Remediation?: { Recommendation?: { Text?: string } };
- Severity?: { Label?: string };
- Resources?: Array<{ Type?: string; Id?: string }>;
- AwsAccountId?: string;
- Region?: string;
- Compliance?: { Status?: string };
- GeneratorId?: string;
- CreatedAt?: string;
- UpdatedAt?: string;
+ return {
+ id: finding.Id ?? '',
+ title,
+ description: finding.Description ?? 'No description available',
+ severity: SEVERITY_BY_SECHUB_LABEL[finding.Severity?.Label ?? ''] ?? 'medium',
+ resourceType: finding.Resources?.[0]?.Type ?? 'unknown',
+ resourceId: finding.Resources?.[0]?.Id ?? 'unknown',
+ remediation: buildRemediationText(finding),
+ evidence: {
+ // Stamping serviceId here lets the UI banner + Fix dialog detect
+ // SecHub findings without parsing findingKey strings.
+ serviceId: SECURITY_HUB_SERVICE_ID,
+ // findingKey is the contract the Fix pipeline reads (see
+ // CloudTestsSection.canFixFinding and cloud-security-query.service
+ // findingKey extraction). Stable across scans of the same control.
+ findingKey: deriveFindingKey(finding.GeneratorId),
+ awsAccountId: finding.AwsAccountId,
+ region,
+ complianceStatus: finding.Compliance?.Status,
+ relatedRequirements: finding.Compliance?.RelatedRequirements ?? [],
+ generatorId: finding.GeneratorId,
+ updatedAt: finding.UpdatedAt,
},
- scanRegion: string,
- ): SecurityFinding {
- const severityMap: Record = {
- INFORMATIONAL: 'info',
- LOW: 'low',
- MEDIUM: 'medium',
- HIGH: 'high',
- CRITICAL: 'critical',
- };
+ createdAt: finding.CreatedAt ?? new Date().toISOString(),
+ passed,
+ };
+}
- const complianceStatus = finding.Compliance?.Status;
- const passed = complianceStatus === 'PASSED';
- const findingRegion = finding.Region || scanRegion;
- const baseTitle = finding.Title || 'Untitled Finding';
-
- return {
- id: finding.Id || '',
- title: `${baseTitle} (${findingRegion})`,
- description: finding.Description || 'No description available',
- severity: severityMap[finding.Severity?.Label || 'INFO'] || 'medium',
- resourceType: finding.Resources?.[0]?.Type || 'unknown',
- resourceId: finding.Resources?.[0]?.Id || 'unknown',
- remediation:
- finding.Remediation?.Recommendation?.Text || 'No remediation available',
- evidence: {
- awsAccountId: finding.AwsAccountId,
- region: findingRegion,
- complianceStatus,
- generatorId: finding.GeneratorId,
- updatedAt: finding.UpdatedAt,
- },
- createdAt: finding.CreatedAt || new Date().toISOString(),
- passed,
- };
+/**
+ * Produces a stable findingKey from a SecHub `GeneratorId` like
+ * `aws-foundational-security-best-practices/v/1.0.0/EC2.13` or
+ * `cis-aws-foundations-benchmark/v/1.2.0/1.1`.
+ *
+ * Strategy: combine the standard prefix (first segment) with the control
+ * identifier (last segment) so distinct findings from different standards
+ * never collide under the same key. The middle segments (version /
+ * revision) are intentionally dropped — they shouldn't change a finding's
+ * identity within a standard.
+ *
+ * aws-foundational-security-best-practices/v/1.0.0/EC2.13
+ * → aws-securityhub-aws-foundational-security-best-practices-ec2.13
+ *
+ * cis-aws-foundations-benchmark/v/1.2.0/1.1
+ * → aws-securityhub-cis-aws-foundations-benchmark-1.1
+ *
+ * pci-dss/v/3.2.1/1.1
+ * → aws-securityhub-pci-dss-1.1 (no collision with the CIS 1.1 above)
+ *
+ * Falls back gracefully when the GeneratorId has no path structure or is
+ * missing entirely — we ALWAYS produce a key because the Fix button gates
+ * on its existence, and missing findingKey would silently disable Fix.
+ */
+export function deriveFindingKey(generatorId: string | undefined): string {
+ if (!generatorId) return 'aws-securityhub-unknown';
+ const trimmed = generatorId.trim();
+ if (!trimmed) return 'aws-securityhub-unknown';
+
+ const segments = trimmed.split('/').filter((s) => s.length > 0);
+ if (segments.length === 0) return 'aws-securityhub-unknown';
+
+ if (segments.length === 1) {
+ // No path structure — sanitize the whole thing.
+ const sanitized = sanitizeKeySegment(segments[0]);
+ return `aws-securityhub-${sanitized || 'unknown'}`;
+ }
+
+ // Combine first segment (standard identifier — namespaces the key) with
+ // the last segment (control identifier — uniquely identifies WHICH check
+ // within that standard). This prevents collisions like CIS 1.1 ↔ PCI 1.1.
+ const standard = sanitizeKeySegment(segments[0]);
+ const control = sanitizeKeySegment(segments[segments.length - 1]);
+ const combined = [standard, control].filter(Boolean).join('-');
+ return `aws-securityhub-${combined || 'unknown'}`;
+}
+
+function sanitizeKeySegment(value: string): string {
+ // Keep alphanumerics, dots, hyphens — drop anything that would make the
+ // key awkward in URLs/logs. Collapse runs of separators.
+ return value
+ .toLowerCase()
+ .replace(/[^a-z0-9.-]+/g, '-')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '');
+}
+
+/**
+ * Builds the remediation string for a SecHub finding. Output format
+ * matches `gcp-security.service.ts buildRemediation` exactly so the
+ * existing parser (`remediation-parser.ts`) extracts the reference URL
+ * and compliance chips with zero frontend changes:
+ *
+ *
+ *
+ * More info:
+ *
+ * Compliance: nist 800-53 (AC-2); cis 1.2.0 (1.1)
+ */
+export function buildRemediationText(finding: SecurityHubRawFinding): string {
+ const parts: string[] = [];
+
+ const text = finding.Remediation?.Recommendation?.Text?.trim();
+ if (text) parts.push(text);
+
+ const url = finding.Remediation?.Recommendation?.Url?.trim();
+ if (url) parts.push(`More info: ${url}`);
+
+ const compliance = formatRelatedRequirements(
+ finding.Compliance?.RelatedRequirements,
+ );
+ if (compliance) parts.push(`Compliance: ${compliance}`);
+
+ return (
+ parts.join('\n\n') ||
+ 'No remediation guidance was provided for this Security Hub finding.'
+ );
+}
+
+/**
+ * Converts SecHub's `RelatedRequirements` strings (e.g.
+ * ["NIST.800-53.r5 AC-2", "CIS AWS Foundations Benchmark v1.2.0 1.1"])
+ * into the parser-compatible format used by GCP:
+ * "nist 800-53 (AC-2); cis 1.2.0 (1.1)"
+ *
+ * Returns '' when there are no related requirements (caller skips the
+ * Compliance: prefix in that case). Each requirement parses into
+ * " ()" — `remediation-parser.ts`
+ * `parseComplianceLine` handles malformed entries gracefully if anything
+ * here doesn't match the expected pattern.
+ */
+export function formatRelatedRequirements(
+ requirements: string[] | undefined,
+): string {
+ if (!requirements || requirements.length === 0) return '';
+ return requirements
+ .map(formatSingleRequirement)
+ .filter((s) => s.length > 0)
+ .join('; ');
+}
+
+function formatSingleRequirement(requirement: string): string {
+ const cleaned = requirement.trim();
+ if (!cleaned) return '';
+
+ // SecHub uses several formats; the most common are:
+ // "NIST.800-53.r5 AC-2"
+ // "CIS AWS Foundations Benchmark v1.2.0 1.1"
+ // "PCI DSS v3.2.1 8.2.3"
+ // "AWS Foundational Security Best Practices v1.0.0/EC2.2"
+ //
+ // AWS FSBP uses `/` between the version and the control id; normalize
+ // it to whitespace first so the same regex handles all four formats.
+ const normalized = cleaned.replace(/\//g, ' ');
+
+ // Match `STANDARD vVERSION CONTROL`. The first group must accept
+ // lowercase letters and hyphens — without them, 3 of the 4 documented
+ // formats (NIST, CIS, AWS FSBP) fall through to the raw fallback and
+ // never produce structured `standard version (control)` output for
+ // the compliance chips.
+ const standardMatch = normalized.match(
+ /^([A-Za-z][A-Za-z0-9 .\-]+?)\s+v?([\d.]+(?:[a-z]\d*)?)\s+([A-Za-z0-9.\-]+)$/,
+ );
+ if (standardMatch) {
+ const [, rawStandard, version, control] = standardMatch;
+ const standard = normalizeStandardName(rawStandard);
+ return `${standard} ${version} (${control})`;
}
+
+ // Fallback — keep the raw string for formats without an explicit
+ // version (e.g. NIST 800-53 where the revision is embedded in the
+ // standard name: "NIST.800-53.r5 AC-2"). The downstream parser
+ // surfaces it as a single chip label, which is still informative.
+ return cleaned;
+}
+
+function normalizeStandardName(value: string): string {
+ // Compact common multi-word framework names so chips render cleanly.
+ // ("PCI DSS" → "pci dss" via lowercase; the full "DSS" token stays
+ // as-is intentionally — the parser handles whitespace-separated parts.)
+ return value
+ .toLowerCase()
+ .replace(/aws foundations benchmark/g, '')
+ .replace(/foundational security best practices/g, 'fsbp')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .replace(/\.$/, '');
}
diff --git a/apps/api/src/cloud-security/reconciliation.service.spec.ts b/apps/api/src/cloud-security/reconciliation.service.spec.ts
index 75c91616e..5fe166473 100644
--- a/apps/api/src/cloud-security/reconciliation.service.spec.ts
+++ b/apps/api/src/cloud-security/reconciliation.service.spec.ts
@@ -301,4 +301,135 @@ describe('CloudReconciliationService.reconcile', () => {
}),
);
});
+
+ describe('scanMode safety — only diffs same-mode runs', () => {
+ it('passes scanMode from current run into the prior-run lookup', async () => {
+ // When SecHub mode is active, reconciliation must look up the prior
+ // SecHub run — not the prior comp_scanners run, which would mark
+ // every SecHub finding as "new" and every comp_scanners finding as
+ // "resolved" (both wrong).
+ dbMock.integrationCheckRun.findUnique.mockResolvedValueOnce({
+ id: 'icr_current',
+ connectionId: 'icn_aws',
+ status: 'success',
+ startedAt: CURRENT_RUN_TIME,
+ completedAt: CURRENT_RUN_TIME,
+ scannedServices: [],
+ scanMode: 'security_hub',
+ connection: { organizationId: 'org_1' },
+ results: [],
+ });
+ dbMock.integrationCheckRun.findFirst.mockResolvedValueOnce(null);
+
+ const service = new CloudReconciliationService(makeExceptionsStub());
+ await service.reconcile({ currentRunId: 'icr_current' });
+
+ // findFirst is called once: the prior-run lookup. Its where clause
+ // MUST scope by the same scanMode as the current run.
+ expect(dbMock.integrationCheckRun.findFirst).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({ scanMode: 'security_hub' }),
+ }),
+ );
+ });
+
+ it('passes null scanMode for GCP / Azure runs so they reconcile against each other', async () => {
+ // GCP / Azure runs have scanMode = null (no scan-mode concept). They
+ // must reconcile against each other (null === null). AWS rows
+ // ALWAYS have an explicit scanMode — pre-feature rows were
+ // backfilled to 'comp_scanners' by the schema migration.
+ dbMock.integrationCheckRun.findUnique.mockResolvedValueOnce({
+ id: 'icr_current',
+ connectionId: 'icn_gcp',
+ status: 'success',
+ startedAt: CURRENT_RUN_TIME,
+ completedAt: CURRENT_RUN_TIME,
+ scannedServices: [],
+ scanMode: null,
+ connection: { organizationId: 'org_1' },
+ results: [],
+ });
+ dbMock.integrationCheckRun.findFirst.mockResolvedValueOnce(null);
+
+ const service = new CloudReconciliationService(makeExceptionsStub());
+ await service.reconcile({ currentRunId: 'icr_current' });
+
+ expect(dbMock.integrationCheckRun.findFirst).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({ scanMode: null }),
+ }),
+ );
+ });
+
+ it('reconciles comp_scanners runs against backfilled comp_scanners history (post-deploy continuity)', async () => {
+ // After the deploy, every existing AWS customer's first scan should
+ // continue diffing against their prior history seamlessly. The
+ // migration backfills pre-feature AWS rows to 'comp_scanners', so
+ // the new scan (also 'comp_scanners') finds matching prior runs.
+ // Locks in that we DO NOT skip History events on the first post-
+ // deploy scan for existing customers.
+ setupRuns({
+ currentResults: [
+ makeResult({ findingKey: 'iam-no-mfa-john', resourceId: 'john', passed: true }),
+ ],
+ priorResults: [
+ makeResult({ findingKey: 'iam-no-mfa-john', resourceId: 'john', passed: false }),
+ ],
+ });
+ // Override findUnique to set scanMode explicitly on the current run.
+ dbMock.integrationCheckRun.findUnique.mockReset();
+ dbMock.integrationCheckRun.findUnique.mockResolvedValueOnce({
+ id: 'icr_current',
+ connectionId: 'icn_aws',
+ status: 'success',
+ startedAt: CURRENT_RUN_TIME,
+ completedAt: CURRENT_RUN_TIME,
+ scannedServices: [],
+ scanMode: 'comp_scanners',
+ connection: { organizationId: 'org_1' },
+ results: [
+ makeResult({ findingKey: 'iam-no-mfa-john', resourceId: 'john', passed: true }),
+ ],
+ });
+
+ const service = new CloudReconciliationService(makeExceptionsStub());
+ const result = await service.reconcile({ currentRunId: 'icr_current' });
+
+ expect(dbMock.integrationCheckRun.findFirst).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({ scanMode: 'comp_scanners' }),
+ }),
+ );
+ // Resolution should still be detected — backfilled prior run had
+ // scanMode='comp_scanners', matches the new run's scanMode.
+ expect(result.resolutions).toBe(1);
+ });
+
+ it('returns 0/0 when no prior run of the same mode exists (post-switch baseline)', async () => {
+ // After a customer switches modes, the next scan finds no matching
+ // prior run and returns a clean baseline. This is the same code
+ // path as a first-ever scan — confirmed here to lock in the
+ // contract that mode switches are safe.
+ dbMock.integrationCheckRun.findUnique.mockResolvedValueOnce({
+ id: 'icr_current',
+ connectionId: 'icn_aws',
+ status: 'success',
+ startedAt: CURRENT_RUN_TIME,
+ completedAt: CURRENT_RUN_TIME,
+ scannedServices: [],
+ scanMode: 'security_hub',
+ connection: { organizationId: 'org_1' },
+ results: [],
+ });
+ // Simulate no prior SecHub run found (only comp_scanners runs exist).
+ dbMock.integrationCheckRun.findFirst.mockResolvedValueOnce(null);
+
+ const service = new CloudReconciliationService(makeExceptionsStub());
+ const result = await service.reconcile({ currentRunId: 'icr_current' });
+
+ expect(result).toEqual({ resolutions: 0, regressions: 0, skipped: false });
+ expect(dbMock.findingResolution.create).not.toHaveBeenCalled();
+ expect(dbMock.findingRegression.create).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/apps/api/src/cloud-security/reconciliation.service.ts b/apps/api/src/cloud-security/reconciliation.service.ts
index 70c0e8370..89f4b0cd9 100644
--- a/apps/api/src/cloud-security/reconciliation.service.ts
+++ b/apps/api/src/cloud-security/reconciliation.service.ts
@@ -47,6 +47,11 @@ export class CloudReconciliationService {
startedAt: true,
status: true,
scannedServices: true,
+ // Used below to scope the prior-run lookup. AWS connections can
+ // switch between 'comp_scanners' and 'security_hub' modes; the two
+ // engines produce findingKeys in completely different namespaces,
+ // so cross-mode diffs would produce garbage resolutions.
+ scanMode: true,
connection: { select: { organizationId: true } },
results: {
select: {
@@ -90,6 +95,16 @@ export class CloudReconciliationService {
id: { not: currentRun.id },
status: 'success',
completedAt: { lt: currentRun.completedAt ?? currentRun.startedAt ?? new Date() },
+ // Only compare runs that used the same scan engine. The two AWS
+ // modes ('comp_scanners' vs 'security_hub') emit findingKeys in
+ // different namespaces — cross-mode diffs would mark every
+ // prior-mode finding as "resolved", which would be a lie. After a
+ // mode switch, the next scan finds no matching prior run and is
+ // treated as a fresh baseline (same as a first-ever scan).
+ //
+ // Null scanMode matches null scanMode — pre-feature historical
+ // runs (and all GCP/Azure runs) reconcile against each other.
+ scanMode: currentRun.scanMode,
},
orderBy: { completedAt: 'desc' },
select: {
diff --git a/apps/api/src/integration-platform/controllers/connections.controller.ts b/apps/api/src/integration-platform/controllers/connections.controller.ts
index 883177d31..88def11a4 100644
--- a/apps/api/src/integration-platform/controllers/connections.controller.ts
+++ b/apps/api/src/integration-platform/controllers/connections.controller.ts
@@ -463,6 +463,17 @@ export class ConnectionsController {
if (Array.isArray(credentials.regions)) {
metadata.regions = credentials.regions;
}
+ // AWS only — which scan engine the customer chose at onboarding
+ // (Comp AI scanners vs Security Hub). Read on every scan by
+ // cloud-security.service.ts. Customers can change it later from
+ // CloudSettingsModal via aws-scan-mode.service.updateMode.
+ if (
+ typeof credentials.awsScanMode === 'string' &&
+ (credentials.awsScanMode === 'comp_scanners' ||
+ credentials.awsScanMode === 'security_hub')
+ ) {
+ metadata.awsScanMode = credentials.awsScanMode;
+ }
// Store roleArn and externalId in metadata for pre-filling the configure form
// These are not secrets - roleArn is visible in AWS console, externalId is typically the org ID
if (typeof credentials.roleArn === 'string') {
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudSettingsModal.test.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudSettingsModal.test.tsx
index 09396ab0c..55d7c89b9 100644
--- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudSettingsModal.test.tsx
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudSettingsModal.test.tsx
@@ -22,11 +22,18 @@ vi.mock('@/hooks/use-api', () => ({
}),
}));
-// Mock useIntegrationMutations
+// Mock useIntegrationMutations + useIntegrationConnection (the latter is
+// used by the AWS scan-mode settings section).
vi.mock('@/hooks/use-integration-platform', () => ({
useIntegrationMutations: () => ({
deleteConnection: vi.fn(),
}),
+ useIntegrationConnection: () => ({
+ connection: { id: 'conn_test', metadata: {} },
+ isLoading: false,
+ error: undefined,
+ refresh: vi.fn(),
+ }),
}));
// Mock @trycompai/ui components
@@ -204,4 +211,37 @@ describe('CloudSettingsModal permission gating', () => {
render();
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument();
});
+
+ it('shows the AWS scan-mode switch button when the user has integration:update', () => {
+ // Regression guard for the cubic P2: the switch button is an UPDATE
+ // operation and must be gated by integration:update, NOT
+ // integration:delete. Admin has both, so the button shows.
+ setMockPermissions(ADMIN_PERMISSIONS);
+ render();
+ expect(
+ screen.getByRole('button', { name: /switch to/i }),
+ ).toBeInTheDocument();
+ });
+
+ it('hides the AWS scan-mode switch button for users without integration:update', () => {
+ // Auditor has read-only permissions — should not see Switch.
+ setMockPermissions(AUDITOR_PERMISSIONS);
+ render();
+ expect(
+ screen.queryByRole('button', { name: /switch to/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('shows the AWS scan-mode switch for update-only users (NOT delete users)', () => {
+ // The exact regression cubic flagged: a user with integration:update
+ // but WITHOUT integration:delete must still see the Switch button,
+ // because the API gates this endpoint on integration:update only.
+ setMockPermissions({ integration: ['read', 'update'] });
+ render();
+ expect(
+ screen.getByRole('button', { name: /switch to/i }),
+ ).toBeInTheDocument();
+ // ...and they correctly DO NOT see Disconnect (delete permission required).
+ expect(screen.queryByText('Disconnect')).not.toBeInTheDocument();
+ });
});
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudSettingsModal.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudSettingsModal.tsx
index be17668aa..ae35262cb 100644
--- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudSettingsModal.tsx
+++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudSettingsModal.tsx
@@ -1,7 +1,10 @@
'use client';
import { useApi } from '@/hooks/use-api';
-import { useIntegrationMutations } from '@/hooks/use-integration-platform';
+import {
+ useIntegrationConnection,
+ useIntegrationMutations,
+} from '@/hooks/use-integration-platform';
import { usePermissions } from '@/hooks/use-permissions';
import {
Dialog,
@@ -20,6 +23,8 @@ import {
import { TrashCan } from '@trycompai/design-system/icons';
import { useState } from 'react';
import { toast } from 'sonner';
+import { ScanModeSwitchDialog } from './ScanModeSwitchDialog';
+import type { AwsScanModeChoice } from '../../integrations/[slug]/components/AwsScanModeStep';
interface CloudProvider {
id: string;
@@ -58,6 +63,12 @@ export function CloudSettingsModal({
const api = useApi();
const { hasPermission } = usePermissions();
const canDelete = hasPermission('integration', 'delete');
+ // Scan-mode switch is an UPDATE operation (the API endpoint is
+ // gated by integration:update — see CloudSecurityController.updateAwsScanMode),
+ // so the UI must use update permission, NOT delete permission. Using
+ // canDelete here would silently block valid update users from seeing
+ // the "Switch" button even though the API would accept their request.
+ const canUpdate = hasPermission('integration', 'update');
const [activeProvider, setActiveProvider] = useState(connectedProviders[0]?.connectionId || '');
const [isDeleting, setIsDeleting] = useState(false);
const { deleteConnection } = useIntegrationMutations();
@@ -124,6 +135,7 @@ export function CloudSettingsModal({
@@ -138,11 +150,13 @@ export function CloudSettingsModal({
function ConnectionTab({
provider,
canDelete,
+ canUpdate,
isDeleting,
onDisconnect,
}: {
provider: CloudProvider;
canDelete: boolean;
+ canUpdate: boolean;
isDeleting: boolean;
onDisconnect: (p: CloudProvider) => void;
}) {
@@ -177,6 +191,19 @@ function ConnectionTab({
: 'To update credentials, disconnect and reconnect with your Microsoft account.'}
+ {/* AWS-only — scan engine switcher. Lets the customer change between
+ Comp AI scanners and Security Hub on an existing connection.
+ Surfaces the current mode + a "Change" button that opens
+ ScanModeSwitchDialog with the right confirmation copy.
+
+ Gated by `canUpdate` (integration:update) — matches the API
+ endpoint at PATCH /v1/cloud-security/connections/:id/scan-mode.
+ Using canDelete here would silently block users who legitimately
+ have update permission. */}
+ {provider.id === 'aws' && !provider.isLegacy && (
+
+ )}
+
{canDelete && (