safegres 1.16.0 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +546 -302
- package/adapters.d.ts +8 -0
- package/adapters.js +18 -0
- package/callgraph/extract.d.ts +45 -0
- package/callgraph/extract.js +92 -3
- package/checks/anti-patterns.js +17 -13
- package/checks/coverage.d.ts +26 -1
- package/checks/coverage.js +6 -4
- package/checks/definer-view.d.ts +109 -0
- package/checks/definer-view.js +236 -0
- package/checks/lattice.d.ts +144 -0
- package/checks/lattice.js +399 -0
- package/checks/role-reach.d.ts +191 -0
- package/checks/role-reach.js +158 -0
- package/checks/role-trust.d.ts +14 -0
- package/checks/set-role.d.ts +21 -0
- package/checks/set-role.js +66 -0
- package/checks/view-exposure.d.ts +68 -0
- package/checks/view-exposure.js +232 -0
- package/checks/view-writes.d.ts +65 -0
- package/checks/view-writes.js +294 -0
- package/cli/audit.js +202 -38
- package/cli/commands.js +6 -1
- package/cli/eval.d.ts +3 -0
- package/cli/eval.js +113 -0
- package/cli/shared.d.ts +22 -0
- package/cli/shared.js +70 -1
- package/cli.js +18 -2
- package/commands/audit.d.ts +10 -0
- package/commands/audit.js +273 -15
- package/commands/eval.d.ts +67 -0
- package/commands/eval.js +93 -0
- package/config/fingerprint.d.ts +40 -0
- package/config/fingerprint.js +71 -0
- package/config/loader.d.ts +6 -0
- package/config/loader.js +14 -0
- package/config/presets.d.ts +67 -3
- package/config/presets.js +213 -10
- package/config/types.d.ts +201 -1
- package/corpus/README.md +75 -0
- package/corpus/bootstrap.sql +14 -0
- package/corpus/cases/01-anon-write-grant/case.json +26 -0
- package/corpus/cases/01-anon-write-grant/schema.sql +19 -0
- package/corpus/cases/02-grant-to-public/case.json +25 -0
- package/corpus/cases/02-grant-to-public/schema.sql +18 -0
- package/corpus/cases/03-rls-off-reachable-by-anon/case.json +30 -0
- package/corpus/cases/03-rls-off-reachable-by-anon/schema.sql +13 -0
- package/corpus/cases/04-permissive-write-policy/case.json +30 -0
- package/corpus/cases/04-permissive-write-policy/schema.sql +21 -0
- package/corpus/cases/05-public-read-policy/case.json +25 -0
- package/corpus/cases/05-public-read-policy/schema.sql +16 -0
- package/corpus/cases/06-update-without-with-check/case.json +25 -0
- package/corpus/cases/06-update-without-with-check/schema.sql +19 -0
- package/corpus/cases/07-rls-enabled-no-policies/case.json +29 -0
- package/corpus/cases/07-rls-enabled-no-policies/schema.sql +14 -0
- package/corpus/cases/08-select-grant-no-policy/case.json +25 -0
- package/corpus/cases/08-select-grant-no-policy/schema.sql +20 -0
- package/corpus/cases/09-write-grant-no-policy/case.json +24 -0
- package/corpus/cases/09-write-grant-no-policy/schema.sql +18 -0
- package/corpus/cases/10-rls-not-forced/case.json +24 -0
- package/corpus/cases/10-rls-not-forced/schema.sql +18 -0
- package/corpus/cases/11-session-user-in-policy/case.json +24 -0
- package/corpus/cases/11-session-user-in-policy/schema.sql +18 -0
- package/corpus/cases/12-volatile-function-in-policy/case.json +24 -0
- package/corpus/cases/12-volatile-function-in-policy/schema.sql +21 -0
- package/corpus/cases/13-security-definer-policy-wrapper/case.json +24 -0
- package/corpus/cases/13-security-definer-policy-wrapper/schema.sql +21 -0
- package/corpus/cases/14-dead-policy/case.json +24 -0
- package/corpus/cases/14-dead-policy/schema.sql +18 -0
- package/corpus/cases/15-unreachable-grant/case.json +24 -0
- package/corpus/cases/15-unreachable-grant/schema.sql +18 -0
- package/corpus/cases/16-anon-permissive-write-policy/case.json +34 -0
- package/corpus/cases/16-anon-permissive-write-policy/schema.sql +17 -0
- package/corpus/cases/17-foreign-key-without-index/case.json +24 -0
- package/corpus/cases/17-foreign-key-without-index/schema.sql +18 -0
- package/corpus/cases/18-policy-column-unindexed/case.json +24 -0
- package/corpus/cases/18-policy-column-unindexed/schema.sql +23 -0
- package/corpus/cases/19-policy-column-cast/case.json +24 -0
- package/corpus/cases/19-policy-column-cast/schema.sql +19 -0
- package/corpus/cases/20-stable-function-per-row/case.json +24 -0
- package/corpus/cases/20-stable-function-per-row/schema.sql +25 -0
- package/corpus/cases/21-table-without-primary-key/case.json +24 -0
- package/corpus/cases/21-table-without-primary-key/schema.sql +14 -0
- package/corpus/cases/22-redundant-index/case.json +24 -0
- package/corpus/cases/22-redundant-index/schema.sql +16 -0
- package/corpus/cases/23-tsvector-without-index/case.json +24 -0
- package/corpus/cases/23-tsvector-without-index/schema.sql +13 -0
- package/corpus/cases/24-anon-set-role-escalation/case.json +29 -0
- package/corpus/cases/24-anon-set-role-escalation/schema.sql +30 -0
- package/corpus/cases/25-definer-view-bypass/case.json +31 -0
- package/corpus/cases/25-definer-view-bypass/schema.sql +36 -0
- package/corpus/cases/26-invoker-view-no-bypass/case.json +31 -0
- package/corpus/cases/26-invoker-view-no-bypass/schema.sql +44 -0
- package/corpus/cases/27-definer-view-write/case.json +36 -0
- package/corpus/cases/27-definer-view-write/schema.sql +40 -0
- package/corpus/cases/28-invoker-view-no-write/case.json +31 -0
- package/corpus/cases/28-invoker-view-no-write/schema.sql +37 -0
- package/corpus/cases/29-rewrite-rule-bypass/case.json +36 -0
- package/corpus/cases/29-rewrite-rule-bypass/schema.sql +52 -0
- package/corpus/cases/30-instead-nothing-rule/case.json +31 -0
- package/corpus/cases/30-instead-nothing-rule/schema.sql +44 -0
- package/corpus/cases/31-matview-snapshot/case.json +32 -0
- package/corpus/cases/31-matview-snapshot/schema.sql +36 -0
- package/corpus/cases/32-matview-no-rls-no-bypass/case.json +30 -0
- package/corpus/cases/32-matview-no-rls-no-bypass/schema.sql +18 -0
- package/corpus/cases/33-leaky-filter-view/case.json +36 -0
- package/corpus/cases/33-leaky-filter-view/schema.sql +29 -0
- package/corpus/cases/34-barrier-view-no-leak/case.json +32 -0
- package/corpus/cases/34-barrier-view-no-leak/schema.sql +29 -0
- package/corpus/index.d.ts +79 -0
- package/corpus/index.js +125 -0
- package/esm/adapters.d.ts +8 -0
- package/esm/adapters.js +6 -0
- package/esm/callgraph/extract.d.ts +45 -0
- package/esm/callgraph/extract.js +89 -3
- package/esm/checks/anti-patterns.js +17 -13
- package/esm/checks/coverage.d.ts +26 -1
- package/esm/checks/coverage.js +3 -3
- package/esm/checks/definer-view.d.ts +109 -0
- package/esm/checks/definer-view.js +228 -0
- package/esm/checks/lattice.d.ts +144 -0
- package/esm/checks/lattice.js +389 -0
- package/esm/checks/role-reach.d.ts +191 -0
- package/esm/checks/role-reach.js +153 -0
- package/esm/checks/role-trust.d.ts +14 -0
- package/esm/checks/set-role.d.ts +21 -0
- package/esm/checks/set-role.js +63 -0
- package/esm/checks/view-exposure.d.ts +68 -0
- package/esm/checks/view-exposure.js +227 -0
- package/esm/checks/view-writes.d.ts +65 -0
- package/esm/checks/view-writes.js +289 -0
- package/esm/cli/audit.js +203 -39
- package/esm/cli/commands.js +6 -1
- package/esm/cli/eval.d.ts +3 -0
- package/esm/cli/eval.js +108 -0
- package/esm/cli/shared.d.ts +22 -0
- package/esm/cli/shared.js +36 -1
- package/esm/cli.js +18 -2
- package/esm/commands/audit.d.ts +10 -0
- package/esm/commands/audit.js +275 -17
- package/esm/commands/eval.d.ts +67 -0
- package/esm/commands/eval.js +90 -0
- package/esm/config/fingerprint.d.ts +40 -0
- package/esm/config/fingerprint.js +68 -0
- package/esm/config/loader.d.ts +6 -0
- package/esm/config/loader.js +14 -0
- package/esm/config/presets.d.ts +67 -3
- package/esm/config/presets.js +212 -9
- package/esm/config/types.d.ts +201 -1
- package/esm/corpus/index.d.ts +79 -0
- package/esm/corpus/index.js +85 -0
- package/esm/exposure/adapters.d.ts +152 -0
- package/esm/exposure/adapters.js +463 -0
- package/esm/exposure/planes.d.ts +59 -0
- package/esm/exposure/planes.js +144 -0
- package/esm/exposure/reach.d.ts +78 -0
- package/esm/exposure/reach.js +105 -0
- package/esm/index.d.ts +33 -6
- package/esm/index.js +16 -3
- package/esm/lint/index.d.ts +11 -0
- package/esm/lint/index.js +10 -0
- package/esm/pg/acl.d.ts +63 -0
- package/esm/pg/acl.js +164 -0
- package/esm/pg/behaviors.d.ts +114 -0
- package/esm/pg/behaviors.js +206 -0
- package/esm/pg/exposure.d.ts +54 -14
- package/esm/pg/exposure.js +162 -56
- package/esm/pg/indexes.d.ts +71 -1
- package/esm/pg/indexes.js +151 -11
- package/esm/pg/introspect.js +4 -1
- package/esm/pg/paths.d.ts +16 -7
- package/esm/pg/paths.js +17 -5
- package/esm/report/compare.d.ts +8 -0
- package/esm/report/compare.js +17 -3
- package/esm/report/github.d.ts +61 -0
- package/esm/report/github.js +240 -0
- package/esm/report/markdown.d.ts +5 -0
- package/esm/report/markdown.js +114 -14
- package/esm/report/pretty.d.ts +5 -0
- package/esm/report/pretty.js +95 -21
- package/esm/report/view.d.ts +75 -0
- package/esm/report/view.js +121 -0
- package/esm/rules/registry.d.ts +3 -1
- package/esm/rules/registry.js +167 -0
- package/esm/types.d.ts +89 -6
- package/esm/version.d.ts +1 -1
- package/esm/version.js +2 -2
- package/exposure/adapters.d.ts +152 -0
- package/exposure/adapters.js +468 -0
- package/exposure/planes.d.ts +59 -0
- package/exposure/planes.js +151 -0
- package/exposure/reach.d.ts +78 -0
- package/exposure/reach.js +109 -0
- package/index.d.ts +33 -6
- package/index.js +79 -2
- package/lint/index.d.ts +11 -0
- package/lint/index.js +19 -0
- package/package.json +27 -12
- package/pg/acl.d.ts +63 -0
- package/pg/acl.js +168 -0
- package/pg/behaviors.d.ts +114 -0
- package/pg/behaviors.js +217 -0
- package/pg/exposure.d.ts +54 -14
- package/pg/exposure.js +164 -56
- package/pg/indexes.d.ts +71 -1
- package/pg/indexes.js +152 -11
- package/pg/introspect.js +4 -1
- package/pg/paths.d.ts +16 -7
- package/pg/paths.js +17 -5
- package/report/compare.d.ts +8 -0
- package/report/compare.js +17 -3
- package/report/github.d.ts +61 -0
- package/report/github.js +283 -0
- package/report/markdown.d.ts +5 -0
- package/report/markdown.js +114 -14
- package/report/pretty.d.ts +5 -0
- package/report/pretty.js +95 -21
- package/report/view.d.ts +75 -0
- package/report/view.js +127 -0
- package/rules/registry.d.ts +3 -1
- package/rules/registry.js +167 -0
- package/types.d.ts +89 -6
- package/version.d.ts +1 -1
- package/version.js +2 -2
package/README.md
CHANGED
|
@@ -12,248 +12,432 @@
|
|
|
12
12
|
<a href="https://www.npmjs.com/package/safegres"><img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/constructive?filename=packages%2Fsafegres%2Fpackage.json"/></a>
|
|
13
13
|
</p>
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
<p align="center"><strong>Safe <em>and</em> fast Postgres. Graded.</strong></p>
|
|
16
16
|
|
|
17
|
-
safegres
|
|
17
|
+
safegres is a static analyzer for a live PostgreSQL database. It reads the system catalog and
|
|
18
|
+
parses every policy predicate and function body into an AST, then answers two questions the
|
|
19
|
+
catalog already contains the answers to — *who can actually reach this row*, and *what does
|
|
20
|
+
checking that cost per row* — and scores them on **two independent axes**.
|
|
18
21
|
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
Two scores, never mixed. Hardening a table can't show up as a performance regression; adding an
|
|
23
|
+
index can't show up as a security win. That is the whole design: the trade you make between them
|
|
24
|
+
becomes visible instead of netting out to a single number.
|
|
21
25
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
safegres
|
|
26
|
+
```bash
|
|
27
|
+
npm i -D safegres
|
|
28
|
+
npx safegres doctor # connection, catalog visibility, and what's missing
|
|
29
|
+
npx safegres audit --perf # two scores, from the catalog
|
|
25
30
|
```
|
|
26
31
|
|
|
27
|
-
|
|
32
|
+
Standard libpq environment (`PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`), a full
|
|
33
|
+
`--connection <url>`, or per-field flags. No agent, no traffic, no application framework, nothing
|
|
34
|
+
executed and nothing written — safe to point at a production replica.
|
|
28
35
|
|
|
29
|
-
|
|
36
|
+
*No database handy? See [CI](#ci-in-one-job): one service container plus the migration command you
|
|
37
|
+
already have.*
|
|
30
38
|
|
|
31
|
-
|
|
39
|
+
## Features
|
|
32
40
|
|
|
33
|
-
|
|
34
|
-
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
* 🛡️ **Complete RLS Auditing** – Grants, RLS enforcement, policy coverage and policy behavior, across every schema and role in the database
|
|
42
|
+
* 🎯 **Operation-Level Coverage** – Every granted `SELECT`/`INSERT`/`UPDATE`/`DELETE` checked against the policies that actually cover it, per role
|
|
43
|
+
* 🔍 **Risky Policy Detection** – Permissive checks, volatile and session-dependent predicates, definer escalation and role bypass, found in the parsed AST rather than by regex
|
|
44
|
+
* ⚡ **Performance on Its Own Axis** – Policy predicates that no index can serve, per-row function calls, missing foreign-key indexes — scored separately, so hardening never reads as a regression
|
|
45
|
+
* 🧭 **Exposure Planes** – The declared API surface is the headline grade; direct-connection and per-role planes are graded beside it, so "what can an anonymous caller reach?" is a number
|
|
46
|
+
* 📊 **Security Scoring & Grades** – Scores, grades, severity counts and per-rule deductions with the payoff of fixing each one
|
|
47
|
+
* 🐘 **Pure PostgreSQL Analysis** – Reads the catalog and parses SQL; no agent, no traffic, no ORM, no application changes, nothing executed
|
|
48
|
+
* ⚙️ **Built for CI** – Summaries, Markdown, JSON and SARIF, GitHub job summaries, annotations and a sticky PR comment, with configurable failure thresholds
|
|
49
|
+
* Δ **Change-Aware Audits** – Compare two reports to show exactly what a branch introduced, fixed or worsened; baselines accept inherited debt and gate only what's new
|
|
50
|
+
* 🧪 **Graded Against a Corpus** – `safegres eval` scores the auditor itself on fixtures with known answers, sealed against config that could game the number
|
|
51
|
+
* 📦 **CLI and Library APIs** – Run it from the command line, or import the analysis, report and renderers into your own tooling
|
|
39
52
|
|
|
40
|
-
|
|
53
|
+
## What you get
|
|
41
54
|
|
|
42
|
-
A plain audit is one command and one gate; `--format markdown` writes the report where a reviewer will actually see it:
|
|
43
|
-
|
|
44
|
-
```yaml
|
|
45
|
-
- name: Audit RLS
|
|
46
|
-
run: |
|
|
47
|
-
npx safegres audit --format markdown >> "$GITHUB_STEP_SUMMARY"
|
|
48
|
-
npx safegres audit --fail-on-grade B --summary
|
|
49
|
-
env:
|
|
50
|
-
PGHOST: localhost
|
|
51
|
-
PGUSER: postgres
|
|
52
|
-
PGPASSWORD: postgres
|
|
53
55
|
```
|
|
56
|
+
safegres 1.16.1 (2026-02-14T09:31:07.884Z)
|
|
54
57
|
|
|
55
|
-
|
|
58
|
+
exposure: 2 schema(s) via config — 41/318 tables exposed
|
|
56
59
|
|
|
57
|
-
|
|
60
|
+
score: 91.4 (A) — model: density
|
|
61
|
+
top deductions: A2 −10 (×1) R3 −4 (×1)
|
|
62
|
+
by rule: A2 B (+8.4) R3 A (+3.5)
|
|
63
|
+
unscored: A4 (×3) A6 (×7) — zero-weight, fixing these cannot move the score
|
|
58
64
|
|
|
59
|
-
|
|
65
|
+
perf score: 78.1 (C) — model: density
|
|
66
|
+
top deductions: X2 −12 (×3) X1 −8 (×2) X9 −4 (×1)
|
|
67
|
+
by rule: X2 C (+11.2) X1 D (+7.6) X9 B (+3.8)
|
|
60
68
|
|
|
61
|
-
|
|
62
|
-
safegres audit --perf --compare main-report.json --compare-ref main --format markdown
|
|
63
|
-
```
|
|
69
|
+
summary: 0 critical 1 high 4 medium 3 low 7 info
|
|
64
70
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
71
|
+
[HIGH] A2 app_public.webhook_deliveries
|
|
72
|
+
Roles [authenticated] have grants on app_public.webhook_deliveries but RLS is disabled
|
|
73
|
+
[MED ] X2 app_public.posts (posts_tenant)
|
|
74
|
+
Policy "posts_tenant" on app_public.posts filters by tenant_id, which is not the
|
|
75
|
+
leading column of any index
|
|
69
76
|
```
|
|
70
77
|
|
|
71
|
-
|
|
78
|
+
Also available as `--format json`, `json-pretty`, `markdown` (job summaries and PR comments) and
|
|
79
|
+
`sarif` (GitHub code scanning).
|
|
72
80
|
|
|
73
|
-
|
|
81
|
+
## How it works
|
|
74
82
|
|
|
75
|
-
|
|
83
|
+
Three analyses over three different representations. None of them run your queries.
|
|
76
84
|
|
|
77
|
-
|
|
85
|
+
**1. The catalog, as a relation.** `pg_class`, `pg_namespace`, `pg_policy`, `pg_roles`,
|
|
86
|
+
`pg_auth_members`, `pg_index` and `aclexplode()` are joined into one snapshot per relation:
|
|
87
|
+
grants, RLS flags, policies with their expressions, indexes, ownership.
|
|
78
88
|
|
|
79
|
-
|
|
80
|
-
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
89
|
+
**2. Policy predicates, as ASTs.** Every `USING` / `WITH CHECK` expression is parsed with
|
|
90
|
+
[`pgsql-parser`](https://github.com/launchql/pgsql-parser) (libpg_query — the *actual* PostgreSQL
|
|
91
|
+
grammar, not a regex) and traversed. That is what makes the interesting rules possible: a literal
|
|
92
|
+
`true` in a write policy (A7), a cast wrapping the policy's own column so no plain index can serve
|
|
93
|
+
it (X3), a non-`IMMUTABLE` call whose arguments reference no column of the row and which sits
|
|
94
|
+
outside an uncorrelated scalar sub-select — i.e. a per-row call the planner will not hoist into an
|
|
95
|
+
InitPlan (X9). Structural detection, not a list of known function names: a GUC-reading helper
|
|
96
|
+
somebody adds next year is caught without configuration.
|
|
97
|
+
|
|
98
|
+
**3. Effective access, as a lattice.** ACL rows are not access. A privilege also arrives via
|
|
99
|
+
`GRANT … TO PUBLIC` and via role inheritance (`pg_auth_members`, INHERIT-following), and it is
|
|
100
|
+
only *reachable* if the role also holds `USAGE` on the schema. safegres computes the effective
|
|
101
|
+
cell each `(relation, role, privilege)` triple lands in, with `pg_has_role` membership semantics,
|
|
102
|
+
and reports the ones that are inconsistent in either direction:
|
|
103
|
+
|
|
104
|
+
| grant | RLS | policy | verdict |
|
|
105
|
+
| --- | --- | --- | --- |
|
|
106
|
+
| yes | on | yes | normal — policy-mediated access |
|
|
107
|
+
| yes | on | no | dead grant (L1 indirect; A4/A5 direct) |
|
|
108
|
+
| yes | off | — | unmediated (A2 table-level; L5 per untrusted role, indirect) |
|
|
109
|
+
| no | — | yes | dead policy (L2) |
|
|
84
110
|
|
|
85
|
-
|
|
111
|
+
The same closure answers the direct question — *what can role X actually reach?* — as
|
|
112
|
+
`report.roleAccess`, with provenance (`direct`, `PUBLIC`, `member of <role>`) per relation.
|
|
86
113
|
|
|
87
|
-
|
|
114
|
+
Two optional analyses go further: `--explain` plans each finding's query shape with
|
|
115
|
+
`EXPLAIN (GENERIC_PLAN)` and lets the planner **refute** the tool's own static claim; `--call-graph`
|
|
116
|
+
computes the transitive closure of function calls from the exposed entry points and enumerates the
|
|
117
|
+
trust boundaries on the way.
|
|
88
118
|
|
|
89
119
|
## What it checks
|
|
90
120
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
|
97
|
-
|
|
|
98
|
-
|
|
|
99
|
-
|
|
|
100
|
-
|
|
|
101
|
-
|
|
|
102
|
-
|
|
|
103
|
-
|
|
|
104
|
-
|
|
|
105
|
-
|
|
|
106
|
-
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
121
|
+
34 rules across two dimensions, plus a source-level convention linter. The prefix letter is a
|
|
122
|
+
family, **not** the dimension: `P1`/`P1b` are performance, `P5` is security.
|
|
123
|
+
|
|
124
|
+
### Security (19 rules)
|
|
125
|
+
|
|
126
|
+
| Code | Severity | Direction | Check |
|
|
127
|
+
| --- | --- | --- | --- |
|
|
128
|
+
| A1 | low | fail-closed | RLS enabled but **0 policies** (deny-all — confirm the lock is intended) |
|
|
129
|
+
| A2 | high | fail-open | Grants exist on a table with **RLS disabled** |
|
|
130
|
+
| A3 | low | fail-open | RLS enabled but **`FORCE ROW LEVEL SECURITY` not set** (table-owner bypass) |
|
|
131
|
+
| A4 | low | fail-closed | INSERT/UPDATE/DELETE grant with **no covering policy** — writes denied at runtime |
|
|
132
|
+
| A5 | low | fail-closed | SELECT grant with **no policy** — queries silently return 0 rows |
|
|
133
|
+
| A6 | info | fail-closed | UPDATE has `USING` but **no `WITH CHECK`** (row-smuggling surface) |
|
|
134
|
+
| A7 | critical | fail-open | Trivially-permissive **write** policy (literal `true`) |
|
|
135
|
+
| A8 | low | fail-open | Trivially-permissive **SELECT** policy (`USING (true)`) |
|
|
136
|
+
| P5 | high | fail-open | Policy references **`session_user`** / `current_user` / `pg_has_role(...)` |
|
|
137
|
+
| R1 | critical | fail-open | An **untrusted role** holds a write privilege † |
|
|
138
|
+
| R2 | high | fail-open | A permissive write policy applies to an untrusted role or PUBLIC † |
|
|
139
|
+
| R3 | medium | fail-open | An RLS table has grants **TO PUBLIC** |
|
|
140
|
+
| L1 | low | fail-closed | **Dead indirect grant** — arrives via PUBLIC/inheritance, no policy admits it |
|
|
141
|
+
| L2 | low | fail-closed | **Dead policy** — applies to a role holding no corresponding grant |
|
|
142
|
+
| L3 | low | fail-closed | **Unreachable grant** — object privilege without schema `USAGE` |
|
|
143
|
+
| L4 | info | neutral | **Dead schema `USAGE`** — reaches no relation and no function |
|
|
144
|
+
| L5 | info | fail-open | An untrusted role reaches an **RLS-off table** via PUBLIC/inheritance † |
|
|
145
|
+
| L6 | info | neutral | **Unaddressable grant** — an API role holds privileges on a relation its API cannot name ‡ |
|
|
146
|
+
| L8 | info | fail-open | **DEFINER view bypass** — an untrusted role reads a base relation as the view's owner † |
|
|
147
|
+
| L9 | info | fail-open | **DEFINER view write** — an auto-updatable definer view writes a base relation as its owner † |
|
|
148
|
+
| L10 | info | fail-open | **Rewrite-rule bypass** — a rule on a view writes a relation as the view's owner, `security_invoker` notwithstanding † |
|
|
149
|
+
| L11 | info | fail-open | **Materialized-view snapshot** — stored rows serve an untrusted role what the base relation's grants and policies would not † |
|
|
150
|
+
| L12 | info | fail-open | **Non-barrier filtering view** — a view is an untrusted role's only path to a relation, but its row filter is not a boundary † |
|
|
151
|
+
| W1 | medium | — | **No exposure surface configured** — whole database assumed reachable, score capped |
|
|
152
|
+
|
|
153
|
+
† R1/R2/L5 are no-ops until you name the untrusted roles:
|
|
154
|
+
`"R1": ["critical", { "roles": ["anonymous"] }]`. They cost nothing on databases without an
|
|
155
|
+
untrusted-role model; the `safegres:constructive` preset configures them for `anonymous`.
|
|
156
|
+
|
|
157
|
+
‡ L6 needs an adapter that can compute [API reach](#api-reach--the-relations-the-api-can-actually-name);
|
|
158
|
+
without one nothing is unaddressable and it never fires.
|
|
159
|
+
|
|
160
|
+
**Direction is the load-bearing idea.** `fail-open` findings are exposure — the untrusted side
|
|
161
|
+
reaches more than intended. `fail-closed` findings are *denied by Postgres at runtime*: an
|
|
162
|
+
availability and hygiene concern, not a leak. They contribute **zero** to the score by default
|
|
163
|
+
(`scoring.failClosedWeight`). safegres does not cry wolf about a grant the database already refuses.
|
|
164
|
+
|
|
165
|
+
### Convention (source-level lint, 4 rules — `safegres:constructive`)
|
|
166
|
+
|
|
167
|
+
House-style rules that read function **definitions** (`pg_get_functiondef`), not the catalog. They
|
|
168
|
+
are pure `source → findings` — no `pg` dependency in the lint module — and are **off outside the
|
|
169
|
+
`safegres:constructive` preset**, which enables them. A function on a non-exposed schema costs
|
|
170
|
+
nothing, like every other rule.
|
|
171
|
+
|
|
172
|
+
| Code | Severity | Direction | Check |
|
|
173
|
+
| --- | --- | --- | --- |
|
|
174
|
+
| C1 | high | fail-open | Function **sets `search_path`** (house rule: never set it — fully-qualify instead) |
|
|
175
|
+
| C2 | medium | neutral | Function uses a **`#variable_conflict`** directive |
|
|
176
|
+
| C3 | low | neutral | Function has an **unqualified relation reference** (relies on `search_path`) |
|
|
177
|
+
| C4 | high | fail-open | Function uses **dynamic SQL** (`EXECUTE` / `EXECUTE … USING` / `FOR … IN EXECUTE`) |
|
|
178
|
+
|
|
179
|
+
C3 ships at `low` (adoption severity — ratchet to error once the tree is clean). C4 cannot be
|
|
180
|
+
statically proven read-only, so every dynamic-SQL site is flagged and must be **waived inline with a
|
|
181
|
+
categorized reason** (`lookup-only`, `codegen`); a reasonless waiver does not suppress it.
|
|
182
|
+
|
|
183
|
+
**Inline suppressions** (ESLint/Prettier style), written as SQL comments inside the body:
|
|
184
|
+
|
|
185
|
+
```sql
|
|
186
|
+
-- safegres-disable-next-line no-dynamic-sql -- lookup-only: building an IN-list of integers
|
|
187
|
+
EXECUTE format('SELECT ... WHERE id = ANY(%L)', ids);
|
|
188
|
+
|
|
189
|
+
EXECUTE 'REFRESH MATERIALIZED VIEW app.mv'; -- safegres-disable-line no-dynamic-sql -- codegen: fixed DDL
|
|
190
|
+
|
|
191
|
+
-- safegres-disable no-dynamic-sql -- lookup-only: this whole block probes the catalog
|
|
192
|
+
...
|
|
193
|
+
-- safegres-enable no-dynamic-sql
|
|
194
|
+
|
|
195
|
+
-- safegres-disable-file no-set-search-path -- vendored extension shim
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
A directive with no rule id applies to every convention rule. Waived findings are **not dropped** —
|
|
199
|
+
they surface as `acknowledged` (accepted-risk) findings carrying their reason, off the score.
|
|
200
|
+
|
|
201
|
+
### Performance (11 rules, `--perf`)
|
|
202
|
+
|
|
203
|
+
| Code | Severity | Check |
|
|
204
|
+
| --- | --- | --- |
|
|
205
|
+
| X1 | medium | **Foreign key with no covering index** — joins and cascading deletes seq-scan the child |
|
|
206
|
+
| X2 | medium | **RLS policy filters on a column that leads no index** — the security qual seq-scans, for every caller |
|
|
207
|
+
| X3 | medium | **Policy casts or wraps its own column** (`tenant_id::text = …`) with no matching expression index |
|
|
208
|
+
| X4 | low | **Policy calls a non-LEAKPROOF function** — the qual can't be pushed below joins or subquery scans |
|
|
209
|
+
| X5 | low | **Redundant index** — exact duplicate of, or leading-column prefix of, another |
|
|
210
|
+
| X6 | low | **No primary key** and no usable replica identity |
|
|
211
|
+
| X7 | medium | **Search column with no index the search can use** — `tsvector` without GIN/GiST, `vector` without HNSW/IVFFlat |
|
|
212
|
+
| X8 | info | **Sort-shaped column leads no index** (`timestamptz`/`date`) — heuristic, scores 0 |
|
|
213
|
+
| X9 | medium | **Policy calls a STABLE function per row** — not wrapped in a scalar sub-select, so no InitPlan |
|
|
214
|
+
| P1 | high | Policy body calls a **VOLATILE function** — re-evaluated per row |
|
|
215
|
+
| P1b | medium | Policy body calls a **STABLE function** in a per-row position |
|
|
216
|
+
|
|
217
|
+
X2/X3/X4/X9 are the checks a generic index linter structurally cannot make, because they require
|
|
218
|
+
the policy predicate's AST. RLS quals are evaluated **before** user quals, on every candidate row,
|
|
219
|
+
for every caller — so an unindexed or cast-wrapped policy column is a whole-table tax rather than
|
|
220
|
+
one slow query. This is where the two dimensions physically intersect, and why they belong in one
|
|
221
|
+
tool.
|
|
222
|
+
|
|
223
|
+
X9, measured on 200k rows with a policy function that counts its own invocations:
|
|
224
|
+
|
|
225
|
+
| Policy qual | Calls | Time |
|
|
226
|
+
| --- | --- | --- |
|
|
227
|
+
| `other_id = current_principal_id()` (Filter) | 200,000 | 424 ms |
|
|
228
|
+
| `other_id = (SELECT current_principal_id())` (InitPlan) | 1 | 22 ms |
|
|
229
|
+
|
|
230
|
+
Four more rules (`S1`–`S4`) read runtime counters rather than the schema and are opt-in with
|
|
231
|
+
`--stats`. Full rationale for every rule, including the access-path signals behind X1 and the
|
|
232
|
+
plan-dependence caveat on X9: **[docs/rules.md](https://github.com/constructive-io/constructive/blob/main/packages/safegres/docs/rules.md)**.
|
|
113
233
|
|
|
114
234
|
## Exposure surface
|
|
115
235
|
|
|
116
|
-
A database-wide score is meaningless if most of the database isn't reachable
|
|
236
|
+
A database-wide score is meaningless if most of the database isn't reachable. Declare the
|
|
237
|
+
**exposure surface** and safegres partitions every finding:
|
|
117
238
|
|
|
118
239
|
- **Exposed** findings (on API-reachable schemas) drive the score.
|
|
119
|
-
- **Internal** findings are reported as unscored
|
|
120
|
-
- **No exposure configured** →
|
|
240
|
+
- **Internal** findings are reported as unscored advisories (`--exposed-only` hides them).
|
|
241
|
+
- **No exposure configured** → `W1`, and the score is capped at 80/B (`scoring.unknownExposureCap`).
|
|
121
242
|
|
|
122
243
|
```jsonc
|
|
123
244
|
{
|
|
124
245
|
"exposure": {
|
|
125
|
-
"schemas": ["app_public", "app_hidden"]
|
|
246
|
+
"schemas": ["app_public", "app_hidden"] // static surface
|
|
126
247
|
// or, on a Constructive database:
|
|
127
|
-
// "resolver": "constructive"
|
|
248
|
+
// "resolver": "constructive" // introspects routing_public.apis → api_schemas
|
|
128
249
|
}
|
|
129
250
|
}
|
|
130
251
|
```
|
|
131
252
|
|
|
132
|
-
|
|
253
|
+
The score improves by being **explicit** (declaring exposure and intent) or by **fixing** a leak —
|
|
254
|
+
never by renaming. A `*_public` schema name is not a declaration; the config is.
|
|
255
|
+
CLI: `--exposure-schemas <csv>`, `--exposed-only`.
|
|
133
256
|
|
|
134
|
-
|
|
257
|
+
Two related declarations, both of which mark findings *acknowledged* (reported as info, excluded
|
|
258
|
+
from the score): `public.read` for deliberately open reads (pricing tables, a public directory)
|
|
259
|
+
and `perf.ignore` for accepted performance debt. Extension-owned relations are skipped by default,
|
|
260
|
+
and an extension that creates objects at runtime can be skipped wholesale with
|
|
261
|
+
`extensions.ignore` — see [docs/rules.md](https://github.com/constructive-io/constructive/blob/main/packages/safegres/docs/rules.md#extension-objects).
|
|
135
262
|
|
|
136
|
-
|
|
263
|
+
### Planes: the other ways in
|
|
137
264
|
|
|
138
|
-
|
|
265
|
+
An API is one way into a database, not the only one. Declare the others as **planes** and each
|
|
266
|
+
gets its own grade — while the headline score stays exactly what it was:
|
|
139
267
|
|
|
140
268
|
```jsonc
|
|
141
269
|
{
|
|
142
|
-
"
|
|
143
|
-
"
|
|
144
|
-
"
|
|
270
|
+
"exposure": {
|
|
271
|
+
"schemas": ["app_public"],
|
|
272
|
+
"planes": [
|
|
273
|
+
{ "name": "direct:reporting", "kind": "role", "roles": ["reporting"] },
|
|
274
|
+
{ "name": "internal", "kind": "schema", "schemas": ["app_private"] }
|
|
275
|
+
]
|
|
145
276
|
}
|
|
146
277
|
}
|
|
147
278
|
```
|
|
148
279
|
|
|
149
|
-
|
|
280
|
+
```
|
|
281
|
+
score 87 (B) security ← still the declared API surface, unchanged
|
|
282
|
+
other access planes — advisory, not part of the score above:
|
|
283
|
+
direct:reporting [role] (reporting): 41 (F) — 12 relation(s), reached via grant
|
|
284
|
+
internal [schema] (app_private): 63 (D) — 9 relation(s)
|
|
285
|
+
```
|
|
150
286
|
|
|
151
|
-
|
|
287
|
+
A role plane is resolved through the same effective-access lattice the `L*` rules use — direct
|
|
288
|
+
grants, `PUBLIC`, and role inheritance — so it answers the question a reviewer actually asks: *if
|
|
289
|
+
this connection string leaks, what does it reach?* Roles with `BYPASSRLS` or superuser are
|
|
290
|
+
reported as not graded rather than given a meaningless F.
|
|
152
291
|
|
|
153
|
-
|
|
292
|
+
Secondary planes never move the headline and never touch finding identity, so adding one cannot
|
|
293
|
+
invalidate a baseline. They gate nothing unless you ask — `failOn.planes` takes a name or glob:
|
|
154
294
|
|
|
155
295
|
```jsonc
|
|
156
|
-
{
|
|
157
|
-
"public": {
|
|
158
|
-
"read": [
|
|
159
|
-
"app_public.plans*", // schema.table globs
|
|
160
|
-
"app_public.event_types",
|
|
161
|
-
"app_public.users" // deliberate public directory
|
|
162
|
-
]
|
|
163
|
-
}
|
|
164
|
-
}
|
|
296
|
+
{ "failOn": { "grade": "B", "planes": { "direct:*": { "grade": "D" } } } }
|
|
165
297
|
```
|
|
166
298
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
- `safegres doctor` warns about stale `public.read` patterns that no longer match any table.
|
|
170
|
-
|
|
171
|
-
## Performance dimension (`--perf`)
|
|
172
|
-
|
|
173
|
-
A slow database is a different problem from an unsafe one, so safegres scores them separately: `safegres perf` (or `safegres audit --perf`) adds `report.perf` with its own findings, summary, and 0-100 score. It is **off by default** — a plain `audit` behaves exactly as before, and no perf finding ever touches the security score.
|
|
174
|
-
|
|
175
|
-
| Code | Severity | Category | Check |
|
|
176
|
-
| --- | --- | --- | --- |
|
|
177
|
-
| X1 | medium | index | **Foreign key with no covering index** — joins and cascading deletes seq-scan the child table |
|
|
178
|
-
| X2 | medium | index | **RLS policy filters on an unindexed column** — the security qual seq-scans on every query against the table |
|
|
179
|
-
| X3 | medium | index | **Policy casts or wraps its own column** (`tenant_id::text = …`, `lower(email) = …`) with no matching expression index |
|
|
180
|
-
| X4 | low | index | **Policy calls a non-LEAKPROOF function** — the qual can't be pushed below joins or subquery scans |
|
|
181
|
-
| X5 | low | index | **Redundant index** — an exact duplicate of, or a leading-column prefix of, another index |
|
|
182
|
-
| X6 | low | index | **No primary key** and no usable replica identity — rows cannot be addressed by updates, deletes, or logical replication |
|
|
183
|
-
| X7 | medium | index | **Search column with no index the search can use** — a `tsvector` without GIN/GiST, a `vector` without HNSW/IVFFlat |
|
|
184
|
-
| X8 | info | index | **Sort-shaped column leads no index** (`timestamptz`/`date`) — ordering or cursor-paginating a connection by it sorts the whole table |
|
|
185
|
-
| X9 | medium | index | **Policy calls a STABLE function per row** — the call isn't wrapped in a scalar sub-select, so the planner can't hoist it into an InitPlan |
|
|
186
|
-
| P1 | high | anti-pattern | Policy body calls a **VOLATILE function** — re-evaluated per row |
|
|
187
|
-
| P1b | medium | anti-pattern | Policy body calls a **STABLE function** in a per-row position |
|
|
299
|
+
`--plane direct:reporting` (or `--plane '*'`) expands one in the terminal and markdown output;
|
|
300
|
+
`--format json` always carries them all in `report.planes`.
|
|
188
301
|
|
|
189
|
-
|
|
302
|
+
### Adapters
|
|
190
303
|
|
|
191
|
-
|
|
304
|
+
Where the exposed surface comes from is an interface, not a hard-coded integration. An adapter is
|
|
305
|
+
an object — no plugin resolution, nothing load-bearing about a package name:
|
|
192
306
|
|
|
193
|
-
|
|
307
|
+
```ts
|
|
308
|
+
// safegres.config.ts
|
|
309
|
+
import type { SafegresConfig } from 'safegres';
|
|
310
|
+
import { constructiveAdapter } from 'safegres/adapters';
|
|
194
311
|
|
|
195
|
-
|
|
312
|
+
import { myGatewayAdapter } from './my-gateway-adapter';
|
|
196
313
|
|
|
197
|
-
|
|
314
|
+
const config: SafegresConfig = {
|
|
315
|
+
exposure: { adapters: [constructiveAdapter, myGatewayAdapter] }
|
|
316
|
+
};
|
|
317
|
+
export default config;
|
|
318
|
+
```
|
|
198
319
|
|
|
199
|
-
|
|
320
|
+
```ts
|
|
321
|
+
interface ExposureAdapter {
|
|
322
|
+
name: string;
|
|
323
|
+
detect(exec: QueryExecutor): Promise<boolean>; // is this stack present?
|
|
324
|
+
resolve(exec: QueryExecutor): Promise<PlaneInput[]>; // one or more planes
|
|
325
|
+
reach?(exec: QueryExecutor, ctx: ReachContext): Promise<ApiReach>; // optional precision
|
|
326
|
+
}
|
|
327
|
+
```
|
|
200
328
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
329
|
+
Built-ins ship for `constructive`, `postgrest`, `supabase`, `hasura` and `graphile` — each reading
|
|
330
|
+
the signal its stack actually leaves in the catalog (see [Configuration](#configuration)), and
|
|
331
|
+
each emitting a primary `api` plane plus whatever secondary planes it can prove: one `api:<name>`
|
|
332
|
+
per API for Constructive, a `direct:<authenticator>` role plane for PostgREST, `app_private` as an
|
|
333
|
+
internal plane for graphile-starter. `postgraphile` is the exception: it contributes no plane at
|
|
334
|
+
all and only supplies [reach](#api-reach--the-relations-the-api-can-actually-name). JSON configs may name a built-in
|
|
335
|
+
(`"adapters": ["supabase"]`); anything else is an error rather than a silent no-op — a typo'd
|
|
336
|
+
adapter would otherwise present as an unexposed database. The old `"resolver": "constructive"`
|
|
337
|
+
still works.
|
|
207
338
|
|
|
208
|
-
|
|
339
|
+
### API reach — the relations the API can actually name
|
|
209
340
|
|
|
210
|
-
|
|
341
|
+
A plane made of schemas answers *is this relation in the API's schemas?*, which over-counts: a
|
|
342
|
+
generated API exposes fields, and a schema routinely holds relations it deliberately does not
|
|
343
|
+
surface — join tables, denormalized shadows, machine-only back-pointers. `reach()` is where an
|
|
344
|
+
adapter narrows a plane from its schemas to its **relations**. The built-in `postgraphile` adapter
|
|
345
|
+
reads the `@behavior` / `@forwardBehavior` / `@backwardBehavior` smart tags to do it, and both
|
|
346
|
+
`graphile` and `constructive` delegate to it — those two answer *which schemas are served*, which
|
|
347
|
+
is a different question from *what the served schemas expose*:
|
|
211
348
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
349
|
+
```jsonc
|
|
350
|
+
{ "exposure": { "schemas": ["app_public"], "adapters": ["postgraphile"] } }
|
|
351
|
+
```
|
|
215
352
|
|
|
216
|
-
|
|
353
|
+
Three properties keep it from quietly deleting findings:
|
|
217
354
|
|
|
218
|
-
|
|
355
|
+
- **Only an explicit denial counts.** Presets grant most behaviors by default, so the *absence* of
|
|
356
|
+
`+list` says nothing. Silence is never read as denial.
|
|
357
|
+
- **Unreachable means unreachable by every route.** A relation with no root entry is still
|
|
358
|
+
addressable by traversing a relation field from one that has, so reach is graph traversal over
|
|
359
|
+
foreign keys, not a per-table test. Hiding one reverse relation is one missing path, not proof.
|
|
360
|
+
- **A role plane is never narrowed.** The API not exposing a table says nothing about a role
|
|
361
|
+
holding a direct connection. Behavior only ever refines `api`/`schema` planes.
|
|
219
362
|
|
|
220
|
-
|
|
363
|
+
Anything subtracted is listed in `report.exposure.unaddressable` rather than silently dropped, and
|
|
364
|
+
`"reach": false` turns the whole thing off. Where an API-edge role still holds privileges on a
|
|
365
|
+
relation its own API cannot name, **L6** reports the grant — unless some RLS policy predicate
|
|
366
|
+
references the relation, since a grant a policy subqueries under the querying role is load-bearing
|
|
367
|
+
however invisible it is to the API.
|
|
221
368
|
|
|
222
|
-
|
|
223
|
-
| --- | --- | --- |
|
|
224
|
-
| `other_id = current_principal_id()` (Filter) | 200,000 | 424 ms |
|
|
225
|
-
| `other_id = (SELECT current_principal_id())` (InitPlan) | 1 | 22 ms |
|
|
369
|
+
## CI in one job
|
|
226
370
|
|
|
227
|
-
|
|
371
|
+
One service container, your existing migration command, one audit:
|
|
228
372
|
|
|
229
|
-
```
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
373
|
+
```yaml
|
|
374
|
+
jobs:
|
|
375
|
+
database-audit:
|
|
376
|
+
runs-on: ubuntu-latest
|
|
377
|
+
services:
|
|
378
|
+
postgres:
|
|
379
|
+
image: postgres:16
|
|
380
|
+
env: { POSTGRES_PASSWORD: postgres }
|
|
381
|
+
options: --health-cmd pg_isready --health-interval 5s --health-retries 10
|
|
382
|
+
ports: ['5432:5432']
|
|
383
|
+
env:
|
|
384
|
+
PGHOST: localhost
|
|
385
|
+
PGUSER: postgres
|
|
386
|
+
PGPASSWORD: postgres
|
|
387
|
+
PGDATABASE: app_audit
|
|
388
|
+
steps:
|
|
389
|
+
- uses: actions/checkout@v4
|
|
390
|
+
# …install deps, then apply your schema with whatever already builds it:
|
|
391
|
+
# rails db:schema:load · manage.py migrate · prisma migrate deploy
|
|
392
|
+
# drizzle-kit push · sqitch deploy · pgpm deploy · psql -f schema.sql
|
|
393
|
+
- run: npx safegres audit --perf --format markdown >> "$GITHUB_STEP_SUMMARY"
|
|
394
|
+
- run: |
|
|
395
|
+
npx safegres audit --perf --summary \
|
|
396
|
+
--fail-on-grade B \
|
|
397
|
+
--perf-baseline ci/safegres-perf-baseline.json --fail-on-new-perf
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
Everything a job repeats every run belongs in the config file instead, so the job is one word. A
|
|
401
|
+
repository whose schema is a pgpm workspace does not need a database at all — `source.pgpm`
|
|
402
|
+
deploys it into an ephemeral one:
|
|
233
403
|
|
|
234
404
|
```jsonc
|
|
405
|
+
// .safegresrc.json — paths are relative to this file
|
|
235
406
|
{
|
|
236
|
-
"
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
"scoring": { "densityK": 0.17 }
|
|
242
|
-
},
|
|
243
|
-
"failOn": { "perfGrade": "B" }
|
|
407
|
+
"extends": "safegres:constructive",
|
|
408
|
+
"source": { "pgpm": "application/app" },
|
|
409
|
+
"perf": { "enabled": true, "baseline": "ci/perf-baseline.json", "failOnNew": true },
|
|
410
|
+
"outputs": { "dir": "safegres-reports" },
|
|
411
|
+
"failOn": { "grade": "B" }
|
|
244
412
|
}
|
|
245
413
|
```
|
|
246
414
|
|
|
247
|
-
|
|
415
|
+
```yaml
|
|
416
|
+
- run: npx safegres audit # or: "audit": "safegres lint" in package.json
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
`outputs.dir` writes `safegres.json`, `safegres.md` and `safegres.sarif` into one directory — name
|
|
420
|
+
an individual file (`outputs.json`) only when the name matters. Directories are created as needed,
|
|
421
|
+
and a flag still wins over the file for a one-off run (`safegres audit --out reports`). Naming a
|
|
422
|
+
connection wins too, so the same config audits a database you already have:
|
|
423
|
+
`safegres audit --database staging`.
|
|
424
|
+
|
|
425
|
+
Scores lead the markdown, then severity counts, then a table per dimension; internal advisories
|
|
426
|
+
and accepted baseline debt fold into `<details>`. Pipe it to `gh pr comment --body-file -` to post
|
|
427
|
+
it on the pull request instead. Library callers get the same renderer as `renderMarkdown(report)`.
|
|
248
428
|
|
|
249
|
-
|
|
429
|
+
`--format sarif` turns findings into GitHub code-scanning alerts, and `--compare` renders the
|
|
430
|
+
delta against a previous run's JSON. Both in **[docs/reporting.md](https://github.com/constructive-io/constructive/blob/main/packages/safegres/docs/reporting.md)**.
|
|
250
431
|
|
|
251
|
-
|
|
432
|
+
## The ratchet
|
|
433
|
+
|
|
434
|
+
An established schema will not be clean on the first run — but it can refuse to get worse. Commit
|
|
435
|
+
today's performance findings as accepted debt and gate on what is *not* in the baseline:
|
|
252
436
|
|
|
253
437
|
```bash
|
|
254
|
-
safegres audit --write-perf-baseline
|
|
255
|
-
safegres audit --perf-baseline
|
|
256
|
-
safegres audit --perf-baseline
|
|
438
|
+
safegres audit --write-perf-baseline ci/safegres-perf-baseline.json # snapshot (implies --perf)
|
|
439
|
+
safegres audit --perf-baseline ci/safegres-perf-baseline.json # diff: new / accepted / fixed
|
|
440
|
+
safegres audit --perf-baseline ci/safegres-perf-baseline.json --fail-on-new-perf # gate
|
|
257
441
|
```
|
|
258
442
|
|
|
259
443
|
```
|
|
@@ -264,171 +448,254 @@ performance vs baseline:
|
|
|
264
448
|
(14 accepted, 2 fixed)
|
|
265
449
|
```
|
|
266
450
|
|
|
267
|
-
Entries are
|
|
451
|
+
Entries are keyed by finding *identity* — `code` + relation + policy + subject (the constraint,
|
|
452
|
+
index, expression, column or function it is about) — never by message text, so rewording a rule or
|
|
453
|
+
retuning a severity in a later release cannot invalidate a committed baseline. Findings that
|
|
454
|
+
disappear are reported as fixed; re-baseline to lock the win in.
|
|
268
455
|
|
|
269
|
-
|
|
456
|
+
On an established schema this is the enforceable gate, and a perf *grade* gate is not: a score
|
|
457
|
+
dominated by inherited debt either fails forever or means nothing. Security has no baseline —
|
|
458
|
+
gate it on a grade you can hold today and raise it as the score climbs.
|
|
270
459
|
|
|
271
|
-
The
|
|
460
|
+
The same mechanism exists for call-graph trust boundaries (`--write-baseline`, `--baseline`,
|
|
461
|
+
`--fail-on-new-boundaries`).
|
|
272
462
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
463
|
+
## Sealed runs
|
|
464
|
+
|
|
465
|
+
Every knob above is deliberate when a team declares its intent in CI, and is the cheapest possible
|
|
466
|
+
cheat when a *score* is the thing being evaluated — of an agent's migration, of a template, of a
|
|
467
|
+
submission. Turning off the rule and re-baselining the debt both raise the number without touching
|
|
468
|
+
the database.
|
|
279
469
|
|
|
280
|
-
|
|
470
|
+
So every report carries the ruler it was measured with:
|
|
281
471
|
|
|
282
472
|
```jsonc
|
|
283
|
-
{
|
|
284
|
-
"
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
"minIndexBytes": 1048576, // S2: ignore indexes too small to be worth dropping
|
|
289
|
-
"deadTupleRatio": 0.2, // S3
|
|
290
|
-
"minTimeShare": 0.05, // S4: share of total sampled time
|
|
291
|
-
"topStatements": 5 // S4: cap
|
|
292
|
-
},
|
|
293
|
-
"scoring": { "includeStats": false } // demote S* to advisories
|
|
294
|
-
}
|
|
473
|
+
"provenance": {
|
|
474
|
+
"version": "1.17.0",
|
|
475
|
+
"fingerprint": "sha256:8f14e45fceea167a…", // over the *resolved* rules, overrides,
|
|
476
|
+
"sealed": true, // scoring weights, exposure and ignores
|
|
477
|
+
"preset": "strict"
|
|
295
478
|
}
|
|
296
479
|
```
|
|
297
480
|
|
|
298
|
-
`
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
The `X*` rules *infer* from the catalog that nothing can serve a query shape. `--explain` asks the database instead: for each probeable finding it plans the query the finding is a claim about with `EXPLAIN (GENERIC_PLAN, FORMAT JSON)` — nothing is executed, and parameters stay parameters, so no value has to be invented for a column — and attaches the plan as `finding.evidence`.
|
|
303
|
-
|
|
304
|
-
The interesting outcome is disagreement. A finding whose probe plans as an index scan is **refuted**: some index the catalog rules didn't credit (a hash index on an FK column, say) does serve it, so the finding is acknowledged, reported as info, and dropped from the perf score. The reverse is deliberately not symmetrical — an empty or unanalyzed table always seq-scans, so a seq scan **confirms** a finding only above a planner row estimate of 1000 (`perf.explain.minRows`); below that the probe is `inconclusive` and the finding is left exactly as the static rule made it.
|
|
481
|
+
`--sealed` grades under a built-in preset alone: no config-file discovery, and `--config`,
|
|
482
|
+
`--rule`, `--exposure-schemas` and every baseline flag are *refused* rather than ignored — a run
|
|
483
|
+
that silently dropped a flag would report a number for rules nobody chose. A harness pins the
|
|
484
|
+
answer it expects:
|
|
305
485
|
|
|
306
486
|
```bash
|
|
307
|
-
safegres audit --
|
|
487
|
+
safegres audit --sealed --preset strict --verify-fingerprint sha256:8f14e45fceea167a… --format json
|
|
308
488
|
```
|
|
309
489
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
foreign key notes_author_id_fkey has no covering index — refuted by EXPLAIN (the planner serves this shape with an index)
|
|
316
|
-
plan (refuted): Bitmap Heap Scan → Bitmap Index Scan
|
|
317
|
-
planner proof: 1 confirmed, 1 refuted, 1 inconclusive of 3 probed
|
|
318
|
-
```
|
|
319
|
-
|
|
320
|
-
Probes exist for X1, X2, X7 and X8 — the rules that name a query shape. X5, X6, `P*` and `S*` are claims about the schema or the workload rather than a plan, so nothing is planned speculatively on their behalf. `GENERIC_PLAN` requires PostgreSQL 16+; on older servers the audit reports `perf.explain.unavailable` and leaves the findings untouched.
|
|
490
|
+
The fingerprint covers the resolved rule set rather than the config text, so it is invariant to how
|
|
491
|
+
a posture was spelled (preset vs. explicit rules, key order) and sensitive to anything that changes
|
|
492
|
+
it — including the safegres version, because a rule's meaning can change without its configuration
|
|
493
|
+
changing. Reports whose fingerprints differ are not comparable, and `--verify-fingerprint` exits
|
|
494
|
+
non-zero rather than let one be read as if it were.
|
|
321
495
|
|
|
322
|
-
|
|
496
|
+
None of this constrains ordinary use: an unsealed run still gets a fingerprint, and `sealed: false`
|
|
497
|
+
is simply the honest statement that local configuration participated.
|
|
323
498
|
|
|
324
|
-
|
|
499
|
+
## The evaluation corpus
|
|
325
500
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
| CG2 | RLS-bypass path — a DEFINER's owner owns (or bypasses RLS on) a table it touches, so RLS does not protect that table on this path |
|
|
331
|
-
| CG3 | Auth-context mutation — a reachable function writes `jwt.claims.*` / `role` |
|
|
332
|
-
| CG1 | Trust hop — execution crosses into a `SECURITY DEFINER` (you are trusting its author's authorization logic) |
|
|
333
|
-
| CG4 | Internal reach — a non-exposed table is reached from a public entry via a DEFINER path |
|
|
334
|
-
| CG5 | Opaque node — dynamic SQL (`EXECUTE`) or an unparseable body; static analysis ends here, audit manually |
|
|
501
|
+
A sealed score says the ruler did not move. It does not say the ruler is right. That is what the
|
|
502
|
+
corpus in [`corpus/`](corpus/README.md) is for: ~20 small schemas, each with one deliberate flaw
|
|
503
|
+
and a written-down answer — the findings a correct audit must produce, the false positives it must
|
|
504
|
+
not, and the one-sentence fix.
|
|
335
505
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
506
|
+
`safegres eval` is the whole loop in one command: it deploys each case into the connected
|
|
507
|
+
database, audits it under a sealed preset, grades the report against the answer key, drops the
|
|
508
|
+
case's schemas again, and exits non-zero if any case fails.
|
|
339
509
|
|
|
340
|
-
```
|
|
341
|
-
|
|
342
|
-
2
|
|
510
|
+
```console
|
|
511
|
+
$ safegres eval --database scratch
|
|
512
|
+
PASS 01-anon-write-grant security 1.2 (F ) R1
|
|
513
|
+
PASS 17-foreign-key-without-index perf 71.2 (C ) X1
|
|
514
|
+
FAIL 18-policy-column-unindexed perf 100 (A+) missed X2
|
|
343
515
|
|
|
344
|
-
|
|
345
|
-
• fx_cg_private.verify_password → fx_cg_private.users
|
|
346
|
-
RLS on fx_cg_private.users does not apply on this path — fx_cg_private.verify_password is SECURITY DEFINER running as postgres (BYPASSRLS/superuser)
|
|
347
|
-
via: fx_cg_public.sign_in → fx_cg_private.verify_password
|
|
516
|
+
25/26 cases passed · recall 98% · precision 100%
|
|
348
517
|
```
|
|
349
518
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
519
|
+
| Flag | |
|
|
520
|
+
| --- | --- |
|
|
521
|
+
| `--preset <name>` | the preset every case is graded under (default `recommended`) |
|
|
522
|
+
| `--corpus <dir>` | your own corpus of `<id>/{case.json,schema.sql}` |
|
|
523
|
+
| `--case <id>` | run one case, or an id prefix — comma-separated |
|
|
524
|
+
| `--list` | print the corpus without touching a database |
|
|
525
|
+
| `--json` | the `EvalReport`: per-case recall, precision, score, fingerprint |
|
|
526
|
+
| `--keep` | leave the case schemas behind, to poke at one by hand |
|
|
353
527
|
|
|
354
|
-
|
|
528
|
+
A config file may set `eval.corpus`, `eval.preset` and `eval.cases` — *what* to run. It cannot
|
|
529
|
+
retune the rules for a run: cases are always graded by the named preset alone, or the corpus would
|
|
530
|
+
be grading itself. The same loop is a library call (`runEval`), and its pieces are public:
|
|
355
531
|
|
|
356
|
-
```
|
|
357
|
-
|
|
358
|
-
safegres audit --baseline .safegres-callgraph.json # diff: report new/resolved boundaries
|
|
359
|
-
safegres audit --baseline .safegres-callgraph.json --fail-on-new-boundaries # gate: exit 1 on new
|
|
360
|
-
```
|
|
532
|
+
```ts
|
|
533
|
+
import { audit, gradeCase, loadConfig, loadCorpus } from 'safegres';
|
|
361
534
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
535
|
+
const { config } = loadConfig({ sealed: true, preset: 'recommended' });
|
|
536
|
+
for (const c of loadCorpus()) {
|
|
537
|
+
await client.query(c.sql);
|
|
538
|
+
const { missed, falsePositives } = gradeCase(await audit(client, { config, ...c }), c);
|
|
539
|
+
}
|
|
366
540
|
```
|
|
367
541
|
|
|
368
|
-
|
|
542
|
+
Cases are data — `schema.sql` plus a `case.json` answer key — so a harness that never runs safegres
|
|
543
|
+
can still use them. Three uses: safegres's own regression suite, worked examples short enough to
|
|
544
|
+
read, and an agent evaluation — hand the agent a case, ask for a fix, and require the expected
|
|
545
|
+
findings to be *gone* with the dimension back at 100 rather than merely a better number.
|
|
369
546
|
|
|
370
547
|
## Configuration
|
|
371
548
|
|
|
372
|
-
|
|
549
|
+
Configured like a linter. Discovered by walking up from the current directory:
|
|
550
|
+
`safegres.config.{ts,js,mjs,cjs}`, `.safegresrc{,.json,.yaml,.yml,.js}`, `safegres.json`, or a
|
|
551
|
+
`"safegres"` key in `package.json` (via
|
|
552
|
+
[confstash](https://github.com/constructive-io/dev-utils/tree/main/packages/confstash)).
|
|
553
|
+
Precedence: **CLI > project config > preset > built-in defaults**.
|
|
373
554
|
|
|
374
555
|
```jsonc
|
|
375
556
|
// .safegresrc.json
|
|
376
557
|
{
|
|
377
558
|
"extends": "safegres:recommended",
|
|
378
|
-
"
|
|
559
|
+
"exposure": { "schemas": ["app_public"], "roles": ["anonymous", "authenticated"] },
|
|
560
|
+
"public": { "read": ["app_public.plans*", "app_public.event_types"] },
|
|
561
|
+
"extensions": { "ignore": ["pg_partman"] },
|
|
379
562
|
"rules": {
|
|
380
|
-
"A3": "
|
|
381
|
-
"A5": "high",
|
|
382
|
-
"
|
|
563
|
+
"A3": "info", // demote — still reported, contributes nothing
|
|
564
|
+
"A5": "high", // retune a severity
|
|
565
|
+
"A7": "off", // disable outright
|
|
566
|
+
"P*": "medium" // prefix wildcard (exact codes win)
|
|
383
567
|
},
|
|
384
568
|
"overrides": [
|
|
385
|
-
{ "tables": ["
|
|
569
|
+
{ "tables": ["app_public.audit_*"], "rules": { "A2": "off" } }
|
|
386
570
|
],
|
|
387
|
-
"
|
|
388
|
-
"
|
|
571
|
+
"perf": { "enabled": true, "ignore": ["app_public.audit_*"], "rules": { "X6": "off" } },
|
|
572
|
+
"scoring": { "densityK": 0.17, "unknownExposureCap": 80 },
|
|
573
|
+
"failOn": { "grade": "B", "perfGrade": "B" }
|
|
389
574
|
}
|
|
390
575
|
```
|
|
391
576
|
|
|
392
|
-
|
|
577
|
+
A typed `safegres.config.ts` with `defineConfig` from `confstash` works identically.
|
|
393
578
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
579
|
+
Presets come in three kinds, and they compose. A **stack** preset knows how your framework
|
|
580
|
+
declares exposure and what its role names mean; a **posture** preset says how harshly to read
|
|
581
|
+
what it finds; both are just partial configs, so `extends` takes an array:
|
|
397
582
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
rules: { A6: 'low' }
|
|
401
|
-
});
|
|
583
|
+
```jsonc
|
|
584
|
+
{ "extends": ["safegres:supabase", "safegres:multi-tenant"] }
|
|
402
585
|
```
|
|
403
586
|
|
|
404
|
-
|
|
587
|
+
| Stack | Resolves exposure from | Treats as untrusted |
|
|
588
|
+
| --- | --- | --- |
|
|
589
|
+
| `safegres:constructive` | `routing_public.apis` → `api_schemas` → `metaschema_public.schema` | `anonymous` |
|
|
590
|
+
| `safegres:postgrest` | `pgrst.db_schemas` in `pg_db_role_setting` | `pgrst.db_anon_role` |
|
|
591
|
+
| `safegres:supabase` | the GUCs if self-hosted, else Supabase's fixed surface | `anon`, `authenticated` |
|
|
592
|
+
| `safegres:hasura` | tracked tables in `hdb_catalog` | `anonymous`, `public` |
|
|
593
|
+
| `safegres:graphile` | the `graphile-starter` layout (`app_public` + `app_hidden`) | `<app>_visitor` |
|
|
594
|
+
|
|
595
|
+
Each reads a real catalog signal — not a schema name. Two carry a caveat worth knowing:
|
|
596
|
+
|
|
597
|
+
- **`graphile`** — PostGraphile's schema list is a process argument that leaves no trace in the
|
|
598
|
+
database, so this preset resolves the starter layout by convention. Naming it *is* the
|
|
599
|
+
declaration that the convention holds; when it doesn't, `exposure.schemas` still wins.
|
|
600
|
+
- **`supabase`** — Supabase configures PostgREST outside the database, so the GUCs are usually
|
|
601
|
+
absent. Its adapter falls back to the platform's fixed surface, but only after proving it is
|
|
602
|
+
looking at Supabase (`auth.users` plus the `anon`/`authenticated`/`service_role` trio). The
|
|
603
|
+
fallback lives in that adapter alone: plain `postgrest` never guesses, and an unconfigured
|
|
604
|
+
PostgREST resolves nothing — reported as unknown exposure, not as a surface.
|
|
605
|
+
|
|
606
|
+
Untrusted roles are usually *resolved*, not named. `pgrst.db_anon_role` and graphile's
|
|
607
|
+
`<app>_visitor` are per-deployment, so those presets point the rules at the surface instead of
|
|
608
|
+
guessing a name:
|
|
609
|
+
|
|
610
|
+
```jsonc
|
|
611
|
+
{ "rules": { "R1": ["critical", { "rolesFrom": "anon" }] } }
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
An adapter resolves two role sets, because "at the API edge" and "reachable without credentials"
|
|
615
|
+
are different questions:
|
|
616
|
+
|
|
617
|
+
| `rolesFrom` | Roles | Use when |
|
|
618
|
+
| --- | --- | --- |
|
|
619
|
+
| `anon` | Only what an unauthenticated caller arrives as — `apis.anon_role`, `pgrst.db_anon_role`, Supabase's `anon`, graphile's visitor | Almost always. A signed-in role holding a write grant is the product; the anon role holding one is the bug. |
|
|
620
|
+
| `exposure` | Every role at the edge, signed-in ones included | A surface where no role should be writing directly. |
|
|
621
|
+
|
|
622
|
+
Both union with any explicit `roles`, so a preset can name a platform default *and* pick up a
|
|
623
|
+
custom one. Rules that ask for neither stay inert — resolved roles never leak into a rule that
|
|
624
|
+
didn't opt in. `report.exposure.anonRoles` carries the anonymous subset, and the pretty renderer
|
|
625
|
+
marks it inline (`api roles: authenticated, anonymous (anon)`).
|
|
405
626
|
|
|
406
|
-
|
|
|
627
|
+
| Posture | Behavior |
|
|
407
628
|
| --- | --- |
|
|
408
629
|
| `safegres:recommended` | Every rule at its default severity (the no-config behavior) |
|
|
409
|
-
| `safegres:strict` | Everything escalated; fail-closed
|
|
410
|
-
| `safegres:
|
|
411
|
-
| `safegres:
|
|
630
|
+
| `safegres:strict` | Everything escalated; fail-closed counts 25%; `failOn: high` |
|
|
631
|
+
| `safegres:multi-tenant` | Row-visibility rules (A1/A2, L1–L3, L5) escalated — in a shared-table database an RLS gap is a cross-tenant read; one critical floors the grade at D |
|
|
632
|
+
| `safegres:oltp` | Perf axis first: the policy-shape rules that turn an index scan into a per-row function call (X2–X4, X9) escalated; `failOn: perfGrade C` |
|
|
633
|
+
| `safegres:minimal` | Structural flags only (A1–A3) — fast smoke check |
|
|
634
|
+
|
|
635
|
+
Presets **retune**, they don't delete: a rule that doesn't apply to a stack is demoted to `info`
|
|
636
|
+
(zero weight, so the score is unchanged) rather than switched off, so it stays in the report and
|
|
637
|
+
stays re-tunable. `minimal` is the deliberate exception — being a smoke check is its whole job.
|
|
412
638
|
|
|
413
639
|
CLI: `--config <path>`, `--preset <name>`, `--rule CODE=off|severity` (repeatable).
|
|
414
640
|
|
|
415
|
-
|
|
641
|
+
## Scoring
|
|
416
642
|
|
|
417
|
-
|
|
643
|
+
Each dimension is scored independently by the same function over disjoint finding sets. The
|
|
644
|
+
default **density** model normalizes by the exposed surface, so a large schema doesn't saturate
|
|
645
|
+
to 0/F:
|
|
418
646
|
|
|
419
647
|
```
|
|
420
648
|
score = 100 · exp(−k · riskPoints / exposedTables)
|
|
421
649
|
```
|
|
422
650
|
|
|
423
|
-
|
|
651
|
+
`riskPoints` is the severity-weighted sum (critical 25, high 10, medium 4, low 1, info 0) of
|
|
652
|
+
*exposed, fail-open, non-acknowledged* findings; `k` defaults to 0.17 (≈ one critical per ten
|
|
653
|
+
exposed tables lands at a C). Non-exposed findings score 0; fail-closed findings score 0 unless
|
|
654
|
+
`scoring.failClosedWeight` is raised; unknown exposure caps the score; any exposed critical floors
|
|
655
|
+
the grade at C (`scoring.floorOnCritical`). Grades: A+ 97 · A 90 · B 80 · C 65 · D 50 · F below.
|
|
656
|
+
The legacy flat-deduction model is `scoring.model: "weighted"`.
|
|
657
|
+
|
|
658
|
+
Every report carries its own arithmetic: per-rule points, grade, and the **payoff** — how far the
|
|
659
|
+
score would move if that rule's findings went away. Gate with `--fail-on <severity>`,
|
|
660
|
+
`--fail-on-score <n>`, `--fail-on-grade <g>` and their `--fail-on-perf-*` counterparts.
|
|
424
661
|
|
|
425
|
-
|
|
662
|
+
## Commands
|
|
426
663
|
|
|
427
664
|
```bash
|
|
428
|
-
safegres
|
|
429
|
-
safegres
|
|
665
|
+
safegres audit # audit the connected database (default command)
|
|
666
|
+
safegres lint # alias for audit, for a package.json script
|
|
667
|
+
safegres perf # audit + the performance dimension (= audit --perf)
|
|
668
|
+
safegres doctor # diagnose config, parser, connection, catalog visibility, exposure
|
|
669
|
+
safegres eval # grade the auditor against a corpus with known answers
|
|
670
|
+
safegres print-config # the resolved effective config (--explain for per-key provenance)
|
|
430
671
|
```
|
|
431
672
|
|
|
673
|
+
| Group | Flags |
|
|
674
|
+
| --- | --- |
|
|
675
|
+
| Connection | `--connection <url>`, `--host`, `--port`, `--user`, `--password`, `--database`, `--pgpm [dir]` |
|
|
676
|
+
| Config | `--config <path>`, `--preset <name>`, `--rule CODE=sev` |
|
|
677
|
+
| Exposure | `--exposure-schemas <csv>`, `--exposed-only` |
|
|
678
|
+
| Scope | `--schemas`, `--exclude-schemas`, `--roles`, `--exclude-roles`, `--ignore-extensions`, `--audit-extension-owned` |
|
|
679
|
+
| Performance | `--perf`, `--stats`, `--explain`, `--perf-baseline <f>`, `--write-perf-baseline <f>`, `--fail-on-new-perf` |
|
|
680
|
+
| Reporting | `--format pretty\|json\|json-pretty\|markdown\|sarif`, `--out <dir>`, `--sarif-sources <dir>`, `--summary`/`-q`, `--verbose`, `--compare <f>`, `--compare-ref <label>`, `--write-snapshot <f>` |
|
|
681
|
+
| Call graph | `--call-graph`, `--baseline <f>`, `--write-baseline <f>`, `--fail-on-new-boundaries` |
|
|
682
|
+
| Gating | `--fail-on <severity>`, `--fail-on-score <n>`, `--fail-on-grade <g>`, `--fail-on-perf-score`, `--fail-on-perf-grade`, `--report-only` |
|
|
683
|
+
| Misc | `--skip-ast`, `--no-color`, `--help`, `--version` |
|
|
684
|
+
|
|
685
|
+
The paths among those — `--pgpm`, the two baselines, and every `--write-*` — have config-file
|
|
686
|
+
equivalents (`source.pgpm`, `perf.baseline`, `callGraph.baseline`, `outputs.dir`/`outputs.*`), so CI can carry
|
|
687
|
+
them in version control rather than in a command line.
|
|
688
|
+
|
|
689
|
+
## Going further
|
|
690
|
+
|
|
691
|
+
- **[docs/rules.md](https://github.com/constructive-io/constructive/blob/main/packages/safegres/docs/rules.md)** — every rule's rationale: the lattice semantics, the access-path
|
|
692
|
+
signals behind X1, the X7/X8/X9 arguments, extension objects.
|
|
693
|
+
- **[docs/reporting.md](https://github.com/constructive-io/constructive/blob/main/packages/safegres/docs/reporting.md)** — SARIF and code scanning, `--compare` deltas and snapshots.
|
|
694
|
+
- **[docs/advanced.md](https://github.com/constructive-io/constructive/blob/main/packages/safegres/docs/advanced.md)** — runtime statistics (`--stats`), planner proof
|
|
695
|
+
(`--explain`), library use, pgpm workspaces.
|
|
696
|
+
- **[docs/call-graph.md](https://github.com/constructive-io/constructive/blob/main/packages/safegres/docs/call-graph.md)** — the trust-boundary checklist (`--call-graph`) and
|
|
697
|
+
its baseline.
|
|
698
|
+
|
|
432
699
|
## Library use
|
|
433
700
|
|
|
434
701
|
```ts
|
|
@@ -439,47 +706,24 @@ import { audit, renderPretty } from 'safegres';
|
|
|
439
706
|
const client = new Client(getPgEnvOptions());
|
|
440
707
|
await client.connect();
|
|
441
708
|
|
|
442
|
-
const report = await audit(client, {
|
|
443
|
-
excludeSchemas: ['my_private_schema']
|
|
444
|
-
});
|
|
445
|
-
|
|
709
|
+
const report = await audit(client, { perf: true });
|
|
446
710
|
console.log(renderPretty(report));
|
|
447
|
-
console.log(
|
|
448
|
-
```
|
|
449
|
-
|
|
450
|
-
##
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
safegres
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
```ts
|
|
464
|
-
import { auditPgpmWorkspace } from 'safegres/pgpm-test';
|
|
465
|
-
|
|
466
|
-
it('passes the security audit', async () => {
|
|
467
|
-
const report = await auditPgpmWorkspace();
|
|
468
|
-
expect(report.score.grade).toBe('A+');
|
|
469
|
-
});
|
|
470
|
-
```
|
|
471
|
-
|
|
472
|
-
Both discover the project's safegres config (`safegres.config.js`,
|
|
473
|
-
`.safegresrc*`, …) by walking up from the workspace directory. pgpm projects
|
|
474
|
-
usually don't have Constructive routing metadata, so declare the exposed
|
|
475
|
-
surface statically:
|
|
476
|
-
|
|
477
|
-
```json
|
|
478
|
-
{
|
|
479
|
-
"extends": "safegres:recommended",
|
|
480
|
-
"exposure": { "schemas": ["app_public"] }
|
|
481
|
-
}
|
|
482
|
-
```
|
|
711
|
+
console.log(report.score.grade, report.perf?.score.grade);
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
## Requirements
|
|
715
|
+
|
|
716
|
+
- **PostgreSQL 14+.** `--explain` needs **16+** (`GENERIC_PLAN`); on older servers it reports
|
|
717
|
+
`perf.explain.unavailable` and leaves findings untouched.
|
|
718
|
+
- **A connection.** No extension is required. `pg_stat_statements` is optional and only powers S4;
|
|
719
|
+
its absence is a note in the report, never an error.
|
|
720
|
+
- **Catalog visibility.** Any role can run the audit, but a role that is neither superuser nor
|
|
721
|
+
`BYPASSRLS` may not see every policy and grant — `safegres doctor` says so explicitly. For a
|
|
722
|
+
score you can gate on, connect as the owner (in CI, that is the default anyway).
|
|
723
|
+
- **`--stats` describes a workload**, so it is meaningless against a freshly provisioned CI
|
|
724
|
+
database. The `X*` and `A*`/`L*`/`R*` rules are deterministic and work fine on an empty one.
|
|
725
|
+
- safegres **reads**. It never creates, alters, or drops anything, and `--explain` plans without
|
|
726
|
+
executing.
|
|
483
727
|
|
|
484
728
|
---
|
|
485
729
|
|