@clear-capabilities/agentic-security-scanner 0.136.9 → 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 +644 -0
- package/bin/agentic-security.js +3 -0
- package/dist/435.index.js +9 -1
- package/dist/agentic-security.mjs +3 -3
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +8 -8
- package/src/dataflow/CLAUDE.md +3 -1
- package/src/dataflow/catalog-expanded.js +1 -0
- package/src/dataflow/catalog.js +157 -31
- package/src/dataflow/engine.js +318 -55
- package/src/dataflow/index.js +15 -0
- package/src/dataflow/points-to.js +19 -6
- package/src/engine.js +281 -207
- package/src/ir/CLAUDE.md +14 -3
- package/src/ir/class-hierarchy.js +57 -11
- package/src/ir/index.js +14 -2
- package/src/ir/parser-cs.js +451 -31
- package/src/ir/parser-java.js +205 -2
- package/src/ir/parser-js.js +149 -2
- package/src/ir/parser-kt.js +436 -18
- package/src/ir/parser-php.js +587 -29
- package/src/ir/parser-py.helper.py +32 -2
- package/src/ir/parser-py.js +31 -4
- package/src/ir/parser-rb.js +124 -19
- package/src/lsp/server.js +7 -1
- package/src/mcp/tools.js +9 -1
- package/src/posture/clustering.js +12 -1
- package/src/posture/sbom.js +2 -2
|
@@ -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
|
@@ -79,33 +79,48 @@ function _extractRubyBody(src, defEnd) {
|
|
|
79
79
|
const _RB_OPENERS = /^(?:if|unless|while|until|for|case|begin|do)\b/;
|
|
80
80
|
const _RB_BLOCK_KW = /\b(?:def|class|module|if|unless|while|until|for|case|begin|do)\b/;
|
|
81
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.
|
|
82
94
|
function _splitStatements(body) {
|
|
83
95
|
const lines = body.split('\n');
|
|
84
96
|
const out = [];
|
|
85
97
|
let buf = '';
|
|
98
|
+
let bufLine = 0;
|
|
86
99
|
let depth = 0;
|
|
87
|
-
|
|
100
|
+
lines.forEach((rawLine, idx) => {
|
|
101
|
+
const lineNo = idx + 1;
|
|
88
102
|
const line = rawLine.trim();
|
|
89
|
-
if (!line || line.startsWith('#'))
|
|
103
|
+
if (!line || line.startsWith('#')) return;
|
|
90
104
|
if (depth === 0 && _RB_OPENERS.test(line)) {
|
|
91
|
-
if (buf.trim()) out.push(buf.trim());
|
|
105
|
+
if (buf.trim()) out.push({ text: buf.trim(), line: bufLine });
|
|
92
106
|
buf = line + '\n';
|
|
107
|
+
bufLine = lineNo;
|
|
93
108
|
for (const m of line.matchAll(/\b(?:if|unless|while|until|for|case|begin|do|def|class|module)\b/g)) depth++;
|
|
94
109
|
if (/\bend\b/.test(line)) depth--;
|
|
95
|
-
if (depth <= 0) { depth = 0; out.push(buf.trim()); buf = ''; }
|
|
96
|
-
|
|
110
|
+
if (depth <= 0) { depth = 0; out.push({ text: buf.trim(), line: bufLine }); buf = ''; }
|
|
111
|
+
return;
|
|
97
112
|
}
|
|
98
113
|
if (depth > 0) {
|
|
99
114
|
buf += line + '\n';
|
|
100
115
|
for (const m of line.matchAll(/\b(?:if|unless|while|until|for|case|begin|do|def|class|module)\b/g)) depth++;
|
|
101
116
|
const endMatches = line.match(/\bend\b/g);
|
|
102
117
|
if (endMatches) depth -= endMatches.length;
|
|
103
|
-
if (depth <= 0) { depth = 0; out.push(buf.trim()); buf = ''; }
|
|
104
|
-
|
|
118
|
+
if (depth <= 0) { depth = 0; out.push({ text: buf.trim(), line: bufLine }); buf = ''; }
|
|
119
|
+
return;
|
|
105
120
|
}
|
|
106
|
-
out.push(line);
|
|
107
|
-
}
|
|
108
|
-
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 });
|
|
109
124
|
return out;
|
|
110
125
|
}
|
|
111
126
|
|
|
@@ -202,8 +217,19 @@ function _lowerStmt(stmt, line) {
|
|
|
202
217
|
return { kind: 'call', line, callee: call.callee, args: _splitTopLevelCommas(call.argsText).map(_lowerExpr) };
|
|
203
218
|
}
|
|
204
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.
|
|
205
231
|
const bareCall = s.match(/^([a-z_]\w*)\s+(.+)$/s);
|
|
206
|
-
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])) {
|
|
207
233
|
return { kind: 'call', line, callee: bareCall[1], args: [_lowerExpr(bareCall[2])] };
|
|
208
234
|
}
|
|
209
235
|
return null;
|
|
@@ -220,6 +246,47 @@ function _qid(file, name, line, body) {
|
|
|
220
246
|
return `${file}::${name}@${line}#${sha}`;
|
|
221
247
|
}
|
|
222
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
|
+
|
|
223
290
|
let _nid = 0;
|
|
224
291
|
function _nextId() { return `rn${++_nid}`; }
|
|
225
292
|
|
|
@@ -243,13 +310,22 @@ function _extractRubyBlockBody(compound) {
|
|
|
243
310
|
return lines.slice(1, -1).join('\n');
|
|
244
311
|
}
|
|
245
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.
|
|
246
322
|
function _buildCfg(bodyText, nodes, prevId, startLine) {
|
|
247
323
|
const stmts = _splitStatements(bodyText);
|
|
248
324
|
let prev = prevId;
|
|
249
|
-
let line = startLine;
|
|
250
325
|
for (const stmt of stmts) {
|
|
251
|
-
const s = stmt.
|
|
252
|
-
|
|
326
|
+
const s = stmt.text;
|
|
327
|
+
const line = startLine + stmt.line - 1;
|
|
328
|
+
if (!s || s.startsWith('#')) continue;
|
|
253
329
|
|
|
254
330
|
const ifMatch = s.match(/^(if|unless)\s+(.+)$/m);
|
|
255
331
|
if (ifMatch && /\bend\b\s*$/.test(s)) {
|
|
@@ -262,7 +338,6 @@ function _buildCfg(bodyText, nodes, prevId, startLine) {
|
|
|
262
338
|
_linkNodes(nodes, thenTail, join);
|
|
263
339
|
_linkNodes(nodes, ifNode, join);
|
|
264
340
|
prev = join;
|
|
265
|
-
line += (s.match(/\n/g) || []).length + 1;
|
|
266
341
|
continue;
|
|
267
342
|
}
|
|
268
343
|
|
|
@@ -276,16 +351,14 @@ function _buildCfg(bodyText, nodes, prevId, startLine) {
|
|
|
276
351
|
const join = _addNode(nodes, { kind: 'noop', line });
|
|
277
352
|
_linkNodes(nodes, header, join);
|
|
278
353
|
prev = join;
|
|
279
|
-
line += (s.match(/\n/g) || []).length + 1;
|
|
280
354
|
continue;
|
|
281
355
|
}
|
|
282
356
|
|
|
283
357
|
const node = _lowerStmt(s, line);
|
|
284
|
-
if (!node)
|
|
358
|
+
if (!node) continue;
|
|
285
359
|
const id = _addNode(nodes, node);
|
|
286
360
|
_linkNodes(nodes, prev, id);
|
|
287
361
|
prev = id;
|
|
288
|
-
line += (s.match(/\n/g) || []).length + 1;
|
|
289
362
|
}
|
|
290
363
|
return prev;
|
|
291
364
|
}
|
|
@@ -296,6 +369,7 @@ export function parseRubyFile(file, code) {
|
|
|
296
369
|
if (code.length > 1_000_000) return null;
|
|
297
370
|
|
|
298
371
|
const functions = [];
|
|
372
|
+
const spans = []; // {start, end}: source ranges fully consumed by a matched def (header through matching `end`)
|
|
299
373
|
DEF_RE.lastIndex = 0;
|
|
300
374
|
_nid = 0;
|
|
301
375
|
let m;
|
|
@@ -333,7 +407,38 @@ export function parseRubyFile(file, code) {
|
|
|
333
407
|
// logic is needed here — only wiring the call.
|
|
334
408
|
calls: callSitesFromCfg(cfg),
|
|
335
409
|
});
|
|
410
|
+
spans.push({ start: m.index + _defBoundaryLen(m.index), end: extracted.end });
|
|
336
411
|
DEF_RE.lastIndex = extracted.end;
|
|
337
412
|
}
|
|
338
|
-
|
|
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;
|
|
339
444
|
}
|
package/src/lsp/server.js
CHANGED
|
@@ -150,7 +150,13 @@ async function scanFile(uri) {
|
|
|
150
150
|
// the reset, a long-lived LSP server would accumulate budget across saves
|
|
151
151
|
// and eventually start skipping custom rules.
|
|
152
152
|
resetCustomRulesBudget(_rootDir);
|
|
153
|
-
|
|
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 });
|
|
154
160
|
// Stage 6 correctness audit: this only ever read scan.findings (the SAST
|
|
155
161
|
// channel). scan.secrets and scan.logicVulns are separate arrays on the
|
|
156
162
|
// raw runScan() result — normalizeFindings is what merges all four
|
package/src/mcp/tools.js
CHANGED
|
@@ -338,8 +338,16 @@ export const scan_diff = {
|
|
|
338
338
|
fileContents[rel] = content;
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
+
// PRD R1 (docs/DETECTION_GAP_REMEDIATION_PRD.md): deep mode is default-on
|
|
342
|
+
// for the interactive CLI scan but was never requested here, so an
|
|
343
|
+
// agent's pre-write self-correction scan was regex/AST-only — blind to
|
|
344
|
+
// any bug whose source and sink are connected only through a call
|
|
345
|
+
// (`fileContents` scopes the deep engine's IR to exactly the files
|
|
346
|
+
// passed in, same bound this tool already enforces via MAX_FILES_PER_SCAN
|
|
347
|
+
// / MAX_TOTAL_SCAN_BYTES, so this does not turn scan_diff into a
|
|
348
|
+
// full-project deep scan).
|
|
341
349
|
const runScan = await getRunScan();
|
|
342
|
-
const result = await runScan(sessionRoot, { network: false, fileContents });
|
|
350
|
+
const result = await runScan(sessionRoot, { network: false, fileContents, deep: true, deepInCi: true });
|
|
343
351
|
const wantSet = new Set(Object.keys(fileContents));
|
|
344
352
|
const sevRank = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
|
345
353
|
const min = sevRank[severity] ?? 0;
|
|
@@ -28,7 +28,18 @@ function sinkKey(f) {
|
|
|
28
28
|
const parser = f.parser || '';
|
|
29
29
|
const rule = f.cwe || f.family || (f.vuln || '').slice(0, 40);
|
|
30
30
|
const file = f.file || f.sink?.file || '';
|
|
31
|
-
|
|
31
|
+
// PRD R3: dataflow/engine.js sets sink.label to the catalog rule id
|
|
32
|
+
// (f.sinkId) for IR-TAINT findings — the same generic string for every
|
|
33
|
+
// callsite of that rule, not a per-callsite snippet. Two textually-unrelated
|
|
34
|
+
// sinks sharing a rule (e.g. two independent eval() calls in one file) would
|
|
35
|
+
// otherwise collapse into one cluster and silently drop a real finding. The
|
|
36
|
+
// dedup pass upstream (dedupeFindingsWithEvidence) already collapses
|
|
37
|
+
// multiple sources converging on the SAME sink line, so every IR-TAINT
|
|
38
|
+
// finding reaching this point has a distinct line — folding the line into
|
|
39
|
+
// the key here can only split buckets more finely, never wrongly merge one.
|
|
40
|
+
const sinkExpr = (parser === 'IR-TAINT'
|
|
41
|
+
? `L${f.sink?.line || f.line || 0}:${f.sink?.label || ''}`
|
|
42
|
+
: (f.sink?.label || f.sink?.snippet || f.snippet || ''))
|
|
32
43
|
.replace(/['"`][^'"`]*['"`]/g, '_S_')
|
|
33
44
|
.replace(/\s+/g, ' ')
|
|
34
45
|
.trim()
|
package/src/posture/sbom.js
CHANGED
|
@@ -61,7 +61,7 @@ export function toCycloneDX(scan, meta = {}) {
|
|
|
61
61
|
version: 1,
|
|
62
62
|
metadata: {
|
|
63
63
|
timestamp: meta.startedAt || new Date().toISOString(),
|
|
64
|
-
tools: [{ vendor: 'Clear Capabilities', name: 'agentic-security', version:
|
|
64
|
+
tools: [{ vendor: 'Clear Capabilities', name: 'agentic-security', version: meta.engineVersion || 'dev' }],
|
|
65
65
|
component: { type: 'application', name: 'scan-target', version: '1.0.0' },
|
|
66
66
|
},
|
|
67
67
|
components: cdxComponents,
|
|
@@ -117,7 +117,7 @@ export function toSPDX(scan, meta = {}) {
|
|
|
117
117
|
documentNamespace: docNamespace,
|
|
118
118
|
creationInfo: {
|
|
119
119
|
created: ts,
|
|
120
|
-
creators: [
|
|
120
|
+
creators: [`Tool: agentic-security-${meta.engineVersion || 'dev'}`],
|
|
121
121
|
},
|
|
122
122
|
packages,
|
|
123
123
|
relationships: packages.map(p => ({
|