@clear-capabilities/agentic-security-scanner 0.195.23 → 0.195.25
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 +31 -0
- package/dist/3596.index.js +1 -1
- package/dist/5752.index.js +1 -1
- package/dist/{01087ded263953062654.json → 809c900a90284017aaff.json} +2 -2
- package/dist/agentic-security.mjs +9 -9
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +2 -2
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/engine.js +3 -0
- package/src/lineage/CLAUDE.md +25 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +23 -0
- package/src/lineage/boundary-join.js +384 -0
- package/src/lineage/coverage.js +4 -1
- package/src/lineage/driver.js +19 -2
- package/src/lineage/engine.js +44 -1
- package/src/lineage/graph-builder.js +165 -33
- package/src/lineage/index.js +37 -0
- package/src/lineage/lineage-source-catalog.js +143 -0
- package/src/lineage/source-seeding.js +152 -16
- package/src/lineage/summaries.js +21 -5
|
@@ -30,6 +30,25 @@ import { emptyState, addIdentity } from './field-identity.js';
|
|
|
30
30
|
import { reclassifySource } from './source-registry.js';
|
|
31
31
|
import { classifyDataElementName } from './classification.js';
|
|
32
32
|
import { dataElementId } from './ids.js';
|
|
33
|
+
import { matchLineageSource } from './lineage-source-catalog.js';
|
|
34
|
+
|
|
35
|
+
// ── Seed kinds (GA-03.1, 0.195.24) ──────────────────────────────────────────
|
|
36
|
+
//
|
|
37
|
+
// `entry`: the identity is placed in the function's ENTRY state at
|
|
38
|
+
// `seedPath` (a member-shaped source such as `req.body.card_number`, whose
|
|
39
|
+
// path is stable for the whole function). This is §3's original mechanism.
|
|
40
|
+
//
|
|
41
|
+
// `after-node`: the identity is placed at `seedPath` in the state leaving
|
|
42
|
+
// CFG node `nodeId`, by `engine.js`'s worklist through `ctx.seedAfterNode`.
|
|
43
|
+
// Used for a CALL-shaped source assigned straight to a variable
|
|
44
|
+
// (`const record = fs.readFileSync(p)`, `const ssn = prompt('SSN')`,
|
|
45
|
+
// `const data = res.json()`): the call has no access path, so an entry seed
|
|
46
|
+
// has nowhere to land, and the value only exists in the target variable
|
|
47
|
+
// AFTER the assignment (an entry seed at the target would be killed by the
|
|
48
|
+
// assignment's own strong update). Before 0.195.24 every such match was
|
|
49
|
+
// recorded in `unseedable[]`; a call-shaped source anywhere else (nested
|
|
50
|
+
// inside another call's arguments, returned directly) still is.
|
|
51
|
+
export const SEED_KINDS = Object.freeze(['entry', 'after-node']);
|
|
33
52
|
|
|
34
53
|
// ── §3.1: where the matched expressions come from ──────────────────────────
|
|
35
54
|
//
|
|
@@ -169,6 +188,26 @@ export function planSeeds(callGraph, { repository, maxSeeds, maxNodes, signal, d
|
|
|
169
188
|
if (seeds.length >= seedCap) { stopReason = 'seeding-limit'; continue; }
|
|
170
189
|
if (nodesVisited >= nodeCap) { stopReason = 'seeding-limit'; continue; }
|
|
171
190
|
functionsScanned += 1;
|
|
191
|
+
const fnSeeds = [];
|
|
192
|
+
const mint = ({ nid, node, entry, decision, seedPath, seedKind }) => {
|
|
193
|
+
const canonicalName = seedPath.slice(seedPath.lastIndexOf('.') + 1);
|
|
194
|
+
const seed = {
|
|
195
|
+
file: fn.file, qid: fn.qid, nodeId: nid, line: node.line ?? null,
|
|
196
|
+
entryId: entry.id, seedPath, canonicalName, seedKind,
|
|
197
|
+
category: decision.category, coverageStatus: decision.coverageStatus,
|
|
198
|
+
externality: decision.externality, reason: decision.reason,
|
|
199
|
+
// §3.4's minting rule. `canonicalName` alone is forbidden by
|
|
200
|
+
// PRD §10.4; the discriminator carries the system proxy
|
|
201
|
+
// (repository + file), the access path, and the category.
|
|
202
|
+
// Function scope (`qid`) is deliberately NOT in the
|
|
203
|
+
// discriminator (§3.4).
|
|
204
|
+
dataElementId: dataElementId(canonicalName, [repository, fn.file, seedPath, decision.category ?? '']),
|
|
205
|
+
dataClasses: classifyDataElementName(canonicalName).classes,
|
|
206
|
+
};
|
|
207
|
+
seeds.push(seed);
|
|
208
|
+
fnSeeds.push(seed);
|
|
209
|
+
return seed;
|
|
210
|
+
};
|
|
172
211
|
for (const [nid, node] of Object.entries(fn.cfg?.nodes ?? {})) {
|
|
173
212
|
nodesVisited += 1;
|
|
174
213
|
if (seeds.length >= seedCap) { stopReason = 'seeding-limit'; break; }
|
|
@@ -176,10 +215,21 @@ export function planSeeds(callGraph, { repository, maxSeeds, maxNodes, signal, d
|
|
|
176
215
|
for (const root of exprRoots(node)) walkExpr(root, (e, p) => { if (p) parentOf.set(e, p); });
|
|
177
216
|
for (const root of exprRoots(node)) {
|
|
178
217
|
walkExpr(root, (e) => {
|
|
179
|
-
|
|
218
|
+
// The main taint catalog first (unchanged), then the lineage-only
|
|
219
|
+
// catalog for the JavaScript collection points the main catalog
|
|
220
|
+
// deliberately does not treat as taint sources.
|
|
221
|
+
const entry = matchSource(e, fn.file) ?? matchLineageSource(e, fn.file);
|
|
180
222
|
if (!entry) return;
|
|
181
223
|
const decision = reclassifySource(entry);
|
|
182
|
-
|
|
224
|
+
let seedPath = seedPathFor(e, parentOf);
|
|
225
|
+
let seedKind = 'entry';
|
|
226
|
+
if (!seedPath && node.kind === 'assign' && node.source === e && typeof node.target === 'string') {
|
|
227
|
+
// A call-shaped source that IS the whole right-hand side of an
|
|
228
|
+
// assignment: the value lands in the target, so seed the target
|
|
229
|
+
// once the node has run (see SEED_KINDS).
|
|
230
|
+
seedPath = node.target;
|
|
231
|
+
seedKind = 'after-node';
|
|
232
|
+
}
|
|
183
233
|
if (!seedPath) {
|
|
184
234
|
unseedable.push({
|
|
185
235
|
file: fn.file, qid: fn.qid, nodeId: nid, line: node.line ?? null,
|
|
@@ -187,23 +237,24 @@ export function planSeeds(callGraph, { repository, maxSeeds, maxNodes, signal, d
|
|
|
187
237
|
});
|
|
188
238
|
return;
|
|
189
239
|
}
|
|
190
|
-
|
|
191
|
-
seeds.push({
|
|
192
|
-
file: fn.file, qid: fn.qid, nodeId: nid, line: node.line ?? null,
|
|
193
|
-
entryId: entry.id, seedPath, canonicalName,
|
|
194
|
-
category: decision.category, coverageStatus: decision.coverageStatus,
|
|
195
|
-
externality: decision.externality, reason: decision.reason,
|
|
196
|
-
// §3.4's minting rule. `canonicalName` alone is forbidden by
|
|
197
|
-
// PRD §10.4; the discriminator carries the system proxy
|
|
198
|
-
// (repository + file), the access path, and the category.
|
|
199
|
-
// Function scope (`qid`) is deliberately NOT in the
|
|
200
|
-
// discriminator (§3.4).
|
|
201
|
-
dataElementId: dataElementId(canonicalName, [repository, fn.file, seedPath, decision.category ?? '']),
|
|
202
|
-
dataClasses: classifyDataElementName(canonicalName).classes,
|
|
203
|
-
});
|
|
240
|
+
mint({ nid, node, entry, decision, seedPath, seedKind });
|
|
204
241
|
});
|
|
205
242
|
}
|
|
206
243
|
}
|
|
244
|
+
// GA-03.1 (0.195.24): a container seed read through an ALIAS. §3.2's
|
|
245
|
+
// rule extends a match outward only through member accesses enclosing
|
|
246
|
+
// it in the SAME statement, so `const body = req.body;` seeds the
|
|
247
|
+
// container `req.body` (data element `body`, no classes) and the later
|
|
248
|
+
// `body.card_number` read is a field nothing seeded. Follow the alias
|
|
249
|
+
// within the function: an assignment of a pure access path that starts
|
|
250
|
+
// at a container seed (or at an earlier alias of one) names an alias,
|
|
251
|
+
// and every outermost pure member read rooted at an alias mints the
|
|
252
|
+
// field seed on the ORIGINAL container path. The alias assignment's own
|
|
253
|
+
// byPath forwarding (`engine.js`, the structure-preserving `ident` and
|
|
254
|
+
// `member` cases) then carries the field identity through the hop.
|
|
255
|
+
// Bounded: the alias table is rebuilt at most four rounds (one per
|
|
256
|
+
// hop of chained aliases) and a wildcard segment is never seeded.
|
|
257
|
+
if (fnSeeds.length) extendThroughAliases(fn, fnSeeds, mint);
|
|
207
258
|
}
|
|
208
259
|
const notScanned = functionsTotal - functionsScanned;
|
|
209
260
|
const completion = {
|
|
@@ -219,6 +270,71 @@ export function planSeeds(callGraph, { repository, maxSeeds, maxNodes, signal, d
|
|
|
219
270
|
return { seeds, unseedable, completion };
|
|
220
271
|
}
|
|
221
272
|
|
|
273
|
+
const MAX_ALIAS_ROUNDS = 4;
|
|
274
|
+
|
|
275
|
+
/** `JSON.parse(x)` / `JSON.stringify(x)` → x (one level); anything else unchanged. */
|
|
276
|
+
function unwrapJsonRoundTrip(e) {
|
|
277
|
+
if (e && e.kind === 'call' && e.callee && e.callee.kind === 'member' && (e.callee.prop === 'parse' || e.callee.prop === 'stringify')
|
|
278
|
+
&& e.callee.object && e.callee.object.kind === 'ident' && e.callee.object.name === 'JSON' && Array.isArray(e.args) && e.args.length) return e.args[0];
|
|
279
|
+
return e;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** `(fn, fnSeeds, mint)`: see the comment at the call site in `planSeeds`. */
|
|
283
|
+
function extendThroughAliases(fn, fnSeeds, mint) {
|
|
284
|
+
const nodes = Object.entries(fn.cfg?.nodes ?? {});
|
|
285
|
+
const containers = fnSeeds.filter((s) => s.seedKind === 'entry');
|
|
286
|
+
if (!containers.length) return;
|
|
287
|
+
const containerFor = (p) => containers.find((s) => p === s.seedPath || p.startsWith(`${s.seedPath}.`)) ?? null;
|
|
288
|
+
// ident -> { basePath, seed }: the container path the alias stands for.
|
|
289
|
+
const aliases = new Map();
|
|
290
|
+
const resolve = (p) => {
|
|
291
|
+
if (!p || p.includes('*')) return null;
|
|
292
|
+
const root = p.slice(0, p.indexOf('.') === -1 ? p.length : p.indexOf('.'));
|
|
293
|
+
const a = aliases.get(root);
|
|
294
|
+
if (a) return { basePath: a.basePath + p.slice(root.length), seed: a.seed };
|
|
295
|
+
const c = containerFor(p);
|
|
296
|
+
return c ? { basePath: p, seed: c } : null;
|
|
297
|
+
};
|
|
298
|
+
for (let round = 0; round < MAX_ALIAS_ROUNDS; round++) {
|
|
299
|
+
let changed = false;
|
|
300
|
+
for (const [, node] of nodes) {
|
|
301
|
+
if (node.kind !== 'assign' || typeof node.target !== 'string' || node.target.includes('.') || node.target.includes('*')) continue;
|
|
302
|
+
if (aliases.has(node.target)) continue;
|
|
303
|
+
// GA-03.3: a JSON round trip keeps the container's structure (the
|
|
304
|
+
// same rule engine.js applies), so `const s = JSON.stringify(req.body)`
|
|
305
|
+
// and `const back = JSON.parse(s)` are aliases of the container too.
|
|
306
|
+
const r = resolve(accessPathOf(unwrapJsonRoundTrip(node.source)));
|
|
307
|
+
if (!r) continue;
|
|
308
|
+
aliases.set(node.target, r);
|
|
309
|
+
changed = true;
|
|
310
|
+
}
|
|
311
|
+
if (!changed) break;
|
|
312
|
+
}
|
|
313
|
+
if (!aliases.size) return;
|
|
314
|
+
const minted = new Set(fnSeeds.map((s) => s.seedPath));
|
|
315
|
+
for (const [, node] of nodes) {
|
|
316
|
+
const parentOf = new Map();
|
|
317
|
+
for (const root of exprRoots(node)) walkExpr(root, (e, p) => { if (p) parentOf.set(e, p); });
|
|
318
|
+
for (const root of exprRoots(node)) {
|
|
319
|
+
walkExpr(root, (e) => {
|
|
320
|
+
if (e.kind !== 'member') return;
|
|
321
|
+
const parent = parentOf.get(e);
|
|
322
|
+
if (parent && parent.kind === 'member' && parent.object === e) return; // not outermost
|
|
323
|
+
const p = accessPathOf(e);
|
|
324
|
+
if (!p || p.includes('*')) return;
|
|
325
|
+
const root = p.slice(0, p.indexOf('.'));
|
|
326
|
+
const a = aliases.get(root);
|
|
327
|
+
if (!a) return;
|
|
328
|
+
const fieldPath = a.basePath + p.slice(root.length);
|
|
329
|
+
if (fieldPath === a.seed.seedPath || minted.has(fieldPath)) return;
|
|
330
|
+
minted.add(fieldPath);
|
|
331
|
+
const site = fn.cfg.nodes[a.seed.nodeId];
|
|
332
|
+
mint({ nid: a.seed.nodeId, node: site ?? node, entry: { id: a.seed.entryId }, decision: a.seed, seedPath: fieldPath, seedKind: 'entry' });
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
222
338
|
// ── §3.5: seedEntryStateFactory — the driver.js opts.seedEntryState hook ──
|
|
223
339
|
|
|
224
340
|
/**
|
|
@@ -233,6 +349,7 @@ export function planSeeds(callGraph, { repository, maxSeeds, maxNodes, signal, d
|
|
|
233
349
|
export function seedEntryStateFactory(seeds) {
|
|
234
350
|
const byQid = new Map();
|
|
235
351
|
for (const s of seeds) {
|
|
352
|
+
if (s.seedKind === 'after-node') continue;
|
|
236
353
|
if (!byQid.has(s.qid)) byQid.set(s.qid, []);
|
|
237
354
|
byQid.get(s.qid).push(s);
|
|
238
355
|
}
|
|
@@ -244,3 +361,22 @@ export function seedEntryStateFactory(seeds) {
|
|
|
244
361
|
return st;
|
|
245
362
|
};
|
|
246
363
|
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Builds the `ctx.seedAfterNode(qid, nodeId) -> [{path, id}] | undefined`
|
|
367
|
+
* function `engine.js`'s worklist consults after every CFG node (GA-03.1,
|
|
368
|
+
* 0.195.24): the `after-node` seeds of that function, grouped by node.
|
|
369
|
+
* Keyed by `qid` rather than bound to one function so the same function
|
|
370
|
+
* works for a callee analysed through `createCallSummaryResolver`.
|
|
371
|
+
*/
|
|
372
|
+
export function seedAfterNodeFactory(seeds) {
|
|
373
|
+
const byKey = new Map();
|
|
374
|
+
for (const s of seeds) {
|
|
375
|
+
if (s.seedKind !== 'after-node') continue;
|
|
376
|
+
const k = `${s.qid}\u0000${s.nodeId}`;
|
|
377
|
+
if (!byKey.has(k)) byKey.set(k, []);
|
|
378
|
+
byKey.get(k).push({ path: s.seedPath, id: s.dataElementId });
|
|
379
|
+
}
|
|
380
|
+
if (!byKey.size) return undefined;
|
|
381
|
+
return (qid, nodeId) => byKey.get(`${qid}\u0000${nodeId}`);
|
|
382
|
+
}
|
package/src/lineage/summaries.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { hashState, emptyState, addIdentity } from './field-identity.js';
|
|
1
|
+
import { hashState, emptyState, addIdentity, joinStates } from './field-identity.js';
|
|
2
2
|
import {
|
|
3
3
|
resolveExprIdentities, residualFlat, analyzeFunctionFieldIdentity,
|
|
4
4
|
argumentContext, callInvocationId, calleeRecorder,
|
|
@@ -425,7 +425,8 @@ export function applyAtCallSite(summary, paramNames, callArgs) {
|
|
|
425
425
|
// integration will pass a resolver backed by `scanner/src/ir/callgraph.js`
|
|
426
426
|
// instead, without this function (or `resolveExprIdentities`) needing to
|
|
427
427
|
// change at all.
|
|
428
|
-
export function createCallSummaryResolver(cache, lookupCallee) {
|
|
428
|
+
export function createCallSummaryResolver(cache, lookupCallee, resolverOpts = {}) {
|
|
429
|
+
const seedEntryState = typeof resolverOpts.seedEntryState === 'function' ? resolverOpts.seedEntryState : null;
|
|
429
430
|
return function resolveCallSummary(calleeExpr, callArgs, callerState, ctx) {
|
|
430
431
|
const resolved = lookupCallee(calleeExpr);
|
|
431
432
|
if (!resolved) return null;
|
|
@@ -435,7 +436,18 @@ export function createCallSummaryResolver(cache, lookupCallee) {
|
|
|
435
436
|
// own header comment) — this call site forwards the caller's `ctx`
|
|
436
437
|
// unmodified; the hazard cannot reappear here because the strip
|
|
437
438
|
// happens one level down, not at each call site.
|
|
438
|
-
const
|
|
439
|
+
const boundState = entryStateFromCall(fn.params, callArgs, callerState, ctx);
|
|
440
|
+
// GA-03.1 (0.195.24): the callee's OWN source seeds are facts about
|
|
441
|
+
// expressions in its body (`req.body.card_number` is a source wherever
|
|
442
|
+
// `extractCard` is called from), so they hold under every entry context,
|
|
443
|
+
// not only the driver's top-level one. Join them onto the call-derived
|
|
444
|
+
// state. The cache key is the JOINED state, so a clean call site and the
|
|
445
|
+
// driver's own analysis of the callee share one key and one summary;
|
|
446
|
+
// DESIGN_GRAPH_BUILDER.md §3.6's rule stands: no seeded summary is ever
|
|
447
|
+
// stored under the empty-entry key. The bind hops below enumerate only
|
|
448
|
+
// the call-derived half (`boundState`): a seed is not an argument.
|
|
449
|
+
const ownSeeds = seedEntryState ? seedEntryState(fn) : null;
|
|
450
|
+
const entryState = ownSeeds ? joinStates(boundState, ownSeeds) : boundState;
|
|
439
451
|
// §13.2's first half: the callee's own entry context, computed once so
|
|
440
452
|
// both the bind hop below and the return-direction wrapper at the
|
|
441
453
|
// bottom of this function can reference the exact same value.
|
|
@@ -454,7 +466,7 @@ export function createCallSummaryResolver(cache, lookupCallee) {
|
|
|
454
466
|
// C4 would collide this binding's target with any caller-local
|
|
455
467
|
// variable of the same name (Decision 5's bug class).
|
|
456
468
|
if (ctx?.recordHop) {
|
|
457
|
-
for (const [path, ids] of
|
|
469
|
+
for (const [path, ids] of boundState) {
|
|
458
470
|
for (const id of ids) {
|
|
459
471
|
ctx.recordHop({
|
|
460
472
|
kind: 'write-out', subKind: 'call-arg-bind',
|
|
@@ -508,6 +520,10 @@ export function createCallSummaryResolver(cache, lookupCallee) {
|
|
|
508
520
|
...(ctx.correlationVersion === 2 ? { correlationVersion: 2 } : null),
|
|
509
521
|
}
|
|
510
522
|
: { resolveCallSummary };
|
|
523
|
+
// GA-03.1 (0.195.24): the callee's after-node seeds are body facts
|
|
524
|
+
// too (source-seeding.js, SEED_KINDS); the hook is keyed by qid, so
|
|
525
|
+
// forwarding the caller's is exact.
|
|
526
|
+
if (ctx?.seedAfterNode) calleeCtx.seedAfterNode = ctx.seedAfterNode;
|
|
511
527
|
const result = analyzeFunctionFieldIdentity(fn, es, calleeCtx);
|
|
512
528
|
return summaryFromAnalysisResult(result);
|
|
513
529
|
});
|
|
@@ -524,7 +540,7 @@ export function createCallSummaryResolver(cache, lookupCallee) {
|
|
|
524
540
|
// under §2.2's rule (or, when there is no path-shaped argument, as
|
|
525
541
|
// edge-forming in its own right) — never dropped either way.
|
|
526
542
|
if (summary?.degradedReason && ctx?.recordHop) {
|
|
527
|
-
for (const [, ids] of
|
|
543
|
+
for (const [, ids] of boundState) {
|
|
528
544
|
for (const id of ids) {
|
|
529
545
|
ctx.recordHop({
|
|
530
546
|
kind: 'production', subKind: 'call-resolved',
|