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
14 changes: 13 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,8 +231,20 @@ async function runMap(args: ParsedArgs): Promise<number> {
`patchstack: ${c.filesDiscovered} file(s) found — ${c.filesParsed} analysed, ` +
`${c.filesPreFiltered} skipped (no server entry point)` +
(c.filesSkipped ? `, ${c.filesSkipped} could not be analysed` : '') +
`. DETECTED surface only — static analysis is best-effort; unproven pairs are marked "heuristic".`,
`. DETECTED surface only — static analysis is best-effort; every flow carries the tier it was ` +
`established at ("exact-local" and "transformed-local" are proven; "imported", "heuristic" and ` +
`"unknown" are not).`,
);
const imported = map.imports ?? [];
if (imported.length > 0) {
// The unmodelled count is the honest headline: it is how much of the dependency surface this map
// cannot speak to at all, and a reader who only sees flows would never learn it.
const unmodelled = imported.filter((d) => d.recognizedSinkKinds.length === 0).length;
console.error(
`patchstack: ${imported.length} package(s) imported — ${unmodelled} with no recognized sink family, ` +
`so a vulnerability in those cannot be judged reachable or unreachable from this map.`,
);
}
const json = JSON.stringify(map, null, 2);
const out = getStringFlag(args.flags, 'out');
if (out) {
Expand Down
35 changes: 32 additions & 3 deletions src/map/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { collectLocalSinks } from './sinks.js';
import { createModuleGraph } from './module-graph.js';
import { isProvenFlow } from './coordinates.js';
import { extractFromFile } from './entries.js';
import { collectFileImports, createImportInventory, readPathAliases, scanFileImports } from './imports.js';

// Framework-AGNOSTIC input-flow extractor. It doesn't gate on a specific stack — it walks any JS/TS
// source and applies recognizer tables for (1) entry points, (2) inputs, (3) sinks, so it generalizes
Expand All @@ -36,21 +37,33 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
try { boundary = realpathSync(cwd); } catch { /* use cwd as-is */ }

const graph = createModuleGraph(ts, { cwd, boundary, followOutside: options.followSymlinks }); // shared cache
const stats: WalkStats = { discovered: 0 };
const stats: WalkStats = { discovered: 0, unwalked: 0 };
const files = collectSources(cwd, boundary, { followOutside: options.followSymlinks }, [], new Set(), stats);
const imports = createImportInventory(readPathAliases(cwd));
let parsed = 0;
let preFiltered = 0;
let importScanFailures = 0;

for (const file of files) {
try {
const text = readFileSync(file, 'utf8');
if (!hasEntrySignal(text)) { preFiltered++; continue; }
const relFile = relative(cwd, file);
// Imports are collected from EVERY file, entry point or not: the data layer of an AI-built app
// usually lives in a file with no handler in it, so a pre-filtered file is exactly where the
// interesting dependency is imported.
if (!hasEntrySignal(text)) {
preFiltered++;
const scanned = scanFileImports(text, ts);
if (scanned === null) importScanFailures++; // this file's imports are unknown, not empty
else imports.add(relFile, scanned, false);
continue;
}
parsed++;
// 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);
const relFile = relative(cwd, file);
imports.add(relFile, collectFileImports(sf, ts), true);
const ctx = { file, owner: relFile, graph };
// The ctx reaches helper summaries too, so a same-file helper using an imported client resolves.
const localSinks = collectLocalSinks(sf, ts, bindings, ctx);
Expand Down Expand Up @@ -98,16 +111,32 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
}
if (endpoints.length === 0) notes.push('No recognized server-side entry points found under the analyzed roots.');

const importList = imports.list();
const unmodelled = importList.filter((d) => d.recognizedSinkKinds.length === 0).length;
// A file we could not read is a file whose imports we do not know — the same unknown as a failed scan.
// An unwalked subtree is the quietest gap of the three: it produces no file, so no counter moves and
// the tree just looks smaller. It has to be part of this or the flag certifies an inventory with a
// hole in it.
const importsComplete = importScanFailures === 0 && failed.length === 0 && stats.unwalked === 0;
notes.push('`imports` lists every package the app imports, from ALL source files — not only files holding an entry point. Absence of a SINK for a package is never evidence; absence of the PACKAGE is evidence only when coverage.importsComplete is true.');
notes.push(`${unmodelled} of ${importList.length} imported package(s) have no recognized sink family (recognizedSinkKinds: []). The extractor models a small set of API families, so for those packages it cannot tell whether input reaches them: a vulnerability in one must stay "needs review" and can never be closed as unreachable using this map.`);
if (!importsComplete) {
notes.push(`The import inventory is INCOMPLETE: ${failed.length} file(s) could not be read, ${importScanFailures} could not be scanned, and ${stats.unwalked} path(s) could not be walked at all (unreadable directory, broken link, or a symlink leaving the project). A package may therefore be imported without appearing in \`imports\`. Do not read a package's absence as "not imported" while coverage.importsComplete is false.`);
}

return {
version: 3,
framework: detectFramework(cwd),
endpoints,
imports: importList,
coverage: {
adapter: 'agnostic-v1',
filesDiscovered: stats.discovered,
filesParsed: parsed,
filesPreFiltered: preFiltered,
filesSkipped: failed.length,
pathsUnwalked: stats.unwalked,
importsComplete,
roots: ['.'],
notes,
},
Expand Down
256 changes: 256 additions & 0 deletions src/map/imports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { ImportedPackage, ImportSite, TsModule } from './types.js';
import { npmPackageOf } from './bindings.js';
import { recognizedSinkKinds } from './sinks.js';

// The app's IMPORT inventory — "which packages does this code pull in", answered for every source file,
// not just the ones that hold an entry point.
//
// Why it is separate from sink collection: a sink is only recorded for the few API families the extractor
// models, and only inside a recognized handler. That makes sinks the wrong instrument for the question a
// vulnerability correlator actually asks — "does this app use package P at all?" — because P may be used
// heavily through an API we have no recognizer for, or from a file with no entry point in it. Answering
// that question from the sink list yields a confident "no", which is the worst possible wrong answer:
// it closes a real vulnerability as unreachable. So imports are collected on their own terms.
//
// Two fidelities, deliberately. Files with an entry-point signal are fully parsed anyway, so their imports
// come from the AST (specifiers AND bound names). The rest — most of a project — are only scanned for
// module specifiers, which is far cheaper than building a syntax tree for every file and is the half that
// matters for correlation. The mixed fidelity is reported per package as `namesComplete` rather than
// smoothed over.

/** A `compilerOptions.paths` entry: `"@/*"` is a prefix, `"foo"` matches only the specifier `foo`. */
export interface PathAlias {
prefix: string;
wildcard: boolean;
}

/** One import edge as found in a single file. */
export interface RawImport {
/** Module specifier exactly as written (`node:fs`, `lodash/merge`, `./db`). */
specifier: string;
/** Bound names: real named bindings, plus the markers `default`, `*`, `require`, `import()`. */
names: string[];
/** 1-based line of the import. */
line?: number;
}

/** Cap per package: enough to point a reviewer at the usage, bounded so a big app can't bloat the map. */
const MAX_SITES = 5;

/**
* A bare specifier that is a legal npm package name. Bare does NOT mean "package": every AI-built app
* configures a path alias (`@/components` → `./src/components`), and those are the app's OWN code. Letting
* them into the inventory is not cosmetic — each one lands in the "no recognized sink family" bucket and
* inflates the count a reviewer reads as unanalysable dependencies.
*/
const NPM_NAME = /^(?:@[^/@\s~][^/@\s]*\/)?[^/@\s.~][^/@\s]*$/;
function isNpmPackageName(pkg: string): boolean {
return pkg.startsWith('node:') || NPM_NAME.test(pkg);
}

/**
* Path-alias prefixes declared in `tsconfig.json` / `jsconfig.json` (`compilerOptions.paths`). Best-effort
* and deliberately shallow: `extends` chains and bundler-config aliases (a `vite.config.ts` `resolve.alias`
* is code, not data) are not followed, so the name check above remains the backstop. Reads tolerantly —
* these files routinely carry comments and trailing commas, which `JSON.parse` rejects.
*/
export function readPathAliases(cwd: string): PathAlias[] {
const aliases: PathAlias[] = [];
for (const name of ['tsconfig.json', 'jsconfig.json']) {
let paths: Record<string, unknown> | undefined;
try {
const raw = readFileSync(join(cwd, name), 'utf8');
const parsed = JSON.parse(stripJsonComments(raw)) as { compilerOptions?: { paths?: Record<string, unknown> } };
paths = parsed.compilerOptions?.paths;
} catch {
continue; // absent or unparseable: fall back to the name check
}
for (const key of Object.keys(paths ?? {})) {
if (key.startsWith('.')) continue; // a relative alias is already excluded as a non-package
// The wildcard has to be carried, not flattened into a prefix: an alias `"foo"` covers the
// specifier `foo` and nothing else, so prefix-matching it would also swallow the real npm
// package `foobar`. Only `"foo/*"` is a prefix.
if (key.endsWith('*')) aliases.push({ prefix: key.slice(0, -1), wildcard: true });
else aliases.push({ prefix: key, wildcard: false });
}
}
return aliases;
}

function stripJsonComments(text: string): string {
let out = '';
let inString = false;
let quote = '';
for (let i = 0; i < text.length; i++) {
const c = text[i];
const next = text[i + 1];
if (inString) {
out += c;
if (c === '\\') { out += next ?? ''; i++; continue; }
if (c === quote) inString = false;
continue;
}
if (c === '"' || c === "'") { inString = true; quote = c; out += c; continue; }
if (c === '/' && next === '/') { while (i < text.length && text[i] !== '\n') i++; out += '\n'; continue; }
if (c === '/' && next === '*') { i += 2; while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++; i++; continue; }
out += c;
}
return out.replace(/,(\s*[}\]])/g, '$1'); // trailing commas
}

/**
* Import edges from a parsed source file: `import`/`export … from`, `require(…)` and dynamic `import(…)`.
* Relative specifiers are included here and filtered later — the caller decides what counts as a package,
* and keeping the raw edge makes this function reusable and easy to test.
*/
export function collectFileImports(sf: any, ts: TsModule): RawImport[] {
const found: RawImport[] = [];
const lineOf = (node: any): number | undefined => {
try {
return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
} catch {
return undefined;
}
};

const visit = (node: any) => {
// import x, { a as b }, * as ns from 'mod' / import 'mod'
if (ts.isImportDeclaration(node) && ts.isStringLiteralLike(node.moduleSpecifier)) {
const names: string[] = [];
const clause = node.importClause;
if (clause?.name) names.push('default');
const nb = clause?.namedBindings;
if (nb) {
if (ts.isNamespaceImport(nb)) names.push('*');
else if (ts.isNamedImports(nb)) {
// The EXPORTED name is what an advisory names; `import { merge as m }` is still `merge`.
for (const el of nb.elements) names.push((el.propertyName ?? el.name).text);
}
}
found.push({ specifier: node.moduleSpecifier.text, names, line: lineOf(node) });
}
// export { a } from 'mod' — an import edge that re-exports; the package is still pulled in.
if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) {
const names: string[] = [];
const ec = node.exportClause;
if (ec && ts.isNamedExports(ec)) for (const el of ec.elements) names.push((el.propertyName ?? el.name).text);
else if (ec && ts.isNamespaceExport(ec)) names.push('*');
else names.push('*'); // export * from 'mod'
found.push({ specifier: node.moduleSpecifier.text, names, line: lineOf(node) });
}
// require('mod') and import('mod') — the call form carries no binding info at the call site, so the
// marker name records HOW it was imported instead of inventing a binding.
if (ts.isCallExpression(node)) {
const arg = node.arguments[0];
const isRequire = ts.isIdentifier(node.expression) && node.expression.text === 'require';
const isDynamic = node.expression.kind === ts.SyntaxKind.ImportKeyword;
if ((isRequire || isDynamic) && arg && ts.isStringLiteralLike(arg)) {
found.push({ specifier: arg.text, names: [isRequire ? 'require' : 'import()'], line: lineOf(node) });
}
}
ts.forEachChild(node, visit);
};
visit(sf);
return found;
}

