@clear-capabilities/agentic-security-scanner 0.151.3 → 0.153.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/dist/1336.index.js +16010 -0
  3. package/dist/agentic-security.mjs +80 -80
  4. package/dist/agentic-security.mjs.sha256 +1 -1
  5. package/package.json +7 -6
  6. package/src/dataflow/CLAUDE.md +1 -1
  7. package/src/dataflow/backward.js +104 -7
  8. package/src/dataflow/catalog.js +802 -13
  9. package/src/dataflow/cross-service-taint.js +154 -11
  10. package/src/dataflow/engine.js +875 -90
  11. package/src/dataflow/exploit-prover.js +111 -23
  12. package/src/dataflow/incremental.js +13 -0
  13. package/src/dataflow/index.js +29 -6
  14. package/src/dataflow/path-feasibility.js +86 -9
  15. package/src/dataflow/string-domain.js +168 -0
  16. package/src/dataflow/summaries.js +69 -8
  17. package/src/engine.js +118 -9
  18. package/src/ir/CLAUDE.md +2 -1
  19. package/src/ir/balanced-call.js +33 -1
  20. package/src/ir/callgraph.js +26 -2
  21. package/src/ir/class-hierarchy.js +261 -7
  22. package/src/ir/csharp-ir.js +97 -8
  23. package/src/ir/index.js +21 -1
  24. package/src/ir/parser-cs.js +706 -24
  25. package/src/ir/parser-java.js +498 -17
  26. package/src/ir/parser-js.js +22 -0
  27. package/src/ir/parser-php.js +446 -43
  28. package/src/ir/parser-rust.js +1338 -0
  29. package/src/ir/php-include-merge.js +190 -0
  30. package/src/lineage/sink-registry.js +18 -0
  31. package/src/lineage/source-registry.js +23 -0
  32. package/src/posture/csharp-analysis.js +341 -43
  33. package/src/posture/finding-defaults.js +5 -1
  34. package/src/posture/fix-coverage.js +1 -0
  35. package/src/posture/proof-coverage.js +1 -0
  36. package/src/report/index.js +12 -0
  37. package/src/sast/CLAUDE.md +1 -1
  38. package/src/sast/authz.js +144 -1
  39. package/src/sast/crypto-protocol.js +65 -7
  40. package/src/sast/csharp-cross-file.js +125 -0
  41. package/src/sast/csharp-structural.js +78 -2
  42. package/src/sast/csharp.js +574 -42
  43. package/src/sast/java-ast-folding.js +268 -11
  44. package/src/sast/java-bench-extras.js +530 -2
  45. package/src/sast/java-deserialization.js +31 -2
  46. package/src/sast/java-structural-cross-file.js +146 -0
  47. package/src/sast/java-structural.js +499 -6
  48. package/src/sast/ldap-injection.js +133 -3
  49. package/src/sast/rust.js +57 -0
  50. package/src/sast/xss-reflected-multilang.js +252 -3
  51. package/src/dataflow/smt-feasibility.js +0 -189
@@ -39,6 +39,24 @@
39
39
  // either a monorepo or a federated-scan API. We DO emit cross-service
40
40
  // findings when the local service is on the receiving end of an edge,
41
41
  // based on the declared upstream taint contract.
42
+ //
43
+ // SCOPE NOTE (next-gen taint capability #7 rebuild): step 1 of the
44
+ // algorithm above ("mark every consume entry-point as tainted-by-default")
45
+ // is NOT implemented anywhere in this file, or anywhere else in the
46
+ // engine — there is no mechanism that seeds a NEW taint source from a
47
+ // `consumes` declaration (e.g. marking a Kafka consumer callback's
48
+ // message parameter as tainted). This module only ANNOTATES findings the
49
+ // taint engine already discovers via its own normal source catalog
50
+ // (`catalog.js`) with cross-service context; it never makes a
51
+ // previously-invisible flow visible. In practice this means an HTTP
52
+ // edge benefits fully (HTTP request sources — body/query/header/cookie —
53
+ // are extensively cataloged already), while a Kafka/queue edge benefits
54
+ // only when the consumed payload happens to ALSO reach an already-
55
+ // cataloged source through some other path — which is rare, since raw
56
+ // message-queue payloads have no catalog entry of their own. Seeding new
57
+ // sources from `consumes` declarations would be a genuinely separate,
58
+ // larger capability; left as a deliberate, documented boundary here
59
+ // rather than attempted in this rebuild.
42
60
 
43
61
  import * as fs from 'node:fs';
44
62
  import * as path from 'node:path';
@@ -116,6 +134,52 @@ export function incomingEdges(graph, currentService) {
116
134
  return (graph.edges || []).filter(e => e.to === currentService.name);
117
135
  }
118
136
 
