@@ -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