@clear-capabilities/agentic-security-scanner 0.136.2 → 0.136.9

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 (107) hide show
  1. package/CHANGELOG.md +236 -0
  2. package/bin/agentic-security.js +186 -37
  3. package/dist/113.index.js +13 -4
  4. package/dist/178.index.js +1 -1
  5. package/dist/207.index.js +5 -4
  6. package/dist/238.index.js +1 -1
  7. package/dist/317.index.js +36 -6
  8. package/dist/384.index.js +1 -1
  9. package/dist/435.index.js +183 -14
  10. package/dist/444.index.js +20 -11
  11. package/dist/449.index.js +8 -1
  12. package/dist/526.index.js +3 -3
  13. package/dist/637.index.js +1 -1
  14. package/dist/agentic-security.mjs +14 -14
  15. package/dist/agentic-security.mjs.sha256 +1 -1
  16. package/dist/compliance-frameworks/nist-privacy-1-1.json +2 -2
  17. package/dist/compliance-frameworks/owasp-asvs-5.json +1 -1
  18. package/package.json +18 -10
  19. package/src/dataflow/CLAUDE.md +10 -4
  20. package/src/dataflow/builtin-summaries.js +1 -1
  21. package/src/dataflow/engine.js +324 -60
  22. package/src/dataflow/implicit-flow.js +68 -36
  23. package/src/dataflow/incremental.js +18 -3
  24. package/src/dataflow/index.js +2 -1
  25. package/src/dataflow/proven-clean.js +41 -0
  26. package/src/dataflow/sanitizer-gate.js +35 -9
  27. package/src/dataflow/sanitizer-proof.js +21 -3
  28. package/src/dataflow/stub-aware-filter.js +36 -13
  29. package/src/dataflow/summaries.js +21 -2
  30. package/src/engine.js +202 -42
  31. package/src/ir/CLAUDE.md +4 -1
  32. package/src/ir/balanced-call.js +55 -0
  33. package/src/ir/parser-cs.js +62 -9
  34. package/src/ir/parser-go.js +29 -11
  35. package/src/ir/parser-java.js +96 -19
  36. package/src/ir/parser-js.js +151 -20
  37. package/src/ir/parser-php.js +44 -9
  38. package/src/ir/parser-rb.js +37 -7
  39. package/src/ir/ssa.js +6 -1
  40. package/src/lsp/server.js +28 -2
  41. package/src/mcp/CLAUDE.md +9 -2
  42. package/src/mcp/redact.js +26 -0
  43. package/src/mcp/tools.js +155 -14
  44. package/src/posture/CLAUDE.md +19 -7
  45. package/src/posture/accuracy-scorecard.js +9 -1
  46. package/src/posture/aibom.js +12 -8
  47. package/src/posture/auditor-walkthrough.js +102 -3
  48. package/src/posture/autopilot.js +8 -1
  49. package/src/posture/calibration-drift.js +11 -5
  50. package/src/posture/calibration.js +24 -2
  51. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +2 -2
  52. package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
  53. package/src/posture/compliance-policy.js +33 -1
  54. package/src/posture/confidence.js +44 -10
  55. package/src/posture/corpus-enroll.js +9 -5
  56. package/src/posture/corpus-match.js +19 -0
  57. package/src/posture/csharp-analysis.js +62 -3
  58. package/src/posture/deploy-platform.js +4 -1
  59. package/src/posture/drift.js +7 -1
  60. package/src/posture/epss.js +13 -1
  61. package/src/posture/evidence-bundle.js +36 -6
  62. package/src/posture/exploitability-probability.js +13 -1
  63. package/src/posture/falsification.js +23 -2
  64. package/src/posture/fix-metrics.js +1 -1
  65. package/src/posture/fix-verify-loop.js +10 -1
  66. package/src/posture/iac-reachability.js +14 -8
  67. package/src/posture/integrity.js +25 -7
  68. package/src/posture/model-rescan.js +65 -0
  69. package/src/posture/mttr.js +5 -0
  70. package/src/posture/poc-inprocess.js +27 -8
  71. package/src/posture/regression-test-gen.js +23 -8
  72. package/src/posture/reverse-blast-radius.js +5 -1
  73. package/src/posture/risk-dollars.js +18 -1
  74. package/src/posture/secret-history.js +20 -11
  75. package/src/posture/security-trend.js +7 -1
  76. package/src/posture/stack-playbook.js +22 -1
  77. package/src/posture/threat-model-grounding.js +2 -2
  78. package/src/posture/validator-metrics.js +10 -3
  79. package/src/posture/verifier.js +32 -57
  80. package/src/report/index.js +183 -14
  81. package/src/runScan.js +1 -1
  82. package/src/sast/_comment-strip.js +15 -4
  83. package/src/sast/_secret-entropy.js +1 -1
  84. package/src/sast/authz.js +6 -4
  85. package/src/sast/bench-shape/index.js +2 -7
  86. package/src/sast/claude-md-prompt-injection.js +14 -3
  87. package/src/sast/cloud-iam.js +60 -7
  88. package/src/sast/cpp-bench-extras.js +1 -1
  89. package/src/sast/csrf.js +7 -5
  90. package/src/sast/env-hygiene.js +5 -2
  91. package/src/sast/iac-terraform.js +25 -0
  92. package/src/sast/java-bench-extras.js +1 -1
  93. package/src/sast/java-constant-fold.js +5 -5
  94. package/src/sast/llm-owasp.js +4 -2
  95. package/src/sast/mcp-audit.js +7 -0
  96. package/src/sast/pipeline.js +8 -0
  97. package/src/sast/prompt-template.js +8 -6
  98. package/src/sast/prototype-pollution.js +6 -2
  99. package/src/sast/redos-nfa.js +6 -6
  100. package/src/sast/secret-concat.js +13 -2
  101. package/src/sast/ssrf-cloud-metadata.js +6 -3
  102. package/src/sast/xss-reflected-multilang.js +1 -1
  103. package/src/sast/xxe.js +1 -1
  104. package/src/sca/CLAUDE.md +3 -4
  105. package/src/sca/container.js +35 -3
  106. package/src/sca/dep-confusion.js +7 -0
  107. package/src/sca/sarif-ingest.js +0 -187
@@ -21,8 +21,21 @@
21
21
  // failure (heredocs, multi-line strings can confuse the regex parser).
22
22
 
23
23
  import * as crypto from 'node:crypto';
24
+ import { callSitesFromCfg } from './call-sites.js';
25
+ import { matchBalancedCall } from './balanced-call.js';
24
26
 
