@blamejs/exceptd-skills 0.12.18 → 0.12.21
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 +224 -52
- package/README.md +1 -1
- package/bin/exceptd.js +841 -68
- package/data/_indexes/_meta.json +14 -14
- package/data/_indexes/activity-feed.json +3 -3
- package/data/_indexes/catalog-summaries.json +3 -3
- package/data/_indexes/chains.json +15 -0
- package/data/_indexes/jurisdiction-map.json +3 -2
- package/data/_indexes/section-offsets.json +175 -175
- package/data/_indexes/summary-cards.json +1 -1
- package/data/_indexes/token-budget.json +83 -83
- package/data/cve-catalog.json +169 -2
- package/data/exploit-availability.json +16 -0
- package/data/playbooks/ai-api.json +20 -1
- package/data/playbooks/containers.json +30 -0
- package/data/playbooks/cred-stores.json +18 -0
- package/data/playbooks/crypto.json +18 -0
- package/data/playbooks/hardening.json +26 -1
- package/data/playbooks/kernel.json +22 -2
- package/data/playbooks/mcp.json +18 -0
- package/data/playbooks/runtime.json +20 -1
- package/data/playbooks/sbom.json +18 -0
- package/data/playbooks/secrets.json +6 -0
- package/data/zeroday-lessons.json +102 -0
- package/lib/auto-discovery.js +68 -15
- package/lib/cross-ref-api.js +43 -10
- package/lib/cve-curation.js +4 -4
- package/lib/playbook-runner.js +545 -63
- package/lib/prefetch.js +65 -18
- package/lib/refresh-external.js +40 -2
- package/lib/refresh-network.js +100 -12
- package/lib/scoring.js +22 -13
- package/lib/sign.js +14 -6
- package/lib/validate-catalog-meta.js +1 -1
- package/lib/validate-indexes.js +2 -2
- package/lib/verify.js +51 -10
- package/manifest.json +47 -48
- package/orchestrator/scheduler.js +10 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
- package/scripts/check-manifest-snapshot.js +1 -1
- package/scripts/check-sbom-currency.js +1 -1
- package/scripts/predeploy.js +10 -5
- package/scripts/refresh-manifest-snapshot.js +2 -2
- package/scripts/validate-vendor-online.js +1 -1
- package/scripts/verify-shipped-tarball.js +94 -6
- package/skills/compliance-theater/skill.md +4 -1
- package/skills/exploit-scoring/skill.md +20 -1
- package/skills/framework-gap-analysis/skill.md +6 -2
- package/skills/kernel-lpe-triage/skill.md +50 -3
- package/skills/threat-model-currency/skill.md +6 -4
- package/skills/webapp-security/skill.md +1 -1
- package/skills/zeroday-gap-learn/skill.md +44 -1
package/lib/playbook-runner.js
CHANGED
|
@@ -290,10 +290,87 @@ function lockFilePath(playbookId) {
|
|
|
290
290
|
function acquireLock(playbookId) {
|
|
291
291
|
const p = lockFilePath(playbookId);
|
|
292
292
|
if (!p) return null;
|
|
293
|
+
const writePayload = () => fs.writeFileSync(
|
|
294
|
+
p,
|
|
295
|
+
JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2),
|
|
296
|
+
{ flag: 'wx' }
|
|
297
|
+
);
|
|
293
298
|
try {
|
|
294
|
-
|
|
299
|
+
writePayload();
|
|
295
300
|
return p;
|
|
296
|
-
} catch {
|
|
301
|
+
} catch (e) {
|
|
302
|
+
// DD P1-3: stale-PID reclaim. Pre-fix the EEXIST path returned null
|
|
303
|
+
// and callers proceeded UNLOCKED — a process that crashed mid-run
|
|
304
|
+
// left its lockfile behind and every subsequent invocation silently
|
|
305
|
+
// ran without mutex protection. Mirror withCatalogLock's pattern:
|
|
306
|
+
// parse the recorded pid, probe with `process.kill(pid, 0)`. ESRCH
|
|
307
|
+
// means the holder is dead — unlink and retry once. EPERM (alive,
|
|
308
|
+
// different user) or any other condition: leave the lock alone and
|
|
309
|
+
// return null with a diagnostic so the caller knows acquisition
|
|
310
|
+
// failed because the lock is genuinely held (not because the FS is
|
|
311
|
+
// broken or the playbook id is malformed).
|
|
312
|
+
if (e && (e.code === 'EEXIST' || e.code === 'EPERM')) {
|
|
313
|
+
try {
|
|
314
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
315
|
+
let pid = null;
|
|
316
|
+
try { pid = JSON.parse(raw).pid; }
|
|
317
|
+
catch {
|
|
318
|
+
const n = Number.parseInt(String(raw).trim(), 10);
|
|
319
|
+
pid = Number.isInteger(n) && n > 0 ? n : null;
|
|
320
|
+
}
|
|
321
|
+
if (Number.isInteger(pid) && pid > 0 && pid !== process.pid && !pidAlive(pid)) {
|
|
322
|
+
try { fs.unlinkSync(p); } catch {}
|
|
323
|
+
try { writePayload(); return p; } catch { /* fall through */ }
|
|
324
|
+
}
|
|
325
|
+
} catch { /* unreadable lockfile — treat as held by a live process */ }
|
|
326
|
+
}
|
|
327
|
+
// Lock genuinely held (or filesystem error). Returning null keeps
|
|
328
|
+
// back-compat with existing call sites that test `if (!lockPath)`.
|
|
329
|
+
// Callers that want a clearer diagnostic should call
|
|
330
|
+
// `acquireLockDiagnostic` instead.
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// DD P1-3: callers needing to distinguish "couldn't acquire because the
|
|
336
|
+
// lock is genuinely held by a live process" from "couldn't acquire
|
|
337
|
+
// because of an unexpected error" can use this thin diagnostic wrapper.
|
|
338
|
+
// Returns either { ok: true, path } or { ok: false, reason, lock_path?, holder_pid? }.
|
|
339
|
+
// The bare `acquireLock` keeps its historical null-on-failure contract.
|
|
340
|
+
function acquireLockDiagnostic(playbookId) {
|
|
341
|
+
const p = lockFilePath(playbookId);
|
|
342
|
+
if (!p) return { ok: false, reason: 'no_lock_path' };
|
|
343
|
+
try {
|
|
344
|
+
fs.writeFileSync(p,
|
|
345
|
+
JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2),
|
|
346
|
+
{ flag: 'wx' });
|
|
347
|
+
return { ok: true, path: p };
|
|
348
|
+
} catch (e) {
|
|
349
|
+
if (e && (e.code === 'EEXIST' || e.code === 'EPERM')) {
|
|
350
|
+
let pid = null;
|
|
351
|
+
try {
|
|
352
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
353
|
+
try { pid = JSON.parse(raw).pid; }
|
|
354
|
+
catch {
|
|
355
|
+
const n = Number.parseInt(String(raw).trim(), 10);
|
|
356
|
+
pid = Number.isInteger(n) && n > 0 ? n : null;
|
|
357
|
+
}
|
|
358
|
+
} catch {}
|
|
359
|
+
if (Number.isInteger(pid) && pid > 0 && pid !== process.pid && !pidAlive(pid)) {
|
|
360
|
+
try { fs.unlinkSync(p); } catch {}
|
|
361
|
+
try {
|
|
362
|
+
fs.writeFileSync(p,
|
|
363
|
+
JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2),
|
|
364
|
+
{ flag: 'wx' });
|
|
365
|
+
return { ok: true, path: p, reclaimed_from_pid: pid };
|
|
366
|
+
} catch (e2) {
|
|
367
|
+
return { ok: false, reason: 'reclaim_failed', error: e2.message, lock_path: p, holder_pid: pid };
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return { ok: false, reason: 'held_by_live_pid', lock_path: p, holder_pid: pid };
|
|
371
|
+
}
|
|
372
|
+
return { ok: false, reason: 'fs_error', error: e && e.message, lock_path: p };
|
|
373
|
+
}
|
|
297
374
|
}
|
|
298
375
|
|
|
299
376
|
function releaseLock(lockPath) {
|
|
@@ -453,21 +530,52 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
453
530
|
// '<id>__fp_checks' in signal_overrides; default behavior (no
|
|
454
531
|
// attestation) treats every required FP check as UNSATISFIED.
|
|
455
532
|
if (verdict === 'hit' && Array.isArray(ind.false_positive_checks_required) && ind.false_positive_checks_required.length) {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
533
|
+
// BB P2-4: a hostile or buggy attestation may be a Proxy whose property
|
|
534
|
+
// accessors throw. The filter below reads `att[fpName]` for each
|
|
535
|
+
// required check; an exception inside the read would crash detect()
|
|
536
|
+
// and abort the entire run. Wrap the FP-check evaluation in a
|
|
537
|
+
// try/catch: on throw, treat ALL required checks as unsatisfied
|
|
538
|
+
// (safest default — never silently honor an attestation we couldn't
|
|
539
|
+
// read) and surface a runtime_error so the operator sees why.
|
|
540
|
+
try {
|
|
541
|
+
const attestation = overrides[`${ind.id}__fp_checks`];
|
|
542
|
+
// S P1-A: arrays satisfy `typeof === 'object'` but are NOT a valid
|
|
543
|
+
// attestation map. A submission like
|
|
544
|
+
// signal_overrides: { sig__fp_checks: [true, true] }
|
|
545
|
+
// would previously have its truthy entries matched via the index
|
|
546
|
+
// fallback (att['0'] === true), silently bypassing every FP-check
|
|
547
|
+
// requirement. Reject arrays explicitly so they fall through to the
|
|
548
|
+
// empty-attestation branch (every required check unsatisfied).
|
|
549
|
+
const safeAtt = Array.isArray(attestation) ? null : attestation;
|
|
550
|
+
const att = (safeAtt && typeof safeAtt === 'object') ? safeAtt : {};
|
|
551
|
+
const unsatisfied = ind.false_positive_checks_required.filter(fpName => {
|
|
552
|
+
// Match either by exact name string OR by indexed key '0', '1', ...
|
|
553
|
+
// because false_positive_checks_required entries are free-text
|
|
554
|
+
// strings, not ids. Operators may attest either by the literal
|
|
555
|
+
// string or by index. Default: unsatisfied.
|
|
556
|
+
if (att[fpName] === true) return false;
|
|
557
|
+
const idx = ind.false_positive_checks_required.indexOf(fpName);
|
|
558
|
+
if (idx !== -1 && att[String(idx)] === true) return false;
|
|
559
|
+
return true;
|
|
560
|
+
});
|
|
561
|
+
if (unsatisfied.length > 0) {
|
|
562
|
+
verdict = 'inconclusive';
|
|
563
|
+
fpChecksUnsatisfied = unsatisfied;
|
|
564
|
+
}
|
|
565
|
+
} catch (e) {
|
|
566
|
+
// Treat every required check as unsatisfied — we couldn't trust the
|
|
567
|
+
// attestation map. Surface the throw so operators can chase the
|
|
568
|
+
// root cause (Proxy with a throwing getter, frozen object that
|
|
569
|
+
// tripped invariants, etc.).
|
|
469
570
|
verdict = 'inconclusive';
|
|
470
|
-
fpChecksUnsatisfied =
|
|
571
|
+
fpChecksUnsatisfied = ind.false_positive_checks_required.slice();
|
|
572
|
+
if (runOpts && Array.isArray(runOpts._runErrors)) {
|
|
573
|
+
runOpts._runErrors.push({
|
|
574
|
+
kind: 'fp_attestation_threw',
|
|
575
|
+
indicator_id: ind.id,
|
|
576
|
+
message: (e && e.message) ? String(e.message) : String(e),
|
|
577
|
+
});
|
|
578
|
+
}
|
|
471
579
|
}
|
|
472
580
|
}
|
|
473
581
|
} else {
|
|
@@ -507,12 +615,60 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
507
615
|
// full false_positive_profile checks and reached an explicit verdict —
|
|
508
616
|
// engine-computed classification can't represent "I saw the indicators and
|
|
509
617
|
// confirmed they're all benign" without this override.
|
|
510
|
-
const
|
|
618
|
+
const rawOverride = (agentSubmission.signals && agentSubmission.signals.detection_classification);
|
|
511
619
|
const validOverrides = new Set(['detected', 'inconclusive', 'not_detected', 'clean']);
|
|
620
|
+
// BB P2-1: any override that's a non-empty string but NOT in the allowlist
|
|
621
|
+
// (e.g. 'present', 'unknown', '', ' detected ', 'Detected') must surface
|
|
622
|
+
// as a runtime_error rather than silently falling through to engine-computed
|
|
623
|
+
// classification. Operators submitting case variants / whitespace-padded
|
|
624
|
+
// strings deserve a clear diagnostic, not a quiet downgrade. Treat the
|
|
625
|
+
// override as absent for classification purposes once recorded.
|
|
626
|
+
const overrideIsString = typeof rawOverride === 'string';
|
|
627
|
+
const overrideIsInAllowlist = overrideIsString && validOverrides.has(rawOverride);
|
|
628
|
+
if (rawOverride !== undefined && rawOverride !== null && !overrideIsInAllowlist) {
|
|
629
|
+
if (runOpts && Array.isArray(runOpts._runErrors)) {
|
|
630
|
+
runOpts._runErrors.push({
|
|
631
|
+
kind: 'classification_override_invalid',
|
|
632
|
+
supplied: rawOverride,
|
|
633
|
+
allowed: ['detected', 'inconclusive', 'not_detected', 'clean'],
|
|
634
|
+
reason: 'signals.detection_classification must be one of the allowlist values exactly (case-sensitive, no surrounding whitespace). Override ignored; engine-computed classification used.',
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
const override = overrideIsInAllowlist ? rawOverride : undefined;
|
|
639
|
+
|
|
640
|
+
// BB P1-1 / BB P1-2: extend the v0.12.19 S P1-B gate to refuse ALL
|
|
641
|
+
// classification overrides (`detected`, `clean`, `not_detected`) when any
|
|
642
|
+
// indicator was FP-downgraded. A submission that maps to `'not_detected'`
|
|
643
|
+
// (either by literal `not_detected` OR by `'clean'`, which v0.12.19 mapped
|
|
644
|
+
// to `'not_detected'` at this site) MUST NOT hide a `verdict: 'hit'`
|
|
645
|
+
// indicator whose `false_positive_checks_required[]` were unattested —
|
|
646
|
+
// that's a strictly worse false-negative outcome than allowing 'detected'
|
|
647
|
+
// through. Substitute 'inconclusive' and emit a runtime_error.
|
|
648
|
+
// BB P2-2: record indicator IDs and an unsatisfied-checks count ONLY —
|
|
649
|
+
// never the literal FP-check check-name strings (those are an attestation-
|
|
650
|
+
// bypass hint for a hostile agent reading the runtime_errors).
|
|
651
|
+
const anyFpDowngrade = indicatorResults.some(r => Array.isArray(r.fp_checks_unsatisfied) && r.fp_checks_unsatisfied.length > 0);
|
|
512
652
|
|
|
513
653
|
let classification;
|
|
514
|
-
if (override
|
|
654
|
+
if (override) {
|
|
515
655
|
classification = override === 'clean' ? 'not_detected' : override;
|
|
656
|
+
if (anyFpDowngrade) {
|
|
657
|
+
const substituted = 'inconclusive';
|
|
658
|
+
const attempted = override; // record what the operator submitted, not the mapped form
|
|
659
|
+
classification = substituted;
|
|
660
|
+
if (runOpts && Array.isArray(runOpts._runErrors)) {
|
|
661
|
+
runOpts._runErrors.push({
|
|
662
|
+
kind: 'classification_override_blocked',
|
|
663
|
+
attempted,
|
|
664
|
+
substituted,
|
|
665
|
+
reason: 'FP-check downgrade: one or more indicators downgraded to inconclusive because false_positive_checks_required entries were not attested. Agent classification override refused.',
|
|
666
|
+
indicators_with_unsatisfied_fp_checks: indicatorResults
|
|
667
|
+
.filter(r => Array.isArray(r.fp_checks_unsatisfied) && r.fp_checks_unsatisfied.length > 0)
|
|
668
|
+
.map(r => ({ id: r.id, fp_checks_unsatisfied_count: r.fp_checks_unsatisfied.length })),
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
}
|
|
516
672
|
} else if (hasDeterministicHit || hasHighConfHit) {
|
|
517
673
|
classification = 'detected';
|
|
518
674
|
} else if (hits.length === 0 && indicatorResults.every(r => r.verdict === 'miss')) {
|
|
@@ -548,7 +704,7 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
548
704
|
from_observation: agentSubmission._signal_origins?.[i.id] || null,
|
|
549
705
|
})),
|
|
550
706
|
indicators_evaluated_count: indicatorResults.length,
|
|
551
|
-
classification_override_applied:
|
|
707
|
+
classification_override_applied: override ? (override === 'clean' ? 'not_detected' : override) : null,
|
|
552
708
|
submission_shape_seen: agentSubmission._original_shape || (agentSubmission.artifacts ? 'nested (v0.10.x)' : 'empty'),
|
|
553
709
|
// E9: pass through any flat-shape observation collisions detected at
|
|
554
710
|
// normalize time so analyze() can publish them under
|
|
@@ -839,7 +995,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
839
995
|
}
|
|
840
996
|
// F5: use the first evidence-correlated CVE as the canonical attribute
|
|
841
997
|
// source for factor scaling. If matchedCves is empty there's no per-CVE
|
|
842
|
-
// evidence to gate on. v0.12.15
|
|
998
|
+
// evidence to gate on. v0.12.15: the prior fallback was
|
|
843
999
|
// `factorCve = null` → every factor returned 0 → catalog-shape playbooks
|
|
844
1000
|
// (secrets, library-author, crypto-codebase, framework, cred-stores,
|
|
845
1001
|
// containers, runtime, crypto, ai-api) that detect WITHOUT a per-CVE
|
|
@@ -866,7 +1022,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
866
1022
|
null);
|
|
867
1023
|
if (factorCve) factorCveSource = 'domain';
|
|
868
1024
|
}
|
|
869
|
-
// v0.12.15
|
|
1025
|
+
// v0.12.15: five shipped playbooks (secrets, library-author,
|
|
870
1026
|
// crypto-codebase, framework, cred-stores, containers, runtime, crypto,
|
|
871
1027
|
// ai-api) ship with empty `domain.cve_refs` because their attack class is
|
|
872
1028
|
// class-of-vulnerability rather than CVE-specific. For those playbooks
|
|
@@ -1361,16 +1517,41 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1361
1517
|
const extraFormats = Array.isArray(agentSignals._bundle_formats)
|
|
1362
1518
|
? agentSignals._bundle_formats.filter(f => f !== primaryFormat)
|
|
1363
1519
|
: [];
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1520
|
+
// B: build every bundle once and reuse, so bundle_body and
|
|
1521
|
+
// bundles_by_format[primary] are the same object identity (and hence
|
|
1522
|
+
// identical on every nested timestamp). Pre-fix, buildEvidenceBundle was
|
|
1523
|
+
// invoked twice for the primary format and each invocation crystallised
|
|
1524
|
+
// a fresh Date.now() — operators diffing bundle_body against
|
|
1525
|
+
// bundles_by_format.<primary> saw spurious millisecond drift on
|
|
1526
|
+
// tracking.initial_release_date / timestamp / current_release_date.
|
|
1527
|
+
const evidencePackage = c.evidence_package ? (() => {
|
|
1528
|
+
const issuedAt = new Date().toISOString();
|
|
1529
|
+
const builtFormats = new Map();
|
|
1530
|
+
const buildOnce = (format) => {
|
|
1531
|
+
if (!builtFormats.has(format)) {
|
|
1532
|
+
builtFormats.set(format, buildEvidenceBundle(format, playbook, analyzeResult, validateResult, agentSignals, sessionId, issuedAt, runOpts));
|
|
1533
|
+
}
|
|
1534
|
+
return builtFormats.get(format);
|
|
1535
|
+
};
|
|
1536
|
+
const primaryBody = buildOnce(primaryFormat);
|
|
1537
|
+
// audit CC P2-1: bundles_by_format must always be an object keyed by the
|
|
1538
|
+
// primary format, even when no extra formats were requested. Pre-fix it
|
|
1539
|
+
// was null in the single-format case, forcing downstream tooling into a
|
|
1540
|
+
// `bundles_by_format ?? { [primaryFormat]: bundle_body }` shim in every
|
|
1541
|
+
// consumer. Now the field is canonically present so iteration is
|
|
1542
|
+
// uniform across single- and multi-format emissions.
|
|
1543
|
+
const byFormat = Object.fromEntries(
|
|
1544
|
+
[primaryFormat, ...extraFormats].map(f => [f, buildOnce(f)])
|
|
1545
|
+
);
|
|
1546
|
+
return {
|
|
1547
|
+
bundle_format: primaryFormat,
|
|
1548
|
+
contents: c.evidence_package.contents || [],
|
|
1549
|
+
destination: c.evidence_package.destination || 'local_only',
|
|
1550
|
+
signed: c.evidence_package.signed !== false,
|
|
1551
|
+
bundle_body: primaryBody,
|
|
1552
|
+
bundles_by_format: byFormat,
|
|
1553
|
+
};
|
|
1554
|
+
})() : null;
|
|
1374
1555
|
|
|
1375
1556
|
if (evidencePackage && evidencePackage.signed && runOpts.session_key) {
|
|
1376
1557
|
const body = JSON.stringify(evidencePackage.bundle_body);
|
|
@@ -1540,20 +1721,87 @@ function buildProductBinding(playbook, sessionId) {
|
|
|
1540
1721
|
// Code Scanning hides results without `artifactLocation.uri`, so we
|
|
1541
1722
|
// surface at least one candidate when any is known. Returns null when no
|
|
1542
1723
|
// candidate exists — caller MUST omit `locations` rather than emit empty.
|
|
1724
|
+
//
|
|
1725
|
+
// A: source segments are heterogeneous — many playbook artifacts
|
|
1726
|
+
// describe a shell-command capture (`uname -r`) or human prose, not a real
|
|
1727
|
+
// file or URI. SARIF `artifactLocation.uri` is defined as a URI reference
|
|
1728
|
+
// (RFC 3986); shell-command text + prose breaks downstream consumers
|
|
1729
|
+
// (GitHub Code Scanning rejects with "invalid URI" or renders garbled).
|
|
1730
|
+
// We accept only path-shaped candidates: absolute POSIX paths, `~`-home
|
|
1731
|
+
// paths, relative paths, drive-prefixed Windows paths, or file-URI
|
|
1732
|
+
// strings. Everything else (commands, English) is dropped, and locations
|
|
1733
|
+
// is omitted entirely when no candidate survives.
|
|
1734
|
+
// Path-shape predicate: accept anything that begins with a POSIX absolute
|
|
1735
|
+
// path (`/...`), home (`~/...` or `~`), relative dot (`./...`, `../...`,
|
|
1736
|
+
// or a bare `.`), drive-prefixed Windows path (`C:\...`, `C:/...`), or a
|
|
1737
|
+
// `file:` URI. Also accept simple relative names that contain a slash
|
|
1738
|
+
// (e.g. `etc/os-release`, `subdir/file.json`) — these are common in
|
|
1739
|
+
// playbook artifact source fields. Reject anything with internal
|
|
1740
|
+
// whitespace (commands like `uname -r`, prose like `kpatch list || ls
|
|
1741
|
+
// /sys/kernel/livepatch`) or that looks like a sentence.
|
|
1742
|
+
function looksLikePath(src) {
|
|
1743
|
+
if (typeof src !== 'string') return false;
|
|
1744
|
+
const trimmed = src.trim();
|
|
1745
|
+
if (!trimmed) return false;
|
|
1746
|
+
if (/\s/.test(trimmed)) return false;
|
|
1747
|
+
if (/^file:/i.test(trimmed)) return true;
|
|
1748
|
+
if (/^[A-Za-z]:[/\\]/.test(trimmed)) return true; // Windows drive
|
|
1749
|
+
if (/^[/~]/.test(trimmed)) return true; // POSIX abs / home
|
|
1750
|
+
if (/^\.\.?(?:[/\\]|$)/.test(trimmed)) return true; // relative dot
|
|
1751
|
+
if (/^[A-Za-z0-9_.+-]+[/\\][^\s]+$/.test(trimmed)) return true; // bare relative path
|
|
1752
|
+
return false;
|
|
1753
|
+
}
|
|
1543
1754
|
function sarifLocationsForIndicator(playbook, indicator) {
|
|
1755
|
+
void indicator;
|
|
1544
1756
|
const arts = (playbook.phases?.look?.artifacts) || [];
|
|
1545
1757
|
const candidates = arts
|
|
1546
1758
|
.map(a => a && (a.source || a.air_gap_alternative))
|
|
1547
1759
|
.filter(Boolean)
|
|
1548
1760
|
.map(src => String(src).split(/\s+(?:AND|OR)\s+/i)[0].trim())
|
|
1549
|
-
.filter(src => src && !/^https?:/i.test(src))
|
|
1761
|
+
.filter(src => src && !/^https?:/i.test(src))
|
|
1762
|
+
.filter(looksLikePath);
|
|
1550
1763
|
if (!candidates.length) return null;
|
|
1551
1764
|
return [{ physicalLocation: { artifactLocation: { uri: candidates[0] } } }];
|
|
1552
1765
|
}
|
|
1553
1766
|
|
|
1554
|
-
|
|
1767
|
+
// Resolve the package version once per process so CSAF tracking.generator
|
|
1768
|
+
// can name the engine that emitted the advisory. Best-effort read — bundle
|
|
1769
|
+
// emission must not crash if package.json is missing (e.g. exotic install).
|
|
1770
|
+
let _CACHED_PKG_VERSION = null;
|
|
1771
|
+
function getEngineVersion() {
|
|
1772
|
+
if (_CACHED_PKG_VERSION != null) return _CACHED_PKG_VERSION;
|
|
1773
|
+
try {
|
|
1774
|
+
const pkg = require(path.join(__dirname, '..', 'package.json'));
|
|
1775
|
+
_CACHED_PKG_VERSION = (pkg && typeof pkg.version === 'string') ? pkg.version : 'unknown';
|
|
1776
|
+
} catch {
|
|
1777
|
+
_CACHED_PKG_VERSION = 'unknown';
|
|
1778
|
+
}
|
|
1779
|
+
return _CACHED_PKG_VERSION;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
// audit CC P1-3 / P1-4: operator-supplied identity strings (--operator) and
|
|
1783
|
+
// publisher namespace URLs (--publisher-namespace) flow into operator-facing
|
|
1784
|
+
// CSAF surfaces. Strip ASCII control characters as a defence-in-depth pass —
|
|
1785
|
+
// bin/exceptd.js already validates the inputs, but the runner is also called
|
|
1786
|
+
// from library consumers that may bypass the CLI surface.
|
|
1787
|
+
function sanitizeOperatorText(s) {
|
|
1788
|
+
if (typeof s !== 'string') return null;
|
|
1789
|
+
// eslint-disable-next-line no-control-regex
|
|
1790
|
+
const cleaned = s.replace(/[\x00-\x1F\x7F]/g, '').trim();
|
|
1791
|
+
return cleaned.length ? cleaned.slice(0, 256) : null;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals, sessionId, issuedAt, runOpts) {
|
|
1795
|
+
runOpts = runOpts || {};
|
|
1555
1796
|
const playbookSlug = urnSlug(playbook._meta.id);
|
|
1556
1797
|
const { productId, productPurl, productName } = buildProductBinding(playbook, sessionId);
|
|
1798
|
+
// B: pin one `now` value per bundle build (and accept an
|
|
1799
|
+
// upstream-provided issuedAt) so multi-format emit produces identical
|
|
1800
|
+
// tracking timestamps across CSAF / OpenVEX / SARIF when close() is
|
|
1801
|
+
// building several formats from the same run. Without the parameter,
|
|
1802
|
+
// each invocation crystallised a fresh `Date.now()` and bundle_body
|
|
1803
|
+
// versus bundles_by_format[primary] would diverge on milliseconds.
|
|
1804
|
+
const now = typeof issuedAt === 'string' && issuedAt ? issuedAt : new Date().toISOString();
|
|
1557
1805
|
|
|
1558
1806
|
// CSAF-2.0 shape. v0.11.5 (#82): include vulnerabilities for both matched
|
|
1559
1807
|
// catalogue CVEs AND fired indicators (treated as advisory pseudo-CVEs
|
|
@@ -1571,38 +1819,205 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1571
1819
|
name: productName,
|
|
1572
1820
|
product_identification_helper: { purl: productPurl }
|
|
1573
1821
|
}];
|
|
1822
|
+
// A: `fixed` product_status MUST reflect operator-supplied VEX
|
|
1823
|
+
// disposition (vex_status === 'fixed' — see analyze() F17), not the
|
|
1824
|
+
// catalog's global `live_patch_available` flag. The catalog flag means
|
|
1825
|
+
// "vendor publishes a live-patch in the world", not "operator deployed
|
|
1826
|
+
// it on this host". Pre-fix the CSAF emitter declared every
|
|
1827
|
+
// live-patchable CVE as fixed regardless of whether the operator's
|
|
1828
|
+
// evidence actually showed the patch applied, producing CSAF documents
|
|
1829
|
+
// that lied to downstream NVD / Red Hat dashboards. When
|
|
1830
|
+
// live_patch_available is the only signal, status stays known_affected
|
|
1831
|
+
// and the live-patch route is surfaced as a `vendor_fix` remediation.
|
|
1832
|
+
// audit CC P1-2: CSAF §3.2.1.2 restricts the `cve` field to the CVE-id
|
|
1833
|
+
// regex `^CVE-[0-9]{4}-[0-9]{4,}$`. The catalog also keys non-CVE
|
|
1834
|
+
// identifiers off `cve_id` (MAL-2026-3083, GHSA-…, OSV-…); strict
|
|
1835
|
+
// validators (BSI CSAF validator, ENISA dashboard) refuse documents that
|
|
1836
|
+
// place non-CVE values in `cve`. Branch by prefix and route non-CVE ids
|
|
1837
|
+
// to the `ids[]` array with a real `system_name`.
|
|
1838
|
+
//
|
|
1839
|
+
// audit CC P2-2: CSAF §3.2.1.5 requires `cvss_v3.vectorString` when a
|
|
1840
|
+
// cvss_v3 score block is emitted. Drop the entire score block when the
|
|
1841
|
+
// catalog has no CVSS data (score AND vector both unset); otherwise
|
|
1842
|
+
// include version + baseScore + vectorString + baseSeverity from the
|
|
1843
|
+
// catalog entry.
|
|
1844
|
+
const csafCvssSeverity = (score) => {
|
|
1845
|
+
if (typeof score !== 'number') return null;
|
|
1846
|
+
if (score >= 9.0) return 'CRITICAL';
|
|
1847
|
+
if (score >= 7.0) return 'HIGH';
|
|
1848
|
+
if (score >= 4.0) return 'MEDIUM';
|
|
1849
|
+
if (score > 0.0) return 'LOW';
|
|
1850
|
+
return 'NONE';
|
|
1851
|
+
};
|
|
1852
|
+
const csafCvssVersionFromVector = (vec) => {
|
|
1853
|
+
if (typeof vec !== 'string') return '3.1';
|
|
1854
|
+
const m = vec.match(/^CVSS:(\d+\.\d+)\//);
|
|
1855
|
+
if (!m) return '3.1';
|
|
1856
|
+
// CSAF cvss_v3 block only accepts 3.x; if the catalog vector is 2.0 or
|
|
1857
|
+
// 4.0 we still tag the block as the value the catalog declared. Strict
|
|
1858
|
+
// validators that gate cvss_v3 to 3.0/3.1 will reject 2.0/4.0 — but
|
|
1859
|
+
// emitting the wrong version on a 4.0 vector would be worse.
|
|
1860
|
+
return m[1];
|
|
1861
|
+
};
|
|
1862
|
+
const csafIdsFor = (id) => {
|
|
1863
|
+
if (typeof id !== 'string' || !id) return { system_name: 'OSV', text: String(id) };
|
|
1864
|
+
if (id.startsWith('GHSA-')) return { system_name: 'GHSA', text: id };
|
|
1865
|
+
if (id.startsWith('MAL-')) return { system_name: 'Malicious-Package', text: id };
|
|
1866
|
+
if (id.startsWith('OSV-')) return { system_name: 'OSV', text: id };
|
|
1867
|
+
if (id.startsWith('SNYK-')) return { system_name: 'Snyk', text: id };
|
|
1868
|
+
// Fallback: surface the raw value under a generic OSV system_name; any
|
|
1869
|
+
// strict validator will at least know it's not a CVE.
|
|
1870
|
+
return { system_name: 'OSV', text: id };
|
|
1871
|
+
};
|
|
1872
|
+
const CSAF_CVE_RE = /^CVE-\d{4}-\d{4,}$/;
|
|
1873
|
+
|
|
1574
1874
|
const cveVulns = analyze.matched_cves.map(c => {
|
|
1575
|
-
const
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1875
|
+
const isFixed = c.vex_status === 'fixed';
|
|
1876
|
+
const remediations = [{
|
|
1877
|
+
category: 'vendor_fix',
|
|
1878
|
+
details: validate.selected_remediation?.description
|
|
1879
|
+
|| (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
|
+
product_ids: [productId],
|
|
1881
|
+
}];
|
|
1882
|
+
// audit CC P2-2: only emit cvss_v3 score block when we have a real
|
|
1883
|
+
// vector string AND a numeric score. Pre-fix every vuln carried
|
|
1884
|
+
// `cvss_v3: { base_score: 0 }` even when the catalog had no CVSS
|
|
1885
|
+
// signal — strict validators reject the truncated block, and
|
|
1886
|
+
// `base_score: 0` was a downstream-misleading default that suggested
|
|
1887
|
+
// an authoritative "informational" score where there was simply no
|
|
1888
|
+
// data.
|
|
1889
|
+
const hasCvss = typeof c.cvss_score === 'number' && typeof c.cvss_vector === 'string' && c.cvss_vector.length > 0;
|
|
1890
|
+
const scores = hasCvss ? [{
|
|
1891
|
+
products: [productId],
|
|
1892
|
+
cvss_v3: {
|
|
1893
|
+
version: csafCvssVersionFromVector(c.cvss_vector),
|
|
1894
|
+
baseScore: c.cvss_score,
|
|
1895
|
+
vectorString: c.cvss_vector,
|
|
1896
|
+
baseSeverity: csafCvssSeverity(c.cvss_score),
|
|
1897
|
+
}
|
|
1898
|
+
}] : [];
|
|
1899
|
+
const base = {
|
|
1900
|
+
scores,
|
|
1579
1901
|
threats: c.active_exploitation === 'confirmed' ? [{ category: 'exploit_status', details: 'Active exploitation confirmed (CISA KEV).' }] : [],
|
|
1580
|
-
remediations
|
|
1581
|
-
product_status:
|
|
1902
|
+
remediations,
|
|
1903
|
+
product_status: isFixed ? { fixed: [productId] } : { known_affected: [productId] }
|
|
1582
1904
|
};
|
|
1905
|
+
// audit CC P1-2: route by id shape.
|
|
1906
|
+
if (CSAF_CVE_RE.test(c.cve_id)) {
|
|
1907
|
+
return { cve: c.cve_id, ...base };
|
|
1908
|
+
}
|
|
1909
|
+
return { ids: [csafIdsFor(c.cve_id)], ...base };
|
|
1583
1910
|
});
|
|
1584
1911
|
const indicatorVulns = indicatorHits.map(i => ({
|
|
1912
|
+
// CSAF `system_name` values land in operator-facing validators; the
|
|
1913
|
+
// "exceptd-indicator" pseudo-authority is namespaced enough that NVD /
|
|
1914
|
+
// Red Hat / ENISA dashboards render it as a non-CVE finding without
|
|
1915
|
+
// misattributing to a real registry (CVE, GHSA, OSV).
|
|
1585
1916
|
ids: [{ system_name: 'exceptd-indicator', text: `${playbook._meta.id}:${i.id}` }],
|
|
1586
1917
|
notes: [{ category: 'description', text: `Indicator ${i.id} fired (${i.confidence}${i.deterministic ? ' / deterministic' : ''}) in playbook ${playbook._meta.id}.` }],
|
|
1587
1918
|
remediations: [{ category: 'mitigation', details: validate.selected_remediation?.description || `Consult playbook brief: exceptd brief ${playbook._meta.id}.`, product_ids: [productId] }],
|
|
1588
1919
|
product_status: { known_affected: [productId] }
|
|
1589
1920
|
}));
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
const
|
|
1921
|
+
// D: framework-gap entries used to ride in `vulnerabilities[]`
|
|
1922
|
+
// with `ids: [{ system_name: 'exceptd-framework-gap' }]`. The
|
|
1923
|
+
// `system_name` slot is reserved for recognised vulnerability tracking
|
|
1924
|
+
// authorities (CVE, GHSA, etc.); exceptd-framework-gap is not one, and
|
|
1925
|
+
// every downstream CSAF consumer (NVD ingester, Red Hat dashboard,
|
|
1926
|
+
// ENISA validator) flagged every run for unknown ids and rendered
|
|
1927
|
+
// false-positive advisories at the framework_gap_mapping length. Now
|
|
1928
|
+
// framework gaps land in `document.notes[]` with `category: details`
|
|
1929
|
+
// where they belong as advisory context, not pseudo-CVEs.
|
|
1930
|
+
const gapNotes = (analyze.framework_gap_mapping || []).map((g, idx) => {
|
|
1931
|
+
const lines = [
|
|
1932
|
+
`Framework: ${g.framework}`,
|
|
1933
|
+
g.claimed_control ? `Claimed control: ${g.claimed_control}` : null,
|
|
1934
|
+
g.actual_gap ? `Gap: ${g.actual_gap}` : null,
|
|
1935
|
+
g.required_control ? `Required: ${g.required_control}` : null,
|
|
1936
|
+
].filter(Boolean);
|
|
1937
|
+
return {
|
|
1938
|
+
category: 'details',
|
|
1939
|
+
title: `Framework gap ${idx + 1}: ${g.framework}${g.claimed_control ? ' / ' + g.claimed_control : ''}`,
|
|
1940
|
+
text: lines.join('\n'),
|
|
1941
|
+
};
|
|
1942
|
+
});
|
|
1943
|
+
// audit CC P1-3: CSAF §3.1.7.4 publisher.namespace MUST be the trust
|
|
1944
|
+
// anchor of the entity publishing the advisory — the OPERATOR running the
|
|
1945
|
+
// scan, not the tool vendor. Pre-fix every CSAF emitted by the runner
|
|
1946
|
+
// claimed https://exceptd.com as namespace, falsely attributing
|
|
1947
|
+
// responsibility for advisory accuracy to the tooling provider. Resolve
|
|
1948
|
+
// in priority order: explicit --publisher-namespace > --operator if it
|
|
1949
|
+
// looks URL-shaped > fallback `urn:exceptd:operator:unknown` with a note
|
|
1950
|
+
// documenting the gap.
|
|
1951
|
+
const operatorClean = sanitizeOperatorText(runOpts.operator);
|
|
1952
|
+
const explicitNs = sanitizeOperatorText(runOpts.publisherNamespace);
|
|
1953
|
+
let publisherNamespace;
|
|
1954
|
+
let publisherNamespaceSource;
|
|
1955
|
+
if (explicitNs && /^https?:\/\//i.test(explicitNs)) {
|
|
1956
|
+
publisherNamespace = explicitNs;
|
|
1957
|
+
publisherNamespaceSource = 'runOpts.publisherNamespace';
|
|
1958
|
+
} else if (operatorClean && /^https?:\/\//i.test(operatorClean)) {
|
|
1959
|
+
publisherNamespace = operatorClean;
|
|
1960
|
+
publisherNamespaceSource = 'runOpts.operator';
|
|
1961
|
+
} else {
|
|
1962
|
+
publisherNamespace = 'urn:exceptd:operator:unknown';
|
|
1963
|
+
publisherNamespaceSource = 'fallback';
|
|
1964
|
+
}
|
|
1965
|
+
const namespaceFallbackNote = (publisherNamespaceSource === 'fallback') ? [{
|
|
1966
|
+
category: 'general',
|
|
1967
|
+
title: 'Publisher namespace not supplied',
|
|
1968
|
+
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
|
+
}] : [];
|
|
1970
|
+
// audit CC P1-3: ALSO surface the unclaimed-publisher condition through
|
|
1971
|
+
// the structured runtime_errors[] accumulator so machine-readable
|
|
1972
|
+
// consumers (CI gates, dashboards) can branch on it without parsing
|
|
1973
|
+
// notes[] prose. The orchestrator's post-close pass folds late-pushed
|
|
1974
|
+
// _runErrors into phases.analyze.runtime_errors before the run-level
|
|
1975
|
+
// return, so the warning surfaces alongside other run-time anomalies.
|
|
1976
|
+
// De-dupe: only push once per bundle-build pass (multi-format emit
|
|
1977
|
+
// builds CSAF once via memoization, so this fires at most once per run).
|
|
1978
|
+
if (publisherNamespaceSource === 'fallback' && Array.isArray(runOpts._runErrors)) {
|
|
1979
|
+
const already = runOpts._runErrors.some(e => e && e.kind === 'bundle_publisher_unclaimed');
|
|
1980
|
+
if (!already) {
|
|
1981
|
+
runOpts._runErrors.push({
|
|
1982
|
+
kind: 'bundle_publisher_unclaimed',
|
|
1983
|
+
reason: 'CSAF document.publisher.namespace fell back to urn:exceptd:operator:unknown because no --publisher-namespace and no URL-shaped --operator were supplied. Operator attribution is unclaimed on this advisory.',
|
|
1984
|
+
remediation: 'Re-run with --publisher-namespace <https-url> (or a URL-shaped --operator).'
|
|
1985
|
+
});
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
// audit CC P1-4: thread the validated --operator name into
|
|
1990
|
+
// tracking.generator (engine identity) AND publisher.contact_details
|
|
1991
|
+
// (operator-of-record). engine.version is read from the package once per
|
|
1992
|
+
// process. contact_details is omitted when no operator was supplied so
|
|
1993
|
+
// the field doesn't carry a misleading null.
|
|
1994
|
+
const publisherBlock = {
|
|
1995
|
+
category: 'vendor',
|
|
1996
|
+
name: 'exceptd',
|
|
1997
|
+
namespace: publisherNamespace,
|
|
1998
|
+
};
|
|
1999
|
+
if (operatorClean) publisherBlock.contact_details = operatorClean;
|
|
2000
|
+
|
|
2001
|
+
// audit CC P1-1: CSAF §3.1.11.3.5.1 defines `final` as an immutable
|
|
2002
|
+
// advisory; subsequent re-emits against the same tracking.id are
|
|
2003
|
+
// refused by strict validators (BSI CSAF Validator). Runtime detection
|
|
2004
|
+
// runs with no operator review loop are inherently revisable, so the
|
|
2005
|
+
// default is `interim`. Operators who have reviewed and are ready to
|
|
2006
|
+
// promote pass `--csaf-status final` (threaded via runOpts.csafStatus);
|
|
2007
|
+
// any other value falls back to `interim` rather than emitting an
|
|
2008
|
+
// unrecognized status word.
|
|
2009
|
+
const allowedCsafStatuses = new Set(['draft', 'interim', 'final']);
|
|
2010
|
+
const csafStatus = allowedCsafStatuses.has(runOpts.csafStatus)
|
|
2011
|
+
? runOpts.csafStatus
|
|
2012
|
+
: 'interim';
|
|
2013
|
+
|
|
1600
2014
|
return {
|
|
1601
2015
|
document: {
|
|
1602
2016
|
category: 'csaf_security_advisory',
|
|
1603
2017
|
csaf_version: '2.0',
|
|
1604
|
-
publisher:
|
|
2018
|
+
publisher: publisherBlock,
|
|
1605
2019
|
title: `exceptd finding: ${playbook.domain.name} (${analyze.matched_cves.length} CVE(s), ${indicatorHits.length} indicator hit(s), ${(analyze.framework_gap_mapping || []).length} framework gap(s))`,
|
|
2020
|
+
notes: [...namespaceFallbackNote, ...gapNotes],
|
|
1606
2021
|
tracking: {
|
|
1607
2022
|
// F2/F9: CSAF tracking.id binds to the run's session_id (threaded
|
|
1608
2023
|
// from run() via close()) so attestation file names, OpenVEX
|
|
@@ -1611,15 +2026,21 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1611
2026
|
// the same millisecond collided and one run's documents
|
|
1612
2027
|
// referenced ids that didn't match anything else on disk.
|
|
1613
2028
|
id: `exceptd-${playbook._meta.id}-${sessionId}`,
|
|
1614
|
-
status:
|
|
2029
|
+
status: csafStatus,
|
|
1615
2030
|
version: playbook._meta.version,
|
|
2031
|
+
// audit CC P1-4: name the engine that emitted the advisory.
|
|
2032
|
+
// CSAF §3.1.11.3.2 places this under tracking.generator.engine.
|
|
2033
|
+
generator: {
|
|
2034
|
+
engine: { name: 'exceptd', version: getEngineVersion() },
|
|
2035
|
+
date: now,
|
|
2036
|
+
},
|
|
1616
2037
|
initial_release_date: now,
|
|
1617
2038
|
current_release_date: now,
|
|
1618
2039
|
revision_history: [{ number: '1', date: now, summary: 'Initial finding emission' }]
|
|
1619
2040
|
}
|
|
1620
2041
|
},
|
|
1621
2042
|
product_tree: { full_product_names: fullProductNames },
|
|
1622
|
-
vulnerabilities: [...cveVulns, ...indicatorVulns
|
|
2043
|
+
vulnerabilities: [...cveVulns, ...indicatorVulns],
|
|
1623
2044
|
exceptd_extension: {
|
|
1624
2045
|
classification: analyze._detect_classification,
|
|
1625
2046
|
rwep: analyze.rwep,
|
|
@@ -1629,6 +2050,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1629
2050
|
evidence_requirements: validate.evidence_requirements,
|
|
1630
2051
|
residual_risk_statement: validate.residual_risk_statement,
|
|
1631
2052
|
indicators_fired: indicatorHits.map(i => ({ id: i.id, confidence: i.confidence, deterministic: i.deterministic })),
|
|
2053
|
+
publisher_namespace_source: publisherNamespaceSource,
|
|
1632
2054
|
}
|
|
1633
2055
|
};
|
|
1634
2056
|
}
|
|
@@ -1644,8 +2066,17 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1644
2066
|
// render empty fields.
|
|
1645
2067
|
if (format === 'sarif' || format === 'sarif-2.1.0') {
|
|
1646
2068
|
const stripNulls = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v != null));
|
|
2069
|
+
// audit CC P2-6: SARIF rule ids are global within a single sarif-log run.
|
|
2070
|
+
// Pre-fix, generic ruleIds like `framework-gap-0` (and shared CVE ids
|
|
2071
|
+
// across playbooks) collided when results from multiple playbook runs
|
|
2072
|
+
// were merged into one SARIF document — GitHub Code Scanning de-dupes
|
|
2073
|
+
// by ruleId, so the second playbook's rule definition silently
|
|
2074
|
+
// overwrote the first. Prefix every ruleId with the playbook slug so
|
|
2075
|
+
// every rule definition is unambiguously attributable to one playbook,
|
|
2076
|
+
// and cross-playbook merges retain all results.
|
|
2077
|
+
const rulePrefix = `${playbookSlug}/`;
|
|
1647
2078
|
const cveResults = analyze.matched_cves.map(c => ({
|
|
1648
|
-
ruleId: c.cve_id
|
|
2079
|
+
ruleId: `${rulePrefix}${c.cve_id}`,
|
|
1649
2080
|
level: c.rwep >= 90 ? 'error' : c.rwep >= 70 ? 'warning' : 'note',
|
|
1650
2081
|
message: { text: `${c.cve_id}: RWEP ${c.rwep}, blast_radius ${analyze.blast_radius_score}. ${validate.selected_remediation?.description || ''}` },
|
|
1651
2082
|
properties: stripNulls({
|
|
@@ -1662,7 +2093,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1662
2093
|
const indicatorResults = indicatorHits.map(i => {
|
|
1663
2094
|
const locs = sarifLocationsForIndicator(playbook, i);
|
|
1664
2095
|
const result = {
|
|
1665
|
-
ruleId: i.id
|
|
2096
|
+
ruleId: `${rulePrefix}${i.id}`,
|
|
1666
2097
|
level: i.deterministic ? 'error' : (i.confidence === 'high' ? 'warning' : 'note'),
|
|
1667
2098
|
message: { text: `Indicator ${i.id} fired (${i.confidence}${i.deterministic ? ' / deterministic' : ''}). Playbook: ${playbook._meta.id}.` },
|
|
1668
2099
|
properties: stripNulls({
|
|
@@ -1677,7 +2108,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1677
2108
|
return result;
|
|
1678
2109
|
});
|
|
1679
2110
|
const gapResults = (analyze.framework_gap_mapping || []).map((g, idx) => ({
|
|
1680
|
-
ruleId:
|
|
2111
|
+
ruleId: `${rulePrefix}framework-gap-${idx}`,
|
|
1681
2112
|
// Framework gaps are control-design observations, not vulnerabilities —
|
|
1682
2113
|
// SARIF §3.27.9 `kind: informational` routes them appropriately.
|
|
1683
2114
|
kind: 'informational',
|
|
@@ -1686,18 +2117,18 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1686
2117
|
properties: stripNulls({ kind: 'framework_gap', framework: g.framework, control: g.claimed_control }),
|
|
1687
2118
|
}));
|
|
1688
2119
|
const cveRules = analyze.matched_cves.map(c => ({
|
|
1689
|
-
id: c.cve_id
|
|
2120
|
+
id: `${rulePrefix}${c.cve_id}`, shortDescription: { text: c.cve_id },
|
|
1690
2121
|
fullDescription: { text: `RWEP ${c.rwep} · KEV=${c.cisa_kev} · active_exploitation=${c.active_exploitation}` },
|
|
1691
2122
|
defaultConfiguration: { level: c.rwep >= 90 ? 'error' : c.rwep >= 70 ? 'warning' : 'note' },
|
|
1692
2123
|
helpUri: `https://nvd.nist.gov/vuln/detail/${c.cve_id}`,
|
|
1693
2124
|
}));
|
|
1694
2125
|
const indicatorRules = indicatorHits.map(i => ({
|
|
1695
|
-
id: i.id
|
|
2126
|
+
id: `${rulePrefix}${i.id}`, shortDescription: { text: i.id },
|
|
1696
2127
|
fullDescription: { text: `Indicator from playbook ${playbook._meta.id}. Type: ${i.type}. Confidence: ${i.confidence}.` },
|
|
1697
2128
|
defaultConfiguration: { level: i.deterministic ? 'error' : (i.confidence === 'high' ? 'warning' : 'note') },
|
|
1698
2129
|
}));
|
|
1699
2130
|
const gapRules = (analyze.framework_gap_mapping || []).map((g, idx) => ({
|
|
1700
|
-
id:
|
|
2131
|
+
id: `${rulePrefix}framework-gap-${idx}`,
|
|
1701
2132
|
shortDescription: { text: `${g.framework}: ${g.claimed_control || `gap-${idx}`}` },
|
|
1702
2133
|
fullDescription: { text: g.actual_gap || `Framework gap in ${g.framework}` },
|
|
1703
2134
|
defaultConfiguration: { level: 'note' },
|
|
@@ -1712,11 +2143,16 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1712
2143
|
rules: [...cveRules, ...indicatorRules, ...gapRules],
|
|
1713
2144
|
} },
|
|
1714
2145
|
results: [...cveResults, ...indicatorResults, ...gapResults],
|
|
1715
|
-
invocations: [{ executionSuccessful: true, properties: {
|
|
2146
|
+
invocations: [{ executionSuccessful: true, properties: stripNulls({
|
|
2147
|
+
// A: apply the B7 stripNulls contract here too — the
|
|
2148
|
+
// `remediation` field is null for any run that didn't surface a
|
|
2149
|
+
// selected_remediation, and SARIF viewers render null property
|
|
2150
|
+
// values as visible empty rows. Same helper as the result
|
|
2151
|
+
// property bags above.
|
|
1716
2152
|
playbook: playbook._meta.id, classification: analyze._detect_classification || 'unknown',
|
|
1717
2153
|
rwep_adjusted: analyze.rwep?.adjusted || 0,
|
|
1718
2154
|
remediation: validate.selected_remediation?.id || null,
|
|
1719
|
-
} }],
|
|
2155
|
+
}) }],
|
|
1720
2156
|
}]
|
|
1721
2157
|
};
|
|
1722
2158
|
}
|
|
@@ -1737,7 +2173,12 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1737
2173
|
// `urn:exceptd:indicator:<playbook>:<indicator-id>` (RFC 8141) so
|
|
1738
2174
|
// they pass IRI validation in downstream VEX consumers.
|
|
1739
2175
|
if (format === 'openvex' || format === 'openvex-0.2.0') {
|
|
1740
|
-
|
|
2176
|
+
// B: reuse the bundle-wide `now` so OpenVEX `timestamp`
|
|
2177
|
+
// aligns with CSAF `document.tracking.initial_release_date` when both
|
|
2178
|
+
// formats are emitted in the same close() pass. Pre-fix each format
|
|
2179
|
+
// crystallised its own Date.now() value, and the two bundles in
|
|
2180
|
+
// bundles_by_format disagreed on milliseconds.
|
|
2181
|
+
const issued = now;
|
|
1741
2182
|
const productEntry = {
|
|
1742
2183
|
'@id': productPurl,
|
|
1743
2184
|
subcomponents: [{ '@id': productPurl }],
|
|
@@ -1752,6 +2193,17 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1752
2193
|
if (remediationDescription) return `Apply remediation from validate phase: ${remediationDescription}`;
|
|
1753
2194
|
return fallback;
|
|
1754
2195
|
};
|
|
2196
|
+
// A: same `vex_status === 'fixed'` correctness rule as the
|
|
2197
|
+
// CSAF emitter. The catalog `live_patch_available` flag is a global
|
|
2198
|
+
// "vendor publishes a live-patch" signal, not an operator-host
|
|
2199
|
+
// disposition. Treating it as `status: fixed` made OpenVEX statements
|
|
2200
|
+
// claim resolution that the operator hadn't actually attested to.
|
|
2201
|
+
// VEX consumers downstream of CISA / SBOM / supply-chain pipelines
|
|
2202
|
+
// treat `fixed` as authoritative — emitting it without operator
|
|
2203
|
+
// attestation is a downstream-misleading bug. Now the OpenVEX
|
|
2204
|
+
// statement says `affected` (with action_statement pointing to the
|
|
2205
|
+
// remediation, which may itself be the vendor live-patch route) unless
|
|
2206
|
+
// the operator declared `vex_status: fixed` on the matched CVE.
|
|
1755
2207
|
const cveStatements = analyze.matched_cves.map(c => {
|
|
1756
2208
|
const stmt = {
|
|
1757
2209
|
vulnerability: { '@id': `urn:cve:${urnSlug(c.cve_id)}`, name: c.cve_id },
|
|
@@ -1759,11 +2211,13 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1759
2211
|
timestamp: issued,
|
|
1760
2212
|
impact_statement: `RWEP ${c.rwep}. Blast radius ${analyze.blast_radius_score}/5.`,
|
|
1761
2213
|
};
|
|
1762
|
-
if (c.
|
|
2214
|
+
if (c.vex_status === 'fixed') {
|
|
1763
2215
|
stmt.status = 'fixed';
|
|
1764
2216
|
} else {
|
|
1765
2217
|
stmt.status = 'affected';
|
|
1766
|
-
stmt.action_statement = actionStatementFor(
|
|
2218
|
+
stmt.action_statement = actionStatementFor(c.live_patch_available
|
|
2219
|
+
? 'Vendor publishes a live-patch — see catalog `live_patch_tools` and apply, then re-attest.'
|
|
2220
|
+
: 'Apply remediation from validate phase.');
|
|
1767
2221
|
}
|
|
1768
2222
|
return stmt;
|
|
1769
2223
|
});
|
|
@@ -1912,6 +2366,16 @@ function normalizeSubmission(submission, playbook) {
|
|
|
1912
2366
|
signals: { ...(submission.signals || {}) },
|
|
1913
2367
|
precondition_checks: { ...(submission.precondition_checks || {}) },
|
|
1914
2368
|
_original_shape: 'flat (v0.11.0)',
|
|
2369
|
+
// BB P1-4: normalizeSubmission pushes structured errors (e.g.
|
|
2370
|
+
// signal_overrides_invalid) onto submission._runErrors above. If the
|
|
2371
|
+
// submission is flat, the fresh `out` literal built here loses that
|
|
2372
|
+
// accumulator unless we forward it. run()'s harvest at the entry to
|
|
2373
|
+
// detect/analyze reads agentSubmission._runErrors — without this carry,
|
|
2374
|
+
// flat submissions with invalid signal_overrides silently lost the
|
|
2375
|
+
// v0.12.19 U REG-1 contract (errors never reached analyze.runtime_errors).
|
|
2376
|
+
...(Array.isArray(submission._runErrors) && submission._runErrors.length
|
|
2377
|
+
? { _runErrors: submission._runErrors.slice() }
|
|
2378
|
+
: {}),
|
|
1915
2379
|
};
|
|
1916
2380
|
const knownPreconditions = new Set((playbook?._meta?.preconditions || []).map(p => p.id));
|
|
1917
2381
|
const knownArtifacts = new Set((playbook?.phases?.look?.artifacts || []).map(a => a.id));
|
|
@@ -2104,6 +2568,20 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
2104
2568
|
// non-fatal anomalies surfaced into analyze.runtime_errors[].
|
|
2105
2569
|
const runErrors = [];
|
|
2106
2570
|
cachedRunOpts._runErrors = runErrors;
|
|
2571
|
+
// U REG-1: normalizeSubmission may push structured errors (e.g.
|
|
2572
|
+
// signal_overrides_invalid) onto submission._runErrors. Pre-fix these were
|
|
2573
|
+
// stranded — they never reached the run-level accumulator that analyze()
|
|
2574
|
+
// slices into runtime_errors[], so F20's "analyze surfaces all runtime
|
|
2575
|
+
// errors" contract was silently broken. Splice the pre-run errors into
|
|
2576
|
+
// the run-level accumulator and strip the field off the submission so it
|
|
2577
|
+
// doesn't pollute the F1 evidence_hash digest (the hash canonicalizes the
|
|
2578
|
+
// submission and a non-deterministic _runErrors would change it).
|
|
2579
|
+
if (Array.isArray(agentSubmission._runErrors) && agentSubmission._runErrors.length) {
|
|
2580
|
+
runErrors.push(...agentSubmission._runErrors);
|
|
2581
|
+
}
|
|
2582
|
+
if (agentSubmission && Object.prototype.hasOwnProperty.call(agentSubmission, '_runErrors')) {
|
|
2583
|
+
delete agentSubmission._runErrors;
|
|
2584
|
+
}
|
|
2107
2585
|
// E6: phases the runner should SKIP execution for, based on skip_phase
|
|
2108
2586
|
// preconditions surfaced in preflight.issues.
|
|
2109
2587
|
const skipPhases = new Set();
|
|
@@ -2522,4 +3000,8 @@ module.exports = {
|
|
|
2522
3000
|
_evalCondition: evalCondition,
|
|
2523
3001
|
_interpolate: interpolate,
|
|
2524
3002
|
_activeRuns: _activeRuns,
|
|
3003
|
+
_acquireLock: acquireLock,
|
|
3004
|
+
_acquireLockDiagnostic: acquireLockDiagnostic,
|
|
3005
|
+
_releaseLock: releaseLock,
|
|
3006
|
+
_lockFilePath: lockFilePath,
|
|
2525
3007
|
};
|