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
28 changes: 25 additions & 3 deletions src/map/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +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';
import { collectFileImports, countUnresolvableImports, createImportInventory, readPathAliases, scanFileImports } from './imports.js';
import { collectInvocations, createInvocationInventory } from './invocations.js';

// Framework-AGNOSTIC input-flow extractor. It doesn't gate on a specific stack — it walks any JS/TS
Expand Down Expand Up @@ -45,6 +45,7 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
let parsed = 0;
let preFiltered = 0;
let importScanFailures = 0;
let unresolvableImports = 0;
let sourceBytes = 0;
const calls = { total: 0, dependency: 0, local: 0, ambiguous: 0 };
const startedAt = Date.now();
Expand All @@ -62,6 +63,7 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
const scanned = scanFileImports(text, ts);
if (scanned === null) importScanFailures++; // this file's imports are unknown, not empty
else imports.add(relFile, scanned, false);
unresolvableImports += countUnresolvableImports(text, ts);
continue;
}
parsed++;
Expand All @@ -70,6 +72,10 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, guessScriptKind(ts, file));
const bindings = buildModuleBindings(sf, ts);
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
// one in a pre-filtered file — and this is the path where it is easiest to assume otherwise.
unresolvableImports += countUnresolvableImports(text, ts);
const ctx = { file, owner: relFile, graph };
// The ctx reaches helper summaries too, so a same-file helper using an imported client resolves.
// The invocation inventory rides the parse we already did for sinks — no second pass, which is what
Expand Down Expand Up @@ -131,12 +137,27 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
// 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;
// Two kinds of gap, one flag. ENVIRONMENTAL gaps (unread, unscanned, unwalked) might close on a
// re-run with different permissions; an INHERENT gap — a specifier computed at runtime — never will,
// because no static pass can resolve it. Both make the inventory incomplete, so both clear the flag;
// they are reported apart so a reviewer can tell "try again" from "this app cannot be answered
// statically" instead of re-running against a permanent property of the source.
const importCoverageGaps = {
unreadableFiles: failed.length,
unscannableFiles: importScanFailures,
unwalkedPaths: stats.unwalked,
unresolvableImports,
};
const environmentalGaps = failed.length + importScanFailures + stats.unwalked;
const importsComplete = environmentalGaps === 0 && unresolvableImports === 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) {
if (environmentalGaps > 0) {
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.`);
}
if (unresolvableImports > 0) {
notes.push(`The import inventory is INCOMPLETE for a reason no re-run can fix: ${unresolvableImports} import(s) do not name a resolvable module — either the specifier is computed at runtime (\`require(REGISTRY[kind])\`) or the loader itself was aliased (\`const r = require\`), so which package is loaded is not knowable from the source. Those imports appear NOWHERE in \`imports\` — not as an unresolved entry, simply absent — so a package's absence is not evidence of it being unused. This is a property of the application's code, not a scan failure. Reported conservatively: an aliased loader counts even if every call through it uses a literal.`);
}

const invocationList = invocations.list();
if (invocationList.length > 0 || parsed > 0) {
Expand Down Expand Up @@ -178,6 +199,7 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
filesSkipped: failed.length,
pathsUnwalked: stats.unwalked,
importsComplete,
importCoverageGaps,
apiInvocations: invocationList.length,
callsTotal: calls.total,
callsDependency: calls.dependency,
Expand Down
163 changes: 163 additions & 0 deletions src/map/imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,169 @@ export function scanFileImports(text: string, ts: TsModule): RawImport[] | null
return refs.map((r) => ({ specifier: r.fileName, names: [], line: lineAt(lineStarts, r.pos) }));
}

/**
* Count imports whose module cannot be determined statically. Two shapes, one number — both feed
* `coverage.importCoverageGaps.unresolvableImports`:
*
* 1. a COMPUTED specifier — `require(REGISTRY[kind])`, `import(name)`, `require("a" + b)`
* 2. an ESCAPED loader — `const r = require`, `(require)(x)`, `module.exports = require`
*
* The second is reported at the escape rather than at the later call: once the loader is behind another
* name, what it loads is unknowable here whether or not the argument is a literal.
*
* `scanFileImports` cannot see these. It reads `ts.preProcessFile(...).importedFiles`, which reports
* resolved literal specifiers only — a computed specifier produces no entry at all, so the import is
* not merely unrecorded, it is invisible. That matters because the inventory's contract says a
* package's ABSENCE is evidence when `importsComplete` is true, and `require(REGISTRY[kind])` makes
* absence unprovable while leaving every read-and-scan check satisfied.
*
* Tokenised rather than parsed: this runs on files deliberately kept out of the AST pass, so it must
* stay at scanner cost. Tokenising (rather than a regex) is what keeps `// require(x)` in a comment
* and `"require(y)"` in a string from counting — a text match would clear the completeness flag on
* prose about `require`, which trades a false negative for a permanent false alarm.
*/
export function countUnresolvableImports(text: string, ts: TsModule): number {
let scanner: any;
try {
scanner = ts.createScanner(ts.ScriptTarget.Latest, /* skipTrivia */ true, undefined, text);
} catch {
return 0; // fail-open: the caller already treats an unscannable file as incomplete
}

// `require` does NOT arrive as an identifier: TypeScript scans it as the contextual `RequireKeyword`
// (it appears in `import x = require(…)`). Keying on the token TEXT with a kind allowlist covers both
// spellings, and survives a TS version that classifies it differently — which is worth the small
// looseness here, because the failure mode of a missed kind is a silent zero count, i.e. exactly the
// false "complete" this function exists to prevent.
const CALLEE_KINDS = new Set<number>([
ts.SyntaxKind.Identifier,
ts.SyntaxKind.RequireKeyword,
ts.SyntaxKind.ImportKeyword,
].filter((k) => k !== undefined) as number[]);

// Three tokens of history, because one is not enough to identify the callee. The token that decides
// whether this is CommonJS `require` sits on a different side of the name in each form:
//
// require(x) callee is the previous token
// require?.(x) `?.` sits BETWEEN the name and `(` — the callee is two back
// loader.require(x) an app method that merely shares the name — `.` sits BEFORE the callee
// loader.require?.(x) both at once, so the qualifier is three back
//
// Getting either direction wrong is a real failure, and they fail in opposite ways: missing the
// optional form leaves `importsComplete` true over an unresolvable import, while counting a member
// method makes it permanently false for an app that named a method `require` — which trains a reader
// to ignore the field. Hence a qualifier check rather than a looser name match.
const DOT_KINDS = new Set<number>([ts.SyntaxKind.DotToken, ts.SyntaxKind.QuestionDotToken]
.filter((k) => k !== undefined) as number[]);

interface Token { kind: number | undefined; text: string }
const EMPTY: Token = { kind: undefined, text: '' };
// Most recent first: history[0] is the token before the current one.
const history: [Token, Token, Token] = [EMPTY, EMPTY, EMPTY];
const remember = (kind: number, text: string): void => {
history[2] = history[1];
history[1] = history[0];
history[0] = { kind, text };
};

/**
* Did the token just before `next` leave a bare `require` unresolvable?
*
* Deliberately asked about `require` only. `import` is a keyword that legitimately appears without a
* following paren in every ESM file (`import qs from "qs"`, `import.meta.url`), so the same rule there
* would report a gap for ordinary static imports — the exact always-false flag the member-method guard
* exists to prevent.
*/
const escapedLoaderReference = (next: number): boolean => {
const callee = history[0];
const qualifier = history[1];

if (callee.kind === undefined || !CALLEE_KINDS.has(callee.kind) || callee.text !== 'require') return false;
// Someone's method named `require` — not the loader.
if (qualifier.kind !== undefined && DOT_KINDS.has(qualifier.kind)) return false;

// Called in place (`require(…)` / `require?.(…)`) is the resolvable form the rest of this function
// judges, and `require.resolve` / `require.cache` reach a property of the loader without loading a
// module at all. Anything else means the loader itself became a value.
return next !== ts.SyntaxKind.OpenParenToken
&& next !== ts.SyntaxKind.QuestionDotToken
&& next !== ts.SyntaxKind.DotToken;
};

let count = 0;

try {
for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) {
const text = scanner.getTokenText();

// `require` used as a VALUE rather than called in place: `const r = require`, `(require)(x)`,
// `module.exports = require`. Once the loader is behind another name, resolving what it loads
// needs dataflow this scan does not do — so the honest answer is that the inventory has a gap,
// and the conservative one is the same answer. Counted at the ESCAPE, not at the later call:
// whatever `r(…)` loads is unknowable from here, literal argument or not.
if (escapedLoaderReference(token)) count++;

if (token !== ts.SyntaxKind.OpenParenToken) {
remember(token, text);
continue;
}

// `?.` immediately before the paren shifts both the callee and its qualifier one place back.
const optionalCall = history[0].kind === ts.SyntaxKind.QuestionDotToken;
const callee = optionalCall ? history[1] : history[0];
const qualifier = optionalCall ? history[2] : history[1];

const isRequireCall = callee.kind !== undefined
&& CALLEE_KINDS.has(callee.kind)
&& (callee.text === 'require' || callee.text === 'import')
// A dot before the name means it is a property of something else, so this is that object's
// method and says nothing about module loading.
&& !(qualifier.kind !== undefined && DOT_KINDS.has(qualifier.kind));

if (!isRequireCall) {
remember(token, text);
continue;
}

remember(token, text);

const argument = scanner.scan();
const argumentText = scanner.getTokenText();
remember(argument, argumentText);

const isLiteral = argument === ts.SyntaxKind.StringLiteral
|| argument === ts.SyntaxKind.NoSubstitutionTemplateLiteral;

if (!isLiteral) {
// A template WITH substitutions (`require(\`./\${name}\`)`) lands here too, correctly: its
// value is not knowable statically either.
count++;
continue;
}

// Starting with a literal is not the same as BEING one: `require("node-" + pkg)` opens with a
// string and is still computed. The literal is the whole specifier only if the argument ends
// right here — a `)`, or a `,` for `import("./m", { with: … })`, where the literal is complete
// and only import attributes follow. Anything else continues the expression.
const following = scanner.scan();
remember(following, scanner.getTokenText());

const argumentEnded = following === ts.SyntaxKind.CloseParenToken
|| following === ts.SyntaxKind.CommaToken;

if (!argumentEnded) count++;
}

// The loop exits on EOF without processing it, so a file ENDING in a bare `require`
// (`module.exports = require`) would otherwise escape unnoticed.
if (escapedLoaderReference(ts.SyntaxKind.EndOfFileToken)) count++;
} catch {
return count; // whatever was counted before the scanner gave up still clears the flag
}

return count;
}

