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.
Files changed (224) hide show
  1. package/README.md +546 -302
  2. package/adapters.d.ts +8 -0
  3. package/adapters.js +18 -0
  4. package/callgraph/extract.d.ts +45 -0
  5. package/callgraph/extract.js +92 -3
  6. package/checks/anti-patterns.js +17 -13
  7. package/checks/coverage.d.ts +26 -1
  8. package/checks/coverage.js +6 -4
  9. package/checks/definer-view.d.ts +109 -0
  10. package/checks/definer-view.js +236 -0
  11. package/checks/lattice.d.ts +144 -0
  12. package/checks/lattice.js +399 -0
  13. package/checks/role-reach.d.ts +191 -0
  14. package/checks/role-reach.js +158 -0
  15. package/checks/role-trust.d.ts +14 -0
  16. package/checks/set-role.d.ts +21 -0
  17. package/checks/set-role.js +66 -0
  18. package/checks/view-exposure.d.ts +68 -0
  19. package/checks/view-exposure.js +232 -0
  20. package/checks/view-writes.d.ts +65 -0
  21. package/checks/view-writes.js +294 -0
  22. package/cli/audit.js +202 -38
  23. package/cli/commands.js +6 -1
  24. package/cli/eval.d.ts +3 -0
  25. package/cli/eval.js +113 -0
  26. package/cli/shared.d.ts +22 -0
  27. package/cli/shared.js +70 -1
  28. package/cli.js +18 -2
  29. package/commands/audit.d.ts +10 -0
  30. package/commands/audit.js +273 -15
  31. package/commands/eval.d.ts +67 -0
  32. package/commands/eval.js +93 -0
  33. package/config/fingerprint.d.ts +40 -0
  34. package/config/fingerprint.js +71 -0
  35. package/config/loader.d.ts +6 -0
  36. package/config/loader.js +14 -0
  37. package/config/presets.d.ts +67 -3
  38. package/config/presets.js +213 -10
  39. package/config/types.d.ts +201 -1
  40. package/corpus/README.md +75 -0
  41. package/corpus/bootstrap.sql +14 -0
  42. package/corpus/cases/01-anon-write-grant/case.json +26 -0
  43. package/corpus/cases/01-anon-write-grant/schema.sql +19 -0
  44. package/corpus/cases/02-grant-to-public/case.json +25 -0
  45. package/corpus/cases/02-grant-to-public/schema.sql +18 -0
  46. package/corpus/cases/03-rls-off-reachable-by-anon/case.json +30 -0
  47. package/corpus/cases/03-rls-off-reachable-by-anon/schema.sql +13 -0
  48. package/corpus/cases/04-permissive-write-policy/case.json +30 -0
  49. package/corpus/cases/04-permissive-write-policy/schema.sql +21 -0
  50. package/corpus/cases/05-public-read-policy/case.json +25 -0
  51. package/corpus/cases/05-public-read-policy/schema.sql +16 -0
  52. package/corpus/cases/06-update-without-with-check/case.json +25 -0
  53. package/corpus/cases/06-update-without-with-check/schema.sql +19 -0
  54. package/corpus/cases/07-rls-enabled-no-policies/case.json +29 -0
  55. package/corpus/cases/07-rls-enabled-no-policies/schema.sql +14 -0
  56. package/corpus/cases/08-select-grant-no-policy/case.json +25 -0
  57. package/corpus/cases/08-select-grant-no-policy/schema.sql +20 -0
  58. package/corpus/cases/09-write-grant-no-policy/case.json +24 -0
  59. package/corpus/cases/09-write-grant-no-policy/schema.sql +18 -0
  60. package/corpus/cases/10-rls-not-forced/case.json +24 -0
  61. package/corpus/cases/10-rls-not-forced/schema.sql +18 -0
  62. package/corpus/cases/11-session-user-in-policy/case.json +24 -0
  63. package/corpus/cases/11-session-user-in-policy/schema.sql +18 -0
  64. package/corpus/cases/12-volatile-function-in-policy/case.json +24 -0
  65. package/corpus/cases/12-volatile-function-in-policy/schema.sql +21 -0
  66. package/corpus/cases/13-security-definer-policy-wrapper/case.json +24 -0
  67. package/corpus/cases/13-security-definer-policy-wrapper/schema.sql +21 -0
  68. package/corpus/cases/14-dead-policy/case.json +24 -0
  69. package/corpus/cases/14-dead-policy/schema.sql +18 -0
  70. package/corpus/cases/15-unreachable-grant/case.json +24 -0
  71. package/corpus/cases/15-unreachable-grant/schema.sql +18 -0
  72. package/corpus/cases/16-anon-permissive-write-policy/case.json +34 -0
  73. package/corpus/cases/16-anon-permissive-write-policy/schema.sql +17 -0
  74. package/corpus/cases/17-foreign-key-without-index/case.json +24 -0
  75. package/corpus/cases/17-foreign-key-without-index/schema.sql +18 -0
  76. package/corpus/cases/18-policy-column-unindexed/case.json +24 -0
  77. package/corpus/cases/18-policy-column-unindexed/schema.sql +23 -0
  78. package/corpus/cases/19-policy-column-cast/case.json +24 -0
  79. package/corpus/cases/19-policy-column-cast/schema.sql +19 -0
  80. package/corpus/cases/20-stable-function-per-row/case.json +24 -0
  81. package/corpus/cases/20-stable-function-per-row/schema.sql +25 -0
  82. package/corpus/cases/21-table-without-primary-key/case.json +24 -0
  83. package/corpus/cases/21-table-without-primary-key/schema.sql +14 -0
  84. package/corpus/cases/22-redundant-index/case.json +24 -0
  85. package/corpus/cases/22-redundant-index/schema.sql +16 -0
  86. package/corpus/cases/23-tsvector-without-index/case.json +24 -0
  87. package/corpus/cases/23-tsvector-without-index/schema.sql +13 -0
  88. package/corpus/cases/24-anon-set-role-escalation/case.json +29 -0
  89. package/corpus/cases/24-anon-set-role-escalation/schema.sql +30 -0
  90. package/corpus/cases/25-definer-view-bypass/case.json +31 -0
  91. package/corpus/cases/25-definer-view-bypass/schema.sql +36 -0
  92. package/corpus/cases/26-invoker-view-no-bypass/case.json +31 -0
  93. package/corpus/cases/26-invoker-view-no-bypass/schema.sql +44 -0
  94. package/corpus/cases/27-definer-view-write/case.json +36 -0
  95. package/corpus/cases/27-definer-view-write/schema.sql +40 -0
  96. package/corpus/cases/28-invoker-view-no-write/case.json +31 -0
  97. package/corpus/cases/28-invoker-view-no-write/schema.sql +37 -0
  98. package/corpus/cases/29-rewrite-rule-bypass/case.json +36 -0
  99. package/corpus/cases/29-rewrite-rule-bypass/schema.sql +52 -0
  100. package/corpus/cases/30-instead-nothing-rule/case.json +31 -0
  101. package/corpus/cases/30-instead-nothing-rule/schema.sql +44 -0
  102. package/corpus/cases/31-matview-snapshot/case.json +32 -0
  103. package/corpus/cases/31-matview-snapshot/schema.sql +36 -0
  104. package/corpus/cases/32-matview-no-rls-no-bypass/case.json +30 -0
  105. package/corpus/cases/32-matview-no-rls-no-bypass/schema.sql +18 -0
  106. package/corpus/cases/33-leaky-filter-view/case.json +36 -0
  107. package/corpus/cases/33-leaky-filter-view/schema.sql +29 -0
  108. package/corpus/cases/34-barrier-view-no-leak/case.json +32 -0
  109. package/corpus/cases/34-barrier-view-no-leak/schema.sql +29 -0
  110. package/corpus/index.d.ts +79 -0
  111. package/corpus/index.js +125 -0
  112. package/esm/adapters.d.ts +8 -0
  113. package/esm/adapters.js +6 -0
  114. package/esm/callgraph/extract.d.ts +45 -0
  115. package/esm/callgraph/extract.js +89 -3
  116. package/esm/checks/anti-patterns.js +17 -13
  117. package/esm/checks/coverage.d.ts +26 -1
  118. package/esm/checks/coverage.js +3 -3
  119. package/esm/checks/definer-view.d.ts +109 -0
  120. package/esm/checks/definer-view.js +228 -0
  121. package/esm/checks/lattice.d.ts +144 -0
  122. package/esm/checks/lattice.js +389 -0
  123. package/esm/checks/role-reach.d.ts +191 -0
  124. package/esm/checks/role-reach.js +153 -0
  125. package/esm/checks/role-trust.d.ts +14 -0
  126. package/esm/checks/set-role.d.ts +21 -0
  127. package/esm/checks/set-role.js +63 -0
  128. package/esm/checks/view-exposure.d.ts +68 -0
  129. package/esm/checks/view-exposure.js +227 -0
  130. package/esm/checks/view-writes.d.ts +65 -0
  131. package/esm/checks/view-writes.js +289 -0
  132. package/esm/cli/audit.js +203 -39
  133. package/esm/cli/commands.js +6 -1
  134. package/esm/cli/eval.d.ts +3 -0
  135. package/esm/cli/eval.js +108 -0
  136. package/esm/cli/shared.d.ts +22 -0
  137. package/esm/cli/shared.js +36 -1
  138. package/esm/cli.js +18 -2
  139. package/esm/commands/audit.d.ts +10 -0
  140. package/esm/commands/audit.js +275 -17
  141. package/esm/commands/eval.d.ts +67 -0
  142. package/esm/commands/eval.js +90 -0
  143. package/esm/config/fingerprint.d.ts +40 -0
  144. package/esm/config/fingerprint.js +68 -0
  145. package/esm/config/loader.d.ts +6 -0
  146. package/esm/config/loader.js +14 -0
  147. package/esm/config/presets.d.ts +67 -3
  148. package/esm/config/presets.js +212 -9
  149. package/esm/config/types.d.ts +201 -1
  150. package/esm/corpus/index.d.ts +79 -0
  151. package/esm/corpus/index.js +85 -0
  152. package/esm/exposure/adapters.d.ts +152 -0
  153. package/esm/exposure/adapters.js +463 -0
  154. package/esm/exposure/planes.d.ts +59 -0
  155. package/esm/exposure/planes.js +144 -0
  156. package/esm/exposure/reach.d.ts +78 -0
  157. package/esm/exposure/reach.js +105 -0
  158. package/esm/index.d.ts +33 -6
  159. package/esm/index.js +16 -3
  160. package/esm/lint/index.d.ts +11 -0
  161. package/esm/lint/index.js +10 -0
  162. package/esm/pg/acl.d.ts +63 -0
  163. package/esm/pg/acl.js +164 -0
  164. package/esm/pg/behaviors.d.ts +114 -0
  165. package/esm/pg/behaviors.js +206 -0
  166. package/esm/pg/exposure.d.ts +54 -14
  167. package/esm/pg/exposure.js +162 -56
  168. package/esm/pg/indexes.d.ts +71 -1
  169. package/esm/pg/indexes.js +151 -11
  170. package/esm/pg/introspect.js +4 -1
  171. package/esm/pg/paths.d.ts +16 -7
  172. package/esm/pg/paths.js +17 -5
  173. package/esm/report/compare.d.ts +8 -0
  174. package/esm/report/compare.js +17 -3
  175. package/esm/report/github.d.ts +61 -0
  176. package/esm/report/github.js +240 -0
  177. package/esm/report/markdown.d.ts +5 -0
  178. package/esm/report/markdown.js +114 -14
  179. package/esm/report/pretty.d.ts +5 -0
  180. package/esm/report/pretty.js +95 -21
  181. package/esm/report/view.d.ts +75 -0
  182. package/esm/report/view.js +121 -0
  183. package/esm/rules/registry.d.ts +3 -1
  184. package/esm/rules/registry.js +167 -0
  185. package/esm/types.d.ts +89 -6
  186. package/esm/version.d.ts +1 -1
  187. package/esm/version.js +2 -2
  188. package/exposure/adapters.d.ts +152 -0
  189. package/exposure/adapters.js +468 -0
  190. package/exposure/planes.d.ts +59 -0
  191. package/exposure/planes.js +151 -0
  192. package/exposure/reach.d.ts +78 -0
  193. package/exposure/reach.js +109 -0
  194. package/index.d.ts +33 -6
  195. package/index.js +79 -2
  196. package/lint/index.d.ts +11 -0
  197. package/lint/index.js +19 -0
  198. package/package.json +27 -12
  199. package/pg/acl.d.ts +63 -0
  200. package/pg/acl.js +168 -0
  201. package/pg/behaviors.d.ts +114 -0
  202. package/pg/behaviors.js +217 -0
  203. package/pg/exposure.d.ts +54 -14
  204. package/pg/exposure.js +164 -56
  205. package/pg/indexes.d.ts +71 -1
  206. package/pg/indexes.js +152 -11
  207. package/pg/introspect.js +4 -1
  208. package/pg/paths.d.ts +16 -7
  209. package/pg/paths.js +17 -5
  210. package/report/compare.d.ts +8 -0
  211. package/report/compare.js +17 -3
  212. package/report/github.d.ts +61 -0
  213. package/report/github.js +283 -0
  214. package/report/markdown.d.ts +5 -0
  215. package/report/markdown.js +114 -14
  216. package/report/pretty.d.ts +5 -0
  217. package/report/pretty.js +95 -21
  218. package/report/view.d.ts +75 -0
  219. package/report/view.js +127 -0
  220. package/rules/registry.d.ts +3 -1
  221. package/rules/registry.js +167 -0
  222. package/types.d.ts +89 -6
  223. package/version.d.ts +1 -1
  224. 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