/**
* Module specifiers from raw text, without building a syntax tree — TypeScript's own pre-processor, the
* same scan the compiler uses to discover a file's dependencies. Token-accurate (a specifier inside a
* comment or string is not reported), and cheap enough to run over every file in a project.
* Names are not recoverable this way; callers mark the result as name-incomplete.
*
* Returns **null** when the scan itself failed. That is deliberately distinct from an empty array: a file
* we could not scan is not a file that imports nothing, and collapsing the two is what would let a server
* conclude "package absent" from a gap in our own analysis.
*/
export function scanFileImports(text: string, ts: TsModule): RawImport[] | null {
let refs: Array<{ fileName: string; pos: number }>;
try {
const pre = ts.preProcessFile(text, /* readImportFiles */ true, /* detectJavaScriptImports */ true);
refs = pre.importedFiles ?? [];
} catch {
return null; // fail-open for the map, but the caller must record that the inventory is now partial
}
if (refs.length === 0) return [];
const lineStarts = lineStartOffsets(text);
return refs.map((r) => ({ specifier: r.fileName, names: [], line: lineAt(lineStarts, r.pos) }));
}

/**
* Aggregates per-file import edges into the per-package inventory. Order-independent: the output is
* sorted, so two runs over the same tree produce byte-identical documents.
*/
export function createImportInventory(aliases: PathAlias[] = []) {
interface Acc {
specifiers: Set<string>;
names: Set<string>;
namesComplete: boolean;
sites: ImportSite[];
siteCount: number;
}
const byPackage = new Map<string, Acc>();

return {
/** @param parsed whether these edges came from a full parse (names are trustworthy) or the scan. */
add(relFile: string, edges: RawImport[], parsed: boolean): void {
for (const edge of edges) {
const aliased = aliases.some((a) => (a.wildcard ? edge.specifier.startsWith(a.prefix) : edge.specifier === a.prefix));
if (aliased) continue;
const pkg = npmPackageOf(edge.specifier);
if (!pkg) continue; // relative/absolute path: app code, not a dependency
if (!isNpmPackageName(pkg)) continue; // an unaliased-but-bare path (`@/x`, `~/lib`)
let acc = byPackage.get(pkg);
if (!acc) {
acc = { specifiers: new Set(), names: new Set(), namesComplete: true, sites: [], siteCount: 0 };
byPackage.set(pkg, acc);
}
acc.specifiers.add(edge.specifier);
for (const n of edge.names) acc.names.add(n);
// One unparsed site makes the whole package's name set a subset — say so rather than imply
// completeness from the names that happen to be present.
if (!parsed) acc.namesComplete = false;
acc.siteCount++;
if (acc.sites.length < MAX_SITES) acc.sites.push({ file: relFile, line: edge.line });
}
},

list(): ImportedPackage[] {
return [...byPackage.entries()]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([pkg, acc]) => {
const names = [...acc.names].sort();
const entry: ImportedPackage = {
package: pkg,
specifiers: [...acc.specifiers].sort(),
namesComplete: acc.namesComplete,
sites: acc.sites,
siteCount: acc.siteCount,
recognizedSinkKinds: recognizedSinkKinds(pkg),
};
if (names.length > 0) entry.names = names;
return entry;
});
},
};
}

