@clear-capabilities/agentic-security-scanner 0.143.0 → 0.145.0

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.
Files changed (135) hide show
  1. package/CHANGELOG.md +555 -0
  2. package/bin/agentic-security.js +770 -49
  3. package/dist/1.index.js +223 -0
  4. package/dist/113.index.js +117 -18
  5. package/dist/144.index.js +163 -0
  6. package/dist/178.index.js +24 -6
  7. package/dist/238.index.js +3 -2
  8. package/dist/265.index.js +191 -0
  9. package/dist/271.index.js +165 -0
  10. package/dist/384.index.js +1 -1
  11. package/dist/435.index.js +187 -52
  12. package/dist/444.index.js +11 -2
  13. package/dist/449.index.js +76 -12
  14. package/dist/526.index.js +117 -18
  15. package/dist/552.index.js +97 -0
  16. package/dist/637.index.js +27 -5
  17. package/dist/730.index.js +311 -0
  18. package/dist/736.index.js +301 -0
  19. package/dist/824.index.js +7 -0
  20. package/dist/905.index.js +88 -22
  21. package/dist/920.index.js +491 -0
  22. package/dist/970.index.js +173 -0
  23. package/dist/agentic-security.mjs +13 -13
  24. package/dist/agentic-security.mjs.sha256 +1 -1
  25. package/dist/calibration-seed.json +2 -0
  26. package/package.json +23 -9
  27. package/src/compare.js +6 -1
  28. package/src/dataflow/CLAUDE.md +1 -1
  29. package/src/dataflow/index.js +18 -0
  30. package/src/dataflow/privacy-catalog.js +290 -0
  31. package/src/dataflow/privacy-deep-walker.js +515 -0
  32. package/src/dataflow/privacy-governance.js +126 -0
  33. package/src/dataflow/privacy-inventory.js +154 -0
  34. package/src/dataflow/privacy-sink-policy.js +125 -0
  35. package/src/dataflow/privacy-taint.js +115 -54
  36. package/src/dataflow/privacy-taxonomy.js +233 -0
  37. package/src/discovery/disprove.js +7 -3
  38. package/src/discovery/hunter.js +9 -5
  39. package/src/discovery/index.js +2 -2
  40. package/src/discovery/llm-invoke.js +69 -13
  41. package/src/egress/audit.js +147 -0
  42. package/src/egress/policy.js +313 -0
  43. package/src/egress/redact.js +180 -0
  44. package/src/engine.js +1048 -302
  45. package/src/fix/apply-fix-service.js +404 -0
  46. package/src/fix/approver-registry.js +157 -0
  47. package/src/history-scan.js +22 -5
  48. package/src/ir/CLAUDE.md +1 -1
  49. package/src/llm-validator/index.js +86 -9
  50. package/src/llm-validator/model-status.js +66 -0
  51. package/src/lsp/server.js +49 -2
  52. package/src/mcp/tools.js +177 -50
  53. package/src/pipeline/analyzer-supervisor.js +93 -0
  54. package/src/pipeline/analyzer-worker.js +26 -0
  55. package/src/pipeline/annotator-runner.js +33 -0
  56. package/src/pipeline/assurance-mode.js +154 -0
  57. package/src/pipeline/cascade-worker-pool.js +172 -0
  58. package/src/pipeline/cascade-worker.js +43 -0
  59. package/src/pipeline/coverage-ledger.js +0 -0
  60. package/src/pipeline/detector-runner.js +51 -0
  61. package/src/pipeline/enrichment-completion.js +58 -0
  62. package/src/pipeline/evidence-provenance.js +91 -0
  63. package/src/pipeline/finding-schema.js +108 -0
  64. package/src/pipeline/legacy-compat.js +101 -0
  65. package/src/pipeline/producer-collector.js +48 -0
  66. package/src/pipeline/producer-registry.js +112 -0
  67. package/src/pipeline/scan-health.js +144 -0
  68. package/src/posture/CLAUDE.md +123 -0
  69. package/src/posture/accuracy-scorecard.js +156 -1
  70. package/src/posture/adversary-agent.js +15 -3
  71. package/src/posture/artifact-registry.js +241 -0
  72. package/src/posture/auditor-walkthrough.js +186 -21
  73. package/src/posture/calibration-feedback.js +201 -0
  74. package/src/posture/calibration-seed.json +2 -0
  75. package/src/posture/calibration.js +25 -0
  76. package/src/posture/compliance-evidence-signing.js +131 -0
  77. package/src/posture/compliance-policy.js +324 -17
  78. package/src/posture/cross-repo-memory.js +7 -2
  79. package/src/posture/custom-rules.js +36 -0
  80. package/src/posture/deterministic.js +8 -1
  81. package/src/posture/encryption-provider.js +205 -0
  82. package/src/posture/evidence-grade-wording.js +71 -0
  83. package/src/posture/fix-history.js +137 -20
  84. package/src/posture/fix-honesty-gate.js +47 -6
  85. package/src/posture/fix-verify.js +65 -8
  86. package/src/posture/fleet.js +0 -0
  87. package/src/posture/flow-narration.js +7 -2
  88. package/src/posture/git-history.js +13 -5
  89. package/src/posture/legal-hold.js +140 -0
  90. package/src/posture/llm-redteam.js +10 -1
  91. package/src/posture/material-change.js +111 -2
  92. package/src/posture/mttr.js +75 -12
  93. package/src/posture/policy-bundle.js +274 -0
  94. package/src/posture/pre-incident-archaeology.js +39 -7
  95. package/src/posture/privacy-framework.js +47 -6
  96. package/src/posture/production-feedback.js +179 -0
  97. package/src/posture/provenance/ai-authorship.js +68 -0
  98. package/src/posture/provenance/branch-entry.js +80 -0
  99. package/src/posture/provenance/cache.js +143 -0
  100. package/src/posture/provenance/confidence.js +36 -0
  101. package/src/posture/provenance/coordinator.js +786 -0
  102. package/src/posture/provenance/dag-walk.js +249 -0
  103. package/src/posture/provenance/evidence-attribution.js +59 -0
  104. package/src/posture/provenance/git-evidence.js +310 -0
  105. package/src/posture/provenance/lifecycle.js +208 -0
  106. package/src/posture/provenance/missing-control-resolver.js +137 -0
  107. package/src/posture/provenance/origin-resolver.js +342 -0
  108. package/src/posture/provenance/predicate-replay.js +133 -0
  109. package/src/posture/provenance/providers/config.js +39 -0
  110. package/src/posture/provenance/providers/github.js +62 -0
  111. package/src/posture/provenance/providers/gitlab.js +58 -0
  112. package/src/posture/provenance/repo-lineage.js +74 -0
  113. package/src/posture/provenance/sca-origin.js +139 -0
  114. package/src/posture/provenance/schema.js +255 -0
  115. package/src/posture/provenance/transitive-sca.js +147 -0
  116. package/src/posture/provenance/validate.js +30 -0
  117. package/src/posture/provenance-evidence-bundle.js +144 -0
  118. package/src/posture/retention-policy.js +132 -0
  119. package/src/posture/risk-dollars.js +216 -26
  120. package/src/posture/sbom-diff.js +15 -2
  121. package/src/posture/scan-checkpoint.js +176 -31
  122. package/src/posture/secret-history.js +10 -2
  123. package/src/posture/state-dir.js +64 -5
  124. package/src/posture/state-lifecycle-report.js +77 -0
  125. package/src/posture/suppressions.js +59 -3
  126. package/src/posture/vuln-archaeology.js +8 -2
  127. package/src/pr-delta.js +25 -4
  128. package/src/privacy/ir-adapter.js +380 -0
  129. package/src/report/index.js +248 -5
  130. package/src/report/oscal.js +7 -2
  131. package/src/runScan.js +34 -5
  132. package/src/sast/cpp.js +3 -14
  133. package/src/sast/rate-limit.js +33 -3
  134. package/src/sca/llm-function-extract.js +6 -0
  135. package/src/util/git-hardening.js +128 -0
