Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions apps/api/src/cloud-security/aws-scan-mode.service.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown> = {
...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 };
}
}
77 changes: 77 additions & 0 deletions apps/api/src/cloud-security/aws-scan-mode.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
});
54 changes: 54 additions & 0 deletions apps/api/src/cloud-security/aws-scan-mode.ts
Original file line number Diff line number Diff line change
@@ -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';
}
3 changes: 2 additions & 1 deletion apps/api/src/cloud-security/cloud-security-audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ interface CloudSecurityAuditParams {
| 'rollback_failed'
| 'service_toggled'
| 'exception_marked'
| 'exception_revoked';
| 'exception_revoked'
| 'scan_mode_changed';
description: string;
metadata?: Record<string, unknown>;
}
Expand Down
32 changes: 32 additions & 0 deletions apps/api/src/cloud-security/cloud-security.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Post,
Get,
Delete,
Patch,
Param,
Query,
Body,
Expand All @@ -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 {
Expand All @@ -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')
Expand Down Expand Up @@ -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')
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/cloud-security/cloud-security.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -40,6 +41,7 @@ import { AuthModule } from '../auth/auth.module';
CloudExceptionService,
CloudReconciliationService,
CloudHistoryService,
CloudAwsScanModeService,
],
exports: [CloudSecurityService],
})
Expand Down
21 changes: 20 additions & 1 deletion apps/api/src/cloud-security/cloud-security.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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<string, unknown> | null) ?? {};
awsScanMode = resolveAwsScanMode(metadata.awsScanMode);
findings = await this.awsService.scanSecurityFindings(
credentials,
variables,
enabledServices,
awsScanMode,
);
break;
}
case 'azure':
findings = await this.azureService.scanSecurityFindings(
credentials,
Expand All @@ -282,6 +296,7 @@ export class CloudSecurityService {
connectionId,
providerSlug,
findings,
awsScanMode,
);

// Reconcile against the prior scan to record resolutions and regressions.
Expand Down Expand Up @@ -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<string> {
const passedCount = findings.filter((f) => f.passed).length;
const failedCount = findings.filter((f) => !f.passed).length;
Expand Down Expand Up @@ -607,6 +625,7 @@ export class CloudSecurityService {
passedCount,
failedCount,
scannedServices,
scanMode: awsScanMode,
},
});

Expand Down
Loading
Loading