From da1154f53e4a30ff80d28d684d1af3b8f1284570 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Sun, 14 Jun 2026 17:08:15 +0000 Subject: [PATCH] feat(cfg): defensive per-statement cap on harvested taint sites (#2195 U11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A statement's harvested sites[] had no explicit bound — a pathological or machine-generated statement (hundreds of nested calls) could grow it without limit. Add DEFAULT_PDG_MAX_SITES_PER_STATEMENT (512, mirroring the PDG edge/fact cap style): openCallSite/addMemberRead check-before-push and stop at the cap, keeping the first 512 sites fully intact and setting an observable sitesTruncated flag. A cap-dropped openCallSite returns a -1 sentinel that pushFrame/setSite*/the occurrence fan-out all tolerate (no dangling parent/via, no clobber of kept sites). Generous enough that no real statement is affected: bench --check fingerprints unchanged, cfg unit suite 617 passed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cfg/visitors/call-site-harvest.ts | 50 ++++++++++++-- gitnexus/test/unit/cfg/harvest.test.ts | 68 +++++++++++++++++++ 2 files changed, 112 insertions(+), 6 deletions(-) diff --git a/gitnexus/src/core/ingestion/cfg/visitors/call-site-harvest.ts b/gitnexus/src/core/ingestion/cfg/visitors/call-site-harvest.ts index 0ff3313da..ed85bc2dc 100644 --- a/gitnexus/src/core/ingestion/cfg/visitors/call-site-harvest.ts +++ b/gitnexus/src/core/ingestion/cfg/visitors/call-site-harvest.ts @@ -116,6 +116,18 @@ export class DefUseAccumulator { } } +/** + * Defensive per-statement cap on harvested taint `sites` (#2195 U11). A real + * statement carries a handful of call / member-read sites; this only bounds a + * pathological or machine-generated statement (e.g. hundreds of nested calls) + * from producing an unbounded site list. Mirrors the PDG edge/fact caps' style + * (a generous-but-finite limit, checked before each push). Overflow is silent + * but observable via {@link CallSiteFactAccumulator.sitesTruncated}; the first + * `DEFAULT_PDG_MAX_SITES_PER_STATEMENT` sites are kept fully intact (callee, + * args, parent), the over-cap tail is dropped. + */ +export const DEFAULT_PDG_MAX_SITES_PER_STATEMENT = 512; + /** * Ordered, deduplicating def/use collector for one statement record, PLUS the * call-site harvest machinery (#2195 U6). A drop-in superset of the simple @@ -137,9 +149,16 @@ export class CallSiteFactAccumulator { private readonly memberReadKeys = new Set(); /** Stack of open call/new sites — the occurrence fan-out targets. */ private readonly frames: SiteFrame[] = []; + /** Set once the per-statement site cap is hit; over-cap sites are dropped. */ + private _sitesTruncated = false; constructor(private readonly line: number) {} + /** True iff this statement hit {@link DEFAULT_PDG_MAX_SITES_PER_STATEMENT}. */ + get sitesTruncated(): boolean { + return this._sitesTruncated; + } + addDef(idx: number): void { if (this.defSeen.has(idx)) return; this.defSeen.add(idx); @@ -190,8 +209,17 @@ export class CallSiteFactAccumulator { return [...this.defs.slice(snap[0]), ...this.mayDefs.slice(snap[1])]; } - /** Open a call/new site; parent = innermost enclosing argument position. */ + /** + * Open a call/new site; parent = innermost enclosing argument position. + * Returns the new site index, or -1 when the per-statement site cap is hit + * (the caller threads -1 through `pushFrame`/`setSite*`, all of which no-op on + * a sentinel index — see {@link DEFAULT_PDG_MAX_SITES_PER_STATEMENT}). + */ openCallSite(kind: 'call' | 'new'): number { + if (this.sites.length >= DEFAULT_PDG_MAX_SITES_PER_STATEMENT) { + this._sitesTruncated = true; + return -1; + } const site: MutableSite = { kind }; const parent = this.innermostArgPosition(); if (parent) site.parent = parent; @@ -232,20 +260,23 @@ export class CallSiteFactAccumulator { } setSiteCallee(siteIdx: number, callee: string): void { - this.sites[siteIdx].callee = callee; + const site = this.sites[siteIdx]; + if (site) site.callee = callee; } setSiteReceiver(siteIdx: number, receiver: number): void { - this.sites[siteIdx].receiver = receiver; + const site = this.sites[siteIdx]; + if (site) site.receiver = receiver; } setSiteResultDefs(siteIdx: number, resultDefs: readonly number[]): void { - this.sites[siteIdx].resultDefs = [...resultDefs]; + const site = this.sites[siteIdx]; + if (site) site.resultDefs = [...resultDefs]; } setSiteSpread(siteIdx: number, firstSpreadArg: number): void { const site = this.sites[siteIdx]; - if (site.spread === undefined) site.spread = firstSpreadArg; + if (site && site.spread === undefined) site.spread = firstSpreadArg; } /** @@ -257,6 +288,10 @@ export class CallSiteFactAccumulator { const parent = this.innermostArgPosition(); const dedupKey = `${object}|${property}|${parent ? `${parent[0]}:${parent[1]}` : 'top'}`; if (this.memberReadKeys.has(dedupKey)) return; + if (this.sites.length >= DEFAULT_PDG_MAX_SITES_PER_STATEMENT) { + this._sitesTruncated = true; + return; + } this.memberReadKeys.add(dedupKey); const site: MutableSite = { kind: 'member-read' }; if (parent) site.parent = parent; @@ -283,7 +318,9 @@ export class CallSiteFactAccumulator { 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; + // A nested frame whose site was cap-dropped (siteIdx -1) is not a real via. + const next = i + 1 < this.frames.length ? this.frames[i + 1].siteIdx : undefined; + const via = next !== undefined && next >= 0 ? next : undefined; this.pushArgEntry(f.siteIdx, f.argIdx, idx, via); } } @@ -295,6 +332,7 @@ export class CallSiteFactAccumulator { via: number | undefined, ): void { const site = this.sites[siteIdx]; + if (!site) return; // cap-dropped frame (siteIdx -1) — no target to fan into const args = (site.args ??= []); while (args.length <= argIdx) args.push([]); const list = args[argIdx]; diff --git a/gitnexus/test/unit/cfg/harvest.test.ts b/gitnexus/test/unit/cfg/harvest.test.ts index 7c8e3a823..a77a36149 100644 --- a/gitnexus/test/unit/cfg/harvest.test.ts +++ b/gitnexus/test/unit/cfg/harvest.test.ts @@ -710,3 +710,71 @@ describe('M3 U1 — taint-site harvest: templates, callbacks, statement granular expect(execSite.args).toEqual([[bindingIdx(cfg, 'x')]]); }); }); + +import { + CallSiteFactAccumulator, + DEFAULT_PDG_MAX_SITES_PER_STATEMENT as MAX_SITES, +} from '../../../src/core/ingestion/cfg/visitors/call-site-harvest.js'; + +describe('U11 — per-statement site cap (defensive bound on harvested sites[])', () => { + it('records every site for a statement below the cap (unchanged)', () => { + const acc = new CallSiteFactAccumulator(1); + for (let i = 0; i < 5; i++) acc.setSiteCallee(acc.openCallSite('call'), `f${i}`); + const facts = acc.finish(); + expect(facts.sites).toHaveLength(5); + expect(acc.sitesTruncated).toBe(false); + expect(facts.sites!.map((s) => s.callee)).toEqual(['f0', 'f1', 'f2', 'f3', 'f4']); + }); + + it('caps a pathological statement at exactly the limit and flags truncation', () => { + const acc = new CallSiteFactAccumulator(1); + const indices: number[] = []; + for (let i = 0; i < MAX_SITES + 50; i++) { + const idx = acc.openCallSite('call'); + acc.setSiteCallee(idx, `f${i}`); + indices.push(idx); + } + const facts = acc.finish(); + // exactly the cap recorded — not the requested over-count, not unbounded + expect(facts.sites).toHaveLength(MAX_SITES); + expect(acc.sitesTruncated).toBe(true); + // under-cap opens get 0..cap-1; the first over-cap open gets the -1 sentinel + expect(indices[MAX_SITES - 1]).toBe(MAX_SITES - 1); + expect(indices[MAX_SITES]).toBe(-1); + // KEPT sites stay fully intact (no clobber from the dropped tail) + expect(facts.sites![0].callee).toBe('f0'); + expect(facts.sites![MAX_SITES - 1].callee).toBe(`f${MAX_SITES - 1}`); + }); + + it('member-reads past the cap are dropped, not unbounded', () => { + const acc = new CallSiteFactAccumulator(1); + for (let i = 0; i < MAX_SITES; i++) acc.openCallSite('call'); // fill to the cap + acc.addMemberRead(0, 'body'); // would-be site #cap+1 + expect(acc.finish().sites).toHaveLength(MAX_SITES); + expect(acc.sitesTruncated).toBe(true); + }); + + it('occurrence machinery stays sound when a nested frame is cap-dropped', () => { + const acc = new CallSiteFactAccumulator(1); + const outer = acc.openCallSite('call'); // a KEPT outer sink site + acc.setSiteCallee(outer, 'sink'); + acc.pushFrame(outer); + acc.setFrameArg(0); + // Saturate the remaining budget so the next openCallSite is cap-dropped. + for (let i = 1; i < MAX_SITES; i++) acc.openCallSite('call'); + const nested = acc.openCallSite('call'); // over cap → -1 sentinel + expect(nested).toBe(-1); + acc.setSiteCallee(nested, 'dropped'); // must no-op, not throw + acc.pushFrame(nested); + acc.setFrameArg(0); + acc.addUse(7); // a use inside the dropped nested call — must not crash + acc.popFrame(); + acc.popFrame(); + const facts = acc.finish(); + expect(facts.uses).toContain(7); // still recorded statement-level + // outer (kept) fanned the use in as a PLAIN occurrence — no dangling -1 via + const flat = (facts.sites![outer].args ?? []).flat(); + expect(flat).toContain(7); + expect(flat.some((e) => Array.isArray(e) && e[1] === -1)).toBe(false); + }); +});