@blamejs/exceptd-skills 0.12.22 → 0.12.24
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/AGENTS.md +18 -12
- package/ARCHITECTURE.md +2 -2
- package/CHANGELOG.md +152 -2
- package/CONTEXT.md +126 -69
- package/README.md +21 -8
- package/bin/exceptd.js +972 -464
- package/data/_indexes/_meta.json +3 -3
- package/data/_indexes/stale-content.json +10 -3
- package/data/playbooks/ai-api.json +1 -1
- package/data/playbooks/containers.json +1 -1
- package/data/playbooks/cred-stores.json +1 -1
- package/data/playbooks/crypto-codebase.json +1 -1
- package/data/playbooks/crypto.json +1 -1
- package/data/playbooks/framework.json +1 -1
- package/data/playbooks/hardening.json +1 -1
- package/data/playbooks/kernel.json +1 -1
- package/data/playbooks/library-author.json +1 -1
- package/data/playbooks/mcp.json +1 -1
- package/data/playbooks/runtime.json +1 -1
- package/data/playbooks/sbom.json +1 -1
- package/data/playbooks/secrets.json +39 -1
- package/lib/auto-discovery.js +28 -4
- package/lib/cross-ref-api.js +12 -11
- package/lib/cve-curation.js +18 -19
- package/lib/exit-codes.js +72 -0
- package/lib/flag-suggest.js +130 -0
- package/lib/id-validation.js +95 -0
- package/lib/lint-skills.js +73 -6
- package/lib/playbook-runner.js +617 -343
- package/lib/prefetch.js +134 -21
- package/lib/refresh-external.js +205 -26
- package/lib/refresh-network.js +64 -16
- package/lib/schemas/cve-catalog.schema.json +7 -1
- package/lib/schemas/playbook.schema.json +51 -0
- package/lib/scoring.js +49 -7
- package/lib/sign.js +10 -11
- package/lib/source-osv.js +7 -7
- package/lib/upstream-check-cli.js +16 -1
- package/lib/upstream-check.js +9 -0
- package/lib/validate-catalog-meta.js +1 -1
- package/lib/validate-cve-catalog.js +1 -1
- package/lib/verify.js +56 -30
- package/manifest.json +40 -40
- package/package.json +8 -2
- package/sbom.cdx.json +6 -6
- package/scripts/check-test-coverage.js +67 -0
- package/scripts/verify-shipped-tarball.js +27 -18
|
@@ -6,6 +6,57 @@
|
|
|
6
6
|
"type": "object",
|
|
7
7
|
"required": ["_meta", "domain", "phases", "directives"],
|
|
8
8
|
"additionalProperties": false,
|
|
9
|
+
"allOf": [
|
|
10
|
+
{
|
|
11
|
+
"if": {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"properties": {
|
|
14
|
+
"_meta": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"properties": { "air_gap_mode": { "const": true } },
|
|
17
|
+
"required": ["air_gap_mode"]
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"required": ["_meta"]
|
|
21
|
+
},
|
|
22
|
+
"then": {
|
|
23
|
+
"description": "When _meta.air_gap_mode is true, every artifact whose source contains a network-call substring (https://, http://, gh api, gh release, curl, wget, fetch) MUST carry an air_gap_alternative. The runner refuses to use the network when --air-gap is set, so an artifact with no offline fallback cannot be collected and the run is incomplete.",
|
|
24
|
+
"type": "object",
|
|
25
|
+
"properties": {
|
|
26
|
+
"phases": {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"properties": {
|
|
29
|
+
"look": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"properties": {
|
|
32
|
+
"artifacts": {
|
|
33
|
+
"type": "array",
|
|
34
|
+
"items": {
|
|
35
|
+
"anyOf": [
|
|
36
|
+
{
|
|
37
|
+
"not": {
|
|
38
|
+
"type": "object",
|
|
39
|
+
"properties": {
|
|
40
|
+
"source": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"pattern": "(https://|http://|gh api|gh release|curl |wget |fetch )"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"required": ["source"]
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
{ "required": ["air_gap_alternative"] }
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
],
|
|
9
60
|
"properties": {
|
|
10
61
|
|
|
11
62
|
"_meta": {
|
package/lib/scoring.js
CHANGED
|
@@ -92,7 +92,7 @@ function score(cveId, catalog) {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
/**
|
|
95
|
-
*
|
|
95
|
+
* Validate an RWEP factor bag. Returns an array of warning strings
|
|
96
96
|
* for missing-but-defaultable fields and out-of-range values. Does NOT
|
|
97
97
|
* throw — operators wanting hard enforcement should treat a non-empty
|
|
98
98
|
* return as a failure themselves.
|
|
@@ -342,13 +342,14 @@ function validate(catalog) {
|
|
|
342
342
|
const errors = [];
|
|
343
343
|
for (const [cveId, entry] of Object.entries(catalog)) {
|
|
344
344
|
if (cveId.startsWith('_')) continue;
|
|
345
|
-
//
|
|
346
|
-
//
|
|
345
|
+
// Skip auto-imported drafts. KEV/GHSA/OSV-discovered drafts store a
|
|
346
|
+
// conservative-default rwep_score (poc=true, reboot=true, etc.)
|
|
347
347
|
// alongside `poc_available: null` and other null-until-curated factor
|
|
348
|
-
// fields, so the recomputed-vs-stored divergence check
|
|
349
|
-
// against them
|
|
350
|
-
// separately via the `_auto_imported_meta.curation_needed` list and
|
|
351
|
-
// strict catalog validator's draft-warning tier. Once curation
|
|
348
|
+
// fields, so the recomputed-vs-stored divergence check would always
|
|
349
|
+
// fire against them and flood the predeploy gate. Drafts are reviewed
|
|
350
|
+
// separately via the `_auto_imported_meta.curation_needed` list and
|
|
351
|
+
// the strict catalog validator's draft-warning tier. Once curation
|
|
352
|
+
// promotes
|
|
352
353
|
// an entry, `_auto_imported` is cleared and full validation resumes.
|
|
353
354
|
if (entry && entry._auto_imported === true) continue;
|
|
354
355
|
for (const field of CVE_SCHEMA_REQUIRED) {
|
|
@@ -380,6 +381,46 @@ function validate(catalog) {
|
|
|
380
381
|
return errors;
|
|
381
382
|
}
|
|
382
383
|
|
|
384
|
+
/**
|
|
385
|
+
* Strict CVSS 3.1 vector parse. Returns `{ ok, version, reason? }`.
|
|
386
|
+
*
|
|
387
|
+
* The CSAF 2.0 cvss_v3 score block requires a canonical CVSS 3.1 vector
|
|
388
|
+
* string. Strict validators (BSI CSAF Validator, ENISA dashboard) reject
|
|
389
|
+
* documents that emit a cvss_v3 block keyed off a malformed vector — the
|
|
390
|
+
* pre-fix permissive `^CVSS:(\d+\.\d+)/` regex let through 3.0 vectors,
|
|
391
|
+
* truncated metric sets, and unknown environmental-metric values, which
|
|
392
|
+
* downstream tooling then rejected wholesale.
|
|
393
|
+
*
|
|
394
|
+
* Required metric set (in order): AV / AC / PR / UI / S / C / I / A.
|
|
395
|
+
* Optional temporal metrics: E / RL / RC.
|
|
396
|
+
* Optional environmental metrics: CR / IR / AR / MAV / MAC / MPR / MUI /
|
|
397
|
+
* MS / MC / MI / MA.
|
|
398
|
+
*/
|
|
399
|
+
// CVSS 3.0 and 3.1 share an identical vector grammar (metric set, value enums,
|
|
400
|
+
// and metric order are the same; only the `CVSS:X.Y/` prefix differs). CSAF
|
|
401
|
+
// 2.0 §3.2.4.3 accepts both versions in the cvss_v3 block. The strict regex
|
|
402
|
+
// matches either prefix; the parser records which version the vector declared
|
|
403
|
+
// so the emitter can stamp the right `version` field.
|
|
404
|
+
const CVSS_3X_RE = /^CVSS:3\.[01]\/AV:[NALP]\/AC:[LH]\/PR:[NLH]\/UI:[NR]\/S:[UC]\/C:[NLH]\/I:[NLH]\/A:[NLH](\/E:[XUPFH])?(\/RL:[XOTWU])?(\/RC:[XURC])?(\/CR:[XLMH])?(\/IR:[XLMH])?(\/AR:[XLMH])?(\/MAV:[XNALP])?(\/MAC:[XLH])?(\/MPR:[XNLH])?(\/MUI:[XNR])?(\/MS:[XUC])?(\/MC:[XNLH])?(\/MI:[XNLH])?(\/MA:[XNLH])?$/;
|
|
405
|
+
|
|
406
|
+
function parseCvss31Vector(v) {
|
|
407
|
+
if (typeof v !== 'string' || v.length === 0) {
|
|
408
|
+
return { ok: false, version: null, reason: 'cvss_vector is not a non-empty string' };
|
|
409
|
+
}
|
|
410
|
+
const versionMatch = v.match(/^CVSS:(\d+\.\d+)\//);
|
|
411
|
+
if (!versionMatch) {
|
|
412
|
+
return { ok: false, version: null, reason: 'cvss_vector does not start with a CVSS:X.Y/ version prefix' };
|
|
413
|
+
}
|
|
414
|
+
const version = versionMatch[1];
|
|
415
|
+
if (version !== '3.0' && version !== '3.1') {
|
|
416
|
+
return { ok: false, version, reason: `cvss_vector declares version ${version}; CSAF 2.0 cvss_v3 accepts 3.0 and 3.1 only. Backfill a CVSS 3.x vector against this CVE in the catalog, or wait for CSAF 2.1 (cvss_v4 support).` };
|
|
417
|
+
}
|
|
418
|
+
if (!CVSS_3X_RE.test(v)) {
|
|
419
|
+
return { ok: false, version, reason: 'cvss_vector does not match the strict CVSS 3.x grammar (missing/invalid mandatory metric, unknown metric value, or out-of-order metric)' };
|
|
420
|
+
}
|
|
421
|
+
return { ok: true, version };
|
|
422
|
+
}
|
|
423
|
+
|
|
383
424
|
module.exports = {
|
|
384
425
|
score,
|
|
385
426
|
scoreCustom,
|
|
@@ -388,6 +429,7 @@ module.exports = {
|
|
|
388
429
|
validate,
|
|
389
430
|
validateFactors,
|
|
390
431
|
deriveRwepFromFactors,
|
|
432
|
+
parseCvss31Vector,
|
|
391
433
|
RWEP_WEIGHTS,
|
|
392
434
|
ACTIVE_EXPLOITATION_LADDER,
|
|
393
435
|
RECOGNISED_FACTOR_KEYS,
|
package/lib/sign.js
CHANGED
|
@@ -178,9 +178,9 @@ function signAll() {
|
|
|
178
178
|
|
|
179
179
|
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
180
180
|
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
181
|
+
// Verdict line FIRST, fingerprint banner after. An operator scrolling
|
|
182
|
+
// output should not be able to see "fingerprint: SHA256..." and assume
|
|
183
|
+
// success when errors > 0.
|
|
184
184
|
if (errors > 0) {
|
|
185
185
|
console.error(`\n[sign] FAILED — ${signed} signed, ${errors} errors.`);
|
|
186
186
|
} else {
|
|
@@ -343,14 +343,13 @@ function canonicalManifestBytes(manifest) {
|
|
|
343
343
|
* Returns the manifest_signature object literal to splice into the
|
|
344
344
|
* manifest top level.
|
|
345
345
|
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
* publish timestamp).
|
|
346
|
+
* The manifest_signature shape carries `algorithm` + `signature_base64`
|
|
347
|
+
* only — no `signed_at` ISO timestamp. A `signed_at` field stripped from
|
|
348
|
+
* the canonical bytes before signing would be unsigned metadata; an
|
|
349
|
+
* attacker who replayed a known-valid signature could rewrite it to any
|
|
350
|
+
* value, lending false freshness authority to a stale signature.
|
|
351
|
+
* Freshness signal lives outside the signed bytes (git-log mtime of
|
|
352
|
+
* manifest.json, npm publish timestamp).
|
|
354
353
|
*
|
|
355
354
|
* @param {object} manifest
|
|
356
355
|
* @param {string} privateKey PEM-encoded Ed25519 private key
|
package/lib/source-osv.js
CHANGED
|
@@ -175,7 +175,7 @@ function osvRequestOnce({ method, reqPath, body, timeoutMs }) {
|
|
|
175
175
|
return new Promise((resolve, reject) => {
|
|
176
176
|
const t = osvTransport();
|
|
177
177
|
if (t.error) {
|
|
178
|
-
//
|
|
178
|
+
// Surface the validation error structurally; no retry.
|
|
179
179
|
return resolve({ ok: false, error: t.error, source: "offline" });
|
|
180
180
|
}
|
|
181
181
|
const { mod, host, port } = t;
|
|
@@ -539,7 +539,7 @@ function extractCvss(rec) {
|
|
|
539
539
|
const prev = vectorsByVersion.get(ver);
|
|
540
540
|
if (!prev) vectorsByVersion.set(ver, v);
|
|
541
541
|
}
|
|
542
|
-
//
|
|
542
|
+
// Try versions in descending order. CVSS 4.0 derivation is not yet
|
|
543
543
|
// implemented here — if v4 was the highest but can't be computed, walk
|
|
544
544
|
// down to v3.x. Only return null when ALL versions fail.
|
|
545
545
|
const versions = Array.from(vectorsByVersion.keys()).sort((a, b) => b - a);
|
|
@@ -737,7 +737,7 @@ function normalizeAdvisory(rec) {
|
|
|
737
737
|
// OSV.dev canonical advisory URL — used as the primary vendor advisory.
|
|
738
738
|
const osvUrl = `https://osv.dev/vulnerability/${encodeURIComponent(rec.id)}`;
|
|
739
739
|
|
|
740
|
-
//
|
|
740
|
+
// Dedupe verification_sources. OSV records frequently carry the
|
|
741
741
|
// canonical osv.dev URL in references[] as well, which would otherwise
|
|
742
742
|
// produce a duplicate alongside the prepended `osvUrl`.
|
|
743
743
|
const verification_sources = Array.from(new Set([
|
|
@@ -746,9 +746,9 @@ function normalizeAdvisory(rec) {
|
|
|
746
746
|
...refUrls.slice(0, 10),
|
|
747
747
|
]));
|
|
748
748
|
|
|
749
|
-
//
|
|
749
|
+
// EPSS coverage does not extend to non-CVE identifiers. Surface this
|
|
750
750
|
// explicitly so curators know to re-query if MITRE later assigns a CVE
|
|
751
|
-
// id to the entry.
|
|
751
|
+
// id to the entry.
|
|
752
752
|
const isCveKey = /^CVE-/i.test(catalogKey);
|
|
753
753
|
const epss_note = isCveKey
|
|
754
754
|
? null
|
|
@@ -840,13 +840,13 @@ async function buildDiff(ctx) {
|
|
|
840
840
|
const cveCatalog = ctx.cveCatalog || {};
|
|
841
841
|
const existingKeys = new Set(Object.keys(cveCatalog));
|
|
842
842
|
const diffs = [];
|
|
843
|
-
//
|
|
843
|
+
// Distinguish unreachable (fetch failed, network or 5xx) from
|
|
844
844
|
// normalize-rejected (record fetched but normalization produced null).
|
|
845
845
|
// Operators triaging a refresh-report want to know whether to chase a
|
|
846
846
|
// network outage or a malformed upstream record.
|
|
847
847
|
let unreachable = 0;
|
|
848
848
|
let normalizeErrors = 0;
|
|
849
|
-
//
|
|
849
|
+
// Ids that ARE in the catalog but skipped because of overlap
|
|
850
850
|
// are not "errors"; surface them so the summary doesn't read as silently
|
|
851
851
|
// dropping work. Particularly useful when a curator dispatches the same
|
|
852
852
|
// batch twice and wonders why nothing happened.
|
|
@@ -28,12 +28,13 @@ const ROOT = path.resolve(__dirname, "..");
|
|
|
28
28
|
const { fetchLatestPublished, buildFreshnessReport } = require("./upstream-check.js");
|
|
29
29
|
|
|
30
30
|
function parseArgs(argv) {
|
|
31
|
-
const out = { timeoutMs: 5000, raw: false };
|
|
31
|
+
const out = { timeoutMs: 5000, raw: false, airGap: false };
|
|
32
32
|
for (let i = 2; i < argv.length; i++) {
|
|
33
33
|
const a = argv[i];
|
|
34
34
|
if (a === "--timeout") { out.timeoutMs = parseInt(argv[++i], 10) || 5000; }
|
|
35
35
|
else if (a.startsWith("--timeout=")) { out.timeoutMs = parseInt(a.slice("--timeout=".length), 10) || 5000; }
|
|
36
36
|
else if (a === "--raw") out.raw = true;
|
|
37
|
+
else if (a === "--air-gap") out.airGap = true;
|
|
37
38
|
}
|
|
38
39
|
return out;
|
|
39
40
|
}
|
|
@@ -52,6 +53,20 @@ function readManifest() {
|
|
|
52
53
|
|
|
53
54
|
(async () => {
|
|
54
55
|
const opts = parseArgs(process.argv);
|
|
56
|
+
// Air-gap short-circuit — the registry probe is a network operation. When
|
|
57
|
+
// the operator has declared air-gapped mode (env var OR --air-gap), emit a
|
|
58
|
+
// structured `skipped` envelope and exit 0. Exit 0 because, like the
|
|
59
|
+
// existing "offline" path, missing freshness data is a graceful degradation
|
|
60
|
+
// — it is NOT an error condition for downstream callers.
|
|
61
|
+
if (process.env.EXCEPTD_AIR_GAP === "1" || opts.airGap) {
|
|
62
|
+
process.stdout.write(JSON.stringify({
|
|
63
|
+
ok: null,
|
|
64
|
+
skipped: "air-gap",
|
|
65
|
+
reason: "registry probe disabled in air-gap mode",
|
|
66
|
+
source: "upstream-check",
|
|
67
|
+
}) + "\n");
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
55
70
|
const registry = await fetchLatestPublished({ timeoutMs: opts.timeoutMs });
|
|
56
71
|
if (opts.raw) {
|
|
57
72
|
process.stdout.write(JSON.stringify(registry) + "\n");
|
package/lib/upstream-check.js
CHANGED
|
@@ -33,6 +33,15 @@ const REQUEST_TIMEOUT_MS = 5000;
|
|
|
33
33
|
* a path to a JSON file with { version, time: { <ver>: ISO } } shape.
|
|
34
34
|
*/
|
|
35
35
|
async function fetchLatestPublished({ timeoutMs = REQUEST_TIMEOUT_MS, pkgName = PKG_NAME } = {}) {
|
|
36
|
+
// Air-gap refusal — registry probes are a network operation and must never
|
|
37
|
+
// be issued when the operator has declared an air-gapped environment.
|
|
38
|
+
// Returning a structured refusal (instead of throwing) lets callers degrade
|
|
39
|
+
// gracefully the same way they handle `offline` — the freshness signal is
|
|
40
|
+
// intentionally absent, not in error.
|
|
41
|
+
if (process.env.EXCEPTD_AIR_GAP === "1") {
|
|
42
|
+
return { ok: false, error: "air-gap-blocked", source: "fetchLatestPublished" };
|
|
43
|
+
}
|
|
44
|
+
|
|
36
45
|
if (process.env.EXCEPTD_REGISTRY_FIXTURE) {
|
|
37
46
|
try {
|
|
38
47
|
const fs = require("fs");
|
|
@@ -242,7 +242,7 @@ function main() {
|
|
|
242
242
|
console.log(
|
|
243
243
|
`\n${passed}/${total} catalogs validated${warnSuffix}${failSuffix}.`,
|
|
244
244
|
);
|
|
245
|
-
//
|
|
245
|
+
// process.exitCode + return so buffered writes drain.
|
|
246
246
|
process.exitCode = failed === 0 ? 0 : 1;
|
|
247
247
|
}
|
|
248
248
|
|
|
@@ -418,7 +418,7 @@ function main() {
|
|
|
418
418
|
(warned ? `, ${warned} with warnings` : '') +
|
|
419
419
|
(failed ? `, ${failed} failed` : '') + '.';
|
|
420
420
|
console.log(summary);
|
|
421
|
-
//
|
|
421
|
+
// process.exitCode + return so buffered output drains.
|
|
422
422
|
process.exitCode = failed === 0 ? 0 : 1;
|
|
423
423
|
}
|
|
424
424
|
|
package/lib/verify.js
CHANGED
|
@@ -164,14 +164,14 @@ function signAll() {
|
|
|
164
164
|
const manifestSig = crypto.sign(null, canonical, {
|
|
165
165
|
key: privateKey, dsaEncoding: 'ieee-p1363',
|
|
166
166
|
});
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
167
|
+
// `signed_at` is intentionally OMITTED. A `signed_at` timestamp
|
|
168
|
+
// alongside the Ed25519 signature would be unsigned metadata (stripped
|
|
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.
|
|
175
175
|
manifest.manifest_signature = {
|
|
176
176
|
algorithm: 'Ed25519',
|
|
177
177
|
signature_base64: manifestSig.toString('base64'),
|
|
@@ -347,11 +347,12 @@ function verifyManifestSignature(manifest) {
|
|
|
347
347
|
if (typeof sig.signature_base64 !== 'string') {
|
|
348
348
|
return { status: 'invalid', reason: 'manifest_signature.signature_base64 missing or not a string' };
|
|
349
349
|
}
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
// (`if (sig.algorithm && sig.algorithm !== 'Ed25519')`)
|
|
353
|
-
//
|
|
354
|
-
// lib/sign.js always writes the field, so no legitimate consumer
|
|
350
|
+
// Require the algorithm field to be present and exactly 'Ed25519'.
|
|
351
|
+
// Accepting a missing algorithm field
|
|
352
|
+
// (`if (sig.algorithm && sig.algorithm !== 'Ed25519')`) would let a
|
|
353
|
+
// downgrade attacker drop the field to bait a weaker default.
|
|
354
|
+
// lib/sign.js always writes the field, so no legitimate consumer
|
|
355
|
+
// breaks.
|
|
355
356
|
if (sig.algorithm !== 'Ed25519') {
|
|
356
357
|
return {
|
|
357
358
|
status: 'invalid',
|
|
@@ -441,10 +442,10 @@ function loadManifestValidated() {
|
|
|
441
442
|
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.`);
|
|
442
443
|
}
|
|
443
444
|
if (sigResult.status === 'missing') {
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
//
|
|
445
|
+
// Dedupe the legacy-tarball warning. Many CLI verbs call
|
|
446
|
+
// loadManifestValidated() more than once per invocation, so a plain
|
|
447
|
+
// console.warn would spam stderr per call. Node's emitWarning() with
|
|
448
|
+
// a stable `code` collapses repeated emissions automatically.
|
|
448
449
|
process.emitWarning(
|
|
449
450
|
'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 `node lib/sign.js sign-all` to add the signature.',
|
|
450
451
|
{ code: 'EXCEPTD_MANIFEST_UNSIGNED' }
|
|
@@ -594,12 +595,12 @@ function validateAgainstSchema(value, schema, here, root) {
|
|
|
594
595
|
* @param {string} [pinPath] optional override (testability)
|
|
595
596
|
*/
|
|
596
597
|
/**
|
|
597
|
-
*
|
|
598
|
-
*
|
|
599
|
-
*
|
|
600
|
-
* tolerates CRLF line endings, ignores comment lines (`#`) and blanks,
|
|
601
|
-
* returns the first non-comment / non-empty line. Returns null if
|
|
602
|
-
* is unreadable / empty.
|
|
598
|
+
* Shared loader for keys/EXPECTED_FINGERPRINT. Reads the pin file, strips
|
|
599
|
+
* a leading UTF-8 BOM (Notepad with files.encoding=utf8bom would otherwise
|
|
600
|
+
* prepend U+FEFF and silently break every verify path on the host),
|
|
601
|
+
* tolerates CRLF line endings, ignores comment lines (`#`) and blanks,
|
|
602
|
+
* and returns the first non-comment / non-empty line. Returns null if
|
|
603
|
+
* the file is unreadable / empty.
|
|
603
604
|
*
|
|
604
605
|
* Shared across four sites so every loader normalises identically:
|
|
605
606
|
* - lib/verify.js (manifest signature gate)
|
|
@@ -610,9 +611,26 @@ function validateAgainstSchema(value, schema, here, root) {
|
|
|
610
611
|
* four sites under a BOM + CRLF fuzz corpus.
|
|
611
612
|
*/
|
|
612
613
|
function loadExpectedFingerprintFirstLine(pinPath) {
|
|
613
|
-
let
|
|
614
|
-
try {
|
|
614
|
+
let buf;
|
|
615
|
+
try { buf = fs.readFileSync(pinPath); }
|
|
615
616
|
catch { return null; }
|
|
617
|
+
if (buf.length >= 2) {
|
|
618
|
+
const b0 = buf[0];
|
|
619
|
+
const b1 = buf[1];
|
|
620
|
+
// UTF-16LE (FF FE) and UTF-16BE (FE FF) pin files would silently decode
|
|
621
|
+
// as UTF-8 mojibake — the first line never matches a live fingerprint
|
|
622
|
+
// and the operator sees no signal. Refuse them; re-save as UTF-8.
|
|
623
|
+
if ((b0 === 0xFF && b1 === 0xFE) || (b0 === 0xFE && b1 === 0xFF)) return null;
|
|
624
|
+
// UTF-16BE-without-BOM defense: a pin file saved as UTF-16BE on a host
|
|
625
|
+
// whose editor stripped the BOM (or never wrote one) decodes as
|
|
626
|
+
// 0x00 <printable ASCII> 0x00 <printable ASCII>... The leading NUL byte
|
|
627
|
+
// would survive into the utf8 string and the first-line compare would
|
|
628
|
+
// never match a SHA256:... fingerprint. Detect "00 XX" where XX is
|
|
629
|
+
// printable ASCII (0x20-0x7E) and refuse — same remediation: re-save
|
|
630
|
+
// the file as UTF-8.
|
|
631
|
+
if (b0 === 0x00 && b1 >= 0x20 && b1 <= 0x7E) return null;
|
|
632
|
+
}
|
|
633
|
+
let raw = buf.toString('utf8');
|
|
616
634
|
if (raw.length > 0 && raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
|
|
617
635
|
const lines = raw
|
|
618
636
|
.split(/\r?\n/)
|
|
@@ -627,7 +645,7 @@ function checkExpectedFingerprint(liveFp, pinPath) {
|
|
|
627
645
|
if (!liveFp || typeof liveFp.sha256 !== 'string') {
|
|
628
646
|
return { status: 'mismatch', expected: 'unknown', actual: '(invalid)', rotationOverride: false };
|
|
629
647
|
}
|
|
630
|
-
//
|
|
648
|
+
// Route through the shared loader so a BOM-prefixed pin file
|
|
631
649
|
// (Notepad with files.encoding=utf8bom) is tolerated identically across
|
|
632
650
|
// every verify site. Pre-fix the verbatim split-trim-find produced a
|
|
633
651
|
// first-line of "SHA256:..." (with leading BOM) that would never equal
|
|
@@ -737,10 +755,18 @@ if (require.main === module) {
|
|
|
737
755
|
);
|
|
738
756
|
} else if (pinResult.status === 'mismatch') {
|
|
739
757
|
if (pinResult.rotationOverride) {
|
|
740
|
-
|
|
741
|
-
`
|
|
742
|
-
|
|
743
|
-
|
|
758
|
+
process.emitWarning(
|
|
759
|
+
`live key fingerprint ${pinResult.actual} differs from pin ${pinResult.expected}; ` +
|
|
760
|
+
`KEYS_ROTATED=1 accepted. Update keys/EXPECTED_FINGERPRINT to lock the new pin.`,
|
|
761
|
+
{ code: 'EXCEPTD_KEYS_ROTATED_OVERRIDE' }
|
|
762
|
+
);
|
|
763
|
+
// Mirror to stderr unconditionally: NODE_NO_WARNINGS=1 silences
|
|
764
|
+
// process.emitWarning, but a key-rotation override is a
|
|
765
|
+
// security-relevant event that must surface in the operator's
|
|
766
|
+
// terminal even when warnings are muted.
|
|
767
|
+
console.error(
|
|
768
|
+
`[verify] KEYS_ROTATED=1 override accepted; live fingerprint ${pinResult.actual} ` +
|
|
769
|
+
`differs from pin ${pinResult.expected}. Update keys/EXPECTED_FINGERPRINT to lock the new pin.`
|
|
744
770
|
);
|
|
745
771
|
} else {
|
|
746
772
|
console.error(
|