@intentius/chant 0.54.0 → 0.54.2
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/discover.d.ts +9 -0
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/secrets.d.ts +2 -5
- package/dist/audit/secrets.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audit/discover.ts +11 -0
- package/src/audit/secrets.test.ts +15 -0
- package/src/audit/secrets.ts +33 -0
- package/src/cli/commands/audit.test.ts +35 -0
package/dist/audit/discover.d.ts
CHANGED
|
@@ -63,6 +63,15 @@ export declare function ciLexiconForPath(path: string): AuditLexicon | undefined
|
|
|
63
63
|
/**
|
|
64
64
|
* Whether a path is worth reading/fetching at all. Bounds the local walk and,
|
|
65
65
|
* crucially, the remote fetch (so we never pull a whole repo's contents).
|
|
66
|
+
*
|
|
67
|
+
* nginx configs (#1979) ride along the way `wrangler.toml` does: the NGX
|
|
68
|
+
* scanner (`./nginx.ts`) reads them directly, no lexicon plugin — but a
|
|
69
|
+
* scanner can only see what this filter admits, so its own path detector is
|
|
70
|
+
* part of the candidate set. (Found the hard way: the checks shipped wired
|
|
71
|
+
* into the CLI while no `.conf` ever survived collection, so they were
|
|
72
|
+
* unreachable on both the local and hosted paths.) Content confirmation —
|
|
73
|
+
* a `conf.d/` file that isn't nginx — happens at parse time in the scanner,
|
|
74
|
+
* never here.
|
|
66
75
|
*/
|
|
67
76
|
export declare function isCandidatePath(path: string): boolean;
|
|
68
77
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"discover.d.ts","sourceRoot":"","sources":["../../src/audit/discover.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAKH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAChD,OAAO,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;
|
|
1
|
+
{"version":3,"file":"discover.d.ts","sourceRoot":"","sources":["../../src/audit/discover.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAKH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAChD,OAAO,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAGvD,mEAAmE;AACnE,eAAO,MAAM,cAAc,sGAAuG,CAAC;AAEnI;;;;;;GAMG;AACH,wBAAsB,gBAAgB,CAAC,KAAK,GAAE,SAAS,MAAM,EAAmB,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC,CAe1G;AAgLD,wGAAwG;AACxG,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,cAAc,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,OAAO,CAAC;CAC7C;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAKvE;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CASrD;AAED;;;;;;;;GAQG;AACH,MAAM,MAAM,WAAW,GAAG,YAAY,GAAG,WAAW,CAAC;AAErD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,WAAW,GAAG,SAAS,CAiBzF;AAED,yFAAyF;AACzF,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,WAAW,CAAC;CACtB;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,QAAQ,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,EAAE,OAAO,EAAE,YAAY,EAAE,GAAG,aAAa,EAAE,CAehH;AA0BD;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,QAAQ,EAAE,EAAE,OAAO,EAAE,YAAY,EAAE,GAAG,UAAU,EAAE,CAiCtF;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,EAAE,GAAG,UAAU,EAAE,CAEvF;AAED,4GAA4G;AAC5G,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,EAAE,CAiB1D"}
|
package/dist/audit/secrets.d.ts
CHANGED
|
@@ -86,10 +86,7 @@ export declare const ENTROPY_RULE_ID = "SEC010";
|
|
|
86
86
|
* trigger it.
|
|
87
87
|
*/
|
|
88
88
|
export declare function looksLikeIdentifierSlug(value: string): boolean;
|
|
89
|
-
/**
|
|
90
|
-
|
|
91
|
-
* the filesystem and network. Findings never carry the matched value —
|
|
92
|
-
* only its rule id, kind, file/line, and a redaction-safe fingerprint.
|
|
93
|
-
*/
|
|
89
|
+
/** Is this a dependency lockfile the secrets scan should skip? */
|
|
90
|
+
export declare function isLockfilePath(path: string): boolean;
|
|
94
91
|
export declare function scanForSecrets(files: ScannableFile[], opts?: SecretsScanOptions): AuditFinding[];
|
|
95
92
|
//# sourceMappingURL=secrets.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"secrets.d.ts","sourceRoot":"","sources":["../../src/audit/secrets.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAG3C,qFAAqF;AACrF,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,eAAe;IAC9B,gEAAgE;IAChE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,0EAA0E;IAC1E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sEAAsE;IACtE,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,kBAAkB;IACjC,0GAA0G;IAC1G,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,kFAAkF;IAClF,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,yFAAyF;IACzF,KAAK,CAAC,EAAE,eAAe,EAAE,CAAC;CAC3B;AAOD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAYvD;AAkDD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,kBAAkB,CAuBtE;AA2GD,2FAA2F;AAC3F,eAAO,MAAM,eAAe,WAAW,CAAC;AAIxC;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAK9D;
|
|
1
|
+
{"version":3,"file":"secrets.d.ts","sourceRoot":"","sources":["../../src/audit/secrets.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAG3C,qFAAqF;AACrF,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,eAAe;IAC9B,gEAAgE;IAChE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,0EAA0E;IAC1E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sEAAsE;IACtE,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,kBAAkB;IACjC,0GAA0G;IAC1G,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,kFAAkF;IAClF,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,yFAAyF;IACzF,KAAK,CAAC,EAAE,eAAe,EAAE,CAAC;CAC3B;AAOD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAYvD;AAkDD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,kBAAkB,CAuBtE;AA2GD,2FAA2F;AAC3F,eAAO,MAAM,eAAe,WAAW,CAAC;AAIxC;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAK9D;AAqED,kEAAkE;AAClE,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAGpD;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,aAAa,EAAE,EAAE,IAAI,GAAE,kBAAuB,GAAG,YAAY,EAAE,CAmEpG"}
|
package/package.json
CHANGED
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
|
|
|
@@ -300,4 +300,19 @@ describe("scanForSecrets — file handling", () => {
|
|
|
300
300
|
expect(findings[0].severity).toBe("error");
|
|
301
301
|
expect(findings[0].lexicon).toBe("secrets");
|
|
302
302
|
});
|
|
303
|
+
|
|
304
|
+
test("dependency lockfiles are skipped entirely — integrity hashes are not secrets", () => {
|
|
305
|
+
// The exact false-positive class that failed the first dogfooding repo:
|
|
306
|
+
// one lockfile's sha512 integrity fields read as hundreds of SEC010s.
|
|
307
|
+
const integrity = `"integrity": "sha512-${"a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8s9T0u1V2w3X4y5Z6".repeat(2)}"`;
|
|
308
|
+
for (const path of ["package-lock.json", "web/package-lock.json", "yarn.lock", "pnpm-lock.yaml", "Cargo.lock", "go.sum"]) {
|
|
309
|
+
expect(scanForSecrets([{ path, content: `${integrity}\n` }]), path).toEqual([]);
|
|
310
|
+
}
|
|
311
|
+
// Even a real credential shape stays silent there — the file is skipped,
|
|
312
|
+
// not entropy-tuned; a credential does not live in a machine-written
|
|
313
|
+
// lockfile, and per-file skipping is what keeps the rule explainable.
|
|
314
|
+
expect(scanForSecrets([{ path: "package-lock.json", content: `${FAKE_AWS_KEY}\n` }])).toEqual([]);
|
|
315
|
+
// A non-lockfile with the same content still fires.
|
|
316
|
+
expect(scanForSecrets([{ path: "config.json", content: `${integrity}\n` }]).length).toBeGreaterThan(0);
|
|
317
|
+
});
|
|
303
318
|
});
|
package/src/audit/secrets.ts
CHANGED
|
@@ -337,6 +337,38 @@ function lineNumberFor(starts: number[], index: number): number {
|
|
|
337
337
|
* the filesystem and network. Findings never carry the matched value —
|
|
338
338
|
* only its rule id, kind, file/line, and a redaction-safe fingerprint.
|
|
339
339
|
*/
|
|
340
|
+
/**
|
|
341
|
+
* Machine-generated dependency lockfiles. Skipped entirely: they are wall-to-
|
|
342
|
+
* wall content-integrity hashes (`sha512-…` and friends), which the entropy
|
|
343
|
+
* heuristic reads as hundreds of "possible secrets" per file — a single
|
|
344
|
+
* `package-lock.json` produced 438 merge-worthy SEC010 findings on the first
|
|
345
|
+
* repo that dogfooded this at `--fail-on merge-worthy`. Integrity hashes are
|
|
346
|
+
* public by construction, a credential does not live in a lockfile, and no
|
|
347
|
+
* other scanner reads them.
|
|
348
|
+
*/
|
|
349
|
+
const LOCKFILE_NAMES = new Set([
|
|
350
|
+
"package-lock.json",
|
|
351
|
+
"npm-shrinkwrap.json",
|
|
352
|
+
"yarn.lock",
|
|
353
|
+
"pnpm-lock.yaml",
|
|
354
|
+
"bun.lock",
|
|
355
|
+
"deno.lock",
|
|
356
|
+
"Cargo.lock",
|
|
357
|
+
"poetry.lock",
|
|
358
|
+
"uv.lock",
|
|
359
|
+
"Pipfile.lock",
|
|
360
|
+
"composer.lock",
|
|
361
|
+
"Gemfile.lock",
|
|
362
|
+
"go.sum",
|
|
363
|
+
"flake.lock",
|
|
364
|
+
]);
|
|
365
|
+
|
|
366
|
+
/** Is this a dependency lockfile the secrets scan should skip? */
|
|
367
|
+
export function isLockfilePath(path: string): boolean {
|
|
368
|
+
const name = path.split("/").pop() ?? path;
|
|
369
|
+
return LOCKFILE_NAMES.has(name);
|
|
370
|
+
}
|
|
371
|
+
|
|
340
372
|
export function scanForSecrets(files: ScannableFile[], opts: SecretsScanOptions = {}): AuditFinding[] {
|
|
341
373
|
const entropyThreshold = opts.entropyThreshold ?? DEFAULT_ENTROPY_THRESHOLD;
|
|
342
374
|
const entropyMinLength = opts.entropyMinLength ?? DEFAULT_ENTROPY_MIN_LENGTH;
|
|
@@ -347,6 +379,7 @@ export function scanForSecrets(files: ScannableFile[], opts: SecretsScanOptions
|
|
|
347
379
|
|
|
348
380
|
for (const file of files) {
|
|
349
381
|
if (!file.content) continue;
|
|
382
|
+
if (isLockfilePath(file.path)) continue;
|
|
350
383
|
const lines = file.content.split("\n");
|
|
351
384
|
const starts = lineStarts(file.content);
|
|
352
385
|
const claimed: Array<[number, number]> = [];
|
|
@@ -614,3 +614,38 @@ describe("Wrangler config audit (#446)", () => {
|
|
|
614
614
|
rmSync(dir, { recursive: true, force: true });
|
|
615
615
|
});
|
|
616
616
|
});
|
|
617
|
+
|
|
618
|
+
describe("nginx config audit (#1979)", () => {
|
|
619
|
+
function tmpRepo(): string {
|
|
620
|
+
const dir = join(tmpdir(), `chant-audit-nginx-${process.pid}-${Math.random().toString(36).slice(2)}`);
|
|
621
|
+
mkdirSync(dir, { recursive: true });
|
|
622
|
+
return dir;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
test("audits an nginx config end to end, even when no lexicon is installed", async () => {
|
|
626
|
+
// The whole-path proof the family shipped without: an nginx file must
|
|
627
|
+
// survive candidate COLLECTION (isCandidatePath) before the NGX scanner
|
|
628
|
+
// can ever see it — a scanner-level unit test cannot catch a filter that
|
|
629
|
+
// drops the file first.
|
|
630
|
+
const dir = tmpRepo();
|
|
631
|
+
mkdirSync(join(dir, "nginx"), { recursive: true });
|
|
632
|
+
writeFileSync(
|
|
633
|
+
join(dir, "nginx", "default.conf"),
|
|
634
|
+
"server {\n listen 80;\n location /files {\n autoindex on;\n }\n}\n",
|
|
635
|
+
);
|
|
636
|
+
const result = await auditCommand({ path: dir, plugins: [], format: "json" });
|
|
637
|
+
expect(result.status).toBe("no-lexicons"); // no lexicon installed — still not a clean miss
|
|
638
|
+
expect(result.findings.map((f) => f.checkId)).toContain("NGX003");
|
|
639
|
+
rmSync(dir, { recursive: true, force: true });
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
test("a systemd-style conf.d drop-in is collected but contributes nothing (content-confirmed)", async () => {
|
|
643
|
+
const dir = tmpRepo();
|
|
644
|
+
mkdirSync(join(dir, "conf.d"), { recursive: true });
|
|
645
|
+
writeFileSync(join(dir, "conf.d", "override.conf"), "[Service]\nRestart=always\n");
|
|
646
|
+
const result = await auditCommand({ path: dir, plugins: [] });
|
|
647
|
+
expect(result.success).toBe(true);
|
|
648
|
+
expect(result.findings.map((f) => f.checkId)).not.toEqual(expect.arrayContaining([expect.stringMatching(/^NGX/)]));
|
|
649
|
+
rmSync(dir, { recursive: true, force: true });
|
|
650
|
+
});
|
|
651
|
+
});
|