@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
package/src/lineage/engine.js
CHANGED
|
@@ -74,6 +74,13 @@ function isV2(ctx) {
|
|
|
74
74
|
// the derivation path of the level that emitted it (the innermost stamp
|
|
75
75
|
// wins, because an inner wrapper's fields are already on the hop when the
|
|
76
76
|
// outer wrapper sees it).
|
|
77
|
+
/** `JSON.parse` / `JSON.stringify` (GA-03.3): the one call pair the resolver treats as structure-preserving. */
|
|
78
|
+
function isJsonRoundTrip(callee) {
|
|
79
|
+
if (typeof callee === 'string') return callee === 'JSON.parse' || callee === 'JSON.stringify';
|
|
80
|
+
return !!callee && callee.kind === 'member' && (callee.prop === 'parse' || callee.prop === 'stringify')
|
|
81
|
+
&& callee.object?.kind === 'ident' && callee.object.name === 'JSON';
|
|
82
|
+
}
|
|
83
|
+
|
|
77
84
|
function sub(ctx, seg, stamp) {
|
|
78
85
|
if (!isV2(ctx)) return ctx;
|
|
79
86
|
const derivation = deriveChild(ctx.derivation, seg);
|
|
@@ -610,6 +617,19 @@ export function resolveExprIdentities(state, expr, ctx) {
|
|
|
610
617
|
return { flat, byPath, widened: false };
|
|
611
618
|
}
|
|
612
619
|
}
|
|
620
|
+
// GA-03.3 (0.195.25): a JSON round trip is structure-PRESERVING.
|
|
621
|
+
// `JSON.stringify(v)` carries exactly v's fields (serialized) and
|
|
622
|
+
// `JSON.parse(s)` yields exactly the fields s was serialized from, so
|
|
623
|
+
// both forward the first argument's `{flat, byPath, widened}`
|
|
624
|
+
// unchanged, the way `assign-expr` forwards its source: the field a
|
|
625
|
+
// queue consumer reads off `JSON.parse(m.Body)` keeps the identity the
|
|
626
|
+
// producer put under that key instead of collapsing to the whole
|
|
627
|
+
// record's flat union. The argument's own recursion emits its hops;
|
|
628
|
+
// nothing is recorded for the call itself (there is no new value).
|
|
629
|
+
// The replacer/reviver/space arguments never carry the value.
|
|
630
|
+
if (isJsonRoundTrip(expr.callee) && (expr.args ?? []).length >= 1) {
|
|
631
|
+
return resolveExprIdentities(state, expr.args[0], sub(ctx, 'a0'));
|
|
632
|
+
}
|
|
613
633
|
const flat = new Set();
|
|
614
634
|
// Hotfix (found during Sub-project E1's design spike; see
|
|
615
635
|
// DESIGN_GRAPH_BUILDER.md §11): an unresolved method call's RECEIVER
|
|
@@ -923,6 +943,18 @@ function step(node, stateIn, widenings, ctx) {
|
|
|
923
943
|
}
|
|
924
944
|
}
|
|
925
945
|
}
|
|
946
|
+
// Known, disclosed gap (GA-03.1, 0.195.24, measured): a bare call
|
|
947
|
+
// statement is never resolved through `ctx.resolveCallSummary`, so a
|
|
948
|
+
// sink INSIDE the callee (`emit(logger, req.body.card_number)` with
|
|
949
|
+
// `logger.info(v)` in emit) never sees the argument's identity; the
|
|
950
|
+
// value is an escape here and nothing more. Resolving it was tried
|
|
951
|
+
// and reverted in 0.195.24: the extra callee analysis consumes a
|
|
952
|
+
// summary context per call site, and under a tight context cap that
|
|
953
|
+
// degrades a later resolution to an empty summary (the §13.2a hazard
|
|
954
|
+
// of DESIGN_PATH_PROVENANCE.md, in the unsound direction), moving
|
|
955
|
+
// seven pinned provenance measurements. Closing it needs the cap to
|
|
956
|
+
// exempt bare-call contexts or a separate budget; pinned as a gap by
|
|
957
|
+
// test/lineage/seeding-across-calls.test.js (IP/2).
|
|
926
958
|
return { state: stateIn, returnFact: null };
|
|
927
959
|
}
|
|
928
960
|
|
|
@@ -1068,7 +1100,18 @@ export function analyzeFunctionFieldIdentity(fn, entryState, ctx) {
|
|
|
1068
1100
|
recordHop: stepRecord,
|
|
1069
1101
|
}
|
|
1070
1102
|
: ctx;
|
|
1071
|
-
|
|
1103
|
+
let { state: out, returnFact } = step(node, incoming, widenings, stepCtx);
|
|
1104
|
+
// GA-03.1 (0.195.24): `after-node` seeds (source-seeding.js, SEED_KINDS).
|
|
1105
|
+
// A call-shaped source assigned to a variable produces its identity in
|
|
1106
|
+
// the TARGET once this node has run; the assignment's own strong update
|
|
1107
|
+
// has already cleared the target, so this is a plain add, idempotent on
|
|
1108
|
+
// a worklist revisit. Keyed by `(scope, nid)` so the same hook serves a
|
|
1109
|
+
// callee analysed through the call-summary resolver. No hop is recorded
|
|
1110
|
+
// here: the first read of the target creates the path node, which then
|
|
1111
|
+
// has no in-edges and reconstructs as an origin, exactly as an entry
|
|
1112
|
+
// seed's first read does.
|
|
1113
|
+
const after = ctx?.seedAfterNode ? ctx.seedAfterNode(scope, nid) : undefined;
|
|
1114
|
+
if (after) for (const s of after) out = addIdentity(out, s.path, s.id);
|
|
1072
1115
|
if (returnFact && returnFact.size > 0) {
|
|
1073
1116
|
// Union onto any existing fact for this node rather than pushing a
|
|
1074
1117
|
// new array entry every visit — see Fix 2 in the final whole-branch
|
|
@@ -111,8 +111,9 @@
|
|
|
111
111
|
// entry point), it does not wrap it.
|
|
112
112
|
|
|
113
113
|
import { matchSinkOrSanitizer } from '../dataflow/catalog.js';
|
|
114
|
-
import { matchPrivacySink } from '../dataflow/privacy-catalog.js';
|
|
114
|
+
import { matchPrivacySink, PRIVACY_SINK_CATALOG } from '../dataflow/privacy-catalog.js';
|
|
115
115
|
import { matchOrmWrite } from '../dataflow/orm-write-catalog.js';
|
|
116
|
+
import { collectBoundaryEndpoints, planBoundaryJoins, queueCommandOf, MAX_BOUNDARY_ROUNDS } from './boundary-join.js';
|
|
116
117
|
// Milestone 2, Sub-project G, increment 1 (FR-408/AC-09): `isSinkPermitted`/
|
|
117
118
|
// `permittingRules` are pure, vocabulary-agnostic functions (no hardcoded
|
|
118
119
|
// sink-name check anywhere in that module — confirmed by direct read) that
|
|
@@ -131,7 +132,7 @@ import { classifyHandling } from './handling-analyzer.js';
|
|
|
131
132
|
import { emptyGraphEnvelope } from './schema.js';
|
|
132
133
|
import { emptyProtection, aggregateVerdicts } from './protection.js';
|
|
133
134
|
import * as ids from './ids.js';
|
|
134
|
-
import { planSeeds, seedEntryStateFactory, exprRoots, walkExpr } from './source-seeding.js';
|
|
135
|
+
import { planSeeds, seedEntryStateFactory, seedAfterNodeFactory, exprRoots, walkExpr } from './source-seeding.js';
|
|
135
136
|
import { RECIPIENT_FACT_FIELDS } from './recipient-profile.js';
|
|
136
137
|
import { pathEvidenceEnabled, evaluatePathEvidence, summarizeFlowEvidence } from './path-evidence.js';
|
|
137
138
|
import { sinkFamilyOf } from './control-credit.js';
|
|
@@ -195,6 +196,19 @@ function resolveSinkAtCallSite(calleeExpr, file) {
|
|
|
195
196
|
};
|
|
196
197
|
}
|
|
197
198
|
|
|
199
|
+
/**
|
|
200
|
+
* GA-03.3 (0.195.25): the registry decision for an AWS SDK v3 send command
|
|
201
|
+
* (`queueCommandOf` in boundary-join.js). Resolves through the privacy
|
|
202
|
+
* catalog's queue entry named by the command table, so the node is the same
|
|
203
|
+
* `queue` decision the v2 `sendMessage`/`publish` shapes get; never through
|
|
204
|
+
* the general catalog, whose bare `send` entry is a different sink.
|
|
205
|
+
*/
|
|
206
|
+
function resolveQueueCommandSite(cmd) {
|
|
207
|
+
const entry = PRIVACY_SINK_CATALOG.find((e) => e.id === cmd.entry);
|
|
208
|
+
if (!entry) return null;
|
|
209
|
+
return { entry, decision: reclassifyPrivacySink(entry), ambiguity: null, queueCommand: cmd.command };
|
|
210
|
+
}
|
|
211
|
+
|
|
198
212
|
/**
|
|
199
213
|
* Milestone 2, Sub-project E, increment 1 (ORM-write sink recognition,
|
|
200
214
|
* `docs/superpowers/plans/2026-08-31-data-flow-explorer-m2-subproject-e1-plan.md`).
|
|
@@ -345,7 +359,17 @@ export function enumerateSinkSites(callGraph) {
|
|
|
345
359
|
for (const fn of callGraph.functions.values()) {
|
|
346
360
|
for (const [nid, node] of Object.entries(fn.cfg?.nodes ?? {})) {
|
|
347
361
|
if (node.kind === 'call' && node.callee) {
|
|
348
|
-
|
|
362
|
+
// GA-03.3 (0.195.25): the AWS SDK v3 command shape,
|
|
363
|
+
// `client.send(new SendMessageCommand(input))`. The general catalog's
|
|
364
|
+
// bare `send` entry (a koa-send path sink) used to claim this call as
|
|
365
|
+
// an unresolved file sink; it is a queue publish, resolved through the
|
|
366
|
+
// privacy catalog's own queue entry so the registry decision is the
|
|
367
|
+
// same one `sqs.sendMessage(input)` gets. A RECEIVE command is a read,
|
|
368
|
+
// not a sink: the site is skipped here and becomes a boundary
|
|
369
|
+
// consumer (boundary-join.js) instead.
|
|
370
|
+
const cmd = queueCommandOf(node.callee, node.args ?? []);
|
|
371
|
+
if (cmd && cmd.role === 'receive') continue;
|
|
372
|
+
const r = cmd ? resolveQueueCommandSite(cmd) : resolveSinkAtCallSite(node.callee, fn.file);
|
|
349
373
|
if (r) {
|
|
350
374
|
sites.push({ file: fn.file, qid: fn.qid, nodeId: nid, line: node.line ?? null, calleeExpr: node.callee, args: node.args ?? [], ...r });
|
|
351
375
|
// Milestone 2, Sub-project E, increment 3: queue/topic identity
|
|
@@ -356,7 +380,7 @@ export function enumerateSinkSites(callGraph) {
|
|
|
356
380
|
// only the queue category needs).
|
|
357
381
|
if (r.decision.category === 'queue') {
|
|
358
382
|
const site = sites[sites.length - 1];
|
|
359
|
-
site.queueDetail = extractQueueDetail(node.args ?? []);
|
|
383
|
+
site.queueDetail = extractQueueDetail(cmd ? [cmd.input] : (node.args ?? []));
|
|
360
384
|
}
|
|
361
385
|
}
|
|
362
386
|
// Milestone 2, Sub-project E, increment 1: ORM-write recognition,
|
|
@@ -414,6 +438,27 @@ function calleeDescriptor(calleeExpr) {
|
|
|
414
438
|
}
|
|
415
439
|
const calleeDisplay = (d) => (!d ? null : d.type === 'member-call' ? `${d.object}.${d.method}` : d.callee);
|
|
416
440
|
|
|
441
|
+
/**
|
|
442
|
+
* GA-03.3: the coverage record for handoff joins, the same for the builder's
|
|
443
|
+
* own sketch and coverage.js's finished ledger. Disabled builds say so;
|
|
444
|
+
* nothing is counted that was not attempted.
|
|
445
|
+
*/
|
|
446
|
+
export function boundaryCoverage(boundaries) {
|
|
447
|
+
if (!boundaries) return { enabled: false };
|
|
448
|
+
return {
|
|
449
|
+
enabled: true,
|
|
450
|
+
rounds: boundaries.rounds, maxRounds: MAX_BOUNDARY_ROUNDS, truncated: boundaries.truncated,
|
|
451
|
+
producers: boundaries.endpoints.producers, consumers: boundaries.endpoints.consumers,
|
|
452
|
+
joins: boundaries.joins.length,
|
|
453
|
+
joinedFields: boundaries.joins.reduce((n, j) => n + j.fields.length, 0),
|
|
454
|
+
declared: boundaries.joins.filter((j) => j.provenance === 'manual').length,
|
|
455
|
+
discovered: boundaries.joins.filter((j) => j.provenance === 'code').length,
|
|
456
|
+
unjoined: boundaries.unjoined.map((u) => ({ side: u.side, kind: u.kind, file: u.file, line: u.line, reason: u.reason })),
|
|
457
|
+
gaps: boundaries.gaps.length,
|
|
458
|
+
note: 'Every join is modeled from a declared contract or a literal identity both sides name; none is an observed message or row.',
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
417
462
|
const nodeLabel = (kind, category, reason) => (category
|
|
418
463
|
? `${category} (${kind})`
|
|
419
464
|
: `unsupported ${kind}: ${String(reason).split(';')[0].slice(0, 80)}`);
|
|
@@ -446,18 +491,57 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
|
|
|
446
491
|
...(opts.signal ? { signal: opts.signal } : {}),
|
|
447
492
|
...(opts.deadline === undefined ? {} : { deadline: opts.deadline }),
|
|
448
493
|
});
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
494
|
+
const runAnalysis = (seedsNow) => {
|
|
495
|
+
const hopsNow = [];
|
|
496
|
+
const afterNode = seedAfterNodeFactory(seedsNow);
|
|
497
|
+
const analysisNow = runFieldIdentityAnalysis(callGraph, {
|
|
498
|
+
recordHop: (h) => hopsNow.push(h),
|
|
499
|
+
seedEntryState: seedEntryStateFactory(seedsNow),
|
|
500
|
+
// GA-03.1 (0.195.24): call-shaped sources assigned to a variable.
|
|
501
|
+
...(afterNode ? { seedAfterNode: afterNode } : {}),
|
|
502
|
+
...(opts.maxContextsPerFn === undefined ? {} : { maxContextsPerFn: opts.maxContextsPerFn }),
|
|
503
|
+
...(opts.maxIterations === undefined ? {} : { maxIterations: opts.maxIterations }),
|
|
504
|
+
// DAG-03: provenance correlation version 2 (off unless requested).
|
|
505
|
+
...(opts.correlationVersion === undefined ? {} : { correlationVersion: opts.correlationVersion }),
|
|
506
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
507
|
+
...(opts.deadline === undefined ? {} : { deadline: opts.deadline }),
|
|
508
|
+
...(opts.onProgress ? { onProgress: opts.onProgress } : {}),
|
|
509
|
+
});
|
|
510
|
+
return { hops: hopsNow, analysis: analysisNow };
|
|
511
|
+
};
|
|
512
|
+
let { hops, analysis } = runAnalysis(seeds);
|
|
513
|
+
// Sink sites are enumerated once, here, because the boundary rounds below
|
|
514
|
+
// need the producer sites before the projection does. Pure over the call
|
|
515
|
+
// graph, so the order change is not observable.
|
|
516
|
+
const { sites, nonStatementSites } = enumerateSinkSites(callGraph);
|
|
517
|
+
// GA-03.3 (0.195.25): cross-service handoffs (boundary-join.js). Opt-in
|
|
518
|
+
// (`opts.boundaries.enabled`); a build that omits it is byte-identical to
|
|
519
|
+
// the build before this existed. A round plans the consumer seeds the
|
|
520
|
+
// current hop stream supports and, when it minted any, re-runs the field
|
|
521
|
+
// analysis so a chain of handoffs resolves; the round count is bounded and
|
|
522
|
+
// a still-productive last round is reported as truncated, never read as
|
|
523
|
+
// convergence.
|
|
524
|
+
let boundaries = null;
|
|
525
|
+
if (opts.boundaries && opts.boundaries.enabled) {
|
|
526
|
+
const endpoints = collectBoundaryEndpoints(callGraph, sites);
|
|
527
|
+
const contracts = Array.isArray(opts.boundaries.contracts) ? opts.boundaries.contracts : [];
|
|
528
|
+
const minted = new Set();
|
|
529
|
+
const joinsById = new Map();
|
|
530
|
+
boundaries = { enabled: true, rounds: 0, truncated: false, seeds: 0, joins: [], unjoined: endpoints.unjoined, gaps: [], endpoints: { producers: endpoints.producers.length, consumers: endpoints.consumers.length } };
|
|
531
|
+
for (let round = 0; round <= MAX_BOUNDARY_ROUNDS; round++) {
|
|
532
|
+
const r = planBoundaryJoins({ endpoints, hops, contracts, callGraph, minted });
|
|
533
|
+
for (const j of r.joins) if (!joinsById.has(j.joinId) || j.fields.length) joinsById.set(j.joinId, j);
|
|
534
|
+
boundaries.unjoined = r.unjoined;
|
|
535
|
+
boundaries.gaps = r.gaps;
|
|
536
|
+
if (!r.seeds.length) break;
|
|
537
|
+
if (round === MAX_BOUNDARY_ROUNDS) { boundaries.truncated = true; break; }
|
|
538
|
+
seeds.push(...r.seeds);
|
|
539
|
+
boundaries.seeds += r.seeds.length;
|
|
540
|
+
boundaries.rounds += 1;
|
|
541
|
+
({ hops, analysis } = runAnalysis(seeds));
|
|
542
|
+
}
|
|
543
|
+
boundaries.joins = [...joinsById.values()].sort((a, b) => (a.joinId < b.joinId ? -1 : 1));
|
|
544
|
+
}
|
|
461
545
|
const store = new PathStore({}, { correlationVersion: opts.correlationVersion });
|
|
462
546
|
store.addHops(hops);
|
|
463
547
|
// DAG-07 R07.2: an analysis unit that hit its budget has no hop
|
|
@@ -480,6 +564,10 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
|
|
|
480
564
|
for (const u of analysisCompletion.incomplete) {
|
|
481
565
|
store.markTruncated(u.qid, u.context, u.reasons[0] ?? 'analysis-incomplete');
|
|
482
566
|
}
|
|
567
|
+
if (boundaries && boundaries.truncated) {
|
|
568
|
+
analysisCompletion.boundaries = { rounds: boundaries.rounds, maxRounds: MAX_BOUNDARY_ROUNDS, truncated: true };
|
|
569
|
+
if (analysisCompletion.status === 'complete') analysisCompletion.status = 'partial';
|
|
570
|
+
}
|
|
483
571
|
|
|
484
572
|
const cfgByQid = new Map();
|
|
485
573
|
for (const fn of callGraph.functions.values()) cfgByQid.set(fn.qid, { fn, nodes: fn.cfg?.nodes ?? {} });
|
|
@@ -491,6 +579,20 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
|
|
|
491
579
|
if (!seedSitesByDe.has(s.dataElementId)) seedSitesByDe.set(s.dataElementId, []);
|
|
492
580
|
seedSitesByDe.get(s.dataElementId).push(s);
|
|
493
581
|
}
|
|
582
|
+
// GA-03.3: one data element can now be seeded in more than one function
|
|
583
|
+
// (its collection point and every consumer a handoff carried it into).
|
|
584
|
+
// The seed a path starts from is the one in the function the path's
|
|
585
|
+
// terminal node lives in; the first seed is the fallback for a path whose
|
|
586
|
+
// terminal is not a seeded location (unchanged behaviour when there is one
|
|
587
|
+
// seed per element, which is every build without boundaries).
|
|
588
|
+
const seedForPath = (p) => {
|
|
589
|
+
const cands = seedSitesByDe.get(p.dataElementId);
|
|
590
|
+
if (!cands || cands.length < 2) return seedByDe.get(p.dataElementId);
|
|
591
|
+
const scope = store.getNode(p.terminal?.nodeId)?.scope ?? store.getNode(p.hops?.[0]?.fromNodeId)?.scope ?? null;
|
|
592
|
+
return cands.find((s) => s.qid === scope) ?? cands[0];
|
|
593
|
+
};
|
|
594
|
+
const siteByKey = new Map();
|
|
595
|
+
for (const site of sites) siteByKey.set(`${site.qid}|${site.nodeId}`, site);
|
|
494
596
|
|
|
495
597
|
const graph = emptyGraphEnvelope({
|
|
496
598
|
graphId: ids.graphId({ repository, commit: opts.commit ?? 'uncommitted', configHash: opts.configHash ?? 'default' }),
|
|
@@ -568,11 +670,22 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
|
|
|
568
670
|
decisionsByNodeId.get(id).push({ kind, category, coverageStatus, externality });
|
|
569
671
|
return n;
|
|
570
672
|
};
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
673
|
+
// GA-03.3: a seed a handoff minted has no collection point of its own; its
|
|
674
|
+
// source node IS the producer's sink node (the queue or the store), so the
|
|
675
|
+
// graph reads as one chain, HTTP to queue to store to HTTP response, with
|
|
676
|
+
// the handoff visible on the edge. The producer site always exists (a join
|
|
677
|
+
// is planned from a site in `sites`); the fallback below is defensive.
|
|
678
|
+
const sourceNodeFor = (s) => {
|
|
679
|
+
if (s.boundary) {
|
|
680
|
+
const psite = siteByKey.get(`${s.boundary.producer.qid}|${s.boundary.producer.nodeId}`);
|
|
681
|
+
if (psite) return sinkNodeFor(psite);
|
|
682
|
+
}
|
|
683
|
+
return mintNode({
|
|
684
|
+
kind: 'source', category: s.category, coverageStatus: s.coverageStatus, externality: s.externality,
|
|
685
|
+
coverageReason: s.reason, subtypeKey: s.category ?? `unsupported-source:${s.entryId}`,
|
|
686
|
+
lifecycleStages: ['collection'],
|
|
687
|
+
});
|
|
688
|
+
};
|
|
576
689
|
const sinkNodeFor = (site) => mintNode({
|
|
577
690
|
kind: site.decision.kind, category: site.decision.category,
|
|
578
691
|
coverageStatus: site.decision.coverageStatus, externality: site.decision.externality,
|
|
@@ -583,9 +696,15 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
|
|
|
583
696
|
storeDetail: site.storeDetail ?? null,
|
|
584
697
|
queueDetail: site.queueDetail ?? null,
|
|
585
698
|
});
|
|
586
|
-
const mintDataElement = (
|
|
587
|
-
let d = deById.get(
|
|
699
|
+
const mintDataElement = (seedIn) => {
|
|
700
|
+
let d = deById.get(seedIn.dataElementId);
|
|
588
701
|
if (!d) {
|
|
702
|
+
// GA-03.3: an element is named by its collection point, never by the
|
|
703
|
+
// key a handoff carried it under (`{contact: email}` is still the
|
|
704
|
+
// element `email` at the consumer). The first non-boundary seed of
|
|
705
|
+
// the element is that collection point; a boundary seed is the
|
|
706
|
+
// fallback only when nothing else minted the element.
|
|
707
|
+
const s = (seedSitesByDe.get(seedIn.dataElementId) ?? []).find((x) => !x.boundary) ?? seedIn;
|
|
589
708
|
d = {
|
|
590
709
|
id: s.dataElementId, name: s.canonicalName, aliases: [], declaredType: null,
|
|
591
710
|
dataClasses: s.dataClasses, aiContexts: [], sourceLocations: [],
|
|
@@ -600,7 +719,6 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
|
|
|
600
719
|
return d;
|
|
601
720
|
};
|
|
602
721
|
|
|
603
|
-
const { sites, nonStatementSites } = enumerateSinkSites(callGraph);
|
|
604
722
|
// E4's FR-203 closure hook (DESIGN_GRAPH_BUILDER.md §9.4 item 5b): a
|
|
605
723
|
// caller may substitute an adjusted decision for a site — e.g.
|
|
606
724
|
// coverage.js's `resolveSiteDecision`, which reclassifies a sink whose
|
|
@@ -652,7 +770,7 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
|
|
|
652
770
|
if (r.truncated) stats.truncatedQueries += 1;
|
|
653
771
|
stats.pathsEnumerated += r.enumeratedPathCount;
|
|
654
772
|
for (const p of r.paths) {
|
|
655
|
-
const seed =
|
|
773
|
+
const seed = seedForPath(p);
|
|
656
774
|
if (!seed) continue;
|
|
657
775
|
connected = true;
|
|
658
776
|
stats.pathsProjected += 1;
|
|
@@ -743,13 +861,18 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
|
|
|
743
861
|
id: edgeIdStr, from: src.id, to: snk.id, relationship: 'data_flow',
|
|
744
862
|
fieldMappings: [{ fromPath, toPath, dataElementIds: [de.id], mappingType, transformationIds: sortedT }],
|
|
745
863
|
protocol: { name: 'in-process', destinationResolution: site.destination?.resolutionStatus ?? 'unknown' },
|
|
746
|
-
|
|
864
|
+
// GA-03.3: an edge from a handoff seed records the crossing it
|
|
865
|
+
// rests on. The connection is modeled (a declared contract or a
|
|
866
|
+
// literal identity both sides name), never observed.
|
|
867
|
+
boundaryCrossings: seed.boundary
|
|
868
|
+
? [{ kind: seed.boundary.kind, identity: seed.boundary.identity, joinId: seed.boundary.joinId, contractId: seed.boundary.contractId, basis: seed.boundary.basis, modeledInput: true, observed: false }]
|
|
869
|
+
: [],
|
|
747
870
|
// Milestone 2, Sub-project F, increment 1 (FR-304): which
|
|
748
|
-
// mechanism discovered this edge.
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
// comment for the full rationale).
|
|
752
|
-
provenance: 'code',
|
|
871
|
+
// mechanism discovered this edge. An edge minted from code is
|
|
872
|
+
// `code`; an edge a boundary contract declared is `manual`
|
|
873
|
+
// (GA-03.3). Never a guess (see schema.js's
|
|
874
|
+
// EDGE_PROVENANCE_VALUES comment for the full rationale).
|
|
875
|
+
provenance: seed.boundary ? seed.boundary.provenance : 'code',
|
|
753
876
|
// Milestone 2, Sub-project B, increment 2 (FR-401):
|
|
754
877
|
// `opts.resolveTransitProtection(site) -> {verdict, evidenceGrade}
|
|
755
878
|
// | undefined`, applied at this exact point — the same block
|
|
@@ -772,7 +895,7 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
|
|
|
772
895
|
// key by design and therefore never splits a flow.
|
|
773
896
|
const flowKey = [src.id, snk.id, de.id, p.shape, g.grade, sortedT.join(',')].join('|');
|
|
774
897
|
if (!groupsByFlowKey.has(flowKey)) groupsByFlowKey.set(flowKey, []);
|
|
775
|
-
groupsByFlowKey.get(flowKey).push({ p, g, src, snk, de, edgeIdStr, sortedT, site, unattributed, truncated: r.truncated, truncationReasons: r.truncationReasons, result: r });
|
|
898
|
+
groupsByFlowKey.get(flowKey).push({ p, g, src, snk, de, seed, edgeIdStr, sortedT, site, unattributed, truncated: r.truncated, truncationReasons: r.truncationReasons, result: r });
|
|
776
899
|
}
|
|
777
900
|
}
|
|
778
901
|
site.connected = connected;
|
|
@@ -809,6 +932,9 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
|
|
|
809
932
|
...(site.ambiguity && site.ambiguity.resolvedBy === 'plurality'
|
|
810
933
|
? [`sink category ambiguous at ${site.file}:${site.line}: also matches ${site.ambiguity.alternatives.join(', ')}`]
|
|
811
934
|
: []),
|
|
935
|
+
...(group[0].seed?.boundary
|
|
936
|
+
? [`crosses a ${group[0].seed.boundary.kind} handoff (${group[0].seed.boundary.provenance === 'manual' ? `contract ${group[0].seed.boundary.contractId}` : `literal identity ${group[0].seed.boundary.identity}`}) that is modeled, not observed: no message or row was seen`]
|
|
937
|
+
: []),
|
|
812
938
|
])].sort();
|
|
813
939
|
// Milestone 2, Sub-project D, increment 1 (FR-403): computed once,
|
|
814
940
|
// here, from this flow's own representative reconstructed path — never
|
|
@@ -1167,11 +1293,17 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
|
|
|
1167
1293
|
...(store.correlationVersion() === 2 ? { correlationVersion: 2 } : null),
|
|
1168
1294
|
},
|
|
1169
1295
|
...(analysisCompletion.status === 'complete' ? {} : { analysisCompletion }),
|
|
1296
|
+
...(boundaries ? { boundaries: boundaryCoverage(boundaries) } : {}),
|
|
1170
1297
|
};
|
|
1171
1298
|
graph.limitations = [
|
|
1172
1299
|
'Protection verdicts are not assessed in Milestone 1: every edge carries not_assessed/none on all three dimensions.',
|
|
1173
1300
|
'Policy verdicts are not evaluated in Milestone 1.',
|
|
1174
1301
|
'External destinations are not resolved (FR-202 is Milestone 2); protocol.destinationResolution is "unknown" on every edge.',
|
|
1302
|
+
// GA-03.3: only a build that opted in says anything here, so a build
|
|
1303
|
+
// that did not is byte-identical to the build before joins existed.
|
|
1304
|
+
...(boundaries
|
|
1305
|
+
? [`Cross-service handoffs are joined from a declared boundary contract or from the same literal queue or collection identity on both sides (${boundaries.joins.length} join(s), ${boundaries.unjoined.length} endpoint(s) left unjoined with a reason); every join is modeled from code, never an observed message or row.`]
|
|
1306
|
+
: []),
|
|
1175
1307
|
];
|
|
1176
1308
|
graph.scanHealth = analysisCompletion.status === 'complete'
|
|
1177
1309
|
? { status: 'complete', reason: 'lineage-graph-builder', analysisCompletion }
|
|
@@ -1192,5 +1324,5 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
|
|
|
1192
1324
|
}
|
|
1193
1325
|
graph.taxonomy = { version: '1.0.0', source: 'built-in + CONFIDENTIAL extension' };
|
|
1194
1326
|
|
|
1195
|
-
return { graph, store, hops, analysisCompletion, seeds, unseedable, sites, nonStatementSites, degraded, stats, decisionsByNodeId };
|
|
1327
|
+
return { graph, store, hops, analysisCompletion, seeds, unseedable, sites, nonStatementSites, degraded, stats, decisionsByNodeId, boundaries };
|
|
1196
1328
|
}
|
package/src/lineage/index.js
CHANGED
|
@@ -70,6 +70,7 @@ import { loadObservations } from './observation-store.js';
|
|
|
70
70
|
// (which owns only the REMOTE side).
|
|
71
71
|
import { validateCrossRepoLink, CROSS_REPO_LINKS_FILENAME } from './cross-repo-link.js';
|
|
72
72
|
import { contributeNixConfigLineage } from './nix-view.js';
|
|
73
|
+
import { BOUNDARY_CONTRACTS_FILENAME } from './boundary-contract.js';
|
|
73
74
|
import { adaptCallGraphForLineage } from './haskell-view.js';
|
|
74
75
|
|
|
75
76
|
// A small, LOCAL, tolerant loader for the operator-declared
|
|
@@ -83,6 +84,31 @@ import { adaptCallGraphForLineage } from './haskell-view.js';
|
|
|
83
84
|
// degrades to an empty/partial array with a console warning naming the
|
|
84
85
|
// count skipped, mirroring `loadRecipientConfig`'s own per-entry
|
|
85
86
|
// discipline.
|
|
87
|
+
/**
|
|
88
|
+
* GA-03.3: the DAG-08 contracts file, `[]` when absent or malformed (the
|
|
89
|
+
* parse error is printed, never thrown). Read directly and absence taken
|
|
90
|
+
* from the read's own error, so there is no existence check before the open.
|
|
91
|
+
*/
|
|
92
|
+
function _loadBoundaryContracts(scanRoot) {
|
|
93
|
+
if (!scanRoot) return [];
|
|
94
|
+
const fp = statePath(scanRoot, BOUNDARY_CONTRACTS_FILENAME);
|
|
95
|
+
let raw;
|
|
96
|
+
try {
|
|
97
|
+
raw = fs.readFileSync(fp, 'utf8');
|
|
98
|
+
} catch (e) {
|
|
99
|
+
if (e && e.code === 'ENOENT') return [];
|
|
100
|
+
console.error(`agentic-security: cannot read boundary contracts file (${fp}); no declared handoffs will be joined (${e.message})`);
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const doc = JSON.parse(raw);
|
|
105
|
+
return Array.isArray(doc) ? doc : Array.isArray(doc && doc.contracts) ? doc.contracts : [];
|
|
106
|
+
} catch (e) {
|
|
107
|
+
console.error(`agentic-security: bad JSON in boundary contracts file (${fp}); no declared handoffs will be joined (${e.message})`);
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
86
112
|
function _loadCrossRepoLinkRecords(filePath) {
|
|
87
113
|
let raw;
|
|
88
114
|
try {
|
|
@@ -262,8 +288,19 @@ export function buildLineageGraph(callGraph, opts = {}) {
|
|
|
262
288
|
// Haskell is rewritten into a lineage VIEW (source reads become seedable member reads, sinks become
|
|
263
289
|
// statement-level calls); every other language, and the shared call graph itself, are untouched.
|
|
264
290
|
const lineageCallGraph = adaptCallGraphForLineage(callGraph);
|
|
291
|
+
// GA-03.3: cross-service handoff joins, opt-in (`opts.boundaries`,
|
|
292
|
+
// from AGENTIC_SECURITY_LINEAGE_BOUNDARIES=1 in engine.js). The
|
|
293
|
+
// operator's DAG-08 boundary contracts are read once here, from the
|
|
294
|
+
// same state directory `dataflow contracts` reads, existence-gated like
|
|
295
|
+
// every other config load in this function; a malformed file is a
|
|
296
|
+
// recorded failure of the join step (an empty contract list with the
|
|
297
|
+
// error named), never a thrown scan.
|
|
298
|
+
const boundaries = opts.boundaries && opts.boundaries.enabled
|
|
299
|
+
? { enabled: true, contracts: opts.boundaries.contracts ?? _loadBoundaryContracts(opts.scanRoot) }
|
|
300
|
+
: undefined;
|
|
265
301
|
const built = buildGraphWithCoverage(lineageCallGraph, {
|
|
266
302
|
repository: opts.repository,
|
|
303
|
+
...(boundaries ? { boundaries } : {}),
|
|
267
304
|
generatedAt: opts.deterministic ? undefined : new Date().toISOString(),
|
|
268
305
|
perFile: opts.perFile,
|
|
269
306
|
parseFailures: opts.parseFailures,
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
//
|
|
2
|
+
// lineage-source-catalog.js — JavaScript data collection points that are
|
|
3
|
+
// lineage sources but NOT taint sources (GA plan, GA-03.1: "address
|
|
4
|
+
// source-category omissions without broadening the main taint catalog
|
|
5
|
+
// incorrectly").
|
|
6
|
+
//
|
|
7
|
+
// Four of the fourteen reachable `SOURCE_CATEGORIES` had no `language: 'js'`
|
|
8
|
+
// entry in `dataflow/catalog.js` at all: `cli-argument` (only `py-sys-argv`
|
|
9
|
+
// and the argparse entries), `http-upload` (only Django and Symfony),
|
|
10
|
+
// `storage-read` (only `py-open-read` and the C `read`/`fread`/`fgets`
|
|
11
|
+
// entries) and `user-input` (only `py-input`, `gets`, `scanf`). The lineage
|
|
12
|
+
// corpus therefore could not mint a source node for any of them from a
|
|
13
|
+
// JavaScript fixture, and the four corpus entries that name them were
|
|
14
|
+
// written in Python syntax on purpose, as a disclosed limitation.
|
|
15
|
+
//
|
|
16
|
+
// Why these entries live HERE and not in `dataflow/catalog.js`'s `CATALOG`:
|
|
17
|
+
// that catalog is the taint engine's source list, and every entry in it
|
|
18
|
+
// becomes an attacker-controlled input for every SAST and taint finding the
|
|
19
|
+
// engine emits. A file's contents (`fs.readFileSync`) and a console prompt
|
|
20
|
+
// (`prompt()`, `readlineSync.question()`) are places personal data ENTERS a
|
|
21
|
+
// program, which is what a lineage graph records, but treating them as
|
|
22
|
+
// attacker-controlled would turn every configuration read into a taint
|
|
23
|
+
// source and flood the SAST output with findings nobody asked for. The
|
|
24
|
+
// Data Flow Explorer already keeps a second, isolated catalog for the same
|
|
25
|
+
// reason (`dataflow/privacy-catalog.js`, never merged into `CATALOG`); this
|
|
26
|
+
// module follows that precedent on the source side. `test/lineage/
|
|
27
|
+
// lineage-source-catalog.test.js` pins that `dataflow/catalog.js`'s own
|
|
28
|
+
// `matchSource` returns null for every shape listed here, so adding an
|
|
29
|
+
// entry here can never widen the taint engine by accident.
|
|
30
|
+
//
|
|
31
|
+
// Reuse boundary: imports nothing from `dataflow/`. The matcher below is a
|
|
32
|
+
// deliberately small structural match over the parser's expression shape
|
|
33
|
+
// (`ident`/`member`/`call`), not a copy of `catalog.js`'s index machinery;
|
|
34
|
+
// an entry that needs receiver types, keyword arguments or a language family
|
|
35
|
+
// other than JavaScript belongs in the main catalog, with the taint
|
|
36
|
+
// consequences weighed there.
|
|
37
|
+
|
|
38
|
+
import { reclassifySource } from './source-registry.js';
|
|
39
|
+
|
|
40
|
+
const JS_FILE = /\.(?:[cm]?js|jsx|tsx?)$/i;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Each entry mirrors the main catalog's source shape (`kind`, `id`,
|
|
44
|
+
* `language`, `framework`, `match`, `label`, `provenance`) so
|
|
45
|
+
* `reclassifySource` reads it unchanged: `provenance` is the key the
|
|
46
|
+
* registry's `PROVENANCE_MAP` resolves to a `SOURCE_CATEGORIES` value.
|
|
47
|
+
* `lineageOnly: true` is the marker a reader greps for; nothing consults it.
|
|
48
|
+
*/
|
|
49
|
+
export const LINEAGE_SOURCE_CATALOG = Object.freeze([
|
|
50
|
+
{
|
|
51
|
+
kind: 'source', id: 'js-process-argv', language: 'js', framework: 'node', lineageOnly: true,
|
|
52
|
+
match: { type: 'member', object: 'process', prop: 'argv' }, label: 'process.argv', provenance: 'cli',
|
|
53
|
+
why: 'a positional command-line argument; the value is whatever the operator typed, not attacker-controlled in the taint sense, so it is a collection point for lineage only',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
kind: 'source', id: 'js-multer-req-file', language: 'js', framework: 'express', lineageOnly: true,
|
|
57
|
+
match: { type: 'member', object: 'req', prop: 'file' }, label: 'req.file', provenance: 'upload',
|
|
58
|
+
why: 'the single uploaded file multer attaches to the request (name, type, buffer, path)',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
kind: 'source', id: 'js-multer-req-files', language: 'js', framework: 'express', lineageOnly: true,
|
|
62
|
+
match: { type: 'member', object: 'req', prop: 'files' }, label: 'req.files', provenance: 'upload',
|
|
63
|
+
why: 'the uploaded files multer attaches to the request, keyed by field name',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
kind: 'source', id: 'js-fs-readFileSync', language: 'js', framework: 'node', lineageOnly: true,
|
|
67
|
+
match: { type: 'call', callee: 'readFileSync' }, label: 'fs.readFileSync()', provenance: 'file-read',
|
|
68
|
+
why: 'the contents of a file; the callback form `fs.readFile(p, cb)` delivers its contents to a callback and is not seeded (its path argument is already a sink in the main catalog)',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
kind: 'source', id: 'js-window-prompt', language: 'js', framework: 'browser', lineageOnly: true,
|
|
72
|
+
match: { type: 'call', callee: 'prompt', bare: true }, label: 'prompt()', provenance: 'stdin',
|
|
73
|
+
why: 'text a person typed into the browser prompt dialog; only the bare global, never `x.prompt()` (inquirer and friends return a promise of an answers object)',
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
kind: 'source', id: 'js-readline-sync-question', language: 'js', framework: 'node', lineageOnly: true,
|
|
77
|
+
match: { type: 'call', callee: 'question', receiver: 'readlineSync' }, label: 'readlineSync.question()', provenance: 'stdin',
|
|
78
|
+
why: 'text a person typed at the console; the readline module\'s own `rl.question(q, cb)` delivers the answer to a callback and is not seeded',
|
|
79
|
+
},
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
/** The receiver chain of a member-call callee, outermost first (`a.b.c()` -> ['a', 'b']). */
|
|
83
|
+
function receiverSegments(callee) {
|
|
84
|
+
const segs = [];
|
|
85
|
+
let cur = callee && callee.kind === 'member' ? callee.object : null;
|
|
86
|
+
let depth = 0;
|
|
87
|
+
while (cur && depth++ < 8) {
|
|
88
|
+
if (cur.kind === 'ident') { segs.unshift(cur.name); break; }
|
|
89
|
+
if (cur.kind === 'member') { if (typeof cur.prop === 'string') segs.unshift(cur.prop); cur = cur.object; continue; }
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
return segs;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function calleeName(callee) {
|
|
96
|
+
if (!callee) return null;
|
|
97
|
+
if (typeof callee === 'string') return callee.slice(callee.lastIndexOf('.') + 1);
|
|
98
|
+
if (callee.kind === 'ident') return callee.name ?? null;
|
|
99
|
+
if (callee.kind === 'member') return typeof callee.prop === 'string' ? callee.prop : null;
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function entryMatches(entry, expr) {
|
|
104
|
+
const m = entry.match;
|
|
105
|
+
if (m.type === 'member') {
|
|
106
|
+
return expr.kind === 'member' && expr.object?.kind === 'ident' && expr.object.name === m.object && expr.prop === m.prop;
|
|
107
|
+
}
|
|
108
|
+
if (m.type === 'call') {
|
|
109
|
+
if (expr.kind !== 'call') return false;
|
|
110
|
+
if (calleeName(expr.callee) !== m.callee) return false;
|
|
111
|
+
if (m.bare) return expr.callee && expr.callee.kind === 'ident';
|
|
112
|
+
if (m.receiver) return receiverSegments(expr.callee).includes(m.receiver);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The lineage-only counterpart of `dataflow/catalog.js`'s `matchSource`:
|
|
120
|
+
* returns the first catalog entry whose `match` fits `expr`, or null.
|
|
121
|
+
* Gated to JavaScript and TypeScript files (an entry here is JavaScript by
|
|
122
|
+
* construction); a missing `file` is permissive, matching `matchSource`.
|
|
123
|
+
* Never throws on a malformed expression.
|
|
124
|
+
*/
|
|
125
|
+
export function matchLineageSource(expr, file) {
|
|
126
|
+
if (!expr || typeof expr !== 'object') return null;
|
|
127
|
+
if (file && !JS_FILE.test(file)) return null;
|
|
128
|
+
for (const entry of LINEAGE_SOURCE_CATALOG) {
|
|
129
|
+
if (entryMatches(entry, expr)) return entry;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The registry decision for every entry, computed once: proves at load time that each `provenance` resolves. */
|
|
135
|
+
export const LINEAGE_SOURCE_DECISIONS = Object.freeze(Object.fromEntries(
|
|
136
|
+
LINEAGE_SOURCE_CATALOG.map((e) => {
|
|
137
|
+
const d = reclassifySource(e);
|
|
138
|
+
if (!d || !d.category || d.coverageStatus === 'unsupported') {
|
|
139
|
+
throw new Error(`lineage-source-catalog: ${e.id} has a provenance (${e.provenance}) the source registry cannot classify`);
|
|
140
|
+
}
|
|
141
|
+
return [e.id, d];
|
|
142
|
+
}),
|
|
143
|
+
));
|