137
+ /**
138
+ * Split a URL path into its non-empty segments, e.g. "/balances/:id" ->
139
+ * ["balances", ":id"].
140
+ */
141
+ function _pathSegments(p) {
142
+ return String(p || '').split('/').filter(Boolean);
143
+ }
144
+
145
+ /**
146
+ * True when a path segment is a parameter placeholder, in EITHER of the
147
+ * two conventions this file's own schema comment documents:
148
+ * Express/Koa-style `:userId` or template-style `{userId}`.
149
+ */
150
+ function _isWildcardSegment(seg) {
151
+ return /^:.+$/.test(seg) || /^\{[^}]+\}$/.test(seg);
152
+ }
153
+
154
+ /**
155
+ * Structural path-template match between an `exposes[].route` entry
156
+ * (e.g. "GET /balances/:userId") and an `edges[].path` entry (e.g.
157
+ * "/balances/{userId}").
158
+ *
159
+ * The previous implementation did `expose.route.includes(edge.path)` — a
160
+ * crude substring check. This file's OWN header comment documents the
161
+ * two sides using DIFFERENT placeholder syntaxes (`:userId` vs
162
+ * `{userId}`), so that substring check could never succeed on the
163
+ * example it was written to describe — confirmed by direct reproduction
164
+ * against that exact example, which returned zero contracts. Fixed by
165
+ * comparing SEGMENT-BY-SEGMENT: a literal segment must match exactly, a
166
+ * placeholder segment on EITHER side matches any segment on the other
167
+ * (since we don't know the upstream's real parameter name convention
168
+ * without parsing it, and the security question — "does an edge exist
169
+ * pointing at this exact path shape" — doesn't depend on knowing it).
170
+ * The route's leading HTTP method token (if present) is stripped before
171
+ * comparing; method itself is not matched (this schema has no per-edge
172
+ * method field), matching the pre-existing, unaudited behavior.
173
+ */
174
+ function _pathTemplateMatches(exposeRoute, edgePath) {
175
+ if (!exposeRoute || !edgePath) return false;
176
+ const routePath = String(exposeRoute).replace(/^[A-Z]+\s+/, '');
177
+ const a = _pathSegments(routePath);
178
+ const b = _pathSegments(String(edgePath).split('?')[0]);
179
+ if (a.length !== b.length) return false;
180
+ return a.every((seg, i) => seg === b[i] || _isWildcardSegment(seg) || _isWildcardSegment(b[i]));
181
+ }
182
+
119
183
  /**
120
184
  * For each consume entry on the current service, locate the upstream
121
185
  * `exposes` entry that produces it (matched by path/topic) and return
@@ -131,7 +195,7 @@ export function upstreamTaintContract(graph, currentService) {
131
195
  const upstream = graph.services[edge.from];
132
196
  if (!upstream) continue;
133
197
  for (const expose of upstream.exposes || []) {
134
- const matches = (edge.via === 'http' && edge.path && expose.route && expose.route.includes(edge.path.split('?')[0]))
198
+ const matches = (edge.via === 'http' && edge.path && expose.route && _pathTemplateMatches(expose.route, edge.path))
135
199
  || (edge.via === 'kafka' && edge.topic && consume.source === edge.topic);
136
200
  if (matches) {
137
201
  contracts.push({
@@ -148,10 +212,82 @@ export function upstreamTaintContract(graph, currentService) {
148
212
  return contracts;
149
213
  }
150
214
 
215
+ // Escapes every regex metacharacter in a literal field name before it is
216
+ // embedded in a `\b...\b` pattern. The previous version only replaced the
217
+ // FIRST `.` (`field.replace('.', '\\.')`, no `/g` flag) — any OTHER dot in
218
+ // a multi-segment field name (e.g. "a.b.c") was left as a live regex
219
+ // metacharacter matching any character, silently over-broadening the
220
+ // match. Fixed generally, for every metacharacter, not just repeated dots.
221
+ function _escapeRegex(s) {
222
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
223
+ }
224
+
225
+ // Provenance values (catalog.js's per-source `provenance` field, e.g.
226
+ // 'http-body' for req.body) that mean "this value arrived over an
227
+ // incoming HTTP request" — the signal used to correlate a finding with a
228
+ // declared `via: 'http'` cross-service edge.
229
+ const HTTP_PROVENANCE = new Set(['http-body', 'url-param', 'path-param', 'header', 'cookie']);
230
+
231
+ // Every provenance value attached anywhere on a finding: the primary
232
+ // source (`f.sourceProvenance`) plus every step in its source chain
233
+ // (`f.chain[].provenance`) — a finding can have more than one source
234
+ // reaching the sink, and any one of them may be the HTTP-originated one.
235
+ function _findingProvenances(f) {
236
+ const provs = new Set();
237
+ if (f && f.sourceProvenance) provs.add(f.sourceProvenance);
238
+ if (f && Array.isArray(f.chain)) {
239
+ for (const c of f.chain) { if (c && c.provenance) provs.add(c.provenance); }
240
+ }
241
+ return provs;
242
+ }
243
+
244
+ // Every textual source LABEL attached anywhere on a finding (the catalog
245
+ // entry's own fixed label, e.g. "req.body" — never the specific accessed
246
+ // property, which the engine does not retain on the finding today). Used
247
+ // only as a best-effort fallback for non-HTTP transports (Kafka/queue),
248
+ // where there is no provenance category to correlate against at all.
249
+ function _findingSourceText(f) {
250
+ const parts = [];
251
+ if (f && f.source && f.source.label) parts.push(f.source.label);
252
+ if (f && Array.isArray(f.chain)) {
253
+ for (const c of f.chain) { if (c && c.label) parts.push(c.label); }
254
+ }
255
+ return parts.join(' ');
256
+ }
257
+
151
258
  /**
152
259
  * Annotate findings whose source matches a cross-service taint contract.
153
- * Adds `crossService: { from, via, path, taintedFields }` and bumps
154
- * severity by one tier (medium → high, high → critical).
260
+ * Adds `crossService: { from, to, via, taintedFields, matchedField,
261
+ * precision }` and bumps severity by one tier (medium → high, high →
262
+ * critical).
263
+ *
264
+ * The previous implementation matched a contract's declared field NAMES
265
+ * as literal text against `f.source.snippet`/`f.source.expr` — fields
266
+ * that confirmed direct code reading never exist on a real finding (the
267
+ * real shape, set in `engine.js`, is `f.source = {file, line, label}`).
268
+ * That made this function a structural no-op for every real taint-engine
269
+ * finding, verified via a direct `runScan()` reproduction of the exact
270
+ * scenario this file's own header describes.
271
+ *
272
+ * Fixed with two correlation strategies, chosen by transport, since a
273
+ * real finding never carries the SPECIFIC field name that reached the
274
+ * sink (only the catalog source's fixed label, e.g. "req.body" — see
275
+ * `_findingSourceText`'s own comment):
276
+ * - `via: 'http'`: correlate by PROVENANCE — an HTTP request source is
277
+ * already reliably categorized by the catalog (`http-body`,
278
+ * `url-param`, `header`, `cookie`, …), so this is a real, sound
279
+ * signal rather than a text-match guess. `matchedField` is left null
280
+ * and `precision: 'transport'` records honestly that the match is at
281
+ * the endpoint level, not a specific declared field — the security
282
+ * conclusion (this value arrived across a declared trust boundary)
283
+ * holds regardless of which exact field it was.
284
+ * - every other transport (Kafka/queue): no provenance category exists
285
+ * for a raw message payload (per this file's own SCOPE NOTE), so the
286
+ * best available signal is literal field-name text matching against
287
+ * whatever source labels the finding does carry — kept as a
288
+ * best-effort fallback, `precision: 'field'` when it hits, but this
289
+ * will rarely fire until source-seeding from `consumes` declarations
290
+ * exists (documented as a separate, not-yet-attempted capability).
155
291
  */