@@ -0,0 +1,48 @@
1
+ // Producer collector (assurance-hardening PRD, Milestone 1, FR-102).
2
+ //
3
+ // The one place a "late producer" (see producer-registry.js's header for
4
+ // exactly which ones — the cross-language/business-logic/privacy group
5
+ // A-03 evidenced) is allowed to append to the shared finding array. Before
6
+ // this, each of these ~12 call sites in engine.js did its own
7
+ // `try { ...; if (x?.length) finalFindings.push(...x); } catch(_) {}` —
8
+ // functionally fine for not crashing the scan, but each one silently
9
+ // swallowed a producer exception with no diagnostic (a producer could be
10
+ // broken for months and nothing would ever say so) and there was no single
11
+ // place enforcing "only a REGISTERED producer may append here."
12
+ //
13
+ // This does not change engine.js's ~90 core SAST/SCA/secrets detector call
14
+ // sites (see producer-registry.js's header for why that is out of scope).
15
+
16
+ import { isRegisteredProducer } from './producer-registry.js';
17
+
18
+ /**
19
+ * @param {object[]} finalFindings - mutated in place (push only)
20
+ * @param {Array<{phase:string, err:string}>} diagnostics - mutated in place (push only), same
21
+ * shape as engine.js's _annotatorErrors so both surfaces read as one list
22
+ * @param {string} producerId - MUST be registered via producer-registry.js's registerProducer()
23
+ * @param {() => (Array|{findings:Array}|null|undefined)} thunk - runs the actual producer;
24
+ * may return a bare findings array (matches every one of these producers' current shape) or
25
+ * an object with a .findings array (forward-compatible with a future AnalyzerResult shape)
26
+ * @returns {{status:'completed'|'failed'|'unregistered', count:number}}
27
+ */
28
+ export function collectProducerResult(finalFindings, diagnostics, producerId, thunk) {
29
+ if (!isRegisteredProducer(producerId)) {
30
+ const msg = `producer "${producerId}" is not registered — refusing to collect its output`;
31
+ diagnostics.push({ phase: `producer:${producerId}`, err: msg });
32
+ return { status: 'unregistered', count: 0 };
33
+ }
34
+ let result;
35
+ try {
36
+ result = thunk();
37
+ } catch (e) {
38
+ diagnostics.push({ phase: `producer:${producerId}`, err: String((e && e.message) || e) });
39
+ return { status: 'failed', count: 0 };
40
+ }
41
+ const findings = Array.isArray(result) ? result : (Array.isArray(result?.findings) ? result.findings : null);
42
+ if (!findings || !findings.length) return { status: 'completed', count: 0 };
43
+ for (const f of findings) {
44
+ if (f && typeof f === 'object' && f.producerId === undefined) f.producerId = producerId;
45
+ }
46
+ finalFindings.push(...findings);
47
+ return { status: 'completed', count: findings.length };
48
+ }
@@ -0,0 +1,112 @@
1
+ // Producer registry (assurance-hardening PRD, Milestone 1, FR-101).
2
+ //
3
+ // engine.js's core ~90 SAST/SCA/secrets detectors are called synchronously
4
+ // inside one large per-file loop and are NOT converted to registry entries
5
+ // by this change — that is a much larger, separate undertaking (every one
6
+ // of those call sites would need to change shape) and is explicitly out of
7
+ // scope here. What this registry DOES cover: the "late producers" that
8
+ // append findings AFTER the main per-file loop and AFTER the primary
9
+ // enrichment chain has already run once — cross-language taint (5
10
+ // boundary types), IaC-reachability, IAM-policy, container-runtime,
11
+ // business-logic-v2, specification-drift, concurrency, and privacy-taint.
12
+ // These are exactly the producers the PRD's A-03 evidence cites (findings
13
+ // that bypass stableId/confidence/calibration because they arrive after
14
+ // engine.js's enrichment chain already finished). See
15
+ // pipeline/producer-collector.js for how registration is enforced at the
16
+ // point findings are actually collected, and pipeline/enrichment-completion.js
17
+ // for how any finding that still lacks the standard fields (regardless of
18
+ // producer) gets them before the collection is frozen.
19
+
20
+ const KNOWN_PHASES = Object.freeze(['sast', 'sca', 'secrets', 'cross-language', 'business-logic', 'privacy']);
21
+
22
+ const _registry = new Map();
23
+
24
+ /**
25
+ * @param {object} def
26
+ * @param {string} def.id - unique producer id
27
+ * @param {string} def.version - producer version string (semver-ish, not enforced)
28
+ * @param {string} def.phase - one of KNOWN_PHASES
29
+ * @param {string[]} [def.languages] - languages this producer applies to, or [] for language-agnostic
30
+ * @param {string[]} [def.dependsOn] - ids of producers that must be registered (and are logically upstream)
31
+ * @throws on duplicate id, unknown phase, or a dependency cycle
32
+ */
33
+ export function registerProducer(def) {
34
+ if (!def || typeof def !== 'object') throw new Error('registerProducer: definition required');
35
+ const { id, version, phase, languages = [], dependsOn = [] } = def;
36
+ if (typeof id !== 'string' || !id) throw new Error('registerProducer: id required');
37
+ if (_registry.has(id)) throw new Error(`registerProducer: duplicate producer id "${id}"`);
38
+ if (typeof version !== 'string' || !version) throw new Error(`registerProducer: "${id}" missing version`);
39
+ if (!KNOWN_PHASES.includes(phase)) {
40
+ throw new Error(`registerProducer: "${id}" has unknown phase "${phase}" — must be one of ${KNOWN_PHASES.join(', ')}`);
41
+ }
42
+ if (!Array.isArray(dependsOn)) throw new Error(`registerProducer: "${id}" dependsOn must be an array`);
43
+
44
+ _registry.set(id, { id, version, phase, languages: [...languages], dependsOn: [...dependsOn] });
45
+ const cycle = _findCycle();
46
+ if (cycle) {
47
+ _registry.delete(id); // don't leave the registry in a broken state
48
+ throw new Error(`registerProducer: dependency cycle detected: ${cycle.join(' -> ')}`);
49
+ }
50
+ }
51
+
52
+ /** Topological cycle check over the current registry. Returns the cycle path, or null. */
53
+ function _findCycle() {
54
+ const WHITE = 0, GRAY = 1, BLACK = 2;
55
+ const color = new Map([..._registry.keys()].map(id => [id, WHITE]));
56
+ const path = [];
57
+ function visit(id) {
58
+ color.set(id, GRAY);
59
+ path.push(id);
60
+ const def = _registry.get(id);
61
+ for (const dep of def?.dependsOn || []) {
62
+ if (!_registry.has(dep)) continue; // an unregistered dependency is reported separately, not a cycle
63
+ const c = color.get(dep);
64
+ if (c === GRAY) return [...path.slice(path.indexOf(dep)), dep];
65
+ if (c === WHITE) {
66
+ const found = visit(dep);
67
+ if (found) return found;
68
+ }
69
+ }
70
+ path.pop();
71
+ color.set(id, BLACK);
72
+ return null;
73
+ }
74
+ for (const id of _registry.keys()) {
75
+ if (color.get(id) === WHITE) {
76
+ const found = visit(id);
77
+ if (found) return found;
78
+ }
79
+ }
80
+ return null;
81
+ }
82
+
83
+ export function isRegisteredProducer(id) {
84
+ return _registry.has(id);
85
+ }
86
+
87
+ export function getProducer(id) {
88
+ return _registry.get(id) || null;
89
+ }
90
+
91
+ export function listProducers() {
92
+ return [..._registry.values()];
93
+ }
94
+
95
+ /** Every dependsOn id must itself be a registered producer. For tests/CI, not called at runtime. */
96
+ export function validateNoUnregisteredDependencies() {
97
+ const missing = [];
98
+ for (const def of _registry.values()) {
99
+ for (const dep of def.dependsOn) {
100
+ if (!_registry.has(dep)) missing.push(`${def.id} depends on unregistered producer "${dep}"`);
101
+ }
102
+ }
103
+ return missing;
104
+ }
105
+
106
+ // Test-only: reset the module-level registry between test files/cases.
107
+ // Never called from production code paths.
108
+ export function _resetForTests() {
109
+ _registry.clear();
110
+ }
111
+
112
+ export const KNOWN_PHASES_FOR_TESTS = KNOWN_PHASES;
@@ -0,0 +1,144 @@
1
+ // Scan health (assurance-hardening PRD, Milestone 0, FR-206).
2
+ //
3
+ // PRD principle: "Separate 'no findings' from 'analysis complete'." Before
4
+ // this module, a scan that hit an annotator exception, timed out on files, or
5
+ // silently downgraded deep-analysis mode reported the same shape as a clean
6
+ // complete scan — the only signals (`annotatorErrors`, `_scanMeta.filesTimedOut`)
7
+ // existed on the result object already, but nothing summarized them into a
8
+ // single status a caller could gate on or a human could read as a headline.
9
+ //
10
+ // This is additive-only (Milestone 0 scope): computeScanHealth() is a pure
11
+ // function over signals the engine already collects, and its output is a new
12
+ // field on the scan result. No existing gate, exit code, or CLI behavior
13
+ // changes when this field is added.
14
+ //
15
+ // `analyzers` (PRD section 10.3) went from a hardcoded `null` to a real,
16
+ // computed summary once FR-201 (every detector call site isolated,
17
+ // {file,analyzer,err} captured), FR-202 (real preemptive deadlines, a
18
+ // _timeout:true marker per killed file), and FR-203 (pipeline/
19
+ // coverage-ledger.js, the actual per-file x per-analyzer status
20
+ // computation) all existed for it to be computed FROM — reporting it
21
+ // before then would have looked more precise than the underlying analysis
22
+ // supported, which is exactly the failure mode this module exists to
23
+ // prevent (see this file's own header history in git blame for that
24
+ // reasoning, kept as a decision record even though the gap it names is
25
+ // now closed).
26
+
27
+ export const SCAN_HEALTH_SCHEMA_VERSION = 1;
28
+
29
+ /**
30
+ * @param {object} input
31
+ * @param {object|null} input.scanMeta - engine's `_scanMeta` (filesScanned, filesSkipped,
32
+ * filesDenseSkipped, filesTimedOut, checkpoint, ...).
33
+ * @param {Array<{phase:string, err:string}>} input.annotatorErrors
34
+ * @param {object} [input.engineErrors] - e.g. { cppDataflowParseErrors }
35
+ * @param {object} [input.deepStatus] - { requested, enabled, inCi, ciOverrideAllowed, reason, failure }
36
+ * @param {object} [input.analyzerCoverage] - coverage-ledger.js's
37
+ * summarizeCoverageForScanHealth() output: {expected, completed, failed,
38
+ * timedOut, skippedByPolicy}. Omitted (not just empty) is treated the
39
+ * same as the old `null` — a caller that hasn't wired FR-203's ledger in
40
+ * yet (e.g. a hand-built scan object in a test) gets `analyzers: null`,
41
+ * never a fabricated all-zero summary.
42
+ * @returns {object} scanHealth per PRD §10.3, additive fields only.
43
+ */
44
+ export function computeScanHealth({ scanMeta = null, annotatorErrors = [], engineErrors = null, deepStatus = null, analyzerCoverage = null } = {}) {
45
+ const conditions = [];
46
+ const safeAnnotatorErrors = Array.isArray(annotatorErrors) ? annotatorErrors : [];
47
+ const filesTimedOut = Number(scanMeta?.filesTimedOut) || 0;
48
+
49
+ if (safeAnnotatorErrors.length > 0) {
50
+ conditions.push(`${safeAnnotatorErrors.length} annotator(s) threw and were skipped: ${
51
+ [...new Set(safeAnnotatorErrors.map(e => e.phase))].join(', ')
52
+ }`);
53
+ }
54
+ if (filesTimedOut > 0) {
55
+ conditions.push(`${filesTimedOut} file(s) exceeded the per-file analysis timeout`);
56
+ }
57
+ if (engineErrors?.cppDataflowParseErrors > 0) {
58
+ conditions.push(`${engineErrors.cppDataflowParseErrors} C/C++ dataflow parse error(s)`);
59
+ }
60
+ if (deepStatus?.failure) {
61
+ conditions.push(`deep analysis (IR-taint) threw and fell back to pattern-only results: ${deepStatus.failure}`);
62
+ }
63
+ if (deepStatus?.requested && !deepStatus.enabled) {
64
+ conditions.push(`deep analysis was requested but did not run: ${deepStatus.reason || 'unknown reason'}`);
65
+ }
66
+ // FR-203: a detector that threw on at least one file (captured via
67
+ // FR-201's runDetector isolation) is a real analysis gap distinct from
68
+ // an ANNOTATOR error above — annotators run post-detection over the
69
+ // whole finding set; detectors run per-file and produce the findings
70
+ // themselves, so a failed detector can mean a real vulnerability was
71
+ // never even looked for. This condition did not exist before FR-203's
72
+ // ledger made "which analyzer, how many files" computable.
73
+ if (analyzerCoverage && analyzerCoverage.failed > 0) {
74
+ conditions.push(`${analyzerCoverage.failed} analyzer(s) threw on at least one file`);
75
+ }
76
+
77
+ const status = conditions.length > 0 ? 'partial' : 'complete';
78
+
79
+ return {
80
+ schemaVersion: SCAN_HEALTH_SCHEMA_VERSION,
81
+ status,
82
+ files: {
83
+ expected: scanMeta?.checkpoint?.total ?? null,
84
+ scanned: scanMeta?.filesScanned ?? null,
85
+ skipped: (Number(scanMeta?.filesSkipped) || 0) + (Number(scanMeta?.filesDenseSkipped) || 0),
86
+ timedOut: filesTimedOut,
87
+ },
88
+ analyzers: analyzerCoverage || null,
89
+ deepAnalysis: deepStatus
90
+ ? {
91
+ requested: !!deepStatus.requested,
92
+ enabled: !!deepStatus.enabled,
93
+ inCi: !!deepStatus.inCi,
94
+ ciOverrideAllowed: !!deepStatus.ciOverrideAllowed,
95
+ reason: deepStatus.reason ?? null,
96
+ failure: deepStatus.failure ?? null,
97
+ }
98
+ : null,
99
+ annotatorErrorCount: safeAnnotatorErrors.length,
100
+ freshness: null,
101
+ conditions,
102
+ };
103
+ }
104
+
105
+ // FR-207 ("Add freshness checks for vulnerability feeds, calibration data,
106
+ // rulesets, and policies — stale dependencies are visible and can fail
107
+ // strict policy"). Deliberately NOT folded into computeScanHealth() itself:
108
+ // the five freshness signals (KEV, EPSS, calibration, custom rules,
109
+ // compliance evidence) are computed in two different places at two
110
+ // different times relative to computeScanHealth's own call site —
111
+ // engine.js has kev/epss/calibration/compliance available before it builds
112
+ // scanHealth, but the custom-rules pattern-DSL only runs in
113
+ // bin/agentic-security.js, AFTER scanHealth already exists on the scan
114
+ // object. A single merge/condition/status function usable from both call
115
+ // sites (once up front, once as a later patch) is simpler and less
116
+ // duplicative than threading a partial value through computeScanHealth
117
+ // twice. Each leg is additive: a leg not present in `freshnessPartial`
118
+ // leaves scanHealth's existing `freshness` object and conditions
119
+ // untouched, exactly like every other optional input this module accepts.
120
+ const FRESHNESS_CONDITION_BUILDERS = {
121
+ kev: (f) => (f?.stale === true) ? `KEV catalog is stale (${f.ageDays} day(s) old) — recently-added CVEs may not be reflected` : null,
122
+ epss: (f) => (f?.stale === true) ? `EPSS exploit-probability data is stale (${f.ageDays} day(s) old)` : null,
123
+ calibration: (f) => (f?.stale === true) ? `calibration data is stale (${f.ageDays} day(s) old${f.generatedAt ? `, last generated ${f.generatedAt}` : ''})` : null,
124
+ customRules: (f) => (f?.stale === true) ? `${f.staleFiles.length} custom rule file(s) exceed their configured review interval` : null,
125
+ compliance: (f) => (Number(f?.stale) > 0) ? `${f.stale} compliance control(s) have stale evidence` : null,
126
+ };
127
+
128
+ export function applyFreshness(scanHealth, freshnessPartial) {
129
+ if (!scanHealth || !freshnessPartial) return scanHealth;
130
+ const freshness = { ...(scanHealth.freshness || {}), ...freshnessPartial };
131
+ const newConditions = [];
132
+ for (const [key, value] of Object.entries(freshnessPartial)) {
133
+ const build = FRESHNESS_CONDITION_BUILDERS[key];
134
+ const msg = build ? build(value) : null;
135
+ if (msg) newConditions.push(msg);
136
+ }
137
+ if (!newConditions.length) return { ...scanHealth, freshness };
138
+ return {
139
+ ...scanHealth,
140
+ freshness,
141
+ conditions: [...scanHealth.conditions, ...newConditions],
142
+ status: scanHealth.status === 'complete' ? 'partial' : scanHealth.status,
143
+ };
144
+ }
@@ -349,6 +349,129 @@ output), and an entry a participant could not run is **unscored**, never counted
349
349
  as a miss. Matching is CWE-only so nobody is scored on this engine's vocabulary.