- 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.
15
+ <p align="center"><strong>Safe <em>and</em> fast Postgres. Graded.</strong></p>
16
16
 
17
- 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.
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
- ```bash
20
- npm install -g safegres
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
- # Standard libpq env vars (PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE)
23
- export PGHOST=localhost PGUSER=postgres PGPASSWORD=password PGDATABASE=mydb
24
- safegres audit
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
- Per-field overrides (`--host`, `--port`, `--user`, `--password`, `--database`) and a full `--connection <url>` flag are also supported. See `safegres audit --help`.
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
- ### Output & verbosity
36
+ *No database handy? See [CI](#ci-in-one-job): one service container plus the migration command you
37
+ already have.*
30
38
 
31
- Pretty output prints the exposure line, score, and the exposed findings. Internal (non-exposed) advisories are collapsed to a one-line count by default so a large database's report stays readable.
39
+ ## Features
32
40
 
33
- - `--summary`, `-q` — print only the exposure line, score/grade, and severity counts (no per-finding lines). Ideal for CI job summaries.
34
- - `--verbose` — expand the internal advisories instead of collapsing them to a count.
35
- - `--exposed-only` — drop internal findings entirely.
36
- - `--format json` / `--format json-pretty` — machine-readable output (always carries every finding).
37
- - `--format markdown` — the same report as GitHub-flavoured markdown, for a job summary or a PR comment (see [CI](#ci)).
38
- - `--format sarif` — SARIF 2.1.0 for GitHub code scanning (see [CI](#ci)).
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
- ### CI
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
- Scores lead, then the severity counts, then a table per dimension; internal (non-exposed) advisories and accepted baseline debt fold into `<details>` so the summary stays skimmable. To post it as a PR comment instead, pipe it to `gh pr comment --body-file -`. The same renderer is available to library callers as `renderMarkdown(report)`.
58
+ exposure: 2 schema(s) via config — 41/318 tables exposed
56
59
 
57
- #### What changed (`--compare`)
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
- A report says what the database *is*; on a pull request the only question is what the branch *did* to it. `--compare` diffs this run against a previous one and renders the movement — a Δ column in the score table, the severity counts that moved, and every rule whose finding count changed:
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
- ```bash
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
- | Dimension | Score | Grade | Δ vs main | Top deductions |
67
- | Security | **99.3** | **A+**| 🟢 ▲ +1.2 (from 98.1) | `A3` −4 (×2) |
68
- | Performance | **72.4** | **C** | 🔴 ▼ −2.6 (from 75.0) · B → C · 40 → 46 findings | `X1` −18 (×46) |
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
- The previous run is a file, not something safegres remembers: a scanner has no memory and shouldn't acquire one, so CI decides what "previous" means (the report artifact from the base branch, a committed scoreboard, last night's nightly) and hands it over. Any earlier `--format json` output works as input. When keeping whole reports is too much, `--write-snapshot <file>` writes just the aggregates the comparison reads — scores, grades, severity counts, per-rule counts — and `--compare` accepts either. `--compare-ref` labels the previous run in the output.
78
+ Also available as `--format json`, `json-pretty`, `markdown` (job summaries and PR comments) and
79
+ `sarif` (GitHub code scanning).
72
80
 
