@blamejs/exceptd-skills 0.12.21 → 0.12.22
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 +57 -24
- package/bin/exceptd.js +364 -62
- package/data/_indexes/_meta.json +3 -3
- package/data/playbooks/runtime.json +2 -0
- package/lib/playbook-runner.js +165 -32
- package/lib/refresh-network.js +9 -4
- package/lib/validate-cve-catalog.js +2 -2
- package/lib/verify.js +36 -5
- package/manifest.json +41 -41
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
- package/scripts/verify-shipped-tarball.js +6 -2
- package/skills/threat-model-currency/skill.md +1 -1
package/data/_indexes/_meta.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": "1.1.0",
|
|
3
|
-
"generated_at": "2026-05-
|
|
3
|
+
"generated_at": "2026-05-15T06:49:36.722Z",
|
|
4
4
|
"generator": "scripts/build-indexes.js",
|
|
5
5
|
"source_count": 50,
|
|
6
6
|
"source_hashes": {
|
|
7
|
-
"manifest.json": "
|
|
7
|
+
"manifest.json": "2adf0e88322f3903ffd323dbcee69b406c7d801a1539cb1e79e569e47bbafc4c",
|
|
8
8
|
"data/atlas-ttps.json": "20339e0ae3cd89c06f1385be31c50f408f827edc2e8ab8aef026ade3bcf0a917",
|
|
9
9
|
"data/attack-techniques.json": "6db08a8e8a4d03d9309b1d185112de7f3c9595d2cd3d24566b7ce0b3b8aa5d1a",
|
|
10
10
|
"data/cve-catalog.json": "7936ba3c8f27156235bf327830e8f1a684658865e97f089aed98b2a7cdbb88ef",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"skills/rag-pipeline-security/skill.md": "78f00a39e66f08da2894e28eeedb32137295ca019eba7110ab28282d613a97eb",
|
|
26
26
|
"skills/ai-c2-detection/skill.md": "095cab9daa072bfabc87152aea1b61ccd6da8f531753b05c181629f04014b5ca",
|
|
27
27
|
"skills/policy-exception-gen/skill.md": "79db45ba722a6dd9bba25bf84e0b52cf659b56b662193cef80a8273337e41df9",
|
|
28
|
-
"skills/threat-model-currency/skill.md": "
|
|
28
|
+
"skills/threat-model-currency/skill.md": "4badf98c3d8ca5f5c9854cbb25ed994491c39a88ff1aa354b72c50e59a7d3261",
|
|
29
29
|
"skills/global-grc/skill.md": "e0487de49679172347653d8c191d1f269193de6f444f6b0c6396d326e45bd72e",
|
|
30
30
|
"skills/zeroday-gap-learn/skill.md": "086df0fe792c80ca864da6917958bf3df7be5ba02df1c5894972a69353306ee6",
|
|
31
31
|
"skills/pqc-first/skill.md": "a5eb776e1ea3bb422a4c18a3bdf39ad2ec1651b3c25e65c89428ba319141b275",
|
package/lib/playbook-runner.js
CHANGED
|
@@ -287,6 +287,15 @@ function lockFilePath(playbookId) {
|
|
|
287
287
|
catch { return null; }
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
+
// PP P1-1: same-PID stale-lockfile reclaim threshold. A same-process orphan
|
|
291
|
+
// (e.g. an earlier run() that crashed without unlinking, or a try/catch that
|
|
292
|
+
// swallowed the release) older than this is presumed dead and reclaimed.
|
|
293
|
+
// 30s mirrors lib/refresh-external.js and lib/prefetch.js; long enough that
|
|
294
|
+
// no legitimate playbook hold reaches it (govern/look/run phases complete
|
|
295
|
+
// well inside one second per playbook), short enough that a wedged process
|
|
296
|
+
// recovers within one CI step rather than the rest of its lifetime.
|
|
297
|
+
const STALE_LOCK_MS = 30_000;
|
|
298
|
+
|
|
290
299
|
function acquireLock(playbookId) {
|
|
291
300
|
const p = lockFilePath(playbookId);
|
|
292
301
|
if (!p) return null;
|
|
@@ -322,6 +331,24 @@ function acquireLock(playbookId) {
|
|
|
322
331
|
try { fs.unlinkSync(p); } catch {}
|
|
323
332
|
try { writePayload(); return p; } catch { /* fall through */ }
|
|
324
333
|
}
|
|
334
|
+
// PP P1-1: same-PID stale-lockfile reclaim. If the recorded pid is
|
|
335
|
+
// ours, the only way to escape an orphaned same-process lockfile is
|
|
336
|
+
// by mtime. Do NOT blindly reclaim same-PID — legitimate reentrancy
|
|
337
|
+
// (e.g. nested run() within one process) must still return null so
|
|
338
|
+
// the caller knows the lock is held. A fresh same-PID lockfile is
|
|
339
|
+
// reentrancy; one older than STALE_LOCK_MS is an orphan from a
|
|
340
|
+
// crashed prior hold (or a try/catch that swallowed the release)
|
|
341
|
+
// and must be reclaimed — otherwise the process can never acquire
|
|
342
|
+
// this lock again for the rest of its lifetime.
|
|
343
|
+
if (Number.isInteger(pid) && pid === process.pid) {
|
|
344
|
+
try {
|
|
345
|
+
const stat = fs.statSync(p);
|
|
346
|
+
if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) {
|
|
347
|
+
try { fs.unlinkSync(p); } catch {}
|
|
348
|
+
try { writePayload(); return p; } catch { /* fall through */ }
|
|
349
|
+
}
|
|
350
|
+
} catch { /* stat failed — treat as held */ }
|
|
351
|
+
}
|
|
325
352
|
} catch { /* unreadable lockfile — treat as held by a live process */ }
|
|
326
353
|
}
|
|
327
354
|
// Lock genuinely held (or filesystem error). Returning null keeps
|
|
@@ -367,6 +394,26 @@ function acquireLockDiagnostic(playbookId) {
|
|
|
367
394
|
return { ok: false, reason: 'reclaim_failed', error: e2.message, lock_path: p, holder_pid: pid };
|
|
368
395
|
}
|
|
369
396
|
}
|
|
397
|
+
// PP P1-1: same-PID stale-lockfile reclaim (diagnostic variant). Same
|
|
398
|
+
// semantics as in acquireLock: a same-process lockfile older than
|
|
399
|
+
// STALE_LOCK_MS is an orphan and must be reclaimed; a fresher one is
|
|
400
|
+
// legitimate reentrancy and stays held.
|
|
401
|
+
if (Number.isInteger(pid) && pid === process.pid) {
|
|
402
|
+
let mtimeMs = null;
|
|
403
|
+
try { mtimeMs = fs.statSync(p).mtimeMs; } catch {}
|
|
404
|
+
if (mtimeMs !== null && (Date.now() - mtimeMs) > STALE_LOCK_MS) {
|
|
405
|
+
try { fs.unlinkSync(p); } catch {}
|
|
406
|
+
try {
|
|
407
|
+
fs.writeFileSync(p,
|
|
408
|
+
JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2),
|
|
409
|
+
{ flag: 'wx' });
|
|
410
|
+
return { ok: true, path: p, reclaimed_self_stale_pid: true, prior_mtime_ms: mtimeMs };
|
|
411
|
+
} catch (e3) {
|
|
412
|
+
return { ok: false, reason: 'reclaim_failed', error: e3.message, lock_path: p, holder_pid: pid };
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return { ok: false, reason: 'held_by_self', lock_path: p, holder_pid: pid };
|
|
416
|
+
}
|
|
370
417
|
return { ok: false, reason: 'held_by_live_pid', lock_path: p, holder_pid: pid };
|
|
371
418
|
}
|
|
372
419
|
return { ok: false, reason: 'fs_error', error: e && e.message, lock_path: p };
|
|
@@ -1534,7 +1581,7 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1534
1581
|
return builtFormats.get(format);
|
|
1535
1582
|
};
|
|
1536
1583
|
const primaryBody = buildOnce(primaryFormat);
|
|
1537
|
-
//
|
|
1584
|
+
// bundles_by_format must always be an object keyed by the
|
|
1538
1585
|
// primary format, even when no extra formats were requested. Pre-fix it
|
|
1539
1586
|
// was null in the single-format case, forcing downstream tooling into a
|
|
1540
1587
|
// `bundles_by_format ?? { [primaryFormat]: bundle_body }` shim in every
|
|
@@ -1779,16 +1826,45 @@ function getEngineVersion() {
|
|
|
1779
1826
|
return _CACHED_PKG_VERSION;
|
|
1780
1827
|
}
|
|
1781
1828
|
|
|
1782
|
-
//
|
|
1829
|
+
// 3 / P1-4: operator-supplied identity strings (--operator) and
|
|
1783
1830
|
// publisher namespace URLs (--publisher-namespace) flow into operator-facing
|
|
1784
1831
|
// CSAF surfaces. Strip ASCII control characters as a defence-in-depth pass —
|
|
1785
1832
|
// bin/exceptd.js already validates the inputs, but the runner is also called
|
|
1786
1833
|
// from library consumers that may bypass the CLI surface.
|
|
1834
|
+
//
|
|
1835
|
+
// MM P1-D: extend the strip to Unicode bidi / format / control / surrogate /
|
|
1836
|
+
// private-use / unassigned categories (\p{C} under the `u` regex flag) so
|
|
1837
|
+
// direct library callers of buildEvidenceBundle cannot smuggle a U+202E
|
|
1838
|
+
// "RTL OVERRIDE" or zero-width joiner past the sanitiser the way the CLI
|
|
1839
|
+
// already refuses (--operator validation in bin/exceptd.js). NFC-normalise
|
|
1840
|
+
// first so a decomposed sequence can't combine past the codepoint check;
|
|
1841
|
+
// cap the result at 256 codepoints (NOT UTF-16 code units) so a string of
|
|
1842
|
+
// astral-plane codepoints can't smuggle a longer-than-256-display string
|
|
1843
|
+
// past the cap by exploiting JavaScript's surrogate-pair string length.
|
|
1844
|
+
// Returns null on rejection (empty after strip, or NFC normalise threw);
|
|
1845
|
+
// callers (the publisher-namespace + contact_details + tracking.generator
|
|
1846
|
+
// sites) treat null as "operator-unclaimed" and route through the existing
|
|
1847
|
+
// fallback (publisher.namespace = urn:exceptd:operator:unknown +
|
|
1848
|
+
// bundle_publisher_unclaimed runtime warning).
|
|
1787
1849
|
function sanitizeOperatorText(s) {
|
|
1788
1850
|
if (typeof s !== 'string') return null;
|
|
1789
|
-
//
|
|
1790
|
-
|
|
1791
|
-
|
|
1851
|
+
// NFC first: a Cf codepoint may be expressed as a base + combining mark
|
|
1852
|
+
// that recomposes into the format category under NFC. Normalise so the
|
|
1853
|
+
// strip catches it.
|
|
1854
|
+
let normalised;
|
|
1855
|
+
try { normalised = s.normalize('NFC'); }
|
|
1856
|
+
catch { return null; }
|
|
1857
|
+
// Strip every Unicode codepoint matching General Category C
|
|
1858
|
+
// (Cc, Cf, Cs, Co, Cn). \p{C} under the `u` flag matches all five.
|
|
1859
|
+
const stripped = normalised.replace(/\p{C}/gu, '');
|
|
1860
|
+
const trimmed = stripped.trim();
|
|
1861
|
+
if (trimmed.length === 0) return null;
|
|
1862
|
+
// Cap at 256 codepoints (Array.from counts codepoints, not UTF-16 code
|
|
1863
|
+
// units, so a 256-codepoint astral-plane string isn't silently extended
|
|
1864
|
+
// past the cap by surrogate-pair encoding).
|
|
1865
|
+
const cps = Array.from(trimmed);
|
|
1866
|
+
if (cps.length <= 256) return cps.join('');
|
|
1867
|
+
return cps.slice(0, 256).join('');
|
|
1792
1868
|
}
|
|
1793
1869
|
|
|
1794
1870
|
function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals, sessionId, issuedAt, runOpts) {
|
|
@@ -1829,14 +1905,14 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1829
1905
|
// that lied to downstream NVD / Red Hat dashboards. When
|
|
1830
1906
|
// live_patch_available is the only signal, status stays known_affected
|
|
1831
1907
|
// and the live-patch route is surfaced as a `vendor_fix` remediation.
|
|
1832
|
-
//
|
|
1908
|
+
// CSAF §3.2.1.2 restricts the `cve` field to the CVE-id
|
|
1833
1909
|
// regex `^CVE-[0-9]{4}-[0-9]{4,}$`. The catalog also keys non-CVE
|
|
1834
1910
|
// identifiers off `cve_id` (MAL-2026-3083, GHSA-…, OSV-…); strict
|
|
1835
1911
|
// validators (BSI CSAF validator, ENISA dashboard) refuse documents that
|
|
1836
1912
|
// place non-CVE values in `cve`. Branch by prefix and route non-CVE ids
|
|
1837
1913
|
// to the `ids[]` array with a real `system_name`.
|
|
1838
1914
|
//
|
|
1839
|
-
//
|
|
1915
|
+
// CSAF §3.2.1.5 requires `cvss_v3.vectorString` when a
|
|
1840
1916
|
// cvss_v3 score block is emitted. Drop the entire score block when the
|
|
1841
1917
|
// catalog has no CVSS data (score AND vector both unset); otherwise
|
|
1842
1918
|
// include version + baseScore + vectorString + baseSeverity from the
|
|
@@ -1853,21 +1929,33 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1853
1929
|
if (typeof vec !== 'string') return '3.1';
|
|
1854
1930
|
const m = vec.match(/^CVSS:(\d+\.\d+)\//);
|
|
1855
1931
|
if (!m) return '3.1';
|
|
1856
|
-
//
|
|
1857
|
-
//
|
|
1858
|
-
//
|
|
1859
|
-
//
|
|
1932
|
+
// Returns the declared version verbatim. The CALLER is responsible for
|
|
1933
|
+
// gating cvss_v3 emission to 3.0 / 3.1 per CSAF 2.0 schema. 2.0 and
|
|
1934
|
+
// 4.0 vectors are tagged here for diagnostic clarity but never reach
|
|
1935
|
+
// the cvss_v3 block downstream.
|
|
1860
1936
|
return m[1];
|
|
1861
1937
|
};
|
|
1862
1938
|
const csafIdsFor = (id) => {
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
//
|
|
1869
|
-
|
|
1870
|
-
return { system_name: '
|
|
1939
|
+
// B: null / undefined / non-string id MUST NOT emit literal
|
|
1940
|
+
// "null" / "undefined" text into the vulnerabilities[] entry. Pre-fix
|
|
1941
|
+
// String(id) coerced both to those literals — strict validators then
|
|
1942
|
+
// rejected the document, and operators saw a phantom "null" CVE in
|
|
1943
|
+
// dashboards. Return null so the caller can skip the entry entirely
|
|
1944
|
+
// and surface a runtime_error for the missing id.
|
|
1945
|
+
if (typeof id !== 'string' || !id) return null;
|
|
1946
|
+
if (id.startsWith('GHSA-')) return { system_name: 'GHSA', text: id };
|
|
1947
|
+
if (id.startsWith('MAL-')) return { system_name: 'Malicious-Package', text: id };
|
|
1948
|
+
if (id.startsWith('OSV-')) return { system_name: 'OSV', text: id };
|
|
1949
|
+
if (id.startsWith('SNYK-')) return { system_name: 'Snyk', text: id };
|
|
1950
|
+
// A: RUSTSEC advisories carry their own tracking authority
|
|
1951
|
+
// (https://rustsec.org); mis-routing them to system_name 'OSV' loses
|
|
1952
|
+
// the upstream provenance link and confuses downstream ingesters that
|
|
1953
|
+
// resolve by (system_name, text) pair.
|
|
1954
|
+
if (id.startsWith('RUSTSEC-')) return { system_name: 'RUSTSEC', text: id };
|
|
1955
|
+
// B: genuinely-unknown prefix surfaces as `exceptd-unknown`
|
|
1956
|
+
// so downstream ingesters know the authority wasn't recognized — pre-fix
|
|
1957
|
+
// every unknown id was misattributed to OSV.
|
|
1958
|
+
return { system_name: 'exceptd-unknown', text: id };
|
|
1871
1959
|
};
|
|
1872
1960
|
const CSAF_CVE_RE = /^CVE-\d{4}-\d{4,}$/;
|
|
1873
1961
|
|
|
@@ -1879,18 +1967,59 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1879
1967
|
|| (c.live_patch_available ? 'Vendor publishes a live-patch — see CVE catalog `live_patch_tools` for the operator-side step.' : 'See selected remediation path.'),
|
|
1880
1968
|
product_ids: [productId],
|
|
1881
1969
|
}];
|
|
1882
|
-
//
|
|
1970
|
+
// B: catalog entries with a missing / non-string cve_id
|
|
1971
|
+
// pre-fix produced literal `text: "null"` / `text: "undefined"` entries
|
|
1972
|
+
// under ids[]. Skip the vulnerability entry entirely and surface a
|
|
1973
|
+
// runtime_error so the catalog gap is visible to operators / CI gates.
|
|
1974
|
+
const idIsCve = typeof c.cve_id === 'string' && CSAF_CVE_RE.test(c.cve_id);
|
|
1975
|
+
let idEntry = null;
|
|
1976
|
+
if (!idIsCve) {
|
|
1977
|
+
idEntry = csafIdsFor(c.cve_id);
|
|
1978
|
+
if (idEntry == null) {
|
|
1979
|
+
if (Array.isArray(runOpts._runErrors)) {
|
|
1980
|
+
const alreadyMissing = runOpts._runErrors.some(e => e && e.kind === 'bundle_cve_id_missing');
|
|
1981
|
+
if (!alreadyMissing) {
|
|
1982
|
+
runOpts._runErrors.push({
|
|
1983
|
+
kind: 'bundle_cve_id_missing',
|
|
1984
|
+
reason: 'A matched_cves[] entry has no string cve_id (null / undefined / non-string). The CSAF vulnerability entry was omitted to avoid emitting literal "null" / "undefined" text under vulnerabilities[].ids[].',
|
|
1985
|
+
remediation: 'Inspect the CVE catalog feed that produced this match; the upstream record is missing its identifier and should be refreshed or excluded.'
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
return null;
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
// only emit cvss_v3 score block when we have a real
|
|
1883
1993
|
// vector string AND a numeric score. Pre-fix every vuln carried
|
|
1884
1994
|
// `cvss_v3: { base_score: 0 }` even when the catalog had no CVSS
|
|
1885
1995
|
// signal — strict validators reject the truncated block, and
|
|
1886
1996
|
// `base_score: 0` was a downstream-misleading default that suggested
|
|
1887
1997
|
// an authoritative "informational" score where there was simply no
|
|
1888
1998
|
// data.
|
|
1999
|
+
//
|
|
2000
|
+
// C: CSAF 2.0 `cvss_v3` ONLY accepts version 3.0 / 3.1.
|
|
2001
|
+
// Catalog vectors prefixed CVSS:2.0/ or CVSS:4.0/ would pre-fix emit a
|
|
2002
|
+
// cvss_v3 block with version: '2.0' / '4.0', which strict validators
|
|
2003
|
+
// (BSI CSAF Validator) reject outright. Drop the block for non-3.x
|
|
2004
|
+
// vectors and surface a runtime_error so operators can see why their
|
|
2005
|
+
// CVSS data didn't make it through.
|
|
1889
2006
|
const hasCvss = typeof c.cvss_score === 'number' && typeof c.cvss_vector === 'string' && c.cvss_vector.length > 0;
|
|
1890
|
-
const
|
|
2007
|
+
const vectorVersion = hasCvss ? csafCvssVersionFromVector(c.cvss_vector) : null;
|
|
2008
|
+
const cvssV3Eligible = hasCvss && (vectorVersion === '3.0' || vectorVersion === '3.1');
|
|
2009
|
+
if (hasCvss && !cvssV3Eligible && Array.isArray(runOpts._runErrors)) {
|
|
2010
|
+
const alreadyUnsup = runOpts._runErrors.some(e => e && e.kind === 'bundle_cvss_v3_version_unsupported');
|
|
2011
|
+
if (!alreadyUnsup) {
|
|
2012
|
+
runOpts._runErrors.push({
|
|
2013
|
+
kind: 'bundle_cvss_v3_version_unsupported',
|
|
2014
|
+
reason: `Catalog entry carries CVSS vector with version ${vectorVersion}; CSAF 2.0 cvss_v3 block only accepts versions 3.0 / 3.1. The score block was omitted from this vulnerability to keep the document valid against strict CSAF validators.`,
|
|
2015
|
+
remediation: 'Backfill a CVSS 3.1 vector against this CVE in the catalog, or wait for CSAF 2.1 (cvss_v4 support) — exceptd targets CSAF 2.0 today.'
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
const scores = cvssV3Eligible ? [{
|
|
1891
2020
|
products: [productId],
|
|
1892
2021
|
cvss_v3: {
|
|
1893
|
-
version:
|
|
2022
|
+
version: vectorVersion,
|
|
1894
2023
|
baseScore: c.cvss_score,
|
|
1895
2024
|
vectorString: c.cvss_vector,
|
|
1896
2025
|
baseSeverity: csafCvssSeverity(c.cvss_score),
|
|
@@ -1902,12 +2031,12 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1902
2031
|
remediations,
|
|
1903
2032
|
product_status: isFixed ? { fixed: [productId] } : { known_affected: [productId] }
|
|
1904
2033
|
};
|
|
1905
|
-
//
|
|
1906
|
-
if (
|
|
2034
|
+
// route by id shape.
|
|
2035
|
+
if (idIsCve) {
|
|
1907
2036
|
return { cve: c.cve_id, ...base };
|
|
1908
2037
|
}
|
|
1909
|
-
return { ids: [
|
|
1910
|
-
});
|
|
2038
|
+
return { ids: [idEntry], ...base };
|
|
2039
|
+
}).filter(v => v != null);
|
|
1911
2040
|
const indicatorVulns = indicatorHits.map(i => ({
|
|
1912
2041
|
// CSAF `system_name` values land in operator-facing validators; the
|
|
1913
2042
|
// "exceptd-indicator" pseudo-authority is namespaced enough that NVD /
|
|
@@ -1940,7 +2069,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1940
2069
|
text: lines.join('\n'),
|
|
1941
2070
|
};
|
|
1942
2071
|
});
|
|
1943
|
-
//
|
|
2072
|
+
// CSAF §3.1.7.4 publisher.namespace MUST be the trust
|
|
1944
2073
|
// anchor of the entity publishing the advisory — the OPERATOR running the
|
|
1945
2074
|
// scan, not the tool vendor. Pre-fix every CSAF emitted by the runner
|
|
1946
2075
|
// claimed https://exceptd.com as namespace, falsely attributing
|
|
@@ -1967,7 +2096,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1967
2096
|
title: 'Publisher namespace not supplied',
|
|
1968
2097
|
text: 'No --publisher-namespace and no URL-shaped --operator were supplied to this run. CSAF §3.1.7.4 requires the namespace to be the publisher\'s trust anchor — i.e. the OPERATOR running the scan, not the tooling vendor. Re-emit with `--publisher-namespace https://your-org.example` (or a URL-shaped `--operator`) to attribute responsibility for advisory accuracy correctly.'
|
|
1969
2098
|
}] : [];
|
|
1970
|
-
//
|
|
2099
|
+
// ALSO surface the unclaimed-publisher condition through
|
|
1971
2100
|
// the structured runtime_errors[] accumulator so machine-readable
|
|
1972
2101
|
// consumers (CI gates, dashboards) can branch on it without parsing
|
|
1973
2102
|
// notes[] prose. The orchestrator's post-close pass folds late-pushed
|
|
@@ -1986,7 +2115,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1986
2115
|
}
|
|
1987
2116
|
}
|
|
1988
2117
|
|
|
1989
|
-
//
|
|
2118
|
+
// thread the validated --operator name into
|
|
1990
2119
|
// tracking.generator (engine identity) AND publisher.contact_details
|
|
1991
2120
|
// (operator-of-record). engine.version is read from the package once per
|
|
1992
2121
|
// process. contact_details is omitted when no operator was supplied so
|
|
@@ -1998,7 +2127,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1998
2127
|
};
|
|
1999
2128
|
if (operatorClean) publisherBlock.contact_details = operatorClean;
|
|
2000
2129
|
|
|
2001
|
-
//
|
|
2130
|
+
// CSAF §3.1.11.3.5.1 defines `final` as an immutable
|
|
2002
2131
|
// advisory; subsequent re-emits against the same tracking.id are
|
|
2003
2132
|
// refused by strict validators (BSI CSAF Validator). Runtime detection
|
|
2004
2133
|
// runs with no operator review loop are inherently revisable, so the
|
|
@@ -2028,7 +2157,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2028
2157
|
id: `exceptd-${playbook._meta.id}-${sessionId}`,
|
|
2029
2158
|
status: csafStatus,
|
|
2030
2159
|
version: playbook._meta.version,
|
|
2031
|
-
//
|
|
2160
|
+
// name the engine that emitted the advisory.
|
|
2032
2161
|
// CSAF §3.1.11.3.2 places this under tracking.generator.engine.
|
|
2033
2162
|
generator: {
|
|
2034
2163
|
engine: { name: 'exceptd', version: getEngineVersion() },
|
|
@@ -2066,7 +2195,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2066
2195
|
// render empty fields.
|
|
2067
2196
|
if (format === 'sarif' || format === 'sarif-2.1.0') {
|
|
2068
2197
|
const stripNulls = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v != null));
|
|
2069
|
-
//
|
|
2198
|
+
// SARIF rule ids are global within a single sarif-log run.
|
|
2070
2199
|
// Pre-fix, generic ruleIds like `framework-gap-0` (and shared CVE ids
|
|
2071
2200
|
// across playbooks) collided when results from multiple playbook runs
|
|
2072
2201
|
// were merged into one SARIF document — GitHub Code Scanning de-dupes
|
|
@@ -2994,6 +3123,10 @@ module.exports = {
|
|
|
2994
3123
|
vexFilterFromDoc,
|
|
2995
3124
|
normalizeSubmission,
|
|
2996
3125
|
autoDetectPreconditions,
|
|
3126
|
+
// MM P1-D: exposed for tests/audit-vv-trust-fixes.test.js so library-side
|
|
3127
|
+
// direct callers (the fallback path the CLI guard cannot reach) can be
|
|
3128
|
+
// exercised without spawning a CLI subprocess.
|
|
3129
|
+
sanitizeOperatorText,
|
|
2997
3130
|
// internal helpers exposed for tests
|
|
2998
3131
|
_resolvedPhase: resolvedPhase,
|
|
2999
3132
|
_deepMerge: deepMerge,
|
package/lib/refresh-network.js
CHANGED
|
@@ -191,7 +191,7 @@ function verifyDetached(publicKeyObj, payload, sigB64) {
|
|
|
191
191
|
} catch { return false; }
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
// v0.12.14
|
|
194
|
+
// v0.12.14: CRLF/BOM normalization mirrors lib/verify.js's
|
|
195
195
|
// normalize(). Duplicated here to keep refresh-network free of cross-module
|
|
196
196
|
// runtime deps. ANY change here MUST be mirrored in lib/verify.js +
|
|
197
197
|
// lib/sign.js + scripts/verify-shipped-tarball.js — the four normalize()
|
|
@@ -342,7 +342,7 @@ async function main() {
|
|
|
342
342
|
process.exitCode = 4; return;
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
-
// v0.12.14
|
|
345
|
+
// v0.12.14: verify SHA-512 SRI first (collision-resistant
|
|
346
346
|
// beyond SHA-1 reach), then SHA-1 shasum for compatibility, then dist.
|
|
347
347
|
// signatures[] (npm registry's Ed25519 signing key). Each layer is
|
|
348
348
|
// defense-in-depth — registry compromise that produces a SHA-1 collision
|
|
@@ -420,8 +420,13 @@ async function main() {
|
|
|
420
420
|
const expectedFingerprintPath = path.join(ROOT, "keys", "EXPECTED_FINGERPRINT");
|
|
421
421
|
if (fs.existsSync(expectedFingerprintPath) && !process.env.KEYS_ROTATED) {
|
|
422
422
|
try {
|
|
423
|
-
|
|
424
|
-
|
|
423
|
+
// KK P1-5: route through the shared lib/verify loader so a BOM-prefixed
|
|
424
|
+
// pin file (Notepad with files.encoding=utf8bom) is tolerated identically
|
|
425
|
+
// across every verify site. Pre-fix the inline split-trim-find returned
|
|
426
|
+
// the BOM as part of the first line, which would never match a live
|
|
427
|
+
// fingerprint and would block every legitimate refresh-network run.
|
|
428
|
+
const { loadExpectedFingerprintFirstLine } = require("./verify.js");
|
|
429
|
+
const expectedFp = loadExpectedFingerprintFirstLine(expectedFingerprintPath);
|
|
425
430
|
// v0.12.16 (codex P1 PR #11): `expectedFp` is read verbatim from
|
|
426
431
|
// keys/EXPECTED_FINGERPRINT (formatted as `SHA256:<base64>`), but
|
|
427
432
|
// `fingerprintPublicKey()` returns the raw base64 without the
|
|
@@ -244,8 +244,8 @@ function additionalChecks(key, entry, ctx) {
|
|
|
244
244
|
}
|
|
245
245
|
|
|
246
246
|
// V2 — Cross-catalog reference resolution. Unresolved refs are warnings
|
|
247
|
-
// for v0.12.x; v0.13.0 will flip to hard failures.
|
|
248
|
-
//
|
|
247
|
+
// for v0.12.x; v0.13.0 will flip to hard failures. V2 expansion
|
|
248
|
+
// extends the walk from cwe_refs only to attack_refs, atlas_refs,
|
|
249
249
|
// d3fend_refs, AND framework_control_gaps.
|
|
250
250
|
const REF_FIELDS = [
|
|
251
251
|
{ field: 'atlas_refs', set: ctx.atlasKeys, file: 'data/atlas-ttps.json' },
|
package/lib/verify.js
CHANGED
|
@@ -362,7 +362,7 @@ function verifyManifestSignature(manifest) {
|
|
|
362
362
|
if (!publicKey) {
|
|
363
363
|
return { status: 'no-key', reason: 'public key missing at keys/public.pem' };
|
|
364
364
|
}
|
|
365
|
-
//
|
|
365
|
+
// consult keys/EXPECTED_FINGERPRINT BEFORE crypto.verify so
|
|
366
366
|
// library callers (refresh-network gate, verify-shipped-tarball gate, tests,
|
|
367
367
|
// downstream consumers via `require("lib/verify")`) cannot bypass the pin.
|
|
368
368
|
// Previously the pin only fired at the CLI tail of `node lib/verify.js`,
|
|
@@ -593,16 +593,46 @@ function validateAgainstSchema(value, schema, here, root) {
|
|
|
593
593
|
* @param {{sha256:string}|null} liveFp publicKeyFingerprint() output
|
|
594
594
|
* @param {string} [pinPath] optional override (testability)
|
|
595
595
|
*/
|
|
596
|
+
/**
|
|
597
|
+
* KK P1-5: shared loader for keys/EXPECTED_FINGERPRINT. Reads the pin file,
|
|
598
|
+
* strips a leading UTF-8 BOM (Notepad with files.encoding=utf8bom would
|
|
599
|
+
* otherwise prepend U+FEFF and silently break every verify path on the host),
|
|
600
|
+
* tolerates CRLF line endings, ignores comment lines (`#`) and blanks, and
|
|
601
|
+
* returns the first non-comment / non-empty line. Returns null if the file
|
|
602
|
+
* is unreadable / empty.
|
|
603
|
+
*
|
|
604
|
+
* Shared across four sites so every loader normalises identically:
|
|
605
|
+
* - lib/verify.js (manifest signature gate)
|
|
606
|
+
* - lib/refresh-network.js (refresh-network pre-swap gate)
|
|
607
|
+
* - scripts/verify-shipped-tarball.js (predeploy gate)
|
|
608
|
+
* - bin/exceptd.js (attestation pin)
|
|
609
|
+
* tests/normalize-contract.test.js asserts byte-identical output across all
|
|
610
|
+
* four sites under a BOM + CRLF fuzz corpus.
|
|
611
|
+
*/
|
|
612
|
+
function loadExpectedFingerprintFirstLine(pinPath) {
|
|
613
|
+
let raw;
|
|
614
|
+
try { raw = fs.readFileSync(pinPath, 'utf8'); }
|
|
615
|
+
catch { return null; }
|
|
616
|
+
if (raw.length > 0 && raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
|
|
617
|
+
const lines = raw
|
|
618
|
+
.split(/\r?\n/)
|
|
619
|
+
.map((l) => l.trim())
|
|
620
|
+
.filter((l) => l.length > 0 && !l.startsWith('#'));
|
|
621
|
+
return lines[0] || null;
|
|
622
|
+
}
|
|
623
|
+
|
|
596
624
|
function checkExpectedFingerprint(liveFp, pinPath) {
|
|
597
625
|
const p = pinPath || EXPECTED_FINGERPRINT_PATH;
|
|
598
626
|
if (!fs.existsSync(p)) return { status: 'no-pin' };
|
|
599
627
|
if (!liveFp || typeof liveFp.sha256 !== 'string') {
|
|
600
628
|
return { status: 'mismatch', expected: 'unknown', actual: '(invalid)', rotationOverride: false };
|
|
601
629
|
}
|
|
602
|
-
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
|
|
630
|
+
// KK P1-5: route through the shared loader so a BOM-prefixed pin file
|
|
631
|
+
// (Notepad with files.encoding=utf8bom) is tolerated identically across
|
|
632
|
+
// every verify site. Pre-fix the verbatim split-trim-find produced a
|
|
633
|
+
// first-line of "SHA256:..." (with leading BOM) that would never equal
|
|
634
|
+
// a live fingerprint.
|
|
635
|
+
const firstLine = loadExpectedFingerprintFirstLine(p) || '';
|
|
606
636
|
if (firstLine === liveFp.sha256) return { status: 'match' };
|
|
607
637
|
return {
|
|
608
638
|
status: 'mismatch',
|
|
@@ -740,6 +770,7 @@ module.exports = {
|
|
|
740
770
|
validateAgainstSchema,
|
|
741
771
|
publicKeyFingerprint,
|
|
742
772
|
checkExpectedFingerprint,
|
|
773
|
+
loadExpectedFingerprintFirstLine,
|
|
743
774
|
canonicalManifestBytes,
|
|
744
775
|
verifyManifestSignature,
|
|
745
776
|
EXPECTED_FINGERPRINT_PATH,
|