import type pg from "pg"; import type { OwnershipModel, Persona, RoleModel, SchemaSnapshot, SeedResult, SeededRow, SelfGrantedPrivilege, TableInfo } from "../types.js"; /** * Do not let a row *we* planted be the reason a check fails. * * Crossline seeds a row for each test user in every table it can, and that is * what makes its oracle exact. But a table whose primary key is the user * reference — `platform_admins (user_id uuid primary key references users(id))` * — is indistinguishable by foreign-key shape from an ordinary user-owned one, * and it is the shape of every privilege table there is: staff lists, admin * rosters, entitlements, feature flags, role assignments, subscription tiers. * Planting a row in one promotes the test user before a single probe runs. * * This was not hypothetical. On a real 41-table production schema, Crossline * produced exactly one finding and had manufactured it itself: it seeded * `platform_admins`, which made `auth_is_platform_admin()` true for the test * user, which made a correctly-guarded `SECURITY DEFINER` function hand over * every tenant's rows — reported as `high`, against a database that does not * have that hole. Proved directly: * * ``` * bob, NOT a platform admin | 0 orgs * bob, AFTER the seeded platform_admins row | 2 orgs * ``` * * The seeder writes as the table owner, so it bypasses both the grant and * row-level security and can reach states no request ever could. * * ## The rule * * A granted crossing is only reported if it survives **withdrawing every row we * planted for the attacking persona that a real user could not have created**. * Three things had to be true of the rule for it to be worth having: * * - **It costs nothing when there is nothing wrong.** Everything here runs * only after a probe has already granted access, so a correctly-secured * schema does no extra work and prints no extra line. * - **It cannot make the tool quieter about a real hole.** A crossing that * still succeeds with those rows gone is reported exactly as before, and a * row the attacker *could* have inserted themselves is a state a real user * reaches, so a crossing that depends on it stays a finding — if a signed-in * user can add themselves to `platform_admins`, the escalation is the bug. * - **It is never silent.** A crossing withdrawn this way is named on the face * of the result, with the table, so nobody reads it as a check that passed * without noticing why. * * Note what is deliberately *not* done: we still seed the table, for both * personas. The victim's row is what lets a definer function prove it can * surface anything at all — without it the control call comes back empty and * the most powerful door in the database is reported as unexaminable. Only the * attacker's row is withdrawn, and only for the crossing being judged. */ export interface PlantedRow { tableId: string; table: TableInfo; row: SeededRow; /** Points at exactly the row we planted for the attacker. */ where: string; params: unknown[]; /** Why no request could have created it. Set once reachability is settled. */ reason?: string; } /** Why a crossing was withdrawn rather than reported. */ export type Escalation = SelfGrantedPrivilege; export interface EscalationContext { candidates: PlantedRow[]; /** * The subset of `candidates` the attacker could not have created, worked out * on first use and then reused. On a schema whose tables a signed-in user can * legitimately write to it is empty, which is what makes the whole mechanism * free on the runs where nothing is wrong. */ fabricated: PlantedRow[] | null; attacker: Persona; roles: RoleModel; userTableId: string | null; orgTableId: string | null; /** Everything planted for the attacker, so a replacement row resolves its keys. */ planted: Map; ownership: Map; } /** * The rows planted for the attacker that could conceivably be a privilege. * * The persona's own user row, their org, and their membership of it are * excluded, and that exclusion is the model rather than an exception to it: * Crossline places Alice and Bob in *separate* orgs on purpose, so those three * rows are what makes each of them an ordinary user of a tenant they belong to. * Withdrawing them would not de-escalate the attacker, it would turn her into * somebody who never signed up — and then "any user who belongs to an org can * read every org", which is a real and common hole, would stop being reported. */ export declare function buildEscalationContext(snapshot: SchemaSnapshot, model: OwnershipModel, seedResult: SeedResult): EscalationContext; export interface Withdrawn { /** The same attempt, made by an attacker holding only what a real user can. */ result: T; escalation: Escalation[]; } /** * Re-make an attempt that succeeded, with the attacker's fabricated privileges * withdrawn. * * Returns `null` — meaning "report what you already have" — whenever the * crossing is the application's own behaviour rather than ours: when there is * nothing we planted that could be responsible, when the crossing succeeds * without it, or when the state it depends on is one the attacker could have * reached without our help. */ export declare function withoutFabricatedPrivileges(client: pg.PoolClient, ctx: EscalationContext, attempt: () => Promise, granted: (result: T) => boolean): Promise | null>;