Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/map/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ import type { TsModule } from './types.js';
// Dependency-free AST helpers shared by every recognizer in this directory.

// Leftmost identifier of a member/call chain (`supabase.from(x).insert` → "supabase", `fs.writeFile` → "fs").
// `new` is part of a chain like any other link: `new Pool().query` roots at `Pool`, and so does
// `function getDb() { return new Pool() }` — the shape a generated app uses for a database client. Omitting
// it dropped the receiver entirely, which reads as "not a dependency" rather than "not followed".
export function rootIdentifier(node: any, ts: TsModule): string | undefined {
let cur = node;
while (cur) {
if (ts.isIdentifier(cur)) return cur.text;
if (ts.isPropertyAccessExpression(cur) || ts.isElementAccessExpression(cur) || ts.isCallExpression(cur) || ts.isNonNullExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isAwaitExpression(cur)) {
if (ts.isPropertyAccessExpression(cur) || ts.isElementAccessExpression(cur) || ts.isCallExpression(cur) || ts.isNewExpression(cur) || ts.isNonNullExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isAwaitExpression(cur)) {
cur = cur.expression;
} else return undefined;
}
Expand Down
22 changes: 21 additions & 1 deletion src/map/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,24 @@ export interface Bindings {
* and a consumer deciding what to act on needs to tell them apart.
*/
originOf(name: string): 'direct' | 'factory' | undefined;
/**
* Whether the name is a NAMED MEMBER of its package (`import { Pool } from 'pg'`,
* `const { parse } = require('json5')`) rather than the module object itself (`import * as fs`,
* `import JSON5 from 'json5'`, `const JSON5 = require('json5')`).
*
* Both are `direct`, but only the member's name belongs to the package. A module object's local name is
* the app's invention — the same require is written `JSON5`, `J5`, or `json5` in three codebases — so it
* is not a name any advisory can be matched against.
*/
isPackageMember(name: string): boolean;
imports: Set<string>;
locals: Set<string>;
}
export function buildModuleBindings(sf: any, ts: TsModule): Bindings {
const nameToModule = new Map<string, string>(); // local name → module specifier
const declared = new Set<string>(); // every name declared in this file
const exportNames = new Map<string, string>(); // local alias → exported name
const members = new Set<string>(); // names bound to a NAMED export, not to the module object
const imports = new Set<string>();
const origins = new Map<string, 'direct' | 'factory'>();

Expand Down Expand Up @@ -58,6 +69,7 @@ export function buildModuleBindings(sf: any, ts: TsModule): Bindings {
if (ts.isNamespaceImport(nb)) record(nb.name.text, mod);
else if (ts.isNamedImports(nb)) for (const el of nb.elements) {
record(el.name.text, mod);
members.add(el.name.text);
// `import { saveOrder as write }` — looking up `write` in the target module would miss.
if (el.propertyName && ts.isIdentifier(el.propertyName)) exportNames.set(el.name.text, el.propertyName.text);
}
Expand All @@ -73,7 +85,14 @@ export function buildModuleBindings(sf: any, ts: TsModule): Bindings {
const reqMod = requireSpecifier(init, ts);
if (reqMod) {
if (ts.isIdentifier(decl.name)) record(decl.name.text, reqMod);
else if (ts.isObjectBindingPattern(decl.name)) for (const el of decl.name.elements) if (ts.isIdentifier(el.name)) record(el.name.text, reqMod);
else if (ts.isObjectBindingPattern(decl.name)) for (const el of decl.name.elements) if (ts.isIdentifier(el.name)) {
record(el.name.text, reqMod);
members.add(el.name.text);
// `const { merge: deepMerge } = require("lodash")` — the CommonJS twin of an import alias.
// Without this the local alias looks like the package's own export name, and reporting it as
// one invents an API the package does not have.
if (el.propertyName && ts.isIdentifier(el.propertyName)) exportNames.set(el.name.text, el.propertyName.text);
}
}
// const x = tracedFactory(...) / const x = new TracedClass(...) → x carries that package.
// Looking up nameToModule (not just direct imports) makes this transitive: pool → conn → ….
Expand Down Expand Up @@ -119,6 +138,7 @@ export function buildModuleBindings(sf: any, ts: TsModule): Bindings {
resolve: (name: string) => nameToModule.get(name),
exportNameOf: (name: string) => exportNames.get(name),
originOf: (name: string) => origins.get(name),
isPackageMember: (name: string) => members.has(name),
imports,
locals,
};
Expand Down
52 changes: 50 additions & 2 deletions src/map/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
let sourceBytes = 0;
const calls = { total: 0, dependency: 0, local: 0, ambiguous: 0 };
const startedAt = Date.now();
// Local modules an entry file imports directly — parsed after the walk for their invocations (below).
const entryFiles = new Set<string>();
const hopTargets = new Set<string>();

for (const file of files) {
try {
Expand All @@ -67,10 +70,16 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
continue;
}
parsed++;
entryFiles.add(file);
// Coordinates are only valid for the exact file content they were derived from.
const fingerprint = createHash('sha256').update(text).digest('hex').slice(0, 16);
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, guessScriptKind(ts, file));
const bindings = buildModuleBindings(sf, ts);
for (const specifier of bindings.imports) {
if (!specifier.startsWith('.')) continue; // node_modules is not this app's surface
const target = graph.resolveLocal(file, specifier);
if (target !== undefined) hopTargets.add(target);
}
imports.add(relFile, collectFileImports(sf, ts), true);
// Counted on this path too. A parsed file is not a covered file: `collectFileImports` requires a
// string-literal specifier, so a computed require in an entry file is just as unattributable as
Expand Down Expand Up @@ -112,6 +121,41 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
}
}

// --- one hop: the local modules an entry file imports ----------------------
// An entry file's dependency calls are usually not written IN the entry file. `src/server.js` imports
// `loadConfig` from `./config`, and the `JSON5.parse(...)` call lives in config.js — a file with no entry
// signal, so the walk above only scanned its imports. Reading the call from the file it is written in is
// the difference between reporting the API an advisory names and reporting nothing about it.
//
// ONE hop, from entry files only, invocations only: no sinks, no endpoints, no recursion into what the
// hop file itself imports. The bound is structural — reachable from an entry by a relative import —
// rather than a file budget, so coverage is predictable from the app's shape instead of from its size.
let hopParsed = 0;
let hopFailures = 0;
for (const target of hopTargets) {
if (entryFiles.has(target)) continue; // already collected above, along with its endpoints
try {
const text = readFileSync(target, 'utf8');
const sf = ts.createSourceFile(target, text, ts.ScriptTarget.Latest, true, guessScriptKind(ts, target));
const bindings = buildModuleBindings(sf, ts);
// `owner` is the hop file, so a site points at the line that makes the call rather than at the
// entry file that reaches it. A coordinate naming the wrong file cannot be checked by hand.
const called = collectInvocations(sf, ts, bindings, { file: target, owner: relative(cwd, target), graph });
invocations.add(called.invocations);
calls.total += called.counts.total;
calls.dependency += called.counts.dependency;
calls.local += called.counts.local;
calls.ambiguous += called.counts.ambiguous;
hopParsed++;
} catch (e) {
if (typeof process !== 'undefined' && process.env?.PS_MAP_DEBUG) console.error('[patchstack] map hop error', target, e);
// Fail-open, and counted: these files were already read once for their imports, so they must NOT
// join `failed` (that would double-count the skip and flip the import-completeness flag over a
// gap that belongs to a different question).
hopFailures++;
}
}

notes.push('Static analysis is best-effort — this is the DETECTED surface, not a proof of completeness.');
notes.push('`inputs` and `sinks` are INVENTORIES (both present in the handler). Only `flows` asserts that an input reaches a sink: require confidence "exact-local" or "transformed-local" before pinning a rule, and identify the input by `inputId` — a field NAME can occur in more than one request namespace.');
notes.push('Sinks are followed into same-file helpers and ONE hop into an imported relative module (a dependency\u2019s internals are not followed); deeper or dynamic indirection is not traced. Sinks inside declared-but-uncalled local functions are excluded.');
Expand Down Expand Up @@ -161,7 +205,10 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac

const invocationList = invocations.list();
if (invocationList.length > 0 || parsed > 0) {
notes.push(`\`apiInvocations\` records ${invocationList.length} dependency API call(s) resolved from the ${parsed} parsed file(s). POSITIVE EVIDENCE ONLY: a package missing from it may still have its API called — see coverage.apiInventoryLimitations.`);
notes.push(`\`apiInvocations\` records ${invocationList.length} dependency API call(s) resolved from ${parsed} entry file(s) plus ${hopParsed} local module(s) they import directly. POSITIVE EVIDENCE ONLY: a package missing from it may still have its API called — see coverage.apiInventoryLimitations.`);
}
if (hopFailures > 0) {
notes.push(`${hopFailures} local module(s) imported by an entry file could not be parsed, so any dependency call written in them is missing from \`apiInvocations\`.`);
}

// Node-only and best-effort: the map runs in a build, but the runtime this file belongs to must stay
Expand Down Expand Up @@ -196,6 +243,7 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
filesDiscovered: stats.discovered,
filesParsed: parsed,
filesPreFiltered: preFiltered,
filesHopParsed: hopParsed,
filesSkipped: failed.length,
pathsUnwalked: stats.unwalked,
importsComplete,
Expand All @@ -213,7 +261,7 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
// every file would raise recall without removing any of these, so no parsing budget could make an
// absent invocation mean "not called".
apiInventoryLimitations: [
'only files carrying an entry-point signal are parsed, so a call in any other file is unseen',
'only files carrying an entry-point signal, and the local modules they import directly, are parsed a call two hops away is unseen',
'a computed callee or property (`client[name]()`) cannot be resolved to an API',
'a dynamic import()/require() with a non-literal specifier is not traced',
'reflection, generated code, and syntax that fails to parse are invisible',
Expand Down
79 changes: 64 additions & 15 deletions src/map/invocations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ import type { ModuleGraph } from './sinks.js';
// package guessed from another import in the same file — does not qualify at all here, because unlike a
// sink there is no second signal (an argument role, a dangerous operation) to corroborate it.
//
// A record has two halves — the package and the API NAME — and tracing the value only establishes the first.
// A name the APP chose is not part of the package's surface however faithfully the value traces: `loadConfig`
// (a local function re-exported from a helper module) and `log` (`const log = createLogger()`) both trace to
// a real dependency, and neither is an API that dependency has. Reporting one is worse than reporting
// nothing: a consumer asking "is the vulnerable function called" gets a name that appears in no advisory,
// while the call that IS in the advisory's surface goes unmentioned. So a name is reported only where it came
// from the package itself, and calls we cannot name are counted, not invented.
//
// PARTIAL BY CONSTRUCTION, and that is not a parsing budget problem. Parsing every file would raise recall,
// but dynamic property access (`client[method]()`), dynamic `import()`/`require()` with a computed
// specifier, reflection, aliases we do not follow, generated code and syntax we cannot parse all remain
Expand All @@ -40,9 +48,10 @@ export interface InvocationContext {
* local calls — precisely backwards for a metric meant to justify widening the parse.
*
* total every call/new expression seen — workload scale
* dependency traced to a package: what the inventory records
* dependency traced to a package AND nameable: exactly what the inventory records
* local the callee is a known local binding or an enclosing parameter — correctly not a dependency
* ambiguous a receiver we could not classify either way, or a computed/dynamic callee
* ambiguous a receiver we could not classify either way, a computed/dynamic callee, or a value that
* traces to a package under a name the app chose (nothing to record — see `named` below)
*
* Resolver quality is `dependency / (dependency + ambiguous)`. `local` belongs in neither term: excluding a
* local helper is a correct answer, not a miss.
Expand All @@ -54,6 +63,17 @@ export interface CallCounts {
ambiguous: number;
}

/** A value traced to a package, and how strong that trace is. */
interface Traced {
pkg: string;
specifier: string;
resolution: InvocationResolution;
/** Whether the name this value is known by here is the PACKAGE's own name for it. */
named: boolean;
/** The package's own name, when an intermediate module renamed the binding on import. */
apiName?: string;
}

/** Collect resolved dependency calls from one parsed file, with the call classification alongside. */
export function collectInvocations(
sf: any,
Expand All @@ -76,31 +96,47 @@ export function collectInvocations(
}
};

/** The package a local name traces to, and how — including one hop for a re-exported dependency value. */
const traceRoot = (root: string): { pkg: string; specifier: string; resolution: InvocationResolution } | null => {
/**
* The package a local name traces to, and how — including one hop for a re-exported dependency value.
*
* `named` answers the second question a record needs: is this name the PACKAGE's, or the app's? It is
* true only for a binding that came straight out of the package — an import/require here, or a
* pass-through re-export from a local module. A value the app derived (`const log = createLogger()`, or a
* local function whose return value happens to root in the package) carries a name the app invented, so
* `named` is false and there is no API to report even though the package is certain. `apiName` carries the
* package's own name for a pass-through, since an intermediate module may have renamed it on import.
*/
const traceRoot = (root: string): Traced | null => {
const specifier = bindings.resolve(root);
if (specifier === undefined) return null;

const direct = npmPackageOf(specifier);
if (direct !== undefined) {
// `direct` vs `factory` is already recorded by the bindings; default to direct when unknown so a
// missing origin cannot silently upgrade a weaker claim.
return { pkg: direct, specifier, resolution: bindings.originOf(root) ?? 'direct' };
const resolution = bindings.originOf(root) ?? 'direct';
return { pkg: direct, specifier, resolution, named: resolution === 'direct' };
}

// A relative specifier: the value may still be a dependency re-exported from a local module
// (`export const db = createClient(...)` in lib/db.ts — the layout generated apps actually use).
const exportName = bindings.exportNameOf(root) ?? root;
const viaModule = ctx.graph.importedPackage(ctx.file, specifier, exportName);
const viaModule = ctx.graph.importedBinding(ctx.file, specifier, exportName);
if (viaModule !== undefined) {
return { pkg: viaModule, specifier, resolution: 'reexport' };
return {
pkg: viaModule.package,
specifier,
resolution: 'reexport',
named: viaModule.origin === 'direct',
apiName: viaModule.name,
};
}

return null;
};

const push = (
traced: { pkg: string; specifier: string; resolution: InvocationResolution },
traced: Traced,
api: string,
receiver: string | undefined,
kind: ApiInvocation['kind'],
Expand Down Expand Up @@ -131,10 +167,19 @@ export function collectInvocations(
if (ts.isIdentifier(callee)) {
// `merge(a, b)` / `new Pool()` — the callee itself is the imported binding.
const traced = traceRoot(callee.text);
if (traced) {
const api = bindings.exportNameOf(callee.text) ?? callee.text;
if (traced?.named) {
// The package's own name for the binding: from the target module for a pass-through re-export,
// otherwise this file's (`import { saveOrder as write }` → `saveOrder`).
const api = traced.apiName ?? bindings.exportNameOf(callee.text) ?? callee.text;
push(traced, api, undefined, ts.isNewExpression(node) ? 'construct' : 'call', node);
counts.dependency++;
} else if (traced) {
// Traced to a package, under a name the app chose — `loadConfig()` from a helper module, or a
// factory result called directly. The only name available is one the package does not export, so
// there is nothing to record. Counted AMBIGUOUS rather than local or dependency: we failed to
// name an API here, and filing that as either a correct exclusion or a recorded call would hide
// the miss. (The real call is recorded where it is written — in the module that makes it.)
counts.ambiguous++;
} else if (isLocalName(callee.text, node)) {
counts.local++;
} else {
Expand All @@ -147,11 +192,15 @@ export function collectInvocations(
const traced = root !== undefined ? traceRoot(root) : null;
if (traced && root !== undefined) {
counts.dependency++;
// A receiver name is only reported when the binding came STRAIGHT from the package. Anything
// else is the app's own name for a value: `pool` re-exported from `./lib`, or `Student` returned
// by `sequelize.define()`. Reporting those as part of the package's API would be inventing an
// API name — and the first version of this did exactly that, calling pg's method `pool.query`.
const receiver = traced.resolution === 'direct' ? bindings.exportNameOf(root) ?? root : undefined;
// A receiver name is only reported when the package itself supplies that name — a NAMED member
// binding (`import { promises as fsp } from 'node:fs'` → `promises`). Everything else is the
// app's own word for a value: `pool` re-exported from `./lib`, `Student` returned by
// `sequelize.define()`, and — the quiet one — `JSON5` in `const JSON5 = require('json5')`, where
// the module object's local name is pure convention. Reporting any of them makes `symbol` a
// string no advisory contains: `JSON5.parse` where the advisory says `parse`.
const receiver = traced.resolution === 'direct' && bindings.isPackageMember(root)
? bindings.exportNameOf(root) ?? root
: undefined;
push(traced, callee.name.text, receiver, 'member', node);
} else if (root !== undefined && ts.isIdentifier(callee.expression) && isLocalName(root, node)) {
// A method called DIRECTLY on a local binding or a handler parameter — `res.json()`,
Expand Down
Loading
Loading