/**
* 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.
Expand Down
28 changes: 28 additions & 0 deletions src/map/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,34 @@ export interface Coverage {
* true, not merely that it isn't false.
*/
importsComplete?: boolean;
/**
* WHY the inventory is incomplete, for a reader deciding what to do about it. Diagnostic only:
* `importsComplete` remains the single gate a consumer reads, and any non-zero count here makes it
* false — so this can be ignored entirely without ever licensing a wrong negative.
*
* The split that matters is durability. The first three are environmental: a permission, a broken
* link, a symlink out of the project — a re-run in a different context may resolve them.
* `unresolvableImports` is inherent: the application does not name the module in a way any static pass
* can resolve. Collapsed into one number, a reviewer re-runs the scan against a permanent property of
* the source and reads the identical result as a flake.
*/
importCoverageGaps?: {
/** Files present but unreadable. */
unreadableFiles: number;
/** Files read but whose imports could not be scanned. */
unscannableFiles: number;
/** Paths that produced no file at all — the quietest gap, since no per-file counter moves. */
unwalkedPaths: number;
/**
* Imports whose module cannot be determined statically: a computed specifier (`require(expr)`,
* `import(expr)`) or an aliased loader (`const r = require`, `(require)(x)`).
*
* Counted conservatively — an aliased loader counts even when every call through it passes a
* literal, because following the alias needs dataflow this scan does not do. That biases toward a
* false "incomplete", which withholds a negative conclusion rather than granting a wrong one.
*/
unresolvableImports: number;
};
/** Source roots analyzed, repo-relative. */
roots: string[];
/** Honest notes on what static analysis could not resolve (dynamic dispatch, indirection, …). */
Expand Down
Loading
Loading