@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,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
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAW060: IAM Policy Attached to No Principal
|
|
3
|
+
*
|
|
4
|
+
* Flags AWS::IAM::Policy / AWS::IAM::ManagedPolicy resources with empty or
|
|
5
|
+
* absent Roles, Users, and Groups that no other part of the template
|
|
6
|
+
* references. A policy attached to no principal grants nothing — the same
|
|
7
|
+
* detached-guardrail shape WAW057 catches for SCPs. Any Ref/GetAtt edge to
|
|
8
|
+
* the policy (a role's ManagedPolicyArns, an attachment-style resource, a
|
|
9
|
+
* stack Output exporting it for another stack to attach) keeps it quiet;
|
|
10
|
+
* plain AWS-managed ARN strings elsewhere are not references to the
|
|
11
|
+
* template's own policy.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
15
|
+
import { parseCFTemplate, findResourceRefs, isIntrinsic, type CFTemplate } from "./cf-refs";
|
|
16
|
+
|
|
17
|
+
const POLICY_TYPES = new Set(["AWS::IAM::Policy", "AWS::IAM::ManagedPolicy"]);
|
|
18
|
+
const PRINCIPAL_PROPS = ["Roles", "Users", "Groups"] as const;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Collect every logical id referenced via Ref/Fn::GetAtt from anywhere in the
|
|
22
|
+
* template except the policy resources themselves — other resources'
|
|
23
|
+
* properties and the Outputs section. An Output edge counts as attachment
|
|
24
|
+
* evidence: the policy may be exported for a consuming stack to attach.
|
|
25
|
+
*/
|
|
26
|
+
function collectExternalRefs(template: CFTemplate, excludeIds: Set<string>): Set<string> {
|
|
27
|
+
const refs = new Set<string>();
|
|
28
|
+
for (const [logicalId, resource] of Object.entries(template.Resources ?? {})) {
|
|
29
|
+
if (excludeIds.has(logicalId)) continue;
|
|
30
|
+
for (const ref of findResourceRefs(resource)) refs.add(ref);
|
|
31
|
+
}
|
|
32
|
+
for (const ref of findResourceRefs(template.Outputs)) refs.add(ref);
|
|
33
|
+
return refs;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function checkPolicyUnattached(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
37
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
38
|
+
|
|
39
|
+
for (const [_lexicon, output] of ctx.outputs) {
|
|
40
|
+
const template = parseCFTemplate(output);
|
|
41
|
+
if (!template?.Resources) continue;
|
|
42
|
+
|
|
43
|
+
const policyIds = new Set(
|
|
44
|
+
Object.entries(template.Resources)
|
|
45
|
+
.filter(([, resource]) => POLICY_TYPES.has(resource.Type))
|
|
46
|
+
.map(([logicalId]) => logicalId),
|
|
47
|
+
);
|
|
48
|
+
if (policyIds.size === 0) continue;
|
|
49
|
+
|
|
50
|
+
const externalRefs = collectExternalRefs(template, policyIds);
|
|
51
|
+
|
|
52
|
+
for (const logicalId of policyIds) {
|
|
53
|
+
const resource = template.Resources[logicalId];
|
|
54
|
+
const props = resource.Properties ?? {};
|
|
55
|
+
|
|
56
|
+
// Any declared principal list keeps the policy quiet. An intrinsic
|
|
57
|
+
// can't be statically evaluated, so it counts as attached.
|
|
58
|
+
let attached = false;
|
|
59
|
+
for (const prop of PRINCIPAL_PROPS) {
|
|
60
|
+
const value = props[prop];
|
|
61
|
+
if (isIntrinsic(value)) attached = true;
|
|
62
|
+
else if (Array.isArray(value) && value.length > 0) attached = true;
|
|
63
|
+
}
|
|
64
|
+
if (attached) continue;
|
|
65
|
+
|
|
66
|
+
// Reverse lookup: anything else in the template holding a Ref/GetAtt
|
|
67
|
+
// edge to this policy attaches (or exports) it.
|
|
68
|
+
if (externalRefs.has(logicalId)) continue;
|
|
69
|
+
|
|
70
|
+
const kind = resource.Type === "AWS::IAM::ManagedPolicy" ? "Managed policy" : "Policy";
|
|
71
|
+
diagnostics.push({
|
|
72
|
+
checkId: "WAW060",
|
|
73
|
+
severity: "warning",
|
|
74
|
+
message: `${kind} "${logicalId}" is attached to no principal — no Roles/Users/Groups and nothing in the template references it; an unattached policy grants nothing`,
|
|
75
|
+
entity: logicalId,
|
|
76
|
+
lexicon: "aws",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return diagnostics;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const waw060: PostSynthCheck = {
|
|
85
|
+
id: "WAW060",
|
|
86
|
+
description: "IAM policy attached to no principal — it grants nothing",
|
|
87
|
+
|
|
88
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
89
|
+
return checkPolicyUnattached(ctx);
|
|
90
|
+
},
|
|
91
|
+
};
|
package/dist/serializer.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAsC,MAAM,6BAA6B,CAAC;
|
|
1
|
+
{"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAsC,MAAM,6BAA6B,CAAC;AAkZlG;;GAEG;AACH,eAAO,MAAM,aAAa,EAAE,UAoE3B,CAAC"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env teardown for the aws lexicon (chant #1222) — both halves of the
|
|
3
|
+
* `teardownOwned` / `executeTeardown` capability pair, at STACK granularity.
|
|
4
|
+
*
|
|
5
|
+
* Per-resource selection is impossible here by construction: the thin read
|
|
6
|
+
* (`describeResources`) is sourced from `describe-stack-resources`, which
|
|
7
|
+
* returns no tags, so no resource-level marker can be read on the teardown
|
|
8
|
+
* path. The stack is aws's ownership boundary anyway — the applier deploys
|
|
9
|
+
* whole stacks and deletes ride CloudFormation — so teardown enumerates the
|
|
10
|
+
* environment's stacks and verifies the marker on each STACK's own tags
|
|
11
|
+
* (`DescribeStacks` → `Tags`), stamped there by the apply paths from the
|
|
12
|
+
* template's `Metadata["chant:ownership"]` block.
|
|
13
|
+
*
|
|
14
|
+
* Which stacks are the environment's: the project's declared `stacks[]` when
|
|
15
|
+
* it is a multi-stack project, else the single-stack convention — the explicit
|
|
16
|
+
* `stack` option, else the stack named after the environment (the same rule
|
|
17
|
+
* `describeResources` and `exportResources` apply, see chant #932).
|
|
18
|
+
*
|
|
19
|
+
* Verification is tag-reading, never name-trusting. A resolved stack whose
|
|
20
|
+
* tags carry exactly the requested identity (managed-by + stack + env) is a
|
|
21
|
+
* candidate of type `AWS::CloudFormation::Stack`. A stack carrying a DIFFERENT
|
|
22
|
+
* marker identity verifiably belongs to another project or env — out of scope,
|
|
23
|
+
* silently, the way another env's resources are. A stack carrying NO marker at
|
|
24
|
+
* all is unknowable — a legacy chant stack from before stack tagging, or a
|
|
25
|
+
* foreign stack that happens to hold the env's name — and is reported as a
|
|
26
|
+
* hole (`filtered` / unverified-ownership), never deleted. An absent stack is
|
|
27
|
+
* knowledge, not a hole: nothing to tear down.
|
|
28
|
+
*
|
|
29
|
+
* Execution re-reads each candidate stack's tags immediately before deleting
|
|
30
|
+
* (the enumeration is a moment old at best, and a delete is not undoable),
|
|
31
|
+
* then rides the existing Query-API delete — `awsDelete`, DeleteStack polled
|
|
32
|
+
* to DELETE_COMPLETE. An identity that no longer matches is `not-prunable:
|
|
33
|
+
* unverified-ownership`; an already-absent stack is `deleted` (teardown is
|
|
34
|
+
* idempotent).
|
|
35
|
+
*/
|
|
36
|
+
import type { TeardownCandidate, TeardownEnumeration, TeardownExecution } from "@intentius/chant/lexicon";
|
|
37
|
+
import { type OwnershipMarker } from "@intentius/chant/ownership";
|
|
38
|
+
import { type AwsReadClientOptions } from "./api/read-client.js";
|
|
39
|
+
import { type AwsHttp } from "./op/activities/aws-apply.js";
|
|
40
|
+
/** The one candidate type this lexicon's teardown produces: whole stacks. */
|
|
41
|
+
export declare const STACK_TYPE = "AWS::CloudFormation::Stack";
|
|
42
|
+
export interface AwsTeardownOptions {
|
|
43
|
+
environment: string;
|
|
44
|
+
/** The identity to select on: this project's ownership stack + the env being torn down. */
|
|
45
|
+
marker: OwnershipMarker;
|
|
46
|
+
/** Explicit deployed stack name (single-stack override). */
|
|
47
|
+
stack?: string;
|
|
48
|
+
/** Region that stack is deployed in. */
|
|
49
|
+
region?: string;
|
|
50
|
+
/** A multi-stack project's declared stacks (chant.config `stacks[]`). */
|
|
51
|
+
stacks?: Array<{
|
|
52
|
+
name: string;
|
|
53
|
+
region?: string;
|
|
54
|
+
}>;
|
|
55
|
+
}
|
|
56
|
+
/** Injectable transports/timeouts, so tests double the Query API (no network). */
|
|
57
|
+
export interface AwsTeardownDeps {
|
|
58
|
+
/** Options for the read client's `DescribeStacks` calls (http injection, endpoint). */
|
|
59
|
+
read?: AwsReadClientOptions;
|
|
60
|
+
/** The applier transport `awsDelete` polls DeleteStack through. */
|
|
61
|
+
applyHttp?: AwsHttp;
|
|
62
|
+
/** DeleteStack settle timeout in ms (default `awsDelete`'s 300000). */
|
|
63
|
+
timeoutMs?: number;
|
|
64
|
+
/** DeleteStack poll interval in ms (default `awsDelete`'s 3000). */
|
|
65
|
+
intervalMs?: number;
|
|
66
|
+
}
|
|
67
|
+
/** The stacks an env teardown resolves to: `stacks[]`, else the single-stack convention. */
|
|
68
|
+
export declare function resolveTeardownStacks(options: AwsTeardownOptions): Array<{
|
|
69
|
+
name: string;
|
|
70
|
+
region?: string;
|
|
71
|
+
}>;
|
|
72
|
+
/**
|
|
73
|
+
* Enumerate the environment's marker-verified stacks — the aws half of
|
|
74
|
+
* `chant lifecycle teardown <env>` planning. Read-only.
|
|
75
|
+
*/
|
|
76
|
+
export declare function teardownOwned(options: AwsTeardownOptions, deps?: AwsTeardownDeps): Promise<TeardownEnumeration>;
|
|
77
|
+
/**
|
|
78
|
+
* Delete the handed-over stacks: re-verify each stack's own tags, then
|
|
79
|
+
* DeleteStack via `awsDelete`, polled to DELETE_COMPLETE. One outcome per
|
|
80
|
+
* candidate, always.
|
|
81
|
+
*/
|
|
82
|
+
export declare function executeTeardown(options: AwsTeardownOptions & {
|
|
83
|
+
candidates: TeardownCandidate[];
|
|
84
|
+
}, deps?: AwsTeardownDeps): Promise<TeardownExecution>;
|
|
85
|
+
//# sourceMappingURL=teardown.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"teardown.d.ts","sourceRoot":"","sources":["../src/teardown.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH,OAAO,KAAK,EACV,iBAAiB,EACjB,mBAAmB,EACnB,iBAAiB,EAGlB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAAiB,KAAK,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAEjF,OAAO,EAKL,KAAK,oBAAoB,EAC1B,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAa,KAAK,OAAO,EAAE,MAAM,2BAA2B,CAAC;AAEpE,6EAA6E;AAC7E,eAAO,MAAM,UAAU,+BAA+B,CAAC;AAEvD,MAAM,WAAW,kBAAkB;IACjC,WAAW,EAAE,MAAM,CAAC;IACpB,2FAA2F;IAC3F,MAAM,EAAE,eAAe,CAAC;IACxB,4DAA4D;IAC5D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,wCAAwC;IACxC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,yEAAyE;IACzE,MAAM,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACnD;AAED,kFAAkF;AAClF,MAAM,WAAW,eAAe;IAC9B,uFAAuF;IACvF,IAAI,CAAC,EAAE,oBAAoB,CAAC;IAC5B,mEAAmE;IACnE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,uEAAuE;IACvE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oEAAoE;IACpE,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,4FAA4F;AAC5F,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,kBAAkB,GAC1B,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAQ1C;AAmDD;;;GAGG;AACH,wBAAsB,aAAa,CACjC,OAAO,EAAE,kBAAkB,EAC3B,IAAI,GAAE,eAAoB,GACzB,OAAO,CAAC,mBAAmB,CAAC,CAuD9B;AAED;;;;GAIG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,kBAAkB,GAAG;IAAE,UAAU,EAAE,iBAAiB,EAAE,CAAA;CAAE,EACjE,IAAI,GAAE,eAAoB,GACzB,OAAO,CAAC,iBAAiB,CAAC,CAW5B"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-aws",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.46.0",
|
|
4
4
|
"description": "AWS CloudFormation lexicon for chant — declarative IaC in TypeScript",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
"typescript": "^5.9.3"
|
|
81
81
|
},
|
|
82
82
|
"peerDependencies": {
|
|
83
|
-
"@intentius/chant": "^0.
|
|
83
|
+
"@intentius/chant": "^0.46.0",
|
|
84
84
|
"typescript": "^5.9.3"
|
|
85
85
|
}
|
|
86
86
|
}
|
|
@@ -24,6 +24,7 @@ import { tmpdir } from "node:os";
|
|
|
24
24
|
import { join } from "node:path";
|
|
25
25
|
import { realDocker, type DockerClient } from "@intentius/chant/components/verbs/cloud-executor";
|
|
26
26
|
import { q } from "@intentius/chant/components/verbs/process-runner";
|
|
27
|
+
import { ownershipStackTagsForBody } from "../ownership.js";
|
|
27
28
|
|
|
28
29
|
export type { DockerClient };
|
|
29
30
|
|
|
@@ -432,10 +433,18 @@ const realCloudFormation: CloudFormationClient = {
|
|
|
432
433
|
body = readFileSync(args.templatePath, "utf8");
|
|
433
434
|
} catch { /* unreadable template — create-change-set reports it */ }
|
|
434
435
|
const capabilities = awsDeployCapabilitiesForBody(body).join(" ");
|
|
436
|
+
// The template's ownership marker becomes the STACK's own tags (#1222) —
|
|
437
|
+
// the identity stack-level teardown verifies before a DeleteStack. Same
|
|
438
|
+
// source as the applier's `Tags.member.N`: `Metadata["chant:ownership"]`.
|
|
439
|
+
const stackTags = Object.entries(ownershipStackTagsForBody(body))
|
|
440
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
441
|
+
.map(([k, v]) => q(`Key=${k},Value=${v}`))
|
|
442
|
+
.join(" ");
|
|
443
|
+
const tagsFlag = stackTags ? ` --tags ${stackTags}` : "";
|
|
435
444
|
await run(
|
|
436
445
|
`aws cloudformation create-change-set --stack-name ${q(args.stackName)} --change-set-name ${q(changeSetName)} ` +
|
|
437
446
|
`--change-set-type ${changeSetType} ` +
|
|
438
|
-
`--template-body file://${args.templatePath} --capabilities ${capabilities}${paramFlag}`,
|
|
447
|
+
`--template-body file://${args.templatePath} --capabilities ${capabilities}${paramFlag}${tagsFlag}`,
|
|
439
448
|
);
|
|
440
449
|
await run(
|
|
441
450
|
`aws cloudformation wait change-set-create-complete --stack-name ${q(args.stackName)} --change-set-name ${q(changeSetName)}`,
|
|
@@ -428,9 +428,13 @@ describeObservationConformance({
|
|
|
428
428
|
},
|
|
429
429
|
},
|
|
430
430
|
{
|
|
431
|
+
// describe-stack-resources also carries no tags to read a stack/env
|
|
432
|
+
// identity from, so `marker` (#1222) stays absent here — stack-level
|
|
433
|
+
// handling reads the stack's own tags instead.
|
|
431
434
|
name: "a healthy stack read",
|
|
432
435
|
declared: ["MyBucket"],
|
|
433
436
|
expectPresent: ["MyBucket"],
|
|
437
|
+
expectNoMarker: ["MyBucket"],
|
|
434
438
|
run: () => {
|
|
435
439
|
stubCfn((action) =>
|
|
436
440
|
action === "DescribeStackResources"
|
|
@@ -67,4 +67,9 @@ export const awsAuditCatalog: Record<string, RuleMeta> = {
|
|
|
67
67
|
WAW056: auditRule("WAW056", "merge-worthy", "guidance", "SCP guardrail has no Deny statement", "Add a Deny statement — SCPs only filter permissions, so a Deny-less SCP guards nothing.", { authority: [AWS_SEC] }),
|
|
68
68
|
WAW057: auditRule("WAW057", "merge-worthy", "guidance", "SCP guardrail attached to no targets", "Attach the SCP to the organization root or an OU via TargetIds.", { authority: [AWS_SEC] }),
|
|
69
69
|
WAW058: auditRule("WAW058", "merge-worthy", "guidance", "Organization audit trail dropped or scoped down", "Keep an organization CloudTrail with IsLogging: true and IsMultiRegionTrail: true.", { authority: [AWS_SEC] }),
|
|
70
|
+
|
|
71
|
+
// #1225 — least-privilege tightening the declared graph can prove. Report-only,
|
|
72
|
+
// so no authority citation (those are reserved for merge-worthy entries).
|
|
73
|
+
WAW059: auditRule("WAW059", "report-only", "guidance", "Wildcard Resource where the declared graph enumerates the touched set", "Tighten Resource from \"*\" to the Fn::GetAtt Arn list of the declared resources the role's consumers touch.", { category: "security" }),
|
|
74
|
+
WAW060: auditRule("WAW060", "report-only", "guidance", "IAM policy attached to no principal", "Attach the policy via Roles/Users/Groups or reference it from a principal's ManagedPolicyArns — unattached it grants nothing.", { category: "security" }),
|
|
70
75
|
};
|
|
@@ -50,6 +50,28 @@ export function findResourceRefs(value: unknown): Set<string> {
|
|
|
50
50
|
return refs;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Build the reverse of `findResourceRefs` for a whole template: logical id →
|
|
55
|
+
* the set of logical ids whose Properties reference it via Ref/Fn::GetAtt.
|
|
56
|
+
* Lets a check walk the graph consumer-ward (who uses this role?) instead of
|
|
57
|
+
* dependency-ward. Used by WAW059.
|
|
58
|
+
*/
|
|
59
|
+
export function buildReverseRefIndex(template: CFTemplate): Map<string, Set<string>> {
|
|
60
|
+
const index = new Map<string, Set<string>>();
|
|
61
|
+
for (const [logicalId, resource] of Object.entries(template.Resources ?? {})) {
|
|
62
|
+
for (const target of findResourceRefs(resource.Properties)) {
|
|
63
|
+
if (target === logicalId) continue;
|
|
64
|
+
let consumers = index.get(target);
|
|
65
|
+
if (!consumers) {
|
|
66
|
+
consumers = new Set();
|
|
67
|
+
index.set(target, consumers);
|
|
68
|
+
}
|
|
69
|
+
consumers.add(logicalId);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return index;
|
|
73
|
+
}
|
|
74
|
+
|
|
53
75
|
/**
|
|
54
76
|
* Check if a value is a CloudFormation intrinsic function (Ref, Fn::*, etc.)
|
|
55
77
|
* that cannot be statically evaluated.
|
|
@@ -50,6 +50,8 @@ import { waw055 } from "./waw055";
|
|
|
50
50
|
import { waw056 } from "./waw056";
|
|
51
51
|
import { waw057 } from "./waw057";
|
|
52
52
|
import { waw058 } from "./waw058";
|
|
53
|
+
import { waw059 } from "./waw059";
|
|
54
|
+
import { waw060 } from "./waw060";
|
|
53
55
|
|
|
54
56
|
export const postSynthChecks: PostSynthCheck[] = [
|
|
55
57
|
cor020,
|
|
@@ -102,4 +104,6 @@ export const postSynthChecks: PostSynthCheck[] = [
|
|
|
102
104
|
waw056,
|
|
103
105
|
waw057,
|
|
104
106
|
waw058,
|
|
107
|
+
waw059,
|
|
108
|
+
waw060,
|
|
105
109
|
];
|