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
2 changes: 2 additions & 0 deletions .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ jobs:
env: {}
- package: graphql/playwright-test
env: {}
- package: packages/safegres
env: {}
# - package: jobs/knative-job-worker
# env: {}
- package: packages/csv-to-pg
Expand Down
55 changes: 55 additions & 0 deletions packages/safegres/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<p align="center">
<img src="https://raw.githubusercontent.com/Safegres/brand/refs/heads/main/safegres.svg" alt="safegres" width="120" />
</p>

# safegres

Pure-Postgres Row-Level Security auditor. No app framework required. Drop it on any PostgreSQL database and get a structured report on grants, RLS enforcement, policy coverage, and risky SQL policy patterns.

safegres audits Row-Level Security from inside Postgres. It checks whether tables with grants are protected by RLS, whether policies actually cover the granted operations, and whether policy bodies contain risky patterns like permissive `true` checks, volatile functions, or role/session-based bypass logic.

```bash
npm install -g safegres

# Standard libpq env vars (PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE)
export PGHOST=localhost PGUSER=postgres PGPASSWORD=password PGDATABASE=mydb
safegres audit
```

Per-field overrides (`--host`, `--port`, `--user`, `--password`, `--database`) and a full `--connection <url>` flag are also supported. See `safegres audit --help`.

## What it checks

| Code | Severity | Category | Check |
| --- | --- | --- | --- |
| A1 | critical | flags | RLS enabled but **0 policies** (effectively deny-all) |
| A2 | high | flags | Grants exist on a table with **RLS disabled** |
| A3 | medium | flags | RLS enabled but **`FORCE ROW LEVEL SECURITY` not set** (table owner bypass) |
| A4 | high | coverage | INSERT / UPDATE / DELETE grant with **no covering policy** for that verb |
| A5 | medium | coverage | SELECT grant with **no policy** (silent empty result) |
| A6 | info | coverage | UPDATE has `USING` but **no `WITH CHECK`** (row-smuggling surface) |
| A7 | high | anti-pattern | Trivially-permissive policy (`USING (true)` / `WITH CHECK (true)`) |
| P1 | high | anti-pattern | Policy body calls a **VOLATILE function** (per-row evaluation) |
| P5 | high | anti-pattern | Policy body references **`session_user`** / `current_user` / `pg_has_role(...)` |

Coverage is aggregated `(table, role) → { hasUsing, hasWithCheck }` across every applicable permissive policy (FOR ALL + PUBLIC-role policies considered). Roles with `BYPASSRLS` are suppressed.

## Library use

```ts
import { Client } from 'pg';
import { getPgEnvOptions } from 'pg-env';
import { audit, renderPretty } from 'safegres';

const client = new Client(getPgEnvOptions());
await client.connect();

const report = await audit(client, {
excludeSchemas: ['my_private_schema']
});

console.log(renderPretty(report));
console.log(`${report.findings.length} findings`);
```


121 changes: 121 additions & 0 deletions packages/safegres/__tests__/audit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import * as fs from 'fs';
import * as path from 'path';

import { getConnections, PgTestClient } from 'pgsql-test';

import { audit } from '../src/commands/audit';
import type { Finding } from '../src/types';

jest.setTimeout(120000);

let pg: PgTestClient;
let teardown: () => Promise<void>;

async function applyFixture(name: string): Promise<void> {
const filepath = path.join(__dirname, 'fixtures', name);
const sql = fs.readFileSync(filepath, 'utf8');
await pg.any(sql);
}

function findingsFor(findings: Finding[], schema: string): Finding[] {
return findings.filter((f) => f.schema === schema);
}

function codes(findings: Finding[]): string[] {
return findings.map((f) => f.code).sort();
}

beforeAll(async () => {
({ pg, teardown } = await getConnections());
});

afterAll(async () => {
if (teardown) await teardown();
});