function lineStartOffsets(text: string): number[] {
const starts = [0];
for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) starts.push(i + 1);
return starts;
}

/** 1-based line containing `pos`, by binary search over the line starts. */
function lineAt(starts: number[], pos: number): number {
let lo = 0;
let hi = starts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if ((starts[mid] ?? 0) <= pos) lo = mid;
else hi = mid - 1;
}
return lo + 1;
}
19 changes: 18 additions & 1 deletion src/map/sinks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createHash } from 'node:crypto';
import type { ArgumentRole, CandidateFamily, Sink, TsModule } from './types.js';
import type { ArgumentRole, CandidateFamily, Sink, SinkKind, TsModule } from './types.js';
import {
isFnLike,
isShadowedByEnclosingBinding,
Expand Down Expand Up @@ -44,6 +44,23 @@ const isFsPackage = (pkg: string) => /^node:fs(\/promises)?$/.test(pkg) || FS_PA
const EXEC_PACKAGES = ['execa', 'cross-spawn', 'shelljs', 'zx'];
const isExecPackage = (pkg: string) => pkg === 'node:child_process' || EXEC_PACKAGES.includes(pkg);

/**
* Which sink families this package can produce, per the recognizer tables above — i.e. what the map is
* even *able* to see about it. Empty for the vast majority of npm: a package with no recognizer can still
* be imported and called, we just have no model of its API, so no flow into it will ever be reported.
* Exposed so the import inventory can say that out loud rather than let a consumer read "no sink" as
* "not reachable". Takes a package ROOT (subpaths already normalized away by `npmPackageOf`), except for
* `node:fs/promises`, whose subpath is part of the builtin's identity.
*/
export function recognizedSinkKinds(pkg: string): SinkKind[] {
const kinds: SinkKind[] = [];
if (isDbPackage(pkg)) kinds.push('db');
if (isFsPackage(pkg)) kinds.push('fs');
if (isExecPackage(pkg)) kinds.push('exec');
if (isHttpPackage(pkg)) kinds.push('http');
return kinds;
}

export interface ModuleGraph {
/** Sinks of `exportName` in the module `specifier` resolves to, relative to `fromFile`. */
importedSinks(fromFile: string, specifier: string, exportName: string): Sink[];
Expand Down
Loading
Loading