@blamejs/exceptd-skills 0.19.32 → 0.19.34
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 +22 -0
- package/bin/exceptd.js +896 -2824
- package/data/_indexes/_meta.json +8 -8
- package/data/_indexes/activity-feed.json +2 -2
- package/data/_indexes/catalog-summaries.json +7 -7
- package/data/_indexes/chains.json +60118 -0
- package/data/attack-techniques.json +267 -7
- package/data/cve-catalog.json +9991 -3
- package/data/cwe-catalog.json +109 -2
- package/data/framework-control-gaps.json +578 -3
- package/data/zeroday-lessons.json +8330 -1
- package/lib/auto-discovery.js +56 -286
- package/lib/canonical-eq.js +7 -40
- package/lib/citation-resolve.js +22 -70
- package/lib/collectors/ai-api.js +20 -54
- package/lib/collectors/cicd-pipeline-compromise.js +40 -108
- package/lib/collectors/citation-hygiene.js +72 -210
- package/lib/collectors/containers.js +41 -130
- package/lib/collectors/cred-stores.js +31 -115
- package/lib/collectors/crypto-codebase.js +55 -138
- package/lib/collectors/crypto.js +24 -54
- package/lib/collectors/hardening.js +20 -78
- package/lib/collectors/kernel.js +16 -46
- package/lib/collectors/library-author.js +57 -206
- package/lib/collectors/mcp.js +24 -70
- package/lib/collectors/runtime.js +24 -86
- package/lib/collectors/sbom.js +34 -106
- package/lib/collectors/scan-excludes.js +31 -138
- package/lib/collectors/secrets.js +62 -178
- package/lib/cross-ref-api.js +39 -123
- package/lib/currency-severity.js +8 -27
- package/lib/cve-batch.js +13 -21
- package/lib/cve-cli.js +13 -20
- package/lib/cve-curation.js +72 -239
- package/lib/cve-regression-watcher.js +29 -152
- package/lib/cvss.js +13 -54
- package/lib/doctor-bucketing.js +3 -19
- package/lib/exit-codes.js +10 -42
- package/lib/flag-suggest.js +7 -25
- package/lib/framework-gap.js +35 -114
- package/lib/gap-detectors.js +37 -159
- package/lib/id-validation.js +9 -30
- package/lib/job-queue.js +13 -36
- package/lib/lint-skills.js +64 -232
- package/lib/playbook-runner.js +693 -2095
- package/lib/prefetch.js +100 -376
- package/lib/refresh-external.js +199 -627
- package/lib/refresh-network.js +75 -307
- package/lib/rfc-cli.js +23 -68
- package/lib/scoring.js +77 -145
- package/lib/sign.js +43 -229
- package/lib/source-advisories.js +43 -194
- package/lib/source-ghsa.js +37 -120
- package/lib/source-osv.js +94 -266
- package/lib/ttp-mapper.js +14 -24
- package/lib/upstream-check-cli.js +10 -28
- package/lib/upstream-check.js +19 -44
- package/lib/validate-catalog-meta.js +17 -61
- package/lib/validate-cve-catalog.js +43 -119
- package/lib/validate-indexes.js +25 -76
- package/lib/validate-package.js +16 -62
- package/lib/validate-playbooks.js +69 -275
- package/lib/validate-vendor.js +16 -49
- package/lib/verify.js +56 -286
- package/lib/version-pins.js +5 -34
- package/lib/worker-pool.js +11 -30
- package/lib/xml-tokenizer.js +47 -152
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +17 -68
- package/orchestrator/event-bus.js +11 -74
- package/orchestrator/index.js +138 -412
- package/orchestrator/pipeline.js +28 -85
- package/orchestrator/scanner.js +34 -138
- package/orchestrator/scheduler.js +20 -84
- package/package.json +2 -2
- package/sbom.cdx.json +253 -253
- package/scripts/audit-catalog-gaps.js +9 -62
- package/scripts/audit-cross-skill.js +5 -31
- package/scripts/audit-perf.js +6 -16
- package/scripts/backfill-theater-test.js +7 -64
- package/scripts/bootstrap.js +12 -44
- package/scripts/build-indexes.js +40 -154
- package/scripts/builders/activity-feed.js +4 -14
- package/scripts/builders/catalog-summaries.js +3 -10
- package/scripts/builders/currency.js +7 -20
- package/scripts/builders/cwe-chains.js +7 -30
- package/scripts/builders/did-ladders.js +6 -13
- package/scripts/builders/frequency.js +5 -19
- package/scripts/builders/jurisdiction-clocks.js +6 -25
- package/scripts/builders/recipes.js +6 -14
- package/scripts/builders/section-offsets.js +13 -51
- package/scripts/builders/stale-content.js +7 -28
- package/scripts/builders/summary-cards.js +8 -29
- package/scripts/builders/theater-fingerprints.js +12 -27
- package/scripts/builders/token-budget.js +4 -31
- package/scripts/check-agents-md-collectors.js +11 -54
- package/scripts/check-catalog-gap-budget.js +15 -32
- package/scripts/check-changelog-extract.js +18 -48
- package/scripts/check-codebase-patterns-currency.js +6 -22
- package/scripts/check-codebase-patterns.js +50 -143
- package/scripts/check-epss-consistency.js +9 -64
- package/scripts/check-framework-gap-coverage.js +13 -31
- package/scripts/check-manifest-snapshot.js +13 -73
- package/scripts/check-sbom-currency.js +44 -142
- package/scripts/check-test-count.js +15 -52
- package/scripts/check-test-coverage.js +66 -197
- package/scripts/check-test-subjects.js +21 -62
- package/scripts/check-ttp-references.js +14 -38
- package/scripts/check-ttp-upstream.js +8 -40
- package/scripts/check-version-bump.js +9 -61
- package/scripts/check-version-tags.js +20 -121
- package/scripts/predeploy.js +38 -184
- package/scripts/refresh-manifest-snapshot.js +16 -38
- package/scripts/refresh-mitre-atlas.js +3 -8
- package/scripts/refresh-mitre-attack.js +1 -8
- package/scripts/refresh-mitre-d3fend.js +3 -9
- package/scripts/refresh-mitre-ics-attack.js +3 -8
- package/scripts/refresh-reverse-refs.js +27 -94
- package/scripts/refresh-rfc-index.js +2 -10
- package/scripts/refresh-sbom.js +31 -161
- package/scripts/refresh-upstream-catalogs.js +40 -137
- package/scripts/release.js +69 -232
- package/scripts/run-e2e-scenarios.js +24 -71
- package/scripts/sync-manifest-metadata.js +10 -34
- package/scripts/sync-package-description.js +8 -17
- package/scripts/validate-vendor-online.js +13 -44
- package/scripts/verify-shipped-tarball.js +35 -140
package/lib/validate-vendor.js
CHANGED
|
@@ -1,20 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* has the same SHA-256 on disk as the manifest claims. Silent hand-edits
|
|
7
|
-
* to a vendored copy fail the build.
|
|
8
|
-
*
|
|
9
|
-
* Also confirms vendor/blamejs/LICENSE matches the recorded license hash so
|
|
10
|
-
* a license-text change is detected as a separate event from a code change.
|
|
11
|
-
*
|
|
12
|
-
* Exit codes:
|
|
13
|
-
* 0 — vendor tree in sync with provenance
|
|
14
|
-
* 1 — at least one file drift / missing
|
|
15
|
-
*
|
|
16
|
-
* Re-vendor with: copy upstream, apply strip rules, refresh hashes in
|
|
17
|
-
* _PROVENANCE.json, re-run this gate.
|
|
3
|
+
* Predeploy gate: every file recorded in `vendor/blamejs/_PROVENANCE.json` must
|
|
4
|
+
* carry the same SHA-256 on disk, and vendor/blamejs/LICENSE must match its
|
|
5
|
+
* recorded hash. Exits 0 when the tree is in sync, 1 on any drift.
|
|
18
6
|
*/
|
|
19
7
|
|
|
20
8
|
const fs = require("fs");
|
|
@@ -37,11 +25,8 @@ function main() {
|
|
|
37
25
|
const prov = JSON.parse(fs.readFileSync(PROV, "utf8"));
|
|
38
26
|
const issues = [];
|
|
39
27
|
|
|
40
|
-
//
|
|
41
|
-
//
|
|
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.
|
|
28
|
+
// A recorded license_file with NO license_sha256 is an unverifiable integrity
|
|
29
|
+
// claim, not a skip: stripping the hash must not disable this check.
|
|
45
30
|
if (prov.license_file) {
|
|
46
31
|
if (!prov.license_sha256) {
|
|
47
32
|
issues.push(`license_file recorded (${prov.license_file}) without license_sha256 — integrity unverifiable`);
|
|
@@ -58,17 +43,14 @@ function main() {
|
|
|
58
43
|
}
|
|
59
44
|
}
|
|
60
45
|
|
|
61
|
-
// Each vendored file.
|
|
62
46
|
for (const [name, info] of Object.entries(prov.files || {})) {
|
|
63
47
|
const p = path.join(ROOT, info.vendored_path);
|
|
64
48
|
if (!fs.existsSync(p)) {
|
|
65
49
|
issues.push(`missing vendored file: ${info.vendored_path}`);
|
|
66
50
|
continue;
|
|
67
51
|
}
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
// `undefined.slice()` while formatting a drift message (the absent-field
|
|
71
|
-
// class, symmetric with the license path above).
|
|
52
|
+
// An entry recorded without vendored_sha256 is an unverifiable integrity
|
|
53
|
+
// claim — report it rather than crash on `undefined.slice()`.
|
|
72
54
|
if (!info.vendored_sha256) {
|
|
73
55
|
issues.push(`${info.vendored_path} recorded without vendored_sha256 — integrity unverifiable`);
|
|
74
56
|
continue;
|
|
@@ -77,20 +59,11 @@ function main() {
|
|
|
77
59
|
if (live !== info.vendored_sha256) {
|
|
78
60
|
issues.push(`drift in ${info.vendored_path}: recorded ${info.vendored_sha256.slice(0, 12)}…, live ${live.slice(0, 12)}…`);
|
|
79
61
|
}
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
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.
|
|
62
|
+
// The vendored_sha256 compare above is self-attesting: it proves the file
|
|
63
|
+
// matches its OWN recorded hash, never that the bytes match blamejs@<pin>.
|
|
64
|
+
// With no strip rules the vendored bytes ARE the upstream bytes, so a
|
|
65
|
+
// divergence from upstream_sha256_at_pin is a forged pin claim, provable
|
|
66
|
+
// offline. The fetching check is scripts/validate-vendor-online.js.
|
|
94
67
|
const stripped = Array.isArray(info.stripped) ? info.stripped : [];
|
|
95
68
|
if (stripped.length === 0) {
|
|
96
69
|
if (!info.upstream_sha256_at_pin) {
|
|
@@ -110,16 +83,10 @@ function main() {
|
|
|
110
83
|
}
|
|
111
84
|
}
|
|
112
85
|
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
// smuggled-in vendored module. Node executes `.js`, `.cjs`, and `.mjs` as
|
|
118
|
-
// code, so a `.cjs`/`.mjs` smuggled module carries the same arbitrary-code
|
|
119
|
-
// risk as a `.js` one — checking only `.js` let a renamed extension slip the
|
|
120
|
-
// gate. `.json`/`.node` are deliberately NOT swept here: `_PROVENANCE.json`
|
|
121
|
-
// itself lives in this directory and is the manifest, not a registered
|
|
122
|
-
// vendored module, so sweeping `.json` would false-positive on the manifest.
|
|
86
|
+
// The loop above is one-directional (manifest → disk): a module on disk but
|
|
87
|
+
// absent from _PROVENANCE.json ships with nothing verifying its integrity.
|
|
88
|
+
// Node executes `.js`, `.cjs` and `.mjs` alike, so a renamed extension must not
|
|
89
|
+
// slip the sweep; `.json` stays out because `_PROVENANCE.json` IS the manifest.
|
|
123
90
|
const LOADABLE_MODULE = /\.(c|m)?js$/;
|
|
124
91
|
const registered = new Set(
|
|
125
92
|
Object.values(prov.files || {}).map((info) => path.basename(info.vendored_path))
|
package/lib/verify.js
CHANGED
|
@@ -1,62 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Skill integrity verifier — Ed25519
|
|
5
|
-
*
|
|
6
|
-
* SHA-256 hashes alone protect against accidental corruption; anyone with repo
|
|
7
|
-
* write access can update the hash after tampering. Ed25519 signatures prove a
|
|
8
|
-
* specific keypair signed each skill. Even if the manifest is updated, a valid
|
|
9
|
-
* signature requires the private key, which never enters this repository.
|
|
10
|
-
*
|
|
11
|
-
* Byte-stability contract (must mirror lib/sign.js):
|
|
12
|
-
* Skill content is normalized BEFORE the signature is verified:
|
|
13
|
-
* 1. Strip a UTF-8 BOM (U+FEFF) if present.
|
|
14
|
-
* 2. Convert CRLF line endings to LF.
|
|
15
|
-
* The same normalization runs in lib/sign.js. A skill file checked
|
|
16
|
-
* out with core.autocrlf=true on Windows therefore verifies against
|
|
17
|
-
* a signature produced on Linux CI (LF). ANY change to normalize()
|
|
18
|
-
* requires the matching change in lib/sign.js — round-trip stability
|
|
19
|
-
* is a hard contract. The v0.11.x signature regression (operators
|
|
20
|
-
* ran `exceptd doctor --signatures` and saw 0/38) was a single
|
|
21
|
-
* instance of this contract drifting; do not relax it.
|
|
22
|
-
*
|
|
23
|
-
* Manifest entries are validated through validateSkillPath() before
|
|
24
|
-
* any file is read. A tampered manifest with `path: "../../../etc/passwd"`
|
|
25
|
-
* cannot escape the skills/ tree. The whole manifest is rejected on
|
|
26
|
-
* the first traversal attempt.
|
|
27
|
-
*
|
|
28
|
-
* The manifest object itself is validated against
|
|
29
|
-
* lib/schemas/manifest.schema.json before any skill is touched.
|
|
30
|
-
* additionalProperties=false at the skill level catches typos and
|
|
31
|
-
* unknown fields that would otherwise silently be dropped.
|
|
32
|
-
*
|
|
33
|
-
* Manifest signature contract (must mirror lib/sign.js):
|
|
34
|
-
* The manifest carries a top-level `manifest_signature` field. Before
|
|
35
|
-
* iterating skills, loadManifestValidated() extracts the signature,
|
|
36
|
-
* recomputes the canonical bytes, and verifies against keys/public.pem.
|
|
37
|
-
* On failure, all skill verification is blocked with a structured
|
|
38
|
-
* error. When the field is absent (older v0.11.x / pre-v0.12.17
|
|
39
|
-
* tarballs in the wild), a warning is emitted but verification
|
|
40
|
-
* continues — this preserves backward compatibility for installs that
|
|
41
|
-
* predate manifest signing.
|
|
42
|
-
*
|
|
43
|
-
* Canonical bytes are computed identically to lib/sign.js
|
|
44
|
-
* canonicalManifestBytes():
|
|
45
|
-
* 1. Clone, delete manifest_signature.
|
|
46
|
-
* 2. JSON.stringify with top-level keys sorted lexicographically.
|
|
47
|
-
* 3. Apply normalize() — strip BOM, CRLF → LF.
|
|
48
|
-
* ANY change to the canonical form requires the matching change in
|
|
49
|
-
* lib/sign.js. Round-trip stability is a hard contract.
|
|
50
|
-
*
|
|
51
|
-
* Signing ceremony: see lib/sign.js
|
|
52
|
-
* Public key: keys/public.pem (tracked in repo)
|
|
53
|
-
* Private key: .keys/private.pem (gitignored, kept off-repo)
|
|
54
|
-
*
|
|
55
|
-
* Usage:
|
|
56
|
-
* node lib/verify.js — verify all skills
|
|
57
|
-
* node lib/verify.js <name> — verify one skill
|
|
58
|
-
* node lib/verify.js update — re-sign all skills (requires private key)
|
|
59
|
-
* node lib/verify.js check-key — verify the public key is present and valid
|
|
4
|
+
* Skill integrity verifier — Ed25519 signatures over normalized skill content.
|
|
60
5
|
*/
|
|
61
6
|
|
|
62
7
|
const fs = require('fs');
|
|
@@ -69,21 +14,10 @@ const SKILLS_DIR = path.join(ROOT, 'skills');
|
|
|
69
14
|
const PUBLIC_KEY_PATH = path.join(ROOT, 'keys', 'public.pem');
|
|
70
15
|
const PRIVATE_KEY_PATH = path.join(ROOT, '.keys', 'private.pem');
|
|
71
16
|
const MANIFEST_SCHEMA_PATH = path.join(__dirname, 'schemas', 'manifest.schema.json');
|
|
72
|
-
//
|
|
73
|
-
// public-key fingerprint against the pinned one and fails the verify run
|
|
74
|
-
// if they differ (unless the operator sets KEYS_ROTATED=1). The file format
|
|
75
|
-
// is a single line "SHA256:<base64>" matching the publicKeyFingerprint()
|
|
76
|
-
// shape. The file is OPTIONAL: when missing, the gate warns-and-continues
|
|
77
|
-
// rather than failing — this preserves bootstrap compatibility on fresh
|
|
78
|
-
// clones / new key ceremonies. Patch-class semantics.
|
|
17
|
+
// Key pin: one line "SHA256:<base64>", compared against live keys/public.pem.
|
|
79
18
|
const EXPECTED_FINGERPRINT_PATH = path.join(ROOT, 'keys', 'EXPECTED_FINGERPRINT');
|
|
80
19
|
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Verify all skills in manifest.json against the Ed25519 public key.
|
|
85
|
-
* @returns {{ valid: string[], invalid: string[], missing_sig: string[], missing_file: string[], no_key: boolean }}
|
|
86
|
-
*/
|
|
20
|
+
// Returns per-status name lists; an absent public key returns no_key:true.
|
|
87
21
|
function verifyAll() {
|
|
88
22
|
const publicKey = loadPublicKey();
|
|
89
23
|
if (!publicKey) {
|
|
@@ -105,11 +39,7 @@ function verifyAll() {
|
|
|
105
39
|
return result;
|
|
106
40
|
}
|
|
107
41
|
|
|
108
|
-
|
|
109
|
-
* Verify one skill by name.
|
|
110
|
-
* @param {string} skillName
|
|
111
|
-
* @returns {{ status: string, reason?: string }}
|
|
112
|
-
*/
|
|
42
|
+
// Throws where verifyAll() reports a status: no public key, or name not in manifest.
|
|
113
43
|
function verifyOne(skillName) {
|
|
114
44
|
const publicKey = loadPublicKey();
|
|
115
45
|
if (!publicKey) throw new Error('No public key at keys/public.pem');
|
|
@@ -121,19 +51,13 @@ function verifyOne(skillName) {
|
|
|
121
51
|
return verifySkill(skill, publicKey);
|
|
122
52
|
}
|
|
123
53
|
|
|
124
|
-
|
|
125
|
-
* Re-sign all skills using the private key and write signatures to manifest.json.
|
|
126
|
-
* Requires .keys/private.pem — never checked in.
|
|
127
|
-
* @returns {{ signed: string[], errors: string[] }}
|
|
128
|
-
*/
|
|
54
|
+
// Rewrites manifest.json in place. Requires .keys/private.pem.
|
|
129
55
|
function signAll() {
|
|
130
56
|
const privateKey = loadPrivateKey();
|
|
131
57
|
if (!privateKey) throw new Error('No private key at .keys/private.pem — run `exceptd doctor --fix` (or `node $(exceptd path)/lib/sign.js generate-keypair` from a contributor checkout)');
|
|
132
58
|
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
// so a stale manifest_signature mismatch here is expected — not a
|
|
136
|
-
// tampering signal. Schema + path validation still apply.
|
|
59
|
+
// Loaded without the signature gate: this run rewrites the signatures, so a
|
|
60
|
+
// stale manifest_signature is expected here. Schema + path checks still run.
|
|
137
61
|
const manifest = loadManifest();
|
|
138
62
|
const schema = JSON.parse(fs.readFileSync(MANIFEST_SCHEMA_PATH, 'utf8'));
|
|
139
63
|
const errors0 = validateAgainstSchema(manifest, schema, 'manifest');
|
|
@@ -158,20 +82,13 @@ function signAll() {
|
|
|
158
82
|
result.signed.push(skill.name);
|
|
159
83
|
}
|
|
160
84
|
|
|
161
|
-
// P1-4: re-sign the manifest after the per-skill signatures changed.
|
|
162
85
|
delete manifest.manifest_signature;
|
|
163
86
|
const canonical = canonicalManifestBytes(manifest);
|
|
164
87
|
const manifestSig = crypto.sign(null, canonical, {
|
|
165
88
|
key: privateKey, dsaEncoding: 'ieee-p1363',
|
|
166
89
|
});
|
|
167
|
-
// `signed_at`
|
|
168
|
-
//
|
|
169
|
-
// from the canonical bytes before signing), so an attacker could replay
|
|
170
|
-
// a known-valid signature against the same canonical content while
|
|
171
|
-
// rewriting `signed_at` to any value — lending false freshness
|
|
172
|
-
// authority to a stale signature. Operators who need a freshness
|
|
173
|
-
// signal should consult the git-log mtime of manifest.json (or the
|
|
174
|
-
// npm publish timestamp), which are external to the signed bytes.
|
|
90
|
+
// No `signed_at` here: unsigned metadata, so a replayed known-valid signature
|
|
91
|
+
// could carry any timestamp. Freshness comes from git log / npm publish time.
|
|
175
92
|
manifest.manifest_signature = {
|
|
176
93
|
algorithm: 'Ed25519',
|
|
177
94
|
signature_base64: manifestSig.toString('base64'),
|
|
@@ -182,46 +99,22 @@ function signAll() {
|
|
|
182
99
|
return result;
|
|
183
100
|
}
|
|
184
101
|
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Normalize skill content for byte-stable verification.
|
|
189
|
-
*
|
|
190
|
-
* Strips a leading UTF-8 BOM (U+FEFF) if present, then converts CRLF
|
|
191
|
-
* line endings to LF. lib/sign.js applies the exact same transform —
|
|
192
|
-
* see the byte-stability contract in the file header.
|
|
193
|
-
*
|
|
194
|
-
* @param {string} content
|
|
195
|
-
* @returns {string}
|
|
196
|
-
*/
|
|
102
|
+
// Strips a leading UTF-8 BOM, then CRLF → LF. lib/sign.js applies the identical
|
|
103
|
+
// transform; diverging breaks the sign→verify round trip for every skill.
|
|
197
104
|
function normalize(content) {
|
|
198
105
|
let s = content;
|
|
199
106
|
if (s.length > 0 && s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
|
|
200
107
|
return s.replace(/\r\n/g, '\n');
|
|
201
108
|
}
|
|
202
109
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
*
|
|
206
|
-
* skill.path MUST be a string.
|
|
207
|
-
* skill.path MUST start with "skills/".
|
|
208
|
-
* skill.path MUST NOT contain "..".
|
|
209
|
-
* skill.path MUST NOT contain backslashes.
|
|
210
|
-
*
|
|
211
|
-
* Same shape as lib/sign.js validateSkillPath(); the two functions
|
|
212
|
-
* are intentionally duplicated rather than cross-imported so the
|
|
213
|
-
* verify path has no runtime dependency on the sign path.
|
|
214
|
-
*
|
|
215
|
-
* @param {string} skillPath
|
|
216
|
-
* @returns {string}
|
|
217
|
-
*/
|
|
110
|
+
// Throws unless skillPath is a string under skills/ with no ".." and no
|
|
111
|
+
// backslashes. Deliberately duplicated in lib/sign.js — change both together.
|
|
218
112
|
function validateSkillPath(skillPath) {
|
|
219
113
|
if (typeof skillPath !== 'string') {
|
|
220
114
|
throw new Error(`[verify] manifest skill.path must be a string, got ${typeof skillPath}`);
|
|
221
115
|
}
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
// slashes" diagnostic, not the misleading "must start with skills/".
|
|
116
|
+
// Before the prefix check: a Windows-style "skills\foo\skill.md" earns the
|
|
117
|
+
// "use forward slashes" diagnostic, not the misleading "must start with skills/".
|
|
225
118
|
if (skillPath.includes('\\')) {
|
|
226
119
|
throw new Error(`[verify] manifest skill.path must use forward slashes, not backslashes: ${JSON.stringify(skillPath)}`);
|
|
227
120
|
}
|
|
@@ -293,22 +186,10 @@ function loadManifest() {
|
|
|
293
186
|
return JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
|
|
294
187
|
}
|
|
295
188
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
* breaks the verify-after-sign round trip; do not modify in isolation.
|
|
301
|
-
*
|
|
302
|
-
* v0.12.17 (codex P1 PR #12): use deep canonicalize() instead of the
|
|
303
|
-
* top-level sortedKeys replacer-array. The replacer-array form acts as
|
|
304
|
-
* a property allowlist applied to EVERY object level — nested fields
|
|
305
|
-
* like skills[].path and skills[].signature got silently dropped from
|
|
306
|
-
* the canonical bytes, letting an attacker swap them without breaking
|
|
307
|
-
* the signature.
|
|
308
|
-
*
|
|
309
|
-
* @param {object} manifest
|
|
310
|
-
* @returns {Buffer} canonical UTF-8 bytes
|
|
311
|
-
*/
|
|
189
|
+
// Deep key sort, never a top-level JSON.stringify replacer array: a replacer
|
|
190
|
+
// acts as a property allowlist at EVERY object level, dropping skills[].path and
|
|
191
|
+
// skills[].signature out of the signed bytes. Mirrors lib/sign.js
|
|
192
|
+
// canonicalManifestBytes(); diverging breaks the verify-after-sign round trip.
|
|
312
193
|
function canonicalize(value) {
|
|
313
194
|
if (Array.isArray(value)) return value.map(canonicalize);
|
|
314
195
|
if (value && typeof value === 'object') {
|
|
@@ -329,31 +210,15 @@ function canonicalManifestBytes(manifest) {
|
|
|
329
210
|
}
|
|
330
211
|
|
|
331
212
|
/**
|
|
332
|
-
* Verify the top-level manifest_signature against keys/public.pem.
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
* { status: 'missing' } — field absent (legacy tarball; warn-but-proceed)
|
|
336
|
-
* { status: 'valid' } — signature verifies
|
|
337
|
-
* { status: 'invalid', — signature malformed, wrong key, or tampered
|
|
338
|
-
* reason: string }
|
|
339
|
-
* { status: 'no-key', — keys/public.pem absent
|
|
340
|
-
* reason: string }
|
|
341
|
-
*
|
|
342
|
-
* @param {object} manifest
|
|
213
|
+
* Verify the top-level manifest_signature against keys/public.pem. Status is
|
|
214
|
+
* one of 'valid'; 'missing' (field absent — a caller may warn and proceed);
|
|
215
|
+
* 'invalid' or 'no-key', both carrying a `reason`.
|
|
343
216
|
*/
|
|
344
217
|
function verifyManifestSignature(manifest) {
|
|
345
|
-
// The key
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
// signature-present check, so a key-substitution attacker who swapped
|
|
350
|
-
// keys/public.pem AND stripped manifest_signature got the early `missing`
|
|
351
|
-
// return and never tripped the pin — authenticating against the attacker key
|
|
352
|
-
// through the library API. Consulting the pin up front closes that on the
|
|
353
|
-
// legacy/missing path too. Honors KEYS_ROTATED=1 for legitimate rotations; a
|
|
354
|
-
// MISSING pin file fails closed (keys/EXPECTED_FINGERPRINT ships in the
|
|
355
|
-
// tarball and is committed, so its absence is the signature of a tamper that
|
|
356
|
-
// stripped the pin to hide a swapped key).
|
|
218
|
+
// The key pin is consulted FIRST, before the signature-present branch: an
|
|
219
|
+
// attacker who swaps keys/public.pem AND strips manifest_signature would
|
|
220
|
+
// otherwise take the early `missing` return. A missing pin file fails closed —
|
|
221
|
+
// the pin is committed and ships in the tarball. KEYS_ROTATED=1 overrides.
|
|
357
222
|
const publicKey = loadPublicKey();
|
|
358
223
|
if (publicKey) {
|
|
359
224
|
const liveFp = publicKeyFingerprint(publicKey);
|
|
@@ -380,12 +245,8 @@ function verifyManifestSignature(manifest) {
|
|
|
380
245
|
if (typeof sig.signature_base64 !== 'string') {
|
|
381
246
|
return { status: 'invalid', reason: 'manifest_signature.signature_base64 missing or not a string' };
|
|
382
247
|
}
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
// (`if (sig.algorithm && sig.algorithm !== 'Ed25519')`) would let a
|
|
386
|
-
// downgrade attacker drop the field to bait a weaker default.
|
|
387
|
-
// lib/sign.js always writes the field, so no legitimate consumer
|
|
388
|
-
// breaks.
|
|
248
|
+
// Exact match, never `sig.algorithm && sig.algorithm !== 'Ed25519'`: tolerating
|
|
249
|
+
// an absent field lets a downgrade attacker drop it. lib/sign.js always writes it.
|
|
389
250
|
if (sig.algorithm !== 'Ed25519') {
|
|
390
251
|
return {
|
|
391
252
|
status: 'invalid',
|
|
@@ -395,8 +256,6 @@ function verifyManifestSignature(manifest) {
|
|
|
395
256
|
if (!publicKey) {
|
|
396
257
|
return { status: 'no-key', reason: 'public key missing at keys/public.pem' };
|
|
397
258
|
}
|
|
398
|
-
// The key-pin (mismatch / no-pin) was already verified up front, before any
|
|
399
|
-
// signature branching — so reaching here means the live key matches the pin.
|
|
400
259
|
let signatureBytes;
|
|
401
260
|
try {
|
|
402
261
|
signatureBytes = Buffer.from(sig.signature_base64, 'base64');
|
|
@@ -417,22 +276,9 @@ function verifyManifestSignature(manifest) {
|
|
|
417
276
|
}
|
|
418
277
|
|
|
419
278
|
/**
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
* Throws on schema violation OR traversal-pattern paths. Either case
|
|
424
|
-
* is a fatal-class bug — surface it loudly rather than verify-against-
|
|
425
|
-
* a-corrupt-manifest.
|
|
426
|
-
*
|
|
427
|
-
* also verifies the top-level manifest_signature. On
|
|
428
|
-
* invalid signature, throws a structured error blocking all skill
|
|
429
|
-
* verification (a coordinated attacker who rewrote manifest.json +
|
|
430
|
-
* manifest-snapshot.json + manifest-snapshot.sha256 still cannot forge
|
|
431
|
-
* the Ed25519 signature without the private key). When the signature
|
|
432
|
-
* field is absent, emits a stderr warning but proceeds — preserves
|
|
433
|
-
* backward compatibility for v0.12.16-and-earlier tarballs.
|
|
434
|
-
*
|
|
435
|
-
* @returns {object}
|
|
279
|
+
* Returns a manifest that passed schema validation, the path-traversal guard and
|
|
280
|
+
* the manifest_signature check, or throws — a verified manifest or nothing. An
|
|
281
|
+
* absent signature field is the sole tolerated case: it warns and proceeds.
|
|
436
282
|
*/
|
|
437
283
|
function loadManifestValidated() {
|
|
438
284
|
const manifest = loadManifest();
|
|
@@ -449,41 +295,29 @@ function loadManifestValidated() {
|
|
|
449
295
|
for (const skill of manifest.skills) {
|
|
450
296
|
validateSkillPath(skill.path);
|
|
451
297
|
}
|
|
452
|
-
//
|
|
453
|
-
// validation so a malformed manifest reports the structural failure
|
|
454
|
-
// before the cryptographic one.
|
|
298
|
+
// After schema + path validation: structural failures report before crypto ones.
|
|
455
299
|
const sigResult = verifyManifestSignature(manifest);
|
|
456
300
|
if (sigResult.status === 'invalid') {
|
|
457
301
|
throw new Error(`[verify] manifest_signature verification FAILED — ${sigResult.reason}. The manifest has been modified (or signed with a different key) since last sign-all. Refusing to verify any skill against this manifest.`);
|
|
458
302
|
}
|
|
459
303
|
if (sigResult.status === 'missing') {
|
|
460
|
-
//
|
|
461
|
-
// loadManifestValidated()
|
|
462
|
-
// console.warn would spam stderr per call. Node's emitWarning() with
|
|
463
|
-
// a stable `code` collapses repeated emissions automatically.
|
|
304
|
+
// emitWarning with a stable `code` collapses repeats; CLI verbs call
|
|
305
|
+
// loadManifestValidated() several times per invocation.
|
|
464
306
|
process.emitWarning(
|
|
465
307
|
'manifest.json has no top-level manifest_signature field. This tarball predates v0.12.17 manifest signing; skills will still be verified but a coordinated rewrite of manifest.json could go undetected. Re-run `exceptd doctor --fix` (or `node $(exceptd path)/lib/sign.js sign-all` from a contributor checkout) to add the signature.',
|
|
466
308
|
{ code: 'EXCEPTD_MANIFEST_UNSIGNED' }
|
|
467
309
|
);
|
|
468
310
|
} else if (sigResult.status === 'no-key') {
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
// BEFORE reaching here, so this fires only for a direct
|
|
472
|
-
// loadManifestValidated() caller, which must not silently receive an
|
|
473
|
-
// unauthenticated manifest: an attacker who deletes keys/public.pem could
|
|
474
|
-
// otherwise smuggle a rewritten manifest past a library consumer.
|
|
311
|
+
// verifyAll()/verifyOne() short-circuit on a missing key before reaching
|
|
312
|
+
// here, so this fires only for a direct library caller.
|
|
475
313
|
throw new Error(`[verify] manifest_signature verification FAILED — ${sigResult.reason}. Cannot verify the manifest without keys/public.pem; refusing to return an unauthenticated manifest.`);
|
|
476
314
|
}
|
|
477
315
|
return manifest;
|
|
478
316
|
}
|
|
479
317
|
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
// schema features manifest.schema.json actually uses: type, required,
|
|
484
|
-
// properties, additionalProperties, items, pattern, minLength,
|
|
485
|
-
// minItems, $defs / $ref (root-relative only — "#/$defs/foo"). Zero
|
|
486
|
-
// external deps.
|
|
318
|
+
// JSON-schema subset, mirroring lib/validate-cve-catalog.js's inline validator:
|
|
319
|
+
// type, required, properties, additionalProperties, items, pattern, minLength,
|
|
320
|
+
// minItems, and root-relative $ref ("#/$defs/foo") only.
|
|
487
321
|
|
|
488
322
|
function typeOf(value) {
|
|
489
323
|
if (value === null) return 'null';
|
|
@@ -580,54 +414,10 @@ function validateAgainstSchema(value, schema, here, root) {
|
|
|
580
414
|
}
|
|
581
415
|
|
|
582
416
|
/**
|
|
583
|
-
*
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
* output for the same key, matches GPG / npm provenance / CT log
|
|
588
|
-
* fingerprints. Operators cross-referencing the key against an
|
|
589
|
-
* external pin will use this line.
|
|
590
|
-
*
|
|
591
|
-
* - SHA3-512: SHA-3 family (Keccak / sponge construction), different
|
|
592
|
-
* mathematical foundation than SHA-2. Hedges against future SHA-2
|
|
593
|
-
* weaknesses. 512-bit output (~88 b64 chars) so collision +
|
|
594
|
-
* second-preimage resistance both exceed the 256-bit Ed25519 key
|
|
595
|
-
* itself. SHA-3 is also the hash family ML-KEM / ML-DSA use
|
|
596
|
-
* internally, so this fingerprint travels well with the project's
|
|
597
|
-
* PQ posture.
|
|
598
|
-
*
|
|
599
|
-
* @param {string|null} pemKey PEM-encoded public key (or null)
|
|
600
|
-
* @returns {{sha256: string, sha3_512: string}|{error: string}}
|
|
601
|
-
*/
|
|
602
|
-
/**
|
|
603
|
-
* compare the live public-key fingerprint against the optional
|
|
604
|
-
* pinned fingerprint in keys/EXPECTED_FINGERPRINT. Returns one of:
|
|
605
|
-
* { status: 'no-pin' } — keys/EXPECTED_FINGERPRINT not present.
|
|
606
|
-
* Callers should warn and continue.
|
|
607
|
-
* { status: 'match' } — live fingerprint matches the pin.
|
|
608
|
-
* { status: 'mismatch', — divergence; caller should fail unless
|
|
609
|
-
* expected, actual, KEYS_ROTATED=1 is set in the environment.
|
|
610
|
-
* rotationOverride }
|
|
611
|
-
*
|
|
612
|
-
* @param {{sha256:string}|null} liveFp publicKeyFingerprint() output
|
|
613
|
-
* @param {string} [pinPath] optional override (testability)
|
|
614
|
-
*/
|
|
615
|
-
/**
|
|
616
|
-
* Shared loader for keys/EXPECTED_FINGERPRINT. Reads the pin file, strips
|
|
617
|
-
* a leading UTF-8 BOM (Notepad with files.encoding=utf8bom would otherwise
|
|
618
|
-
* prepend U+FEFF and silently break every verify path on the host),
|
|
619
|
-
* tolerates CRLF line endings, ignores comment lines (`#`) and blanks,
|
|
620
|
-
* and returns the first non-comment / non-empty line. Returns null if
|
|
621
|
-
* the file is unreadable / empty.
|
|
622
|
-
*
|
|
623
|
-
* Shared across five sites so every loader normalises identically:
|
|
624
|
-
* - lib/verify.js (manifest signature gate)
|
|
625
|
-
* - lib/refresh-network.js (refresh-network pre-swap gate)
|
|
626
|
-
* - scripts/verify-shipped-tarball.js (predeploy gate)
|
|
627
|
-
* - bin/exceptd.js (attestation pin)
|
|
628
|
-
* - lib/prefetch.js (cache-consume index signature pin)
|
|
629
|
-
* tests/normalize-contract.test.js asserts byte-identical output across the
|
|
630
|
-
* sites under a BOM + CRLF fuzz corpus.
|
|
417
|
+
* First non-comment, non-blank line of keys/EXPECTED_FINGERPRINT, or null when
|
|
418
|
+
* the file is unreadable or empty. Strips a BOM and tolerates CRLF. Every site
|
|
419
|
+
* that reads the pin normalises through here, so a pin that works in one works
|
|
420
|
+
* in all; tests/normalize-contract.test.js asserts that.
|
|
631
421
|
*/
|
|
632
422
|
function loadExpectedFingerprintFirstLine(pinPath) {
|
|
633
423
|
let buf;
|
|
@@ -636,17 +426,11 @@ function loadExpectedFingerprintFirstLine(pinPath) {
|
|
|
636
426
|
if (buf.length >= 2) {
|
|
637
427
|
const b0 = buf[0];
|
|
638
428
|
const b1 = buf[1];
|
|
639
|
-
// UTF-
|
|
640
|
-
//
|
|
641
|
-
// and the operator sees no signal. Refuse them; re-save as UTF-8.
|
|
429
|
+
// A UTF-16 pin file decodes as UTF-8 mojibake, so the first line silently
|
|
430
|
+
// never matches a live fingerprint. Refuse it; the fix is re-saving as UTF-8.
|
|
642
431
|
if ((b0 === 0xFF && b1 === 0xFE) || (b0 === 0xFE && b1 === 0xFF)) return null;
|
|
643
|
-
// UTF-16BE
|
|
644
|
-
//
|
|
645
|
-
// 0x00 <printable ASCII> 0x00 <printable ASCII>... The leading NUL byte
|
|
646
|
-
// would survive into the utf8 string and the first-line compare would
|
|
647
|
-
// never match a SHA256:... fingerprint. Detect "00 XX" where XX is
|
|
648
|
-
// printable ASCII (0x20-0x7E) and refuse — same remediation: re-save
|
|
649
|
-
// the file as UTF-8.
|
|
432
|
+
// UTF-16BE whose BOM was stripped: "00 <printable ASCII>" repeating. The
|
|
433
|
+
// leading NUL survives into the string and defeats the compare the same way.
|
|
650
434
|
if (b0 === 0x00 && b1 >= 0x20 && b1 <= 0x7E) return null;
|
|
651
435
|
}
|
|
652
436
|
let raw = buf.toString('utf8');
|
|
@@ -658,15 +442,17 @@ function loadExpectedFingerprintFirstLine(pinPath) {
|
|
|
658
442
|
return lines[0] || null;
|
|
659
443
|
}
|
|
660
444
|
|
|
445
|
+
// Status is 'no-pin' (no pin file — the caller decides whether that is fatal),
|
|
446
|
+
// 'match', or 'mismatch' with expected/actual and rotationOverride set from
|
|
447
|
+
// KEYS_ROTATED=1. pinPath overrides the default location for tests.
|
|
661
448
|
function checkExpectedFingerprint(liveFp, pinPath) {
|
|
662
449
|
const p = pinPath || EXPECTED_FINGERPRINT_PATH;
|
|
663
450
|
if (!fs.existsSync(p)) return { status: 'no-pin' };
|
|
664
451
|
if (!liveFp || typeof liveFp.sha256 !== 'string') {
|
|
665
452
|
return { status: 'mismatch', expected: 'unknown', actual: '(invalid)', rotationOverride: false };
|
|
666
453
|
}
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
// every verify site. Pre-fix the verbatim split-trim-find produced a
|
|
454
|
+
// The shared loader tolerates a BOM-prefixed pin file identically across every
|
|
455
|
+
// verify site. A verbatim split-trim-find here would instead yield a
|
|
670
456
|
// allow:bidi-codepoint-literal — illustrative BOM-prefixed first-line in the pin-loader doc comment
|
|
671
457
|
// first-line of "SHA256:..." (with leading BOM) that would never equal
|
|
672
458
|
// a live fingerprint.
|
|
@@ -695,8 +481,6 @@ function publicKeyFingerprint(pemKey) {
|
|
|
695
481
|
}
|
|
696
482
|
}
|
|
697
483
|
|
|
698
|
-
// --- CLI ---
|
|
699
|
-
|
|
700
484
|
if (require.main === module) {
|
|
701
485
|
const arg = process.argv[2];
|
|
702
486
|
|
|
@@ -737,14 +521,8 @@ if (require.main === module) {
|
|
|
737
521
|
if (result.no_key) process.exit(1);
|
|
738
522
|
|
|
739
523
|
const total = Object.values(result).filter(Array.isArray).flat().length;
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
// fingerprint banner without first seeing whether the verdict
|
|
743
|
-
// was pass or fail. The previous order printed the success
|
|
744
|
-
// summary then the fingerprint; if verification was actually
|
|
745
|
-
// failing (TAMPERED / UNSIGNED / MISSING) the success line was
|
|
746
|
-
// never reached but the fingerprint had already been printed,
|
|
747
|
-
// which can read as "success" at a glance.
|
|
524
|
+
// Verdict first, fingerprint banner after: a banner printed above a TAMPERED /
|
|
525
|
+
// UNSIGNED / MISSING verdict reads as success at a glance.
|
|
748
526
|
if (result.invalid.length > 0) {
|
|
749
527
|
console.error(`\n[verify] ${result.invalid.length}/${total} FAILED — TAMPERED: ${result.invalid.join(', ')}`);
|
|
750
528
|
} else if (result.missing_sig.length > 0) {
|
|
@@ -755,18 +533,13 @@ if (require.main === module) {
|
|
|
755
533
|
console.log(`\n[verify] All skills verified. ${result.valid.length}/${total} skills passed Ed25519 verification.`);
|
|
756
534
|
}
|
|
757
535
|
|
|
758
|
-
// Fingerprint banner comes AFTER the verdict.
|
|
759
536
|
const pubKey = loadPublicKey();
|
|
760
537
|
const fp = publicKeyFingerprint(pubKey);
|
|
761
538
|
console.log(`[verify] Public key: keys/public.pem`);
|
|
762
539
|
console.log(`[verify] ${fp.sha256}`);
|
|
763
540
|
console.log(`[verify] ${fp.sha3_512}`);
|
|
764
541
|
|
|
765
|
-
// pin
|
|
766
|
-
// live fingerprint MUST match it (or KEYS_ROTATED=1 must be set to
|
|
767
|
-
// intentionally override). When the file is absent, emit a single-line
|
|
768
|
-
// warning but continue — fresh clones / bootstrap workflows should not
|
|
769
|
-
// fail the gate before the operator has committed a fingerprint.
|
|
542
|
+
// On this path an absent pin warns and continues: a fresh clone has no pin yet.
|
|
770
543
|
const pinResult = checkExpectedFingerprint(fp);
|
|
771
544
|
if (pinResult.status === 'no-pin') {
|
|
772
545
|
console.warn(
|
|
@@ -780,10 +553,7 @@ if (require.main === module) {
|
|
|
780
553
|
`KEYS_ROTATED=1 accepted. Update keys/EXPECTED_FINGERPRINT to lock the new pin.`,
|
|
781
554
|
{ code: 'EXCEPTD_KEYS_ROTATED_OVERRIDE' }
|
|
782
555
|
);
|
|
783
|
-
//
|
|
784
|
-
// process.emitWarning, but a key-rotation override is a
|
|
785
|
-
// security-relevant event that must surface in the operator's
|
|
786
|
-
// terminal even when warnings are muted.
|
|
556
|
+
// Mirrored to stderr: NODE_NO_WARNINGS=1 silences emitWarning.
|
|
787
557
|
console.error(
|
|
788
558
|
`[verify] KEYS_ROTATED=1 override accepted; live fingerprint ${pinResult.actual} ` +
|
|
789
559
|
`differs from pin ${pinResult.expected}. Update keys/EXPECTED_FINGERPRINT to lock the new pin.`
|