@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
@@ -0,0 +1,379 @@
1
+ /**
2
+ * nginx config audit (#1979, the #446 follow-up) — the second spike-gated
3
+ * format after Wrangler (PR #1978). nginx's native config is a directive/block
4
+ * DSL no structured-data parser reads, so before this the audit engine could
5
+ * fetch an estate's `nginx.conf` but not see inside it.
6
+ *
7
+ * Follows `wrangler.ts`'s shape exactly: no lexicon plugin, no
8
+ * `AuditInput`/`classifyFiles` involvement, no npm package. A principled,
9
+ * dependency-free recursive-descent parser (directives end with `;`, blocks
10
+ * nest with `{}`, `#` comments, single/double quotes) feeds a fixed check
11
+ * list; `auditNginxConfigs` is called alongside `auditWranglerConfigs` from
12
+ * the CLI. Disjoint id namespace (`NGX0xx`), `AuditFinding.lexicon = "nginx"`.
13
+ *
14
+ * Detection is two-stage on purpose: the *path* detector is broad (`.conf`
15
+ * under nginx-ish directories), because `conf.d/`-style names are shared with
16
+ * unrelated software (systemd drop-ins are `.conf` too) — so a candidate only
17
+ * produces findings when its parsed form also carries an nginx marker
18
+ * directive (`http`, `server`, `location`, `upstream`, …). A file that fails
19
+ * to parse, or parses to something that isn't nginx, contributes nothing:
20
+ * the audit contract is "runs against any repo," never a crash on odd config.
21
+ *
22
+ * Pure: no fs, no network, no Node-only global — Workers-safe (epic #350).
23
+ */
24
+
25
+ import type { AuditFinding } from "./core";
26
+
27
+ /** The minimal file shape the scanner needs (matches `discover.ts`'s `RepoFile`). */
28
+ export interface ScannableFile {
29
+ path: string;
30
+ content: string;
31
+ }
32
+
33
+ /** nginx-ish directories whose `.conf` files are candidates. */
34
+ const NGINX_DIR_RE = /(^|\/)(nginx|conf\.d|sites-available|sites-enabled|snippets)(\/|$)/;
35
+
36
+ /** Detector: does this path look like nginx config? (Confirmed by content markers at parse time.) */
37
+ export function isNginxConfigPath(path: string): boolean {
38
+ const base = path.split("/").pop() ?? path;
39
+ if (base === "nginx.conf") return true;
40
+ return base.endsWith(".conf") && NGINX_DIR_RE.test(path.slice(0, path.length - base.length));
41
+ }
42
+
43
+ // ── Parser ──────────────────────────────────────────────────────────────────
44
+
45
+ /** One parsed directive. A block directive (`server { … }`) carries `block`. */
46
+ export interface NginxDirective {
47
+ name: string;
48
+ args: string[];
49
+ block?: NginxDirective[];
50
+ /** 1-based line the directive's name starts on. */
51
+ line: number;
52
+ }
53
+
54
+ export class NginxParseError extends Error {
55
+ constructor(message: string, public readonly line: number) {
56
+ super(`${message} (line ${line})`);
57
+ this.name = "NginxParseError";
58
+ }
59
+ }
60
+
61
+ interface Token {
62
+ value: string;
63
+ line: number;
64
+ kind: "word" | "{" | "}" | ";";
65
+ }
66
+
67
+ function tokenize(content: string): Token[] {
68
+ const tokens: Token[] = [];
69
+ let i = 0;
70
+ let line = 1;
71
+ const n = content.length;
72
+ while (i < n) {
73
+ const c = content[i];
74
+ if (c === "\n") {
75
+ line++;
76
+ i++;
77
+ } else if (c === " " || c === "\t" || c === "\r") {
78
+ i++;
79
+ } else if (c === "#") {
80
+ while (i < n && content[i] !== "\n") i++;
81
+ } else if (c === "{" || c === "}" || c === ";") {
82
+ tokens.push({ value: c, line, kind: c });
83
+ i++;
84
+ } else if (c === '"' || c === "'") {
85
+ const quote = c;
86
+ const startLine = line;
87
+ let value = "";
88
+ i++;
89
+ while (i < n && content[i] !== quote) {
90
+ if (content[i] === "\\" && i + 1 < n) {
91
+ value += content[i + 1];
92
+ if (content[i + 1] === "\n") line++;
93
+ i += 2;
94
+ } else {
95
+ if (content[i] === "\n") line++;
96
+ value += content[i];
97
+ i++;
98
+ }
99
+ }
100
+ if (i >= n) throw new NginxParseError("Unterminated quoted string", startLine);
101
+ i++; // closing quote
102
+ tokens.push({ value, line: startLine, kind: "word" });
103
+ } else {
104
+ const start = i;
105
+ const startLine = line;
106
+ while (i < n && !' \t\r\n#{};"\''.includes(content[i])) i++;
107
+ tokens.push({ value: content.slice(start, i), line: startLine, kind: "word" });
108
+ }
109
+ }
110
+ return tokens;
111
+ }
112
+
113
+ /** Parse nginx config into a directive tree. Throws `NginxParseError` on malformed input. */
114
+ export function parseNginx(content: string): NginxDirective[] {
115
+ const tokens = tokenize(content);
116
+ let pos = 0;
117
+
118
+ function parseDirectives(depth: number): NginxDirective[] {
119
+ const out: NginxDirective[] = [];
120
+ while (pos < tokens.length) {
121
+ const tok = tokens[pos];
122
+ if (tok.kind === "}") {
123
+ if (depth === 0) throw new NginxParseError("Unexpected '}'", tok.line);
124
+ pos++;
125
+ return out;
126
+ }
127
+ if (tok.kind !== "word") throw new NginxParseError(`Unexpected '${tok.value}'`, tok.line);
128
+ const name = tok.value;
129
+ const line = tok.line;
130
+ pos++;
131
+ const args: string[] = [];
132
+ for (;;) {
133
+ if (pos >= tokens.length) throw new NginxParseError(`Directive "${name}" is never terminated`, line);
134
+ const next = tokens[pos];
135
+ if (next.kind === "word") {
136
+ args.push(next.value);
137
+ pos++;
138
+ } else if (next.kind === ";") {
139
+ pos++;
140
+ out.push({ name, args, line });
141
+ break;
142
+ } else if (next.kind === "{") {
143
+ pos++;
144
+ out.push({ name, args, line, block: parseDirectives(depth + 1) });
145
+ break;
146
+ } else {
147
+ throw new NginxParseError(`Unexpected '}' inside directive "${name}"`, next.line);
148
+ }
149
+ }
150
+ }
151
+ if (depth > 0) throw new NginxParseError("Unclosed '{'", tokens[tokens.length - 1]?.line ?? 1);
152
+ return out;
153
+ }
154
+
155
+ return parseDirectives(0);
156
+ }
157
+
158
+ // ── Walk helpers ────────────────────────────────────────────────────────────
159
+
160
+ /** Directive names that confirm a parsed `.conf` is actually nginx. */
161
+ const NGINX_MARKERS = new Set(["http", "server", "location", "upstream", "events", "stream", "listen", "proxy_pass", "server_name"]);
162
+
163
+ function hasNginxMarker(tree: NginxDirective[]): boolean {
164
+ return tree.some((d) => NGINX_MARKERS.has(d.name) || (d.block !== undefined && hasNginxMarker(d.block)));
165
+ }
166
+
167
+ /** Depth-first visit of every directive with its ancestor block-name chain. */
168
+ function walk(tree: NginxDirective[], visit: (d: NginxDirective, ancestors: string[]) => void, ancestors: string[] = []): void {
169
+ for (const d of tree) {
170
+ visit(d, ancestors);
171
+ if (d.block) walk(d.block, visit, [...ancestors, d.name]);
172
+ }
173
+ }
174
+
175
+ // ── NGX001: deprecated TLS protocol enabled ─────────────────────────────────
176
+
177
+ const DEPRECATED_TLS = new Set(["sslv2", "sslv3", "tlsv1", "tlsv1.1"]);
178
+
179
+ function checkDeprecatedTls(file: ScannableFile, tree: NginxDirective[]): AuditFinding[] {
180
+ const out: AuditFinding[] = [];
181
+ walk(tree, (d) => {
182
+ if (d.name !== "ssl_protocols") return;
183
+ const bad = d.args.filter((a) => DEPRECATED_TLS.has(a.toLowerCase()));
184
+ if (bad.length === 0) return;
185
+ out.push({
186
+ checkId: "NGX001",
187
+ severity: "error",
188
+ message: `ssl_protocols enables ${bad.join(", ")} — protocols with known breaks (POODLE, BEAST) that every current client has moved past. Serve TLSv1.2 and TLSv1.3 only.`,
189
+ file: file.path,
190
+ lexicon: "nginx",
191
+ entity: "ssl_protocols",
192
+ line: d.line,
193
+ });
194
+ });
195
+ return out;
196
+ }
197
+
198
+ // ── NGX002: weak cipher suite enabled ───────────────────────────────────────
199
+
200
+ const WEAK_CIPHER_RE = /(RC4|DES|MD5|NULL|EXPORT|LOW|aNULL|eNULL)/i;
201
+
202
+ function checkWeakCiphers(file: ScannableFile, tree: NginxDirective[]): AuditFinding[] {
203
+ const out: AuditFinding[] = [];
204
+ walk(tree, (d) => {
205
+ if (d.name !== "ssl_ciphers") return;
206
+ // A cipher string is ':'-separated; an entry starting with '!' is an
207
+ // exclusion (good), so only positive entries can be findings.
208
+ const weak = d.args
209
+ .flatMap((a) => a.split(":"))
210
+ .filter((s) => s !== "" && !s.startsWith("!") && WEAK_CIPHER_RE.test(s));
211
+ if (weak.length === 0) return;
212
+ out.push({
213
+ checkId: "NGX002",
214
+ severity: "error",
215
+ message: `ssl_ciphers enables weak suite(s): ${[...new Set(weak)].join(", ")} — breakable ciphers (RC4/DES/MD5/NULL/EXPORT classes) undermine every connection that negotiates one. Use a modern cipher list.`,
216
+ file: file.path,
217
+ lexicon: "nginx",
218
+ entity: "ssl_ciphers",
219
+ line: d.line,
220
+ });
221
+ });
222
+ return out;
223
+ }
224
+
225
+ // ── NGX003: directory listing enabled ───────────────────────────────────────
226
+
227
+ function checkAutoindex(file: ScannableFile, tree: NginxDirective[]): AuditFinding[] {
228
+ const out: AuditFinding[] = [];
229
+ walk(tree, (d, ancestors) => {
230
+ if (d.name !== "autoindex" || d.args[0]?.toLowerCase() !== "on") return;
231
+ out.push({
232
+ checkId: "NGX003",
233
+ severity: "warning",
234
+ message: `autoindex on (${ancestors.join(" > ") || "top level"}) — the directory's full file listing is served to anyone, including files never meant to be enumerated.`,
235
+ file: file.path,
236
+ lexicon: "nginx",
237
+ entity: "autoindex",
238
+ line: d.line,
239
+ });
240
+ });
241
+ return out;
242
+ }
243
+
244
+ // ── NGX004: alias path traversal (the gixy classic) ─────────────────────────
245
+
246
+ /** Location args end with the prefix; earlier args are modifiers (`=`, `^~`, `~`, `~*`). */
247
+ function locationPrefix(args: string[]): { prefix: string; isRegex: boolean } | undefined {
248
+ if (args.length === 0) return undefined;
249
+ const modifiers = args.slice(0, -1);
250
+ return { prefix: args[args.length - 1], isRegex: modifiers.includes("~") || modifiers.includes("~*") };
251
+ }
252
+
253
+ function checkAliasTraversal(file: ScannableFile, tree: NginxDirective[]): AuditFinding[] {
254
+ const out: AuditFinding[] = [];
255
+ walk(tree, (d) => {
256
+ if (d.name !== "location" || !d.block) return;
257
+ const loc = locationPrefix(d.args);
258
+ if (!loc || loc.isRegex || loc.prefix.endsWith("/")) return;
259
+ for (const inner of d.block) {
260
+ if (inner.name !== "alias") continue;
261
+ const target = inner.args[0] ?? "";
262
+ if (!target.endsWith("/")) continue;
263
+ out.push({
264
+ checkId: "NGX004",
265
+ severity: "error",
266
+ message: `location "${loc.prefix}" (no trailing slash) with alias "${target}" — a request for "${loc.prefix}../" resolves outside the aliased directory (path traversal). End the location prefix with "/" to match the alias.`,
267
+ file: file.path,
268
+ lexicon: "nginx",
269
+ entity: loc.prefix,
270
+ line: inner.line,
271
+ });
272
+ }
273
+ });
274
+ return out;
275
+ }
276
+
277
+ // ── NGX005: status endpoint with no access restriction ──────────────────────
278
+
279
+ function checkOpenStatusEndpoint(file: ScannableFile, tree: NginxDirective[]): AuditFinding[] {
280
+ const out: AuditFinding[] = [];
281
+ walk(tree, (d) => {
282
+ if (d.name !== "location" || !d.block) return;
283
+ const hasStatus = d.block.some((inner) => inner.name === "stub_status");
284
+ if (!hasStatus) return;
285
+ const restricted = d.block.some(
286
+ (inner) => inner.name === "deny" || inner.name === "allow" || inner.name === "auth_basic" || inner.name === "auth_request",
287
+ );
288
+ if (restricted) return;
289
+ out.push({
290
+ checkId: "NGX005",
291
+ severity: "warning",
292
+ message: `stub_status in location "${d.args[d.args.length - 1] ?? ""}" with no allow/deny or auth — the server's connection metrics are readable by anyone, a reconnaissance gift.`,
293
+ file: file.path,
294
+ lexicon: "nginx",
295
+ entity: "stub_status",
296
+ line: d.line,
297
+ });
298
+ });
299
+ return out;
300
+ }
301
+
302
+ // ── NGX006: server version disclosure (server_tokens not disabled) ──────────
303
+
304
+ function checkServerTokens(file: ScannableFile, tree: NginxDirective[]): AuditFinding[] {
305
+ const http = tree.find((d) => d.name === "http" && d.block);
306
+ if (!http) return []; // a partial include has no http{} — the main conf is where this belongs
307
+ let disabled = false;
308
+ walk(tree, (d) => {
309
+ if (d.name === "server_tokens" && d.args[0]?.toLowerCase() !== "on") disabled = true;
310
+ });
311
+ if (disabled) return [];
312
+ return [
313
+ {
314
+ checkId: "NGX006",
315
+ severity: "info",
316
+ message: `server_tokens is not disabled — nginx defaults to advertising its exact version in the Server header and error pages, handing scanners a version to match CVEs against. Add "server_tokens off;" in the http block.`,
317
+ file: file.path,
318
+ lexicon: "nginx",
319
+ entity: "http",
320
+ line: http.line,
321
+ },
322
+ ];
323
+ }
324
+
325
+ // ── NGX007: access logging disabled at server scope ─────────────────────────
326
+
327
+ function checkAccessLogOff(file: ScannableFile, tree: NginxDirective[]): AuditFinding[] {
328
+ const out: AuditFinding[] = [];
329
+ walk(tree, (d, ancestors) => {
330
+ if (d.name !== "access_log" || d.args[0]?.toLowerCase() !== "off") return;
331
+ // Silencing a single noisy location (favicon, health checks) is normal
332
+ // practice; going dark at http/server scope is what blocks investigation.
333
+ const scope = ancestors[ancestors.length - 1] ?? "top level";
334
+ if (scope === "location") return;
335
+ out.push({
336
+ checkId: "NGX007",
337
+ severity: "info",
338
+ message: `access_log off at ${scope} scope — no requests are recorded for this ${scope === "server" ? "server" : "configuration"}, which will slow down or block incident investigation.`,
339
+ file: file.path,
340
+ lexicon: "nginx",
341
+ entity: scope,
342
+ line: d.line,
343
+ });
344
+ });
345
+ return out;
346
+ }
347
+
348
+ const CHECKS: Array<(file: ScannableFile, tree: NginxDirective[]) => AuditFinding[]> = [
349
+ checkDeprecatedTls,
350
+ checkWeakCiphers,
351
+ checkAutoindex,
352
+ checkAliasTraversal,
353
+ checkOpenStatusEndpoint,
354
+ checkServerTokens,
355
+ checkAccessLogOff,
356
+ ];
357
+
358
+ /**
359
+ * Audit every nginx config in `files`. Pure with respect to the filesystem
360
+ * and network. A candidate that fails to parse, or parses to something with
361
+ * no nginx marker directive (a systemd-style `.conf` in a `conf.d/`),
362
+ * contributes no findings.
363
+ */
364
+ export function auditNginxConfigs(files: ScannableFile[]): AuditFinding[] {
365
+ const findings: AuditFinding[] = [];
366
+ for (const file of files) {
367
+ if (!isNginxConfigPath(file.path)) continue;
368
+ let tree: NginxDirective[];
369
+ try {
370
+ tree = parseNginx(file.content);
371
+ } catch (err) {
372
+ if (err instanceof NginxParseError) continue;
373
+ throw err;
374
+ }
375
+ if (!hasNginxMarker(tree)) continue;
376
+ for (const check of CHECKS) findings.push(...check(file, tree));
377
+ }
378
+ return findings;
379
+ }
@@ -23,6 +23,7 @@ const GROUPS: Array<{ heading: string; prefixes: string[]; blurb: string }> = [
23
23
  { heading: "fountain (FTN)", prefixes: ["FTN"], blurb: "Run against fountain manifests (`apiVersion: fountain.dev/v1`) — standalone `fountain apply` YAML is parsed back into the entity graph, so the same rules fire on `chant build` and `chant audit`." },
24
24
  { heading: "Secrets & credentials (SEC)", prefixes: ["SEC"], blurb: "Lexicon-independent — scans the raw text of every scanned file for likely credentials, regardless of which audit lexicons are installed. Matched values are always redacted; see [suppressing false positives](/chant/cli/audit/#suppressing-a-secrets-finding)." },
25
25
  { heading: "Wrangler config (WRG)", prefixes: ["WRG"], blurb: "Lexicon-independent, audit-only (#446) — scans `wrangler.toml`, Cloudflare Workers' native deploy config, which the engine cannot otherwise parse (it is not YAML/JSON). No authoring surface: chant does not write Wrangler config, it only reads it for these checks." },
26
+ { heading: "nginx config (NGX)", prefixes: ["NGX"], blurb: "Lexicon-independent, audit-only (#1979) — parses nginx's native directive/block config (`nginx.conf` and `.conf` files under nginx-ish directories, confirmed by content). No authoring surface: chant does not write nginx config, it only reads it for these checks." },
26
27
  {
27
28
  heading: "Agent configuration (AGT)",
28
29
  prefixes: ["AGT"],
@@ -12,6 +12,7 @@ import { AUDIT_LEXICONS, classifyFiles, collectCandidates, loadAuditPlugins, unc
12
12
  import { RULE_CATALOG, resolveAuditCatalog, type RuleMeta } from "../../audit/catalog";
13
13
  import { scanForSecrets, parseSecretsConfig, type SecretsScanOptions } from "../../audit/secrets";
14
14
  import { auditWranglerConfigs } from "../../audit/wrangler";
15
+ import { auditNginxConfigs } from "../../audit/nginx";
15
16
  import { renderMarkdown } from "../../audit/report";
16
17
  import { renderHtml, type ReportTheme } from "../../audit/report-html";
17
18
  import { buildReportJson, REPORT_SCHEMA_VERSION, type AuditSnapshot } from "../../audit/report-model";
@@ -390,15 +391,19 @@ export async function auditCommand(options: AuditCommandOptions): Promise<AuditC
390
391
  // `classifyFiles`/an installed lexicon package (no such package exists —
391
392
  // this format ships only a detector and checks, per the issue's scope).
392
393
  const wranglerFindings = auditWranglerConfigs(candidates);
394
+ // nginx config audit (#1979) — same lexicon-independent shape as wrangler:
395
+ // its own path detector plus a parsed-content marker check (a `.conf` in a
396
+ // shared directory name like conf.d/ only counts once it parses as nginx).
397
+ const nginxFindings = auditNginxConfigs(candidates);
393
398
 
394
399
  if (plugins.length === 0) {
395
400
  const output = format === "json" ? renderNoLexiconsJson(options.path, unclaimed) : renderNoLexicons(options.path, unclaimed);
396
- return { success: true, status: "no-lexicons", output, findings: [...secretsFindings, ...wranglerFindings], scanned: [], unclaimed, exitCode: NO_LEXICONS_EXIT_CODE, stream: format === "json" ? "stdout" : "stderr" };
401
+ return { success: true, status: "no-lexicons", output, findings: [...secretsFindings, ...wranglerFindings, ...nginxFindings], scanned: [], unclaimed, exitCode: NO_LEXICONS_EXIT_CODE, stream: format === "json" ? "stdout" : "stderr" };
397
402
  }
398
403
 
399
404
  const missingLexiconNote = missingLexiconHint(unclaimed);
400
405
 
401
- if (inputs.length === 0 && secretsFindings.length === 0 && wranglerFindings.length === 0) {
406
+ if (inputs.length === 0 && secretsFindings.length === 0 && wranglerFindings.length === 0 && nginxFindings.length === 0) {
402
407
  const output = `No auditable files found under ${options.path}.${missingLexiconNote ? ` ${missingLexiconNote}` : ""}`;
403
408
  return { success: true, status: "ok", output, findings: [], scanned: [], unclaimed, exitCode: 0 };
404
409
  }
@@ -412,7 +417,7 @@ export async function auditCommand(options: AuditCommandOptions): Promise<AuditC
412
417
  return { success: false, output: "", findings: [], scanned, exitCode: 1, error: msg };
413
418
  }
414
419
  }
415
- findings = [...findings, ...secretsFindings, ...wranglerFindings];
420
+ findings = [...findings, ...secretsFindings, ...wranglerFindings, ...nginxFindings];
416
421
  // Resolve the audit catalog once, aggregating the audited lexicons' own
417
422
  // metadata over core's static catalog (#687). The lexicons are already loaded
418
423
  // by `auditFiles` above, so this is cheap. Core's static catalog (always
@@ -95,7 +95,10 @@ describe("carveApply", () => {
95
95
  expect(stamped).toContain('{"Key":"chant:env","Value":"prod"}');
96
96
 
97
97
  const m = readCarveManifest(res.manifestPath!)!;
98
- expect(m.apply!.stampedFiles).toEqual([emitted]);
98
+ // Recorded relative to the manifest's directory (#2039), not the
99
+ // run-time absolute path the stamping itself used.
100
+ expect(m.apply!.stampedFiles).toEqual(["assets.ts"]);
101
+ expect(m.emit!.files).toEqual(["assets.ts"]);
99
102
  expect(formatCarveApply(res)).toContain("Stamped the ownership marker into");
100
103
 
101
104
  // Idempotent: a second graduation leaves the source stable.
@@ -15,7 +15,7 @@ import { cdkNotSupported, isCloudAssembly } from "../../cdk/assembly";
15
15
  import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
16
16
  import { boundaryReport } from "../../terraform/carve";
17
17
  import { graduationPlan, stampOwnershipIntoSource, type GraduationPlan } from "../../terraform/graduate";
18
- import { resolveCarveManifest, updateCarveManifest } from "../../terraform/manifest";
18
+ import { resolveCarveManifest, resolveManifestFilePath, updateCarveManifest } from "../../terraform/manifest";
19
19
  import { resolveEmitProvider } from "../../terraform/carve-provider";
20
20
 
21
21
  export interface CarveApplyOptions {
@@ -82,7 +82,9 @@ export async function carveApply(opts: CarveApplyOptions): Promise<CarveApplyRes
82
82
  // tags the source actually carries into the apply.
83
83
  let stamped: string[] | undefined;
84
84
  if (opts.writeSource) {
85
- const files = resolved.manifest?.emit?.files ?? [];
85
+ // Manifest paths are relative to the manifest's own directory (#2039);
86
+ // resolve them back to real files (absolute version-1 entries pass through).
87
+ const files = (resolved.manifest?.emit?.files ?? []).map((f) => resolveManifestFilePath(outDir, f));
86
88
  if (files.length === 0) {
87
89
  return {
88
90
  ok: false,
@@ -126,7 +126,8 @@ describe("carveBridge", () => {
126
126
  expect(patch).toContain("+ ASSETS_BUCKET = data.aws_s3_bucket.assets.bucket");
127
127
 
128
128
  const m = readCarveManifest(res.manifestPath!)!;
129
- expect(m.bridge!.patch).toBe(res.patchPath);
129
+ // The manifest records the patch relative to its own directory (#2039).
130
+ expect(m.bridge!.patch).toBe("aws_s3_bucket-assets-bridge.patch");
130
131
  expect(formatCarveBridge(res)).toContain("git apply");
131
132
 
132
133
  // The patch really applies: `git apply` from the estate reproduces the
@@ -106,7 +106,9 @@ describe("carve emit --state (real adoption from tfstate)", () => {
106
106
  expect(manifest.target).toBe("aws_s3_bucket.assets");
107
107
  expect(manifest.statePath).toBe(join(dir, "terraform.tfstate"));
108
108
  expect(manifest.emit!.source).toBe("tfstate");
109
- expect(manifest.emit!.files).toEqual([join(out, "src", "assets.ts")]);
109
+ // Recorded relative to the manifest's own directory (#2039) — the same
110
+ // base the graph IR's sourceLoc uses, which is the #2040 join.
111
+ expect(manifest.emit!.files).toEqual([join("src", "assets.ts")]);
110
112
  expect(manifest.boundary.inbound.map((e) => e.survivor)).toEqual(["aws_lambda_function.api"]);
111
113
 
112
114
  const text = formatCarveEmit(res);
@@ -0,0 +1,116 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { carveStatus, carveStatusJson, formatCarveStatus } from "./carve-status";
6
+ import { writeCarveManifest, type CarveManifest } from "../../terraform/manifest";
7
+ import type { CarveReport } from "../../terraform/carve";
8
+
9
+ function report(target: string): CarveReport {
10
+ return {
11
+ target,
12
+ carveSet: [{ address: target, type: target.split(".")[0] }],
13
+ peelability: 90,
14
+ inbound: [],
15
+ outbound: [],
16
+ reversible: true,
17
+ diagnostics: [],
18
+ };
19
+ }
20
+
21
+ function manifest(target: string): CarveManifest {
22
+ return { version: 1, target, from: "/estate", boundary: report(target) };
23
+ }
24
+
25
+ function withDir<T>(fn: (dir: string) => T): T {
26
+ const dir = mkdtempSync(join(tmpdir(), "chant-carve-status-"));
27
+ try {
28
+ return fn(dir);
29
+ } finally {
30
+ rmSync(dir, { recursive: true, force: true });
31
+ }
32
+ }
33
+
34
+ describe("carve status", () => {
35
+ test("walks the tree at any depth and reports target, stage, and root-relative path", () => {
36
+ withDir((dir) => {
37
+ // Flat under a named output dir…
38
+ writeCarveManifest(join(dir, "carveout"), manifest("aws_s3_bucket.assets"));
39
+ // …and nested deeper than the two levels a walker would guess at.
40
+ const deep = { ...manifest("aws_sqs_queue.jobs"), tfType: "aws_sqs_queue" };
41
+ deep.emit = { source: "tfstate" as const, files: ["src/jobs.ts"], at: "2026-08-30T00:00:00Z" };
42
+ writeCarveManifest(join(dir, "stacks", "queues", "carveout"), deep);
43
+
44
+ const res = carveStatus({ from: dir });
45
+ expect(res.ok).toBe(true);
46
+ expect(res.from).toBe(dir);
47
+ expect(res.carves).toHaveLength(2);
48
+
49
+ const [flat, nested] = res.carves!;
50
+ expect(flat.path).toBe(join("carveout", "aws_s3_bucket-assets.carve.json"));
51
+ expect(flat.target).toBe("aws_s3_bucket.assets");
52
+ expect(flat.stage).toBe("planned"); // boundary only, no recorded step
53
+ expect(flat.at).toEqual({});
54
+
55
+ expect(nested.path).toBe(join("stacks", "queues", "carveout", "aws_sqs_queue-jobs.carve.json"));
56
+ expect(nested.target).toBe("aws_sqs_queue.jobs");
57
+ expect(nested.tfType).toBe("aws_sqs_queue");
58
+ expect(nested.stage).toBe("emitted");
59
+ expect(nested.at).toEqual({ emit: "2026-08-30T00:00:00Z" });
60
+ expect(nested.emittedFiles).toEqual(["src/jobs.ts"]);
61
+ });
62
+ });
63
+
64
+ test("stage is the highest recorded step: emitted < bridged < applied", () => {
65
+ withDir((dir) => {
66
+ const m = manifest("aws_s3_bucket.assets");
67
+ m.emit = { source: "tfstate", files: ["src/assets.ts"], at: "t1" };
68
+ m.bridge = { written: ["runbook.md"], appliedInPlace: false, at: "t2" };
69
+ m.apply = { marker: { stack: "assets", env: "prod" }, ownershipTags: {}, at: "t3" };
70
+ writeCarveManifest(dir, m);
71
+
72
+ const res = carveStatus({ from: dir });
73
+ expect(res.carves![0].stage).toBe("applied");
74
+ expect(res.carves![0].at).toEqual({ emit: "t1", bridge: "t2", apply: "t3" });
75
+ });
76
+ });
77
+
78
+ test("reports unreadable manifest files instead of failing or silently dropping them", () => {
79
+ withDir((dir) => {
80
+ writeCarveManifest(dir, manifest("aws_s3_bucket.assets"));
81
+ writeFileSync(join(dir, "bad.carve.json"), "not json");
82
+
83
+ const res = carveStatus({ from: dir });
84
+ expect(res.ok).toBe(true);
85
+ expect(res.carves).toHaveLength(1);
86
+ expect(res.unreadable).toEqual(["bad.carve.json"]);
87
+ expect(formatCarveStatus(res)).toContain("bad.carve.json");
88
+ });
89
+ });
90
+
91
+ test("skips node_modules and .git", () => {
92
+ withDir((dir) => {
93
+ mkdirSync(join(dir, "node_modules", "dep"), { recursive: true });
94
+ writeCarveManifest(join(dir, "node_modules", "dep"), manifest("aws_s3_bucket.vendored"));
95
+ const res = carveStatus({ from: dir });
96
+ expect(res.carves).toHaveLength(0);
97
+ expect(formatCarveStatus(res)).toContain("No carve manifests");
98
+ });
99
+ });
100
+
101
+ test("errors on a non-directory root", () => {
102
+ const res = carveStatus({ from: "/definitely/not/a/dir" });
103
+ expect(res.ok).toBe(false);
104
+ expect(res.error).toContain("Not a directory");
105
+ });
106
+
107
+ test("--json payload carries from, carves, and unreadable only when present", () => {
108
+ withDir((dir) => {
109
+ writeCarveManifest(dir, manifest("aws_s3_bucket.assets"));
110
+ const res = carveStatus({ from: dir });
111
+ const json = carveStatusJson(res);
112
+ expect(Object.keys(json)).toEqual(["from", "carves"]);
113
+ expect((json.carves as unknown[]).length).toBe(1);
114
+ });
115
+ });
116
+ });