@blamejs/exceptd-skills 0.19.33 → 0.19.35
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 +28 -0
- package/bin/exceptd.js +895 -2828
- package/data/_indexes/_meta.json +2 -2
- 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 +170 -76
- package/lib/collectors/cicd-pipeline-compromise.js +113 -136
- 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 +198 -211
- package/lib/collectors/mcp.js +24 -70
- package/lib/collectors/runtime.js +24 -86
- package/lib/collectors/sbom.js +130 -118
- package/lib/collectors/scan-excludes.js +33 -139
- 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 -155
- 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 +39 -113
- 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 +88 -236
- package/lib/playbook-runner.js +759 -2107
- package/lib/prefetch.js +101 -376
- package/lib/refresh-external.js +199 -633
- package/lib/refresh-network.js +78 -311
- package/lib/rfc-cli.js +23 -68
- package/lib/scoring.js +85 -146
- 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 +28 -27
- package/lib/upstream-check-cli.js +36 -29
- package/lib/upstream-check.js +19 -44
- package/lib/validate-catalog-meta.js +17 -61
- package/lib/validate-cve-catalog.js +52 -121
- package/lib/validate-indexes.js +25 -76
- package/lib/validate-package.js +16 -62
- package/lib/validate-playbooks.js +78 -286
- 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 -413
- package/orchestrator/pipeline.js +28 -85
- package/orchestrator/scanner.js +34 -138
- package/orchestrator/scheduler.js +20 -84
- package/package.json +1 -1
- package/sbom.cdx.json +242 -242
- package/scripts/audit-catalog-gaps.js +9 -62
- package/scripts/audit-cross-skill.js +5 -31
- package/scripts/audit-perf.js +29 -28
- 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 +21 -31
- package/scripts/builders/token-budget.js +4 -31
- package/scripts/check-agents-md-collectors.js +26 -57
- 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 +63 -143
- package/scripts/check-epss-consistency.js +9 -64
- package/scripts/check-framework-gap-coverage.js +13 -31
- package/scripts/check-manifest-snapshot.js +62 -81
- package/scripts/check-sbom-currency.js +44 -142
- package/scripts/check-test-count.js +15 -52
- package/scripts/check-test-coverage.js +83 -198
- 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 +7 -8
- package/scripts/refresh-mitre-attack.js +1 -8
- package/scripts/refresh-mitre-d3fend.js +3 -9
- package/scripts/refresh-mitre-ics-attack.js +7 -8
- package/scripts/refresh-reverse-refs.js +27 -94
- package/scripts/refresh-rfc-index.js +7 -10
- package/scripts/refresh-sbom.js +31 -161
- package/scripts/refresh-upstream-catalogs.js +63 -148
- package/scripts/release.js +69 -234
- package/scripts/run-e2e-scenarios.js +26 -73
- 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 -141
|
@@ -1,33 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* framework_gaps) from manifest.json and compares it to the committed
|
|
8
|
-
* manifest-snapshot.json baseline.
|
|
9
|
-
*
|
|
10
|
-
* The skill surface is the public contract this repo offers downstream
|
|
11
|
-
* AI assistants: skill names that downstream prompts may reference,
|
|
12
|
-
* trigger keywords that downstream skill-matchers index on, and the
|
|
13
|
-
* data files that skills rely on. Removing a skill or trigger keyword
|
|
14
|
-
* silently breaks every consumer that pinned that surface.
|
|
15
|
-
*
|
|
16
|
-
* Exit codes:
|
|
17
|
-
* 0 — no breaking changes (additive changes printed but not failing)
|
|
18
|
-
* 1 — breaking changes detected
|
|
19
|
-
* 2 — script-level error (missing baseline, IO failure, etc.)
|
|
20
|
-
*
|
|
21
|
-
* Operators see this gate in SECURITY.md / CONTRIBUTING.md as a CI
|
|
22
|
-
* promise: "removed skills, removed triggers, or removed data deps fail
|
|
23
|
-
* the build before they reach main."
|
|
24
|
-
*
|
|
25
|
-
* Usage:
|
|
26
|
-
* node scripts/check-manifest-snapshot.js
|
|
27
|
-
*
|
|
28
|
-
* Regenerate the baseline after an intentional removal:
|
|
29
|
-
* node scripts/refresh-manifest-snapshot.js
|
|
30
|
-
* git add manifest-snapshot.json && git commit
|
|
3
|
+
* CI gate: diffs the public skill surface in manifest.json — name, version,
|
|
4
|
+
* triggers, data_deps, ref arrays — against the committed manifest-snapshot.json.
|
|
5
|
+
* Exit 0 when nothing broke (additive changes print and still pass), 1 on a
|
|
6
|
+
* breaking change, 2 on a script-level error such as a missing baseline.
|
|
31
7
|
*/
|
|
32
8
|
|
|
33
9
|
const fs = require("fs");
|
|
@@ -39,9 +15,7 @@ const MANIFEST_PATH = path.join(ROOT, "manifest.json");
|
|
|
39
15
|
const SNAPSHOT_PATH = path.join(ROOT, "manifest-snapshot.json");
|
|
40
16
|
|
|
41
17
|
function captureSurface(manifest) {
|
|
42
|
-
//
|
|
43
|
-
// pinned against. NOT included: sha256 / signature / signed_at —
|
|
44
|
-
// those change every commit and are not a public contract.
|
|
18
|
+
// Only what a consumer pins against: sha256, signature and signed_at change every commit.
|
|
45
19
|
const skills = (manifest.skills || []).map(s => ({
|
|
46
20
|
name: s.name,
|
|
47
21
|
version: s.version || null,
|
|
@@ -63,37 +37,66 @@ function captureSurface(manifest) {
|
|
|
63
37
|
};
|
|
64
38
|
}
|
|
65
39
|
|
|
40
|
+
// Absent and corrupt are different states and must not collapse into one.
|
|
41
|
+
//
|
|
42
|
+
// ABSENT (key not in the object) is the stale baseline this gate exists to
|
|
43
|
+
// report on: a snapshot committed before a surface field existed carries no key
|
|
44
|
+
// for it, and an absent field is an empty surface, not a crash. Reading it
|
|
45
|
+
// unguarded threw a TypeError that the CLI's outer catch turned into exit 2 with
|
|
46
|
+
// a stack trace instead of an additive-change report.
|
|
47
|
+
//
|
|
48
|
+
// PRESENT-BUT-NOT-AN-ARRAY (a string, an object, null) is corruption. Coercing
|
|
49
|
+
// it to [] would report every live entry as additive and exit 0 — a gate that
|
|
50
|
+
// passes without checking anything. It raises instead, and the outer catch turns
|
|
51
|
+
// that into exit 2 naming the skill and the field.
|
|
52
|
+
function asArray(value, skillName, field) {
|
|
53
|
+
if (value === undefined) return [];
|
|
54
|
+
if (Array.isArray(value)) return value;
|
|
55
|
+
throw new Error(
|
|
56
|
+
`${skillName}: ${field} is ${value === null ? "null" : typeof value}, not an array. ` +
|
|
57
|
+
"The baseline or manifest is corrupt, not merely stale — regenerate the baseline " +
|
|
58
|
+
"with `node scripts/refresh-manifest-snapshot.js` and re-check the manifest."
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// captureSurface() always writes `skills`, so unlike the per-field case above
|
|
63
|
+
// there is no legitimate historical baseline without it: absent and non-array
|
|
64
|
+
// are both corruption here, and treating either as [] would report every skill
|
|
65
|
+
// as added and exit 0.
|
|
66
|
+
function skillList(surface, which) {
|
|
67
|
+
const skills = surface && surface.skills;
|
|
68
|
+
if (Array.isArray(skills)) return skills;
|
|
69
|
+
throw new Error(
|
|
70
|
+
`${which}.skills is ${skills === null ? "null" : typeof skills}, not an array. ` +
|
|
71
|
+
"Regenerate the baseline with `node scripts/refresh-manifest-snapshot.js`."
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
66
75
|
function diff(baseline, current) {
|
|
67
76
|
const breaking = [];
|
|
68
77
|
const additive = [];
|
|
69
78
|
|
|
70
|
-
const bSkills = new Map(baseline.
|
|
71
|
-
const cSkills = new Map(current.
|
|
79
|
+
const bSkills = new Map(skillList(baseline, "baseline").map(s => [s.name, s]));
|
|
80
|
+
const cSkills = new Map(skillList(current, "current").map(s => [s.name, s]));
|
|
72
81
|
|
|
73
|
-
// Removed skills are breaking.
|
|
74
82
|
for (const name of bSkills.keys()) {
|
|
75
83
|
if (!cSkills.has(name)) {
|
|
76
84
|
breaking.push(`removed skill: ${name}`);
|
|
77
85
|
}
|
|
78
86
|
}
|
|
79
87
|
|
|
80
|
-
// Added skills are additive.
|
|
81
88
|
for (const name of cSkills.keys()) {
|
|
82
89
|
if (!bSkills.has(name)) {
|
|
83
90
|
additive.push(`added skill: ${name}`);
|
|
84
91
|
}
|
|
85
92
|
}
|
|
86
93
|
|
|
87
|
-
// For each skill present in both, diff the pinned facts.
|
|
88
94
|
for (const [name, b] of bSkills) {
|
|
89
95
|
const c = cSkills.get(name);
|
|
90
96
|
if (!c) continue;
|
|
91
97
|
|
|
92
|
-
// version downgrades are breaking; bumps are additive.
|
|
93
98
|
if (b.version && c.version && b.version !== c.version) {
|
|
94
|
-
//
|
|
95
|
-
// upstream and the manifest version field is informational. The
|
|
96
|
-
// operator should bump, not unbump.
|
|
99
|
+
// Lexicographic, not semver: nothing upstream enforces a semver shape.
|
|
97
100
|
if (c.version < b.version) {
|
|
98
101
|
breaking.push(`${name}: version downgraded ${b.version} -> ${c.version}`);
|
|
99
102
|
} else {
|
|
@@ -102,45 +105,44 @@ function diff(baseline, current) {
|
|
|
102
105
|
}
|
|
103
106
|
|
|
104
107
|
// Removed trigger keywords break downstream skill matchers.
|
|
105
|
-
const
|
|
108
|
+
const bTriggers = asArray(b.triggers, name, "baseline triggers");
|
|
109
|
+
const cTriggers = asArray(c.triggers, name, "triggers");
|
|
110
|
+
const removedTriggers = bTriggers.filter(t => !cTriggers.includes(t));
|
|
106
111
|
if (removedTriggers.length > 0) {
|
|
107
112
|
breaking.push(`${name}: removed trigger keywords: ${removedTriggers.join(", ")}`);
|
|
108
113
|
}
|
|
109
|
-
const addedTriggers =
|
|
114
|
+
const addedTriggers = cTriggers.filter(t => !bTriggers.includes(t));
|
|
110
115
|
if (addedTriggers.length > 0) {
|
|
111
116
|
additive.push(`${name}: added trigger keywords: ${addedTriggers.join(", ")}`);
|
|
112
117
|
}
|
|
113
118
|
|
|
114
|
-
// Removed data deps break the skill at load time.
|
|
115
|
-
const
|
|
119
|
+
// Removed data deps break the skill at load time.
|
|
120
|
+
const bDeps = asArray(b.data_deps, name, "baseline data_deps");
|
|
121
|
+
const cDeps = asArray(c.data_deps, name, "data_deps");
|
|
122
|
+
const removedDeps = bDeps.filter(d => !cDeps.includes(d));
|
|
116
123
|
if (removedDeps.length > 0) {
|
|
117
124
|
breaking.push(`${name}: removed data deps: ${removedDeps.join(", ")}`);
|
|
118
125
|
}
|
|
119
|
-
const addedDeps =
|
|
126
|
+
const addedDeps = cDeps.filter(d => !bDeps.includes(d));
|
|
120
127
|
if (addedDeps.length > 0) {
|
|
121
128
|
additive.push(`${name}: added data deps: ${addedDeps.join(", ")}`);
|
|
122
129
|
}
|
|
123
130
|
|
|
124
|
-
//
|
|
125
|
-
// Per AGENTS.md rule #4 (no orphaned controls) and #12 (external
|
|
126
|
-
// data version pinning), narrowing the cited surface is a
|
|
127
|
-
// deliberate decision worth surfacing in CI. Treat as breaking;
|
|
128
|
-
// the operator can refresh the baseline alongside the intent.
|
|
131
|
+
// Narrowing the cited surface is deliberate (AGENTS.md #4, #12), so removal is breaking.
|
|
129
132
|
for (const field of ["atlas_refs", "attack_refs", "framework_gaps", "rfc_refs", "cwe_refs", "d3fend_refs", "dlp_refs"]) {
|
|
130
|
-
const
|
|
133
|
+
const bRefs = asArray(b[field], name, `baseline ${field}`);
|
|
134
|
+
const cRefs = asArray(c[field], name, field);
|
|
135
|
+
const removed = bRefs.filter(r => !cRefs.includes(r));
|
|
131
136
|
if (removed.length > 0) {
|
|
132
137
|
breaking.push(`${name}: removed ${field}: ${removed.join(", ")}`);
|
|
133
138
|
}
|
|
134
|
-
const added =
|
|
139
|
+
const added = cRefs.filter(r => !bRefs.includes(r));
|
|
135
140
|
if (added.length > 0) {
|
|
136
141
|
additive.push(`${name}: added ${field}: ${added.join(", ")}`);
|
|
137
142
|
}
|
|
138
143
|
}
|
|
139
144
|
}
|
|
140
145
|
|
|
141
|
-
// ATLAS pinned-version change is breaking per AGENTS.md rule #12
|
|
142
|
-
// (never silently inherit version changes). The operator must update
|
|
143
|
-
// the baseline alongside the audit of TTP ID changes.
|
|
144
146
|
if (baseline.atlas_version && current.atlas_version &&
|
|
145
147
|
baseline.atlas_version !== current.atlas_version) {
|
|
146
148
|
breaking.push(
|
|
@@ -171,33 +173,16 @@ function formatDiff(result) {
|
|
|
171
173
|
}
|
|
172
174
|
|
|
173
175
|
/**
|
|
174
|
-
* Verify the
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
* (the surface diff alone is defeated by editing manifest.json AND the
|
|
178
|
-
* baseline in lockstep — e.g. to hide a removed skill/trigger). The
|
|
179
|
-
* sidecar pins the baseline's exact bytes so that lockstep edit no longer
|
|
180
|
-
* produces a matching pair.
|
|
181
|
-
*
|
|
182
|
-
* Pairing invariant: refresh-manifest-snapshot.js always writes BOTH the
|
|
183
|
-
* snapshot and its sidecar, and package.json `files` ships them together.
|
|
184
|
-
* So a snapshot present WITHOUT its sidecar is not a benign legacy state
|
|
185
|
-
* for any tree the current gate runs against (CI/predeploy run on the
|
|
186
|
-
* committed tree, where both are present; the shipped tarball ships both).
|
|
187
|
-
* An absent sidecar next to a present snapshot is the integrity-evasion
|
|
188
|
-
* shape — treat it as a failure, symmetric with the present-but-mismatch
|
|
189
|
-
* failure. Returns { ok, error } so callers can surface a hard exit.
|
|
190
|
-
*
|
|
191
|
-
* @param {string} root repo root containing the snapshot + sidecar
|
|
192
|
-
* @returns {{ok: boolean, error: (string|null)}}
|
|
176
|
+
* Verify the snapshot still hashes to its .sha256 sidecar. The surface diff
|
|
177
|
+
* alone is defeated by editing manifest.json and the baseline in lockstep; the
|
|
178
|
+
* sidecar pins the baseline's exact bytes so that pair no longer matches.
|
|
193
179
|
*/
|
|
194
180
|
function checkSnapshotIntegrity(root) {
|
|
195
181
|
const snapshotPath = path.join(root, "manifest-snapshot.json");
|
|
196
182
|
const shaPath = path.join(root, "manifest-snapshot.sha256");
|
|
197
183
|
|
|
198
184
|
if (!fs.existsSync(snapshotPath)) {
|
|
199
|
-
//
|
|
200
|
-
// distinct error. Nothing for the integrity check to anchor against.
|
|
185
|
+
// Nothing to anchor against; the caller's baseline read reports this.
|
|
201
186
|
return { ok: true, error: null };
|
|
202
187
|
}
|
|
203
188
|
|
|
@@ -253,11 +238,7 @@ if (require.main === module) {
|
|
|
253
238
|
process.exit(2);
|
|
254
239
|
}
|
|
255
240
|
|
|
256
|
-
//
|
|
257
|
-
// and the sidecar must be present whenever the snapshot is. A missing
|
|
258
|
-
// sidecar next to a present snapshot is itself a failure — it is the
|
|
259
|
-
// only thing that would have caught a lockstep hand-edit of manifest.json
|
|
260
|
-
// + the baseline, so allowing it to be deleted re-opens that bypass.
|
|
241
|
+
// Before the diff: a tampered baseline must not be diffed as though trustworthy.
|
|
261
242
|
const integrity = checkSnapshotIntegrity(ROOT);
|
|
262
243
|
if (!integrity.ok) {
|
|
263
244
|
console.error("[check-manifest-snapshot] " + integrity.error);
|
|
@@ -1,21 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* catalog counts. Drift means an SBOM regen was forgotten — operators
|
|
7
|
-
* downloading the tarball would see counts that disagree with the actual
|
|
8
|
-
* manifest.json and data/*.json contents.
|
|
9
|
-
*
|
|
10
|
-
* Anchors to ROOT in this order of preference:
|
|
11
|
-
* 1. `--root <dir>` on argv (testability — staged tempdir layouts).
|
|
12
|
-
* 2. `EXCEPTD_ROOT` environment variable.
|
|
13
|
-
* 3. `path.join(__dirname, '..')` — the running script's parent dir.
|
|
14
|
-
*
|
|
15
|
-
* Exit 0 on current SBOM, 1 on any drift (catalog count, skill count, or
|
|
16
|
-
* CycloneDX-format mismatch).
|
|
17
|
-
*
|
|
18
|
-
* No external dependencies. Node 24 stdlib only.
|
|
3
|
+
* Predeploy gate: sbom.cdx.json must be current against the live skill and
|
|
4
|
+
* catalog counts, the recorded file hashes and the shipped-file set — drift
|
|
5
|
+
* means the tarball ships an SBOM that disagrees with its own contents.
|
|
19
6
|
*/
|
|
20
7
|
|
|
21
8
|
const fs = require("fs");
|
|
@@ -31,16 +18,10 @@ function resolveRoot(argv) {
|
|
|
31
18
|
return path.join(__dirname, "..");
|
|
32
19
|
}
|
|
33
20
|
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
// RFC-…) with a single _meta block, so the live entry total is the key count
|
|
37
|
-
// excluding _meta.
|
|
21
|
+
// Live entry total for a data/*.json catalog, or null when the file is absent so
|
|
22
|
+
// a `--root` pointed at a partial tree skips that token instead of crashing.
|
|
38
23
|
function catalogEntryCount(dataDir, file) {
|
|
39
24
|
const p = path.join(dataDir, file);
|
|
40
|
-
// A --root pointed at a partial tree (no such catalog file) skips that
|
|
41
|
-
// token's check rather than crashing — catalog PRESENCE is asserted by
|
|
42
|
-
// the cardinality check above and the per-component hash check below,
|
|
43
|
-
// not by the description parser.
|
|
44
25
|
if (!fs.existsSync(p)) return null;
|
|
45
26
|
const j = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
46
27
|
if (Array.isArray(j)) return j.length;
|
|
@@ -50,18 +31,13 @@ function catalogEntryCount(dataDir, file) {
|
|
|
50
31
|
return 0;
|
|
51
32
|
}
|
|
52
33
|
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
// pre-computed index cache under data/_indexes/ is regenerated/test-mutated, so
|
|
57
|
-
// it carries no per-file component. The completeness check below must apply the
|
|
58
|
-
// SAME exclusions or it would demand a component for a file the generator never
|
|
59
|
-
// emits one for.
|
|
34
|
+
// Mirrors scripts/refresh-sbom.js SELF_EXCLUDED + DERIVABLE_PREFIXES: the
|
|
35
|
+
// completeness check must apply the SAME exclusions or it demands a component
|
|
36
|
+
// the generator never emits.
|
|
60
37
|
const SBOM_SELF_EXCLUDED = new Set(["sbom.cdx.json"]);
|
|
61
38
|
const SBOM_DERIVABLE_PREFIXES = ["data/_indexes/"];
|
|
62
39
|
// Mirrors refresh-sbom's ALWAYS_SHIPPED: npm adds package.json to every tarball
|
|
63
|
-
// without it appearing in `files`, so
|
|
64
|
-
// component for it the same way the generator now emits one.
|
|
40
|
+
// without it appearing in `files`, so a component is expected for it.
|
|
65
41
|
const SBOM_ALWAYS_SHIPPED = ["package.json", "sources/README.md"];
|
|
66
42
|
|
|
67
43
|
function sbomIsDerivable(rel) {
|
|
@@ -70,9 +46,7 @@ function sbomIsDerivable(rel) {
|
|
|
70
46
|
);
|
|
71
47
|
}
|
|
72
48
|
|
|
73
|
-
//
|
|
74
|
-
// Mirrors refresh-sbom's walkFiles (which is root-agnostic — it walks whatever
|
|
75
|
-
// absolute dir it is given).
|
|
49
|
+
// Mirrors refresh-sbom's walkFiles, walking whatever absolute dir it is handed.
|
|
76
50
|
function walkFilesAbs(absDir) {
|
|
77
51
|
const out = [];
|
|
78
52
|
let entries;
|
|
@@ -86,15 +60,9 @@ function walkFilesAbs(absDir) {
|
|
|
86
60
|
return out;
|
|
87
61
|
}
|
|
88
62
|
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
// own REPO_ROOT (it joins/relativizes against __dirname/..), so under a `--root`
|
|
93
|
-
// target the completeness check would otherwise validate the SOURCE repo's file
|
|
94
|
-
// list against the TARGET SBOM — the wrong tree. Replicating the expansion here
|
|
95
|
-
// (same SELF_EXCLUDED + DERIVABLE_PREFIXES exclusions, same dedupe+sort) keeps
|
|
96
|
-
// the gate honest under `--root` without reaching across into the generator's
|
|
97
|
-
// module-level root.
|
|
63
|
+
// The shipped-file set that must each have a file: component, computed against
|
|
64
|
+
// the TARGET tree. refresh-sbom's expandAllowlist is bound to its own REPO_ROOT,
|
|
65
|
+
// so it would validate the source repo's file list against the target SBOM.
|
|
98
66
|
function expandAllowlistAt(allowlist, root) {
|
|
99
67
|
const abs = [];
|
|
100
68
|
for (const entry of [...allowlist, ...SBOM_ALWAYS_SHIPPED]) {
|
|
@@ -114,11 +82,8 @@ function expandAllowlistAt(allowlist, root) {
|
|
|
114
82
|
return rel;
|
|
115
83
|
}
|
|
116
84
|
|
|
117
|
-
// The description
|
|
118
|
-
//
|
|
119
|
-
// 468 D3FEND / 8888 RFCs)". Each token maps to one data/*.json catalog whose
|
|
120
|
-
// live entry count must match. `label` is the regex-escaped text that follows
|
|
121
|
-
// the number in the description.
|
|
85
|
+
// The description embeds per-catalog ENTRY counts as free text ("… 177 CWEs /
|
|
86
|
+
// 805 ATT&CK + ICS …"). `label` is the regex-escaped text after the number.
|
|
122
87
|
const DESCRIPTION_ENTRY_TOKENS = [
|
|
123
88
|
{ file: "cve-catalog.json", label: "CVEs" },
|
|
124
89
|
{ file: "cwe-catalog.json", label: "CWEs" },
|
|
@@ -161,14 +126,8 @@ function checkSbomCurrency(root) {
|
|
|
161
126
|
errors.push("SBOM is not CycloneDX 1.6");
|
|
162
127
|
}
|
|
163
128
|
|
|
164
|
-
// The
|
|
165
|
-
//
|
|
166
|
-
// package.json). The numeric properties above only cover catalog/skill
|
|
167
|
-
// CARDINALITY (file count + skill count), so a catalog's entry total can
|
|
168
|
-
// drift past the count baked into the description while the dedicated SBOM
|
|
169
|
-
// gate still passes. Parse each token out of the description and assert it
|
|
170
|
-
// against the live entry count so a stale published-SBOM description fails
|
|
171
|
-
// the gate.
|
|
129
|
+
// The numeric properties above cover only CARDINALITY, so an entry total can
|
|
130
|
+
// drift in the description while those still agree.
|
|
172
131
|
const description =
|
|
173
132
|
(sbom.metadata && sbom.metadata.component && sbom.metadata.component.description) || "";
|
|
174
133
|
for (const { file, label } of DESCRIPTION_ENTRY_TOKENS) {
|
|
@@ -188,7 +147,6 @@ function checkSbomCurrency(root) {
|
|
|
188
147
|
);
|
|
189
148
|
}
|
|
190
149
|
}
|
|
191
|
-
// The skill count is embedded in the same description string ("N skills").
|
|
192
150
|
const skillMatch = description.match(/(\d+)\s+skills\b/);
|
|
193
151
|
if (!skillMatch) {
|
|
194
152
|
errors.push(
|
|
@@ -200,15 +158,9 @@ function checkSbomCurrency(root) {
|
|
|
200
158
|
);
|
|
201
159
|
}
|
|
202
160
|
|
|
203
|
-
// The "N catalogs" and "N jurisdictions" free-text counts in the same
|
|
204
|
-
// description string were never validated — only the per-catalog entry tokens
|
|
205
|
-
// and the skill count were. Pin them to the live values so a stale
|
|
206
|
-
// description (e.g. after an auto-refresh changed a count) fails the gate.
|
|
207
161
|
const catalogMatch = description.match(/(\d+)\s+catalogs?\b/i);
|
|
208
162
|
if (!catalogMatch) {
|
|
209
|
-
//
|
|
210
|
-
// description (or an auto-refresh that dropped the token) must not silently
|
|
211
|
-
// skip the count check — that is the asymmetric-absent fail-open class.
|
|
163
|
+
// Absence fails CLOSED: a reworded description must not skip the check.
|
|
212
164
|
errors.push(
|
|
213
165
|
"SBOM description is missing the catalog-count token (N catalogs) — regenerate via `npm run refresh-sbom`"
|
|
214
166
|
);
|
|
@@ -220,21 +172,16 @@ function checkSbomCurrency(root) {
|
|
|
220
172
|
const liveJurisdictions = (() => {
|
|
221
173
|
try {
|
|
222
174
|
const gf = JSON.parse(fs.readFileSync(path.join(dataDir, "global-frameworks.json"), "utf8"));
|
|
223
|
-
// Non-underscore top-level keys — the
|
|
224
|
-
// README badge and catalog-summaries use.
|
|
175
|
+
// Non-underscore top-level keys — the count the README badge reports.
|
|
225
176
|
return Object.keys(gf).filter((k) => !k.startsWith("_")).length;
|
|
226
177
|
} catch {
|
|
227
178
|
return null;
|
|
228
179
|
}
|
|
229
180
|
})();
|
|
230
181
|
const jurisdictionMatch = description.match(/(\d+)\s+jurisdictions?\b/i);
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
// When the source IS present, absence of the token fails CLOSED (the
|
|
235
|
-
// description token is the SBOM's only jurisdiction-count assertion — there is
|
|
236
|
-
// no structured jurisdiction property — so a dropped token would otherwise
|
|
237
|
-
// leave the count entirely unvalidated).
|
|
182
|
+
// A partial `--root` tree without global-frameworks.json skips the check. Where
|
|
183
|
+
// the source IS present the token is the SBOM's only jurisdiction assertion —
|
|
184
|
+
// no structured property carries it — so a dropped token fails CLOSED.
|
|
238
185
|
if (liveJurisdictions !== null) {
|
|
239
186
|
if (!jurisdictionMatch) {
|
|
240
187
|
errors.push(
|
|
@@ -247,15 +194,9 @@ function checkSbomCurrency(root) {
|
|
|
247
194
|
}
|
|
248
195
|
}
|
|
249
196
|
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
// the file: component pass below (a bumped or renamed skill changes those
|
|
254
|
-
// bytes). This branch is therefore not exercised by a clean SBOM, but it is
|
|
255
|
-
// retained as a tamper guard: a forged or buggy SBOM that injected a skill
|
|
256
|
-
// component with a stale version (or a skill name no longer in the manifest)
|
|
257
|
-
// is still caught here. Vendor components are validated against
|
|
258
|
-
// vendor/blamejs/_PROVENANCE.json.
|
|
197
|
+
// A clean SBOM carries no "skill:" components — skill drift shows up in the
|
|
198
|
+
// file: hashes. This branch is the tamper guard for a forged SBOM that injects
|
|
199
|
+
// one with a stale version.
|
|
259
200
|
const components = Array.isArray(sbom.components) ? sbom.components : [];
|
|
260
201
|
const skillByName = new Map(
|
|
261
202
|
(manifest.skills || []).map((s) => [s.name, s])
|
|
@@ -297,27 +238,17 @@ function checkSbomCurrency(root) {
|
|
|
297
238
|
}
|
|
298
239
|
}
|
|
299
240
|
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
// release-ordering bug where sbom.cdx.json was regenerated BEFORE the
|
|
304
|
-
// final sign-all pass — the recorded manifest.json hash drifted from
|
|
305
|
-
// the signed-and-committed bytes, but the count-based check above
|
|
306
|
-
// could not see it. Codex P2 flag on PR #48 surfaced one instance;
|
|
307
|
-
// this gate makes it unreachable.
|
|
241
|
+
// Every "file:" component's recorded hash must match the live bytes. This is
|
|
242
|
+
// what catches an SBOM regenerated BEFORE the final sign-all, where the
|
|
243
|
+
// recorded manifest.json hash drifts from the signed-and-committed bytes.
|
|
308
244
|
let fileComponentsChecked = 0;
|
|
309
245
|
const rootResolved = path.resolve(root);
|
|
310
246
|
for (const comp of components) {
|
|
311
247
|
const bomRef = typeof comp["bom-ref"] === "string" ? comp["bom-ref"] : "";
|
|
312
248
|
if (!bomRef.startsWith("file:")) continue;
|
|
313
249
|
const relPath = bomRef.slice("file:".length);
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
// tampered or carelessly-edited sbom.cdx.json with `file:../outside`
|
|
317
|
-
// would read + hash a path OUTSIDE the checkout — the gate would
|
|
318
|
-
// either report "exists, hash matches" (silently weakening the
|
|
319
|
-
// integrity guarantee) or "does not exist" without ever flagging the
|
|
320
|
-
// attempted escape. Refuse early.
|
|
250
|
+
// A `file:../outside` bom-ref would otherwise hash a path outside the
|
|
251
|
+
// checkout and report "exists, hash matches" — never the escape.
|
|
321
252
|
if (relPath.includes("..") || path.isAbsolute(relPath)) {
|
|
322
253
|
errors.push(
|
|
323
254
|
`SBOM file component "${relPath}" rejected: path must be repo-relative without ".." segments (path-traversal guard)`
|
|
@@ -325,9 +256,8 @@ function checkSbomCurrency(root) {
|
|
|
325
256
|
continue;
|
|
326
257
|
}
|
|
327
258
|
const absPath = path.resolve(rootResolved, relPath);
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
// changes to the textual filter would surface here.
|
|
259
|
+
// The resolved path must also land under root — a symlink, or a loosening of
|
|
260
|
+
// the textual filter above, surfaces here.
|
|
331
261
|
const rel = path.relative(rootResolved, absPath);
|
|
332
262
|
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
333
263
|
errors.push(
|
|
@@ -341,17 +271,8 @@ function checkSbomCurrency(root) {
|
|
|
341
271
|
);
|
|
342
272
|
continue;
|
|
343
273
|
}
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
// Trivy / Dependency-Track / GitHub Dependency Graph). SHA3-512
|
|
347
|
-
// is the SHA-3 family hedge, matching the existing key-fingerprint
|
|
348
|
-
// pattern (lib/verify.js). Both must agree with the live bytes;
|
|
349
|
-
// a mismatch on either fires the same drift error. A missing
|
|
350
|
-
// SHA-256 is a hard error (the universal contract is the floor);
|
|
351
|
-
// a missing SHA3-512 surfaces as a downgrade-attack warning so an
|
|
352
|
-
// operator who intentionally strips the second hash from an
|
|
353
|
-
// SBOM (post-quantum posture relaxation) sees it in the gate
|
|
354
|
-
// output, not in the JSON downstream.
|
|
274
|
+
// SHA-256 is the universal-tool contract (CycloneDX 1.6 default, read by
|
|
275
|
+
// Anchore / Trivy / Dependency-Track); SHA3-512 is the SHA-3 family hedge.
|
|
355
276
|
const sha256Entry = (comp.hashes || []).find((h) => h && h.alg === "SHA-256");
|
|
356
277
|
const sha3Entry = (comp.hashes || []).find((h) => h && h.alg === "SHA3-512");
|
|
357
278
|
if (!sha256Entry || typeof sha256Entry.content !== "string") {
|
|
@@ -367,12 +288,8 @@ function checkSbomCurrency(root) {
|
|
|
367
288
|
`SBOM file component "${relPath}" SHA-256 drift: recorded ${sha256Entry.content.slice(0, 12)}…, live ${liveSha256.slice(0, 12)}… — re-sign skills (\`node $(exceptd path)/lib/sign.js sign-all\` from a contributor checkout) and then \`npm run refresh-sbom\`, in that order (sbom must regenerate AFTER the final sign).`,
|
|
368
289
|
);
|
|
369
290
|
}
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
// sbom-generator regression) that strips the SHA3-512 column would
|
|
373
|
-
// silently pass the gate under an `if (sha3Entry)` guard, defeating
|
|
374
|
-
// the downgrade defense the dual-hash design is supposed to provide.
|
|
375
|
-
// Refuse absence as a hard error.
|
|
291
|
+
// Absence is a hard error, never an `if (sha3Entry)` guard: stripping the
|
|
292
|
+
// SHA3-512 column would otherwise pass and defeat the downgrade defense.
|
|
376
293
|
if (!sha3Entry || typeof sha3Entry.content !== "string") {
|
|
377
294
|
errors.push(
|
|
378
295
|
`SBOM file component "${relPath}" lacks a SHA3-512 hash entry — the dual-hash contract (SHA-256 + SHA3-512) requires both algorithms on every file: component. Regenerate via \`npm run refresh-sbom\` (v0.13.12+).`
|
|
@@ -388,20 +305,12 @@ function checkSbomCurrency(root) {
|
|
|
388
305
|
fileComponentsChecked++;
|
|
389
306
|
}
|
|
390
307
|
|
|
391
|
-
// Completeness
|
|
392
|
-
// every RECORDED
|
|
393
|
-
//
|
|
394
|
-
// newly-shipped file would ship unhashed and silent. And the aggregate
|
|
395
|
-
// bundle digest in metadata.component.hashes[] was never recomputed. Reuse
|
|
396
|
-
// refresh-sbom's exact allowlist expansion + digest so the gate can't drift
|
|
397
|
-
// from the generator.
|
|
308
|
+
// Completeness plus the aggregate bundle digest. The per-file pass verifies
|
|
309
|
+
// every RECORDED component, so without this a newly-shipped file carrying no
|
|
310
|
+
// component would go unhashed and silent.
|
|
398
311
|
try {
|
|
399
|
-
// bundleDigest
|
|
400
|
-
//
|
|
401
|
-
// allowlist expansion, by contrast, MUST run against the target `root`
|
|
402
|
-
// (expandAllowlistAt below) — refresh-sbom's exported expandAllowlist is
|
|
403
|
-
// pinned to its own source-repo REPO_ROOT and would validate the wrong tree
|
|
404
|
-
// under `--root`.
|
|
312
|
+
// bundleDigest reads only the file: component objects, so it is
|
|
313
|
+
// root-agnostic; the allowlist expansion is not, hence expandAllowlistAt.
|
|
405
314
|
const { bundleDigest } = require("./refresh-sbom");
|
|
406
315
|
const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
|
407
316
|
const expected = expandAllowlistAt(pkg.files || [], root);
|
|
@@ -416,16 +325,11 @@ function checkSbomCurrency(root) {
|
|
|
416
325
|
);
|
|
417
326
|
}
|
|
418
327
|
}
|
|
419
|
-
//
|
|
420
|
-
// SHA-256 hashes and compare to metadata.component.hashes[] (the per-file
|
|
421
|
-
// pass already tied each recorded hash to live bytes).
|
|
328
|
+
// Recomputed from the recorded per-file SHA-256s, already tied to live bytes.
|
|
422
329
|
const compHashes = (sbom.metadata && sbom.metadata.component && sbom.metadata.component.hashes) || [];
|
|
423
330
|
const recorded = (compHashes.find((h) => h && h.alg === "SHA-256") || {}).content;
|
|
424
331
|
if (fileComps.length) {
|
|
425
|
-
//
|
|
426
|
-
// verification anchor. An absent one must fail the gate (symmetric with
|
|
427
|
-
// the per-file SHA3-512 hard-error that defeats downgrade/strip attacks) —
|
|
428
|
-
// previously an absent digest silently skipped the comparison entirely.
|
|
332
|
+
// The bundle-as-a-whole anchor: absence fails rather than skipping.
|
|
429
333
|
if (!recorded) {
|
|
430
334
|
errors.push(
|
|
431
335
|
"SBOM metadata.component.hashes lacks a SHA-256 bundle digest — the aggregate verification anchor is missing; run `npm run refresh-sbom`"
|
|
@@ -459,9 +363,7 @@ function main() {
|
|
|
459
363
|
if (!result.ok) {
|
|
460
364
|
for (const e of result.errors) process.stderr.write(e + "\n");
|
|
461
365
|
process.stderr.write("Run `npm run refresh-sbom` to regenerate sbom.cdx.json.\n");
|
|
462
|
-
//
|
|
463
|
-
// writes drain before the event loop exits. process.exit() can
|
|
464
|
-
// truncate piped output (CI log capture, JSON consumers).
|
|
366
|
+
// process.exitCode, not process.exit() — the exit can truncate a piped write.
|
|
465
367
|
process.exitCode = 1;
|
|
466
368
|
return;
|
|
467
369
|
}
|