Skip to content

Commit e3d275f

Browse files
authored
Merge pull request #2808 from trycompai/feat/support-gov-cloud-aws
[dev] [tofikwest] feat/support-gov-cloud-aws
2 parents d92c327 + 44d7cbd commit e3d275f

22 files changed

Lines changed: 1136 additions & 334 deletions

apps/api/.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,9 @@ BACKGROUND_CHECK_WEBHOOK_SECRET=
5353
BACKGROUND_WH_ENDPOINT=
5454
STRIPE_BACKGROUND_CHECK_PRICE_ID=price_1TRWckCkFWhKYvHIA1GLv1sO
5555

56+
# We are not using security hub service it is only the variable prefix for matching with old one
5657
SECURITY_HUB_ROLE_ASSUMER_ARN=
58+
SECURITY_HUB_GOVCLOUD_ROLE_ASSUMER_ARN=
59+
SECURITY_HUB_GOVCLOUD_ACCESS_KEY_ID=
60+
SECURITY_HUB_GOVCLOUD_SECRET_ACCESS_KEY=
61+
SECURITY_HUB_GOVCLOUD_SESSION_TOKEN=

apps/api/src/cloud-security/ai-remediation.prompt.ts

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,28 @@ export type CompletePermissions = z.infer<typeof completePermissionsSchema>;
9999

100100
// ─── Prompt Builders ────────────────────────────────────────────────────────
101101

102+
function inferAwsPartition(finding: {
103+
resourceId: string;
104+
evidence: Record<string, unknown>;
105+
}): 'aws' | 'aws-us-gov' {
106+
if (finding.resourceId.startsWith('arn:aws-us-gov:')) return 'aws-us-gov';
107+
108+
const region =
109+
typeof finding.evidence.region === 'string' ? finding.evidence.region : '';
110+
return region.startsWith('us-gov-') ? 'aws-us-gov' : 'aws';
111+
}
112+
113+
function inferAwsRegion(finding: {
114+
resourceId: string;
115+
evidence: Record<string, unknown>;
116+
}): string {
117+
if (typeof finding.evidence.region === 'string')
118+
return finding.evidence.region;
119+
120+
const arnMatch = finding.resourceId.match(/^arn:[^:]+:[^:]*:([^:]*):/);
121+
return arnMatch?.[1] || 'the execution region';
122+
}
123+
102124
const SYSTEM_PROMPT = `You are an AWS security remediation expert. You analyze security findings and produce structured fix plans that will be executed by an automated system using AWS SDK v3.
103125
104126
A human will ALWAYS review your plan before execution. Be precise and correct.
@@ -117,12 +139,20 @@ A human will ALWAYS review your plan before execution. Be precise and correct.
117139
118140
## RESOURCE ID PARSING
119141
- Extract actual resource names from ARNs:
120-
- "arn:aws:s3:::my-bucket" → Bucket: "my-bucket"
121-
- "arn:aws:kms:us-east-1:123:key/abc" → KeyId: "arn:aws:kms:us-east-1:123:key/abc" (use full ARN for KMS)
122-
- "arn:aws:rds:us-east-1:123:db:mydb" → DBInstanceIdentifier: "mydb"
123-
- "arn:aws:ec2:us-east-1:123:vpc/vpc-abc" → VpcId: "vpc-abc"
142+
- "arn:aws:s3:::my-bucket" or "arn:aws-us-gov:s3:::my-bucket" → Bucket: "my-bucket"
143+
- "arn:aws:kms:us-east-1:123:key/abc" → KeyId: use the full ARN exactly as provided
144+
- "arn:aws-us-gov:kms:us-gov-west-1:123:key/abc" → KeyId: use the full GovCloud ARN exactly as provided
145+
- "arn:aws:rds:us-east-1:123:db:mydb" or "arn:aws-us-gov:rds:us-gov-west-1:123:db:mydb" → DBInstanceIdentifier: "mydb"
146+
- "arn:aws:ec2:us-east-1:123:vpc/vpc-abc" or "arn:aws-us-gov:ec2:us-gov-west-1:123:vpc/vpc-abc" → VpcId: "vpc-abc"
124147
- Use the correct parameter names that the AWS SDK expects
125148
149+
## AWS PARTITIONS AND GOVCLOUD
150+
- Preserve the AWS partition from the finding context.
151+
- If AWS Partition is "aws-us-gov", every ARN you create or pass MUST start with "arn:aws-us-gov:".
152+
- If AWS Partition is "aws", every ARN you create or pass MUST start with "arn:aws:".
153+
- Never convert a GovCloud ARN to a commercial AWS ARN.
154+
- For GovCloud findings, use GovCloud regions such as "us-gov-west-1" or "us-gov-east-1"; never default to "us-east-1".
155+
126156
## SAFETY RULES (NEVER violate)
127157
- NEVER delete data, buckets, tables, databases, or file systems
128158
- NEVER modify IAM policies, roles, or users in ways that could lock out users
@@ -259,10 +289,19 @@ export function buildFixPlanPrompt(finding: {
259289
findingKey: string;
260290
evidence: Record<string, unknown>;
261291
}): string {
292+
const awsPartition = inferAwsPartition(finding);
293+
const awsRegion = inferAwsRegion(finding);
294+
262295
return `Analyze this AWS security finding and generate a fix plan.
263296
264297
IMPORTANT: Your fix must change the EXACT AWS setting/resource that caused this finding. The scan will re-check the same thing after the fix — if you fix something different, the finding will persist.
265298
299+
AWS EXECUTION CONTEXT:
300+
- AWS Partition: ${awsPartition}
301+
- Region: ${awsRegion}
302+
- When constructing ARNs, use partition prefix: arn:${awsPartition}:
303+
- If region-specific values are needed, use this region unless the finding explicitly gives a different one.
304+
266305
FINDING:
267306
- Title: ${finding.title}
268307
- Description: ${finding.description ?? 'N/A'}

