Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions packages/safegres/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- | --- | --- |
Expand All @@ -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
Expand Down Expand Up @@ -263,18 +267,50 @@ interface ExposureAdapter {
name: string;
detect(exec: QueryExecutor): Promise<boolean>; // is this stack present?
resolve(exec: QueryExecutor): Promise<PlaneInput[]>; // one or more planes
reach?(exec: QueryExecutor, ctx: ReachContext): Promise<ApiReach>; // optional precision
}
```

Built-ins ship for `constructive`, `postgrest`, `supabase`, `hasura` and `graphile` — each reading
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:<name>`
per API for Constructive, a `direct:<authenticator>` 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:
Expand Down
69 changes: 69 additions & 0 deletions packages/safegres/__tests__/fixtures/reach.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading