@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.
@@ -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
- return {"file": file, "functions": fns, "topLevel": None}
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:
@@ -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: null,
337
+ topLevel,
311
338
  };
312
339
  }
@@ -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
- for (const rawLine of lines) {
100
+ lines.forEach((rawLine, idx) => {
101
+ const lineNo = idx + 1;
88
102
  const line = rawLine.trim();
89
- if (!line || line.startsWith('#')) continue;
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
- continue;
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
- continue;
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+)$/.test(bareCall[1])) {
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.trim();
252
- if (!s || s.startsWith('#')) { line++; continue; }
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) { line += (s.match(/\n/g) || []).length + 1; continue; }
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
- return functions.length ? { file, functions, topLevel: null } : null;
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
- const { scan } = await runScan(_rootDir, { fileContents, depFileContents });
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
- const sinkExpr = (f.sink?.label || f.sink?.snippet || f.snippet || '')
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()
@@ -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: '0.7.0' }],
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: ['Tool: agentic-security-0.7.0'],
120
+ creators: [`Tool: agentic-security-${meta.engineVersion || 'dev'}`],
121
121
  },
122
122
  packages,
123
123
  relationships: packages.map(p => ({