@clear-capabilities/agentic-security-scanner 0.128.1 → 0.132.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 (87) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/bin/agentic-security.js +52 -2
  3. package/dist/11.index.js +2 -2
  4. package/dist/113.index.js +498 -7
  5. package/dist/178.index.js +1 -1
  6. package/dist/207.index.js +220 -0
  7. package/dist/238.index.js +218 -0
  8. package/dist/259.index.js +975 -0
  9. package/dist/384.index.js +1 -1
  10. package/dist/415.index.js +1 -1
  11. package/dist/435.index.js +4 -4
  12. package/dist/526.index.js +844 -0
  13. package/dist/637.index.js +1 -1
  14. package/dist/830.index.js +1 -1
  15. package/dist/agentic-security.mjs +106 -194
  16. package/dist/agentic-security.mjs.sha256 +1 -1
  17. package/package.json +33 -17
  18. package/src/dataflow/CLAUDE.md +4 -1
  19. package/src/dataflow/async-sequencing.js +8 -3
  20. package/src/dataflow/catalog.js +278 -11
  21. package/src/dataflow/cross-repo.js +1 -1
  22. package/src/dataflow/cross-service-taint.js +1 -1
  23. package/src/dataflow/engine.js +182 -61
  24. package/src/dataflow/ifds.js +10 -5
  25. package/src/dataflow/index.js +15 -3
  26. package/src/dataflow/points-to.js +8 -2
  27. package/src/dataflow/proof-gate.js +7 -0
  28. package/src/dataflow/sanitizer-gate.js +89 -0
  29. package/src/dataflow/tabulation.js +14 -3
  30. package/src/engine.js +170 -7
  31. package/src/integrations/index.js +1 -1
  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 +13 -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 +2 -2
  49. package/src/posture/CLAUDE.md +193 -1
  50. package/src/posture/accuracy-scorecard.js +317 -0
  51. package/src/posture/api-contract.js +1 -1
  52. package/src/posture/attestation.js +202 -0
  53. package/src/posture/auditor-walkthrough.js +12 -3
  54. package/src/posture/compliance-policy.js +1 -1
  55. package/src/posture/corpus-enroll.js +303 -0
  56. package/src/posture/corpus-match.js +52 -0
  57. package/src/posture/cross-lang-openapi.js +1 -1
  58. package/src/posture/custom-rules.js +3 -3
  59. package/src/posture/execution-proof.js +92 -0
  60. package/src/posture/exploitability-probability.js +1 -1
  61. package/src/posture/falsification.js +45 -1
  62. package/src/posture/fix-metrics.js +197 -0
  63. package/src/posture/fix-verify.js +129 -2
  64. package/src/posture/license-policy.js +1 -1
  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 +0 -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/report/index.js +11 -0
  76. package/src/runScan.js +5 -7
  77. package/src/sandbox/CLAUDE.md +340 -0
  78. package/src/sandbox/backend-disabled.js +14 -0
  79. package/src/sandbox/backend-namespace.js +335 -0
  80. package/src/sandbox/backend-userspace.js +83 -0
  81. package/src/sandbox/capabilities.js +181 -0
  82. package/src/sandbox/index.js +30 -0
  83. package/src/sandbox/limits.js +63 -0
  84. package/src/sandbox/result.js +104 -0
  85. package/src/sca/dep-confusion.js +1 -1
  86. package/src/util/glob.js +173 -0
  87. 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,19 @@ 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 }]],
405
+ // Decorators are SYNTAX we must accept, never transform — without them the
406
+ // parser rejects the whole file and every finding in it silently disappears.
407
+ // Measured on one real target: 201 JS files unparseable, all decorator-using
408
+ // framework code. 'decorators-legacy' covers the framework and TypeScript
409
+ // parameter forms; 'decoratorAutoAccessors' adds the modern `accessor` field.
410
+ // The modern 'decorators' variant was rejected: it cannot parse TS parameter
411
+ // decorators, so it would trade one blind spot for another.
412
+ parserOpts: { plugins: ['decorators-legacy', 'decoratorAutoAccessors'] },
401
413
  plugins: [plugin],
402
414
  ast: false, code: false, babelrc: false, configFile: false,
403
415
  });
@@ -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
@@ -22,8 +22,8 @@ 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
24
 
25
- // Lazy-loaded: these transitively pull in npm packages (fast-glob,
26
- // @babel/core) that aren't available in the plugin-cache install path
25
+ // Lazy-loaded: these transitively pull in npm packages (@babel/core and
26
+ // friends) that aren't available in the plugin-cache install path
27
27
  // (no node_modules). Deferring keeps the MCP server bootable everywhere;
28
28
  // the import only runs when a tool that needs them is actually called.
29
29
  let _runScan;