apps/api/src/cloud-security/aws-command-executor.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import type { AwsCredentialIdentity } from '@aws-sdk/types';
22
import type { AwsCommandStep } from './ai-remediation.prompt';
3+
import {
4+
type AwsPartition,
5+
getAwsPartitionForRegion,
6+
} from './aws-partition.utils';
37

48
import * as s3 from '@aws-sdk/client-s3';
59
import * as dynamodb from '@aws-sdk/client-dynamodb';
@@ -145,6 +149,50 @@ const JSON_STRING_PARAMS = new Set([
145149
'Definition',
146150
]);
147151

152+
function normalizeArnPartition(value: string, partition: AwsPartition): string {
153+
if (partition === 'aws-us-gov') {
154+
return value.replace(/\barn:aws:/g, 'arn:aws-us-gov:');
155+
}
156+
157+
return value;
158+
}
159+
160+
function normalizeArnPartitionsInValue(
161+
value: unknown,
162+
partition: AwsPartition,
163+
): unknown {
164+
if (typeof value === 'string') {
165+
return normalizeArnPartition(value, partition);
166+
}
167+
168+
if (Array.isArray(value)) {
169+
return value.map((item) => normalizeArnPartitionsInValue(item, partition));
170+
}
171+
172+
if (value !== null && typeof value === 'object') {
173+
return Object.fromEntries(
174+
Object.entries(value).map(([key, item]) => [
175+
key,
176+
normalizeArnPartitionsInValue(item, partition),
177+
]),
178+
);
179+
}
180+
181+
return value;
182+
}
183+
184+
function normalizeArnPartitions(
185+
input: Record<string, unknown>,
186+
region: string,
187+
): void {
188+
const partition = getAwsPartitionForRegion(region);
189+
if (partition === 'aws') return;
190+
191+
for (const [key, value] of Object.entries(input)) {
192+
input[key] = normalizeArnPartitionsInValue(value, partition);
193+
}
194+
}
195+
148196
/**
149197
* Universal pre-execution param normalisation.
150198
* Fixes common AI mistakes without per-command logic.
@@ -154,6 +202,8 @@ function normaliseInputParams(
154202
command: string,
155203
region: string,
156204
): void {
205+
normalizeArnPartitions(input, region);
206+
157207
for (const [key, value] of Object.entries(input)) {
158208
// Rule 1: Stringify any object param that AWS expects as a JSON string
159209
if (
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import {
2+
getAwsBaseCredentials,
3+
getAwsDefaultRegion,
4+
getAwsPartitionForRegion,
5+
parseAwsRoleArn,
6+
validateAwsPartitionConfig,
7+
} from './aws-partition.utils';
8+
9+
describe('aws partition utils', () => {
10+
it('uses GovCloud defaults for the aws-us-gov partition', () => {
11+
expect(getAwsDefaultRegion('aws')).toBe('us-east-1');
12+
expect(getAwsDefaultRegion('aws-us-gov')).toBe('us-gov-west-1');
13+
expect(getAwsPartitionForRegion('us-east-1')).toBe('aws');
14+
expect(getAwsPartitionForRegion('us-gov-east-1')).toBe('aws-us-gov');
15+
});
16+
17+
it('parses commercial and GovCloud role ARNs', () => {
18+
expect(
19+
parseAwsRoleArn('arn:aws:iam::123456789012:role/CompAI-Auditor'),
20+
).toEqual({ partition: 'aws', accountId: '123456789012' });
21+
expect(
22+
parseAwsRoleArn('arn:aws-us-gov:iam::123456789012:role/CompAI-Auditor'),
23+
).toEqual({ partition: 'aws-us-gov', accountId: '123456789012' });
24+
});
25+
26+
it('rejects mismatched role ARN and region partitions', () => {
27+
expect(
28+
validateAwsPartitionConfig({
29+
partition: 'aws-us-gov',
30+
roleArn: 'arn:aws:iam::123456789012:role/CompAI-Auditor',
31+
regions: ['us-gov-west-1', 'us-east-1'],
32+
}),
33+
).toEqual([
34+
'IAM Role ARN partition (aws) must match selected AWS environment (aws-us-gov).',
35+
'Selected regions do not match aws-us-gov: us-east-1.',
36+
]);
37+
});
38+
39+
it('accepts commercial and GovCloud configurations independently', () => {
40+
expect(
41+
validateAwsPartitionConfig({
42+
partition: 'aws',
43+
roleArn: 'arn:aws:iam::123456789012:role/CompAI-Auditor',
44+
regions: ['us-east-1', 'us-west-2'],
45+
}),
46+
).toEqual([]);
47+
48+
expect(
49+
validateAwsPartitionConfig({
50+
partition: 'aws-us-gov',
51+
roleArn: 'arn:aws-us-gov:iam::123456789012:role/CompAI-Auditor',
52+
regions: ['us-gov-west-1', 'us-gov-east-1'],
53+
}),
54+
).toEqual([]);
55+
});
56+
57+
it('uses explicit GovCloud base credentials when configured', () => {
58+
process.env.SECURITY_HUB_GOVCLOUD_ACCESS_KEY_ID = 'AKIAGOV';
59+
process.env.SECURITY_HUB_GOVCLOUD_SECRET_ACCESS_KEY = 'secret';
60+
process.env.SECURITY_HUB_GOVCLOUD_SESSION_TOKEN = 'token';
61+
62+
expect(getAwsBaseCredentials('aws-us-gov')).toEqual({
63+
accessKeyId: 'AKIAGOV',
64+
secretAccessKey: 'secret',
65+
sessionToken: 'token',
66+
});
67+
expect(getAwsBaseCredentials('aws')).toBeUndefined();
68+
69+
delete process.env.SECURITY_HUB_GOVCLOUD_ACCESS_KEY_ID;
70+
delete process.env.SECURITY_HUB_GOVCLOUD_SECRET_ACCESS_KEY;
71+
delete process.env.SECURITY_HUB_GOVCLOUD_SESSION_TOKEN;
72+
});
73+
});
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import type { AwsCredentialIdentity } from '@aws-sdk/types';
2+
3+
export type AwsPartition = 'aws' | 'aws-us-gov';
4+
5+
const AWS_PARTITIONS = new Set<AwsPartition>(['aws', 'aws-us-gov']);
6+
7+
export function normalizeAwsPartition(value: unknown): AwsPartition {
8+
return typeof value === 'string' && AWS_PARTITIONS.has(value as AwsPartition)
9+
? (value as AwsPartition)
10+
: 'aws';
11+
}
12+
13+
export function getAwsDefaultRegion(partition: AwsPartition): string {
14+
return partition === 'aws-us-gov' ? 'us-gov-west-1' : 'us-east-1';
15+
}
16+
17+
export function getAwsPartitionForRegion(region: string): AwsPartition {
18+
return region.startsWith('us-gov-') ? 'aws-us-gov' : 'aws';
19+
}
20+
21+
export function getAwsRoleAssumerEnvName(partition: AwsPartition): string {
22+
return partition === 'aws-us-gov'
23+
? 'SECURITY_HUB_GOVCLOUD_ROLE_ASSUMER_ARN'
24+
: 'SECURITY_HUB_ROLE_ASSUMER_ARN';
25+
}
26+
27+
export function getAwsRoleAssumerArn(partition: AwsPartition): string | undefined {
28+
return process.env[getAwsRoleAssumerEnvName(partition)];
29+
}
30+
31+
export function getAwsBaseCredentials(
32+
partition: AwsPartition,
33+
): AwsCredentialIdentity | undefined {
34+
if (partition !== 'aws-us-gov') return undefined;
35+
36+
const accessKeyId = process.env.SECURITY_HUB_GOVCLOUD_ACCESS_KEY_ID;
37+
const secretAccessKey =
38+
process.env.SECURITY_HUB_GOVCLOUD_SECRET_ACCESS_KEY;
39+
if (!accessKeyId || !secretAccessKey) return undefined;
40+
41+
return {
42+
accessKeyId,
43+
secretAccessKey,
44+
sessionToken: process.env.SECURITY_HUB_GOVCLOUD_SESSION_TOKEN,
45+
};
46+
}
47+
48+
export function parseAwsRoleArn(
49+
roleArn: string,
50+
): { partition: AwsPartition; accountId: string } | null {
51+
const match = roleArn.match(/^arn:(aws|aws-us-gov):iam::(\d{12}):role\/.+$/);
52+
if (!match) return null;
53+
54+
return {
55+
partition: match[1] as AwsPartition,
56+
accountId: match[2],
57+
};
58+
}
59+
60+
export function validateAwsPartitionConfig(params: {
61+
partition: AwsPartition;
62+
roleArn: string;
63+
regions: string[];
64+
remediationRoleArn?: string;
65+
}): string[] {
66+
const errors: string[] = [];
67+
const parsedRoleArn = parseAwsRoleArn(params.roleArn);
68+
69+
if (!parsedRoleArn) {
70+
errors.push(
71+
'Invalid IAM Role ARN format. Expected: arn:aws:iam::ACCOUNT_ID:role/ROLE_NAME or arn:aws-us-gov:iam::ACCOUNT_ID:role/ROLE_NAME',
72+
);
73+
} else if (parsedRoleArn.partition !== params.partition) {
74+
errors.push(
75+
`IAM Role ARN partition (${parsedRoleArn.partition}) must match selected AWS environment (${params.partition}).`,
76+
);
77+
}
78+
79+
if (params.remediationRoleArn) {
80+
const parsedRemediationArn = parseAwsRoleArn(params.remediationRoleArn);
81+
if (!parsedRemediationArn) {
82+
errors.push('Invalid Remediation Role ARN format.');
83+
} else if (parsedRemediationArn.partition !== params.partition) {
84+
errors.push(
85+
`Remediation Role ARN partition (${parsedRemediationArn.partition}) must match selected AWS environment (${params.partition}).`,
86+
);
87+
}
88+
}
89+
90+
const mismatchedRegions = params.regions.filter(
91+
(region) => getAwsPartitionForRegion(region) !== params.partition,
92+
);
93+
if (mismatchedRegions.length > 0) {
94+
errors.push(
95+
`Selected regions do not match ${params.partition}: ${mismatchedRegions.join(', ')}.`,
96+
);
97+
}
98+
99+
return errors;
100+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export interface CloudProvider {
3131
variables: Record<string, unknown> | null;
3232
requiredVariables: string[];
3333
accountId?: string;
34+
awsType?: string;
3435
regions?: string[];
3536
tenantId?: string;
3637
subscriptionId?: string;
@@ -133,6 +134,8 @@ export class CloudSecurityQueryService {
133134
typeof metadata.accountId === 'string'
134135
? metadata.accountId
135136
: undefined,
137+
awsType:
138+
typeof metadata.awsType === 'string' ? metadata.awsType : undefined,
136139
regions: Array.isArray(metadata.regions)
137140
? metadata.regions.filter((r): r is string => typeof r === 'string')
138141
: undefined,
@@ -171,6 +174,8 @@ export class CloudSecurityQueryService {
171174
typeof settings.accountId === 'string'
172175
? settings.accountId
173176
: undefined,
177+
awsType:
178+
typeof settings.awsType === 'string' ? settings.awsType : undefined,
174179
regions: Array.isArray(settings.regions)
175180
? settings.regions.filter((r): r is string => typeof r === 'string')
176181
: undefined,

0 commit comments

Comments
 (0)