73
- Library callers get the same thing as `compareReports(previous, report)`, with `toSnapshot` / `parseSnapshot` / `serializeSnapshot` for the file side; the result is carried in JSON output as `comparison`.
81
+ ## How it works
74
82
 
75
- #### Code scanning (SARIF)
83
+ Three analyses over three different representations. None of them run your queries.
76
84
 
77
- `--format sarif` emits SARIF 2.1.0, so findings become GitHub code-scanning alerts — Security tab, inline PR annotations, dismissals that stick:
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
- ```yaml
80
- - run: npx safegres audit --perf --format sarif --sarif-sources ./deploy > safegres.sarif
81
- - uses: github/codeql-action/upload-sarif@v3
82
- with: { sarif_file: safegres.sarif }
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
- An alert needs a file and a line, but safegres reads the catalog — a live database has no source location. `--sarif-sources <dir>` scans that directory's `.sql` for the `CREATE TABLE` / `CREATE POLICY` that defines each object, so a finding on `app_public.widgets` points at the migration that created it (policy findings resolve to the `CREATE POLICY` line). Findings that don't resolve are still emitted, without a location — GitHub drops those, other SARIF consumers keep them.
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
- Results are fingerprinted by finding *identity* (code + relation + policy + subject, the same key the [perf baseline](#perf-baseline-the-ratchet) uses), never by message text, so rewording a rule in a later release doesn't close and reopen every alert. Perf rules are tagged `performance`, security rules `security`.
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
- | Code | Severity | Direction | Category | Check |
92
- | --- | --- | --- | --- | --- |
93
- | A1 | low | fail-closed | flags | RLS enabled but **0 policies** (deny-all — confirm the lock is intended) |
94
- | A2 | high | fail-open | flags | Grants exist on a table with **RLS disabled** |
95
- | A3 | low | fail-open | flags | RLS enabled but **`FORCE ROW LEVEL SECURITY` not set** (table owner bypass) |
96
- | A4 | low | fail-closed | coverage | INSERT / UPDATE / DELETE grant with **no covering policy** — writes are denied at runtime |
97
- | A5 | low | fail-closed | coverage | SELECT grant with **no policy** — queries silently return 0 rows |
98
- | A6 | info | fail-closed | coverage | UPDATE has `USING` but **no `WITH CHECK`** (row-smuggling surface) |
99
- | A7 | critical | fail-open | anti-pattern | Trivially-permissive **WRITE** policy (INSERT/UPDATE/DELETE/ALL with literal `true`) |
100
- | A8 | low | fail-open | anti-pattern | Trivially-permissive **SELECT** policy (`USING (true)` — confirm public-read is intended) |
101
- | P1 | high | neutral | anti-pattern | Policy body calls a **VOLATILE function** (per-row evaluation) — *scored on the [perf axis](#performance-dimension---perf)* |
102
- | P5 | high | fail-open | anti-pattern | Policy body references **`session_user`** / `current_user` / `pg_has_role(...)` |
103
- | R1 | critical | fail-open | anti-pattern | An **untrusted role** (options: `{ roles: [...] }`) holds a write privilege |
104
- | R2 | high | fail-open | anti-pattern | A permissive write policy applies to an untrusted role or PUBLIC |
105
- | R3 | medium | fail-open | anti-pattern | An RLS table has grants **TO PUBLIC** (includes all current/future roles) |
106
- | W1 | medium | — | meta | No exposure surface configured — whole database assumed reachable, score capped |
107
-
108
- **Direction matters**: `fail-open` findings are actual exposure (the untrusted side can reach more than intended). `fail-closed` findings are denied at runtime — an availability/hygiene concern, not a leak — and contribute **nothing to the score** by default (tune with `scoring.failClosedWeight`).
109
-
110
- Coverage is aggregated `(table, role) → { hasUsing, hasWithCheck }` across every applicable permissive policy (FOR ALL + PUBLIC-role policies considered). Roles with `BYPASSRLS` are suppressed.
111
-
112
- R1/R2 are no-ops until a role list is configured — e.g. `"R1": ["critical", { "roles": ["anonymous"] }]` — so they cost nothing on databases without an untrusted-role model. The `safegres:constructive` preset configures them for `anonymous`.
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 through the app's APIs. Declare (or auto-resolve) the **exposure surface** and safegres partitions findings:
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 *internal advisories* (hide entirely with `--exposed-only`).
120
- - **No exposure configured** → a `W1` warning is emitted and the score is capped at 80/B (`scoring.unknownExposureCap`).
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"] // static surface
246
+ "schemas": ["app_public", "app_hidden"] // static surface
126
247
  // or, on a Constructive database:
127
- // "resolver": "constructive" // introspects routing_public.apis → api_schemas
248
+ // "resolver": "constructive" // introspects routing_public.apis → api_schemas
128
249
  }
129
250
  }
130
251
  ```