350
350
  Driver: `scripts/comparison.mjs`, over the CVE-replay corpus.
351
351
 
352
+ **State artifact registry (assurance-hardening PRD FR-701/FR-703)** — `artifact-registry.js`. The registry `cmdReset` (bin/agentic-security.js) now iterates instead of two hardcoded WIPE/WIPE_DIRS Sets. Every known `.agentic-security/` artifact is classified `generated` (scanner-written, safe to delete on reset) or `operator-config` (hand- or agent-authored input, never deleted) — built from an audit of every `statePath()`/`stateDir()` call site, not guessed from filenames; several looked generated by name but turned out to be inputs (`.agentic-security/logic-claims.json`, `.agentic-security/exploit-history.jsonl`, `.agentic-security/cve-alerts.json`, `.agentic-security/network-policy.json`, `.agentic-security/current-intent.md` — see the module's own header for the evidence behind each). Guarded by a completeness test (`test/artifact-registry-completeness.test.js`) that scans for every `statePath()`/`stateDir()` literal and fails if one isn't registered — a `no-dead-modules.test.js`-style drift guard, not a snapshot.
353
+
354
+ ## Finding provenance — `provenance/` (20 modules)
355
+
356
+ The only SUBDIRECTORY under `posture/`, because it is a pipeline rather than an
357
+ annotator: twenty small modules that together answer "which commit introduced
358
+ this finding, and how sure are we?" Everything outside the subdirectory sees one
359
+ function, `annotateGitProvenance(findings, ctx)` from `coordinator.js`, wired in
360
+ `engine.js` after every finding has been appended.
361
+
362
+ **Read the naming rule before you touch anything here.** The exported function is
363
+ `annotateGitProvenance` — NOT `annotateProvenance` (taken by
364
+ `sca/sigstore-verify.js`, build attestations) and NOT `annotateFindingProvenance`
365
+ (taken by `posture/provenance.js`, parser-corroboration signals). `engine.js`
366
+ imports all three; either alternative name is a duplicate binding, and the second
367
+ takes a findings array as its first argument exactly like this one, so a wrong
368
+ import would RUN rather than fail. The field is `finding.findingProvenance`,
369
+ never bare `.provenance` — `finding.provenance` and `supplyChainEntry.provenance`
370
+ are both pre-existing unrelated fields.
371
+
372
+ **The pipeline**, in call order — all LIVE-WIRED into `engine.js`'s scan unless noted:
373
+
374
+ | Module | Answers |
375
+ |---|---|
376
+ | `coordinator.js` | the integration point — budget, cache, per-finding dispatch, the terminal-status guarantee |
377
+ | `git-evidence.js` | the only Git wrapper (`getRepoState`, `blameLine`, `candidateCommitsForLine`, `getBlobAtCommit`, `commitMeta`) |
378
+ | `origin-resolver.js` | which commit introduced a SAST finding |
379
+ | `dag-walk.js` | (M3 §3.1) non-first-parent DAG walk + revert/cherry-pick detection for `--provenance deep` |
380
+ | `predicate-replay.js` | was this finding's condition true at commit X (calls `runFullScan` on that commit's blobs) |
381
+ | `sca-origin.js` | which commit moved a directly-declared dependency version into an advisory's vulnerable range |
382
+ | `transitive-sca.js` | (M3 §3.2) the same question for a TRANSITIVE dependency, re-deriving lockfile ancestry per historical commit |
383
+ | `branch-entry.js` | which branch/PR merge brought the origin commit into the current branch |
384
+ | `evidence-attribution.js` | the path:line:commit triples for source / sink / manifest |
385
+ | `confidence.js` | HIGH / MEDIUM / LOW plus the reasons behind it |
386
+ | `lifecycle.js` | the introduce / remediate / reintroduce ledger |
387
+ | `cache.js` | per-(HEAD, stableId, ruleset, boundary, mode) memo under its own top-level `.agentic-security/provenance-cache/` (split out from `provenance/` so it can carry a `'cache'` retentionClass the permanent lifecycle ledger must not get — see artifact-registry.js) |
388
+ | `schema.js` | the status/method/role/confidence enums, `emptyProvenance`, `redactFindingProvenance`, `isProvenanceHealthy` |
389
+ | `validate.js` | shape assertion for tests |
390
+ | `missing-control-resolver.js` | (M3 §3.3, FR-PROV-017) when a previously-observed safeguard disappeared — **wired into `coordinator.js`**: `resolveMissingControlOrigin` calls `resolveMissingControl` for any finding with `missingControlCandidate:true` (today, `sast/rate-limit.js`'s findings) |
391
+ | `providers/config.js`, `providers/github.js`, `providers/gitlab.js` | (M3 §3.4, FR-PROV-022) GitHub/GitLab PR-metadata + CODEOWNERS fetch, config resolved from `.agentic-security/provenance-providers.yml` / token env vars — **wired into `coordinator.js`**: `resolveProviderConfig` is resolved once per scan in `annotateGitProvenance`, and `fetchPRMetadata`/`fetchCodeowners` are called per `complete`-status finding (capped, see `MAX_PROVIDER_ENRICHMENTS_PER_SCAN`), landing on `findingProvenance.providerEnrichment` |
392
+ | `repo-lineage.js` | (M4 §4.2) loads + fully verifies an operator-declared `.agentic-security/repo-lineage.json` cross-repo link (local clones only, no remote fetch) — used by `origin-resolver.js`'s root-commit case, not a standalone-unwired module |
393
+ | `ai-authorship.js` | (M4 §4.3) extensible AI-authorship verifier registry (`registerAIAuthorshipVerifier`/`resolveAIAuthorship`), defaults to `{status:'unknown', verifier:null}` with nothing registered (today's real state) — wired into `origin-resolver.js`'s `originFrom`, so every SAST `findingOrigin` carries `aiAuthorship`; scoped to SAST only, not direct/transitive SCA origins |
394
+
395
+ **Four invariants, each with a test that fails if you relax it:**
396
+
397
+ - **Terminal status, always.** After `annotateGitProvenance` returns, every
398
+ finding carries a `findingProvenance` with one of `complete` / `partial` /
399
+ `uncommitted` / `not_available` / `budget_exhausted` / `error`. There is no
400
+ path — missing git binary, malformed finding, downstream throw — that leaves
401
+ the field absent. `engine.js` additionally backstops every channel OUTSIDE
402
+ the `_runAnnotator` wrapper, because that wrapper swallows throws —
403
+ `findings` and `supplyChain` with a full not_available/error catch-all as
404
+ before; since Task 11, `secrets` and blameable `logicVulns` go through REAL
405
+ resolution (real stableIds backfilled, real `annotateGitProvenance` calls
406
+ made), so their outside-the-wrapper coverage narrowed to a defensive
407
+ catch-all for whatever the real call somehow didn't reach, plus the 3
408
+ synthetic-line `logicVulns` producers (`license-policy:`/`deploy-platform:`/
409
+ `stack-playbook:`), which stay on a permanent, principled not_available —
410
+ never routed through `resolveOrigin` at all, not merely deferred.
411
+ - **Never false certainty.** A shallow clone cannot reach `complete`; an
412
+ unverifiable parent boundary degrades to `partial` with its reason carried
413
+ through. `origin-resolver.js` decides this on the `shallow` flag of the
414
+ repoState object, and it must come from the REAL `getRepoState()` — pass it a
415
+ stub and the guarantee is gone.
416
+ - **The lifecycle ledger only closes findings on a COMPLETE scan.** `applyScan`'s
417
+ remediation pass turns absence into the claim "this was fixed," which is sound
418
+ only if the scan looked everywhere. `runScan.js` computes `completeScan` (false
419
+ for `--changed-since`/`--pr` and for caller-supplied `fileContents`) and threads
420
+ it through `runFullScan` to `updateLifecycle`. `updateLifecycle` is also gated on
421
+ the `scanRoot` being **a directory that exists** — not merely truthy.
422
+ `resolveProjectRoot` honours a caller-supplied scanRoot only when it resolves to
423
+ a real directory; for `null`, for a typo'd path, or for a file, it falls back to
424
+ walking up from the PROCESS CWD. Both doors led to the same corruption: a scan
425
+ that never looked at your project writing your project's ledger, and then —
426
+ finding nothing while still claiming `completeScan` — remediating every open
427
+ finding in it. `agentic-security scan ./typo` is the reachable form. This repo's
428
+ own checkout accumulated a 1.1 MB ledger of spurious events that way.
429
+ - **One budget for the whole scan.** `engine.js` computes ONE `deadlineAt` and
430
+ passes it to all five of its `annotateGitProvenance` calls (SAST findings,
431
+ direct SCA deps, transitive SCA deps per Task 7, then secrets and blameable
432
+ logicVulns per Task 11); a caller-supplied `deadlineAt`/`perFindingBudgetMs`
433
+ wins over the coordinator's own computation. Inside, each finding gets
434
+ `max(2s, remaining/count)` so one deep-history finding cannot starve the rest.
435
+ `budget_exhausted` is the one result that is **never cached** — it is a property
436
+ of the run, not the repository, and caching it would pin a timeout in place
437
+ until HEAD moved.
438
+
439
+ **Re-entrancy brake.** `predicate-replay.js` calls `runFullScan` back on historical
440
+ blobs, so every internal re-scan must pass `provenance:false` or the pass recurses
441
+ without bound. Present callers: `history-scan.js` (×3), `pr-delta.js`,
442
+ `fix-verify.js`, `compare.js`; `lsp/server.js` uses the wider
443
+ `withStateWritesDisabled`.
444
+
445
+ **Privacy.** Author emails are collected but redacted by `redactFindingProvenance`
446
+ at every output boundary (`report/index.js`, `mcp/tools.js`) unless
447
+ `AGENTIC_SECURITY_INCLUDE_AUTHOR_EMAIL=1` / `--include-author-email`. Separately,
448
+ `AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS=1` / `--pseudonymize-authors` (PRD Section 8)
449
+ replaces `authorName` with a stable `Contributor-XXXXXXXX` pseudonym instead of
450
+ withholding it — `redactFindingProvenance` applies the same treatment to
451
+ `providerEnrichment.reviewers`/`codeowners` (FR-PROV-022's PR-reviewer logins and
452
+ raw CODEOWNERS lines), not just `findingOrigin`. Both `report/index.js` and
453
+ `mcp/tools.js` read the env var per call to build the redaction options
454
+ (`mcp/tools.js` deliberately never reads `AGENTIC_SECURITY_INCLUDE_AUTHOR_EMAIL`
455
+ itself — an agent caller gets no raw email regardless of that flag); the
456
+ `auditor-walkthrough.js` narrative reads it too, for the one `earliestOrigin`
457
+ field that bypasses `redactFindingProvenance` entirely (see that module's own
458
+ comment on why).
459
+
460
+ **At rest, `provenance/cache.js` stores the UNREDACTED record, on purpose.**
461
+ Redaction is a read-time/output-time concern — the same cached record gets
462
+ replayed back out through `redactFindingProvenance` differently per output
463
+ call (default vs. `--include-author-email` vs. `--pseudonymize-authors`), which
464
+ only works if the cache holds one raw, policy-independent copy. Pre-redacting
465
+ at write time would freeze whichever policy was active when the entry was
466
+ cached, breaking that per-call flexibility for every later reader (second
467
+ independent Finding Provenance PRD audit). The accepted mitigation is a
468
+ permissions floor, not encryption: every `cacheSet` chmods the entry file to
469
+ `0600` and the `provenance-cache/` directory to `0700` (same posture as
470
+ `integrity.js`'s per-install HMAC key). This defeats other local users/processes
471
+ reading the cache; it does not defeat root or the same OS user. See
472
+ `cache.js`'s own header for the full tradeoff writeup, including why
473
+ encryption-at-rest was considered and deferred.
474
+
352
475
  ## Gotchas
353
476
 
354
477
  - The seed `calibration-seed.json` is small (n < 30 for several families). Don't treat it as a held-out set — that's `holdout-eval.js`'s job, against an externally-supplied JSONL.
@@ -113,12 +113,28 @@ export function aggregateCorpus(detail) {
113
113
 
114
114
  /**
115
115
  * Build the machine-readable scorecard model. `inputs`:
116
- * provenance { engineVersion, bundleSha256, commit, nodeVersion, generatedAt }
116
+ * provenance { engineVersion, bundleSha256, commit, nodeVersion, generatedAt,
117
+ * corpusVersion?, scope? } — the last two are FR-901's
118
+ * "corpus version" and "scope" fields; optional so a
119
+ * caller with no corpus baseline to hash still gets a
120
+ * valid scorecard rather than a thrown error.
117
121
  * corpusDetail per-entry records from a corpus run performed THIS run
118
122
  * selfScan { targets: {name:{total,byFile}}, polyglot: {total,byLanguage} }
119
123
  * — measured THIS run
120
124
  * committed { corpusBaseline, proofCorpus } — read from committed files,
121
125
  * labelled as such in the output, never used to derive a rate
126
+ * scan optional — a scan-shaped object (`{findings, secrets,
127
+ * supplyChain}`, trimmed to just those arrays) from a run over
128
+ * a full (non-shallow) Git clone, used ONLY to compute
129
+ * provenanceCoverage below. `scripts/scorecard.mjs` passes
130
+ * `selfScan.provenanceScan` — the self-scan harness
131
+ * (bench/self-scan/measure.mjs) already runs a real
132
+ * `runScan()` over this project's own full git clone with
133
+ * provenance resolution on by default, so this reuses that
134
+ * run's already-computed `findingProvenance` rather than
135
+ * performing a second scan. Still optional: a caller with no
136
+ * such scan renders "not measured this run" rather than a
137
+ * fabricated rate. See PRD Success Metrics.
122
138
  */
123
139
  export function buildScorecard(inputs) {
124
140
  const corpus = aggregateCorpus(inputs.corpusDetail);
@@ -159,6 +175,14 @@ export function buildScorecard(inputs) {
159
175
  byTier: corpus.byTier,
160
176
  },
161
177
  selfScan: { measuredThisRun: true, targets, polyglot: selfScan.polyglot || { total: 0, byLanguage: {} } },
178
+ // PRD Success Metrics: "Provenance coverage >=95% complete or uncommitted
179
+ // for P0-supported findings in full Git clones." `inputs.scan` is
180
+ // optional (see the JSDoc above) — absent when no caller yet supplies a
181
+ // real scan, in which case this reports "not measured" rather than a
182
+ // fabricated 0/0.
183
+ provenanceCoverage: inputs.scan
184
+ ? { measuredThisRun: true, ...computeProvenanceCoverage(inputs.scan) }
185
+ : { measuredThisRun: false },
162
186
  taintRecall: (() => {
163
187
  const lr = inputs.layerRecall;
164
188
  if (!lr) {
@@ -190,6 +214,38 @@ export function buildScorecard(inputs) {
190
214
  overall: committed.independent.overall || null,
191
215
  wide: committed.independent.wide || null,
192
216
  byLanguage: committed.independent.byLanguage || null,
217
+ // FR-904: "rule authors cannot optimize against the full scored
218
+ // population" — bench/independent/runner.mjs's T0.7 already
219
+ // computes a deterministic (id-hashed) held-out slice, scored
220
+ // separately and never tuned against, but the published scorecard
221
+ // used to report only the merged `overall` figures, so the one
222
+ // number this requirement is actually about never reached a
223
+ // release artifact anyone reads. Passed through unmodified —
224
+ // absent (null) on any committed RESULT.json predating T0.7.
225
+ heldOut: committed.independent.heldOut || null,
226
+ development: committed.independent.development || null,
227
+ }
228
+ : null,
229
+ // FR-905: "publish false-positive adjudication and coverage
230
+ // methodology." Three of the four named categories were already
231
+ // published (unsupported cases via population.unscored above; the
232
+ // qualitative FP/unlabeled-output methodology in
233
+ // bench/independent/README.md's "Honest limits" section) — this is
234
+ // the fourth: WHY a false negative is a false negative, broken down
235
+ // by mechanism (bench/independent/why-missed.mjs). Read from a
236
+ // committed file for the same reason `independent` above is — the
237
+ // full population's diagnostic run is measured in minutes, far too
238
+ // long to sit inside `npm run scorecard`. Absent (null) until
239
+ // why-missed.mjs has been run at least once and its summary
240
+ // committed.
241
+ whyMissed: committed.whyMissed
242
+ ? {
243
+ source: 'bench/independent/why-missed-summary.json',
244
+ measuredAt: committed.whyMissed.measuredAt || null,
245
+ scope: committed.whyMissed.scope || null,
246
+ total: committed.whyMissed.total ?? null,
247
+ skipped: committed.whyMissed.skipped ?? null,
248
+ byBucket: committed.whyMissed.byBucket || null,
193
249
  }
194
250
  : null,
195
251
  proofCorpus: proof
@@ -213,6 +269,28 @@ export function buildScorecard(inputs) {
213
269
  };
214
270
  }
215
271
 
272
+ // PRD Success Metrics: "Provenance coverage >=95% complete or uncommitted
273
+ // for P0-supported findings in full Git clones." P0-supported scope per
274
+ // the PRD's own Release Scope table: code (SAST), secrets, IaC/config,
275
+ // direct dependency findings. Secrets now get real origin resolution
276
+ // (Task 11 -- `engine.js` calls `annotateGitProvenance` on `scan.secrets`
277
+ // with a real per-pattern-backfilled stableId, the same as SAST findings),
278
+ // so this metric no longer has a structural reason to read lower for the
279
+ // secrets share of the denominator than for any other P0-scoped channel.
280
+ export function computeProvenanceCoverage(scan) {
281
+ const p0Findings = [
282
+ ...(scan.findings || []),
283
+ ...(scan.secrets || []),
284
+ ...(scan.supplyChain || []).filter((s) => s.type === 'vulnerable_dep' && s.isDirect),
285
+ ];
286
+ const d = p0Findings.length;
287
+ const n = p0Findings.filter((f) => {
288
+ const status = f.findingProvenance?.status;
289
+ return status === 'complete' || status === 'uncommitted';
290
+ }).length;
291
+ return { n, d };
292
+ }
293
+
216
294
  function rateRow(r) {
217
295
  return `| ${r.key} | ${r.entries} | ${formatRate(r.detection.n, r.detection.d)} | ${formatRate(r.silence.n, r.silence.d)} |`;
218
296
  }
@@ -241,6 +319,12 @@ export function renderScorecardMarkdown(m) {
241
319
  }
242
320
  L.push(`| Node | ${p.nodeVersion || 'unknown'} |`);
243
321
  L.push(`| Corpus entries | ${c.totalEntries} (${c.scoredEntries} scored) |`);
322
+ // FR-901: "Published results identify engine version, corpus version,
323
+ // commit, scope, and date" — corpusVersion (a content hash, independent
324
+ // of the engine's own commit) and scope (what was actually measured)
325
+ // close the two named fields the rows above didn't already cover.
326
+ if (p.corpusVersion) L.push(`| Corpus version | \`${p.corpusVersion}\` |`);
327
+ if (p.scope) L.push(`| Scope | ${p.scope} |`);
244
328
  L.push(`| ${TIMESTAMP_MARKER} | ${p.generatedAt || 'unknown'} |`);
245
329
  L.push('');
246
330
  L.push('## What these numbers are, and what they are not');
@@ -428,6 +512,24 @@ export function renderScorecardMarkdown(m) {
428
512
  }
429
513
  L.push('Per-file counts are in `docs/scorecard.json`.');
430
514
  L.push('');
515
+ if (m.provenanceCoverage && m.provenanceCoverage.measuredThisRun) {
516
+ L.push('## Provenance coverage');
517
+ L.push('');
518
+ L.push('PRD Success Metric: **>=95% of P0-scoped findings (SAST + secrets + direct**');
519
+ L.push('**dependency findings) resolve to `complete` or `uncommitted` git provenance**');
520
+ L.push('in a full (non-shallow) clone. Transitive dependency findings are excluded —');
521
+ L.push('the PRD\'s Release Scope table names direct dependency findings only.');
522
+ L.push('');
523
+ L.push('| P0-scoped findings — complete/uncommitted provenance |');
524
+ L.push('| --- |');
525
+ L.push(`| ${formatRate(m.provenanceCoverage.n, m.provenanceCoverage.d)} |`);
526
+ L.push('');
527
+ L.push('Secrets, SAST, and direct-dependency findings all resolve through the same');
528
+ L.push('git-origin resolution pipeline, so a gap in this rate reflects the clone');
529
+ L.push('itself (shallow history, uncommitted lines the pipeline could not blame) —');
530
+ L.push('not a channel this measurement structurally cannot yet cover.');
531
+ L.push('');
532
+ }
431
533
  // PRD F12.6 — the honest scorecard publishes the LIMITS too, not only the
432
534
  // rates. Three claims this project makes are only meaningful with their
433
535
  // caveat attached, and each caveat was invisible before this section:
@@ -532,6 +634,57 @@ export function renderScorecardMarkdown(m) {
532
634
  L.push('in this document**, and publishing it is the point of the exercise. The figure');
533
635
  L.push('went DOWN when the benchmark was corrected, and is published that way.');
534
636
  L.push('');
637
+ // FR-904 (assurance-hardening PRD): "rule authors cannot optimize
638
+ // against the full scored population." T0.7's held-out slice is a
639
+ // no-op section (silently omitted) on a RESULT.json predating it —
640
+ // never a fabricated 0/0 row pretending to be data.
641
+ if (ind.heldOut && ind.development) {
642
+ L.push('### Held-out slice — never tuned against');
643
+ L.push('');
644
+ L.push('`bench/independent/runner.mjs` splits the population by a deterministic hash of');
645
+ L.push('each entry\'s id (T0.7) — a fixed 20% held-out slice, stable across runs and');
646
+ L.push('population growth, that detector development never sees scored results for.');
647
+ L.push('This is the number that answers whether the figures above reflect genuine');
648
+ L.push('accuracy or tuning against the population being measured.');
649
+ L.push('');
650
+ L.push('| | Held-out (never tuned against) | Development |');
651
+ L.push('| --- | --- | --- |');
652
+ L.push(`| Entries | ${ind.heldOut.entries} | ${ind.development.entries} |`);
653
+ L.push(`| Precision | ${formatRate(ind.heldOut.localized?.precision?.n, ind.heldOut.localized?.precision?.d)} | ${formatRate(ind.development.localized?.precision?.n, ind.development.localized?.precision?.d)} |`);
654
+ L.push(`| Recall | ${formatRate(ind.heldOut.localized?.recall?.n, ind.heldOut.localized?.recall?.d)} | ${formatRate(ind.development.localized?.recall?.n, ind.development.localized?.recall?.d)} |`);
655
+ const heldF1 = ind.heldOut.localized?.f1;
656
+ const devF1 = ind.development.localized?.f1;
657
+ L.push(`| F1 | ${heldF1 === null || heldF1 === undefined ? 'n/a' : heldF1.toFixed(3)} | ${devF1 === null || devF1 === undefined ? 'n/a' : devF1.toFixed(3)} |`);
658
+ L.push('');
659
+ }
660
+ // FR-905: the 4th named category ("missed findings" methodology) —
661
+ // WHY a false negative is a false negative, broken down by mechanism.
662
+ // Omitted entirely (not a fabricated zero row) until why-missed.mjs has
663
+ // been run and its summary committed.
664
+ const wm = m.committedInputs.whyMissed;
665
+ if (wm) {
666
+ L.push('### Missed findings — why, not just how many');
667
+ L.push('');
668
+ L.push(`**Measured ${wm.measuredAt}** (*committed artifact*, \`${wm.source}\`) — ` +
669
+ `${wm.total} false negative(s) diagnosed${wm.skipped ? `, ${wm.skipped} skipped (not fetched)` : ''}.`);
670
+ L.push('');
671
+ L.push('Each is classified into exactly one mechanism: does something fire and get');
672
+ L.push('suppressed (by an ignore pragma, a sanitizer, a custom rule, or the');
673
+ L.push('guard-recognition window), does a finding land on the wrong file or CWE, or');
674
+ L.push('does nothing fire at all. This is the difference the raw recall number above');
675
+ L.push('cannot show by itself — "this shape does not occur in these real advisories"');
676
+ L.push('and "a real detection was masked downstream" look identical as one number and');
677
+ L.push('very different once broken down this way.');
678
+ L.push('');
679
+ if (wm.byBucket && Object.keys(wm.byBucket).length) {
680
+ L.push('| Mechanism | Count |');
681
+ L.push('| --- | --- |');
682
+ for (const [bucket, count] of Object.entries(wm.byBucket).sort((a, b) => b[1] - a[1])) {
683
+ L.push(`| ${bucket} | ${count} |`);
684
+ }
685
+ L.push('');
686
+ }
687
+ }
535
688
  }
536
689
  L.push('## Committed artifacts referenced (not re-run by this command)');
537
690
  L.push('');
@@ -565,6 +718,8 @@ export function renderScorecardMarkdown(m) {
565
718
  L.push('| Corpus drift gate | `npm run bench:cve-replay:check` |');
566
719
  L.push('| Self-scan counts | `node bench/self-scan/measure.mjs --json` |');
567
720
  L.push('| Self-scan drift gate | `npm run bench:self-scan:check` |');
721
+ L.push('| Independent population (read, not re-run — ~32 minutes) | `npm run bench:independent` |');
722
+ L.push('| Missed-findings mechanism breakdown (read, not re-run) | `npm run bench:independent:why-missed -- --all` |');
568
723
  L.push('| This whole document | `npm run scorecard` |');
569
724
  L.push('');
570
725
  L.push('Running `npm run scorecard` twice on an unchanged tree produces an');