Skip to content

Commit 84e61e7

Browse files
authored
Merge pull request #2873 from trycompai/main
[comp] Production Deploy
2 parents ec5a1cf + 6059fcc commit 84e61e7

24 files changed

Lines changed: 1849 additions & 120 deletions
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import {
2+
BadRequestException,
3+
ForbiddenException,
4+
Injectable,
5+
Logger,
6+
} from '@nestjs/common';
7+
import { db, Prisma } from '@db';
8+
import {
9+
type AwsScanMode,
10+
resolveAwsScanMode,
11+
} from './aws-scan-mode';
12+
import { logCloudSecurityActivity } from './cloud-security-audit';
13+
14+
/**
15+
* Manages the AWS scan-engine choice on a connection. Lives separately
16+
* from CloudSecurityService so the scan-mode concern has one obvious
17+
* home — engineers grep for `aws-scan-mode.service` and find every
18+
* read / write site at once.
19+
*/
20+
@Injectable()
21+
export class CloudAwsScanModeService {
22+
private readonly logger = new Logger(CloudAwsScanModeService.name);
23+
24+
/**
25+
* Update the scan engine on an AWS connection. Validates:
26+
* - The connection exists and belongs to the caller's org.
27+
* - The connection is an AWS provider (other providers don't have
28+
* a scan-mode concept).
29+
*
30+
* Idempotent — re-applying the same mode is a successful no-op.
31+
*
32+
* Writes an audit-log entry so a mode change is traceable later.
33+
* Reconciliation reads `IntegrationCheckRun.scanMode` per run, so
34+
* after this update the next scan automatically uses the new engine
35+
* and reconciliation only diffs same-mode runs (see
36+
* `reconciliation.service.ts`).
37+
*/
38+
async updateMode(params: {
39+
connectionId: string;
40+
organizationId: string;
41+
userId: string;
42+
mode: AwsScanMode;
43+
}): Promise<{ mode: AwsScanMode }> {
44+
const connection = await db.integrationConnection.findFirst({
45+
where: {
46+
id: params.connectionId,
47+
organizationId: params.organizationId,
48+
},
49+
select: {
50+
id: true,
51+
metadata: true,
52+
provider: { select: { slug: true } },
53+
},
54+
});
55+
56+
if (!connection) {
57+
throw new ForbiddenException(
58+
'Connection not found or does not belong to your organization.',
59+
);
60+
}
61+
if (connection.provider?.slug !== 'aws') {
62+
throw new BadRequestException(
63+
'Scan engine choice is only available for AWS connections.',
64+
);
65+
}
66+
67+
const metadata = (connection.metadata ?? {}) as Record<string, unknown>;
68+
const previousMode = resolveAwsScanMode(metadata.awsScanMode);
69+
70+
if (previousMode === params.mode) {
71+
// Idempotent no-op — return the current mode without writing.
72+
return { mode: params.mode };
73+
}
74+
75+
const nextMetadata: Record<string, unknown> = {
76+
...metadata,
77+
awsScanMode: params.mode,
78+
};
79+
80+
await db.integrationConnection.update({
81+
where: { id: connection.id },
82+
data: {
83+
metadata: nextMetadata as unknown as Prisma.InputJsonValue,
84+
},
85+
});
86+
87+
await logCloudSecurityActivity({
88+
organizationId: params.organizationId,
89+
userId: params.userId,
90+
connectionId: connection.id,
91+
action: 'scan_mode_changed',
92+
description: `Switched AWS scan engine: ${previousMode} → ${params.mode}`,
93+
metadata: {
94+
previousMode,
95+
newMode: params.mode,
96+
},
97+
});
98+
99+
this.logger.log(
100+
`Connection ${connection.id} scan mode: ${previousMode} → ${params.mode}`,
101+
);
102+
return { mode: params.mode };
103+
}
104+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import {
2+
AWS_SCAN_MODES,
3+
DEFAULT_AWS_SCAN_MODE,
4+
isSecurityHubMode,
5+
resolveAwsScanMode,
6+
} from './aws-scan-mode';
7+
8+
describe('aws-scan-mode', () => {
9+
describe('resolveAwsScanMode', () => {
10+
it('returns "security_hub" when the value is exactly that string', () => {
11+
expect(resolveAwsScanMode('security_hub')).toBe('security_hub');
12+
});
13+
14+
it('returns the default for "comp_scanners"', () => {
15+
expect(resolveAwsScanMode('comp_scanners')).toBe('comp_scanners');
16+
});
17+
18+
it('returns the default for unknown strings', () => {
19+
// Defensive — typos / future modes / corrupted JSON variables must
20+
// never accidentally activate Security Hub mode.
21+
expect(resolveAwsScanMode('SECURITY_HUB')).toBe(DEFAULT_AWS_SCAN_MODE);
22+
expect(resolveAwsScanMode('securityhub')).toBe(DEFAULT_AWS_SCAN_MODE);
23+
expect(resolveAwsScanMode('xyz')).toBe(DEFAULT_AWS_SCAN_MODE);
24+
});
25+
26+
it('returns the default for missing / non-string values', () => {
27+
expect(resolveAwsScanMode(undefined)).toBe(DEFAULT_AWS_SCAN_MODE);
28+
expect(resolveAwsScanMode(null)).toBe(DEFAULT_AWS_SCAN_MODE);
29+
expect(resolveAwsScanMode(0)).toBe(DEFAULT_AWS_SCAN_MODE);
30+
expect(resolveAwsScanMode({})).toBe(DEFAULT_AWS_SCAN_MODE);
31+
expect(resolveAwsScanMode([])).toBe(DEFAULT_AWS_SCAN_MODE);
32+
});
33+
});
34+
35+
describe('isSecurityHubMode', () => {
36+
it('is true only for the exact "security_hub" string', () => {
37+
expect(isSecurityHubMode('security_hub')).toBe(true);
38+
expect(isSecurityHubMode('comp_scanners')).toBe(false);
39+
expect(isSecurityHubMode(undefined)).toBe(false);
40+
expect(isSecurityHubMode('SECURITY_HUB')).toBe(false);
41+
});
42+
});
43+
44+
describe('DEFAULT_AWS_SCAN_MODE', () => {
45+
it('is "comp_scanners" — today\'s behavior is the safe default', () => {
46+
// This is intentionally guarded by a test: changing the default
47+
// would silently shift production behavior for every existing
48+
// connection that does not have an explicit scan mode set.
49+
expect(DEFAULT_AWS_SCAN_MODE).toBe('comp_scanners');
50+
});
51+
});
52+
53+
describe('AWS_SCAN_MODES', () => {
54+
it('lists exactly the two known modes (source of truth for DTOs / validators)', () => {
55+
// If a new mode is added, this test must change in lockstep with
56+
// the AwsScanMode union — the DTO `@IsIn(AWS_SCAN_MODES)` will
57+
// automatically pick up the new value, but reviewers should see
58+
// this test fail as a sanity check that the list was updated.
59+
expect([...AWS_SCAN_MODES]).toEqual(['comp_scanners', 'security_hub']);
60+
});
61+
62+
it('contains the default mode', () => {
63+
// Guards against future refactors that might remove the default
64+
// from the list — would break validation for every existing
65+
// connection that lacks an explicit mode.
66+
expect([...AWS_SCAN_MODES]).toContain(DEFAULT_AWS_SCAN_MODE);
67+
});
68+
69+
it('every entry passes resolveAwsScanMode round-trip (self-consistency)', () => {
70+
// Catches drift if someone adds a mode to the array without
71+
// updating resolveAwsScanMode.
72+
for (const mode of AWS_SCAN_MODES) {
73+
expect(resolveAwsScanMode(mode)).toBe(mode);
74+
}
75+
});
76+
});
77+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* Determines which engine performs an AWS security scan for a given
3+
* connection. This is the single source of truth for scan-mode strings —
4+
* importers never spell the values themselves.
5+
*
6+
* - 'comp_scanners' — run our service adapters directly against AWS
7+
* APIs (today's default; full Fix button support).
8+
* - 'security_hub' — call AWS Security Hub's GetFindings API and
9+
* surface whatever findings the customer's
10+
* Security Hub is configured to evaluate (CIS,
11+
* NIST 800-53, PCI, etc.).
12+
*
13+
* Persisted in two places:
14+
* - `IntegrationConnection.metadata.awsScanMode` — the customer's
15+
* current choice for the connection. Stored in `metadata` (not
16+
* `variables`) because it's a non-secret display field that the
17+
* frontend reads via the connection endpoint; mirrors `awsType`,
18+
* `roleArn`, `regions` etc.
19+
* - `IntegrationCheckRun.scanMode` — which engine produced this run.
20+
* Read by reconciliation to diff like-for-like; findingKeys live
21+
* in different namespaces across modes, so cross-mode diffs would
22+
* produce false resolutions/regressions.
23+
*/
24+
export type AwsScanMode = 'comp_scanners' | 'security_hub';
25+
26+
/** Canonical list of valid scan modes. Exported so DTOs, validators,
27+
* and tests reference ONE array instead of duplicating the string
28+
* literals everywhere. If a new mode is added, only this file changes
29+
* and all importers automatically pick it up — that's the
30+
* "single source of truth" promise this module makes. */
31+
export const AWS_SCAN_MODES = [
32+
'comp_scanners',
33+
'security_hub',
34+
] as const satisfies readonly AwsScanMode[];
35+
36+
/** Default behavior for AWS connections with no scan-mode set (including
37+
* every pre-feature connection that already exists in production). */
38+
export const DEFAULT_AWS_SCAN_MODE: AwsScanMode = 'comp_scanners';
39+
40+
/**
41+
* Coerces a value (typically from JSON variables) into a valid
42+
* AwsScanMode. Unknown / missing / wrong-typed values fall back to the
43+
* default. Use this everywhere instead of comparing strings inline.
44+
*/
45+
export function resolveAwsScanMode(value: unknown): AwsScanMode {
46+
return value === 'security_hub' ? 'security_hub' : DEFAULT_AWS_SCAN_MODE;
47+
}
48+
49+
/** True when the value, if persisted, would represent a SecHub-mode
50+
* connection. Reads in one place — keeps `=== 'security_hub'` out of
51+
* callers. */
52+
export function isSecurityHubMode(value: unknown): boolean {
53+
return resolveAwsScanMode(value) === 'security_hub';
54+
}

‎apps/api/src/cloud-security/cloud-security-audit.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ interface CloudSecurityAuditParams {
1313
| 'rollback_failed'
1414
| 'service_toggled'
1515
| 'exception_marked'
16-
| 'exception_revoked';
16+
| 'exception_revoked'
17+
| 'scan_mode_changed';
1718
description: string;
1819
metadata?: Record<string, unknown>;
1920
}

‎apps/api/src/cloud-security/cloud-security.controller.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
Post,
44
Get,
55
Delete,
6+
Patch,
67
Param,
78
Query,
89
Body,
@@ -27,8 +28,10 @@ import { CloudSecurityLegacyService } from './cloud-security-legacy.service';
2728
import { CheckDefinitionService } from './check-definition.service';
2829
import { CloudExceptionService } from './exception.service';
2930
import { CloudHistoryService } from './history.service';
31+
import { CloudAwsScanModeService } from './aws-scan-mode.service';
3032
import { parseExceptionExpiry } from './exception-expiry.utils';
3133
import { MarkExceptionDto } from './dto/mark-exception.dto';
34+
import { UpdateAwsScanModeDto } from './dto/update-scan-mode.dto';
3235
import { logCloudSecurityActivity } from './cloud-security-audit';
3336
import { CloudSecurityActivityService } from './cloud-security-activity.service';
3437
import {
@@ -52,6 +55,7 @@ export class CloudSecurityController {
5255
private readonly checkDefinitionService: CheckDefinitionService,
5356
private readonly exceptionService: CloudExceptionService,
5457
private readonly historyService: CloudHistoryService,
58+
private readonly scanModeService: CloudAwsScanModeService,
5559
) {}
5660

5761
@Get('activity')
@@ -134,6 +138,34 @@ export class CloudSecurityController {
134138
return { data: result };
135139
}
136140

141+
@Patch('connections/:connectionId/scan-mode')
142+
@UseGuards(HybridAuthGuard, PermissionGuard)
143+
@RequirePermission('integration', 'update')
144+
@ApiOperation({
145+
summary:
146+
'Switch the AWS scan engine for a connection (Comp AI scanners ↔ Security Hub)',
147+
})
148+
async updateAwsScanMode(
149+
@Param('connectionId') connectionId: string,
150+
@Body() body: UpdateAwsScanModeDto,
151+
@OrganizationId() organizationId: string,
152+
@Req() req: { userId?: string },
153+
) {
154+
if (!req.userId) {
155+
throw new HttpException(
156+
'Switching the scan engine requires session authentication.',
157+
HttpStatus.UNAUTHORIZED,
158+
);
159+
}
160+
const result = await this.scanModeService.updateMode({
161+
connectionId,
162+
organizationId,
163+
userId: req.userId,
164+
mode: body.mode,
165+
});
166+
return { data: result };
167+
}
168+
137169
@Delete('exceptions/:exceptionId')
138170
@UseGuards(HybridAuthGuard, PermissionGuard)
139171
@RequirePermission('integration', 'update')

‎apps/api/src/cloud-security/cloud-security.module.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { AiDescriptionService } from './ai-description.service';
1515
import { CheckDefinitionService } from './check-definition.service';
1616
import { CloudExceptionService } from './exception.service';
1717
import { CloudHistoryService } from './history.service';
18+
import { CloudAwsScanModeService } from './aws-scan-mode.service';
1819
import { CloudReconciliationService } from './reconciliation.service';
1920
import { CloudSecurityActivityService } from './cloud-security-activity.service';
2021
import { IntegrationPlatformModule } from '../integration-platform/integration-platform.module';
@@ -40,6 +41,7 @@ import { AuthModule } from '../auth/auth.module';
4041
CloudExceptionService,
4142
CloudReconciliationService,
4243
CloudHistoryService,
44+
CloudAwsScanModeService,
4345
],
4446
exports: [CloudSecurityService],
4547
})

‎apps/api/src/cloud-security/cloud-security.service.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { AWSSecurityService } from './providers/aws-security.service';
99
import { AzureSecurityService } from './providers/azure-security.service';
1010
import { AWS_SERVICE_TASK_MAPPINGS } from './aws-task-mappings';
1111
import { CloudReconciliationService } from './reconciliation.service';
12+
import { type AwsScanMode, resolveAwsScanMode } from './aws-scan-mode';
1213

1314
export interface SecurityFinding {
1415
id: string;
@@ -245,6 +246,11 @@ export class CloudSecurityService {
245246
}
246247
}
247248

249+
// AWS-only — which engine produced this scan. Persisted on the run
250+
// so reconciliation only diffs like-for-like (cross-mode findingKeys
251+
// live in different namespaces). Null for GCP / Azure runs.
252+
let awsScanMode: AwsScanMode | null = null;
253+
248254
switch (providerSlug) {
249255
case 'gcp':
250256
findings = await this.gcpService.scanSecurityFindings(
@@ -253,13 +259,21 @@ export class CloudSecurityService {
253259
enabledServices,
254260
);
255261
break;
256-
case 'aws':
262+
case 'aws': {
263+
// AWS scan-mode lives on connection.metadata (non-secret, frontend-
264+
// readable); credentials are encrypted blobs intended for the AWS
265+
// SDK. Read from metadata so a single source of truth.
266+
const metadata =
267+
(connection.metadata as Record<string, unknown> | null) ?? {};
268+
awsScanMode = resolveAwsScanMode(metadata.awsScanMode);
257269
findings = await this.awsService.scanSecurityFindings(
258270
credentials,
259271
variables,
260272
enabledServices,
273+
awsScanMode,
261274
);
262275
break;
276+
}
263277
case 'azure':
264278
findings = await this.azureService.scanSecurityFindings(
265279
credentials,
@@ -282,6 +296,7 @@ export class CloudSecurityService {
282296
connectionId,
283297
providerSlug,
284298
findings,
299+
awsScanMode,
285300
);
286301

287302
// Reconcile against the prior scan to record resolutions and regressions.
@@ -573,6 +588,9 @@ export class CloudSecurityService {
573588
connectionId: string,
574589
provider: string,
575590
findings: SecurityFinding[],
591+
// AWS only — which engine produced these findings. Stored on the run so
592+
// reconciliation can avoid cross-mode diffs. Null for GCP / Azure runs.
593+
awsScanMode: AwsScanMode | null,
576594
): Promise<string> {
577595
const passedCount = findings.filter((f) => f.passed).length;
578596
const failedCount = findings.filter((f) => !f.passed).length;
@@ -607,6 +625,7 @@ export class CloudSecurityService {
607625
passedCount,
608626
failedCount,
609627
scannedServices,
628+
scanMode: awsScanMode,
610629
},
611630
});
612631

0 commit comments

Comments
 (0)