131
252
 
132
- CLI: `--exposure-schemas <csv>`, `--exposed-only`. The `safegres:constructive` preset sets `exposure.resolver: "constructive"` so the surface is discovered automatically from the routing plane (including API roles from `role_name`/`anon_role`).
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
- ## Extension objects
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
- An extension's tables are the `node_modules` of a database: they live in the same catalog, scan like anything else, and are not yours to alter — `ALTER TABLE` on one breaks `pg_dump` and upgrades. safegres skips relations an extension owns (`pg_depend.deptype = 'e'`), and their partitions, by default.
263
+ ### Planes: the other ways in
137
264
 
138
- Ownership alone is not enough. An extension that creates objects *at runtime* never registers them as dependencies: on one Constructive database only 2 of `pg_partman`'s 32 relations were owned, leaving 30 template tables looking like unsecured application tables (30 of the report's 39 criticals). Naming the extension skips its schema wholesale:
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
- "extensions": {
143
- "ignore": ["pg_partman"], // skip everything in the extension's schema
144
- "skipOwned": true // default; false audits extension-owned relations too
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
- CLI: `--ignore-extensions <csv>`, `--audit-extension-owned`. Unknown or uninstalled names are ignored, so one config works across environments. The `safegres:constructive` preset ships `ignore: ["pg_partman"]`.
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
- ## Declared public surface
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
- Some open reads are deliberate — pricing tables, reference data, a public user directory. Declare them and safegres treats them as intent instead of findings:
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
- - An open SELECT policy (`USING (true)` — rule A8) on a declared table is **acknowledged**: reported as info, excluded from the score.
168
- - An open read on any *undeclared* table stays a scored finding — even in a `*_public`-named schema. Naming is never treated as intent; the config declaration is.
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
- Every check is pure catalog + AST analysis: deterministic, workload-free, and safe to run against an empty CI database. An index covers a foreign key only when its *leading* columns are the FK's columns and it covers every row — partial and expression indexes don't count, because the planner can't use them for the referential-integrity lookup. Constraint-backed, unique, partial, and expression indexes are never reported as redundant.
302
+ ### Adapters
190
303
 
191
- X7 exists because the column type *is* the API declaration: `graphile-search` exposes a full-text filter for every `tsvector` column and a similarity search for every `vector` column, purely from the codec — so an unindexed one is a first-class API field backed by a sequential scan plus a per-row match or distance computation. BM25 and pg_trgm are deliberately not checked: those adapters are discovered *from* their indexes, so a missing index means the feature was never exposed. X8 is the one heuristic in the set — any column is orderable over a connection, but timestamps are what feeds are actually sorted and keyset-paginated by — so it defaults to `info`, contributes 0 to the score, and is meant to be read, not gated on (`perf.rules: { "X8": "off" }` to silence it). Trailing-position and partial indexes don't count for either rule: neither can serve the sort or the search on its own.
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
- ### Access paths: the evidence behind X1
307
+ ```ts
308
+ // safegres.config.ts
309
+ import type { SafegresConfig } from 'safegres';
310
+ import { constructiveAdapter } from 'safegres/adapters';
194
311
 
195
- X1 is right about the mechanics — a `DELETE` on the parent really does scan the child — but it assumes the child is a relation somebody traverses. On a provisioning-config table, one whose keys are written once at setup and never looked rows up by, the index it asks for is a write on every insert in exchange for speeding up a scan of one row. Acting on X1 across such a schema makes the database measurably worse while the grade goes up.
312
+ import { myGatewayAdapter } from './my-gateway-adapter';
196
313
 
197
- The tempting gate, `pg_class.reltuples`, doesn't work here: safegres grades an ephemeral CI database that has never held data, so every row estimate is 0 at exactly the moment it grades. And row count is the wrong question anyway — a huge append-only log nobody joins on wants no FK index, while a tiny lookup table every request hits does. The property that matters is whether anything reads the key, which is structural, so it survives an empty database.
314
+ const config: SafegresConfig = {
315
+ exposure: { adapters: [constructiveAdapter, myGatewayAdapter] }
316
+ };
317
+ export default config;
318
+ ```
198
319
 
199
- So safegres collects **signals** about every foreign key, each pointing one way and saying why, and reports them on the finding (`context.pathSignals`) and in aggregate (`report.perf.paths`):
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
- | Signal | Direction | Fires when |
202
- | --- | --- | --- |
203
- | `policy-read` | read | an RLS policy predicate names one of the key's columns |
204
- | `view-read` | read | a view or materialized view names one of them |
205
- | `write-once-pointer` | shape | every column of the key has a constant default (`uuid_nil()`, a literal) |
206
- | `config-record` | shape | the table carries two or more write-once pointers (`perf.paths.minPointers`) |
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
- A **read** signal is decisive — the database itself traverses the column, so the key is a query path and X1 applies as written. That is what keeps the tenant key out of trouble with no special case: `database_id` appears in essentially every policy. A **shape** signal is not: a `NOT NULL` key defaulting to the nil UUID looks like a slot a provisioner fills in, but a generated API can expose a reverse relation over any foreign key regardless of how its default is written, and if it does, the index is wanted after all.
339
+ ### API reach — the relations the API can actually name
209
340
 
210
- So by default the shape **changes nothing** — no finding is removed, no severity moves, no score shifts. What it does is tell you where to look, and `perf.paths.onWriteOncePointer` decides what X1 does about it:
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
- - `report` (default) — the finding stands, with the signals attached to it;
213
- - `demote` — write-once-shaped keys drop to `info`, so they are read rather than gated on and contribute nothing to the score;
214
- - `suppress` — no finding. Only defensible once you know the generated API does not expose these relations; a shape is not a proof.
349
+ ```jsonc
350
+ { "exposure": { "schemas": ["app_public"], "adapters": ["postgraphile"] } }
351
+ ```
215
352
 
216
- The signal that *would* settle it is the one that isn't here yet: whether the generated GraphQL surface still contains the field. It slots in as another signal, and only then does "nothing can reach this key" become a conclusion anything should act on. `perf.paths.infer: false` skips the collection entirely.
353
+ Three properties keep it from quietly deleting findings:
217
354
 
218
- X2–X4 and X9 are the checks a generic index linter can't make, because they read the policy predicate. RLS quals are evaluated *before* user quals, on every candidate row, for every caller — so an unindexed or cast-wrapped policy column is a whole-table tax rather than a slow query. X2 requires the policy column to be the *leading* column of some index (a trailing position can't serve the qual alone); X3 looks for an expression index matching the exact wrapped shape; X4 skips built-ins, whose leakproofness is a property of the server rather than a schema choice.
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
- X9 is the one that costs the most and looks the most innocent. `STABLE` promises a function's result won't change within the statement; it does **not** make the planner evaluate it once. Measured on 200k rows with a policy function that counts its own invocations:
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
- | Policy qual | Calls | Time |
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
- The honest caveat: the penalty is **plan-dependent**. When the planner can turn the qual into an index condition it evaluates the function once per scan even unwrapped — so the same policy costs one call on an indexed column and 200,000 on a Filter (unindexed column, a join, an OR branch, a plan change after `ANALYZE`). Wrapping removes the dependence: `(SELECT f())` references no column, so it is hoisted into an **InitPlan** and evaluated once per query whatever plan is chosen, and the result is a constant the index can be probed with. X9 is structural, not a name list — it fires on any non-IMMUTABLE call whose arguments reference no column of the row and that isn't already inside an uncorrelated scalar sub-select, so a GUC-reading helper added next year is caught without configuration. `current_setting()` itself is STABLE and is flagged too: removing the wrapper function doesn't avoid the per-row call. VOLATILE calls are deliberately excluded — per-row evaluation is their defined behaviour, and hoisting one would change semantics (that's P1's job). Being inside an `EXISTS` sub-select is not a defence: that subquery is correlated with the outer row, so it runs per row and takes the call with it.
371
+ One service container, your existing migration command, one audit:
228
372
 
