/** Core domain types shared across introspection, inference, probing, and reporting. */ export interface ColumnInfo { name: string; /** Postgres type name, e.g. "uuid", "text", "character varying", "USER-DEFINED" */ dataType: string; /** Underlying type for domains/enums; enum labels live in `enumLabels`. */ udtName: string; nullable: boolean; hasDefault: boolean; /** * The column's DEFAULT exactly as Postgres stores it — `gen_random_uuid()`, * `nextval('posts_id_seq'::regclass)`, `encode(gen_random_bytes(24), 'hex')`. * `null` where the column has none. * * Read rather than inferred, because it is the one place the schema states how * a column's values are produced. That matters for identifiers: whether an id * comes off a sequence or out of a CSPRNG decides whether a stranger could * have guessed it, which is a different question from whether it leaked. */ defaultExpression: string | null; /** True for identity/serial columns — never write these. */ isGenerated: boolean; maxLength: number | null; enumLabels: string[] | null; /** * For an array column whose element type is an enum, that enum's labels. * * `pg_type` describes `escalation_level[]` as the array type, not the enum, so * `enumLabels` is null and nothing about the column says which strings it will * accept. Kept separate rather than folded into `enumLabels` so that "this * column holds one of these labels" and "this column holds an array of these * labels" can never be confused for one another. */ elementEnumLabels?: string[] | null; } export interface ForeignKey { /** Columns on this table, positionally aligned with `refColumns`. */ columns: string[]; refSchema: string; refTable: string; refColumns: string[]; constraintName: string; } export interface CheckConstraint { name: string; /** Raw expression source, e.g. "((status)::text = ANY (ARRAY['a','b']))" */ expression: string; } export interface PolicyInfo { name: string; /** SELECT | INSERT | UPDATE | DELETE | ALL */ command: string; permissive: boolean; roles: string[]; using: string | null; withCheck: string | null; } export interface GrantInfo { grantee: string; privilege: string; } export interface TableInfo { schema: string; name: string; /** schema-qualified, e.g. "public.posts" */ id: string; columns: ColumnInfo[]; primaryKey: string[]; foreignKeys: ForeignKey[]; checks: CheckConstraint[]; uniqueConstraints: string[][]; rlsEnabled: boolean; rlsForced: boolean; policies: PolicyInfo[]; grants: GrantInfo[]; } /** * What the database actually exposes for the data plane to test. * * Crossline's data-plane checks impersonate the roles a PostgREST-style request * runs as. If the database grants those roles nothing and enables row-level * security nowhere, there is no authorization model here to examine — * authorization lives in application code, which is a perfectly reasonable way * to build an app. Saying so is very different from reporting that the database * was examined and found sound. */ /** * The database roles a request actually runs as. * * `anon` and `authenticated` are a Supabase convention, not a Postgres one. A * PostgREST deployment might use `web_anon`, and plenty of applications use a * single `app_user`. Hardcoding the Supabase pair was the last place this check * assumed a stack, and it turned every other setup into a correct-but-useless * "inconclusive" instead of a real result. * * Either role may be null: an application with no anonymous access has nothing * for the signed-out checks to run as, and that is a fact about the database * rather than a failure. Both null means nothing can be tested. */ export interface RoleModel { /** The role a request runs as with no credentials. */ anonymous: string | null; /** The role a request runs as once logged in. */ authenticated: string | null; /** Roles considered but not classified, so the report can say what to set. */ considered: string[]; source: "configured" | "inferred" | "none"; /** * Session settings this schema reads to identify the caller, beyond * PostgREST's own, discovered from the policy text. * * Lives here because it travels to exactly the places an impersonation is * built, and because it answers the same question the roles do: how does a * request say who it is? */ identitySettings?: readonly string[]; } /** * A zero-argument function that returns the current request's user id. * * `auth.uid()` is Supabase's; a plain Postgres application defines its own and * calls it whatever it likes. These are found structurally — the body reads * `request.jwt.claims`, which is what makes a function this rather than any * other — so a generated policy can call whatever the database actually * exposes instead of naming a Supabase helper that may not exist. */ export interface ClaimsFunction { /** Schema-qualified, e.g. "public.current_user_id". */ id: string; /** Return type as Postgres reports it, e.g. "uuid", "text", "bigint". */ returnType: string; /** * The single JWT claim this function's body reads, when it reads exactly one. * `"sub"` for `auth.uid()`, `"role"` for `auth.role()`, `null` for * `auth.jwt()`, which returns the whole claim set and singles out none. * * Read from the body rather than the name, and load-bearing rather than * decorative: infer/policy-text.ts will believe a policy that says * `col = ()` only when the function is known to return the caller's * *subject*. Both are discovered by the same structural rule, so without this * `role = auth.role()` would be read as "the role column holds the caller's * identity" and would hand every row of that table to everybody. */ claim: string | null; /** Roles that may execute it. A policy calling a function the role cannot * execute fails at query time for exactly the role it was written for. */ executableBy: string[]; } /** One input parameter of a callable function. */ export interface FunctionArg { /** `null` when the function was declared with unnamed parameters. */ name: string | null; /** Declared type as Postgres formats it, e.g. "uuid", "character varying(50)". */ type: string; hasDefault: boolean; } /** * A `SECURITY DEFINER` function, which is a hole the table probes cannot see. * * Such a function executes with its *owner's* privileges, so it runs outside the * caller's row-level security entirely. That is the standard Postgres way to * express a capability policies cannot — Supabase's own documentation steers * people towards it — and it is where cross-tenant support tooling actually * lives on real schemas. Probing only tables leaves the most powerful path in * the database invisible while every table around it reports clean. * * `executableBy` is resolved with `has_function_privilege`, not by reading the * ACL, because the ACL does not tell the whole truth: `REVOKE EXECUTE ... FROM * anon` leaves the implicit `PUBLIC` grant in place, so the function stays * callable by anon. Reading that wrong understates reachability, which is the * dangerous direction. */ export interface DefinerFunction { /** schema.name — not unique when the function is overloaded. */ id: string; schema: string; name: string; /** schema.name(argtypes) — unique, and what a repro must name. */ signature: string; /** `sql`, `plpgsql`, `plpython3u`, `c`, … */ language: string; volatility: "immutable" | "stable" | "volatile"; returnType: string; returnsSet: boolean; args: FunctionArg[]; /** True when the parameter list has a shape we cannot construct a call for. */ unsupportedArgs: boolean; /** Of the roles we test as, the ones that may actually EXECUTE this. */ executableBy: string[]; } export interface AuthSurface { /** At least one role to impersonate was identified. */ rolesResolved: boolean; /** Those roles hold at least one grant on an application table here. */ grantsToTestedRoles: boolean; /** Row-level security is enabled on at least one application table. */ rlsAnywhere: boolean; /** A claims function such as `auth.uid()` exists. */ hasClaimsFunction: boolean; } /** * How the user table was decided. * * This is the most load-bearing inference in the tool: get it wrong and * ownership cannot be inferred for anything, every table degrades to * reference or unknown, and a wide-open database can look quiet. So how it * was decided travels with the answer, and `init` prints it. */ export type UserTableSource = /** Named in crossline.config.json. */ "configured" /** The auth provider's own table (Supabase's auth.users). */ | "provider" /** Structural: other tables point owner-shaped columns at it. */ | "inferred" /** Nothing structural pointed anywhere; chosen on its name alone. */ | "conventional" /** No user table could be identified at all. */ | "none"; export interface SchemaSnapshot { /** Application tables — the ones we seed and probe. */ tables: TableInfo[]; /** Table id that holds application users, e.g. "auth.users". */ userTableId: string | null; /** How that was decided, and why — shown to the developer, never buried. */ userTableSource: UserTableSource; userTableRationale: string; /** * The user table itself. Kept out of `tables` because it belongs to the auth * provider rather than the developer, but needed so we can create personas. */ userTable: TableInfo | null; /** Schema-qualified names of existing functions, so generated helpers do not collide. */ functions: string[]; /** Functions that answer "who is making this request", found by what they read. */ claimsFunctions: ClaimsFunction[]; /** Callable `SECURITY DEFINER` functions in the application's own schemas. */ definerFunctions: DefinerFunction[]; roles: RoleModel; authSurface: AuthSurface; capturedAt: string; } export type Classification = /** Rows belong to exactly one user via a direct column. */ "user_owned" /** Rows belong to an org/team; visible to that org's members only. */ | "org_scoped" /** Ownership is inherited through a FK path to an owned parent. */ | "child_owned" /** Join table linking users to orgs. */ | "membership" /** Shared reference data. Everyone may read; nobody but the service may write. */ | "reference" /** Deliberately world-readable application data (e.g. published posts). */ | "public" /** Inference failed. Treated as user_owned but reported as low confidence. */ | "unknown"; export interface OwnershipPathStep { fromTable: string; /** * The child-side columns of one foreign key, positionally aligned with * `toColumns`. A composite key is a single relationship spelled across * several columns, so a step carries all of them and the generated policy * joins on every one — matching on part of a composite key would let a row * inherit the wrong parent's protection. */ fromColumns: string[]; toTable: string; toColumns: string[]; } export interface TableOwnership { tableId: string; classification: Classification; /** Direct owner column for user_owned; null otherwise. */ ownerColumn: string | null; /** Direct org column for org_scoped; null otherwise. */ orgColumn: string | null; /** FK hops from this table to the table that carries the owner, for child_owned. */ ownershipPath: OwnershipPathStep[]; confidence: "high" | "medium" | "low"; /** Human-readable justification, shown in the confirmation step. */ rationale: string; /** True once a human has confirmed or corrected this row. */ confirmed: boolean; } export interface OwnershipModel { tables: TableOwnership[]; userTableId: string | null; orgTableId: string | null; membershipTableId: string | null; generatedAt: string; } export type PersonaName = "alice" | "bob"; export interface Persona { name: PersonaName; userId: string; email: string; orgId: string | null; /** * The identity provider that assigned `userId`, when one did. * * Crossline normally generates the persona ids itself and then seeds rows * under them. Clerk, Auth0, Cognito and Firebase Auth all assign the id * instead, and the id has to be theirs *before* anything is planted — a * session issued for `user_2ab…` while the rows belong to a uuid of ours * would reach nothing, and reaching nothing is exactly what a correctly * secured application looks like. This field records that the ordering * happened, so a strategy that depends on it can refuse rather than assume. */ assignedBy?: string; } /** * How a planted row came to exist. * * Reported per table, because the four are not equally strong evidence and a * developer reading a finding deserves to know which one is behind it. * * synthesis invented from the catalog. Every value is ours, so the row owes * nothing to whatever data happened to be in the table. * adopted a trigger created the row the moment the persona was created, * and it already carries the persona's id in the owner column. * clone copied from a row the database had already accepted, with a * fresh key and the owner re-pointed at the persona. * suspension synthesised after a CHECK constraint or a trigger was lifted * out of the way, and put back before anything was probed. */ export type SeedStrategy = "synthesis" | "adopted" | "clone" | "suspension"; export interface SeededRow { tableId: string; persona: PersonaName; /** Primary key values, keyed by column name. This is the oracle. */ pk: Record; /** Canary token planted in a text column, when the table had one. */ canary: string | null; /** Full row as inserted, for building repro requests. */ values: Record; /** Which of the seeding strategies produced this row. */ strategy: SeedStrategy; } /** * A rule lifted out of the way to get a row in, and put back before probing. * * Reported per table and never silently: a table that was only checkable * because a constraint or a trigger was suspended is a table the developer * should look at with that in mind. */ export interface SeedSuspension { tableId: string; /** What was lifted, e.g. `check constraint offers_check`. */ suspended: string[]; } export interface SeedResult { personas: Record; rows: SeededRow[]; /** Tables we could not seed, with the reason — reported, never silently dropped. */ skipped: { tableId: string; reason: string; }[]; /** Tables that could only be seeded with a rule lifted; see {@link SeedSuspension}. */ suspended: SeedSuspension[]; } export type Operation = "select" | "update" | "delete" | "insert_forgery"; export type Actor = "anon" | "other_user"; export type Plane = "data" | "api"; /** What the ownership model says *should* happen. */ export type Expectation = "denied" | "allowed"; /** * How a write attempt was settled against the database. * * A write cannot be settled from the reply — a handler that modifies another * user's row and answers `{"ok":true}` looks exactly like one that refused — so * every write is decided by reading the rows back. These are the outcomes of * that reading, and they are recorded rather than collapsed into a boolean * because two of them mean very different things to whoever has to act on it. * * crossed the other user's row holds the value we sent. A leak. * scoped_to_caller our own row took it instead: the handler scoped the * write, which is a genuine refusal of the crossing. * refused the request was rejected and nothing changed, and the * same write does land when the row's owner sends it — * so the endpoint works and this caller was turned away. * accepted_and_dropped the handler answered with success and nothing changed, * with the same owner proof. Safe on this attempt, but * the caller was never told it had been refused. * unsettled nothing changed and nothing proves the endpoint would * have applied this field for anybody. Claims nothing. * * `refused` and `accepted_and_dropped` both require the owner's own identical * write to have landed. Without that an endpoint that is simply broken for * everyone would be scored as an endpoint that protects its rows. */ export type WriteSettlement = "crossed" | "scoped_to_caller" | "refused" | "accepted_and_dropped" | "unsettled"; /** * An endpoint that hands a row to whoever can name it — a share link, an invite * link, an unsubscribe URL. Reported as its own thing, and never as a leak. * * Crossline's whole method is that it plants the rows, so a response containing * a planted identifier is a fact rather than a judgement. That method has one * blind spot, and this is it: on `GET /api/public/forms/{uuid}` the *only* * secret in the request is the identifier we substituted into the path, and we * had it solely because we planted it. Reporting that as "an unauthenticated * client can read another user's forms" states something the run did not * establish — it never showed a stranger could obtain the id — and it is the * false positive that made seven of eight critical cross-user reads in the * corpus wave wrong. * * The opposite mistake is far worse, so the bar for landing here is deliberately * high and every part of it is a fact from this run: * * - the identifier is drawn from a space too large to enumerate, read off the * column's type and default rather than off the value we happened to plant; * - nothing in this run handed that identifier to this caller — no listing, no * other response — so it is not merely random, it is unobtainable; * - and another endpoint over the same table *does* enforce the crossing: it * serves the row's owner and refuses this same caller. The application * demonstrably knows these rows are private, and this endpoint is a second * door that opens for anyone holding the link. * * Fail any one of those and it stays a finding. A sequential id is guessable, so * reaching another user's row with one is the commonest real authorization bug * there is; an id a collection endpoint will hand out is not a secret however * random it looks; and a table nothing protects is not sharing anything * deliberately, it is simply unprotected. */ export interface CapabilityUrl { tableId: string; /** The endpoint's pattern, e.g. "GET /api/public/forms/:id". */ route: string; /** The request as sent, with the identifier substituted in. */ requestedPath: string; actor: Actor; /** The column the identifier came from, and why it cannot be guessed. */ identifier: { column: string; reason: string; }; /** * The endpoint over the same table that does enforce the crossing, and which * is why this one reads as a deliberate share surface rather than a hole. */ guardedBy: string; repro: string; } /** * A cross-user read that came back with the marker, on a run that has no way to * establish who the resource belongs to. * * It is a fact — the response carried a 128-bit value planted by a creation * request as somebody else, and this request did not send it — and it is not a * finding, because a document deliberately shared with everybody produces * exactly this observation. Calling it a leak would be asserting a step the run * never took, which is the failure mode this engine exists to avoid. So it is * printed, in its own list, it is never counted as a passing check, and it fails * no build. * * This only ever arises where there is no schema to read ownership out of. With * a database behind the run, a cross-user read is settled either way and is a * finding or a pass like anything else. */ export interface CrossUserObservation { /** The endpoint pattern, e.g. "GET /api/notes/:id". */ route: string; /** The request as sent, with the identifier substituted in. */ requestedPath: string; resource: string; actor: Actor; /** The marker, and the persona whose creation request planted it. */ evidence: string; repro: string; /** Why this is not being reported as a finding. */ withheld: string; } export interface ProbeResult { id: string; plane: Plane; tableId: string; /** Present for API-plane probes. */ route?: string; /** * Present when the row was reached by *calling a function* rather than by * querying the table. The signature, e.g. "public.tenant_documents(uuid)". */ functionId?: string; actor: Actor; operation: Operation; expectation: Expectation; /** What actually happened. */ accessGranted: boolean; /** * Whether this attempt actually settled the question. * * An attempt that failed for a reason unrelated to access control — a crashed * handler, a statement timeout, a trigger raising, a row we could not even * construct — proves nothing. Scoring those as passes is how a tool reports * silence as safety, which is far more dangerous than a false positive: a * false positive gets investigated, a false negative gets trusted. * * A refusal *is* conclusive, including one raised by the database as * insufficient privilege, because the actor genuinely could not reach the row. */ conclusive: boolean; /** Evidence that the oracle matched: the specific PK/canary that leaked. */ evidence: string | null; /** For reads: how many rows of the table the actor could see in total. */ rowsVisible: number | null; /** Copy-pasteable reproduction (SQL or curl). */ repro: string; error: string | null; /** For a write confirmed against the database: how it was settled. */ writeSettlement?: WriteSettlement; /** * Set when this granted read was reached by presenting an identifier the * caller could not have obtained. It is not a finding, and it is not silently * dropped either — see {@link CapabilityUrl}. */ capability?: CapabilityUrl; /** * Set when this crossing succeeded only because of a row *Crossline* planted * for the attacking test user, in a table no request could insert into. * * The result recorded here is the re-made attempt with that row withdrawn, so * `accessGranted` is the honest answer about a real signed-in stranger. The * explanation is kept because a check that was nearly a finding, and was not * reported for a reason the reader could not otherwise see, has to be visible * on the face of the result. */ escalation?: SelfGrantedPrivilege[]; } /** * A privilege Crossline handed its own test user, and then withdrew. * * `platform_admins (user_id uuid primary key references users(id))` is shaped * exactly like an ordinary user-owned table, so the seeder planted a row in it * and promoted the test user to platform administrator before any probe ran. * Every entitlement table, feature-flag table, role assignment and subscription * tier has that same shape. */ export interface SelfGrantedPrivilege { tableId: string; /** What the row did, and why no real user could have it. */ detail: string; } /** * The report-level roll-up of {@link SelfGrantedPrivilege}: one entry per * privilege table, naming every crossing that stopped being a finding once the * row was withdrawn. * * This must appear on a passing run as prominently as anywhere else. The whole * point is that the reader can see the one judgement Crossline is not entitled * to make — whether this table really does grant privileges, and whether its * being reachable only to the table's owner is how it is meant to work. */ export interface WithheldCrossing { tableId: string; detail: string; /** What would have been reported, e.g. `public.documents via public.pp_admin_fleet()`. */ crossings: string[]; } export type Severity = "critical" | "high" | "medium"; export interface Violation { id: string; severity: Severity; tableId: string; route?: string; /** Set when the row was reached through a `SECURITY DEFINER` function. */ functionId?: string; actor: Actor; operation: Operation; plane: Plane; title: string; detail: string; evidence: string; rowsVisible: number | null; repro: string; } export interface RunSummary { total: number; passed: number; failed: number; /** Attempts that ran but settled nothing. Never counted as passing. */ inconclusive: number; /** * Granted reads that turned out to be capability URLs. Counted apart from * both columns on purpose: they are not failures, and calling them passes * would bury an endpoint the developer should look at. */ capabilities: number; skipped: number; /** * Positive checks: the owner can still reach their own data. * * Split three ways for the same reason the probes are. "A policy is denying * the person it was written for" and "that check could not be settled" are * different facts, and since the owner checks now gate the verdict, merging * them would let an unsettleable check collapse an otherwise valid run. */ positivePassed: number; positiveFailed: number; positiveInconclusive: number; /** * Cross-user *write* attempts that settled: an update, a delete or a forged * insert that either was refused or went through, on any plane. * * Kept apart from `passed` because it answers a question the totals cannot: * whether this run examined the application's write surface at all. A run * that settled a hundred reads and no writes has established nothing about * what one user can change of another's — which is the situation an * application whose mutations are all Next.js server actions is always in, * and the reason a discovered-but-uncallable action can decide a verdict. */ mutationsSettled: number; /** * Coverage in the unit that actually matters. "57 checks passed" says nothing * about whether the checks covered the tables holding the data. */ tablesChecked: number; tablesUnchecked: number; } /** Which tables were genuinely checked, and why the rest were not. */ export interface TableCoverage { checked: string[]; unchecked: { tableId: string; reason: string; }[]; } /** * A table the legitimate owner could not reach through the identity we tested. * * This used to be a bare counter — "2 owner checks failing" — with no table * named and a single sentence blaming a policy. On a database where every table * is the same shape you can work out which two; on a half-secured one you * cannot, and the counter sits next to a green tick where it is easy to skim * past. Worse, the sentence was wrong for two of the three causes: a table * granted to nobody has no policy to blame, and neither does one with row-level * security switched on and nothing written for it. * * The schema-read causes are read off the schema, in the order a request meets * them, and against the role the failing check actually ran as. Nothing here is * inferred from how a response looked. * * The plane matters as much as the cause. A refusal that arrived over HTTP was * not handed down by any database role Crossline can name — the application * made that query itself, as whatever principal its connection string holds — * so reading a grant or a policy off the schema and presenting it as the reason * is a cause nobody observed. Those are reported as what they are: an endpoint * that refused its own user, with no cause attributed. */ export interface OwnerLockout { tableId: string; /** Which plane the owner's refused attempt was made on. */ plane: Plane; /** The endpoint that refused the owner, for an API-plane lockout. */ route?: string; cause: /** The role holds no privilege here, so it never reaches row-level security. */ "no_grant" /** Row-level security is on and no policy exists: Postgres denies everybody. */ | "rls_without_policy" /** Policies exist and one of them refuses the owner their own row. */ | "policy_denies_owner" /** * The owner's own request through the application was refused. No database * cause is attributed: the request did not run as a role we impersonated, * so which wall it met is not something this run observed. */ | "endpoint_refuses_owner" /** * Refused, and nothing in the run says by what — the role could not be * named, or the table is not in the snapshot. Stated rather than guessed. */ | "unattributable"; /** One sentence naming the cause and what closes it. */ detail: string; } /** * What the API phase managed to check, in the units that decide whether its * silence means anything. * * Route discovery is best-effort by construction, so a run given `--api` can * easily check nothing at all through the API — no routes found, none matching * a table, or an app that was not running. That must never read as an API with * nothing wrong with it: the developer asked for this plane and believes they * tested it, which makes a quiet zero here worse than never passing the flag. */ export interface ApiCoverage { /** True when `--api` was actually asked for. */ requested: boolean; routesDiscovered: number; /** Routes that produced at least one settled cross-user attempt. */ routesSettled: number; /** Tables a settled API attempt actually reached. */ tablesReached: string[]; /** * Tables at least one discovered endpoint addresses, settled or not. * * The difference from `tablesReached` is the whole point: a table an endpoint * serves and every attempt against which collapsed is a gap, while a table no * endpoint serves at all was never in this plane's reach to begin with. Both * are "unchecked", and telling a reader which is which is the difference * between a bug to fix and a scope to state. */ tablesTargeted: string[]; /** * The tables Crossline wrote to in order to *be somebody*, rather than to * plant evidence: the table users live in, plus whatever table a session had * to be planted in to make a request as one. * * These are the identity mechanism, not application data. Nothing about them * is excused from being probed or from producing a finding — this list exists * so that a run can tell "the app has an endpoint onto this and it went * unchecked" apart from "the auth adapter owns this table and no endpoint in * the application addresses it at all". */ identityTables: string[]; /** Tables in the ownership model, for the fraction the API plane covered. */ tablesInModel: number; /** Every discovered route that settled nothing, with the reason. */ unreached: { route: string; reason: string; }[]; /** * Registrations discovery could see but could not read a path out of. * * Express-style discovery reads source, so a route registered through a * helper — `router.get(`/${resource}/:id`, …)` — genuinely cannot be resolved * to a URL. The failure that matters is not missing it, it is missing it * quietly: an endpoint nobody probed then looks exactly like an endpoint with * nothing wrong with it. Each one is named with its file and the call, so * `routes` in the config file closes it in a line. */ unreadableRegistrations: { source: string; detail: string; reason: string; }[]; /** * Writes a stranger sent that came back with a success status while the other * user's row stayed exactly as it was. * * Not findings, and deliberately not phrased as any: nothing crossed, and the * proof is the same exact-identifier oracle as everywhere else. They are * listed because the caller could not tell this apart from a write that * worked, and because an endpoint that reports success without applying the * change is one refactor away from applying it. */ silentWrites: { route: string; actor: Actor; }[]; /** * Next.js server actions the build named, and which nothing probed. * * A server action is a mutation with no route: a POST to a page's own URL * carrying an opaque id. Discovery reads the ids out of the build output, so * they can be *named* exactly — but the build records no argument types, and * a call built on a guessed signature is rejected by the framework before the * action decides anything. Scoring that rejection as a refusal would be * scoring an exception as authorization, so nothing is asserted about them. * * They are here so that an application whose whole write surface is server * actions cannot come back clean and silent about it. This is a disclosed * gap, not a check. */ serverActions?: ServerActionCoverage; /** How Crossline became a signed-in user on this run, for the report. */ signedInAs: string | null; /** * Why not one request could be made as a specific signed-in user. * * Null when one could. When it is set, only the signed-out half of the API * suite ran: nothing here is evidence about what one logged-in user can reach * of another's, however many routes were touched. */ signedInUnavailable: string | null; /** Set when the phase never got as far as probing anything. */ note: string | null; /** * How the application under test came to be running, when Crossline started * it rather than being pointed at one. Null when a URL was simply given. * * Printed, because running a command out of somebody's `package.json` is not * a thing to do silently. */ appServer?: string | null; } /** Server actions found in a Next.js build, none of which were called. */ export interface ServerActionCoverage { found: { /** The opaque `Next-Action` id. */ id: string; /** The URL it is dispatched from. */ path: string; /** Source file, relative to the project root. */ source: string; /** Export name, or Next's synthetic name for an inline action. */ exportedName: string; /** * False on a build older than Next 15.5, which records no file or name for * an action — only its id and the page bundle it landed in. The endpoint is * still named exactly; what cannot be said is which function it runs. */ attributed: boolean; inline: boolean; }[]; /** * Actions somebody declared the argument shape for, and which were therefore * called. * * The build records no argument types, so this is the only way an action gets * checked at all. What a declaration buys is *reach* and nothing else: every * entry here went through the same checks an endpoint does — the owner's own * call had to land, the seeded marker had to be absent from the request and * present in the reply, and a write was settled against the database. * * `settled: false` is the ordinary outcome of a wrong declaration and carries * the application's own refusal in `reason`. It is never a pass, and the * action stays in `found` alongside the ones nobody described. */ checked?: { id: string; path: string; source: string; exportedName: string; /** The table the crossing was attempted over, once resolved. */ resource: string | null; settled: boolean; reason: string | null; }[]; /** * Declarations that named no action this build contains. * * A declaration is input from outside the run and can be stale — a renamed * export, a moved file, a build that predates the change. Dropping one * silently would leave the developer believing an action is checked when * nothing ever called it, so each is named with why it matched nothing. */ undeclared?: { exportedName: string; source?: string; reason: string; }[]; /** * Set when the source declares server actions and no build output names them. * * The worst of the three states: the run finds nothing and an application * with no actions also produces nothing, so without this sentence the output * is the same either way. */ unbuilt: string | null; /** Why none of `found` was called. One sentence, always printed with them. */ reason: string; } /** * What the function plane managed to establish. * * Reported separately from tables because the unit is different and because the * honest answer here is often "we did not call it". Calling an unknown function * is not like reading a table: something named `build_tenant_archive` might * write, bill, email or delete, and the transaction we roll back contains * database writes but not external effects. Declining to invoke is the right * default, and it has to read as *unchecked* — never folded into a pass. */ export interface FunctionCoverage { /** `SECURITY DEFINER` functions found in the application's schemas. */ discovered: number; /** Functions a cross-user call actually settled something about. */ checked: string[]; unchecked: { functionId: string; reason: string; /** * False when we never invoked it at all — a stranger can call this and we * did not look. That is a different fact from "we called it and nothing of * ours came back", and only the first is worth putting in front of someone * on every run. */ called: boolean; }[]; /** * Executable by neither role we test as, so not reachable and not a hole. * Recorded because "we saw it and it is fine" is worth being able to prove. */ notReachable: string[]; } /** * `inconclusive` is not a softer `pass`. It means the run did not establish * anything, and it must never be reported as a clean bill of health or exit 0. */ export type Verdict = "pass" | "fail" | "inconclusive"; export interface RunReport { brand: string; version: string; startedAt: string; finishedAt: string; target: string; mode: "read_only" | "full"; summary: RunSummary; verdict: Verdict; /** Why the run established nothing. Empty unless the verdict is inconclusive. */ inconclusiveReasons: string[]; /** * Claims this run deliberately did not make, stated on the face of the result. * * Distinct from `inconclusiveReasons`, and the distinction is the whole point: * a reason is a gap in the claim that was attempted, and it blocks a pass. A * note here is a *different* claim that this architecture puts out of reach — * on a Prisma / Drizzle / raw-`pg` database there is one trusted role and no * second database principal, so the data plane has no line to cross and the * evidence has to come through the API. That run can be a genuine pass; what * it must never do is let the reader think the database itself was examined. */ notEstablished: string[]; /** Named loudly: a table nobody checked must not read as a table that passed. */ coverage: TableCoverage; /** Tables the owner could not reach either, named rather than counted. */ ownerLockedOut: OwnerLockout[]; /** * Crossings withheld because the only thing that made them possible was a row * Crossline planted for its own test user. Never empty and silent. */ withheld: WithheldCrossing[]; /** * Cross-user reads that came back with another account's marker, on a run * with no schema to establish ownership from. * * Present only on a run with no database, where the whole read half of the * suite is deliberately reported rather than asserted about. Never findings, * never passes, and never folded into the tick — see {@link CrossUserObservation}. */ observations?: CrossUserObservation[]; /** * Things this run created and did not remove, in words. * * Only on the run with no database. There the writes go through the * application and cannot be rolled back, and there is no connection to delete * a row over — so what survives the run is named, with where to find it, * rather than left for the developer to discover. */ residue?: string[]; /** * What the coverage numbers count. `table` — the default, and every run with * a database — or `resource`, where the units are the application's own * collections and no schema was read. */ coverageUnit?: "table" | "resource"; /** Present only when `--api` was requested. */ apiCoverage?: ApiCoverage; /** Present when the schema has any callable `SECURITY DEFINER` function. */ functionCoverage?: FunctionCoverage; violations: Violation[]; /** * Endpoints that answer anyone holding the link. Kept out of `violations` * because nothing here was shown to be reachable by a stranger, and kept in * the report because "confirm this is meant to be a share link" is a real * question with a real answer that only the developer has. */ capabilityUrls: CapabilityUrl[]; probes: ProbeResult[]; seedSkipped: { tableId: string; reason: string; }[]; /** * Tables that could only be seeded by lifting a CHECK constraint or a trigger * out of the way. Everything named here was restored before a single probe * ran; it is stated so that a developer knows the table was checkable only * because a rule was suspended to plant a row in it. */ seedSuspended: SeedSuspension[]; model: OwnershipModel; fixes?: FixProposal[]; } /** Did applying this actually close the holes without breaking legitimate access? */ export interface FixVerification { attempted: boolean; violationsResolved: boolean; positiveChecksStillPass: boolean; remainingViolationIds: string[]; brokeChecks: string[]; } /** * A second, stricter policy the developer can choose instead. * * It exists for the one judgement the re-run structurally cannot make. An * org-scoped policy widens access from the row's owner to every member of the * org, and because the two test users are always in *different* orgs, * verification proves strangers stay out and says nothing about teammates. That * is precisely the axis along which a wrong roster inference silently widened * access. Rather than pick for the developer and caveat it, offer both forms * and let the person who actually knows the answer decide. * * Held to the same bar as the primary: applied to a throwaway copy and * re-tested, or not offered at all. */ export interface FixAlternative { /** Short label for the choice, e.g. "owner-scoped". */ label: string; sql: string; explanation: string; /** * True when this alternative grants *more* access than the primary. * * It matters because it decides what the re-run established. A narrowing * alternative that passes is proven in full. A widening one is proven only * to keep cross-org strangers out and the owner in — the sharing it opts * into is along the exact axis two users in different orgs never test, which * is why it is offered as an explicit choice and never chosen for anyone. */ widens: boolean; verification: FixVerification | null; } export interface FixProposal { tableId: string; /** Violation ids this fix is intended to resolve. */ resolves: string[]; sql: string; explanation: string; /** * Judgement calls the generated policy makes on the developer's behalf. * Surfaced rather than buried: an over-restrictive fix is a real cost, and we * would rather propose the safe default and say so than guess silently. */ caveats: string[]; /** Set by verify/: a fix is never emitted without this. */ verification: FixVerification | null; /** A stricter alternative, where the choice is the developer's to make. */ alternative?: FixAlternative; }