@blamejs/exceptd-skills 0.18.6 → 0.18.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/bin/exceptd.js +364 -119
- package/data/_indexes/_meta.json +22 -3
- package/data/cve-catalog.json +25 -0
- package/data/playbooks/framework.json +2 -2
- package/data/playbooks/post-quantum-migration.json +1 -1
- package/lib/auto-discovery.js +30 -10
- package/lib/collectors/ai-api.js +9 -2
- package/lib/collectors/cicd-pipeline-compromise.js +24 -5
- package/lib/collectors/cred-stores.js +17 -4
- package/lib/collectors/crypto.js +9 -2
- package/lib/collectors/hardening.js +9 -2
- package/lib/collectors/library-author.js +29 -5
- package/lib/collectors/mcp.js +9 -2
- package/lib/collectors/runtime.js +9 -2
- package/lib/collectors/sbom.js +28 -15
- package/lib/collectors/scan-excludes.js +25 -0
- package/lib/collectors/secrets.js +40 -4
- package/lib/cve-curation.js +84 -8
- package/lib/lint-skills.js +75 -3
- package/lib/playbook-runner.js +443 -50
- package/lib/prefetch.js +22 -2
- package/lib/refresh-external.js +32 -1
- package/lib/refresh-network.js +235 -26
- package/lib/schemas/cve-catalog.schema.json +5 -0
- package/lib/scoring.js +141 -21
- package/lib/sign.js +107 -29
- package/lib/source-advisories.js +23 -5
- package/lib/source-ghsa.js +25 -1
- package/lib/source-osv.js +26 -1
- package/lib/upstream-check.js +1 -1
- package/lib/validate-cve-catalog.js +30 -4
- package/lib/validate-indexes.js +135 -29
- package/lib/validate-playbooks.js +19 -7
- package/lib/validate-vendor.js +69 -8
- package/lib/verify.js +23 -6
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +14 -3
- package/orchestrator/index.js +100 -20
- package/orchestrator/scanner.js +8 -0
- package/package.json +1 -1
- package/sbom.cdx.json +130 -130
- package/scripts/audit-cross-skill.js +1 -1
- package/scripts/bootstrap.js +1 -0
- package/scripts/build-indexes.js +84 -11
- package/scripts/check-agents-md-collectors.js +41 -13
- package/scripts/check-changelog-extract.js +4 -4
- package/scripts/check-codebase-patterns.js +19 -5
- package/scripts/check-manifest-snapshot.js +74 -30
- package/scripts/check-sbom-currency.js +25 -5
- package/scripts/check-test-count.js +26 -7
- package/scripts/check-test-coverage.js +44 -4
- package/scripts/check-version-tags.js +27 -8
- package/scripts/predeploy.js +1 -1
- package/scripts/refresh-manifest-snapshot.js +14 -4
- package/scripts/refresh-reverse-refs.js +7 -1
- package/scripts/refresh-sbom.js +1 -1
- package/scripts/release.js +3 -3
- package/scripts/run-e2e-scenarios.js +18 -8
- package/scripts/validate-vendor-online.js +28 -2
- package/scripts/verify-shipped-tarball.js +65 -6
- package/sources/validators/cve-validator.js +17 -1
- package/vendor/blamejs/_PROVENANCE.json +4 -2
|
@@ -4,8 +4,17 @@
|
|
|
4
4
|
/**
|
|
5
5
|
* scripts/check-version-tags.js
|
|
6
6
|
*
|
|
7
|
-
* Refuses NEW version-stamped
|
|
8
|
-
*
|
|
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
|
-
* -
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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: `
|
|
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
|
|
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
|
}
|
package/scripts/predeploy.js
CHANGED
|
@@ -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
|
-
|
|
75
|
-
|
|
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
|
-
//
|
|
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
|
}
|
package/scripts/refresh-sbom.js
CHANGED
|
@@ -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(
|
|
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
|
package/scripts/release.js
CHANGED
|
@@ -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
|
|
270
|
+
const filterStr = filter ? filter.slice("--filter=".length) : null;
|
|
258
271
|
const json = process.argv.includes("--json");
|
|
259
272
|
|
|
260
|
-
const scenarios =
|
|
261
|
-
.
|
|
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;
|
|
@@ -66,13 +66,39 @@ function rawUrlForPin(sourceRepo, commit, upstreamPath) {
|
|
|
66
66
|
if (!m) return null;
|
|
67
67
|
const [, owner, repo] = m;
|
|
68
68
|
const cleanPath = String(upstreamPath || "").replace(/^\/+/, "");
|
|
69
|
+
// Validate every file-derived component before embedding it in the fetch URL,
|
|
70
|
+
// so a tampered _PROVENANCE.json cannot steer the request: owner/repo are
|
|
71
|
+
// GitHub-name-shaped, the commit is a hex git object id, and the path is a
|
|
72
|
+
// relative, traversal-free repo path. With the host a string literal, the
|
|
73
|
+
// fetch destination is fully constrained — not metadata-controlled.
|
|
74
|
+
if (!/^[A-Za-z0-9._-]+$/.test(owner) || !/^[A-Za-z0-9._-]+$/.test(repo)) return null;
|
|
75
|
+
if (!/^[0-9a-fA-F]{7,64}$/.test(String(commit || ""))) return null;
|
|
76
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(cleanPath) || cleanPath.includes("..")) return null;
|
|
69
77
|
return `https://raw.githubusercontent.com/${owner}/${repo}/${commit}/${cleanPath}`;
|
|
70
78
|
}
|
|
71
79
|
|
|
72
80
|
const MAX_REDIRECTS = 5;
|
|
73
81
|
|
|
82
|
+
// Only GitHub-controlled hosts may be fetched. The initial URL is always a
|
|
83
|
+
// raw.githubusercontent.com URL computed from the committed _PROVENANCE.json,
|
|
84
|
+
// but redirects re-enter fetchBuffer with a server-supplied Location; pinning
|
|
85
|
+
// the host to github.com / *.githubusercontent.com stops a redirect (or a
|
|
86
|
+
// tampered provenance source_repo) from steering the fetch at an internal or
|
|
87
|
+
// attacker-controlled address.
|
|
88
|
+
const ALLOWED_FETCH_HOST = /(?:^|\.)githubusercontent\.com$|^github\.com$/;
|
|
89
|
+
|
|
90
|
+
function assertAllowedHost(url) {
|
|
91
|
+
let host;
|
|
92
|
+
try { host = new URL(url).hostname.toLowerCase(); }
|
|
93
|
+
catch { throw new Error(`unparseable fetch URL: ${url}`); }
|
|
94
|
+
if (!ALLOWED_FETCH_HOST.test(host)) {
|
|
95
|
+
throw new Error(`refusing to fetch from non-allowlisted host: ${host}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
74
99
|
function fetchBuffer(url, timeoutMs, redirectsLeft = MAX_REDIRECTS) {
|
|
75
100
|
return new Promise((resolve, reject) => {
|
|
101
|
+
try { assertAllowedHost(url); } catch (e) { return reject(e); }
|
|
76
102
|
const req = https.get(url, (res) => {
|
|
77
103
|
// v0.12.14 (codex P2): cap redirect hops. A redirect loop (or a
|
|
78
104
|
// 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
|
-
|
|
160
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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": "
|
|
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)",
|