229
- ```bash
230
- safegres perf --database mydb
231
- safegres audit --database mydb --perf --fail-on-perf-grade B
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
- "perf": {
237
- "enabled": true,
238
- "rules": { "X6": "off" }, // perf-dimension codes only
239
- "ignore": ["app_public.audit_*"], // declared-intentional perf debt
240
- "paths": { "onWriteOncePointer": "report" }, // signals are reported, not acted on (default)
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
- Tables matched by `perf.ignore` are acknowledged — reported as info, excluded from the perf score — the same way `public.read` works for open reads. Perf findings live in `report.findings` alongside the security ones (so `--fail-on <severity>` still sees them), but only they feed `report.perf.score`, and only security findings feed `report.score`.
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
- ### Perf baseline (the ratchet)
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
- An established schema will not reach a clean perf report in one pass — but it can refuse to get worse. Commit today's findings as accepted debt, then gate CI on findings that are *not* in the baseline:
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 .safegres-perf.json # snapshot (implies --perf)
255
- safegres audit --perf-baseline .safegres-perf.json # diff: new vs accepted vs fixed
256
- safegres audit --perf-baseline .safegres-perf.json --fail-on-new-perf # gate: exit 1 on new debt
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 identified by `code` + relation + policy + subject (the constraint, index, expression, column, or function the finding is about), so rewording a message or retuning a severity between safegres versions never invalidates a committed baseline, and two findings of the same code on the same table stay distinct. Findings that disappear are reported as fixed — re-run `--write-perf-baseline` to lock the win in and stop them regressing silently. The diff is also carried in JSON output as `perf.diff`, and available to library callers as `diffPerf(findings, baseline)`.
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
- ### Runtime statistics (`--stats`)
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 `X*` rules read the schema; the `S*` rules read what the workload actually did to it. They come from `pg_stat_user_tables`, `pg_stat_user_indexes` and — when the extension is installed — `pg_stat_statements`, so they only mean something against a database that has served representative traffic. Opt in with `--stats` (which implies `--perf`):
460
+ The same mechanism exists for call-graph trust boundaries (`--write-baseline`, `--baseline`,
461
+ `--fail-on-new-boundaries`).
272
462
 
273
- | Code | Severity | Check |
274
- | --- | --- | --- |
275
- | S1 | medium | **Sequential-scan-dominant table** — seq scans outnumber index scans 10:1 on a table with indexes and ≥ 1000 live rows |
276
- | S2 | low | **Index the planner has never chosen** (`idx_scan = 0`) and larger than 1 MiB — pure write cost, unless it's there for a rare report |
277
- | S3 | low | **Dead-tuple bloat** — dead tuples ≥ 20% of live rows; autovacuum is not keeping up |
278
- | S4 | info | **Statement hotspot** — a statement taking ≥ 5% of sampled execution time whose relations are in scope |
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
- Every threshold is a floor, and every floor is configurable, because a counter is only evidence if there is enough of it:
470
+ So every report carries the ruler it was measured with:
281
471
 
282
472
  ```jsonc
