import type { CapabilityUrl, ProbeResult, TableInfo } from "../types.js"; /** * Telling a share link apart from a broken access check. * * Crossline seeds its own rows, so "this response contained a row we planted for * the other user" is a fact and not a judgement. Against an endpoint whose only * input is the row's identifier, though, that fact is weaker than it looks: we * put the identifier in the URL ourselves, and we had it only because we planted * the row. Every share link, invite link, password-reset link and unsubscribe * URL on the internet works exactly that way, and reporting one as "an * unauthenticated client can read another user's data" is a false positive * against a feature the developer asked for on purpose. * * The naive fix — stop counting a read as a crossing whenever the caller * supplied an identifier — would suppress insecure direct object reference, * which is the single most common real authorization bug there is. So the * question is never "was an id supplied". It is **could a stranger have obtained * it**, and that decomposes into facts this run can actually establish: * * 1. Entropy, read off the schema. An integer off a sequence is enumerable by * construction; a uuid is 122 random bits. Read off the column's type and * default rather than off the value we planted, because the value we * planted measures our own randomness, not the application's. * 2. Reachability, read off this run. If any request this caller could make * came back carrying the identifier without having been given it, then the * application hands the id out and it is not a secret, however random it * looks. * 3. Deliberateness, read off this run. Another endpoint performs *the same * lookup* — takes the same identifier — and answers it differently * depending on who asks: it serves the row's owner and refuses this caller. * Two id-addressed doors onto one table, one gated on identity and one not, * is what a sharing feature looks like from the outside. A merely scoped * collection is not enough and deliberately so: a list that shows only your * own rows beside an item lookup that shows anybody's is not sharing, it is * the textbook broken object-level check. * * All three, or it stays a finding. The residual risk of the class is a genuine * hole on a resource that is correctly guarded elsewhere — an unprotected * `/download` beside a protected `/:id` — and it is bounded by the class being * reported rather than dropped: the endpoint is named, the caller is named, and * the developer is asked to confirm. */ export interface IdentifierEntropy { /** True only when the schema *proves* the space is too large to enumerate. */ unguessable: boolean; /** One sentence, quoted into the report either way. */ reason: string; } /** * Can a stranger guess a value of this column? * * Deliberately one-sided: `unguessable` is claimed only where the schema settles * it. "We could not tell" comes back as guessable, which keeps the finding — * silence beats a guess, and the guess that costs least is the one that leaves a * possible hole reported. */ export declare function identifierEntropy(table: TableInfo | undefined, columnName: string): IdentifierEntropy; /** * Did this request name that row — as a whole value, not as a substring? * * The distinction the entire class rests on is between a caller who had to know * the identifier and one who did not, so this has to be exact. A substring test * says yes to almost anything on a short id: `1` is inside every URL with a `1` * anywhere in it, and `/api/v1/notes` would read as a request that named row 1. * A collection endpoint mistaken for an id-addressed one is the dangerous * direction — it is how a request needing no secret at all could be waved * through as though a link had authorized it. * * Compared per path segment, per query value and per body field, which is * exactly where {@link Variant} puts the identifier in its several forms. */ export declare function requestNames(path: string, body: unknown, value: string): boolean; /** * One cross-user API attempt, with the facts the classification needs and which * a `ProbeResult` does not carry: the route's *pattern* rather than the URL it * was substituted into, and whether the request supplied the victim's id or was * handed it back. */ export interface ApiAttempt { probe: ProbeResult; /** "GET /api/public/forms/:id" — the pattern, for grouping by endpoint. */ routeKey: string; variant: string; /** The owner asking for their own row, which proves the endpoint works. */ positive: boolean; /** * The victim's identifier as this request carried it, with the column it came * from. `null` for a collection request, which names nobody. */ supplied: { column: string; value: string; } | null; /** * True when the response contained the victim's identifier and the request did * not. That is the application handing the id to this caller. */ disclosedIdentifier: boolean; } /** * Mark the granted reads that were only reached by presenting an unguessable * identifier, and return them. * * Runs over the whole API phase at once rather than per route, because two of * the three facts are about *other* endpoints: whether any of them disclosed the * identifier, and whether any of them enforces the crossing. A route probed * first cannot be judged until the last one has run. */ export declare function classifyCapabilities(attempts: ApiAttempt[], tables: Map, /** * Entropy measured from the identifiers the application itself generated, * keyed `tableId:column`. * * For the run that has no database. There is no column to read a type and a * default off there, so the same question — could a stranger have obtained * this identifier — is answered from the values the application assigned to * the resources this run created. See `entropy.ts`, which is where the * measuring and all of its refusals live; this only takes the answer. * * Absent, everything below is exactly as it was: the schema decides, and a * table nobody could find in it is guessable, which keeps the finding. */ measured?: Map): CapabilityUrl[];