@dotzen/dotzen 0.0.1
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/LICENSE +21 -0
- package/README.md +45 -0
- package/bin/dotzen.js +3 -0
- package/dist/cli/check.d.ts +9 -0
- package/dist/cli/check.js +79 -0
- package/dist/cli/main.d.ts +1 -0
- package/dist/cli/main.js +141 -0
- package/dist/cli/scaffold.d.ts +36 -0
- package/dist/cli/scaffold.js +171 -0
- package/dist/engine/evaluate.d.ts +31 -0
- package/dist/engine/evaluate.js +590 -0
- package/dist/hcl/model.d.ts +91 -0
- package/dist/hcl/model.js +5 -0
- package/dist/hcl/normalize.d.ts +16 -0
- package/dist/hcl/normalize.js +462 -0
- package/dist/hcl/parse.d.ts +13 -0
- package/dist/hcl/parse.js +85 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +37 -0
- package/dist/report/report.d.ts +18 -0
- package/dist/report/report.js +109 -0
- package/dist/result/errors.d.ts +27 -0
- package/dist/result/errors.js +3 -0
- package/dist/result/result.d.ts +22 -0
- package/dist/result/result.js +37 -0
- package/dist/spec/load.d.ts +14 -0
- package/dist/spec/load.js +78 -0
- package/dist/spec/rule.d.ts +161 -0
- package/dist/spec/rule.js +190 -0
- package/dist/version/config.d.ts +29 -0
- package/dist/version/config.js +66 -0
- package/dist/vocabulary/azure.d.ts +63 -0
- package/dist/vocabulary/azure.js +88 -0
- package/dist/vocabulary/gcp.d.ts +57 -0
- package/dist/vocabulary/gcp.js +80 -0
- package/dist/vocabulary/index.d.ts +174 -0
- package/dist/vocabulary/index.js +235 -0
- package/package.json +74 -0
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.evaluate = evaluate;
|
|
4
|
+
const model_1 = require("../hcl/model");
|
|
5
|
+
const vocabulary_1 = require("../vocabulary");
|
|
6
|
+
const PLAINTEXT_PROTOCOLS = new Set(['HTTP', 'TCP']);
|
|
7
|
+
/**
|
|
8
|
+
* A resource reference embedded in an unresolved expression, e.g.
|
|
9
|
+
* `${aws_s3_bucket.data.id}` -> captures type `aws_s3_bucket`, name `data`.
|
|
10
|
+
* `var.`/`local.`/`data.` prefixes match too but never collide with a real
|
|
11
|
+
* resource address, so they are harmless in the index.
|
|
12
|
+
*/
|
|
13
|
+
const RESOURCE_REF = /\$\{\s*([a-z][a-z0-9_]*)\.([A-Za-z0-9_-]+)/;
|
|
14
|
+
function buildAssociations(resources) {
|
|
15
|
+
const idx = new Map();
|
|
16
|
+
for (const res of resources) {
|
|
17
|
+
for (const [attr, v] of Object.entries(res.attributes)) {
|
|
18
|
+
if (v.kind !== 'unresolved')
|
|
19
|
+
continue;
|
|
20
|
+
const m = RESOURCE_REF.exec(v.expr);
|
|
21
|
+
if (!m)
|
|
22
|
+
continue;
|
|
23
|
+
const parentAddr = `${m[1]}.${m[2]}`;
|
|
24
|
+
const set = idx.get(parentAddr) ?? new Set();
|
|
25
|
+
set.add(`${res.type}|${attr}`);
|
|
26
|
+
idx.set(parentAddr, set);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return idx;
|
|
30
|
+
}
|
|
31
|
+
const assertNever = (x) => {
|
|
32
|
+
throw new Error(`unhandled condition: ${JSON.stringify(x)}`);
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Whether a resource is in scope for a given condition of a rule. The
|
|
36
|
+
* base match is target-vs-type; denyIngress additionally reaches the
|
|
37
|
+
* decomposed ingress-rule resource (per-condition, so mustHaveTags on a
|
|
38
|
+
* security group does not).
|
|
39
|
+
*/
|
|
40
|
+
function inScope(condition, target, r) {
|
|
41
|
+
if (target.kind === 'all')
|
|
42
|
+
return true;
|
|
43
|
+
if (target.types.includes(r.type))
|
|
44
|
+
return true;
|
|
45
|
+
// Decomposed "child" resources are governed by a rule on their parent
|
|
46
|
+
// type: separate ingress-rule resources for denyIngress, separate
|
|
47
|
+
// bucket-acl resources for denyAcl (both are modern replacements for an
|
|
48
|
+
// inline block/argument).
|
|
49
|
+
if (condition.kind === 'denyIngress' &&
|
|
50
|
+
target.types.includes(vocabulary_1.AwsResource.SecurityGroup) &&
|
|
51
|
+
r.type === vocabulary_1.AwsResource.VpcSecurityGroupIngressRule)
|
|
52
|
+
return true;
|
|
53
|
+
return (condition.kind === 'denyAcl' &&
|
|
54
|
+
target.types.includes(vocabulary_1.AwsResource.S3Bucket) &&
|
|
55
|
+
r.type === vocabulary_1.AwsResource.S3BucketAcl);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Environment scoping is a filter, not a check: an env-scoped rule applies
|
|
59
|
+
* only to resources whose `environment` tag resolves to that environment.
|
|
60
|
+
* A resource with an absent/unresolved environment is skipped (fail-open) —
|
|
61
|
+
* pair env-scoped rules with a `mustHaveTags(Environment)` rule so every
|
|
62
|
+
* resource is forced to declare its environment (defense in depth, doc 04).
|
|
63
|
+
*/
|
|
64
|
+
function environmentMatches(rule, r) {
|
|
65
|
+
if (!rule.environment)
|
|
66
|
+
return true;
|
|
67
|
+
return (r.environment?.kind === 'literal' &&
|
|
68
|
+
r.environment.value === rule.environment);
|
|
69
|
+
}
|
|
70
|
+
const isLiteralNumber = (v) => v.kind === 'literal' && typeof v.value === 'number';
|
|
71
|
+
const portInRange = (port, ing) => {
|
|
72
|
+
if (!isLiteralNumber(ing.fromPort) || !isLiteralNumber(ing.toPort))
|
|
73
|
+
return 'unknown';
|
|
74
|
+
return port >= ing.fromPort.value && port <= ing.toPort.value;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Shared port/CIDR check for deny-ingress and deny-egress (three-way):
|
|
78
|
+
* does any rule in `rules` open a targeted port to a targeted CIDR?
|
|
79
|
+
*/
|
|
80
|
+
function evalPortCidr(c, rules, direction) {
|
|
81
|
+
let sawUnknown = false;
|
|
82
|
+
for (const ing of rules) {
|
|
83
|
+
const portMatches = c.ports.map((p) => portInRange(p, ing));
|
|
84
|
+
if (portMatches.includes('unknown')) {
|
|
85
|
+
sawUnknown = true;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (!portMatches.includes(true))
|
|
89
|
+
continue; // no targeted port on this rule
|
|
90
|
+
const literalCidrs = ing.cidrBlocks.filter((v) => v.kind === 'literal');
|
|
91
|
+
const hasUnresolvedCidr = ing.cidrBlocks.some((v) => v.kind === 'unresolved');
|
|
92
|
+
const matchedCidr = literalCidrs.find((v) => v.kind === 'literal' && c.from.includes(v.value));
|
|
93
|
+
if (matchedCidr && matchedCidr.kind === 'literal') {
|
|
94
|
+
return {
|
|
95
|
+
kind: 'violation',
|
|
96
|
+
detail: `port ${c.ports.join('/')} open to ${matchedCidr.value}`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (hasUnresolvedCidr)
|
|
100
|
+
sawUnknown = true;
|
|
101
|
+
}
|
|
102
|
+
if (sawUnknown)
|
|
103
|
+
return {
|
|
104
|
+
kind: 'cannotEvaluate',
|
|
105
|
+
reason: `${direction} port or cidr is an unresolved reference`,
|
|
106
|
+
};
|
|
107
|
+
return { kind: 'pass' };
|
|
108
|
+
}
|
|
109
|
+
const evalDenyIngress = (c, r) => evalPortCidr(c, r.ingress, 'ingress');
|
|
110
|
+
const evalDenyEgress = (c, r) => evalPortCidr(c, r.egress ?? [], 'egress');
|
|
111
|
+
/** Evaluate one mustHaveTags condition (three-way). */
|
|
112
|
+
function evalMustHaveTags(c, r) {
|
|
113
|
+
if (r.tags.kind === 'unresolved')
|
|
114
|
+
return {
|
|
115
|
+
kind: 'cannotEvaluate',
|
|
116
|
+
reason: 'tags are an unresolved reference',
|
|
117
|
+
};
|
|
118
|
+
const present = new Set(r.tags.keys);
|
|
119
|
+
const missing = c.tags.filter((t) => !present.has(t));
|
|
120
|
+
if (missing.length > 0)
|
|
121
|
+
return { kind: 'violation', detail: `missing tags: ${missing.join(', ')}` };
|
|
122
|
+
return { kind: 'pass' };
|
|
123
|
+
}
|
|
124
|
+
const isLiteralTrue = (v) => v?.kind === 'literal' && v.value === true;
|
|
125
|
+
/**
|
|
126
|
+
* Boolean attribute must be true. An absent attribute counts as not-true
|
|
127
|
+
* (matches the AWS default of `false` for the attributes this targets,
|
|
128
|
+
* e.g. storage_encrypted); an unresolved value degrades to cannotEvaluate.
|
|
129
|
+
*/
|
|
130
|
+
function evalMustBeTrue(c, r) {
|
|
131
|
+
let sawUnknown = false;
|
|
132
|
+
const failing = [];
|
|
133
|
+
for (const attr of c.attrs) {
|
|
134
|
+
const v = r.attributes[attr];
|
|
135
|
+
if (v?.kind === 'unresolved') {
|
|
136
|
+
sawUnknown = true;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (!isLiteralTrue(v))
|
|
140
|
+
failing.push(attr);
|
|
141
|
+
}
|
|
142
|
+
if (failing.length > 0)
|
|
143
|
+
return { kind: 'violation', detail: `must be true: ${failing.join(', ')}` };
|
|
144
|
+
if (sawUnknown)
|
|
145
|
+
return {
|
|
146
|
+
kind: 'cannotEvaluate',
|
|
147
|
+
reason: 'attribute is an unresolved reference',
|
|
148
|
+
};
|
|
149
|
+
return { kind: 'pass' };
|
|
150
|
+
}
|
|
151
|
+
const isLiteralFalse = (v) => v?.kind === 'literal' && v.value === false;
|
|
152
|
+
/**
|
|
153
|
+
* Boolean attribute must be explicitly false. Absent counts as a
|
|
154
|
+
* violation — use for attributes whose insecure AWS default is `true`
|
|
155
|
+
* (e.g. EKS `vpc_config.endpoint_public_access`). Unresolved => cannot evaluate.
|
|
156
|
+
*/
|
|
157
|
+
function evalMustBeFalse(c, r) {
|
|
158
|
+
let sawUnknown = false;
|
|
159
|
+
const failing = [];
|
|
160
|
+
for (const attr of c.attrs) {
|
|
161
|
+
const v = r.attributes[attr];
|
|
162
|
+
if (v?.kind === 'unresolved') {
|
|
163
|
+
sawUnknown = true;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (!isLiteralFalse(v))
|
|
167
|
+
failing.push(attr);
|
|
168
|
+
}
|
|
169
|
+
if (failing.length > 0)
|
|
170
|
+
return { kind: 'violation', detail: `must be false: ${failing.join(', ')}` };
|
|
171
|
+
if (sawUnknown)
|
|
172
|
+
return {
|
|
173
|
+
kind: 'cannotEvaluate',
|
|
174
|
+
reason: 'attribute is an unresolved reference',
|
|
175
|
+
};
|
|
176
|
+
return { kind: 'pass' };
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* AwsAttribute must be present. Absent is the violation; any value — literal
|
|
180
|
+
* or an unresolved reference — counts as set (so this never degrades to
|
|
181
|
+
* cannotEvaluate: presence is statically knowable).
|
|
182
|
+
*/
|
|
183
|
+
function evalMustBeSet(c, r) {
|
|
184
|
+
const missing = c.attrs.filter((a) => r.attributes[a] === undefined);
|
|
185
|
+
if (missing.length > 0)
|
|
186
|
+
return { kind: 'violation', detail: `must be set: ${missing.join(', ')}` };
|
|
187
|
+
return { kind: 'pass' };
|
|
188
|
+
}
|
|
189
|
+
/** Boolean attribute must not be true (absent => not-true => pass). */
|
|
190
|
+
function evalDenyWhenTrue(c, r) {
|
|
191
|
+
let sawUnknown = false;
|
|
192
|
+
const offending = [];
|
|
193
|
+
for (const attr of c.attrs) {
|
|
194
|
+
const v = r.attributes[attr];
|
|
195
|
+
if (v?.kind === 'unresolved') {
|
|
196
|
+
sawUnknown = true;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (isLiteralTrue(v))
|
|
200
|
+
offending.push(attr);
|
|
201
|
+
}
|
|
202
|
+
if (offending.length > 0)
|
|
203
|
+
return {
|
|
204
|
+
kind: 'violation',
|
|
205
|
+
detail: `must not be true: ${offending.join(', ')}`,
|
|
206
|
+
};
|
|
207
|
+
if (sawUnknown)
|
|
208
|
+
return {
|
|
209
|
+
kind: 'cannotEvaluate',
|
|
210
|
+
reason: 'attribute is an unresolved reference',
|
|
211
|
+
};
|
|
212
|
+
return { kind: 'pass' };
|
|
213
|
+
}
|
|
214
|
+
/** Deny a public ACL. Absent `acl` => default private => pass. */
|
|
215
|
+
function evalDenyAcl(c, r) {
|
|
216
|
+
const v = r.attributes.acl;
|
|
217
|
+
if (v === undefined)
|
|
218
|
+
return { kind: 'pass' };
|
|
219
|
+
if (v.kind === 'unresolved')
|
|
220
|
+
return { kind: 'cannotEvaluate', reason: 'acl is an unresolved reference' };
|
|
221
|
+
if (c.acls.includes(v.value))
|
|
222
|
+
return { kind: 'violation', detail: `acl is ${v.value}` };
|
|
223
|
+
return { kind: 'pass' };
|
|
224
|
+
}
|
|
225
|
+
/** AwsAttribute must equal a specific value (absent => not equal => violation). */
|
|
226
|
+
function evalMustEqual(c, r) {
|
|
227
|
+
const v = r.attributes[c.attr];
|
|
228
|
+
if (v?.kind === 'unresolved')
|
|
229
|
+
return {
|
|
230
|
+
kind: 'cannotEvaluate',
|
|
231
|
+
reason: `${c.attr} is an unresolved reference`,
|
|
232
|
+
};
|
|
233
|
+
if (v?.kind === 'literal' && String(v.value) === c.value)
|
|
234
|
+
return { kind: 'pass' };
|
|
235
|
+
return { kind: 'violation', detail: `${c.attr} must equal "${c.value}"` };
|
|
236
|
+
}
|
|
237
|
+
/** Numeric attribute must be >= min (absent/below => violation). */
|
|
238
|
+
function evalMustBeAtLeast(c, r) {
|
|
239
|
+
const v = r.attributes[c.attr];
|
|
240
|
+
if (v?.kind === 'unresolved')
|
|
241
|
+
return {
|
|
242
|
+
kind: 'cannotEvaluate',
|
|
243
|
+
reason: `${c.attr} is an unresolved reference`,
|
|
244
|
+
};
|
|
245
|
+
if (v?.kind === 'literal' && typeof v.value === 'number' && v.value >= c.min)
|
|
246
|
+
return { kind: 'pass' };
|
|
247
|
+
return { kind: 'violation', detail: `${c.attr} must be >= ${c.min}` };
|
|
248
|
+
}
|
|
249
|
+
/** Numeric attribute must be <= max (absent/above => violation). */
|
|
250
|
+
function evalMustBeAtMost(c, r) {
|
|
251
|
+
const v = r.attributes[c.attr];
|
|
252
|
+
if (v?.kind === 'unresolved')
|
|
253
|
+
return {
|
|
254
|
+
kind: 'cannotEvaluate',
|
|
255
|
+
reason: `${c.attr} is an unresolved reference`,
|
|
256
|
+
};
|
|
257
|
+
if (v?.kind === 'literal' && typeof v.value === 'number' && v.value <= c.max)
|
|
258
|
+
return { kind: 'pass' };
|
|
259
|
+
return { kind: 'violation', detail: `${c.attr} must be <= ${c.max}` };
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Flag over-permissive IAM `Allow` statements:
|
|
263
|
+
* - `Action: "*"` (full privileges) — sharpened to "full administrative
|
|
264
|
+
* access" when paired with `Resource: "*"`;
|
|
265
|
+
* - `NotAction` (allow everything EXCEPT a list) — an over-broad grant AWS
|
|
266
|
+
* warns against, and a real least-privilege anti-pattern.
|
|
267
|
+
* A `jsonencode(...)`/variable policy is `unresolved` => could-not-evaluate.
|
|
268
|
+
*/
|
|
269
|
+
function evalDenyIamWildcard(_c, r) {
|
|
270
|
+
const p = r.policy;
|
|
271
|
+
if (!p)
|
|
272
|
+
return { kind: 'pass' }; // no inline policy document
|
|
273
|
+
if (p.kind === 'unresolved')
|
|
274
|
+
return {
|
|
275
|
+
kind: 'cannotEvaluate',
|
|
276
|
+
reason: 'IAM policy is not a literal JSON document (jsonencode/var)',
|
|
277
|
+
};
|
|
278
|
+
for (const s of p.statements) {
|
|
279
|
+
if (s.effect.toLowerCase() !== 'allow')
|
|
280
|
+
continue;
|
|
281
|
+
if (s.actions.includes('*'))
|
|
282
|
+
return {
|
|
283
|
+
kind: 'violation',
|
|
284
|
+
detail: s.resources.includes('*')
|
|
285
|
+
? 'Allow grants Action "*" on Resource "*" (full administrative access)'
|
|
286
|
+
: 'Allow statement grants Action "*" (full privileges)',
|
|
287
|
+
};
|
|
288
|
+
if (s.notActions.length > 0)
|
|
289
|
+
return {
|
|
290
|
+
kind: 'violation',
|
|
291
|
+
detail: `Allow with NotAction grants everything except ${s.notActions.join(', ')}`,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
return { kind: 'pass' };
|
|
295
|
+
}
|
|
296
|
+
const literalItems = (items) => items
|
|
297
|
+
.filter((i) => i.kind === 'literal')
|
|
298
|
+
.map((i) => i.value);
|
|
299
|
+
const hasUnresolvedItem = (items) => items.some((i) => i.kind === 'unresolved');
|
|
300
|
+
/** Flag a list attribute that contains any forbidden value (three-way). */
|
|
301
|
+
function evalListContains(c, r) {
|
|
302
|
+
const l = r.lists?.[c.attr];
|
|
303
|
+
if (!l) {
|
|
304
|
+
// whole list may be an unresolved reference stored as a scalar
|
|
305
|
+
return r.attributes[c.attr]?.kind === 'unresolved'
|
|
306
|
+
? {
|
|
307
|
+
kind: 'cannotEvaluate',
|
|
308
|
+
reason: `${c.attr} is an unresolved reference`,
|
|
309
|
+
}
|
|
310
|
+
: { kind: 'pass' }; // absent list -> nothing forbidden present
|
|
311
|
+
}
|
|
312
|
+
if (l.kind === 'unresolved')
|
|
313
|
+
return {
|
|
314
|
+
kind: 'cannotEvaluate',
|
|
315
|
+
reason: `${c.attr} is an unresolved reference`,
|
|
316
|
+
};
|
|
317
|
+
const found = literalItems(l.items).find((v) => c.values.includes(v));
|
|
318
|
+
if (found !== undefined)
|
|
319
|
+
return {
|
|
320
|
+
kind: 'violation',
|
|
321
|
+
detail: `${c.attr} contains "${String(found)}"`,
|
|
322
|
+
};
|
|
323
|
+
if (hasUnresolvedItem(l.items))
|
|
324
|
+
return {
|
|
325
|
+
kind: 'cannotEvaluate',
|
|
326
|
+
reason: `${c.attr} has an unresolved element`,
|
|
327
|
+
};
|
|
328
|
+
return { kind: 'pass' };
|
|
329
|
+
}
|
|
330
|
+
/** Require a list attribute to include all of `values` (three-way). */
|
|
331
|
+
function evalListMustInclude(c, r) {
|
|
332
|
+
const l = r.lists?.[c.attr];
|
|
333
|
+
if (!l)
|
|
334
|
+
return r.attributes[c.attr]?.kind === 'unresolved'
|
|
335
|
+
? {
|
|
336
|
+
kind: 'cannotEvaluate',
|
|
337
|
+
reason: `${c.attr} is an unresolved reference`,
|
|
338
|
+
}
|
|
339
|
+
: {
|
|
340
|
+
kind: 'violation',
|
|
341
|
+
detail: `${c.attr} must include ${c.values.join(', ')}`,
|
|
342
|
+
};
|
|
343
|
+
if (l.kind === 'unresolved')
|
|
344
|
+
return {
|
|
345
|
+
kind: 'cannotEvaluate',
|
|
346
|
+
reason: `${c.attr} is an unresolved reference`,
|
|
347
|
+
};
|
|
348
|
+
const present = literalItems(l.items);
|
|
349
|
+
const missing = c.values.filter((v) => !present.includes(v));
|
|
350
|
+
if (missing.length === 0)
|
|
351
|
+
return { kind: 'pass' };
|
|
352
|
+
if (hasUnresolvedItem(l.items))
|
|
353
|
+
return {
|
|
354
|
+
kind: 'cannotEvaluate',
|
|
355
|
+
reason: `${c.attr} has an unresolved element`,
|
|
356
|
+
};
|
|
357
|
+
return {
|
|
358
|
+
kind: 'violation',
|
|
359
|
+
detail: `${c.attr} must include ${missing.join(', ')}`,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
/** Flag a scalar attribute whose literal value is in a forbidden set. */
|
|
363
|
+
function evalDenyValue(c, r) {
|
|
364
|
+
const v = r.attributes[c.attr];
|
|
365
|
+
if (v === undefined)
|
|
366
|
+
return { kind: 'pass' }; // absent
|
|
367
|
+
if (v.kind === 'unresolved')
|
|
368
|
+
return {
|
|
369
|
+
kind: 'cannotEvaluate',
|
|
370
|
+
reason: `${c.attr} is an unresolved reference`,
|
|
371
|
+
};
|
|
372
|
+
if (c.values.includes(String(v.value)))
|
|
373
|
+
return { kind: 'violation', detail: `${c.attr} is "${String(v.value)}"` };
|
|
374
|
+
return { kind: 'pass' };
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Allowlist: the attribute's literal value must be one of `values`. Absent
|
|
378
|
+
* or any other value is a violation (mirror of denyValue); unresolved =>
|
|
379
|
+
* cannot evaluate.
|
|
380
|
+
*/
|
|
381
|
+
function evalMustBeOneOf(c, r) {
|
|
382
|
+
const v = r.attributes[c.attr];
|
|
383
|
+
if (v?.kind === 'unresolved')
|
|
384
|
+
return {
|
|
385
|
+
kind: 'cannotEvaluate',
|
|
386
|
+
reason: `${c.attr} is an unresolved reference`,
|
|
387
|
+
};
|
|
388
|
+
if (v?.kind === 'literal' && c.values.includes(String(v.value)))
|
|
389
|
+
return { kind: 'pass' };
|
|
390
|
+
return {
|
|
391
|
+
kind: 'violation',
|
|
392
|
+
detail: `${c.attr} must be one of ${c.values.join(', ')}`,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Flag a plaintext listener (`protocol` HTTP/TCP) — unless it exists only
|
|
397
|
+
* to redirect to HTTPS (`default_action.type = "redirect"`), the standard
|
|
398
|
+
* safe pattern. Absent/unresolved protocol => pass / could-not-evaluate.
|
|
399
|
+
*/
|
|
400
|
+
function evalDenyPlaintextListener(_c, r) {
|
|
401
|
+
const proto = r.attributes.protocol;
|
|
402
|
+
if (proto === undefined)
|
|
403
|
+
return { kind: 'pass' };
|
|
404
|
+
if (proto.kind === 'unresolved')
|
|
405
|
+
return {
|
|
406
|
+
kind: 'cannotEvaluate',
|
|
407
|
+
reason: 'listener protocol is an unresolved reference',
|
|
408
|
+
};
|
|
409
|
+
if (!PLAINTEXT_PROTOCOLS.has(String(proto.value)))
|
|
410
|
+
return { kind: 'pass' };
|
|
411
|
+
const action = r.attributes['default_action.type'];
|
|
412
|
+
if (action?.kind === 'literal' && action.value === 'redirect')
|
|
413
|
+
return { kind: 'pass' }; // HTTP->HTTPS redirect is fine
|
|
414
|
+
return {
|
|
415
|
+
kind: 'violation',
|
|
416
|
+
detail: `plaintext listener (protocol ${String(proto.value)})`,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Flag an ECS task definition with a privileged container. Parses the
|
|
421
|
+
* literal-JSON `container_definitions`; a `jsonencode(...)`/variable value
|
|
422
|
+
* degrades to could-not-evaluate.
|
|
423
|
+
*/
|
|
424
|
+
function evalDenyPrivilegedContainers(_c, r) {
|
|
425
|
+
const c = r.containers;
|
|
426
|
+
if (!c)
|
|
427
|
+
return { kind: 'pass' };
|
|
428
|
+
if (c.kind === 'unresolved')
|
|
429
|
+
return {
|
|
430
|
+
kind: 'cannotEvaluate',
|
|
431
|
+
reason: 'container_definitions is not a literal JSON array (jsonencode/var)',
|
|
432
|
+
};
|
|
433
|
+
const priv = c.containers.find((x) => x.privileged);
|
|
434
|
+
if (priv)
|
|
435
|
+
return { kind: 'violation', detail: `privileged container "${priv.name}"` };
|
|
436
|
+
return { kind: 'pass' };
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Flag a hardcoded literal where a reference belongs (e.g. a secret).
|
|
440
|
+
* Inverts the usual semantics: a *literal* is the violation; an
|
|
441
|
+
* *unresolved* reference (var/data) is the desired state and passes;
|
|
442
|
+
* absent passes (nothing set here).
|
|
443
|
+
*/
|
|
444
|
+
function evalDenyLiteral(c, r) {
|
|
445
|
+
const offending = c.attrs.filter((a) => r.attributes[a]?.kind === 'literal');
|
|
446
|
+
if (offending.length > 0)
|
|
447
|
+
return {
|
|
448
|
+
kind: 'violation',
|
|
449
|
+
detail: `hardcoded value — use a reference: ${offending.join(', ')}`,
|
|
450
|
+
};
|
|
451
|
+
return { kind: 'pass' };
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Cross-resource: pass iff some resource of `childType` references this one
|
|
455
|
+
* through its `via` attribute. Association is by resource reference
|
|
456
|
+
* (`bucket = aws_s3_bucket.x.id`), the idiomatic Terraform wiring; a child
|
|
457
|
+
* that points at its parent by a literal name would not be linked.
|
|
458
|
+
*/
|
|
459
|
+
function evalMustHaveAssociated(c, r, ctx) {
|
|
460
|
+
const key = `${c.childType}|${c.via}`;
|
|
461
|
+
if (ctx.associations.get((0, model_1.address)(r))?.has(key))
|
|
462
|
+
return { kind: 'pass' };
|
|
463
|
+
return {
|
|
464
|
+
kind: 'violation',
|
|
465
|
+
detail: `no associated ${c.childType} (referencing this via ${c.via})`,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Whether a resource declares a given nested block. Prefers the recorded
|
|
470
|
+
* block paths (which capture even empty blocks); falls back to the flattened
|
|
471
|
+
* dotted keys so hand-built resources without `blocks` still work.
|
|
472
|
+
*/
|
|
473
|
+
function hasBlock(block, r) {
|
|
474
|
+
if (r.blocks?.includes(block))
|
|
475
|
+
return true;
|
|
476
|
+
const prefix = `${block}.`;
|
|
477
|
+
return (Object.keys(r.attributes).some((k) => k.startsWith(prefix)) ||
|
|
478
|
+
Object.keys(r.lists ?? {}).some((k) => k.startsWith(prefix)));
|
|
479
|
+
}
|
|
480
|
+
/** Same-resource: pass iff the resource declares the given nested block. */
|
|
481
|
+
function evalMustHaveBlock(c, r) {
|
|
482
|
+
return hasBlock(c.block, r)
|
|
483
|
+
? { kind: 'pass' }
|
|
484
|
+
: { kind: 'violation', detail: `missing ${c.block} block` };
|
|
485
|
+
}
|
|
486
|
+
/** Same-resource: flag iff the resource declares the given nested block. */
|
|
487
|
+
function evalDenyBlockPresence(c, r) {
|
|
488
|
+
return hasBlock(c.block, r)
|
|
489
|
+
? { kind: 'violation', detail: `${c.block} block must not be declared` }
|
|
490
|
+
: { kind: 'pass' };
|
|
491
|
+
}
|
|
492
|
+
/** Exhaustive dispatch: a new condition kind is a compile error (Layer 4). */
|
|
493
|
+
function evalCondition(c, r, ctx) {
|
|
494
|
+
switch (c.kind) {
|
|
495
|
+
case 'denyIngress':
|
|
496
|
+
return evalDenyIngress(c, r);
|
|
497
|
+
case 'denyEgress':
|
|
498
|
+
return evalDenyEgress(c, r);
|
|
499
|
+
case 'mustHaveTags':
|
|
500
|
+
return evalMustHaveTags(c, r);
|
|
501
|
+
case 'mustBeTrue':
|
|
502
|
+
return evalMustBeTrue(c, r);
|
|
503
|
+
case 'mustBeFalse':
|
|
504
|
+
return evalMustBeFalse(c, r);
|
|
505
|
+
case 'mustBeSet':
|
|
506
|
+
return evalMustBeSet(c, r);
|
|
507
|
+
case 'denyWhenTrue':
|
|
508
|
+
return evalDenyWhenTrue(c, r);
|
|
509
|
+
case 'denyAcl':
|
|
510
|
+
return evalDenyAcl(c, r);
|
|
511
|
+
case 'mustEqual':
|
|
512
|
+
return evalMustEqual(c, r);
|
|
513
|
+
case 'mustBeAtLeast':
|
|
514
|
+
return evalMustBeAtLeast(c, r);
|
|
515
|
+
case 'mustBeAtMost':
|
|
516
|
+
return evalMustBeAtMost(c, r);
|
|
517
|
+
case 'denyIamWildcard':
|
|
518
|
+
return evalDenyIamWildcard(c, r);
|
|
519
|
+
case 'listContains':
|
|
520
|
+
return evalListContains(c, r);
|
|
521
|
+
case 'listMustInclude':
|
|
522
|
+
return evalListMustInclude(c, r);
|
|
523
|
+
case 'denyValue':
|
|
524
|
+
return evalDenyValue(c, r);
|
|
525
|
+
case 'mustBeOneOf':
|
|
526
|
+
return evalMustBeOneOf(c, r);
|
|
527
|
+
case 'denyPlaintextListener':
|
|
528
|
+
return evalDenyPlaintextListener(c, r);
|
|
529
|
+
case 'denyPrivilegedContainers':
|
|
530
|
+
return evalDenyPrivilegedContainers(c, r);
|
|
531
|
+
case 'denyLiteral':
|
|
532
|
+
return evalDenyLiteral(c, r);
|
|
533
|
+
case 'mustHaveAssociated':
|
|
534
|
+
return evalMustHaveAssociated(c, r, ctx);
|
|
535
|
+
case 'mustHaveBlock':
|
|
536
|
+
return evalMustHaveBlock(c, r);
|
|
537
|
+
case 'denyBlockPresence':
|
|
538
|
+
return evalDenyBlockPresence(c, r);
|
|
539
|
+
default:
|
|
540
|
+
return assertNever(c);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* TOTAL: always returns a CheckReport (doc 06, Rule 4). Folds over
|
|
545
|
+
* rules x conditions x in-scope resources (Rule 3), accumulating.
|
|
546
|
+
*/
|
|
547
|
+
function evaluate(rules, resources) {
|
|
548
|
+
const violations = [];
|
|
549
|
+
const couldNotEvaluate = [];
|
|
550
|
+
let passed = 0;
|
|
551
|
+
const ctx = { associations: buildAssociations(resources) };
|
|
552
|
+
for (const rule of rules) {
|
|
553
|
+
for (const resource of resources) {
|
|
554
|
+
if (!environmentMatches(rule, resource))
|
|
555
|
+
continue;
|
|
556
|
+
for (const condition of rule.conditions) {
|
|
557
|
+
if (!inScope(condition, rule.target, resource))
|
|
558
|
+
continue;
|
|
559
|
+
const outcome = evalCondition(condition, resource, ctx);
|
|
560
|
+
switch (outcome.kind) {
|
|
561
|
+
case 'violation':
|
|
562
|
+
violations.push({
|
|
563
|
+
ruleId: rule.id,
|
|
564
|
+
message: rule.message,
|
|
565
|
+
rationale: rule.rationale,
|
|
566
|
+
effect: rule.effect,
|
|
567
|
+
resource: (0, model_1.address)(resource),
|
|
568
|
+
file: resource.file,
|
|
569
|
+
line: resource.line,
|
|
570
|
+
approvers: rule.approvers,
|
|
571
|
+
});
|
|
572
|
+
break;
|
|
573
|
+
case 'cannotEvaluate':
|
|
574
|
+
couldNotEvaluate.push({
|
|
575
|
+
ruleId: rule.id,
|
|
576
|
+
resource: (0, model_1.address)(resource),
|
|
577
|
+
file: resource.file,
|
|
578
|
+
line: resource.line,
|
|
579
|
+
reason: outcome.reason,
|
|
580
|
+
});
|
|
581
|
+
break;
|
|
582
|
+
case 'pass':
|
|
583
|
+
passed += 1;
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return { violations, passed, couldNotEvaluate };
|
|
590
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { AnyResource } from '../vocabulary';
|
|
2
|
+
/**
|
|
3
|
+
* dotzen's own resource model. The engine only ever sees this — never the
|
|
4
|
+
* parser's raw output (doc 06). Unresolvable expressions are explicit,
|
|
5
|
+
* which is what feeds the `couldNotEvaluate` outcome.
|
|
6
|
+
*/
|
|
7
|
+
export type NormalizedValue = {
|
|
8
|
+
readonly kind: 'literal';
|
|
9
|
+
readonly value: string | number | boolean;
|
|
10
|
+
} | {
|
|
11
|
+
readonly kind: 'unresolved';
|
|
12
|
+
readonly expr: string;
|
|
13
|
+
};
|
|
14
|
+
export interface IngressRule {
|
|
15
|
+
readonly fromPort: NormalizedValue;
|
|
16
|
+
readonly toPort: NormalizedValue;
|
|
17
|
+
readonly cidrBlocks: NormalizedValue[];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Tags are either statically knowable (a literal map, so we know which
|
|
21
|
+
* keys are present) or unresolved (e.g. `tags = var.tags` / `merge(...)`),
|
|
22
|
+
* which must degrade to "could not evaluate" rather than a false verdict.
|
|
23
|
+
*/
|
|
24
|
+
export type TagsInfo = {
|
|
25
|
+
readonly kind: 'resolved';
|
|
26
|
+
readonly keys: string[];
|
|
27
|
+
} | {
|
|
28
|
+
readonly kind: 'unresolved';
|
|
29
|
+
};
|
|
30
|
+
/** One IAM policy statement (from a parsed literal-JSON policy document). */
|
|
31
|
+
export interface PolicyStatement {
|
|
32
|
+
readonly effect: string;
|
|
33
|
+
readonly actions: string[];
|
|
34
|
+
readonly resources: string[];
|
|
35
|
+
/** `NotAction` entries — an Allow with NotAction is an over-broad grant. */
|
|
36
|
+
readonly notActions: string[];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* An IAM `policy` argument: parsed when it is a literal JSON document
|
|
40
|
+
* (heredoc / inline string); `unresolved` when it is a `jsonencode(...)`
|
|
41
|
+
* expression, a variable, or malformed — which must degrade to "could not
|
|
42
|
+
* evaluate" rather than a guess.
|
|
43
|
+
*/
|
|
44
|
+
export type PolicyInfo = {
|
|
45
|
+
readonly kind: 'parsed';
|
|
46
|
+
readonly statements: PolicyStatement[];
|
|
47
|
+
} | {
|
|
48
|
+
readonly kind: 'unresolved';
|
|
49
|
+
};
|
|
50
|
+
/** A list-valued attribute (array of scalars), for `listContains`/`listMustInclude`. */
|
|
51
|
+
export type ListInfo = {
|
|
52
|
+
readonly kind: 'resolved';
|
|
53
|
+
readonly items: NormalizedValue[];
|
|
54
|
+
} | {
|
|
55
|
+
readonly kind: 'unresolved';
|
|
56
|
+
};
|
|
57
|
+
/** One ECS container (from a parsed literal-JSON `container_definitions`). */
|
|
58
|
+
export interface ContainerDef {
|
|
59
|
+
readonly name: string;
|
|
60
|
+
readonly privileged: boolean;
|
|
61
|
+
}
|
|
62
|
+
export type ContainerInfo = {
|
|
63
|
+
readonly kind: 'parsed';
|
|
64
|
+
readonly containers: ContainerDef[];
|
|
65
|
+
} | {
|
|
66
|
+
readonly kind: 'unresolved';
|
|
67
|
+
};
|
|
68
|
+
export interface NormalizedResource {
|
|
69
|
+
readonly type: AnyResource;
|
|
70
|
+
readonly name: string;
|
|
71
|
+
readonly file: string;
|
|
72
|
+
readonly line: number;
|
|
73
|
+
readonly ingress: IngressRule[];
|
|
74
|
+
/** Egress rules (security groups). Optional — absent means none. */
|
|
75
|
+
readonly egress?: IngressRule[];
|
|
76
|
+
readonly tags: TagsInfo;
|
|
77
|
+
/** Scalar attributes (nested blocks flattened to dotted keys). */
|
|
78
|
+
readonly attributes: Record<string, NormalizedValue>;
|
|
79
|
+
/** List-valued attributes (arrays of scalars), by dotted key. */
|
|
80
|
+
readonly lists?: Record<string, ListInfo>;
|
|
81
|
+
/** Dotted paths of every nested block declared (even empty ones), for
|
|
82
|
+
* `mustHaveBlock` / `denyBlockPresence`. */
|
|
83
|
+
readonly blocks?: string[];
|
|
84
|
+
/** Resolved value of the `environment` tag, if present — used for rule scoping. */
|
|
85
|
+
readonly environment?: NormalizedValue;
|
|
86
|
+
/** Parsed IAM `policy` document, if the resource has one. */
|
|
87
|
+
readonly policy?: PolicyInfo;
|
|
88
|
+
/** Parsed ECS `container_definitions`, if the resource has them. */
|
|
89
|
+
readonly containers?: ContainerInfo;
|
|
90
|
+
}
|
|
91
|
+
export declare const address: (r: NormalizedResource) => string;
|