Skip to content

Commit fec481e

Browse files
committed
Merge branch 'fix/cubic-iam' into feat/aws-integration-batch
2 parents 5f455d1 + 88a825c commit fec481e

5 files changed

Lines changed: 193 additions & 6 deletions

File tree

‎apps/sim/blocks/blocks/iam.test.ts‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { IAMBlock } from '@/blocks/blocks/iam'
6+
7+
const SHAPE_ERROR =
8+
'Condition Context Keys must be a JSON array of { contextKeyName, contextKeyValues, contextKeyType }'
9+
10+
const CONTEXT_ENTRY = {
11+
contextKeyName: 'aws:SourceIp',
12+
contextKeyValues: ['203.0.113.10'],
13+
contextKeyType: 'ip',
14+
}
15+
16+
function simulateParams(extra: Record<string, unknown>) {
17+
return IAMBlock.tools.config!.params!({
18+
operation: 'simulate_principal_policy',
19+
region: 'us-east-1',
20+
accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
21+
secretAccessKey: 'secret',
22+
policySourceArn: 'arn:aws:iam::000000000000:role/example',
23+
actionNames: 's3:GetObject',
24+
...extra,
25+
})
26+
}
27+
28+
describe('IAMBlock contextEntries parsing', () => {
29+
it('passes a JSON array string through as parsed context entries', () => {
30+
const result = simulateParams({ contextEntries: JSON.stringify([CONTEXT_ENTRY]) })
31+
expect(result.contextEntries).toEqual([CONTEXT_ENTRY])
32+
})
33+
34+
it('passes an already-parsed array through unchanged', () => {
35+
const result = simulateParams({ contextEntries: [CONTEXT_ENTRY] })
36+
expect(result.contextEntries).toEqual([CONTEXT_ENTRY])
37+
})
38+
39+
it('rejects a JSON object rather than silently simulating without the context keys', () => {
40+
expect(() => simulateParams({ contextEntries: JSON.stringify(CONTEXT_ENTRY) })).toThrow(
41+
SHAPE_ERROR
42+
)
43+
})
44+
45+
it('rejects a JSON scalar rather than silently dropping it', () => {
46+
expect(() => simulateParams({ contextEntries: '"aws:SourceIp"' })).toThrow(SHAPE_ERROR)
47+
expect(() => simulateParams({ contextEntries: '42' })).toThrow(SHAPE_ERROR)
48+
})
49+
50+
it('rejects a non-array object supplied directly', () => {
51+
expect(() => simulateParams({ contextEntries: CONTEXT_ENTRY })).toThrow(SHAPE_ERROR)
52+
})
53+
54+
it('rejects malformed JSON with the same shape message', () => {
55+
expect(() => simulateParams({ contextEntries: '{not json' })).toThrow(SHAPE_ERROR)
56+
})
57+
58+
it('omits contextEntries when the field is blank', () => {
59+
const result = simulateParams({ contextEntries: '' })
60+
expect(result.contextEntries).toBeUndefined()
61+
})
62+
63+
it('omits contextEntries for an empty JSON array without throwing', () => {
64+
const result = simulateParams({ contextEntries: '[]' })
65+
expect(result.contextEntries).toBeUndefined()
66+
})
67+
})
68+
69+
describe('IAMBlock contextEntries wand config', () => {
70+
it('generates a JSON array, matching the prompt and the tool contract', () => {
71+
const subBlock = IAMBlock.subBlocks.find((block) => block.id === 'contextEntries')
72+
expect(subBlock?.wandConfig?.generationType).toBe('json-array')
73+
})
74+
})

‎apps/sim/blocks/blocks/iam.ts‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@ import type { BlockConfig, BlockMeta } from '@/blocks/types'
33
import { AuthMode, IntegrationType } from '@/blocks/types'
44
import type { IAMBaseResponse } from '@/tools/iam/types'
55

