From 8b7fa7edcc2f56f4a5322b398d3d81396cc8d2d5 Mon Sep 17 00:00:00 2001
From: Tofik Hasanov
Date: Tue, 19 May 2026 11:05:07 -0400
Subject: [PATCH 1/6] feat(cloud-tests): aws security hub as alternative scan
engine
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Customer Blazestack (relayed by Joe in CX) uses AWS Security Hub for
NIST 800-53 compliance and asked us to read findings from their
existing Security Hub deployment. This change makes Security Hub a
first-class scan engine alongside our adapter-based scanning, switchable
at connect time or later in settings.
## What changed
A connection's awsScanMode determines which engine runs on each scan:
- 'comp_scanners' (default): our 49 service adapters run, today's
behavior — byte-for-byte identical when the field is unset or
explicitly 'comp_scanners'.
- 'security_hub': only the SecurityHubAdapter runs, reads findings
from GetFindings, surfaces native NIST/CIS/PCI control mappings,
uses the same Fix pipeline as adapter findings.
The two modes are mutually exclusive by code structure (different
methods on AWSSecurityService) — there is no runtime state where both
run, so duplicate findings are impossible.
## Files an engineer can follow the thread through
- aws-scan-mode.ts — type + helper (single source of truth)
- aws-scan-mode.service.ts — settings switcher
- aws-scan-mode.spec.ts — type tests
- aws-security.service.ts — scanViaAdapters / scanViaSecurityHub split
- providers/aws/security-hub.adapter.ts — finding mapping + findingKey derivation
- cloud-security.service.ts — reads mode from metadata, persists on each run
- reconciliation.service.ts — same-mode-only diffs (safety against switch)
- cloud-security.controller.ts — PATCH /connections/:id/scan-mode
- dto/update-scan-mode.dto.ts — validated request body
- IntegrationCheckRun.scanMode column — per-run engine attribution
- EmptyStateOnboarding.tsx + AwsScanModeStep — Step 1 at AWS connect time
- CloudSettingsModal + ScanModeSwitchDialog — change later from settings
- RemediationDialog (SecurityHubFixDisclosure) — banner on SecHub-sourced fixes
## Why current AWS logic is safe
- Default for missing field is 'comp_scanners' — every existing
connection in production continues with today's behavior.
- SecurityHubAdapter is NOT in the registered adapters array; it can
only run when the customer explicitly opts in.
- Reconciliation now scopes prior-run lookup by scanMode, so a mode
switch produces a clean baseline instead of fake "resolved" rows.
- The AI fix prompt is input-agnostic (verified) — SecHub findings
flow through the same pipeline without prompt changes.
- The remediation-parser already extracts reference-URL and
compliance chips from GCP's `More info: / Compliance:` text format;
the SecHub adapter emits text in the same format so chips render
with zero frontend changes.
## Tests
- aws-scan-mode.spec.ts — 6 tests (resolve + default)
- security-hub.adapter.spec.ts — 23 tests (helpers + mapping)
- reconciliation.service.spec.ts — +3 tests (same-mode safety)
- AwsScanModeStep.test.tsx — 6 tests (picker UI)
Cloud-security API tests: 157 passing (1 pre-existing TLS env failure
in remediation.controller.spec.ts unchanged).
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../cloud-security/aws-scan-mode.service.ts | 104 ++++++
.../src/cloud-security/aws-scan-mode.spec.ts | 51 +++
apps/api/src/cloud-security/aws-scan-mode.ts | 44 +++
.../cloud-security/cloud-security-audit.ts | 3 +-
.../cloud-security.controller.ts | 32 ++
.../cloud-security/cloud-security.module.ts | 2 +
.../cloud-security/cloud-security.service.ts | 21 +-
.../dto/update-scan-mode.dto.ts | 20 ++
.../providers/aws-security.service.ts | 179 ++++++++--
.../aws/security-hub.adapter.spec.ts | 225 ++++++++++++
.../providers/aws/security-hub.adapter.ts | 322 ++++++++++++++----
.../reconciliation.service.spec.ts | 85 +++++
.../cloud-security/reconciliation.service.ts | 15 +
.../controllers/connections.controller.ts | 11 +
.../components/CloudSettingsModal.test.tsx | 9 +-
.../components/CloudSettingsModal.tsx | 86 ++++-
.../components/CloudTestsSection.tsx | 8 +
.../components/RemediationDialog.tsx | 38 +++
.../components/ScanModeSwitchDialog.tsx | 153 +++++++++
.../components/AwsScanModeStep.test.tsx | 74 ++++
.../[slug]/components/AwsScanModeStep.tsx | 146 ++++++++
.../components/EmptyStateOnboarding.tsx | 73 ++--
.../migration.sql | 2 +
.../prisma/schema/integration-platform.prisma | 9 +
24 files changed, 1592 insertions(+), 120 deletions(-)
create mode 100644 apps/api/src/cloud-security/aws-scan-mode.service.ts
create mode 100644 apps/api/src/cloud-security/aws-scan-mode.spec.ts
create mode 100644 apps/api/src/cloud-security/aws-scan-mode.ts
create mode 100644 apps/api/src/cloud-security/dto/update-scan-mode.dto.ts
create mode 100644 apps/api/src/cloud-security/providers/aws/security-hub.adapter.spec.ts
create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/components/ScanModeSwitchDialog.tsx
create mode 100644 apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/AwsScanModeStep.test.tsx
create mode 100644 apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/AwsScanModeStep.tsx
create mode 100644 packages/db/prisma/migrations/20260519143715_integration_check_run_scan_mode/migration.sql
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 0000000000..d8811eeadd
--- /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 0000000000..95701b206c
--- /dev/null
+++ b/apps/api/src/cloud-security/aws-scan-mode.spec.ts
@@ -0,0 +1,51 @@
+import {
+ 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');
+ });
+ });
+});
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 0000000000..3907878727
--- /dev/null
+++ b/apps/api/src/cloud-security/aws-scan-mode.ts
@@ -0,0 +1,44 @@
+/**
+ * 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';
+
+/** 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 9e3f4ea0b9..f28e675043 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 862b9ec649..a0286c4a0a 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 52618413eb..9b5b5497ce 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 c815a3fabb..39d700a767 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 0000000000..fd92285ef6
--- /dev/null
+++ b/apps/api/src/cloud-security/dto/update-scan-mode.dto.ts
@@ -0,0 +1,20 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsIn, IsString } from 'class-validator';
+import 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.
+ */
+export class UpdateAwsScanModeDto {
+ @ApiProperty({
+ description: 'Which scan engine to use for this AWS connection.',
+ enum: ['comp_scanners', 'security_hub'],
+ example: 'security_hub',
+ })
+ @IsString()
+ @IsIn(['comp_scanners', 'security_hub'])
+ 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 bf6c825935..fa0f0a35fd 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 0000000000..d78d1a4aea
--- /dev/null
+++ b/apps/api/src/cloud-security/providers/aws/security-hub.adapter.spec.ts
@@ -0,0 +1,225 @@
+import {
+ buildRemediationText,
+ deriveFindingKey,
+ formatRelatedRequirements,
+ mapSecurityHubFinding,
+ SECURITY_HUB_SERVICE_ID,
+ type SecurityHubRawFinding,
+} from './security-hub.adapter';
+
+describe('security-hub.adapter helpers', () => {
+ describe('deriveFindingKey', () => {
+ it('extracts the trailing control id from a foundational best-practices GeneratorId', () => {
+ expect(
+ deriveFindingKey('aws-foundational-security-best-practices/v/1.0.0/EC2.13'),
+ ).toBe('aws-securityhub-ec2.13');
+ });
+
+ it('extracts the trailing control id from a CIS GeneratorId', () => {
+ expect(
+ deriveFindingKey('cis-aws-foundations-benchmark/v/1.2.0/1.1'),
+ ).toBe('aws-securityhub-1.1');
+ });
+
+ it('extracts the trailing control id from a NIST GeneratorId', () => {
+ expect(deriveFindingKey('nist-800-53/r/5/AC-2')).toBe(
+ 'aws-securityhub-ac-2',
+ );
+ });
+
+ it('sanitizes characters not safe for use in identifiers', () => {
+ expect(deriveFindingKey('weird:control id with spaces!')).toMatch(
+ /^aws-securityhub-/,
+ );
+ });
+
+ it('produces a stable key (no timestamps / randomness) so reconciliation can diff scans', () => {
+ 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('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('formats a NIST 800-53 requirement in parser-compatible form', () => {
+ expect(formatRelatedRequirements(['NIST.800-53.r5 AC-2'])).toMatch(
+ /nist.*AC-2/i,
+ );
+ });
+
+ 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-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 3c082933ee..d44a3aa6ac 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,229 @@ 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',
+};
- 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;
+/**
+ * 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})`;
+
+ 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: take the trailing control identifier (last `/`-delimited
+ * segment) since that's what makes the finding semantically unique.
+ * Falls back to a sanitized form of the full GeneratorId, then to a
+ * generic key, so we ALWAYS produce a key — the Fix button gates on
+ * its existence, so 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 lastSegment = trimmed.split('/').pop() ?? trimmed;
+ const sanitized = sanitizeKeySegment(lastSegment);
+ if (sanitized) return `aws-securityhub-${sanitized}`;
+
+ return `aws-securityhub-${sanitizeKeySegment(trimmed) || '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"
+ // We try a few patterns then fall back to a sensible default so we
+ // surface SOMETHING rather than drop a real compliance mapping.
+
+ const standardMatch = cleaned.match(
+ /^([A-Z][A-Z0-9 .]+?)(?:\s+v?([\d.]+(?:[a-z]\d*)?))?\s+([A-Za-z0-9.\-]+)$/,
+ );
+ if (standardMatch) {
+ const [, rawStandard, version, control] = standardMatch;
+ const standard = normalizeStandardName(rawStandard);
+ const ver = version ?? 'unspecified';
+ return `${standard} ${ver} (${control})`;
}
+
+ // Fallback — keep the raw string so we don't silently drop data.
+ return cleaned;
+}
+
+function normalizeStandardName(value: string): string {
+ // Compact common multi-word framework names so chips render cleanly.
+ return value
+ .toLowerCase()
+ .replace(/aws foundations benchmark/g, '')
+ .replace(/dss/g, 'dss')
+ .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 75c91616e6..4d8041dfc6 100644
--- a/apps/api/src/cloud-security/reconciliation.service.spec.ts
+++ b/apps/api/src/cloud-security/reconciliation.service.spec.ts
@@ -301,4 +301,89 @@ 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 legacy / non-AWS runs so they reconcile against each other', async () => {
+ // Pre-feature runs and GCP/Azure runs have scanMode = null. They
+ // must still reconcile against each other (null === null).
+ 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('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 70c0e8370c..89f4b0cd96 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 883177d315..88def11a48 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 09396ab0c8..d2a8ae74a6 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
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 be17668aa9..7ef7bdd174 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;
@@ -177,6 +182,14 @@ 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. */}
+ {provider.id === 'aws' && !provider.isLegacy && (
+
+ )}
+
{canDelete && (