@blamejs/exceptd-skills 0.12.22 → 0.12.23
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 +47 -1
- package/CONTEXT.md +126 -69
- package/README.md +7 -7
- package/bin/exceptd.js +437 -347
- 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/hardening.json +1 -1
- package/data/playbooks/kernel.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 +15 -1
- package/lib/auto-discovery.js +2 -2
- package/lib/cross-ref-api.js +12 -11
- package/lib/cve-curation.js +18 -19
- package/lib/lint-skills.js +5 -5
- package/lib/playbook-runner.js +296 -297
- package/lib/prefetch.js +21 -21
- package/lib/refresh-external.js +15 -18
- package/lib/refresh-network.js +33 -12
- package/lib/scoring.js +8 -7
- package/lib/sign.js +10 -11
- package/lib/source-osv.js +7 -7
- package/lib/validate-catalog-meta.js +1 -1
- package/lib/validate-cve-catalog.js +1 -1
- package/lib/verify.js +36 -26
- package/manifest.json +40 -40
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
- package/scripts/verify-shipped-tarball.js +18 -18
package/lib/prefetch.js
CHANGED
|
@@ -18,11 +18,10 @@
|
|
|
18
18
|
* rfc/<doc-name>.json — IETF Datatracker doc record
|
|
19
19
|
* pins/<owner>__<repo>__releases.json — MITRE GitHub releases listing
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
* `
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* ones consumed by --source filtering.
|
|
21
|
+
* The registered source names in SOURCES below are `rfc` and `pins`.
|
|
22
|
+
* `--source ietf` or `--source github` would hit "unknown source"
|
|
23
|
+
* because no such key exists. The names below are the canonical ones
|
|
24
|
+
* consumed by --source filtering.
|
|
26
25
|
*
|
|
27
26
|
* Usage:
|
|
28
27
|
* node lib/prefetch.js # fetch everything not fresh
|
|
@@ -237,7 +236,7 @@ async function withIndexLock(cacheDir, mutator) {
|
|
|
237
236
|
// raised when the other process is mid-unlink). Treat both as
|
|
238
237
|
// "lock held, back off" rather than a fatal error.
|
|
239
238
|
if (e.code !== "EEXIST" && e.code !== "EPERM") throw e;
|
|
240
|
-
//
|
|
239
|
+
// PID-liveness check. Same pattern as withCatalogLock in
|
|
241
240
|
// lib/refresh-external.js — read the lockfile's PID, probe with
|
|
242
241
|
// process.kill(pid, 0); ESRCH → holder dead, reclaim immediately;
|
|
243
242
|
// EPERM → holder alive (different user), keep waiting. The mtime
|
|
@@ -322,11 +321,12 @@ function isFresh(idx, source, id, maxAgeMs) {
|
|
|
322
321
|
|
|
323
322
|
function authHeadersForSource(source) {
|
|
324
323
|
if (source === "nvd" && process.env.NVD_API_KEY) return { apiKey: process.env.NVD_API_KEY };
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
324
|
+
// The registered source name for MITRE GitHub releases is `pins`
|
|
325
|
+
// (see SOURCES above). Accept both `pins` and `github` so GITHUB_TOKEN
|
|
326
|
+
// reaches the per-request Authorization header regardless of which
|
|
327
|
+
// spelling the operator's automation uses; without this, anonymous
|
|
328
|
+
// rate-limited fetches happen even when a token is configured. Be
|
|
329
|
+
// forgiving of
|
|
330
330
|
// the historical naming and the registered name.
|
|
331
331
|
if ((source === "pins" || source === "github") && process.env.GITHUB_TOKEN) {
|
|
332
332
|
return { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` };
|
|
@@ -415,14 +415,14 @@ async function prefetch(options = {}) {
|
|
|
415
415
|
const dir = path.dirname(targetPath);
|
|
416
416
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
417
417
|
const body = JSON.stringify(res.json, null, 2) + "\n";
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
418
|
+
// Stage the payload to a same-volume tmp file BEFORE attempting
|
|
419
|
+
// to acquire the index lock. If withIndexLock fails (timeout
|
|
420
|
+
// after MAX_RETRIES), the partially-completed download must be
|
|
421
|
+
// discarded — not left on disk as an orphan payload with no
|
|
422
|
+
// index entry. Air-gap operators feed off `readCached`, which
|
|
423
|
+
// consults the index; an unindexed payload silently becomes junk
|
|
424
|
+
// taking cache space. Pattern: stage → lock → rename+index →
|
|
425
|
+
// release. The rename is atomic same-volume; if it fails inside
|
|
426
426
|
// the lock we clean up the tmp file. If we never reach the rename
|
|
427
427
|
// (lock acquisition throws), the tmp file is unlinked in the
|
|
428
428
|
// catch block below.
|
|
@@ -520,8 +520,8 @@ function readCached(cacheDir, source, id, opts = {}) {
|
|
|
520
520
|
const idx = loadIndex(cacheDir);
|
|
521
521
|
const meta = idx.entries[entryKey(source, id)];
|
|
522
522
|
if (!meta) return null;
|
|
523
|
-
//
|
|
524
|
-
// `new Date(undefined).getTime()` is NaN and `NaN > maxAgeMs` is false
|
|
523
|
+
// When `fetched_at` is missing / non-string / unparseable,
|
|
524
|
+
// `new Date(undefined).getTime()` is NaN and `NaN > maxAgeMs` is false,
|
|
525
525
|
// so the cached entry would have been returned as if fresh. Treat any
|
|
526
526
|
// non-finite age as "no provenance, refuse" unless the caller explicitly
|
|
527
527
|
// opted into allowStale.
|
package/lib/refresh-external.js
CHANGED
|
@@ -94,11 +94,9 @@ function parseArgs(argv) {
|
|
|
94
94
|
else if (a.startsWith("--from-fixture=")) out.fromFixture = a.slice("--from-fixture=".length);
|
|
95
95
|
else if (a === "--report-out") out.reportOut = argv[++i];
|
|
96
96
|
else if (a.startsWith("--report-out=")) out.reportOut = a.slice("--report-out=".length);
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
// network calls. Now the flag is honoured; env var still works as a
|
|
101
|
-
// fallback so existing automation isn't broken.
|
|
97
|
+
// Honour `--air-gap` here so it reaches the GHSA/OSV source modules.
|
|
98
|
+
// EXCEPTD_AIR_GAP=1 still works as an env-var fallback so existing
|
|
99
|
+
// automation isn't broken.
|
|
102
100
|
else if (a === "--air-gap") out.airGap = true;
|
|
103
101
|
}
|
|
104
102
|
return out;
|
|
@@ -852,10 +850,9 @@ function loadCtx(opts) {
|
|
|
852
850
|
d3fendCatalog: JSON.parse(fs.readFileSync(ABS("data/d3fend-catalog.json"), "utf8")),
|
|
853
851
|
fixtures: null,
|
|
854
852
|
cacheDir: null,
|
|
855
|
-
//
|
|
856
|
-
//
|
|
857
|
-
//
|
|
858
|
-
// sources only saw `ctx?.airGap` as undefined when the CLI flag was used.
|
|
853
|
+
// Thread --air-gap (or EXCEPTD_AIR_GAP=1) through to ctx.airGap so the
|
|
854
|
+
// GHSA + OSV source modules (lib/source-ghsa.js, lib/source-osv.js)
|
|
855
|
+
// branch on it and refuse network egress.
|
|
859
856
|
airGap: !!(opts && opts.airGap) || process.env.EXCEPTD_AIR_GAP === "1",
|
|
860
857
|
};
|
|
861
858
|
if (opts.fromFixture) {
|
|
@@ -949,15 +946,15 @@ async function withCatalogLock(catalogPath, mutator) {
|
|
|
949
946
|
// Windows the same race surfaces as EPERM (sharing-violation raised
|
|
950
947
|
// when the holder is mid-unlink). Treat both as "lock held, back off."
|
|
951
948
|
if (e.code !== "EEXIST" && e.code !== "EPERM") throw e;
|
|
952
|
-
//
|
|
953
|
-
//
|
|
954
|
-
//
|
|
955
|
-
//
|
|
956
|
-
//
|
|
957
|
-
//
|
|
958
|
-
//
|
|
959
|
-
//
|
|
960
|
-
//
|
|
949
|
+
// PID-liveness check before falling back to mtime. The lockfile
|
|
950
|
+
// contains String(process.pid) of the holder; parse it and probe with
|
|
951
|
+
// `process.kill(pid, 0)`. ESRCH means the holder is dead — reclaim
|
|
952
|
+
// immediately rather than waiting STALE_LOCK_MS for the mtime gate
|
|
953
|
+
// to expire. EPERM (holder alive, different user) is treated as
|
|
954
|
+
// "alive, keep waiting." The mtime gate remains as a belt-and-
|
|
955
|
+
// suspenders for cases where the lockfile content is missing /
|
|
956
|
+
// malformed / belongs to a recycled PID. Matches the PID pattern in
|
|
957
|
+
// orchestrator/index.js _acquireWatchLock and
|
|
961
958
|
// lib/playbook-runner.js pidAlive().
|
|
962
959
|
let reclaimedByPid = false;
|
|
963
960
|
try {
|
package/lib/refresh-network.js
CHANGED
|
@@ -418,27 +418,48 @@ async function main() {
|
|
|
418
418
|
// re-bootstrap. Missing EXPECTED_FINGERPRINT file → warn-and-continue
|
|
419
419
|
// (don't break existing installs whose tree predates the pin file).
|
|
420
420
|
const expectedFingerprintPath = path.join(ROOT, "keys", "EXPECTED_FINGERPRINT");
|
|
421
|
+
if (fs.existsSync(expectedFingerprintPath) && process.env.KEYS_ROTATED === "1") {
|
|
422
|
+
process.emitWarning(
|
|
423
|
+
`EXPECTED_FINGERPRINT pin check skipped via KEYS_ROTATED=1 during refresh-network. ` +
|
|
424
|
+
`Update keys/EXPECTED_FINGERPRINT to lock the new pin once rotation completes.`,
|
|
425
|
+
{ code: 'EXCEPTD_KEYS_ROTATED_OVERRIDE' }
|
|
426
|
+
);
|
|
427
|
+
}
|
|
421
428
|
if (fs.existsSync(expectedFingerprintPath) && !process.env.KEYS_ROTATED) {
|
|
422
429
|
try {
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
// across every verify site.
|
|
430
|
+
// Route through the shared lib/verify loader so a BOM-prefixed pin
|
|
431
|
+
// file (Notepad with files.encoding=utf8bom) is tolerated identically
|
|
432
|
+
// across every verify site. An inline split-trim-find would retain
|
|
426
433
|
// the BOM as part of the first line, which would never match a live
|
|
427
434
|
// fingerprint and would block every legitimate refresh-network run.
|
|
428
435
|
const { loadExpectedFingerprintFirstLine } = require("./verify.js");
|
|
429
436
|
const expectedFp = loadExpectedFingerprintFirstLine(expectedFingerprintPath);
|
|
430
|
-
//
|
|
431
|
-
//
|
|
437
|
+
// Pin file present but the loader returned null. The loader refuses
|
|
438
|
+
// UTF-16LE / UTF-16BE pin files and any other shape that cannot be
|
|
439
|
+
// safely decoded. Treat this as a hard fail: the operator placed a
|
|
440
|
+
// pin file but the bytes are not consumable, so we must not fall
|
|
441
|
+
// through to an unpinned refresh. Re-save the pin as UTF-8 (with or
|
|
442
|
+
// without BOM) and retry.
|
|
443
|
+
if (expectedFp === null) {
|
|
444
|
+
emit({
|
|
445
|
+
ok: false,
|
|
446
|
+
error: `keys/EXPECTED_FINGERPRINT exists but its bytes could not be parsed (likely UTF-16 BOM or non-UTF-8 encoding)`,
|
|
447
|
+
pin_path: expectedFingerprintPath,
|
|
448
|
+
hint: "Re-save the pin file as UTF-8 (the loader accepts UTF-8-with-BOM). Refusing to swap on --network with an unparseable pin.",
|
|
449
|
+
}, opts.json);
|
|
450
|
+
process.exitCode = 5; return;
|
|
451
|
+
}
|
|
452
|
+
// The pin file is canonically formatted as `SHA256:<base64>`, but
|
|
432
453
|
// `fingerprintPublicKey()` returns the raw base64 without the
|
|
433
454
|
// `SHA256:` prefix. Comparing the two raw strings would refuse every
|
|
434
455
|
// legitimate run unless KEYS_ROTATED=1 was set. Normalize by stripping
|
|
435
456
|
// the prefix from the pin file before compare. lib/verify.js's
|
|
436
457
|
// checkExpectedFingerprint() does the symmetric thing (adds the
|
|
437
458
|
// prefix to localFp); either side works as long as one is canonical.
|
|
438
|
-
const expectedFpBase64 = expectedFp
|
|
459
|
+
const expectedFpBase64 = expectedFp.startsWith("SHA256:")
|
|
439
460
|
? expectedFp.slice("SHA256:".length)
|
|
440
461
|
: expectedFp;
|
|
441
|
-
if (expectedFpBase64
|
|
462
|
+
if (expectedFpBase64 !== localFp) {
|
|
442
463
|
emit({
|
|
443
464
|
ok: false,
|
|
444
465
|
error: `local keys/public.pem fingerprint diverges from keys/EXPECTED_FINGERPRINT pin`,
|
|
@@ -687,13 +708,13 @@ if (require.main === module) {
|
|
|
687
708
|
module.exports = {
|
|
688
709
|
parseTar,
|
|
689
710
|
fingerprintPublicKey,
|
|
690
|
-
//
|
|
691
|
-
//
|
|
692
|
-
//
|
|
711
|
+
// Exported for tests/normalize-contract.test.js so the byte-stability
|
|
712
|
+
// contract can be asserted across all four normalize() implementations
|
|
713
|
+
// (lib/sign.js, lib/verify.js, lib/refresh-network.js,
|
|
693
714
|
// scripts/verify-shipped-tarball.js).
|
|
694
715
|
normalizeSkillBytes,
|
|
695
|
-
//
|
|
696
|
-
//
|
|
716
|
+
// Exported for in-process tests of the refresh path's manifest envelope
|
|
717
|
+
// check.
|
|
697
718
|
verifyTarballManifestSignature,
|
|
698
719
|
canonicalManifestBytesForRefresh,
|
|
699
720
|
};
|
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) {
|
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.
|
|
@@ -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,18 @@ 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
|
+
}
|
|
625
|
+
let raw = buf.toString('utf8');
|
|
616
626
|
if (raw.length > 0 && raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
|
|
617
627
|
const lines = raw
|
|
618
628
|
.split(/\r?\n/)
|
|
@@ -627,7 +637,7 @@ function checkExpectedFingerprint(liveFp, pinPath) {
|
|
|
627
637
|
if (!liveFp || typeof liveFp.sha256 !== 'string') {
|
|
628
638
|
return { status: 'mismatch', expected: 'unknown', actual: '(invalid)', rotationOverride: false };
|
|
629
639
|
}
|
|
630
|
-
//
|
|
640
|
+
// Route through the shared loader so a BOM-prefixed pin file
|
|
631
641
|
// (Notepad with files.encoding=utf8bom) is tolerated identically across
|
|
632
642
|
// every verify site. Pre-fix the verbatim split-trim-find produced a
|
|
633
643
|
// first-line of "SHA256:..." (with leading BOM) that would never equal
|