6+
/**
7+
* Raised when `contextEntries` is not a JSON array of context entries.
8+
*
9+
* A simulation that quietly drops the caller's condition context keys returns a
10+
* *wrong* permission answer — `aws:SourceIp` never applies, so a policy that should
11+
* have denied reports `allowed`. Rejecting the input is the only safe outcome.
12+
*/
13+
const CONTEXT_ENTRIES_SHAPE_ERROR =
14+
'Condition Context Keys must be a JSON array of { contextKeyName, contextKeyValues, contextKeyType }'
15+
616
export const IAMBlock: BlockConfig<IAMBaseResponse> = {
717
type: 'iam',
818
name: 'AWS IAM',
@@ -407,7 +417,7 @@ export const IAMBlock: BlockConfig<IAMBaseResponse> = {
407417
enabled: true,
408418
prompt:
409419
'Generate a JSON array of AWS IAM simulation context entries. Each element must have contextKeyName (a full condition context key such as aws:SourceIp), contextKeyValues (an array of strings), and contextKeyType (one of string, stringList, numeric, numericList, boolean, booleanList, ip, ipList, binary, binaryList, date, dateList). Return ONLY the JSON array - no explanations, no extra text.',
410-
generationType: 'json-object',
420+
generationType: 'json-array',
411421
placeholder: 'Describe the request conditions to simulate, e.g. "from IP 203.0.113.10"',
412422
},
413423
},
@@ -685,12 +695,13 @@ export const IAMBlock: BlockConfig<IAMBaseResponse> = {
685695
try {
686696
parsed = JSON.parse(contextEntries)
687697
} catch {
688-
throw new Error(
689-
'Condition Context Keys must be a JSON array of { contextKeyName, contextKeyValues, contextKeyType }'
690-
)
698+
throw new Error(CONTEXT_ENTRIES_SHAPE_ERROR)
691699
}
692700
}
693-
if (Array.isArray(parsed) && parsed.length > 0) result.contextEntries = parsed
701+
if (!Array.isArray(parsed)) {
702+
throw new Error(CONTEXT_ENTRIES_SHAPE_ERROR)
703+
}
704+
if (parsed.length > 0) result.contextEntries = parsed
694705
}
695706
if (maxItems) {
696707
const parsed = Number.parseInt(String(maxItems), 10)
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { iamRegionSchema } from '@/lib/api/contracts/tools/aws/iam-shared'
6+
7+
describe('iamRegionSchema', () => {
8+
it.each([
9+
'us-east-1',
10+
'eu-west-2',
11+
'ap-southeast-4',
12+
'sa-east-1',
13+
'il-central-1',
14+
'mx-central-1',
15+
'ca-west-1',
16+
])('accepts the commercial region %s', (region) => {
17+
expect(iamRegionSchema.safeParse(region).success).toBe(true)
18+
})
19+
20+
it.each(['us-gov-east-1', 'us-gov-west-1', 'cn-north-1', 'cn-northwest-1'])(
21+
'accepts the partitioned region %s',
22+
(region) => {
23+
expect(iamRegionSchema.safeParse(region).success).toBe(true)
24+
}
25+
)
26+
27+
it.each(['us-iso-east-1', 'us-isob-east-1', 'eu-isoe-west-1', 'eusc-de-east-1'])(
28+
'accepts the isolated region %s',
29+
(region) => {
30+
expect(iamRegionSchema.safeParse(region).success).toBe(true)
31+
}
32+
)
33+
34+
/**
35+
* The pinned `@aws-sdk/client-iam` endpoint ruleset maps the ISO-F partition to
36+
* `https://iam.us-isof-south-1.csp.hci.ic.gov`, so IAM demonstrably exists there.
37+
*/
38+
it.each(['us-isof-south-1', 'us-isof-east-1'])('accepts the ISO-F region %s', (region) => {
39+
expect(iamRegionSchema.safeParse(region).success).toBe(true)
40+
})
41+
42+
it('rejects an empty region', () => {
43+
expect(iamRegionSchema.safeParse('').success).toBe(false)
44+
})
45+
46+
it.each([
47+
'us_east_1',
48+
'US-EAST-1',
49+
'us east 1',
50+
'useast1',
51+
'us-east-1.evil.example.com',
52+
'us-east-1/../admin',
53+
'us-east-1:443',
54+
'user@us-east-1',
55+
'us-east-1\nx-injected: 1',
56+
'-us-east-1',
57+
'us-east-1-',
58+
])('rejects the host-unsafe or malformed value %j', (region) => {
59+
expect(iamRegionSchema.safeParse(region).success).toBe(false)
60+
})
61+
62+
it('rejects a region longer than 64 characters', () => {
63+
expect(iamRegionSchema.safeParse(`us-${'a'.repeat(70)}-1`).success).toBe(false)
64+
})
65+
})
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { validateAwsRegion } from '@/lib/core/security/input-validation'
3+
4+
const ok = [
5+
'us-east-1',
6+
'eu-west-2',
7+
'us-gov-west-1',
8+
'cn-northwest-1',
9+
'us-iso-east-1',
10+
'us-isob-east-1',
11+
'eu-isoe-west-1',
12+
'eusc-de-east-1',
13+
'us-isof-south-1',
14+
'us-isof-east-1',
15+
'ap-southeast-4',
16+
'me-central-1',
17+
]
18+
const bad = [
19+
'',
20+
'us-east',
21+
'US-EAST-1',
22+
'us-east-1.evil.com',
23+
'us-east-1/x',
24+
'us-isog-east-1',
25+
'../us-east-1',
26+
'us-east-1:80',
27+
]
28+
describe('validateAwsRegion partitions', () => {
29+
for (const r of ok) it(`accepts ${r}`, () => expect(validateAwsRegion(r).isValid).toBe(true))
30+
for (const r of bad)
31+
it(`rejects ${JSON.stringify(r)}`, () => expect(validateAwsRegion(r).isValid).toBe(false))
32+
})

‎apps/sim/lib/core/security/input-validation.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -630,8 +630,13 @@ export function validateAwsRegion(
630630
}
631631
}
632632

633+
/**
634+
* Partition prefixes are matched longest-first: `us-isob` and `us-isof` must precede
635+
* `us-iso`, or the shorter alternative wins and the trailing partition letter fails to
636+
* match the following `-`.
637+
*/
633638
const awsRegionPattern =
634-
/^(eu-isoe|eusc-[a-z]{2}|us-isob|us-iso|us-gov|af|ap|ca|cn|eu|il|me|mx|sa|us)-(central|north|northeast|northwest|south|southeast|southwest|east|west)-\d{1,2}$/
639+
/^(eu-isoe|eusc-[a-z]{2}|us-isob|us-isof|us-iso|us-gov|af|ap|ca|cn|eu|il|me|mx|sa|us)-(central|north|northeast|northwest|south|southeast|southwest|east|west)-\d{1,2}$/
635640

636641
if (!awsRegionPattern.test(value)) {
637642
logger.warn('Invalid AWS region format', {

0 commit comments

Comments
 (0)