import type { ApiCoverage, CrossUserObservation, OwnerLockout, OwnershipModel, ProbeResult, RunSummary, SchemaSnapshot, TableCoverage, Verdict, Violation, WithheldCrossing } from "../types.js"; /** * Turn raw probe outcomes into findings. * * A finding requires two things to be simultaneously true: the model said this * access should be denied, and it demonstrably was not. Nothing here infers, * scores, or estimates — if a probe reached a row we planted for somebody else, * that is a fact, and if it did not, there is nothing to report. */ export declare function judge(probes: ProbeResult[], model: OwnershipModel, opts?: { /** * Which rule was applied when these probes ran, matching * `ApiProbeOptions.ownership`. It has to be the same one here: the prober * decides what to attempt from it, and this decides what to *say* about * what came back. Reading a run-premise crossing through the schema rule * printed "ownership could not be determined from the schema" over a * database-less run, which names a schema nobody read and a config key that * would not help. */ premise?: "schema" | "run"; }): Violation[]; export declare function summarize(probes: ProbeResult[], skippedCount: number, coverage: TableCoverage): RunSummary; /** * Work out which tables were genuinely checked. * * "57 checks, 0 failing" is not the same claim as "your data is protected", and * the gap between them is where a false all-clear lives. A table is counted as * checked only if at least one cross-user attempt against it actually settled * something; everything else is listed with the reason nobody could check it, * so an unchecked table reads as unchecked rather than as a table that passed. */ export declare function tableCoverage(model: OwnershipModel, probes: ProbeResult[], skipped: { tableId: string; reason: string; }[], /** * The API surface, when there was one. Used only to say *why* a table went * unchecked: "no endpoint in this application addresses it" and "every * attempt against it collapsed" are both unchecked and are not the same * problem, and a coverage list that cannot tell them apart sends the reader * looking for a bug that is not there. */ api?: ApiCoverage, opts?: { /** * Where the ownership premise came from, matching `ApiProbeOptions.ownership`. * * `schema` is the default and everything that has ever run. `run` is the * database-less run, where every entry is classified `unknown` because no * schema was read — and where that must *not* be read as "ownership could * not be determined". Something narrower was asserted and settled: an * unrelated account's write or delete to a resource this run created as * somebody else. Applying the schema-path rule here would report every * resource as unchecked on a run that checked them. */ premise?: "schema" | "run"; }): TableCoverage; /** * Say so when the application's own routes never mention this table. * * Returns null unless that is established rather than merely likely. Three * things have to hold, all read off the run: * * - the API plane ran and discovered routes at all; * - discovery read every registration it saw. One it could not resolve to a * URL means the surface is not enumerated, and an endpoint we could not * read is exactly where the missing door would be; * - no discovered route resolves to this table. * * This is a *reason*, never an excuse: the table stays in the unchecked list * either way. What it changes is what the reader is sent to look at. */ export declare function unservedByApi(tableId: string, api: ApiCoverage | undefined): string | null; /** * Does the database expose any authorization model for the data plane to examine? * * Two very different situations are false here and conflating them is how this * goes wrong in either direction: * * - There is no untrusted database principal at all. One trusted role, the * application's own connection string. This is the Prisma / Drizzle / * raw-`pg` default and the largest population outside Supabase. "User A" * and "user B" are not database identities here, so the data plane has no * line to cross — there is nothing it failed to test, because there was * nothing there to test. * * - Roles with real access exist and we could not tell which was which. * That is a genuine gap: something was there and we failed to test it, * and it has to keep blocking a pass. That case is *not* this predicate. * * Exported because it is also the question that decides whether Crossline * should start the application itself: a schema that answers for itself needs * no running app, and one that cannot needs the API plane or it has nothing. */ export declare function noUntrustedPrincipal(snapshot: SchemaSnapshot): boolean; /** * Decide what the run actually established. * * The owner checks are the load-bearing part of this. They were introduced as a * guard against shipping an over-restrictive fix, but they are really the proof * that the harness is testing anything at all: if the legitimate owner could * not reach their own row through the identity we tested with, then every * "denied" we recorded is unfalsifiable, and a screen full of passes means only * that nothing worked. A run whose positive checks all collapse has proved * nothing and must never print a pass. * * The same is true before a single query runs, when the database exposes no * authorization model for the data plane to test. * * And it is true of coverage. A run with zero probes — seeding collapsed, every * table skipped — used to fall through every guard here, because each guard * only fired when something had been attempted. More generally, a run that * skipped a table it believes holds private data cannot call the database * clean: the check-level safeguards name that table honestly, and the verdict * has to answer for it too, or the honesty never reaches the exit code. The * developer can fix the cause, or accept the gap explicitly by naming the * table in `acceptUnchecked` — what they must never get is an unearned green. */ export declare function verdictFor(snapshot: SchemaSnapshot, violations: Violation[], summary: RunSummary, model: OwnershipModel, coverage: TableCoverage, opts?: { acceptUnchecked?: string[]; actorFailures?: { actor: "anon" | "other_user"; role: string; error: string; }[]; apiCoverage?: ApiCoverage; /** * Whether this run was willing to write at all. A read-only run declines * every mutation by the developer's own instruction, so the mutations it * did not make are not mutations it failed to make. */ mode?: "read_only" | "full"; }): { verdict: Verdict; reasons: string[]; notEstablished: string[]; }; /** * The same decision, for a run with no database at all. * * It is a separate function rather than a flag on {@link verdictFor} because * almost every input that one reads does not exist here: there is no snapshot, * no roles, no grants, no row-level security, and no ownership model inferred * from anything. Feeding it a synthetic empty snapshot would make it print "the * database enforces nothing itself", which is a claim about a database this run * never saw — the exact species of overclaim the mechanism exists to prevent. * * What is *not* separate is where the two halves of the answer go. A reason * blocks a pass; a note in `notEstablished` is a different claim this * architecture puts out of reach, printed on the face of the result. That is the * same distinction the database-backed run makes, and this path needs it more * sharply than any other: writes and deletes here are settled, and reads * structurally cannot be. */ export declare function verdictForHttpRun(input: { violations: Violation[]; summary: RunSummary; coverage: TableCoverage; observations: CrossUserObservation[]; /** What the endpoints managed to check. */ api: ApiCoverage; /** Why the run never got as far as probing anything. Null when it did. */ unavailable: string | null; /** * Resources whose cross-user *reads* this run was entitled to settle, because * it established who they belong to: each account's own resource came back * carrying that account's own identifier, in a field whose name was never * consulted. See `establishOwnership` in `src/api/plant.ts`. * * Empty is the ordinary case and was until recently the only one, which is * why the paragraph below has two forms. Printing "cross-user reads were not * settled" over a run that settled them understates the result exactly as * badly as the opposite would overstate it. */ readsSettled?: string[]; /** * Why a resource's reads could not be settled, in the words of the rule that * refused them — one sentence each, naming the field or the fact that was * missing. * * Without these the reader is told that ownership "could not be established" * and never what would establish it, which is the difference between a * message they can act on and one they learn to skip. It is the same * information the model carries in its rationale, put where a run prints it. */ ownershipRefusals?: string[]; }): { verdict: Verdict; reasons: string[]; notEstablished: string[]; }; /** * Positive checks that stopped working. Not a security problem — the opposite — * but it means a policy is now denying the person it was meant to serve, and it * is the guard rail that stops us shipping a "fix" that locks everyone out. */ export declare function brokenOwnerAccess(probes: ProbeResult[]): ProbeResult[]; /** * Name the tables the owner could not reach, and say what actually stopped * them. * * The half-secured database is where this matters. When every table has the * same shape, "2 owner checks failing" is a solvable puzzle; when two tables out * of five are locked out and the other three come back green, the counter sits * beside a tick with nothing tying it to a table — and the PR comment and the * agent summary dropped it altogether, printing "Safe to commit" over a schema * with a table nobody on earth could read. * * The cause is read off the schema, in the order a request meets the walls: a * role granted nothing never reaches row-level security, so blaming a policy * there sends the developer to `pg_policies` to find nothing and conclude the * tool is confused. * * None of this is a finding. Every table here denies the stranger *more* firmly * than a policy would, so reporting it as a leak would be crying wolf on the * most secure shape a table can have. It is stated as a fact, next to the * result, and it does not touch the verdict. */ /** * Crossings that would have been findings until the privilege Crossline handed * its own test user was taken back. * * Grouped by the table that held the privilege, because that is the thing the * reader has to look at. One line per table beats one line per check: a staff * table that unlocks a support function usually unlocks several, and a list of * eleven near-identical sentences is a list nobody reads. */ export declare function withheldCrossings(probes: ProbeResult[]): WithheldCrossing[]; export declare function ownerLockouts( /** * Narrowed to the two fields this actually reads, so a run with no schema can * still ask the question. Every lockout on that path is the `api` kind, which * deliberately attributes no database cause — and passing a fabricated * snapshot to get at it would be inventing tables and roles that do not exist. */ snapshot: Pick, probes: ProbeResult[]): OwnerLockout[];