@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.
- package/dist/audit/catalog.d.ts +4 -0
- package/dist/audit/catalog.d.ts.map +1 -1
- package/dist/audit/nginx.d.ts +53 -0
- package/dist/audit/nginx.d.ts.map +1 -0
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/carve-apply.d.ts.map +1 -1
- package/dist/cli/commands/carve-status.d.ts +56 -0
- package/dist/cli/commands/carve-status.d.ts.map +1 -0
- package/dist/cli/handlers/carve-status.d.ts +9 -0
- package/dist/cli/handlers/carve-status.d.ts.map +1 -0
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/components/auto-release.d.ts +3 -1
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +2 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +7 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/lexicon.d.ts +8 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/release-ledger.d.ts +55 -1
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/op/activity-contract.d.ts +15 -1
- package/dist/op/activity-contract.d.ts.map +1 -1
- package/dist/terraform/manifest.d.ts +31 -6
- package/dist/terraform/manifest.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audit/catalog.ts +35 -0
- package/src/audit/edge-init-safety.test.ts +36 -3
- package/src/audit/nginx.test.ts +180 -0
- package/src/audit/nginx.ts +379 -0
- package/src/audit/rules-doc.ts +1 -0
- package/src/cli/commands/audit.ts +8 -3
- package/src/cli/commands/carve-apply.test.ts +4 -1
- package/src/cli/commands/carve-apply.ts +4 -2
- package/src/cli/commands/carve-bridge.test.ts +2 -1
- package/src/cli/commands/carve-emit-state.test.ts +3 -1
- package/src/cli/commands/carve-status.test.ts +116 -0
- package/src/cli/commands/carve-status.ts +163 -0
- package/src/cli/handlers/carve-status.ts +30 -0
- package/src/cli/handlers/components.ts +5 -1
- package/src/cli/handlers/graph.ts +3 -0
- package/src/cli/handlers/run.ts +4 -1
- package/src/cli/main.ts +8 -0
- package/src/components/auto-release.ts +4 -1
- package/src/components/cli-support.ts +4 -2
- package/src/graph-ir.ts +7 -0
- package/src/lexicon.ts +8 -0
- package/src/lifecycle/release-ledger.test.ts +64 -0
- package/src/lifecycle/release-ledger.ts +79 -1
- package/src/op/activity-contract.ts +14 -0
- package/src/terraform/manifest.test.ts +52 -0
- 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
|
+
}
|
package/src/audit/rules-doc.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|