Skip to content

Commit 8327ebf

Browse files
Merge branch 'main' into chas/remove-hosts-when-removing-member
2 parents 4a5d164 + b106216 commit 8327ebf

20 files changed

Lines changed: 1559 additions & 377 deletions

File tree

apps/api/.env.example

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,6 @@ GROQ_API_KEY=
3333
# Resend (for sending emails)
3434
RESEND_API_KEY=
3535
RESEND_FROM_SYSTEM= # e.g., noreply@mail.trycomp.ai
36-
RESEND_FROM_DEFAULT= # e.g., hello@mail.trycomp.ai
36+
RESEND_FROM_DEFAULT= # e.g., hello@mail.trycomp.ai
37+
38+
SECURITY_HUB_ROLE_ASSUMER_ARN=

apps/api/src/cloud-security/providers/aws-security.service.ts

Lines changed: 198 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -32,93 +32,127 @@ export class AWSSecurityService {
3232
);
3333
}
3434

35-
const region =
36-
(credentials.region as string) ||
37-
(variables.region as string) ||
38-
'us-east-1';
35+
// Get all configured regions, or default to us-east-1
36+
const configuredRegions = this.getConfiguredRegions(credentials, variables);
37+
this.logger.log(
38+
`Scanning ${configuredRegions.length} region(s): ${configuredRegions.join(', ')}`,
39+
);
3940

41+
// Assume role ONCE before scanning all regions (IAM is global, not regional)
42+
// This avoids N×2 STS API calls when scanning N regions
4043
let awsCredentials: AwsCredentials;
44+
// Note: configuredRegions is guaranteed to have at least one element (defaults to ['us-east-1'])
45+
const primaryRegion = configuredRegions[0];
4146

4247
if (isRoleAuth) {
43-
const customerRoleArn = credentials.roleArn as string;
44-
const externalId = credentials.externalId as string;
45-
46-
const roleAssumerArn = process.env.SECURITY_HUB_ROLE_ASSUMER_ARN;
47-
if (!roleAssumerArn) {
48-
throw new Error(
49-
'Missing SECURITY_HUB_ROLE_ASSUMER_ARN (our roleAssumer ARN).',
50-
);
51-
}
48+
awsCredentials = await this.assumeRole(credentials, primaryRegion);
49+
} else {
50+
awsCredentials = {
51+
accessKeyId: credentials.access_key_id as string,
52+
secretAccessKey: credentials.secret_access_key as string,
53+
};
54+
}
5255

53-
// Hop 1: task role -> roleAssumer
54-
const baseSts = new STSClient({ region });
55-
const roleAssumerResp = await baseSts.send(
56-
new AssumeRoleCommand({
57-
RoleArn: roleAssumerArn,
58-
RoleSessionName: 'CompRoleAssumer',
59-
DurationSeconds: 3600,
60-
}),
61-
);
56+
const allFindings: SecurityFinding[] = [];
57+
const successfulRegions: string[] = [];
58+
const failedRegions: string[] = [];
6259

