airlock-rls
v0.4.0
Published
The CI gate for Supabase RLS. Fails your build if a table ships without Row Level Security or a policy is permissive (USING (true)) — and scans your deployed site for an exposed service_role key.
Maintainers
Readme
Airlock RLS — the CI gate for Supabase
Scanners check that a policy exists. Airlock proves it works — and blocks the merge if it doesn't.
Airlock fails your CI when a table ships without Row Level Security, or when a
policy is permissive (USING (true) / WITH CHECK (true) — the always-true
rules that let every API role through and quietly defeat tenant isolation).
It's not another scanner you read a report from. It's a gate: the migration doesn't reach production with an exposed table.
Why this exists
The #1 Supabase security footgun is shipping a table to the public schema
with RLS off, or a policy that's technically present but logically wide open.
Native advisors and free scanners tell you after the fact. Airlock runs in CI
and fails the build, so the leak never merges.
What it checks
Airlock audits the logic of your policies, not just their presence.
Fails the build (exposure):
rls_disabled— a table with RLS off; every row exposed to the API roles.permissive_true— a policyUSING (true)/WITH CHECK (true), or one that reduces to always-true: a common tautology OR-joined to the scope token (auth.uid() = x OR 2=2,OR 'a'='a',OR 1<2,OR 1 in (1),OR length(x) >= 0, even nestedOR (a OR 1=1)), a reflexiveowner_id = owner_id, or the bare form — the scope token is present but the row still matches for everyone. (A tautology underANDis fine — the scope survives.) It catches the common always-true shapes (literaltrue, self-equality, evaluated constant comparisons, a constantINa constant list, a non-negative built-in likelength(x) >= 0) as a hard fail. An OR branch it can't prove — an exotic tautology (NOT (1=2)), a widening predicate (OR status = 'published'), anything unrecognized — is not passed silently: it becomes anor_branch_unscopedwarn (see below), so a new tautology shape never re-opens a silent hole.anon_unscoped— an anon-readable policy that doesn't scope to the user (noauth.uid()) — everyone reads every row, even when it isn't literallytrue.write_unscoped— an anon INSERT/UPDATE whoseWITH CHECKis present but doesn't tie the new row to the caller — anyone can forge rows as another tenant.delete_unscoped— aFOR DELETEpolicy whoseUSINGdoesn't scope to the caller, so anyone the policy applies to can delete rows they don't own. Unlike a read, this is a fail forauthenticatedtoo: destroying another tenant's data is worse than reading it, and it isn't reversible.update_using_unscoped— aFOR UPDATEpolicy whoseUSINGdoesn't scope to the caller. The subtle one: a scopedWITH CHECKconstrains what the new row may look like, butUSINGis what decides which rows can be targeted. With an openUSING, any row can be taken over — and writing less SQL (noWITH CHECKat all) used to look safer to the gate than writing it explicitly. Also a fail forauthenticated.anon_read_leak(DAST) — with an anon key, Airlock actually reads each table over the REST API; a returned row is a proven leak, not an inference.matview_exposed— a materialized view a client role can read. RLS does not apply to a matview at all:CREATE MATERIALIZED VIEW public.all_payments AS SELECT * FROM paymentsplus a grant toanonis a full dump of the underlying table, no matter how correct the policies onpaymentsare.service_role_exposed— a Supabase service key shipped to the browser (aservice_roleJWT or ansb_secret_...key). It bypasses every RLS policy at once, so whoever reads it owns your database. Scanned straight from your deployed site — no database needed (see below).
Warns (worth a review):
or_branch_unscoped— the policy scopes toauth.uid()but anORbranch widens it in a way a static scan can't prove restricts (auth.uid() = owner OR status = 'published',OR deleted_at IS NULL, an exotic tautology). It may be intentional public sharing, or a leak — this is a fail-safe: unproven OR branches warn instead of passing silently. Prove it with the DAST pass or allow-list it if intended.authenticated_unscoped— any logged-in user reads all rows (role-only check).helper_scoped— a client-reachable read scoped only through a helper function of any name (is_public(),authorize('posts.read'),belongs_to_org()); a static scan can't see inside it, so verify it actually restricts the caller (or allow-list it if intentional).write_unchecked— an INSERT/UPDATE policy with noWITH CHECKguard.anon_write_inconclusive(DAST,--dast-write) — an anon INSERT hit a column constraint (e.g.NOT NULL), not an RLS block. That can fire before the RLS check, so it does not prove RLS would admit a valid row — verify by hand. Only a201(a row actually inserted) is reported as a proven write leak.public_bucket— a public storage bucket.security_definer— a function that runs as its owner and can bypass RLS.
What it does NOT cover yet (declared, not silently missed):
FORCE ROW LEVEL SECURITYis never read — onlyrowsecurity. A table whose owner is reachable keeps a silent bypass.--sitedoes not defeat DNS rebinding. Literal private, loopback, link-local and metadata addresses are refused (including their IPv4-mapped IPv6 spellings), and every fetch has a timeout and a body cap. Redirects are followed by hand and every hop is re-validated, so a302to an internal address is refused — but a hostname that resolves to a private address is still not caught, because the check runs on the URL, not on the resolved socket.- Checks that could not run are reported, not assumed clean. If the audit
role lacks permission to read
storage.buckets, the realtime publication or storage policies, each one is surfaced ascheck_skippedwith its reason. A clean report means "I looked", not "I could not look". - One schema per run. Other schemas exposed through PostgREST are not audited. A schema with nothing in it now fails rather than passing green, but that only catches the typo, not the second schema you forgot to scan.
- An unscoped but not provably-true qualifier for
authenticated(e.g.USING (org = 'acme')) is a warn, not a fail — there the scan genuinely cannot tell whether the openness is intended. Gate it with--fail-on warn. A provable tautology forauthenticatedis a fail. - Custom roles reached only by
SET ROLE. A role a client can switch into viaauthenticatoris undecidable statically, so aUSING(true)policy on such a role is not flagged. A custom role reached by inheritance (GRANT app_role TO authenticated) IS now caught — every logged-in user holds it withoutSET ROLE, and Airlock resolves that membership withpg_has_role. ALTER POLICYis not read. Airlock audits the policies as the database currently holds them (pg_policies), which is the end state — but if you are looking for a migration-time gate on a policy being loosened, that ismigration-guard's job, not this one.- A bare
--allowname is refused when it is ambiguous. Policy names are unique per table, not per schema, so--allow public_readis only applied when exactly one table carries that name. When two or more do, nothing is silenced and anallow_ambiguouswarn tells you the qualified form to use. This is a deliberate refusal, not a miss. - Only policies may be waived by a bare name. Buckets, views, materialized
views, SECURITY DEFINER functions and realtime tables live in their own
namespaces and must be qualified the same way the finding prints them:
storage:receipts,view:reports,matview:reports,fn:is_admin,realtime:orders. One flat name used to silence all of them at once, so--allow reportscould mute a matview FAIL the author never looked at. A bare name that would have matched raisesallow_needs_namespaceand waives nothing. - An
--allowentry that matches nothing raisesallow_unused. A waiver left behind by a rename protects nothing while reading like it does. - A
WITH CHECKscoped only through a helper function is treated as scoped (same "can't see inside" limit as reads) — use the DAST write probe to prove it.
Intentionally-public policies (a status page, a contact form) can be waved through with an allow-list, so the gate stays honest without crying wolf.
The DAST pass (prove it, don't infer it)
Give Airlock a project URL and an anon key and it runs the dynamic check the static scanners can't — it reads each table as an anonymous attacker would:
airlock "$SUPABASE_DB_URL" --url "$SUPABASE_URL" --anon-key "$SUPABASE_ANON_KEY"Scan a deployed site for an exposed service key (zero setup)
The worst leak a Supabase app can have is its service key in the browser — a
service_role JWT or an sb_secret_... key bundled into the frontend. It
bypasses every RLS policy at once. Airlock finds it with only your site URL —
no database, no credentials:
npx airlock-rls --site https://your-app.comIt fetches the page and its JS bundles and fails (exit 1) if a service key is
present. It never flags the anon key — that one is public by design — and
never prints the key it finds.
Use it as a GitHub Action (the gate)
Add your Supabase Postgres connection string as a repo secret named
SUPABASE_DB_URL (a read-only role is enough — the static audit reads only
catalog metadata: pg_tables, pg_policies, grants, functions and views, never
your row data), then drop this in .github/workflows/rls-gate.yml:
name: RLS Gate
on: [push, pull_request]
jobs:
rls-gate:
runs-on: ubuntu-latest
steps:
- uses: mateuszingano/airlock-rls@v1
with:
db-url: ${{ secrets.SUPABASE_DB_URL }}
# allow: public_read,status_select # optional
# schema: public # optionalIf any table is exposed or any policy is permissive, the job exits non-zero and
the merge is blocked. A full example lives in examples/rls-gate.yml.
Action inputs
| Input | Required | Default | Description |
| -------------- | -------- | ---------- | ------------------------------------------------------------------ |
| db-url | yes | — | Postgres connection string for the project to audit. |
| allow | no | '' | Comma-separated waivers: policy names, or ns:name for buckets/views/matviews/functions/realtime. |
| schema | no | public | Schema to audit. |
| site | no | '' | Deployed site URL to also scan for an exposed service_role key. |
| url | no | '' | Supabase project URL. With anon-key, runs the DAST pass that proves exposure. |
| anon-key | no | '' | Public anon key for the DAST pass (public by design). Used with url. |
| dast-write | no | false | Also probe anonymous INSERTs during DAST (safe — leaves no test data). |
| node-version | no | 20 | Node.js version used to run the audit. |
Use it as a CLI (local / any CI)
# via npx (no install)
SUPABASE_DB_URL=postgresql://... npx airlock-rls
# or install it
npm i -D airlock-rls
SUPABASE_DB_URL=postgresql://... npx airlock
# pass the URL directly
airlock postgresql://postgres:[email protected]:54322/postgres
# machine-readable output
airlock --jsonGet the URL from supabase status (local) or your project's connection string.
Options
--fail-on <level> What breaks the build: "fail" (default) or "warn". Several
rules emit warn — including an authenticated tenant leak —
so without this they are printed, never enforced.
(also read from $RLS_AUDIT_FAIL_ON).
--strict Alias for --fail-on warn.
--allow <names> Policy names to treat as intentionally permissive
(also read from $RLS_AUDIT_ALLOW). Prefer "table.policy":
a bare name applies only when one table carries it.
Non-policy objects must be qualified by namespace:
storage:<bucket>, view:<name>, matview:<name>,
fn:<name>, realtime:<table>.
--schema <name> Schema to audit (default: public).
--url URL Supabase project URL — enables the DAST pass ($SUPABASE_URL).
--anon-key VALUE Public anon key for the DAST pass ($SUPABASE_ANON_KEY).
--dast-write Also probe anonymous INSERTs (safe — leaves no test data).
--site URL Deployed site URL — scan its HTML/JS for an exposed
service_role key. Needs no database ($SUPABASE_SITE_URL).
--json Print the result as JSON instead of a report.
-h, --help Show help.
-v, --version Show the version.The DB URL is optional when you pass --site: airlock --site https://your-app.com
runs the service-key scan on its own, with no database.
Exit codes
| Code | Meaning |
| ---- | --------------------------------------------------- |
| 0 | Passed — no exposure found. |
| 1 | Failed — at least one exposed table or permissive policy. |
| 2 | Usage / connection error (bad args, no URL, DB unreachable). |
Use it as a library
import { audit } from 'airlock-rls'
const result = await audit({ dbUrl: process.env.SUPABASE_DB_URL, schema: 'public' })
if (!result.passed) {
console.error(`${result.problems} problem(s)`)
for (const f of result.findings.filter((f) => f.severity === 'fail')) {
console.error(` ✗ ${f.object} — ${f.detail}`)
}
}audit() returns:
{
schema, // the audited schema
findings, // Finding[] — { kind, severity: 'fail'|'warn', object, detail }, fail-first
allowed, // Finding[] — permissive findings waved through by `allow`
problems, // number of severity 'fail' findings
warnings, // number of severity 'warn' findings
passed, // problems === 0
tables, // string[] — every table in the schema (used by the DAST pass)
}It never exits the process — you decide how to report.
What's free vs. paid
Airlock is open core. This gate — the CLI and the GitHub Action — is free and MIT-licensed, forever. Paid tiers (coming) add the pieces a one-shot CI run can't cover:
- Logic audit — deeper policy analysis beyond always-true.
- Continuous monitoring — a scheduled run that alerts on drift when a new migration reopens a hole between CI runs.
Development
npm install
npm test # unit tests, no database required
npm start -- --helpThe audit logic (src/audit.mjs) is framework-free and split into a pure core
(buildResult, tested without a DB) and the thin audit() that talks to Postgres.
Integration test (real Postgres)
test/integration.test.mjs runs the real audit() against a live database using
the fixtures in test/fixtures/ (a leaky schema that must fail, a clean one that
must pass). It's gated: with no database URL it skips, so npm test stays
green offline. Point it at any Postgres to run it:
AIRLOCK_TEST_DB_URL=postgresql://postgres:[email protected]:5432/postgres npm testIn CI, the integration job in .github/workflows/test.yml stands up a Postgres
service container and runs it automatically.
License
MIT © ZINGUI
