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 && ( + )} + + + { + refresh(); + }} + /> + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx index 4908df52f..ec3d58383 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx @@ -205,6 +205,11 @@ export function CloudTestsSection({ guidedSteps?: string[]; risk?: string; description?: string; + // Set when the finding came from AWS Security Hub — surfaces a + // disclosure banner in the Fix dialog since the AI plan is generated + // from AWS's remediation text (quality varies) and SecHub re-evaluates + // findings on its own schedule. + fromSecurityHub?: boolean; } | null>(null); const [showSetupDialog, setShowSetupDialog] = useState(false); const { hasPermission } = usePermissions(); @@ -813,6 +818,7 @@ export function CloudTestsSection({ checkResultId: finding.id, remediationKey: key, findingTitle: finding.title ?? 'Finding', + fromSecurityHub: finding.serviceId === 'security-hub', }) } onSetup={() => setShowSetupDialog(true)} @@ -918,6 +924,7 @@ export function CloudTestsSection({ checkResultId: finding.id, remediationKey: key, findingTitle: finding.title ?? 'Finding', + fromSecurityHub: finding.serviceId === 'security-hub', }) } onSetup={() => setShowSetupDialog(true)} @@ -1066,6 +1073,7 @@ export function CloudTestsSection({ guidedSteps={remediationTarget.guidedSteps} risk={remediationTarget.risk} description={remediationTarget.description} + fromSecurityHub={remediationTarget.fromSecurityHub} onComplete={() => { toast.message('Re-scanning to verify fix...'); handleRunScan(); diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationDialog.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationDialog.tsx index 7b6676a5a..de9b05103 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationDialog.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/RemediationDialog.tsx @@ -42,6 +42,14 @@ interface RemediationDialogProps { guidedSteps?: string[]; risk?: string; description?: string; + /** + * True when the underlying finding came from AWS Security Hub rather + * than one of our service adapters. Surfaces a disclosure banner — + * SecHub remediation text quality varies and SecHub re-evaluates + * findings on its own schedule, so the customer should verify in the + * AWS console after applying. + */ + fromSecurityHub?: boolean; onComplete?: () => void; } @@ -88,6 +96,33 @@ function RichText({ text }: { text: string }) { ); } +/** + * Disclosure shown in the Fix dialog when the underlying finding came + * from AWS Security Hub. Two things customers need to know that aren't + * true for our own adapter findings: + * + * 1. AI fix plans are generated from AWS's remediation text, which + * varies in specificity. The plan may need adjustment after + * reviewing in the AWS console. + * 2. Security Hub re-evaluates findings on its own schedule (hours, + * not minutes). After a successful Fix, the SecHub-side finding + * may take time to clear even though the underlying issue is gone. + */ +function SecurityHubFixDisclosure() { + return ( +
+

Security Hub finding

+

+ This fix was generated from AWS Security Hub remediation + guidance. Verify the result in your AWS console after applying — + Security Hub re-evaluates findings on its own schedule, so the + finding may take a few hours to clear there even after a + successful fix. +

+
+ ); +} + function CodeBlock({ code }: { code: string }) { const [copied, setCopied] = useState(false); const handleCopy = () => { @@ -270,6 +305,7 @@ export function RemediationDialog({ guidedSteps, risk, description, + fromSecurityHub, onComplete, }: RemediationDialogProps) { const [preview, setPreview] = useState(null); @@ -468,6 +504,8 @@ export function RemediationDialog({ + {fromSecurityHub && } +
{/* Applying state — shown while executing */} {isExecuting && !succeeded && !error && ( diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ScanModeSwitchDialog.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ScanModeSwitchDialog.tsx new file mode 100644 index 000000000..c23a0066b --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ScanModeSwitchDialog.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { useApi } from '@/hooks/use-api'; +import { Button } from '@trycompai/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@trycompai/ui/dialog'; +import { Loader2 } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { mutate as globalMutate } from 'swr'; + +import { + type AwsScanModeChoice, +} from '../../integrations/[slug]/components/AwsScanModeStep'; + +const MODE_LABEL: Record = { + comp_scanners: 'Comp AI Scanners', + security_hub: 'AWS Security Hub', +}; + +export interface ScanModeSwitchDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + connectionId: string; + currentMode: AwsScanModeChoice; + targetMode: AwsScanModeChoice; + /** Called after a successful switch so the parent can refetch. */ + onSwitched?: () => void; +} + +/** + * Confirmation dialog for switching the AWS scan engine on an existing + * connection. Posts to `PATCH /v1/cloud-security/connections/:id/scan-mode` + * and invalidates SWR caches so the findings list + history tab pick up + * the change on the next scan. + * + * Switching is non-destructive but has real consequences worth flagging: + * 1. The next scan is a fresh baseline — reconciliation only diffs + * same-mode runs (see reconciliation.service.ts), so the customer + * shouldn't expect "resolved" events from the prior engine. + * 2. Existing exceptions stay in the database but may not match + * findings from the new engine (different checkId namespaces). + */ +export function ScanModeSwitchDialog({ + open, + onOpenChange, + connectionId, + currentMode, + targetMode, + onSwitched, +}: ScanModeSwitchDialogProps) { + const api = useApi(); + const [submitting, setSubmitting] = useState(false); + + const handleConfirm = async () => { + setSubmitting(true); + const response = await api.patch( + `/v1/cloud-security/connections/${connectionId}/scan-mode`, + { mode: targetMode }, + ); + setSubmitting(false); + + if (response.error) { + const message = + typeof response.error === 'string' + ? response.error + : 'Could not switch scan engine — please try again.'; + toast.error(message); + return; + } + + toast.success(`Scan engine switched to ${MODE_LABEL[targetMode]}`); + // Invalidate any cached findings + history for this connection so the + // UI re-fetches with the new mode on the next access. + globalMutate( + (key) => + Array.isArray(key) && + typeof key[0] === 'string' && + (key[0].startsWith('/v1/cloud-security/findings') || + key[0].startsWith('/v1/cloud-security/history') || + key[0].startsWith('/v1/cloud-security/providers')), + ); + onSwitched?.(); + onOpenChange(false); + }; + + return ( + !submitting && onOpenChange(next)}> + + + Switch scan engine? + + Change which engine produces findings for this AWS connection. + + + +
+
+
+

From

+

{MODE_LABEL[currentMode]}

+
+ +
+

To

+

{MODE_LABEL[targetMode]}

+
+
+ +
+

What changes:

+
    +
  • + The next scan is a fresh baseline — resolutions and + regressions only compare same-engine runs. +
  • +
  • + Existing exceptions stay marked but may not match findings + from the new engine (different control identifiers). +
  • +
  • + In-flight remediations from the previous engine continue + independently. +
  • +
+
+
+ +
+ + +
+
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/AwsScanModeStep.test.tsx b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/AwsScanModeStep.test.tsx new file mode 100644 index 000000000..a0798133e --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/AwsScanModeStep.test.tsx @@ -0,0 +1,74 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { + AwsScanModeStep, + DEFAULT_AWS_SCAN_MODE_CHOICE, +} from './AwsScanModeStep'; + +describe('AwsScanModeStep', () => { + it('renders both scan engine options', () => { + render( + {}} />, + ); + expect(screen.getByText('Comp AI Scanners')).toBeInTheDocument(); + expect(screen.getByText('AWS Security Hub')).toBeInTheDocument(); + }); + + it('marks the value prop as selected (controlled component)', () => { + const { rerender } = render( + {}} />, + ); + + const compRadio = screen.getByRole('radio', { name: /comp ai scanners/i }); + const sechubRadio = screen.getByRole('radio', { name: /aws security hub/i }); + expect(compRadio).toBeChecked(); + expect(sechubRadio).not.toBeChecked(); + + rerender( {}} />); + expect(compRadio).not.toBeChecked(); + expect(sechubRadio).toBeChecked(); + }); + + it('calls onChange with the new mode when the customer picks the other option', () => { + const onChange = vi.fn(); + render(); + + fireEvent.click( + screen.getByRole('radio', { name: /aws security hub/i }), + ); + expect(onChange).toHaveBeenCalledWith('security_hub'); + }); + + it('does not call onChange when re-selecting the same option', () => { + // Defensive — re-clicking the selected radio fires onChange with the + // same value (browser semantics), which is fine but worth noting. + const onChange = vi.fn(); + render(); + + // Click on the already-selected one — radio onChange does NOT fire + // when re-selecting the checked option, so onChange stays at 0. + fireEvent.click( + screen.getByRole('radio', { name: /comp ai scanners/i }), + ); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('disables all radios when the disabled prop is true', () => { + render( + {}} + disabled + />, + ); + const radios = screen.getAllByRole('radio'); + radios.forEach((radio) => expect(radio).toBeDisabled()); + }); + + it('exposes a single source of truth for the default mode', () => { + // Guarded with a test so changing the default is intentional — + // shifting it would silently flip every new connection's scan + // engine choice. + expect(DEFAULT_AWS_SCAN_MODE_CHOICE).toBe('comp_scanners'); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/AwsScanModeStep.tsx b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/AwsScanModeStep.tsx new file mode 100644 index 000000000..13e22bf94 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/AwsScanModeStep.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { Cloud, ShieldCheck } from 'lucide-react'; + +/** + * String constants for the two AWS scan-engine modes. Mirrors the + * AwsScanMode type in apps/api/src/cloud-security/aws-scan-mode.ts — + * the single source of truth for these values lives on the API side + * (because that's what gets validated server-side), but we duplicate + * the literals here so the onboarding UI doesn't import API code. + * + * If you add a new mode, update BOTH files and the type assertion in + * the parent EmptyStateOnboarding that passes this to createConnection. + */ +export type AwsScanModeChoice = 'comp_scanners' | 'security_hub'; + +export const DEFAULT_AWS_SCAN_MODE_CHOICE: AwsScanModeChoice = 'comp_scanners'; + +interface AwsScanModeStepProps { + /** Currently-selected mode. */ + value: AwsScanModeChoice; + /** Called when the customer picks a different option. */ + onChange: (next: AwsScanModeChoice) => void; + /** When true, disables interaction (e.g., during connection creation). */ + disabled?: boolean; +} + +/** + * AWS-onboarding step that lets the customer choose which engine + * performs their cloud security scans. Two radio cards, mutually + * exclusive: + * + * 1. Comp AI Scanners (default) — our service adapters, free, + * full Fix button support. + * 2. AWS Security Hub — read findings from the customer's existing + * Security Hub deployment, surface native NIST 800-53 / CIS / PCI + * mapping, Fix button available with a small disclosure. + * + * Pure UI — no API calls, no side effects. The parent owns state and + * passes the choice into the createConnection payload via the + * `awsScanMode` variable. + */ +export function AwsScanModeStep({ + value, + onChange, + disabled, +}: AwsScanModeStepProps) { + return ( +
+ AWS scan engine + onChange('comp_scanners')} + icon={} + title="Comp AI Scanners" + subtitle="Recommended" + description={ + 'Run our security checks directly against your AWS account. ' + + 'No additional AWS cost. Full Fix button support. Covers IAM, ' + + 'CloudTrail, S3, EC2, RDS, KMS, GuardDuty, and 40+ other services.' + } + badges={['Free', 'Fix button supported']} + /> + onChange('security_hub')} + icon={} + title="AWS Security Hub" + subtitle="Read findings from your existing Security Hub" + description={ + 'Use the findings your Security Hub deployment already produces. ' + + 'Surfaces NIST 800-53 / CIS / PCI control mappings natively. ' + + 'Requires Security Hub to be enabled in your AWS account ' + + '(AWS bills per finding ingested + per control checked).' + } + badges={[ + 'Native NIST / CIS / PCI mapping', + 'Requires Security Hub subscription', + ]} + /> +
+ ); +} + +function ScanModeCard({ + modeValue, + selected, + onSelect, + icon, + title, + subtitle, + description, + badges, +}: { + modeValue: AwsScanModeChoice; + selected: boolean; + onSelect: () => void; + icon: React.ReactNode; + title: string; + subtitle: string; + description: string; + badges: string[]; +}) { + return ( + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/EmptyStateOnboarding.tsx b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/EmptyStateOnboarding.tsx index 18091edcf..f5a741f46 100644 --- a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/EmptyStateOnboarding.tsx +++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/EmptyStateOnboarding.tsx @@ -14,6 +14,11 @@ import { import { ArrowRight, Shield } from 'lucide-react'; import { useCallback, useMemo, useState } from 'react'; import { toast } from 'sonner'; +import { + AwsScanModeStep, + DEFAULT_AWS_SCAN_MODE_CHOICE, + type AwsScanModeChoice, +} from './AwsScanModeStep'; // ─── Primitives ───────────────────────────────────────────────────────── @@ -497,6 +502,12 @@ function CloudSetup({ const [connecting, setConnecting] = useState(false); const [credentials, setCredentials] = useState>({}); const [errors, setErrors] = useState>({}); + // AWS only — which scan engine the customer is choosing for this + // connection. Sent in createConnection's credentials payload as the + // `awsScanMode` variable, then read on every scan in cloud-security.service. + const [awsScanMode, setAwsScanMode] = useState( + DEFAULT_AWS_SCAN_MODE_CHOICE, + ); const allFields = provider.credentialFields ?? []; const visibleFields = allFields.filter( @@ -525,6 +536,13 @@ function CloudSetup({ const arnMatch = String(finalCredentials.roleArn ?? '').match(/:(\d{12}):/); finalCredentials.connectionName = arnMatch ? `AWS ${arnMatch[1]}` : 'AWS Account'; } + // AWS only — persist the customer's scan engine choice on the + // connection. The scan service reads this from variables on every + // run. resolveAwsScanMode() handles the missing-field case for + // every other provider and for pre-feature historical connections. + if (provider.id === 'aws') { + finalCredentials.awsScanMode = awsScanMode; + } const newErrors: Record = {}; for (const field of allFields) { @@ -558,7 +576,7 @@ function CloudSetup({ } finally { setConnecting(false); } - }, [allFields, credentials, createConnection, provider, orgId, onConnected]); + }, [allFields, awsScanMode, credentials, createConnection, provider, orgId, onConnected]); const connectionFields = visibleFields.filter( (f) => f.id !== 'remediationRoleArn' && f.id !== 'regions' && f.id !== 'awsType', @@ -601,25 +619,42 @@ function CloudSetup({ > {/* ─── Left: Unified setup flow ─── */}
- {awsTypeFields.length > 0 && ( + {/* Step 1 — AWS only — Scan engine choice. Mutually exclusive + between Comp AI scanners (default) and AWS Security Hub. + Customers can switch later from CloudSettingsModal. */} + {provider.id === 'aws' && (
- {/* Step 1 */} - - {awsTypeFields.map((field) => ( - updateCredential(field.id, v)} - /> - ))} + +
)} - {/* Step 2 */} + + {/* Step 2 — AWS only — Commercial vs GovCloud */} + {awsTypeFields.length > 0 && ( + <> + {provider.id === 'aws' &&
} +
+ + {awsTypeFields.map((field) => ( + updateCredential(field.id, v)} + /> + ))} +
+ + )} + {/* Step 3 — AWS only — IAM role creation script */} {provider.setupScript && (
- + - {/* Step 3 */} + {/* Step 4 — Connection details (Role ARN etc.) */}
- + {connectionFields.map((field) => ( - {/* Step 4 */} + {/* Step 5 — Scan configuration (regions) */} {regionFields.length > 0 && ( <>
- + {regionFields.map((field) => (