import type { Db } from "../db/connect.js"; import type { Actor, CapabilityUrl, CrossUserObservation, OwnershipModel, ProbeResult, SchemaSnapshot, SeedResult, SeededRow } from "../types.js"; import { type DeclaredAction, type Route } from "./routes.js"; import type { Impersonation } from "./session.js"; import { type IdentifierEntropy } from "./capability.js"; import type { ResourceOwnership } from "./plant.js"; import { type ResourceOracle } from "./oracle.js"; export interface ApiProbeOptions { baseUrl: string; /** * The schema, for the column behind each endpoint's identifier. An id off a * sequence and an id out of a CSPRNG are the same string in a URL and * completely different facts about who could have sent the request. * * Absent on a run with no database, where there is no schema to read a * column's type out of — and where, in consequence, no granted read can be * weighed as a capability URL at all. */ snapshot?: SchemaSnapshot; /** * Who this run is able to be. Established once, alongside seeding, because * becoming a user can mean writing to the database. */ identity: Impersonation; mode: "read_only" | "full"; timeoutMs?: number; /** * Used to confirm destructive probes against the database rather than * inferring them from a status code. A 200 from a DELETE proves only that * *something* was deleted — checking whether the victim's row actually * disappeared is the difference between evidence and a guess. */ db?: Db; /** * Where the facts about a planted row come from, when it is not Postgres. * * Supplying this instead of `db` is what lets the API plane run against MySQL, * Mongo, DynamoDB or a Rails app: the four questions are the same, and only * the way they are answered changes. */ oracle?: ResourceOracle; /** * Where the *ownership* premise comes from, which is a different question to * where the row facts come from and has to be answered separately. * * `schema` the default and what has always run. The ownership model was * inferred from a real schema, so a table is known to be * user-owned, org-scoped or deliberately public, and * {@link expectedFor} can say what a stranger's read should do. * `run` there is no schema. All that was established is that a request * carrying a credential *proven* to authenticate one persona * created this resource, and that the same persona can read it * back. That is enough to condemn a stranger's write or delete — * no application shares a resource for a stranger to overwrite — * and it is not enough to condemn a stranger's *read*, because a * deliberately shared document looks exactly the same. Reads are * therefore performed and reported as observations, and never as * findings — except where {@link attested} says this run * established, from the application's own answers, who the * resource belongs to. See {@link ApiProbeOutcome.observations}. */ ownership?: "schema" | "run"; /** * Resources whose owner this run established, keyed by resource id. * * The one thing that lifts a cross-user read out of the observations on a run * with no schema, and the whole of what it changes is the line below that * decides which of the two a read becomes. Everything else is unchanged and * deliberately so: the oracle is still the 128-bit marker and only when the * request did not carry it, the owner's own read still has to land before a * refusal counts as protection, and the share-link rule still runs over * everything at once. * * How an entry gets here is `establishOwnership` in `plant.ts`, which is * where every reason it can be refused is written down: the same field of * each account's own resource holding *that* account's identifier, those * identifiers wide enough that the match could not be an accident, nothing * saying the application published the resource, and a declaration honoured * only where the run can see the same thing it describes. */ attested?: Map; /** * The entropy of the identifiers this application generated, keyed * `resourceId:column`. * * The share-link rule asks whether a stranger could have obtained the * identifier. With a schema that is read off the column's type and default; * with none it is measured from the identifiers the application assigned to * the resources this run created, and handed in here. */ identifierEntropy?: Map; /** * Next.js server actions whose argument shape somebody supplied, joined to * the ids the build recorded. * * Absent, or empty, and no action is called — which is what has always * happened and stays the correct answer for an action nobody described. See * {@link probeServerActions}. */ actions?: DeclaredAction[]; /** * The value a write probe plants, when the default is not good enough. * * The seeder's canary carries 48 bits, which is ample against a table we can * read back by primary key. With no database the only oracle is the value * itself, so the run mints 128 bits from a CSPRNG instead and the caller * supplies the mint. */ mintMarker?: () => string; } /** * Re-exported from the shared types, where it moved so the report can carry one. * Every caller that imported it from here still can. */ export type { CrossUserObservation } from "../types.js"; /** * What the API phase actually managed to check. * * Route discovery is best-effort by construction — Express registration is read * out of source — so "found no routes" and "found routes that matched no table" * are both likely outcomes, and neither may read as "the API is fine". A run * invoked with `--api` that silently checked nothing through the API is worse * than one that was never given the flag, because the developer believes they * tested something. */ export interface ApiProbeOutcome { probes: ProbeResult[]; /** Routes that produced at least one settled cross-user attempt. */ settledRoutes: string[]; /** Discovered routes that produced no settled cross-user check, and why. */ unreached: { route: string; reason: string; }[]; /** Tables a settled cross-user attempt actually reached through the API. */ tablesReached: string[]; /** * Tables at least one discovered endpoint addresses, whether or not anything * it did settled. A table absent from this list is not one the API plane * failed at — it is one the application's own routes never mention. */ tablesTargeted: string[]; /** * Cross-user writes the handler answered with a success status while the * target row stayed exactly as it was. Settled, and settled as refusals — * listed separately because the caller was never told it had been refused. */ silentWrites: { route: string; actor: Actor; }[]; /** * Endpoints that answered anyone holding the row's identifier, where nothing * showed that a stranger could obtain it. Reported as their own class, never * counted as leaks. */ capabilityUrls: CapabilityUrl[]; /** * Why not one request was made as a specific signed-in user, when none could * be. The signed-in half of the suite did not run, and saying so is the whole * point: a request with no credential on it settles nothing about a logged-in * stranger, however many routes it touches. */ signedInUnavailable: string | null; /** * Cross-user reads that came back with another persona's marker on a run with * no ownership premise. Facts, printed as facts, and never findings. */ observations: CrossUserObservation[]; /** * What happened to each server action a declaration described. * * Every one lands in exactly one of these, for the same reason routes do: an * action that was called and settled nothing must not be indistinguishable * from one that came back clean. */ actionsChecked: ActionOutcome[]; } /** One declared server action, and whether calling it established anything. */ export interface ActionOutcome { id: string; exportedName: string; source: string; path: string; /** The table the resource was taken from, once resolved. Null when it was not. */ resource: string | null; settled: boolean; /** Why nothing was settled. Null when something was. */ reason: string | null; } /** * The same question, asked of the application instead of the database. * * The data plane proves whether row-level security holds. This proves whether * the API layer holds, which is a different question with a different answer: * an endpoint can leak through a hand-written query that bypasses RLS entirely, * and RLS can be missing on a table the API happens to guard properly today. * * The oracle is the same and just as exact — the response body either contains * an identifier we planted for the other user, or it does not. */ export declare function probeApiPlane(routes: Route[], seedResult: SeedResult, model: OwnershipModel, opts: ApiProbeOptions): Promise; /** * Reads before writes, over the whole declared list. * * A cross-user write lands on the column the seeder planted its marker in — * that is what makes the write detectable — so a write that ran first would * overwrite the very marker a later read is looking for, and the read would * come back clean on an application that leaks. The route loop above orders * itself the same way and for the same reason; `METHOD_ORDER` is that rule * spelled for HTTP methods, and this is it spelled for a surface where the * method is always POST and only the arguments say what will happen. * * Exported and pure because the ordering is the assertion. It cannot be shown * through the fixture: the seeder plants its canary in *every* eligible text * column, so a `documents` row with both `title` and `body` still carries one * after a write destroys the other, and inverting this order changes nothing * observable there. The table it protects is the one with a single text column, * where the marker has nowhere else to survive — so the invariant is pinned * here rather than left resting on a fixture that happens to be forgiving. */ export declare function readsBeforeWrites(declared: DeclaredAction[]): DeclaredAction[]; /** * Did this endpoint settle anything about the crossing? * * Same bar as the data plane: an attempt that failed for a reason unrelated to * access control proves nothing, and a route made only of those is an unchecked * route, named as one. * * A capability URL is the newer member of that family and the reason this is a * function of its own. The request did come back with the row — but it came back * because the request named the row, so nothing was settled about whether a * stranger can reach it. Counting the route as checked on that basis would be a * coverage number claiming more than was tested, which is the shape of every * serious defect this engine has had. */ export declare function settlementFor(tableId: string, probes: ProbeResult[]): { settled: true; } | { settled: false; reason: string; }; /** * Exact match against something we planted, never a similarity judgement. * * `sent` is the request as it went out, and it is supplied on a run with no * database. Two things change when it is: * * - a value the request itself carried is not evidence of anything, however * exactly it comes back. An endpoint that echoes the id in the URL would * otherwise read as a leak on every app that has ever been written. The * comparison is `requestNames`, which matches whole path segments and whole * parameter values rather than looking for the text anywhere; * - the primary key stops being an oracle at all. With a schema behind the * run, a long random key that comes back unasked is weighed by the * capability pass; without one there is nothing to weigh it with, so the * only admissible evidence is the marker this run minted and planted. */ /** * The oracle for a server action, and the one place it must differ. * * A server action's reply is not a document, it is React's flight stream, and * the stream re-renders the page *with the arguments that were just sent*. The * identifier we crossed with therefore comes back in the response body on every * single call — on the leaky app and on the corrected one alike. Any oracle * that admits "an identifier belonging to the other user appeared in the reply" * would report a leak against every server action ever written, which is not a * weaker check, it is a tool nobody keeps installed past the first afternoon. * * So this function is deliberately narrower than {@link leakedMarker}, and the * narrowing is structural rather than careful: * * - it never looks at `victim.pk`. There is no branch here in which a primary * key match becomes evidence, so no future edit to a call site can turn one * on. The pk is the thing we send; it can only ever be an echo. * - the only admissible evidence is the canary the seeder planted, which we * minted and which appears nowhere in the request — and that is checked * against the *whole serialized request*, by plain containment. Not by * whole-segment matching, which would let a canary embedded in an argument * through; containment is the stronger test and the cheap one. * * A table with no canary column therefore settles nothing here, and says so. * That is the intended answer rather than a gap to be filled with something * weaker: silence beats a guess. */ export declare function actionLeak(victim: SeededRow, request: string, response: string): string | null; export declare function leakedMarker(victim: SeededRow, body: string, sent: { path: string; body: unknown; } | null): string | null;