156
292
  export function annotateCrossServiceFindings(findings, graph, currentService) {
157
293
  if (!Array.isArray(findings) || !graph || !currentService) return { annotated: 0, bumped: 0 };
@@ -159,18 +295,25 @@ export function annotateCrossServiceFindings(findings, graph, currentService) {
159
295
  if (!contracts.length) return { annotated: 0, bumped: 0 };
160
296
  let annotated = 0, bumped = 0;
161
297
  for (const f of findings) {
162
- const sourceExpr = (f.source && (f.source.snippet || f.source.expr)) || f.snippet || '';
163
298
  for (const contract of contracts) {
164
- const matches = contract.taintedFields.some(field => {
165
- const pat = new RegExp(`\\b${field.replace('.', '\\.')}\\b`);
166
- return pat.test(sourceExpr);
167
- });
168
- if (!matches) continue;
299
+ let matchedField = null;
300
+ let matched = false;
301
+ if (contract.via === 'http') {
302
+ const provs = _findingProvenances(f);
303
+ matched = [...provs].some(p => HTTP_PROVENANCE.has(p));
304
+ } else {
305
+ const text = _findingSourceText(f);
306
+ matchedField = contract.taintedFields.find(field => new RegExp(`\\b${_escapeRegex(field)}\\b`).test(text)) || null;
307
+ matched = !!matchedField;
308
+ }
309
+ if (!matched) continue;
169
310
  f.crossService = {
170
311
  from: contract.upstreamService,
171
312
  to: currentService.name,
172
313
  via: contract.via,
173
- taintedField: contract.taintedFields.find(field => new RegExp(`\\b${field.replace('.', '\\.')}\\b`).test(sourceExpr)),
314
+ taintedFields: contract.taintedFields,
315
+ matchedField,
316
+ precision: matchedField ? 'field' : 'transport',
174
317
  };
175
318
  annotated++;
176
319
  // Severity bump.
@@ -199,4 +342,4 @@ export function runCrossServiceTaint(scanRoot, findings) {
199
342
  return annotateCrossServiceFindings(findings, graph, current);
200
343
  }
201
344
 
202
- export const _internals = { _normalizeGraph, SERVICES_FILE_NAMES };
345
+ export const _internals = { _normalizeGraph, SERVICES_FILE_NAMES, _pathTemplateMatches, _escapeRegex };