@blamejs/exceptd-skills 0.18.6 → 0.18.8
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 +42 -0
- package/bin/exceptd.js +364 -119
- package/data/_indexes/_meta.json +22 -3
- package/data/cve-catalog.json +25 -0
- package/data/playbooks/framework.json +2 -2
- package/data/playbooks/post-quantum-migration.json +1 -1
- package/lib/auto-discovery.js +30 -10
- package/lib/collectors/ai-api.js +9 -2
- package/lib/collectors/cicd-pipeline-compromise.js +24 -5
- package/lib/collectors/cred-stores.js +17 -4
- package/lib/collectors/crypto.js +9 -2
- package/lib/collectors/hardening.js +9 -2
- package/lib/collectors/library-author.js +29 -5
- package/lib/collectors/mcp.js +9 -2
- package/lib/collectors/runtime.js +9 -2
- package/lib/collectors/sbom.js +28 -15
- package/lib/collectors/scan-excludes.js +25 -0
- package/lib/collectors/secrets.js +40 -4
- package/lib/cve-curation.js +84 -8
- package/lib/lint-skills.js +75 -3
- package/lib/playbook-runner.js +443 -50
- package/lib/prefetch.js +22 -2
- package/lib/refresh-external.js +32 -1
- package/lib/refresh-network.js +235 -26
- package/lib/schemas/cve-catalog.schema.json +5 -0
- package/lib/scoring.js +141 -21
- package/lib/sign.js +107 -29
- package/lib/source-advisories.js +23 -5
- package/lib/source-ghsa.js +25 -1
- package/lib/source-osv.js +26 -1
- package/lib/upstream-check.js +1 -1
- package/lib/validate-cve-catalog.js +30 -4
- package/lib/validate-indexes.js +135 -29
- package/lib/validate-playbooks.js +19 -7
- package/lib/validate-vendor.js +69 -8
- package/lib/verify.js +23 -6
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +14 -3
- package/orchestrator/index.js +100 -20
- package/orchestrator/scanner.js +8 -0
- package/package.json +1 -1
- package/sbom.cdx.json +130 -130
- package/scripts/audit-cross-skill.js +1 -1
- package/scripts/bootstrap.js +1 -0
- package/scripts/build-indexes.js +84 -11
- package/scripts/check-agents-md-collectors.js +41 -13
- package/scripts/check-changelog-extract.js +4 -4
- package/scripts/check-codebase-patterns.js +19 -5
- package/scripts/check-manifest-snapshot.js +74 -30
- package/scripts/check-sbom-currency.js +25 -5
- package/scripts/check-test-count.js +26 -7
- package/scripts/check-test-coverage.js +44 -4
- package/scripts/check-version-tags.js +27 -8
- package/scripts/predeploy.js +1 -1
- package/scripts/refresh-manifest-snapshot.js +14 -4
- package/scripts/refresh-reverse-refs.js +7 -1
- package/scripts/refresh-sbom.js +1 -1
- package/scripts/release.js +3 -3
- package/scripts/run-e2e-scenarios.js +18 -8
- package/scripts/validate-vendor-online.js +28 -2
- package/scripts/verify-shipped-tarball.js +65 -6
- package/sources/validators/cve-validator.js +17 -1
- package/vendor/blamejs/_PROVENANCE.json +4 -2
|
@@ -43,10 +43,15 @@ const FRAMEWORK_GAPS_PATH = path.join(REPO_ROOT, 'data', 'framework-control-gaps
|
|
|
43
43
|
// one of these, the entry must carry an `iocs` block per AGENTS.md Hard Rule
|
|
44
44
|
// #14. Surfaced as a warning by default so drafts and pre-IoC entries don't
|
|
45
45
|
// break patch-class compatibility; promoted to an error under --strict.
|
|
46
|
+
// The host-based entries anchor on scheme + host so a lookalike that merely
|
|
47
|
+
// embeds the host as a label (`exploit-db.com.attacker.example`,
|
|
48
|
+
// `not-exploit-db.com`, `evil.com/?ref=packetstormsecurity.com`) is no longer
|
|
49
|
+
// misclassified as a public-exploit source. Verified zero classification
|
|
50
|
+
// change across the live catalog's verification URLs.
|
|
46
51
|
const PUBLIC_EXPLOIT_URL_PATTERNS = [
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
/^https?:\/\/(?:[\w-]+\.)*github\.com\/.+\/(?:exploits?|pocs?)\b/i,
|
|
53
|
+
/^https?:\/\/(?:[\w-]+\.)*exploit-?db\.com(?:[/:?#]|$)/i,
|
|
54
|
+
/^https?:\/\/(?:[\w-]+\.)*packetstormsecurity\.com(?:[/:?#]|$)/i,
|
|
50
55
|
/\bmetasploit\b/i,
|
|
51
56
|
/\/poc\//i,
|
|
52
57
|
/-poc\b/i,
|
|
@@ -215,6 +220,17 @@ function isUsableDate(value) {
|
|
|
215
220
|
}
|
|
216
221
|
const d = new Date(value + 'T00:00:00Z');
|
|
217
222
|
if (Number.isNaN(d.getTime())) return { ok: false, reason: 'unparseable' };
|
|
223
|
+
// Reject impossible calendar dates (Feb 30, Apr 31, Feb 29 in a non-leap
|
|
224
|
+
// year). `new Date('2026-02-30T00:00:00Z')` does NOT throw — it silently
|
|
225
|
+
// rolls over to March 2 and reports a valid getTime() — so a malformed KEV
|
|
226
|
+
// deadline would pass this, the only date-validity guard. Require the parsed
|
|
227
|
+
// Y-M-D to round-trip back to the input components.
|
|
228
|
+
const [yStr, mStr, dStr] = value.split('-');
|
|
229
|
+
if (d.getUTCFullYear() !== Number(yStr) ||
|
|
230
|
+
d.getUTCMonth() + 1 !== Number(mStr) ||
|
|
231
|
+
d.getUTCDate() !== Number(dStr)) {
|
|
232
|
+
return { ok: false, reason: `impossible calendar date ${value}` };
|
|
233
|
+
}
|
|
218
234
|
const year = Number(value.slice(0, 4));
|
|
219
235
|
if (year < MIN_VALID_YEAR || year > MAX_VALID_YEAR) {
|
|
220
236
|
return {
|
|
@@ -262,7 +278,17 @@ function additionalChecks(key, entry, ctx) {
|
|
|
262
278
|
},
|
|
263
279
|
];
|
|
264
280
|
for (const { field, set, file } of REF_FIELDS) {
|
|
265
|
-
if (!set)
|
|
281
|
+
if (!set) {
|
|
282
|
+
// A reference catalog is part of a valid tree, so its absence is not a
|
|
283
|
+
// "defense-in-depth skip" — it means the ref family CANNOT be validated,
|
|
284
|
+
// and an orphaned id would ship unchecked. Surface it (promoted to a hard
|
|
285
|
+
// error under --strict, like an unresolved ref) for any entry that
|
|
286
|
+
// actually carries the field, rather than passing silently.
|
|
287
|
+
if (entry[field] && (Array.isArray(entry[field]) ? entry[field].length > 0 : typeof entry[field] === 'object' && Object.keys(entry[field]).length > 0)) {
|
|
288
|
+
warnings.push(`${key}: cannot validate ${field} — ${file} catalog is absent, so its ids go unchecked (an error under --strict).`);
|
|
289
|
+
}
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
266
292
|
const refs = entry[field];
|
|
267
293
|
// Most ref fields are arrays of id strings (atlas_refs, cwe_refs, ...),
|
|
268
294
|
// but framework_control_gaps is a map keyed BY control id. An
|
package/lib/validate-indexes.js
CHANGED
|
@@ -36,6 +36,122 @@ function sha256(buf) {
|
|
|
36
36
|
return crypto.createHash("sha256").update(buf).digest("hex");
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Confirm every derived index OUTPUT exists, is a real file, and parses as
|
|
41
|
+
* JSON. Returns an array of human-readable issue strings (empty = all good).
|
|
42
|
+
* Source-hash drift alone does NOT catch a deleted/truncated/corrupted output,
|
|
43
|
+
* so this runs in addition to the source-hash comparison.
|
|
44
|
+
*/
|
|
45
|
+
function verifyOutputs(idxDir, outputs) {
|
|
46
|
+
const issues = [];
|
|
47
|
+
// O_NOFOLLOW + a single descriptor: the open atomically refuses a symlinked
|
|
48
|
+
// index path (ELOOP) and every subsequent stat/read rides the same fd, so
|
|
49
|
+
// there is no existsSync->lstat->readFileSync TOCTOU on the path. Where
|
|
50
|
+
// O_NOFOLLOW is unavailable the flag degrades to 0 (plain open).
|
|
51
|
+
const O_NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
|
|
52
|
+
for (const file of outputs) {
|
|
53
|
+
const abs = path.join(idxDir, file);
|
|
54
|
+
let fd;
|
|
55
|
+
try {
|
|
56
|
+
fd = fs.openSync(abs, fs.constants.O_RDONLY | O_NOFOLLOW);
|
|
57
|
+
} catch (e) {
|
|
58
|
+
if (e.code === "ELOOP") {
|
|
59
|
+
issues.push(`derived index file is a symbolic link: data/_indexes/${file}`);
|
|
60
|
+
} else if (e.code === "ENOENT") {
|
|
61
|
+
issues.push(`derived index file missing: data/_indexes/${file} — run npm run build-indexes`);
|
|
62
|
+
} else {
|
|
63
|
+
issues.push(`derived index file unreadable: data/_indexes/${file} (${e.message})`);
|
|
64
|
+
}
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
// readFileSync(fd) loops read() to EOF — a single readSync may return
|
|
69
|
+
// fewer than st.size bytes (short read on a network/FUSE-backed fd),
|
|
70
|
+
// leaving the tail NUL-filled and truncating the JSON. Reading via the
|
|
71
|
+
// open fd keeps the open→fstat ordering TOCTOU-free.
|
|
72
|
+
JSON.parse(fs.readFileSync(fd, "utf8"));
|
|
73
|
+
} catch (e) {
|
|
74
|
+
issues.push(`derived index file does not parse: data/_indexes/${file} (${e.message})`);
|
|
75
|
+
} finally {
|
|
76
|
+
fs.closeSync(fd);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return issues;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Compare a live source set against the recorded source_hashes table and return
|
|
84
|
+
* { drift, missing } message arrays. Pure (no I/O of its own beyond the injected
|
|
85
|
+
* absFn + fs reads of the named sources) so it is unit-testable for the
|
|
86
|
+
* fail-closed edge cases:
|
|
87
|
+
* - a source that vanished between discovery and hashing (TOCTOU) is REPORTED
|
|
88
|
+
* missing, never an unhandled ENOENT crash;
|
|
89
|
+
* - a non-string recorded hash (corrupted _meta.json) is REPORTED as drift,
|
|
90
|
+
* never a TypeError from .slice() of a non-string.
|
|
91
|
+
*
|
|
92
|
+
* @param {Set<string>|string[]} liveSources source paths discovered live
|
|
93
|
+
* @param {Record<string,string>} recorded the _meta.json source_hashes table
|
|
94
|
+
* @param {(p:string)=>string} absFn resolves a source path to absolute
|
|
95
|
+
* @returns {{ drift: string[], missing: string[] }}
|
|
96
|
+
*/
|
|
97
|
+
function checkSourceHashes(liveSources, recorded, absFn) {
|
|
98
|
+
const drift = [];
|
|
99
|
+
const missing = [];
|
|
100
|
+
const recordedKeys = new Set(Object.keys(recorded || {}));
|
|
101
|
+
for (const p of liveSources) {
|
|
102
|
+
if (!recordedKeys.has(p)) {
|
|
103
|
+
missing.push(`new source not in index: ${p}`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const abs = absFn(p);
|
|
107
|
+
// F16 — also check listed-but-symlinked sources. A source can disappear or
|
|
108
|
+
// be swapped between discovery and hashing (TOCTOU); resolve its type and
|
|
109
|
+
// read it defensively so a vanished or newly-symlinked file is REPORTED as
|
|
110
|
+
// missing, never an unhandled ENOENT that crashes the whole validation.
|
|
111
|
+
// Open through a single O_NOFOLLOW descriptor and hash the bytes from THAT
|
|
112
|
+
// descriptor — no path stat-then-read, so there is no check-then-use window.
|
|
113
|
+
// O_NOFOLLOW refuses a symlinked leaf at open (ELOOP) on POSIX; an absent
|
|
114
|
+
// file (ENOENT) is reported missing instead of crashing; the fstat type
|
|
115
|
+
// check refuses a dir/junction/fifo on every platform.
|
|
116
|
+
let live;
|
|
117
|
+
{
|
|
118
|
+
let fd;
|
|
119
|
+
try { fd = fs.openSync(abs, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)); }
|
|
120
|
+
catch (e) {
|
|
121
|
+
if (e.code === 'ENOENT') missing.push(`source file disappeared between discovery and hashing: ${p}`);
|
|
122
|
+
else if (e.code === 'ELOOP') missing.push(`source ${p} is a symbolic link — refusing to follow`);
|
|
123
|
+
else missing.push(`source ${p} could not be opened: ${e.code || e.message}`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
if (!fs.fstatSync(fd).isFile()) {
|
|
128
|
+
missing.push(`source ${p} is not a regular file (symlink / dir / fifo) — refusing`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
live = sha256(fs.readFileSync(fd));
|
|
132
|
+
} catch (e) {
|
|
133
|
+
missing.push(`source ${p} could not be read: ${e.code || e.message}`);
|
|
134
|
+
continue;
|
|
135
|
+
} finally { fs.closeSync(fd); }
|
|
136
|
+
}
|
|
137
|
+
const recordedHash = recorded[p];
|
|
138
|
+
if (typeof recordedHash !== 'string') {
|
|
139
|
+
// A null/non-string recorded hash means a corrupted _meta.json
|
|
140
|
+
// source_hashes map — report it as drift rather than crashing on
|
|
141
|
+
// .slice() of a non-string (fail-closed, not fail-open).
|
|
142
|
+
drift.push(`hash drift: ${p} (recorded entry is not a string: ${JSON.stringify(recordedHash)}, live ${live.slice(0, 12)}…)`);
|
|
143
|
+
} else if (live !== recordedHash) {
|
|
144
|
+
drift.push(`hash drift: ${p} (recorded ${recordedHash.slice(0, 12)}…, live ${live.slice(0, 12)}…)`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const p of recordedKeys) {
|
|
148
|
+
if (!(liveSources instanceof Set ? liveSources.has(p) : liveSources.includes(p))) {
|
|
149
|
+
missing.push(`stale source in index (file removed): ${p}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { drift, missing };
|
|
153
|
+
}
|
|
154
|
+
|
|
39
155
|
function main() {
|
|
40
156
|
if (!fs.existsSync(META)) {
|
|
41
157
|
console.error("[validate-indexes] data/_indexes/_meta.json missing — run `npm run build-indexes`.");
|
|
@@ -92,39 +208,29 @@ function main() {
|
|
|
92
208
|
}
|
|
93
209
|
for (const s of manifest.skills) liveSources.add(s.path);
|
|
94
210
|
|
|
95
|
-
const drift =
|
|
96
|
-
const missing = [];
|
|
97
|
-
const recordedKeys = new Set(Object.keys(recorded));
|
|
211
|
+
const { drift, missing } = checkSourceHashes(liveSources, recorded, ABS);
|
|
98
212
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
if (live !== recorded[p]) {
|
|
116
|
-
drift.push(`hash drift: ${p} (recorded ${recorded[p].slice(0, 12)}…, live ${live.slice(0, 12)}…)`);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
for (const p of recordedKeys) {
|
|
120
|
-
if (!liveSources.has(p)) {
|
|
121
|
-
missing.push(`stale source in index (file removed): ${p}`);
|
|
122
|
-
}
|
|
213
|
+
// Verify the derived index OUTPUTS themselves exist and parse. Source-hash
|
|
214
|
+
// drift alone does not catch a deleted/truncated/corrupted OUTPUT: a clean
|
|
215
|
+
// source tree with a missing index file would otherwise pass as "current".
|
|
216
|
+
// _meta.outputs is the canonical list build-indexes records; an absent list
|
|
217
|
+
// FAILS (rather than silently skipping the check) so an old _meta can't pass.
|
|
218
|
+
// Absent `outputs` list (an older build that predates this field) is itself a
|
|
219
|
+
// staleness condition — fold it into the unified STALE report rather than
|
|
220
|
+
// early-returning, so a concurrent source-hash drift still surfaces too.
|
|
221
|
+
const outputs = Array.isArray(meta.outputs) ? meta.outputs : null;
|
|
222
|
+
if (!outputs || outputs.length === 0) {
|
|
223
|
+
missing.push(
|
|
224
|
+
"data/_indexes/_meta.json has no `outputs` list — rebuild with " +
|
|
225
|
+
"`npm run build-indexes` so derived-index integrity can be confirmed"
|
|
226
|
+
);
|
|
227
|
+
} else {
|
|
228
|
+
for (const issue of verifyOutputs(IDX_DIR, outputs)) missing.push(issue);
|
|
123
229
|
}
|
|
124
230
|
|
|
125
231
|
const issues = [...drift, ...missing];
|
|
126
232
|
if (issues.length === 0) {
|
|
127
|
-
console.log(`[validate-indexes] indexes current — ${
|
|
233
|
+
console.log(`[validate-indexes] indexes current — ${Object.keys(recorded).length} sources hashed at ${meta.generated_at}.`);
|
|
128
234
|
return;
|
|
129
235
|
}
|
|
130
236
|
|
|
@@ -136,4 +242,4 @@ function main() {
|
|
|
136
242
|
|
|
137
243
|
if (require.main === module) main();
|
|
138
244
|
|
|
139
|
-
module.exports = { main };
|
|
245
|
+
module.exports = { main, verifyOutputs, checkSourceHashes };
|
|
@@ -248,7 +248,11 @@ function loadContext() {
|
|
|
248
248
|
const cve = readJson(CVE_PATH);
|
|
249
249
|
const cwe = readJson(CWE_PATH);
|
|
250
250
|
const d3 = readJson(D3FEND_PATH);
|
|
251
|
-
|
|
251
|
+
// Required, like the other ref catalogs (cwe / d3fend). It is committed and
|
|
252
|
+
// always present; loading it optionally let attack_ref validation skip
|
|
253
|
+
// silently when it was absent — the asymmetry that allowed an unresolvable
|
|
254
|
+
// attack_ref to ship. Its absence must fail loud.
|
|
255
|
+
const attack = readJson(ATTACK_PATH);
|
|
252
256
|
|
|
253
257
|
// Closed controlled-vocabulary enums sourced from the schema so the
|
|
254
258
|
// hard-error enum checks in checkCrossRefs stay in lockstep with the
|
|
@@ -266,11 +270,16 @@ function loadContext() {
|
|
|
266
270
|
.jurisdiction_obligations.items.properties.clock_starts.enum || null;
|
|
267
271
|
frameworksEnum =
|
|
268
272
|
schema.properties.domain.properties.frameworks_in_scope.items.enum || null;
|
|
269
|
-
} catch {
|
|
270
|
-
//
|
|
271
|
-
// here
|
|
272
|
-
|
|
273
|
-
|
|
273
|
+
} catch (e) {
|
|
274
|
+
// The playbook schema is a committed, required file. Swallowing a read error
|
|
275
|
+
// here silently disabled the clock_starts / frameworks_in_scope closed-vocab
|
|
276
|
+
// checks, so a typo in those fields could ship. Fail loud instead.
|
|
277
|
+
throw new Error(`validate-playbooks: cannot read playbook schema ${SCHEMA_PATH} — ${e && e.message}. The closed-vocabulary checks must not silently disable.`);
|
|
278
|
+
}
|
|
279
|
+
// A successful parse with the wrong shape (enum path moved/renamed) would also
|
|
280
|
+
// leave the enums null and disable the checks. Treat that as fatal too.
|
|
281
|
+
if (!Array.isArray(clockStartsEnum) || !Array.isArray(frameworksEnum)) {
|
|
282
|
+
throw new Error(`validate-playbooks: playbook schema ${SCHEMA_PATH} did not yield the clock_starts / frameworks_in_scope enums (shape changed). Refusing to validate with the closed-vocab checks silently disabled — fix the schema path expressions in loadContext().`);
|
|
274
283
|
}
|
|
275
284
|
|
|
276
285
|
return {
|
|
@@ -521,7 +530,10 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
521
530
|
// here at error severity.
|
|
522
531
|
if (meta.air_gap_mode === true) {
|
|
523
532
|
const look = phases.look || {};
|
|
524
|
-
|
|
533
|
+
// Case-insensitive + word-bounded so `HTTPS://`, `Curl`, and `fetch(` (no
|
|
534
|
+
// trailing space) still flag a network source — otherwise an artifact could
|
|
535
|
+
// ship under air_gap_mode with no offline alternative and run incomplete.
|
|
536
|
+
const netSourceRe = /(https?:\/\/|\bgh (?:api|release)\b|\bcurl\b|\bwget\b|\bfetch\b)/i;
|
|
525
537
|
for (const [i, art] of (look.artifacts || []).entries()) {
|
|
526
538
|
if (!art || typeof art !== 'object') continue;
|
|
527
539
|
if (typeof art.source === 'string' && netSourceRe.test(art.source)) {
|
package/lib/validate-vendor.js
CHANGED
|
@@ -37,15 +37,23 @@ function main() {
|
|
|
37
37
|
const prov = JSON.parse(fs.readFileSync(PROV, "utf8"));
|
|
38
38
|
const issues = [];
|
|
39
39
|
|
|
40
|
-
// License file.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
// License file. A recorded license_file with NO license_sha256 is an
|
|
41
|
+
// unverifiable integrity claim, not a skip: stripping the hash from the
|
|
42
|
+
// manifest must not silently disable the LICENSE-text check (the
|
|
43
|
+
// absent-field-fails-open class). Only a manifest that records no
|
|
44
|
+
// license_file at all is exempt.
|
|
45
|
+
if (prov.license_file) {
|
|
46
|
+
if (!prov.license_sha256) {
|
|
47
|
+
issues.push(`license_file recorded (${prov.license_file}) without license_sha256 — integrity unverifiable`);
|
|
45
48
|
} else {
|
|
46
|
-
const
|
|
47
|
-
if (
|
|
48
|
-
issues.push(`
|
|
49
|
+
const p = path.join(ROOT, "vendor", "blamejs", prov.license_file);
|
|
50
|
+
if (!fs.existsSync(p)) {
|
|
51
|
+
issues.push(`missing license file: ${prov.license_file}`);
|
|
52
|
+
} else {
|
|
53
|
+
const live = sha256(fs.readFileSync(p));
|
|
54
|
+
if (live !== prov.license_sha256) {
|
|
55
|
+
issues.push(`LICENSE drift: recorded ${prov.license_sha256.slice(0, 12)}…, live ${live.slice(0, 12)}…`);
|
|
56
|
+
}
|
|
49
57
|
}
|
|
50
58
|
}
|
|
51
59
|
}
|
|
@@ -57,10 +65,43 @@ function main() {
|
|
|
57
65
|
issues.push(`missing vendored file: ${info.vendored_path}`);
|
|
58
66
|
continue;
|
|
59
67
|
}
|
|
68
|
+
// A files[] entry recorded without vendored_sha256 is an unverifiable
|
|
69
|
+
// integrity claim — surface it as a clean issue rather than crashing on
|
|
70
|
+
// `undefined.slice()` while formatting a drift message (the absent-field
|
|
71
|
+
// class, symmetric with the license path above).
|
|
72
|
+
if (!info.vendored_sha256) {
|
|
73
|
+
issues.push(`${info.vendored_path} recorded without vendored_sha256 — integrity unverifiable`);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
60
76
|
const live = sha256(fs.readFileSync(p));
|
|
61
77
|
if (live !== info.vendored_sha256) {
|
|
62
78
|
issues.push(`drift in ${info.vendored_path}: recorded ${info.vendored_sha256.slice(0, 12)}…, live ${live.slice(0, 12)}…`);
|
|
63
79
|
}
|
|
80
|
+
// Offline upstream-pin cross-check. The vendored_sha256 compare above is
|
|
81
|
+
// self-attesting — it only proves the file matches its OWN recorded hash,
|
|
82
|
+
// never that the bytes match blamejs@<pin> upstream. The full upstream
|
|
83
|
+
// verification (scripts/validate-vendor-online.js) needs the network and
|
|
84
|
+
// is not a predeploy gate, so a hand-edited upstream_sha256_at_pin
|
|
85
|
+
// advertising a pin that never existed otherwise passes every
|
|
86
|
+
// automatically-run gate.
|
|
87
|
+
//
|
|
88
|
+
// For a file recorded with NO strip rules, the vendored bytes are
|
|
89
|
+
// byte-identical to upstream by definition, so upstream_sha256_at_pin MUST
|
|
90
|
+
// equal vendored_sha256. A divergence is a forged or internally
|
|
91
|
+
// inconsistent pin claim, and it is provable OFFLINE — no fetch required.
|
|
92
|
+
// This closes the upstream side of the integrity check for every
|
|
93
|
+
// unmodified vendored file (the common case) inside the existing gate.
|
|
94
|
+
const stripped = Array.isArray(info.stripped) ? info.stripped : [];
|
|
95
|
+
if (stripped.length === 0) {
|
|
96
|
+
if (!info.upstream_sha256_at_pin) {
|
|
97
|
+
issues.push(`${info.vendored_path} has no strip rules but records no upstream_sha256_at_pin — upstream pin claim unverifiable`);
|
|
98
|
+
} else if (info.upstream_sha256_at_pin !== info.vendored_sha256) {
|
|
99
|
+
issues.push(
|
|
100
|
+
`${info.vendored_path} records no strip rules, so its upstream_sha256_at_pin must equal vendored_sha256, ` +
|
|
101
|
+
`but upstream_sha256_at_pin is ${String(info.upstream_sha256_at_pin).slice(0, 12)}… vs vendored ${info.vendored_sha256.slice(0, 12)}… — forged or inconsistent upstream pin`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
64
105
|
// Smoke-check the vendored module loads.
|
|
65
106
|
try {
|
|
66
107
|
require(p);
|
|
@@ -69,6 +110,26 @@ function main() {
|
|
|
69
110
|
}
|
|
70
111
|
}
|
|
71
112
|
|
|
113
|
+
// On-disk inventory cross-check (BJS-04): the loop above is one-directional
|
|
114
|
+
// (manifest → disk). A .js file PRESENT in vendor/blamejs/ but ABSENT from
|
|
115
|
+
// _PROVENANCE.json would ship in the tarball and be require()'d by lib code
|
|
116
|
+
// while NOTHING verifies its integrity — an unregistered or smuggled-in
|
|
117
|
+
// vendored module. Flag every on-disk .js that the manifest does not record.
|
|
118
|
+
const registered = new Set(
|
|
119
|
+
Object.values(prov.files || {}).map((info) => path.basename(info.vendored_path))
|
|
120
|
+
);
|
|
121
|
+
const vendorDir = path.join(ROOT, "vendor", "blamejs");
|
|
122
|
+
try {
|
|
123
|
+
for (const f of fs.readdirSync(vendorDir)) {
|
|
124
|
+
if (!f.endsWith(".js")) continue;
|
|
125
|
+
if (!registered.has(f)) {
|
|
126
|
+
issues.push(`unregistered vendored file: vendor/blamejs/${f} is on disk but absent from _PROVENANCE.json — its integrity is not verified`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} catch (err) {
|
|
130
|
+
issues.push(`cannot read vendor/blamejs/ for the inventory cross-check: ${err.message}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
72
133
|
if (issues.length === 0) {
|
|
73
134
|
const fileCount = Object.keys(prov.files || {}).length;
|
|
74
135
|
console.log(`[validate-vendor] vendor tree current — ${fileCount} file(s) validated against pin ${prov.pinned_commit?.slice(0, 12) || "?"}.`);
|
package/lib/verify.js
CHANGED
|
@@ -369,8 +369,11 @@ function verifyManifestSignature(manifest) {
|
|
|
369
369
|
// Previously the pin only fired at the CLI tail of `node lib/verify.js`,
|
|
370
370
|
// letting a coordinated attacker who swapped keys/public.pem authenticate
|
|
371
371
|
// against the attacker key without any divergence surfaced through the
|
|
372
|
-
// library API. Honors KEYS_ROTATED=1 for legitimate rotations
|
|
373
|
-
//
|
|
372
|
+
// library API. Honors KEYS_ROTATED=1 for legitimate rotations. A MISSING pin
|
|
373
|
+
// file is now rejected too: keys/EXPECTED_FINGERPRINT ships in the tarball and
|
|
374
|
+
// is committed to the repo, so its absence is not a legacy state — it is the
|
|
375
|
+
// signature a key-substitution attack leaves when it strips the pin to hide a
|
|
376
|
+
// swapped keys/public.pem.
|
|
374
377
|
const liveFp = publicKeyFingerprint(publicKey);
|
|
375
378
|
const pinResult = checkExpectedFingerprint(liveFp);
|
|
376
379
|
if (pinResult.status === 'mismatch' && !pinResult.rotationOverride) {
|
|
@@ -382,6 +385,17 @@ function verifyManifestSignature(manifest) {
|
|
|
382
385
|
actual: pinResult.actual,
|
|
383
386
|
};
|
|
384
387
|
}
|
|
388
|
+
if (pinResult.status === 'no-pin') {
|
|
389
|
+
// A missing pin fails closed unconditionally — there is nothing to override.
|
|
390
|
+
// KEYS_ROTATED only applies to a fingerprint MISMATCH (a new key + a new
|
|
391
|
+
// pin); a legitimate rotation updates the pin in place, it never removes it,
|
|
392
|
+
// so an absent pin is treated as tampering and restoring it is the only fix.
|
|
393
|
+
return {
|
|
394
|
+
status: 'invalid',
|
|
395
|
+
reason: `key-pin absent: keys/EXPECTED_FINGERPRINT is missing, so a swapped keys/public.pem cannot be detected. The pin ships in the tarball and is committed — restore it from the package or version control.`,
|
|
396
|
+
pin_absent: true,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
385
399
|
let signatureBytes;
|
|
386
400
|
try {
|
|
387
401
|
signatureBytes = Buffer.from(sig.signature_base64, 'base64');
|
|
@@ -451,10 +465,13 @@ function loadManifestValidated() {
|
|
|
451
465
|
{ code: 'EXCEPTD_MANIFEST_UNSIGNED' }
|
|
452
466
|
);
|
|
453
467
|
} else if (sigResult.status === 'no-key') {
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
|
|
468
|
+
// The documented contract is "return a VERIFIED manifest or throw" — never
|
|
469
|
+
// an unverified one. verifyAll()/verifyOne() short-circuit on a missing key
|
|
470
|
+
// BEFORE reaching here, so this fires only for a direct
|
|
471
|
+
// loadManifestValidated() caller, which must not silently receive an
|
|
472
|
+
// unauthenticated manifest: an attacker who deletes keys/public.pem could
|
|
473
|
+
// otherwise smuggle a rewritten manifest past a library consumer.
|
|
474
|
+
throw new Error(`[verify] manifest_signature verification FAILED — ${sigResult.reason}. Cannot verify the manifest without keys/public.pem; refusing to return an unauthenticated manifest.`);
|
|
458
475
|
}
|
|
459
476
|
return manifest;
|
|
460
477
|
}
|