mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-11 03:38:07 +00:00
feat(cfg): call-site sites[] taint substrate for C-family (#2195 U6)
Extend the C/C++/C#/Java/Go harvests with the call-site sites[] taint substrate (SiteRecord/SiteArgOccurrence), mirroring the TS shape so the shared taint matcher consumes all languages uniformly. Extract the grammar-agnostic site machinery into cfg/visitors/call-site-harvest.ts (CallSiteFactAccumulator -- names no language); each harvest adds only its per-grammar visitCall/walkChain over its call node (C/C++ call_expression, C# invocation_expression, Java method_invocation, Go call_expression). INERT BY DESIGN: no C-family taint model exists (registerBuiltinTaintModels is TS/JS only), so getSourceSinkConfig returns undefined for these languages and the harvested sites produce ZERO TAINTED edges -- the positive source->sink->TAINTED path is deferred with the model authoring. sites emitted only when non-empty; facts-only attachment, block/edge topology unchanged (pre-existing topology + def/use tests byte-identical). 23 new substrate tests; 574 green across the cfg/taint/emit suites; gate green; tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fff2c1489a
commit
b294a387a5
9 changed files with 1231 additions and 174 deletions
|
|
@ -51,6 +51,14 @@
|
|||
*/
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import type { BindingEntry, StatementFacts } from '../types.js';
|
||||
import { CallSiteFactAccumulator } from './call-site-harvest.js';
|
||||
|
||||
/**
|
||||
* The per-statement def/use + call-site collector. Aliased to the shared
|
||||
* {@link CallSiteFactAccumulator} so the harvester references one name for both
|
||||
* the value (constructor) and the type.
|
||||
*/
|
||||
type FactAccumulator = CallSiteFactAccumulator;
|
||||
|
||||
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
|
||||
const NESTED_FUNCTION_TYPES = new Set(['lambda_expression', 'function_definition']);
|
||||
|
|
@ -91,6 +99,13 @@ export class CCppHarvester {
|
|||
private readonly nearestScopeCache = new Map<number, Scope>();
|
||||
/** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */
|
||||
private conditionalDepth = 0;
|
||||
/**
|
||||
* Call/new node id → bindings whose declarator/assignment VALUE is exactly
|
||||
* that call (#2195 U6). Registered by the declaration/assignment handlers
|
||||
* BEFORE the value walk, consumed by {@link visitCall} when it reaches the
|
||||
* node (mirrors the TS harvester's `resultDefTargets`).
|
||||
*/
|
||||
private readonly resultDefTargets = new Map<number, number[]>();
|
||||
|
||||
constructor(private readonly fnNode: SyntaxNode) {
|
||||
this.fnId = fnNode.id;
|
||||
|
|
@ -404,8 +419,13 @@ export class CCppHarvester {
|
|||
// `int x;` is not a def (it writes nothing at runtime), matching the
|
||||
// TS bare-`var` rule. Pointer/array/member declarators are not scalar
|
||||
// defs either — their inner identifiers stay uses.
|
||||
if (name && value && declarator?.type === 'identifier') this.def(name, acc);
|
||||
else if (declarator && declarator.type !== 'identifier') this.walkValue(declarator, acc);
|
||||
if (name && value && declarator?.type === 'identifier') {
|
||||
const snap = acc.defSnapshot();
|
||||
this.def(name, acc);
|
||||
this.registerResultDefs(value, acc.defsSince(snap));
|
||||
} else if (declarator && declarator.type !== 'identifier') {
|
||||
this.walkValue(declarator, acc);
|
||||
}
|
||||
if (value) this.walkValue(value, acc);
|
||||
}
|
||||
return;
|
||||
|
|
@ -416,8 +436,12 @@ export class CCppHarvester {
|
|||
if (left) {
|
||||
const lv = this.unwrapLvalue(left);
|
||||
if (lv.type === 'identifier') {
|
||||
const snap = acc.defSnapshot();
|
||||
this.def(lv, acc);
|
||||
if (op !== '=') this.use(lv, acc); // compound assign reads too
|
||||
// A plain `x = f(a)` attaches `resultDefs: [x]` to f's site; a
|
||||
// compound `x += f(a)` does not (the prior value flows in too).
|
||||
if (op === '=' && right) this.registerResultDefs(right, acc.defsSince(snap));
|
||||
} else {
|
||||
this.walkValue(lv, acc); // member/pointer/subscript target — uses only
|
||||
}
|
||||
|
|
@ -456,11 +480,20 @@ export class CCppHarvester {
|
|||
if (alt) this.conditional(() => this.walkValue(alt, acc));
|
||||
return;
|
||||
}
|
||||
case 'call_expression':
|
||||
// #2195 U6: explicit case (previously default-descended) — same uses,
|
||||
// plus a taint-site record. Defs/uses stay byte-identical.
|
||||
this.visitCall(node, acc, 'call');
|
||||
return;
|
||||
case 'new_expression':
|
||||
// C++ `new Foo(x)` — constructor call site (`type` field is the callee).
|
||||
this.visitCall(node, acc, 'new');
|
||||
return;
|
||||
case 'field_expression': {
|
||||
// `a.b` / `a->b` — value read of the chain root only; the field name is
|
||||
// not a scalar binding. Mirrors the TS member-read use semantics.
|
||||
const arg = node.childForFieldName('argument');
|
||||
if (arg) this.walkValue(arg, acc);
|
||||
// not a scalar binding. Mirrors the TS member-read use semantics, plus a
|
||||
// member-read site for the innermost identifier-rooted access.
|
||||
this.walkChain(node, acc, false);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
|
|
@ -470,47 +503,145 @@ export class CCppHarvester {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Ordered, deduplicating def/use collector for one statement record. */
|
||||
class FactAccumulator {
|
||||
private readonly defs: number[] = [];
|
||||
private readonly uses: number[] = [];
|
||||
private readonly mayDefs: number[] = [];
|
||||
private readonly defSeen = new Set<number>();
|
||||
private readonly useSeen = new Set<number>();
|
||||
private readonly mayDefSeen = new Set<number>();
|
||||
// ── taint-site harvest (#2195 U6) ────────────────────────────────────────
|
||||
|
||||
constructor(private readonly line: number) {}
|
||||
|
||||
addDef(idx: number): void {
|
||||
if (this.defSeen.has(idx)) return;
|
||||
this.defSeen.add(idx);
|
||||
this.defs.push(idx);
|
||||
/**
|
||||
* When `value`'s root (after stripping parens) is a call/new node, remember
|
||||
* that its site should carry `resultDefs: defs` — consumed by
|
||||
* {@link visitCall} once the value walk reaches the node.
|
||||
*/
|
||||
private registerResultDefs(value: SyntaxNode, defs: readonly number[]): void {
|
||||
if (defs.length === 0) return;
|
||||
const root = this.unwrapLvalue(value);
|
||||
if (root.type === 'call_expression' || root.type === 'new_expression') {
|
||||
this.resultDefTargets.set(root.id, [...defs]);
|
||||
}
|
||||
}
|
||||
|
||||
addMayDef(idx: number): void {
|
||||
if (this.mayDefSeen.has(idx)) return;
|
||||
this.mayDefSeen.add(idx);
|
||||
this.mayDefs.push(idx);
|
||||
/**
|
||||
* Explicit call/new handler: records a call site (callee path, receiver,
|
||||
* per-arg occurrence entries, result defs) while reproducing EXACTLY the uses
|
||||
* the old default descent recorded — callee chain root + arguments. `new`
|
||||
* sites read the `type` field as the callee; `call` sites the `function`
|
||||
* field.
|
||||
*/
|
||||
private visitCall(node: SyntaxNode, acc: FactAccumulator, kind: 'call' | 'new'): void {
|
||||
const calleeNode = node.childForFieldName(kind === 'new' ? 'type' : 'function');
|
||||
const argsNode = node.childForFieldName('arguments');
|
||||
const siteIdx = acc.openCallSite(kind);
|
||||
acc.pushFrame(siteIdx);
|
||||
let calleePath: string | undefined;
|
||||
if (calleeNode) {
|
||||
const callee = this.unwrapLvalue(calleeNode);
|
||||
if (callee.type === 'identifier' || callee.type === 'type_identifier') {
|
||||
// The callee NAME is a statement-level use but NOT a value occurrence in
|
||||
// any enclosing argument (`exec(escape(x))` must not put `escape` into
|
||||
// exec's arg 0). A `new Foo(...)` type identifier is a type, not a
|
||||
// scalar binding — record neither a use nor an occurrence for it.
|
||||
if (callee.type === 'identifier') acc.addUseWithoutOccurrence(this.resolve(callee));
|
||||
calleePath = callee.text;
|
||||
} else if (callee.type === 'field_expression') {
|
||||
// skipFinalRead: the final access IS the callee, carried by the dotted
|
||||
// path — recording it as a member read would double-count.
|
||||
const chain = this.walkChain(callee, acc, true);
|
||||
calleePath = chain.path;
|
||||
if (chain.rootIdx !== undefined) acc.setSiteReceiver(siteIdx, chain.rootIdx);
|
||||
} else if (callee.type === 'qualified_identifier') {
|
||||
// `ns::g(...)` — a static dotted path, no scalar receiver binding.
|
||||
calleePath = this.qualifiedPath(callee);
|
||||
this.walkValue(callee, acc);
|
||||
} else {
|
||||
// Call-rooted chains, function-pointer expressions — the walk still
|
||||
// records uses and nested sites.
|
||||
this.walkValue(callee, acc);
|
||||
}
|
||||
if (calleePath !== undefined) acc.setSiteCallee(siteIdx, calleePath);
|
||||
}
|
||||
const resultDefs = this.resultDefTargets.get(node.id);
|
||||
if (resultDefs !== undefined) acc.setSiteResultDefs(siteIdx, resultDefs);
|
||||
if (argsNode) {
|
||||
let pos = 0;
|
||||
for (let i = 0; i < argsNode.namedChildCount; i++) {
|
||||
const arg = argsNode.namedChild(i);
|
||||
if (!arg || arg.type === 'comment') continue;
|
||||
acc.setFrameArg(pos);
|
||||
this.walkValue(arg, acc);
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
acc.popFrame();
|
||||
}
|
||||
|
||||
addUse(idx: number): void {
|
||||
if (this.useSeen.has(idx)) return;
|
||||
this.useSeen.add(idx);
|
||||
this.uses.push(idx);
|
||||
/**
|
||||
* Member chain walk shared by value position and callee position. Use-
|
||||
* recording is identical to the old default descent (chain-root identifier
|
||||
* once). Member-read sites: at most ONE per chain — the INNERMOST access —
|
||||
* and only when the chain root is an identifier; `skipFinalRead` suppresses it
|
||||
* when that access is the callee (carried by the dotted path instead).
|
||||
*/
|
||||
private walkChain(
|
||||
node: SyntaxNode,
|
||||
acc: FactAccumulator,
|
||||
skipFinalRead: boolean,
|
||||
): { path?: string; rootIdx?: number } {
|
||||
// Collect field accesses outer→inner (unshift), then resolve the root.
|
||||
const accesses: string[] = [];
|
||||
let cur: SyntaxNode = this.unwrapLvalue(node);
|
||||
for (;;) {
|
||||
if (cur.type === 'field_expression') {
|
||||
const field = cur.childForFieldName('field');
|
||||
accesses.unshift(field?.text ?? '');
|
||||
const obj = cur.childForFieldName('argument');
|
||||
if (!obj) break;
|
||||
cur = this.unwrapLvalue(obj);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let rootIdx: number | undefined;
|
||||
let rootSegment: string | undefined;
|
||||
if (cur.type === 'identifier' || cur.type === 'field_identifier') {
|
||||
rootIdx = this.resolve(cur);
|
||||
acc.addUse(rootIdx);
|
||||
rootSegment = cur.text;
|
||||
} else {
|
||||
this.walkValue(cur, acc); // call-rooted etc. — uses + nested sites
|
||||
}
|
||||
const innermost = accesses[0];
|
||||
if (rootIdx !== undefined && innermost && !(skipFinalRead && accesses.length === 1)) {
|
||||
acc.addMemberRead(rootIdx, innermost);
|
||||
}
|
||||
const path =
|
||||
rootSegment !== undefined && accesses.every((a) => a !== '')
|
||||
? [rootSegment, ...accesses].join('.')
|
||||
: undefined;
|
||||
return { path, rootIdx };
|
||||
}
|
||||
|
||||
defCount(): number {
|
||||
return this.defs.length + this.mayDefs.length;
|
||||
}
|
||||
|
||||
finish(): StatementFacts {
|
||||
return {
|
||||
line: this.line,
|
||||
defs: this.defs,
|
||||
uses: this.uses,
|
||||
...(this.mayDefs.length > 0 ? { mayDefs: this.mayDefs } : {}),
|
||||
};
|
||||
/** Dotted path of a `ns::a::b` qualified_identifier (`::` folded to `.`). */
|
||||
private qualifiedPath(node: SyntaxNode): string | undefined {
|
||||
const segs: string[] = [];
|
||||
let cur: SyntaxNode | null = node;
|
||||
let hops = 16;
|
||||
while (cur && hops-- > 0) {
|
||||
if (cur.type === 'qualified_identifier') {
|
||||
const scope = cur.childForFieldName('scope');
|
||||
const name = cur.childForFieldName('name');
|
||||
if (scope) segs.push(scope.text);
|
||||
cur = name ?? null;
|
||||
} else {
|
||||
segs.push(cur.text);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return segs.length ? segs.join('.') : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered, deduplicating def/use + call-site collector for one statement record.
|
||||
* The shared {@link CallSiteFactAccumulator} carries the def/use machinery the
|
||||
* old local class had, plus the taint-site harvest (#2195 U6).
|
||||
*/
|
||||
const FactAccumulator = CallSiteFactAccumulator;
|
||||
|
|
|
|||
277
gitnexus/src/core/ingestion/cfg/visitors/call-site-harvest.ts
Normal file
277
gitnexus/src/core/ingestion/cfg/visitors/call-site-harvest.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
/**
|
||||
* Shared call-site taint substrate for the C-family CFG harvesters (#2195 U6,
|
||||
* plan R7 / KTD2) — the language-agnostic mechanism the C/C++, C#, Java and Go
|
||||
* harvesters layer their grammar-specific call/member walks on top of.
|
||||
*
|
||||
* This file is PURE MECHANISM: it contains no tree-sitter node-type or field
|
||||
* literals (each harvester supplies those when it drives `openCallSite` /
|
||||
* `addMemberRead` / `setFrameArg`), so it names no language and carries nothing
|
||||
* the grammar-literal CI gate needs to validate. It is the C-family analogue of
|
||||
* the `FactAccumulator` site machinery in
|
||||
* {@link import('./typescript-harvest.js')} — extracted into one place because
|
||||
* the four C-family harvesters already share an identical def/use accumulator,
|
||||
* and the site layer is identical across them too (only the per-grammar node
|
||||
* shapes differ, and those live in each harvester's `walkValue`/`visitCall`).
|
||||
*
|
||||
* Produces the same {@link SiteRecord} shape the (future, deferred) shared
|
||||
* taint matcher consumes uniformly across all languages: callee path, receiver,
|
||||
* per-argument occurrence entries (with sanitizer-interposition via-tags),
|
||||
* result defs, spread/template markers, and member reads. INERT BY DESIGN — no
|
||||
* C-family source/sink/sanitizer model is registered today (`getSourceSinkConfig`
|
||||
* returns undefined for every C-family language), so a harvest with no model
|
||||
* produces ZERO TAINTED edges; this only emits the substrate the deferred model
|
||||
* work will match against.
|
||||
*
|
||||
* Sites are emitted on {@link StatementFacts.sites} only when non-empty, exactly
|
||||
* like the TS harvester — flag-off runs never harvest, and most fact-bearing
|
||||
* statements carry no calls.
|
||||
*
|
||||
* NOTE: nothing serialized here may carry a field named `nodeId` — the durable
|
||||
* parsedfile-store reviver dedups objects keyed on that field name.
|
||||
*/
|
||||
import type { SiteArgOccurrence, SiteRecord, StatementFacts } from '../types.js';
|
||||
|
||||
/** Mutable build-time view of a {@link SiteRecord}. */
|
||||
interface MutableSite {
|
||||
kind: SiteRecord['kind'];
|
||||
parent?: [number, number];
|
||||
callee?: string;
|
||||
receiver?: number;
|
||||
args?: SiteArgOccurrence[][];
|
||||
resultDefs?: number[];
|
||||
spread?: number;
|
||||
template?: boolean;
|
||||
requireArg?: string;
|
||||
object?: number;
|
||||
property?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One open call/new site during the walk (mirrors the TS `SiteFrame`). `argIdx`
|
||||
* is the argument position currently being walked, or -1 while outside any
|
||||
* argument (callee walk) — occurrences recorded then do NOT land in this frame's
|
||||
* args, but still fan out (via-tagged) to enclosing arg-active frames.
|
||||
*/
|
||||
interface SiteFrame {
|
||||
siteIdx: number;
|
||||
argIdx: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered, deduplicating def/use collector for one statement record, PLUS the
|
||||
* call-site harvest machinery (#2195 U6). A drop-in superset of the simple
|
||||
* def/use accumulator the C-family harvesters used before the substrate landed
|
||||
* — `addDef`/`addMayDef`/`addUse`/`defCount`/`useCount`/`finish` are unchanged,
|
||||
* so harvesters that never open a site emit byte-identical facts (no `sites`
|
||||
* key, since `finish` omits it when empty).
|
||||
*/
|
||||
export class CallSiteFactAccumulator {
|
||||
private readonly defs: number[] = [];
|
||||
private readonly uses: number[] = [];
|
||||
private readonly mayDefs: number[] = [];
|
||||
private readonly defSeen = new Set<number>();
|
||||
private readonly useSeen = new Set<number>();
|
||||
private readonly mayDefSeen = new Set<number>();
|
||||
/** Taint sites recorded for this statement. */
|
||||
private readonly sites: MutableSite[] = [];
|
||||
/** Composite (object|property|parent) keys of recorded member-read sites — O(1) dedup. */
|
||||
private readonly memberReadKeys = new Set<string>();
|
||||
/** Stack of open call/new sites — the occurrence fan-out targets. */
|
||||
private readonly frames: SiteFrame[] = [];
|
||||
|
||||
constructor(private readonly line: number) {}
|
||||
|
||||
addDef(idx: number): void {
|
||||
if (this.defSeen.has(idx)) return;
|
||||
this.defSeen.add(idx);
|
||||
this.defs.push(idx);
|
||||
}
|
||||
|
||||
/** A def that may not execute (conditional context) — gen without kill. */
|
||||
addMayDef(idx: number): void {
|
||||
if (this.mayDefSeen.has(idx)) return;
|
||||
this.mayDefSeen.add(idx);
|
||||
this.mayDefs.push(idx);
|
||||
}
|
||||
|
||||
addUse(idx: number): void {
|
||||
// Occurrence fan-out happens BEFORE the statement-level dedup: `exec(x, x)`
|
||||
// records x at BOTH arg positions even though `uses` lists it once.
|
||||
this.recordOccurrence(idx);
|
||||
this.addUseWithoutOccurrence(idx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Statement-level use that is NOT a value occurrence in any open site
|
||||
* argument — bare callee names only (see each harvester's `visitCall`).
|
||||
*/
|
||||
addUseWithoutOccurrence(idx: number): void {
|
||||
if (this.useSeen.has(idx)) return;
|
||||
this.useSeen.add(idx);
|
||||
this.uses.push(idx);
|
||||
}
|
||||
|
||||
defCount(): number {
|
||||
return this.defs.length + this.mayDefs.length;
|
||||
}
|
||||
|
||||
useCount(): number {
|
||||
return this.uses.length;
|
||||
}
|
||||
|
||||
// ── site machinery (#2195 U6, mirrors the TS harvester) ──────────────────
|
||||
|
||||
/** `[defs.length, mayDefs.length]` marker for {@link defsSince}. */
|
||||
defSnapshot(): readonly [number, number] {
|
||||
return [this.defs.length, this.mayDefs.length];
|
||||
}
|
||||
|
||||
/** Binding indices def'd (must- OR may-) since the snapshot was taken. */
|
||||
defsSince(snap: readonly [number, number]): number[] {
|
||||
return [...this.defs.slice(snap[0]), ...this.mayDefs.slice(snap[1])];
|
||||
}
|
||||
|
||||
/** Open a call/new site; parent = innermost enclosing argument position. */
|
||||
openCallSite(kind: 'call' | 'new'): number {
|
||||
const site: MutableSite = { kind };
|
||||
const parent = this.innermostArgPosition();
|
||||
if (parent) site.parent = parent;
|
||||
this.sites.push(site);
|
||||
return this.sites.length - 1;
|
||||
}
|
||||
|
||||
pushFrame(siteIdx: number): void {
|
||||
this.frames.push({ siteIdx, argIdx: -1 });
|
||||
}
|
||||
|
||||
popFrame(): void {
|
||||
this.frames.pop();
|
||||
}
|
||||
|
||||
/** Set the argument position the top frame is currently walking. */
|
||||
setFrameArg(argIdx: number): void {
|
||||
const top = this.frames[this.frames.length - 1];
|
||||
if (top) top.argIdx = argIdx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` with all open arg frames temporarily detached (argIdx = -1), so
|
||||
* identifier reads inside still record USES but do NOT fan occurrences into
|
||||
* the enclosing sink-argument position (e.g. the non-value operands of a
|
||||
* comma expression — only the final operand's value flows).
|
||||
*/
|
||||
suppressOccurrences(fn: () => void): void {
|
||||
const saved = this.frames.map((f) => f.argIdx);
|
||||
for (const f of this.frames) f.argIdx = -1;
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
this.frames.forEach((f, i) => {
|
||||
f.argIdx = saved[i];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setSiteCallee(siteIdx: number, callee: string): void {
|
||||
this.sites[siteIdx].callee = callee;
|
||||
}
|
||||
|
||||
setSiteReceiver(siteIdx: number, receiver: number): void {
|
||||
this.sites[siteIdx].receiver = receiver;
|
||||
}
|
||||
|
||||
setSiteResultDefs(siteIdx: number, resultDefs: readonly number[]): void {
|
||||
this.sites[siteIdx].resultDefs = [...resultDefs];
|
||||
}
|
||||
|
||||
setSiteSpread(siteIdx: number, firstSpreadArg: number): void {
|
||||
const site = this.sites[siteIdx];
|
||||
if (site.spread === undefined) site.spread = firstSpreadArg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a value-position member read. Exact duplicates within the statement
|
||||
* (same object/property/parent position) dedup; reads at DIFFERENT argument
|
||||
* positions stay distinct (`exec(req.body, req.body)` is two occurrences).
|
||||
*/
|
||||
addMemberRead(object: number, property: string): void {
|
||||
const parent = this.innermostArgPosition();
|
||||
const dedupKey = `${object}|${property}|${parent ? `${parent[0]}:${parent[1]}` : 'top'}`;
|
||||
if (this.memberReadKeys.has(dedupKey)) return;
|
||||
this.memberReadKeys.add(dedupKey);
|
||||
const site: MutableSite = { kind: 'member-read' };
|
||||
if (parent) site.parent = parent;
|
||||
site.object = object;
|
||||
site.property = property;
|
||||
this.sites.push(site);
|
||||
}
|
||||
|
||||
private innermostArgPosition(): [number, number] | undefined {
|
||||
for (let i = this.frames.length - 1; i >= 0; i--) {
|
||||
const f = this.frames[i];
|
||||
if (f.argIdx >= 0) return [f.siteIdx, f.argIdx];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan a binding occurrence out to every arg-active open frame, via-tagged
|
||||
* with the site of the IMMEDIATELY nested frame when one exists:
|
||||
* `exec(escape(x))` puts a plain `x` in escape's arg 0 and `[x, escapeIdx]`
|
||||
* in exec's arg 0 — the sanitizer-interposition substrate.
|
||||
*/
|
||||
private recordOccurrence(idx: number): void {
|
||||
for (let i = this.frames.length - 1; i >= 0; i--) {
|
||||
const f = this.frames[i];
|
||||
if (f.argIdx < 0) continue;
|
||||
const via = i + 1 < this.frames.length ? this.frames[i + 1].siteIdx : undefined;
|
||||
this.pushArgEntry(f.siteIdx, f.argIdx, idx, via);
|
||||
}
|
||||
}
|
||||
|
||||
private pushArgEntry(
|
||||
siteIdx: number,
|
||||
argIdx: number,
|
||||
bindingIdx: number,
|
||||
via: number | undefined,
|
||||
): void {
|
||||
const site = this.sites[siteIdx];
|
||||
const args = (site.args ??= []);
|
||||
while (args.length <= argIdx) args.push([]);
|
||||
const list = args[argIdx];
|
||||
// Dedup exact (binding, via) pairs per position — `f(x + x)` is one entry;
|
||||
// `f(x + g(x))` keeps the plain AND the via-tagged entry (distinct paths).
|
||||
for (const e of list) {
|
||||
const match =
|
||||
typeof e === 'number'
|
||||
? via === undefined && e === bindingIdx
|
||||
: via !== undefined && e[0] === bindingIdx && e[1] === via;
|
||||
if (match) return;
|
||||
}
|
||||
list.push(via === undefined ? bindingIdx : [bindingIdx, via]);
|
||||
}
|
||||
|
||||
finish(): StatementFacts {
|
||||
return {
|
||||
line: this.line,
|
||||
defs: this.defs,
|
||||
uses: this.uses,
|
||||
// Optional fields stay absent when empty — keeps the serialized
|
||||
// side-channel payload lean (most statements have no may-defs / sites).
|
||||
...(this.mayDefs.length > 0 ? { mayDefs: this.mayDefs } : {}),
|
||||
...(this.sites.length > 0 ? { sites: this.sites.map(finalizeSite) } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Trim trailing empty arg positions; drop `args` entirely when all-empty. */
|
||||
const finalizeSite = (site: MutableSite): SiteRecord => {
|
||||
const args = site.args;
|
||||
if (args !== undefined) {
|
||||
let end = args.length;
|
||||
while (end > 0 && args[end - 1].length === 0) end--;
|
||||
if (end === 0) delete site.args;
|
||||
else if (end < args.length) site.args = args.slice(0, end);
|
||||
}
|
||||
return site as SiteRecord;
|
||||
};
|
||||
|
|
@ -46,6 +46,13 @@
|
|||
*/
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import type { BindingEntry, StatementFacts } from '../types.js';
|
||||
import { CallSiteFactAccumulator } from './call-site-harvest.js';
|
||||
|
||||
/**
|
||||
* The per-statement def/use + call-site collector, aliased to the shared
|
||||
* {@link CallSiteFactAccumulator} (one name for the value and the type).
|
||||
*/
|
||||
type FactAccumulator = CallSiteFactAccumulator;
|
||||
|
||||
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
|
||||
const NESTED_FUNCTION_TYPES = new Set([
|
||||
|
|
@ -89,6 +96,12 @@ export class CsharpHarvester {
|
|||
private readonly nearestScopeCache = new Map<number, Scope>();
|
||||
/** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */
|
||||
private conditionalDepth = 0;
|
||||
/**
|
||||
* Call/new node id → bindings whose declarator/assignment VALUE is exactly
|
||||
* that call (#2195 U6). Registered before the value walk, consumed by
|
||||
* {@link visitCall} (mirrors the TS harvester's `resultDefTargets`).
|
||||
*/
|
||||
private readonly resultDefTargets = new Map<number, number[]>();
|
||||
|
||||
constructor(private readonly fnNode: SyntaxNode) {
|
||||
this.fnId = fnNode.id;
|
||||
|
|
@ -390,7 +403,11 @@ export class CsharpHarvester {
|
|||
const name = d.childForFieldName('name');
|
||||
// The initializer (if any) is the LAST named child after `name`.
|
||||
const init = this.declaratorInit(d);
|
||||
if (name && init) this.def(name, acc);
|
||||
if (name && init) {
|
||||
const snap = acc.defSnapshot();
|
||||
this.def(name, acc);
|
||||
this.registerResultDefs(init, acc.defsSince(snap));
|
||||
}
|
||||
if (init) this.walkValue(init, acc);
|
||||
}
|
||||
}
|
||||
|
|
@ -403,8 +420,10 @@ export class CsharpHarvester {
|
|||
if (left) {
|
||||
const lv = this.unwrapLvalue(left);
|
||||
if (lv.type === 'identifier') {
|
||||
const snap = acc.defSnapshot();
|
||||
this.def(lv, acc);
|
||||
if (op !== '=') this.use(lv, acc); // compound assign reads too
|
||||
if (op === '=' && right) this.registerResultDefs(right, acc.defsSince(snap));
|
||||
} else if (lv.type === 'tuple_expression') {
|
||||
this.defTupleTargets(lv, acc); // `(a, b) = …` deconstruction
|
||||
} else {
|
||||
|
|
@ -452,11 +471,20 @@ export class CsharpHarvester {
|
|||
if (alt) this.conditional(() => this.walkValue(alt, acc));
|
||||
return;
|
||||
}
|
||||
case 'invocation_expression':
|
||||
// #2195 U6: explicit case (previously default-descended) — same uses,
|
||||
// plus a taint-site record. Defs/uses stay byte-identical.
|
||||
this.visitCall(node, acc, 'call');
|
||||
return;
|
||||
case 'object_creation_expression':
|
||||
// `new Foo(x)` — constructor call site (`type` field is the callee).
|
||||
this.visitCall(node, acc, 'new');
|
||||
return;
|
||||
case 'member_access_expression': {
|
||||
// `a.B` — value read of the chain root only; the member name is not a
|
||||
// scalar binding. Mirrors the TS member-read use semantics.
|
||||
const expr = node.childForFieldName('expression');
|
||||
if (expr) this.walkValue(expr, acc);
|
||||
// scalar binding. Mirrors the TS member-read use semantics, plus a
|
||||
// member-read site for the innermost identifier-rooted access.
|
||||
this.walkChain(node, acc, false);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
|
|
@ -467,6 +495,133 @@ export class CsharpHarvester {
|
|||
}
|
||||
}
|
||||
|
||||
// ── taint-site harvest (#2195 U6) ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* When `value`'s root (after stripping parens) is an invocation/creation
|
||||
* node, remember that its site should carry `resultDefs: defs` — consumed by
|
||||
* {@link visitCall} once the value walk reaches the node.
|
||||
*/
|
||||
private registerResultDefs(value: SyntaxNode, defs: readonly number[]): void {
|
||||
if (defs.length === 0) return;
|
||||
const root = this.unwrapLvalue(value);
|
||||
if (root.type === 'invocation_expression' || root.type === 'object_creation_expression') {
|
||||
this.resultDefTargets.set(root.id, [...defs]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit invocation / object-creation handler: records a call site (callee
|
||||
* path, receiver, per-arg occurrence entries, result defs) while reproducing
|
||||
* EXACTLY the uses the old default descent recorded. C# wraps each argument in
|
||||
* an `argument` node; `new Foo(...)` reads the `type` field as the callee.
|
||||
*/
|
||||
private visitCall(node: SyntaxNode, acc: FactAccumulator, kind: 'call' | 'new'): void {
|
||||
const calleeNode = node.childForFieldName(kind === 'new' ? 'type' : 'function');
|
||||
const argsNode = node.childForFieldName('arguments');
|
||||
const siteIdx = acc.openCallSite(kind);
|
||||
acc.pushFrame(siteIdx);
|
||||
let calleePath: string | undefined;
|
||||
if (calleeNode) {
|
||||
const callee = this.unwrapLvalue(calleeNode);
|
||||
if (callee.type === 'identifier') {
|
||||
// For a `new Foo(...)`, the `type` is a type name, not a scalar
|
||||
// binding — record neither a use nor an occurrence for it; for a bare
|
||||
// call the callee name is a statement-level use but not an occurrence.
|
||||
if (kind === 'call') acc.addUseWithoutOccurrence(this.resolve(callee));
|
||||
calleePath = callee.text;
|
||||
} else if (callee.type === 'member_access_expression') {
|
||||
// skipFinalRead: the final access IS the callee, carried by the path.
|
||||
// A static dotted path (`System.Console.WriteLine(...)`) parses as a
|
||||
// member_access_expression chain too, so this one branch covers both
|
||||
// instance and static dotted callees.
|
||||
const chain = this.walkChain(callee, acc, true);
|
||||
calleePath = chain.path;
|
||||
if (chain.rootIdx !== undefined) acc.setSiteReceiver(siteIdx, chain.rootIdx);
|
||||
} else {
|
||||
this.walkValue(callee, acc);
|
||||
}
|
||||
if (calleePath !== undefined) acc.setSiteCallee(siteIdx, calleePath);
|
||||
}
|
||||
const resultDefs = this.resultDefTargets.get(node.id);
|
||||
if (resultDefs !== undefined) acc.setSiteResultDefs(siteIdx, resultDefs);
|
||||
if (argsNode) {
|
||||
let pos = 0;
|
||||
for (let i = 0; i < argsNode.namedChildCount; i++) {
|
||||
const arg = argsNode.namedChild(i);
|
||||
// C# wraps each value in an `argument` node; the value is the inner
|
||||
// expression (a named argument `name: x` exposes the label through the
|
||||
// `name` field — skip it so `x` is what flows).
|
||||
if (!arg || arg.type === 'comment') continue;
|
||||
acc.setFrameArg(pos);
|
||||
const value = arg.type === 'argument' ? this.argumentValue(arg) : arg;
|
||||
if (value) this.walkValue(value, acc);
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
acc.popFrame();
|
||||
}
|
||||
|
||||
/**
|
||||
* The value expression inside an `argument` node. A named argument
|
||||
* (`name: x`) carries the label on the `name` field and the value as a
|
||||
* sibling; a positional argument is just the value. Returns the last named
|
||||
* child that is not the `name`-field label (and skips `ref`/`out`/`in`
|
||||
* modifier keywords, which are anonymous tokens, not named children).
|
||||
*/
|
||||
private argumentValue(arg: SyntaxNode): SyntaxNode | undefined {
|
||||
const label = arg.childForFieldName('name');
|
||||
for (let i = arg.namedChildCount - 1; i >= 0; i--) {
|
||||
const c = arg.namedChild(i);
|
||||
if (c && c.id !== label?.id) return c;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Member chain walk shared by value position and callee position. Records the
|
||||
* chain-root identifier as a use (identical to the old default descent), plus
|
||||
* at most ONE member-read site — the INNERMOST access — when the root is an
|
||||
* identifier; `skipFinalRead` suppresses it when that access is the callee.
|
||||
*/
|
||||
private walkChain(
|
||||
node: SyntaxNode,
|
||||
acc: FactAccumulator,
|
||||
skipFinalRead: boolean,
|
||||
): { path?: string; rootIdx?: number } {
|
||||
const accesses: string[] = [];
|
||||
let cur: SyntaxNode = this.unwrapLvalue(node);
|
||||
for (;;) {
|
||||
if (cur.type === 'member_access_expression') {
|
||||
const name = cur.childForFieldName('name');
|
||||
accesses.unshift(name?.text ?? '');
|
||||
const expr = cur.childForFieldName('expression');
|
||||
if (!expr) break;
|
||||
cur = this.unwrapLvalue(expr);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let rootIdx: number | undefined;
|
||||
let rootSegment: string | undefined;
|
||||
if (cur.type === 'identifier') {
|
||||
rootIdx = this.resolve(cur);
|
||||
acc.addUse(rootIdx);
|
||||
rootSegment = cur.text;
|
||||
} else {
|
||||
this.walkValue(cur, acc);
|
||||
}
|
||||
const innermost = accesses[0];
|
||||
if (rootIdx !== undefined && innermost && !(skipFinalRead && accesses.length === 1)) {
|
||||
acc.addMemberRead(rootIdx, innermost);
|
||||
}
|
||||
const path =
|
||||
rootSegment !== undefined && accesses.every((a) => a !== '')
|
||||
? [rootSegment, ...accesses].join('.')
|
||||
: undefined;
|
||||
return { path, rootIdx };
|
||||
}
|
||||
|
||||
/** The initializer value of a `variable_declarator` — the named child after `name`. */
|
||||
private declaratorInit(declarator: SyntaxNode): SyntaxNode | undefined {
|
||||
const name = declarator.childForFieldName('name');
|
||||
|
|
@ -499,45 +654,9 @@ export class CsharpHarvester {
|
|||
}
|
||||
}
|
||||
|
||||
/** Ordered, deduplicating def/use collector for one statement record. */
|
||||
class FactAccumulator {
|
||||
private readonly defs: number[] = [];
|
||||
private readonly uses: number[] = [];
|
||||
private readonly mayDefs: number[] = [];
|
||||
private readonly defSeen = new Set<number>();
|
||||
private readonly useSeen = new Set<number>();
|
||||
private readonly mayDefSeen = new Set<number>();
|
||||
|
||||
constructor(private readonly line: number) {}
|
||||
|
||||
addDef(idx: number): void {
|
||||
if (this.defSeen.has(idx)) return;
|
||||
this.defSeen.add(idx);
|
||||
this.defs.push(idx);
|
||||
}
|
||||
|
||||
addMayDef(idx: number): void {
|
||||
if (this.mayDefSeen.has(idx)) return;
|
||||
this.mayDefSeen.add(idx);
|
||||
this.mayDefs.push(idx);
|
||||
}
|
||||
|
||||
addUse(idx: number): void {
|
||||
if (this.useSeen.has(idx)) return;
|
||||
this.useSeen.add(idx);
|
||||
this.uses.push(idx);
|
||||
}
|
||||
|
||||
defCount(): number {
|
||||
return this.defs.length + this.mayDefs.length;
|
||||
}
|
||||
|
||||
finish(): StatementFacts {
|
||||
return {
|
||||
line: this.line,
|
||||
defs: this.defs,
|
||||
uses: this.uses,
|
||||
...(this.mayDefs.length > 0 ? { mayDefs: this.mayDefs } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Ordered, deduplicating def/use + call-site collector for one statement record.
|
||||
* The shared {@link CallSiteFactAccumulator} carries the def/use machinery the
|
||||
* old local class had, plus the taint-site harvest (#2195 U6).
|
||||
*/
|
||||
const FactAccumulator = CallSiteFactAccumulator;
|
||||
|
|
|
|||
|
|
@ -67,6 +67,13 @@
|
|||
*/
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import type { BindingEntry, StatementFacts } from '../types.js';
|
||||
import { CallSiteFactAccumulator } from './call-site-harvest.js';
|
||||
|
||||
/**
|
||||
* The per-statement def/use + call-site collector, aliased to the shared
|
||||
* {@link CallSiteFactAccumulator} (one name for the value and the type).
|
||||
*/
|
||||
type FactAccumulator = CallSiteFactAccumulator;
|
||||
|
||||
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
|
||||
const NESTED_FUNCTION_TYPES = new Set([
|
||||
|
|
@ -109,6 +116,12 @@ export class GoHarvester {
|
|||
private readonly nearestScopeCache = new Map<number, Scope>();
|
||||
/** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */
|
||||
private conditionalDepth = 0;
|
||||
/**
|
||||
* Call node id → bindings whose declaration/assignment VALUE is exactly that
|
||||
* call (#2195 U6). Registered before the value walk, consumed by
|
||||
* {@link visitCall} (mirrors the TS harvester's `resultDefTargets`).
|
||||
*/
|
||||
private readonly resultDefTargets = new Map<number, number[]>();
|
||||
|
||||
constructor(private readonly fnNode: SyntaxNode) {
|
||||
this.fnId = fnNode.id;
|
||||
|
|
@ -474,6 +487,10 @@ export class GoHarvester {
|
|||
case 'short_var_declaration': {
|
||||
const left = node.childForFieldName('left');
|
||||
const right = node.childForFieldName('right');
|
||||
// Register result-defs BEFORE walking the value so the nested call site
|
||||
// (reached during the value walk) carries them — single-target only
|
||||
// (`x := f(a)`; a multi-target `a, b := f()` attaches nothing).
|
||||
if (left && right) this.registerListResultDefs(left, right);
|
||||
if (right) this.walkValue(right, acc);
|
||||
if (left) {
|
||||
for (let i = 0; i < left.namedChildCount; i++) {
|
||||
|
|
@ -486,6 +503,7 @@ export class GoHarvester {
|
|||
case 'var_declaration': {
|
||||
for (const spec of this.varSpecs(node)) {
|
||||
const value = spec.childForFieldName('value');
|
||||
if (value && this.singleSpecName(spec)) this.registerResultDefs(value, [spec]);
|
||||
if (value) this.walkValue(value, acc);
|
||||
if (value) {
|
||||
for (let i = 0; i < spec.namedChildCount; i++) {
|
||||
|
|
@ -500,6 +518,9 @@ export class GoHarvester {
|
|||
const left = node.childForFieldName('left');
|
||||
const right = node.childForFieldName('right');
|
||||
const op = node.childForFieldName('operator')?.text ?? '=';
|
||||
// Plain `x = f(a)` attaches `resultDefs: [x]`; a compound `x += f(a)`
|
||||
// does not (the prior value flows in too).
|
||||
if (op === '=' && left && right) this.registerListResultDefs(left, right);
|
||||
if (right) this.walkValue(right, acc);
|
||||
if (left) this.defLeftList(left, acc, op !== '=');
|
||||
return;
|
||||
|
|
@ -528,11 +549,17 @@ export class GoHarvester {
|
|||
}
|
||||
return;
|
||||
}
|
||||
case 'call_expression':
|
||||
// #2195 U6: explicit case (previously default-descended) — same uses,
|
||||
// plus a taint-site record. Go has no `new` (constructor calls are plain
|
||||
// `call_expression`s). Defs/uses stay byte-identical.
|
||||
this.visitCall(node, acc);
|
||||
return;
|
||||
case 'selector_expression': {
|
||||
// `a.b` — value read of the operand root only; the field name is not a
|
||||
// scalar binding. Mirrors the TS member-read use semantics.
|
||||
const operand = node.childForFieldName('operand');
|
||||
if (operand) this.walkValue(operand, acc);
|
||||
// scalar binding. Mirrors the TS member-read use semantics, plus a
|
||||
// member-read site for the innermost identifier-rooted access.
|
||||
this.walkChain(node, acc, false);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
|
|
@ -542,47 +569,177 @@ export class GoHarvester {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Ordered, deduplicating def/use collector for one statement record. */
|
||||
class FactAccumulator {
|
||||
private readonly defs: number[] = [];
|
||||
private readonly uses: number[] = [];
|
||||
private readonly mayDefs: number[] = [];
|
||||
private readonly defSeen = new Set<number>();
|
||||
private readonly useSeen = new Set<number>();
|
||||
private readonly mayDefSeen = new Set<number>();
|
||||
// ── taint-site harvest (#2195 U6) ────────────────────────────────────────
|
||||
|
||||
constructor(private readonly line: number) {}
|
||||
|
||||
addDef(idx: number): void {
|
||||
if (this.defSeen.has(idx)) return;
|
||||
this.defSeen.add(idx);
|
||||
this.defs.push(idx);
|
||||
/** The single `var_spec` name when the spec declares exactly one name, else undefined. */
|
||||
private singleSpecName(spec: SyntaxNode): SyntaxNode | undefined {
|
||||
const names: SyntaxNode[] = [];
|
||||
for (let i = 0; i < spec.namedChildCount; i++) {
|
||||
const c = spec.namedChild(i);
|
||||
if (c?.type === 'identifier') names.push(c);
|
||||
}
|
||||
return names.length === 1 ? names[0] : undefined;
|
||||
}
|
||||
|
||||
addMayDef(idx: number): void {
|
||||
if (this.mayDefSeen.has(idx)) return;
|
||||
this.mayDefSeen.add(idx);
|
||||
this.mayDefs.push(idx);
|
||||
/**
|
||||
* Register result-defs for a single-target LHS `expression_list` → RHS
|
||||
* `expression_list` whose sole element is a call. `a, b := f()` (multi-target)
|
||||
* and `x, y := f(), g()` attach nothing — the per-target mapping is ambiguous,
|
||||
* matching the TS harvester's per-declarator restriction.
|
||||
*/
|
||||
private registerListResultDefs(left: SyntaxNode, right: SyntaxNode): void {
|
||||
const leftNames = this.listIdentifiers(left);
|
||||
const rightVals = this.listElements(right);
|
||||
if (leftNames.length !== 1 || rightVals.length !== 1) return;
|
||||
this.registerResultDefs(rightVals[0], [leftNames[0]]);
|
||||
}
|
||||
|
||||
addUse(idx: number): void {
|
||||
if (this.useSeen.has(idx)) return;
|
||||
this.useSeen.add(idx);
|
||||
this.uses.push(idx);
|
||||
/** Identifier elements of an `expression_list` (`a, b` ⇒ [a, b]). */
|
||||
private listIdentifiers(list: SyntaxNode): SyntaxNode[] {
|
||||
const out: SyntaxNode[] = [];
|
||||
for (let i = 0; i < list.namedChildCount; i++) {
|
||||
const c = list.namedChild(i);
|
||||
if (c?.type === 'identifier') out.push(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
defCount(): number {
|
||||
return this.defs.length + this.mayDefs.length;
|
||||
/** Named elements of an `expression_list` (or the node itself if not a list). */
|
||||
private listElements(list: SyntaxNode): SyntaxNode[] {
|
||||
if (list.type !== 'expression_list') return [list];
|
||||
const out: SyntaxNode[] = [];
|
||||
for (let i = 0; i < list.namedChildCount; i++) {
|
||||
const c = list.namedChild(i);
|
||||
if (c) out.push(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
finish(): StatementFacts {
|
||||
return {
|
||||
line: this.line,
|
||||
defs: this.defs,
|
||||
uses: this.uses,
|
||||
...(this.mayDefs.length > 0 ? { mayDefs: this.mayDefs } : {}),
|
||||
};
|
||||
/**
|
||||
* When `value`'s root (after stripping parens) is a call, remember its site
|
||||
* should carry `resultDefs` — the binding indices of `targets` (def-position
|
||||
* identifiers, resolved against the completed scope tree). Consumed by
|
||||
* {@link visitCall} once the value walk reaches the node.
|
||||
*/
|
||||
private registerResultDefs(value: SyntaxNode, targets: readonly SyntaxNode[]): void {
|
||||
const root = this.unwrapLvalue(value);
|
||||
if (root.type !== 'call_expression') return;
|
||||
const defs: number[] = [];
|
||||
for (const target of targets) {
|
||||
// A `var_spec` carries its name(s) as children; an identifier resolves
|
||||
// directly. Skip the blank identifier (`_`), which binds nothing.
|
||||
const names =
|
||||
target.type === 'identifier' ? [target] : this.listIdentifiers(target);
|
||||
for (const n of names) {
|
||||
if (n.text === '_') continue;
|
||||
defs.push(this.resolve(n));
|
||||
}
|
||||
}
|
||||
if (defs.length > 0) this.resultDefTargets.set(root.id, defs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit `call_expression` handler. Records a call site (callee path,
|
||||
* receiver, per-arg occurrence entries, result defs) while reproducing EXACTLY
|
||||
* the uses the old default descent recorded (callee chain root + arguments).
|
||||
*/
|
||||
private visitCall(node: SyntaxNode, acc: FactAccumulator): void {
|
||||
const calleeNode = node.childForFieldName('function');
|
||||
const argsNode = node.childForFieldName('arguments');
|
||||
const siteIdx = acc.openCallSite('call');
|
||||
acc.pushFrame(siteIdx);
|
||||
let calleePath: string | undefined;
|
||||
if (calleeNode) {
|
||||
const callee = this.unwrapLvalue(calleeNode);
|
||||
if (callee.type === 'identifier') {
|
||||
if (callee.text !== '_') acc.addUseWithoutOccurrence(this.resolve(callee));
|
||||
calleePath = callee.text;
|
||||
} else if (callee.type === 'selector_expression') {
|
||||
// skipFinalRead: the final `.field` IS the callee, carried by the path.
|
||||
const chain = this.walkChain(callee, acc, true);
|
||||
calleePath = chain.path;
|
||||
if (chain.rootIdx !== undefined) acc.setSiteReceiver(siteIdx, chain.rootIdx);
|
||||
} else {
|
||||
// Call-rooted chains, conversions (`T(x)`), parenthesized funcs — the
|
||||
// walk still records uses and nested sites.
|
||||
this.walkValue(callee, acc);
|
||||
}
|
||||
if (calleePath !== undefined) acc.setSiteCallee(siteIdx, calleePath);
|
||||
}
|
||||
const resultDefs = this.resultDefTargets.get(node.id);
|
||||
if (resultDefs !== undefined) acc.setSiteResultDefs(siteIdx, resultDefs);
|
||||
if (argsNode) {
|
||||
let pos = 0;
|
||||
for (let i = 0; i < argsNode.namedChildCount; i++) {
|
||||
const arg = argsNode.namedChild(i);
|
||||
if (!arg || arg.type === 'comment') continue;
|
||||
// `f(xs...)` — a variadic spread. Mark the first spread position so the
|
||||
// matcher degrades soundly; the inner value still walks for occurrences.
|
||||
if (arg.type === 'variadic_argument') {
|
||||
acc.setFrameArg(pos);
|
||||
acc.setSiteSpread(siteIdx, pos);
|
||||
const inner = arg.namedChild(0);
|
||||
if (inner) this.walkValue(inner, acc);
|
||||
} else {
|
||||
acc.setFrameArg(pos);
|
||||
this.walkValue(arg, acc);
|
||||
}
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
acc.popFrame();
|
||||
}
|
||||
|
||||
/**
|
||||
* `selector_expression` chain walk shared by value position and callee
|
||||
* position. Records the chain-root identifier as a use (identical to the old
|
||||
* default descent) plus at most ONE member-read site — the INNERMOST access —
|
||||
* when the root is an identifier; `skipFinalRead` suppresses it when that
|
||||
* access is the callee (carried by the dotted path instead).
|
||||
*/
|
||||
private walkChain(
|
||||
node: SyntaxNode,
|
||||
acc: FactAccumulator,
|
||||
skipFinalRead: boolean,
|
||||
): { path?: string; rootIdx?: number } {
|
||||
const accesses: string[] = [];
|
||||
let cur: SyntaxNode = this.unwrapLvalue(node);
|
||||
for (;;) {
|
||||
if (cur.type === 'selector_expression') {
|
||||
const field = cur.childForFieldName('field');
|
||||
accesses.unshift(field?.text ?? '');
|
||||
const operand = cur.childForFieldName('operand');
|
||||
if (!operand) break;
|
||||
cur = this.unwrapLvalue(operand);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let rootIdx: number | undefined;
|
||||
let rootSegment: string | undefined;
|
||||
if (cur.type === 'identifier' && cur.text !== '_') {
|
||||
rootIdx = this.resolve(cur);
|
||||
acc.addUse(rootIdx);
|
||||
rootSegment = cur.text;
|
||||
} else {
|
||||
this.walkValue(cur, acc);
|
||||
}
|
||||
const innermost = accesses[0];
|
||||
if (rootIdx !== undefined && innermost && !(skipFinalRead && accesses.length === 1)) {
|
||||
acc.addMemberRead(rootIdx, innermost);
|
||||
}
|
||||
const path =
|
||||
rootSegment !== undefined && accesses.every((a) => a !== '')
|
||||
? [rootSegment, ...accesses].join('.')
|
||||
: undefined;
|
||||
return { path, rootIdx };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered, deduplicating def/use + call-site collector for one statement record.
|
||||
* The shared {@link CallSiteFactAccumulator} carries the def/use machinery the
|
||||
* old local class had, plus the taint-site harvest (#2195 U6).
|
||||
*/
|
||||
const FactAccumulator = CallSiteFactAccumulator;
|
||||
|
|
|
|||
|
|
@ -43,6 +43,13 @@
|
|||
*/
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import type { BindingEntry, StatementFacts } from '../types.js';
|
||||
import { CallSiteFactAccumulator } from './call-site-harvest.js';
|
||||
|
||||
/**
|
||||
* The per-statement def/use + call-site collector, aliased to the shared
|
||||
* {@link CallSiteFactAccumulator} (one name for the value and the type).
|
||||
*/
|
||||
type FactAccumulator = CallSiteFactAccumulator;
|
||||
|
||||
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
|
||||
const NESTED_FUNCTION_TYPES = new Set([
|
||||
|
|
@ -89,6 +96,12 @@ export class JavaHarvester {
|
|||
private readonly nearestScopeCache = new Map<number, Scope>();
|
||||
/** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */
|
||||
private conditionalDepth = 0;
|
||||
/**
|
||||
* Call/new node id → bindings whose declarator/assignment VALUE is exactly
|
||||
* that call (#2195 U6). Registered before the value walk, consumed by
|
||||
* {@link visitCall} (mirrors the TS harvester's `resultDefTargets`).
|
||||
*/
|
||||
private readonly resultDefTargets = new Map<number, number[]>();
|
||||
|
||||
constructor(private readonly fnNode: SyntaxNode) {
|
||||
this.fnId = fnNode.id;
|
||||
|
|
@ -384,7 +397,11 @@ export class JavaHarvester {
|
|||
// Only an INITIALIZED declarator writes (`int x = e;`). A bare
|
||||
// `int x;` is not a def (it writes nothing at runtime), matching the
|
||||
// TS bare-`var` rule.
|
||||
if (name && value) this.def(name, acc);
|
||||
if (name && value) {
|
||||
const snap = acc.defSnapshot();
|
||||
this.def(name, acc);
|
||||
this.registerResultDefs(value, acc.defsSince(snap));
|
||||
}
|
||||
if (value) this.walkValue(value, acc);
|
||||
}
|
||||
return;
|
||||
|
|
@ -396,8 +413,10 @@ export class JavaHarvester {
|
|||
if (left) {
|
||||
const lv = this.unwrapLvalue(left);
|
||||
if (lv.type === 'identifier') {
|
||||
const snap = acc.defSnapshot();
|
||||
this.def(lv, acc);
|
||||
if (op !== '=') this.use(lv, acc); // compound assign reads too
|
||||
if (op === '=' && right) this.registerResultDefs(right, acc.defsSince(snap));
|
||||
} else {
|
||||
this.walkValue(lv, acc); // field/array target — uses only
|
||||
}
|
||||
|
|
@ -439,11 +458,20 @@ export class JavaHarvester {
|
|||
if (alt) this.conditional(() => this.walkValue(alt, acc));
|
||||
return;
|
||||
}
|
||||
case 'method_invocation':
|
||||
// #2195 U6: explicit case (previously default-descended) — same uses,
|
||||
// plus a taint-site record. Defs/uses stay byte-identical.
|
||||
this.visitCall(node, acc);
|
||||
return;
|
||||
case 'object_creation_expression':
|
||||
// `new Foo(x)` — constructor call site (`type` field is the callee).
|
||||
this.visitNew(node, acc);
|
||||
return;
|
||||
case 'field_access': {
|
||||
// `a.b` — value read of the object root only; the field name is not a
|
||||
// scalar binding. Mirrors the TS member-read use semantics.
|
||||
const obj = node.childForFieldName('object');
|
||||
if (obj) this.walkValue(obj, acc);
|
||||
// scalar binding. Mirrors the TS member-read use semantics, plus a
|
||||
// member-read site for the innermost identifier-rooted access.
|
||||
this.walkChain(node, acc, false);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
|
|
@ -454,6 +482,134 @@ export class JavaHarvester {
|
|||
}
|
||||
}
|
||||
|
||||
// ── taint-site harvest (#2195 U6) ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* When `value`'s root (after stripping parens) is a method-invocation /
|
||||
* object-creation node, remember its site should carry `resultDefs: defs`.
|
||||
*/
|
||||
private registerResultDefs(value: SyntaxNode, defs: readonly number[]): void {
|
||||
if (defs.length === 0) return;
|
||||
const root = this.unwrapLvalue(value);
|
||||
if (root.type === 'method_invocation' || root.type === 'object_creation_expression') {
|
||||
this.resultDefTargets.set(root.id, [...defs]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit `method_invocation` handler. Unlike a member-chain callee, Java
|
||||
* carries the method NAME on the `name` field and the receiver on a sibling
|
||||
* `object` field (`db.query(x)` ⇒ object `db`, name `query`); a bare call
|
||||
* (`exec(x)`) has no `object`. Reproduces EXACTLY the uses the old default
|
||||
* descent recorded (object root + arguments) and adds the call site.
|
||||
*/
|
||||
private visitCall(node: SyntaxNode, acc: FactAccumulator): void {
|
||||
const objectNode = node.childForFieldName('object');
|
||||
const nameNode = node.childForFieldName('name');
|
||||
const argsNode = node.childForFieldName('arguments');
|
||||
const siteIdx = acc.openCallSite('call');
|
||||
acc.pushFrame(siteIdx);
|
||||
let receiverPath: string | undefined;
|
||||
if (objectNode) {
|
||||
// The receiver is a value read (object chain root) — record its uses and
|
||||
// the member-read sites, and capture its dotted path + binding root.
|
||||
const chain = this.walkChain(objectNode, acc, false);
|
||||
receiverPath = chain.path;
|
||||
if (chain.rootIdx !== undefined) acc.setSiteReceiver(siteIdx, chain.rootIdx);
|
||||
}
|
||||
if (nameNode) {
|
||||
// The method NAME was a (synthetic) statement-level use under the old
|
||||
// default descent — preserve it byte-identically, but never as a value
|
||||
// occurrence in an enclosing argument (`exec(escape(x))` must not put the
|
||||
// `escape` name into exec's arg 0).
|
||||
acc.addUseWithoutOccurrence(this.resolve(nameNode));
|
||||
const callee =
|
||||
receiverPath !== undefined ? `${receiverPath}.${nameNode.text}` : nameNode.text;
|
||||
acc.setSiteCallee(siteIdx, callee);
|
||||
}
|
||||
const resultDefs = this.resultDefTargets.get(node.id);
|
||||
if (resultDefs !== undefined) acc.setSiteResultDefs(siteIdx, resultDefs);
|
||||
this.walkArgs(argsNode, acc);
|
||||
acc.popFrame();
|
||||
}
|
||||
|
||||
/** Explicit `object_creation_expression` (`new Foo(x)`) handler. */
|
||||
private visitNew(node: SyntaxNode, acc: FactAccumulator): void {
|
||||
const typeNode = node.childForFieldName('type');
|
||||
const argsNode = node.childForFieldName('arguments');
|
||||
const siteIdx = acc.openCallSite('new');
|
||||
acc.pushFrame(siteIdx);
|
||||
if (typeNode) {
|
||||
// The type name is not a scalar binding — record it only as the callee
|
||||
// path, never a use/occurrence (matches the type-position semantics).
|
||||
acc.setSiteCallee(siteIdx, typeNode.text.replace(/\s+/g, ''));
|
||||
}
|
||||
const resultDefs = this.resultDefTargets.get(node.id);
|
||||
if (resultDefs !== undefined) acc.setSiteResultDefs(siteIdx, resultDefs);
|
||||
this.walkArgs(argsNode, acc);
|
||||
acc.popFrame();
|
||||
}
|
||||
|
||||
/** Walk an `argument_list`, tagging each positional argument for occurrences. */
|
||||
private walkArgs(argsNode: SyntaxNode | null, acc: FactAccumulator): void {
|
||||
if (!argsNode) return;
|
||||
let pos = 0;
|
||||
for (let i = 0; i < argsNode.namedChildCount; i++) {
|
||||
const arg = argsNode.namedChild(i);
|
||||
if (!arg || COMMENT_TYPES.has(arg.type)) continue;
|
||||
acc.setFrameArg(pos);
|
||||
this.walkValue(arg, acc);
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `field_access` chain walk shared by value position and the method-invocation
|
||||
* receiver. Records the chain-root identifier as a use (identical to the old
|
||||
* default descent) plus at most ONE member-read site — the INNERMOST access —
|
||||
* when the root is an identifier; `skipFinalRead` suppresses it when that
|
||||
* access is the callee (never the case for `field_access`, which is value-only).
|
||||
*/
|
||||
private walkChain(
|
||||
node: SyntaxNode,
|
||||
acc: FactAccumulator,
|
||||
skipFinalRead: boolean,
|
||||
): { path?: string; rootIdx?: number } {
|
||||
const accesses: string[] = [];
|
||||
let cur: SyntaxNode = this.unwrapLvalue(node);
|
||||
for (;;) {
|
||||
if (cur.type === 'field_access') {
|
||||
const field = cur.childForFieldName('field');
|
||||
accesses.unshift(field?.text ?? '');
|
||||
const obj = cur.childForFieldName('object');
|
||||
if (!obj) break;
|
||||
cur = this.unwrapLvalue(obj);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let rootIdx: number | undefined;
|
||||
let rootSegment: string | undefined;
|
||||
if (cur.type === 'identifier') {
|
||||
rootIdx = this.resolve(cur);
|
||||
acc.addUse(rootIdx);
|
||||
rootSegment = cur.text;
|
||||
} else if (cur.type === 'this' || cur.type === 'super') {
|
||||
rootSegment = cur.text; // `this`/`super` are path segments, never bind
|
||||
} else {
|
||||
this.walkValue(cur, acc);
|
||||
}
|
||||
const innermost = accesses[0];
|
||||
if (rootIdx !== undefined && innermost && !(skipFinalRead && accesses.length === 1)) {
|
||||
acc.addMemberRead(rootIdx, innermost);
|
||||
}
|
||||
const path =
|
||||
rootSegment !== undefined && accesses.every((a) => a !== '')
|
||||
? [rootSegment, ...accesses].join('.')
|
||||
: undefined;
|
||||
return { path, rootIdx };
|
||||
}
|
||||
|
||||
/** The operand identifier of an `update_expression` (`x++` / `--x`). */
|
||||
private updateOperand(node: SyntaxNode): SyntaxNode | undefined {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
|
|
@ -464,49 +620,9 @@ export class JavaHarvester {
|
|||
}
|
||||
}
|
||||
|
||||
/** Ordered, deduplicating def/use collector for one statement record. */
|
||||
class FactAccumulator {
|
||||
private readonly defs: number[] = [];
|
||||
private readonly uses: number[] = [];
|
||||
private readonly mayDefs: number[] = [];
|
||||
private readonly defSeen = new Set<number>();
|
||||
private readonly useSeen = new Set<number>();
|
||||
private readonly mayDefSeen = new Set<number>();
|
||||
|
||||
constructor(private readonly line: number) {}
|
||||
|
||||
addDef(idx: number): void {
|
||||
if (this.defSeen.has(idx)) return;
|
||||
this.defSeen.add(idx);
|
||||
this.defs.push(idx);
|
||||
}
|
||||
|
||||
addMayDef(idx: number): void {
|
||||
if (this.mayDefSeen.has(idx)) return;
|
||||
this.mayDefSeen.add(idx);
|
||||
this.mayDefs.push(idx);
|
||||
}
|
||||
|
||||
addUse(idx: number): void {
|
||||
if (this.useSeen.has(idx)) return;
|
||||
this.useSeen.add(idx);
|
||||
this.uses.push(idx);
|
||||
}
|
||||
|
||||
defCount(): number {
|
||||
return this.defs.length + this.mayDefs.length;
|
||||
}
|
||||
|
||||
useCount(): number {
|
||||
return this.uses.length;
|
||||
}
|
||||
|
||||
finish(): StatementFacts {
|
||||
return {
|
||||
line: this.line,
|
||||
defs: this.defs,
|
||||
uses: this.uses,
|
||||
...(this.mayDefs.length > 0 ? { mayDefs: this.mayDefs } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Ordered, deduplicating def/use + call-site collector for one statement record.
|
||||
* The shared {@link CallSiteFactAccumulator} carries the def/use machinery the
|
||||
* old local class had, plus the taint-site harvest (#2195 U6).
|
||||
*/
|
||||
const FactAccumulator = CallSiteFactAccumulator;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
createCCfgVisitor,
|
||||
createCppCfgVisitor,
|
||||
} from '../../../src/core/ingestion/cfg/visitors/c-cpp.js';
|
||||
import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
|
||||
import type { FunctionCfg, SiteRecord } from '../../../src/core/ingestion/cfg/types.js';
|
||||
import { makeCfgHarness, type CfgHarness } from '../../helpers/cfg-harness.js';
|
||||
|
||||
// U2 — the C/C++ CfgVisitor, one hazard per test (KTD5: real-parser regression,
|
||||
|
|
@ -60,6 +60,18 @@ function bindingIdx(cfg: FunctionCfg, name: string): number {
|
|||
return i;
|
||||
}
|
||||
|
||||
/** Every taint `SiteRecord` harvested across the function's statements. */
|
||||
function allSites(cfg: FunctionCfg): SiteRecord[] {
|
||||
const out: SiteRecord[] = [];
|
||||
for (const b of cfg.blocks) for (const s of b.statements ?? []) out.push(...(s.sites ?? []));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** True iff at least one statement carries a (non-empty) `sites` array. */
|
||||
function hasAnySites(cfg: FunctionCfg): boolean {
|
||||
return cfg.blocks.some((b) => (b.statements ?? []).some((s) => (s.sites ?? []).length > 0));
|
||||
}
|
||||
|
||||
describe('C CfgVisitor — structure', () => {
|
||||
it('straight-line body: ENTRY → block → EXIT (seq)', () => {
|
||||
const cfg = c.cfgOf(`void f() { a(); b(); c(); }`);
|
||||
|
|
@ -303,3 +315,70 @@ describe('C++ CfgVisitor — lambdas are CFG-bearing functions', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
// U6 — call-site `sites[]` taint substrate. INERT BY DESIGN: no C-family taint
|
||||
// model is registered, so these sites produce zero TAINTED edges; they only
|
||||
// give the deferred per-language source/sink model something to match against.
|
||||
// The assertions verify the substrate is harvested in the TS `SiteRecord` shape.
|
||||
describe('C CfgVisitor — call-site sites[] substrate', () => {
|
||||
it('a bare call records a `call` site with callee name + arg occurrence', () => {
|
||||
const cfg = c.cfgOf(`void f(int cmd) { exec(cmd); }`);
|
||||
const sites = allSites(cfg);
|
||||
const exec = sites.find((s) => s.kind === 'call' && s.callee === 'exec');
|
||||
expect(exec).toBeDefined();
|
||||
// `cmd` (binding 0) occurs at argument position 0.
|
||||
expect(exec?.args?.[0]).toContainEqual(bindingIdx(cfg, 'cmd'));
|
||||
});
|
||||
|
||||
it('a method call (`db.query(x)`) records the receiver binding + dotted callee', () => {
|
||||
const cfg = c.cfgOf(`void f(int x) { db.query(x); }`);
|
||||
const site = allSites(cfg).find((s) => s.kind === 'call' && s.callee === 'db.query');
|
||||
expect(site).toBeDefined();
|
||||
expect(site?.receiver).toBe(bindingIdx(cfg, 'db'));
|
||||
expect(site?.args?.[0]).toContainEqual(bindingIdx(cfg, 'x'));
|
||||
});
|
||||
|
||||
it('a nested call (`exec(escape(x))`) via-tags the inner site (sanitizer substrate)', () => {
|
||||
const cfg = c.cfgOf(`void f(int x) { exec(escape(x)); }`);
|
||||
const sites = allSites(cfg);
|
||||
const exec = sites.findIndex((s) => s.callee === 'exec');
|
||||
const escape = sites.findIndex((s) => s.callee === 'escape');
|
||||
expect(exec).toBeGreaterThanOrEqual(0);
|
||||
expect(escape).toBeGreaterThanOrEqual(0);
|
||||
const x = bindingIdx(cfg, 'x');
|
||||
// escape's arg 0 carries a plain `x`; exec's arg 0 carries `[x, escapeSiteIdx]`.
|
||||
expect(sites[escape].args?.[0]).toContainEqual(x);
|
||||
expect(sites[exec].args?.[0]).toContainEqual([x, escape]);
|
||||
});
|
||||
|
||||
it('a call assigned to a variable records resultDefs', () => {
|
||||
const cfg = c.cfgOf(`void f(int a) { int y = load(a); }`);
|
||||
const site = allSites(cfg).find((s) => s.callee === 'load');
|
||||
expect(site?.resultDefs).toContain(bindingIdx(cfg, 'y'));
|
||||
});
|
||||
|
||||
it('a CFG-only function (no calls) emits NO sites key (omit-when-empty)', () => {
|
||||
const cfg = c.cfgOf(`void f(int a, int b) { int x = a + b; }`);
|
||||
expect(hasAnySites(cfg)).toBe(false);
|
||||
expect(allSites(cfg)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('C++ CfgVisitor — call-site sites[] substrate', () => {
|
||||
it('a `new Foo(x)` records a `new` site with the constructor as callee', () => {
|
||||
const cfg = cpp.cfgOf(`void f(int x) { auto p = new Foo(x); }`);
|
||||
const site = allSites(cfg).find((s) => s.kind === 'new');
|
||||
expect(site).toBeDefined();
|
||||
expect(site?.callee).toBe('Foo');
|
||||
expect(site?.args?.[0]).toContainEqual(bindingIdx(cfg, 'x'));
|
||||
// `auto p = new Foo(x)` attaches resultDefs of `p` to the new site.
|
||||
expect(site?.resultDefs).toContain(bindingIdx(cfg, 'p'));
|
||||
});
|
||||
|
||||
it('a `ns::g(z)` namespace call folds `::` into a dotted callee path', () => {
|
||||
const cfg = cpp.cfgOf(`void f(int z) { ns::g(z); }`);
|
||||
const site = allSites(cfg).find((s) => s.kind === 'call' && s.callee === 'ns.g');
|
||||
expect(site).toBeDefined();
|
||||
expect(site?.args?.[0]).toContainEqual(bindingIdx(cfg, 'z'));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createRequire } from 'node:module';
|
||||
import { createCsharpCfgVisitor } from '../../../src/core/ingestion/cfg/visitors/csharp.js';
|
||||
import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
|
||||
import type { FunctionCfg, SiteRecord } from '../../../src/core/ingestion/cfg/types.js';
|
||||
import { makeCfgHarness, type CfgHarness } from '../../helpers/cfg-harness.js';
|
||||
|
||||
// U3 — the C# CfgVisitor, one hazard per test (KTD5: real-parser regression,
|
||||
|
|
@ -63,6 +63,15 @@ const hasUse = (cfg: FunctionCfg, idx: number): boolean =>
|
|||
const hasMayDef = (cfg: FunctionCfg, idx: number): boolean =>
|
||||
cfg.blocks.some((bl) => bl.statements?.some((s) => (s.mayDefs ?? []).includes(idx)));
|
||||
|
||||
/** Every taint `SiteRecord` harvested across the function's statements. */
|
||||
function allSites(cfg: FunctionCfg): SiteRecord[] {
|
||||
const out: SiteRecord[] = [];
|
||||
for (const b of cfg.blocks) for (const s of b.statements ?? []) out.push(...(s.sites ?? []));
|
||||
return out;
|
||||
}
|
||||
const hasAnySites = (cfg: FunctionCfg): boolean =>
|
||||
cfg.blocks.some((b) => (b.statements ?? []).some((s) => (s.sites ?? []).length > 0));
|
||||
|
||||
const wrap = (body: string): string => `class C { void M(${''}) { ${body} } }`;
|
||||
|
||||
describe('C# CfgVisitor — structure', () => {
|
||||
|
|
@ -387,3 +396,50 @@ describe('C# CfgVisitor — does not throw on exotic shapes', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
// U6 — call-site `sites[]` taint substrate. INERT BY DESIGN: no C# taint model
|
||||
// is registered, so these sites produce zero TAINTED edges; they only give the
|
||||
// deferred per-language source/sink model something to match against.
|
||||
describe('C# CfgVisitor — call-site sites[] substrate', () => {
|
||||
const cfgOf = (body: string): FunctionCfg => cs.cfgOf(`class C { void f(int cmd, int x, int a) { ${body} } }`);
|
||||
|
||||
it('a bare invocation records a `call` site with callee name + arg occurrence', () => {
|
||||
const cfg = cfgOf(`Exec(cmd);`);
|
||||
const site = allSites(cfg).find((s) => s.kind === 'call' && s.callee === 'Exec');
|
||||
expect(site).toBeDefined();
|
||||
expect(site?.args?.[0]).toContainEqual(bindingIdx(cfg, 'cmd'));
|
||||
});
|
||||
|
||||
it('a member invocation (`db.Query(x)`) records the receiver + dotted callee', () => {
|
||||
const cfg = cfgOf(`db.Query(x);`);
|
||||
const site = allSites(cfg).find((s) => s.kind === 'call' && s.callee === 'db.Query');
|
||||
expect(site).toBeDefined();
|
||||
expect(site?.receiver).toBe(bindingIdx(cfg, 'db'));
|
||||
expect(site?.args?.[0]).toContainEqual(bindingIdx(cfg, 'x'));
|
||||
});
|
||||
|
||||
it('`new Foo(x)` records a `new` site with the type as callee', () => {
|
||||
const cfg = cfgOf(`var p = new Foo(x);`);
|
||||
const site = allSites(cfg).find((s) => s.kind === 'new');
|
||||
expect(site?.callee).toBe('Foo');
|
||||
expect(site?.args?.[0]).toContainEqual(bindingIdx(cfg, 'x'));
|
||||
expect(site?.resultDefs).toContain(bindingIdx(cfg, 'p'));
|
||||
});
|
||||
|
||||
it('a nested invocation via-tags the inner site (sanitizer substrate)', () => {
|
||||
const cfg = cfgOf(`Exec(Escape(x));`);
|
||||
const sites = allSites(cfg);
|
||||
const exec = sites.findIndex((s) => s.callee === 'Exec');
|
||||
const escape = sites.findIndex((s) => s.callee === 'Escape');
|
||||
expect(exec).toBeGreaterThanOrEqual(0);
|
||||
expect(escape).toBeGreaterThanOrEqual(0);
|
||||
const x = bindingIdx(cfg, 'x');
|
||||
expect(sites[escape].args?.[0]).toContainEqual(x);
|
||||
expect(sites[exec].args?.[0]).toContainEqual([x, escape]);
|
||||
});
|
||||
|
||||
it('a CFG-only function (no calls) emits NO sites key (omit-when-empty)', () => {
|
||||
const cfg = cs.cfgOf(`class C { void f(int a, int b) { int x = a + b; } }`);
|
||||
expect(hasAnySites(cfg)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createRequire } from 'node:module';
|
||||
import { createGoCfgVisitor } from '../../../src/core/ingestion/cfg/visitors/go.js';
|
||||
import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
|
||||
import type { FunctionCfg, SiteRecord } from '../../../src/core/ingestion/cfg/types.js';
|
||||
import { makeCfgHarness, type CfgHarness } from '../../helpers/cfg-harness.js';
|
||||
import { isExitReachableFromAllBlocks } from '../../../src/core/ingestion/cfg/post-dominators.js';
|
||||
import { computeControlDependence } from '../../../src/core/ingestion/cfg/control-dependence.js';
|
||||
|
|
@ -54,6 +54,15 @@ const hasUse = (cfg: FunctionCfg, idx: number): boolean =>
|
|||
const hasMayDef = (cfg: FunctionCfg, idx: number): boolean =>
|
||||
cfg.blocks.some((bl) => bl.statements?.some((s) => (s.mayDefs ?? []).includes(idx)));
|
||||
|
||||
/** Every taint `SiteRecord` harvested across the function's statements. */
|
||||
function allSites(cfg: FunctionCfg): SiteRecord[] {
|
||||
const out: SiteRecord[] = [];
|
||||
for (const b of cfg.blocks) for (const s of b.statements ?? []) out.push(...(s.sites ?? []));
|
||||
return out;
|
||||
}
|
||||
const hasAnySites = (cfg: FunctionCfg): boolean =>
|
||||
cfg.blocks.some((b) => (b.statements ?? []).some((s) => (s.sites ?? []).length > 0));
|
||||
|
||||
/** Wrap a Go function body in a minimal compilable package. */
|
||||
const pkg = (src: string): string => `package main\n${src}\n`;
|
||||
|
||||
|
|
@ -424,3 +433,57 @@ describe('Go CfgVisitor — functionStartColumn', () => {
|
|||
expect(cfgs[0].functionStartColumn).not.toBe(cfgs[1].functionStartColumn);
|
||||
});
|
||||
});
|
||||
|
||||
// U6 — call-site `sites[]` taint substrate. INERT BY DESIGN: no Go taint model
|
||||
// is registered, so these sites produce zero TAINTED edges; they only give the
|
||||
// deferred per-language source/sink model something to match against. Go has no
|
||||
// `new` — constructor-style calls are plain `call_expression`s.
|
||||
describe('Go CfgVisitor — call-site sites[] substrate', () => {
|
||||
const cfgOf = (body: string): FunctionCfg =>
|
||||
go.cfgOf(pkg(`func f(cmd, x, a int, xs []int) { ${body} }`));
|
||||
|
||||
it('a bare call records a `call` site with callee name + arg occurrence', () => {
|
||||
const cfg = cfgOf(`exec(cmd)`);
|
||||
const site = allSites(cfg).find((s) => s.kind === 'call' && s.callee === 'exec');
|
||||
expect(site).toBeDefined();
|
||||
expect(site?.args?.[0]).toContainEqual(bindingIdx(cfg, 'cmd'));
|
||||
});
|
||||
|
||||
it('a selector call (`db.Query(x)`) records the receiver binding + dotted callee', () => {
|
||||
const cfg = cfgOf(`db.Query(x)`);
|
||||
const site = allSites(cfg).find((s) => s.kind === 'call' && s.callee === 'db.Query');
|
||||
expect(site).toBeDefined();
|
||||
expect(site?.receiver).toBe(bindingIdx(cfg, 'db'));
|
||||
expect(site?.args?.[0]).toContainEqual(bindingIdx(cfg, 'x'));
|
||||
});
|
||||
|
||||
it('a call assigned with `:=` records resultDefs', () => {
|
||||
const cfg = cfgOf(`r := load(a); _ = r`);
|
||||
const site = allSites(cfg).find((s) => s.callee === 'load');
|
||||
expect(site?.resultDefs).toContain(bindingIdx(cfg, 'r'));
|
||||
});
|
||||
|
||||
it('a variadic call (`g(xs...)`) marks the spread position', () => {
|
||||
const cfg = cfgOf(`g(xs...)`);
|
||||
const site = allSites(cfg).find((s) => s.callee === 'g');
|
||||
expect(site?.spread).toBe(0);
|
||||
expect(site?.args?.[0]).toContainEqual(bindingIdx(cfg, 'xs'));
|
||||
});
|
||||
|
||||
it('a nested call via-tags the inner site (sanitizer substrate)', () => {
|
||||
const cfg = cfgOf(`exec(escape(x))`);
|
||||
const sites = allSites(cfg);
|
||||
const exec = sites.findIndex((s) => s.callee === 'exec');
|
||||
const escape = sites.findIndex((s) => s.callee === 'escape');
|
||||
expect(exec).toBeGreaterThanOrEqual(0);
|
||||
expect(escape).toBeGreaterThanOrEqual(0);
|
||||
const x = bindingIdx(cfg, 'x');
|
||||
expect(sites[escape].args?.[0]).toContainEqual(x);
|
||||
expect(sites[exec].args?.[0]).toContainEqual([x, escape]);
|
||||
});
|
||||
|
||||
it('a CFG-only function (no calls) emits NO sites key (omit-when-empty)', () => {
|
||||
const cfg = cfgOf(`x := a + a; _ = x`);
|
||||
expect(hasAnySites(cfg)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createRequire } from 'node:module';
|
||||
import { createJavaCfgVisitor } from '../../../src/core/ingestion/cfg/visitors/java.js';
|
||||
import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
|
||||
import type { FunctionCfg, SiteRecord } from '../../../src/core/ingestion/cfg/types.js';
|
||||
import { makeCfgHarness, type CfgHarness } from '../../helpers/cfg-harness.js';
|
||||
|
||||
// U4 — the Java CfgVisitor, one hazard per test (KTD5: real-parser regression,
|
||||
|
|
@ -54,6 +54,15 @@ function bindingIdx(cfg: FunctionCfg, name: string): number {
|
|||
return i;
|
||||
}
|
||||
|
||||
/** Every taint `SiteRecord` harvested across the function's statements. */
|
||||
function allSites(cfg: FunctionCfg): SiteRecord[] {
|
||||
const out: SiteRecord[] = [];
|
||||
for (const b of cfg.blocks) for (const s of b.statements ?? []) out.push(...(s.sites ?? []));
|
||||
return out;
|
||||
}
|
||||
const hasAnySites = (cfg: FunctionCfg): boolean =>
|
||||
cfg.blocks.some((b) => (b.statements ?? []).some((s) => (s.sites ?? []).length > 0));
|
||||
|
||||
const hasDef = (cfg: FunctionCfg, idx: number): boolean =>
|
||||
cfg.blocks.some((bl) => bl.statements?.some((s) => s.defs.includes(idx)));
|
||||
const hasUse = (cfg: FunctionCfg, idx: number): boolean =>
|
||||
|
|
@ -513,3 +522,53 @@ describe('Java CfgVisitor — does not throw on exotic shapes', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
// U6 — call-site `sites[]` taint substrate. INERT BY DESIGN: no Java taint model
|
||||
// is registered, so these sites produce zero TAINTED edges; they only give the
|
||||
// deferred per-language source/sink model something to match against.
|
||||
describe('Java CfgVisitor — call-site sites[] substrate', () => {
|
||||
const cfgOf = (body: string): FunctionCfg =>
|
||||
java.cfgOf(`class C { void f(int cmd, int x, int a) { ${body} } }`);
|
||||
|
||||
it('a bare method call records a `call` site with callee + arg occurrence', () => {
|
||||
const cfg = cfgOf(`exec(cmd);`);
|
||||
const site = allSites(cfg).find((s) => s.kind === 'call' && s.callee === 'exec');
|
||||
expect(site).toBeDefined();
|
||||
expect(site?.args?.[0]).toContainEqual(bindingIdx(cfg, 'cmd'));
|
||||
});
|
||||
|
||||
it('a receiver call (`db.query(x)`) records the receiver binding + dotted callee', () => {
|
||||
// Java carries the method NAME on the `name` field and the receiver on the
|
||||
// sibling `object` field — the substrate normalizes both to receiver+callee.
|
||||
const cfg = cfgOf(`db.query(x);`);
|
||||
const site = allSites(cfg).find((s) => s.kind === 'call' && s.callee === 'db.query');
|
||||
expect(site).toBeDefined();
|
||||
expect(site?.receiver).toBe(bindingIdx(cfg, 'db'));
|
||||
expect(site?.args?.[0]).toContainEqual(bindingIdx(cfg, 'x'));
|
||||
});
|
||||
|
||||
it('`new Foo(x)` records a `new` site with the type as callee', () => {
|
||||
const cfg = cfgOf(`Object p = new Foo(x);`);
|
||||
const site = allSites(cfg).find((s) => s.kind === 'new');
|
||||
expect(site?.callee).toBe('Foo');
|
||||
expect(site?.args?.[0]).toContainEqual(bindingIdx(cfg, 'x'));
|
||||
expect(site?.resultDefs).toContain(bindingIdx(cfg, 'p'));
|
||||
});
|
||||
|
||||
it('a nested call via-tags the inner site (sanitizer substrate)', () => {
|
||||
const cfg = cfgOf(`exec(escape(x));`);
|
||||
const sites = allSites(cfg);
|
||||
const exec = sites.findIndex((s) => s.callee === 'exec');
|
||||
const escape = sites.findIndex((s) => s.callee === 'escape');
|
||||
expect(exec).toBeGreaterThanOrEqual(0);
|
||||
expect(escape).toBeGreaterThanOrEqual(0);
|
||||
const x = bindingIdx(cfg, 'x');
|
||||
expect(sites[escape].args?.[0]).toContainEqual(x);
|
||||
expect(sites[exec].args?.[0]).toContainEqual([x, escape]);
|
||||
});
|
||||
|
||||
it('a CFG-only function (no calls) emits NO sites key (omit-when-empty)', () => {
|
||||
const cfg = java.cfgOf(`class C { void f(int a, int b) { int x = a + b; } }`);
|
||||
expect(hasAnySites(cfg)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue