@intentius/chant 0.53.1 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/audit/catalog.d.ts +4 -0
  2. package/dist/audit/catalog.d.ts.map +1 -1
  3. package/dist/audit/nginx.d.ts +53 -0
  4. package/dist/audit/nginx.d.ts.map +1 -0
  5. package/dist/audit/rules-doc.d.ts.map +1 -1
  6. package/dist/cli/commands/audit.d.ts.map +1 -1
  7. package/dist/cli/commands/carve-apply.d.ts.map +1 -1
  8. package/dist/cli/commands/carve-status.d.ts +56 -0
  9. package/dist/cli/commands/carve-status.d.ts.map +1 -0
  10. package/dist/cli/handlers/carve-status.d.ts +9 -0
  11. package/dist/cli/handlers/carve-status.d.ts.map +1 -0
  12. package/dist/cli/handlers/components.d.ts.map +1 -1
  13. package/dist/cli/handlers/run.d.ts.map +1 -1
  14. package/dist/cli/main.d.ts.map +1 -1
  15. package/dist/components/auto-release.d.ts +3 -1
  16. package/dist/components/auto-release.d.ts.map +1 -1
  17. package/dist/components/cli-support.d.ts +2 -0
  18. package/dist/components/cli-support.d.ts.map +1 -1
  19. package/dist/graph-ir.d.ts +7 -0
  20. package/dist/graph-ir.d.ts.map +1 -1
  21. package/dist/lexicon.d.ts +8 -0
  22. package/dist/lexicon.d.ts.map +1 -1
  23. package/dist/lifecycle/release-ledger.d.ts +55 -1
  24. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  25. package/dist/op/activity-contract.d.ts +15 -1
  26. package/dist/op/activity-contract.d.ts.map +1 -1
  27. package/dist/terraform/manifest.d.ts +31 -6
  28. package/dist/terraform/manifest.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/src/audit/catalog.ts +35 -0
  31. package/src/audit/edge-init-safety.test.ts +36 -3
  32. package/src/audit/nginx.test.ts +180 -0
  33. package/src/audit/nginx.ts +379 -0
  34. package/src/audit/rules-doc.ts +1 -0
  35. package/src/cli/commands/audit.ts +8 -3
  36. package/src/cli/commands/carve-apply.test.ts +4 -1
  37. package/src/cli/commands/carve-apply.ts +4 -2
  38. package/src/cli/commands/carve-bridge.test.ts +2 -1
  39. package/src/cli/commands/carve-emit-state.test.ts +3 -1
  40. package/src/cli/commands/carve-status.test.ts +116 -0
  41. package/src/cli/commands/carve-status.ts +163 -0
  42. package/src/cli/handlers/carve-status.ts +30 -0
  43. package/src/cli/handlers/components.ts +5 -1
  44. package/src/cli/handlers/graph.ts +3 -0
  45. package/src/cli/handlers/run.ts +4 -1
  46. package/src/cli/main.ts +8 -0
  47. package/src/components/auto-release.ts +4 -1
  48. package/src/components/cli-support.ts +4 -2
  49. package/src/graph-ir.ts +7 -0
  50. package/src/lexicon.ts +8 -0
  51. package/src/lifecycle/release-ledger.test.ts +64 -0
  52. package/src/lifecycle/release-ledger.ts +79 -1
  53. package/src/op/activity-contract.ts +14 -0
  54. package/src/terraform/manifest.test.ts +52 -0
  55. package/src/terraform/manifest.ts +72 -10
