@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,290 @@
1
+ /**
2
+ * TF030: a security group rule opens a sensitive port to the whole internet.
3
+ *
4
+ * The native Terraform counterpart of the aws lexicon's WAW019 (#2285, under
5
+ * #2284), with the same port list and the same two open CIDRs. It cannot be a
6
+ * bridge to WAW019: the probe (docs/design/waw-hcl-fidelity-probe.md) ran the
7
+ * shipped WAW019 over a root opening 22, 3389, 3306 and 5432 to the world and
8
+ * got nothing back, because none of the three Terraform shapes looks like
9
+ * CloudFormation's `SecurityGroupIngress[].CidrIp`:
10
+ *
11
+ * - `ingress` on `aws_security_group`. The AWS provider types it as an
12
+ * attribute, `set(object({...}))`, not a nested block, though HCL lets it
13
+ * be written either way. hcl2json renders both spellings as one array of
14
+ * rule objects, so one reader covers both. `cidr_blocks` and
15
+ * `ipv6_cidr_blocks` are lists where CloudFormation has a scalar.
16
+ * - `aws_security_group_rule`, which is ingress only when `type = "ingress"`.
17
+ * - `aws_vpc_security_group_ingress_rule`, which spells the CIDR as the
18
+ * scalars `cidr_ipv4` / `cidr_ipv6` and the protocol as `ip_protocol`.
19
+ *
20
+ * A rule is reported when three things are all proved by literals: it is
21
+ * ingress, a CIDR is `0.0.0.0/0` or `::/0`, and its ports reach a sensitive
22
+ * port. Protocol `-1` (or `"all"`) reaches every port whatever `from_port` and
23
+ * `to_port` say. `tcp`, `udp`, `6` and `17` reach the ports between
24
+ * `from_port` and `to_port` inclusive, so `0`-`65535` reaches all four.
25
+ * Anything else (`icmp`, `icmpv6`, a protocol number like `50`) has no ports
26
+ * and is never reported.
27
+ *
28
+ * Nothing here evaluates an expression. When the answer hangs on a value the
29
+ * parse hands back as a reference or a template (`cidr_blocks =
30
+ * var.allowed`, `from_port = var.ssh_port`, `ingress = local.rules`, a
31
+ * `dynamic "ingress"` block), the rule emits one `info` finding that starts
32
+ * `Not determined:` and names the attributes, per the convention fixed on
33
+ * #2284. It never guesses a warning or an error out of something it could
34
+ * not read. A fact that settles the rule on its own still settles it, so
35
+ * `cidr_blocks = var.x` on port 443 is silent: no CIDR makes 443 sensitive.
36
+ *
37
+ * Absence is safe for this rule, which is the easy case of #2284's
38
+ * three-valued question. An `aws_security_group` with no `ingress` admits
39
+ * nothing (the provider default is no rules), and its rules may live in
40
+ * standalone resources, which this check reads in their own right.
41
+ *
42
+ * Scope: root and child modules alike (#2112). The condition is a property
43
+ * of the block itself.
44
+ */
45
+
46
+ import type {
47
+ PostSynthCheck,
48
+ PostSynthContext,
49
+ PostSynthDiagnostic,
50
+ } from "@intentius/chant/lint/post-synth";
51
+ import { RESOURCE_TYPE, type BlockBody } from "../../hcl/parse";
52
+ import { attr } from "../../hcl/value";
53
+ import { blocksOfType, nestedBodies, type TerraformBlock } from "./blocks";
54
+
55
+ /** The ports WAW019 treats as sensitive, with the name a message gives each. */
56
+ export const SENSITIVE_PORTS: ReadonlyMap<number, string> = new Map([
57
+ [22, "SSH"],
58
+ [3389, "RDP"],
59
+ [3306, "MySQL"],
60
+ [5432, "PostgreSQL"],
61
+ ]);
62
+
63
+ /** The two CIDRs that mean "every address". */
64
+ export const OPEN_CIDRS: ReadonlySet<string> = new Set(["0.0.0.0/0", "::/0"]);
65
+
66
+ /** Protocols whose rules carry a port range. */
67
+ const PORTED_PROTOCOLS = new Set(["tcp", "udp", "6", "17"]);
68
+ /** Protocols that mean every protocol and therefore every port. */
69
+ const ALL_PROTOCOLS = new Set(["-1", "all"]);
70
+
71
+ /**
72
+ * A three-valued answer. `unknown` carries the sentence fragments that say
73
+ * which attribute could not be read and what it holds.
74
+ */
75
+ type Answer<T> = { state: "yes"; value: T } | { state: "no" } | { state: "unknown"; why: string[] };
76
+
77
+ const NO = { state: "no" } as const;
78
+
79
+ /**
80
+ * An expression as its author wrote it. hcl2json wraps a bare reference in
81
+ * `${...}` (`var.x` arrives as `"${var.x}"`), so a message quoting the raw
82
+ * value would show a spelling that is not in the file.
83
+ */
84
+ function shown(raw: unknown): string {
85
+ const s = typeof raw === "string" ? raw : JSON.stringify(raw);
86
+ const whole = /^\$\{([\s\S]*)\}$/.exec(s);
87
+ return whole && !whole[1].includes("${") ? whole[1].trim() : s;
88
+ }
89
+
90
+ function unknown(name: string, raw: unknown): { state: "unknown"; why: string[] } {
91
+ return { state: "unknown", why: [`\`${name}\` is \`${shown(raw)}\``] };
92
+ }
93
+
94
+ /** Does a string carry an interpolation, i.e. is it something the parse did not evaluate? */
95
+ function isExpression(value: string): boolean {
96
+ return value.includes("${");
97
+ }
98
+
99
+ /**
100
+ * Which open CIDR, if any, a rule admits. List attributes (`cidr_blocks`,
101
+ * `ipv6_cidr_blocks`) and scalar ones (`cidr_ipv4`, `cidr_ipv6`) both pass
102
+ * through here: a scalar literal is read as a one-element list.
103
+ */
104
+ function openCidr(body: BlockBody, names: readonly string[]): Answer<string> {
105
+ const why: string[] = [];
106
+ for (const name of names) {
107
+ const a = attr(body, name);
108
+ if (a.kind === "absent") continue;
109
+ if (a.kind !== "literal") {
110
+ why.push(...unknown(name, a.raw).why);
111
+ continue;
112
+ }
113
+ const items = Array.isArray(a.value) ? a.value : [a.value];
114
+ for (const item of items) {
115
+ if (typeof item !== "string") continue;
116
+ if (OPEN_CIDRS.has(item.trim())) return { state: "yes", value: item.trim() };
117
+ if (isExpression(item)) why.push(`an element of \`${name}\` is \`${shown(item)}\``);
118
+ }
119
+ }
120
+ return why.length > 0 ? { state: "unknown", why } : NO;
121
+ }
122
+
123
+ /** A literal port: a number, or a numeric string, which hcl2json passes through when the source quotes it. */
124
+ function literalPort(body: BlockBody, name: string): Answer<number> {
125
+ const a = attr(body, name);
126
+ if (a.kind === "absent") return { state: "unknown", why: [`\`${name}\` is not set`] };
127
+ if (a.kind !== "literal") return unknown(name, a.raw);
128
+ const n = typeof a.value === "number" ? a.value : typeof a.value === "string" ? Number(a.value.trim()) : NaN;
129
+ return Number.isInteger(n) ? { state: "yes", value: n } : unknown(name, a.raw);
130
+ }
131
+
132
+ /** What a rule's ports reach: every port, a closed range, or nothing port-shaped. */
133
+ interface Reach {
134
+ /** `null` for protocol `-1`/`all`, which reaches every port whatever the range says. */
135
+ range: [number, number] | null;
136
+ /** The sensitive ports inside the reach, in list order. */
137
+ ports: number[];
138
+ }
139
+
140
+ function sensitiveReach(body: BlockBody, protocolName: string): Answer<Reach> {
141
+ const p = attr(body, protocolName);
142
+ let protocol = "tcp"; // the provider requires a protocol; a body without one is read as port-scoped
143
+ if (p.kind === "reference" || p.kind === "template") return unknown(protocolName, p.raw);
144
+ if (p.kind === "literal") protocol = String(p.value).trim().toLowerCase();
145
+
146
+ if (ALL_PROTOCOLS.has(protocol)) return { state: "yes", value: { range: null, ports: [...SENSITIVE_PORTS.keys()] } };
147
+ if (!PORTED_PROTOCOLS.has(protocol)) return NO;
148
+
149
+ const from = literalPort(body, "from_port");
150
+ const to = literalPort(body, "to_port");
151
+ if (from.state !== "yes" || to.state !== "yes") {
152
+ const why = [...(from.state === "unknown" ? from.why : []), ...(to.state === "unknown" ? to.why : [])];
153
+ return { state: "unknown", why };
154
+ }
155
+ const ports = [...SENSITIVE_PORTS.keys()].sort((a, b) => a - b).filter((port) => from.value <= port && port <= to.value);
156
+ return ports.length > 0 ? { state: "yes", value: { range: [from.value, to.value], ports } } : NO;
157
+ }
158
+
159
+ /** Is a standalone `aws_security_group_rule` an ingress rule? */
160
+ function isIngress(body: BlockBody): Answer<true> {
161
+ const t = attr(body, "type");
162
+ if (t.kind === "absent") return NO;
163
+ if (t.kind !== "literal") return unknown("type", t.raw);
164
+ return String(t.value).trim().toLowerCase() === "ingress" ? { state: "yes", value: true } : NO;
165
+ }
166
+
167
+ /** The outcome for one ingress rule, from its three facts. */
168
+ type Verdict =
169
+ | { state: "open"; cidr: string; reach: Reach }
170
+ | { state: "closed" }
171
+ | { state: "unknown"; why: string[] };
172
+
173
+ function judge(ingress: Answer<true>, cidr: Answer<string>, reach: Answer<Reach>): Verdict {
174
+ // One proved "no" settles it: whatever the unread values hold, this rule
175
+ // cannot open a sensitive port to the world.
176
+ if (ingress.state === "no" || cidr.state === "no" || reach.state === "no") return { state: "closed" };
177
+ const why = [ingress, cidr, reach].flatMap((a) => (a.state === "unknown" ? a.why : []));
178
+ if (why.length > 0) return { state: "unknown", why };
179
+ if (cidr.state !== "yes" || reach.state !== "yes") return { state: "closed" }; // unreachable; narrows the types
180
+ return { state: "open", cidr: cidr.value, reach: reach.value };
181
+ }
182
+
183
+ const INGRESS: Answer<true> = { state: "yes", value: true };
184
+
185
+ /** An inline rule of an `aws_security_group` (block or attribute syntax, same shape after the parse). */
186
+ function judgeInline(rule: BlockBody): Verdict {
187
+ return judge(INGRESS, openCidr(rule, ["cidr_blocks", "ipv6_cidr_blocks"]), sensitiveReach(rule, "protocol"));
188
+ }
189
+
190
+ /** "a, b and c" */
191
+ function listed(items: string[]): string {
192
+ return items.length <= 1 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
193
+ }
194
+
195
+ function describeReach(reach: Reach): string {
196
+ if (reach.range === null) return "every port (protocol -1), SSH, RDP, MySQL and PostgreSQL among them";
197
+ const [from, to] = reach.range;
198
+ if (from === to) return `port ${from} (${SENSITIVE_PORTS.get(from)})`;
199
+ return `ports ${from}-${to}, which include ${listed(reach.ports.map((p) => `${SENSITIVE_PORTS.get(p)} (${p})`))}`;
200
+ }
201
+
202
+ export const tf030: PostSynthCheck = {
203
+ id: "TF030",
204
+ description: "Security group rule allows unrestricted ingress on a sensitive port",
205
+
206
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
207
+ const diagnostics: PostSynthDiagnostic[] = [];
208
+
209
+ const report = (block: TerraformBlock, where: string, verdict: Verdict): void => {
210
+ const subject = where === "" ? `"${block.address}"` : `"${block.address}" ${where}`;
211
+ if (verdict.state === "open") {
212
+ diagnostics.push({
213
+ checkId: "TF030",
214
+ severity: "error",
215
+ message:
216
+ `${subject} allows ingress from ${verdict.cidr} on ${describeReach(verdict.reach)}. ` +
217
+ "Anyone on the internet can reach it. Restrict the CIDR to the sources that need it, or " +
218
+ "reach the host through a bastion, a VPN or SSM Session Manager instead.",
219
+ entity: block.key,
220
+ lexicon: "terraform",
221
+ });
222
+ } else if (verdict.state === "unknown") {
223
+ diagnostics.push({
224
+ checkId: "TF030",
225
+ severity: "info",
226
+ message:
227
+ `Not determined: ${subject}: ${verdict.why.join("; ")}. TF030 does not evaluate expressions, ` +
228
+ "so it cannot tell whether this rule opens SSH, RDP, MySQL or PostgreSQL to 0.0.0.0/0 or ::/0. " +
229
+ "Check the value the expression resolves to.",
230
+ entity: block.key,
231
+ lexicon: "terraform",
232
+ });
233
+ }
234
+ };
235
+
236
+ for (const block of blocksOfType(ctx, RESOURCE_TYPE)) {
237
+ const type = block.address.slice(0, block.address.indexOf("."));
238
+
239
+ if (type === "aws_security_group") {
240
+ const ingress = attr(block.body, "ingress");
241
+ if (ingress.kind === "reference" || ingress.kind === "template") {
242
+ report(block, "", { state: "unknown", why: unknown("ingress", ingress.raw).why });
243
+ } else if (ingress.kind === "literal") {
244
+ const rules = Array.isArray(ingress.value) ? ingress.value : [ingress.value];
245
+ rules.forEach((rule, i) => {
246
+ const where = `ingress rule ${i + 1}`;
247
+ if (typeof rule === "object" && rule !== null && !Array.isArray(rule)) {
248
+ report(block, where, judgeInline(rule as BlockBody));
249
+ } else {
250
+ report(block, where, unknown(`ingress[${i}]`, rule));
251
+ }
252
+ });
253
+ }
254
+ // A `dynamic "ingress"` block's rules come from its for_each, which is
255
+ // not evaluated. Its content still settles the case where no element
256
+ // could be open (a private CIDR literal, a non-sensitive port).
257
+ for (const dyn of nestedBodies(block.body, "dynamic")) {
258
+ for (const generator of nestedBodies(dyn, "ingress")) {
259
+ for (const content of nestedBodies(generator, "content")) {
260
+ const verdict = judgeInline(content);
261
+ if (verdict.state === "closed") continue;
262
+ const why = verdict.state === "unknown" ? verdict.why : [];
263
+ report(block, 'dynamic "ingress"', {
264
+ state: "unknown",
265
+ why: [`its rules come from \`for_each = ${shown(generator.for_each)}\``, ...why],
266
+ });
267
+ }
268
+ }
269
+ }
270
+ // No `ingress` at all: the group admits nothing. Absence is safe here.
271
+ continue;
272
+ }
273
+
274
+ if (type === "aws_security_group_rule") {
275
+ report(
276
+ block,
277
+ "",
278
+ judge(isIngress(block.body), openCidr(block.body, ["cidr_blocks", "ipv6_cidr_blocks"]), sensitiveReach(block.body, "protocol")),
279
+ );
280
+ continue;
281
+ }
282
+
283
+ if (type === "aws_vpc_security_group_ingress_rule") {
284
+ report(block, "", judge(INGRESS, openCidr(block.body, ["cidr_ipv4", "cidr_ipv6"]), sensitiveReach(block.body, "ip_protocol")));
285
+ }
286
+ }
287
+
288
+ return diagnostics;
289
+ },
290
+ };
@@ -0,0 +1,229 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import type { PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
3
+ import { blocksToEntities } from "../../hcl/parse";
4
+ import { loadFixture } from "./fixtures/load";
5
+ import { tf031 } from "./tf031";
6
+
7
+ async function inline(source: string): Promise<PostSynthContext> {
8
+ const entities = await blocksToEntities([{ name: "main.tf", source }], "app");
9
+ return { outputs: new Map(), entities } as unknown as PostSynthContext;
10
+ }
11
+
12
+ const warnings = (d: PostSynthDiagnostic[]) => d.filter((x) => x.severity === "warning");
13
+ const infos = (d: PostSynthDiagnostic[]) => d.filter((x) => x.severity === "info");
14
+ const entities = (d: PostSynthDiagnostic[]) => d.map((x) => x.entity).sort();
15
+
16
+ describe("TF031 over vendored real-world fixtures", () => {
17
+ test("a literal jsonencode policy with Action and Resource \"*\" (BishopFox/iam-vulnerable)", async () => {
18
+ const diags = tf031.check(await loadFixture("TF031", "positive-jsonencode"));
19
+ expect(diags).toHaveLength(1);
20
+ expect(diags[0]).toMatchObject({
21
+ checkId: "TF031",
22
+ severity: "warning",
23
+ lexicon: "terraform",
24
+ entity: "TF031/aws_iam_policy.privesc-AssumeRole-high-priv-policy",
25
+ });
26
+ expect(diags[0].message).toContain('statement 1 allows Action "*" and Resource "*"');
27
+ });
28
+
29
+ test("aws_iam_policy_document statements with resources = [\"*\"] (alphagov/tech-ops)", async () => {
30
+ const diags = tf031.check(await loadFixture("TF031", "positive-data-source"));
31
+ expect(warnings(diags).map((d) => d.entity).sort()).toEqual([
32
+ "TF031/data.aws_iam_policy_document.sts_inline_policy_document",
33
+ "TF031/data.aws_iam_policy_document.support_inline_policy_document",
34
+ ]);
35
+ // The role policies name those documents' .json: checked at the document, not again.
36
+ expect(infos(diags)).toEqual([]);
37
+ });
38
+
39
+ test("a heredoc reads as JSON; a variable policy is not determined (tf_aws_ecs)", async () => {
40
+ const diags = tf031.check(await loadFixture("TF031", "positive-heredoc"));
41
+ expect(entities(warnings(diags))).toEqual([
42
+ "TF031/aws_iam_policy.ecs_policy",
43
+ "TF031/data.aws_iam_policy_document.consul_task_policy",
44
+ ]);
45
+ const [nd] = infos(diags);
46
+ expect(infos(diags)).toHaveLength(1);
47
+ expect(nd.entity).toBe("TF031/aws_iam_policy.custom_ecs_policy");
48
+ expect(nd.message).toMatch(/^Not determined: /);
49
+ expect(nd.message).toContain("var.custom_iam_policy");
50
+ });
51
+
52
+ test("policies built with concat() are not determined, never a warning (alphagov/tech-ops)", async () => {
53
+ const diags = tf031.check(await loadFixture("TF031", "not-determined"));
54
+ expect(warnings(diags)).toEqual([]);
55
+ expect(entities(infos(diags))).toEqual([
56
+ "TF031/aws_iam_role.concourse_secrets_admin",
57
+ "TF031/aws_iam_role_policy.concourse_secrets_admin",
58
+ ]);
59
+ for (const d of diags) expect(d.message).toMatch(/^Not determined: .*Statement is a function call \(concat/);
60
+ });
61
+
62
+ test("scoped policies report nothing at all (alphagov/tech-ops)", async () => {
63
+ expect(tf031.check(await loadFixture("TF031", "negative"))).toEqual([]);
64
+ });
65
+ });
66
+
67
+ describe("TF031 cases the fixtures do not isolate", () => {
68
+ test("Resource \"*\" alone fires, naming only the field that is a wildcard", async () => {
69
+ const diags = tf031.check(
70
+ await inline(`
71
+ resource "aws_iam_role_policy" "p" {
72
+ role = aws_iam_role.r.id
73
+ policy = jsonencode({
74
+ Version = "2012-10-17"
75
+ Statement = [{ Sid = "Logs", Effect = "Allow", Action = ["logs:PutLogEvents"], Resource = "*" }]
76
+ })
77
+ }`),
78
+ );
79
+ expect(diags).toHaveLength(1);
80
+ expect(diags[0].message).toContain('statement 1 (Sid "Logs") allows Resource "*"');
81
+ expect(diags[0].message).toContain('allows Resource "*". ');
82
+ });
83
+
84
+ test("a literal Action \"*\" fires even when Resource is a reference", async () => {
85
+ const diags = tf031.check(
86
+ await inline(`
87
+ resource "aws_iam_user_policy" "p" {
88
+ user = "u"
89
+ policy = jsonencode({ Statement = [{ Effect = "Allow", Action = "*", Resource = var.arn }] })
90
+ }`),
91
+ );
92
+ expect(warnings(diags)).toHaveLength(1);
93
+ expect(diags[0].message).toContain('allows Action "*"');
94
+ });
95
+
96
+ test("a variable Resource with a scoped Action is not determined", async () => {
97
+ const diags = tf031.check(
98
+ await inline(`
99
+ resource "aws_iam_group_policy" "p" {
100
+ group = "g"
101
+ policy = jsonencode({ Statement = [{ Effect = "Allow", Action = "s3:GetObject", Resource = var.arn }] })
102
+ }`),
103
+ );
104
+ expect(diags).toHaveLength(1);
105
+ expect(diags[0].severity).toBe("info");
106
+ expect(diags[0].message).toContain("statement 1 Resource is a reference (var...)");
107
+ });
108
+
109
+ test("a template whose literal text is not * is scoped, not not-determined", async () => {
110
+ const diags = tf031.check(
111
+ await inline(`
112
+ resource "aws_iam_policy" "p" {
113
+ policy = jsonencode({ Statement = [{ Effect = "Allow", Action = "s3:GetObject", Resource = "arn:aws:s3:::\${var.bucket}/*" }] })
114
+ }
115
+ data "aws_iam_policy_document" "d" {
116
+ statement {
117
+ actions = ["s3:GetObject"]
118
+ resources = ["arn:aws:s3:::\${var.bucket}/*", aws_s3_bucket.b.arn]
119
+ }
120
+ }`),
121
+ );
122
+ expect(diags).toEqual([]);
123
+ });
124
+
125
+ test("Deny statements never fire", async () => {
126
+ const diags = tf031.check(
127
+ await inline(`
128
+ resource "aws_iam_policy" "p" {
129
+ policy = jsonencode({ Statement = [{ Effect = "Deny", Action = "*", Resource = "*" }] })
130
+ }
131
+ data "aws_iam_policy_document" "d" {
132
+ statement {
133
+ effect = "Deny"
134
+ actions = ["*"]
135
+ resources = ["*"]
136
+ }
137
+ }`),
138
+ );
139
+ expect(diags).toEqual([]);
140
+ });
141
+
142
+ test("an Effect from an expression beside a wildcard is not determined", async () => {
143
+ const diags = tf031.check(
144
+ await inline(`
145
+ data "aws_iam_policy_document" "d" {
146
+ statement {
147
+ effect = var.effect
148
+ actions = ["*"]
149
+ }
150
+ }`),
151
+ );
152
+ expect(diags).toHaveLength(1);
153
+ expect(diags[0].severity).toBe("info");
154
+ expect(diags[0].message).toContain("Effect is an expression (var.effect)");
155
+ });
156
+
157
+ test("inline_policy on aws_iam_role is read, and assume_role_policy is in scope", async () => {
158
+ const diags = tf031.check(
159
+ await inline(`
160
+ resource "aws_iam_role" "r" {
161
+ assume_role_policy = jsonencode({ Statement = [{ Effect = "Allow", Action = "*", Principal = { Service = "ec2.amazonaws.com" } }] })
162
+ inline_policy {
163
+ name = "admin"
164
+ policy = jsonencode({ Statement = [{ Effect = "Allow", Action = "*", Resource = "*" }] })
165
+ }
166
+ }`),
167
+ );
168
+ expect(diags.map((d) => d.message.match(/`([^`]+)`/)?.[1])).toEqual(["assume_role_policy", "inline_policy[0].policy"]);
169
+ });
170
+
171
+ test("file(), a templated heredoc and a for-expression argument are not determined", async () => {
172
+ const diags = tf031.check(
173
+ await inline(`
174
+ resource "aws_iam_policy" "f" {
175
+ policy = file("policy.json")
176
+ }
177
+ resource "aws_iam_policy" "t" {
178
+ policy = <<EOT
179
+ {"Statement":[{"Effect":"Allow","Action":"*","Resource":"\${var.arn}"}]}
180
+ EOT
181
+ }
182
+ resource "aws_iam_policy" "j" {
183
+ policy = jsonencode(local.policy)
184
+ }
185
+ data "aws_iam_policy_document" "dyn" {
186
+ dynamic "statement" {
187
+ for_each = var.statements
188
+ content {
189
+ actions = statement.value.actions
190
+ }
191
+ }
192
+ }`),
193
+ );
194
+ expect(warnings(diags)).toEqual([]);
195
+ const byEntity = Object.fromEntries(infos(diags).map((d) => [d.entity, d.message]));
196
+ expect(byEntity["app/aws_iam_policy.f"]).toContain("file() call");
197
+ expect(byEntity["app/aws_iam_policy.t"]).toContain("template string");
198
+ expect(byEntity["app/aws_iam_policy.j"]).toContain("jsonencode() call whose argument holds a reference");
199
+ expect(byEntity["app/data.aws_iam_policy_document.dyn"]).toContain('dynamic "statement"');
200
+ });
201
+
202
+ test("a policy naming a document declared in another module is not determined", async () => {
203
+ const diags = tf031.check(
204
+ await inline(`
205
+ resource "aws_iam_policy" "p" {
206
+ policy = data.aws_iam_policy_document.elsewhere.json
207
+ }`),
208
+ );
209
+ expect(diags).toHaveLength(1);
210
+ expect(diags[0].message).toContain("data.aws_iam_policy_document.elsewhere.json");
211
+ });
212
+
213
+ test("absence is not a finding: no policy argument, no inline_policy, empty data source", async () => {
214
+ const diags = tf031.check(
215
+ await inline(`
216
+ resource "aws_iam_policy" "p" {
217
+ name = "unset"
218
+ }
219
+ resource "aws_iam_role" "r" {
220
+ name = "no-policies"
221
+ }
222
+ data "aws_iam_policy_document" "empty" {}
223
+ resource "aws_vpc_endpoint" "out_of_scope" {
224
+ policy = jsonencode({ Statement = [{ Effect = "Allow", Action = "*", Resource = "*", Principal = "*" }] })
225
+ }`),
226
+ );
227
+ expect(diags).toEqual([]);
228
+ });
229
+ });