@clear-capabilities/agentic-security-scanner 0.136.2 → 0.137.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 +880 -0
- package/bin/agentic-security.js +189 -37
- package/dist/113.index.js +13 -4
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +5 -4
- package/dist/238.index.js +1 -1
- package/dist/317.index.js +36 -6
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +192 -15
- package/dist/444.index.js +20 -11
- package/dist/449.index.js +8 -1
- package/dist/526.index.js +3 -3
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +15 -15
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/nist-privacy-1-1.json +2 -2
- package/dist/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/package.json +21 -13
- package/src/dataflow/CLAUDE.md +12 -4
- package/src/dataflow/builtin-summaries.js +1 -1
- package/src/dataflow/catalog-expanded.js +1 -0
- package/src/dataflow/catalog.js +157 -31
- package/src/dataflow/engine.js +639 -112
- package/src/dataflow/implicit-flow.js +68 -36
- package/src/dataflow/incremental.js +18 -3
- package/src/dataflow/index.js +17 -1
- package/src/dataflow/points-to.js +19 -6
- package/src/dataflow/proven-clean.js +41 -0
- package/src/dataflow/sanitizer-gate.js +35 -9
- package/src/dataflow/sanitizer-proof.js +21 -3
- package/src/dataflow/stub-aware-filter.js +36 -13
- package/src/dataflow/summaries.js +21 -2
- package/src/engine.js +430 -196
- package/src/ir/CLAUDE.md +16 -2
- package/src/ir/balanced-call.js +55 -0
- package/src/ir/class-hierarchy.js +57 -11
- package/src/ir/index.js +14 -2
- package/src/ir/parser-cs.js +513 -40
- package/src/ir/parser-go.js +29 -11
- package/src/ir/parser-java.js +300 -20
- package/src/ir/parser-js.js +300 -22
- package/src/ir/parser-kt.js +436 -18
- package/src/ir/parser-php.js +631 -38
- package/src/ir/parser-py.helper.py +32 -2
- package/src/ir/parser-py.js +31 -4
- package/src/ir/parser-rb.js +161 -26
- package/src/ir/ssa.js +6 -1
- package/src/lsp/server.js +35 -3
- package/src/mcp/CLAUDE.md +9 -2
- package/src/mcp/redact.js +26 -0
- package/src/mcp/tools.js +164 -15
- package/src/posture/CLAUDE.md +19 -7
- package/src/posture/accuracy-scorecard.js +9 -1
- package/src/posture/aibom.js +12 -8
- package/src/posture/auditor-walkthrough.js +102 -3
- package/src/posture/autopilot.js +8 -1
- package/src/posture/calibration-drift.js +11 -5
- package/src/posture/calibration.js +24 -2
- package/src/posture/clustering.js +12 -1
- package/src/posture/compliance-frameworks/nist-privacy-1-1.json +2 -2
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/src/posture/compliance-policy.js +33 -1
- package/src/posture/confidence.js +44 -10
- package/src/posture/corpus-enroll.js +9 -5
- package/src/posture/corpus-match.js +19 -0
- package/src/posture/csharp-analysis.js +62 -3
- package/src/posture/deploy-platform.js +4 -1
- package/src/posture/drift.js +7 -1
- package/src/posture/epss.js +13 -1
- package/src/posture/evidence-bundle.js +36 -6
- package/src/posture/exploitability-probability.js +13 -1
- package/src/posture/falsification.js +23 -2
- package/src/posture/fix-metrics.js +1 -1
- package/src/posture/fix-verify-loop.js +10 -1
- package/src/posture/iac-reachability.js +14 -8
- package/src/posture/integrity.js +25 -7
- package/src/posture/model-rescan.js +65 -0
- package/src/posture/mttr.js +5 -0
- package/src/posture/poc-inprocess.js +27 -8
- package/src/posture/regression-test-gen.js +23 -8
- package/src/posture/reverse-blast-radius.js +5 -1
- package/src/posture/risk-dollars.js +18 -1
- package/src/posture/sbom.js +2 -2
- package/src/posture/secret-history.js +20 -11
- package/src/posture/security-trend.js +7 -1
- package/src/posture/stack-playbook.js +22 -1
- package/src/posture/threat-model-grounding.js +2 -2
- package/src/posture/validator-metrics.js +10 -3
- package/src/posture/verifier.js +32 -57
- package/src/report/index.js +183 -14
- package/src/runScan.js +1 -1
- package/src/sast/_comment-strip.js +15 -4
- package/src/sast/_secret-entropy.js +1 -1
- package/src/sast/authz.js +6 -4
- package/src/sast/bench-shape/index.js +2 -7
- package/src/sast/claude-md-prompt-injection.js +14 -3
- package/src/sast/cloud-iam.js +60 -7
- package/src/sast/cpp-bench-extras.js +1 -1
- package/src/sast/csrf.js +7 -5
- package/src/sast/env-hygiene.js +5 -2
- package/src/sast/iac-terraform.js +25 -0
- package/src/sast/java-bench-extras.js +1 -1
- package/src/sast/java-constant-fold.js +5 -5
- package/src/sast/llm-owasp.js +4 -2
- package/src/sast/mcp-audit.js +7 -0
- package/src/sast/pipeline.js +8 -0
- package/src/sast/prompt-template.js +8 -6
- package/src/sast/prototype-pollution.js +6 -2
- package/src/sast/redos-nfa.js +6 -6
- package/src/sast/secret-concat.js +13 -2
- package/src/sast/ssrf-cloud-metadata.js +6 -3
- package/src/sast/xss-reflected-multilang.js +1 -1
- package/src/sast/xxe.js +1 -1
- package/src/sca/CLAUDE.md +3 -4
- package/src/sca/container.js +35 -3
- package/src/sca/dep-confusion.js +7 -0
- package/src/sca/sarif-ingest.js +0 -187
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
# { file, functions: [
|
|
13
13
|
# { qid, name, line, params, file,
|
|
14
14
|
# cfg: { entry: nodeId, exit: nodeId, nodes: { id: node } } }
|
|
15
|
-
# ], topLevel: null
|
|
15
|
+
# ], topLevel: <qid of synthetic <module> function, or null if the file
|
|
16
|
+
# has no top-level statements worth lowering (PRD R14(b))> }
|
|
16
17
|
#
|
|
17
18
|
# node = {
|
|
18
19
|
# kind: 'entry' | 'exit' | 'noop' | 'loop-header' | 'assign' | 'call'
|
|
@@ -595,7 +596,36 @@ def _process_one(file: str, content: str) -> dict[str, Any]:
|
|
|
595
596
|
except SyntaxError as e:
|
|
596
597
|
return {"file": file, "functions": [], "topLevel": None, "_error": f"syntax-error: {e.msg} (line {e.lineno})"}
|
|
597
598
|
fns = _extract_functions(tree, file)
|
|
598
|
-
|
|
599
|
+
# R14(b): lower top-level (module-scope) statements into a synthetic
|
|
600
|
+
# <module> function, mirroring parser-js.js's Program-level lowering.
|
|
601
|
+
# Only included when it carries real content — a FunctionDef/ClassDef
|
|
602
|
+
# encountered here lowers to a noop placeholder (_lower_stmt already
|
|
603
|
+
# does this so nested defs aren't double-counted; see _extract_functions
|
|
604
|
+
# above, which independently captures them via ast.walk), so a
|
|
605
|
+
# function-only file must not gain a <module> entry just because its
|
|
606
|
+
# single top-level statement happens to be a def.
|
|
607
|
+
mod_builder = CfgBuilder("<module>")
|
|
608
|
+
mod_builder.lower(tree.body)
|
|
609
|
+
mod_has_content = any(
|
|
610
|
+
n.get("kind") not in ("entry", "exit", "noop")
|
|
611
|
+
for n in mod_builder.nodes.values()
|
|
612
|
+
)
|
|
613
|
+
top_level_qid = None
|
|
614
|
+
if mod_has_content:
|
|
615
|
+
top_level_qid = _qid(file, "<module>", 1)
|
|
616
|
+
fns.append({
|
|
617
|
+
"qid": top_level_qid,
|
|
618
|
+
"name": "<module>",
|
|
619
|
+
"line": 1,
|
|
620
|
+
"params": [],
|
|
621
|
+
"file": file,
|
|
622
|
+
"cfg": {
|
|
623
|
+
"entry": mod_builder.entry,
|
|
624
|
+
"exit": mod_builder.exit,
|
|
625
|
+
"nodes": mod_builder.nodes,
|
|
626
|
+
},
|
|
627
|
+
})
|
|
628
|
+
return {"file": file, "functions": fns, "topLevel": top_level_qid}
|
|
599
629
|
|
|
600
630
|
|
|
601
631
|
def main() -> int:
|
package/src/ir/parser-py.js
CHANGED
|
@@ -170,6 +170,7 @@ function _findTopLevel(s, sep) {
|
|
|
170
170
|
function extractFunctions(text, file) {
|
|
171
171
|
const lines = blankComments(text, 'py').split('\n');
|
|
172
172
|
const fns = [];
|
|
173
|
+
const consumed = new Set();
|
|
173
174
|
for (let i = 0; i < lines.length; i++) {
|
|
174
175
|
const line = lines[i];
|
|
175
176
|
// Premortem #14: balanced-paren signature parse to handle default values
|
|
@@ -194,16 +195,18 @@ function extractFunctions(text, file) {
|
|
|
194
195
|
const after = line.slice(p + 1);
|
|
195
196
|
if (!/^\s*(?:->\s*[^:]+)?:\s*(?:#.*)?$/.test(after)) continue;
|
|
196
197
|
const params = _splitArgs(paramsText).map(s => s.trim().split(/[:=]/)[0].trim()).filter(Boolean);
|
|
198
|
+
consumed.add(i + 1);
|
|
197
199
|
// Collect body lines: anything indented strictly more than `indent`
|
|
198
200
|
// until we hit a line with same-or-less indent.
|
|
199
201
|
const body = [];
|
|
200
202
|
let j = i + 1;
|
|
201
203
|
while (j < lines.length) {
|
|
202
204
|
const l = lines[j];
|
|
203
|
-
if (l.trim() === '') { body.push({ line: j + 1, text: '' }); j++; continue; }
|
|
205
|
+
if (l.trim() === '') { body.push({ line: j + 1, text: '' }); consumed.add(j + 1); j++; continue; }
|
|
204
206
|
const li = l.match(/^(\s*)/)[1].length;
|
|
205
207
|
if (li <= indent) break;
|
|
206
208
|
body.push({ line: j + 1, text: l.slice(indent + 4) }); // strip one indent
|
|
209
|
+
consumed.add(j + 1);
|
|
207
210
|
j++;
|
|
208
211
|
}
|
|
209
212
|
fns.push({
|
|
@@ -214,7 +217,22 @@ function extractFunctions(text, file) {
|
|
|
214
217
|
body,
|
|
215
218
|
});
|
|
216
219
|
}
|
|
217
|
-
return fns;
|
|
220
|
+
return { fns, consumed, lines };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// R14(b): the complement of extractFunctions' consumed lines is the
|
|
224
|
+
// module-level (top-level) statement text, lowered through the same
|
|
225
|
+
// buildCfg() every real function body already uses. Only line text is
|
|
226
|
+
// needed — buildCfg/_classifyLine already trim() before matching, so
|
|
227
|
+
// leading indentation on a stray line is harmless.
|
|
228
|
+
function _moduleLevelBody(lines, consumed) {
|
|
229
|
+
const body = [];
|
|
230
|
+
for (let i = 0; i < lines.length; i++) {
|
|
231
|
+
const lineNo = i + 1;
|
|
232
|
+
if (consumed.has(lineNo)) continue;
|
|
233
|
+
body.push({ line: lineNo, text: lines[i] });
|
|
234
|
+
}
|
|
235
|
+
return body;
|
|
218
236
|
}
|
|
219
237
|
|
|
220
238
|
// ── Build CFG from a function's body lines ──────────────────────────────
|
|
@@ -295,7 +313,7 @@ export function parsePythonFile(file, raw) {
|
|
|
295
313
|
if (!file || !raw || typeof raw !== 'string') return null;
|
|
296
314
|
if (!/\.py$/i.test(file)) return null;
|
|
297
315
|
if (raw.length > 1_000_000) return null;
|
|
298
|
-
const fnRecs = extractFunctions(raw, file);
|
|
316
|
+
const { fns: fnRecs, consumed, lines } = extractFunctions(raw, file);
|
|
299
317
|
const functions = fnRecs.map(fn => ({
|
|
300
318
|
qid: fn.qid,
|
|
301
319
|
name: fn.name,
|
|
@@ -304,9 +322,18 @@ export function parsePythonFile(file, raw) {
|
|
|
304
322
|
cfg: buildCfg(fn),
|
|
305
323
|
file,
|
|
306
324
|
}));
|
|
325
|
+
const modBody = _moduleLevelBody(lines, consumed);
|
|
326
|
+
const modCfg = buildCfg({ body: modBody });
|
|
327
|
+
const modHasContent = Object.values(modCfg.nodes).some(n => n.kind !== 'entry' && n.kind !== 'exit' && n.kind !== 'noop');
|
|
328
|
+
let topLevel = null;
|
|
329
|
+
if (modHasContent) {
|
|
330
|
+
const modQid = `${file}::module::<module>`;
|
|
331
|
+
functions.push({ qid: modQid, name: '<module>', line: 1, params: [], cfg: modCfg, file });
|
|
332
|
+
topLevel = modQid;
|
|
333
|
+
}
|
|
307
334
|
return {
|
|
308
335
|
file,
|
|
309
336
|
functions,
|
|
310
|
-
topLevel
|
|
337
|
+
topLevel,
|
|
311
338
|
};
|
|
312
339
|
}
|
package/src/ir/parser-rb.js
CHANGED
|
@@ -21,8 +21,21 @@
|
|
|
21
21
|
// failure (heredocs, multi-line strings can confuse the regex parser).
|
|
22
22
|
|
|
23
23
|
import * as crypto from 'node:crypto';
|
|
24
|
+
import { callSitesFromCfg } from './call-sites.js';
|
|
25
|
+
import { matchBalancedCall } from './balanced-call.js';
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
// `[ \t]*` before the optional parameter list, NOT `\s*`.
|
|
28
|
+
//
|
|
29
|
+
// `\s*` crosses newlines, so for `def show\n c = params[:c]` the match ran to
|
|
30
|
+
// the next line's indentation. `parseRubyFile` then computes the body start as
|
|
31
|
+
// `indexOf('\n', m.index + m[0].length)`, which landed on the newline at the END
|
|
32
|
+
// of the first statement — so the body was sliced from after it and statement 1
|
|
33
|
+
// of EVERY Ruby method was silently discarded (a single-statement body became
|
|
34
|
+
// empty). In a Rails controller that first statement is almost always the
|
|
35
|
+
// `params` read, i.e. the taint source, which is why `bench/layer-recall`
|
|
36
|
+
// measured Ruby at 0/20 IR-TAINT recall while all 20 corpus entries passed on
|
|
37
|
+
// the regex layer. A parameter list on the same line still matches.
|
|
38
|
+
const DEF_RE = /(?:^|\n)[ \t]*def\s+(?:self\.)?(\w+[?!=]?)[ \t]*(?:\(([^)]*)\))?/g;
|
|
26
39
|
|
|
27
40
|
function _extractRubyBody(src, defEnd) {
|
|
28
41
|
let depth = 1;
|
|
@@ -66,33 +79,48 @@ function _extractRubyBody(src, defEnd) {
|
|
|
66
79
|
const _RB_OPENERS = /^(?:if|unless|while|until|for|case|begin|do)\b/;
|
|
67
80
|
const _RB_BLOCK_KW = /\b(?:def|class|module|if|unless|while|until|for|case|begin|do)\b/;
|
|
68
81
|
|
|
82
|
+
// Returns `{ text, line }[]` — `line` is the 1-indexed line, relative to the
|
|
83
|
+
// START of `body`, where that statement's text begins (the array index of
|
|
84
|
+
// its first raw source line, +1). For a multi-line if/while/until block
|
|
85
|
+
// this is the line of the OPENING keyword, not of `end`. Tracking this
|
|
86
|
+
// directly from each raw line's position — rather than recomputing it
|
|
87
|
+
// afterwards by counting newlines inside the joined, already-trimmed
|
|
88
|
+
// statement text — avoids silently losing blank/comment lines that were
|
|
89
|
+
// skipped along the way (they're dropped entirely by the `!line` guard
|
|
90
|
+
// below and never contribute to any count once the text is joined). See
|
|
91
|
+
// parser-php.js's twin fix and comment for the full rationale (Finding 2 of
|
|
92
|
+
// the R14(b) final whole-branch review) — this is the same root bug in a
|
|
93
|
+
// per-line splitter instead of a per-semicolon one.
|
|
69
94
|
function _splitStatements(body) {
|
|
70
95
|
const lines = body.split('\n');
|
|
71
96
|
const out = [];
|
|
72
97
|
let buf = '';
|
|
98
|
+
let bufLine = 0;
|
|
73
99
|
let depth = 0;
|
|
74
|
-
|
|
100
|
+
lines.forEach((rawLine, idx) => {
|
|
101
|
+
const lineNo = idx + 1;
|
|
75
102
|
const line = rawLine.trim();
|
|
76
|
-
if (!line || line.startsWith('#'))
|
|
103
|
+
if (!line || line.startsWith('#')) return;
|
|
77
104
|
if (depth === 0 && _RB_OPENERS.test(line)) {
|
|
78
|
-
if (buf.trim()) out.push(buf.trim());
|
|
105
|
+
if (buf.trim()) out.push({ text: buf.trim(), line: bufLine });
|
|
79
106
|
buf = line + '\n';
|
|
107
|
+
bufLine = lineNo;
|
|
80
108
|
for (const m of line.matchAll(/\b(?:if|unless|while|until|for|case|begin|do|def|class|module)\b/g)) depth++;
|
|
81
109
|
if (/\bend\b/.test(line)) depth--;
|
|
82
|
-
if (depth <= 0) { depth = 0; out.push(buf.trim()); buf = ''; }
|
|
83
|
-
|
|
110
|
+
if (depth <= 0) { depth = 0; out.push({ text: buf.trim(), line: bufLine }); buf = ''; }
|
|
111
|
+
return;
|
|
84
112
|
}
|
|
85
113
|
if (depth > 0) {
|
|
86
114
|
buf += line + '\n';
|
|
87
115
|
for (const m of line.matchAll(/\b(?:if|unless|while|until|for|case|begin|do|def|class|module)\b/g)) depth++;
|
|
88
116
|
const endMatches = line.match(/\bend\b/g);
|
|
89
117
|
if (endMatches) depth -= endMatches.length;
|
|
90
|
-
if (depth <= 0) { depth = 0; out.push(buf.trim()); buf = ''; }
|
|
91
|
-
|
|
118
|
+
if (depth <= 0) { depth = 0; out.push({ text: buf.trim(), line: bufLine }); buf = ''; }
|
|
119
|
+
return;
|
|
92
120
|
}
|
|
93
|
-
out.push(line);
|
|
94
|
-
}
|
|
95
|
-
if (buf.trim()) out.push(buf.trim());
|
|
121
|
+
out.push({ text: line, line: lineNo });
|
|
122
|
+
});
|
|
123
|
+
if (buf.trim()) out.push({ text: buf.trim(), line: bufLine });
|
|
96
124
|
return out;
|
|
97
125
|
}
|
|
98
126
|
|
|
@@ -110,10 +138,15 @@ function _lowerExpr(text) {
|
|
|
110
138
|
if (/^(true|false|nil)\b/.test(s)) return { kind: 'literal', value: s };
|
|
111
139
|
// Symbol
|
|
112
140
|
if (/^:\w+/.test(s)) return { kind: 'literal', value: s };
|
|
113
|
-
// Call: obj.method(args) or method(args)
|
|
114
|
-
|
|
141
|
+
// Call: obj.method(args) or method(args). matchBalancedCall finds the
|
|
142
|
+
// paren that actually balances the FIRST '(' — not the greedy-to-end-of-
|
|
143
|
+
// string match the old `/\((.*)\)\s*$/` used, which corrupted the
|
|
144
|
+
// argument text for a chained call (`sanitize(x).strip` produced
|
|
145
|
+
// args="x).strip", which then fell through to {kind:'unknown'} and
|
|
146
|
+
// silently dropped x).
|
|
147
|
+
const callMatch = matchBalancedCall(s, /^([\w.]+)/);
|
|
115
148
|
if (callMatch) {
|
|
116
|
-
return { kind: 'call', callee: callMatch
|
|
149
|
+
return { kind: 'call', callee: callMatch.callee, args: _splitTopLevelCommas(callMatch.argsText).map(_lowerExpr) };
|
|
117
150
|
}
|
|
118
151
|
// Method call without parens is very common in Ruby but hard to detect
|
|
119
152
|
// reliably with regex. We handle the explicit-paren form above.
|
|
@@ -179,13 +212,24 @@ function _lowerStmt(stmt, line) {
|
|
|
179
212
|
return { kind: 'assign', line, target: assign[1], source: _lowerExpr(assign[2]) };
|
|
180
213
|
}
|
|
181
214
|
// Statement-form call with parens
|
|
182
|
-
const call = s
|
|
215
|
+
const call = matchBalancedCall(s, /^([\w.]+)/);
|
|
183
216
|
if (call) {
|
|
184
|
-
return { kind: 'call', line, callee: call
|
|
217
|
+
return { kind: 'call', line, callee: call.callee, args: _splitTopLevelCommas(call.argsText).map(_lowerExpr) };
|
|
185
218
|
}
|
|
186
219
|
// Statement-form call without parens (common Ruby idiom): redirect_to expr
|
|
220
|
+
//
|
|
221
|
+
// `class` and `module` must be excluded here: before R14(b), top-level
|
|
222
|
+
// text was never fed through `_lowerStmt` at all, so a wrapper like
|
|
223
|
+
// `class Foo < ApplicationController` never reached this heuristic. Now
|
|
224
|
+
// that every top-level statement is, an unguarded match lowers it to a
|
|
225
|
+
// bogus `{kind:'call', callee:'class', ...}` node — and since nearly
|
|
226
|
+
// every Ruby file wraps its top-level content in a `class`/`module`
|
|
227
|
+
// (an extremely common idiom), this alone caused most of this repo's own
|
|
228
|
+
// Ruby fixtures to gain a spurious `<module>` entry containing nothing
|
|
229
|
+
// but this one bogus node, contradicting the "zero existing fixtures
|
|
230
|
+
// gain a <module> entry" constraint for Ruby specifically.
|
|
187
231
|
const bareCall = s.match(/^([a-z_]\w*)\s+(.+)$/s);
|
|
188
|
-
if (bareCall && /^[a-z_]/.test(bareCall[1]) && !/^(?:if|unless|while|until|for|case|when|elsif|else|end|return|raise|require|include|extend|attr_\w
|
|
232
|
+
if (bareCall && /^[a-z_]/.test(bareCall[1]) && !/^(?:if|unless|while|until|for|case|when|elsif|else|end|return|raise|require|include|extend|attr_\w+|class|module)$/.test(bareCall[1])) {
|
|
189
233
|
return { kind: 'call', line, callee: bareCall[1], args: [_lowerExpr(bareCall[2])] };
|
|
190
234
|
}
|
|
191
235
|
return null;
|
|
@@ -202,6 +246,47 @@ function _qid(file, name, line, body) {
|
|
|
202
246
|
return `${file}::${name}@${line}#${sha}`;
|
|
203
247
|
}
|
|
204
248
|
|
|
249
|
+
// DEF_RE's leading alternation `(?:^|\n)` matches a single boundary
|
|
250
|
+
// character (the newline ending the PRECEDING line) that belongs to
|
|
251
|
+
// whatever precedes the def, not to the def itself. `m.index` always points
|
|
252
|
+
// at the START of that boundary. `_blank` (below) never touches actual `\n`
|
|
253
|
+
// characters — only non-newline characters are turned into spaces — so
|
|
254
|
+
// including this boundary newline in the def's span would be harmless
|
|
255
|
+
// either way; this still excludes it, purely so the span's meaning (source
|
|
256
|
+
// consumed by the def declaration) doesn't include a character that
|
|
257
|
+
// belongs to the previous line, mirroring parser-php.js's twin fix (there
|
|
258
|
+
// the equivalent boundary CAN be a non-newline character like `;`, which
|
|
259
|
+
// does need excluding).
|
|
260
|
+
function _defBoundaryLen(idx) {
|
|
261
|
+
return idx === 0 ? 0 : 1;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Blank out every real def's span in a COPY of the full source (replace its
|
|
265
|
+
// characters with spaces, preserving every newline exactly). This lets the
|
|
266
|
+
// WHOLE file be lowered in a single _buildCfg call at startLine=1 for the
|
|
267
|
+
// module-level CFG, which keeps every remaining statement's reported line
|
|
268
|
+
// number exactly equal to its real source line — no character is ever
|
|
269
|
+
// deleted, only turned into a space, so nothing can shift. This replaces
|
|
270
|
+
// the old per-gap slicing + per-gap startLine re-derivation, which
|
|
271
|
+
// mis-tracked lines whenever a gap slice started with leading
|
|
272
|
+
// blank/newline characters and broke the line-scoped
|
|
273
|
+
// `agentic-security-ignore` suppression pragma for module-level findings.
|
|
274
|
+
function _blankSpans(code, spans) {
|
|
275
|
+
let out = '';
|
|
276
|
+
let cursor = 0;
|
|
277
|
+
for (const span of spans) {
|
|
278
|
+
if (span.start > cursor) out += code.slice(cursor, span.start);
|
|
279
|
+
out += _blank(code.slice(span.start, span.end));
|
|
280
|
+
cursor = span.end;
|
|
281
|
+
}
|
|
282
|
+
out += code.slice(cursor);
|
|
283
|
+
return out;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function _blank(text) {
|
|
287
|
+
return text.replace(/[^\n]/g, ' ');
|
|
288
|
+
}
|
|
289
|
+
|
|
205
290
|
let _nid = 0;
|
|
206
291
|
function _nextId() { return `rn${++_nid}`; }
|
|
207
292
|
|
|
@@ -225,13 +310,22 @@ function _extractRubyBlockBody(compound) {
|
|
|
225
310
|
return lines.slice(1, -1).join('\n');
|
|
226
311
|
}
|
|
227
312
|
|
|
313
|
+
// `startLine` is the absolute source line of the FIRST raw line of
|
|
314
|
+
// `bodyText`. Each statement's absolute line is `startLine + stmt.line - 1`
|
|
315
|
+
// (`stmt.line` from _splitStatements is already 1-indexed and relative to
|
|
316
|
+
// `bodyText`), so — unlike the old incremental `line++`/`line += newlines+1`
|
|
317
|
+
// bookkeeping this replaced — no line is ever derived by re-counting
|
|
318
|
+
// newlines in already-joined, already-trimmed text. That old scheme
|
|
319
|
+
// silently dropped any blank line (or, at module level, any blanked-out def
|
|
320
|
+
// span — see `_blankSpans`) that preceded a statement, which is exactly
|
|
321
|
+
// what made module-level Ruby findings report the wrong source line.
|
|
228
322
|
function _buildCfg(bodyText, nodes, prevId, startLine) {
|
|
229
323
|
const stmts = _splitStatements(bodyText);
|
|
230
324
|
let prev = prevId;
|
|
231
|
-
let line = startLine;
|
|
232
325
|
for (const stmt of stmts) {
|
|
233
|
-
const s = stmt.
|
|
234
|
-
|
|
326
|
+
const s = stmt.text;
|
|
327
|
+
const line = startLine + stmt.line - 1;
|
|
328
|
+
if (!s || s.startsWith('#')) continue;
|
|
235
329
|
|
|
236
330
|
const ifMatch = s.match(/^(if|unless)\s+(.+)$/m);
|
|
237
331
|
if (ifMatch && /\bend\b\s*$/.test(s)) {
|
|
@@ -244,7 +338,6 @@ function _buildCfg(bodyText, nodes, prevId, startLine) {
|
|
|
244
338
|
_linkNodes(nodes, thenTail, join);
|
|
245
339
|
_linkNodes(nodes, ifNode, join);
|
|
246
340
|
prev = join;
|
|
247
|
-
line += (s.match(/\n/g) || []).length + 1;
|
|
248
341
|
continue;
|
|
249
342
|
}
|
|
250
343
|
|
|
@@ -258,16 +351,14 @@ function _buildCfg(bodyText, nodes, prevId, startLine) {
|
|
|
258
351
|
const join = _addNode(nodes, { kind: 'noop', line });
|
|
259
352
|
_linkNodes(nodes, header, join);
|
|
260
353
|
prev = join;
|
|
261
|
-
line += (s.match(/\n/g) || []).length + 1;
|
|
262
354
|
continue;
|
|
263
355
|
}
|
|
264
356
|
|
|
265
357
|
const node = _lowerStmt(s, line);
|
|
266
|
-
if (!node)
|
|
358
|
+
if (!node) continue;
|
|
267
359
|
const id = _addNode(nodes, node);
|
|
268
360
|
_linkNodes(nodes, prev, id);
|
|
269
361
|
prev = id;
|
|
270
|
-
line += (s.match(/\n/g) || []).length + 1;
|
|
271
362
|
}
|
|
272
363
|
return prev;
|
|
273
364
|
}
|
|
@@ -278,6 +369,7 @@ export function parseRubyFile(file, code) {
|
|
|
278
369
|
if (code.length > 1_000_000) return null;
|
|
279
370
|
|
|
280
371
|
const functions = [];
|
|
372
|
+
const spans = []; // {start, end}: source ranges fully consumed by a matched def (header through matching `end`)
|
|
281
373
|
DEF_RE.lastIndex = 0;
|
|
282
374
|
_nid = 0;
|
|
283
375
|
let m;
|
|
@@ -298,12 +390,55 @@ export function parseRubyFile(file, code) {
|
|
|
298
390
|
const exit = _addNode(nodes, { kind: 'exit', line: startLine });
|
|
299
391
|
const tail = _buildCfg(extracted.body, nodes, entry, startLine + 1);
|
|
300
392
|
_linkNodes(nodes, tail, exit);
|
|
393
|
+
const cfg = { entry, exit, nodes };
|
|
301
394
|
functions.push({
|
|
302
395
|
qid: _qid(file, name, startLine, extracted.body),
|
|
303
396
|
name, line: startLine, params, file,
|
|
304
|
-
cfg
|
|
397
|
+
cfg,
|
|
398
|
+
// Ruby never emitted `fn.calls` at all (ir/CLAUDE.md documents this
|
|
399
|
+
// as a known gap) — tabulation.js, dataflow/index.js and
|
|
400
|
+
// callgraph.js all read it to build call edges, so an absent array
|
|
401
|
+
// is indistinguishable from "calls nothing," disabling ALL Ruby
|
|
402
|
+
// interprocedural taint. call-sites.js's callSitesFromCfg is the
|
|
403
|
+
// same language-agnostic CFG walk parser-py-cst.js already uses for
|
|
404
|
+
// exactly this; Ruby's node shapes ('call' with callee/args,
|
|
405
|
+
// 'assign' with source, 'return'/'throw' with value, 'if' with
|
|
406
|
+
// cond) match its documented contract already, so no new lowering
|
|
407
|
+
// logic is needed here — only wiring the call.
|
|
408
|
+
calls: callSitesFromCfg(cfg),
|
|
305
409
|
});
|
|
410
|
+
spans.push({ start: m.index + _defBoundaryLen(m.index), end: extracted.end });
|
|
306
411
|
DEF_RE.lastIndex = extracted.end;
|
|
307
412
|
}
|
|
308
|
-
|
|
413
|
+
|
|
414
|
+
// R14(b): lower top-level (module-scope) statements into a synthetic
|
|
415
|
+
// <module> function, mirroring parser-js.js's Program-level lowering.
|
|
416
|
+
// Every real def's span is blanked (see _blankSpans) in a copy of the
|
|
417
|
+
// full source, and the WHOLE blanked text is lowered in a single
|
|
418
|
+
// _buildCfg call at startLine=1 — this keeps every remaining statement's
|
|
419
|
+
// reported line number exactly equal to its real source line, since no
|
|
420
|
+
// character is ever deleted, only blanked to a space (newlines always
|
|
421
|
+
// survive). Same approach as parser-php.js's twin fix.
|
|
422
|
+
spans.sort((a, b) => a.start - b.start);
|
|
423
|
+
const blanked = _blankSpans(code, spans);
|
|
424
|
+
const modNodes = {};
|
|
425
|
+
const modEntry = _addNode(modNodes, { kind: 'entry', line: 1 });
|
|
426
|
+
const modExit = _addNode(modNodes, { kind: 'exit', line: 1 });
|
|
427
|
+
const modTail = _buildCfg(blanked, modNodes, modEntry, 1);
|
|
428
|
+
_linkNodes(modNodes, modTail, modExit);
|
|
429
|
+
const modHasContent = Object.values(modNodes).some(n => n.kind !== 'entry' && n.kind !== 'exit');
|
|
430
|
+
let topLevel = null;
|
|
431
|
+
if (modHasContent) {
|
|
432
|
+
const moduleCfg = { entry: modEntry, exit: modExit, nodes: modNodes };
|
|
433
|
+
const modQid = _qid(file, '<module>', 1, code);
|
|
434
|
+
functions.push({
|
|
435
|
+
qid: modQid,
|
|
436
|
+
name: '<module>', line: 1, params: [], file,
|
|
437
|
+
cfg: moduleCfg,
|
|
438
|
+
calls: callSitesFromCfg(moduleCfg),
|
|
439
|
+
});
|
|
440
|
+
topLevel = modQid;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return functions.length ? { file, functions, topLevel } : null;
|
|
309
444
|
}
|
package/src/ir/ssa.js
CHANGED
|
@@ -43,7 +43,12 @@ export function isSSAEnabled() {
|
|
|
43
43
|
*
|
|
44
44
|
* Returns Map<nodeId, Set<nodeId>> — dom[n] = set of nodes that dominate n.
|
|
45
45
|
*/
|
|
46
|
-
|
|
46
|
+
// Exported (Stage 6 correctness audit) so dataflow/implicit-flow.js can
|
|
47
|
+
// reuse this tested algorithm to correctly scope "is node N genuinely
|
|
48
|
+
// inside branch B" (N is inside iff B dominates N — every path to N passes
|
|
49
|
+
// through B) instead of a path-dependent DFS depth counter that had no way
|
|
50
|
+
// to detect a branch's join point.
|
|
51
|
+
export function computeDominators(cfg) {
|
|
47
52
|
const nodes = Object.keys(cfg.nodes || {});
|
|
48
53
|
const entry = cfg.entry;
|
|
49
54
|
const dom = new Map();
|
package/src/lsp/server.js
CHANGED
|
@@ -18,6 +18,8 @@ import * as path from 'node:path';
|
|
|
18
18
|
import * as readline from 'node:readline';
|
|
19
19
|
import { runScan } from '../runScan.js';
|
|
20
20
|
import { resetCustomRulesBudget } from '../posture/custom-rules.js';
|
|
21
|
+
import { redactFinding } from '../mcp/redact.js';
|
|
22
|
+
import { _remediationOf } from '../report/index.js';
|
|
21
23
|
|
|
22
24
|
const PROTOCOL_VERSION = '3.17';
|
|
23
25
|
const SERVER_NAME = 'agentic-security-lsp';
|
|
@@ -52,6 +54,13 @@ function sevToLsp(sev) {
|
|
|
52
54
|
|
|
53
55
|
function findingToDiagnostic(f) {
|
|
54
56
|
const line = Math.max(0, (f.line || 1) - 1);
|
|
57
|
+
// Stage 6 correctness audit: this read f.remediation directly, but raw
|
|
58
|
+
// scan.findings entries (what this consumes, pre-normalizeFindings) come
|
|
59
|
+
// from two conventions — most posture/*.js and newer sast/*.js modules
|
|
60
|
+
// set `remediation`, while ~127 of engine.js's own detectors set a `fix`
|
|
61
|
+
// STRING field instead. _remediationOf carries the same precedence
|
|
62
|
+
// report/index.js already established for this exact split (CMP-3).
|
|
63
|
+
const remediation = _remediationOf(f);
|
|
55
64
|
return {
|
|
56
65
|
range: {
|
|
57
66
|
start: { line, character: 0 },
|
|
@@ -60,7 +69,7 @@ function findingToDiagnostic(f) {
|
|
|
60
69
|
severity: sevToLsp(f.severity),
|
|
61
70
|
source: 'agentic-security',
|
|
62
71
|
code: f.cwe || f.family || 'finding',
|
|
63
|
-
message: `${f.vuln || 'Security finding'}${
|
|
72
|
+
message: `${f.vuln || 'Security finding'}${remediation ? '\n\n' + remediation : ''}`.slice(0, 2000),
|
|
64
73
|
tags: [],
|
|
65
74
|
};
|
|
66
75
|
}
|
|
@@ -141,8 +150,27 @@ async function scanFile(uri) {
|
|
|
141
150
|
// the reset, a long-lived LSP server would accumulate budget across saves
|
|
142
151
|
// and eventually start skipping custom rules.
|
|
143
152
|
resetCustomRulesBudget(_rootDir);
|
|
144
|
-
|
|
145
|
-
|
|
153
|
+
// PRD R1 (docs/DETECTION_GAP_REMEDIATION_PRD.md): deep mode is
|
|
154
|
+
// default-on for the interactive CLI scan but was never requested here,
|
|
155
|
+
// so every on-save diagnostic pass was regex/AST-only — blind to any bug
|
|
156
|
+
// whose source and sink are connected only through a call. Scoped to
|
|
157
|
+
// exactly the saved file (fileContents has one entry), so this does not
|
|
158
|
+
// turn every keystroke's save into a full-project deep scan.
|
|
159
|
+
const { scan } = await runScan(_rootDir, { fileContents, depFileContents, deep: true, deepInCi: true });
|
|
160
|
+
// Stage 6 correctness audit: this only ever read scan.findings (the SAST
|
|
161
|
+
// channel). scan.secrets and scan.logicVulns are separate arrays on the
|
|
162
|
+
// raw runScan() result — normalizeFindings is what merges all four
|
|
163
|
+
// channels, and that hasn't run here — so a saved file with a hardcoded
|
|
164
|
+
// credential got a clean problem pane, no diagnostic at all. Unlike the
|
|
165
|
+
// MCP surface, this server never applied redactFinding either (nothing
|
|
166
|
+
// here imported mcp/redact.js), which would have been a landmine the
|
|
167
|
+
// moment secrets/logicVulns were added without it: those channels are
|
|
168
|
+
// exactly where raw secret material shows up in `snippet`. Both fixed
|
|
169
|
+
// together — merge the channels AND redact — so the fix for one gap
|
|
170
|
+
// doesn't open the other.
|
|
171
|
+
const findings = [...(scan.findings || []), ...(scan.secrets || []), ...(scan.logicVulns || [])]
|
|
172
|
+
.filter(f => f.file === rel)
|
|
173
|
+
.map(f => redactFinding(f));
|
|
146
174
|
await publishDiagnostics(uri, findings);
|
|
147
175
|
} catch (e) {
|
|
148
176
|
process.stderr.write(`agentic-security-lsp: scan failed: ${e.message}\n`);
|
|
@@ -273,3 +301,7 @@ export function startLspServer() {
|
|
|
273
301
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
274
302
|
startLspServer();
|
|
275
303
|
}
|
|
304
|
+
|
|
305
|
+
function _setRootDir(dir) { _rootDir = dir; _depCache = { rootDir: null, depFileContents: {} }; }
|
|
306
|
+
|
|
307
|
+
export const _internals = { findingToDiagnostic, scanFile, uriToPath, pathToUri, _diagnosticsByUri, _setRootDir };
|
package/src/mcp/CLAUDE.md
CHANGED
|
@@ -12,14 +12,21 @@ MCP server. JSON-RPC 2.0 over NDJSON on stdin/stdout. Bin entry `../../bin/agent
|
|
|
12
12
|
| `find_rule_module` | ✓ | reads `scanner/src/{sast,posture}/` to answer "which file detects CWE-X / family Y" |
|
|
13
13
|
| `lookup_cve` | ✓ | reads local OSV / KEV / EPSS cache; staleness-tiered |
|
|
14
14
|
| `synthesize_fix` | ✓ | reads last-scan; returns the patch text |
|
|
15
|
-
| `verify_fix` |
|
|
15
|
+
| `verify_fix` | ✗ | re-scans patched files in memory, runs lint + the project test suite + the fix-honesty gate + PoC re-check; does not touch the target project's own files, but appends a record to `.agentic-security/fix-metrics.jsonl` per attempt |
|
|
16
16
|
| `apply_fix` | ✗ | writes via `posture/fix-history.js` (with backup) |
|
|
17
17
|
| `append_scratchpad` | ✗ | writes under `.agentic-security/agent-scratchpad/<agent>/<session>/` only |
|
|
18
18
|
| `read_scratchpad` | ✓ | paginated read of scratchpad files |
|
|
19
19
|
| `append_agents_memory` | ✗ | appends to `.agentic-security/AGENTS.md` continual-learning file |
|
|
20
20
|
| `read_agents_memory` | ✓ | tail of `.agentic-security/AGENTS.md` |
|
|
21
|
+
| `synthesize_sca_upgrade` | ✓ | runs an ecosystem dry-run (`npm install --dry-run` etc.); returns the upgrade plan; no writes |
|
|
22
|
+
| `apply_sca_upgrade` | ✗ | backs up manifests, runs the package manager, runs the project test command, restores manifests on test failure |
|
|
23
|
+
| `query_triage_memory` | ✓ | reads past triage decisions (wont-fix/false-positive) by natural-language query |
|
|
24
|
+
| `query_findings_memory` | ✓ | reads accumulated scan memory (findings + triage history + AGENTS.md) by natural-language query |
|
|
25
|
+
| `query_cache_telemetry` | ✓ | reads prompt-cache economics from the current session transcript; no network |
|
|
21
26
|
|
|
22
|
-
|
|
27
|
+
**17 tools, not 12** — this table previously stopped at 12 and the count quoted elsewhere (root `CLAUDE.md`, the non-Claude plugin manifests) said "Six." Re-derive with `grep -c "name: '" scanner/src/mcp/tools.js` rather than trusting a hardcoded number here again.
|
|
28
|
+
|
|
29
|
+
**Two write tools, not one.** `apply_fix` and `apply_sca_upgrade` both write; `verify_fix` also writes (see its row above) though not to the target project's own files. `apply_fix` additionally requires `confirm:true` AND the last-scan HMAC to verify AND the target path not on the reserved-write list; `apply_sca_upgrade` requires `confirm:true` and gates on its own test-restore cycle.
|
|
23
30
|
|
|
24
31
|
## Hardening posture (OWASP MCP Top 10)
|
|
25
32
|
|
package/src/mcp/redact.js
CHANGED
|
@@ -27,6 +27,32 @@ const PATTERNS = [
|
|
|
27
27
|
[/rk_(?:live|test)_[A-Za-z0-9]{20,}/g, 'stripe-restricted-key'],
|
|
28
28
|
[/SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/g, 'sendgrid-key'],
|
|
29
29
|
[/AIza[0-9A-Za-z_-]{35}/g, 'google-api-key'],
|
|
30
|
+
// Stage 4 correctness audit (coverage breadth, AI security): this list
|
|
31
|
+
// only covered a small subset of what the scanner's OWN credential
|
|
32
|
+
// detector (engine.js's CREDENTIAL_PATTERNS, 40+ provider shapes) finds
|
|
33
|
+
// — a Shopify/Telegram/Twilio/Discord-webhook/Square/Google-OAuth/JDBC
|
|
34
|
+
// secret detected and reported by a scan reached explain_finding's
|
|
35
|
+
// output completely unredacted, because none of those shapes were in
|
|
36
|
+
// THIS separate, narrower list. Reusing the same regex bodies as
|
|
37
|
+
// engine.js's CREDENTIAL_PATTERNS for the shapes verified to leak
|
|
38
|
+
// (rather than importing engine.js itself, which would pull its entire
|
|
39
|
+
// multi-thousand-line module graph into the MCP server's dependency
|
|
40
|
+
// surface for a handful of consts).
|
|
41
|
+
[/ya29\.[0-9A-Za-z_-]{20,}/g, 'google-oauth-token'],
|
|
42
|
+
[/shp(?:at|ss|ca|pa)_[a-fA-F0-9]{32}/g, 'shopify-token'],
|
|
43
|
+
[/(?<![0-9])[0-9]{8,10}:AA[0-9A-Za-z_-]{33}(?![A-Za-z0-9_])/g, 'telegram-bot-token'],
|
|
44
|
+
[/twilio.{0,20}SK[0-9a-fA-F]{32}/gi, 'twilio-api-key'],
|
|
45
|
+
[/sq0atp-[0-9A-Za-z_-]{22}/g, 'square-access-token'],
|
|
46
|
+
[/sq0csp-[0-9A-Za-z_-]{43}/g, 'square-oauth-secret'],
|
|
47
|
+
[/access_token\$production\$[0-9a-z]{16}\$[0-9a-f]{32}/g, 'paypal-braintree-token'],
|
|
48
|
+
[/https:\/\/(?:discordapp|discord)\.com\/api\/webhooks\/[0-9]+\/[A-Za-z0-9_-]+/g, 'discord-webhook'],
|
|
49
|
+
[/https:\/\/hooks\.slack\.com\/services\/T[a-zA-Z0-9_]{8}\/B[a-zA-Z0-9_]{8,12}\/[a-zA-Z0-9_]{24}/g, 'slack-webhook'],
|
|
50
|
+
[/https:\/\/outlook\.office\.com\/webhook\/[A-Za-z0-9\-@]+\/IncomingWebhook\/[A-Za-z0-9-]+\/[A-Za-z0-9-]+/g, 'teams-webhook'],
|
|
51
|
+
[/https:\/\/(?:www\.)?hooks\.zapier\.com\/hooks\/catch\/[A-Za-z0-9]+\/[A-Za-z0-9]+\//g, 'zapier-webhook'],
|
|
52
|
+
// JDBC connection string carrying a password: only redact when password
|
|
53
|
+
// evidence is actually on the line (matches engine.js's own ctx gate),
|
|
54
|
+
// so a credential-free JDBC URL in docs isn't needlessly mangled.
|
|
55
|
+
[/jdbc:[a-z:]+:\/\/[A-Za-z0-9.\-_:;=/@?,&]*(?:@|password=|passwd=|pwd=)[A-Za-z0-9.\-_:;=/@?,&]*/gi, 'jdbc-connection-string'],
|
|
30
56
|
// JWT — three dot-separated b64url segments starting with eyJ
|
|
31
57
|
[/eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, 'jwt'],
|
|
32
58
|
// PEM-encoded private keys
|