{"id":50016480,"url":"https://github.com/pgrls/pgrls","last_synced_at":"2026-05-30T05:01:51.811Z","repository":{"id":354590140,"uuid":"1220250277","full_name":"pgrls/pgrls","owner":"pgrls","description":"Static analyzer for Postgres Row-Level Security — 43 lint rules covering tenant and per-user row-scoping bugs, performance traps, and hygiene; 11 mechanically auto-fixable; semantic policy-diff command for CI gating; pytest plugin for RLS isolation tests.","archived":false,"fork":false,"pushed_at":"2026-05-22T05:21:49.000Z","size":1897,"stargazers_count":7,"open_issues_count":0,"forks_count":1,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-22T11:57:24.425Z","etag":null,"topics":["ci","linter","multi-tenant","postgres","postgresql","rls","row-level-security","security","static-analysis","supabase"],"latest_commit_sha":null,"homepage":null,"language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/pgrls.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-04-24T17:51:28.000Z","updated_at":"2026-05-22T05:21:38.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/pgrls/pgrls","commit_stats":null,"previous_names":["pgrls/pgrls"],"tags_count":87,"template":false,"template_full_name":null,"purl":"pkg:github/pgrls/pgrls","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pgrls%2Fpgrls","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pgrls%2Fpgrls/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pgrls%2Fpgrls/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pgrls%2Fpgrls/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/pgrls","download_url":"https://codeload.github.com/pgrls/pgrls/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pgrls%2Fpgrls/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33543974,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"ssl_error","status_checked_at":"2026-05-26T15:22:15.568Z","response_time":63,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.5:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["ci","linter","multi-tenant","postgres","postgresql","rls","row-level-security","security","static-analysis","supabase"],"created_at":"2026-05-20T04:02:26.436Z","updated_at":"2026-05-30T05:01:51.804Z","avatar_url":"https://github.com/pgrls.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# pgrls\n\n[![PyPI version](https://img.shields.io/pypi/v/pgrls.svg)](https://pypi.org/project/pgrls/)\n[![Python versions](https://img.shields.io/pypi/pyversions/pgrls.svg)](https://pypi.org/project/pgrls/)\n[![License: MIT](https://img.shields.io/pypi/l/pgrls.svg)](https://github.com/pgrls/pgrls/blob/main/LICENSE)\n[![CI](https://img.shields.io/github/actions/workflow/status/pgrls/pgrls/test.yml?branch=main\u0026label=tests)](https://github.com/pgrls/pgrls/actions/workflows/test.yml)\n[![Downloads](https://img.shields.io/pypi/dm/pgrls.svg)](https://pypistats.org/packages/pgrls)\n\n**[▶ 23-second demo](https://raw.githubusercontent.com/pgrls/pgrls/main/docs/screencast.svg)** · **[Quickstart](docs/QUICKSTART.md)** · **[Rule reference](AGENTS.md)** · **[Docs site](https://pgrls.github.io/pgrls-docs/)** · **[CHANGELOG](CHANGELOG.md)** · **[PyPI](https://pypi.org/project/pgrls/)**\n\n\u003e **Static analyzer for Postgres Row-Level Security.**\n\u003e Catches the policy bugs eyeball-review misses — broken row scoping (across tenants *and* between users in the same tenant), inverted auth checks, write-side holes; 17 of 48 rules mechanically auto-fixable.\n\u003e `pgrls diff` classifies every migration **SAFE / BREAKING / REQUIRES_REVIEW / DANGEROUS** so CI gates on real regressions, not safe schema changes.\n\u003e MIT, framework-agnostic (Supabase, PostgREST, Hasura, Django, raw SQL), CI-native (text / JSON / SARIF / Markdown / GitHub-PR-comment / GitHub annotations / JUnit XML).\n\n\u003c!--\n  Animated SVG cast generated by termtosvg from the demo recipe in\n  docs/screencast.md. Click-through opens the raw SVG which animates\n  in the browser (GitHub's \u003cimg\u003e sandbox renders the first frame as a\n  static preview). Both src and href use absolute raw.githubusercontent\n  URLs so PyPI's project page renders the image too — relative paths\n  break there. Re-record with `bash docs/screencast.md`'s recipe after\n  a feature ships.\n--\u003e\n\u003cp align=\"center\"\u003e\n  \u003ca href=\"https://raw.githubusercontent.com/pgrls/pgrls/main/docs/screencast.svg\"\u003e\n    \u003cimg src=\"https://raw.githubusercontent.com/pgrls/pgrls/main/docs/screencast.svg\" alt=\"pgrls 60-second tour\" width=\"780\"\u003e\n  \u003c/a\u003e\n\u003c/p\u003e\n\n\u003e **Beta — actively maintained.** 48 lint rules, 17 mechanically auto-fixable, [semantic policy-diff command](#diff--pgrls-snapshot--pgrls-diff), pytest plugin for RLS isolation tests. Tested on PostgreSQL 15, 16, 17. Stable JSON / SARIF schema for CI integrations. The [CHANGELOG](CHANGELOG.md) records every release; current build is shown by the PyPI badge above.\n\u003e\n\u003e - **Lint \u0026 fix** — `pgrls lint` checks a live database against all forty-eight rules and reports findings as text, JSON, SARIF, Markdown, GitHub-PR-comment (`--format pr-comment`), GitHub Actions annotations (`--format github`), or JUnit XML (`--format junit`) for CI. `pgrls fix` auto-remediates the mechanically-fixable rules (SEC001, SEC002, SEC006, SEC011, SEC015, SEC017, SEC019, SEC020, SEC030, SEC031, SEC032, PERF001, PERF003, PERF004, HYG003, VIEW001, VIEW002) — to stdout or a migration-ready `.sql` file (`--output`). `pgrls lint --baseline` records existing findings so CI fails only on *new* ones, letting a team adopt pgrls on a legacy database without clearing the whole backlog first.\n\u003e - **Generate** — `pgrls generate` scaffolds gold-standard RLS for tenant tables that lack it (ENABLE + FORCE, a permissive tenant-isolation policy, a restrictive floor, and the index) — output designed to lint clean. Don't trust your ORM's RLS; generate correct RLS, then lint it.\n\u003e - **Test** — the `pgrls.testing` pytest plugin for writing RLS tests: role switching, per-test transactions, and tenant-isolation assertions.\n\u003e - **Snapshot \u0026 diff** — `pgrls snapshot` / `pgrls diff` is a semantic RLS-policy diff that classifies every change SAFE / BREAKING / REQUIRES_REVIEW / DANGEROUS. Optional Z3-based predicate analysis (`pip install pgrls[diff-z3]`), plus migration-as-input — apply a migration to an ephemeral Postgres and diff the result (`pip install pgrls[diff-apply]`), with `CREATE EXTENSION` auto-detection and a cached-baseline Docker image for fast re-runs.\n\u003e - **TypeScript port** — [`pgrls-test`](https://www.npmjs.com/package/pgrls-test) on npm implements the same RLS-testing contract for JS/TS — both `pg` and `postgres.js` driver adapters, vitest-friendly. See [`ts/`](ts/) in this repo.\n\u003e - **VS Code extension** — [`pgrls/pgrls-vscode`](https://github.com/pgrls/pgrls-vscode) wraps the CLI; `pgrls: Lint database` surfaces findings as diagnostics in the Problems panel, with hover documentation per rule.\n\n## Install\n\n```bash\npip install pgrls\n```\n\nRequires Python 3.11+ and Postgres 15+. pgrls is tested in CI against PostgreSQL 15–17 (see [`.github/workflows/test.yml`](.github/workflows/test.yml) for the matrix).\n\n## Real-world bugs pgrls catches\n\nThe kind of mistake that ships to prod despite policy review:\n\n```sql\nCREATE POLICY tenant_read ON public.documents\n    FOR SELECT TO authenticated\n    USING (auth.uid() IS NULL OR owner = auth.uid());\n```\n\nLooks fine — and is structured the way many RLS tutorials show it. But `auth.uid()` returns `NULL` for any connection without a session JWT. For those connections the `IS NULL` branch is `true`, the `OR` short-circuits, and the policy admits *every* row of `public.documents`. It's a recurring pattern in multi-tenant Supabase / PostgREST projects — the kind of thing a public CVE write-up names by hindsight.\n\n`pgrls` flags it as **SEC004** (severity `error`) in milliseconds. With `--explain`, the rule's reference paragraph is appended underneath the finding (lines hard-wrapped here for the README; the real output is one long line per paragraph):\n\n```\n$ pgrls lint --rule SEC004 --explain\n  ERROR  SEC004  public.documents.tenant_read\n         Policy 'tenant_read' on public.documents contains a top-level\n         `auth_func() IS NULL` disjunct in its USING clause. For anonymous\n         connections that disjunct evaluates to true, satisfying the policy\n         and exposing every row. Remove the IS NULL disjunct or replace with\n         an explicit deny.\n\n         The pattern: a policy with USING (auth_func() IS NULL OR \u003creal check\u003e).\n         auth_func() returns NULL for anonymous connections, so the IS NULL\n         disjunct is true and the OR is satisfied without ever evaluating the\n         real check. Anonymous clients see all rows.\n\npgrls: 1 error.\n```\n\nRLS isn't only about keeping tenants apart. The same bug class bites *within* a single tenant, when rows are meant to be per-user:\n\n```sql\nCREATE TABLE documents (id uuid, tenant_id int, owner_id uuid, body text);\nALTER TABLE documents ENABLE ROW LEVEL SECURITY;\nCREATE POLICY tenant_scope ON documents\n    USING (tenant_id = current_setting('app.tenant')::int);\n```\n\nCross-tenant reads are blocked, so this passes a tenant-isolation review. But there's an `owner_id` column and nothing keys on it, so every user *in* a tenant reads every other user's documents. If that table holds drafts, DMs, or private uploads, it's a leak. **SEC027** (info) flags the table so you decide: add a per-user predicate, or confirm it's intentionally tenant-shared and allowlist it.\n\n[Browse the full rule catalogue in AGENTS.md](docs/RULES.md#rule-sec004) for the other 41 — missing `WITH CHECK`, `BYPASSRLS` roles, per-row auth-function evaluation, search-path attacks, view-mediated RLS bypasses, and more.\n\n## Usage\n\nScaffold a config (optional — `pgrls` runs with zero config):\n\n```bash\npgrls init          # writes a commented pgrls.toml; --force to overwrite\n```\n\nPoint `pgrls` at any Postgres database:\n\n```bash\nexport DATABASE_URL=\"postgres://user:pass@host:5432/db\"\npgrls lint\n```\n\nOr pass the URL directly:\n\n```bash\npgrls lint --database-url \"postgres://user:pass@host:5432/db\"\n```\n\nLimit the scan to specific schemas:\n\n```bash\npgrls lint --schemas public,tenant\n```\n\nPoint at a non-default config file, or pick an output format:\n\n```bash\npgrls lint --config ./config/pgrls.toml --format text     # human-readable (default)\npgrls lint --config ./config/pgrls.toml --format json     # machine-readable for CI\npgrls lint --config ./config/pgrls.toml --format sarif    # GitHub Code Scanning\npgrls lint --config ./config/pgrls.toml --format markdown   # rendered CI reports / runbooks\npgrls lint --config ./config/pgrls.toml --format pr-comment # collapsible per-rule GitHub PR comment\npgrls lint --config ./config/pgrls.toml --format github     # GitHub Actions run annotations\npgrls lint --config ./config/pgrls.toml --format junit    # JUnit XML for CI test reports\n```\n\nOr run only specific rules — handy when scoping a SARIF report in CI, or\ninvestigating one rule in isolation. `--rule` is case-insensitive,\nrepeatable, and overrides `[lint] disable` in the config so you can pull a\ndisabled rule back in for one run without editing the config:\n\n```bash\npgrls lint --rule SEC001 --rule SEC003\n```\n\nOr run everything *except* certain rules with `--exclude-rule` (the\ncomplement of `--rule`; case-insensitive, repeatable):\n\n```bash\npgrls lint --exclude-rule SEC022 --exclude-rule PERF002\n```\n\nTrim the printed report to the findings you care about with\n`--min-severity` (display-only — the exit code still reflects every finding\nper `--fail-on`, so hiding info noise can't flip CI green), and write the\nreport to a file instead of stdout with `--output`/`-o`:\n\n```bash\npgrls lint --min-severity warning            # hide info-level nudges from output\npgrls lint --format sarif --output pgrls.sarif\n```\n\nPass `--explain` to append each rule's reference paragraph beneath its\nfinding in the text output, so a CI log carries the *why* next to the\n*where* without a separate `pgrls explain \u003cRULE\u003e` lookup. Text format\nonly — JSON / SARIF / Markdown / GitHub / JUnit keep their schemas stable.\n\n### Example output\n\nText (default):\n\n```\n  ERROR  SEC001  public.users\n         Table public.users does not have row-level security enabled.\n         Add ENABLE ROW LEVEL SECURITY or include the table in\n         [lint.rules.SEC001].allowlist if it is a public reference table.\n\npgrls: 1 error.\n```\n\nJSON (`--format json`):\n\n```json\n{\n  \"violations\": [\n    {\n      \"rule_id\": \"SEC001\",\n      \"severity\": \"error\",\n      \"title\": \"RLS not enabled on table\",\n      \"message\": \"Table public.users does not have row-level security enabled. Add ENABLE ROW LEVEL SECURITY or include the table in [lint.rules.SEC001].allowlist if it is a public reference table.\",\n      \"location\": \"public.users\"\n    }\n  ],\n  \"summary\": { \"errors\": 1, \"warnings\": 0, \"infos\": 0, \"total\": 1 }\n}\n```\n\nThe JSON shape is the public CI contract — top-level keys, per-violation keys, and summary keys are stable across releases. Pipe through `jq` to filter, count, or transform; ship to a dashboard; upload as a build artifact.\n\nSARIF (`--format sarif`) emits a SARIF v2.1.0 document. GitHub Code Scanning, Azure DevOps, and other static-analysis aggregators consume it directly — see the GitHub Actions recipe below for the upload step that puts findings inline on PRs.\n\nExit codes follow the standard linter convention:\n\n- `0` — clean (or findings below `fail_on`)\n- `1` — findings met or exceeded `fail_on` (default `warning`); your schema has an RLS issue\n- `2` — `pgrls` itself failed to run (bad config, DB unreachable, fixer SQL rolled back, etc.). Distinct from `1` so CI alerts can route \"schema bug\" differently from \"tool error.\"\n\n### Baseline — adopt pgrls on a legacy database\n\nRunning pgrls against an existing database for the first time often surfaces a backlog of pre-existing findings. `--baseline` lets you ratchet: record today's findings and have CI fail only on *new* ones.\n\n```bash\n# First run (file absent): records every current finding, exits 0.\npgrls lint --database-url \"$DATABASE_URL\" --baseline pgrls-baseline.json\n\n# Later runs: report and fail only on findings NOT in the baseline.\npgrls lint --database-url \"$DATABASE_URL\" --baseline pgrls-baseline.json\n```\n\nThe first run writes the baseline file and exits `0`. Every later run suppresses findings already recorded and exits nonzero only when a *new* finding appears — so a team can adopt pgrls without fixing the whole backlog up front, then chip away at the baseline over time. Commit the baseline file to the repo.\n\nTo re-baseline after deliberately accepting new findings, pass `--update-baseline` alongside `--baseline FILE`; the baseline is refreshed in place with the current findings (replace, not merge — stale entries for findings that no longer fire are dropped). No need to delete the file first.\n\n### Auto-remediation: `pgrls fix`\n\n`pgrls fix` generates SQL for the rules whose remediation is mechanical. Default mode is dry-run — it prints the SQL but does not modify the database. Pass `--apply` to execute, or `--output \u003cfile\u003e` to write a migration-ready `.sql` script (a header plus one `-- [rule] description` comment per statement) instead of printing to stdout.\n\n```bash\n# Dry-run: print what would change.\npgrls fix --database-url \"$DATABASE_URL\"\n\n# Apply for real.\npgrls fix --database-url \"$DATABASE_URL\" --apply\n\n# Only fix one rule.\npgrls fix --database-url \"$DATABASE_URL\" --rule SEC002 --apply\n\n# Write the SQL to a migration-ready file instead of stdout.\npgrls fix --database-url \"$DATABASE_URL\" --output migration.sql\n\n# CI gate: exit 1 if any auto-fixable violations exist (no SQL emitted).\npgrls fix --database-url \"$DATABASE_URL\" --check\n```\n\nCurrently fixable: **SEC001** (emits `ALTER TABLE … ENABLE ROW LEVEL SECURITY;`), **SEC002** (emits `ALTER TABLE … FORCE ROW LEVEL SECURITY;`), **SEC006** (emits `ALTER POLICY … WITH CHECK (…)` mirroring the policy's `USING`), **SEC011** (emits `ALTER POLICY … USING (…) / WITH CHECK (…)` stripping an `OR true` debug bypass), **SEC019** (emits `ALTER POLICY … USING (…) / WITH CHECK (…)` adding the `missing_ok = true` second argument to one-argument `current_setting()` calls), **SEC020** (emits `ALTER POLICY … WITH CHECK (…)` replacing a constant-`true` `WITH CHECK` with the policy's `USING`), **SEC031** (emits `DROP POLICY … ON …;` for a no-op restrictive `USING (true)` floor — it AND-combines to nothing, so dropping it leaves access unchanged), **PERF001** (rewrites unwrapped auth calls as `(SELECT auth.uid())` and emits `ALTER POLICY … USING (…);`), **PERF003** (emits `CREATE INDEX ON … (…);` for a policy-predicate column with no leading-column index), **HYG003** (emits `DROP POLICY … ON …;` for a policy that exactly duplicates another on the same table), **VIEW001** (emits `ALTER VIEW … SET (security_invoker = true);`), and **VIEW002** (emits `ALTER VIEW … SET (security_barrier = true);`). Other rules need human intent (which role? which column? which policy?) and are not auto-fixed.\n\n## Scaffold RLS — `pgrls generate`\n\n`pgrls fix` repairs RLS you already have; `pgrls generate` writes it from\nscratch for tenant tables that have none. The shoot-out showed ORMs emit\nbroken or absent row security — the counter is to generate correct RLS and\nlint it.\n\nFor every table that carries a tenant-discriminator column (default\n`tenant_id`) and has **no** policies, `generate` emits the complete\ngold-standard setup — `ENABLE` + `FORCE` row security, a permissive\ntenant-isolation policy, a `RESTRICTIVE` floor, and the supporting index —\n**designed to lint clean**:\n\n```bash\n# Dry-run: print the SQL for every unprotected tenant_id table.\npgrls generate --database-url \"$DATABASE_URL\"\n\n# Write a migration, or apply in one all-or-nothing transaction.\npgrls generate --database-url \"$DATABASE_URL\" --output rls.sql\npgrls generate --database-url \"$DATABASE_URL\" --apply\n\n# The round-trip the feature guarantees:\npgrls generate --apply \u0026\u0026 pgrls lint   # → no findings\n```\n\nThe predicate compares the column to a session value, wrapped in\n`(SELECT …)` for per-statement caching and cast to the column's type:\n\n```sql\nCREATE POLICY posts_tenant_isolation ON public.posts TO authenticated\n    USING (tenant_id = (SELECT current_setting('app.tenant_id', true)::uuid))\n    WITH CHECK (tenant_id = (SELECT current_setting('app.tenant_id', true)::uuid));\n```\n\n`--convention postgrest` switches the source to\n`current_setting('request.jwt.claim.tenant_id', true)`; `--setting-name`,\n`--role` (default `authenticated`), and `--no-restrictive` tune the rest.\n(The restrictive floor is what silences the SEC007 \"all policies\npermissive\" advisory — `--no-restrictive` trades it back for that info\nfinding.)\nFor a non-conventional column, name it explicitly:\n`--table public.orgs:org_id`. Tables that already have policies are\n**skipped** — `generate` never overwrites hand-written policy intent, so\nre-running it is a no-op. Scope is the common single-column tenant model;\nper-CRUD, membership-join, and row-owner shapes stay hand-written.\n\n## RLS posture — `pgrls report`\n\n`pgrls lint` answers *\"what's wrong?\"*; `pgrls report` answers *\"what's the posture overall?\"* — a factual, rule-free snapshot of every table's row-level-security state, for audits and onboarding.\n\n```bash\npgrls report --database-url \"$DATABASE_URL\"                                  # text table + summary\npgrls report --database-url \"$DATABASE_URL\" --format json                    # machine-readable\npgrls report --database-url \"$DATABASE_URL\" --format markdown -o posture.md  # write an audit doc\npgrls report --database-url \"$DATABASE_URL\" --format html -o posture.html    # standalone HTML page, print/PDF-ready\n```\n\nEach table gets a coarse status — `protected` (RLS on, FORCE'd, ≥1 permissive policy), `not-forced` (RLS on with a permissive policy, but owner bypasses), `no-policies` (RLS on but no permissive policy → default-deny; covers zero policies *and* restrictive-only tables), `covered-by-parent` (a partition child whose RLS-enabled parent covers queries routed through it — credited when that parent is among the scanned schemas), or `rls-off` — plus an aggregate summary. It runs **no rules** and emits no findings; use `pgrls lint` for that.\n\n## Tracking trends — `pgrls history`\n\nPair a daily cron with `pgrls lint --format json -o snapshots/$(date -u +%FT%H%M%SZ).json` and ask `pgrls history snapshots/` weekly — \"are we gaining ground over time, or is the findings count creeping up?\"\n\n```bash\npgrls history snapshots/                       # terminal table\npgrls history snapshots/ --format markdown     # paste-ready GFM (for a weekly update / PR comment)\npgrls history snapshots/ --format html -o trend.html   # standalone trend page, print/PDF-ready\npgrls history snapshots/ --format json -o trend.json   # machine-readable for plotting\n```\n\nEach row is one snapshot plus the **NEW** / **FIXED** delta vs. the prior snapshot in chronological order (findings keyed by `(rule_id, location)` so a schema-wide finding stays PERSISTENT rather than NEW+FIXED on every comparison). A trailing summary line names the net change over the full series.\n\n## Configuration\n\nDrop a `pgrls.toml` next to your project. See `pgrls.example.toml` in the repo for a fully commented version.\n\n```toml\n[database]\nurl = \"$DATABASE_URL\"\nschemas = [\"public\"]\n\n[lint]\ndisable = []\nfail_on = \"warning\"\n\n[lint.rules.SEC001]\nallowlist = [\"countries\", \"currencies\"]\n\n# `severity` re-tiers a rule's findings without disabling it —\n# \"error\" | \"warning\" | \"info\". SEC019 is an info rule; promoting\n# it to error makes a one-arg current_setting() call fail CI.\n[lint.rules.SEC019]\nseverity = \"error\"\n```\n\n### Sharing config across projects — `extends`\n\nA config can layer on top of a shared base with a top-level `extends`\n(a path, or a list of paths resolved relative to the file that declares\nit) — handy for a monorepo or an org-wide ruleset:\n\n```toml\nextends = \"../pgrls.base.toml\"   # or [\"../base.toml\", \"./team.toml\"]\n\n[lint]\nfail_on = \"error\"                # override one key; inherit the rest\n```\n\nTables deep-merge key-by-key (a child can set `[lint.rules.SEC001].severity`\nwhile inheriting the base's `allowlist`); scalars and arrays are *replaced*,\nnot appended (a child `disable` list wins wholesale). For a list, later\nentries override earlier ones, and the declaring file overrides every base.\nA cycle in the `extends` chain is an error.\n\n### Editor support — JSON Schema\n\npgrls ships a JSON Schema for `pgrls.toml` ([`pgrls.schema.json`](pgrls.schema.json)) so editors autocomplete keys and flag typos and invalid values (a misspelled `[lint.rles]`, a bad `fail_on`). `pgrls init` writes a `#:schema` directive on the first line, which the [Even Better TOML](https://marketplace.visualstudio.com/items?itemName=tamasfe.even-better-toml) VS Code extension applies automatically:\n\n```toml\n#:schema https://raw.githubusercontent.com/pgrls/pgrls/main/pgrls.schema.json\n```\n\nPoint any JSON-Schema-aware TOML tooling at that URL for the same validation.\n\n## Testing your RLS — `pgrls.testing`\n\nInstall with `pip install pgrls[testing]` to pull in pytest alongside pgrls.\n\n`pgrls.testing` is a pytest plugin that lets you write RLS tests with idiomatic pytest ergonomics. The `pgrls_db` fixture opens a connection, starts a per-test transaction, lets you switch roles + claims for each scenario, and rolls back at end so nothing persists between tests.\n\n```python\ndef test_user_a_cannot_see_user_bs_invoices(pgrls_db):\n    pgrls_db.seed(\"public.invoices\", [\n        {\"id\": \"1\", \"tenant_id\": \"tenant-a\", \"amount\": 100},\n        {\"id\": \"2\", \"tenant_id\": \"tenant-b\", \"amount\": 200},\n    ])\n    with pgrls_db.as_role(\n        \"authenticated\",\n        claims={\"sub\": \"user-a\", \"tenant_id\": \"tenant-a\"},\n    ):\n        pgrls_db.assert_rows(\"SELECT id FROM invoices\", count=1)\n        pgrls_db.assert_invisible(\n            \"SELECT id FROM invoices WHERE tenant_id = 'tenant-b'\"\n        )\n        pgrls_db.assert_rejected(\n            \"INSERT INTO invoices (tenant_id, amount) VALUES ('tenant-b', 999)\"\n        )\n```\n\nThe plugin assumes the standard PostgREST conventions (`SET LOCAL ROLE` + `request.jwt.claims` GUC). Configure the connection string via one of the following — the first one defined wins:\n\n- A `pgrls_test_database_url` fixture in your `conftest.py`. This *replaces* the plugin's default fixture (pytest fixture shadowing); when you supply one, the env-var fallback below is not consulted. Useful for per-session testcontainers.\n- The `PGRLS_TEST_DATABASE_URL` environment variable.\n- The `DATABASE_URL` environment variable (fallback).\n\nSetting none of the three causes `pgrls_db` to raise `PgrlsTestConfigError`.\n\nThe cross-language contract is documented at [`docs/pgrls-test-protocol.md`](docs/pgrls-test-protocol.md). The **TypeScript port** ships as [`pgrls-test`](https://www.npmjs.com/package/pgrls-test) on npm — same Layer 1 protocol, same wire-level behaviour, idiomatic JS/TS surface (camelCase API, `pg` and `postgres.js` adapters). Source under [`ts/`](ts/) in this repo. The **Go port** is shipping in stages at [`go/`](go/) (module `github.com/pgrls/pgrls/go`, versioned independently as `go/v0.7.x`); step 1 (scaffold + `ProtocolVersion` constant + error types) shipped in `go/v0.7.0`, with steps 2–7 (Driver interface, pgx + lib/pq adapters, Client API, assertion helpers, conformance suite, release tag) tracked in [`go/CHANGELOG.md`](go/CHANGELOG.md).\n\n### Coverage — which policies are actually tested\n\nWhen your `pgrls.testing` suite runs, the plugin records which\n`(table, role, command)` tuples each test exercised and writes them to\n`.pgrls-coverage.json` on session finish (gitignored; disable with\n`pgrls_coverage = false` in your pytest config or `PGRLS_COVERAGE=off`).\n\n`pgrls coverage` cross-references that artifact against the live schema\nand reports which policies a test exercised and which were never\ntouched — the cross-tenant `DELETE` nobody wrote a test for. A policy\nis *covered* when a test queried its table, under a role it targets\n(or `PUBLIC`), with a matching command.\n\n```bash\npgrls coverage                          # text report (text/json/markdown/html)\npgrls coverage --fail-under 80          # exit 1 if coverage \u003c 80% (CI gate)\npgrls lint --coverage .pgrls-coverage.json   # enables HYG004 for uncovered policies\n```\n\n## Diff — `pgrls snapshot` + `pgrls diff`\n\n`pgrls diff` is the semantic policy diff command. Point it at any two\nPostgres sources — two snapshot files, a snapshot and a live DB, or two\nlive DBs — and it classifies every RLS change as SAFE, BREAKING,\nREQUIRES_REVIEW, or DANGEROUS. Use it in CI to gate deployments on\nactual security regressions without blocking safe migrations.\n\n```bash\n# Capture a baseline from the current branch (filter to a schema list\n# to keep snapshots small and stable).\npgrls snapshot --database-url \"$DATABASE_URL\" --schemas app -o base.json\n\n# After applying a migration, compare live DB to the baseline. The\n# --schemas filter applies to the URL side only (the snapshot file\n# already carries the filter from capture time).\npgrls diff base.json --database-url \"$DATABASE_URL\" --schemas app\n```\n\nThe default `--fail-on dangerous` threshold means CI only fails when a\ngenuinely dangerous change is detected (RLS toggled off, a permissive\npolicy added, a predicate widened, etc.). Pass `--fail-on requires-review`\nfor a stricter gate, or set `[diff].fail_on` in `pgrls.toml` to make\nthe choice persistent (CLI flag → `[diff].fail_on` → built-in\n`dangerous`). Output is git-diff-style by default (`--format text`);\nuse `--format json` or `--format sarif` for CI integrations that\nalready parse `pgrls lint` output (same `Violation` shape),\n`--format markdown` for a paste-ready PR-comment table with\nclassification badges, or `--format html` for a standalone audit\npage (embedded CSS, opens offline, prints to PDF) — same shape\n`pgrls report --format html` and `pgrls history --format html` use.\n\nPass `--explain` to append a one-paragraph rationale beneath each\nclassified Change in the text output — why a dropped PERMISSIVE\npolicy is BREAKING rather than DANGEROUS, why a column drop is\nREQUIRES_REVIEW, etc. Text format only; JSON / SARIF already carry\nthe classification tag.\n\n| Change category                        | Default classification |\n|----------------------------------------|------------------------|\n| RLS toggled off                        | DANGEROUS              |\n| Table dropped                          | BREAKING               |\n| Permissive policy added                | DANGEROUS              |\n| Restrictive policy dropped             | DANGEROUS              |\n| USING predicate widened (OR added)     | DANGEROUS              |\n| USING predicate tightened (AND added)  | SAFE                   |\n| Roles widened (PUBLIC or new role)     | DANGEROUS              |\n| Column dropped (still referenced)      | REQUIRES_REVIEW        |\n| GRANT added on non-RLS table to PUBLIC | DANGEROUS              |\n\nSee [AGENTS.md](AGENTS.md) for the full classification table and AST\npattern documentation.\n\n## Rules\n\n`pgrls lint` ships these rules:\n\n| ID | Severity | Catches |\n|---|---|---|\n| [SEC001](docs/RULES.md#rule-sec001) | error | Tables in scanned schemas with RLS disabled and no policies (a table with policies but RLS off is SEC032) |\n| [SEC002](docs/RULES.md#rule-sec002) | error | Tables with RLS enabled but FORCE ROW LEVEL SECURITY off |\n| [SEC003](docs/RULES.md#rule-sec003) | error | Permissive policies granted to PUBLIC |\n| [SEC004](docs/RULES.md#rule-sec004) | error | Inverted auth check (Lovable CVE pattern) in USING |\n| [SEC005](docs/RULES.md#rule-sec005) | warning | Policy expression has no own-column reference |\n| [SEC006](docs/RULES.md#rule-sec006) | error | INSERT/UPDATE/ALL policies with no WITH CHECK |\n| [SEC007](docs/RULES.md#rule-sec007) | info | All policies on a table are permissive (no RESTRICTIVE floor) |\n| [SEC008](docs/RULES.md#rule-sec008) | warning | Permissive policy USING clause is constant `true` (admits every row) |\n| [SEC009](docs/RULES.md#rule-sec009) | warning | RLS enabled but no policies defined (silent deny-all) |\n| [SEC010](docs/RULES.md#rule-sec010) | warning | Policy `USING`/`WITH CHECK` clause is constant `false` (deny-all anti-pattern) |\n| [SEC011](docs/RULES.md#rule-sec011) | warning | Policy expression has an `OR true` branch (debug bypass left in) |\n| [SEC012](docs/RULES.md#rule-sec012) | warning | Table has only RESTRICTIVE policies (silent deny-all — needs at least one PERMISSIVE) |\n| [SEC013](docs/RULES.md#rule-sec013) | warning | Trigger on RLS-protected table can bypass policies (triggers fire as table owner) |\n| [SEC014](docs/RULES.md#rule-sec014) | warning | SECURITY DEFINER function bypasses caller's RLS (audit every SECDEF function) |\n| [SEC015](docs/RULES.md#rule-sec015) | warning | SECURITY DEFINER function exposed to `pg_temp` search-path shadowing |\n| [SEC016](docs/RULES.md#rule-sec016) | warning | Role with the `BYPASSRLS` attribute bypasses every RLS policy |\n| [SEC017](docs/RULES.md#rule-sec017) | warning | Function with the `LEAKPROOF` attribute is evaluated below the RLS barrier |\n| [SEC018](docs/RULES.md#rule-sec018) | warning | Policy compares a column against `current_user` / `session_user` (no isolation under a shared pool role) |\n| [SEC019](docs/RULES.md#rule-sec019) | info | Policy calls `current_setting()` without the `missing_ok` argument (raises on an unset GUC) |\n| [SEC020](docs/RULES.md#rule-sec020) | warning | Policy `WITH CHECK` is constant `true` while `USING` restricts (writes accept rows reads never would) |\n| [SEC021](docs/RULES.md#rule-sec021) | info | Policy compares an identity column against a hardcoded literal (e.g. `tenant_id = 1`) |\n| [SEC022](docs/RULES.md#rule-sec022) | info | RLS-enabled table whose policies are all `FOR SELECT` — no write-side policy, so INSERT/UPDATE/DELETE are denied |\n| [SEC023](docs/RULES.md#rule-sec023) | warning | Policy granted to a role carrying `BYPASSRLS` — the role skips the policy entirely, so its `TO` clause is inert |\n| [SEC024](docs/RULES.md#rule-sec024) | info | Policy calls `current_setting()` with an unqualified parameter name (a dropped prefix the application cannot `SET`) |\n| [SEC025](docs/RULES.md#rule-sec025) | warning | Policy predicate references another table whose RLS is disabled — the cross-table read is only as strong as the referenced table's isolation |\n| [SEC026](docs/RULES.md#rule-sec026) | warning | Policy predicate uses `LIKE` / `ILIKE` / `SIMILAR TO` / POSIX regex against an auth-context value (a wildcard-shape GUC matches every row) |\n| [SEC027](docs/RULES.md#rule-sec027) | info | RLS table has an owner / user-identity column that no policy scopes by — rows may be visible across users within the same tenant |\n| [SEC028](docs/RULES.md#rule-sec028) | warning | Permissive write policy (INSERT/UPDATE/ALL) whose `WITH CHECK` is constant `true` — accepts every write; the `TO` clause gates who, not what |\n| [SEC029](docs/RULES.md#rule-sec029) | warning | Role can `SET ROLE` to a `BYPASSRLS` role through membership — escalation path that silently disables every policy (BYPASSRLS is not inherited, but reachable) |\n| [SEC030](docs/RULES.md#rule-sec030) | info | Policy scopes by a nullable discriminator column (`tenant_id = current_setting(…)` where the column allows NULL) — NULL rows escape scoping today and leak the moment a NULL-tolerant predicate appears |\n| [SEC031](docs/RULES.md#rule-sec031) | warning | RESTRICTIVE policy whose `USING` is constant `true` — AND-combines to a no-op, so it looks like a security floor but enforces none |\n| [SEC032](docs/RULES.md#rule-sec032) | error | Table has policies but RLS is not enabled — the policies are dormant (Postgres ignores them) and the table is wide open despite looking RLS-managed |\n| [SEC033](docs/RULES.md#rule-sec033) | error | Policy scopes by a user-modifiable JWT claim (`user_metadata` / `raw_user_meta_data`) — the authenticated user can rewrite the value via the auth API, bypassing the check; use `app_metadata` (service-role-only) instead |\n| [SEC034](docs/RULES.md#rule-sec034) | warning | Policy gates rows on `auth.email()` — silent denial-of-service-to-self when the user changes email, when SQL `=` is case-sensitive but emails aren't, or when plus-addressing means `x+y@host` ≠ `x@host`; scope by `auth.uid()` instead |\n| [SEC036](docs/RULES.md#rule-sec036) | error | Policy `EXISTS (SELECT FROM auth.users WHERE …)` sub-select with no caller binding — checks \"is there any admin at all\" instead of \"is THIS user an admin\", so every authenticated user passes once any matching row exists |\n| [SEC037](docs/RULES.md#rule-sec037) | warning | Policy compares `auth.role()` to a value outside the known role set (`anon` / `authenticated` / `service_role`) — comparison never matches and silently denies every row, masking the broken policy |\n| [PERF001](docs/RULES.md#rule-perf001) | warning | Auth function called per-row in policy USING (unwrapped) |\n| [PERF002](docs/RULES.md#rule-perf002) | warning | Policy expression uses a VOLATILE function (`random()`, `clock_timestamp()`, …) |\n| [PERF003](docs/RULES.md#rule-perf003) | warning | Policy predicate column without a leading-column index (sequential scan on every query) |\n| [PERF004](docs/RULES.md#rule-perf004) | warning | Policy predicate wraps an indexed column in a function (e.g. `lower(email)`) so the plain index can't serve it — Postgres seq-scans; needs an expression index |\n| [HYG001](docs/RULES.md#rule-hyg001) | error | Policies referencing columns that don't exist on the table |\n| [HYG002](docs/RULES.md#rule-hyg002) | warning | Policy named like a placeholder (`todo`, `fixme`, `tmp`, …) |\n| [HYG003](docs/RULES.md#rule-hyg003) | info | Policy is an exact duplicate of another policy on the same table |\n| [HYG004](docs/RULES.md#rule-hyg004) | info | Policy has no behavioral test exercising it (needs `pgrls lint --coverage`) |\n| [VIEW001](docs/RULES.md#rule-view001) | error | View over RLS-protected table without `WITH (security_invoker = true)` |\n| [VIEW002](docs/RULES.md#rule-view002) | warning | View over RLS-protected table without `WITH (security_barrier = true)` |\n| [VIEW003](docs/RULES.md#rule-view003) | warning | Materialized view over RLS-protected table (RLS not honored at query time) |\n| [VIEW004](docs/RULES.md#rule-view004) | warning | View calls SECURITY DEFINER function that reads an RLS-protected table |\n\nRun `pgrls explain \u003cRULE\u003e` (for example `pgrls explain SEC023`) to print any\nrule's full rationale — what it flags, why it matters, how detection works,\nand how to allowlist a false positive — on the command line. Bare\n`pgrls explain` (no argument) lists the catalog: one line per rule with its\nseverity and title. Pass `--format markdown` to either form (`pgrls explain\nSEC023 --format markdown`, `pgrls explain --format markdown`) for a\npaste-ready Markdown document — an `## SEC023 — …` heading + the body, or a\nMarkdown table of the catalog. `--format json` emits machine-readable rule\nmetadata (id, severity, title, a `fixable` flag, and — for a single rule —\nthe full reference body) for IDE / tooling integrations. All forms read only\npgrls's built-in rule catalog, so they need no database connection.\n\nFor canonical SQL fixes per rule, see [AGENTS.md](AGENTS.md). For per-rule\nconfiguration options (allowlists, etc.), see `pgrls.example.toml`.\n\nFor per-release changes, see [CHANGELOG.md](CHANGELOG.md).\n\n## CI integration\n\npgrls is designed to live in your CI alongside any other linter. It\nneeds a Postgres database with your schema applied; it then connects,\nintrospects, and exits non-zero if any rule at or above\n`fail_on` (default `warning`) fires.\n\n### pre-commit\n\n```yaml\n# .pre-commit-config.yaml\nrepos:\n  - repo: https://github.com/pgrls/pgrls\n    rev: v0.5.7\n    hooks:\n      - id: pgrls-lint\n        # pgrls hits a real database, so most teams scope this to\n        # `pre-push` rather than every commit.\n        stages: [pre-push]\n        args:\n          - --database-url=$DATABASE_URL\n          - --config=pgrls.toml\n```\n\n### GitHub Actions\n\nThe quickest path is the published Action ([`pgrls/pgrls-action`](https://github.com/marketplace/actions/pgrls-postgres-rls-linter) on the GitHub Marketplace) — it installs `pgrls` from PyPI and runs `pgrls lint` against a reachable database:\n\n```yaml\n- uses: pgrls/pgrls-action@v1\n  with:\n    database-url: ${{ secrets.PGRLS_DATABASE_URL }}\n    schemas: public\n    fail-on: error\n```\n\nIt exposes every flag `pgrls lint` does (`--format`, `--rule`, `--exclude-rule`, `--baseline`, `--output`, `--min-severity`, …); see the [Marketplace listing](https://github.com/marketplace/actions/pgrls-postgres-rls-linter) for the full input table.\n\nOr run `pgrls` directly — useful when you want to spin up an ephemeral Postgres as a job service:\n\n```yaml\n# .github/workflows/pgrls.yml\nname: pgrls\non: [push, pull_request]\njobs:\n  lint:\n    runs-on: ubuntu-latest\n    services:\n      postgres:\n        image: postgres:16-alpine\n        env:\n          POSTGRES_USER: ci\n          POSTGRES_PASSWORD: ci\n          POSTGRES_DB: ci\n        ports: [\"5432:5432\"]\n        options: \u003e-\n          --health-cmd pg_isready\n          --health-interval 10s\n          --health-retries 5\n    env:\n      DATABASE_URL: postgres://ci:ci@localhost:5432/ci\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-python@v5\n        with:\n          python-version: \"3.11\"\n      - run: pip install pgrls\n      - name: Apply schema\n        run: psql \"$DATABASE_URL\" -v ON_ERROR_STOP=1 -f migrations/all.sql\n      - name: Lint RLS\n        run: pgrls lint --format sarif \u003e pgrls.sarif\n      - name: Upload SARIF for code scanning\n        uses: github/codeql-action/upload-sarif@v3\n        if: always()\n        with:\n          sarif_file: pgrls.sarif\n```\n\nThe SARIF upload puts findings inline on the PR as code-scanning\nalerts — no extra dashboard plumbing. Use `--format json` instead\nof `--format sarif` if you want to pipe to `jq`, build your own\ndashboard, or keep the report as a build artifact.\n\n## Roadmap\n\n- **More lint rules.** Continued expansion of the SEC / PERF / HYG / VIEW catalog. Polished error messages.\n- ~~**TypeScript port of `pgrls.testing`**~~ — shipped as the [`pgrls-test`](https://www.npmjs.com/package/pgrls-test) npm package, versioned independently of the Python package (tagged `ts-v0.6.0`). Source: [`ts/`](ts/).\n- **Go port** of `pgrls.testing` following the same Layer 1 protocol — versioned independently of the Python package as the `go/v0.7.x` sequence (Go module tag prefix `go/`, distinct from the Python package's tags). Step 1 (scaffold + protocol-version constant + error types) landed in `go/v0.7.0`; subsequent steps (Driver interface, pgx + lib/pq adapters, Client API, assertion helpers, conformance suite) tracked in [`go/CHANGELOG.md`](go/CHANGELOG.md).\n- ~~**SAT-based predicate implication checking.**~~ Z3-driven semantic predicate analysis landed in v0.4.x.\n- ~~**Migration-as-input.**~~ `pgrls diff --apply migration.sql` shipped in v0.5.0; baseline cache + extension auto-detect in v0.5.1–v0.5.2.\n\n## License\n\nMIT — see `LICENSE`.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpgrls%2Fpgrls","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fpgrls%2Fpgrls","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpgrls%2Fpgrls/lists"}