@blamejs/exceptd-skills 0.18.6 → 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 +20 -0
  2. package/bin/exceptd.js +261 -56
  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
package/lib/sign.js CHANGED
@@ -52,9 +52,12 @@
52
52
  * .keys/private.pem on Windows, restrictWindowsAcl() shells to icacls
53
53
  * to strip inherited entries and grant Full Control only to the current
54
54
  * user. If icacls is unavailable (Server Core, exotic shells), the call
55
- * warns to stderr but does not fail keypair generation — generating the
56
- * key was the load-bearing step; ACL tightening is best-effort hardening
57
- * on top.
55
+ * warns to stderr and generateKeypair() returns { aclHardened: false }.
56
+ * The CLI dispatch then exits non-zero so automation (bootstrap.js,
57
+ * doctor --fix) does not treat an unhardened key as a clean generation —
58
+ * set EXCEPTD_ALLOW_WEAK_KEY_ACL=1 to accept the weaker ACL on a
59
+ * single-user host. (The key write itself still succeeds; the non-zero
60
+ * exit signals "key present but not ACL-hardened," not "no key.")
58
61
  *
59
62
  * Signing ceremony:
60
63
  * 1. node lib/sign.js generate-keypair — generate keypair (one time, per deployment)
@@ -78,6 +81,12 @@ const path = require('path');
78
81
  const crypto = require('crypto');
79
82
  const { execFileSync } = require('child_process');
80
83
  const { safeExit } = require('./exit-codes');
84
+ // Reuse the EXACT validator lib/verify.js applies at load time, so the signer
85
+ // and the verifier can never disagree about what a well-formed manifest is.
86
+ // Requiring verify.js is side-effect-free (its CLI block is guarded by
87
+ // `require.main === module`) and verify.js does not require sign.js, so there
88
+ // is no circular dependency.
89
+ const { validateAgainstSchema } = require('./verify');
81
90
 
82
91
  const ROOT = path.join(__dirname, '..');
83
92
  const MANIFEST_PATH = path.join(ROOT, 'manifest.json');
@@ -85,6 +94,7 @@ const KEYS_DIR = path.join(ROOT, '.keys');
85
94
  const PUBLIC_KEYS_DIR = path.join(ROOT, 'keys');
86
95
  const PRIVATE_KEY_PATH = path.join(KEYS_DIR, 'private.pem');
87
96
  const PUBLIC_KEY_PATH = path.join(PUBLIC_KEYS_DIR, 'public.pem');
97
+ const MANIFEST_SCHEMA_PATH = path.join(__dirname, 'schemas', 'manifest.schema.json');
88
98
 
89
99
  // --- public API ---
90
100
 
@@ -96,28 +106,6 @@ const PUBLIC_KEY_PATH = path.join(PUBLIC_KEYS_DIR, 'public.pem');
96
106
  * @param {{ rotate: boolean }} options
97
107
  */
98
108
  function generateKeypair({ rotate = false } = {}) {
99
- if (fs.existsSync(PRIVATE_KEY_PATH) && !rotate) {
100
- console.error('[sign] Private key already exists at .keys/private.pem');
101
- console.error('[sign] Use --rotate to generate a new keypair and invalidate existing signatures.');
102
- process.exit(1);
103
- }
104
-
105
- // Refuse to silently overwrite an existing public key when no private key
106
- // is present. This is the v0.11.x signature-regression class: a host with
107
- // a working pubkey but missing privkey running generate-keypair would
108
- // produce a fresh pubkey divergent from every shipped signature. Operators
109
- // running `exceptd doctor --fix` on a stock install would replace the
110
- // shipped keys/public.pem with one whose private half exists only on
111
- // their machine — every subsequent verify against shipped signatures fails.
112
- // Force the operator to be explicit via --rotate (which signals intent to
113
- // re-sign).
114
- if (fs.existsSync(PUBLIC_KEY_PATH) && !rotate) {
115
- console.error('[sign] Public key already exists at keys/public.pem but no matching private key.');
116
- console.error('[sign] Refusing to overwrite the public key — that would orphan every existing signature.');
117
- console.error('[sign] If you are setting up a fresh signing identity, pass --rotate to confirm. After --rotate you must re-sign all skills with sign-all.');
118
- process.exit(1);
119
- }
120
-
121
109
  fs.mkdirSync(KEYS_DIR, { recursive: true, mode: 0o700 });
122
110
  fs.mkdirSync(PUBLIC_KEYS_DIR, { recursive: true });
123
111
 
@@ -126,8 +114,49 @@ function generateKeypair({ rotate = false } = {}) {
126
114
  publicKeyEncoding: { type: 'spki', format: 'pem' }
127
115
  });
