@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.
- package/CHANGELOG.md +223 -0
- package/bin/agentic-security.js +52 -2
- package/dist/11.index.js +2 -2
- package/dist/113.index.js +498 -7
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +220 -0
- package/dist/238.index.js +218 -0
- package/dist/259.index.js +975 -0
- package/dist/384.index.js +1 -1
- package/dist/415.index.js +1 -1
- package/dist/435.index.js +4 -4
- package/dist/526.index.js +844 -0
- package/dist/637.index.js +1 -1
- package/dist/830.index.js +1 -1
- package/dist/agentic-security.mjs +106 -194
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +33 -17
- package/src/dataflow/CLAUDE.md +4 -1
- package/src/dataflow/async-sequencing.js +8 -3
- package/src/dataflow/catalog.js +278 -11
- package/src/dataflow/cross-repo.js +1 -1
- package/src/dataflow/cross-service-taint.js +1 -1
- package/src/dataflow/engine.js +182 -61
- package/src/dataflow/ifds.js +10 -5
- package/src/dataflow/index.js +15 -3
- package/src/dataflow/points-to.js +8 -2
- package/src/dataflow/proof-gate.js +7 -0
- package/src/dataflow/sanitizer-gate.js +89 -0
- package/src/dataflow/tabulation.js +14 -3
- package/src/engine.js +170 -7
- package/src/integrations/index.js +1 -1
- package/src/ir/CLAUDE.md +49 -4
- package/src/ir/call-sites.js +66 -0
- package/src/ir/callgraph.js +174 -7
- package/src/ir/class-hierarchy.js +22 -2
- package/src/ir/index.js +138 -51
- package/src/ir/ir-stats.js +126 -0
- package/src/ir/parser-cpp.js +829 -0
- package/src/ir/parser-cs.js +4 -1
- package/src/ir/parser-go.js +4 -1
- package/src/ir/parser-js.js +13 -1
- package/src/ir/parser-kt.js +4 -1
- package/src/ir/parser-php.js +10 -3
- package/src/ir/parser-py-cst.js +62 -10
- package/src/ir/tree-sitter-loader.js +13 -1
- package/src/llm-validator/index.js +9 -2
- package/src/llm-validator/redact.js +157 -0
- package/src/mcp/tools.js +2 -2
- package/src/posture/CLAUDE.md +193 -1
- package/src/posture/accuracy-scorecard.js +317 -0
- package/src/posture/api-contract.js +1 -1
- package/src/posture/attestation.js +202 -0
- package/src/posture/auditor-walkthrough.js +12 -3
- package/src/posture/compliance-policy.js +1 -1
- package/src/posture/corpus-enroll.js +303 -0
- package/src/posture/corpus-match.js +52 -0
- package/src/posture/cross-lang-openapi.js +1 -1
- package/src/posture/custom-rules.js +3 -3
- package/src/posture/execution-proof.js +92 -0
- package/src/posture/exploitability-probability.js +1 -1
- package/src/posture/falsification.js +45 -1
- package/src/posture/fix-metrics.js +197 -0
- package/src/posture/fix-verify.js +129 -2
- package/src/posture/license-policy.js +1 -1
- package/src/posture/profile.js +1 -1
- package/src/posture/proof-tier.js +33 -0
- package/src/posture/relevance.js +379 -0
- package/src/posture/root-cause-sweep.js +0 -0
- package/src/posture/rule-overrides.js +1 -1
- package/src/posture/sca-policy.js +1 -1
- package/src/posture/scan-checkpoint.js +277 -0
- package/src/posture/suppressions.js +1 -1
- package/src/posture/test-runner.js +147 -0
- package/src/posture/verification-separation.js +131 -0
- package/src/report/index.js +11 -0
- package/src/runScan.js +5 -7
- package/src/sandbox/CLAUDE.md +340 -0
- package/src/sandbox/backend-disabled.js +14 -0
- package/src/sandbox/backend-namespace.js +335 -0
- package/src/sandbox/backend-userspace.js +83 -0
- package/src/sandbox/capabilities.js +181 -0
- package/src/sandbox/index.js +30 -0
- package/src/sandbox/limits.js +63 -0
- package/src/sandbox/result.js +104 -0
- package/src/sca/dep-confusion.js +1 -1
- package/src/util/glob.js +173 -0
- package/src/util/yaml.js +24 -0
package/src/ir/parser-cs.js
CHANGED
|
@@ -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
|
|
257
|
+
cfg,
|
|
258
|
+
calls: callSitesFromCfg(cfg),
|
|
256
259
|
});
|
|
257
260
|
METHOD_RE.lastIndex = extracted.end + 1;
|
|
258
261
|
}
|
package/src/ir/parser-go.js
CHANGED
|
@@ -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
|
|
408
|
+
cfg,
|
|
409
|
+
calls: callSitesFromCfg(cfg),
|
|
407
410
|
});
|
|
408
411
|
FUNC_RE.lastIndex = extracted.end + 1;
|
|
409
412
|
}
|
package/src/ir/parser-js.js
CHANGED
|
@@ -397,7 +397,19 @@ export function parseJsFile(file, code) {
|
|
|
397
397
|
try {
|
|
398
398
|
babelTransformSync(code, {
|
|
399
399
|
filename: file,
|
|
400
|
-
|
|
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
|
});
|
package/src/ir/parser-kt.js
CHANGED
|
@@ -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
|
|
255
|
+
cfg,
|
|
256
|
+
calls: callSitesFromCfg(cfg),
|
|
254
257
|
});
|
|
255
258
|
FUN_RE.lastIndex = extracted.end + 1;
|
|
256
259
|
}
|
package/src/ir/parser-php.js
CHANGED
|
@@ -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
|
|
327
|
+
cfg,
|
|
328
|
+
calls: callSitesFromCfg(cfg),
|
|
326
329
|
});
|
|
327
|
-
|
|
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
|
}
|
package/src/ir/parser-py-cst.js
CHANGED
|
@@ -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:
|
|
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
|
-
//
|
|
106
|
-
//
|
|
107
|
-
// budget
|
|
108
|
-
//
|
|
109
|
-
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
26
|
-
//
|
|
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;
|