diff --git a/packages/safegres/README.md b/packages/safegres/README.md index 1a54c39f7..8563ae190 100644 --- a/packages/safegres/README.md +++ b/packages/safegres/README.md @@ -104,10 +104,10 @@ trust boundaries on the way. ## What it checks -29 rules across two dimensions. The prefix letter is a family, **not** the dimension: `P1`/`P1b` +30 rules across two dimensions. The prefix letter is a family, **not** the dimension: `P1`/`P1b` are performance, `P5` is security. -### Security (18 rules) +### Security (19 rules) | Code | Severity | Direction | Check | | --- | --- | --- | --- | @@ -128,12 +128,16 @@ are performance, `P5` is security. | L3 | low | fail-closed | **Unreachable grant** — object privilege without schema `USAGE` | | L4 | info | neutral | **Dead schema `USAGE`** — reaches no relation and no function | | L5 | info | fail-open | An untrusted role reaches an **RLS-off table** via PUBLIC/inheritance † | +| L6 | info | neutral | **Unaddressable grant** — an API role holds privileges on a relation its API cannot name ‡ | | W1 | medium | — | **No exposure surface configured** — whole database assumed reachable, score capped | † R1/R2/L5 are no-ops until you name the untrusted roles: `"R1": ["critical", { "roles": ["anonymous"] }]`. They cost nothing on databases without an untrusted-role model; the `safegres:constructive` preset configures them for `anonymous`. +‡ L6 needs an adapter that can compute [API reach](#api-reach--the-relations-the-api-can-actually-name); +without one nothing is unaddressable and it never fires. + **Direction is the load-bearing idea.** `fail-open` findings are exposure — the untrusted side reaches more than intended. `fail-closed` findings are *denied by Postgres at runtime*: an availability and hygiene concern, not a leak. They contribute **zero** to the score by default @@ -263,6 +267,7 @@ interface ExposureAdapter { name: string; detect(exec: QueryExecutor): Promise; // is this stack present? resolve(exec: QueryExecutor): Promise; // one or more planes + reach?(exec: QueryExecutor, ctx: ReachContext): Promise; // optional precision } ``` @@ -270,11 +275,42 @@ Built-ins ship for `constructive`, `postgrest`, `supabase`, `hasura` and `graphi the signal its stack actually leaves in the catalog (see [Configuration](#configuration)), and each emitting a primary `api` plane plus whatever secondary planes it can prove: one `api:` per API for Constructive, a `direct:` role plane for PostgREST, `app_private` as an -internal plane for graphile-starter. JSON configs may name a built-in +internal plane for graphile-starter. `postgraphile` is the exception: it contributes no plane at +all and only supplies [reach](#api-reach--the-relations-the-api-can-actually-name). JSON configs may name a built-in (`"adapters": ["supabase"]`); anything else is an error rather than a silent no-op — a typo'd adapter would otherwise present as an unexposed database. The old `"resolver": "constructive"` still works. +### API reach — the relations the API can actually name + +A plane made of schemas answers *is this relation in the API's schemas?*, which over-counts: a +generated API exposes fields, and a schema routinely holds relations it deliberately does not +surface — join tables, denormalized shadows, machine-only back-pointers. `reach()` is where an +adapter narrows a plane from its schemas to its **relations**. The built-in `postgraphile` adapter +reads the `@behavior` / `@forwardBehavior` / `@backwardBehavior` smart tags to do it, and both +`graphile` and `constructive` delegate to it — those two answer *which schemas are served*, which +is a different question from *what the served schemas expose*: + +```jsonc +{ "exposure": { "schemas": ["app_public"], "adapters": ["postgraphile"] } } +``` + +Three properties keep it from quietly deleting findings: + +- **Only an explicit denial counts.** Presets grant most behaviors by default, so the *absence* of + `+list` says nothing. Silence is never read as denial. +- **Unreachable means unreachable by every route.** A relation with no root entry is still + addressable by traversing a relation field from one that has, so reach is graph traversal over + foreign keys, not a per-table test. Hiding one reverse relation is one missing path, not proof. +- **A role plane is never narrowed.** The API not exposing a table says nothing about a role + holding a direct connection. Behavior only ever refines `api`/`schema` planes. + +Anything subtracted is listed in `report.exposure.unaddressable` rather than silently dropped, and +`"reach": false` turns the whole thing off. Where an API-edge role still holds privileges on a +relation its own API cannot name, **L6** reports the grant — unless some RLS policy predicate +references the relation, since a grant a policy subqueries under the querying role is load-bearing +however invisible it is to the API. + ## CI in one job One service container, your existing migration command, one audit: diff --git a/packages/safegres/__tests__/fixtures/reach.sql b/packages/safegres/__tests__/fixtures/reach.sql new file mode 100644 index 000000000..45fa3ebbb --- /dev/null +++ b/packages/safegres/__tests__/fixtures/reach.sql @@ -0,0 +1,69 @@ +-- One API schema whose generated surface is narrower than its schema. +-- +-- posts — fully exposed +-- comments — root-denied, but reachable as posts' reverse relation +-- audit_shadow — root-denied AND its reverse relation denied: unaddressable +-- policy_shadow — same denials, but a policy on posts subqueries it, so +-- revoking its grant would break authorization (L6 must +-- stay silent about it) + +CREATE SCHEMA IF NOT EXISTS fx_reach_api; + +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fx_reach_api_role') THEN + CREATE ROLE fx_reach_api_role NOLOGIN; + END IF; +END $$; + +GRANT USAGE ON SCHEMA fx_reach_api TO fx_reach_api_role; + +CREATE TABLE fx_reach_api.posts ( + id bigserial PRIMARY KEY, + owner_id uuid NOT NULL, + body text +); + +CREATE TABLE fx_reach_api.comments ( + id bigserial PRIMARY KEY, + post_id bigint NOT NULL REFERENCES fx_reach_api.posts (id), + body text +); + +CREATE TABLE fx_reach_api.audit_shadow ( + id bigserial PRIMARY KEY, + post_id bigint NOT NULL REFERENCES fx_reach_api.posts (id), + note text +); + +CREATE TABLE fx_reach_api.policy_shadow ( + id bigserial PRIMARY KEY, + post_id bigint NOT NULL REFERENCES fx_reach_api.posts (id), + owner_id uuid NOT NULL +); + +-- comments: no root entry, but the reverse relation on posts survives. +COMMENT ON TABLE fx_reach_api.comments IS '@behavior -select -insert -update -delete'; + +-- audit_shadow: no root entry and no relation field either way. +COMMENT ON TABLE fx_reach_api.audit_shadow IS '@behavior -select -insert -update -delete'; +COMMENT ON CONSTRAINT audit_shadow_post_id_fkey ON fx_reach_api.audit_shadow IS + E'@backwardBehavior -list -connection -single\n@forwardBehavior -single'; + +-- policy_shadow: identically hidden, but load-bearing for RLS on posts. +COMMENT ON TABLE fx_reach_api.policy_shadow IS '@behavior -select -insert -update -delete'; +COMMENT ON CONSTRAINT policy_shadow_post_id_fkey ON fx_reach_api.policy_shadow IS + E'@backwardBehavior -list -connection -single\n@forwardBehavior -single'; + +ALTER TABLE fx_reach_api.posts ENABLE ROW LEVEL SECURITY; +CREATE POLICY posts_select ON fx_reach_api.posts FOR SELECT TO fx_reach_api_role + USING ( + id IN ( + SELECT post_id FROM fx_reach_api.policy_shadow + WHERE owner_id = current_setting('jwt.claims.user_id', true)::uuid + ) + ); + +GRANT SELECT ON fx_reach_api.posts TO fx_reach_api_role; +GRANT SELECT ON fx_reach_api.comments TO fx_reach_api_role; +GRANT SELECT ON fx_reach_api.audit_shadow TO fx_reach_api_role; +GRANT SELECT ON fx_reach_api.policy_shadow TO fx_reach_api_role; diff --git a/packages/safegres/__tests__/reach.test.ts b/packages/safegres/__tests__/reach.test.ts new file mode 100644 index 000000000..5ca22aee8 --- /dev/null +++ b/packages/safegres/__tests__/reach.test.ts @@ -0,0 +1,275 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { getConnections, PgTestClient } from 'pgsql-test'; + +import { audit } from '../src/commands/audit'; +import { postgraphileAdapter } from '../src/exposure/adapters'; +import { resolvePlaneReach } from '../src/exposure/planes'; +import type { ApiReach } from '../src/exposure/reach'; +import { computeApiReach } from '../src/exposure/reach'; +import { emptyBehaviorSnapshot, parseBehaviorTags } from '../src/pg/behaviors'; +import type { ResolvedPlane } from '../src/pg/exposure'; +import { resolveReach } from '../src/pg/exposure'; +import type { TableSnapshot } from '../src/pg/introspect'; + +jest.setTimeout(120000); + +let pg: PgTestClient; +let teardown: () => Promise; + +const SCHEMA = 'fx_reach_api'; + +beforeAll(async () => { + ({ pg, teardown } = await getConnections()); + const sql = fs.readFileSync(path.join(__dirname, 'fixtures', 'reach.sql'), 'utf8'); + await pg.any(sql); +}); + +afterAll(async () => { + if (teardown) await teardown(); +}); + +function snapshot(entries: { + tables?: Record; + constraints?: Record; +}) { + const snap = emptyBehaviorSnapshot(); + for (const [key, value] of Object.entries(entries.tables ?? {})) { + snap.tables.set(key, value); + } + for (const [key, value] of Object.entries(entries.constraints ?? {})) { + snap.constraintDirections.set(key, parseBehaviorTags(value)); + } + return snap; +} + +describe('behavior tag parsing', () => { + it('keeps the three tags apart and lets a directional one win', () => { + const tags = parseBehaviorTags('@behavior -list\n@backwardBehavior -list -connection -single'); + expect(tags.both).toBe('-list'); + expect(tags.backward).toBe('-list -connection -single'); + expect(tags.forward).toBeUndefined(); + }); + + it('stops at the first non-tag line, as smart tags do', () => { + const tags = parseBehaviorTags('@behavior -select\nprose about the table\n@forwardBehavior -single'); + expect(tags.both).toBe('-select'); + expect(tags.forward).toBeUndefined(); + }); +}); + +describe('computeApiReach', () => { + const relations = ['s.parent', 's.child']; + const edges = [{ from: 's.child', to: 's.parent', constraint: 'child_parent_fkey' }]; + + it('says nothing when nothing is declared — silence is not denial', () => { + const reach = computeApiReach({ relations, edges, behaviors: emptyBehaviorSnapshot() }); + expect(reach.unreachable).toEqual([]); + expect(reach.hiddenBackwardRelations).toEqual([]); + }); + + it('keeps a root-denied relation that a surviving reverse relation still reaches', () => { + const reach = computeApiReach({ + relations, + edges, + behaviors: snapshot({ tables: { 's.child': '-select -insert -update -delete' } }) + }); + expect(reach.unreachable).toEqual([]); + }); + + it('reports the relation only when the root and every relation field are denied', () => { + const reach = computeApiReach({ + relations, + edges, + behaviors: snapshot({ + tables: { 's.child': '-select -insert -update -delete' }, + constraints: { + 's.child.child_parent_fkey': + '@backwardBehavior -list -connection -single\n@forwardBehavior -single' + } + }) + }); + expect(reach.unreachable).toEqual([ + { schema: 's', table: 'child', reason: expect.stringContaining('every relation field') } + ]); + }); + + it('does not treat a partial root denial as absence', () => { + const reach = computeApiReach({ + relations, + edges, + behaviors: snapshot({ + tables: { 's.child': '-select -insert' }, + constraints: { + 's.child.child_parent_fkey': + '@backwardBehavior -list -connection -single\n@forwardBehavior -single' + } + }) + }); + expect(reach.unreachable).toEqual([]); + }); + + it('reports a hidden reverse relation without calling the table unreachable', () => { + const reach = computeApiReach({ + relations, + edges, + behaviors: snapshot({ + constraints: { 's.child.child_parent_fkey': '@backwardBehavior -list -connection -single' } + }) + }); + expect(reach.hiddenBackwardRelations).toEqual(['s.child.child_parent_fkey']); + expect(reach.unreachable).toEqual([]); + }); + + it('applies an undirected @behavior to both directions', () => { + const reach = computeApiReach({ + relations, + edges, + behaviors: snapshot({ + tables: { 's.child': '-select -insert -update -delete' }, + constraints: { 's.child.child_parent_fkey': '@behavior -list -connection -single' } + }) + }); + expect(reach.hiddenBackwardRelations).toEqual(['s.child.child_parent_fkey']); + expect(reach.unreachable).toHaveLength(1); + }); + + it('walks more than one hop', () => { + const reach = computeApiReach({ + relations: ['s.a', 's.b', 's.c'], + edges: [ + { from: 's.b', to: 's.a', constraint: 'b_a_fkey' }, + { from: 's.c', to: 's.b', constraint: 'c_b_fkey' } + ], + behaviors: snapshot({ + tables: { + 's.b': '-select -insert -update -delete', + 's.c': '-select -insert -update -delete' + } + }) + }); + expect(reach.unreachable).toEqual([]); + }); +}); + +describe('the postgraphile adapter against a live catalog', () => { + it('detects behavior tags and resolves no planes of its own', async () => { + expect(await postgraphileAdapter.detect(pg.client as never)).toBe(true); + expect(await postgraphileAdapter.resolve(pg.client as never)).toEqual([]); + }); + + it('scopes to the whole database when no schema is named', async () => { + const reach = await postgraphileAdapter.reach!(pg.client as never, { schemas: [] }); + expect((reach.unreachable ?? []).map((r) => r.table).sort()).toEqual([ + 'audit_shadow', + 'policy_shadow' + ]); + }); + + it('subtracts only the relation denied at the root and in both directions', async () => { + const reach = await resolveReach(pg.client as never, { adapters: ['postgraphile'] }, { + schemas: [SCHEMA] + }); + + const names = (reach?.unreachable ?? []).map((r) => r.table).sort(); + expect(names).toEqual(['audit_shadow', 'policy_shadow']); + expect(reach!.hiddenBackwardRelations.sort()).toEqual([ + `${SCHEMA}.audit_shadow.audit_shadow_post_id_fkey`, + `${SCHEMA}.policy_shadow.policy_shadow_post_id_fkey` + ]); + }); +}); + +describe('plane reach', () => { + const tables = [ + { schema: SCHEMA, name: 'posts', grants: [] }, + { schema: SCHEMA, name: 'audit_shadow', grants: [] } + ] as unknown as TableSnapshot[]; + const apiReach: ApiReach = { + unreachable: [{ schema: SCHEMA, table: 'audit_shadow', reason: 'denied' }], + hiddenBackwardRelations: [] + }; + + it('narrows an api plane to what the API can address', () => { + const plane: ResolvedPlane = { + name: 'api', + kind: 'api', + primary: true, + source: 'config', + schemas: [SCHEMA], + roles: [], + anonRoles: [] + }; + const [reach] = resolvePlaneReach([plane], tables, new Map(), apiReach); + expect([...reach.relations]).toEqual([`${SCHEMA}.posts`]); + expect(reach.unaddressable).toHaveLength(1); + }); + + it('leaves a role plane alone — a grant is reachable whatever the API says', () => { + const plane: ResolvedPlane = { + name: 'direct', + kind: 'role', + primary: false, + source: 'config', + schemas: [], + roles: ['fx_reach_api_role'], + anonRoles: [] + }; + const withReach = resolvePlaneReach([plane], tables, new Map(), apiReach)[0]; + const withoutReach = resolvePlaneReach([plane], tables, new Map())[0]; + expect([...withReach.relations]).toEqual([...withoutReach.relations]); + expect(withReach.unaddressable).toBeUndefined(); + }); +}); + +describe('audit integration', () => { + const exposure = { + adapters: ['postgraphile'], + schemas: [SCHEMA], + roles: ['fx_reach_api_role'] + }; + + it('excludes unaddressable relations from the exposed surface and reports them', async () => { + const report = await audit(pg.client as never, { schemas: [SCHEMA], exposure }); + + expect(report.exposure!.exposedTables).toBe(2); + expect(report.exposure!.totalTables).toBe(4); + expect((report.exposure!.unaddressable ?? []).map((r) => r.table).sort()).toEqual([ + 'audit_shadow', + 'policy_shadow' + ]); + }); + + it('grades an unaddressable relation as unexposed', async () => { + const report = await audit(pg.client as never, { schemas: [SCHEMA], exposure }); + const shadow = report.findings.filter((f) => f.table === 'audit_shadow'); + expect(shadow.length).toBeGreaterThan(0); + expect(shadow.every((f) => f.exposed === false)).toBe(true); + }); + + it('reports L6 for the grant no request can use, and stays silent about the one a policy needs', async () => { + const report = await audit(pg.client as never, { schemas: [SCHEMA], exposure }); + const l6 = report.findings.filter((f) => f.code === 'L6'); + expect(l6.map((f) => f.table)).toEqual(['audit_shadow']); + expect(l6[0].role).toBe('fx_reach_api_role'); + }); + + it('changes nothing when reach is turned off', async () => { + const report = await audit(pg.client as never, { + schemas: [SCHEMA], + exposure: { ...exposure, reach: false } + }); + expect(report.exposure!.exposedTables).toBe(4); + expect(report.exposure!.unaddressable).toBeUndefined(); + expect(report.findings.some((f) => f.code === 'L6')).toBe(false); + }); + + it('marks the hidden reverse relations as a declared path signal', async () => { + const report = await audit(pg.client as never, { + schemas: [SCHEMA], + exposure, + perf: true + }); + expect(report.perf!.paths!.declaredHidden).toBe(2); + }); +}); diff --git a/packages/safegres/docs/rules.md b/packages/safegres/docs/rules.md index eea4db84b..a94b03c81 100644 --- a/packages/safegres/docs/rules.md +++ b/packages/safegres/docs/rules.md @@ -31,6 +31,15 @@ cell each `(relation, role, privilege)` triple lands in: plus schema composition: an object grant is unreachable without `USAGE` on its schema (L3), and `USAGE` that reaches no relation and no function is dead surface (L4). +L6 composes the lattice with [API reach](../README.md#api-reach--the-relations-the-api-can-actually-name): +an API-edge role holding privileges on a relation the generated API cannot address. It is not a +leak — the role would have to connect directly to use the grant — which is exactly why such grants +survive for years. Two conditions gate it, both deliberately conservative: an adapter must have +*proved* the relation unaddressable (silence in the behavior tags is never denial), and no RLS +policy predicate anywhere may reference the relation. The second is the important one: a policy +can subquery a table under the querying role, so the grant is load-bearing however invisible it is +to the API, and a naive recommendation to revoke it would break authorization at runtime. + Restrictive-only policies never count as coverage. `BYPASSRLS` and superuser roles are exempt from policy checks — they are not subject to RLS, so a "missing policy" finding for them would be noise. Policies are matched with `pg_has_role` semantics: a policy `TO authenticated` covers a diff --git a/packages/safegres/src/adapters.ts b/packages/safegres/src/adapters.ts index 148cb222d..d92023aaf 100644 --- a/packages/safegres/src/adapters.ts +++ b/packages/safegres/src/adapters.ts @@ -4,14 +4,16 @@ * import an adapter without pulling in the whole auditor. */ -export type { ExposureAdapter, PlaneInput } from './exposure/adapters'; +export type { ExposureAdapter, PlaneInput, ReachContext } from './exposure/adapters'; export { BUILTIN_ADAPTERS, constructiveAdapter, definePlanes, graphileAdapter, hasuraAdapter, + postgraphileAdapter, postgrestAdapter, resolveAdapters, supabaseAdapter } from './exposure/adapters'; +export type { ApiReach, UnreachableRelation } from './exposure/reach'; diff --git a/packages/safegres/src/checks/lattice.ts b/packages/safegres/src/checks/lattice.ts index 5478dbc6d..36ad1733e 100644 --- a/packages/safegres/src/checks/lattice.ts +++ b/packages/safegres/src/checks/lattice.ts @@ -324,6 +324,62 @@ export interface LatticeRoleOptions { rolesFrom?: 'exposure' | 'anon'; } +export interface UnaddressableGrantOptions { + /** API-edge roles: the roles requests actually arrive as. */ + roles: string[]; + /** + * Relations named by some RLS policy predicate anywhere in the database. + * + * The reason this parameter exists: a grant on a relation the API cannot + * address is *not* automatically dead, because a policy on a different + * relation may subquery it under the querying role. Revoking that grant + * breaks authorization at runtime, silently, everywhere. A relation any + * predicate mentions is therefore never reported, whatever the API says. + */ + policyReferenced: Set; +} + +/** + * L6: an API-edge role holds privileges on a relation its own API cannot + * address. + * + * The composition the two halves of safegres were built for: the lattice knows + * what a role effectively holds, exposure reach knows what the generated API + * can name, and the difference is grant surface that exists for nothing. It is + * not a leak — the role would have to connect directly to use it — which is + * exactly why it is the kind of finding that survives for years. + */ +export function checkUnaddressableGrant( + table: TableSnapshot, + graph: RoleGraph, + unaddressable: Set, + options: UnaddressableGrantOptions +): Finding[] { + const key = `${table.schema}.${table.name}`; + if (!unaddressable.has(key) || options.policyReferenced.has(key)) return []; + + const out: Finding[] = []; + for (const role of options.roles) { + const grants = effectiveGrants(table, role, graph).filter((e) => + RLS_PRIVILEGES.includes(e.privilege) + ); + if (grants.length === 0) continue; + const privileges = grants.map((e) => e.privilege).join(', '); + out.push({ + code: 'L6', + severity: 'info', + category: 'coverage', + schema: table.schema, + table: table.name, + role, + privilege: privileges, + message: `API role ${role} holds ${privileges} on ${key}, which the generated API declares it cannot address — the grant serves no request`, + hint: 'Revoke the grant, or drop the behavior denial if the relation is meant to be exposed. No policy predicate references this relation, so nothing else depends on the grant.' + }); + } + return out; +} + /** * L5: an untrusted role reaches an RLS-disabled table *indirectly* — through * a grant TO PUBLIC or by inheriting from a granted role. Direct grants are diff --git a/packages/safegres/src/commands/audit.ts b/packages/safegres/src/commands/audit.ts index 432efd097..cf4d84a10 100644 --- a/packages/safegres/src/commands/audit.ts +++ b/packages/safegres/src/commands/audit.ts @@ -27,6 +27,7 @@ import { checkDeadPolicies, checkDeadSchemaUsage, checkIndirectCoverageGaps, + checkUnaddressableGrant, checkUnreachableGrants, checkUntrustedIndirectAccess, computeRoleAccess, @@ -59,7 +60,7 @@ import { resolvePlaneReach, scorePlane, stampPlanes } from '../exposure/planes'; import { type ExplainReport, proveFindings } from '../perf/explain'; import { introspectRoleGraph, introspectSchemaAcls } from '../pg/acl'; import type { ResolvedExposure } from '../pg/exposure'; -import { resolveExposure, resolvePlanes } from '../pg/exposure'; +import { resolveExposure, resolvePlanes, resolveReach } from '../pg/exposure'; import { introspectFunctions } from '../pg/functions'; import { introspectIndexes, introspectViewBodies, type TableIndexSnapshot } from '../pg/indexes'; import { asExecutor, type IntrospectOptions, introspectTables, type QueryExecutor, type TableSnapshot } from '../pg/introspect'; @@ -158,6 +159,18 @@ export async function audit( const exposure = await resolveExposure(exec, exposureConfig); const exposedSchemas = new Set(exposure.schemas); const planes = await resolvePlanes(exec, exposureConfig, exposure); + // Relation-level precision within those schemas: what the generated API can + // actually name. Schema membership is the coarse answer; this is the fine + // one, and only an adapter that can prove it contributes. + const apiReach = exposure.known + ? await resolveReach(exec, exposureConfig, { + schemas: exposure.schemas, + excludeSchemas: options.excludeSchemas ?? config.excludeSchemas + }) + : undefined; + const unaddressable = new Set( + (apiReach?.unreachable ?? []).map((r) => `${r.schema}.${r.table}`) + ); const extensions = options.extensions ?? config.extensions; @@ -168,8 +181,13 @@ export async function audit( extensions }); + const isExposed = (schema: string, table?: string): boolean => { + if (!exposedSchemas.has(schema)) return false; + return table === undefined || !unaddressable.has(`${schema}.${table}`); + }; + const exposedTables = exposure.known - ? snapshot.filter((t) => exposedSchemas.has(t.schema)).length + ? snapshot.filter((t) => isExposed(t.schema, t.name)).length : snapshot.length; // Effective-access inputs for the lattice rules: the INHERIT-following @@ -181,6 +199,12 @@ export async function audit( }); const schemaAclsByName = new Map(schemaAcls.map((a) => [a.schema, a])); + // Roles requests arrive as, and the relations some policy predicate names. + // L6 needs both: the first is whose grants it is talking about, the second + // is the veto that stops it recommending a revoke of a load-bearing grant. + const apiRoles = exposure.roles ?? []; + const policyReferenced = policyReferencedRelations(snapshot); + let findings: Finding[] = []; // --- Performance dimension (opt-in): index hygiene --- @@ -207,7 +231,10 @@ export async function audit( schemas: options.schemas ?? config.schemas, excludeSchemas: options.excludeSchemas ?? config.excludeSchemas }), - { minPointers: config.perf?.paths?.minPointers } + { + minPointers: config.perf?.paths?.minPointers, + hiddenBackwardRelations: new Set(apiReach?.hiddenBackwardRelations ?? []) + } ) : new Map(); @@ -253,6 +280,14 @@ export async function audit( withExposedRoles(tableRules.get('L5')?.options as LatticeRoleOptions, exposure) ) ); + if (unaddressable.size > 0 && apiRoles.length > 0) { + findings.push( + ...checkUnaddressableGrant(table, roleGraph, unaddressable, { + roles: apiRoles, + policyReferenced + }) + ); + } // --- AST-level anti-patterns (and, with perf on, policy-aware index rules) --- if (!skipAst) { @@ -301,7 +336,7 @@ export async function audit( const meta = RULES_BY_CODE.get(f.code); if (meta && f.direction === undefined) f.direction = meta.direction; if (meta && f.dimension === undefined) f.dimension = dimensionOf(meta); - if (exposure.known && f.schema) f.exposed = exposedSchemas.has(f.schema); + if (exposure.known && f.schema) f.exposed = isExposed(f.schema, f.table); // A key that looks like a write-once provisioning pointer, where the // reviewer has chosen to read the finding rather than gate on it. Applied @@ -379,7 +414,10 @@ export async function audit( ? { anonRoles: exposure.anonRoles } : {}), exposedTables, - totalTables: snapshot.length + totalTables: snapshot.length, + ...(apiReach && apiReach.unreachable.length > 0 + ? { unaddressable: apiReach.unreachable.filter((r) => exposedSchemas.has(r.schema)) } + : {}) }; const securityFindings = findings.filter((f) => f.dimension !== 'perf'); @@ -406,7 +444,7 @@ export async function audit( // Access planes. The primary plane's score is `report.score` — computed // above, against the exposure surface — so declaring planes can never move // the headline number; the secondaries answer what the headline cannot. - const reaches = resolvePlaneReach(planes, snapshot, roleGraph); + const reaches = resolvePlaneReach(planes, snapshot, roleGraph, apiReach); stampPlanes(findings, reaches); if (reaches.length > 1) { report.planes = reaches.map((reach) => { @@ -468,12 +506,16 @@ export async function audit( if (paths.size > 0) { const all = [...paths.values()]; const shaped = all.filter((p) => p.assessment === 'write-once-shaped'); + const declaredHidden = all.filter((p) => + p.signals.some((s) => s.name === 'behavior-hidden') + ).length; perf.paths = { total: paths.size, read: all.filter((p) => p.assessment === 'read').length, writeOnceShaped: shaped.length, tables: new Set(shaped.map((p) => `${p.schema}.${p.table}`)).size, - onWriteOncePointer + onWriteOncePointer, + ...(declaredHidden > 0 ? { declaredHidden } : {}) }; } @@ -616,6 +658,37 @@ async function auditTableAst( return dedupe(findings); } +/** + * Relations named by any RLS policy predicate in the snapshot. + * + * Whole-word token matching over the predicate text, matching both the bare + * and the schema-qualified name. Deliberately over-eager: a spurious match + * costs one unreported L6, a missed one costs a recommendation to revoke a + * grant that authorization depends on. + */ +function policyReferencedRelations(tables: TableSnapshot[]): Set { + const tokens = new Set(); + for (const table of tables) { + for (const policy of table.policies) { + for (const clause of [policy.using, policy.withCheck]) { + if (!clause) continue; + for (const match of clause.matchAll(/[A-Za-z_][A-Za-z0-9_$]*(?:\.[A-Za-z_][A-Za-z0-9_$]*)?/g)) { + tokens.add(match[0].toLowerCase()); + } + } + } + } + + const referenced = new Set(); + for (const table of tables) { + const key = `${table.schema}.${table.name}`; + if (tokens.has(key.toLowerCase()) || tokens.has(table.name.toLowerCase())) { + referenced.add(key); + } + } + return referenced; +} + function compareFindings(a: Finding, b: Finding): number { const order: Record = { critical: 0, high: 1, medium: 2, low: 3, info: 4 }; if (order[a.severity] !== order[b.severity]) return order[a.severity] - order[b.severity]; diff --git a/packages/safegres/src/config/types.ts b/packages/safegres/src/config/types.ts index 8b9404164..2dd587079 100644 --- a/packages/safegres/src/config/types.ts +++ b/packages/safegres/src/config/types.ts @@ -40,12 +40,17 @@ export interface ExposureConfig { * (`routing_public.apis` → `api_schemas` → `metaschema_public.schema`, * plus the platform plane) to discover exposed schemas and API roles. * + * - `postgraphile`: contribute no planes, but read behavior tags so the + * planes another resolver or `schemas` establishes are narrowed from + * "every relation in the schema" to the relations the generated API can + * address. Pair it with `schemas`, or use it alone for the reach only. + * * Equivalent to listing the corresponding built-in in `adapters`. */ - resolver?: 'static' | 'constructive'; + resolver?: 'static' | 'constructive' | 'postgraphile'; /** * Exposure adapters: objects implementing `ExposureAdapter`, or the name of - * a built-in (`'constructive'`). An adapter whose `detect()` succeeds + * a built-in (`'constructive'`, `'postgraphile'`). An adapter whose `detect()` succeeds * contributes planes; static `schemas`/`roles` extend, never replace, what * it found. Adapters are values, not module names — a custom one is an * object you construct, and nothing is resolved by package name. @@ -63,6 +68,15 @@ export interface ExposureConfig { anonRoles?: string[]; /** Name of the primary plane. Default `api`. */ name?: string; + /** + * Let adapters narrow a plane's reach from its schemas to the relations the + * generated API can actually address (`postgraphile` reads behavior tags for + * this). Default `true`; an adapter that cannot answer changes nothing. + * + * Set `false` to grade every relation in an exposed schema as exposed, which + * is the safer reading if you do not trust your behavior declarations. + */ + reach?: boolean; /** * Additional access planes to grade: the ways into the database that are * not the declared API. Each is scored on the security axis with the same diff --git a/packages/safegres/src/exposure/adapters.ts b/packages/safegres/src/exposure/adapters.ts index 330b9072c..3349e66ff 100644 --- a/packages/safegres/src/exposure/adapters.ts +++ b/packages/safegres/src/exposure/adapters.ts @@ -21,7 +21,10 @@ */ import type { PlaneKind } from '../config/types'; +import { introspectBehaviors, SYSTEM_SCHEMAS } from '../pg/behaviors'; import type { QueryExecutor } from '../pg/introspect'; +import type { ApiReach, ReachEdge } from './reach'; +import { computeApiReach } from './reach'; /** A plane as an adapter reports it, before roles are resolved against the catalog. */ export interface PlaneInput { @@ -41,6 +44,13 @@ export interface PlaneInput { anonRoles?: string[]; } +/** What an adapter is asked to compute reach for. */ +export interface ReachContext { + /** Schemas on the plane. Empty means "the whole database". */ + schemas: string[]; + excludeSchemas?: string[]; +} + export interface ExposureAdapter { /** Stable identifier, reported as the exposure `source`. */ name: string; @@ -48,6 +58,15 @@ export interface ExposureAdapter { detect(exec: QueryExecutor): Promise; /** The planes this stack exposes. An empty array means "present, exposes nothing". */ resolve(exec: QueryExecutor): Promise; + /** + * Optional relation-level precision *within* a plane's schemas: which + * relations the stack's generated API cannot address at all. + * + * Separate from `resolve` because it answers a different question and most + * adapters cannot answer it. An adapter that knows the schemas but not the + * fields simply omits this, and the plane stays schema-granular. + */ + reach?(exec: QueryExecutor, context: ReachContext): Promise; } /** @@ -158,6 +177,90 @@ export const constructiveAdapter: ExposureAdapter = { } } return planes; + }, + + // A Constructive API *is* a PostGraphile API, so the reach question has the + // same answer. Delegated rather than duplicated, and defined below the + // delegate — see `postgraphileAdapter`. + reach: (exec, context) => postgraphileAdapter.reach!(exec, context) +}; + +/** + * PostGraphile behaviors as relation-level reach. + * + * Deliberately *not* folded into {@link constructiveAdapter}, even though + * Constructive is a PostGraphile stack: behaviors are a Graphile convention + * that any Graphile database follows, and each adapter should say one true + * thing. This one contributes no planes — it has no idea which schemas an API + * serves — and only narrows the planes another adapter, or the config, already + * established. + */ +export const postgraphileAdapter: ExposureAdapter = { + name: 'postgraphile', + + async detect(exec: QueryExecutor): Promise { + const { rows } = await exec.query<{ ok: boolean }>( + `SELECT EXISTS ( + SELECT 1 FROM pg_description + WHERE description ~ '(^|\n)@(behavior|forwardBehavior|backwardBehavior)\\s' + ) AS ok` + ); + return rows[0]?.ok === true; + }, + + async resolve(): Promise { + return []; + }, + + async reach(exec: QueryExecutor, context: ReachContext): Promise { + const behaviors = await introspectBehaviors(exec, { + schemas: context.schemas, + excludeSchemas: context.excludeSchemas + }); + + // `schemas` empty means "the whole database", so the filter is built + // rather than passed as an always-bound parameter: an unreferenced + // placeholder has no inferable type and Postgres rejects the statement. + const params: string[][] = [[...SYSTEM_SCHEMAS]]; + const filters: string[] = [`n.nspname <> ALL ($1::text[])`]; + if (context.schemas.length > 0) { + params.push(context.schemas); + filters.push(`n.nspname = ANY ($${params.length}::text[])`); + } + if (context.excludeSchemas && context.excludeSchemas.length > 0) { + params.push(context.excludeSchemas); + filters.push(`n.nspname <> ALL ($${params.length}::text[])`); + } + + const { rows } = await exec.query<{ + relation: string; + constraint_name: string | null; + references: string | null; + }>( + `SELECT n.nspname || '.' || c.relname AS relation, + co.conname AS constraint_name, + CASE WHEN co.oid IS NULL THEN NULL + ELSE fn.nspname || '.' || fc.relname END AS references + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + LEFT JOIN pg_constraint co ON co.conrelid = c.oid AND co.contype = 'f' + LEFT JOIN pg_class fc ON fc.oid = co.confrelid + LEFT JOIN pg_namespace fn ON fn.oid = fc.relnamespace + WHERE c.relkind IN ('r', 'p') + AND ${filters.join('\n AND ')}`, + params + ); + + const relations = new Set(); + const edges: ReachEdge[] = []; + for (const row of rows) { + relations.add(row.relation); + if (row.constraint_name && row.references) { + edges.push({ from: row.relation, to: row.references, constraint: row.constraint_name }); + } + } + + return computeApiReach({ relations: [...relations].sort(), edges, behaviors }); } }; @@ -322,6 +425,11 @@ export const hasuraAdapter: ExposureAdapter = { * is consent: listing this adapter *is* the declaration that the convention * holds here. When it doesn't, list `exposure.schemas` instead — explicit * always wins. + * + * Reach is a separate question — *which schemas are served* versus *which + * relations the served schemas expose* — and is delegated to + * {@link postgraphileAdapter}, which answers it for any Graphile stack + * regardless of schema layout. */ export const graphileAdapter: ExposureAdapter = { name: 'graphile', @@ -362,7 +470,9 @@ export const graphileAdapter: ExposureAdapter = { planes.push({ name: 'internal', kind: 'schema', schemas: ['app_private'] }); } return planes; - } + }, + + reach: (exec, context) => postgraphileAdapter.reach!(exec, context) }; /** @@ -402,6 +512,7 @@ export const BUILTIN_ADAPTERS: Record = { constructive: constructiveAdapter, graphile: graphileAdapter, hasura: hasuraAdapter, + postgraphile: postgraphileAdapter, postgrest: postgrestAdapter, supabase: supabaseAdapter }; diff --git a/packages/safegres/src/exposure/planes.ts b/packages/safegres/src/exposure/planes.ts index 50254231a..a481031da 100644 --- a/packages/safegres/src/exposure/planes.ts +++ b/packages/safegres/src/exposure/planes.ts @@ -18,6 +18,7 @@ import type { TableSnapshot } from '../pg/introspect'; import { computeScore } from '../score/score'; import type { Finding, PlaneReport } from '../types'; import { summarize } from '../types'; +import type { ApiReach, UnreachableRelation } from './reach'; /** A plane with its reach resolved against the catalog. */ export interface PlaneReach { @@ -30,6 +31,12 @@ export interface PlaneReach { reachedVia?: 'grant' | 'PUBLIC' | 'inheritance'; /** Why a declared plane was not graded. */ skipped?: string; + /** + * Relations in the plane's schemas that its API cannot address, subtracted + * from `relations`. Reported so the subtraction is auditable rather than + * invisible. + */ + unaddressable?: UnreachableRelation[]; } export function relationKey(schema: string, table: string): string { @@ -46,16 +53,32 @@ export function relationKey(schema: string, table: string): string { export function resolvePlaneReach( planes: ResolvedPlane[], tables: TableSnapshot[], - graph: RoleGraph + graph: RoleGraph, + apiReach?: ApiReach ): PlaneReach[] { return planes.map((plane) => { + // A role plane is grant-truth and no API declaration narrows it: the + // GraphQL API not exposing a table says nothing about a role holding a + // direct connection to the database. This is the whole reason relation + // reach is safe to apply at all. if (plane.kind === 'role') return roleReach(plane, tables, graph); const schemas = new Set(plane.schemas); const relations = new Set( tables.filter((t) => schemas.has(t.schema)).map((t) => relationKey(t.schema, t.name)) ); - return { plane, relations, schemas: [...schemas].sort() }; + + const unaddressable = (apiReach?.unreachable ?? []).filter((r) => + relations.has(relationKey(r.schema, r.table)) + ); + for (const r of unaddressable) relations.delete(relationKey(r.schema, r.table)); + + return { + plane, + relations, + schemas: [...schemas].sort(), + ...(unaddressable.length > 0 ? { unaddressable } : {}) + }; }); } @@ -156,6 +179,9 @@ export function scorePlane( schemas: reach.schemas, ...(plane.roles.length > 0 ? { roles: plane.roles } : {}), exposedTables: reach.relations.size, + ...(reach.unaddressable && reach.unaddressable.length > 0 + ? { unaddressableTables: reach.unaddressable.length } + : {}), ...(reach.reachedVia ? { reachedVia: reach.reachedVia } : {}), score: computeScore(findings, scoring, { exposedTables: reach.relations.size, diff --git a/packages/safegres/src/exposure/reach.ts b/packages/safegres/src/exposure/reach.ts new file mode 100644 index 000000000..55dc3b8c3 --- /dev/null +++ b/packages/safegres/src/exposure/reach.ts @@ -0,0 +1,153 @@ +/** + * API reach: which relations a generated API can actually address. + * + * A plane made of schemas answers "is this relation in the API's schemas?", + * which is as much precision as a schema list can carry. It over-counts in a + * way that matters: a generated API exposes types and fields, not schemas, and + * a schema routinely contains relations the API deliberately does not surface + * — join tables, denormalised shadows, machine-only back-pointers. + * + * Where the author has *declared* that, the declaration is better evidence + * than anything else safegres can collect. `reltuples` and `idx_scan` are + * measurements, and safegres grades ephemeral CI databases that have never + * held data, so both read zero at exactly the moment they would have to mean + * something. A behavior tag reads the same in CI as in production. + * + * Two properties keep this honest: + * + * - **Only an explicit denial counts.** Presets grant most behaviors by + * default, so the absence of `+list` says nothing whatsoever. Silence is + * never read as denial. + * - **Unreachable means unreachable by *every* route.** A relation the API + * cannot address at the root can still be addressed by traversing a relation + * field from one that can, so this is a graph reachability problem and not a + * per-table test. Subtracting a relation that is in fact addressable would + * silently drop real findings out of the score, which is the one failure + * mode worth designing against. + */ + +import type { BehaviorSnapshot } from '../pg/behaviors'; +import { deniesAll, directionalBehavior } from '../pg/behaviors'; + +/** + * Abilities that between them cover every root entry point to a relation. + * All four must be denied before the relation is absent from the API root: a + * table that cannot be read but can still be inserted into is present. + */ +export const ROOT_ABILITIES = ['select', 'insert', 'update', 'delete']; + +/** The reverse (child-listing) field exists under any one of these. */ +export const BACKWARD_ABILITIES = ['list', 'connection', 'single']; + +/** The forward (parent) field is a single record. */ +export const FORWARD_ABILITIES = ['single']; + +/** One foreign key, as reach cares about it. */ +export interface ReachEdge { + /** Referencing relation, `schema.table`. */ + from: string; + /** Referenced relation, `schema.table`. */ + to: string; + constraint: string; +} + +export interface ReachInputs { + /** Every relation in scope, `schema.table`. */ + relations: string[]; + edges: ReachEdge[]; + behaviors: BehaviorSnapshot; +} + +export interface UnreachableRelation { + schema: string; + table: string; + /** Why it is unreachable, in one human-readable clause. */ + reason: string; +} + +export interface ApiReach { + /** Relations no field of the generated API can address. */ + unreachable: UnreachableRelation[]; + /** + * `schema.table.constraint` for every foreign key whose reverse relation is + * declared absent. A hidden reverse relation is not the same claim as an + * unreachable table — it is one path, not all of them — so it is reported + * separately and consumed by the X1 access-path signals. + */ + hiddenBackwardRelations: string[]; +} + +function splitRelation(relation: string): { schema: string; table: string } { + const dot = relation.indexOf('.'); + return { schema: relation.slice(0, dot), table: relation.slice(dot + 1) }; +} + +/** + * Compute what the generated API can address. + * + * The traversal starts from every relation the API roots — anything not + * explicitly denied all of {@link ROOT_ABILITIES} — and walks relation fields + * in both directions. What it never visits is a relation that is denied at the + * root *and* has no surviving field pointing at it from anywhere reachable. + */ +export function computeApiReach(inputs: ReachInputs): ApiReach { + const { relations, edges, behaviors } = inputs; + const inScope = new Set(relations); + + const rooted = relations.filter( + (relation) => !deniesAll(behaviors.tables.get(relation), ROOT_ABILITIES) + ); + + const hiddenBackwardRelations: string[] = []; + const adjacency = new Map(); + const addEdge = (from: string, to: string): void => { + const list = adjacency.get(from); + if (list) list.push(to); + else adjacency.set(from, [to]); + }; + + for (const edge of edges) { + const key = `${edge.from}.${edge.constraint}`; + const directions = behaviors.constraintDirections.get(key); + + const backwardHidden = deniesAll( + directionalBehavior(directions, 'backward'), + BACKWARD_ABILITIES + ); + if (backwardHidden) hiddenBackwardRelations.push(key); + // The reverse field lives on the referenced table and reaches the + // referencing one. + if (!backwardHidden && inScope.has(edge.to)) addEdge(edge.to, edge.from); + + const forwardHidden = deniesAll(directionalBehavior(directions, 'forward'), FORWARD_ABILITIES); + if (!forwardHidden && inScope.has(edge.from)) addEdge(edge.from, edge.to); + } + + const reached = new Set(); + const queue = [...rooted]; + for (const relation of rooted) reached.add(relation); + while (queue.length > 0) { + const current = queue.pop() as string; + for (const next of adjacency.get(current) ?? []) { + if (!inScope.has(next) || reached.has(next)) continue; + reached.add(next); + queue.push(next); + } + } + + const unreachable: UnreachableRelation[] = []; + for (const relation of relations) { + if (reached.has(relation)) continue; + const inbound = edges.filter((e) => e.to === relation || e.from === relation).length; + unreachable.push({ + ...splitRelation(relation), + reason: + `a behavior denies ${ROOT_ABILITIES.join(', ')} on the relation` + + (inbound > 0 + ? ', and every relation field that could reach it is denied too' + : ', and no relation field points at it') + }); + } + + return { unreachable, hiddenBackwardRelations: hiddenBackwardRelations.sort() }; +} diff --git a/packages/safegres/src/index.ts b/packages/safegres/src/index.ts index fa9dc32eb..66562c27b 100644 --- a/packages/safegres/src/index.ts +++ b/packages/safegres/src/index.ts @@ -124,13 +124,14 @@ export type { } from './config/types'; export type { CaseResult, CorpusCase, ExpectedFinding } from './corpus'; export { corpusDir, gradeCase, loadCase, loadCorpus } from './corpus'; -export type { ExposureAdapter, PlaneInput } from './exposure/adapters'; +export type { ExposureAdapter, PlaneInput, ReachContext } from './exposure/adapters'; export { BUILTIN_ADAPTERS, constructiveAdapter, definePlanes, graphileAdapter, hasuraAdapter, + postgraphileAdapter, postgrestAdapter, resolveAdapters, supabaseAdapter @@ -143,6 +144,13 @@ export { scorePlane, stampPlanes } from './exposure/planes'; +export type { ApiReach, ReachEdge, ReachInputs, UnreachableRelation } from './exposure/reach'; +export { + BACKWARD_ABILITIES, + computeApiReach, + FORWARD_ABILITIES, + ROOT_ABILITIES +} from './exposure/reach'; export type { BaselineFinding, PerfBaseline, PerfDiff } from './perf/baseline'; export { diffPerf, @@ -162,6 +170,7 @@ export { resolveConstructiveExposure, resolveExposure, resolvePlanes, + resolveReach, UNKNOWN_EXPOSURE } from './pg/exposure'; export type { FunctionGrant, FunctionSnapshot, IntrospectFunctionOptions } from './pg/functions'; diff --git a/packages/safegres/src/pg/behaviors.ts b/packages/safegres/src/pg/behaviors.ts new file mode 100644 index 000000000..5a1d0cb46 --- /dev/null +++ b/packages/safegres/src/pg/behaviors.ts @@ -0,0 +1,261 @@ +/** + * PostGraphile *behaviors*, read from the object comments they are declared in. + * + * A behavior string is the schema author stating which parts of the generated + * API an object participates in — `@behavior -list -connection` on a foreign + * key constraint says the reverse relation is not exposed. That is a different + * kind of evidence from anything else safegres collects: `pg_class.reltuples` + * and `pg_stat_user_indexes.idx_scan` are *measurements*, and safegres grades + * an ephemeral CI database that has never held data, so both read zero at + * exactly the moment they would have to mean something. A declaration reads the + * same in CI as in production. + * + * Read from the comment rather than from a running Graphile instance, or from + * whatever tables the author generated the comment out of: + * + * - `@behavior` in a comment is the PostGraphile v5 convention, so this works + * on any Graphile database rather than one project's metadata schema. + * - The comment is the composed value — one tag per object — so it is already + * the author's resolved intent. + * - A live instance would additionally resolve preset defaults, but requires an + * API to be running. safegres grades a database. + * + * The consequence of that last point is the rule this module exists to enforce: + * **only an explicit negative fragment is evidence.** Presets grant most + * behaviors by default, so the *absence* of `+list` says nothing whatsoever. A + * scanner that reads silence as denial is a scanner that tells you to drop an + * index a live API is using. + */ + +import type { IntrospectOptions, QueryExecutor } from './introspect'; + +/** One `[+|-]scope` term of a behavior string. */ +export interface BehaviorFragment { + modifier: '+' | '-'; + /** + * The scope as written, e.g. `list`, `resource:connection`, `*`. Scope paths + * are `:`-separated and increasingly specific left to right. + */ + scope: string; +} + +/** + * The three behavior tags a relation can carry. + * + * `@behavior` applies to *both* directions of a foreign key. Graphile also + * accepts `@forwardBehavior` and `@backwardBehavior`, which apply to only one + * — and the distinction is not cosmetic: denying `list` with a plain + * `@behavior` removes the forward `post.author` field as well as the reverse + * `author.posts` list, which is almost never what the author meant. + */ +export interface ConstraintBehaviors { + /** `@behavior` — applies to both directions. */ + both?: string; + /** `@forwardBehavior` — the referencing table's field pointing at the parent. */ + forward?: string; + /** `@backwardBehavior` — the referenced table's field listing the children. */ + backward?: string; +} + +/** Behaviors found on each kind of object, keyed as described on each map. */ +export interface BehaviorSnapshot { + /** `schema.table` → behavior string. */ + tables: Map; + /** `schema.table.column` → behavior string. */ + columns: Map; + /** `schema.table.constraint` → behavior string. */ + constraints: Map; + /** `schema.table.constraint` → the directional tags on that constraint. */ + constraintDirections: Map; +} + +export function emptyBehaviorSnapshot(): BehaviorSnapshot { + return { + tables: new Map(), + columns: new Map(), + constraints: new Map(), + constraintDirections: new Map() + }; +} + +/** + * The behavior governing one direction of a relation: the directional tag when + * present, else the undirected one. They do not merge — Graphile resolves the + * specific tag against the preset, and treating `@behavior -list` as an extra + * fragment on top of `@backwardBehavior +list` would invert the author's + * override. + */ +export function directionalBehavior( + behaviors: ConstraintBehaviors | undefined, + direction: 'forward' | 'backward' +): string | undefined { + if (!behaviors) return undefined; + return behaviors[direction] ?? behaviors.both; +} + +/** Catalog schemas no API surface ever contains. */ +export const SYSTEM_SCHEMAS = ['pg_catalog', 'information_schema', 'pg_toast']; + +const DEFAULT_EXCLUDES = SYSTEM_SCHEMAS; + +/** + * Extract the `@behavior` smart tag from an object comment. + * + * Smart tags occupy the leading lines of the comment, `@tag [value]`, and stop + * at the first line that is not one; everything after is the description. Only + * `@behavior` is returned — `@omit` is deliberately not translated here, since + * its v4 semantics ("remove from the schema") are not the same question as a + * behavior fragment and conflating them would put a guess in the evidence. + */ +export function parseBehaviorTag(comment: string | null | undefined): string | null { + return parseBehaviorTags(comment).both ?? null; +} + +/** Every behavior tag on a comment, by direction. */ +export function parseBehaviorTags(comment: string | null | undefined): ConstraintBehaviors { + const out: ConstraintBehaviors = {}; + if (!comment) return out; + for (const line of comment.split('\n')) { + const trimmed = line.trim(); + if (!trimmed.startsWith('@')) break; + const match = /^@(behavior|forwardBehavior|backwardBehavior)\s+(.+)$/.exec(trimmed); + if (!match) continue; + const value = match[2].trim(); + if (match[1] === 'behavior') out.both = value; + else if (match[1] === 'forwardBehavior') out.forward = value; + else out.backward = value; + } + return out; +} + +/** Split a behavior string into fragments. A bare scope is a grant, as in PostGraphile. */ +export function parseFragments(behavior: string | null | undefined): BehaviorFragment[] { + if (!behavior) return []; + const out: BehaviorFragment[] = []; + for (const term of behavior.trim().split(/\s+/)) { + if (!term) continue; + const modifier = term[0] === '-' ? '-' : '+'; + const scope = term[0] === '+' || term[0] === '-' ? term.slice(1) : term; + if (scope) out.push({ modifier, scope }); + } + return out; +} + +/** + * Does this fragment's scope speak to `ability`? + * + * Scope paths are `:`-separated and get more specific left to right + * (`resource:connection`), so the ability is the final segment. `*` matches + * anything, which is how `-*` denies wholesale. + */ +function scopeMatches(scope: string, ability: string): boolean { + const segments = scope.split(':'); + const last = segments[segments.length - 1]; + return last === ability || last === '*'; +} + +/** + * Resolve one ability against a behavior string. + * + * `undefined` means *undeclared*, and is the answer that matters: it is not + * `false`. Later fragments win, matching PostGraphile — `-* +list` grants + * `list` and denies the rest. + */ +export function resolveAbility( + behavior: string | null | undefined, + ability: string +): boolean | undefined { + let verdict: boolean | undefined; + for (const fragment of parseFragments(behavior)) { + if (scopeMatches(fragment.scope, ability)) verdict = fragment.modifier === '+'; + } + return verdict; +} + +/** + * True when *every* one of `abilities` is explicitly denied. + * + * Every, not some: a relation reachable as a single record is still reachable, + * and one undeclared ability is enough to leave the question open. + */ +export function deniesAll(behavior: string | null | undefined, abilities: string[]): boolean { + if (abilities.length === 0) return false; + return abilities.every((ability) => resolveAbility(behavior, ability) === false); +} + +/** + * Behavior tags on every table, column and constraint in scope. + * + * Extension-owned objects are not filtered here: the maps are only ever + * consulted for relations that survived the scan's own filtering, so an extra + * entry costs a map slot and changes nothing. + */ +export async function introspectBehaviors( + exec: QueryExecutor, + options: Pick = {} +): Promise { + const excludes = [...DEFAULT_EXCLUDES, ...(options.excludeSchemas ?? [])]; + const schemaFilter = options.schemas && options.schemas.length > 0 + ? `n.nspname = ANY($1::text[])` + : `NOT (n.nspname = ANY($2::text[]))`; + + // Both parameters are referenced (even when only one filters) so Postgres can + // infer their types — an unused $N errors out at bind time. + const sql = ` + WITH _params AS ( + SELECT $1::text[] AS include_schemas, $2::text[] AS exclude_schemas + ), + rels AS ( + SELECT c.oid, n.nspname AS schema_name, c.relname AS table_name + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind IN ('r', 'p', 'v', 'm') + AND ${schemaFilter} + ) + SELECT 'table'::text AS kind, r.schema_name, r.table_name, NULL::text AS member, d.description + FROM rels r + JOIN pg_description d + ON d.objoid = r.oid AND d.classoid = 'pg_class'::regclass AND d.objsubid = 0 + UNION ALL + SELECT 'column', r.schema_name, r.table_name, a.attname, d.description + FROM rels r + JOIN pg_attribute a ON a.attrelid = r.oid AND a.attnum > 0 AND NOT a.attisdropped + JOIN pg_description d + ON d.objoid = r.oid AND d.classoid = 'pg_class'::regclass AND d.objsubid = a.attnum + UNION ALL + SELECT 'constraint', r.schema_name, r.table_name, co.conname, d.description + FROM rels r + JOIN pg_constraint co ON co.conrelid = r.oid + JOIN pg_description d + ON d.objoid = co.oid AND d.classoid = 'pg_constraint'::regclass + `; + + const { rows } = await exec.query<{ + kind: 'table' | 'column' | 'constraint'; + schema_name: string; + table_name: string; + member: string | null; + description: string | null; + }>(sql, [options.schemas ?? [], excludes]); + + const snapshot = emptyBehaviorSnapshot(); + for (const row of rows) { + const tags = parseBehaviorTags(row.description); + const relation = `${row.schema_name}.${row.table_name}`; + if (row.kind === 'table') { + if (tags.both) snapshot.tables.set(relation, tags.both); + continue; + } + if (!row.member) continue; + const key = `${relation}.${row.member}`; + if (row.kind === 'column') { + if (tags.both) snapshot.columns.set(key, tags.both); + continue; + } + if (tags.both) snapshot.constraints.set(key, tags.both); + if (tags.both || tags.forward || tags.backward) { + snapshot.constraintDirections.set(key, tags); + } + } + return snapshot; +} diff --git a/packages/safegres/src/pg/exposure.ts b/packages/safegres/src/pg/exposure.ts index 1ffde2370..a81deef63 100644 --- a/packages/safegres/src/pg/exposure.ts +++ b/packages/safegres/src/pg/exposure.ts @@ -11,6 +11,7 @@ import type { ExposureConfig, PlaneKind } from '../config/types'; import type { ExposureAdapter, PlaneInput } from '../exposure/adapters'; import { BUILTIN_ADAPTERS, resolveAdapters } from '../exposure/adapters'; +import type { ApiReach, UnreachableRelation } from '../exposure/reach'; import type { QueryExecutor } from './introspect'; export interface ResolvedExposure { @@ -154,6 +155,50 @@ export async function resolvePlanes( return planes; } +/** + * Relation-level reach for the exposed schemas, from whichever adapters can + * compute it. + * + * Adapters *intersect*: a relation is subtracted from a plane only when every + * adapter that has an opinion agrees it is unaddressable. One adapter's + * silence is not agreement, so an adapter without `reach` is not consulted, + * but an adapter that ran and did not name a relation is a positive vote to + * keep it. + */ +export async function resolveReach( + exec: QueryExecutor, + config: ExposureConfig | undefined, + context: { schemas: string[]; excludeSchemas?: string[] } +): Promise { + if (!config || config.reach === false) return undefined; + + let agreed: Map | undefined; + const hidden = new Set(); + let ran = false; + + for (const adapter of adaptersFor(config)) { + if (!adapter.reach) continue; + if (!(await adapter.detect(exec))) continue; + const reach = await adapter.reach(exec, context); + ran = true; + for (const key of reach.hiddenBackwardRelations) hidden.add(key); + + const named = new Map( + reach.unreachable.map((r) => [`${r.schema}.${r.table}`, r]) + ); + if (agreed === undefined) agreed = named; + else for (const key of [...agreed.keys()]) if (!named.has(key)) agreed.delete(key); + } + + if (!ran) return undefined; + return { + unreachable: [...(agreed?.values() ?? [])].sort((a, b) => + `${a.schema}.${a.table}`.localeCompare(`${b.schema}.${b.table}`) + ), + hiddenBackwardRelations: [...hidden].sort() + }; +} + function defaultKind(plane: PlaneInput): PlaneKind { if (plane.kind) return plane.kind; return plane.roles && plane.roles.length > 0 && !(plane.schemas && plane.schemas.length > 0) diff --git a/packages/safegres/src/pg/paths.ts b/packages/safegres/src/pg/paths.ts index f24d24ab6..54a9d7dc9 100644 --- a/packages/safegres/src/pg/paths.ts +++ b/packages/safegres/src/pg/paths.ts @@ -28,11 +28,15 @@ * * Shape alone must never suppress a finding. A generated API can expose a * reverse relation over any foreign key regardless of how its default is - * written, and if it does, the path is reachable and the index is wanted. The - * signal that settles it is therefore the one this module does *not* yet have: - * whether the generated GraphQL surface still contains the field. Add it as - * another {@link PathSignal} — the shape of the API is designed for that — and - * only then is `unreachable` a conclusion anything should act on. + * written, and if it does, the path is reachable and the index is wanted. + * + * - `behavior-hidden` is the third kind, **declared**: the schema author has + * said the reverse relation is not in the generated API. It comes from the + * same behavior tags exposure reach reads, so the two axes agree about what + * the API contains rather than each guessing separately. It is reported and + * not acted on — a hidden relation is one missing path, not proof that + * nothing traverses the key, since the referential-integrity scan on a + * parent `DELETE` runs whatever the API exposes. */ import type { TableIndexSnapshot } from './indexes'; @@ -43,9 +47,14 @@ import type { TableSnapshot } from './introspect'; * key; `shape` means the schema resembles an idiom in which nothing does, which * is a suspicion rather than a finding. */ -export type SignalDirection = 'read' | 'shape'; +export type SignalDirection = 'read' | 'shape' | 'declared'; -export type SignalName = 'policy-read' | 'view-read' | 'write-once-pointer' | 'config-record'; +export type SignalName = + | 'policy-read' + | 'view-read' + | 'write-once-pointer' + | 'config-record' + | 'behavior-hidden'; export interface PathSignal { name: SignalName; @@ -87,6 +96,11 @@ export interface ClassifyOptions { * the nullable, undefaulted pointer sitting alongside the defaulted ones. */ minPointers?: number; + /** + * `schema.table.constraint` keys whose reverse relation an API declares + * absent, as computed by exposure reach. + */ + hiddenBackwardRelations?: Set; } export const DEFAULT_MIN_POINTERS = 2; @@ -138,6 +152,7 @@ export function classifyPaths( options: ClassifyOptions = {} ): Map { const minPointers = options.minPointers ?? DEFAULT_MIN_POINTERS; + const hidden = options.hiddenBackwardRelations ?? new Set(); const policyTokens = identifierTokens( tables.flatMap((t) => t.policies.flatMap((p) => [p.using, p.withCheck])) ); @@ -194,6 +209,14 @@ export function classifyPaths( }); } + if (hidden.has(pathKey(table.schema, table.name, fk.name))) { + signals.push({ + name: 'behavior-hidden', + direction: 'declared', + detail: `a behavior declares the reverse relation over ${fk.name} absent from the API` + }); + } + paths.set(pathKey(table.schema, table.name, fk.name), { schema: table.schema, table: table.name, diff --git a/packages/safegres/src/report/markdown.ts b/packages/safegres/src/report/markdown.ts index f28ee10f4..9f566d043 100644 --- a/packages/safegres/src/report/markdown.ts +++ b/packages/safegres/src/report/markdown.ts @@ -60,11 +60,15 @@ export function renderMarkdown(report: Report, options: RenderMarkdownOptions = out.push(...scoreTable(report, options), ''); if (report.exposure) { - const { known, source, exposedTables, totalTables, roles } = report.exposure; + const { known, source, exposedTables, totalTables, roles, unaddressable } = report.exposure; out.push( known ? `Exposure (${source}): **${exposedTables}/${totalTables}** tables reachable` + `${roles && roles.length > 0 ? ` via \`${roles.join('`, `')}\`` : ''}.` + + (unaddressable && unaddressable.length > 0 + ? ` ${unaddressable.length} relation(s) in those schemas are declared unaddressable` + + ` by the API: ${unaddressable.map((r) => `\`${r.schema}.${r.table}\``).join(', ')}.` + : '') : '> [!WARNING]\n> Exposure unknown — the entire database is assumed reachable and the score is capped.', '' ); diff --git a/packages/safegres/src/report/pretty.ts b/packages/safegres/src/report/pretty.ts index c8cabd4a2..139745f6c 100644 --- a/packages/safegres/src/report/pretty.ts +++ b/packages/safegres/src/report/pretty.ts @@ -70,6 +70,12 @@ export function renderPretty(report: Report, options: RenderPrettyOptions = {}): const roles = exposure.roles.map((r) => (anon.has(r) ? `${r} (anon)` : r)); lines.push(` api roles: ${roles.join(', ')}`); } + if (exposure.unaddressable && exposure.unaddressable.length > 0) { + lines.push( + ` ${exposure.unaddressable.length} relation(s) in those schemas the API cannot address: ` + + exposure.unaddressable.map((r) => `${r.schema}.${r.table}`).join(', ') + ); + } } else { lines.push( paint('medium', 'exposure: unknown — entire database assumed reachable (score capped)') diff --git a/packages/safegres/src/rules/registry.ts b/packages/safegres/src/rules/registry.ts index f2b4a2e1b..8352adaff 100644 --- a/packages/safegres/src/rules/registry.ts +++ b/packages/safegres/src/rules/registry.ts @@ -190,6 +190,14 @@ export const RULES: RuleMeta[] = [ title: 'Untrusted role reaches an RLS-off table via PUBLIC or inheritance (options: { roles: [...] })', scope: 'table' }, + { + code: 'L6', + category: 'coverage', + defaultSeverity: 'info', + direction: 'neutral', + title: 'Unaddressable grant — an API role holds privileges on a relation its API cannot name', + scope: 'table' + }, { code: 'W1', category: 'meta', diff --git a/packages/safegres/src/types.ts b/packages/safegres/src/types.ts index c0144c906..5c1466bdc 100644 --- a/packages/safegres/src/types.ts +++ b/packages/safegres/src/types.ts @@ -124,6 +124,11 @@ export interface PlaneReport { roles?: string[]; /** Relations the plane reaches (the density denominator). */ exposedTables: number; + /** + * Relations in the plane's schemas its API cannot address, and which are + * therefore excluded from `exposedTables`. + */ + unaddressableTables?: number; /** Role planes: the most direct way the reach arrives. */ reachedVia?: 'grant' | 'PUBLIC' | 'inheritance'; /** Security score for this plane, same model as the headline. */ @@ -155,6 +160,13 @@ export interface ExposureReport { exposedTables: number; /** All tables the audit introspected. */ totalTables: number; + /** + * Relations in the exposed schemas the generated API cannot address, and + * which are therefore excluded from `exposedTables`. Present only when an + * adapter could prove it; listed rather than counted, because a subtraction + * from the score denominator should be readable. + */ + unaddressable?: import('./exposure/reach').UnreachableRelation[]; } /** @@ -200,6 +212,11 @@ export interface PerfPathsReport { tables: number; /** What X1 did with the write-once-shaped keys. */ onWriteOncePointer: 'report' | 'demote' | 'suppress'; + /** + * Keys whose reverse relation an API behavior declares absent. Reported + * only: one missing path is not proof that nothing traverses the key. + */ + declaredHidden?: number; } /** Where the `S*` findings' numbers came from, and how much to trust them. */