@intentius/chant-lexicon-terraform 0.100.0 → 0.102.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.
Files changed (115) hide show
  1. package/dist/codegen/package.d.ts +1 -1
  2. package/dist/config.d.ts +2 -2
  3. package/dist/hcl/jsonencode.d.ts +106 -0
  4. package/dist/hcl/jsonencode.d.ts.map +1 -0
  5. package/dist/hcl/marker.d.ts.map +1 -1
  6. package/dist/integrity.json +11 -2
  7. package/dist/lint/audit-catalog.d.ts.map +1 -1
  8. package/dist/lint/audit-lineage.d.ts +1 -1
  9. package/dist/lint/audit-lineage.d.ts.map +1 -1
  10. package/dist/lint/post-synth/flat-attributes.d.ts +69 -0
  11. package/dist/lint/post-synth/flat-attributes.d.ts.map +1 -0
  12. package/dist/lint/post-synth/index.d.ts.map +1 -1
  13. package/dist/lint/post-synth/tf030.d.ts +51 -0
  14. package/dist/lint/post-synth/tf030.d.ts.map +1 -0
  15. package/dist/lint/post-synth/tf031.d.ts +55 -0
  16. package/dist/lint/post-synth/tf031.d.ts.map +1 -0
  17. package/dist/lint/post-synth/tf032.d.ts +71 -0
  18. package/dist/lint/post-synth/tf032.d.ts.map +1 -0
  19. package/dist/lint/post-synth/tf033.d.ts +50 -0
  20. package/dist/lint/post-synth/tf033.d.ts.map +1 -0
  21. package/dist/lint/post-synth/tf034.d.ts +25 -0
  22. package/dist/lint/post-synth/tf034.d.ts.map +1 -0
  23. package/dist/lint/post-synth/tf035.d.ts +33 -0
  24. package/dist/lint/post-synth/tf035.d.ts.map +1 -0
  25. package/dist/lint/post-synth/tf036.d.ts +33 -0
  26. package/dist/lint/post-synth/tf036.d.ts.map +1 -0
  27. package/dist/lint/post-synth/tf037.d.ts +31 -0
  28. package/dist/lint/post-synth/tf037.d.ts.map +1 -0
  29. package/dist/manifest.json +1 -1
  30. package/dist/okf/index.md +8 -0
  31. package/dist/okf/rules/TF030.md +11 -0
  32. package/dist/okf/rules/TF031.md +11 -0
  33. package/dist/okf/rules/TF032.md +11 -0
  34. package/dist/okf/rules/TF033.md +11 -0
  35. package/dist/okf/rules/TF034.md +11 -0
  36. package/dist/okf/rules/TF035.md +11 -0
  37. package/dist/okf/rules/TF036.md +11 -0
  38. package/dist/okf/rules/TF037.md +11 -0
  39. package/dist/op/activity-contracts.d.ts +18 -18
  40. package/dist/op/builders.d.ts.map +1 -1
  41. package/dist/rules/flat-attributes.ts +146 -0
  42. package/dist/rules/tf030.ts +290 -0
  43. package/dist/rules/tf031.ts +370 -0
  44. package/dist/rules/tf032.ts +226 -0
  45. package/dist/rules/tf033.ts +160 -0
  46. package/dist/rules/tf034.ts +63 -0
  47. package/dist/rules/tf035.ts +74 -0
  48. package/dist/rules/tf036.ts +119 -0
  49. package/dist/rules/tf037.ts +101 -0
  50. package/package.json +3 -3
  51. package/src/hcl/jsonencode.test.ts +226 -0
  52. package/src/hcl/jsonencode.ts +454 -0
  53. package/src/lint/audit-catalog.ts +148 -0
  54. package/src/lint/audit-lineage.ts +155 -1
  55. package/src/lint/post-synth/estate-2284.test.ts +68 -0
  56. package/src/lint/post-synth/fixtures/TF030/negative-rules.tf +45 -0
  57. package/src/lint/post-synth/fixtures/TF030/negative.tf +55 -0
  58. package/src/lint/post-synth/fixtures/TF030/not-determined-port.tf +28 -0
  59. package/src/lint/post-synth/fixtures/TF030/not-determined-standalone.tf +32 -0
  60. package/src/lint/post-synth/fixtures/TF030/not-determined-vpc-rule.tf +21 -0
  61. package/src/lint/post-synth/fixtures/TF030/not-determined.tf +39 -0
  62. package/src/lint/post-synth/fixtures/TF030/positive-ipv6.tf +44 -0
  63. package/src/lint/post-synth/fixtures/TF030/positive-mixed.tf +36 -0
  64. package/src/lint/post-synth/fixtures/TF030/positive-standalone.tf +25 -0
  65. package/src/lint/post-synth/fixtures/TF030/positive.tf +43 -0
  66. package/src/lint/post-synth/fixtures/TF031/negative.tf +89 -0
  67. package/src/lint/post-synth/fixtures/TF031/not-determined.tf +139 -0
  68. package/src/lint/post-synth/fixtures/TF031/positive-data-source.tf +57 -0
  69. package/src/lint/post-synth/fixtures/TF031/positive-heredoc.tf +149 -0
  70. package/src/lint/post-synth/fixtures/TF031/positive-jsonencode.tf +115 -0
  71. package/src/lint/post-synth/fixtures/TF032/negative-heredoc.tf +20 -0
  72. package/src/lint/post-synth/fixtures/TF032/negative-references.tf +214 -0
  73. package/src/lint/post-synth/fixtures/TF032/negative.tf +99 -0
  74. package/src/lint/post-synth/fixtures/TF032/positive-heredoc.tf +22 -0
  75. package/src/lint/post-synth/fixtures/TF032/positive-sidecar.tf +197 -0
  76. package/src/lint/post-synth/fixtures/TF032/positive.tf +54 -0
  77. package/src/lint/post-synth/fixtures/TF033/negative.tf +39 -0
  78. package/src/lint/post-synth/fixtures/TF033/not-determined-aurora.tf +25 -0
  79. package/src/lint/post-synth/fixtures/TF033/positive-absent.tf +93 -0
  80. package/src/lint/post-synth/fixtures/TF033/positive.tf +50 -0
  81. package/src/lint/post-synth/fixtures/TF034/negative.tf +24 -0
  82. package/src/lint/post-synth/fixtures/TF034/positive-absent.tf +36 -0
  83. package/src/lint/post-synth/fixtures/TF034/positive.tf +14 -0
  84. package/src/lint/post-synth/fixtures/TF035/negative-absent.tf +43 -0
  85. package/src/lint/post-synth/fixtures/TF035/negative.tf +54 -0
  86. package/src/lint/post-synth/fixtures/TF035/positive.tf +37 -0
  87. package/src/lint/post-synth/fixtures/TF036/negative.tf +61 -0
  88. package/src/lint/post-synth/fixtures/TF036/not-determined.tf +28 -0
  89. package/src/lint/post-synth/fixtures/TF036/positive.tf +38 -0
  90. package/src/lint/post-synth/fixtures/TF037/negative-exclusion.tf +63 -0
  91. package/src/lint/post-synth/fixtures/TF037/negative.tf +27 -0
  92. package/src/lint/post-synth/fixtures/TF037/not-determined.tf +27 -0
  93. package/src/lint/post-synth/fixtures/TF037/positive-absent.tf +15 -0
  94. package/src/lint/post-synth/fixtures/TF037/positive.tf +26 -0
  95. package/src/lint/post-synth/fixtures/estate-2284/clean/main.tf +132 -0
  96. package/src/lint/post-synth/fixtures/estate-2284/violating/main.tf +112 -0
  97. package/src/lint/post-synth/flat-attributes.ts +146 -0
  98. package/src/lint/post-synth/index.ts +16 -0
  99. package/src/lint/post-synth/tf030.test.ts +219 -0
  100. package/src/lint/post-synth/tf030.ts +290 -0
  101. package/src/lint/post-synth/tf031.test.ts +229 -0
  102. package/src/lint/post-synth/tf031.ts +370 -0
  103. package/src/lint/post-synth/tf032.test.ts +127 -0
  104. package/src/lint/post-synth/tf032.ts +226 -0
  105. package/src/lint/post-synth/tf033.test.ts +126 -0
  106. package/src/lint/post-synth/tf033.ts +160 -0
  107. package/src/lint/post-synth/tf034.test.ts +71 -0
  108. package/src/lint/post-synth/tf034.ts +63 -0
  109. package/src/lint/post-synth/tf035.test.ts +68 -0
  110. package/src/lint/post-synth/tf035.ts +74 -0
  111. package/src/lint/post-synth/tf036.test.ts +87 -0
  112. package/src/lint/post-synth/tf036.ts +119 -0
  113. package/src/lint/post-synth/tf037.test.ts +88 -0
  114. package/src/lint/post-synth/tf037.ts +101 -0
  115. package/src/op/activities/terraform.test.ts +2 -2