128
116
 
129
- fs.writeFileSync(PRIVATE_KEY_PATH, privateKey, { encoding: 'utf8', mode: 0o600 });
130
- fs.writeFileSync(PUBLIC_KEY_PATH, publicKey, { encoding: 'utf8', mode: 0o644 });
117
+ // Atomic create-exclusive (flag 'wx' = O_CREAT|O_EXCL): the open itself
118
+ // refuses an existing key — there is no separate existsSync to race against,
119
+ // and exclusive-create won't follow a symlink/file an attacker preplanted at
120
+ // the key path. EEXIST translates to the same operator-facing refusals the
121
+ // bootstrap has always surfaced; --rotate is a deliberate re-key (flag 'w').
122
+ // The public-key refusal is the v0.11.x signature-regression guard: a host
123
+ // with a working pubkey but no privkey must NOT silently regenerate the
124
+ // pubkey (that orphans every shipped signature) — force --rotate to confirm.
125
+ const openFlag = rotate ? 'w' : 'wx';
126
+ let privFd;
127
+ try {
128
+ privFd = fs.openSync(PRIVATE_KEY_PATH, openFlag, 0o600);
129
+ } catch (e) {
130
+ if (e.code === 'EEXIST') {
131
+ console.error('[sign] Private key already exists at .keys/private.pem');
132
+ console.error('[sign] Use --rotate to generate a new keypair and invalidate existing signatures.');
133
+ process.exit(1);
134
+ }
135
+ throw e;
136
+ }
137
+ let pubFd;
138
+ try {
139
+ pubFd = fs.openSync(PUBLIC_KEY_PATH, openFlag, 0o644);
140
+ } catch (e) {
141
+ fs.closeSync(privFd);
142
+ // A pubkey clash after we exclusively created the privkey must not leave a
143
+ // half-written signing identity behind.
144
+ try { fs.unlinkSync(PRIVATE_KEY_PATH); } catch { /* best effort */ }
145
+ if (e.code === 'EEXIST') {
146
+ console.error('[sign] Public key already exists at keys/public.pem but no matching private key.');
147
+ console.error('[sign] Refusing to overwrite the public key — that would orphan every existing signature.');
148
+ console.error('[sign] If you are setting up a fresh signing identity, pass --rotate to confirm. After --rotate you must re-sign all skills with sign-all.');
149
+ process.exit(1);
150
+ }
151
+ throw e;
152
+ }
153
+ try {
154
+ fs.writeSync(privFd, privateKey);
155
+ fs.writeSync(pubFd, publicKey);
156
+ } finally {
157
+ fs.closeSync(privFd);
158
+ fs.closeSync(pubFd);
159
+ }
131
160
 
132
161
  // on win32, fs.writeFileSync `mode` does not produce
133
162
  // a POSIX-style restrictive ACL. Tighten via icacls so other desktop
