@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.
- package/CHANGELOG.md +20 -0
- package/bin/exceptd.js +261 -56
- 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 +24 -3
- 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 +375 -40
- package/lib/prefetch.js +6 -1
- package/lib/refresh-external.js +32 -1
- package/lib/refresh-network.js +201 -24
- package/lib/schemas/cve-catalog.schema.json +5 -0
- package/lib/scoring.js +106 -13
- 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 +19 -3
- package/lib/validate-indexes.js +62 -1
- package/lib/validate-playbooks.js +4 -1
- package/lib/validate-vendor.js +69 -8
- package/manifest.json +53 -53
- package/orchestrator/index.js +69 -13
- package/package.json +1 -1
- package/sbom.cdx.json +124 -124
- package/scripts/audit-cross-skill.js +1 -1
- package/scripts/bootstrap.js +1 -0
- package/scripts/build-indexes.js +58 -4
- 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 +20 -2
- package/scripts/verify-shipped-tarball.js +65 -6
- package/sources/validators/cve-validator.js +17 -1
- 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
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
-
|
|
130
|
-
|
|
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,
|
package/lib/source-advisories.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
}
|
package/lib/source-ghsa.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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" });
|
package/lib/upstream-check.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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 {
|
package/lib/validate-indexes.js
CHANGED
|
@@ -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
|
-
|
|
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)) {
|
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) || "?"}.`);
|