@@ -1 +1 @@
1
- {"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../../src/terraform/manifest.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../../src/terraform/manifest.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.53.1",
3
+ "version": "0.54.0",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -127,6 +127,22 @@ export const CF_STATIC_ASSETS: Authority = {
127
127
  name: "Cloudflare Workers — Static assets",
128
128
  url: "https://developers.cloudflare.com/workers/static-assets/",
129
129
  };
130
+ export const MOZILLA_TLS: Authority = {
131
+ name: "Mozilla — Server Side TLS",
132
+ url: "https://wiki.mozilla.org/Security/Server_Side_TLS",
133
+ };
134
+ export const CWE_DIR_LISTING: Authority = {
135
+ name: "CWE-548 — Exposure of Information Through Directory Listing",
136
+ url: "https://cwe.mitre.org/data/definitions/548.html",
137
+ };
138
+ export const GIXY_ALIAS: Authority = {
139
+ name: "Gixy — alias traversal",
140
+ url: "https://github.com/yandex/gixy/blob/master/docs/en/plugins/aliastraversal.md",
141
+ };
142
+ export const NGINX_STUB_STATUS: Authority = {
143
+ name: "nginx — ngx_http_stub_status_module",
144
+ url: "https://nginx.org/en/docs/http/ngx_http_stub_status_module.html",
145
+ };
130
146
  export const CWE_HARDCODED_CREDS: Authority = {
131
147
  name: "CWE-798 — Use of Hard-coded Credentials",
132
148
  url: "https://cwe.mitre.org/data/definitions/798.html",
@@ -197,6 +213,14 @@ export const RULE_CATEGORY: Record<string, Category> = {
197
213
  WRG004: "security",
198
214
  WRG005: "security",
199
215
  WRG006: "security",
216
+ // NGX — nginx config audit (#1979), lexicon-independent like SEC/WRG.
217
+ NGX001: "security",
218
+ NGX002: "security",
219
+ NGX003: "security",
220
+ NGX004: "security",
221
+ NGX005: "security",
222
+ NGX006: "best-practice",
223
+ NGX007: "best-practice",
200
224
  // AGT — agent configuration (`chant audit --agents`). Core-owned like COR/EXT:
201
225
  // these run against the machine's own agent config, not against any one
202
226
  // lexicon's emitted output, so no lexicon ships them.
@@ -247,6 +271,17 @@ export const RULE_CATALOG: Record<string, RuleMeta> = {
247
271
  WRG005: meta("WRG005", M, G, "Non-production environment shares a data store with production", "Give the non-production environment its own KV namespace/R2 bucket/D1 database id instead of reusing production's.", [CF_ENVIRONMENTS]),
248
272
  WRG006: meta("WRG006", M, G, "Static assets served from the project root", "Point [site].bucket / [assets].directory at a dedicated public output folder, not the project root, so non-public files (config, source maps, .git) aren't served.", [CF_STATIC_ASSETS]),
249
273
 
274
+ // nginx config audit (#1979, the #446 follow-up) — lexicon-independent,
275
+ // same shape as SEC/WRG: `nginx.ts` scans every nginx config it detects
276
+ // regardless of which (if any) audit lexicons are installed.
277
+ NGX001: meta("NGX001", M, G, "Deprecated TLS protocol enabled", "Remove SSLv2/SSLv3/TLSv1/TLSv1.1 from ssl_protocols and serve TLSv1.2 and TLSv1.3 only.", [MOZILLA_TLS]),
278
+ NGX002: meta("NGX002", M, G, "Weak cipher suite enabled", "Remove the RC4/DES/MD5/NULL/EXPORT-class entries from ssl_ciphers and use a modern cipher list (e.g. Mozilla's intermediate configuration).", [MOZILLA_TLS]),
279
+ NGX003: meta("NGX003", M, G, "Directory listing enabled", "Remove `autoindex on` (or scope it to a directory that is genuinely meant to be enumerated) so file listings aren't served to anyone who asks.", [CWE_DIR_LISTING]),
280
+ NGX004: meta("NGX004", M, G, "alias path traversal", "End the location prefix with \"/\" so it matches the trailing slash of the alias target — without it, a request for \"<prefix>../\" escapes the aliased directory.", [GIXY_ALIAS]),
281
+ NGX005: meta("NGX005", M, G, "Status endpoint with no access restriction", "Restrict the stub_status location with allow/deny (or auth_basic/auth_request) so connection metrics aren't public reconnaissance.", [NGINX_STUB_STATUS]),
282
+ NGX006: meta("NGX006", R, G, "Server version disclosure", "Add `server_tokens off;` in the http block so nginx stops advertising its exact version in the Server header and error pages."),
283
+ NGX007: meta("NGX007", R, G, "Access logging disabled at server scope", "Re-enable access_log at http/server scope (silencing a single noisy location is fine) so requests are recorded for incident investigation."),
284
+
250
285
  // ── Agent configuration (`chant audit --agents`) ──────────────────
251
286
  AGT001: agentMeta(
252
287
  "AGT001",
@@ -54,8 +54,8 @@ function resolveRelative(from: string, spec: string): string | undefined {
54
54
  return undefined;
55
55
  }
56
56
 
57
- /** Static (non-type-only) imports and re-exports of a file, resolved to paths. */
58
- function staticImports(sf: ts.SourceFile): string[] {
57
+ /** Static (non-type-only) import/re-export specifiers of a file. */
58
+ function staticSpecifiers(sf: ts.SourceFile): string[] {
59
59
  const out: string[] = [];
60
60
  for (const stmt of sf.statements) {
61
61
  let spec: string | undefined;
@@ -66,7 +66,16 @@ function staticImports(sf: ts.SourceFile): string[] {
66
66
  if (stmt.isTypeOnly) continue;
67
67
  spec = (stmt.moduleSpecifier as ts.StringLiteral).text;
68
68
  }
69
- if (!spec || !spec.startsWith(".")) continue;
69
+ if (spec) out.push(spec);
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /** Static (non-type-only) imports and re-exports of a file, resolved to paths. */
75
+ function staticImports(sf: ts.SourceFile): string[] {
76
+ const out: string[] = [];
77
+ for (const spec of staticSpecifiers(sf)) {
78
+ if (!spec.startsWith(".")) continue;
70
79
  const resolved = resolveRelative(sf.fileName, spec);
71
80
  if (resolved) out.push(resolved);
72
81
  }
@@ -171,4 +180,28 @@ describe("edge-imported lexicon modules are init-safe", () => {
171
180
  for (const sf of transitiveClosure(barrels()).values()) offenders.push(...moduleScopeOffenses(sf));
172
181
  expect(offenders, "move these into a function (lazy) — they crash edge bundles").toEqual([]);
173
182
  });
183
+
184
+ test("no static import of the full @intentius/chant barrel in the transitive import graph", () => {
185
+ // The relative-import walk above stops at package specifiers, and the bare
186
+ // core barrel is exactly the package import that must never appear: its
187
+ // static graph reaches build() → discovery/fold-import → the TypeScript
188
+ // compiler, whose own module init touches __filename and crashes workerd
189
+ // at startup. This is how WHM503's `../../render` import (whose render.ts
190
+ // imports `@intentius/chant` for Composite) broke the hosted audit's
191
+ // 0.53.1 bump. Subpath imports (`@intentius/chant/lint/post-synth`,
192
+ // `@intentius/chant/effect-receipt`, …) are the deliberately edge-clean
193
+ // seams and stay allowed.
194
+ const offenders: string[] = [];
195
+ for (const sf of transitiveClosure(barrels()).values()) {
196
+ for (const spec of staticSpecifiers(sf)) {
197
+ if (spec === "@intentius/chant") {
198
+ offenders.push(sf.fileName.replace(lexiconsDir, "lexicons"));
199
+ }
200
+ }
201
+ }
202
+ expect(
203
+ offenders,
204
+ "these edge-reachable modules import the full core barrel — import the specific subpath (or split the needed piece into a light module) instead",
205
+ ).toEqual([]);
206
+ });
174
207
  });
@@ -0,0 +1,180 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { auditNginxConfigs, isNginxConfigPath, parseNginx, NginxParseError, type ScannableFile } from "./nginx";
3
+ import { RULE_CATALOG, RULE_CATEGORY } from "./catalog";
4
+
5
+ const file = (content: string, path = "nginx.conf"): ScannableFile => ({ path, content });
6
+
7
+ /** A minimal, otherwise-clean server (server_tokens off keeps NGX006 out of unrelated tests). */
8
+ const wrap = (body: string): string => `http {\n server_tokens off;\n server {\n listen 443 ssl;\n${body}\n }\n}\n`;
9
+
10
+ describe("isNginxConfigPath (detector)", () => {
11
+ test("accepts nginx.conf anywhere and .conf under nginx-ish directories", () => {
12
+ for (const p of [
13
+ "nginx.conf",
14
+ "deploy/nginx.conf",
15
+ "docker/nginx/default.conf",
16
+ "etc/nginx/conf.d/app.conf",
17
+ "nginx/sites-available/site.conf",
18
+ "nginx/sites-enabled/site.conf",
19
+ "etc/nginx/snippets/ssl.conf",
20
+ ]) {
21
+ expect(isNginxConfigPath(p), p).toBe(true);
22
+ }
23
+ });
24
+
25
+ test("rejects .conf files with no nginx-ish ancestry", () => {
26
+ for (const p of ["app.conf", "etc/systemd/system/app.service.d/override.conf", "supervisord.conf", "src/logrotate.conf"]) {
27
+ expect(isNginxConfigPath(p), p).toBe(false);
28
+ }
29
+ });
30
+ });
31
+
32
+ describe("parseNginx", () => {
33
+ test("parses directives, nested blocks, quotes, and comments with line numbers", () => {
34
+ const tree = parseNginx(
35
+ `# main\nworker_processes auto;\nhttp {\n server {\n listen 80; # inline comment\n server_name "ex ample.com" 'two';\n }\n}\n`,
36
+ );
37
+ expect(tree.map((d) => d.name)).toEqual(["worker_processes", "http"]);
38
+ expect(tree[0].line).toBe(2);
39
+ const server = tree[1].block![0];
40
+ expect(server.name).toBe("server");
41
+ expect(server.block!.map((d) => d.name)).toEqual(["listen", "server_name"]);
42
+ expect(server.block![0].line).toBe(5);
43
+ // Quoting keeps spaces and strips the quotes themselves.
44
+ expect(server.block![1].args).toEqual(["ex ample.com", "two"]);
45
+ });
46
+
47
+ test("throws NginxParseError on unbalanced braces and unterminated strings", () => {
48
+ expect(() => parseNginx("http { server {")).toThrow(NginxParseError);
49
+ expect(() => parseNginx("}")).toThrow(NginxParseError);
50
+ expect(() => parseNginx('server_name "unterminated;')).toThrow(NginxParseError);
51
+ });
52
+ });
53
+
54
+ describe("auditNginxConfigs — gating", () => {
55
+ test("a candidate that fails to parse contributes no findings (never a crash)", () => {
56
+ expect(auditNginxConfigs([file("http { { }")])).toEqual([]);
57
+ });
58
+
59
+ test("a .conf in a shared directory name that isn't nginx (no marker directive) contributes nothing", () => {
60
+ // systemd-style drop-in that happens to live under conf.d/
61
+ const systemd = file("[Service]\n", "conf.d/app.conf");
62
+ expect(auditNginxConfigs([systemd])).toEqual([]);
63
+ // Even nginx-looking directives without any marker stay silent.
64
+ expect(auditNginxConfigs([file("autoindex on;\n", "conf.d/x.conf")])).toEqual([]);
65
+ });
66
+
67
+ test("a non-candidate path is never scanned", () => {
68
+ expect(auditNginxConfigs([file(wrap("autoindex on;"), "README.md")])).toEqual([]);
69
+ });
70
+ });
71
+
72
+ describe("NGX001 — deprecated TLS protocols", () => {
73
+ test("flags SSLv3/TLSv1/TLSv1.1 with the offending names, keeps modern-only silent", () => {
74
+ const bad = auditNginxConfigs([file(wrap(" ssl_protocols SSLv3 TLSv1 TLSv1.1 TLSv1.2;"))]);
75
+ expect(bad).toHaveLength(1);
76
+ expect(bad[0].checkId).toBe("NGX001");
77
+ expect(bad[0].severity).toBe("error");
78
+ expect(bad[0].message).toContain("SSLv3");
79
+ expect(bad[0].line).toBeGreaterThan(1);
80
+
81
+ expect(auditNginxConfigs([file(wrap(" ssl_protocols TLSv1.2 TLSv1.3;"))])).toEqual([]);
82
+ });
83
+ });
84
+
85
+ describe("NGX002 — weak cipher suites", () => {
86
+ test("flags positive weak entries; exclusions (!RC4) and modern suites pass", () => {
87
+ const bad = auditNginxConfigs([file(wrap(' ssl_protocols TLSv1.2;\n ssl_ciphers "HIGH:RC4-SHA:DES-CBC3-SHA";'))]);
88
+ expect(bad.map((f) => f.checkId)).toEqual(["NGX002"]);
89
+ expect(bad[0].message).toContain("RC4-SHA");
90
+
91
+ const good = auditNginxConfigs([
92
+ file(wrap(' ssl_protocols TLSv1.2;\n ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256:!RC4:!MD5:!aNULL";')),
93
+ ]);
94
+ expect(good).toEqual([]);
95
+ });
96
+ });
97
+
98
+ describe("NGX003 — directory listing", () => {
99
+ test("flags autoindex on, not autoindex off", () => {
100
+ const bad = auditNginxConfigs([file(wrap(" location /files/ {\n autoindex on;\n }"))]);
101
+ expect(bad.map((f) => f.checkId)).toEqual(["NGX003"]);
102
+ expect(auditNginxConfigs([file(wrap(" autoindex off;"))])).toEqual([]);
103
+ });
104
+ });
105
+
106
+ describe("NGX004 — alias traversal", () => {
107
+ test("flags the gixy shape: prefix without trailing slash + alias with one", () => {
108
+ const bad = auditNginxConfigs([file(wrap(" location /i {\n alias /data/w3/images/;\n }"))]);
109
+ expect(bad.map((f) => f.checkId)).toEqual(["NGX004"]);
110
+ expect(bad[0].message).toContain("/i../");
111
+ });
112
+
113
+ test("slash-terminated prefixes, slashless alias targets, and regex locations pass", () => {
114
+ for (const body of [
115
+ " location /i/ {\n alias /data/w3/images/;\n }",
116
+ " location /i {\n alias /data/w3/images;\n }",
117
+ " location ~ ^/img {\n alias /data/w3/images/;\n }",
118
+ ]) {
119
+ expect(auditNginxConfigs([file(wrap(body))]), body).toEqual([]);
120
+ }
121
+ });
122
+ });
123
+
124
+ describe("NGX005 — open status endpoint", () => {
125
+ test("flags stub_status with no restriction; allow/deny or auth_basic silences it", () => {
126
+ const bad = auditNginxConfigs([file(wrap(" location /nginx_status {\n stub_status;\n }"))]);
127
+ expect(bad.map((f) => f.checkId)).toEqual(["NGX005"]);
128
+
129
+ for (const guarded of [
130
+ " location /nginx_status {\n stub_status;\n allow 127.0.0.1;\n deny all;\n }",
131
+ ' location /nginx_status {\n stub_status;\n auth_basic "metrics";\n }',
132
+ ]) {
133
+ expect(auditNginxConfigs([file(wrap(guarded))]), guarded).toEqual([]);
134
+ }
135
+ });
136
+ });
137
+
138
+ describe("NGX006 — server version disclosure", () => {
139
+ test("flags an http block with no server_tokens off; the off switch (anywhere) silences it", () => {
140
+ const bare = auditNginxConfigs([file("http {\n server {\n listen 80;\n }\n}\n")]);
141
+ expect(bare.map((f) => f.checkId)).toEqual(["NGX006"]);
142
+ expect(bare[0].severity).toBe("info");
143
+
144
+ expect(auditNginxConfigs([file("http {\n server_tokens off;\n server {\n listen 80;\n }\n}\n")])).toEqual([]);
145
+ });
146
+
147
+ test("a partial include with no http block is not flagged (the main conf owns this)", () => {
148
+ expect(auditNginxConfigs([file("server {\n listen 80;\n}\n", "etc/nginx/conf.d/app.conf")])).toEqual([]);
149
+ });
150
+ });
151
+
152
+ describe("NGX007 — access logging disabled at server scope", () => {
153
+ test("flags http/server-scope access_log off; a single silenced location is normal practice", () => {
154
+ const bad = auditNginxConfigs([
155
+ file("http {\n server_tokens off;\n server {\n listen 80;\n access_log off;\n }\n}\n"),
156
+ ]);
157
+ expect(bad.map((f) => f.checkId)).toEqual(["NGX007"]);
158
+
159
+ const ok = auditNginxConfigs([
160
+ file(
161
+ "http {\n server_tokens off;\n server {\n listen 80;\n location = /healthz {\n access_log off;\n }\n }\n}\n",
162
+ ),
163
+ ]);
164
+ expect(ok).toEqual([]);
165
+ });
166
+ });
167
+
168
+ describe("catalog coverage", () => {
169
+ test("every NGX finding id has catalog metadata and a category mapping", () => {
170
+ for (const id of ["NGX001", "NGX002", "NGX003", "NGX004", "NGX005", "NGX006", "NGX007"]) {
171
+ expect(RULE_CATALOG[id], id).toBeDefined();
172
+ expect(RULE_CATALOG[id].remediation.length).toBeGreaterThan(0);
173
+ expect(RULE_CATEGORY[id], id).toBeDefined();
174
+ }
175
+ // The launch families carry external authority, per the epic's tiering goal.
176
+ for (const id of ["NGX001", "NGX002", "NGX003", "NGX004", "NGX005"]) {
177
+ expect(RULE_CATALOG[id].authority?.length, id).toBeGreaterThan(0);
178
+ }
179
+ });
180
+ });