describe('audit — Script A', () => {
it('A1: flags RLS enabled with zero policies', async () => {
await applyFixture('a1-rls-enabled-no-policies.sql');
const report = await audit(pg.client as never, { schemas: ['fx_a1'] });
const found = findingsFor(report.findings, 'fx_a1');
expect(codes(found)).toEqual(expect.arrayContaining(['A1']));
expect(found.find((f) => f.code === 'A1')?.severity).toBe('critical');
});

it('A2: flags grants on table with RLS disabled', async () => {
await applyFixture('a2-grants-no-rls.sql');
const report = await audit(pg.client as never, { schemas: ['fx_a2'] });
const found = findingsFor(report.findings, 'fx_a2');
const a2 = found.find((f) => f.code === 'A2');
expect(a2).toBeDefined();
expect(a2?.severity).toBe('high');
expect((a2?.context as { roles?: string[] } | undefined)?.roles).toContain('fx_a2_reader');
});

it('A3: flags RLS enabled but not forced', async () => {
await applyFixture('a3-rls-not-forced.sql');
const report = await audit(pg.client as never, { schemas: ['fx_a3'] });
const found = findingsFor(report.findings, 'fx_a3');
expect(codes(found)).toEqual(expect.arrayContaining(['A3']));
});

it('A4: flags INSERT grant with no matching policy', async () => {
await applyFixture('a4-insert-grant-no-policy.sql');
const report = await audit(pg.client as never, { schemas: ['fx_a4'] });
const found = findingsFor(report.findings, 'fx_a4');
const a4 = found.find((f) => f.code === 'A4');
expect(a4).toBeDefined();
expect(a4?.role).toBe('fx_a4_writer');
expect(a4?.privilege).toBe('INSERT');
// SELECT is covered so A5 shouldn't fire for SELECT
expect(found.find((f) => f.code === 'A5' && f.privilege === 'SELECT')).toBeUndefined();
});

it('A6: flags UPDATE coverage missing WITH CHECK for role', async () => {
await applyFixture('a6-update-no-with-check.sql');
const report = await audit(pg.client as never, { schemas: ['fx_a6'] });
const found = findingsFor(report.findings, 'fx_a6');
const a6 = found.find((f) => f.code === 'A6');
expect(a6).toBeDefined();
expect(a6?.severity).toBe('info');
expect(a6?.role).toBe('fx_a6_editor');
expect(a6?.privilege).toBe('UPDATE');
});

it('A7: flags permissive policy with body = literal true (fail-open)', async () => {
await applyFixture('a7-trivially-permissive.sql');
const report = await audit(pg.client as never, { schemas: ['fx_a7'] });
const found = findingsFor(report.findings, 'fx_a7');
const a7 = found.find((f) => f.code === 'A7');
expect(a7).toBeDefined();
expect(a7?.severity).toBe('high');
expect(a7?.policy).toBe('fx_a7_open');
expect((a7?.context as { clauses?: string[] } | undefined)?.clauses).toEqual(['USING']);
});

it('P1: flags policy using VOLATILE function', async () => {
await applyFixture('p1-volatile-func.sql');
const report = await audit(pg.client as never, { schemas: ['fx_p1'] });
const found = findingsFor(report.findings, 'fx_p1');
const p1 = found.find((f) => f.code === 'P1');
expect(p1).toBeDefined();
expect(p1?.severity).toBe('high');
expect((p1?.context as { function?: string } | undefined)?.function).toBe('fx_p1.slow_auth_lookup');
});

it('P5: flags current_user reference in policy', async () => {
await applyFixture('p5-session-user.sql');
const report = await audit(pg.client as never, { schemas: ['fx_p5'] });
const found = findingsFor(report.findings, 'fx_p5');
const p5 = found.find((f) => f.code === 'P5');
expect(p5).toBeDefined();
expect(p5?.severity).toBe('high');
});

it('clean table produces no findings', async () => {
await applyFixture('clean-table.sql');
const report = await audit(pg.client as never, { schemas: ['fx_clean'] });
const found = findingsFor(report.findings, 'fx_clean');
expect(found).toEqual([]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- A1 seed: RLS enabled on a table with zero policies.
-- Expected finding: A1 (critical)

CREATE SCHEMA IF NOT EXISTS fx_a1;

CREATE TABLE fx_a1.posts (
id bigserial PRIMARY KEY,
body text
);

ALTER TABLE fx_a1.posts ENABLE ROW LEVEL SECURITY;
18 changes: 18 additions & 0 deletions packages/safegres/__tests__/fixtures/a2-grants-no-rls.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-- A2 seed: grants to a non-owner role on a table with RLS disabled.
-- Expected finding: A2 (high)

CREATE SCHEMA IF NOT EXISTS fx_a2;

DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fx_a2_reader') THEN
CREATE ROLE fx_a2_reader NOLOGIN;
END IF;
END $$;

CREATE TABLE fx_a2.widgets (
id bigserial PRIMARY KEY,
body text
);

GRANT SELECT, INSERT ON fx_a2.widgets TO fx_a2_reader;
-- RLS intentionally NOT enabled.
15 changes: 15 additions & 0 deletions packages/safegres/__tests__/fixtures/a3-rls-not-forced.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- A3 seed: RLS enabled but not FORCE'd, with at least one policy so A1 doesn't trigger.
-- Expected finding: A3 (medium)

CREATE SCHEMA IF NOT EXISTS fx_a3;

CREATE TABLE fx_a3.notes (
id bigserial PRIMARY KEY,
body text,
owner_id uuid
);

ALTER TABLE fx_a3.notes ENABLE ROW LEVEL SECURITY;

CREATE POLICY notes_select ON fx_a3.notes
FOR SELECT USING (true);
25 changes: 25 additions & 0 deletions packages/safegres/__tests__/fixtures/a4-insert-grant-no-policy.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
-- A4 seed: INSERT grant to a role, but only a SELECT policy exists for that role.
-- Expected findings: A4 (high), and the INSERT will fail at runtime.

CREATE SCHEMA IF NOT EXISTS fx_a4;

DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fx_a4_writer') THEN
CREATE ROLE fx_a4_writer NOLOGIN;
END IF;
END $$;

CREATE TABLE fx_a4.events (
id bigserial PRIMARY KEY,
body text,
owner_id uuid
);

ALTER TABLE fx_a4.events ENABLE ROW LEVEL SECURITY;

GRANT SELECT, INSERT ON fx_a4.events TO fx_a4_writer;

CREATE POLICY events_select ON fx_a4.events
FOR SELECT TO fx_a4_writer
USING (true);
-- No INSERT policy for fx_a4_writer.
28 changes: 28 additions & 0 deletions packages/safegres/__tests__/fixtures/a6-update-no-with-check.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-- A6 seed: UPDATE policy with USING but no WITH CHECK.
-- Expected finding: A6 (high)

CREATE SCHEMA IF NOT EXISTS fx_a6;

DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fx_a6_editor') THEN
CREATE ROLE fx_a6_editor NOLOGIN;
END IF;
END $$;

CREATE TABLE fx_a6.docs (
id bigserial PRIMARY KEY,
owner_id uuid NOT NULL,
body text
);

ALTER TABLE fx_a6.docs ENABLE ROW LEVEL SECURITY;

GRANT SELECT, UPDATE ON fx_a6.docs TO fx_a6_editor;

CREATE POLICY docs_update ON fx_a6.docs
FOR UPDATE TO fx_a6_editor
USING (true);
-- missing WITH CHECK — rows can be moved out of the visible set.

CREATE POLICY docs_select ON fx_a6.docs
FOR SELECT TO fx_a6_editor USING (true);
24 changes: 24 additions & 0 deletions packages/safegres/__tests__/fixtures/a7-trivially-permissive.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-- A7 seed: permissive policy whose body is the literal `true` —
-- effectively "RLS enabled, nothing gated" ⇒ fail-open.
-- Expected finding: A7 (high)

CREATE SCHEMA IF NOT EXISTS fx_a7;

DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fx_a7_reader') THEN
CREATE ROLE fx_a7_reader;
END IF;
END $$;

CREATE TABLE fx_a7.posts (
id bigserial PRIMARY KEY,
body text
);

ALTER TABLE fx_a7.posts ENABLE ROW LEVEL SECURITY;
ALTER TABLE fx_a7.posts FORCE ROW LEVEL SECURITY;

CREATE POLICY fx_a7_open ON fx_a7.posts FOR SELECT TO fx_a7_reader USING (true);

GRANT USAGE ON SCHEMA fx_a7 TO fx_a7_reader;
GRANT SELECT ON fx_a7.posts TO fx_a7_reader;
35 changes: 35 additions & 0 deletions packages/safegres/__tests__/fixtures/clean-table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
-- Baseline: correctly-configured table with RLS + FORCE + grants covered by
-- non-trivial policies.
-- Expected findings: none.

CREATE SCHEMA IF NOT EXISTS fx_clean;

DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fx_clean_user') THEN
CREATE ROLE fx_clean_user NOLOGIN;
END IF;
END $$;

CREATE TABLE fx_clean.items (
id bigserial PRIMARY KEY,
owner_id uuid NOT NULL,
body text
);

ALTER TABLE fx_clean.items ENABLE ROW LEVEL SECURITY;
ALTER TABLE fx_clean.items FORCE ROW LEVEL SECURITY;

GRANT SELECT, INSERT, UPDATE ON fx_clean.items TO fx_clean_user;

-- Non-trivial policies gated on the row owner column.
CREATE POLICY items_select ON fx_clean.items
FOR SELECT TO fx_clean_user
USING (owner_id IS NOT NULL);

CREATE POLICY items_insert ON fx_clean.items
FOR INSERT TO fx_clean_user
WITH CHECK (owner_id IS NOT NULL);

CREATE POLICY items_update ON fx_clean.items
FOR UPDATE TO fx_clean_user
USING (owner_id IS NOT NULL) WITH CHECK (owner_id IS NOT NULL);
27 changes: 27 additions & 0 deletions packages/safegres/__tests__/fixtures/p1-volatile-func.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- P1 seed: policy references a user-defined VOLATILE function.
-- Expected finding: P1 (high) — per-row evaluation.

CREATE SCHEMA IF NOT EXISTS fx_p1;

DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fx_p1_user') THEN
CREATE ROLE fx_p1_user NOLOGIN;
END IF;
END $$;

CREATE FUNCTION fx_p1.slow_auth_lookup() RETURNS uuid
LANGUAGE sql
VOLATILE
AS $$ SELECT gen_random_uuid() $$;

CREATE TABLE fx_p1.records (
id bigserial PRIMARY KEY,
owner_id uuid
);

ALTER TABLE fx_p1.records ENABLE ROW LEVEL SECURITY;
GRANT SELECT ON fx_p1.records TO fx_p1_user;

CREATE POLICY records_select ON fx_p1.records
FOR SELECT TO fx_p1_user
USING (owner_id = fx_p1.slow_auth_lookup());
Loading
Loading