@intentius/chant 0.53.1 → 0.54.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audit/catalog.d.ts +4 -0
- package/dist/audit/catalog.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +9 -0
- package/dist/audit/discover.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/discover.ts +11 -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.test.ts +35 -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
|
@@ -10,8 +10,16 @@
|
|
|
10
10
|
import type { CarveReport } from "./carve.js";
|
|
11
11
|
import type { OwnershipMarker } from "../ownership.js";
|
|
12
12
|
export declare const CARVE_MANIFEST_SUFFIX = ".carve.json";
|
|
13
|
+
/**
|
|
14
|
+
* Version 2 (#2039): `emit.files`, `bridge.written`, `bridge.patch` and
|
|
15
|
+
* `apply.stampedFiles` are recorded relative to the manifest's own directory,
|
|
16
|
+
* so the manifest stays true when the tree is copied, moved, or checked out
|
|
17
|
+
* elsewhere. Version-1 manifests recorded them absolute; readers resolve
|
|
18
|
+
* either through `resolveManifestFilePath`.
|
|
19
|
+
*/
|
|
20
|
+
export declare const CARVE_MANIFEST_VERSION = 2;
|
|
13
21
|
export interface CarveManifest {
|
|
14
|
-
version: 1;
|
|
22
|
+
version: 1 | 2;
|
|
15
23
|
/** Terraform address of the carved resource, e.g. `aws_s3_bucket.assets`. */
|
|
16
24
|
target: string;
|
|
17
25
|
/** Terraform resource type, when known. */
|
|
@@ -25,7 +33,13 @@ export interface CarveManifest {
|
|
|
25
33
|
/** Recorded by `carve emit`. */
|
|
26
34
|
emit?: {
|
|
27
35
|
source: "tfstate" | "live";
|
|
28
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Emitted chant source file path(s), relative to the manifest's directory
|
|
38
|
+
* (absolute in version-1 manifests). The manifest sits at the scaffolded
|
|
39
|
+
* project's root, so these are also project-root-relative — the same base
|
|
40
|
+
* `chant graph --format ir` uses for `sourceLoc.file`, which is the join
|
|
41
|
+
* from a carved Terraform address to the chant entity it became (#2040).
|
|
42
|
+
*/
|
|
29
43
|
files: string[];
|
|
30
44
|
/**
|
|
31
45
|
* Deferred outbound inputs declared as build parameters in the emitted
|
|
@@ -45,9 +59,10 @@ export interface CarveManifest {
|
|
|
45
59
|
};
|
|
46
60
|
/** Recorded by `carve bridge`. */
|
|
47
61
|
bridge?: {
|
|
62
|
+
/** Files written, relative to the manifest's directory (absolute in version-1 manifests). */
|
|
48
63
|
written: string[];
|
|
49
64
|
appliedInPlace: boolean;
|
|
50
|
-
/** The git-applyable `.patch` carrying the whole survivor edit. */
|
|
65
|
+
/** The git-applyable `.patch` carrying the whole survivor edit, relative to the manifest's directory (absolute in version-1 manifests). */
|
|
51
66
|
patch?: string;
|
|
52
67
|
/** Carved addresses whose own `.tf` block the rewrites remove (#998). */
|
|
53
68
|
excised?: string[];
|
|
@@ -57,7 +72,7 @@ export interface CarveManifest {
|
|
|
57
72
|
apply?: {
|
|
58
73
|
marker: OwnershipMarker;
|
|
59
74
|
ownershipTags: Record<string, string>;
|
|
60
|
-
/** Emitted files the marker was stamped into (`--write-source`). */
|
|
75
|
+
/** Emitted files the marker was stamped into (`--write-source`), relative to the manifest's directory (absolute in version-1 manifests). */
|
|
61
76
|
stampedFiles?: string[];
|
|
62
77
|
at: string;
|
|
63
78
|
};
|
|
@@ -66,9 +81,15 @@ export interface CarveManifest {
|
|
|
66
81
|
export declare function carveSlug(target: string): string;
|
|
67
82
|
/** Where the manifest for `target` lives inside an output dir. */
|
|
68
83
|
export declare function carveManifestPath(outDir: string, target: string): string;
|
|
84
|
+
/**
|
|
85
|
+
* Resolve a manifest-recorded file path against the manifest's directory.
|
|
86
|
+
* Absolute paths (version-1 manifests) pass through untouched, so both
|
|
87
|
+
* on-disk spellings read the same.
|
|
88
|
+
*/
|
|
89
|
+
export declare function resolveManifestFilePath(manifestDir: string, p: string): string;
|
|
69
90
|
/** Write (or overwrite) a manifest into the output dir. Returns the path. */
|
|
70
91
|
export declare function writeCarveManifest(outDir: string, manifest: CarveManifest): string;
|
|
71
|
-
/** Read a manifest, or null when absent/unreadable/not a
|
|
92
|
+
/** Read a manifest, or null when absent/unreadable/not a known-version manifest. */
|
|
72
93
|
export declare function readCarveManifest(path: string): CarveManifest | null;
|
|
73
94
|
/** All carve manifest paths in an output dir. */
|
|
74
95
|
export declare function listCarveManifests(outDir: string): string[];
|
|
@@ -84,6 +105,10 @@ export interface ResolvedManifest {
|
|
|
84
105
|
* several is an error naming what is (or is not) there.
|
|
85
106
|
*/
|
|
86
107
|
export declare function resolveCarveManifest(outDir: string, select?: string): ResolvedManifest;
|
|
87
|
-
/**
|
|
108
|
+
/**
|
|
109
|
+
* Read-modify-write a section of an existing manifest. No-op when absent.
|
|
110
|
+
* The whole manifest is re-normalized on write, so updating a version-1
|
|
111
|
+
* manifest also migrates its recorded paths to the relative contract.
|
|
112
|
+
*/
|
|
88
113
|
export declare function updateCarveManifest(outDir: string, target: string, patch: Partial<Pick<CarveManifest, "boundary" | "emit" | "bridge" | "apply">>): string | undefined;
|
|
89
114
|
//# sourceMappingURL=manifest.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../../src/terraform/manifest.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAC3C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAEpD,eAAO,MAAM,qBAAqB,gBAAgB,CAAC;AAEnD,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../../src/terraform/manifest.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
package/src/audit/catalog.ts
CHANGED
|
@@ -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",
|
package/src/audit/discover.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { basename, join, relative } from "path";
|
|
|
33
33
|
import { parseYAML } from "../yaml";
|
|
34
34
|
import type { LexiconPlugin } from "../lexicon";
|
|
35
35
|
import type { AuditInput, AuditLexicon } from "./core";
|
|
36
|
+
import { isNginxConfigPath } from "./nginx";
|
|
36
37
|
|
|
37
38
|
/** Lexicons the auditor knows how to detect and run checks for. */
|
|
38
39
|
export const AUDIT_LEXICONS = ["github", "gitlab", "forgejo", "k8s", "docker", "aws", "azure", "gcp", "helm", "fountain"] as const;
|
|
@@ -267,6 +268,15 @@ export function ciLexiconForPath(path: string): AuditLexicon | undefined {
|
|
|
267
268
|
/**
|
|
268
269
|
* Whether a path is worth reading/fetching at all. Bounds the local walk and,
|
|
269
270
|
* crucially, the remote fetch (so we never pull a whole repo's contents).
|
|
271
|
+
*
|
|
272
|
+
* nginx configs (#1979) ride along the way `wrangler.toml` does: the NGX
|
|
273
|
+
* scanner (`./nginx.ts`) reads them directly, no lexicon plugin — but a
|
|
274
|
+
* scanner can only see what this filter admits, so its own path detector is
|
|
275
|
+
* part of the candidate set. (Found the hard way: the checks shipped wired
|
|
276
|
+
* into the CLI while no `.conf` ever survived collection, so they were
|
|
277
|
+
* unreachable on both the local and hosted paths.) Content confirmation —
|
|
278
|
+
* a `conf.d/` file that isn't nginx — happens at parse time in the scanner,
|
|
279
|
+
* never here.
|
|
270
280
|
*/
|
|
271
281
|
export function isCandidatePath(path: string): boolean {
|
|
272
282
|
const name = basename(path);
|
|
@@ -275,6 +285,7 @@ export function isCandidatePath(path: string): boolean {
|
|
|
275
285
|
if (name === "Chart.yaml") return true;
|
|
276
286
|
if (isSecretBearingName(name)) return true;
|
|
277
287
|
if (isWranglerConfigName(name)) return true;
|
|
288
|
+
if (isNginxConfigPath(path)) return true;
|
|
278
289
|
return /\.(ya?ml|json|template)$/i.test(name);
|
|
279
290
|
}
|
|
280
291
|
|
|
@@ -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)
|
|
58
|
-
function
|
|
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 (
|
|
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
|
+
});
|