@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.
- package/dist/codegen/package.d.ts +1 -1
- package/dist/config.d.ts +2 -2
- package/dist/hcl/jsonencode.d.ts +106 -0
- package/dist/hcl/jsonencode.d.ts.map +1 -0
- package/dist/hcl/marker.d.ts.map +1 -1
- package/dist/integrity.json +11 -2
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/audit-lineage.d.ts +1 -1
- package/dist/lint/audit-lineage.d.ts.map +1 -1
- package/dist/lint/post-synth/flat-attributes.d.ts +69 -0
- package/dist/lint/post-synth/flat-attributes.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/tf030.d.ts +51 -0
- package/dist/lint/post-synth/tf030.d.ts.map +1 -0
- package/dist/lint/post-synth/tf031.d.ts +55 -0
- package/dist/lint/post-synth/tf031.d.ts.map +1 -0
- package/dist/lint/post-synth/tf032.d.ts +71 -0
- package/dist/lint/post-synth/tf032.d.ts.map +1 -0
- package/dist/lint/post-synth/tf033.d.ts +50 -0
- package/dist/lint/post-synth/tf033.d.ts.map +1 -0
- package/dist/lint/post-synth/tf034.d.ts +25 -0
- package/dist/lint/post-synth/tf034.d.ts.map +1 -0
- package/dist/lint/post-synth/tf035.d.ts +33 -0
- package/dist/lint/post-synth/tf035.d.ts.map +1 -0
- package/dist/lint/post-synth/tf036.d.ts +33 -0
- package/dist/lint/post-synth/tf036.d.ts.map +1 -0
- package/dist/lint/post-synth/tf037.d.ts +31 -0
- package/dist/lint/post-synth/tf037.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +8 -0
- package/dist/okf/rules/TF030.md +11 -0
- package/dist/okf/rules/TF031.md +11 -0
- package/dist/okf/rules/TF032.md +11 -0
- package/dist/okf/rules/TF033.md +11 -0
- package/dist/okf/rules/TF034.md +11 -0
- package/dist/okf/rules/TF035.md +11 -0
- package/dist/okf/rules/TF036.md +11 -0
- package/dist/okf/rules/TF037.md +11 -0
- package/dist/op/activity-contracts.d.ts +18 -18
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/rules/flat-attributes.ts +146 -0
- package/dist/rules/tf030.ts +290 -0
- package/dist/rules/tf031.ts +370 -0
- package/dist/rules/tf032.ts +226 -0
- package/dist/rules/tf033.ts +160 -0
- package/dist/rules/tf034.ts +63 -0
- package/dist/rules/tf035.ts +74 -0
- package/dist/rules/tf036.ts +119 -0
- package/dist/rules/tf037.ts +101 -0
- package/package.json +3 -3
- package/src/hcl/jsonencode.test.ts +226 -0
- package/src/hcl/jsonencode.ts +454 -0
- package/src/lint/audit-catalog.ts +148 -0
- package/src/lint/audit-lineage.ts +155 -1
- package/src/lint/post-synth/estate-2284.test.ts +68 -0
- package/src/lint/post-synth/fixtures/TF030/negative-rules.tf +45 -0
- package/src/lint/post-synth/fixtures/TF030/negative.tf +55 -0
- package/src/lint/post-synth/fixtures/TF030/not-determined-port.tf +28 -0
- package/src/lint/post-synth/fixtures/TF030/not-determined-standalone.tf +32 -0
- package/src/lint/post-synth/fixtures/TF030/not-determined-vpc-rule.tf +21 -0
- package/src/lint/post-synth/fixtures/TF030/not-determined.tf +39 -0
- package/src/lint/post-synth/fixtures/TF030/positive-ipv6.tf +44 -0
- package/src/lint/post-synth/fixtures/TF030/positive-mixed.tf +36 -0
- package/src/lint/post-synth/fixtures/TF030/positive-standalone.tf +25 -0
- package/src/lint/post-synth/fixtures/TF030/positive.tf +43 -0
- package/src/lint/post-synth/fixtures/TF031/negative.tf +89 -0
- package/src/lint/post-synth/fixtures/TF031/not-determined.tf +139 -0
- package/src/lint/post-synth/fixtures/TF031/positive-data-source.tf +57 -0
- package/src/lint/post-synth/fixtures/TF031/positive-heredoc.tf +149 -0
- package/src/lint/post-synth/fixtures/TF031/positive-jsonencode.tf +115 -0
- package/src/lint/post-synth/fixtures/TF032/negative-heredoc.tf +20 -0
- package/src/lint/post-synth/fixtures/TF032/negative-references.tf +214 -0
- package/src/lint/post-synth/fixtures/TF032/negative.tf +99 -0
- package/src/lint/post-synth/fixtures/TF032/positive-heredoc.tf +22 -0
- package/src/lint/post-synth/fixtures/TF032/positive-sidecar.tf +197 -0
- package/src/lint/post-synth/fixtures/TF032/positive.tf +54 -0
- package/src/lint/post-synth/fixtures/TF033/negative.tf +39 -0
- package/src/lint/post-synth/fixtures/TF033/not-determined-aurora.tf +25 -0
- package/src/lint/post-synth/fixtures/TF033/positive-absent.tf +93 -0
- package/src/lint/post-synth/fixtures/TF033/positive.tf +50 -0
- package/src/lint/post-synth/fixtures/TF034/negative.tf +24 -0
- package/src/lint/post-synth/fixtures/TF034/positive-absent.tf +36 -0
- package/src/lint/post-synth/fixtures/TF034/positive.tf +14 -0
- package/src/lint/post-synth/fixtures/TF035/negative-absent.tf +43 -0
- package/src/lint/post-synth/fixtures/TF035/negative.tf +54 -0
- package/src/lint/post-synth/fixtures/TF035/positive.tf +37 -0
- package/src/lint/post-synth/fixtures/TF036/negative.tf +61 -0
- package/src/lint/post-synth/fixtures/TF036/not-determined.tf +28 -0
- package/src/lint/post-synth/fixtures/TF036/positive.tf +38 -0
- package/src/lint/post-synth/fixtures/TF037/negative-exclusion.tf +63 -0
- package/src/lint/post-synth/fixtures/TF037/negative.tf +27 -0
- package/src/lint/post-synth/fixtures/TF037/not-determined.tf +27 -0
- package/src/lint/post-synth/fixtures/TF037/positive-absent.tf +15 -0
- package/src/lint/post-synth/fixtures/TF037/positive.tf +26 -0
- package/src/lint/post-synth/fixtures/estate-2284/clean/main.tf +132 -0
- package/src/lint/post-synth/fixtures/estate-2284/violating/main.tf +112 -0
- package/src/lint/post-synth/flat-attributes.ts +146 -0
- package/src/lint/post-synth/index.ts +16 -0
- package/src/lint/post-synth/tf030.test.ts +219 -0
- package/src/lint/post-synth/tf030.ts +290 -0
- package/src/lint/post-synth/tf031.test.ts +229 -0
- package/src/lint/post-synth/tf031.ts +370 -0
- package/src/lint/post-synth/tf032.test.ts +127 -0
- package/src/lint/post-synth/tf032.ts +226 -0
- package/src/lint/post-synth/tf033.test.ts +126 -0
- package/src/lint/post-synth/tf033.ts +160 -0
- package/src/lint/post-synth/tf034.test.ts +71 -0
- package/src/lint/post-synth/tf034.ts +63 -0
- package/src/lint/post-synth/tf035.test.ts +68 -0
- package/src/lint/post-synth/tf035.ts +74 -0
- package/src/lint/post-synth/tf036.test.ts +87 -0
- package/src/lint/post-synth/tf036.ts +119 -0
- package/src/lint/post-synth/tf037.test.ts +88 -0
- package/src/lint/post-synth/tf037.ts +101 -0
- 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,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
|
+
};
|