25
- const DEF_RE = /(?:^|\n)\s*def\s+(?:self\.)?(\w+[?!=]?)\s*(?:\(([^)]*)\))?/g;
27
+ // `[ \t]*` before the optional parameter list, NOT `\s*`.
28
+ //
29
+ // `\s*` crosses newlines, so for `def show\n c = params[:c]` the match ran to
30
+ // the next line's indentation. `parseRubyFile` then computes the body start as
31
+ // `indexOf('\n', m.index + m[0].length)`, which landed on the newline at the END
32
+ // of the first statement — so the body was sliced from after it and statement 1
33
+ // of EVERY Ruby method was silently discarded (a single-statement body became
34
+ // empty). In a Rails controller that first statement is almost always the
35
+ // `params` read, i.e. the taint source, which is why `bench/layer-recall`
36
+ // measured Ruby at 0/20 IR-TAINT recall while all 20 corpus entries passed on
37
+ // the regex layer. A parameter list on the same line still matches.
38
+ const DEF_RE = /(?:^|\n)[ \t]*def\s+(?:self\.)?(\w+[?!=]?)[ \t]*(?:\(([^)]*)\))?/g;
26
39
 
27
40
  function _extractRubyBody(src, defEnd) {
28
41
  let depth = 1;
@@ -110,10 +123,15 @@ function _lowerExpr(text) {
110
123
  if (/^(true|false|nil)\b/.test(s)) return { kind: 'literal', value: s };
111
124
  // Symbol
112
125
  if (/^:\w+/.test(s)) return { kind: 'literal', value: s };
113
- // Call: obj.method(args) or method(args)
114
- const callMatch = s.match(/^([\w.]+)\s*\((.*)\)\s*$/s);
126
+ // Call: obj.method(args) or method(args). matchBalancedCall finds the
127
+ // paren that actually balances the FIRST '(' — not the greedy-to-end-of-
128
+ // string match the old `/\((.*)\)\s*$/` used, which corrupted the
129
+ // argument text for a chained call (`sanitize(x).strip` produced
130
+ // args="x).strip", which then fell through to {kind:'unknown'} and
131
+ // silently dropped x).
132
+ const callMatch = matchBalancedCall(s, /^([\w.]+)/);
115
133
  if (callMatch) {
116
- return { kind: 'call', callee: callMatch[1], args: _splitTopLevelCommas(callMatch[2]).map(_lowerExpr) };
134
+ return { kind: 'call', callee: callMatch.callee, args: _splitTopLevelCommas(callMatch.argsText).map(_lowerExpr) };
117
135
  }
118
136
  // Method call without parens is very common in Ruby but hard to detect
119
137
  // reliably with regex. We handle the explicit-paren form above.
@@ -179,9 +197,9 @@ function _lowerStmt(stmt, line) {
179
197
  return { kind: 'assign', line, target: assign[1], source: _lowerExpr(assign[2]) };
180
198
  }
181
199
  // Statement-form call with parens
182
- const call = s.match(/^([\w.]+)\s*\((.*)\)\s*$/s);
200
+ const call = matchBalancedCall(s, /^([\w.]+)/);
183
201
  if (call) {
184
- return { kind: 'call', line, callee: call[1], args: _splitTopLevelCommas(call[2]).map(_lowerExpr) };
202
+ return { kind: 'call', line, callee: call.callee, args: _splitTopLevelCommas(call.argsText).map(_lowerExpr) };
185
203
  }
186
204
  // Statement-form call without parens (common Ruby idiom): redirect_to expr
187
205
  const bareCall = s.match(/^([a-z_]\w*)\s+(.+)$/s);
@@ -298,10 +316,22 @@ export function parseRubyFile(file, code) {
298
316
  const exit = _addNode(nodes, { kind: 'exit', line: startLine });
299
317
  const tail = _buildCfg(extracted.body, nodes, entry, startLine + 1);
300
318
  _linkNodes(nodes, tail, exit);
319
+ const cfg = { entry, exit, nodes };
301
320
  functions.push({
302
321
  qid: _qid(file, name, startLine, extracted.body),
303
322
  name, line: startLine, params, file,
304
- cfg: { entry, exit, nodes },
323
+ cfg,
324
+ // Ruby never emitted `fn.calls` at all (ir/CLAUDE.md documents this
325
+ // as a known gap) — tabulation.js, dataflow/index.js and
326
+ // callgraph.js all read it to build call edges, so an absent array
327
+ // is indistinguishable from "calls nothing," disabling ALL Ruby
328
+ // interprocedural taint. call-sites.js's callSitesFromCfg is the
329
+ // same language-agnostic CFG walk parser-py-cst.js already uses for
330
+ // exactly this; Ruby's node shapes ('call' with callee/args,
331
+ // 'assign' with source, 'return'/'throw' with value, 'if' with
332
+ // cond) match its documented contract already, so no new lowering
333
+ // logic is needed here — only wiring the call.
334
+ calls: callSitesFromCfg(cfg),
305
335
  });
306
336
  DEF_RE.lastIndex = extracted.end;
307
337
  }
package/src/ir/ssa.js CHANGED
@@ -43,7 +43,12 @@ export function isSSAEnabled() {
43
43
  *
44
44
  * Returns Map<nodeId, Set<nodeId>> — dom[n] = set of nodes that dominate n.
45
45
  */
46
- function computeDominators(cfg) {
46
+ // Exported (Stage 6 correctness audit) so dataflow/implicit-flow.js can
47
+ // reuse this tested algorithm to correctly scope "is node N genuinely
48
+ // inside branch B" (N is inside iff B dominates N — every path to N passes
49
+ // through B) instead of a path-dependent DFS depth counter that had no way
50
+ // to detect a branch's join point.
51
+ export function computeDominators(cfg) {
47
52
  const nodes = Object.keys(cfg.nodes || {});
48
53
  const entry = cfg.entry;
49
54
  const dom = new Map();
package/src/lsp/server.js CHANGED
@@ -18,6 +18,8 @@ import * as path from 'node:path';
18
18
  import * as readline from 'node:readline';
19
19
  import { runScan } from '../runScan.js';
20
20
  import { resetCustomRulesBudget } from '../posture/custom-rules.js';
21
+ import { redactFinding } from '../mcp/redact.js';
22
+ import { _remediationOf } from '../report/index.js';
21
23
 
22
24
  const PROTOCOL_VERSION = '3.17';
23
25
  const SERVER_NAME = 'agentic-security-lsp';
@@ -52,6 +54,13 @@ function sevToLsp(sev) {
52
54
 
53
55
  function findingToDiagnostic(f) {
54
56
  const line = Math.max(0, (f.line || 1) - 1);
57
+ // Stage 6 correctness audit: this read f.remediation directly, but raw
58
+ // scan.findings entries (what this consumes, pre-normalizeFindings) come
59
+ // from two conventions — most posture/*.js and newer sast/*.js modules
60
+ // set `remediation`, while ~127 of engine.js's own detectors set a `fix`
61
+ // STRING field instead. _remediationOf carries the same precedence
62
+ // report/index.js already established for this exact split (CMP-3).
63
+ const remediation = _remediationOf(f);
55
64
  return {
56
65
  range: {
57
66
  start: { line, character: 0 },
@@ -60,7 +69,7 @@ function findingToDiagnostic(f) {
60
69
  severity: sevToLsp(f.severity),
61
70
  source: 'agentic-security',
62
71
  code: f.cwe || f.family || 'finding',
63
- message: `${f.vuln || 'Security finding'}${f.remediation ? '\n\n' + (typeof f.remediation === 'string' ? f.remediation : '') : ''}`.slice(0, 2000),
72
+ message: `${f.vuln || 'Security finding'}${remediation ? '\n\n' + remediation : ''}`.slice(0, 2000),
64
73
  tags: [],
65
74
  };
66
75
  }
@@ -142,7 +151,20 @@ async function scanFile(uri) {
142
151
  // and eventually start skipping custom rules.
143
152
  resetCustomRulesBudget(_rootDir);
144
153
  const { scan } = await runScan(_rootDir, { fileContents, depFileContents });
145
- const findings = (scan.findings || []).filter(f => f.file === rel);
154
+ // Stage 6 correctness audit: this only ever read scan.findings (the SAST
155
+ // channel). scan.secrets and scan.logicVulns are separate arrays on the
156
+ // raw runScan() result — normalizeFindings is what merges all four
157
+ // channels, and that hasn't run here — so a saved file with a hardcoded
158
+ // credential got a clean problem pane, no diagnostic at all. Unlike the
159
+ // MCP surface, this server never applied redactFinding either (nothing
160
+ // here imported mcp/redact.js), which would have been a landmine the
161
+ // moment secrets/logicVulns were added without it: those channels are
162
+ // exactly where raw secret material shows up in `snippet`. Both fixed
163
+ // together — merge the channels AND redact — so the fix for one gap
164
+ // doesn't open the other.
165
+ const findings = [...(scan.findings || []), ...(scan.secrets || []), ...(scan.logicVulns || [])]
166
+ .filter(f => f.file === rel)
167
+ .map(f => redactFinding(f));
146
168
  await publishDiagnostics(uri, findings);
147
169
  } catch (e) {
148
170
  process.stderr.write(`agentic-security-lsp: scan failed: ${e.message}\n`);
@@ -273,3 +295,7 @@ export function startLspServer() {
273
295
  if (import.meta.url === `file://${process.argv[1]}`) {
274
296
  startLspServer();
275
297
  }
298
+
299
+ function _setRootDir(dir) { _rootDir = dir; _depCache = { rootDir: null, depFileContents: {} }; }
300
+
301
+ export const _internals = { findingToDiagnostic, scanFile, uriToPath, pathToUri, _diagnosticsByUri, _setRootDir };
package/src/mcp/CLAUDE.md CHANGED
@@ -12,14 +12,21 @@ MCP server. JSON-RPC 2.0 over NDJSON on stdin/stdout. Bin entry `../../bin/agent
12
12
  | `find_rule_module` | ✓ | reads `scanner/src/{sast,posture}/` to answer "which file detects CWE-X / family Y" |
13
13
  | `lookup_cve` | ✓ | reads local OSV / KEV / EPSS cache; staleness-tiered |
14
14
  | `synthesize_fix` | ✓ | reads last-scan; returns the patch text |
15
- | `verify_fix` | | re-scans patched files in memory + runs lint; no writes |
15
+ | `verify_fix` | | re-scans patched files in memory, runs lint + the project test suite + the fix-honesty gate + PoC re-check; does not touch the target project's own files, but appends a record to `.agentic-security/fix-metrics.jsonl` per attempt |
16
16
  | `apply_fix` | ✗ | writes via `posture/fix-history.js` (with backup) |
17
17
  | `append_scratchpad` | ✗ | writes under `.agentic-security/agent-scratchpad/<agent>/<session>/` only |
18
18
  | `read_scratchpad` | ✓ | paginated read of scratchpad files |
19
19
  | `append_agents_memory` | ✗ | appends to `.agentic-security/AGENTS.md` continual-learning file |
20
20
  | `read_agents_memory` | ✓ | tail of `.agentic-security/AGENTS.md` |
21
+ | `synthesize_sca_upgrade` | ✓ | runs an ecosystem dry-run (`npm install --dry-run` etc.); returns the upgrade plan; no writes |
22
+ | `apply_sca_upgrade` | ✗ | backs up manifests, runs the package manager, runs the project test command, restores manifests on test failure |
23
+ | `query_triage_memory` | ✓ | reads past triage decisions (wont-fix/false-positive) by natural-language query |
24
+ | `query_findings_memory` | ✓ | reads accumulated scan memory (findings + triage history + AGENTS.md) by natural-language query |
25
+ | `query_cache_telemetry` | ✓ | reads prompt-cache economics from the current session transcript; no network |
21
26
 
22
- `apply_fix` is the only write tool. It requires `confirm:true` AND the last-scan HMAC to verify AND the target path not on the reserved-write list.
27
+ **17 tools, not 12** this table previously stopped at 12 and the count quoted elsewhere (root `CLAUDE.md`, the non-Claude plugin manifests) said "Six." Re-derive with `grep -c "name: '" scanner/src/mcp/tools.js` rather than trusting a hardcoded number here again.
28
+
29
+ **Two write tools, not one.** `apply_fix` and `apply_sca_upgrade` both write; `verify_fix` also writes (see its row above) though not to the target project's own files. `apply_fix` additionally requires `confirm:true` AND the last-scan HMAC to verify AND the target path not on the reserved-write list; `apply_sca_upgrade` requires `confirm:true` and gates on its own test-restore cycle.
23
30
 
24
31
  ## Hardening posture (OWASP MCP Top 10)
25
32
 
package/src/mcp/redact.js CHANGED
@@ -27,6 +27,32 @@ const PATTERNS = [
27
27
  [/rk_(?:live|test)_[A-Za-z0-9]{20,}/g, 'stripe-restricted-key'],
28
28
  [/SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/g, 'sendgrid-key'],
29
29
  [/AIza[0-9A-Za-z_-]{35}/g, 'google-api-key'],
30
+ // Stage 4 correctness audit (coverage breadth, AI security): this list
31
+ // only covered a small subset of what the scanner's OWN credential
32
+ // detector (engine.js's CREDENTIAL_PATTERNS, 40+ provider shapes) finds
33
+ // — a Shopify/Telegram/Twilio/Discord-webhook/Square/Google-OAuth/JDBC
34
+ // secret detected and reported by a scan reached explain_finding's
35
+ // output completely unredacted, because none of those shapes were in
36
+ // THIS separate, narrower list. Reusing the same regex bodies as
37
+ // engine.js's CREDENTIAL_PATTERNS for the shapes verified to leak
38
+ // (rather than importing engine.js itself, which would pull its entire
39
+ // multi-thousand-line module graph into the MCP server's dependency
40
+ // surface for a handful of consts).
41
+ [/ya29\.[0-9A-Za-z_-]{20,}/g, 'google-oauth-token'],
42
+ [/shp(?:at|ss|ca|pa)_[a-fA-F0-9]{32}/g, 'shopify-token'],
43
+ [/(?<![0-9])[0-9]{8,10}:AA[0-9A-Za-z_-]{33}(?![A-Za-z0-9_])/g, 'telegram-bot-token'],
44
+ [/twilio.{0,20}SK[0-9a-fA-F]{32}/gi, 'twilio-api-key'],
45
+ [/sq0atp-[0-9A-Za-z_-]{22}/g, 'square-access-token'],
46
+ [/sq0csp-[0-9A-Za-z_-]{43}/g, 'square-oauth-secret'],
47
+ [/access_token\$production\$[0-9a-z]{16}\$[0-9a-f]{32}/g, 'paypal-braintree-token'],
48
+ [/https:\/\/(?:discordapp|discord)\.com\/api\/webhooks\/[0-9]+\/[A-Za-z0-9_-]+/g, 'discord-webhook'],
49
+ [/https:\/\/hooks\.slack\.com\/services\/T[a-zA-Z0-9_]{8}\/B[a-zA-Z0-9_]{8,12}\/[a-zA-Z0-9_]{24}/g, 'slack-webhook'],
50
+ [/https:\/\/outlook\.office\.com\/webhook\/[A-Za-z0-9\-@]+\/IncomingWebhook\/[A-Za-z0-9-]+\/[A-Za-z0-9-]+/g, 'teams-webhook'],
51
+ [/https:\/\/(?:www\.)?hooks\.zapier\.com\/hooks\/catch\/[A-Za-z0-9]+\/[A-Za-z0-9]+\//g, 'zapier-webhook'],
52
+ // JDBC connection string carrying a password: only redact when password
53
+ // evidence is actually on the line (matches engine.js's own ctx gate),
54
+ // so a credential-free JDBC URL in docs isn't needlessly mangled.
55
+ [/jdbc:[a-z:]+:\/\/[A-Za-z0-9.\-_:;=/@?,&]*(?:@|password=|passwd=|pwd=)[A-Za-z0-9.\-_:;=/@?,&]*/gi, 'jdbc-connection-string'],
30
56
  // JWT — three dot-separated b64url segments starting with eyJ
31
57
  [/eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, 'jwt'],
32
58
  // PEM-encoded private keys
package/src/mcp/tools.js CHANGED
@@ -21,6 +21,7 @@ import { synthesizeDeterministicPatch } from '../posture/deterministic-fix.js';
21
21
  import { verifyLastScan } from '../posture/integrity.js';
22
22
  import { analyzeTranscript, formatCacheReport, renderCacheStatusLine } from '../posture/cache-economics.js';
23
23
  import { redactString, redactFinding } from './redact.js';
24
+ import { _remediationOf } from '../report/index.js';
24
25
 
25
26
  // Lazy-loaded: these transitively pull in npm packages (@babel/core and
26
27
  // friends) that aren't available in the plugin-cache install path
@@ -152,8 +153,13 @@ function _validateScratchpadPath(relPath) {
152
153
  return { ok: true, agent, session, fileParts };
153
154
  }
154
155
 
156
+ // Routes through the same lstat+realpath confinement every other write/
157
+ // path-taking tool uses (OWASP MCP05) — a lexical prefix/charset check
158
+ // alone doesn't stop a pre-planted symlink at any path component from
159
+ // relocating the write/read outside the session root. Throws on escape;
160
+ // callers must catch (see append_scratchpad / read_scratchpad).
155
161
  function _scratchpadAbs(sessionRoot, relPath) {
156
- return path.resolve(sessionRoot, relPath.replace(/\\/g, '/'));
162
+ return _confine(sessionRoot, relPath.replace(/\\/g, '/'), 'scratchpad path');
157
163
  }
158
164
 
159
165
  function _scratchpadTotalBytes(sessionRoot) {
@@ -337,12 +343,22 @@ export const scan_diff = {
337
343
  const wantSet = new Set(Object.keys(fileContents));
338
344
  const sevRank = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
339
345
  const min = sevRank[severity] ?? 0;
340
- const findings = (result.scan.findings || [])
346
+ // Stage 6 correctness audit: this only ever read result.scan.findings
347
+ // (the SAST channel) — scan.secrets and scan.logicVulns are separate
348
+ // arrays on the raw runScan() result (report/index.js's normalizeFindings
349
+ // is what merges all four channels, and that merge hasn't run yet here).
350
+ // A file containing a bare hardcoded credential reported findingCount: 0
351
+ // through a tool whose own description promises "Use BEFORE writing a
352
+ // Write/Edit to disk so the agent can self-correct". Also reused
353
+ // _remediationOf so a fix-string detector (the majority of engine.js's
354
+ // own, ~127 call sites) doesn't silently report an empty `description`
355
+ // the way reading only `.remediation` did.
356
+ const findings = [...(result.scan.findings || []), ...(result.scan.secrets || []), ...(result.scan.logicVulns || [])]
341
357
  .filter(f => wantSet.has(String(f.file || '').replace(/\\/g, '/')) && (sevRank[f.severity] ?? 0) >= min)
342
358
  .map(f => redactFinding({
343
359
  id: f.id, severity: f.severity, file: f.file, line: f.line,
344
360
  title: f.title || f.vuln, cwe: f.cwe,
345
- description: f.description, remediation: f.remediation,
361
+ description: f.description, remediation: _remediationOf(f),
346
362
  }));
347
363
  // Harness-anatomy #1: offload when the result exceeds OFFLOAD_THRESHOLD.
348
364
  // The agent gets a head+tail preview plus a path it can page through;
@@ -505,10 +521,40 @@ export const apply_fix = {
505
521
  additionalProperties: { type: 'string', maxLength: 500_000 },
506
522
  minProperties: 1, maxProperties: 8,
507
523
  },
524
+ // Stage 6 correctness audit: same gap and same fix as verify_fix — the
525
+ // honesty gate is reachable but was never wired to any real caller.
526
+ // Here it's stronger than advisory: the inline re-verify below already
527
+ // gates the WRITE on `verdict.ok`, and verifyFixCore's own `ok`
528
+ // formula already folds in `honesty.ok` when fixMeta is supplied — so
529
+ // passing it through here makes a dishonest fixMeta (hand-wave
530
+ // residual, uncited false-positive verdict) block the write itself,
531
+ // not just report a verdict.
532
+ fixMeta: {
533
+ type: 'object',
534
+ additionalProperties: false,
535
+ properties: {
536
+ residual: { type: 'string', maxLength: 2000 },
537
+ verdict: { type: 'string', maxLength: 64 },
538
+ evidence: { type: 'array', items: { type: 'string', maxLength: 500 }, maxItems: 20 },
539
+ signals: {
540
+ type: 'object',
541
+ additionalProperties: false,
542
+ properties: {
543
+ sinkSignatureChanged: { type: 'boolean' },
544
+ allCallersRouted: { type: 'boolean' },
545
+ testDiscriminates: { type: 'boolean' },
546
+ rateLimitOnly: { type: 'boolean' },
547
+ docsOnly: { type: 'boolean' },
548
+ logOnlyNoReject: { type: 'boolean' },
549
+ partialSanitization: { type: 'boolean' },
550
+ },
551
+ },
552
+ },
553
+ },
508
554
  },
509
555
  required: ['finding_id', 'confirm'],
510
556
  },
511
- async handler({ finding_id, confirm, dry_run = false, patch = null }, ctx) {
557
+ async handler({ finding_id, confirm, dry_run = false, patch = null, fixMeta = null }, ctx) {
512
558
  if (confirm !== true) {
513
559
  return { _meta: META, applied: false, reason: 'apply_fix requires confirm: true.' };
514
560
  }
@@ -564,6 +610,7 @@ export const apply_fix = {
564
610
  scanRoot: ctx.sessionRoot,
565
611
  originalFindingStableId: f.stableId,
566
612
  files: _files,
613
+ fixMeta,
567
614
  });
568
615
  }
569
616
  } catch (e) {
@@ -573,7 +620,7 @@ export const apply_fix = {
573
620
  return {
574
621
  _meta: META, applied: false,
575
622
  reason: `patch rejected by verifier: ${verdict.summary || verdict.rescan?.reason || 'did not verify'}`,
576
- verify: { rescan: verdict.rescan, lint: { runner: verdict.lint?.runner, ok: verdict.lint?.ok } },
623
+ verify: { rescan: verdict.rescan, lint: { runner: verdict.lint?.runner, ok: verdict.lint?.ok }, honesty: verdict.honesty || null },
577
624
  };
578
625
  }
579
626
  if (dry_run) {
@@ -585,7 +632,7 @@ export const apply_fix = {
585
632
  const originalContent = fs.existsSync(v.abs) ? await fsp.readFile(v.abs, 'utf8') : '';
586
633
  const entry = await applyFixHistory({
587
634
  scanRoot: ctx.sessionRoot, file: rel, originalContent, newContent: v.content,
588
- findingId: f.id, stableId: f.stableId, ruleId: f.rule || null, vuln: f.vuln || f.title || null,
635
+ findingId: f.id, stableId: f.stableId, ruleId: f.ruleId || f.cwe || f.family || null, vuln: f.vuln || f.title || null,
589
636
  });
590
637
  written.push({ file: rel, historyId: entry.id, backupPath: entry.backupPath });
591
638
  }
@@ -643,7 +690,7 @@ export const apply_fix = {
643
690
  newContent: f.fix.replacement,
644
691
  findingId: f.id,
645
692
  stableId: f.stableId || null, // premortem 4R-8
646
- ruleId: f.rule || null,
693
+ ruleId: f.ruleId || f.cwe || f.family || null,
647
694
  vuln: f.vuln || f.title || null,
648
695
  });
649
696
  } catch (e) {
@@ -680,7 +727,7 @@ export const apply_fix = {
680
727
  // proceed with apply_fix.
681
728
  export const verify_fix = {
682
729
  name: 'verify_fix',
683
- description: 'Verify a proposed patch before applying. Re-scans the patched files in memory and runs the project linter. Returns { ok, rescan, lint, summary }. No filesystem writes.',
730
+ description: 'Verify a proposed patch before applying. Re-scans the patched files in memory, runs the project linter, runs the project test suite, checks fix honesty (FULL/MITIGATION/WORKAROUND) when fixMeta is supplied, and re-runs the PoC when one exists. Returns { ok, rescan, lint, tests, honesty, poc, summary }. Does not write to the target project’s own files, but DOES append one record per attempt to .agentic-security/fix-metrics.jsonl for the measured fix-loop.',
684
731
  inputSchema: {
685
732
  type: 'object',
686
733
  additionalProperties: false,
@@ -692,10 +739,41 @@ export const verify_fix = {
692
739
  minProperties: 1,
693
740
  maxProperties: 8,
694
741
  },
742
+ // Stage 6 correctness audit: posture/fix-honesty-gate.js's deterministic
743
+ // honesty checks (vague-assurance residual prose, unbacked false-
744
+ // positive verdicts, tier/residual consistency) were fully built and
745
+ // fix-verify.js already consulted them when given a `fixMeta` — but
746
+ // this schema never had a `fixMeta` property, so no call through the
747
+ // MCP surface could ever supply one. The gate can only run against
748
+ // claims the AGENT self-reports (residual risk, verdict, evidence,
749
+ // completeness signals) — nothing here is server-computable — so
750
+ // fixing this meant exposing the property, not inventing a lookup.
751
+ fixMeta: {
752
+ type: 'object',
753
+ additionalProperties: false,
754
+ properties: {
755
+ residual: { type: 'string', maxLength: 2000 },
756
+ verdict: { type: 'string', maxLength: 64 },
757
+ evidence: { type: 'array', items: { type: 'string', maxLength: 500 }, maxItems: 20 },
758
+ signals: {
759
+ type: 'object',
760
+ additionalProperties: false,
761
+ properties: {
762
+ sinkSignatureChanged: { type: 'boolean' },
763
+ allCallersRouted: { type: 'boolean' },
764
+ testDiscriminates: { type: 'boolean' },
765
+ rateLimitOnly: { type: 'boolean' },
766
+ docsOnly: { type: 'boolean' },
767
+ logOnlyNoReject: { type: 'boolean' },
768
+ partialSanitization: { type: 'boolean' },
769
+ },
770
+ },
771
+ },
772
+ },
695
773
  },
696
774
  required: ['stable_id', 'files'],
697
775
  },
698
- async handler({ stable_id, files }, ctx) {
776
+ async handler({ stable_id, files, fixMeta }, ctx) {
699
777
  // Confine every file path before passing to the verifier.
700
778
  const confined = {};
701
779
  for (const [relPath, content] of Object.entries(files || {})) {
@@ -707,17 +785,48 @@ export const verify_fix = {
707
785
  confined[relPath] = String(content);
708
786
  }
709
787
  try {
788
+ // The PoC-re-check leg (verifyFixCore's `pocLeg`) needs a `poc` param
789
+ // to do anything — until now nothing supplied one, so it always
790
+ // reported {status:'not-requested'} through this surface (see
791
+ // posture/CLAUDE.md's disclosure). Rather than widening inputSchema
792
+ // to make the CALLER pass PoC data back, look it up server-side: the
793
+ // scan pipeline already attaches an HTTP-shaped f.poc to matching
794
+ // findings by default (engine.js's annotatePocs), and last-scan.json
795
+ // already carries it under the same stableId this handler receives.
796
+ // Best-effort: a missing/unsigned/tampered scan just means no PoC is
797
+ // available to re-check, not a verify_fix failure — the rescan/lint/
798
+ // tests legs below are independent of this and still apply.
799
+ let poc = null;
800
+ try {
801
+ const { scan: lastScan } = _readLastScanVerified(ctx.sessionRoot, { allowUnsigned: true });
802
+ const orig = lastScan && (lastScan.findings || []).find(f => f.stableId === stable_id);
803
+ if (orig && orig.poc && orig.poc.code) poc = { ...orig.poc, finding: orig };
804
+ } catch { /* best-effort lookup; poc stays null */ }
805
+
710
806
  const verifyFixCore = await getVerifyFixCore();
711
807
  const r = await verifyFixCore({
712
808
  scanRoot: ctx.sessionRoot,
713
809
  originalFindingStableId: stable_id,
714
810
  files: confined,
811
+ poc,
812
+ fixMeta,
715
813
  });
716
814
  return {
717
815
  _meta: META,
718
816
  ok: r.ok,
719
817
  rescan: { ok: r.rescan.ok, reason: r.rescan.reason, introduced: r.rescan.introduced || [] },
720
818
  lint: { runner: r.lint.runner, ok: r.lint.ok, skipped: r.lint.skipped || false, output: redactString(r.lint.output || '').slice(0, 1500) },
819
+ // verifyFix computes five legs, not two — tests/honesty/poc were
820
+ // being silently dropped here, leaving an agent with no structured
821
+ // way to see WHY verification failed when the failure was in one
822
+ // of those three (only the free-text summary carried it).
823
+ // test-runner.js's runProjectTests never returns raw stdout/stderr,
824
+ // so no redaction is needed there; honesty.violations are static,
825
+ // code-generated strings; poc.reason is redacted defensively since
826
+ // it can echo proof-harness detail derived from scanned source.
827
+ tests: r.tests,
828
+ honesty: r.honesty,
829
+ poc: r.poc ? { ...r.poc, reason: r.poc.reason ? redactString(r.poc.reason) : r.poc.reason } : r.poc,
721
830
  summary: r.summary,
722
831
  };
723
832
  } catch (e) {
@@ -801,7 +910,13 @@ export const synthesize_fix = {
801
910
  regression_test: f.regression_test || null,
802
911
  remediation: typeof fix.description === 'string' ? fix.description : (typeof fix === 'string' ? fix : null),
803
912
  patchBounds: { touchedFiles, locDelta, oversized },
804
- recommendsFixPlan: oversized && !hasReplacement && !autofix,
913
+ // oversized can only be true when hasReplacement is true (locDelta is
914
+ // only computed in that branch, and touchedFiles never varies) — a
915
+ // `!hasReplacement` conjunct here was a structural contradiction that
916
+ // made this permanently false. The correct signal: the stored
917
+ // replacement itself is too big to trust auto-applying, and there's
918
+ // no safer deterministic alternative.
919
+ recommendsFixPlan: oversized && !autofix,
805
920
  };
806
921
  },
807
922
  };
@@ -933,7 +1048,9 @@ export const append_scratchpad = {
933
1048
  async handler({ path: relPath, content }, ctx) {
934
1049
  const v = _validateScratchpadPath(relPath);
935
1050
  if (!v.ok) return { _meta: META, ok: false, reason: v.reason };
936
- const abs = _scratchpadAbs(ctx.sessionRoot, relPath);
1051
+ let abs;
1052
+ try { abs = _scratchpadAbs(ctx.sessionRoot, relPath); }
1053
+ catch (e) { return { _meta: META, ok: false, reason: `path-escape refused: ${e.message}` }; }
937
1054
  const total = _scratchpadTotalBytes(ctx.sessionRoot);
938
1055
  if (total + content.length > SCRATCHPAD_MAX_TOTAL_BYTES) {
939
1056
  return {
@@ -979,7 +1096,9 @@ export const read_scratchpad = {
979
1096
  async handler({ path: relPath, offset, limit }, ctx) {
980
1097
  const v = _validateScratchpadPath(relPath);
981
1098
  if (!v.ok) return { _meta: META, ok: false, reason: v.reason };
982
- const abs = _scratchpadAbs(ctx.sessionRoot, relPath);
1099
+ let abs;
1100
+ try { abs = _scratchpadAbs(ctx.sessionRoot, relPath); }
1101
+ catch (e) { return { _meta: META, ok: false, reason: `path-escape refused: ${e.message}` }; }
983
1102
  if (!fs.existsSync(abs)) return { _meta: META, ok: false, reason: 'not-found' };
984
1103
  let stat;
985
1104
  try { stat = fs.statSync(abs); } catch (e) { return { _meta: META, ok: false, reason: `stat-failed: ${e.message}` }; }
@@ -1075,7 +1194,20 @@ export const query_triage_memory = {
1075
1194
  },
1076
1195
  async handler({ query }, ctx) {
1077
1196
  const { queryMemory } = await import('../posture/triage-memory.js');
1078
- const results = queryMemory(ctx.sessionRoot, query || '');
1197
+ const raw = queryMemory(ctx.sessionRoot, query || '');
1198
+ // Stage 6 correctness audit: this returned queryMemory's output
1199
+ // verbatim, with no redaction pass — every other tool that echoes
1200
+ // scanned-source-derived text redacts it (mcp/CLAUDE.md's "Adding a new
1201
+ // tool" step 3). Round-trip through redactString the same way
1202
+ // redactFinding already does for its own opaque `.trace` field: results
1203
+ // here mix shapes (a triage decision's free-text `reason`, a finding's
1204
+ // `vuln`/`family`/file path), so scrubbing the whole serialized
1205
+ // structure catches secret-shaped substrings regardless of which field
1206
+ // they landed in, rather than hardcoding a field allowlist that could
1207
+ // miss one.
1208
+ let results;
1209
+ try { results = JSON.parse(redactString(JSON.stringify(raw))); }
1210
+ catch { results = raw; }
1079
1211
  return {
1080
1212
  _meta: META,
1081
1213
  count: results.length,
@@ -1103,7 +1235,16 @@ export const query_findings_memory = {
1103
1235
  },
1104
1236
  async handler({ query }, ctx) {
1105
1237
  const { queryFindingsMemory } = await import('../posture/findings-memory.js');
1106
- return { _meta: META, ...queryFindingsMemory(ctx.sessionRoot, query || '') };
1238
+ const raw = queryFindingsMemory(ctx.sessionRoot, query || '');
1239
+ // Stage 6 correctness audit — same redaction gap and same fix as
1240
+ // query_triage_memory just above: this mixes four differently-shaped
1241
+ // result kinds (finding / triage / history / AGENTS.md text), so a
1242
+ // whole-structure redactString round-trip is applied rather than a
1243
+ // per-field allowlist that could miss one of the four shapes.
1244
+ let body;
1245
+ try { body = JSON.parse(redactString(JSON.stringify(raw))); }
1246
+ catch { body = raw; }
1247
+ return { _meta: META, ...body };
1107
1248
  },
1108
1249
  };
1109
1250
 
@@ -19,7 +19,7 @@ Annotators that run **after** every detector has emitted, plus state stores read
19
19
 
20
20
  **Production-posture ingest** — `auth-posture-import.js`, `network-policy-import.js`, `telemetry-ingest.js`, `waf-ingest.js`, `feature-flags.js`. These read customer-side YAML and convert to mitigation flags consumed by `mitigation-composite.js`.
21
21
 
22
- **Fix lifecycle** — `fix-history.js` (apply + backup + recover), `fix-verify.js` (closed-loop re-scan + lint), `fix-plan.js` (oversized-patch fallback), `regression-test-gen.js`, `deterministic-fix.js` (safe context-independent literal-swap patch synthesis — md5/sha1→sha256, TLS verify-off→on — materialized on demand by `mcp/synthesize_fix`; every patch still passes through `apply_fix`'s inline verify before it lands).
22
+ **Fix lifecycle** — `fix-history.js` (apply + backup + recover), `fix-verify.js` (**five legs, not "re-scan + lint"**: rescan + lint + the project test suite + the fix-honesty gate + a PoC re-check, and it appends one record per attempt to `.agentic-security/fix-metrics.jsonl` — see `mcp/CLAUDE.md`'s `verify_fix` row, which had the same stale "no writes" claim), `fix-plan.js` (oversized-patch fallback — **not currently wired to anything**, see the dead-module allowlist), `regression-test-gen.js`, `deterministic-fix.js` (safe context-independent literal-swap patch synthesis — md5/sha1→sha256, TLS verify-off→on — materialized on demand by `mcp/synthesize_fix`; every patch still passes through `apply_fix`'s inline verify before it lands).
23
23
 
24
24
  **Measured fix loop (R5)** — `fix-metrics.js`. `verifyFix` times each stage
25
25
  (`rescan`/`lint`/`tests`/`honesty`) and appends one record per attempt to
@@ -49,7 +49,8 @@ rather than creating a stray state dir outside a project.
49
49
  - `entrypoint-inventory.js` — attack-surface completeness ledger. Enumerates every entry point (HTTP/queue/cron/CLI/env/upload/webhook) with a disposition each; on `scan.entrypointInventory`.
50
50
  - `root-cause-sweep.js` — from confirmed findings, finds sibling instances detectors missed with total-count accounting (`found === candidates + mitigated`); on `scan.rootCauseSweep`. Searches the corpus **once per distinct sink pattern**, not once per finding — findings deriving the same pattern share one walk and one set of (read-only) match records. The counts are always exact; the materialised `instances` list is a bounded sample (`INSTANCE_SAMPLE_LIMIT`, 100) and says so via `instancesTruncated`. Both properties are load-bearing on large corpora: the per-finding walk was O(findings × corpus-bytes) and the instance records were O(findings × matches), which together exhausted a 6 GB heap on a 40k-file suite. If you touch this module, keep the own-site exclusion **per pattern group** — resolving it globally makes a group subtract an exclusion it never matched and drives counts negative.
51
51
  - `model-routing.js` — capability-based CWE/severity→model policy; stamps `finding.dispatchModel` (strongest for crypto/auth/critical, mid for injection, cheapest for low-sev hardening) for cost-sensitive subagent dispatch.
52
- - `fix-honesty-gate.js` — deterministic honesty gates on fix output: a residual-risk hand-wave guard, a cited-file:line requirement for any FP/safe verdict, and FULL/MITIGATION/WORKAROUND completeness tiers. Consumed by `fix-verify.js` when the caller supplies fix metadata; the closed-loop test leg (`fix-verify-loop.js`) is wired into `mcp/apply_fix` behind `AGENTIC_SECURITY_FIX_RUN_TESTS=1`.
52
+ - `fix-honesty-gate.js` — deterministic honesty gates on fix output: a residual-risk hand-wave guard, a cited-file:line requirement for any FP/safe verdict, and FULL/MITIGATION/WORKAROUND completeness tiers. `fix-verify.js` accepts a `fixMeta` param and consults this gate when it is present (`if (fixMeta && typeof fixMeta === 'object')`). Both `mcp/apply_fix` and `mcp/verify_fix` now expose an optional `fixMeta: {residual, verdict, evidence, signals}` input property and pass it straight through — `fixMeta` is inherently agent-self-reported (only the caller claiming a fix worked knows its own residual-risk reasoning), so the fix was exposing the property, not computing anything server-side. On `apply_fix`'s patch path this is stronger than advisory: `verifyFixCore`'s own `ok` formula already folds in `honesty.ok`, and the inline re-verify already gates the write on `ok` — so a hand-wave residual or an uncited false-positive verdict in `fixMeta` blocks the write itself. The closed-loop test leg (`fix-verify-loop.js`) is separately wired into `mcp/apply_fix` behind `AGENTIC_SECURITY_FIX_RUN_TESTS=1` and does not (yet) thread `fixMeta` through — that path still bypasses the honesty gate.
53
+ - **The PoC-re-check leg is now genuinely reachable, without a schema change.** `verifyFixCore` accepts a `poc` param and, when given one with `poc.code` set, re-runs the proof harness against the patched files (`fix-verify.js`, the `pocLeg` block). `mcp/tools.js`'s `verify_fix` `inputSchema` still has no `poc` property — instead of widening it to make the caller resupply PoC data it never had, the handler looks up the original finding server-side from `last-scan.json` via `stable_id` (best-effort, `allowUnsigned: true` — a missing/tampered scan just means no PoC is available, not a `verify_fix` failure) and passes its `f.poc` straight through. Since `annotatePocs` attaches an HTTP-shaped `f.poc` by default on every scan (see the "Operator entry point" section below), any finding with a matching CWE template gets its PoC re-checked automatically on every `verify_fix` call — no agent-side plumbing required. `tests`/`honesty`/`poc` are all forwarded in the response (previously silently dropped).
53
54
 
54
55
  **Relevance scoping (R6 + R9)** — `relevance.js`. Turns the two existing *inventories* into *inputs*: `entrypoint-inventory.js` supplies the attack surface, `threat-model.js` supplies assets/boundaries/STRIDE, and `annotateRelevance(findings, ctx)` scores each finding by how reachable and how threat-modelled it is. Sets `entrypointReachable: true|false|null`, `relevance` (0..1), `relevanceTier: 'direct'|'indirect'|'unreachable'|'unknown'`, `relevanceFactors[]`, and re-ranks `exploitability` (ordinal priority, ×1.15 direct / ×0.6 unreachable, floored at 0.05, tier label recomputed on the same thresholds `annotateExploitability` uses). Reachability is a forward BFS over a literal-specifier import graph (JS/TS relative + Python dotted + Java FQCN) starting at every entry-point file.
55
56
 
@@ -76,6 +77,8 @@ Canonicalisation is an **allowlist, not a denylist** — each finding reduces to
76
77
 
77
78
  Wired in `bin/agentic-security.js` after every filter and after `makeDeterministic`, over `normalizeFindings(scan)` — i.e. it attests the set that actually ships — and surfaced as `attestation` in `toJSON`. `bundleSha` is read from the sidecar next to the *running* bundle and is `'unavailable'` when running from source, rather than reporting a dist hash that may not correspond to this run.
78
79
 
80
+ **`verifyRunAttestation` now has two real callers.** `agentic-security verify-attestation <file>` auto-detects whether the given JSON is an evidence bundle (`.finding`+`.signature`, verified via `evidence-bundle.js`'s Ed25519 path, unchanged) or a run attestation (`.digest`+`.canonicalisation`, either bare or embedded under a full `last-scan.json`'s `.attestation` field) and dispatches accordingly. A run attestation isn't self-contained the way a bundle is — verifying it means re-scanning the project (`--against <path>`, default `.`) and confirming the fresh scan reproduces the attested digest, which is the actual, meaningful claim this artifact makes ("does this codebase, scanned now, match what was attested earlier"). Separately, `scripts/release-check.mjs`'s `attestation-self-check` gate round-trips a synthetic finding set through compute→verify (and a mutated copy through verify, which must fail) on every release, catching a broken canonicalisation or signing path before it ships — independent of whether any project ever calls `verify-attestation` on a real artifact.
81
+
79
82
  **Integrity + signing** — `integrity.js` (per-install HMAC for `last-scan.json`), `rule-pack-signing.js`. The HMAC key lives at `$XDG_CONFIG_HOME/agentic-security/scan-key`; override via `$AGENTIC_SECURITY_HMAC_KEY`. Premortem-derived; do not regress to hostname-derived.
80
83
 
81
84
  **Rule lifecycle** — `custom-rules.js` (YAML pattern DSL), `rule-overrides.js` (`disable:` gated on signature), `rule-packs.js`, `rule-synthesis.js` (proposes suppressions from triage feedback), `ruleset-version.js`.
@@ -105,7 +108,7 @@ is also true of a scan that read zero files — so when nothing was examined eve
105
108
  mapped control degrades to `engine-gap` instead of reporting as satisfied. That
106
109
  one was caught by the module's own test, not in review.
107
110
 
108
- **Posture artifacts** — `sbom.js`, `aibom.js`, `api-inventory.js`, `threat-model.js`, `trust-boundary-diagram.js`, `stack-playbook.js`, `deploy-platform.js`, `license-policy.js`, `material-change.js`, `mttr.js`, `streak.js`, `scorecard.js`, `security-trend.js`.
111
+ **Posture artifacts** — `sbom.js`, `aibom.js`, `api-inventory.js`, `threat-model.js`, `trust-boundary-diagram.js`, `stack-playbook.js`, `deploy-platform.js`, `license-policy.js`, `material-change.js`, `mttr.js`, `streak.js`, `accuracy-scorecard.js` (see "Published accuracy scorecard (R3)" above — this line previously named a bare "scorecard" module that never existed under that filename), `security-trend.js`.
109
112
 
110
113
  **Why this fired** — `why-fired.js`. Runs LAST so it reflects every annotation. Customer-facing provenance.
111
114
 
@@ -234,10 +237,19 @@ the CI-gated tier and graduation into it is a human decision with a stated
234
237
  policy; an automated writer must not decide what blocks everyone's build.
235
238
 
236
239
  **Operator entry point:** `scripts/enroll-proven-finding.mjs <project>`
237
- (`--dry-run` scores without writing). It proves findings itself — the scan
238
- pipeline does **not** attach a `poc` to findings or promote proof tiers, so
239
- `last-scan.json` never contains an `execution-proven` finding on its own. PoCs
240
- come from the PoC-generator. Enrolment additionally needs fixed content
240
+ (`--dry-run` scores without writing). It proves findings itself — **but the
241
+ scan pipeline DOES attach an HTTP-shaped `f.poc` by default** (`annotatePocs`,
242
+ `engine.js`, unconditional not behind a flag; findings with no matching CWE
243
+ template get `f.poc: null`). What the scan pipeline does NOT do by default is
244
+ the *sandbox execution proof* that promotes a finding to the
245
+ `execution-proven` tier: that pass is genuinely opt-in
246
+ (`AGENTIC_SECURITY_PROVE=1`), so `last-scan.json` never contains an
247
+ `execution-proven` finding from an ordinary scan on its own — this file
248
+ previously conflated "attaches a poc" with "promotes to execution-proven,"
249
+ which are two different passes with two different default states. Enrolment
250
+ still proves findings itself via its own sandboxed run, independent of
251
+ whichever tier `last-scan.json` shipped with. Enrolment additionally needs
252
+ fixed content
241
253
  (`finding.fix.patch`) for `post/`; a proven finding with no fix is reported as
242
254
  skipped, not dropped. After enrolling, refresh the baseline
243
255
  (`npm run bench:cve-replay:update-baseline`) and commit it.
@@ -335,8 +335,16 @@ export function renderScorecardMarkdown(m) {
335
335
  L.push('at the commit where a vulnerability really existed, with the CWE assigned by a');
336
336
  L.push('public advisory database rather than by this project.');
337
337
  L.push('');
338
+ // population.unscored is an array of {id, reason} (bench/independent/runner.mjs) —
339
+ // render its count, not the array itself (Array#toString would stringify to
340
+ // "[object Object],[object Object]" or blank for an empty array, both silently
341
+ // wrong on the line this section calls "the number that matters"). Tolerates a
342
+ // bare number too, for any already-committed artifact predating this fix.
343
+ const unscoredList = ind.population?.unscored;
344
+ const unscoredCount = Array.isArray(unscoredList) ? unscoredList.length
345
+ : (typeof unscoredList === 'number' ? unscoredList : 0);
338
346
  L.push(`**Measured ${ind.measuredAt} on engine ${ind.engineVersion}, ` +
339
- `n=${ind.population?.scoredEntries}, ${ind.population?.unscored} unscored** ` +
347
+ `n=${ind.population?.scoredEntries}, ${unscoredCount} unscored** ` +
340
348
  '(*committed artifact*, `' + ind.source + '` — read, not re-run: scoring takes ~32 minutes).');
341
349
  L.push('');
342
350
  L.push('| | Advisory-local (**the claim**) | Wide (diagnostic) |');