Skip to content

[ENG-3582] map: record which dependency APIs the app calls, separately from dataflow - #139

Merged
patchstackdave merged 3 commits into
mainfrom
davejong/eng-3582-api-invocation-inventory
Aug 17, 2026
Merged

[ENG-3582] map: record which dependency APIs the app calls, separately from dataflow#139
patchstackdave merged 3 commits into
mainfrom
davejong/eng-3582-api-invocation-inventory

Conversation

@patchstackdave

Copy link
Copy Markdown
Contributor

Closes ENG-3582. Builds on the import inventory and the capability contract (#136, #137, both merged).

Why

Sinks answer "can request input reach a dangerous operation". That is only askable for the few API families the extractor models — ~3.6% of real advisories, the same rate across two independent 500-record TI samples. For the library tail the useful question is simpler, and we could not answer it at all: is the package's API invoked?

The motivating case is real, and I verified it before writing anything: sequelize CVE-2026-69240's own PoC is Model.findOne({ where: { x: req.query.x } }). findOne is not a recognized sink operation, so the advisory's own demonstration shape produced nothing — the vulnerability could only ever be reported as "the package is imported".

It is recorded now, and a test asserts that same call still produces no sink, so the two layers cannot quietly become redundant.

Discipline

Resolve the receiver, never trust a method name — the sink rule, for the sink reason. An untraceable receiver is not an invocation of a package, and inferred attribution does not qualify at all here: unlike a sink there is no argument role or dangerous operation to corroborate a guess.

Each record carries its own evidence rather than leaving strength to be inferred from context:

{ "package": "sequelize", "api": "findOne", "symbol": "findOne",
  "kind": "member", "attribution": "import", "resolution": "factory",
  "callCount": 2, "sites": [{ "file": "src/search.ts", "line": 42, "start": 1201, "end": 1224 }] }

resolution separates called on an imported binding from called on a value followed through a factory or through a local re-export — different strengths of the same claim, which a consumer deciding what to act on needs told apart.

Partial by construction, and no completeness flag

Parsing every file would raise recall but would not make absence safe: dynamic property access, computed import()/require(), reflection, unfollowed aliases, generated code and unparseable syntax all stay invisible.

So coverage.apiInventoryLimitations enumerates those shapes and there is deliberately no apiInventoryComplete — a boolean is something a consumer could read as licence for "the vulnerable API is not called", and no parsing budget could earn that. Tests assert the limitations exist and that no completeness flag does.

The numbers to decide full parsing with

Recorded per run, because "32 invocations" means nothing without scale. On the real Lovable app: 66 files discovered, 6 parsed, 190KB source, 29ms, 32 invocations across 4 packages, 58 resolved / 99 unresolved = 37% of call sites traced, document 19KB → 30KB.

That is the basis for deciding later whether parsing more files moves enough real TI pairs to justify the cost — a performance question, explicitly not a completeness one.

Two corrections found while building it

  • receiver first reported pg's method as pool.query, where pool is the local module's export name and no part of pg's API. It is now recorded only when the binding came straight from the package — otherwise we would be inventing API names.
  • Bindings now records how a name reached its package instead of the resolution being inferred at the call site.

Verification

15 new tests, 1009 passing, typecheck clean. Most of that file is about what must not be recorded: an untraceable receiver, a local function shadowing a dependency export, and any attribution other than a resolved import.

Capability vocabulary went through the versioned contract — invocationKinds, invocationResolutions, 1.0.0 → 1.1.0 — which the version checker confirmed as an additive minor.

Next

The platform side consumes this as a verdict tier between imported and reachable, named api-called rather than anything implying the vulnerable API was called: the inventory knows the package's API was invoked, not which one the advisory implicates. That needs TI to name the affected API (ENG-3583).

…y from dataflow

The sink analysis answers "can request input reach a dangerous operation", which is only askable for
the few API families it models — measured at ~3.6% of real advisories, identically across two
independent 500-record TI samples. For the library tail the useful question is simpler and we could
not answer it at all: is the package's API invoked?

The motivating case is real and was verified before writing anything. sequelize CVE-2026-69240's own
proof of concept is `Model.findOne({ where: { x: req.query.x } })`, and `findOne` is not a recognized
sink operation — so the advisory's own demonstration shape produced NOTHING, and the vulnerability
could only ever be reported as "the package is imported". It is now recorded, and a test asserts the
same call still produces no sink, so the two layers cannot quietly become redundant.

Discipline is the sink discipline: resolve the RECEIVER, never trust a method name. An untraceable
receiver is not an invocation of a package, and `inferred` attribution does not qualify at all here —
unlike a sink there is no argument role or dangerous operation to corroborate a guess.

Every record carries its own evidence rather than leaving strength to be inferred from context:
`attribution`, `resolution` (direct | factory | reexport) and the span. "Called on an imported
binding" and "called on a value we followed through a factory" are different claims.

PARTIAL BY CONSTRUCTION, and deliberately with no completeness flag. Parsing every file would raise
recall but would not make absence safe: dynamic property access, computed import()/require(),
reflection, unfollowed aliases, generated code and unparseable syntax all remain invisible. So
`coverage.apiInventoryLimitations` enumerates the shapes instead — the list IS the statement, because
a boolean is something a consumer could read as licence for "the vulnerable API is not called". Full
parsing stays a performance question, not a completeness proof, and the measurements to decide it are
now recorded per run: source bytes, wall time, peak RSS, invocations, and resolved vs unresolved call
sites (the honest denominator — 32 invocations means nothing without the 37% trace rate beside it).

Two corrections found while building it. `receiver` initially reported pg's method as `pool.query`,
where `pool` is the local module's export name and not part of pg's API; it is now recorded only when
the binding came straight from the package. And `Bindings` now records HOW a name reached its package,
because the resolution had been inferred rather than known.

Capability vocabulary goes through the versioned contract: invocationKinds and invocationResolutions
added, 1.0.0 -> 1.1.0, which the version checker confirmed as an additive minor.
@coderbuds

coderbuds Bot commented Aug 17, 2026

Copy link
Copy Markdown

Comprehensive invocation inventory feature with thorough tests and clear structure.

🎯 Quality: 93% Elite · 📦 Size: Large — consider splitting if possible

🛡️ Standards: no pre-flight fit check ran for this change — wire assess-change-fit into your coding agents to catch size before opening.

📈 This month: Your 60th PR — above team average · Averaging Good

See how your team is trending →

…e dependency calls

The metric was wrong in a way that would have misinformed the decision it exists for.
`apiCallsUnresolved` counted every untraced call expression, so ordinary local helpers and
application methods landed in it. `resolved / (resolved + unresolved)` was therefore a property of
the APP — how much of its code calls dependencies — not of the resolver. Worse, it would have gone
DOWN as parsing widened, because more parsing finds more local calls: exactly backwards for a number
meant to justify parsing more.

Four buckets now, and they sum to the total (asserted, so none can silently absorb calls):

  callsTotal       every call/new expression — workload scale
  callsDependency  traced to a package
  callsLocal       a known local binding or an enclosing parameter — correctly excluded
  callsAmbiguous   a receiver we could not classify, or a computed/dynamic callee

Resolver quality is `callsDependency / (callsDependency + callsAmbiguous)`. `callsLocal` is in neither
term: declining to attribute `res.json()` to a package is a correct answer, not a miss.

The line between local and ambiguous took a correction of its own. A handler parameter is local, so
`res.json()` is local — but `res.locals.db.query()` is NOT, even though its root is that same
parameter: the value being called is whatever was stashed on `res.locals`, and a database client is
precisely what apps put there. Only a method called DIRECTLY on a local binding counts as local;
anything deeper is ambiguous, which matches how the sink analysis already treats those receivers.

On the real app the corrected figure is 52% of dependency-candidate receivers resolved, against the
37% the old rate reported — and the old number would have looked worse the more we parsed.
`peakRssBytes` held `memoryUsage().rss` — the resident size at the moment extraction finished, after
the walk's garbage may already have been collected. That is a point-in-time reading, and calling it a
peak would have overstated it in exactly the place it would be used: deciding whether parsing more
files is affordable.

Split into two honestly-labelled numbers rather than just renaming, because the peak is the one the
decision needs:

  rssBytes      resident size when extraction finished — point-in-time, not a high-water mark
  peakRssBytes  a real high-water mark from the OS (`resourceUsage().maxRSS`), but PROCESS-wide: it
                includes loading the TypeScript compiler, so it bounds the cost of running `map` from
                above rather than attributing a peak to extraction alone

Both are documented with those caveats, and a test pins the ordering invariant. Absent where the
platform does not report them — nothing here may assume `process`, since this file's runtime has to
stay edge-safe.
@patchstackdave

Copy link
Copy Markdown
Contributor Author

/review

@patchstackdave
patchstackdave merged commit dabbefa into main Aug 17, 2026
6 checks passed
@patchstackdave
patchstackdave deleted the davejong/eng-3582-api-invocation-inventory branch August 17, 2026 16:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants