@clear-capabilities/agentic-security-scanner 0.127.0 → 0.130.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 (88) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/bin/agentic-security.js +33 -0
  3. package/dist/11.index.js +353 -0
  4. package/dist/113.index.js +727 -0
  5. package/dist/178.index.js +1 -1
  6. package/dist/207.index.js +217 -0
  7. package/dist/384.index.js +1 -1
  8. package/dist/415.index.js +1 -1
  9. package/dist/435.index.js +19 -8
  10. package/dist/526.index.js +555 -0
  11. package/dist/637.index.js +1 -1
  12. package/dist/826.index.js +4 -1
  13. package/dist/830.index.js +1 -1
  14. package/dist/agentic-security.mjs +113 -163
  15. package/dist/agentic-security.mjs.sha256 +1 -1
  16. package/package.json +23 -15
  17. package/src/dataflow/CLAUDE.md +4 -1
  18. package/src/dataflow/async-sequencing.js +8 -3
  19. package/src/dataflow/catalog.js +278 -11
  20. package/src/dataflow/cross-repo.js +1 -1
  21. package/src/dataflow/cross-service-taint.js +1 -1
  22. package/src/dataflow/engine.js +182 -61
  23. package/src/dataflow/ifds.js +10 -5
  24. package/src/dataflow/index.js +15 -3
  25. package/src/dataflow/points-to.js +8 -2
  26. package/src/dataflow/proof-gate.js +7 -0
  27. package/src/dataflow/sanitizer-gate.js +89 -0
  28. package/src/dataflow/tabulation.js +14 -3
  29. package/src/engine.js +181 -8
  30. package/src/integrations/index.js +1 -1
  31. package/src/integrations/tickets.js +9 -3
  32. package/src/ir/CLAUDE.md +49 -4
  33. package/src/ir/call-sites.js +66 -0
  34. package/src/ir/callgraph.js +174 -7
  35. package/src/ir/class-hierarchy.js +22 -2
  36. package/src/ir/index.js +138 -51
  37. package/src/ir/ir-stats.js +126 -0
  38. package/src/ir/parser-cpp.js +829 -0
  39. package/src/ir/parser-cs.js +4 -1
  40. package/src/ir/parser-go.js +4 -1
  41. package/src/ir/parser-js.js +5 -1
  42. package/src/ir/parser-kt.js +4 -1
  43. package/src/ir/parser-php.js +10 -3
  44. package/src/ir/parser-py-cst.js +62 -10
  45. package/src/ir/tree-sitter-loader.js +13 -1
  46. package/src/llm-validator/index.js +9 -2
  47. package/src/llm-validator/redact.js +157 -0
  48. package/src/mcp/tools.js +17 -6
  49. package/src/posture/CLAUDE.md +122 -0
  50. package/src/posture/accuracy-scorecard.js +317 -0
  51. package/src/posture/api-contract.js +1 -1
  52. package/src/posture/attestation.js +199 -0
  53. package/src/posture/auditor-walkthrough.js +12 -3
  54. package/src/posture/compliance-policy.js +1 -1
  55. package/src/posture/cross-lang-openapi.js +1 -1
  56. package/src/posture/custom-rules.js +1 -1
  57. package/src/posture/entrypoint-inventory.js +248 -0
  58. package/src/posture/execution-proof.js +52 -0
  59. package/src/posture/exploitability-probability.js +1 -1
  60. package/src/posture/falsification.js +165 -0
  61. package/src/posture/fix-honesty-gate.js +175 -0
  62. package/src/posture/fix-verify.js +71 -3
  63. package/src/posture/license-policy.js +1 -1
  64. package/src/posture/model-routing.js +126 -0
  65. package/src/posture/profile.js +1 -1
  66. package/src/posture/proof-tier.js +33 -0
  67. package/src/posture/relevance.js +379 -0
  68. package/src/posture/root-cause-sweep.js +262 -0
  69. package/src/posture/rule-overrides.js +1 -1
  70. package/src/posture/sca-policy.js +1 -1
  71. package/src/posture/scan-checkpoint.js +277 -0
  72. package/src/posture/suppressions.js +1 -1
  73. package/src/posture/test-runner.js +147 -0
  74. package/src/posture/verification-separation.js +131 -0
  75. package/src/pr-comment.js +3 -1
  76. package/src/report/index.js +11 -0
  77. package/src/runScan.js +3 -1
  78. package/src/sandbox/CLAUDE.md +218 -0
  79. package/src/sandbox/backend-disabled.js +14 -0
  80. package/src/sandbox/backend-namespace.js +83 -0
  81. package/src/sandbox/backend-userspace.js +100 -0
  82. package/src/sandbox/capabilities.js +53 -0
  83. package/src/sandbox/index.js +30 -0
  84. package/src/sandbox/limits.js +42 -0
  85. package/src/sandbox/result.js +104 -0
  86. package/src/sca/dep-confusion.js +1 -1
  87. package/src/util/untrusted.js +148 -0
  88. package/src/util/yaml.js +24 -0
@@ -26,6 +26,7 @@
26
26
  // parser-py-cst.js) once we have a dotnet capability probe.
27
27
 
28
28
  import * as crypto from 'node:crypto';
29
+ import { callSitesFromCfg } from './call-sites.js';
29
30
 
30
31
  const METHOD_RE = new RegExp(
31
32
  '(?:^|[\\s;{}])(?:public|private|protected|internal|static|virtual|override|async|sealed|abstract|new|readonly|partial)' +
@@ -249,10 +250,12 @@ export function parseCSharpFile(file, code) {
249
250
  }
250
251
  nodes[prev].succ.push('exit');
251
252
  nodes.exit.pred.push(prev);
253
+ const cfg = { entry: 'entry', exit: 'exit', nodes };
252
254
  functions.push({
253
255
  qid: _qid(file, name, startLine, extracted.body),
254
256
  name, line: startLine, params, file,
255
- cfg: { entry: 'entry', exit: 'exit', nodes },
257
+ cfg,
258
+ calls: callSitesFromCfg(cfg),
256
259
  });
257
260
  METHOD_RE.lastIndex = extracted.end + 1;
258
261
  }
