@dotzen/dotzen 0.0.1 → 0.0.3
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/README.md +24 -0
- package/dist/cli/main.js +3 -0
- package/dist/engine/evaluate.js +11 -3
- package/dist/hcl/model.d.ts +3 -0
- package/dist/hcl/normalize.js +55 -12
- package/dist/spec/load.js +11 -1
- package/dist/spec/rule.d.ts +14 -2
- package/dist/spec/rule.js +12 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,13 +22,37 @@ export const spec = [
|
|
|
22
22
|
|
|
23
23
|
Each finding is `block` (fails the build), `warn`, or `require_approval` (pauses CI for sign-off). When a value can't be resolved statically, dotzen reports **"could not evaluate"** rather than guessing — a false positive is worse than an honest gap.
|
|
24
24
|
|
|
25
|
+
**Org-specific tags:** tag taxonomies vary by org, so `mustHaveTags` accepts your own keys — declare them in your own `enum` (keeps typos as compile errors) and mix with the built-in `Tag`:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
enum OrgTag {
|
|
29
|
+
ApmId = 'apm_id',
|
|
30
|
+
CmdbAppId = 'cmdb_app_id',
|
|
31
|
+
}
|
|
32
|
+
rule()
|
|
33
|
+
.resource(AwsResource.S3Bucket)
|
|
34
|
+
.mustHaveTags(OrgTag.ApmId, OrgTag.CmdbAppId, Tag.Environment)
|
|
35
|
+
.message('...')
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
dotzen resolves tags through `var`/`local` refs and `merge(<literal>, var.tags)` — so a required key hardcoded in the module passes, while one that might come from a caller's `var.tags` reports "could not evaluate" (never a false violation).
|
|
39
|
+
|
|
25
40
|
## Getting started
|
|
26
41
|
|
|
27
42
|
```bash
|
|
28
43
|
npx @dotzen/dotzen init # scaffold .zen/spec.ts + dotzen.json
|
|
44
|
+
npm i -D @dotzen/dotzen # editor autocomplete + type-checking for spec.ts
|
|
29
45
|
npx @dotzen/dotzen check # evaluate ./terraform against the spec
|
|
30
46
|
```
|
|
31
47
|
|
|
48
|
+
Two modes, both supported:
|
|
49
|
+
|
|
50
|
+
- **Authoring** — install as a devDependency so your editor resolves the
|
|
51
|
+
DSL types (`import { rule } from '@dotzen/dotzen'`) and gives you
|
|
52
|
+
autocomplete + compile-time safety while you write rules.
|
|
53
|
+
- **Running** — `npx @dotzen/dotzen check` stays zero-install (nothing to add
|
|
54
|
+
to your project) — ideal for CI. The engine resolves the DSL import itself.
|
|
55
|
+
|
|
32
56
|
- `--format json` for machine-readable output.
|
|
33
57
|
- Pin the version in `dotzen.json` (never `@latest` in CI).
|
|
34
58
|
|
package/dist/cli/main.js
CHANGED
|
@@ -96,6 +96,9 @@ function runInit(dir, terraform) {
|
|
|
96
96
|
process.stdout.write(` created ${c}\n`);
|
|
97
97
|
for (const s of res.skipped)
|
|
98
98
|
process.stdout.write(` skipped ${s} (already exists)\n`);
|
|
99
|
+
process.stdout.write('\nFor editor autocomplete + type-checking of .zen/spec.ts, install the\n' +
|
|
100
|
+
'types locally: npm i -D @dotzen/dotzen (and add node_modules/ to\n' +
|
|
101
|
+
'.gitignore). Running `check` via npx needs no local install.\n');
|
|
99
102
|
if (res.detected) {
|
|
100
103
|
const roots = Array.isArray(res.terraform) ? res.terraform : [res.terraform];
|
|
101
104
|
const fmt = (r) => typeof r === 'string' ? `"${r}"` : `"${r.path}" (${r.environment})`;
|
package/dist/engine/evaluate.js
CHANGED
|
@@ -117,9 +117,17 @@ function evalMustHaveTags(c, r) {
|
|
|
117
117
|
};
|
|
118
118
|
const present = new Set(r.tags.keys);
|
|
119
119
|
const missing = c.tags.filter((t) => !present.has(t));
|
|
120
|
-
if (missing.length
|
|
121
|
-
return { kind: '
|
|
122
|
-
|
|
120
|
+
if (missing.length === 0)
|
|
121
|
+
return { kind: 'pass' };
|
|
122
|
+
// A `partial` set (e.g. merge(<literal>, var.tags)) proves presence but not
|
|
123
|
+
// absence — a var arg may supply the missing key, so don't claim a
|
|
124
|
+
// violation; degrade honestly instead.
|
|
125
|
+
if (r.tags.kind === 'partial')
|
|
126
|
+
return {
|
|
127
|
+
kind: 'cannotEvaluate',
|
|
128
|
+
reason: `tag(s) not in the resolvable portion (a var/merge arg may add them): ${missing.join(', ')}`,
|
|
129
|
+
};
|
|
130
|
+
return { kind: 'violation', detail: `missing tags: ${missing.join(', ')}` };
|
|
123
131
|
}
|
|
124
132
|
const isLiteralTrue = (v) => v?.kind === 'literal' && v.value === true;
|
|
125
133
|
/**
|
package/dist/hcl/model.d.ts
CHANGED
package/dist/hcl/normalize.js
CHANGED
|
@@ -284,20 +284,63 @@ function egressFor(block, scope) {
|
|
|
284
284
|
...dynamicBlocks(block, 'egress', scope),
|
|
285
285
|
];
|
|
286
286
|
}
|
|
287
|
+
// Object-literal keys (`ident = …`) in an HCL expression string. A single
|
|
288
|
+
// `=` (not `==`/`>=`/etc.) is HCL's attribute-assignment operator, so this
|
|
289
|
+
// reliably picks up flat map keys inside a `merge(...)` argument.
|
|
290
|
+
const OBJECT_KEY = /([A-Za-z_][A-Za-z0-9_-]*)\s*=(?!=)/g;
|
|
291
|
+
// `var.x` / `local.y` reference tokens mentioned in an expression.
|
|
292
|
+
const REF_TOKEN = /\b(var|local)\.([A-Za-z0-9_-]+)/g;
|
|
287
293
|
/**
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
294
|
+
* Keys known to be present on a tags value, and whether that set is
|
|
295
|
+
* COMPLETE. Follows sole `var`/`local` references to their value, and
|
|
296
|
+
* unions the literal keys (and resolvable refs) inside a `merge(...)`.
|
|
297
|
+
* Returns `null` when nothing can be determined (an unresolvable reference
|
|
298
|
+
* or an opaque expression) — which becomes "could not evaluate".
|
|
291
299
|
*/
|
|
292
|
-
function
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
300
|
+
function tagKeys(value, scope, depth = 8) {
|
|
301
|
+
if (value === undefined)
|
|
302
|
+
return { keys: [], complete: true };
|
|
303
|
+
if (value && typeof value === 'object' && !Array.isArray(value))
|
|
304
|
+
return { keys: Object.keys(value), complete: true };
|
|
305
|
+
if (typeof value !== 'string')
|
|
306
|
+
return null;
|
|
307
|
+
const ref = SOLE_REF.exec(value); // exactly one ${var.x} / ${local.y}
|
|
308
|
+
if (ref) {
|
|
309
|
+
if (depth <= 0)
|
|
310
|
+
return null;
|
|
311
|
+
const key = `${ref[1]}.${ref[2]}`;
|
|
312
|
+
return scope.has(key) ? tagKeys(scope.get(key), scope, depth - 1) : null;
|
|
313
|
+
}
|
|
314
|
+
if (/\bmerge\s*\(/.test(value)) {
|
|
315
|
+
// merge() only ever ADDS keys, so presence is monotonic. Collect the
|
|
316
|
+
// inline literal keys plus keys from any resolvable ref args; mark the
|
|
317
|
+
// set incomplete (a var/unresolved arg may contribute more).
|
|
318
|
+
const keys = new Set(value.match(OBJECT_KEY)?.map((m) => m.replace(/\s*=$/, '')) ?? []);
|
|
319
|
+
for (const [, kind, name] of value.matchAll(REF_TOKEN)) {
|
|
320
|
+
const scopeKey = `${kind}.${name}`;
|
|
321
|
+
if (depth > 0 && scope.has(scopeKey)) {
|
|
322
|
+
const sub = tagKeys(scope.get(scopeKey), scope, depth - 1);
|
|
323
|
+
if (sub)
|
|
324
|
+
for (const k of sub.keys)
|
|
325
|
+
keys.add(k);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return { keys: [...keys], complete: false };
|
|
329
|
+
}
|
|
330
|
+
return null; // some other unresolvable expression
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Tags: a literal map (or a `var`/`local` reference resolved to one) gives
|
|
334
|
+
* a complete key set; `merge(<literal>, var.tags)` gives a PARTIAL set
|
|
335
|
+
* (known-present keys, may be more); anything else is unresolved.
|
|
336
|
+
*/
|
|
337
|
+
function tagsOf(block, scope) {
|
|
338
|
+
const r = tagKeys(block?.tags, scope);
|
|
339
|
+
if (r === null)
|
|
297
340
|
return { kind: 'unresolved' };
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
341
|
+
return r.complete
|
|
342
|
+
? { kind: 'resolved', keys: r.keys }
|
|
343
|
+
: { kind: 'partial', keys: r.keys };
|
|
301
344
|
}
|
|
302
345
|
/** Resolved value of the `environment` tag (for rule scoping), if present. */
|
|
303
346
|
function environmentOf(block, scope) {
|
|
@@ -445,7 +488,7 @@ function normalize(parsed, file, rawText, scope = new Map(), environmentOverride
|
|
|
445
488
|
line: findLine(rawText, type, name),
|
|
446
489
|
ingress: ingressFor(type, block, scope),
|
|
447
490
|
egress: egressFor(block, scope),
|
|
448
|
-
tags: tagsOf(block),
|
|
491
|
+
tags: tagsOf(block, scope),
|
|
449
492
|
attributes: extracted.attributes,
|
|
450
493
|
lists: extracted.lists,
|
|
451
494
|
blocks: extracted.blocks,
|
package/dist/spec/load.js
CHANGED
|
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.importSpecModule = importSpecModule;
|
|
37
37
|
exports.loadSpec = loadSpec;
|
|
38
38
|
const fs = __importStar(require("fs"));
|
|
39
|
+
const path = __importStar(require("path"));
|
|
39
40
|
const jiti_1 = require("jiti");
|
|
40
41
|
const result_1 = require("../result/result");
|
|
41
42
|
/**
|
|
@@ -47,7 +48,16 @@ async function importSpecModule(specPath) {
|
|
|
47
48
|
if (!fs.existsSync(specPath))
|
|
48
49
|
return (0, result_1.err)({ kind: 'ConfigNotFound', path: specPath });
|
|
49
50
|
try {
|
|
50
|
-
|
|
51
|
+
// A scaffolded spec imports `@dotzen/dotzen`, but under `npx` the engine
|
|
52
|
+
// runs from the npx cache while the user's project has no local install —
|
|
53
|
+
// so that bare specifier won't resolve from the spec's location. Alias it
|
|
54
|
+
// to THIS running engine's own barrel (dist/index.js at runtime, or
|
|
55
|
+
// src/index.ts under the test transpiler — extension-less so jiti picks
|
|
56
|
+
// the right one). This is what makes the zero-install `npx` flow work.
|
|
57
|
+
const enginePath = path.join(__dirname, '..', 'index');
|
|
58
|
+
const jiti = (0, jiti_1.createJiti)(__filename, {
|
|
59
|
+
alias: { '@dotzen/dotzen': enginePath },
|
|
60
|
+
});
|
|
51
61
|
const mod = (await jiti.import(specPath));
|
|
52
62
|
const spec = mod.spec;
|
|
53
63
|
if (!Array.isArray(spec))
|
package/dist/spec/rule.d.ts
CHANGED
|
@@ -18,7 +18,7 @@ export type Condition = {
|
|
|
18
18
|
readonly from: Cidr[];
|
|
19
19
|
} | {
|
|
20
20
|
readonly kind: 'mustHaveTags';
|
|
21
|
-
readonly tags:
|
|
21
|
+
readonly tags: string[];
|
|
22
22
|
} | {
|
|
23
23
|
readonly kind: 'mustBeTrue';
|
|
24
24
|
readonly attrs: AnyAttribute[];
|
|
@@ -112,7 +112,19 @@ export declare class RuleBuilder {
|
|
|
112
112
|
environment(env: Environment): this;
|
|
113
113
|
denyIngress(...ports: Port[]): this;
|
|
114
114
|
denyEgress(...ports: Port[]): this;
|
|
115
|
-
|
|
115
|
+
/**
|
|
116
|
+
* Required tag KEYS. Accepts the built-in `Tag` enum AND org-specific
|
|
117
|
+
* keys — because tag taxonomies are org-defined, unlike cloud-fixed
|
|
118
|
+
* resource types/ports. Always back org keys with your OWN `enum` (never
|
|
119
|
+
* bare strings) so typos stay compile errors:
|
|
120
|
+
*
|
|
121
|
+
* enum OrgTag { ApmId = 'apm_id', CmdbAppId = 'cmdb_app_id' }
|
|
122
|
+
* rule().resource(...).mustHaveTags(OrgTag.ApmId, Tag.Environment)
|
|
123
|
+
*
|
|
124
|
+
* The `string & {}` keeps `Tag` autocomplete while allowing your enum's
|
|
125
|
+
* (string-valued) members through.
|
|
126
|
+
*/
|
|
127
|
+
mustHaveTags(...tags: (Tag | (string & {}))[]): this;
|
|
116
128
|
mustBeTrue(...attrs: AnyAttribute[]): this;
|
|
117
129
|
/** AnyAttribute must be explicitly false; absent counts as a violation
|
|
118
130
|
* (use for attributes whose insecure AWS default is `true`). */
|
package/dist/spec/rule.js
CHANGED
|
@@ -44,6 +44,18 @@ class RuleBuilder {
|
|
|
44
44
|
});
|
|
45
45
|
return this;
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Required tag KEYS. Accepts the built-in `Tag` enum AND org-specific
|
|
49
|
+
* keys — because tag taxonomies are org-defined, unlike cloud-fixed
|
|
50
|
+
* resource types/ports. Always back org keys with your OWN `enum` (never
|
|
51
|
+
* bare strings) so typos stay compile errors:
|
|
52
|
+
*
|
|
53
|
+
* enum OrgTag { ApmId = 'apm_id', CmdbAppId = 'cmdb_app_id' }
|
|
54
|
+
* rule().resource(...).mustHaveTags(OrgTag.ApmId, Tag.Environment)
|
|
55
|
+
*
|
|
56
|
+
* The `string & {}` keeps `Tag` autocomplete while allowing your enum's
|
|
57
|
+
* (string-valued) members through.
|
|
58
|
+
*/
|
|
47
59
|
mustHaveTags(...tags) {
|
|
48
60
|
this._conditions.push({ kind: 'mustHaveTags', tags });
|
|
49
61
|
return this;
|