@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.
@@ -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
- const entry = matchSource(e, fn.file);
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
- const seedPath = seedPathFor(e, parentOf);
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
- const canonicalName = seedPath.slice(seedPath.lastIndexOf('.') + 1);
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
+ }
@@ -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 entryState = entryStateFromCall(fn.params, callArgs, callerState, ctx);
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 entryState) {
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 entryState) {
543
+ for (const [, ids] of boundState) {
528
544
  for (const id of ids) {
529
545
  ctx.recordHop({
530
546
  kind: 'production', subKind: 'call-resolved',