Skip to content

Commit 0c93130

Browse files
map: resolve a client imported from a local module to its dependency (#132)
* map: resolve a client imported from a local module to its dependency A regression I introduced, found while reviewing the integration plan against the code. The attributable-receiver rule treats a RELATIVE receiver as app code — correct for a lookalike helper, wrong for the layout generated apps actually use: // src/lib/db.ts export const db = createClient(url, key); // @supabase/supabase-js // src/server.ts import { db } from './lib/db'; app.post('/orders', (req, res) => db.from('orders').insert({ title: req.body.title })); `db` resolved to the specifier `./lib/db` and nothing followed it, so the endpoint reported ZERO sinks. Before the attribution work it at least surfaced as an unattributed sink (inventory only, never a candidate); after, it was invisible — worse, because correlating a CVE to an endpoint joins on the sink's package, so no vulnerability in `pg` or `@supabase/supabase-js` could ever be pinned to a route in the common case. The module graph already resolves relative specifiers and parses the target module (that is how imported helper functions are followed). This adds a different question about the same data: not "what sinks are in there" but "what does this export TRACE TO" — `importedPackage(fromFile, specifier, exportName)`, answered by the target module's own bindings. `baseOf` asks it when the receiver is relative; a package means the receiver IS that dependency, through an import-to-import chain that is fully static, so it earns `attribution: 'import'` rather than the weaker `inferred`. The narrowness is the point, and it is what keeps the earlier fix intact: the hop only produces a sink when the export actually terminates in a dependency. `import * as helper from './util'` where that module exports ordinary functions — including one named `from` — still yields nothing at all. Restored, with a candidate that was previously invisible: db.from('orders').insert({title}) -> db sink, @supabase/supabase-js, attribution import pool.query(req.body.sql) -> db sink, pg, exact-local, sql-injection candidate import { db as renamed } -> followed via its exported name helper.exec(req.body.cmd) -> still no sink (the guard) export { db } from './client' -> still no sink (one hop only, asserted as a limitation) The project boundary applies to the new resolver too — a symlink leading out of the project is refused, asserted rather than assumed. Corpus gains the lib/-client layout as a STACK case (not adversarial: this is what normal generated code looks like), and every new assertion was checked against a build with the hop disabled — 5 of them fail without it. 918 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * map: a traced package must also establish the API before it can be a DB sink Review of this branch caught a false-candidate path, and checking it against main showed the class is older than this branch: package provenance was being treated as API provenance. `.query()` / `.execute()` / `.from().insert()` are generic method names. Any receiver that resolved to a real package was admitted as a SQL sink, so an `@apollo/client` instance — a genuine dependency with nothing to do with SQL — produced `attribution: 'import'` and a precise SQL-injection candidate for a GraphQL call. That rule would block legitimate traffic and mitigate nothing, which is the worst shape a generated rule can have. main: const local = new ApolloClient(); local.query(req.body.sql) -> candidate (already live) main: import { client } from './lib/gql'; client.query(...) -> no sink here: both shapes -> candidate So the imported-client hop widened an existing hole rather than opening one. The fix is at the root: a DB recognizer now requires the resolved package to establish a database API (`isDbPackage`, covering the inference list plus real drivers not in it, and subpath imports such as `drizzle-orm/node-postgres`). A traced package that is NOT a DB provider keeps its inventory entry — a `.query()` on an unknown client is worth a human's attention — but it is marked `apiUnconfirmed`, which means: no rule, no `provider: 'sql'` claim, and no `candidateFamily` either. Advertising the sql-injection family on a GraphQL call would mis-classify it for any consumer that reads the family without checking `ruleGeneratable`. The refusal says which of the two things is missing, since they ask a reviewer to check different things: "not a known db provider: it does not establish a db API (method name alone is not evidence)" vs the existing untraceable-receiver and inferred-package reasons. Coverage: both Apollo shapes (imported and same-file) plus controls for `pg` and a `drizzle-orm` subpath, and a new adversarial corpus case — a non-DB client with a `.query()` method is precisely a lookalike, so it belongs in that category. Verified 5 assertions fail with the gate disabled. 927 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * map: claim a provider only when the receiver was traced Follow-up from review, taken now rather than deferred because it is the same mistake one field over. `res.locals.db.query(x)` in a file that imports `pg` was reported as `provider: 'sql'`. The flow was already refused (the package is only INFERRED from the file, not traced to the receiver), so no rule could come of it — but the inventory still asserted a SQL API about a receiver nobody traced, and the inventory is what a human reads. An inferred package means "this file talks to pg", never "this receiver is a pg client". `provider` is now set only when the receiver resolved (`attribution: 'import'`/`'global'`), for the SQL and prisma paths alike. `package` still carries the hint that made us look, and `attribution` already states how strong it is. The review suggested a `providerConfidence` field for this. I went the other way deliberately: that value would be derived from `attribution` and `provider`, and a second confidence field is exactly what drifts — this codebase has already had `confidence: 'precise'` survive in prose after it stopped existing in code. Deriving the claim at the point of construction keeps one source of truth. If a consumer later needs "possible DB API" as a distinct display state, it can compute it from the two fields it already has. Note this is NOT the same state as `apiUnconfirmed`: there the package is wrong for the API (`@apollo/client` for a `.query()`), here the package is right and the receiver is unknown. Two different things for a reviewer to check, so they stay distinguishable. One existing test looked its sink up BY `provider === 'prisma'` — the very claim being removed — so it now finds the sink by package and asserts the absent provider. Verified the new assertion fails with the gate reverted. 928 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * map: an unconfirmed API must not make the argument role look wrong Found by refreshing the demo viewer against this branch, which is a useful reminder that a consumer reading the output notices things a test asserting one field does not. Withholding `candidateFamily` for a sink whose package does not establish the API had a side effect: the "argument role X is not a blockable pattern on its own" check keys off a missing family, so it fired too, and the GraphQL `.query()` refusal read: sink package "@apollo/client" is not a known db provider … argument role "sql" on a db sink is not a blockable pattern on its own <- misleading Role `sql` IS normally blockable. That second line sends a reviewer to look at the argument when the problem is the package, and the queue of reasons is meant to be a work list, not a pile. The role check now skips a sink whose API is unconfirmed; the package reason already explains the refusal, and the test pins the refusal to exactly one reason. 928 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e71f6f9 commit 0c93130

8 files changed

Lines changed: 411 additions & 29 deletions

File tree

src/map/extract.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,11 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
5050
const fingerprint = createHash('sha256').update(text).digest('hex').slice(0, 16);
5151
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, guessScriptKind(ts, file));
5252
const bindings = buildModuleBindings(sf, ts);
53-
const localSinks = collectLocalSinks(sf, ts, bindings);
5453
const relFile = relative(cwd, file);
55-
for (const ep of extractFromFile(sf, ts, localSinks, bindings, { file, owner: relFile, graph })) {
54+
const ctx = { file, owner: relFile, graph };
55+
// The ctx reaches helper summaries too, so a same-file helper using an imported client resolves.
56+
const localSinks = collectLocalSinks(sf, ts, bindings, ctx);
57+
for (const ep of extractFromFile(sf, ts, localSinks, bindings, ctx)) {
5658
// A FILE-BASED route handler carries its URL path in its location, not in the code, so derive
5759
// it here — without this a rule can only be param-pinned, never route-scoped (`when.path`).
5860
if (ep.route === undefined && ep.entryKind === 'edge-function') {

src/map/flows.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,12 @@ function linkFlows(
193193
: 'heuristic';
194194
const roles = new Set<ArgumentRole>(matched.flatMap(({ roles: rs }) => [...rs]));
195195
// Prefer a role that maps to a mitigation class over a generic one (a value can reach two args).
196-
const family = [...roles].map((r) => CANDIDATE_FAMILIES[sink.kind]?.[r]).find(Boolean);
196+
// A sink whose package does not establish this API cannot support the mitigation class either:
197+
// labelling a GraphQL `.query()` as the sql-injection family would mis-classify it for any consumer
198+
// that reads `candidateFamily` without also checking `ruleGeneratable`.
199+
const family = sink.apiUnconfirmed
200+
? undefined
201+
: [...roles].map((r) => CANDIDATE_FAMILIES[sink.kind]?.[r]).find(Boolean);
197202
const argumentRole = family
198203
? [...roles].find((r) => CANDIDATE_FAMILIES[sink.kind]?.[r])
199204
: [...roles].find((r) => r !== 'unknown') ?? (proven ? 'unknown' : undefined);
@@ -211,6 +216,9 @@ function linkFlows(
211216
// from the receiver, so `res.locals.db.query(x)` in a file that happens to import `pg` looks
212217
// identical to a real pool — and `res.locals.db` may be any app object. Such sinks stay in the
213218
// inventory for review; they just cannot compile a rule that blocks live traffic on a guess.
219+
if (sink.apiUnconfirmed) {
220+
reasons.push(`sink package "${sink.package}" is not a known ${sink.kind} provider: it does not establish a ${sink.kind} API (method name alone is not evidence)`);
221+
}
214222
if (sink.attribution !== 'import' && sink.attribution !== 'global') {
215223
reasons.push(sink.attribution === 'inferred'
216224
? `sink package "${sink.package}" was inferred from the file's other imports, not from the receiver (${sink.kind}.${sink.op ?? '?'}): the receiver may be any app object`
@@ -224,7 +232,10 @@ function linkFlows(
224232
? `dynamic computed key reaches this sink (${l.detail}): the field cannot be named by a parameter`
225233
: `spread reaches this sink (${l.detail}): the specific field is not identifiable`);
226234
}
227-
if (proven && argumentRole && argumentRole !== 'unknown' && !family) {
235+
// `!sink.apiUnconfirmed`: when the family was withheld because the PACKAGE does not establish this
236+
// API, the role is not what's wrong — role "sql" is normally blockable, and saying otherwise sends a
237+
// reviewer to look at the wrong thing. The package reason above already explains the refusal.
238+
if (proven && argumentRole && argumentRole !== 'unknown' && !family && !sink.apiUnconfirmed) {
228239
// e.g. a request value in a parameterized db `values` object: real reachability, but not a
229240
// pattern a generic blocking rule can express.
230241
reasons.push(`argument role "${argumentRole}" on a ${sink.kind} sink is not a blockable pattern on its own`);

src/map/module-graph.ts

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { readFileSync, realpathSync, statSync } from 'node:fs';
22
import { dirname, join, relative, resolve as resolvePath } from 'node:path';
33
import type { Sink, TsModule } from './types.js';
44
import { guessScriptKind, isFnLike, localCalls } from './ast.js';
5-
import { buildModuleBindings } from './bindings.js';
5+
import { buildModuleBindings, npmPackageOf, type Bindings } from './bindings.js';
66
import { isInside } from './sources.js';
77
import { collectLocalSinks, type ModuleGraph } from './sinks.js';
88

@@ -14,35 +14,44 @@ import { collectLocalSinks, type ModuleGraph } from './sinks.js';
1414
const RESOLVE_EXTS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'];
1515

1616
export function createModuleGraph(ts: TsModule, opts: { cwd: string; boundary: string; followOutside?: boolean }): ModuleGraph {
17-
// file → { fnSinks, calleesOf } | null (unreadable/unparseable)
18-
const cache = new Map<string, { fnSinks: Map<string, Sink[]>; calleesOf: Map<string, string[]> } | null>();
17+
// file → { fnSinks, calleesOf, bindings } | null (unreadable/unparseable)
18+
type Entry = { fnSinks: Map<string, Sink[]>; calleesOf: Map<string, string[]>; bindings: Bindings };
19+
const cache = new Map<string, Entry | null>();
1920

2021
const load = (file: string) => {
2122
if (cache.has(file)) return cache.get(file) ?? null;
22-
let entry: { fnSinks: Map<string, Sink[]>; calleesOf: Map<string, string[]> } | null = null;
23+
let entry: Entry | null = null;
2324
try {
2425
const text = readFileSync(file, 'utf8');
2526
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, guessScriptKind(ts, file));
2627
const bindings = buildModuleBindings(sf, ts);
27-
entry = { fnSinks: collectLocalSinks(sf, ts, bindings), calleesOf: collectCallees(sf, ts) };
28+
// `bindings` is kept for `importedPackage`: the module's own view of what its exports came from.
29+
entry = { fnSinks: collectLocalSinks(sf, ts, bindings), calleesOf: collectCallees(sf, ts), bindings };
2830
} catch {
2931
entry = null; // fail-open: an unreadable dependency must not break the map
3032
}
3133
cache.set(file, entry);
3234
return entry;
3335
};
3436

37+
/** Shared guard: resolve a relative specifier, and refuse to leave the project. */
38+
const resolveInProject = (fromFile: string, specifier: string): string | undefined => {
39+
const target = resolveRelativeModule(fromFile, specifier);
40+
if (!target) return undefined;
41+
// Stay inside the project: `../../other-repo/db` (or a symlink) would otherwise pull an unrelated
42+
// codebase into this app's attack surface. The primary walker enforces this; so must the resolver.
43+
if (!opts.followOutside) {
44+
let real = target;
45+
try { real = realpathSync(target); } catch { /* use as-is */ }
46+
if (!isInside(real, opts.boundary)) return undefined;
47+
}
48+
return target;
49+
};
50+
3551
return {
3652
importedSinks(fromFile, specifier, exportName) {
37-
const target = resolveRelativeModule(fromFile, specifier);
53+
const target = resolveInProject(fromFile, specifier);
3854
if (!target) return [];
39-
// Stay inside the project: `../../other-repo/db` (or a symlink) would otherwise pull an unrelated
40-
// codebase into this app's attack surface. The primary walker enforces this; so must the resolver.
41-
if (!opts.followOutside) {
42-
let real = target;
43-
try { real = realpathSync(target); } catch { /* use as-is */ }
44-
if (!isInside(real, opts.boundary)) return [];
45-
}
4655
const mod = load(target);
4756
if (!mod) return [];
4857
const collected = [...(mod.fnSinks.get(exportName) ?? [])];
@@ -55,6 +64,22 @@ export function createModuleGraph(ts: TsModule, opts: { cwd: string; boundary: s
5564
const rel = relative(opts.cwd, target);
5665
return collected.map((s) => ({ ...s, file: rel }));
5766
},
67+
68+
// A different question about the same module: not "what sinks are in there" but "what does this
69+
// export TRACE TO". The common AI-built layout puts the client in a lib file —
70+
// `export const db = createClient(...)` in `lib/db.ts`, imported everywhere — so the receiver of
71+
// `db.from('orders').insert(...)` resolves to a relative specifier and nothing else. Refusing it (as
72+
// an unattributable receiver) is right for app code but wrong here: one hop away it is a real
73+
// dependency, and that chain is import-to-import, fully static — evidence, not inference.
74+
importedPackage(fromFile, specifier, exportName) {
75+
const target = resolveInProject(fromFile, specifier);
76+
if (!target) return undefined;
77+
const mod = load(target);
78+
if (!mod) return undefined;
79+
// The target module's own bindings answer it: `db` there resolves through
80+
// `const db = createClient(...)` back to the package `createClient` was imported from.
81+
return npmPackageOf(mod.bindings.resolve(exportName));
82+
},
5883
};
5984
}
6085

src/map/sinks.ts

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,21 @@ const HTTP_MEMBER_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'h
2020
// When a sink's base can't be traced precisely, infer its package from the file's imports of a known
2121
// provider for that sink kind (a file almost always uses one db/http client).
2222
const DB_PACKAGES = ['@supabase/supabase-js', '@prisma/client', 'drizzle-orm', 'knex', 'kysely', 'pg', 'mysql2', 'mysql', 'sequelize', 'typeorm', 'mongoose', 'better-sqlite3'];
23+
// Drivers that are not in the inference list above but do establish a database API when a receiver
24+
// resolves to them. Extend deliberately: this list is what separates "a package we can trace" from
25+
// "a package that proves a DB API", and admitting the wrong one produces a false SQL-injection rule.
26+
const MORE_DB_PACKAGES = ['postgres', 'mssql', 'tedious', 'oracledb', 'sqlite3', 'mongodb', 'ioredis',
27+
'@planetscale/database', '@neondatabase/serverless', '@libsql/client', '@vercel/postgres', 'slonik', 'sql.js'];
28+
/**
29+
* Does `pkg` establish a DATABASE api? Package provenance is not API provenance: `.query()` is a generic
30+
* method name, and an `@apollo/client` (or any HTTP-ish client) instance resolves to a real package while
31+
* having nothing to do with SQL. Without this gate, `client.query(req.body.sql)` compiled a precise
32+
* SQL-injection candidate for a GraphQL call — a rule that blocks legitimate traffic and mitigates nothing.
33+
* Subpath imports count (`drizzle-orm/node-postgres`).
34+
*/
35+
const isDbPackage = (pkg: string) =>
36+
DB_PACKAGES.includes(pkg) || MORE_DB_PACKAGES.includes(pkg) ||
37+
[...DB_PACKAGES, ...MORE_DB_PACKAGES].some((p) => pkg.startsWith(p + '/'));
2338
const HTTP_PACKAGES = ['axios', 'got', 'node-fetch', 'undici', 'superagent', 'ky'];
2439
const isHttpPackage = (pkg: string) => HTTP_PACKAGES.includes(pkg) || pkg === 'node:http' || pkg === 'node:https';
2540
// A filesystem/process API is not only a node: builtin — these wrappers expose the same sinks, and
@@ -32,6 +47,12 @@ const isExecPackage = (pkg: string) => pkg === 'node:child_process' || EXEC_PACK
3247
export interface ModuleGraph {
3348
/** Sinks of `exportName` in the module `specifier` resolves to, relative to `fromFile`. */
3449
importedSinks(fromFile: string, specifier: string, exportName: string): Sink[];
50+
/**
51+
* The npm package `exportName` traces to inside the module `specifier` resolves to — for a client
52+
* instance re-exported from a local module (`export const db = createClient(...)`). ONE hop: a
53+
* re-export chain (`export { db } from './client'`) is not followed.
54+
*/
55+
importedPackage(fromFile: string, specifier: string, exportName: string): string | undefined;
3556
}
3657

3758
export interface SinkContext {
@@ -43,14 +64,14 @@ export interface SinkContext {
4364
}
4465

4566
// --- sinks (agnostic) -------------------------------------------------------
46-
export function collectLocalSinks(sf: any, ts: TsModule, bindings: Bindings): Map<string, Sink[]> {
67+
export function collectLocalSinks(sf: any, ts: TsModule, bindings: Bindings, ctx?: SinkContext): Map<string, Sink[]> {
4768
const map = new Map<string, Sink[]>();
4869
const visit = (node: any) => {
49-
if (ts.isFunctionDeclaration(node) && node.name && node.body) map.set(node.name.text, directSinks(node.body, ts, bindings));
70+
if (ts.isFunctionDeclaration(node) && node.name && node.body) map.set(node.name.text, directSinks(node.body, ts, bindings, ctx));
5071
else if (ts.isVariableStatement(node)) {
5172
for (const decl of node.declarationList.declarations) {
5273
if (ts.isIdentifier(decl.name) && decl.initializer && isFnLike(decl.initializer, ts)) {
53-
map.set(decl.name.text, directSinks(decl.initializer.body, ts, bindings));
74+
map.set(decl.name.text, directSinks(decl.initializer.body, ts, bindings, ctx));
5475
}
5576
}
5677
}
@@ -65,7 +86,7 @@ export function sinksFrom(arrowOrNode: any, ts: TsModule, localSinks: Map<string
6586
const body = arrowOrNode.isSyntheticBody ? arrowOrNode.body
6687
: isFnLike(arrowOrNode, ts) ? arrowOrNode.body : arrowOrNode;
6788
if (!body) return [];
68-
const sinks = directSinks(body, ts, bindings);
89+
const sinks = directSinks(body, ts, bindings, ctx);
6990
for (const called of localCalls(body, ts)) {
7091
// Same-file helper.
7192
for (const s of localSinks.get(called) ?? []) sinks.push(s);
@@ -109,7 +130,7 @@ function namespaceMemberCalls(node: any, ts: TsModule): Array<[string, string]>
109130
// it: resolved precisely from the call's base identifier via the file's imports, else inferred from
110131
// the file's imports of a known provider for that sink kind. A receiver that traces to a plain local
111132
// object/class/function is NOT a dependency sink and is dropped.
112-
function directSinks(node: any, ts: TsModule, bindings: Bindings): Sink[] {
133+
function directSinks(node: any, ts: TsModule, bindings: Bindings, ctx?: SinkContext): Sink[] {
113134
const sinks: Sink[] = [];
114135
// `spec` is the raw module specifier the receiver came from, which `pkg` cannot express: a RELATIVE
115136
// specifier yields no package, and that is a positive fact (the receiver is app code) rather than the
@@ -122,13 +143,38 @@ function directSinks(node: any, ts: TsModule, bindings: Bindings): Sink[] {
122143
const relative = spec !== undefined && (spec.startsWith('.') || spec.startsWith('/'));
123144
const pkg = npmPackageOf(spec);
124145
if (pkg) return { pkg, root, spec };
146+
// A RELATIVE receiver is app code — unless one hop away it is a dependency. `import { db } from
147+
// './lib/db'` where that module does `export const db = createClient(...)` is the most common layout
148+
// in generated apps, and treating it as app code made the sink vanish entirely. Ask the target module
149+
// what the export traces to: a package means the receiver IS that dependency (an import-to-import
150+
// chain, so `attribution: 'import'`), and NO package means it stays app code — which is what keeps
151+
// `import * as helper from './util'; helper.exec(x)` correctly sink-free.
152+
if (relative && ctx && spec) {
153+
const viaModule = ctx.graph.importedPackage(ctx.file, spec, bindings.exportNameOf(root) ?? root);
154+
if (viaModule) return { pkg: viaModule, root, spec };
155+
}
125156
return { local: bindings.locals.has(root), root, spec, relative };
126157
};
127158
// Whether `package` is evidence or a guess. A resolved import binding is evidence ('import'); a
128159
// package inferred from the file's OTHER imports is a guess that is usually right ('inferred'); an
129160
// untraceable receiver is neither (undefined) and must not drive an auto-generated rule.
130161
const attributionOf = (b: { pkg?: string }, pkg: string | undefined): Sink['attribution'] =>
131162
b.pkg ? 'import' : pkg ? 'inferred' : undefined;
163+
/**
164+
* Claim `provider: 'sql'` only when the package actually establishes a database API. A traced package
165+
* that is not a DB provider stays in the INVENTORY (a `.query()` on it is worth a human's attention)
166+
* but is marked so no rule can be compiled from it — and it does not get to call itself SQL.
167+
*/
168+
const dbApi = (pkg: string | undefined, attribution: Sink['attribution']): { provider?: string; apiUnconfirmed?: true } => {
169+
if (pkg && !isDbPackage(pkg)) return { apiUnconfirmed: true };
170+
// `provider` is a claim about the API at THIS call site, so it needs the receiver — not just the file.
171+
// An INFERRED package means "this file talks to pg", never "this receiver is a pg client", and
172+
// `res.locals.db.query(x)` in a file that imports pg is exactly that. The flow is already refused;
173+
// asserting `provider: 'sql'` anyway would overstate it in the inventory, where a human reads it.
174+
// `package` still carries the hint, and `attribution` already says how strong it is — deriving the
175+
// claim from it here keeps one source of truth rather than a second confidence field to drift.
176+
return attribution === 'import' || attribution === 'global' ? { provider: 'sql' } : {};
177+
};
132178
const infer = (kind: 'db' | 'http'): string | undefined => {
133179
const table = kind === 'db' ? DB_PACKAGES : HTTP_PACKAGES;
134180
for (const p of table) if (bindings.imports.has(p)) return p;
@@ -152,7 +198,8 @@ function directSinks(node: any, ts: TsModule, bindings: Bindings): Sink[] {
152198
const parent = n.parent;
153199
if (!b.local && !b.relative && parent && ts.isPropertyAccessExpression(parent) && DB_OPS.has(parent.name.text)) {
154200
const pkg = b.pkg ?? infer('db');
155-
push({ kind: 'db', provider: 'sql', package: pkg, table, op: parent.name.text, attribution: attributionOf(b, pkg), ...spanOf(opCallOf(parent, ts)) });
201+
const attribution = attributionOf(b, pkg);
202+
push({ kind: 'db', ...dbApi(pkg, attribution), package: pkg, table, op: parent.name.text, attribution, ...spanOf(opCallOf(parent, ts)) });
156203
}
157204
}
158205
if (ts.isPropertyAccessExpression(callee)) {
@@ -169,13 +216,16 @@ function directSinks(node: any, ts: TsModule, bindings: Bindings): Sink[] {
169216
if (PRISMA_OPS.has(method) && ts.isPropertyAccessExpression(callee.expression)) {
170217
const prismaLikely = b.pkg === '@prisma/client' ||
171218
(!b.pkg && (bindings.imports.has('@prisma/client') || /prisma/i.test(b.root ?? '')));
172-
if (prismaLikely) push({ kind: 'db', provider: 'prisma', package: '@prisma/client', table: callee.expression.name.text, op: method, attribution: b.pkg ? 'import' : 'inferred', ...spanOf(n) });
219+
// Same rule for the provider claim: only a resolved receiver earns `provider: 'prisma'`; a
220+
// prisma-NAMED receiver is a hint, and `package` + `attribution` already say so.
221+
if (prismaLikely) push({ kind: 'db', ...(b.pkg ? { provider: 'prisma' } : {}), package: '@prisma/client', table: callee.expression.name.text, op: method, attribution: b.pkg ? 'import' : 'inferred', ...spanOf(n) });
173222
}
174223
// db: raw `.query(` / `.execute(`. Any object can have a `.query` method, so an untraceable
175224
// receiver stays in the inventory with NO attribution — visible to a human, never auto-ruled.
176225
if (method === 'query' || method === 'execute') {
177226
const pkg = b.pkg ?? infer('db');
178-
push({ kind: 'db', provider: 'sql', package: pkg, op: method, attribution: attributionOf(b, pkg), ...spanOf(n) });
227+
const attribution = attributionOf(b, pkg);
228+
push({ kind: 'db', ...dbApi(pkg, attribution), package: pkg, op: method, attribution, ...spanOf(n) });
179229
}
180230
// fs / exec via a namespace: `fs.writeFile(` / `child_process.exec(`. The receiver must
181231
// actually resolve to a filesystem/process package — the method name alone proves nothing.

0 commit comments

Comments
 (0)