@blamejs/exceptd-skills 0.19.32 → 0.19.34
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/CHANGELOG.md +22 -0
- package/bin/exceptd.js +896 -2824
- package/data/_indexes/_meta.json +8 -8
- package/data/_indexes/activity-feed.json +2 -2
- package/data/_indexes/catalog-summaries.json +7 -7
- package/data/_indexes/chains.json +60118 -0
- package/data/attack-techniques.json +267 -7
- package/data/cve-catalog.json +9991 -3
- package/data/cwe-catalog.json +109 -2
- package/data/framework-control-gaps.json +578 -3
- package/data/zeroday-lessons.json +8330 -1
- package/lib/auto-discovery.js +56 -286
- package/lib/canonical-eq.js +7 -40
- package/lib/citation-resolve.js +22 -70
- package/lib/collectors/ai-api.js +20 -54
- package/lib/collectors/cicd-pipeline-compromise.js +40 -108
- package/lib/collectors/citation-hygiene.js +72 -210
- package/lib/collectors/containers.js +41 -130
- package/lib/collectors/cred-stores.js +31 -115
- package/lib/collectors/crypto-codebase.js +55 -138
- package/lib/collectors/crypto.js +24 -54
- package/lib/collectors/hardening.js +20 -78
- package/lib/collectors/kernel.js +16 -46
- package/lib/collectors/library-author.js +57 -206
- package/lib/collectors/mcp.js +24 -70
- package/lib/collectors/runtime.js +24 -86
- package/lib/collectors/sbom.js +34 -106
- package/lib/collectors/scan-excludes.js +31 -138
- package/lib/collectors/secrets.js +62 -178
- package/lib/cross-ref-api.js +39 -123
- package/lib/currency-severity.js +8 -27
- package/lib/cve-batch.js +13 -21
- package/lib/cve-cli.js +13 -20
- package/lib/cve-curation.js +72 -239
- package/lib/cve-regression-watcher.js +29 -152
- package/lib/cvss.js +13 -54
- package/lib/doctor-bucketing.js +3 -19
- package/lib/exit-codes.js +10 -42
- package/lib/flag-suggest.js +7 -25
- package/lib/framework-gap.js +35 -114
- package/lib/gap-detectors.js +37 -159
- package/lib/id-validation.js +9 -30
- package/lib/job-queue.js +13 -36
- package/lib/lint-skills.js +64 -232
- package/lib/playbook-runner.js +693 -2095
- package/lib/prefetch.js +100 -376
- package/lib/refresh-external.js +199 -627
- package/lib/refresh-network.js +75 -307
- package/lib/rfc-cli.js +23 -68
- package/lib/scoring.js +77 -145
- package/lib/sign.js +43 -229
- package/lib/source-advisories.js +43 -194
- package/lib/source-ghsa.js +37 -120
- package/lib/source-osv.js +94 -266
- package/lib/ttp-mapper.js +14 -24
- package/lib/upstream-check-cli.js +10 -28
- package/lib/upstream-check.js +19 -44
- package/lib/validate-catalog-meta.js +17 -61
- package/lib/validate-cve-catalog.js +43 -119
- package/lib/validate-indexes.js +25 -76
- package/lib/validate-package.js +16 -62
- package/lib/validate-playbooks.js +69 -275
- package/lib/validate-vendor.js +16 -49
- package/lib/verify.js +56 -286
- package/lib/version-pins.js +5 -34
- package/lib/worker-pool.js +11 -30
- package/lib/xml-tokenizer.js +47 -152
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +17 -68
- package/orchestrator/event-bus.js +11 -74
- package/orchestrator/index.js +138 -412
- package/orchestrator/pipeline.js +28 -85
- package/orchestrator/scanner.js +34 -138
- package/orchestrator/scheduler.js +20 -84
- package/package.json +2 -2
- package/sbom.cdx.json +253 -253
- package/scripts/audit-catalog-gaps.js +9 -62
- package/scripts/audit-cross-skill.js +5 -31
- package/scripts/audit-perf.js +6 -16
- package/scripts/backfill-theater-test.js +7 -64
- package/scripts/bootstrap.js +12 -44
- package/scripts/build-indexes.js +40 -154
- package/scripts/builders/activity-feed.js +4 -14
- package/scripts/builders/catalog-summaries.js +3 -10
- package/scripts/builders/currency.js +7 -20
- package/scripts/builders/cwe-chains.js +7 -30
- package/scripts/builders/did-ladders.js +6 -13
- package/scripts/builders/frequency.js +5 -19
- package/scripts/builders/jurisdiction-clocks.js +6 -25
- package/scripts/builders/recipes.js +6 -14
- package/scripts/builders/section-offsets.js +13 -51
- package/scripts/builders/stale-content.js +7 -28
- package/scripts/builders/summary-cards.js +8 -29
- package/scripts/builders/theater-fingerprints.js +12 -27
- package/scripts/builders/token-budget.js +4 -31
- package/scripts/check-agents-md-collectors.js +11 -54
- package/scripts/check-catalog-gap-budget.js +15 -32
- package/scripts/check-changelog-extract.js +18 -48
- package/scripts/check-codebase-patterns-currency.js +6 -22
- package/scripts/check-codebase-patterns.js +50 -143
- package/scripts/check-epss-consistency.js +9 -64
- package/scripts/check-framework-gap-coverage.js +13 -31
- package/scripts/check-manifest-snapshot.js +13 -73
- package/scripts/check-sbom-currency.js +44 -142
- package/scripts/check-test-count.js +15 -52
- package/scripts/check-test-coverage.js +66 -197
- package/scripts/check-test-subjects.js +21 -62
- package/scripts/check-ttp-references.js +14 -38
- package/scripts/check-ttp-upstream.js +8 -40
- package/scripts/check-version-bump.js +9 -61
- package/scripts/check-version-tags.js +20 -121
- package/scripts/predeploy.js +38 -184
- package/scripts/refresh-manifest-snapshot.js +16 -38
- package/scripts/refresh-mitre-atlas.js +3 -8
- package/scripts/refresh-mitre-attack.js +1 -8
- package/scripts/refresh-mitre-d3fend.js +3 -9
- package/scripts/refresh-mitre-ics-attack.js +3 -8
- package/scripts/refresh-reverse-refs.js +27 -94
- package/scripts/refresh-rfc-index.js +2 -10
- package/scripts/refresh-sbom.js +31 -161
- package/scripts/refresh-upstream-catalogs.js +40 -137
- package/scripts/release.js +69 -232
- package/scripts/run-e2e-scenarios.js +24 -71
- package/scripts/sync-manifest-metadata.js +10 -34
- package/scripts/sync-package-description.js +8 -17
- package/scripts/validate-vendor-online.js +13 -44
- package/scripts/verify-shipped-tarball.js +35 -140
|
@@ -1,23 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Predeploy gate: runs the gap detectors and asserts no class exceeds its
|
|
5
|
+
* budget. Exit 0 within budget, 1 regressed, 2 internal error.
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* when the broader test suite is skipped (or is the gate that's
|
|
11
|
-
* failing for an unrelated reason).
|
|
12
|
-
*
|
|
13
|
-
* Exit codes:
|
|
14
|
-
* 0 — every extended class within budget
|
|
15
|
-
* 1 — at least one class regressed
|
|
16
|
-
* 2 — internal error
|
|
17
|
-
*
|
|
18
|
-
* The budget is intentionally duplicated (here + integrity test) for
|
|
19
|
-
* fail-loud-at-two-levels. Operators see the regression in BOTH the
|
|
20
|
-
* test-suite output AND the predeploy gate-summary table.
|
|
7
|
+
* The budget is duplicated in tests/shipped-catalog-integrity.test.js so a
|
|
8
|
+
* regression shows in both the test output and the gate-summary table, and so
|
|
9
|
+
* the gate still reports when the suite is skipped or failing elsewhere. Both
|
|
10
|
+
* copies move together.
|
|
21
11
|
*/
|
|
22
12
|
|
|
23
13
|
const path = require("path");
|
|
@@ -47,16 +37,13 @@ function loadAll() {
|
|
|
47
37
|
};
|
|
48
38
|
}
|
|
49
39
|
|
|
50
|
-
// Per-class regression budgets
|
|
51
|
-
//
|
|
40
|
+
// Per-class regression budgets, mirrored in
|
|
41
|
+
// tests/shipped-catalog-integrity.test.js.
|
|
52
42
|
const BUDGET = {
|
|
53
43
|
"content-quality": 12,
|
|
54
|
-
// temporal-staleness
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
// operator-remediation date, not catalog freshness, and grew without bound as
|
|
58
|
-
// the catalog aged and KEV drafts got curated. Actual is 0 with fresh data;
|
|
59
|
-
// 10 leaves headroom for entries aging past a threshold before a refresh.
|
|
44
|
+
// temporal-staleness counts only the maintainer-controllable freshness fields,
|
|
45
|
+
// which sit at 0 on fresh data; the headroom covers entries aging past a
|
|
46
|
+
// threshold between refreshes.
|
|
60
47
|
"temporal-staleness": 10,
|
|
61
48
|
"logical-consistency": 5,
|
|
62
49
|
"cross-ref-completeness": 5,
|
|
@@ -71,20 +58,16 @@ function main() {
|
|
|
71
58
|
for (const f of all) byClass[f.class] = (byClass[f.class] || 0) + 1;
|
|
72
59
|
const regressions = [];
|
|
73
60
|
|
|
74
|
-
// Fail-closed
|
|
75
|
-
//
|
|
76
|
-
// 8th detector lands without a budget update, the gate fires with
|
|
77
|
-
// an unbudgeted-class error instead of silently passing.
|
|
61
|
+
// Fail-closed: a class the detectors emit without a budget entry fires an
|
|
62
|
+
// unbudgeted-class error rather than passing unmeasured.
|
|
78
63
|
const unbudgeted = [];
|
|
79
64
|
for (const cls of Object.keys(byClass)) {
|
|
80
65
|
if (!(cls in BUDGET)) {
|
|
81
66
|
unbudgeted.push({ class: cls, count: byClass[cls] });
|
|
82
67
|
}
|
|
83
68
|
}
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
// a new class produces zero findings on this run but still needs
|
|
87
|
-
// an explicit budget so a future regression caps fail-closed).
|
|
69
|
+
// The inverse: a class declared in DETECTOR_CLASSES needs a budget even when
|
|
70
|
+
// it emits nothing on this run, or the first regression has no cap to hit.
|
|
88
71
|
const missingBudget = [];
|
|
89
72
|
if (Array.isArray(D.DETECTOR_CLASSES)) {
|
|
90
73
|
for (const cls of D.DETECTOR_CLASSES) {
|
|
@@ -2,34 +2,14 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Release-notes extraction + quality gate.
|
|
5
|
+
* Release-notes extraction + quality gate. .github/workflows/release.yml publishes
|
|
6
|
+
* the GitHub Release body by awk-extracting the `## <version> …` CHANGELOG section,
|
|
7
|
+
* falling back to "Release of v<version>." when the extract is empty. This runs the
|
|
8
|
+
* SAME extraction before tag-push and lints what it finds, so a malformed or
|
|
9
|
+
* internal-narrative section fails here rather than shipping as the public body.
|
|
6
10
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* between the version heading and the next `## ` heading, falling back to a
|
|
10
|
-
* generic "Release of v<version>." line if the extract is empty. This gate
|
|
11
|
-
* runs that SAME extraction locally before tag-push, and additionally lints
|
|
12
|
-
* the extracted notes for operator-facing quality, so a malformed or
|
|
13
|
-
* internal-narrative-laced section fails here rather than shipping as the
|
|
14
|
-
* public release body.
|
|
15
|
-
*
|
|
16
|
-
* Two layers:
|
|
17
|
-
* 1. EXTRACT — the `## <version> — <date>` section exists, is non-empty
|
|
18
|
-
* (won't trigger the workflow's "Release of v…" fallback),
|
|
19
|
-
* the heading version matches package.json, and the heading
|
|
20
|
-
* carries an ISO date.
|
|
21
|
-
* 2. LINT — the extracted body is operator-facing-clean: no internal
|
|
22
|
-
* phase/pass/slice/sweep narrative, no agent-dispatch /
|
|
23
|
-
* conversation residue, no tautological "all tests pass"
|
|
24
|
-
* noise. (Mirrors the operator-facing discipline; the release
|
|
25
|
-
* body is the most public surface there is.)
|
|
26
|
-
*
|
|
27
|
-
* Exit: process.exitCode 0 on pass, 1 on any failure. Functions are exported
|
|
28
|
-
* for fixture-based testing (no subprocess needed).
|
|
29
|
-
*
|
|
30
|
-
* Usage:
|
|
31
|
-
* node scripts/check-changelog-extract.js # uses package.json version
|
|
32
|
-
* node scripts/check-changelog-extract.js <version> # explicit MAJOR.MINOR.PATCH
|
|
11
|
+
* process.exitCode 0 on pass, 1 on any failure. Takes an optional
|
|
12
|
+
* MAJOR.MINOR.PATCH argument, defaulting to the package.json version.
|
|
33
13
|
*/
|
|
34
14
|
|
|
35
15
|
const fs = require('node:fs');
|
|
@@ -39,10 +19,9 @@ const ROOT = path.resolve(__dirname, '..');
|
|
|
39
19
|
const CHANGELOG = path.join(ROOT, 'CHANGELOG.md');
|
|
40
20
|
const PACKAGE_JSON = path.join(ROOT, 'package.json');
|
|
41
21
|
|
|
42
|
-
// Replicates the release.yml awk:
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
// heading can't accidentally match a longer one that shares its prefix.
|
|
22
|
+
// Replicates the release.yml awk: lines after the `## <version> ` heading up to
|
|
23
|
+
// the next `## `. The trailing space mirrors the workflow's `"^## " v " "`, so a
|
|
24
|
+
// shorter version heading cannot match a longer one sharing its prefix.
|
|
46
25
|
function extractSection(text, version) {
|
|
47
26
|
const lines = text.split(/\r?\n/);
|
|
48
27
|
const out = [];
|
|
@@ -56,8 +35,7 @@ function extractSection(text, version) {
|
|
|
56
35
|
}
|
|
57
36
|
if (startRe.test(ln)) capturing = true;
|
|
58
37
|
}
|
|
59
|
-
//
|
|
60
|
-
// either way, but trimming makes the non-empty test honest).
|
|
38
|
+
// awk keeps the blank lines; trimming them makes the non-empty test honest.
|
|
61
39
|
while (out.length && out[0].trim() === '') out.shift();
|
|
62
40
|
while (out.length && out[out.length - 1].trim() === '') out.pop();
|
|
63
41
|
return out;
|
|
@@ -69,10 +47,8 @@ function headingLine(text, version) {
|
|
|
69
47
|
return text.split(/\r?\n/).find((l) => re.test(l)) || null;
|
|
70
48
|
}
|
|
71
49
|
|
|
72
|
-
// Operator-facing forbidden patterns
|
|
73
|
-
//
|
|
74
|
-
// bare "phase" in "multi-phase attack" is fine; "Phase 9" is the tell). Each
|
|
75
|
-
// entry: { id, re, why }.
|
|
50
|
+
// Operator-facing forbidden patterns: high-confidence markers only, since a false
|
|
51
|
+
// positive blocks a release. "multi-phase attack" is prose; "Phase 9" is the tell.
|
|
76
52
|
const FORBIDDEN = [
|
|
77
53
|
{ id: 'phase-number', re: /\bphase\s+\d/i, why: 'internal phase number (operators have no roadmap)' },
|
|
78
54
|
{ id: 'pass-number', re: /\b(?:audit|curation|drift|fix|bug)?[- ]?pass\s+\d/i, why: 'internal pass/batch number' },
|
|
@@ -100,17 +76,11 @@ function readPackageVersion() {
|
|
|
100
76
|
return JSON.parse(fs.readFileSync(PACKAGE_JSON, 'utf8')).version;
|
|
101
77
|
}
|
|
102
78
|
|
|
103
|
-
// Every
|
|
104
|
-
//
|
|
105
|
-
// release
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
// version. Tags are the authoritative record of what was released.
|
|
109
|
-
// Tags whose release never published: the tag-push event was dropped (e.g.
|
|
110
|
-
// a GitHub Actions outage) and — because the v* ruleset forbids re-pushing a
|
|
111
|
-
// tag — the recovery is a version bump re-released with the same notes under
|
|
112
|
-
// the NEW heading. The orphan tag therefore legitimately has no CHANGELOG
|
|
113
|
-
// entry of its own. Tag exists, npm/GitHub Release do not.
|
|
79
|
+
// Every released version keeps its own `## <version> ` heading; tags are the
|
|
80
|
+
// authoritative record of which versions those are. An orphan is a tag whose
|
|
81
|
+
// release never published: the v* ruleset forbids re-pushing a tag, so the
|
|
82
|
+
// recovery was a version bump carrying the same notes under the NEW heading, and
|
|
83
|
+
// such a tag legitimately has no CHANGELOG entry of its own.
|
|
114
84
|
const ORPHAN_RELEASE_TAGS = new Set(['0.13.111', '0.15.25']);
|
|
115
85
|
|
|
116
86
|
function releasedVersionsFromTags() {
|
|
@@ -1,24 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Advisory drift detector between exceptd's adopted codebase-pattern classes
|
|
5
|
+
* and the sibling blamejs codebase-patterns test they derive from. A class
|
|
6
|
+
* present upstream but absent from UPSTREAM_TRIAGED is new and wants triage.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* already-owned by another exceptd gate, helper-dependent, or out of scope
|
|
11
|
-
* for a local-file-read security CLI. UPSTREAM_TRIAGED below records every
|
|
12
|
-
* class that triage covered. When upstream grows a NEW class not in that set,
|
|
13
|
-
* this check flags it so the maintainer can decide whether to adopt it — the
|
|
14
|
-
* same forcing function the GitHub-actions / vendored-bundle currency checks
|
|
15
|
-
* provide for those surfaces.
|
|
16
|
-
*
|
|
17
|
-
* Advisory by design: it never fails a release. It exits 0 and prints a
|
|
18
|
-
* NOTICE when upstream has drifted, and exits 0 silently when the sibling
|
|
19
|
-
* repo is not present (a fresh clone / CI runner without the sibling). Point
|
|
20
|
-
* it elsewhere with EXCEPTD_UPSTREAM_PATTERNS env var if the sibling lives at
|
|
21
|
-
* a non-default path.
|
|
8
|
+
* Always exits 0 — it prints a NOTICE on drift and exits silently when the
|
|
9
|
+
* sibling repo is absent. EXCEPTD_UPSTREAM_PATTERNS overrides its path.
|
|
22
10
|
*/
|
|
23
11
|
|
|
24
12
|
const fs = require("node:fs");
|
|
@@ -26,10 +14,7 @@ const path = require("node:path");
|
|
|
26
14
|
|
|
27
15
|
const ROOT = path.resolve(__dirname, "..");
|
|
28
16
|
|
|
29
|
-
//
|
|
30
|
-
// Every key here was classified (adopted / already-owned / helper-dependent /
|
|
31
|
-
// out-of-scope). A class appearing upstream but absent here is NEW and wants
|
|
32
|
-
// triage. Refresh this list (and re-triage the delta) when this check fires.
|
|
17
|
+
// Refresh this list, re-triaging the delta, when this check fires.
|
|
33
18
|
const UPSTREAM_TRIAGED = Object.freeze([ // keep-sorted
|
|
34
19
|
"ai-disclosure-on-request-without-requested-gate",
|
|
35
20
|
"archive-gz-without-safedecompress",
|
|
@@ -83,7 +68,6 @@ function upstreamPatternsPath() {
|
|
|
83
68
|
return path.resolve(ROOT, "..", "blamejs", "test", "layer-0-primitives", "codebase-patterns.test.js");
|
|
84
69
|
}
|
|
85
70
|
|
|
86
|
-
// Extract the allow-class keys from the upstream VALID_ALLOW_CLASSES literal.
|
|
87
71
|
function upstreamClasses(src) {
|
|
88
72
|
const m = src.match(/VALID_ALLOW_CLASSES\s*=\s*(?:Object\.freeze\()?\{([\s\S]*?)\}/);
|
|
89
73
|
if (!m) return null;
|
|
@@ -1,39 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* class as a single numbered report instead of dying on the first hit.
|
|
4
|
+
* Grep gate for code-shape bug classes that recur across releases; CLASSES
|
|
5
|
+
* below is the registry.
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
* - process-exit-after-stdout-write : a library-callable function writes to
|
|
10
|
-
* the result channel (process.stdout.write / console.log) and then calls
|
|
11
|
-
* process.exit(), which truncates the buffered write when stdout is
|
|
12
|
-
* piped. Route through `safeExit(EXIT_CODES.X); return;` (lib/exit-codes).
|
|
13
|
-
* This is the stdout-flush-truncation class the validate-cves fix closed by hand.
|
|
14
|
-
* - dynamic-regex : `new RegExp(<non-literal>)` — a ReDoS sink when the
|
|
15
|
-
* pattern derives from operator input. Use a static literal, or anchor +
|
|
16
|
-
* length-cap the input, or mark the site `// allow:dynamic-regex —
|
|
17
|
-
* <reason>` when the source is a trusted bundled schema.
|
|
18
|
-
* - orphan-allow-class : an `// allow:<class>` marker whose class is not in
|
|
19
|
-
* VALID_ALLOW_CLASSES, or is missing the `— <reason>` tail. A typo'd
|
|
20
|
-
* marker suppresses nothing, so the underlying violation would ship
|
|
21
|
-
* unflagged — this meta-guard keeps the marker mechanism trustworthy.
|
|
22
|
-
* - unsorted-marked-array : a flat string array tagged `// keep-sorted` that
|
|
23
|
-
* drifted out of alphabetical order. Opt-in — only marked arrays are
|
|
24
|
-
* checked, so a one-time allowlist sort becomes a standing guarantee.
|
|
25
|
-
* - misaligned-marked-run : a `// keep-aligned` const/weight table whose
|
|
26
|
-
* `=`/`:` assignment columns are not all equal. Opt-in, same shape.
|
|
27
|
-
*
|
|
28
|
-
* Exceptions live at the violation site, not in this file:
|
|
7
|
+
* Exceptions live at the violation site:
|
|
29
8
|
* - file-level, in the first 50 lines: // codebase-patterns:allow-file <class> — <reason>
|
|
30
9
|
* - per-line, on the same line or up to 2 lines above: // allow:<class> — <reason>
|
|
31
10
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* - anti-coincidence test assertions -> scripts/check-test-coverage.js
|
|
36
|
-
* - internal-path leaks in operator output -> tests/operator-leak-grep.test.js
|
|
11
|
+
* Owned elsewhere: phase/version vocabulary (check-version-tags.js), CLI-dispatch
|
|
12
|
+
* process.exit (tests/safe-exit-grep.test.js), test assertions
|
|
13
|
+
* (check-test-coverage.js), operator-output path leaks (operator-leak-grep.test.js).
|
|
37
14
|
*/
|
|
38
15
|
|
|
39
16
|
const fs = require("node:fs");
|
|
@@ -41,8 +18,8 @@ const path = require("node:path");
|
|
|
41
18
|
|
|
42
19
|
const ROOT = path.resolve(__dirname, "..");
|
|
43
20
|
|
|
44
|
-
//
|
|
45
|
-
//
|
|
21
|
+
// Classes that accept an `// allow:<class>` marker. orphan-allow-class is the
|
|
22
|
+
// meta-guard itself, so it is not markable.
|
|
46
23
|
const VALID_ALLOW_CLASSES = Object.freeze({
|
|
47
24
|
"process-exit-after-stdout-write": true,
|
|
48
25
|
"dynamic-regex": true,
|
|
@@ -57,8 +34,6 @@ const EXCLUDE_DIRS = new Set([
|
|
|
57
34
|
"data", ".test-output", ".keys", "keys", "coverage",
|
|
58
35
|
]);
|
|
59
36
|
|
|
60
|
-
// ---- file walk -----------------------------------------------------------
|
|
61
|
-
|
|
62
37
|
function relPath(abs) {
|
|
63
38
|
return path.relative(ROOT, abs).split(path.sep).join("/");
|
|
64
39
|
}
|
|
@@ -104,18 +79,15 @@ function readLines(rel) {
|
|
|
104
79
|
return lines;
|
|
105
80
|
}
|
|
106
81
|
|
|
107
|
-
// Strip a trailing `//` line comment
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
// scanner skips string contents — otherwise the rest of the line, including a
|
|
111
|
-
// real `process.exit(...)` / `new RegExp(...)`, was silently truncated away and
|
|
112
|
-
// the detector never fired.
|
|
82
|
+
// Strip a trailing `//` line comment so a class name mentioned in a comment
|
|
83
|
+
// can't arm a detector. String-aware: a `//` inside a quoted string (a `http://`
|
|
84
|
+
// URL) is not a comment — truncating there hides a real hit later on the line.
|
|
113
85
|
function stripLineComment(line) {
|
|
114
86
|
let inStr = null; // active quote char, or null
|
|
115
87
|
for (let i = 0; i < line.length; i++) {
|
|
116
88
|
const ch = line[i];
|
|
117
89
|
if (inStr) {
|
|
118
|
-
if (ch === "\\") { i++; continue; }
|
|
90
|
+
if (ch === "\\") { i++; continue; }
|
|
119
91
|
if (ch === inStr) inStr = null;
|
|
120
92
|
} else if (ch === "'" || ch === '"' || ch === "`") {
|
|
121
93
|
inStr = ch;
|
|
@@ -126,8 +98,6 @@ function stripLineComment(line) {
|
|
|
126
98
|
return line;
|
|
127
99
|
}
|
|
128
100
|
|
|
129
|
-
// ---- allow-marker engine -------------------------------------------------
|
|
130
|
-
|
|
131
101
|
function hasFileAllow(rel, cls) {
|
|
132
102
|
const head = readLines(rel).slice(0, 50);
|
|
133
103
|
const re = new RegExp("codebase-patterns:allow-file\\s+" + cls + "\\b");
|
|
@@ -147,23 +117,10 @@ function filterMarkers(hits, cls) {
|
|
|
147
117
|
return hits.filter((h) => !hasFileAllow(h.file, cls) && !hasLineAllow(h.file, h.line, cls));
|
|
148
118
|
}
|
|
149
119
|
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
// lines. Braces inside a single/double/template string, a `//` line comment,
|
|
155
|
-
// or a `/* */` block comment do NOT affect depth — otherwise a `{` or `}`
|
|
156
|
-
// typed inside a string literal in the require.main block miscounts the brace
|
|
157
|
-
// balance and the computed block range slides onto an unrelated later function
|
|
158
|
-
// (whose process.exit() is then wrongly treated as a CLI-entry exit and not
|
|
159
|
-
// flagged). `inTemplate` and `inBlockComment` are the cross-line states a
|
|
160
|
-
// per-line stripper cannot model, so the tokenizer state object is threaded
|
|
161
|
-
// line-to-line by the caller.
|
|
162
|
-
//
|
|
163
|
-
// `state` is mutated in place: { inSingle, inDouble, inTemplate, inBlock,
|
|
164
|
-
// templateDepth } — `templateDepth` tracks `${ … }` interpolation nesting so
|
|
165
|
-
// the closing `}` of an interpolation is treated as template punctuation, not
|
|
166
|
-
// a code brace, while braces INSIDE the interpolation expression still count.
|
|
120
|
+
// Counts `{` / `}` in REAL CODE context only: a brace inside a string, template
|
|
121
|
+
// or comment must not move the depth, or the computed require.main range slides
|
|
122
|
+
// onto a later function. `state` is mutated in place and threaded line to line,
|
|
123
|
+
// since `inTemplate` / `inBlock` are cross-line states.
|
|
167
124
|
function countCodeBraces(line, state) {
|
|
168
125
|
let delta = 0;
|
|
169
126
|
for (let i = 0; i < line.length; i++) {
|
|
@@ -190,13 +147,12 @@ function countCodeBraces(line, state) {
|
|
|
190
147
|
// Enter an interpolation expression: braces inside ARE code.
|
|
191
148
|
state.templateExpr.push(0);
|
|
192
149
|
state.inTemplate = false;
|
|
193
|
-
i++;
|
|
150
|
+
i++;
|
|
194
151
|
continue;
|
|
195
152
|
}
|
|
196
153
|
continue;
|
|
197
154
|
}
|
|
198
|
-
|
|
199
|
-
if (ch === "/" && next === "/") break; // `//` — rest of the line is a comment
|
|
155
|
+
if (ch === "/" && next === "/") break;
|
|
200
156
|
if (ch === "/" && next === "*") { state.inBlock = true; i++; continue; }
|
|
201
157
|
if (ch === "'") { state.inSingle = true; continue; }
|
|
202
158
|
if (ch === '"') { state.inDouble = true; continue; }
|
|
@@ -222,17 +178,12 @@ function newBraceState() {
|
|
|
222
178
|
return { inSingle: false, inDouble: false, inTemplate: false, inBlock: false, templateExpr: [] };
|
|
223
179
|
}
|
|
224
180
|
|
|
225
|
-
// Line ranges (1-based, inclusive) of `if (require.main === module) { ... }`
|
|
226
|
-
//
|
|
227
|
-
// is correct. process.exit there is owned by tests/safe-exit-grep.test.js and
|
|
228
|
-
// is not a library-surface concern.
|
|
181
|
+
// Line ranges (1-based, inclusive) of `if (require.main === module) { ... }` blocks,
|
|
182
|
+
// where print-then-exit is correct — owned by tests/safe-exit-grep.test.js.
|
|
229
183
|
function requireMainRanges(lines) {
|
|
230
184
|
const ranges = [];
|
|
231
185
|
for (let i = 0; i < lines.length; i++) {
|
|
232
186
|
if (/\brequire\.main\s*===\s*module\b/.test(lines[i])) {
|
|
233
|
-
// Find the opening brace (same line or next few), then balance —
|
|
234
|
-
// string/comment/template-aware so braces inside literals don't skew the
|
|
235
|
-
// depth (see countCodeBraces).
|
|
236
187
|
let depth = 0;
|
|
237
188
|
let started = false;
|
|
238
189
|
let j = i;
|
|
@@ -252,17 +203,10 @@ function inRanges(ranges, lineNo) {
|
|
|
252
203
|
return ranges.some(([a, b]) => lineNo >= a && lineNo <= b);
|
|
253
204
|
}
|
|
254
205
|
|
|
255
|
-
//
|
|
256
|
-
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
// function). The bare-identifier (third) alternative matches a declaration /
|
|
260
|
-
// method-shorthand opener (`foo() {`, `async bar() {`), but it must REFUSE
|
|
261
|
-
// control-flow openers (`for (…) {`, `if (…) {`, `while/switch/catch (…) {`):
|
|
262
|
-
// a control-flow block sitting between a stdout write and a process.exit() is
|
|
263
|
-
// inside the SAME function, so stopping the backward scan there would wrongly
|
|
264
|
-
// leave the exit unflagged. The negative lookahead excludes the control-flow
|
|
265
|
-
// keywords; `function` and arrow alternatives are unchanged.
|
|
206
|
+
// Opens a new function body, so the backward stdout-write scan stops at the
|
|
207
|
+
// enclosing function. The bare-identifier alternative must REFUSE control-flow
|
|
208
|
+
// openers (`if (…) {`, `for (…) {`): those sit inside the SAME function, and
|
|
209
|
+
// stopping there leaves a real exit-after-write unflagged.
|
|
266
210
|
const FUNCTION_START = /(^|[^.\w])function\b|=>\s*\{?\s*$|^\s*(async\s+)?(?!(?:if|for|while|switch|catch|do|else|with|finally|return)\b)[A-Za-z_$][\w$]*\s*\([^)]*\)\s*\{/;
|
|
267
211
|
|
|
268
212
|
function detectProcessExitAfterStdout(files) {
|
|
@@ -275,8 +219,7 @@ function detectProcessExitAfterStdout(files) {
|
|
|
275
219
|
if (!/\bprocess\.exit\s*\(/.test(code)) continue;
|
|
276
220
|
const lineNo = i + 1;
|
|
277
221
|
if (inRanges(mainRanges, lineNo)) continue; // CLI-entry block: legitimate
|
|
278
|
-
// Scan backward within the enclosing function for a result-channel
|
|
279
|
-
// write (console.log / process.stdout.write). Stop at a function start.
|
|
222
|
+
// Scan backward within the enclosing function for a result-channel write.
|
|
280
223
|
let sawStdout = false;
|
|
281
224
|
for (let k = i - 1; k >= 0 && k >= i - 60; k--) {
|
|
282
225
|
const prev = stripLineComment(lines[k]);
|
|
@@ -291,11 +234,8 @@ function detectProcessExitAfterStdout(files) {
|
|
|
291
234
|
return filterMarkers(hits, "process-exit-after-stdout-write");
|
|
292
235
|
}
|
|
293
236
|
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
// a backtick template) is operator-derivable and flagged. Backtick is NOT
|
|
297
|
-
// exempt — a template literal can interpolate operator input, so it must be
|
|
298
|
-
// flagged the same as a bare identifier.
|
|
237
|
+
// A first arg opening with `"`, `'` or `/` is a literal, so static and safe.
|
|
238
|
+
// Backtick is NOT exempt — a template literal can interpolate operator input.
|
|
299
239
|
function isStaticRegexFirstChar(ch) {
|
|
300
240
|
return ch === '"' || ch === "'" || ch === "/";
|
|
301
241
|
}
|
|
@@ -312,23 +252,17 @@ function detectDynamicRegex(files) {
|
|
|
312
252
|
hits.push({ file: rel, line: i + 1, content: lines[i].trim() });
|
|
313
253
|
continue;
|
|
314
254
|
}
|
|
315
|
-
// Multi-line form:
|
|
316
|
-
//
|
|
317
|
-
// line. The single-line match above can't see the first-arg char, so it
|
|
318
|
-
// would silently pass a dynamic RegExp whose argument starts next line.
|
|
319
|
-
// Look ahead, skipping blank and comment-only lines (capped at 5), and
|
|
320
|
-
// inspect the first code line's first non-whitespace char.
|
|
255
|
+
// Multi-line form: the pattern arg starts on a later line, so look ahead
|
|
256
|
+
// past blank and comment-only lines, capped at 5.
|
|
321
257
|
if (!/\bnew RegExp\s*\(\s*$/.test(code)) continue;
|
|
322
258
|
let firstChar = null;
|
|
323
259
|
for (let k = i + 1; k <= i + 5 && k < lines.length; k++) {
|
|
324
260
|
const ahead = stripLineComment(lines[k]).replace(/^\s+/, "");
|
|
325
|
-
if (ahead === "") continue;
|
|
261
|
+
if (ahead === "") continue;
|
|
326
262
|
firstChar = ahead[0];
|
|
327
263
|
break;
|
|
328
264
|
}
|
|
329
|
-
//
|
|
330
|
-
// suspicious — flag conservatively. Otherwise apply the SAME literal
|
|
331
|
-
// exemption as the single-line path.
|
|
265
|
+
// Nothing parseable within the cap is suspicious — flag it.
|
|
332
266
|
if (firstChar !== null && isStaticRegexFirstChar(firstChar)) continue;
|
|
333
267
|
hits.push({ file: rel, line: i + 1, content: lines[i].trim() });
|
|
334
268
|
}
|
|
@@ -336,13 +270,9 @@ function detectDynamicRegex(files) {
|
|
|
336
270
|
return filterMarkers(hits, "dynamic-regex");
|
|
337
271
|
}
|
|
338
272
|
|
|
339
|
-
// Raw bidi-override / zero-width / invisible / null codepoints
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
// should emit them programmatically (via vendor/blamejs/codepoint-class) or
|
|
343
|
-
// escape them (\uXXXX), never type them literally. The range table holds only
|
|
344
|
-
// numeric codepoints + the regex is built from escapes, so this detector's own
|
|
345
|
-
// source is clean (and the file self-skips below regardless).
|
|
273
|
+
// Raw bidi-override / zero-width / invisible / null codepoints typed as literals
|
|
274
|
+
// — the Trojan-Source class (CVE-2021-42574). Source emits them via
|
|
275
|
+
// vendor/blamejs/codepoint-class or a \uXXXX escape instead.
|
|
346
276
|
const _BIDI_LITERAL_RANGES = [
|
|
347
277
|
[0x202A, 0x202E], [0x2066, 0x2069], 0x200E, 0x200F, 0x061C, // bidi overrides + isolates
|
|
348
278
|
0x200B, 0x200C, 0x200D, 0x00AD, 0x2060, 0xFEFF, // zero-width / invisible
|
|
@@ -378,13 +308,8 @@ function detectOrphanAllowClass(files) {
|
|
|
378
308
|
const cmt = lines[i].indexOf("//");
|
|
379
309
|
if (cmt === -1) continue;
|
|
380
310
|
const comment = lines[i].slice(cmt);
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
// file-level: codebase-patterns:allow-file <class> — <reason>
|
|
384
|
-
// The file-level form is the broadest exemption (it suppresses every hit
|
|
385
|
-
// of its class in the file), so a reason-less or unknown-class file-level
|
|
386
|
-
// marker must be caught here too — otherwise it would suppress silently
|
|
387
|
-
// and never reach the per-line orphan check.
|
|
311
|
+
// Both marker forms carry the same class + reason rules. The file-level
|
|
312
|
+
// form suppresses every hit of its class, so it must be caught here.
|
|
388
313
|
const fileLevel = comment.match(/\bcodebase-patterns:allow-file\s+([a-z0-9-]+)\b(.*)$/);
|
|
389
314
|
const perLine = comment.match(/\ballow:([a-z0-9-]+)\b(.*)$/);
|
|
390
315
|
const m = fileLevel || perLine;
|
|
@@ -402,16 +327,11 @@ function detectOrphanAllowClass(files) {
|
|
|
402
327
|
return hits;
|
|
403
328
|
}
|
|
404
329
|
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
// code is never flagged. They turn a one-time cleanup (sorting an allowlist,
|
|
408
|
-
// aligning a const table) into a standing guarantee: mark the cleaned site and
|
|
409
|
-
// the gate keeps it clean.
|
|
330
|
+
// The two detectors below fire only on sites that opt in via a marker, so
|
|
331
|
+
// unmarked code is never flagged.
|
|
410
332
|
|
|
411
|
-
// `// keep-sorted` marks a flat string-literal array that must stay
|
|
412
|
-
//
|
|
413
|
-
// carries the marker are checked; arrays containing object/nested elements are
|
|
414
|
-
// skipped (not a flat string list).
|
|
333
|
+
// `// keep-sorted` marks a flat string-literal array that must stay alphabetically
|
|
334
|
+
// sorted; an array with object or nested elements is skipped.
|
|
415
335
|
function scanUnsortedMarkedArray(rel, lines) {
|
|
416
336
|
const hits = [];
|
|
417
337
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -428,7 +348,7 @@ function scanUnsortedMarkedArray(rel, lines) {
|
|
|
428
348
|
body += " " + seg;
|
|
429
349
|
if (started && depth <= 0) break;
|
|
430
350
|
}
|
|
431
|
-
if (/[{]/.test(body)) continue;
|
|
351
|
+
if (/[{]/.test(body)) continue;
|
|
432
352
|
const strs = [];
|
|
433
353
|
const re = /(['"])((?:\\.|(?!\1).)*)\1/g;
|
|
434
354
|
let m;
|
|
@@ -452,9 +372,8 @@ function detectUnsortedMarkedArray(files) {
|
|
|
452
372
|
}
|
|
453
373
|
|
|
454
374
|
// `// keep-aligned` marks a contiguous run of `IDENT = value` / `IDENT: value`
|
|
455
|
-
// lines
|
|
456
|
-
//
|
|
457
|
-
// line. Opt-in, so only deliberately-aligned tables are enforced.
|
|
375
|
+
// lines whose assignment columns must all line up. The run starts at the line
|
|
376
|
+
// after the marker and ends at the first blank or non-assignment line.
|
|
458
377
|
function scanMisalignedMarkedRun(rel, lines) {
|
|
459
378
|
const hits = [];
|
|
460
379
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -487,22 +406,13 @@ function detectMisalignedMarkedRun(files) {
|
|
|
487
406
|
return hits;
|
|
488
407
|
}
|
|
489
408
|
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
// file imports a SQL driver, any SQL STATEMENT (`"SELECT …"`) or CLAUSE built
|
|
494
|
-
// by string concatenation (`… + " WHERE " + …`) is a parameterization/injection
|
|
495
|
-
// sink and must use the driver's bound-parameter API instead. The SQL-driver
|
|
496
|
-
// import is the gate — a SQL-looking string in a file with no driver executes
|
|
497
|
-
// nothing, so prose that merely begins with "Update …" / "Delete …" in a
|
|
498
|
-
// non-DB file is never scanned (no false positives). A trusted static DDL
|
|
499
|
-
// string can opt out with `// allow:hand-rolled-sql — <reason>`.
|
|
409
|
+
// Forward guard: the driver import is the gate, so prose merely beginning
|
|
410
|
+
// "Update …" in a non-DB file is never scanned. In a file that does import one,
|
|
411
|
+
// a statement or a concatenated clause is an injection sink.
|
|
500
412
|
const SQL_DRIVER_IMPORT = /require\(\s*["'](?:node:sqlite|better-sqlite3|sqlite3|sqlite|pg|mysql2?|knex|sequelize|drizzle-orm|postgres|@libsql\/[\w.-]+)(?:\/[^"']*)?["']\s*\)|\bfrom\s+["'](?:node:sqlite|better-sqlite3|pg|mysql2?|knex|sequelize|drizzle-orm)(?:\/[^"']*)?["']/;
|
|
501
413
|
const SQL_STMT_START = /(["'`])\s*(?:SELECT\b|INSERT\s+(?:INTO|OR)\b|REPLACE\s+INTO\b|UPDATE\s+["'`]?[A-Za-z_]|DELETE\s+FROM\b|CREATE\s+(?:TABLE|UNIQUE\s+INDEX|INDEX|TRIGGER|VIRTUAL\s+TABLE)\b|ALTER\s+TABLE\b|DROP\s+(?:TABLE|TRIGGER|INDEX)\b|MERGE\s+INTO\b)/i;
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
// concatenation operator rather than requiring the clause string to close on
|
|
505
|
-
// the same quote with no interior quotes.
|
|
414
|
+
// The trailing-concat form tolerates a quote inside the clause
|
|
415
|
+
// (`" WHERE name = 'x' " + id`) by scanning to the first `+`.
|
|
506
416
|
const SQL_CLAUSE_FRAG = /(?:\+\s*["'`]\s*(?:SET|FROM|WHERE|VALUES|ORDER\s+BY|GROUP\s+BY|HAVING|RETURNING|LIMIT|OFFSET|ON\s+CONFLICT|(?:INNER\s+|LEFT\s+|RIGHT\s+|CROSS\s+)?JOIN)\b|["'`]\s*(?:SET|FROM|WHERE|VALUES\s*\(|ORDER\s+BY|GROUP\s+BY|HAVING|RETURNING|ON\s+CONFLICT|(?:INNER\s+|LEFT\s+|RIGHT\s+|CROSS\s+)?JOIN)\b[^+]*\+)/i;
|
|
507
417
|
function detectHandRolledSql(files) {
|
|
508
418
|
const hits = [];
|
|
@@ -564,11 +474,8 @@ const CLASSES = [
|
|
|
564
474
|
];
|
|
565
475
|
|
|
566
476
|
function main() {
|
|
567
|
-
// Fail closed
|
|
568
|
-
//
|
|
569
|
-
// unreadable/empty — so a wholesale missing source tree would make every
|
|
570
|
-
// class report "clean" and the gate pass without scanning anything (the
|
|
571
|
-
// absent-input false-pass class). Refuse to call zero-files-scanned "clean".
|
|
477
|
+
// Fail closed on an empty scan universe: each detector silently finds no hits
|
|
478
|
+
// when its roots are unreadable. Zero files scanned is not "clean".
|
|
572
479
|
const universe = filesUnder(["bin/exceptd.js", "lib", "orchestrator", "scripts"]);
|
|
573
480
|
if (universe.length === 0) {
|
|
574
481
|
console.error("[check-codebase-patterns] FAIL — zero source files found under bin/lib/orchestrator/scripts; refusing to report clean without scanning anything.");
|