63-
const roleAssumerCreds = roleAssumerResp.Credentials;
64-
if (!roleAssumerCreds?.AccessKeyId || !roleAssumerCreds.SecretAccessKey) {
65-
throw new Error(
66-
'Failed to assume roleAssumer - no credentials returned',
60+
// Scan each region using the same credentials
61+
for (const region of configuredRegions) {
62+
try {
63+
const regionFindings = await this.scanRegionWithCredentials(
64+
awsCredentials,
65+
region,
6766
);
67+
allFindings.push(...regionFindings);
68+
successfulRegions.push(region);
69+
} catch (error) {
70+
const errorMessage =
71+
error instanceof Error ? error.message : String(error);
72+
// Use warn - per-region failures are expected (e.g., Security Hub not enabled)
73+
this.logger.warn(`Error scanning region ${region}: ${errorMessage}`);
74+
failedRegions.push(region);
75+
// Continue with other regions
6876
}
77+
}
6978

70-
const roleAssumerAwsCreds: AwsCredentials = {
71-
accessKeyId: roleAssumerCreds.AccessKeyId,
72-
secretAccessKey: roleAssumerCreds.SecretAccessKey,
73-
sessionToken: roleAssumerCreds.SessionToken,
74-
};
75-
76-
// Hop 2: roleAssumer -> customer role (ExternalId enforced by customer trust policy)
77-
const roleAssumerSts = new STSClient({
78-
region,
79-
credentials: roleAssumerAwsCreds,
80-
});
79+
// Log summary
80+
this.logger.log(
81+
`Scan complete: ${allFindings.length} findings from ${successfulRegions.length} regions`,
82+
);
8183

82-
this.logger.log(
83-
`Assuming customer role ${customerRoleArn} in region ${region}`,
84+
// If ALL regions failed, throw an error so the caller knows the scan failed
85+
if (successfulRegions.length === 0 && failedRegions.length > 0) {
86+
throw new Error(
87+
`All ${failedRegions.length} region(s) failed to scan: ${failedRegions.join(', ')}`,
8488
);
89+
}
8590

86-
const customerResp = await roleAssumerSts.send(
87-
new AssumeRoleCommand({
88-
RoleArn: customerRoleArn,
89-
ExternalId: externalId,
90-
RoleSessionName: 'CompSecurityAudit',
91-
DurationSeconds: 3600,
92-
}),
91+
return allFindings;
92+
}
93+
94+
/**
95+
* Get the list of regions to scan from credentials or variables.
96+
* Always returns at least one region (defaults to us-east-1).
97+
*/
98+
private getConfiguredRegions(
99+
credentials: Record<string, unknown>,
100+
variables: Record<string, unknown>,
101+
): string[] {
102+
// Check credentials.regions (array from multi-select)
103+
if (Array.isArray(credentials.regions) && credentials.regions.length > 0) {
104+
const filtered = credentials.regions.filter(
105+
(r): r is string => typeof r === 'string' && r.trim().length > 0,
93106
);
107+
// Only use filtered result if it has valid strings
108+
if (filtered.length > 0) {
109+
return filtered;
110+
}
111+
}
94112

95-
const customerCreds = customerResp.Credentials;
96-
if (!customerCreds?.AccessKeyId || !customerCreds.SecretAccessKey) {
97-
throw new Error(
98-
'Failed to assume customer role - no credentials returned',
99-
);
113+
// Check variables.regions (array)
114+
if (Array.isArray(variables.regions) && variables.regions.length > 0) {
115+
const filtered = variables.regions.filter(
116+
(r): r is string => typeof r === 'string' && r.trim().length > 0,
117+
);
118+
// Only use filtered result if it has valid strings
119+
if (filtered.length > 0) {
120+
return filtered;
100121
}
122+
}
101123

102-
awsCredentials = {
103-
accessKeyId: customerCreds.AccessKeyId,
104-
secretAccessKey: customerCreds.SecretAccessKey,
105-
sessionToken: customerCreds.SessionToken,
106-
};
107-
} else {
108-
awsCredentials = {
109-
accessKeyId: credentials.access_key_id as string,
110-
secretAccessKey: credentials.secret_access_key as string,
111-
};
124+
// Check single region in credentials or variables
125+
const singleRegion =
126+
(credentials.region as string) || (variables.region as string);
127+
128+
if (
129+
singleRegion &&
130+
typeof singleRegion === 'string' &&
131+
singleRegion.trim()
132+
) {
133+
return [singleRegion.trim()];
112134
}
113135

136+
// Default to us-east-1
137+
return ['us-east-1'];
138+
}
139+
140+
/**
141+
* Scan a single AWS region using pre-obtained credentials.
142+
* Credentials are reused across regions since IAM is global.
143+
*/
144+
private async scanRegionWithCredentials(
145+
awsCredentials: AwsCredentials,
146+
region: string,
147+
): Promise<SecurityFinding[]> {
114148
const securityHub = new SecurityHubClient({
115149
region,
116150
credentials: awsCredentials,
117151
});
118152

119153
try {
120-
const findings = await this.fetchSecurityHubFindings(securityHub);
121-
this.logger.log(`Found ${findings.length} AWS security findings`);
154+
const findings = await this.fetchSecurityHubFindings(securityHub, region);
155+
this.logger.log(`Found ${findings.length} findings in region ${region}`);
122156
return findings;
123157
} catch (error) {
124158
const errorMessage =
@@ -128,16 +162,88 @@ export class AWSSecurityService {
128162
errorMessage.includes('not subscribed') ||
129163
errorMessage.includes('AccessDenied')
130164
) {
131-
this.logger.warn('Security Hub not enabled in this region');
165+
this.logger.warn(`Security Hub not enabled in region ${region}`);
132166
return [];
133167
}
134168

135169
throw error;
136170
}
137171
}
138172

173+
/**
174+
* Assume IAM role for cross-account access
175+
*/
176+
private async assumeRole(
177+
credentials: Record<string, unknown>,
178+
region: string,
179+
): Promise<AwsCredentials> {
180+
const customerRoleArn = credentials.roleArn as string;
181+
const externalId = credentials.externalId as string;
182+
183+
const roleAssumerArn = process.env.SECURITY_HUB_ROLE_ASSUMER_ARN;
184+
if (!roleAssumerArn) {
185+
throw new Error(
186+
'Missing SECURITY_HUB_ROLE_ASSUMER_ARN (our roleAssumer ARN).',
187+
);
188+
}
189+
190+
// Hop 1: task role -> roleAssumer
191+
const baseSts = new STSClient({ region });
192+
const roleAssumerResp = await baseSts.send(
193+
new AssumeRoleCommand({
194+
RoleArn: roleAssumerArn,
195+
RoleSessionName: 'CompRoleAssumer',
196+
DurationSeconds: 3600,
197+
}),
198+
);
199+
200+
const roleAssumerCreds = roleAssumerResp.Credentials;
201+
if (!roleAssumerCreds?.AccessKeyId || !roleAssumerCreds.SecretAccessKey) {
202+
throw new Error('Failed to assume roleAssumer - no credentials returned');
203+
}
204+
205+
const roleAssumerAwsCreds: AwsCredentials = {
206+
accessKeyId: roleAssumerCreds.AccessKeyId,
207+
secretAccessKey: roleAssumerCreds.SecretAccessKey,
208+
sessionToken: roleAssumerCreds.SessionToken,
209+
};
210+
211+
// Hop 2: roleAssumer -> customer role (ExternalId enforced by customer trust policy)
212+
const roleAssumerSts = new STSClient({
213+
region,
214+
credentials: roleAssumerAwsCreds,
215+
});
216+
217+
this.logger.log(
218+
`Assuming customer role ${customerRoleArn} in region ${region}`,
219+
);
220+
221+
const customerResp = await roleAssumerSts.send(
222+
new AssumeRoleCommand({
223+
RoleArn: customerRoleArn,
224+
ExternalId: externalId,
225+
RoleSessionName: 'CompSecurityAudit',
226+
DurationSeconds: 3600,
227+
}),
228+
);
229+
230+
const customerCreds = customerResp.Credentials;
231+
if (!customerCreds?.AccessKeyId || !customerCreds.SecretAccessKey) {
232+
throw new Error(
233+
'Failed to assume customer role - no credentials returned',
234+
);
235+
}
236+
237+
return {
238+
accessKeyId: customerCreds.AccessKeyId,
239+
secretAccessKey: customerCreds.SecretAccessKey,
240+
sessionToken: customerCreds.SessionToken,
241+
};
242+
}
243+
139244
private async fetchSecurityHubFindings(
140245
securityHub: SecurityHubClient,
246+
region: string,
141247
): Promise<SecurityFinding[]> {
142248
const allFindings: SecurityFinding[] = [];
143249

@@ -156,7 +262,7 @@ export class AWSSecurityService {
156262

157263
if (response.Findings) {
158264
for (const finding of response.Findings) {
159-
allFindings.push(this.mapFinding(finding));
265+
allFindings.push(this.mapFinding(finding, region));
160266
}
161267
}
162268

@@ -173,7 +279,7 @@ export class AWSSecurityService {
173279
if (response.Findings) {
174280
for (const finding of response.Findings) {
175281
if (allFindings.length >= 500) break;
176-
allFindings.push(this.mapFinding(finding));
282+
allFindings.push(this.mapFinding(finding, region));
177283
}
178284
}
179285

@@ -183,20 +289,23 @@ export class AWSSecurityService {
183289
return allFindings;
184290
}
185291

186-
private mapFinding(finding: {
187-
Id?: string;
188-
Title?: string;
189-
Description?: string;
190-
Remediation?: { Recommendation?: { Text?: string } };
191-
Severity?: { Label?: string };
192-
Resources?: Array<{ Type?: string; Id?: string }>;
193-
AwsAccountId?: string;
194-
Region?: string;
195-
Compliance?: { Status?: string };
196-
GeneratorId?: string;
197-
CreatedAt?: string;
198-
UpdatedAt?: string;
199-
}): SecurityFinding {
292+
private mapFinding(
293+
finding: {
294+
Id?: string;
295+
Title?: string;
296+
Description?: string;
297+
Remediation?: { Recommendation?: { Text?: string } };
298+
Severity?: { Label?: string };
299+
Resources?: Array<{ Type?: string; Id?: string }>;
300+
AwsAccountId?: string;
301+
Region?: string;
302+
Compliance?: { Status?: string };
303+
GeneratorId?: string;
304+
CreatedAt?: string;
305+
UpdatedAt?: string;
306+
},
307+
scanRegion: string,
308+
): SecurityFinding {
200309
const severityMap: Record<string, SecurityFinding['severity']> = {
201310
INFORMATIONAL: 'info',
202311
LOW: 'low',
@@ -208,9 +317,16 @@ export class AWSSecurityService {
208317
const complianceStatus = finding.Compliance?.Status;
209318
const passed = complianceStatus === 'PASSED';
210319

320+
// Use the finding's region if available, otherwise use the scan region
321+
const findingRegion = finding.Region || scanRegion;
322+
323+
// Append region to title for frontend filtering (e.g., "Finding Title (us-east-1)")
324+
const baseTitle = finding.Title || 'Untitled Finding';
325+
const titleWithRegion = `${baseTitle} (${findingRegion})`;
326+
211327
return {
212328
id: finding.Id || '',
213-
title: finding.Title || 'Untitled Finding',
329+
title: titleWithRegion,
214330
description: finding.Description || 'No description available',
215331
severity: severityMap[finding.Severity?.Label || 'INFO'] || 'medium',
216332
resourceType: finding.Resources?.[0]?.Type || 'unknown',
@@ -219,7 +335,7 @@ export class AWSSecurityService {
219335
finding.Remediation?.Recommendation?.Text || 'No remediation available',
220336
evidence: {
221337
awsAccountId: finding.AwsAccountId,
222-
region: finding.Region,
338+
region: findingRegion,
223339
complianceStatus,
224340
generatorId: finding.GeneratorId,
225341
updatedAt: finding.UpdatedAt,

0 commit comments

Comments
 (0)