@@ -21,6 +21,7 @@
21
21
  // - struct field assignments (x.Field = val) beyond simple dotted targets
22
22
 
23
23
  import * as crypto from 'node:crypto';
24
+ import { callSitesFromCfg } from './call-sites.js';
24
25
 
25
26
  const FUNC_RE = new RegExp(
26
27
  '(?:^|[\\n;{}])\\s*func\\s+' +
@@ -400,10 +401,12 @@ export function parseGoFile(file, code) {
400
401
  const exit = _addNode(nodes, { kind: 'exit', line: startLine });
401
402
  const tail = _buildCfg(extracted.body, nodes, entry, startLine + 1);
402
403
  _link(nodes, tail, exit);
404
+ const cfg = { entry, exit, nodes };
403
405
  functions.push({
404
406
  qid: _qid(file, name, startLine, extracted.body),
405
407
  name, line: startLine, params, file,
406
- cfg: { entry, exit, nodes },
408
+ cfg,
409
+ calls: callSitesFromCfg(cfg),
407
410
  });
408
411
  FUNC_RE.lastIndex = extracted.end + 1;
409
412
  }
@@ -397,7 +397,11 @@ export function parseJsFile(file, code) {
397
397
  try {
398
398
  babelTransformSync(code, {
399
399
  filename: file,
400
- presets: [presetReact, [presetTypescript, { isTSX: true, allExtensions: true }]],
400
+ // Babel 8 removed preset-typescript's .isTSX/.allExtensions. ignoreExtensions
401
+ // is the documented replacement: parse every file with the same TS grammar
402
+ // regardless of extension. JSX stays enabled via preset-react, so .js files
403
+ // containing JSX still parse — which .isTSX/.allExtensions guaranteed before.
404
+ presets: [presetReact, [presetTypescript, { ignoreExtensions: true }]],
401
405
  plugins: [plugin],
402
406
  ast: false, code: false, babelrc: false, configFile: false,
403
407
  });
@@ -21,6 +21,7 @@
21
21
  // gradle helper) is the upgrade path.
22
22
 
23
23
  import * as crypto from 'node:crypto';
24
+ import { callSitesFromCfg } from './call-sites.js';
24
25
 
25
26
  const FUN_RE = new RegExp(
26
27
  '(?:^|[\\s;{}])(?:public|private|internal|protected|inline|suspend|tailrec|operator|infix|open|abstract|override|final|external)?' +
@@ -247,10 +248,12 @@ export function parseKotlinFile(file, code) {
247
248
  }
248
249
  nodes[prev].succ.push('exit');
249
250
  nodes.exit.pred.push(prev);
251
+ const cfg = { entry: 'entry', exit: 'exit', nodes };
250
252
  functions.push({
251
253
  qid: _qid(file, name, startLine, extracted.body),
252
254
  name, line: startLine, params, file,
253
- cfg: { entry: 'entry', exit: 'exit', nodes },
255
+ cfg,
256
+ calls: callSitesFromCfg(cfg),
254
257
  });
255
258
  FUN_RE.lastIndex = extracted.end + 1;
256
259
  }
@@ -18,9 +18,10 @@
18
18
  // - control flow (if/for/while/switch) — body is straight-line
19
19
 
20
20
  import * as crypto from 'node:crypto';
21
+ import { callSitesFromCfg } from './call-sites.js';
21
22
 
22
23
  const FUNC_RE = new RegExp(
23
- '(?:^|[\\n;{}])\\s*' +
24
+ '(?:^|[\\n;{}]|<\\?php|<\\?)\\s*' +
24
25
  '(?:(?:public|private|protected|static|abstract|final)\\s+)*' +
25
26
  'function\\s+' +
26
27
  '([A-Za-z_]\\w*)' + // function name (g1)
@@ -319,12 +320,18 @@ export function parsePhpFile(file, code) {
319
320
  const exit = _addNode(nodes, { kind: 'exit', line: startLine });
320
321
  const tail = _buildCfg(extracted.body, nodes, entry, startLine + 1);
321
322
  _linkNodes(nodes, tail, exit);
323
+ const cfg = { entry, exit, nodes };
322
324
  functions.push({
323
325
  qid: _qid(file, name, startLine, extracted.body),
324
326
  name, line: startLine, params, file,
325
- cfg: { entry, exit, nodes },
327
+ cfg,
328
+ calls: callSitesFromCfg(cfg),
326
329
  });
327
- FUNC_RE.lastIndex = extracted.end + 1;
330
+ // Don't skip past the closing brace: for `<?php function h(){...} function m(){...}`
331
+ // that brace is the only boundary character available to anchor the next
332
+ // function's match (there's no newline/semicolon between them), and advancing
333
+ // past it here would make the following function declaration unmatchable.
334
+ FUNC_RE.lastIndex = extracted.end;
328
335
  }
329
336
  return functions.length ? { file, functions, topLevel: null } : null;
330
337
  }
@@ -32,6 +32,7 @@ import * as cp from 'node:child_process';
32
32
  import * as path from 'node:path';
33
33
  import * as fs from 'node:fs';
34
34
  import { fileURLToPath } from 'node:url';
35
+ import { callSitesFromCfg } from './call-sites.js';
35
36
 
36
37
  const HERE = path.dirname(fileURLToPath(import.meta.url));
37
38
  const HELPER_PATH = path.join(HERE, 'parser-py.helper.py');
@@ -41,15 +42,39 @@ const HELPER_PATH = path.join(HERE, 'parser-py.helper.py');
41
42
  // { ok: false, reason: '...' } on failure
42
43
  let _capability = null;
43
44
 
45
+ // Degradation record (BLOCKER 1 — corpus-gate flakiness).
46
+ //
47
+ // A CST→regex fallback is invisible to callers but silently removes Python
48
+ // interprocedural analysis for that run (`parser-py.js` emits no `fn.calls`
49
+ // — see ./CLAUDE.md). That turned a loaded machine into a phantom detection
50
+ // regression in the CVE-replay gate. Callers that care (the corpus runner)
51
+ // reset this before a scan and check it after, so a degraded parser is
52
+ // reported as an ENVIRONMENT error rather than scored as a false negative.
53
+ let _degradation = null;
54
+ export function noteParserDegradation(reason) { if (!_degradation) _degradation = reason; }
55
+ export function pythonParserDegradation() { return _degradation; }
56
+ export function resetPythonParserDegradation() { _degradation = null; }
57
+
58
+ // Probe timeout. 1500 ms was too tight: on a machine still busy from a prior
59
+ // test run, `python3 --version` genuinely took >1.5 s, the probe failed, and
60
+ // because the result is cached process-wide EVERY later scan in that process
61
+ // silently used the regex parser. Timeouts are also no longer cached (see
62
+ // below), so a transient spike can't poison the whole process.
63
+ const PROBE_TIMEOUT_MS = Number(process.env.AGENTIC_SECURITY_PY_PROBE_TIMEOUT_MS || 5000);
64
+
44
65
  export function probePythonAvailable() {
45
66
  if (_capability) return _capability;
67
+ let sawTimeout = false;
46
68
  // Try the canonical names in order. macOS / most Linux have python3;
47
69
  // some Linuxes only have python. We don't accept python2 (no f-strings).
48
70
  for (const bin of ['python3', 'python']) {
49
71
  let r;
50
72
  try {
51
- r = cp.spawnSync(bin, ['--version'], { encoding: 'utf8', timeout: 1500 });
73
+ r = cp.spawnSync(bin, ['--version'], { encoding: 'utf8', timeout: PROBE_TIMEOUT_MS });
52
74
  } catch { continue; }
75
+ // spawnSync sets .error (ETIMEDOUT) and a null status when the timeout
76
+ // fires. That is a load symptom, not "python is missing" — don't cache it.
77
+ if (r.error && (r.error.code === 'ETIMEDOUT' || r.signal)) { sawTimeout = true; continue; }
53
78
  if (r.status !== 0) continue;
54
79
  // Output format: "Python 3.12.2" (or 2.x — reject those).
55
80
  const m = /Python\s+(\d+)\.(\d+)\.(\d+)/.exec(r.stdout || r.stderr || '');
@@ -60,6 +85,10 @@ export function probePythonAvailable() {
60
85
  _capability = { ok: true, python: bin, version: `${m[1]}.${m[2]}.${m[3]}` };
61
86
  return _capability;
62
87
  }
88
+ if (sawTimeout) {
89
+ // Uncached: the next call re-probes once the machine is less busy.
90
+ return { ok: false, reason: 'probe-timeout', transient: true };
91
+ }
63
92
  _capability = { ok: false, reason: 'no-python3-on-path' };
64
93
  return _capability;
65
94
  }
@@ -87,8 +116,8 @@ export function parsePythonFile(file, raw) {
87
116
  export function parsePythonFilesBatch(entries) {
88
117
  if (!Array.isArray(entries) || entries.length === 0) return [];
89
118
  const cap = probePythonAvailable();
90
- if (!cap.ok) return null;
91
- if (!fs.existsSync(HELPER_PATH)) return null;
119
+ if (!cap.ok) { noteParserDegradation(`python-unavailable:${cap.reason}`); return null; }
120
+ if (!fs.existsSync(HELPER_PATH)) { noteParserDegradation('helper-script-missing'); return null; }
92
121
  const filtered = entries.filter(e =>
93
122
  e && typeof e.file === 'string' && /\.py$/i.test(e.file) &&
94
123
  typeof e.content === 'string' && e.content.length <= 1_000_000
@@ -96,29 +125,34 @@ export function parsePythonFilesBatch(entries) {
96
125
  if (filtered.length === 0) return [];
97
126
  let payload;
98
127
  try { payload = JSON.stringify(filtered); }
99
- catch { return null; }
128
+ catch { noteParserDegradation('payload-serialize-failed'); return null; }
100
129
  let r;
101
130
  try {
102
131
  r = cp.spawnSync(cap.python, [HELPER_PATH], {
103
132
  input: payload,
104
133
  encoding: 'utf8',
105
- // 10 s for a whole batch. The helper itself processes files in a
106
- // simple linear loop; on a 100-file repo a single-digit-second
107
- // budget is plenty. If a customer hits the timeout, the regex
108
- // parser fallback catches them.
109
- timeout: 10_000,
134
+ // 30 s for a whole batch. The helper processes files in a simple
135
+ // linear loop; single-digit seconds is plenty on a 100-file repo, but
136
+ // the budget has to survive a machine that is busy with something else
137
+ // (this was one half of the corpus-gate flakiness — a loaded box blew
138
+ // the old 10 s budget and silently dropped to the regex parser).
139
+ // Tunable for constrained runners.
140
+ timeout: Number(process.env.AGENTIC_SECURITY_PY_BATCH_TIMEOUT_MS || 30_000),
110
141
  maxBuffer: 64 * 1024 * 1024,
111
142
  });
112
143
  } catch (e) {
113
144
  if (process.env.AGENTIC_SECURITY_PY_PARSER_DEBUG === '1') {
114
145
  process.stderr.write(`parser-py-cst: spawn failed — ${e.message}\n`);
115
146
  }
147
+ noteParserDegradation(`helper-spawn-failed:${e.message}`);
116
148
  return null;
117
149
  }
118
150
  if (r.status !== 0 || !r.stdout) {
119
151
  if (process.env.AGENTIC_SECURITY_PY_PARSER_DEBUG === '1') {
120
152
  process.stderr.write(`parser-py-cst: helper exit=${r.status} stderr=${r.stderr || ''}\n`);
121
153
  }
154
+ noteParserDegradation(r.error && (r.error.code === 'ETIMEDOUT' || r.signal)
155
+ ? 'helper-batch-timeout' : `helper-exit-${r.status}`);
122
156
  return null;
123
157
  }
124
158
  let out;
@@ -127,9 +161,27 @@ export function parsePythonFilesBatch(entries) {
127
161
  if (process.env.AGENTIC_SECURITY_PY_PARSER_DEBUG === '1') {
128
162
  process.stderr.write(`parser-py-cst: helper output not JSON — ${e.message}\n`);
129
163
  }
164
+ noteParserDegradation('helper-output-not-json');
130
165
  return null;
131
166
  }
132
- return out;
167
+ return _annotateCalls(out);
168
+ }
169
+
170
+ // Populate `fn.calls` (post-parse, from the CFG) on every function of every
171
+ // file entry the helper returned. Done here rather than in the helper
172
+ // itself. Uses the shared `callSitesFromCfg` (./call-sites.js) — this used
173
+ // to be a second, drifted copy of that walker; folded into the shared module
174
+ // (which gained `elements`/`props` traversal to match) once the two were
175
+ // verified to produce identical output for Python.
176
+ function _annotateCalls(entries) {
177
+ if (!Array.isArray(entries)) return entries;
178
+ for (const entry of entries) {
179
+ if (!entry || !Array.isArray(entry.functions)) continue;
180
+ for (const fn of entry.functions) {
181
+ if (fn && fn.cfg) fn.calls = callSitesFromCfg(fn.cfg);
182
+ }
183
+ }
184
+ return entries;
133
185
  }
134
186
 
135
187
  // Reset the cache — for tests.
@@ -1,7 +1,19 @@
1
1
  // Tree-sitter parser loader (roadmap #8) — OPTIONAL, runtime-lazy, degrades.
2
2
  //
3
3
  // web-tree-sitter + tree-sitter-wasms are OPTIONAL dependencies (pinned to an
4
- // ABI-matched pair: web-tree-sitter 0.20.8 ↔ tree-sitter-wasms 0.1.13). They
4
+ // ABI-matched pair: web-tree-sitter 0.20.8 ↔ tree-sitter-wasms 0.1.13).
5
+ //
6
+ // DO NOT bump web-tree-sitter on its own. Verified 2026-07-27 against 0.26.11:
7
+ // every grammar in tree-sitter-wasms 0.1.13 fails to instantiate (empty Error
8
+ // from Language.load), because the prebuilt grammars target the older grammar
9
+ // ABI. 0.1.13 is the newest tree-sitter-wasms published, so there is no matched
10
+ // pair to upgrade TO — bumping the runtime silently drops all tree-sitter
11
+ // language support (rust, solidity, go, swift, cpp, c) rather than failing loudly.
12
+ // Revisit only when tree-sitter-wasms publishes grammars built for the newer ABI.
13
+ //
14
+ // 0.25+ also moved Language off the Parser class to a top-level named export, so
15
+ // that migration needs _ensureRuntime/getParserFor updated together with the pin.
16
+ // They
5
17
  // are NOT bundled into dist (the build marks them `--external`), so the
6
18
  // committed bundle stays self-contained and small; this loader requires them
7
19
  // lazily at runtime and returns null when they're absent. That keeps the
@@ -45,6 +45,7 @@ import * as fs from 'node:fs';
45
45
  import * as path from 'node:path';
46
46
  import * as crypto from 'node:crypto';
47
47
  import { statePath, ensureStateDir, safeWriteState } from '../posture/state-dir.js';
48
+ import { redactSecrets } from './redact.js';
48
49
 
49
50
  // Bump on every prompt change so the cache invalidates. Exported as a
50
51
  // stable public symbol (premortem 4R-15) so the validator-cache GC subcommand
@@ -195,12 +196,18 @@ function renderPrompt(finding, fileContents, challenge, nonce) {
195
196
  : `${finding.file}:${finding.line} [single-point detection, no cross-file path]`;
196
197
  // Defensive: strip the delimiter literally from the untrusted excerpt so
197
198
  // an attacker can't close it early by embedding our token.
198
- const sterileContext = String(context || '')
199
+ let sterileContext = String(context || '')
199
200
  .replace(/BEGIN-UNTRUSTED-CODE-EXCERPT-[a-f0-9]+/gi, '[stripped-delimiter]')
200
201
  .replace(/END-UNTRUSTED-CODE-EXCERPT-[a-f0-9]+/gi, '[stripped-delimiter]');
201
- const sterileSnippet = String(finding.snippet || '')
202
+ let sterileSnippet = String(finding.snippet || '')
202
203
  .replace(/[\r\n]+/g, ' ')
203
204
  .slice(0, 400);
205
+ // R10 — redact likely live credentials (API keys, tokens, private keys,
206
+ // connection-string passwords, ...) out of BOTH excerpts before anything
207
+ // leaves the machine. This is the last choke point before the prompt is
208
+ // assembled — everything downstream sees only redacted text.
209
+ sterileContext = redactSecrets(sterileContext).text;
210
+ sterileSnippet = redactSecrets(sterileSnippet).text;
204
211
  return PROMPT_TEMPLATE
205
212
  .replace(/\{\{nonce\}\}/g, nonce)
206
213
  .replace(/\{\{challenge\}\}/g, challenge)
@@ -0,0 +1,157 @@
1
+ // R10 — secret redaction for the Layer-3 LLM validator prompt.
2
+ //
3
+ // The validator sends real source-code excerpts to a model API. Those
4
+ // excerpts can contain live credentials that were hardcoded in the scanned
5
+ // project (API keys, tokens, private keys, connection-string passwords).
6
+ // This module strips likely secret VALUES while preserving the surrounding
7
+ // code STRUCTURE (variable name, operator, quotes, call shape) so the
8
+ // validator can still reason about the finding.
9
+ //
10
+ // Design tension (this is the point of the module): redact too little and a
11
+ // live key leaves the machine; redact too aggressively and the validator
12
+ // loses the context it needs to judge a finding. We resolve it by only
13
+ // touching values that match a specific credential shape — never whole
14
+ // lines, never identifiers, never ordinary string literals — and by biasing
15
+ // every heuristic toward leaving normal code alone (see the exclusions on
16
+ // the entropy pass below).
17
+ //
18
+ // Pure function, no I/O, no logging of the redacted material itself (only a
19
+ // count is returned) — callers must not print the input/output around this
20
+ // call in a way that defeats the point.
21
+
22
+ const REDACTED_PLACEHOLDER = '[REDACTED-SECRET]';
23
+
24
+ // Case-insensitive names that, when assigned a quoted string, are treated as
25
+ // carrying a credential. Exact identifiers only (word-boundary matched) —
26
+ // e.g. `password_field` does NOT match `password` because `_` is a word
27
+ // character and blocks the trailing \b.
28
+ const SECRET_KEY_NAMES = [
29
+ 'apiKey',
30
+ 'api_key',
31
+ 'secret',
32
+ 'token',
33
+ 'password',
34
+ 'passwd',
35
+ 'client_secret',
36
+ 'access_key',
37
+ 'private_key',
38
+ 'authorization',
39
+ ];
40
+
41
+ const KEY_VALUE_RE = new RegExp(
42
+ '(\\b(?:' + SECRET_KEY_NAMES.join('|') + ')\\b)(\\s*[:=]\\s*)([\'"`])([^\'"`]+)\\3',
43
+ 'gi'
44
+ );
45
+
46
+ // `Authorization: Bearer <blob>` — the blob only, scheme word survives.
47
+ const BEARER_RE = /\bBearer\s+[A-Za-z0-9._~+/=-]+/gi;
48
+
49
+ // PEM private-key blocks, any key type (`RSA PRIVATE KEY`, `EC PRIVATE KEY`,
50
+ // `PRIVATE KEY`, …). BEGIN/END markers are preserved so the excerpt still
51
+ // reads as "a private key was here"; the body is collapsed to one placeholder.
52
+ const PEM_RE = /-----BEGIN\s+[A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END\s+[A-Z0-9 ]*PRIVATE KEY-----/gi;
53
+
54
+ // `scheme://user:PASSWORD@host` — only the password segment is replaced;
55
+ // scheme, user, and host/path survive.
56
+ const CONN_STRING_RE = /(:\/\/[^:/\s'"@]+:)([^@/\s'"]+)(@)/g;
57
+
58
+ // Quoted string literals (single/double/backtick), escape-aware, so the
59
+ // entropy pass below can inspect literal contents without being fooled by
60
+ // an escaped quote inside the literal.
61
+ const QUOTED_STRING_RE = /'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"|`(?:\\.|[^`\\])*`/g;
62
+
63
+ // Minimum length before a bare string literal is even considered for the
64
+ // entropy pass. Conservative on purpose — short strings are indistinguishable
65
+ // from ordinary code tokens.
66
+ const ENTROPY_MIN_LENGTH = 24;
67
+ const ENTROPY_MIN_BITS_PER_CHAR = 4.0;
68
+ // base64url-ish charset only. Anything outside this (colons, slashes,
69
+ // semicolons, spaces, commas — e.g. a `data:...;base64,...` URI or a
70
+ // sentence) is left alone by construction, not by a special-cased exclusion.
71
+ const ENTROPY_CHARSET_RE = /^[A-Za-z0-9+/_=-]+$/;
72
+ // Pure-hex strings (git SHAs, color codes, hash digests) are common in
73
+ // ordinary code and have materially lower entropy-per-symbol than a random
74
+ // base64 secret at the same bit strength; treat them as non-secret.
75
+ const HEX_ONLY_RE = /^[0-9a-fA-F]+$/;
76
+
77
+ function shannonEntropy(str) {
78
+ const freq = new Map();
79
+ for (const ch of str) freq.set(ch, (freq.get(ch) || 0) + 1);
80
+ const len = str.length;
81
+ let entropy = 0;
82
+ for (const count of freq.values()) {
83
+ const p = count / len;
84
+ entropy -= p * Math.log2(p);
85
+ }
86
+ return entropy;
87
+ }
88
+
89
+ function looksLikeHighEntropySecret(inner) {
90
+ if (inner === REDACTED_PLACEHOLDER) return false; // already redacted upstream
91
+ if (inner.length < ENTROPY_MIN_LENGTH) return false;
92
+ if (!ENTROPY_CHARSET_RE.test(inner)) return false;
93
+ if (HEX_ONLY_RE.test(inner)) return false; // e.g. git SHA — leave alone
94
+ return shannonEntropy(inner) >= ENTROPY_MIN_BITS_PER_CHAR;
95
+ }
96
+
97
+ // redactSecrets(text) -> { text, redactions }
98
+ //
99
+ // Runs a fixed sequence of passes, most-specific first (PEM blocks and
100
+ // connection strings have unambiguous shapes; the entropy pass is the most
101
+ // general and runs last so it never fights with a more specific rule over
102
+ // the same span). Order does not affect whether a real secret is caught —
103
+ // only how many passes independently flag it — so a slight redaction-count
104
+ // overlap on an already-redacted span is harmless (the value is still gone
105
+ // exactly once; see KEY_VALUE_RE + BEARER_RE interaction for the one case
106
+ // where both can fire on the same literal).
107
+ export function redactSecrets(text) {
108
+ if (typeof text !== 'string' || text.length === 0) {
109
+ return { text: typeof text === 'string' ? text : '', redactions: 0 };
110
+ }
111
+
112
+ let redactions = 0;
113
+ let out = text;
114
+
115
+ // 1. PEM private-key blocks.
116
+ out = out.replace(PEM_RE, (m) => {
117
+ redactions++;
118
+ const begin = (m.match(/^-----BEGIN\s+[A-Z0-9 ]*PRIVATE KEY-----/i) || [])[0] || '-----BEGIN PRIVATE KEY-----';
119
+ const end = (m.match(/-----END\s+[A-Z0-9 ]*PRIVATE KEY-----$/i) || [])[0] || '-----END PRIVATE KEY-----';
120
+ return `${begin}\n${REDACTED_PLACEHOLDER}\n${end}`;
121
+ });
122
+
123
+ // 2. Connection-string passwords.
124
+ out = out.replace(CONN_STRING_RE, (_m, pre, _pass, at) => {
125
+ redactions++;
126
+ return `${pre}${REDACTED_PLACEHOLDER}${at}`;
127
+ });
128
+
129
+ // 3. Bearer tokens.
130
+ out = out.replace(BEARER_RE, () => {
131
+ redactions++;
132
+ return `Bearer ${REDACTED_PLACEHOLDER}`;
133
+ });
134
+
135
+ // 4. `secretName = "value"` / `secretName: "value"` assignments.
136
+ //
137
+ // Special case: `authorization: "Bearer <blob>"` — keep the `Bearer `
138
+ // scheme word (already handled generically by BEARER_RE above; this just
139
+ // keeps this pass from re-swallowing it when it runs on the same span).
140
+ out = out.replace(KEY_VALUE_RE, (_m, keyName, opWs, quote, value) => {
141
+ redactions++;
142
+ const bearer = /^(Bearer\s+)(.+)$/i.exec(value);
143
+ if (bearer) return `${keyName}${opWs}${quote}${bearer[1]}${REDACTED_PLACEHOLDER}${quote}`;
144
+ return `${keyName}${opWs}${quote}${REDACTED_PLACEHOLDER}${quote}`;
145
+ });
146
+
147
+ // 5. Long high-entropy string literals not caught by a more specific rule.
148
+ out = out.replace(QUOTED_STRING_RE, (m) => {
149
+ const q = m[0];
150
+ const inner = m.slice(1, -1);
151
+ if (!looksLikeHighEntropySecret(inner)) return m;
152
+ redactions++;
153
+ return `${q}${REDACTED_PLACEHOLDER}${q}`;
154
+ });
155
+
156
+ return { text: out, redactions };
157
+ }
package/src/mcp/tools.js CHANGED
@@ -549,12 +549,23 @@ export const apply_fix = {
549
549
  // Inline re-verify — the load-bearing gate. Must pass to write.
550
550
  let verdict;
551
551
  try {
552
- const verifyFixCore = await getVerifyFixCore();
553
- verdict = await verifyFixCore({
554
- scanRoot: ctx.sessionRoot,
555
- originalFindingStableId: f.stableId,
556
- files: Object.fromEntries(Object.entries(confinedAbs).map(([rel, v]) => [rel, v.content])),
557
- });
552
+ const _files = Object.fromEntries(Object.entries(confinedAbs).map(([rel, v]) => [rel, v.content]));
553
+ if (process.env.AGENTIC_SECURITY_FIX_RUN_TESTS === '1') {
554
+ // Addition #7 — connect the closed-loop verifier: add the project test
555
+ // suite as a fourth verification leg (scan + lint + tests). Opt-in
556
+ // because many repos have no runner and we must not fail-closed by
557
+ // default. Normalized to the scan+lint verdict shape used below.
558
+ const { verifyFixWithTests } = await import('../posture/fix-verify-loop.js');
559
+ const t = await verifyFixWithTests({ scanRoot: ctx.sessionRoot, originalFindingStableId: f.stableId, files: _files });
560
+ verdict = { ok: t.ok, summary: t.summary, rescan: t.legs?.scan?.detail, lint: t.legs?.lint?.detail, tests: t.legs?.tests, testVerdict: t.verdict };
561
+ } else {
562
+ const verifyFixCore = await getVerifyFixCore();
563
+ verdict = await verifyFixCore({
564
+ scanRoot: ctx.sessionRoot,
565
+ originalFindingStableId: f.stableId,
566
+ files: _files,
567
+ });
568
+ }
558
569
  } catch (e) {
559
570
  return { _meta: META, applied: false, reason: `patch verification failed: ${e.message}` };
560
571
  }
@@ -9,6 +9,8 @@ Annotators that run **after** every detector has emitted, plus state stores read
9
9
 
10
10
  **Calibration + held-out evaluation** — `calibration.js`, `calibration-drift.js`, `validator-metrics.js`, `holdout-eval.js`. The seed corpus lives at `calibration-seed.json`; held-out labels are taken via `loadLabeledJsonl`. Brier and ECE both live in `holdout-eval.js`; never reintroduce a "fit-on-the-table" version.
11
11
 
12
+ **Published accuracy scorecard (R3)** — `accuracy-scorecard.js`. Pure aggregation + markdown/JSON rendering for `docs/SCORECARD.md`; the impure driver that performs the corpus and self-scan runs is `scripts/scorecard.mjs` (`npm run scorecard`). Every rate is carried as `{n, d}` and rendered through `formatRate()` so a percentage can never appear without its denominator; entries a run could not score are excluded from every denominator *and* disclosed by name. No F1 is emitted — see the module header for why, and don't add one without a labelled real-world population to measure precision over.
13
+
12
14
  **Cross-language taint** — `cross-lang-{openapi,grpc,graphql,orm,queues,meta}.js`. Each parses a contract artifact (`openapi.json`, `*.proto`, `*.graphql`, queue config) and emits a chain finding when the same data crosses a language boundary into another module's finding.
13
15
 
14
16
  **Risk amplification** — `epss.js`, `kev` (in `version.js`), `blast-radius.js`, `crown-jewels.js`, `exploitability.js`, `bounty-prediction.js`, `risk-in-dollars` (lives in `scripts/`, not here).
@@ -21,6 +23,38 @@ Annotators that run **after** every detector has emitted, plus state stores read
21
23
 
22
24
  **Agentic verification** — `verifier.js`, `verifier-target.js`, `verifier-ephemeral.js`, `harness-discovery.js`, `adversary-agent.js`, `defender-agent.js`, `auditor-agent.js`, `three-agent-pipeline.js`.
23
25
 
26
+ **Methodology additions (`docs/AGENTIC_METHODOLOGY_PRD.md`)** — default-on annotators/artifacts that layer the agentic-hunter methodology on the deterministic engine:
27
+ - `falsification.js` — default falsification pass. For each taint-style finding, tries to DISPROVE it (locate a context-matched control on the path, reusing `dataflow/sanitizer-proof.js`'s shape rules read-only); a blocked finding is demoted + `quarantined`, never removed and never severity-touched (recall-preserving, like `proof-gate`). Wired after `annotateProofGate`. Opt out: `AGENTIC_SECURITY_NO_FALSIFICATION=1`. Optional LLM tier over survivors when an endpoint is configured.
28
+ - `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`.
29
+ - `root-cause-sweep.js` — from confirmed findings, finds sibling instances detectors missed with total-count accounting (`found === candidates + mitigated`); on `scan.rootCauseSweep`.
30
+ - `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.
31
+ - `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`.
32
+
33
+ **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.
34
+
35
+ Its contract is **recall-preserving, same precedent as `falsification.js` / `dataflow/proof-gate.js`**: it never removes a finding, never touches `severity`, and never asserts `unreachable` without positive evidence — a negative verdict additionally requires the intra-repo import graph to be hole-free *along the reachable set* (an unresolved relative import or a non-literal `require(x)` in a reachable file hides a possible edge, so every would-be `unreachable` degrades to `unknown`). `null`/`'unknown'` is a first-class state and is **not** the same as `false`. Wired in `engine.js` after the entry-point inventory is built and after every finding has been appended (multi-sink and cross-language chains included) so nothing escapes annotation; this is the one annotator that deliberately runs after `why-fired`.
36
+
37
+ **Enforced verification separation (R7)** — `verification-separation.js`. The falsification pass could already try to *disprove* a finding; what it could not do was prove the checker was not the producer. This module supplies that structural guarantee:
38
+
39
+ - `recordProducer(finding, producerId)` stamps provenance **write-once** — a later party cannot re-stamp itself as producer to manufacture separation.
40
+ - `assertSeparation(finding, verifierId)` refuses when verifier === producer, and **fails closed** when no producer was recorded (unestablishable separation is not separation).
41
+ - `recordVerdict(finding, {verifierId, lens, verdict, reason})` runs that check itself, so there is no path to a recorded verdict that skips it. `lens` is the perspective (`'control-flow'`, `'reachability'`, `'data-shape'`, `'llm-review'`); `verdict ∈ 'upheld'|'refuted'|'undecided'`. One verifier gets one vote per lens — a re-vote replaces rather than stuffs.
42
+ - `consensusOf(finding) -> {verdict, upheld, refuted, undecided, lenses[]}` — majority, `'undecided'` on a tie or on no verdicts.
43
+
44
+ Producer ids are namespaced `detector:<parser>`, verifier ids `verifier:<name>`, so the two spaces cannot collide. **Nothing throws** (posture convention): every entry point returns `{ok:false, refused:true, reason}`.
45
+
46
+ Contract is **recall-preserving, same precedent as `falsification.js` / `proof-gate.js`**: a `refuted` verdict never removes a finding and never touches `severity`. It is a triage signal, not a deletion.
47
+
48
+ Wired in `falsification.js`: the detector is stamped as producer, the falsification pass records under `VERIFIER_FALSIFICATION` on the `control-flow` lens, and the optional LLM tier records separately under `VERIFIER_LLM_REVIEW` on the `llm-review` lens — which is what makes a contested finding legible *as contested* (upheld vs refuted → consensus `undecided`) instead of resolved by whoever spoke last. Result lands on `finding.verification = {producer, verdicts[], consensus}`.
49
+
50
+ **Run attestation (R4)** — `attestation.js`. Turns determinism from an implementation property into something a third party can check. `computeRunAttestation({findings, engineVersion, rulesetVersion, bundleSha, root, sign})` returns `{digest, algorithm, findingCount, engineVersion, rulesetVersion, bundleSha, canonicalisation, proves, doesNotProve, signature?}`; `verifyRunAttestation(attestation, {findings, …})` re-derives and returns `{ok, reason}`.
51
+
52
+ Canonicalisation is an **allowlist, not a denylist** — each finding reduces to `id ⇥ severity ⇥ file ⇥ line ⇥ cwe ⇥ vuln`, rows sorted, multiplicity preserved. That is what makes the digest independent of emission order, run ids, timestamps, durations, separator style, and the absolute prefix (when `root` is given), while a changed severity/file/line/rule id/cwe or a finding appearing or disappearing all change it. A new volatile field cannot leak in without being added to the allowlist deliberately. `parser` and `family` are deliberately **excluded**: `parser` records which analysis engine fired, which is environment-sensitive (the Python AST path vs. its regex fallback), so including it would report an environment difference as a findings difference.
53
+
54
+ **What it proves / does not prove** — the attestation carries both statements inline, and both are asserted by a test so they cannot be quietly dropped. It proves two finding sets with the same digest under the same canonicalisation are the same findings from the same engine/ruleset/bundle. It does **not** prove cross-machine reproducibility — no second machine was compared, and this repo makes no such claim. Signing reuses `integrity.js`'s per-install HMAC key handling verbatim (`signLastScan`); no second key mechanism was introduced. `verifyLastScan` is *not* reused because it verifies against a sibling `.sig` file whereas an attestation carries its signature inline, so verification re-signs and compares in constant time. Being symmetric, the signature is tamper-evidence for the operator, not third-party non-repudiation.
55
+
56
+ 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.
57
+
24
58
  **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.
25
59
 
26
60
  **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`.
@@ -37,6 +71,94 @@ Annotators that run **after** every detector has emitted, plus state stores read
37
71
  - **No throwing.** Every annotation in `engine.js` is wrapped `try { … } catch (_) {}`. Your annotator must degrade gracefully — set `null` on the field and continue.
38
72
  - **Dead-module test.** `npm run test:lifecycle` fails the build if you export a public symbol from a posture module that no other source file imports. Wire it in `engine.js` (or allowlist it with a written reason in `test/no-dead-modules.test.js`).
39
73
 
74
+ ## Execution-proof tiers (R2)
75
+
76
+ `proof-tier.js` + `execution-proof.js` add a fourth axis to a finding's
77
+ credibility, orthogonal to `confidence`/`exploitability`: whether the bug was
78
+ *run*, not just reasoned about.
79
+
80
+ **The four tiers** (`PROOF_TIERS`, most-proven first):
81
+
82
+ - `execution-proven` — a generated PoC ran inside the sandbox and the sandbox
83
+ observed the predicted effect (a marker file the PoC's payload should have
84
+ written showed up). The strongest claim the pipeline can make.
85
+ - `proof-failed` — a PoC ran and the marker did **not** appear. This is a
86
+ **triage signal, not a false-positive verdict**. Absence of proof is not
87
+ proof of absence: the PoC may be wrong, the param key may be misinferred,
88
+ or the vulnerable path may need state the single-shot PoC didn't set up.
89
+ Never auto-close or downgrade severity off `proof-failed` alone.
90
+ - `taint-proven` — the analyser's static reasoning (`IR-TAINT`/`MULTI-SINK`)
91
+ found it; nothing executed. This is `proofTierOf()`'s default when no
92
+ execution evidence has been attached.
93
+ - `unproven` — no analyser backing recorded at all (e.g. `REGEX` parser).
94
+
95
+ **Why a marker file, not an exit code.** The sandbox cannot reliably
96
+ distinguish "the payload was denied by confinement" from "the payload ran
97
+ and happened to exit 0" — both look like a clean exit from the parent
98
+ process's point of view. A marker file the PoC only writes *if its exploit
99
+ path actually executes* turns that ambiguity into a directly observable
100
+ fact: the file exists, or it doesn't. Exit code alone is used only for
101
+ timeout/crash detection, never as the proof signal itself.
102
+
103
+ **The backend is recorded in every evidence object** (`proofEvidence.backend`,
104
+ e.g. `'userspace'`) because not all confinement backends carry the same
105
+ guarantee. The kernel-namespace backend is unverified on this host and, even
106
+ where available, only confines network — it does **not** confine writes.
107
+ Treat `execution-proven` evidence from a non-`userspace` backend as weaker
108
+ than the same tier from `userspace` until that backend's write confinement
109
+ is independently verified. `attachProofTier()` also enforces the demotion
110
+ guard: `ran:false` can never yield `execution-proven` or `proof-failed`,
111
+ regardless of what tier was requested — it falls back to the finding's
112
+ static standing (`proofTierOf`).
113
+
114
+ `proofTier`/`proofEvidence` are copied through `report/index.js`'s
115
+ `normalizeFindings()` only when the annotator actually attached them —
116
+ never synthesised at the report layer.
117
+
118
+ ## Scan checkpointing / resume (R8)
119
+
120
+ `scan-checkpoint.js` lets an interrupted scan resume instead of restarting, which
121
+ is what caps usable repository size today. **Opt-in only**: `AGENTIC_SECURITY_RESUME=1`
122
+ (or `runScan(root, {resume:true})`). Default behaviour is byte-for-byte unchanged
123
+ and nothing is written.
124
+
125
+ **What is checkpointed.** Only the per-file loop in `engine.js#runFullScan` — the
126
+ one place per-file work happens. Each completed file's *entire* contribution is
127
+ persisted (routes, findings, taint sources/sinks/sanitizers, logic vulns, secrets,
128
+ at-rest/in-transit ciphers, the suppression-log delta, and the per-file taint
129
+ result the cross-file pass reads), not just its findings. Everything after the
130
+ loop — cross-file taint, gadget detection, the whole annotation pipeline — re-runs
131
+ from scratch, so nothing that depends on the global picture can be stale by
132
+ construction. On replay, `pfr[p]`'s arrays are rebuilt as slices of the aggregate
133
+ arrays, so object identity between the two matches an uninterrupted run exactly.
134
+
135
+ **The property.** A resumed scan must produce the same finding set as an
136
+ uninterrupted one; a checkpoint that silently drops findings turns a slow scan
137
+ into a quietly incomplete one, which is worse than no checkpoint. `test/scan-checkpoint.test.js`
138
+ asserts this end-to-end: a child process is hard-exited (`process.exit`, no
139
+ unwinding) partway through a real scan, the resumed scan is compared against a
140
+ genuinely uninterrupted one, and the fixture is asserted to exercise every
141
+ channel inside the replayed prefix so a dropped channel cannot go unnoticed.
142
+
143
+ **Invalidation is deliberately blunt.** The run key covers engine version,
144
+ ruleset version, bundle SHA, a content hash of every scanned and dependency file
145
+ (which subsumes mtime), and every `AGENTIC_SECURITY_*` env switch. If any of it
146
+ moved, the checkpoint is discarded and the scan starts clean. Redoing work is
147
+ slow; resuming stale work is a correctness bug.
148
+
149
+ **Crash safety: append-and-fsync.** JSONL — one header line pinning the run key,
150
+ then one record per file carrying a SHA-256 of its own payload, each written with
151
+ a single `writeSync` and `fsyncSync`'d before the next file is analysed. Recovery
152
+ reads forward while records verify and truncates at the last byte that did, so a
153
+ torn or tampered tail is dropped rather than resumed into. Nothing is rewritten
154
+ in place. Values JSON cannot round-trip (Date/RegExp/Map/function/…) are refused
155
+ rather than recorded lossily — that file just gets rescanned. On clean completion
156
+ the checkpoint is removed, so the next run cannot resume consumed state.
157
+
158
+ State lives at `<scanRoot>/.agentic-security/scan-checkpoint.jsonl`; like every
159
+ other module here, nothing throws — a failure to open, read or append degrades to
160
+ "no checkpoint", i.e. a normal full scan.
161
+
40
162
  ## Gotchas
41
163
 
42
164
  - 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.