@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.
- package/CHANGELOG.md +125 -0
- package/dist/1336.index.js +16010 -0
- package/dist/agentic-security.mjs +80 -80
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +7 -6
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/backward.js +104 -7
- package/src/dataflow/catalog.js +802 -13
- package/src/dataflow/cross-service-taint.js +154 -11
- package/src/dataflow/engine.js +875 -90
- package/src/dataflow/exploit-prover.js +111 -23
- package/src/dataflow/incremental.js +13 -0
- package/src/dataflow/index.js +29 -6
- package/src/dataflow/path-feasibility.js +86 -9
- package/src/dataflow/string-domain.js +168 -0
- package/src/dataflow/summaries.js +69 -8
- package/src/engine.js +118 -9
- package/src/ir/CLAUDE.md +2 -1
- package/src/ir/balanced-call.js +33 -1
- package/src/ir/callgraph.js +26 -2
- package/src/ir/class-hierarchy.js +261 -7
- package/src/ir/csharp-ir.js +97 -8
- package/src/ir/index.js +21 -1
- package/src/ir/parser-cs.js +706 -24
- package/src/ir/parser-java.js +498 -17
- package/src/ir/parser-js.js +22 -0
- package/src/ir/parser-php.js +446 -43
- package/src/ir/parser-rust.js +1338 -0
- package/src/ir/php-include-merge.js +190 -0
- package/src/lineage/sink-registry.js +18 -0
- package/src/lineage/source-registry.js +23 -0
- package/src/posture/csharp-analysis.js +341 -43
- package/src/posture/finding-defaults.js +5 -1
- package/src/posture/fix-coverage.js +1 -0
- package/src/posture/proof-coverage.js +1 -0
- package/src/report/index.js +12 -0
- package/src/sast/CLAUDE.md +1 -1
- package/src/sast/authz.js +144 -1
- package/src/sast/crypto-protocol.js +65 -7
- package/src/sast/csharp-cross-file.js +125 -0
- package/src/sast/csharp-structural.js +78 -2
- package/src/sast/csharp.js +574 -42
- package/src/sast/java-ast-folding.js +268 -11
- package/src/sast/java-bench-extras.js +530 -2
- package/src/sast/java-deserialization.js +31 -2
- package/src/sast/java-structural-cross-file.js +146 -0
- package/src/sast/java-structural.js +499 -6
- package/src/sast/ldap-injection.js +133 -3
- package/src/sast/rust.js +57 -0
- package/src/sast/xss-reflected-multilang.js +252 -3
- 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
|
|
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,
|
|
154
|
-
* severity by one tier (medium → high, high →
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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 };
|