@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,454 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the argument of a `jsonencode(...)` call as structure, when it is
|
|
3
|
+
* literal (chant #2286, shared with #2287).
|
|
4
|
+
*
|
|
5
|
+
* hcl2json does not evaluate expressions. `policy = jsonencode({...})` and
|
|
6
|
+
* `container_definitions = jsonencode([...])` both reach a block body as ONE
|
|
7
|
+
* string holding the expression's source text, wrapped as an interpolation:
|
|
8
|
+
*
|
|
9
|
+
* ```
|
|
10
|
+
* "${jsonencode({\n Version = \"2012-10-17\"\n Statement = [{ Action = \"*\" }]\n })}"
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* The text inside is HCL object syntax, not JSON (`=` or `:` separators, bare
|
|
14
|
+
* keys, newline-separated items, `#` comments). A rule that wants the policy
|
|
15
|
+
* or the container definitions has to read that text itself. This file does
|
|
16
|
+
* so, narrowly: it accepts literal objects, tuples, quoted strings, numbers,
|
|
17
|
+
* `true`, `false` and `null`, and nothing else. Any expression in the
|
|
18
|
+
* argument (a reference, a function call, a for-expression, a splat, a
|
|
19
|
+
* conditional, an operator, a template interpolation inside a string, a
|
|
20
|
+
* heredoc, a parenthesised key) makes the whole read `not-determined`, with a
|
|
21
|
+
* reason naming what was found. It never evaluates anything, so it can never
|
|
22
|
+
* return a value Terraform would not also produce from the same text.
|
|
23
|
+
*
|
|
24
|
+
* Three answers:
|
|
25
|
+
* - `literal`: the value is a single whole-string `${jsonencode(...)}` and
|
|
26
|
+
* its argument is fully literal. `value` is the plain JS value
|
|
27
|
+
* `jsonencode` would encode.
|
|
28
|
+
* - `not-determined`: the value is a `${jsonencode(...)}` call, but the
|
|
29
|
+
* argument holds something this reader does not evaluate.
|
|
30
|
+
* - `not-jsonencode`: the value is not a `${jsonencode(...)}` call at all
|
|
31
|
+
* (a plain string, a reference, `file(...)`, a number, absent). Callers
|
|
32
|
+
* decide what that means for their attribute; a plain string, for example,
|
|
33
|
+
* may itself be JSON from a heredoc.
|
|
34
|
+
*
|
|
35
|
+
* The whole-string test is shape-based: the string starts `${jsonencode(` and
|
|
36
|
+
* ends `)}`. A string that matches that shape but is really a template with
|
|
37
|
+
* two interpolations (`${jsonencode(a)}-${jsonencode(b)}`) fails the literal
|
|
38
|
+
* parse at the point where the first call closes early, and reads as
|
|
39
|
+
* `not-determined`, never as `literal`.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
export type JsonencodeRead =
|
|
43
|
+
/** The argument is a fully literal HCL object/tuple/string/number/bool/null tree. */
|
|
44
|
+
| { kind: "literal"; value: unknown }
|
|
45
|
+
/** `jsonencode(...)` whose argument contains a reference, function call, for-expression, splat, conditional, etc. */
|
|
46
|
+
| { kind: "not-determined"; reason: string }
|
|
47
|
+
/** The value is not a single whole-string `${jsonencode(...)}` at all. */
|
|
48
|
+
| { kind: "not-jsonencode" };
|
|
49
|
+
|
|
50
|
+
export interface ReadJsonencodeOptions {
|
|
51
|
+
/**
|
|
52
|
+
* Read past a non-literal VALUE instead of giving up on the whole argument.
|
|
53
|
+
* Each such value (a reference, a function call, a conditional, an
|
|
54
|
+
* interpolated string, a heredoc, any other expression) becomes an
|
|
55
|
+
* {@link UnknownLeaf} in the returned tree, so a rule can still read the
|
|
56
|
+
* literal fields beside it: `Action = "*"` next to
|
|
57
|
+
* `Resource = aws_s3_bucket.x.arn`, or a container's literal `environment`
|
|
58
|
+
* next to `image = var.image`.
|
|
59
|
+
*
|
|
60
|
+
* The skip is narrow. It consumes one value expression, balancing `()[]{}`
|
|
61
|
+
* and quoted strings, up to the next `,`, newline or closing bracket at its
|
|
62
|
+
* own depth. A member whose KEY is an expression (an interpolated quoted
|
|
63
|
+
* key or a parenthesised computed key) is dropped from its object and listed
|
|
64
|
+
* under {@link UNKNOWN_KEYS} on that object. Damage to the STRUCTURE is
|
|
65
|
+
* still `not-determined`: an expression as the whole argument, including
|
|
66
|
+
* a for-expression or a splat (`jsonencode(local.policy)`,
|
|
67
|
+
* `jsonencode(var.x[*])`), since then no key or element is known.
|
|
68
|
+
*/
|
|
69
|
+
unknownLeaves?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Marks a value position `readJsonencode(raw, { unknownLeaves: true })` could not read. */
|
|
73
|
+
export const UNKNOWN: unique symbol = Symbol("jsonencode.unknown");
|
|
74
|
+
|
|
75
|
+
/** A value position holding an expression; `reason` names what was found there. */
|
|
76
|
+
export interface UnknownLeaf {
|
|
77
|
+
readonly [UNKNOWN]: true;
|
|
78
|
+
readonly reason: string;
|
|
79
|
+
/**
|
|
80
|
+
* The skipped expression's own source text, trimmed (`aws_s3_bucket.x.arn`,
|
|
81
|
+
* `"arn:aws:s3:::${var.bucket}/*"` with its quotes). A rule may read it to
|
|
82
|
+
* rule a value OUT (a quoted template whose literal text already differs
|
|
83
|
+
* from the value it looks for), never to evaluate it.
|
|
84
|
+
*/
|
|
85
|
+
readonly source: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Marks an object, read with `unknownLeaves`, that had members whose KEY is an
|
|
90
|
+
* expression (`"router-${var.n}.rule" = ...`, `(var.k) = ...`). Those members
|
|
91
|
+
* are dropped from the object and their reasons listed here, so a reader can
|
|
92
|
+
* tell "this key is absent" from "some key here could not be read".
|
|
93
|
+
*/
|
|
94
|
+
export const UNKNOWN_KEYS: unique symbol = Symbol("jsonencode.unknownKeys");
|
|
95
|
+
|
|
96
|
+
/** Whether `obj` is an object that dropped one or more members with an expression key. */
|
|
97
|
+
export function hasUnknownKeys(obj: unknown): obj is Record<string, unknown> & { [UNKNOWN_KEYS]: string[] } {
|
|
98
|
+
return (
|
|
99
|
+
typeof obj === "object" &&
|
|
100
|
+
obj !== null &&
|
|
101
|
+
Array.isArray((obj as Record<symbol, unknown>)[UNKNOWN_KEYS]) &&
|
|
102
|
+
((obj as Record<symbol, unknown>)[UNKNOWN_KEYS] as unknown[]).length > 0
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Whether `v` is a value position the reader skipped. */
|
|
107
|
+
export function isUnknown(v: unknown): v is UnknownLeaf {
|
|
108
|
+
return typeof v === "object" && v !== null && (v as Record<symbol, unknown>)[UNKNOWN] === true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const PREFIX = /^\$\{\s*jsonencode\s*\(/;
|
|
112
|
+
const SUFFIX = /\)\s*\}$/;
|
|
113
|
+
|
|
114
|
+
/** Read `raw` (an hcl2json attribute value) as a `jsonencode(...)` call. */
|
|
115
|
+
export function readJsonencode(raw: unknown, options: ReadJsonencodeOptions = {}): JsonencodeRead {
|
|
116
|
+
if (typeof raw !== "string") return { kind: "not-jsonencode" };
|
|
117
|
+
const prefix = PREFIX.exec(raw);
|
|
118
|
+
if (!prefix || !SUFFIX.test(raw)) return { kind: "not-jsonencode" };
|
|
119
|
+
|
|
120
|
+
const reader = new Reader(raw, prefix[0].length, options.unknownLeaves === true);
|
|
121
|
+
try {
|
|
122
|
+
const value = reader.value(0);
|
|
123
|
+
reader.skipSpace();
|
|
124
|
+
// The argument must end at the call's own `)`, then the interpolation's `}`.
|
|
125
|
+
reader.expect(")", "a second argument or an expression after the literal");
|
|
126
|
+
reader.skipSpace();
|
|
127
|
+
reader.expect("}", "text after the jsonencode call");
|
|
128
|
+
if (!reader.atEnd()) throw new NotLiteral("text after the jsonencode call");
|
|
129
|
+
return { kind: "literal", value };
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if (err instanceof NotLiteral) return { kind: "not-determined", reason: err.message };
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
class NotLiteral extends Error {}
|
|
137
|
+
|
|
138
|
+
/** Damage to the shape itself: never recovered by skipping a leaf. */
|
|
139
|
+
class Structural extends NotLiteral {}
|
|
140
|
+
|
|
141
|
+
/** An object key that is an expression; recoverable by dropping the member under `unknownLeaves`. */
|
|
142
|
+
class KeyNotLiteral extends Structural {}
|
|
143
|
+
|
|
144
|
+
const IDENT = /[A-Za-z_][A-Za-z0-9_-]*/y;
|
|
145
|
+
const NUMBER = /-?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/y;
|
|
146
|
+
|
|
147
|
+
/** A cursor over the source text of the jsonencode argument. */
|
|
148
|
+
class Reader {
|
|
149
|
+
constructor(
|
|
150
|
+
private readonly src: string,
|
|
151
|
+
private pos: number,
|
|
152
|
+
private readonly unknownLeaves = false,
|
|
153
|
+
) {}
|
|
154
|
+
|
|
155
|
+
atEnd(): boolean {
|
|
156
|
+
return this.pos >= this.src.length;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private peek(): string {
|
|
160
|
+
return this.src[this.pos] ?? "";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
expect(ch: string, what: string): void {
|
|
164
|
+
if (this.peek() !== ch) throw new NotLiteral(what === "" ? `expected "${ch}"` : what);
|
|
165
|
+
this.pos++;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Whitespace, newlines and the three HCL comment forms. */
|
|
169
|
+
skipSpace(newlines = true): void {
|
|
170
|
+
for (;;) {
|
|
171
|
+
const c = this.peek();
|
|
172
|
+
if (c === " " || c === "\t" || c === "\r" || (newlines && c === "\n")) {
|
|
173
|
+
this.pos++;
|
|
174
|
+
} else if (c === "#" || (c === "/" && this.src[this.pos + 1] === "/")) {
|
|
175
|
+
const end = this.src.indexOf("\n", this.pos);
|
|
176
|
+
this.pos = end === -1 ? this.src.length : end;
|
|
177
|
+
} else if (c === "/" && this.src[this.pos + 1] === "*") {
|
|
178
|
+
const end = this.src.indexOf("*/", this.pos + 2);
|
|
179
|
+
if (end === -1) throw new NotLiteral("an unterminated comment");
|
|
180
|
+
this.pos = end + 2;
|
|
181
|
+
} else {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** One value. `depth` is 0 for the whole argument, which is never skipped. */
|
|
188
|
+
value(depth: number): unknown {
|
|
189
|
+
this.skipSpace();
|
|
190
|
+
if (!this.unknownLeaves || depth === 0) return this.literal(depth);
|
|
191
|
+
const start = this.pos;
|
|
192
|
+
try {
|
|
193
|
+
return this.literal(depth);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
if (!(err instanceof NotLiteral) || err instanceof Structural) throw err;
|
|
196
|
+
this.pos = start;
|
|
197
|
+
this.skipExpression();
|
|
198
|
+
return { [UNKNOWN]: true, reason: err.message, source: this.src.slice(start, this.pos).trim() } satisfies UnknownLeaf;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private literal(depth: number): unknown {
|
|
203
|
+
const c = this.peek();
|
|
204
|
+
let v: unknown;
|
|
205
|
+
if (c === "{") v = this.object(depth + 1);
|
|
206
|
+
else if (c === "[") v = this.tuple(depth + 1);
|
|
207
|
+
else if (c === '"') v = this.string();
|
|
208
|
+
else if (c === "<" && this.src.startsWith("<<", this.pos)) throw new NotLiteral("a heredoc");
|
|
209
|
+
else if (c === "(") throw new NotLiteral("a parenthesised expression");
|
|
210
|
+
else if (c === "-" || (c >= "0" && c <= "9")) v = this.number();
|
|
211
|
+
else v = this.keyword();
|
|
212
|
+
this.afterValue();
|
|
213
|
+
return v;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* A literal is complete only if what follows it closes or separates. An
|
|
218
|
+
* operator, a conditional, an attribute access or an index after it makes
|
|
219
|
+
* the whole thing an expression.
|
|
220
|
+
*/
|
|
221
|
+
private afterValue(): void {
|
|
222
|
+
this.skipSpace(false);
|
|
223
|
+
const c = this.peek();
|
|
224
|
+
if (c === "" || c === "\n" || c === "," || c === "}" || c === "]" || c === ")") return;
|
|
225
|
+
const next = this.src[this.pos + 1];
|
|
226
|
+
if (c === "#" || (c === "/" && (next === "/" || next === "*"))) return; // a comment, consumed by the caller's skipSpace
|
|
227
|
+
if (c === "?") throw new NotLiteral("a conditional expression");
|
|
228
|
+
if (c === "." || c === "[") throw new NotLiteral("an attribute access, index or splat");
|
|
229
|
+
throw new NotLiteral(`an operator or expression ("${c}")`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private object(depth: number): Record<string, unknown> {
|
|
233
|
+
this.expect("{", "");
|
|
234
|
+
const out: Record<string, unknown> = {};
|
|
235
|
+
for (;;) {
|
|
236
|
+
this.skipSpace();
|
|
237
|
+
if (this.peek() === "}") {
|
|
238
|
+
this.pos++;
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
const quoted = this.peek() === '"';
|
|
242
|
+
const memberStart = this.pos;
|
|
243
|
+
let key: string;
|
|
244
|
+
try {
|
|
245
|
+
key = this.key();
|
|
246
|
+
} catch (err) {
|
|
247
|
+
if (!(err instanceof KeyNotLiteral) || !this.unknownLeaves) throw err;
|
|
248
|
+
// Drop the whole member: the skip runs from the key through its value
|
|
249
|
+
// to the separator, balancing brackets and quotes on the way.
|
|
250
|
+
this.pos = memberStart;
|
|
251
|
+
this.skipExpression();
|
|
252
|
+
const bag = out as Record<symbol, string[]>;
|
|
253
|
+
(bag[UNKNOWN_KEYS] ??= []).push(err.message);
|
|
254
|
+
this.skipSpace(false);
|
|
255
|
+
if (this.peek() === ",") this.pos++;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
this.skipSpace(false);
|
|
259
|
+
const sep = this.peek();
|
|
260
|
+
if (sep !== "=" && sep !== ":") {
|
|
261
|
+
// A for-expression is a VALUE: under unknownLeaves the enclosing
|
|
262
|
+
// value() skips it as a leaf; as the whole argument it is not determined.
|
|
263
|
+
if (!quoted && key === "for") throw new NotLiteral("a for-expression");
|
|
264
|
+
throw new Structural(`an expression where "${key}" needs = or :`);
|
|
265
|
+
}
|
|
266
|
+
this.pos++;
|
|
267
|
+
out[key] = this.value(depth);
|
|
268
|
+
this.skipSpace(false);
|
|
269
|
+
if (this.peek() === ",") this.pos++;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private key(): string {
|
|
274
|
+
const c = this.peek();
|
|
275
|
+
if (c === '"') {
|
|
276
|
+
try {
|
|
277
|
+
return this.string();
|
|
278
|
+
} catch (err) {
|
|
279
|
+
if (err instanceof NotLiteral) throw new KeyNotLiteral(`an object key holding ${err.message.replace(/^a string with /, "")}`);
|
|
280
|
+
throw err;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (c === "(") throw new KeyNotLiteral("a computed object key");
|
|
284
|
+
IDENT.lastIndex = this.pos;
|
|
285
|
+
const m = IDENT.exec(this.src);
|
|
286
|
+
if (!m) throw new Structural(`an unexpected "${c}" where an object key belongs`);
|
|
287
|
+
this.pos += m[0].length;
|
|
288
|
+
return m[0];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private tuple(depth: number): unknown[] {
|
|
292
|
+
this.expect("[", "");
|
|
293
|
+
this.skipSpace();
|
|
294
|
+
if (/^for\s/.test(this.src.slice(this.pos, this.pos + 4))) throw new NotLiteral("a for-expression");
|
|
295
|
+
const out: unknown[] = [];
|
|
296
|
+
for (;;) {
|
|
297
|
+
this.skipSpace();
|
|
298
|
+
if (this.peek() === "]") {
|
|
299
|
+
this.pos++;
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
out.push(this.value(depth));
|
|
303
|
+
this.skipSpace();
|
|
304
|
+
if (this.peek() === ",") this.pos++;
|
|
305
|
+
else if (this.peek() !== "]") throw new NotLiteral("a tuple item followed by an expression");
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** A quoted HCL string. A `${` or `%{` (not escaped as `$${`/`%%{`) is a template. */
|
|
310
|
+
private string(): string {
|
|
311
|
+
this.expect('"', "");
|
|
312
|
+
let out = "";
|
|
313
|
+
for (;;) {
|
|
314
|
+
const c = this.peek();
|
|
315
|
+
if (c === "") throw new NotLiteral("an unterminated string");
|
|
316
|
+
if (c === "\n") throw new NotLiteral("a newline inside a quoted string");
|
|
317
|
+
if (c === '"') {
|
|
318
|
+
this.pos++;
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
if ((c === "$" || c === "%") && this.src[this.pos + 1] === c && this.src[this.pos + 2] === "{") {
|
|
322
|
+
out += `${c}{`;
|
|
323
|
+
this.pos += 3;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if ((c === "$" || c === "%") && this.src[this.pos + 1] === "{") {
|
|
327
|
+
throw new NotLiteral(c === "$" ? "a string with a ${...} interpolation" : "a string with a %{...} directive");
|
|
328
|
+
}
|
|
329
|
+
if (c === "\\") {
|
|
330
|
+
out += this.escape();
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
out += c;
|
|
334
|
+
this.pos++;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private escape(): string {
|
|
339
|
+
const e = this.src[this.pos + 1] ?? "";
|
|
340
|
+
this.pos += 2;
|
|
341
|
+
switch (e) {
|
|
342
|
+
case "n":
|
|
343
|
+
return "\n";
|
|
344
|
+
case "r":
|
|
345
|
+
return "\r";
|
|
346
|
+
case "t":
|
|
347
|
+
return "\t";
|
|
348
|
+
case '"':
|
|
349
|
+
return '"';
|
|
350
|
+
case "\\":
|
|
351
|
+
return "\\";
|
|
352
|
+
case "u":
|
|
353
|
+
case "U": {
|
|
354
|
+
const len = e === "u" ? 4 : 8;
|
|
355
|
+
const hex = this.src.slice(this.pos, this.pos + len);
|
|
356
|
+
if (!/^[0-9A-Fa-f]+$/.test(hex) || hex.length !== len) throw new NotLiteral("a malformed unicode escape");
|
|
357
|
+
this.pos += len;
|
|
358
|
+
return String.fromCodePoint(parseInt(hex, 16));
|
|
359
|
+
}
|
|
360
|
+
default:
|
|
361
|
+
throw new NotLiteral(`an unknown string escape "\\${e}"`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private number(): number {
|
|
366
|
+
NUMBER.lastIndex = this.pos;
|
|
367
|
+
const m = NUMBER.exec(this.src);
|
|
368
|
+
if (!m) throw new NotLiteral("a unary operator on an expression");
|
|
369
|
+
this.pos += m[0].length;
|
|
370
|
+
return Number(m[0]);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private keyword(): unknown {
|
|
374
|
+
IDENT.lastIndex = this.pos;
|
|
375
|
+
const m = IDENT.exec(this.src);
|
|
376
|
+
if (!m) throw new NotLiteral(`an unexpected "${this.peek()}"`);
|
|
377
|
+
const word = m[0];
|
|
378
|
+
this.pos += word.length;
|
|
379
|
+
if (word === "true") return true;
|
|
380
|
+
if (word === "false") return false;
|
|
381
|
+
if (word === "null") return null;
|
|
382
|
+
this.skipSpace(false);
|
|
383
|
+
if (this.peek() === "(") throw new NotLiteral(`a function call (${word}(...))`);
|
|
384
|
+
if (word === "for") throw new NotLiteral("a for-expression");
|
|
385
|
+
return this.reference(word);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Skip one value expression from the cursor: balance `()[]{}` and quoted
|
|
390
|
+
* strings (with their `${...}` interpolations), stop at a `,`, a newline, a
|
|
391
|
+
* comment or a closing bracket at depth 0. A heredoc is skipped to its
|
|
392
|
+
* closing marker line.
|
|
393
|
+
*/
|
|
394
|
+
private skipExpression(): void {
|
|
395
|
+
let depth = 0;
|
|
396
|
+
while (!this.atEnd()) {
|
|
397
|
+
const c = this.peek();
|
|
398
|
+
if (depth === 0) {
|
|
399
|
+
if (c === "," || c === "\n" || c === "}" || c === "]" || c === ")" || c === "#") return;
|
|
400
|
+
if (c === "/" && (this.src[this.pos + 1] === "/" || this.src[this.pos + 1] === "*")) return;
|
|
401
|
+
}
|
|
402
|
+
if (c === '"') this.skipQuoted();
|
|
403
|
+
else if (c === "<" && this.src.startsWith("<<", this.pos)) this.skipHeredoc();
|
|
404
|
+
else {
|
|
405
|
+
if (c === "(" || c === "[" || c === "{") depth++;
|
|
406
|
+
else if (c === ")" || c === "]" || c === "}") depth--;
|
|
407
|
+
this.pos++;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Skip a quoted string, including any `${...}` interpolations (which may hold strings). */
|
|
413
|
+
private skipQuoted(): void {
|
|
414
|
+
this.pos++;
|
|
415
|
+
while (!this.atEnd()) {
|
|
416
|
+
const c = this.peek();
|
|
417
|
+
if (c === "\\") this.pos += 2;
|
|
418
|
+
else if (c === '"') {
|
|
419
|
+
this.pos++;
|
|
420
|
+
return;
|
|
421
|
+
} else if ((c === "$" || c === "%") && this.src[this.pos + 1] === "{") {
|
|
422
|
+
this.pos += 2;
|
|
423
|
+
let depth = 1;
|
|
424
|
+
while (!this.atEnd() && depth > 0) {
|
|
425
|
+
const d = this.peek();
|
|
426
|
+
if (d === '"') {
|
|
427
|
+
this.skipQuoted();
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (d === "{") depth++;
|
|
431
|
+
else if (d === "}") depth--;
|
|
432
|
+
this.pos++;
|
|
433
|
+
}
|
|
434
|
+
} else this.pos++;
|
|
435
|
+
}
|
|
436
|
+
throw new Structural("an unterminated string");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private skipHeredoc(): void {
|
|
440
|
+
const head = /<<-?([A-Za-z_][A-Za-z0-9_-]*)[ \t]*\n/y;
|
|
441
|
+
head.lastIndex = this.pos;
|
|
442
|
+
const m = head.exec(this.src);
|
|
443
|
+
if (!m) throw new Structural("a malformed heredoc");
|
|
444
|
+
const close = new RegExp(`^[ \\t]*${m[1]}[ \\t]*$`, "m");
|
|
445
|
+
const rest = this.src.slice(this.pos + m[0].length);
|
|
446
|
+
const end = close.exec(rest);
|
|
447
|
+
if (!end) throw new Structural("an unterminated heredoc");
|
|
448
|
+
this.pos += m[0].length + end.index + end[0].length;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private reference(word: string): never {
|
|
452
|
+
throw new NotLiteral(`a reference (${word}${this.peek() === "." ? "..." : ""})`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
@@ -17,12 +17,60 @@ const HASHICORP_STYLE_VARIABLES: Authority = {
|
|
|
17
17
|
url: "https://developer.hashicorp.com/terraform/language/style#variables",
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
+
const AWS_IAM_LEAST_PRIVILEGE: Authority = {
|
|
21
|
+
name: "AWS IAM security best practices (Apply least-privilege permissions)",
|
|
22
|
+
url: "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#grant-least-privilege",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// TF032's authority: the ECS developer guide's own instruction for credentials,
|
|
26
|
+
// which is the remediation the rule's message gives.
|
|
27
|
+
const ECS_SENSITIVE_DATA: Authority = {
|
|
28
|
+
name: "Amazon ECS Developer Guide: Pass sensitive data to an Amazon ECS container",
|
|
29
|
+
url: "https://docs.aws.amazon.com/AmazonECS/latest/developerguide/specifying-sensitive-data.html",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// TF033-TF037 (#2288) cite the AWS service guide page that states the
|
|
33
|
+
// encryption or immutability control each rule checks.
|
|
34
|
+
const AWS_RDS_ENCRYPTION: Authority = {
|
|
35
|
+
name: "Amazon RDS User Guide: Encrypting Amazon RDS resources",
|
|
36
|
+
url: "https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Overview.Encryption.html",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const AWS_SNS_SSE: Authority = {
|
|
40
|
+
name: "Amazon SNS Developer Guide: Securing Amazon SNS data with server-side encryption",
|
|
41
|
+
url: "https://docs.aws.amazon.com/sns/latest/dg/sns-server-side-encryption.html",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const AWS_SQS_SSE: Authority = {
|
|
45
|
+
name: "Amazon SQS Developer Guide: Encryption at rest in Amazon SQS",
|
|
46
|
+
url: "https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-server-side-encryption.html",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const AWS_EBS_ENCRYPTION: Authority = {
|
|
50
|
+
name: "Amazon EBS User Guide: Amazon EBS encryption",
|
|
51
|
+
url: "https://docs.aws.amazon.com/ebs/latest/userguide/ebs-encryption.html",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const AWS_ECR_TAG_IMMUTABILITY: Authority = {
|
|
55
|
+
name: "Amazon ECR User Guide: Preventing image tags from being overwritten",
|
|
56
|
+
url: "https://docs.aws.amazon.com/AmazonECR/latest/userguide/image-tag-mutability.html",
|
|
57
|
+
};
|
|
58
|
+
|
|
20
59
|
// The vendor guides behind TF014, TF015 and TF021 are credited as lineage in
|
|
21
60
|
// ./audit-lineage.ts, not as `authority` here. `authority` is reserved for
|
|
22
61
|
// security rules that fail a merge (packages/core/src/audit/catalog.test.ts
|
|
23
62
|
// holds both halves of that invariant), and these three are correctness and
|
|
24
63
|
// best-practice rules (#2112).
|
|
25
64
|
|
|
65
|
+
/**
|
|
66
|
+
* The document WAW019 cites for the same finding in the aws lexicon, named
|
|
67
|
+
* without the em-dash its entry there carries (the docs prose lint counts them).
|
|
68
|
+
*/
|
|
69
|
+
const AWS_SECURITY_PILLAR: Authority = {
|
|
70
|
+
name: "AWS Well-Architected Framework, Security Pillar",
|
|
71
|
+
url: "https://docs.aws.amazon.com/wellarchitected/latest/security-pillar/welcome.html",
|
|
72
|
+
};
|
|
73
|
+
|
|
26
74
|
export const terraformAuditCatalog: Record<string, RuleMeta> = {
|
|
27
75
|
TF001: {
|
|
28
76
|
id: "TF001",
|
|
@@ -321,6 +369,106 @@ export const terraformAuditCatalog: Record<string, RuleMeta> = {
|
|
|
321
369
|
"pointed at whatever vault owns the credential, and rotate what was committed.",
|
|
322
370
|
yamlBased: false,
|
|
323
371
|
},
|
|
372
|
+
TF030: {
|
|
373
|
+
id: "TF030",
|
|
374
|
+
tier: "merge-worthy",
|
|
375
|
+
fixKind: "guidance",
|
|
376
|
+
category: "security",
|
|
377
|
+
title: "Security group rule allows unrestricted ingress on a sensitive port",
|
|
378
|
+
remediation:
|
|
379
|
+
"Restrict the CIDR on SSH, RDP, MySQL and PostgreSQL ports to the sources that need them, or reach " +
|
|
380
|
+
"the host through a bastion, a VPN or SSM Session Manager.",
|
|
381
|
+
authority: [AWS_SECURITY_PILLAR],
|
|
382
|
+
yamlBased: false,
|
|
383
|
+
},
|
|
384
|
+
TF031: {
|
|
385
|
+
id: "TF031",
|
|
386
|
+
tier: "merge-worthy",
|
|
387
|
+
fixKind: "guidance",
|
|
388
|
+
category: "security",
|
|
389
|
+
title: "IAM policy allows a wildcard Action or Resource",
|
|
390
|
+
remediation:
|
|
391
|
+
"Name the actions the workload calls and the ARNs it touches. For the few actions that accept only " +
|
|
392
|
+
'Resource "*" (`ecr:GetAuthorizationToken`, `sts:GetCallerIdentity`), keep that statement on its own and ' +
|
|
393
|
+
"suppress it with `# chant-ignore-block: TF031`. A policy TF031 reports as not determined is built from " +
|
|
394
|
+
"an expression; it is not a finding.",
|
|
395
|
+
authority: [AWS_IAM_LEAST_PRIVILEGE],
|
|
396
|
+
yamlBased: false,
|
|
397
|
+
},
|
|
398
|
+
TF032: {
|
|
399
|
+
id: "TF032",
|
|
400
|
+
tier: "merge-worthy",
|
|
401
|
+
fixKind: "guidance",
|
|
402
|
+
category: "security",
|
|
403
|
+
title: "ECS container definition passes a credential as a plaintext environment value",
|
|
404
|
+
remediation:
|
|
405
|
+
"Move the value from the container's `environment` to its `secrets`, with a `valueFrom` naming a " +
|
|
406
|
+
"Secrets Manager secret or SSM parameter, and rotate the committed value.",
|
|
407
|
+
authority: [ECS_SENSITIVE_DATA],
|
|
408
|
+
yamlBased: false,
|
|
409
|
+
},
|
|
410
|
+
TF033: {
|
|
411
|
+
id: "TF033",
|
|
412
|
+
tier: "merge-worthy",
|
|
413
|
+
fixKind: "guidance",
|
|
414
|
+
category: "security",
|
|
415
|
+
title: "RDS DB instance or cluster storage is not encrypted",
|
|
416
|
+
remediation:
|
|
417
|
+
"Set `storage_encrypted = true` (and `kms_key_id` for a customer managed key). An existing database cannot be " +
|
|
418
|
+
"encrypted in place: restore an encrypted copy of a snapshot. An Aurora cluster, or a database created from a " +
|
|
419
|
+
"snapshot or replica, is reported as not determined, not as a finding.",
|
|
420
|
+
authority: [AWS_RDS_ENCRYPTION],
|
|
421
|
+
yamlBased: false,
|
|
422
|
+
},
|
|
423
|
+
TF034: {
|
|
424
|
+
id: "TF034",
|
|
425
|
+
tier: "merge-worthy",
|
|
426
|
+
fixKind: "guidance",
|
|
427
|
+
category: "security",
|
|
428
|
+
title: "SNS topic has no server-side encryption",
|
|
429
|
+
remediation:
|
|
430
|
+
'Set `kms_master_key_id` to a customer managed KMS key, or to `"alias/aws/sns"` for the AWS managed one. No AWS ' +
|
|
431
|
+
"default encrypts a topic that names no key.",
|
|
432
|
+
authority: [AWS_SNS_SSE],
|
|
433
|
+
yamlBased: false,
|
|
434
|
+
},
|
|
435
|
+
TF035: {
|
|
436
|
+
id: "TF035",
|
|
437
|
+
tier: "merge-worthy",
|
|
438
|
+
fixKind: "guidance",
|
|
439
|
+
category: "security",
|
|
440
|
+
title: "SQS queue turns server-side encryption off",
|
|
441
|
+
remediation:
|
|
442
|
+
"Remove `sqs_managed_sse_enabled = false` (new queues are encrypted with SSE-SQS by default), set it to `true`, " +
|
|
443
|
+
"or set `kms_master_key_id`. A queue that sets neither attribute is encrypted and is not reported.",
|
|
444
|
+
authority: [AWS_SQS_SSE],
|
|
445
|
+
yamlBased: false,
|
|
446
|
+
},
|
|
447
|
+
TF036: {
|
|
448
|
+
id: "TF036",
|
|
449
|
+
tier: "merge-worthy",
|
|
450
|
+
fixKind: "guidance",
|
|
451
|
+
category: "security",
|
|
452
|
+
title: "EBS volume does not ask for encryption",
|
|
453
|
+
remediation:
|
|
454
|
+
"Set `encrypted = true` (and `kms_key_id` for a customer managed key). A volume that leaves `encrypted` unset " +
|
|
455
|
+
"depends on the Region's EBS encryption by default and is reported as not determined, not as a finding.",
|
|
456
|
+
authority: [AWS_EBS_ENCRYPTION],
|
|
457
|
+
yamlBased: false,
|
|
458
|
+
},
|
|
459
|
+
TF037: {
|
|
460
|
+
id: "TF037",
|
|
461
|
+
tier: "merge-worthy",
|
|
462
|
+
fixKind: "guidance",
|
|
463
|
+
category: "security",
|
|
464
|
+
title: "ECR repository allows mutable image tags",
|
|
465
|
+
remediation:
|
|
466
|
+
'Set `image_tag_mutability = "IMMUTABLE"`, or `"IMMUTABLE_WITH_EXCLUSION"` with an ' +
|
|
467
|
+
"`image_tag_mutability_exclusion_filter` naming the few moving tags (`latest`) that must stay mutable. The " +
|
|
468
|
+
"provider default is `MUTABLE`.",
|
|
469
|
+
authority: [AWS_ECR_TAG_IMMUTABILITY],
|
|
470
|
+
yamlBased: false,
|
|
471
|
+
},
|
|
324
472
|
};
|
|
325
473
|
|
|
326
474
|
// Prior art credits, if any, live beside the rules in ./audit-lineage.ts (see
|