283
- {
284
- "perf": {
285
- "stats": {
286
- "minRows": 1000, // S1/S3: below this a scan is the right plan
287
- "seqScanRatio": 10, // S1: seq:idx scan ratio that counts as dominant
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
- `S*` findings are **scored** on the perf axis — asking for `--stats` is the opt-in — but `perf.scoring.includeStats: false` demotes them to advisories if you want the grade to stay purely deterministic. The report carries its own provenance in `perf.stats`: how many tables were read, when the counters were last reset (the window the numbers describe), whether they were scored, and a note when `pg_stat_statements` isn't installed. Absent statistics are never an error; the audit just says so.
299
-
300
- ### Planner proof (`--explain`)
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 --perf --explain --database mydb
487
+ safegres audit --sealed --preset strict --verify-fingerprint sha256:8f14e45fceea167a… --format json
308
488
  ```
309
489
 
310
- ```
311
- [medium] X1 app_public.posts
312
- foreign key posts_author_id_fkey has no covering index
313
- plan (confirmed): Seq Scan
314
- [info] X1 app_public.notes
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
- ## Call graph (`--call-graph`)
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
- RLS findings tell you what the *tables* allow. The call graph tells you what the *functions* reach: starting from the exposed entry points (functions the API roles can `EXECUTE`), safegres statically walks each body and lists every **trust boundary** on the way — unscored, because a public `SECURITY DEFINER` calling private functions is the intended pattern (that's how `sign_in` works). The output is a deterministic checklist for human review:
499
+ ## The evaluation corpus
325
500
 
326
- | Code | Boundary |
327
- |------|----------|
328
- | CF1 | `SECURITY DEFINER` without a pinned `search_path` (CWE-426) — provable misconfiguration, fix these |
329
- | CF2 | `SECURITY DEFINER` executable by `anonymous`/PUBLIC — widest blast radius, confirm intent |
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
- ```bash
337
- safegres audit --database mydb --call-graph
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
- call graph — trust boundaries reachable from the exposed surface (unscored; human review)
342
- 2 entry point(s) → 4 reachable function(s) | 3 trust hop(s) 1 RLS-bypass 1 auth-context 1 internal-reach 1 opaque
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
- CG2 — RLS-bypass paths (RLS does not protect the table on this path) (1)
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
- Bodies are analyzed for `sql` and `plpgsql` functions (via the PL/pgSQL parser); overloads collapse into one node per `schema.name`; unqualified calls resolve to every user function with that name (a conservative over-approximation). JSON output (`--format json`) carries the full graph — nodes, edges, and checklist — sorted stably so it can be snapshotted and diffed in CI.
351
-
352
- ### Baseline diffing (CI gate for new trust boundaries)
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
- Snapshot the checklist once, commit it, and let CI report anything **new**:
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
- ```bash
357
- safegres audit --write-baseline .safegres-callgraph.json # snapshot (implies --call-graph)
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
- baseline: 1 NEW trust boundary — review and re-baseline to accept:
364
- + [CF2] app_public.new_fn
365
- SECURITY DEFINER executable by PUBLIC, anonymous — widest blast radius; confirm this is intended
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
- The baseline stores only boundary *identity* (`code` + entry + function + table), so message rewording and path changes between safegres versions never invalidate it. A boundary that disappears is reported as resolved; re-run `--write-baseline` to accept either direction. The diff is also carried in JSON output (`callGraphDiff`).
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
- safegres is configurable like a linter. Config is discovered by walking up from the current directory: `safegres.config.{ts,js,mjs,cjs}`, `.safegresrc{,.json,.yaml,.yml,.js}`, `safegres.json`, or a `"safegres"` key in package.json (via [confstash](https://github.com/constructive-io/dev-utils/tree/main/packages/confstash)).
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
- "excludeSchemas": ["archive"],
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": "off", // disable a rule
381
- "A5": "high", // retune a severity
382
- "P*": "medium" // prefix wildcards
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": ["public.audit_*"], "rules": { "A2": "off" } }
569
+ { "tables": ["app_public.audit_*"], "rules": { "A2": "off" } }
386
570
  ],
387
- "scoring": { "weights": { "medium": 2 } },
388
- "failOn": { "severity": "high", "grade": "B" }
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
- Or typed:
577
+ A typed `safegres.config.ts` with `defineConfig` from `confstash` works identically.
393
578
 
394
- ```ts
395
- // safegres.config.ts
396
- import { defineConfig } from 'confstash';
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
- export default defineConfig({
399
- extends: 'safegres:constructive',
400
- rules: { A6: 'low' }
401
- });
583
+ ```jsonc
584
+ { "extends": ["safegres:supabase", "safegres:multi-tenant"] }
402
585
  ```
403
586
 
404
- ### Presets
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
- | Preset | Behavior |
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 findings count 25% toward the score, `failOn: high` |
410
- | `safegres:constructive` | Auto-resolves exposure from the routing plane; R1/R2 watch `anonymous`; leak surfaces (A2, P5) critical; A3 off (API roles never own tables); `pg_partman`'s schema ignored |
411
- | `safegres:minimal` | Structural flags only (A1–A3) — fast CI smoke check |
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
- ### Scoring
641
+ ## Scoring
416
642
 
417
- Every report includes a config-driven score (0–100 + grade). The default **density** model normalizes by the exposed surface so large schemas don't saturate to 0/F:
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
- where `riskPoints` is the severity-weighted sum (critical 25, high 10, medium 4, low 1, info 0) of *exposed, fail-open* findings, and `k` defaults to 0.17 (≈ one critical per 10 exposed tables lands at a C). Non-exposed findings score 0; fail-closed findings score 0 unless `scoring.failClosedWeight` is raised; unknown exposure caps the score (`scoring.unknownExposureCap`, default 80). Any exposed critical floors the grade at C (`scoring.floorOnCritical`). The legacy flat-deduction model is available via `scoring.model: "weighted"`. Tune via `scoring.weights`, `scoring.perRuleWeights`, `scoring.densityK`, `scoring.gradeBands`. Gate CI with `--fail-on-score <n>` / `--fail-on-grade <g>` or `failOn` in config.
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
- ### Other commands
662
+ ## Commands
426
663
 
427
664
  ```bash
428
- safegres doctor # diagnose config, parser, connection, catalog access, blind spots
429
- safegres print-config # show the resolved effective config (--explain for per-key provenance)
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(`${report.findings.length} findings`);
448
- ```
449
-
450
- ## pgpm projects
451
-
452
- For pgpm workspaces, safegres can deploy the workspace into an ephemeral test
453
- database and audit it — no running database or connection flags required
454
- (needs the optional peer dependency `pgsql-test`):
455
-
456
- ```bash
457
- safegres audit --pgpm # nearest pgpm module/workspace from cwd
458
- safegres audit --pgpm ./packages/my-db
459
- ```
460
-
461
- Or as a jest test via the `safegres/pgpm-test` entrypoint:
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