@blamejs/exceptd-skills 0.18.5 → 0.18.7

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/bin/exceptd.js +277 -60
  3. package/data/_indexes/_meta.json +22 -3
  4. package/data/cve-catalog.json +25 -0
  5. package/data/playbooks/framework.json +2 -2
  6. package/data/playbooks/post-quantum-migration.json +1 -1
  7. package/lib/auto-discovery.js +30 -10
  8. package/lib/collectors/ai-api.js +9 -2
  9. package/lib/collectors/cicd-pipeline-compromise.js +24 -5
  10. package/lib/collectors/cred-stores.js +17 -4
  11. package/lib/collectors/crypto.js +9 -2
  12. package/lib/collectors/hardening.js +9 -2
  13. package/lib/collectors/library-author.js +24 -3
  14. package/lib/collectors/mcp.js +9 -2
  15. package/lib/collectors/runtime.js +9 -2
  16. package/lib/collectors/sbom.js +28 -15
  17. package/lib/collectors/scan-excludes.js +25 -0
  18. package/lib/collectors/secrets.js +40 -4
  19. package/lib/cve-curation.js +84 -8
  20. package/lib/lint-skills.js +75 -3
  21. package/lib/playbook-runner.js +375 -40
  22. package/lib/prefetch.js +6 -1
  23. package/lib/refresh-external.js +32 -1
  24. package/lib/refresh-network.js +201 -24
  25. package/lib/schemas/cve-catalog.schema.json +5 -0
  26. package/lib/scoring.js +106 -13
  27. package/lib/sign.js +107 -29
  28. package/lib/source-advisories.js +23 -5
  29. package/lib/source-ghsa.js +25 -1
  30. package/lib/source-osv.js +26 -1
  31. package/lib/upstream-check.js +1 -1
  32. package/lib/validate-cve-catalog.js +19 -3
  33. package/lib/validate-indexes.js +62 -1
  34. package/lib/validate-playbooks.js +4 -1
  35. package/lib/validate-vendor.js +69 -8
  36. package/manifest.json +53 -53
  37. package/orchestrator/index.js +69 -13
  38. package/package.json +1 -1
  39. package/sbom.cdx.json +124 -124
  40. package/scripts/audit-cross-skill.js +1 -1
  41. package/scripts/bootstrap.js +1 -0
  42. package/scripts/build-indexes.js +58 -4
  43. package/scripts/check-agents-md-collectors.js +41 -13
  44. package/scripts/check-changelog-extract.js +4 -4
  45. package/scripts/check-codebase-patterns.js +19 -5
  46. package/scripts/check-manifest-snapshot.js +74 -30
  47. package/scripts/check-sbom-currency.js +25 -5
  48. package/scripts/check-test-count.js +26 -7
  49. package/scripts/check-test-coverage.js +44 -4
  50. package/scripts/check-version-tags.js +27 -8
  51. package/scripts/predeploy.js +1 -1
  52. package/scripts/refresh-manifest-snapshot.js +14 -4
  53. package/scripts/refresh-reverse-refs.js +7 -1
  54. package/scripts/refresh-sbom.js +1 -1
  55. package/scripts/release.js +3 -3
  56. package/scripts/run-e2e-scenarios.js +18 -8
  57. package/scripts/validate-vendor-online.js +20 -2
  58. package/scripts/verify-shipped-tarball.js +65 -6
  59. package/sources/validators/cve-validator.js +17 -1
  60. package/vendor/blamejs/_PROVENANCE.json +4 -2
