@blamejs/exceptd-skills 0.14.11 → 0.14.13
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 +45 -0
- package/README.md +38 -8
- package/bin/exceptd.js +123 -16
- package/data/_indexes/_meta.json +2 -2
- package/lib/collectors/cicd-pipeline-compromise.js +6 -1
- package/lib/collectors/containers.js +9 -1
- package/lib/collectors/library-author.js +6 -1
- package/lib/cve-cli.js +7 -3
- package/lib/playbook-runner.js +97 -28
- package/lib/prefetch.js +30 -0
- package/lib/refresh-external.js +41 -0
- package/lib/rfc-cli.js +7 -2
- package/lib/schemas/playbook.schema.json +3 -1
- package/lib/scoring.js +8 -1
- package/lib/validate-playbooks.js +119 -0
- package/manifest.json +44 -44
- package/orchestrator/index.js +98 -11
- package/package.json +1 -1
- package/sbom.cdx.json +40 -40
package/lib/playbook-runner.js
CHANGED
|
@@ -691,15 +691,26 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
691
691
|
}
|
|
692
692
|
}
|
|
693
693
|
} else {
|
|
694
|
-
//
|
|
694
|
+
// An override WAS supplied for this indicator but its value didn't
|
|
695
|
+
// canonicalize to hit/miss/inconclusive (e.g. "maybe", "present", a
|
|
696
|
+
// number). Pre-fix this was silently dropped — the operator believed
|
|
697
|
+
// they'd asserted a result but the signal vanished, yielding a false
|
|
698
|
+
// not_detected. Surface it as a runtime_error (mirrors the
|
|
699
|
+
// classification_override_invalid signal) so the drop is visible.
|
|
700
|
+
if (rawOverride !== undefined && runOpts && Array.isArray(runOpts._runErrors)) {
|
|
701
|
+
pushRunError(runOpts._runErrors, {
|
|
702
|
+
kind: 'signal_override_unrecognized',
|
|
703
|
+
indicator_id: ind.id,
|
|
704
|
+
supplied_value: (typeof rawOverride === 'object') ? JSON.stringify(rawOverride).slice(0, 80) : String(rawOverride).slice(0, 80),
|
|
705
|
+
message: `signal_overrides["${ind.id}"] value was not recognized (expected hit/miss/inconclusive or a boolean); the signal was ignored.`,
|
|
706
|
+
}, { dedupeKey: e => e.indicator_id || '' });
|
|
707
|
+
}
|
|
708
|
+
// Without a usable override, treat any captured artifact as evidence
|
|
695
709
|
// the indicator could be evaluated. Mark inconclusive if any artifact
|
|
696
710
|
// was captured (engine doesn't pattern-match raw artifact content; the
|
|
697
711
|
// host AI is responsible for that). With NO captured artifacts, this is
|
|
698
712
|
// a clean empty submission — emit 'miss' so the run can reach
|
|
699
713
|
// classification:'not_detected' rather than getting stuck inconclusive.
|
|
700
|
-
// A clean empty run with no captured artifacts must emit 'miss' so
|
|
701
|
-
// classification can reach 'not_detected'; otherwise theater_verdict
|
|
702
|
-
// stays 'pending_agent_run' indefinitely.
|
|
703
714
|
const anyCaptured = Object.values(artifacts).some(a => a && a.captured);
|
|
704
715
|
verdict = anyCaptured ? 'inconclusive' : 'miss';
|
|
705
716
|
}
|
|
@@ -765,12 +776,16 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
765
776
|
const anyFpDowngrade = indicatorResults.some(r => Array.isArray(r.fp_checks_unsatisfied) && r.fp_checks_unsatisfied.length > 0);
|
|
766
777
|
|
|
767
778
|
let classification;
|
|
779
|
+
// Track whether the override was actually honored, so we don't report a
|
|
780
|
+
// refused override as "applied" (L6).
|
|
781
|
+
let overrideEffective = !!override;
|
|
768
782
|
if (override) {
|
|
769
783
|
classification = override === 'clean' ? 'not_detected' : override;
|
|
770
784
|
if (anyFpDowngrade) {
|
|
771
785
|
const substituted = 'inconclusive';
|
|
772
786
|
const attempted = override; // record what the operator submitted, not the mapped form
|
|
773
787
|
classification = substituted;
|
|
788
|
+
overrideEffective = false;
|
|
774
789
|
if (runOpts && Array.isArray(runOpts._runErrors)) {
|
|
775
790
|
pushRunError(runOpts._runErrors, {
|
|
776
791
|
kind: 'classification_override_blocked',
|
|
@@ -782,6 +797,24 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
782
797
|
.map(r => ({ id: r.id, fp_checks_unsatisfied_count: r.fp_checks_unsatisfied.length })),
|
|
783
798
|
}, { dedupeKey: e => String(e.attempted) });
|
|
784
799
|
}
|
|
800
|
+
} else if (classification === 'not_detected' && hasDeterministicHit) {
|
|
801
|
+
// A not_detected/clean override must not silently bury a DETERMINISTIC
|
|
802
|
+
// hit. A deterministic indicator firing is high-signal evidence; hiding
|
|
803
|
+
// it as not_detected is a strictly worse false-negative than leaving the
|
|
804
|
+
// run inconclusive. (Probabilistic hits remain overridable — that's the
|
|
805
|
+
// legitimate "I confirmed these are benign" workflow.) Substitute
|
|
806
|
+
// inconclusive and surface why.
|
|
807
|
+
classification = 'inconclusive';
|
|
808
|
+
overrideEffective = false;
|
|
809
|
+
if (runOpts && Array.isArray(runOpts._runErrors)) {
|
|
810
|
+
pushRunError(runOpts._runErrors, {
|
|
811
|
+
kind: 'classification_override_masks_deterministic_hit',
|
|
812
|
+
attempted: override,
|
|
813
|
+
substituted: 'inconclusive',
|
|
814
|
+
reason: 'A not_detected/clean classification override was refused because one or more deterministic indicators fired. A deterministic hit cannot be downgraded to not_detected.',
|
|
815
|
+
deterministic_hit_indicators: hits.filter(r => r.deterministic).map(r => r.id),
|
|
816
|
+
}, { dedupeKey: e => String(e.attempted) });
|
|
817
|
+
}
|
|
785
818
|
}
|
|
786
819
|
} else if (hasDeterministicHit || hasHighConfHit) {
|
|
787
820
|
classification = 'detected';
|
|
@@ -818,7 +851,11 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
818
851
|
from_observation: agentSubmission._signal_origins?.[i.id] || null,
|
|
819
852
|
})),
|
|
820
853
|
indicators_evaluated_count: indicatorResults.length,
|
|
821
|
-
|
|
854
|
+
// Only report the override as applied when it was actually honored — a
|
|
855
|
+
// refused override (FP-downgrade or deterministic-hit masking) left
|
|
856
|
+
// `classification` as inconclusive, so reporting the attempted value here
|
|
857
|
+
// would contradict the verdict.
|
|
858
|
+
classification_override_applied: (override && overrideEffective) ? (override === 'clean' ? 'not_detected' : override) : null,
|
|
822
859
|
submission_shape_seen: agentSubmission._original_shape || (agentSubmission.artifacts ? 'nested (v0.10.x)' : 'empty'),
|
|
823
860
|
// Pass through any flat-shape observation collisions detected at
|
|
824
861
|
// normalize time so analyze() can publish them under
|
|
@@ -2345,7 +2382,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2345
2382
|
}] : [];
|
|
2346
2383
|
const base = {
|
|
2347
2384
|
scores,
|
|
2348
|
-
threats: c.active_exploitation === 'confirmed' ? [{ category: 'exploit_status', details:
|
|
2385
|
+
threats: c.active_exploitation === 'confirmed' ? [{ category: 'exploit_status', details: `Active exploitation confirmed${c.cisa_kev ? ' (CISA KEV)' : ''}.` }] : [],
|
|
2349
2386
|
remediations,
|
|
2350
2387
|
product_status: isFixed ? { fixed: [productId] } : { known_affected: [productId] }
|
|
2351
2388
|
};
|
|
@@ -2473,9 +2510,18 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2473
2510
|
? { tlp: { label: CSAF_TLP_LABEL[runOpts.tlp] }, text: `TLP:${runOpts.tlp}` }
|
|
2474
2511
|
: null;
|
|
2475
2512
|
|
|
2513
|
+
// CSAF 2.0: an advisory with zero vulnerabilities is a csaf_informational_advisory
|
|
2514
|
+
// (Profile 5, which does not require /vulnerabilities) rather than a
|
|
2515
|
+
// csaf_security_advisory (Profile 4, where an empty vulnerabilities array is
|
|
2516
|
+
// semantically wrong and warns under strict profile validators). A clean run
|
|
2517
|
+
// becomes an informational attestation; any firing CVE/indicator keeps the
|
|
2518
|
+
// security-advisory category.
|
|
2519
|
+
const csafCategory = (cveVulns.length + indicatorVulns.length) > 0
|
|
2520
|
+
? 'csaf_security_advisory'
|
|
2521
|
+
: 'csaf_informational_advisory';
|
|
2476
2522
|
return {
|
|
2477
2523
|
document: {
|
|
2478
|
-
category:
|
|
2524
|
+
category: csafCategory,
|
|
2479
2525
|
csaf_version: '2.0',
|
|
2480
2526
|
publisher: publisherBlock,
|
|
2481
2527
|
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))`,
|
|
@@ -2562,20 +2608,29 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2562
2608
|
// every rule definition is unambiguously attributable to one playbook,
|
|
2563
2609
|
// and cross-playbook merges retain all results.
|
|
2564
2610
|
const rulePrefix = `${playbookSlug}/`;
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2611
|
+
// CVE-match results get the coarse playbook-source location fallback
|
|
2612
|
+
// (passing a null indicator skips the per-indicator evidence-locations
|
|
2613
|
+
// branch). Without any `locations`, GitHub Code Scanning silently DROPS
|
|
2614
|
+
// these results — the highest-severity result class would never surface.
|
|
2615
|
+
const cveFallbackLocs = sarifLocationsForIndicator(playbook, null);
|
|
2616
|
+
const cveResults = analyze.matched_cves.map(c => {
|
|
2617
|
+
const result = {
|
|
2618
|
+
ruleId: `${rulePrefix}${c.cve_id}`,
|
|
2619
|
+
level: c.rwep >= 90 ? 'error' : c.rwep >= 70 ? 'warning' : 'note',
|
|
2620
|
+
message: { text: `${c.cve_id}: RWEP ${c.rwep}, blast_radius ${analyze.blast_radius_score == null ? 'not assessed' : analyze.blast_radius_score}. ${validate.selected_remediation?.description || ''}` },
|
|
2621
|
+
properties: stripNulls({
|
|
2622
|
+
kind: 'cve_match',
|
|
2623
|
+
rwep: c.rwep,
|
|
2624
|
+
cisa_kev: c.cisa_kev,
|
|
2625
|
+
cisa_kev_due_date: c.cisa_kev_due_date ?? null,
|
|
2626
|
+
active_exploitation: c.active_exploitation ?? null,
|
|
2627
|
+
ai_discovered: c.ai_discovered ?? null,
|
|
2628
|
+
blast_radius_score: analyze.blast_radius_score,
|
|
2629
|
+
}),
|
|
2630
|
+
};
|
|
2631
|
+
if (cveFallbackLocs) result.locations = cveFallbackLocs;
|
|
2632
|
+
return result;
|
|
2633
|
+
});
|
|
2579
2634
|
const indicatorHits = (analyze._detect_indicators || []).filter(i => i.verdict === 'hit');
|
|
2580
2635
|
const indicatorResults = indicatorHits.map(i => {
|
|
2581
2636
|
const locs = sarifLocationsForIndicator(playbook, i);
|
|
@@ -2696,7 +2751,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2696
2751
|
vulnerability: { '@id': vulnIdToUrn(c.cve_id), name: c.cve_id },
|
|
2697
2752
|
products: [productEntry],
|
|
2698
2753
|
timestamp: issued,
|
|
2699
|
-
impact_statement: `RWEP ${c.rwep}. Blast radius ${analyze.blast_radius_score}/5
|
|
2754
|
+
impact_statement: `RWEP ${c.rwep}. ${analyze.blast_radius_score == null ? 'Blast radius not assessed.' : `Blast radius ${analyze.blast_radius_score}/5.`}`,
|
|
2700
2755
|
};
|
|
2701
2756
|
if (c.vex_status === 'fixed') {
|
|
2702
2757
|
stmt.status = 'fixed';
|
|
@@ -2767,11 +2822,14 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2767
2822
|
vexAuthor = vexOperatorClean;
|
|
2768
2823
|
} else {
|
|
2769
2824
|
vexAuthor = 'urn:exceptd:operator:unknown';
|
|
2825
|
+
// Same shape + singleton dedupe as the CSAF path so a multi-format emit
|
|
2826
|
+
// produces one canonical bundle_publisher_unclaimed entry that machine
|
|
2827
|
+
// consumers can read consistently (reason/remediation, not message).
|
|
2770
2828
|
pushRunError(runOpts._runErrors, {
|
|
2771
2829
|
kind: 'bundle_publisher_unclaimed',
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
});
|
|
2830
|
+
reason: 'OpenVEX author fell back to urn:exceptd:operator:unknown because no --publisher-namespace and no URL-shaped --operator were supplied. Disposition attribution is unclaimed on this VEX document.',
|
|
2831
|
+
remediation: 'Re-run with --publisher-namespace <https-url> (or a URL-shaped --operator).'
|
|
2832
|
+
}, { dedupeKey: () => 'singleton' });
|
|
2775
2833
|
}
|
|
2776
2834
|
return {
|
|
2777
2835
|
'@context': 'https://openvex.dev/ns/v0.2.0',
|
|
@@ -3356,11 +3414,22 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
3356
3414
|
* through directly. Avoids JSON.stringify's replacer indirection because
|
|
3357
3415
|
* a top-level array would otherwise miss the canonicalization recursion.
|
|
3358
3416
|
*/
|
|
3359
|
-
|
|
3417
|
+
const CANONICAL_MAX_DEPTH = 200;
|
|
3418
|
+
function canonicalStringify(v, _depth = 0) {
|
|
3360
3419
|
if (v === null || typeof v !== 'object') return JSON.stringify(v);
|
|
3361
|
-
|
|
3420
|
+
// Bounded recursion: adversarial (or accidental) deeply-nested evidence
|
|
3421
|
+
// would otherwise overflow the stack with an opaque "internal error". This
|
|
3422
|
+
// runs on every run() (evidence_hash + session_id derivation), so the guard
|
|
3423
|
+
// turns a crash into an actionable rejection. 200 levels is far beyond any
|
|
3424
|
+
// legitimate submission.
|
|
3425
|
+
if (_depth > CANONICAL_MAX_DEPTH) {
|
|
3426
|
+
const e = new Error(`evidence nesting exceeds the maximum depth of ${CANONICAL_MAX_DEPTH} — flatten the submission`);
|
|
3427
|
+
e.code = 'EVIDENCE_TOO_DEEP';
|
|
3428
|
+
throw e;
|
|
3429
|
+
}
|
|
3430
|
+
if (Array.isArray(v)) return '[' + v.map(x => canonicalStringify(x, _depth + 1)).join(',') + ']';
|
|
3362
3431
|
const keys = Object.keys(v).sort();
|
|
3363
|
-
return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalStringify(v[k])).join(',') + '}';
|
|
3432
|
+
return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalStringify(v[k], _depth + 1)).join(',') + '}';
|
|
3364
3433
|
}
|
|
3365
3434
|
|
|
3366
3435
|
/**
|
package/lib/prefetch.js
CHANGED
|
@@ -121,6 +121,12 @@ function parseArgs(argv) {
|
|
|
121
121
|
else if (a.startsWith("--max-age=")) out.maxAgeMs = parseDuration(a.slice("--max-age=".length));
|
|
122
122
|
else if (a === "--cache-dir") out.cacheDir = path.resolve(argv[++i]);
|
|
123
123
|
else if (a.startsWith("--cache-dir=")) out.cacheDir = path.resolve(a.slice("--cache-dir=".length));
|
|
124
|
+
// Any remaining --flag is an unrecognized typo. Record it; main() refuses
|
|
125
|
+
// before any network work rather than silently dropping it.
|
|
126
|
+
else if (typeof a === "string" && a.startsWith("--")) {
|
|
127
|
+
const base = a.indexOf("=") === -1 ? a : a.slice(0, a.indexOf("="));
|
|
128
|
+
(out._unknownFlags || (out._unknownFlags = [])).push(base);
|
|
129
|
+
}
|
|
124
130
|
}
|
|
125
131
|
// The global air-gap switch implies a report-only / no-egress run: treat
|
|
126
132
|
// EXCEPTD_AIR_GAP=1 the same as --no-network so prefetch never plans live
|
|
@@ -650,12 +656,36 @@ function readCached(cacheDir, source, id, opts = {}) {
|
|
|
650
656
|
}
|
|
651
657
|
}
|
|
652
658
|
|
|
659
|
+
// Known --flag base names prefetch accepts. Drives the unknown-flag error
|
|
660
|
+
// message's known list.
|
|
661
|
+
const PREFETCH_KNOWN_FLAGS = Object.freeze([
|
|
662
|
+
"--force", "--no-network", "--dry-run", "--air-gap", "--quiet", "--help", "-h",
|
|
663
|
+
"--source", "--max-age", "--cache-dir",
|
|
664
|
+
]);
|
|
665
|
+
|
|
653
666
|
async function main() {
|
|
654
667
|
const opts = parseArgs(process.argv);
|
|
655
668
|
if (opts.help) {
|
|
656
669
|
printHelp();
|
|
657
670
|
return;
|
|
658
671
|
}
|
|
672
|
+
|
|
673
|
+
// Reject unknown flags BEFORE any network work. A swallowed typo (e.g.
|
|
674
|
+
// `--max-aeg 12h`) previously fell through to a default full-cache fetch.
|
|
675
|
+
// Exit 2 matches prefetch's existing usage-error convention (invalid
|
|
676
|
+
// --source / --max-age also surface as exit 2 via main()'s catch).
|
|
677
|
+
if (Array.isArray(opts._unknownFlags) && opts._unknownFlags.length > 0) {
|
|
678
|
+
const uniq = [...new Set(opts._unknownFlags)];
|
|
679
|
+
process.stderr.write(JSON.stringify({
|
|
680
|
+
ok: false,
|
|
681
|
+
verb: "prefetch",
|
|
682
|
+
error: `prefetch: unknown flag(s): ${uniq.join(", ")}`,
|
|
683
|
+
unknown_flags: uniq,
|
|
684
|
+
known_flags: PREFETCH_KNOWN_FLAGS,
|
|
685
|
+
}) + "\n");
|
|
686
|
+
process.exitCode = 2;
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
659
689
|
// Why process.exitCode and not process.exit():
|
|
660
690
|
// On Windows + Node 25 (libuv), calling process.exit() synchronously
|
|
661
691
|
// while in-flight fetch / AbortController teardown is still mid-close
|
package/lib/refresh-external.js
CHANGED
|
@@ -109,6 +109,22 @@ function parseArgs(argv) {
|
|
|
109
109
|
// older than 7d or one that was prefetched without a signing keypair.
|
|
110
110
|
// EXCEPTD_FORCE_STALE=1 mirrors for non-interactive automation.
|
|
111
111
|
else if (a === "--force-stale") out.forceStale = true;
|
|
112
|
+
// Aliases that bin/exceptd.js may pass through or translate; accept them
|
|
113
|
+
// here so the unknown-flag guard below doesn't false-reject a legitimate
|
|
114
|
+
// operator invocation. (--no-network / --indexes-only / --network /
|
|
115
|
+
// --curate / --prefetch are normally rewritten upstream, but tolerate
|
|
116
|
+
// them when refresh-external is invoked directly.)
|
|
117
|
+
else if (
|
|
118
|
+
a === "--no-network" || a === "--prefetch" || a === "--indexes-only" ||
|
|
119
|
+
a === "--network" || a === "--curate" || a === "--force-stale-acked"
|
|
120
|
+
) { /* accepted, no-op at this layer */ }
|
|
121
|
+
// Any remaining --flag is an unrecognized typo. Record it; refuse after
|
|
122
|
+
// the loop rather than silently dropping it into a default full-refresh
|
|
123
|
+
// (which previously hit the live network on every source).
|
|
124
|
+
else if (typeof a === "string" && a.startsWith("--")) {
|
|
125
|
+
const base = a.indexOf("=") === -1 ? a : a.slice(0, a.indexOf("="));
|
|
126
|
+
(out._unknownFlags || (out._unknownFlags = [])).push(base);
|
|
127
|
+
}
|
|
112
128
|
}
|
|
113
129
|
if (process.env.EXCEPTD_FORCE_STALE === "1") out.forceStale = true;
|
|
114
130
|
// Report-only is intrinsic to the advisory poll regardless of flag order —
|
|
@@ -1423,6 +1439,15 @@ async function seedSingleAdvisory(opts) {
|
|
|
1423
1439
|
process.exitCode = 3;
|
|
1424
1440
|
}
|
|
1425
1441
|
|
|
1442
|
+
// Known --flag base names refresh accepts (operator-facing surface + the
|
|
1443
|
+
// bin-translated aliases). Drives the unknown-flag error message's known list.
|
|
1444
|
+
const REFRESH_KNOWN_FLAGS = Object.freeze([
|
|
1445
|
+
"--apply", "--quiet", "--swarm", "--json", "--help", "-h", "--advisory",
|
|
1446
|
+
"--check-advisories", "--catalog", "--from-cache", "--source", "--from-fixture",
|
|
1447
|
+
"--report-out", "--air-gap", "--force-stale", "--force-stale-acked",
|
|
1448
|
+
"--no-network", "--prefetch", "--indexes-only", "--network", "--curate",
|
|
1449
|
+
]);
|
|
1450
|
+
|
|
1426
1451
|
async function main() {
|
|
1427
1452
|
const opts = parseArgs(process.argv);
|
|
1428
1453
|
if (opts.help) {
|
|
@@ -1432,6 +1457,22 @@ async function main() {
|
|
|
1432
1457
|
return;
|
|
1433
1458
|
}
|
|
1434
1459
|
|
|
1460
|
+
// Reject unknown flags BEFORE any network / catalog work. A swallowed typo
|
|
1461
|
+
// (e.g. `--aply`) previously fell through to a default all-sources live
|
|
1462
|
+
// refresh. Exit 2 matches refresh's own scheme (2 = error / unknown source).
|
|
1463
|
+
if (Array.isArray(opts._unknownFlags) && opts._unknownFlags.length > 0) {
|
|
1464
|
+
const uniq = [...new Set(opts._unknownFlags)];
|
|
1465
|
+
process.stderr.write(JSON.stringify({
|
|
1466
|
+
ok: false,
|
|
1467
|
+
verb: "refresh",
|
|
1468
|
+
error: `refresh: unknown flag(s): ${uniq.join(", ")}`,
|
|
1469
|
+
unknown_flags: uniq,
|
|
1470
|
+
known_flags: REFRESH_KNOWN_FLAGS,
|
|
1471
|
+
}) + "\n");
|
|
1472
|
+
process.exitCode = 2;
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1435
1476
|
// v0.12.0: `--advisory <id>` short-circuits the normal source loop and
|
|
1436
1477
|
// seeds a single CVE catalog entry from GHSA. Exits non-zero ("draft
|
|
1437
1478
|
// written, please review") so CI pipelines surface the needed editorial
|
package/lib/rfc-cli.js
CHANGED
|
@@ -57,7 +57,12 @@ const { resolveRfc } = require("./citation-resolve.js");
|
|
|
57
57
|
const a = norm(claimedTitle), b = norm(r.title);
|
|
58
58
|
titleMatch = a.length > 0 && (b.includes(a) || a.includes(b));
|
|
59
59
|
}
|
|
60
|
-
|
|
60
|
+
// Derive `ok` from the resolved status + title-check the same way the exit
|
|
61
|
+
// code is derived below — a non-zero exit (status nonexistent OR an explicit
|
|
62
|
+
// title mismatch) must carry ok:false, not the inverted ok:true the envelope
|
|
63
|
+
// previously hardcoded.
|
|
64
|
+
const fails = r.status === "nonexistent" || titleMatch === false;
|
|
65
|
+
const body = { verb: "rfc", ...r, ...(claimedTitle ? { claimed_title: claimedTitle, title_match: titleMatch } : {}), ok: !fails };
|
|
61
66
|
|
|
62
67
|
if (json) {
|
|
63
68
|
process.stdout.write(JSON.stringify(body, null, pretty ? 2 : 0) + "\n");
|
|
@@ -77,5 +82,5 @@ const { resolveRfc } = require("./citation-resolve.js");
|
|
|
77
82
|
process.stdout.write(line + "\n");
|
|
78
83
|
}
|
|
79
84
|
// A mismatched or nonexistent citation is a non-zero exit for gates.
|
|
80
|
-
if (
|
|
85
|
+
if (fails) process.exitCode = 2;
|
|
81
86
|
})();
|
|
@@ -151,7 +151,7 @@
|
|
|
151
151
|
"attack_refs": { "type": "array", "items": { "type": "string", "pattern": "^T\\d{4}(\\.\\d{3})?$" } },
|
|
152
152
|
"cve_refs": { "type": "array", "items": { "type": "string", "pattern": "^CVE-\\d{4}-\\d{4,}$" }, "description": "All CVEs must exist in data/cve-catalog.json." },
|
|
153
153
|
"cwe_refs": { "type": "array", "items": { "type": "string", "pattern": "^CWE-\\d+$" } },
|
|
154
|
-
"d3fend_refs": { "type": "array", "items": { "type": "string" } },
|
|
154
|
+
"d3fend_refs": { "type": "array", "items": { "type": "string", "pattern": "^D3-[A-Z]+$" } },
|
|
155
155
|
"frameworks_in_scope": {
|
|
156
156
|
"type": "array",
|
|
157
157
|
"items": {
|
|
@@ -300,6 +300,7 @@
|
|
|
300
300
|
"properties": {
|
|
301
301
|
"artifacts": {
|
|
302
302
|
"type": "array",
|
|
303
|
+
"minItems": 1,
|
|
303
304
|
"items": {
|
|
304
305
|
"type": "object",
|
|
305
306
|
"required": ["id", "type", "source", "description", "required"],
|
|
@@ -364,6 +365,7 @@
|
|
|
364
365
|
"properties": {
|
|
365
366
|
"indicators": {
|
|
366
367
|
"type": "array",
|
|
368
|
+
"minItems": 1,
|
|
367
369
|
"items": {
|
|
368
370
|
"type": "object",
|
|
369
371
|
"required": ["id", "type", "value", "confidence", "deterministic"],
|
package/lib/scoring.js
CHANGED
|
@@ -132,7 +132,14 @@ function validateFactors(factors) {
|
|
|
132
132
|
if (factors.blast_radius === undefined || factors.blast_radius === null) {
|
|
133
133
|
warnings.push('blast_radius: missing (treated as 0)');
|
|
134
134
|
} else if (typeof factors.blast_radius !== 'number') {
|
|
135
|
-
|
|
135
|
+
// scoreCustom coerces a numeric string (e.g. "30") via Number(); keep the
|
|
136
|
+
// two surfaces consistent — accept a finite numeric string with a soft note
|
|
137
|
+
// rather than rejecting what the scorer will happily use.
|
|
138
|
+
if (typeof factors.blast_radius === 'string' && Number.isFinite(Number(factors.blast_radius)) && factors.blast_radius.trim() !== '') {
|
|
139
|
+
warnings.push(`blast_radius: numeric string "${factors.blast_radius}" accepted (coerced to ${Number(factors.blast_radius)}); prefer a JSON number`);
|
|
140
|
+
} else {
|
|
141
|
+
warnings.push(`blast_radius: expected number, got ${typeof factors.blast_radius} (${JSON.stringify(factors.blast_radius)})`);
|
|
142
|
+
}
|
|
136
143
|
} else if (Number.isNaN(factors.blast_radius)) {
|
|
137
144
|
warnings.push('blast_radius: NaN is not a valid numeric value (treated as 0)');
|
|
138
145
|
} else if (!Number.isFinite(factors.blast_radius)) {
|
|
@@ -16,15 +16,25 @@
|
|
|
16
16
|
* phases.direct.skill_chain[].skill — both are resolved)
|
|
17
17
|
* - phases.govern.skill_preload[] → manifest.json.skills[]
|
|
18
18
|
* - domain.atlas_refs[] → data/atlas-ttps.json keys
|
|
19
|
+
* - domain.attack_refs[] → data/attack-techniques.json keys
|
|
19
20
|
* - domain.cve_refs[] → data/cve-catalog.json keys
|
|
20
21
|
* - domain.cwe_refs[] → data/cwe-catalog.json keys
|
|
21
22
|
* - domain.d3fend_refs[] → data/d3fend-catalog.json keys
|
|
22
23
|
* - phases.detect.indicators[].attack_ref → data/attack-techniques.json
|
|
23
24
|
* - phases.detect.indicators[].atlas_ref → data/atlas-ttps.json
|
|
24
25
|
* - phases.detect.indicators[].cve_ref → data/cve-catalog.json
|
|
26
|
+
* - phases.detect.false_positive_profile[].indicator_id
|
|
27
|
+
* → phases.detect.indicators[].id
|
|
25
28
|
*
|
|
26
29
|
* Internal consistency:
|
|
27
30
|
* - Indicator ids are unique within a playbook.
|
|
31
|
+
* - Every playbook maps to at least one TTP via domain.atlas_refs or
|
|
32
|
+
* domain.attack_refs (the cross-cutting correlation layer is exempt).
|
|
33
|
+
* - When _meta.air_gap_mode is true, network-sourced look.artifacts carry
|
|
34
|
+
* a non-empty air_gap_alternative (error if missing).
|
|
35
|
+
* - Closed controlled vocabularies (jurisdiction_obligations[].clock_starts,
|
|
36
|
+
* domain.frameworks_in_scope[]) are enforced at error severity, unlike the
|
|
37
|
+
* evolving-drift enums (artifact/indicator `type`) which stay warnings.
|
|
28
38
|
* - rwep_threshold ordering: close <= monitor <= escalate, each in 0..100.
|
|
29
39
|
* - close.notification_actions[].obligation_ref resolves to a synthesized
|
|
30
40
|
* "<jurisdiction>/<regulation> <window_hours>h" key from
|
|
@@ -229,6 +239,29 @@ function loadContext() {
|
|
|
229
239
|
const d3 = readJson(D3FEND_PATH);
|
|
230
240
|
const attack = readJsonIfExists(ATTACK_PATH);
|
|
231
241
|
|
|
242
|
+
// Closed controlled-vocabulary enums sourced from the schema so the
|
|
243
|
+
// hard-error enum checks in checkCrossRefs stay in lockstep with the
|
|
244
|
+
// schema's own enum lists. A typo'd clock_starts must hard-fail the
|
|
245
|
+
// predeploy gate (it changes when a notification clock starts ticking),
|
|
246
|
+
// so unlike evolving-drift enums (artifact/indicator `type`) these are
|
|
247
|
+
// promoted to error severity rather than left as generic-validator
|
|
248
|
+
// warnings.
|
|
249
|
+
let clockStartsEnum = null;
|
|
250
|
+
let frameworksEnum = null;
|
|
251
|
+
try {
|
|
252
|
+
const schema = readJson(SCHEMA_PATH);
|
|
253
|
+
clockStartsEnum =
|
|
254
|
+
schema.properties.phases.properties.govern.properties
|
|
255
|
+
.jurisdiction_obligations.items.properties.clock_starts.enum || null;
|
|
256
|
+
frameworksEnum =
|
|
257
|
+
schema.properties.domain.properties.frameworks_in_scope.items.enum || null;
|
|
258
|
+
} catch {
|
|
259
|
+
// If the schema shape changes, fall back to no closed-vocab enforcement
|
|
260
|
+
// here (the generic validator still emits warnings).
|
|
261
|
+
clockStartsEnum = null;
|
|
262
|
+
frameworksEnum = null;
|
|
263
|
+
}
|
|
264
|
+
|
|
232
265
|
return {
|
|
233
266
|
skillKeys: new Set(manifest.skills.map((s) => s.name)),
|
|
234
267
|
atlasKeys: new Set(Object.keys(atlas).filter((k) => !k.startsWith('_'))),
|
|
@@ -238,6 +271,8 @@ function loadContext() {
|
|
|
238
271
|
attackKeys: attack
|
|
239
272
|
? new Set(Object.keys(attack).filter((k) => !k.startsWith('_')))
|
|
240
273
|
: null,
|
|
274
|
+
clockStartsEnum: clockStartsEnum ? new Set(clockStartsEnum) : null,
|
|
275
|
+
frameworksEnum: frameworksEnum ? new Set(frameworksEnum) : null,
|
|
241
276
|
};
|
|
242
277
|
}
|
|
243
278
|
|
|
@@ -311,6 +346,15 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
311
346
|
warn(`domain.atlas_refs: unresolved "${a}" (not in data/atlas-ttps.json)`);
|
|
312
347
|
}
|
|
313
348
|
}
|
|
349
|
+
// Hard Rule #4 ("no orphaned controls"): domain.attack_refs must resolve to
|
|
350
|
+
// the ATT&CK technique catalog, mirroring the atlas_refs block above and the
|
|
351
|
+
// detect.indicators[].attack_ref check below. Without this, every TTP listed
|
|
352
|
+
// at the domain level bypassed catalog cross-referencing.
|
|
353
|
+
for (const a of domain.attack_refs || []) {
|
|
354
|
+
if (ctx.attackKeys && !ctx.attackKeys.has(a)) {
|
|
355
|
+
warn(`domain.attack_refs: unresolved "${a}" (not in data/attack-techniques.json)`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
314
358
|
for (const c of domain.cve_refs || []) {
|
|
315
359
|
if (!ctx.cveKeys.has(c)) {
|
|
316
360
|
warn(`domain.cve_refs: unresolved "${c}" (not in data/cve-catalog.json)`);
|
|
@@ -359,6 +403,19 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
359
403
|
}
|
|
360
404
|
}
|
|
361
405
|
|
|
406
|
+
// false_positive_profile[].indicator_id must reference a real indicator id.
|
|
407
|
+
// A dangling reference means an FP-distinguishing test is wired to nothing,
|
|
408
|
+
// so the runner can never apply it. Warning severity (vocabulary-style
|
|
409
|
+
// drift, not a structural break).
|
|
410
|
+
for (const [i, fp] of (detect.false_positive_profile || []).entries()) {
|
|
411
|
+
if (!fp || typeof fp !== 'object') continue;
|
|
412
|
+
if (fp.indicator_id && !indIds.has(fp.indicator_id)) {
|
|
413
|
+
warn(
|
|
414
|
+
`phases.detect.false_positive_profile[${i}].indicator_id: unresolved "${fp.indicator_id}" — no matching phases.detect.indicators[].id`,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
362
419
|
// rwep_threshold ordering. Hard error — a misordered threshold actively
|
|
363
420
|
// breaks the scoring path.
|
|
364
421
|
const rwep = direct.rwep_threshold || {};
|
|
@@ -397,6 +454,68 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
397
454
|
}
|
|
398
455
|
}
|
|
399
456
|
|
|
457
|
+
// Air-gap completeness. When _meta.air_gap_mode is true the runner refuses
|
|
458
|
+
// to touch the network, so every artifact whose source is a network call
|
|
459
|
+
// (https://, http://, gh api, gh release, curl, wget, fetch) MUST carry a
|
|
460
|
+
// non-empty air_gap_alternative or the run is silently incomplete. The
|
|
461
|
+
// schema encodes this as an allOf/if/then block, but the inline validator
|
|
462
|
+
// does not implement conditional keywords, so it is enforced imperatively
|
|
463
|
+
// here at error severity.
|
|
464
|
+
if (meta.air_gap_mode === true) {
|
|
465
|
+
const look = phases.look || {};
|
|
466
|
+
const netSourceRe = /(https?:\/\/|gh api|gh release|curl |wget |fetch )/;
|
|
467
|
+
for (const [i, art] of (look.artifacts || []).entries()) {
|
|
468
|
+
if (!art || typeof art !== 'object') continue;
|
|
469
|
+
if (typeof art.source === 'string' && netSourceRe.test(art.source)) {
|
|
470
|
+
const alt = art.air_gap_alternative;
|
|
471
|
+
if (typeof alt !== 'string' || alt.trim().length === 0) {
|
|
472
|
+
err(
|
|
473
|
+
`phases.look.artifacts[${i}]: _meta.air_gap_mode is true and source "${art.source}" makes a network call, but no non-empty air_gap_alternative is set — the artifact cannot be collected offline`,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// TTP-mapping floor (Hard Rule #4): every playbook must map to at least one
|
|
481
|
+
// adversary technique via domain.atlas_refs OR domain.attack_refs. The sole
|
|
482
|
+
// exemption is the cross-cutting correlation layer (_meta.scope ===
|
|
483
|
+
// "cross-cutting"), which has no first-party TTPs — it correlates findings
|
|
484
|
+
// produced by the other playbooks. Error severity for everything else.
|
|
485
|
+
const atlasCount = (domain.atlas_refs || []).length;
|
|
486
|
+
const attackCount = (domain.attack_refs || []).length;
|
|
487
|
+
if (atlasCount === 0 && attackCount === 0 && meta.scope !== 'cross-cutting') {
|
|
488
|
+
err(
|
|
489
|
+
'domain: no TTP mapping — at least one of domain.atlas_refs or domain.attack_refs must be non-empty (cross-cutting correlation playbooks are exempt)',
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Closed controlled-vocabulary enums: a value outside the schema's closed
|
|
494
|
+
// enum is an error, not a warning, so a typo cannot ship. Unlike the
|
|
495
|
+
// evolving-drift enums (artifact `type`, indicator `type`) which the generic
|
|
496
|
+
// validator keeps at warning, these vocabularies are fixed and load-bearing
|
|
497
|
+
// (clock_starts decides when a notification deadline starts counting;
|
|
498
|
+
// frameworks_in_scope drives gap-analysis routing).
|
|
499
|
+
if (ctx.clockStartsEnum) {
|
|
500
|
+
for (const [i, o] of (govern.jurisdiction_obligations || []).entries()) {
|
|
501
|
+
if (!o || typeof o !== 'object') continue;
|
|
502
|
+
if (o.clock_starts !== undefined && !ctx.clockStartsEnum.has(o.clock_starts)) {
|
|
503
|
+
err(
|
|
504
|
+
`phases.govern.jurisdiction_obligations[${i}].clock_starts: invalid value ${JSON.stringify(o.clock_starts)} — not in closed vocabulary ${JSON.stringify([...ctx.clockStartsEnum])}`,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (ctx.frameworksEnum) {
|
|
510
|
+
for (const [i, f] of (domain.frameworks_in_scope || []).entries()) {
|
|
511
|
+
if (typeof f === 'string' && !ctx.frameworksEnum.has(f)) {
|
|
512
|
+
err(
|
|
513
|
+
`domain.frameworks_in_scope[${i}]: invalid value ${JSON.stringify(f)} — not in closed vocabulary`,
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
400
519
|
return findings;
|
|
401
520
|
}
|
|
402
521
|
|