@@ -0,0 +1,370 @@
1
+ /**
2
+ * TF031: an IAM policy allows a wildcard Action or a wildcard Resource
3
+ * (chant #2286, epic #2284).
4
+ *
5
+ * The Terraform counterpart of the aws lexicon's WAW020, written natively
6
+ * against the parsed HCL rather than bridged (docs/design/waw-hcl-fidelity-probe.md
7
+ * explains why a bridge reported nothing here). WAW020 flags `Action: "*"`
8
+ * only; this rule also flags `Resource: "*"`, as #2286 asks and as tfsec's
9
+ * `aws-iam-no-policy-wildcards` does.
10
+ *
11
+ * What it reads. A policy argument in Terraform is a string, and hcl2json
12
+ * hands it over unevaluated, so the rule reads three shapes and no others:
13
+ *
14
+ * - `jsonencode({...})`: hcl2json keeps the call as the one string
15
+ * `"${jsonencode({...})}"` with HCL object syntax inside. `readJsonencode`
16
+ * (`../../hcl/jsonencode.ts`) reads the literal parts of that argument as
17
+ * structure, leaving each expression in it as an opaque leaf.
18
+ * - a literal string, which is what a heredoc with no interpolation becomes in
19
+ * the parse: the JSON text itself, read with `JSON.parse`.
20
+ * - a `data "aws_iam_policy_document"` block, whose `statement { actions,
21
+ * resources, effect }` arrive as plain structure. A policy argument that is
22
+ * exactly `data.aws_iam_policy_document.<name>.json` (or `.minified_json`)
23
+ * naming a document declared in the same module is not reported again: the
24
+ * document is checked where it is declared.
25
+ *
26
+ * The three-valued answer. A finding needs a literal that proves it: an
27
+ * `Allow` statement (the data source's default effect, or a literal
28
+ * `"Allow"`) whose Action or Resource is the literal `"*"` or a list holding
29
+ * it. A `Deny` statement never fires; `Deny` with `Action = "*"` is the usual
30
+ * guard rail. When the value the rule needs is not readable (a reference, a
31
+ * `file()` or `templatefile()` call, a template, a for-expression, a
32
+ * `dynamic "statement"`), the rule emits one `info` diagnostic starting
33
+ * `Not determined:` that names the attribute and why, and never a warning.
34
+ * A value that cannot be `"*"` whatever it evaluates to is not "not
35
+ * determined": an attribute of a managed resource or data source
36
+ * (`aws_s3_bucket.logs.arn`) or a template whose literal text already
37
+ * differs from `*` (`"arn:aws:s3:::${var.bucket}/*"`) is read as scoped.
38
+ *
39
+ * Absence. A resource with no policy argument, or a role with no
40
+ * `inline_policy`, is unconfigured, not insecure, and is not reported.
41
+ *
42
+ * In scope: `aws_iam_policy.policy`, `aws_iam_role_policy.policy`,
43
+ * `aws_iam_user_policy.policy`, `aws_iam_group_policy.policy`,
44
+ * `aws_iam_role.inline_policy[].policy`, `aws_iam_role.assume_role_policy`
45
+ * (WAW020 walks `AssumeRolePolicyDocument` too) and the
46
+ * `aws_iam_policy_document` data source. Resource-based policies (S3, SQS,
47
+ * KMS, VPC endpoint) are out of scope: there `Resource: "*"` usually means
48
+ * "this resource" and the risk sits in `Principal`.
49
+ *
50
+ * Scope: root and child modules alike (#2112). The condition is a property
51
+ * of the block itself.
52
+ */
53
+
54
+ import type {
55
+ PostSynthCheck,
56
+ PostSynthContext,
57
+ PostSynthDiagnostic,
58
+ } from "@intentius/chant/lint/post-synth";
59
+ import { DATA_TYPE, RESOURCE_TYPE, scopeOfKey, type BlockBody } from "../../hcl/parse";
60
+ import { attr } from "../../hcl/value";
61
+ import { hasUnknownKeys, isUnknown, readJsonencode } from "../../hcl/jsonencode";
62
+ import { blocksOfType, nestedBodies, type TerraformBlock } from "./blocks";
63
+
64
+ /** Resource type to the string-valued policy arguments it carries. */
65
+ const POLICY_ARGUMENTS: Record<string, readonly string[]> = {
66
+ aws_iam_policy: ["policy"],
67
+ aws_iam_role_policy: ["policy"],
68
+ aws_iam_user_policy: ["policy"],
69
+ aws_iam_group_policy: ["policy"],
70
+ aws_iam_role: ["assume_role_policy"],
71
+ };
72
+
73
+ const POLICY_DOCUMENT = "aws_iam_policy_document";
74
+
75
+ /** One readable value's answer to "is this `*`?". */
76
+ type Star = { kind: "star" } | { kind: "no" } | { kind: "unknown"; reason: string };
77
+
78
+ const NO: Star = { kind: "no" };
79
+ const STAR: Star = { kind: "star" };
80
+
81
+ /** A statement that proves a wildcard. */
82
+ interface Hit {
83
+ /** 1-based statement position within its document. */
84
+ index: number;
85
+ sid?: string;
86
+ fields: string[];
87
+ }
88
+
89
+ /** What reading one document found. */
90
+ interface DocumentRead {
91
+ hits: Hit[];
92
+ unknown: string[];
93
+ }
94
+
95
+ /** Expression roots that name an input or a computed value, not a managed object. */
96
+ const VALUE_ROOTS = new Set(["var", "local", "module", "each", "count", "path", "terraform", "self"]);
97
+
98
+ /** `aws_s3_bucket.logs.arn`, `data.aws_caller_identity.current.account_id`, with optional index or splat. */
99
+ const OBJECT_ATTRIBUTE = /^(?:data\.)?([a-z][a-z0-9_]*)\.[A-Za-z_][\w-]*(?:\[[^\]]*\])?(?:\.[A-Za-z_][\w-]*|\[[^\]]*\])+$/;
100
+
101
+ const SPANS = /[$%]\{(?:[^{}]|\{[^{}]*\})*\}/g;
102
+
103
+ /**
104
+ * Whether an expression the rule cannot evaluate could still be `*`. A
105
+ * resource or data source attribute is a provider-computed ARN or id, never
106
+ * `*`; a template is `*` only if its literal text is nothing but `*`.
107
+ */
108
+ function couldBeStar(expr: string): boolean {
109
+ const e = expr.trim();
110
+ if (e.startsWith('"') && e.endsWith('"') && e.length >= 2) return templateCouldBeStar(e.slice(1, -1));
111
+ const m = OBJECT_ATTRIBUTE.exec(e);
112
+ if (m && !VALUE_ROOTS.has(m[1])) return false;
113
+ return true;
114
+ }
115
+
116
+ function templateCouldBeStar(text: string): boolean {
117
+ return /^\**$/.test(text.replace(SPANS, ""));
118
+ }
119
+
120
+ /** One string from the hcl2json tree (a data source list element or effect). */
121
+ function hclString(value: string, what: string): Star {
122
+ const a = attr({ v: value }, "v");
123
+ if (a.kind === "literal") return value === "*" ? STAR : NO;
124
+ if (a.kind === "reference") {
125
+ const expr = a.refs?.[0] ?? "";
126
+ return couldBeStar(expr) ? { kind: "unknown", reason: `${what} is an expression (${expr})` } : NO;
127
+ }
128
+ return templateCouldBeStar(value) ? { kind: "unknown", reason: `${what} is a template ("${value}")` } : NO;
129
+ }
130
+
131
+ /** Action or Resource in a policy JSON tree (from jsonencode or JSON.parse). */
132
+ function jsonStar(value: unknown, field: string): Star {
133
+ if (value === "*") return STAR;
134
+ if (isUnknown(value)) {
135
+ return couldBeStar(value.source) ? { kind: "unknown", reason: `${field} is ${value.reason}` } : NO;
136
+ }
137
+ if (Array.isArray(value)) return anyStar(value.map((v) => jsonStar(v, field)));
138
+ return NO;
139
+ }
140
+
141
+ /** A list holds `*` if any element does; it is unknown if none does and one could. */
142
+ function anyStar(items: Star[]): Star {
143
+ if (items.some((s) => s.kind === "star")) return STAR;
144
+ return items.find((s) => s.kind === "unknown") ?? NO;
145
+ }
146
+
147
+ type Effect = "allow" | "other" | { unknown: string };
148
+
149
+ /** Read one statement's Effect, Action and Resource and add what it proves to `out`. */
150
+ function judge(index: number, sid: string | undefined, effect: Effect, action: Star, resource: Star, out: DocumentRead): void {
151
+ if (effect === "other") return;
152
+ const fields: string[] = [];
153
+ if (action.kind === "star") fields.push("Action");
154
+ if (resource.kind === "star") fields.push("Resource");
155
+ if (effect !== "allow") {
156
+ if (fields.length > 0 || action.kind === "unknown" || resource.kind === "unknown") {
157
+ out.unknown.push(`statement ${index} Effect is ${effect.unknown}`);
158
+ }
159
+ return;
160
+ }
161
+ if (fields.length > 0) {
162
+ out.hits.push({ index, sid, fields });
163
+ return;
164
+ }
165
+ for (const s of [action, resource]) if (s.kind === "unknown") out.unknown.push(`statement ${index} ${s.reason}`);
166
+ }
167
+
168
+ /** A policy document's statements, as JSON structure. */
169
+ function readJsonDocument(doc: unknown, out: DocumentRead): void {
170
+ if (isUnknown(doc)) {
171
+ out.unknown.push(`the document is ${doc.reason}`);
172
+ return;
173
+ }
174
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return;
175
+ const record = doc as Record<string, unknown>;
176
+ const statement = record.Statement;
177
+ if (statement === undefined) {
178
+ if (hasUnknownKeys(record)) out.unknown.push("the document has a key that is an expression");
179
+ return;
180
+ }
181
+ if (isUnknown(statement)) {
182
+ out.unknown.push(`Statement is ${statement.reason}`);
183
+ return;
184
+ }
185
+ const list = Array.isArray(statement) ? statement : [statement];
186
+ list.forEach((stmt, i) => {
187
+ const index = i + 1;
188
+ if (isUnknown(stmt)) {
189
+ out.unknown.push(`statement ${index} is ${stmt.reason}`);
190
+ return;
191
+ }
192
+ if (typeof stmt !== "object" || stmt === null || Array.isArray(stmt)) return;
193
+ const s = stmt as Record<string, unknown>;
194
+ const missing = s.Effect === undefined || s.Action === undefined || s.Resource === undefined;
195
+ if (hasUnknownKeys(s) && missing) {
196
+ out.unknown.push(`statement ${index} has a key that is an expression`);
197
+ }
198
+ const effect: Effect = isUnknown(s.Effect)
199
+ ? { unknown: s.Effect.reason }
200
+ : s.Effect === "Allow"
201
+ ? "allow"
202
+ : "other";
203
+ const sid = typeof s.Sid === "string" && s.Sid !== "" ? s.Sid : undefined;
204
+ judge(index, sid, effect, jsonStar(s.Action, "Action"), jsonStar(s.Resource, "Resource"), out);
205
+ });
206
+ }
207
+
208
+ /** `actions`/`resources` of a policy-document statement block. */
209
+ function hclListStar(body: BlockBody, name: string, field: string): Star {
210
+ const value = body[name];
211
+ if (value === undefined || value === null) return NO;
212
+ if (typeof value === "string") return hclString(value, `\`${name}\``);
213
+ if (Array.isArray(value)) {
214
+ return anyStar(value.map((v) => (typeof v === "string" ? hclString(v, `an element of \`${name}\``) : NO)));
215
+ }
216
+ return { kind: "unknown", reason: `\`${name}\` is not a list of strings (${field})` };
217
+ }
218
+
219
+ /** Read the `statement` blocks of a `data "aws_iam_policy_document"`. */
220
+ function readPolicyDocumentBlock(body: BlockBody): DocumentRead {
221
+ const out: DocumentRead = { hits: [], unknown: [] };
222
+ nestedBodies(body, "statement").forEach((stmt, i) => {
223
+ const index = i + 1;
224
+ const e = attr(stmt, "effect");
225
+ // `effect` defaults to "Allow" in the provider schema, so absence allows.
226
+ const effect: Effect =
227
+ e.kind === "absent"
228
+ ? "allow"
229
+ : e.kind === "literal"
230
+ ? e.value === "Allow"
231
+ ? "allow"
232
+ : "other"
233
+ : { unknown: `an expression (${e.refs?.join(", ") ?? ""})` };
234
+ const sid = typeof stmt.sid === "string" && attr(stmt, "sid").kind === "literal" && stmt.sid !== "" ? stmt.sid : undefined;
235
+ judge(index, sid, effect, hclListStar(stmt, "actions", "Action"), hclListStar(stmt, "resources", "Resource"), out);
236
+ });
237
+ for (const dyn of nestedBodies(body, "dynamic")) {
238
+ if (dyn.statement !== undefined && out.hits.length === 0) {
239
+ out.unknown.push('a `dynamic "statement"` block generates statements from an expression');
240
+ }
241
+ }
242
+ return out;
243
+ }
244
+
245
+ /** The `data.aws_iam_policy_document.<name>` a policy argument names, when it is exactly its `.json`. */
246
+ const DOCUMENT_JSON = /^data\.aws_iam_policy_document\.([A-Za-z_][\w-]*)\.(?:json|minified_json)$/;
247
+
248
+ /** Read one string-valued policy argument. `null` when there is nothing to report on (absent, or checked elsewhere). */
249
+ function readPolicyArgument(
250
+ body: BlockBody,
251
+ name: string,
252
+ documentDeclared: (docName: string) => boolean,
253
+ ): DocumentRead | null {
254
+ const a = attr(body, name);
255
+ if (a.kind === "absent") return null;
256
+ const out: DocumentRead = { hits: [], unknown: [] };
257
+
258
+ const encoded = readJsonencode(a.raw, { unknownLeaves: true });
259
+ if (encoded.kind === "literal") {
260
+ readJsonDocument(encoded.value, out);
261
+ return out;
262
+ }
263
+ if (encoded.kind === "not-determined") {
264
+ out.unknown.push(`it is a jsonencode() call whose argument holds ${encoded.reason}`);
265
+ return out;
266
+ }
267
+
268
+ if (a.kind === "reference") {
269
+ const expr = a.refs?.[0] ?? "";
270
+ const doc = DOCUMENT_JSON.exec(expr);
271
+ if (doc && documentDeclared(doc[1])) return null;
272
+ const call = /^([a-z_]+)\s*\(/.exec(expr);
273
+ out.unknown.push(
274
+ call
275
+ ? `it is a ${call[1]}() call, which TF031 does not evaluate`
276
+ : `it is a reference (${expr}) to a value TF031 cannot read here`,
277
+ );
278
+ return out;
279
+ }
280
+ if (a.kind === "template") {
281
+ out.unknown.push("it is a template string with interpolations");
282
+ return out;
283
+ }
284
+ if (typeof a.value !== "string") return null;
285
+ let parsed: unknown;
286
+ try {
287
+ parsed = JSON.parse(a.value);
288
+ } catch {
289
+ out.unknown.push("it is a literal string that does not parse as JSON (a heredoc with template directives, or not a policy)");
290
+ return out;
291
+ }
292
+ readJsonDocument(parsed, out);
293
+ return out;
294
+ }
295
+
296
+ function describeHits(hits: Hit[]): string {
297
+ return hits
298
+ .map((h) => {
299
+ const label = h.sid ? `statement ${h.index} (Sid "${h.sid}")` : `statement ${h.index}`;
300
+ return `${label} allows ${h.fields.map((f) => `${f} "*"`).join(" and ")}`;
301
+ })
302
+ .join("; ");
303
+ }
304
+
305
+ function report(block: TerraformBlock, where: string, read: DocumentRead, diagnostics: PostSynthDiagnostic[]): void {
306
+ if (read.hits.length > 0) {
307
+ diagnostics.push({
308
+ checkId: "TF031",
309
+ severity: "warning",
310
+ message:
311
+ `"${block.address}" ${where}: ${describeHits(read.hits)}. Action "*" grants every AWS action and ` +
312
+ 'Resource "*" reaches every resource in the account, including ones created later. Name the actions ' +
313
+ "the workload calls and the ARNs it touches. A few actions (ecr:GetAuthorizationToken, " +
314
+ 'sts:GetCallerIdentity) accept only Resource "*"; keep such a statement with a ' +
315
+ "`# chant-ignore-block: TF031` above the block.",
316
+ entity: block.key,
317
+ lexicon: "terraform",
318
+ });
319
+ return;
320
+ }
321
+ if (read.unknown.length > 0) {
322
+ const more = read.unknown.length > 1 ? ` (and ${read.unknown.length - 1} more)` : "";
323
+ diagnostics.push({
324
+ checkId: "TF031",
325
+ severity: "info",
326
+ message:
327
+ `Not determined: "${block.address}" ${where}: ${read.unknown[0]}${more}. TF031 reads only literal ` +
328
+ "policy structure and does not evaluate expressions, so it cannot tell whether this policy allows " +
329
+ 'Action "*" or Resource "*".',
330
+ entity: block.key,
331
+ lexicon: "terraform",
332
+ });
333
+ }
334
+ }
335
+
336
+ export const tf031: PostSynthCheck = {
337
+ id: "TF031",
338
+ description: "IAM policy allows a wildcard Action or Resource",
339
+
340
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
341
+ const diagnostics: PostSynthDiagnostic[] = [];
342
+ const documents = blocksOfType(ctx, DATA_TYPE).filter((b) => b.address.startsWith(`data.${POLICY_DOCUMENT}.`));
343
+ const declared = new Set(documents.map((b) => `${scopeOfKey(b.key)}/${b.address}`));
344
+
345
+ for (const block of blocksOfType(ctx, RESOURCE_TYPE)) {
346
+ const type = block.address.slice(0, block.address.indexOf("."));
347
+ const names = POLICY_ARGUMENTS[type];
348
+ if (!names) continue;
349
+ const scope = scopeOfKey(block.key);
350
+ const documentDeclared = (name: string): boolean => declared.has(`${scope}/data.${POLICY_DOCUMENT}.${name}`);
351
+
352
+ for (const name of names) {
353
+ const read = readPolicyArgument(block.body, name, documentDeclared);
354
+ if (read) report(block, `\`${name}\``, read, diagnostics);
355
+ }
356
+ if (type === "aws_iam_role") {
357
+ nestedBodies(block.body, "inline_policy").forEach((inline, i) => {
358
+ const read = readPolicyArgument(inline, "policy", documentDeclared);
359
+ if (read) report(block, `\`inline_policy[${i}].policy\``, read, diagnostics);
360
+ });
361
+ }
362
+ }
363
+
364
+ for (const block of documents) {
365
+ report(block, "`statement`", readPolicyDocumentBlock(block.body), diagnostics);
366
+ }
367
+
368
+ return diagnostics;
369
+ },
370
+ };
@@ -0,0 +1,127 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import type { PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
3
+ import { tf032 } from "./tf032";
4
+ import { loadFixture } from "./fixtures/load";
5
+ import { blocksToEntities } from "../../hcl/parse";
6
+
7
+ async function ctxOf(source: string): Promise<PostSynthContext> {
8
+ const entities = await blocksToEntities([{ name: "main.tf", source }], "TF032");
9
+ return { outputs: new Map(), entities } as unknown as PostSynthContext;
10
+ }
11
+
12
+ const errors = (d: PostSynthDiagnostic[]) => d.filter((x) => x.severity === "error");
13
+ const infos = (d: PostSynthDiagnostic[]) => d.filter((x) => x.severity === "info");
14
+
15
+ /** One task definition around a container_definitions expression. */
16
+ const task = (defs: string) => `resource "aws_ecs_task_definition" "t" {\n family = "t"\n container_definitions = ${defs}\n}\n`;
17
+
18
+ describe("TF032: plaintext credential in an ECS container definition", () => {
19
+ test("fires on each literal credential in a jsonencode'd environment (edstem paylink-platform)", async () => {
20
+ const diags = tf032.check(await loadFixture("TF032", "positive"));
21
+ expect(errors(diags).map((d) => d.message.match(/passes "([^"]+)"/)?.[1])).toEqual(["DB_PASSWORD", "PAYMENT_API_KEY"]);
22
+ // `image = "${var.ecr_repo}:latest"` is an unknown leaf, not a reason to give up on the container.
23
+ expect(infos(diags)).toHaveLength(0);
24
+ for (const d of errors(diags)) {
25
+ expect(d.checkId).toBe("TF032");
26
+ expect(d.entity).toBe("TF032/aws_ecs_task_definition.api");
27
+ expect(d.message).toContain("redacted");
28
+ expect(d.message).not.toContain("Pl-Pr0d-Db-2025!");
29
+ expect(d.message).not.toContain("pk_live_");
30
+ }
31
+ });
32
+
33
+ test("reads a heredoc with no interpolation as JSON (trivy-checks' failing example)", async () => {
34
+ const diags = tf032.check(await loadFixture("TF032", "positive-heredoc"));
35
+ expect(errors(diags)).toHaveLength(1);
36
+ expect(errors(diags)[0].message).toContain('"DATABASE_PASSWORD"');
37
+ expect(errors(diags)[0].message).toContain('container "my_service"');
38
+ });
39
+
40
+ test("fires on a literal sidecar password and reports the local-fed environments as not determined (govuk forms-admin)", async () => {
41
+ const diags = tf032.check(await loadFixture("TF032", "positive-sidecar"));
42
+ expect(errors(diags)).toHaveLength(1);
43
+ expect(errors(diags)[0].message).toContain('container "postgres" passes "POSTGRES_PASSWORD"');
44
+ const nd = infos(diags);
45
+ expect(nd).toHaveLength(2);
46
+ for (const d of nd) {
47
+ expect(d.message.startsWith("Not determined:")).toBe(true);
48
+ expect(d.message).toContain("`environment`");
49
+ }
50
+ expect(nd.map((d) => d.message.match(/container "([^"]+)"/)?.[1])).toEqual(["forms-admin", "forms-admin-seeding"]);
51
+ });
52
+
53
+ test("is silent when credentials come through secrets (vapor penny-bot)", async () => {
54
+ expect(tf032.check(await loadFixture("TF032", "negative"))).toEqual([]);
55
+ });
56
+
57
+ test("is silent on trivy-checks' passing example", async () => {
58
+ expect(tf032.check(await loadFixture("TF032", "negative-heredoc"))).toEqual([]);
59
+ });
60
+
61
+ test("a reference value is not a literal, a container with no environment is not insecure (testdrivenio)", async () => {
62
+ const diags = tf032.check(await loadFixture("TF032", "negative-references"));
63
+ expect(errors(diags)).toEqual([]);
64
+ // The one task whose definitions are `data.template_file.app.rendered`.
65
+ expect(infos(diags)).toHaveLength(1);
66
+ expect(infos(diags)[0].entity).toBe("TF032/aws_ecs_task_definition.app");
67
+ expect(infos(diags)[0].message).toMatch(/^Not determined: .*data\.template_file\.app\.rendered/);
68
+ });
69
+
70
+ describe("not determined, never a warning", () => {
71
+ test.each([
72
+ ["file()", 'file("service.json")', /call to file\(\)/],
73
+ ["templatefile()", 'templatefile("defs.tpl", { image = var.image })', /call to templatefile\(\)/],
74
+ ["a local", "local.container_definitions", /reference `local\.container_definitions`/],
75
+ ["jsonencode(concat(...))", "jsonencode(concat([{ name = \"a\" }], var.extra))", /jsonencode argument holds/],
76
+ ["a heredoc with interpolation", '<<EOT\n[{"name":"a","image":"${var.image}"}]\nEOT', /interpolation/],
77
+ ])("%s", async (_label, defs, reason) => {
78
+ const diags = tf032.check(await ctxOf(task(defs)));
79
+ expect(diags).toHaveLength(1);
80
+ expect(diags[0].severity).toBe("info");
81
+ expect(diags[0].message.startsWith("Not determined:")).toBe(true);
82
+ expect(diags[0].message).toMatch(reason);
83
+ });
84
+
85
+ test("an environment entry that is itself an expression", async () => {
86
+ const diags = tf032.check(
87
+ await ctxOf(task('jsonencode([{ name = "a", environment = [{ name = "MODE", value = "api" }, local.extra_env] }])')),
88
+ );
89
+ expect(diags).toHaveLength(1);
90
+ expect(diags[0].severity).toBe("info");
91
+ expect(diags[0].message).toContain('container "a"');
92
+ });
93
+ });
94
+
95
+ test("the same name through secrets does not fire; a secret-named literal in environment does", async () => {
96
+ const viaSecrets = task(
97
+ 'jsonencode([{ name = "a", secrets = [{ name = "DB_PASSWORD", valueFrom = "arn:aws:ssm:eu-west-1:123456789012:parameter/db" }] }])',
98
+ );
99
+ expect(tf032.check(await ctxOf(viaSecrets))).toEqual([]);
100
+ const viaEnv = task('jsonencode([{ name = "a", environment = [{ name = "DB_PASSWORD", value = "s3cr3t-Pa55word" }] }])');
101
+ expect(errors(tf032.check(await ctxOf(viaEnv)))).toHaveLength(1);
102
+ });
103
+
104
+ test("short values, placeholders and lookalike names are not credentials", async () => {
105
+ const defs = task(
106
+ 'jsonencode([{ name = "a", environment = [' +
107
+ '{ name = "PASSWORD_MIN_LENGTH", value = "8" },' +
108
+ '{ name = "DB_PASSWORD", value = "changeme-please" },' +
109
+ '{ name = "TOKENIZER", value = "wordpiece-uncased" },' +
110
+ '{ name = "DB_SECRET_ARN", value = "arn:aws:secretsmanager:eu-west-1:123456789012:secret:db" }' +
111
+ "] }])",
112
+ );
113
+ expect(tf032.check(await ctxOf(defs))).toEqual([]);
114
+ });
115
+
116
+ test("a credential-shaped value fires whatever it is called", async () => {
117
+ const defs = task('jsonencode([{ name = "a", environment = [{ name = "UPSTREAM", value = "AKIAIOSFODNN7EXAMPLF" }] }])');
118
+ const diags = errors(tf032.check(await ctxOf(defs)));
119
+ expect(diags).toHaveLength(1);
120
+ expect(diags[0].message).toContain("shape of a credential");
121
+ });
122
+
123
+ test("only aws_ecs_task_definition is read", async () => {
124
+ const src = 'resource "aws_ecs_service" "s" {\n name = "s"\n container_definitions = local.x\n}\n';
125
+ expect(tf032.check(await ctxOf(src))).toEqual([]);
126
+ });
127
+ });