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
13 changes: 7 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
} from './guide.js';
import { runProtect, runVerify } from './protect/install/index.js';
import { buildInputMap } from './map/index.js';
import { isProvenFlow } from './map/coordinates.js';
import { setupProtection, wireBuildScripts } from './setup.js';
import { detectStack, type StackDescriptor } from './stack.js';
import { PatchstackError } from './types.js';
Expand All @@ -59,8 +60,8 @@ Usage:
Never runs the project build
patchstack-connect map [--dir <p>] [--out <f>] Map the app's attack surface: entry points, the
inputs each reads, the sinks it can reach, and
evidence-backed input→sink flows (each marked
precise or heuristic). Best-effort static
evidence-backed input→sink flows (each labelled
with how the link was established). Best-effort static
analysis — reports the DETECTED surface, with
coverage counters. Prints JSON (--out writes a
file; --follow-symlinks leaves the project dir).
Expand Down Expand Up @@ -211,15 +212,15 @@ async function runMap(args: ParsedArgs): Promise<number> {
console.error(`patchstack: ${error}`);
return 1;
}
// Human summary → stderr; the JSON → stdout (so it can be piped / written). Report PRECISE flows
// separately from the inventories: only a precise flow is evidence that an input reaches a sink.
// Human summary → stderr; the JSON → stdout (so it can be piped / written). Report PROVEN flows
// separately from the inventories: only a proven tier is evidence that an input reaches a sink.
const inputs = map.endpoints.reduce((n, e) => n + e.inputs.length, 0);
const sinks = map.endpoints.reduce((n, e) => n + e.sinks.length, 0);
const precise = map.endpoints.reduce((n, e) => n + e.flows.filter((f) => f.confidence === 'precise').length, 0);
const proven = map.endpoints.reduce((n, e) => n + e.flows.filter((f) => isProvenFlow(f.confidence)).length, 0);
const c = map.coverage;
console.error(
`patchstack: ${map.endpoints.length} entry point(s), ${inputs} input(s), ${sinks} sink(s), ` +
`${precise} proven input→sink flow(s) [${map.framework}].`,
`${proven} proven input→sink flow(s) [${map.framework}].`,
);
console.error(
// All three buckets, explicitly: "6/66 parsed" reads as "91% unanalysed" when the other 60 files
Expand Down
47 changes: 43 additions & 4 deletions src/map/coordinates.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { InputField, InputSource } from './types.js';
import type { AddressSpace, FieldShape, InputField, InputSource } from './types.js';

/**
* Map an input to the EXACT rule-engine parameter that addresses it, or null with a reason. Verified
Expand Down Expand Up @@ -40,6 +40,42 @@ export function runtimeCoordinate(source: InputSource | undefined, path: string)
}
}

/**
* The request region an input lives in — its identity, independent of whether we can currently ADDRESS
* it. A route param has a space (`route-param`) but no coordinate; an array path has a space (`post`)
* but needs an `array_key_value` rule. Keep those two questions apart: conflating them made an
* unaddressable input indistinguishable from one in another region.
*/
export function addressSpaceOf(source: InputSource | undefined): AddressSpace {
switch (source) {
case 'json-body':
case 'form-body':
case 'multipart':
case 'body':
case 'server-fn-data':
return 'post';
case 'query': return 'get';
case 'cookie': return 'cookie';
case 'file': return 'files';
case 'header': return 'server';
case 'route-param': return 'route-param';
default: return 'unknown';
}
}

/**
* Is this flow backed by a read seen at the sink's own call site? True for the two `*-local` tiers.
* `imported` / `heuristic` / `unknown` all mean "we did not see the argument", for different reasons.
*/
export function isProvenFlow(confidence: string | undefined): boolean {
return confidence === 'exact-local' || confidence === 'transformed-local';
}

/** `<space>:<path>` — an input's identity within an endpoint. */
export function inputIdOf(source: InputSource | undefined, path: string): string {
return `${addressSpaceOf(source)}:${path}`;
}

/**
* The rule-engine NAMESPACE an input lands in (`post`, `get`, `cookie`, `files`, `server`), or null when
* it has no address. Derived from `runtimeCoordinate` on purpose: comparing raw source labels would call
Expand All @@ -53,7 +89,10 @@ export function namespaceOf(source: InputSource | undefined, path: string): stri
return dot === -1 ? runtimeParameter : runtimeParameter.slice(0, dot);
}

/** Attach `source` + the runtime coordinate to every extracted input. */
export function withCoordinates(fields: InputField[], source: InputSource): InputField[] {
return fields.map((f) => ({ ...f, source: f.source ?? source, ...runtimeCoordinate(f.source ?? source, f.name) }));
/** Place extracted fields in a request region: attach `source`, the runtime coordinate, and the id. */
export function withCoordinates(fields: FieldShape[], source: InputSource): InputField[] {
return fields.map((f) => {
const src = f.source ?? source;
return { ...f, source: src, id: inputIdOf(src, f.name), ...runtimeCoordinate(src, f.name) };
});
}
11 changes: 6 additions & 5 deletions src/map/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { collectSources, detectFramework, hasEntrySignal, type WalkStats } from
import { functionNameFromPath, routeFromFilePath } from './routes.js';
import { collectLocalSinks } from './sinks.js';
import { createModuleGraph } from './module-graph.js';
import { isProvenFlow } from './coordinates.js';
import { extractFromFile } from './entries.js';

// Framework-AGNOSTIC input-flow extractor. It doesn't gate on a specific stack — it walks any JS/TS
Expand Down Expand Up @@ -77,9 +78,9 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
}

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 — prefer flows with confidence "precise" when pinning a rule to a parameter.');
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.');
notes.push('A sink `package` is resolved from the file’s imports (precise) or inferred from a known provider import; an unresolved package means the backing dependency could not be traced.');
notes.push('A sink `package` is resolved from the file’s imports (`attribution: "import"`) or inferred from another import in the same file (`"inferred"`); an inferred package is a hint for a reviewer, not evidence about the receiver, and never licenses a rule.');
if (!options.followSymlinks) notes.push('Symlinks leaving the project directory were not followed (use --follow-symlinks to include them).');
if (failed.length > 0) {
const sample = failed.slice(0, 5).join(', ');
Expand All @@ -89,14 +90,14 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
if (unresolved > 0) {
notes.push(`${unresolved} endpoint(s) declare an input validator that could not be statically parsed — their inputs are UNKNOWN, not empty (marked inputsResolved: false).`);
}
const heuristicOnly = endpoints.filter((e) => e.sinks.length > 0 && e.inputs.length > 0 && !e.flows.some((f) => f.confidence === 'precise')).length;
const heuristicOnly = endpoints.filter((e) => e.sinks.length > 0 && e.inputs.length > 0 && !e.flows.some((f) => isProvenFlow(f.confidence))).length;
if (heuristicOnly > 0) {
notes.push(`${heuristicOnly} endpoint(s) have inputs and sinks but no PRECISE data link — their flows are "heuristic" (may reach), not proven.`);
notes.push(`${heuristicOnly} endpoint(s) have inputs and sinks but no proven data link — their flows say "may reach", not "does reach" (see each flow's confidence).`);
}
if (endpoints.length === 0) notes.push('No recognized server-side entry points found under the analyzed roots.');

return {
version: 2,
version: 3,
framework: detectFramework(cwd),
endpoints,
coverage: {
Expand Down
Loading
Loading