@@ -174,6 +203,9 @@ function generateKeypair({ rotate = false } = {}) {
174
203
  function signAll() {
175
204
  const privateKey = loadPrivateKey();
176
205
  const manifest = loadManifest();
206
+ // Schema-validate BEFORE any mutation/write — refuse to sign a manifest
207
+ // the verifier would reject. Matches lib/verify.js signAll().
208
+ validateManifestSchema(manifest, 'sign-all');
177
209
  // Validate every entry's path before doing any I/O. Reject the whole
178
210
  // manifest on the first traversal attempt — we never want to sign
179
211
  // half a manifest then exit non-zero with a partial mutation.
@@ -230,6 +262,9 @@ function signAll() {
230
262
  function signOne(skillName) {
231
263
  const privateKey = loadPrivateKey();
232
264
  const manifest = loadManifest();
265
+ // Schema-validate BEFORE any mutation/write — refuse to sign a manifest
266
+ // the verifier would reject. Matches lib/verify.js signAll().
267
+ validateManifestSchema(manifest, 'sign');
233
268
  const skill = manifest.skills.find(s => s.name === skillName);
234
269
  if (!skill) { console.error(`Skill not found: ${skillName}`); process.exit(1); }
235
270
 
@@ -329,6 +364,33 @@ function loadManifest() {
329
364
  return JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
330
365
  }
331
366
 
367
+ /**
368
+ * Validate the manifest against lib/schemas/manifest.schema.json BEFORE
369
+ * signing, mirroring lib/verify.js signAll() (which throws on schema
370
+ * violation before re-signing). Without this the signer is the WEAKER
371
+ * check: a manifest that is path-safe but schema-invalid (unknown
372
+ * per-skill field, malformed version, bad atlas_ref, missing required
373
+ * field) gets a valid manifest_signature here, then lib/verify.js
374
+ * loadManifestValidated() THROWS on the same schema at install time and
375
+ * refuses to verify any skill. The producer must never emit an artifact
376
+ * the consumer rejects, so the signer is held to at least the verifier's
377
+ * bar. The validator itself is imported from lib/verify.js so the two
378
+ * halves can never drift to different rules.
379
+ *
380
+ * @param {object} manifest parsed manifest.json
381
+ * @param {string} who 'sign-all' | 'sign' — surfaced in the error
382
+ * @throws on any schema violation
383
+ */
384
+ function validateManifestSchema(manifest, who) {
385
+ const schema = JSON.parse(fs.readFileSync(MANIFEST_SCHEMA_PATH, 'utf8'));
386
+ const errors = validateAgainstSchema(manifest, schema, 'manifest');
387
+ if (errors.length > 0) {
388
+ const detail = errors.slice(0, 10).map(e => ' - ' + e).join('\n');
389
+ const more = errors.length > 10 ? `\n ...and ${errors.length - 10} more` : '';
390
+ throw new Error(`[sign] manifest.json failed schema validation before ${who} — refusing to sign:\n${detail}${more}`);
391
+ }
392
+ }
393
+
332
394
  /**
333
395
  * canonical byte form of the manifest, used for both
334
396
  * signing (lib/sign.js) and verification (lib/verify.js).
@@ -460,9 +522,24 @@ if (require.main === module) {
460
522
  const arg = process.argv[3];
461
523
 
462
524
  switch (cmd) {
463
- case 'generate-keypair':
464
- generateKeypair({ rotate: process.argv.includes('--rotate') });
525
+ case 'generate-keypair': {
526
+ const { aclHardened } = generateKeypair({ rotate: process.argv.includes('--rotate') });
527
+ // On win32 a failed icacls hardening leaves the private key inheriting
528
+ // the parent ACL — potentially readable by other desktop users. Make
529
+ // that detectable to automation (bootstrap.js, doctor --fix) instead of
530
+ // burying it in a mid-banner line under a 0 exit: fail loud unless the
531
+ // operator opts into the weaker ACL on a host where it is acceptable.
532
+ // exitCode (not process.exit) so the buffered stdout banner drains.
533
+ if (process.platform === 'win32' && aclHardened === false) {
534
+ if (process.env.EXCEPTD_ALLOW_WEAK_KEY_ACL === '1') {
535
+ console.warn('[sign] WARN: private-key ACL was NOT hardened; continuing because EXCEPTD_ALLOW_WEAK_KEY_ACL=1.');
536
+ } else {
537
+ console.error('[sign] ERROR: private key written but Windows ACL hardening FAILED — the key may be readable by other desktop users on this machine. Strip inheritance manually (icacls .keys\\private.pem /inheritance:r /grant:r "%USERNAME%":F), or set EXCEPTD_ALLOW_WEAK_KEY_ACL=1 to accept the weaker ACL on a single-user host.');
538
+ safeExit(1);
539
+ }
540
+ }
465
541
  break;
542
+ }
466
543
  case 'sign-all':
467
544
  signAll();
468
545
  break;
@@ -502,6 +579,7 @@ module.exports = {
502
579
  signOne,
503
580
  normalize,
504
581
  validateSkillPath,
582
+ validateManifestSchema,
505
583
  canonicalManifestBytes,
506
584
  signCanonicalManifest,
507
585
  restrictWindowsAcl,
@@ -49,6 +49,7 @@
49
49
 
50
50
  const path = require('path');
51
51
  const fs = require('fs');
52
+ const { withRetry } = require('../vendor/blamejs/retry.js');
52
53
 
53
54
  const TODAY = new Date().toISOString().slice(0, 10);
54
55
 
@@ -338,13 +339,30 @@ async function fetchFeed(feed, ctx) {
338
339
  return { ok: true, body: fs.readFileSync(p, 'utf8') };
339
340
  }
340
341
  if (typeof fetch !== 'function') return { ok: false, error: 'fetch() not available — Node 18+ required' };
341
- try {
342
+ // Adopt the vendored blamejs retry primitive (BJS-02): a transient feed
343
+ // failure (HTTP 429/5xx, timeout/abort, network reset) is retried with
344
+ // backoff + jitter instead of dropping the feed on the first hiccup. A
345
+ // permanent 4xx is not retried. Each attempt gets a fresh abort timer.
346
+ const attempt = async () => {
342
347
  const ac = new AbortController();
343
348
  const timer = setTimeout(() => ac.abort(), 8000);
344
- const r = await fetch(feed.url, { signal: ac.signal, headers: { 'User-Agent': 'exceptd-advisories-poller/0.13.1 (+https://exceptd.com)' } });
345
- clearTimeout(timer);
346
- if (!r.ok) return { ok: false, error: `HTTP ${r.status}` };
347
- return { ok: true, body: await r.text() };
349
+ try {
350
+ const r = await fetch(feed.url, { signal: ac.signal, headers: { 'User-Agent': 'exceptd-advisories-poller/0.13.1 (+https://exceptd.com)' } });
351
+ if (!r.ok) { const e = new Error(`HTTP ${r.status}`); e.statusCode = r.status; throw e; }
352
+ return await r.text();
353
+ } finally {
354
+ clearTimeout(timer);
355
+ }
356
+ };
357
+ const retryable = (err) => {
358
+ if (err && typeof err.statusCode === 'number') return err.statusCode === 429 || err.statusCode >= 500;
359
+ if (err && (err.name === 'AbortError' || err.name === 'TimeoutError')) return true;
360
+ const code = err && (err.code || (err.cause && err.cause.code));
361
+ return !!code && /^(ECONNRESET|ECONNREFUSED|ECONNABORTED|ETIMEDOUT|EPIPE|EAGAIN|ENOTFOUND|ENETUNREACH|UND_ERR)/.test(String(code));
362
+ };
363
+ try {
364
+ const body = await withRetry(attempt, { maxAttempts: 3, baseDelayMs: 100, maxDelayMs: 2000, jitterFactor: 0.5, isRetryable: retryable });
365
+ return { ok: true, body };
348
366
  } catch (e) {
349
367
  return { ok: false, error: e.message || String(e) };
350
368
  }
@@ -33,6 +33,16 @@ const GHSA_PATH = "/advisories?per_page=50&type=reviewed&sort=published&directio
33
33
  const REQUEST_TIMEOUT_MS = 10000;
34
34
  const USER_AGENT = "exceptd-security/source-ghsa (+https://exceptd.com)";
35
35
 
36
+ // Cap the streamed response so a hijacked DNS / compromised CDN / MITM serving
37
+ // api.github.com can't stream unbounded bytes into RAM. A 50-advisory page is
38
+ // well under a MB; 16 MB is generous headroom. Symmetric with the JSON cap
39
+ // getJsonOnce enforces in lib/refresh-network.js and the OSV poller's cap.
40
+ // Tunable via EXCEPTD_GHSA_RESPONSE_CAP_BYTES for future API growth.
41
+ function ghsaResponseCapBytes() {
42
+ const env = parseInt(process.env.EXCEPTD_GHSA_RESPONSE_CAP_BYTES, 10);
43
+ return Number.isFinite(env) && env > 0 ? env : 16 * 1024 * 1024;
44
+ }
45
+
36
46
  /**
37
47
  * Field-dropped watch set — fields the buildDiff regression-detector
38
48
  * watches when the upstream still has an entry but a previously-populated
@@ -123,8 +133,22 @@ function ghsaRequestOnce({ path, headers, timeoutMs }) {
123
133
  return resolve({ ok: false, error: `GHSA returned HTTP ${status}`, source: "offline" });
124
134
  }
125
135
  const chunks = [];
126
- res.on("data", (c) => chunks.push(c));
136
+ let total = 0;
137
+ const cap = ghsaResponseCapBytes();
138
+ let capped = false;
139
+ res.on("data", (c) => {
140
+ total += c.length;
141
+ if (total > cap) {
142
+ capped = true;
143
+ req.destroy(new Error(`GHSA response exceeds ${cap}-byte cap during streaming download`));
144
+ return;
145
+ }
146
+ chunks.push(c);
147
+ });
127
148
  res.on("end", () => {
149
+ // If the cap fired, req.destroy already routed through req.on("error");
150
+ // don't also resolve here (avoids a double-settle on the promise).
151
+ if (capped) return;
128
152
  try {
129
153
  const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
130
154
  const advisories = Array.isArray(body) ? body : (body ? [body] : []);
package/lib/source-osv.js CHANGED
@@ -47,6 +47,17 @@ const OSV_HOST = "api.osv.dev";
47
47
  const REQUEST_TIMEOUT_MS = 10000;
48
48
  const USER_AGENT = "exceptd-security/source-osv (+https://exceptd.com)";
49
49
 
50
+ // Cap the streamed response so a hostile or misconfigured endpoint (a hijacked
51
+ // DNS / compromised CDN / OSV_HOST_OVERRIDE pointed at a malicious server)
52
+ // can't stream unbounded bytes into RAM. A real /v1/vulns/{id} or /v1/query
53
+ // response is a few KB; 16 MB is generous headroom. Symmetric with the JSON
54
+ // cap getJsonOnce enforces in lib/refresh-network.js. Tunable via
55
+ // EXCEPTD_OSV_RESPONSE_CAP_BYTES for future API growth.
56
+ function osvResponseCapBytes() {
57
+ const env = parseInt(process.env.EXCEPTD_OSV_RESPONSE_CAP_BYTES, 10);
58
+ return Number.isFinite(env) && env > 0 ? env : 16 * 1024 * 1024;
59
+ }
60
+
50
61
  // Identifier namespaces OSV uses as PRIMARY keys. GHSA-* is intentionally
51
62
  // NOT in this list — `seedSingleAdvisory` in lib/refresh-external.js routes
52
63
  // CVE-* and GHSA-* through `source-ghsa` because GHSA carries richer field
@@ -214,8 +225,22 @@ function osvRequestOnce({ method, reqPath, body, timeoutMs }) {
214
225
  return resolve({ ok: false, error, status, source: "offline" });
215
226
  }
216
227
  const chunks = [];
217
- res.on("data", (c) => chunks.push(c));
228
+ let total = 0;
229
+ const cap = osvResponseCapBytes();
230
+ let capped = false;
231
+ res.on("data", (c) => {
232
+ total += c.length;
233
+ if (total > cap) {
234
+ capped = true;
235
+ req.destroy(new Error(`OSV response exceeds ${cap}-byte cap during streaming download`));
236
+ return;
237
+ }
238
+ chunks.push(c);
239
+ });
218
240
  res.on("end", () => {
241
+ // If the cap fired, req.destroy already routed through req.on("error");
242
+ // don't also resolve here (avoids a double-settle on the promise).
243
+ if (capped) return;
219
244
  try {
220
245
  const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
221
246
  resolve({ ok: true, record: parsed, source: "osv-api" });
@@ -55,7 +55,7 @@ async function fetchLatestPublished({ timeoutMs = REQUEST_TIMEOUT_MS, pkgName =
55
55
  }
56
56
 
57
57
  return new Promise((resolve) => {
58
- const path = `/${encodeURIComponent(pkgName).replace("%40", "@").replace("%2F", "/")}`;
58
+ const path = `/${encodeURIComponent(pkgName).replace(/%40/g, "@").replace(/%2F/g, "/")}`;
59
59
  const req = https.get({
60
60
  host: REGISTRY_HOST,
61
61
  path,
@@ -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
- /github\.com\/.+\/(exploits?|poc|pocs)\b/i,
48
- /\bexploit-?db\.com\b/i,
49
- /\bpacketstormsecurity\.com\b/i,
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 {
@@ -36,6 +36,49 @@ 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
+
39
82
  function main() {
40
83
  if (!fs.existsSync(META)) {
41
84
  console.error("[validate-indexes] data/_indexes/_meta.json missing — run `npm run build-indexes`.");
@@ -122,6 +165,24 @@ function main() {
122
165
  }
123
166
  }
124
167
 
168
+ // Verify the derived index OUTPUTS themselves exist and parse. Source-hash
169
+ // drift alone does not catch a deleted/truncated/corrupted OUTPUT: a clean
170
+ // source tree with a missing index file would otherwise pass as "current".
171
+ // _meta.outputs is the canonical list build-indexes records; an absent list
172
+ // FAILS (rather than silently skipping the check) so an old _meta can't pass.
173
+ // Absent `outputs` list (an older build that predates this field) is itself a
174
+ // staleness condition — fold it into the unified STALE report rather than
175
+ // early-returning, so a concurrent source-hash drift still surfaces too.
176
+ const outputs = Array.isArray(meta.outputs) ? meta.outputs : null;
177
+ if (!outputs || outputs.length === 0) {
178
+ missing.push(
179
+ "data/_indexes/_meta.json has no `outputs` list — rebuild with " +
180
+ "`npm run build-indexes` so derived-index integrity can be confirmed"
181
+ );
182
+ } else {
183
+ for (const issue of verifyOutputs(IDX_DIR, outputs)) missing.push(issue);
184
+ }
185
+
125
186
  const issues = [...drift, ...missing];
126
187
  if (issues.length === 0) {
127
188
  console.log(`[validate-indexes] indexes current — ${recordedKeys.size} sources hashed at ${meta.generated_at}.`);
@@ -136,4 +197,4 @@ function main() {
136
197
 
137
198
  if (require.main === module) main();
138
199
 
139
- module.exports = { main };
200
+ module.exports = { main, verifyOutputs };
@@ -521,7 +521,10 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
521
521
  // here at error severity.
522
522
  if (meta.air_gap_mode === true) {
523
523
  const look = phases.look || {};
524
- const netSourceRe = /(https?:\/\/|gh api|gh release|curl |wget |fetch )/;
524
+ // Case-insensitive + word-bounded so `HTTPS://`, `Curl`, and `fetch(` (no
525
+ // trailing space) still flag a network source — otherwise an artifact could
526
+ // ship under air_gap_mode with no offline alternative and run incomplete.
527
+ const netSourceRe = /(https?:\/\/|\bgh (?:api|release)\b|\bcurl\b|\bwget\b|\bfetch\b)/i;
525
528
  for (const [i, art] of (look.artifacts || []).entries()) {
526
529
  if (!art || typeof art !== 'object') continue;
527
530
  if (typeof art.source === 'string' && netSourceRe.test(art.source)) {
@@ -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
- if (prov.license_file && prov.license_sha256) {
42
- const p = path.join(ROOT, "vendor", "blamejs", prov.license_file);
43
- if (!fs.existsSync(p)) {
44
- issues.push(`missing license file: ${prov.license_file}`);
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 live = sha256(fs.readFileSync(p));
47
- if (live !== prov.license_sha256) {
48
- issues.push(`LICENSE drift: recorded ${prov.license_sha256.slice(0, 12)}…, live ${live.slice(0, 12)}…`);
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) || "?"}.`);