@@ -4,8 +4,17 @@
4
4
  /**
5
5
  * scripts/check-version-tags.js
6
6
  *
7
- * Refuses NEW version-stamped comments / filenames in the tracked
8
- * source tree. The authoritative version surfaces are:
7
+ * Refuses NEW version-stamped lines / filenames in the tracked source
8
+ * tree. "Line" is deliberate and broader than "comment": a 0.x version
9
+ * stamp is residue wherever a stranger reads it, which per the
10
+ * operator-facing-surface rules includes string literals that ship to
11
+ * operators — CLI `--help` text, error messages, and test descriptions —
12
+ * not just `//` comments. The scan therefore tests the WHOLE line, so a
13
+ * `version: '0.18.7'` data literal or a `--flag (v0.18.7)` help string
14
+ * counts the same as a `// v0.18.7` comment. Genuinely-load-bearing
15
+ * version references (real test fixtures, deprecation timelines) get the
16
+ * file added to COMMENT_EXEMPT below. The authoritative version surfaces
17
+ * are:
9
18
  *
10
19
  * 1. package.json / manifest.json `"version"` field
11
20
  * 2. CHANGELOG.md `## X.Y.Z` headings
@@ -22,7 +31,7 @@
22
31
  * compare against the baseline:
23
32
  *
24
33
  * - Filename violations beyond baseline → fail.
25
- * - Comment violations beyond baseline (in any file) → fail.
34
+ * - Line violations beyond baseline (in any file) → fail.
26
35
  * - Violations strictly within baseline → ok.
27
36
  * - Violations below baseline (drift reduced) → ok +
28
37
  * suggestion to refresh the baseline.
@@ -106,7 +115,11 @@ function gitIgnoredSet(relPaths) {
106
115
  // Pattern: project version like `v0.13.22` or bare `0.13.22`. Matches
107
116
  // our pre-1.0 release range. External package versions like ATLAS
108
117
  // `v5.6.0` or CycloneDX `1.6` don't match because the major is 0.
109
- const VERSION_TAG_RE = /\bv?0\.\d+\.\d+\b/;
118
+ // The dot/digit lookarounds keep an IPv4 octet run (e.g. `127.0.0.1`, whose
119
+ // `0.0.1` tail would otherwise count as a version tag) and any longer
120
+ // dotted-numeric sequence from registering — a release version is never
121
+ // embedded inside a larger digit.digit run.
122
+ const VERSION_TAG_RE = /(?<![\d.])v?0\.\d+\.\d+(?![\d.])/;
110
123
 
111
124
  // Phase residue patterns — broader than just version tags.
112
125
  const PHASE_RESIDUE_RES = [
@@ -135,7 +148,13 @@ function walk(dir, results = []) {
135
148
  return results;
136
149
  }
137
150
 
138
- function countCommentViolations(rel) {
151
+ // Counts version-stamp lines in a file. Intentionally WHOLE-LINE, not
152
+ // comment-only: a 0.x stamp inside a shipped string literal (CLI --help text,
153
+ // an error message, a test description) is operator-readable residue just like
154
+ // a `//` comment, so it counts the same. A file with a genuinely load-bearing
155
+ // version literal (real test fixture, deprecation timeline) is exempted by path
156
+ // in COMMENT_EXEMPT, not by narrowing the scan.
157
+ function countLineViolations(rel) {
139
158
  if (COMMENT_EXEMPT.has(rel)) return 0;
140
159
  const ext = path.extname(rel);
141
160
  if (!SCAN_EXTS.has(ext)) return 0;
@@ -163,7 +182,7 @@ function scanCurrent() {
163
182
  // scanned — a new file about to be committed is what the gate guards.
164
183
  if (ignored.has(rel)) continue;
165
184
  if (FILENAME_VERSION_RE.test(rel)) filenameViolations.push(rel);
166
- const n = countCommentViolations(rel);
185
+ const n = countLineViolations(rel);
167
186
  if (n > 0) byFile[rel] = n;
168
187
  }
169
188
  return { byFile, filenameViolations };
@@ -238,7 +257,7 @@ function main() {
238
257
  path: rel,
239
258
  baseline: prior,
240
259
  actual: n,
241
- reason: `comment-level version-tag count grew from ${prior} to ${n} — describe the WHY of the current code, not the release that introduced it`,
260
+ reason: `version-tag line count grew from ${prior} to ${n} (counts stamps in comments, strings, and data) — describe the WHY of the current code, not the release that introduced it`,
242
261
  });
243
262
  }
244
263
  }
@@ -254,7 +273,7 @@ function main() {
254
273
  path: rel,
255
274
  baseline: 0,
256
275
  actual: n,
257
- reason: `new file carries ${n} version-tag comment(s) — describe the WHY of the current code, not the release that introduced it`,
276
+ reason: `new file carries ${n} version-tag line(s) (counts stamps in comments, strings, and data) — describe the WHY of the current code, not the release that introduced it`,
258
277
  });
259
278
  }
260
279
  }
@@ -422,7 +422,7 @@ function main() {
422
422
  ".\n"
423
423
  );
424
424
 
425
- process.exit(failures.length > 0 ? 1 : 0);
425
+ process.exit(failures.length > 0 ? 1 : 0); // allow:process-exit-after-stdout-write — local-only gate runner; output is the human/CI summary written synchronously above, never a piped --json result channel
426
426
  }
427
427
 
428
428
  module.exports = { GATES };
@@ -71,8 +71,18 @@ const commitOnly =
71
71
  argv.includes("--commit-only") ||
72
72
  process.env.EXCEPTD_SNAPSHOT_AUDIT_ACK === "1";
73
73
 
74
- if (fs.existsSync(SNAPSHOT_PATH) && !commitOnly) {
75
- const current = fs.readFileSync(SNAPSHOT_PATH, "utf8");
74
+ // Read the committed snapshot once and branch on the read RESULT rather than
75
+ // an existsSync(SNAPSHOT_PATH)-then-readFileSync probe — the latter is a
76
+ // check-then-use window (CodeQL js/file-system-race) where the existence the
77
+ // guard decides on may not be the file it then reads. ENOENT IS the "no prior
78
+ // snapshot, write a fresh one" signal.
79
+ let current = null;
80
+ try {
81
+ current = fs.readFileSync(SNAPSHOT_PATH, "utf8");
82
+ } catch (e) {
83
+ if (e.code !== "ENOENT") throw e;
84
+ }
85
+ if (current !== null && !commitOnly) {
76
86
  // Normalise the _generated_at timestamp for comparison — that field
77
87
  // changes every run and shouldn't trigger the guard.
78
88
  const stripGenerated = (s) => s.replace(
@@ -80,7 +90,7 @@ if (fs.existsSync(SNAPSHOT_PATH) && !commitOnly) {
80
90
  );
81
91
  if (stripGenerated(current) === stripGenerated(newJson)) {
82
92
  console.log("[refresh-manifest-snapshot] snapshot unchanged — nothing to do.");
83
- process.exit(0);
93
+ process.exit(0); // allow:process-exit-after-stdout-write — local snapshot-refresh tool; the status line above is human-read on a TTY, not a piped --json result channel
84
94
  }
85
95
  process.stderr.write(
86
96
  "[refresh-manifest-snapshot] REFUSING to overwrite manifest-snapshot.json — " +
@@ -90,7 +100,7 @@ if (fs.existsSync(SNAPSHOT_PATH) && !commitOnly) {
90
100
  "force a deliberate decision about removed skills / triggers / refs before " +
91
101
  "the baseline is rewritten.\n"
92
102
  );
93
- process.exit(1);
103
+ process.exit(1); // allow:process-exit-after-stdout-write — local snapshot-refresh tool; the refusal note above goes to stderr on a TTY, not a piped --json result channel
94
104
  }
95
105
 
96
106
  fs.writeFileSync(SNAPSHOT_PATH, newJson, "utf8");
@@ -222,7 +222,13 @@ function rebuildCatalog(cfg, manifest, cveCatalog) {
222
222
  }
223
223
 
224
224
  // Surface forward refs that point at catalog entries that don't exist.
225
- // Not fatal here — that's a separate validation concern — but we report.
225
+ // Informational only — orphans never change the exit code (this script is
226
+ // unconditionally write-mode, exit 0 always; see the file header). The
227
+ // failing gates for the two signals this script can surface live
228
+ // elsewhere: orphan forward refs are hard-errored by lib/lint-skills.js
229
+ // ref-resolution ("<id> not present in data/<catalog>"), and reverse-field
230
+ // drift is hard-failed by tests/reverse-ref-drift.test.js. Do not wire
231
+ // this script itself as a "reverse refs clean?" check — it always passes.
226
232
  for (const id of index.keys()) {
227
233
  if (!seenIds.has(id)) orphans.push(id);
228
234
  }
@@ -338,7 +338,7 @@ function buildSbom() {
338
338
  version: pkg.version,
339
339
  description: pkg.description,
340
340
  licenses: [{ license: { id: 'Apache-2.0' } }],
341
- purl: `pkg:npm/${pkg.name.replace('@', '%40')}@${pkg.version}`,
341
+ purl: `pkg:npm/${pkg.name.replace(/@/g, '%40')}@${pkg.version}`,
342
342
  // Bundle digest over every shipped file (see bundleDigest above
343
343
  // for the canonical-input rule). Operators can recompute this
344
344
  // from the per-file components[] list and compare without
@@ -413,7 +413,7 @@ function cmdWatch() {
413
413
  console.log("\nfailed checks (" + failed.length + "):");
414
414
  failed.forEach(function (c) { console.log(" ✗ " + c.name + " " + (c.link || "")); });
415
415
  console.log("\nFix in code, push, then re-run: node scripts/release.js watch");
416
- process.exit(3);
416
+ process.exit(3); // allow:process-exit-after-stdout-write — maintainer-run release orchestrator; the guidance line above is human-read on a TTY, not a piped result channel
417
417
  }
418
418
 
419
419
  // CodeQL SAST gate — a standing per-release step. An open, un-triaged CodeQL
@@ -432,7 +432,7 @@ function cmdWatch() {
432
432
  });
433
433
  console.log("\nFix real findings in code (push, let CodeQL re-scan) OR dismiss by-design FPs with a " +
434
434
  "written reason, then re-run: node scripts/release.js watch");
435
- process.exit(3);
435
+ process.exit(3); // allow:process-exit-after-stdout-write — maintainer-run release orchestrator; the guidance line above is human-read on a TTY, not a piped result channel
436
436
  } else {
437
437
  _ok("zero open CodeQL alerts");
438
438
  }
@@ -445,7 +445,7 @@ function cmdWatch() {
445
445
  if (c) console.log(" - by " + c.author.login + ": " + c.body.split("\n")[0]);
446
446
  });
447
447
  console.log("\nFix in code, push, resolve the thread, then re-run: node scripts/release.js watch");
448
- process.exit(3);
448
+ process.exit(3); // allow:process-exit-after-stdout-write — maintainer-run release orchestrator; the guidance line above is human-read on a TTY, not a piped result channel
449
449
  }
450
450
  _ok("zero unresolved review threads");
451
451
  console.log("\nnext: node scripts/release.js merge");
@@ -252,16 +252,26 @@ function runScenario(scenarioPath) {
252
252
  }
253
253
  }
254
254
 
255
+ // Select scenario directories under SCENARIO_DIR, optionally narrowed by a
256
+ // filter. The filter is matched as a plain substring (String.includes), NOT
257
+ // compiled with new RegExp: a regex built from a CLI argument is a regex-
258
+ // injection / ReDoS vector, and scenario names are literal NN-name strings, so
259
+ // substring selection is behavior-equivalent for every legitimate filter
260
+ // (e.g. --filter=library-author). Returns sorted basenames.
261
+ function selectScenarios(filterStr, dir = SCENARIO_DIR) {
262
+ return fs.readdirSync(dir)
263
+ .filter(d => /^\d+-/.test(d))
264
+ .filter(d => !filterStr || d.includes(filterStr))
265
+ .sort();
266
+ }
267
+
255
268
  function main() {
256
269
  const filter = process.argv.find(a => a.startsWith("--filter="));
257
- const filterRe = filter ? new RegExp(filter.slice("--filter=".length)) : null;
270
+ const filterStr = filter ? filter.slice("--filter=".length) : null;
258
271
  const json = process.argv.includes("--json");
259
272
 
260
- const scenarios = fs.readdirSync(SCENARIO_DIR)
261
- .filter(d => /^\d+-/.test(d))
262
- .filter(d => !filterRe || filterRe.test(d))
263
- .map(d => path.join(SCENARIO_DIR, d))
264
- .sort();
273
+ const scenarios = selectScenarios(filterStr)
274
+ .map(d => path.join(SCENARIO_DIR, d));
265
275
 
266
276
  const results = [];
267
277
  for (const s of scenarios) {
@@ -293,9 +303,9 @@ function main() {
293
303
  process.stdout.write(`\n${passed.length}/${results.length} scenarios passed${failed.length ? `, ${failed.length} failed` : ""}${skipped.length ? `, ${skipped.length} skipped` : ""}.\n`);
294
304
  }
295
305
 
296
- process.exit(failed.length === 0 ? 0 : 1);
306
+ process.exit(failed.length === 0 ? 0 : 1); // allow:process-exit-after-stdout-write — local e2e runner; the pass/fail summary above is human/CI-read on a TTY, not a piped --json result channel
297
307
  }
298
308
 
299
- module.exports = { evaluateScenario, diffExpect, tryParseJson, stderrBanFailures, runScenario, SCENARIO_DIR };
309
+ module.exports = { evaluateScenario, diffExpect, tryParseJson, stderrBanFailures, runScenario, selectScenarios, SCENARIO_DIR };
300
310
 
301
311
  if (require.main === module) main();
@@ -48,10 +48,10 @@ function parseArgs(argv) {
48
48
  process.stdout.write(
49
49
  "Usage: node scripts/validate-vendor-online.js [--timeout <ms>] [--json]\n"
50
50
  );
51
- process.exit(0);
51
+ process.exit(0); // allow:process-exit-after-stdout-write — local vendor-check script; the usage line above is human-read on a TTY, not a piped --json result channel
52
52
  } else {
53
53
  process.stderr.write(`Unknown argument: ${a}\n`);
54
- process.exit(2);
54
+ process.exit(2); // allow:process-exit-after-stdout-write — local vendor-check script; the error note goes to stderr on a TTY, not a piped --json result channel
55
55
  }
56
56
  }
57
57
  return out;
@@ -71,8 +71,26 @@ function rawUrlForPin(sourceRepo, commit, upstreamPath) {
71
71
 
72
72
  const MAX_REDIRECTS = 5;
73
73
 
74
+ // Only GitHub-controlled hosts may be fetched. The initial URL is always a
75
+ // raw.githubusercontent.com URL computed from the committed _PROVENANCE.json,
76
+ // but redirects re-enter fetchBuffer with a server-supplied Location; pinning
77
+ // the host to github.com / *.githubusercontent.com stops a redirect (or a
78
+ // tampered provenance source_repo) from steering the fetch at an internal or
79
+ // attacker-controlled address.
80
+ const ALLOWED_FETCH_HOST = /(?:^|\.)githubusercontent\.com$|^github\.com$/;
81
+
82
+ function assertAllowedHost(url) {
83
+ let host;
84
+ try { host = new URL(url).hostname.toLowerCase(); }
85
+ catch { throw new Error(`unparseable fetch URL: ${url}`); }
86
+ if (!ALLOWED_FETCH_HOST.test(host)) {
87
+ throw new Error(`refusing to fetch from non-allowlisted host: ${host}`);
88
+ }
89
+ }
90
+
74
91
  function fetchBuffer(url, timeoutMs, redirectsLeft = MAX_REDIRECTS) {
75
92
  return new Promise((resolve, reject) => {
93
+ try { assertAllowedHost(url); } catch (e) { return reject(e); }
76
94
  const req = https.get(url, (res) => {
77
95
  // v0.12.14 (codex P2): cap redirect hops. A redirect loop (or a
78
96
  // hostile / mis-configured upstream that keeps returning 3xx with
@@ -108,12 +108,48 @@ function verifyExtractedManifestSignature(manifest, publicKeyPem) {
108
108
  return ok ? { status: "valid" } : { status: "invalid", reason: "Ed25519 manifest signature did not verify against extracted public.pem" };
109
109
  }
110
110
 
111
+ // Decide one skill entry's verification outcome. Pulled out as a pure
112
+ // function so the missing-signature guard is unit-testable without
113
+ // spawning `npm pack`. Mirrors lib/verify.js verifySkill()'s missing_sig
114
+ // branch: a manifest skill entry MAY legally omit `signature` (it is not
115
+ // in the schema's skillEntry.required set — a freshly-added skill, or one
116
+ // skipped during a partial sign-all, leaves it absent while the manifest
117
+ // envelope is still validly re-signed over that field-absent state). Such
118
+ // an entry must be a STRUCTURED miss, not Buffer.from(undefined,'base64')
119
+ // throwing a TypeError that the script body's catch re-throws as an
120
+ // uncaught stack trace.
121
+ // { status: 'missing' } — no Ed25519 signature in manifest
122
+ // { status: 'pass' } — signature verified
123
+ // { status: 'fail' } — signature did not verify
124
+ // { status: 'fail', reason: str } — malformed base64 / verify threw
125
+ function verifySkillSignatureOutcome(s, normalizedContent, pubKey) {
126
+ const cryptoMod = require("crypto");
127
+ if (typeof s.signature !== "string" || s.signature.length === 0) {
128
+ return { status: "missing" };
129
+ }
130
+ let sigBytes;
131
+ try {
132
+ sigBytes = Buffer.from(s.signature, "base64");
133
+ } catch (e) {
134
+ return { status: "fail", reason: `malformed base64 signature: ${e.message}` };
135
+ }
136
+ let ok = false;
137
+ try {
138
+ ok = cryptoMod.verify(null, normalizedContent, pubKey, sigBytes);
139
+ } catch (e) {
140
+ return { status: "fail", reason: `crypto.verify threw: ${e.message}` };
141
+ }
142
+ return ok ? { status: "pass" } : { status: "fail" };
143
+ }
144
+
111
145
  // Exported so tests/normalize-contract.test.js can assert byte-identical
112
- // normalize() behavior across all four implementations.
146
+ // normalize() behavior across all four implementations, and so the
147
+ // missing-signature guard above is unit-testable.
113
148
  module.exports = {
114
149
  normalizeSkillBytes,
115
150
  verifyExtractedManifestSignature,
116
151
  canonicalManifestBytesForTarball,
152
+ verifySkillSignatureOutcome,
117
153
  };
118
154
 
119
155
  const ROOT = path.resolve(__dirname, "..");
@@ -156,8 +192,21 @@ try {
156
192
  }
157
193
  const tarballName = pack.stdout.trim().split(/\r?\n/).filter(Boolean).pop();
158
194
  const tarballPath = path.join(tmpRoot, tarballName);
159
- if (!fs.existsSync(tarballPath)) fail(`expected tarball at ${tarballPath}, not found`, 2);
160
- emit(`tarball: ${tarballPath} (${fs.statSync(tarballPath).size} bytes)`);
195
+ // Open the packed tarball once and fstat + read via the descriptor so the
196
+ // existence check, size report, and read all observe the same inode — no
197
+ // existsSync→statSync→readFileSync TOCTOU on the path.
198
+ let tgz;
199
+ try {
200
+ const fd = fs.openSync(tarballPath, "r");
201
+ try {
202
+ const st = fs.fstatSync(fd);
203
+ emit(`tarball: ${tarballPath} (${st.size} bytes)`);
204
+ tgz = Buffer.alloc(st.size);
205
+ fs.readSync(fd, tgz, 0, st.size, 0);
206
+ } finally { fs.closeSync(fd); }
207
+ } catch (e) {
208
+ fail(`expected tarball at ${tarballPath}: ${e.message}`, 2);
209
+ }
161
210
 
162
211
  // Extract via Node — bypasses GNU tar's "C:..." path quirk on Windows
163
212
  // where it interprets the colon as a remote-host separator.
@@ -165,7 +214,6 @@ try {
165
214
  fs.mkdirSync(extractDir, { recursive: true });
166
215
  const zlib = require("zlib");
167
216
  const { parseTar: parseTarSource } = require(path.join(ROOT, "lib", "refresh-network.js"));
168
- const tgz = fs.readFileSync(tarballPath);
169
217
  const tarBuf = zlib.gunzipSync(tgz);
170
218
  const entries = parseTarSource(tarBuf);
171
219
  for (const e of entries) {
@@ -353,7 +401,17 @@ try {
353
401
  // pack. This is the recurring CRLF/line-ending-bypass class.
354
402
  const rawContent = fs.readFileSync(skillPath);
355
403
  const normalizedContent = normalizeSkillBytes(rawContent);
356
- const ok = crypto.verify(null, normalizedContent, pubKey, Buffer.from(s.signature, "base64"));
404
+ const outcome = verifySkillSignatureOutcome(s, normalizedContent, pubKey);
405
+ // A skill entry that legally omits `signature` (not in the schema's
406
+ // required set) must be a STRUCTURED miss — not Buffer.from(undefined,
407
+ // 'base64') throwing a TypeError that the body's catch re-throws as an
408
+ // uncaught stack trace. Mirrors lib/verify.js verifySkill()'s missing_sig.
409
+ if (outcome.status === "missing") {
410
+ miss++;
411
+ failures.push(`${s.name}: no Ed25519 signature in manifest`);
412
+ continue;
413
+ }
414
+ const ok = outcome.status === "pass";
357
415
  if (ok) pass++;
358
416
  else {
359
417
  fail_count++;
@@ -372,7 +430,8 @@ try {
372
430
  srcSha = crypto.createHash("sha256").update(normalizeSkillBytes(srcContent)).digest("hex").slice(0, 16);
373
431
  }
374
432
  const equal = srcContent && rawContent.equals(srcContent) ? "equal" : "DIFFER";
375
- failures.push(`${s.name}: signature did not verify (tarball size=${rawContent.length} sha-normalized=${tarSha}; source size=${srcSize} sha-normalized=${srcSha}; raw bytes ${equal})`);
433
+ const reasonSuffix = outcome.reason ? ` [${outcome.reason}]` : "";
434
+ failures.push(`${s.name}: signature did not verify${reasonSuffix} (tarball size=${rawContent.length} sha-normalized=${tarSha}; source size=${srcSize} sha-normalized=${srcSha}; raw bytes ${equal})`);
376
435
  }
377
436
  }
378
437
 
@@ -107,6 +107,11 @@ function extractNvdCvss(nvdJson) {
107
107
  return {
108
108
  score: up.baseScore,
109
109
  vector: up.vector,
110
+ // The DECLARED CVSS version (from cvssData.version / the metric bucket),
111
+ // independent of the vector string — NVD can carry a baseScore with a
112
+ // missing/empty vectorString, and the downgrade guard needs the version
113
+ // even then.
114
+ version: up.version,
110
115
  source: up.source,
111
116
  };
112
117
  }
@@ -170,6 +175,7 @@ async function validateCve(cveId, localEntry) {
170
175
  const fetched = {
171
176
  cvss_score: null,
172
177
  cvss_vector: null,
178
+ cvss_version: null,
173
179
  in_kev: null,
174
180
  kev_date: null,
175
181
  epss: null,
@@ -208,6 +214,7 @@ async function validateCve(cveId, localEntry) {
208
214
  if (cvss) {
209
215
  fetched.cvss_score = cvss.score;
210
216
  fetched.cvss_vector = cvss.vector;
217
+ fetched.cvss_version = cvss.version;
211
218
  fetched.sources.nvd = { reachable: true, found: true, source: cvss.source };
212
219
  } else {
213
220
  fetched.sources.nvd = { reachable: true, found: true, source: null, note: 'no cvss metrics in NVD response' };
@@ -276,7 +283,16 @@ async function validateCve(cveId, localEntry) {
276
283
  // suppression (lib/refresh-external.js nvdDiffFromCache) so the live and
277
284
  // cache refresh paths converge; a same-version re-score still flows through.
278
285
  const localCvssVersion = cvssVersionOf(local.cvss_vector);
279
- const fetchedCvssVersion = cvssVersionOf(fetched.cvss_vector);
286
+ // Prefer the upstream metric's DECLARED version over deriving it from the
287
+ // vector string: NVD can return a baseScore with a missing/empty
288
+ // vectorString, and cvssVersionOf(null) is null — which would defeat the
289
+ // downgrade guard on the live path (a v2 score with no vector would surface
290
+ // as a drift over a curated v3.x) while the cache path, which uses the
291
+ // declared up.version, still suppressed it. Fall back to the vector only when
292
+ // the declared version is absent.
293
+ const fetchedCvssVersion = fetched.cvss_version != null
294
+ ? fetched.cvss_version
295
+ : cvssVersionOf(fetched.cvss_vector);
280
296
  const cvssIsDowngrade =
281
297
  fetchedCvssVersion != null && localCvssVersion != null && fetchedCvssVersion < localCvssVersion;
282
298
  if (cveFoundInNvd && !cvssIsDowngrade) {
@@ -57,8 +57,10 @@
57
57
  "vendored_path": "vendor/blamejs/codepoint-class.js",
58
58
  "vendored_sha256": "2be79cf25de87f46b608aec98ee790f4cf1035ffee48fe70ff082d3cf6f324ba",
59
59
  "upstream_path": "lib/codepoint-class.js",
60
- "upstream_sha256_at_pin": "2be79cf25de87f46b608aec98ee790f4cf1035ffee48fe70ff082d3cf6f324ba",
61
- "stripped": [],
60
+ "upstream_sha256_at_pin": "18bcf1e99d168845a41c34e351e2323951319d2054634ca5021b002093e0fc03",
61
+ "stripped": [
62
+ "`// allow:raw-byte-literal — …` lint-suppression markers on 12 inline comments (the raw-byte-literal codebase-pattern rule does not scan vendor/, so the marker prefix is dropped to `// …`; comment text otherwise preserved)"
63
+ ],
62
64
  "surface_preserved": [
63
65
  "BIDI_RE / C0_CTRL_RE / ZERO_WIDTH_RE / NULL_BYTE (classification regexes)",
64
66
  "BIDI_RE_G / C0_CTRL_RE_G / ZW_RE_G / NULL_RE_G (global strip regexes)",