@intentius/chant-lexicon-aws 0.45.0 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/cloud-executor.d.ts.map +1 -1
- package/dist/composites/lambda-function.d.ts +4 -4
- package/dist/composites/lambda-function.d.ts.map +1 -1
- package/dist/integrity.json +5 -3
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/cf-refs.d.ts +7 -0
- package/dist/lint/post-synth/cf-refs.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/waw059.d.ts +36 -0
- package/dist/lint/post-synth/waw059.d.ts.map +1 -0
- package/dist/lint/post-synth/waw060.d.ts +16 -0
- package/dist/lint/post-synth/waw060.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +2 -0
- package/dist/okf/rules/WAW059.md +25 -0
- package/dist/okf/rules/WAW060.md +17 -0
- package/dist/okf/types/Action.md +1 -0
- package/dist/okf/types/Bucket.md +1 -0
- package/dist/okf/types/GlobalTable.md +4 -0
- package/dist/okf/types/IamPolicy.md +2 -0
- package/dist/okf/types/InstanceProfile.md +4 -0
- package/dist/okf/types/ManagedPolicy.md +2 -0
- package/dist/okf/types/Map.md +1 -0
- package/dist/okf/types/Queue.md +1 -0
- package/dist/okf/types/Role.md +1 -0
- package/dist/okf/types/Table.md +1 -0
- package/dist/okf/types/Type.md +2 -0
- package/dist/op/activities/aws-apply.d.ts +48 -5
- package/dist/op/activities/aws-apply.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +5 -4
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/ownership.d.ts +18 -0
- package/dist/ownership.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/cf-refs.ts +22 -0
- package/dist/rules/waw059.ts +353 -0
- package/dist/rules/waw060.ts +91 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/teardown.d.ts +85 -0
- package/dist/teardown.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/components/cloud-executor.ts +10 -1
- package/src/lifecycle-integration.test.ts +4 -0
- package/src/lint/audit-catalog.ts +5 -0
- package/src/lint/post-synth/cf-refs.ts +22 -0
- package/src/lint/post-synth/index.ts +4 -0
- package/src/lint/post-synth/waw059.test.ts +309 -0
- package/src/lint/post-synth/waw059.ts +353 -0
- package/src/lint/post-synth/waw060.test.ts +131 -0
- package/src/lint/post-synth/waw060.ts +91 -0
- package/src/op/activities/aws-apply.test.ts +111 -0
- package/src/op/activities/aws-apply.ts +100 -6
- package/src/op/activities/index.ts +5 -3
- package/src/ownership.test.ts +24 -1
- package/src/ownership.ts +37 -0
- package/src/plugin.ts +18 -0
- package/src/serializer-ownership.test.ts +18 -0
- package/src/serializer.ts +9 -1
- package/src/teardown.test.ts +258 -0
- package/src/teardown.ts +276 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { createPostSynthContext } from "@intentius/chant-test-utils";
|
|
3
|
+
import { waw059, checkWildcardResourceEnumerable } from "./waw059";
|
|
4
|
+
|
|
5
|
+
function makeCtx(template: object) {
|
|
6
|
+
return createPostSynthContext({ aws: template });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const TRUST = {
|
|
10
|
+
Version: "2012-10-17",
|
|
11
|
+
Statement: [
|
|
12
|
+
{
|
|
13
|
+
Effect: "Allow",
|
|
14
|
+
Principal: { Service: "lambda.amazonaws.com" },
|
|
15
|
+
Action: "sts:AssumeRole",
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function role(statement: Record<string, unknown>) {
|
|
21
|
+
return {
|
|
22
|
+
Type: "AWS::IAM::Role",
|
|
23
|
+
Properties: {
|
|
24
|
+
AssumeRolePolicyDocument: TRUST,
|
|
25
|
+
Policies: [
|
|
26
|
+
{
|
|
27
|
+
PolicyName: "app",
|
|
28
|
+
PolicyDocument: { Version: "2012-10-17", Statement: [statement] },
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function lambda(env: Record<string, unknown>) {
|
|
36
|
+
return {
|
|
37
|
+
Type: "AWS::Lambda::Function",
|
|
38
|
+
Properties: {
|
|
39
|
+
Runtime: "nodejs20.x",
|
|
40
|
+
Handler: "index.handler",
|
|
41
|
+
Role: { "Fn::GetAtt": ["AppRole", "Arn"] },
|
|
42
|
+
Environment: { Variables: env },
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const BUCKET = { Type: "AWS::S3::Bucket", Properties: {} };
|
|
48
|
+
const TABLE = { Type: "AWS::DynamoDB::Table", Properties: { BillingMode: "PAY_PER_REQUEST" } };
|
|
49
|
+
const QUEUE = { Type: "AWS::SQS::Queue", Properties: {} };
|
|
50
|
+
|
|
51
|
+
describe("WAW059: wildcard Resource where the declared graph enumerates the touched set", () => {
|
|
52
|
+
test("check metadata", () => {
|
|
53
|
+
expect(waw059.id).toBe("WAW059");
|
|
54
|
+
expect(waw059.description).toContain("declared graph");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("flags an over-broad role inline statement and suggests the declared Arns", () => {
|
|
58
|
+
const ctx = makeCtx({
|
|
59
|
+
Resources: {
|
|
60
|
+
DataBucket: BUCKET,
|
|
61
|
+
AppRole: role({
|
|
62
|
+
Effect: "Allow",
|
|
63
|
+
Action: ["s3:GetObject", "s3:PutObject"],
|
|
64
|
+
Resource: "*",
|
|
65
|
+
}),
|
|
66
|
+
AppFunc: lambda({ BUCKET_NAME: { Ref: "DataBucket" } }),
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
const diags = checkWildcardResourceEnumerable(ctx);
|
|
70
|
+
expect(diags).toHaveLength(1);
|
|
71
|
+
expect(diags[0].checkId).toBe("WAW059");
|
|
72
|
+
expect(diags[0].severity).toBe("warning");
|
|
73
|
+
expect(diags[0].entity).toBe("AppRole");
|
|
74
|
+
expect(diags[0].message).toContain('{"Fn::GetAtt":["DataBucket","Arn"]}');
|
|
75
|
+
expect(diags[0].message).toContain('"/*"');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("flags a service-wide ARN pattern, not just the bare wildcard", () => {
|
|
79
|
+
const ctx = makeCtx({
|
|
80
|
+
Resources: {
|
|
81
|
+
Jobs: TABLE,
|
|
82
|
+
AppRole: role({
|
|
83
|
+
Effect: "Allow",
|
|
84
|
+
Action: ["dynamodb:GetItem", "dynamodb:Query"],
|
|
85
|
+
Resource: "arn:aws:dynamodb:*:*:table/*",
|
|
86
|
+
}),
|
|
87
|
+
AppFunc: lambda({ TABLE_NAME: { Ref: "Jobs" } }),
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
const diags = checkWildcardResourceEnumerable(ctx);
|
|
91
|
+
expect(diags).toHaveLength(1);
|
|
92
|
+
expect(diags[0].message).toContain('{"Fn::GetAtt":["Jobs","Arn"]}');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("flags an IAM::Policy attached to a declared role via Ref", () => {
|
|
96
|
+
const ctx = makeCtx({
|
|
97
|
+
Resources: {
|
|
98
|
+
WorkQueue: QUEUE,
|
|
99
|
+
AppRole: { Type: "AWS::IAM::Role", Properties: { AssumeRolePolicyDocument: TRUST } },
|
|
100
|
+
AppPolicy: {
|
|
101
|
+
Type: "AWS::IAM::Policy",
|
|
102
|
+
Properties: {
|
|
103
|
+
PolicyName: "app",
|
|
104
|
+
Roles: [{ Ref: "AppRole" }],
|
|
105
|
+
PolicyDocument: {
|
|
106
|
+
Version: "2012-10-17",
|
|
107
|
+
Statement: [{ Effect: "Allow", Action: "sqs:SendMessage", Resource: "*" }],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
AppFunc: lambda({ QUEUE_URL: { Ref: "WorkQueue" } }),
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
const diags = checkWildcardResourceEnumerable(ctx);
|
|
115
|
+
expect(diags).toHaveLength(1);
|
|
116
|
+
expect(diags[0].entity).toBe("AppPolicy");
|
|
117
|
+
expect(diags[0].message).toContain('{"Fn::GetAtt":["WorkQueue","Arn"]}');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("exact-scoped statement is quiet", () => {
|
|
121
|
+
const ctx = makeCtx({
|
|
122
|
+
Resources: {
|
|
123
|
+
DataBucket: BUCKET,
|
|
124
|
+
AppRole: role({
|
|
125
|
+
Effect: "Allow",
|
|
126
|
+
Action: ["s3:GetObject"],
|
|
127
|
+
Resource: [{ "Fn::GetAtt": ["DataBucket", "Arn"] }],
|
|
128
|
+
}),
|
|
129
|
+
AppFunc: lambda({ BUCKET_NAME: { Ref: "DataBucket" } }),
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
expect(checkWildcardResourceEnumerable(ctx)).toHaveLength(0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("foreign edge (literal service ARN in a consumer) is quiet", () => {
|
|
136
|
+
const ctx = makeCtx({
|
|
137
|
+
Resources: {
|
|
138
|
+
DataBucket: BUCKET,
|
|
139
|
+
AppRole: role({ Effect: "Allow", Action: "s3:GetObject", Resource: "*" }),
|
|
140
|
+
AppFunc: lambda({
|
|
141
|
+
BUCKET_NAME: { Ref: "DataBucket" },
|
|
142
|
+
EXTERNAL_BUCKET: "arn:aws:s3:::partner-drop-zone",
|
|
143
|
+
}),
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
expect(checkWildcardResourceEnumerable(ctx)).toHaveLength(0);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("intrinsic edge (Fn::ImportValue in a consumer) is quiet", () => {
|
|
150
|
+
const ctx = makeCtx({
|
|
151
|
+
Resources: {
|
|
152
|
+
DataBucket: BUCKET,
|
|
153
|
+
AppRole: role({ Effect: "Allow", Action: "s3:GetObject", Resource: "*" }),
|
|
154
|
+
AppFunc: lambda({
|
|
155
|
+
BUCKET_NAME: { Ref: "DataBucket" },
|
|
156
|
+
SHARED_BUCKET: { "Fn::ImportValue": "shared-bucket-name" },
|
|
157
|
+
}),
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
expect(checkWildcardResourceEnumerable(ctx)).toHaveLength(0);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("statement with a Condition is quiet", () => {
|
|
164
|
+
const ctx = makeCtx({
|
|
165
|
+
Resources: {
|
|
166
|
+
DataBucket: BUCKET,
|
|
167
|
+
AppRole: role({
|
|
168
|
+
Effect: "Allow",
|
|
169
|
+
Action: "s3:GetObject",
|
|
170
|
+
Resource: "*",
|
|
171
|
+
Condition: { StringEquals: { "aws:PrincipalOrgID": "o-abc123" } },
|
|
172
|
+
}),
|
|
173
|
+
AppFunc: lambda({ BUCKET_NAME: { Ref: "DataBucket" } }),
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
expect(checkWildcardResourceEnumerable(ctx)).toHaveLength(0);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("action outside the curated table is quiet", () => {
|
|
180
|
+
const ctx = makeCtx({
|
|
181
|
+
Resources: {
|
|
182
|
+
DataBucket: BUCKET,
|
|
183
|
+
AppRole: role({
|
|
184
|
+
Effect: "Allow",
|
|
185
|
+
Action: ["ec2:DescribeInstances", "elasticloadbalancing:DescribeTargetGroups"],
|
|
186
|
+
Resource: "*",
|
|
187
|
+
}),
|
|
188
|
+
AppFunc: lambda({ BUCKET_NAME: { Ref: "DataBucket" } }),
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
expect(checkWildcardResourceEnumerable(ctx)).toHaveLength(0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("action needing '*' in a curated service is quiet (not in the table)", () => {
|
|
195
|
+
const ctx = makeCtx({
|
|
196
|
+
Resources: {
|
|
197
|
+
DataBucket: BUCKET,
|
|
198
|
+
AppRole: role({ Effect: "Allow", Action: "s3:ListAllMyBuckets", Resource: "*" }),
|
|
199
|
+
AppFunc: lambda({ BUCKET_NAME: { Ref: "DataBucket" } }),
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
expect(checkWildcardResourceEnumerable(ctx)).toHaveLength(0);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("role with no consumers is quiet", () => {
|
|
206
|
+
const ctx = makeCtx({
|
|
207
|
+
Resources: {
|
|
208
|
+
DataBucket: BUCKET,
|
|
209
|
+
AppRole: role({ Effect: "Allow", Action: "s3:GetObject", Resource: "*" }),
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
expect(checkWildcardResourceEnumerable(ctx)).toHaveLength(0);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("consumers that touch no declared resource of the service are quiet", () => {
|
|
216
|
+
const ctx = makeCtx({
|
|
217
|
+
Resources: {
|
|
218
|
+
AppRole: role({ Effect: "Allow", Action: "s3:GetObject", Resource: "*" }),
|
|
219
|
+
AppFunc: lambda({ STAGE: "prod" }),
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
expect(checkWildcardResourceEnumerable(ctx)).toHaveLength(0);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("trust policy statements never fire (Principal gate)", () => {
|
|
226
|
+
const ctx = makeCtx({
|
|
227
|
+
Resources: {
|
|
228
|
+
DataBucket: BUCKET,
|
|
229
|
+
AppRole: { Type: "AWS::IAM::Role", Properties: { AssumeRolePolicyDocument: TRUST } },
|
|
230
|
+
AppFunc: lambda({ BUCKET_NAME: { Ref: "DataBucket" } }),
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
expect(checkWildcardResourceEnumerable(ctx)).toHaveLength(0);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("policy attached to users or literal role names is quiet", () => {
|
|
237
|
+
const ctx = makeCtx({
|
|
238
|
+
Resources: {
|
|
239
|
+
DataBucket: BUCKET,
|
|
240
|
+
UserPolicy: {
|
|
241
|
+
Type: "AWS::IAM::Policy",
|
|
242
|
+
Properties: {
|
|
243
|
+
PolicyName: "u",
|
|
244
|
+
Users: ["alice"],
|
|
245
|
+
PolicyDocument: {
|
|
246
|
+
Version: "2012-10-17",
|
|
247
|
+
Statement: [{ Effect: "Allow", Action: "s3:GetObject", Resource: "*" }],
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
NamePolicy: {
|
|
252
|
+
Type: "AWS::IAM::Policy",
|
|
253
|
+
Properties: {
|
|
254
|
+
PolicyName: "n",
|
|
255
|
+
Roles: ["pre-existing-role-name"],
|
|
256
|
+
PolicyDocument: {
|
|
257
|
+
Version: "2012-10-17",
|
|
258
|
+
Statement: [{ Effect: "Allow", Action: "s3:GetObject", Resource: "*" }],
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
expect(checkWildcardResourceEnumerable(ctx)).toHaveLength(0);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("managed policy attached from the role side via ManagedPolicyArns fires", () => {
|
|
268
|
+
const ctx = makeCtx({
|
|
269
|
+
Resources: {
|
|
270
|
+
Jobs: TABLE,
|
|
271
|
+
AppManaged: {
|
|
272
|
+
Type: "AWS::IAM::ManagedPolicy",
|
|
273
|
+
Properties: {
|
|
274
|
+
PolicyDocument: {
|
|
275
|
+
Version: "2012-10-17",
|
|
276
|
+
Statement: [{ Effect: "Allow", Action: "dynamodb:PutItem", Resource: "*" }],
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
AppRole: {
|
|
281
|
+
Type: "AWS::IAM::Role",
|
|
282
|
+
Properties: {
|
|
283
|
+
AssumeRolePolicyDocument: TRUST,
|
|
284
|
+
ManagedPolicyArns: [{ Ref: "AppManaged" }],
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
AppFunc: lambda({ TABLE_NAME: { Ref: "Jobs" } }),
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
const diags = checkWildcardResourceEnumerable(ctx);
|
|
291
|
+
expect(diags).toHaveLength(1);
|
|
292
|
+
expect(diags[0].entity).toBe("AppManaged");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("mixed wildcard and concrete Resource entries are quiet (partially scoped)", () => {
|
|
296
|
+
const ctx = makeCtx({
|
|
297
|
+
Resources: {
|
|
298
|
+
DataBucket: BUCKET,
|
|
299
|
+
AppRole: role({
|
|
300
|
+
Effect: "Allow",
|
|
301
|
+
Action: "s3:GetObject",
|
|
302
|
+
Resource: ["arn:aws:s3:::some-bucket/*", "*"],
|
|
303
|
+
}),
|
|
304
|
+
AppFunc: lambda({ BUCKET_NAME: { Ref: "DataBucket" } }),
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
expect(checkWildcardResourceEnumerable(ctx)).toHaveLength(0);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAW059: Wildcard Resource where the declared graph enumerates the touched set
|
|
3
|
+
*
|
|
4
|
+
* An identity policy that allows resource-scopable actions on `Resource: "*"`
|
|
5
|
+
* (or a service-wide ARN pattern) when every consumer of the attached role
|
|
6
|
+
* only touches resources declared in the same template. In that case the
|
|
7
|
+
* declared graph already enumerates the touched set, so the statement can be
|
|
8
|
+
* tightened to the `Fn::GetAtt` Arn list of those declared entities.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately narrow (#1225). Fires only when ALL of these hold:
|
|
11
|
+
* - the statement is an `Effect: Allow` identity-policy statement (role inline
|
|
12
|
+
* policy, IAM::Policy, IAM::ManagedPolicy) — trust policies carry a
|
|
13
|
+
* Principal and are skipped, resource policies live on other types;
|
|
14
|
+
* - no Condition/NotAction/NotResource and no intrinsics in Action/Resource;
|
|
15
|
+
* - `Resource` is `"*"` or a service-wide ARN pattern;
|
|
16
|
+
* - every Action is in the curated resource-scopable table (s3, dynamodb,
|
|
17
|
+
* sqs) — actions that genuinely need `"*"` (s3:ListAllMyBuckets,
|
|
18
|
+
* dynamodb:ListTables, sqs:ListQueues) are not in the table;
|
|
19
|
+
* - the attached principal resolves to declared roles only, those roles have
|
|
20
|
+
* consumers in the template, and no consumer carries a foreign edge (a
|
|
21
|
+
* literal ARN of the service) or an intrinsic edge (Fn::ImportValue) that
|
|
22
|
+
* would put resources outside the declared graph in reach.
|
|
23
|
+
*
|
|
24
|
+
* Anything the static graph cannot prove stays quiet.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
28
|
+
import {
|
|
29
|
+
parseCFTemplate,
|
|
30
|
+
walkPolicyStatements,
|
|
31
|
+
findResourceRefs,
|
|
32
|
+
buildReverseRefIndex,
|
|
33
|
+
isIntrinsic,
|
|
34
|
+
type CFTemplate,
|
|
35
|
+
type CFResource,
|
|
36
|
+
} from "./cf-refs";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Curated table of actions that support resource-level permissions, per
|
|
40
|
+
* service. Lower-cased (IAM action matching is case-insensitive). An action
|
|
41
|
+
* outside this table gates the whole statement off — it may legitimately
|
|
42
|
+
* require `Resource: "*"`.
|
|
43
|
+
*/
|
|
44
|
+
export const RESOURCE_SCOPABLE_ACTIONS: Record<string, ReadonlySet<string>> = {
|
|
45
|
+
s3: new Set([
|
|
46
|
+
"getobject",
|
|
47
|
+
"getobjectversion",
|
|
48
|
+
"getobjectacl",
|
|
49
|
+
"getobjecttagging",
|
|
50
|
+
"putobject",
|
|
51
|
+
"putobjectacl",
|
|
52
|
+
"putobjecttagging",
|
|
53
|
+
"deleteobject",
|
|
54
|
+
"deleteobjectversion",
|
|
55
|
+
"listbucket",
|
|
56
|
+
"listbucketversions",
|
|
57
|
+
"listbucketmultipartuploads",
|
|
58
|
+
"listmultipartuploadparts",
|
|
59
|
+
"abortmultipartupload",
|
|
60
|
+
"getbucketlocation",
|
|
61
|
+
]),
|
|
62
|
+
dynamodb: new Set([
|
|
63
|
+
"getitem",
|
|
64
|
+
"batchgetitem",
|
|
65
|
+
"query",
|
|
66
|
+
"scan",
|
|
67
|
+
"putitem",
|
|
68
|
+
"updateitem",
|
|
69
|
+
"deleteitem",
|
|
70
|
+
"batchwriteitem",
|
|
71
|
+
"conditioncheckitem",
|
|
72
|
+
"describetable",
|
|
73
|
+
"getrecords",
|
|
74
|
+
"getsharditerator",
|
|
75
|
+
"describestream",
|
|
76
|
+
]),
|
|
77
|
+
sqs: new Set([
|
|
78
|
+
"sendmessage",
|
|
79
|
+
"sendmessagebatch",
|
|
80
|
+
"receivemessage",
|
|
81
|
+
"deletemessage",
|
|
82
|
+
"deletemessagebatch",
|
|
83
|
+
"changemessagevisibility",
|
|
84
|
+
"changemessagevisibilitybatch",
|
|
85
|
+
"getqueueattributes",
|
|
86
|
+
"getqueueurl",
|
|
87
|
+
"purgequeue",
|
|
88
|
+
]),
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** CloudFormation types that declare an entity of each curated service. */
|
|
92
|
+
const SERVICE_RESOURCE_TYPES: Record<string, ReadonlySet<string>> = {
|
|
93
|
+
s3: new Set(["AWS::S3::Bucket"]),
|
|
94
|
+
dynamodb: new Set(["AWS::DynamoDB::Table", "AWS::DynamoDB::GlobalTable"]),
|
|
95
|
+
sqs: new Set(["AWS::SQS::Queue"]),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* ARN resource segments that make an ARN service-wide rather than
|
|
100
|
+
* entity-scoped. `arn:aws:s3:::my-bucket/*` is bucket-scoped and NOT here.
|
|
101
|
+
*/
|
|
102
|
+
const SERVICE_WIDE_REST: Record<string, ReadonlySet<string>> = {
|
|
103
|
+
s3: new Set(["*"]),
|
|
104
|
+
dynamodb: new Set(["*", "table/*"]),
|
|
105
|
+
sqs: new Set(["*"]),
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const IAM_TYPES = new Set(["AWS::IAM::Policy", "AWS::IAM::Role", "AWS::IAM::ManagedPolicy"]);
|
|
109
|
+
|
|
110
|
+
const ARN_SERVICE_RE = /arn:[^:\s]*:([a-z0-9-]+):/g;
|
|
111
|
+
|
|
112
|
+
function asStringArray(value: unknown): string[] | null {
|
|
113
|
+
if (typeof value === "string") return [value];
|
|
114
|
+
if (Array.isArray(value) && value.every((v) => typeof v === "string")) {
|
|
115
|
+
return value as string[];
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** `"*"`, or an ARN whose service is in `services` and whose resource segment is service-wide. */
|
|
121
|
+
function isServiceWideResource(value: string, services: Set<string>): boolean {
|
|
122
|
+
if (value === "*") return true;
|
|
123
|
+
const m = /^arn:[^:]*:([^:]*):[^:]*:[^:]*:(.+)$/.exec(value);
|
|
124
|
+
if (!m) return false;
|
|
125
|
+
const [, service, rest] = m;
|
|
126
|
+
if (!services.has(service)) return false;
|
|
127
|
+
return SERVICE_WIDE_REST[service]?.has(rest) ?? false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The services an Allow statement touches, if every action lands in the
|
|
132
|
+
* curated resource-scopable table. Null when any action is outside it
|
|
133
|
+
* (or malformed / an action-name wildcard).
|
|
134
|
+
*/
|
|
135
|
+
function curatedServices(actions: string[]): Set<string> | null {
|
|
136
|
+
const services = new Set<string>();
|
|
137
|
+
for (const action of actions) {
|
|
138
|
+
const m = /^([a-z0-9-]+):([A-Za-z0-9]+)$/.exec(action);
|
|
139
|
+
if (!m) return null;
|
|
140
|
+
const [, service, name] = m;
|
|
141
|
+
const table = RESOURCE_SCOPABLE_ACTIONS[service];
|
|
142
|
+
if (!table || !table.has(name.toLowerCase())) return null;
|
|
143
|
+
services.add(service);
|
|
144
|
+
}
|
|
145
|
+
return services.size > 0 ? services : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Resolve which declared roles a policy resource is attached to. Null means
|
|
150
|
+
* "cannot prove" (users/groups, literal role names, no roles at all).
|
|
151
|
+
*/
|
|
152
|
+
function attachedRoles(logicalId: string, resource: CFResource, template: CFTemplate): Set<string> | null {
|
|
153
|
+
const resources = template.Resources ?? {};
|
|
154
|
+
if (resource.Type === "AWS::IAM::Role") return new Set([logicalId]);
|
|
155
|
+
|
|
156
|
+
const props = resource.Properties ?? {};
|
|
157
|
+
// Users/groups have no consumers in the resource graph — untraceable.
|
|
158
|
+
if (Array.isArray(props.Users) && props.Users.length > 0) return null;
|
|
159
|
+
if (Array.isArray(props.Groups) && props.Groups.length > 0) return null;
|
|
160
|
+
|
|
161
|
+
const roles = new Set<string>();
|
|
162
|
+
if (props.Roles !== undefined) {
|
|
163
|
+
if (!Array.isArray(props.Roles)) return null;
|
|
164
|
+
for (const entry of props.Roles) {
|
|
165
|
+
// A literal role name points outside the declared graph.
|
|
166
|
+
if (typeof entry === "string") return null;
|
|
167
|
+
const refs = findResourceRefs(entry);
|
|
168
|
+
if (refs.size !== 1) return null;
|
|
169
|
+
const [id] = refs;
|
|
170
|
+
if (resources[id]?.Type !== "AWS::IAM::Role") return null;
|
|
171
|
+
roles.add(id);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// A managed policy can also be attached from the role side (ManagedPolicyArns).
|
|
176
|
+
if (resource.Type === "AWS::IAM::ManagedPolicy") {
|
|
177
|
+
for (const [roleId, candidate] of Object.entries(resources)) {
|
|
178
|
+
if (candidate.Type !== "AWS::IAM::Role") continue;
|
|
179
|
+
if (findResourceRefs(candidate.Properties?.ManagedPolicyArns).has(logicalId)) {
|
|
180
|
+
roles.add(roleId);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return roles.size > 0 ? roles : null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The non-IAM resources that reference any of the roles (the role's
|
|
190
|
+
* consumers). An IAM::InstanceProfile is a pass-through hop: its own
|
|
191
|
+
* consumers count instead.
|
|
192
|
+
*/
|
|
193
|
+
function consumersOf(
|
|
194
|
+
roleIds: Set<string>,
|
|
195
|
+
reverseIndex: Map<string, Set<string>>,
|
|
196
|
+
template: CFTemplate,
|
|
197
|
+
): Array<[string, CFResource]> {
|
|
198
|
+
const resources = template.Resources ?? {};
|
|
199
|
+
const seen = new Set<string>();
|
|
200
|
+
const queue = [...roleIds].flatMap((id) => [...(reverseIndex.get(id) ?? [])]);
|
|
201
|
+
const consumers: Array<[string, CFResource]> = [];
|
|
202
|
+
|
|
203
|
+
while (queue.length > 0) {
|
|
204
|
+
const id = queue.shift() as string;
|
|
205
|
+
if (seen.has(id)) continue;
|
|
206
|
+
seen.add(id);
|
|
207
|
+
const resource = resources[id];
|
|
208
|
+
if (!resource) continue;
|
|
209
|
+
if (resource.Type === "AWS::IAM::InstanceProfile") {
|
|
210
|
+
queue.push(...(reverseIndex.get(id) ?? []));
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (resource.Type.startsWith("AWS::IAM::")) continue; // attachment, not consumption
|
|
214
|
+
consumers.push([id, resource]);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return consumers;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* A consumer edge the declared graph cannot enumerate: a literal ARN of one
|
|
222
|
+
* of the statement's services (a foreign, undeclared entity) or an
|
|
223
|
+
* Fn::ImportValue anywhere in the consumer (a cross-stack edge).
|
|
224
|
+
*/
|
|
225
|
+
function hasForeignOrIntrinsicEdge(value: unknown, services: Set<string>): boolean {
|
|
226
|
+
if (value === null || value === undefined) return false;
|
|
227
|
+
if (typeof value === "string") {
|
|
228
|
+
for (const m of value.matchAll(ARN_SERVICE_RE)) {
|
|
229
|
+
if (services.has(m[1])) return true;
|
|
230
|
+
}
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
if (typeof value !== "object") return false;
|
|
234
|
+
if (Array.isArray(value)) {
|
|
235
|
+
return value.some((item) => hasForeignOrIntrinsicEdge(item, services));
|
|
236
|
+
}
|
|
237
|
+
const obj = value as Record<string, unknown>;
|
|
238
|
+
if ("Fn::ImportValue" in obj) return true;
|
|
239
|
+
return Object.values(obj).some((v) => hasForeignOrIntrinsicEdge(v, services));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const S3_OBJECT_ACTIONS = new Set([
|
|
243
|
+
"getobject",
|
|
244
|
+
"getobjectversion",
|
|
245
|
+
"getobjectacl",
|
|
246
|
+
"getobjecttagging",
|
|
247
|
+
"putobject",
|
|
248
|
+
"putobjectacl",
|
|
249
|
+
"putobjecttagging",
|
|
250
|
+
"deleteobject",
|
|
251
|
+
"deleteobjectversion",
|
|
252
|
+
"listmultipartuploadparts",
|
|
253
|
+
"abortmultipartupload",
|
|
254
|
+
]);
|
|
255
|
+
|
|
256
|
+
function policyKind(type: string): string {
|
|
257
|
+
if (type === "AWS::IAM::Role") return "role";
|
|
258
|
+
if (type === "AWS::IAM::ManagedPolicy") return "managed policy";
|
|
259
|
+
return "policy";
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function checkWildcardResourceEnumerable(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
263
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
264
|
+
|
|
265
|
+
for (const [_lexicon, output] of ctx.outputs) {
|
|
266
|
+
const template = parseCFTemplate(output);
|
|
267
|
+
if (!template?.Resources) continue;
|
|
268
|
+
|
|
269
|
+
const reverseIndex = buildReverseRefIndex(template);
|
|
270
|
+
|
|
271
|
+
for (const [logicalId, resource] of Object.entries(template.Resources)) {
|
|
272
|
+
if (!IAM_TYPES.has(resource.Type)) continue;
|
|
273
|
+
|
|
274
|
+
// walkPolicyStatements also yields a role's AssumeRolePolicyDocument
|
|
275
|
+
// statements; those always carry a Principal and are gated off below.
|
|
276
|
+
for (const stmt of walkPolicyStatements(resource)) {
|
|
277
|
+
if (stmt.Effect !== "Allow") continue;
|
|
278
|
+
if ("Condition" in stmt || "NotAction" in stmt || "NotResource" in stmt) continue;
|
|
279
|
+
if ("Principal" in stmt || "NotPrincipal" in stmt) continue;
|
|
280
|
+
if (isIntrinsic(stmt.Action) || isIntrinsic(stmt.Resource)) continue;
|
|
281
|
+
|
|
282
|
+
const actions = asStringArray(stmt.Action);
|
|
283
|
+
const resources = asStringArray(stmt.Resource);
|
|
284
|
+
if (!actions || !resources || actions.length === 0 || resources.length === 0) continue;
|
|
285
|
+
|
|
286
|
+
const services = curatedServices(actions);
|
|
287
|
+
if (!services) continue;
|
|
288
|
+
if (!resources.every((r) => isServiceWideResource(r, services))) continue;
|
|
289
|
+
|
|
290
|
+
const roles = attachedRoles(logicalId, resource, template);
|
|
291
|
+
if (!roles) continue;
|
|
292
|
+
|
|
293
|
+
const consumers = consumersOf(roles, reverseIndex, template);
|
|
294
|
+
if (consumers.length === 0) continue;
|
|
295
|
+
|
|
296
|
+
// Every consumer edge must stay inside the declared graph.
|
|
297
|
+
if (consumers.some(([, c]) => hasForeignOrIntrinsicEdge(c.Properties, services))) continue;
|
|
298
|
+
|
|
299
|
+
// The declared entities of each granted service the consumers touch.
|
|
300
|
+
const touched = new Set<string>();
|
|
301
|
+
let enumerable = true;
|
|
302
|
+
for (const service of services) {
|
|
303
|
+
const types = SERVICE_RESOURCE_TYPES[service];
|
|
304
|
+
const serviceTouched = new Set<string>();
|
|
305
|
+
for (const [, consumer] of consumers) {
|
|
306
|
+
for (const ref of findResourceRefs(consumer.Properties)) {
|
|
307
|
+
if (types.has(template.Resources[ref]?.Type ?? "")) serviceTouched.add(ref);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (serviceTouched.size === 0) {
|
|
311
|
+
enumerable = false;
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
for (const id of serviceTouched) touched.add(id);
|
|
315
|
+
}
|
|
316
|
+
if (!enumerable) continue;
|
|
317
|
+
|
|
318
|
+
const suggestion = JSON.stringify(
|
|
319
|
+
[...touched].sort().map((id) => ({ "Fn::GetAtt": [id, "Arn"] })),
|
|
320
|
+
);
|
|
321
|
+
const objectHint = actions.some((a) => {
|
|
322
|
+
const [service, name] = a.split(":");
|
|
323
|
+
return service === "s3" && S3_OBJECT_ACTIONS.has(name.toLowerCase());
|
|
324
|
+
})
|
|
325
|
+
? ' (s3 object-level actions also need the "/*" object suffix on the bucket Arn)'
|
|
326
|
+
: "";
|
|
327
|
+
|
|
328
|
+
diagnostics.push({
|
|
329
|
+
checkId: "WAW059",
|
|
330
|
+
severity: "warning",
|
|
331
|
+
message:
|
|
332
|
+
`IAM ${policyKind(resource.Type)} "${logicalId}" allows ${actions.join(", ")} on ` +
|
|
333
|
+
`Resource ${JSON.stringify(stmt.Resource)}, but the declared graph enumerates the touched set — ` +
|
|
334
|
+
`tighten Resource to ${suggestion}${objectHint}`,
|
|
335
|
+
entity: logicalId,
|
|
336
|
+
lexicon: "aws",
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return diagnostics;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export const waw059: PostSynthCheck = {
|
|
346
|
+
id: "WAW059",
|
|
347
|
+
description:
|
|
348
|
+
"Wildcard Resource where the declared graph enumerates the touched set — tighten to the consumers' declared Arns",
|
|
349
|
+
|
|
350
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
351
|
+
return checkWildcardResourceEnumerable(ctx);
|
|
352
|
+
},
|
|
353
|
+
};
|