@safegres/test 0.0.1 → 1.30.7
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 +825 -412
- package/adapters.d.ts +8 -0
- package/adapters.js +18 -0
- package/ast/helpers.d.ts +16 -0
- package/ast/helpers.js +40 -0
- package/ast/parse.d.ts +31 -0
- package/ast/parse.js +87 -0
- package/ast/walk.d.ts +27 -0
- package/ast/walk.js +79 -0
- package/callgraph/baseline.d.ts +28 -0
- package/callgraph/baseline.js +55 -0
- package/callgraph/extract.d.ts +93 -0
- package/callgraph/extract.js +340 -0
- package/callgraph/graph.d.ts +82 -0
- package/callgraph/graph.js +276 -0
- package/checks/anti-patterns.d.ts +53 -0
- package/checks/anti-patterns.js +247 -0
- package/checks/column-grants.d.ts +20 -0
- package/checks/column-grants.js +74 -0
- package/checks/coverage.d.ts +43 -0
- package/checks/coverage.js +110 -0
- package/checks/definer-function.d.ts +91 -0
- package/checks/definer-function.js +458 -0
- package/checks/definer-view.d.ts +167 -0
- package/checks/definer-view.js +392 -0
- package/checks/indexes.d.ts +82 -0
- package/checks/indexes.js +263 -0
- package/checks/lattice.d.ts +179 -0
- package/checks/lattice.js +487 -0
- package/checks/object-acls.d.ts +32 -0
- package/checks/object-acls.js +129 -0
- package/checks/policy-index.d.ts +78 -0
- package/checks/policy-index.js +349 -0
- package/checks/revocable-grants.d.ts +140 -0
- package/checks/revocable-grants.js +377 -0
- package/checks/rls-flags.d.ts +25 -0
- package/checks/rls-flags.js +82 -0
- package/checks/role-reach.d.ts +329 -0
- package/checks/role-reach.js +277 -0
- package/checks/role-trust.d.ts +45 -0
- package/checks/role-trust.js +91 -0
- package/checks/set-role.d.ts +21 -0
- package/checks/set-role.js +66 -0
- package/checks/stats.d.ts +52 -0
- package/checks/stats.js +192 -0
- package/checks/view-exposure.d.ts +68 -0
- package/checks/view-exposure.js +236 -0
- package/checks/view-writes.d.ts +88 -0
- package/checks/view-writes.js +350 -0
- package/ci/baseline.d.ts +104 -0
- package/ci/baseline.js +111 -0
- package/ci/github.d.ts +47 -0
- package/ci/github.js +197 -0
- package/cli/audit.d.ts +3 -0
- package/cli/audit.js +560 -0
- package/cli/baseline.d.ts +3 -0
- package/cli/baseline.js +104 -0
- package/cli/commands.d.ts +2 -0
- package/cli/commands.js +60 -0
- package/cli/doctor.d.ts +3 -0
- package/cli/doctor.js +72 -0
- package/cli/eval.d.ts +3 -0
- package/cli/eval.js +153 -0
- package/cli/print-config.d.ts +3 -0
- package/cli/print-config.js +54 -0
- package/cli/shared.d.ts +43 -0
- package/cli/shared.js +153 -0
- package/cli.d.ts +2 -0
- package/cli.js +69 -0
- package/commands/audit.d.ts +65 -0
- package/commands/audit.js +779 -0
- package/commands/doctor.d.ts +19 -0
- package/commands/doctor.js +181 -0
- 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 +44 -0
- package/config/loader.js +210 -0
- package/config/presets.d.ts +95 -0
- package/config/presets.js +327 -0
- package/config/resolve.d.ts +47 -0
- package/config/resolve.js +192 -0
- package/config/schema.d.ts +54 -0
- package/config/schema.js +269 -0
- package/config/types.d.ts +562 -0
- package/config/validate.d.ts +14 -0
- package/config/validate.js +125 -0
- package/corpus/README.md +83 -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/cases/35-column-grant-projection/case.json +27 -0
- package/corpus/cases/35-column-grant-projection/schema.sql +21 -0
- package/corpus/cases/35-policy-function-not-leakproof/case.json +24 -0
- package/corpus/cases/35-policy-function-not-leakproof/schema.sql +27 -0
- package/corpus/cases/36-column-grant-rls-mediated/case.json +27 -0
- package/corpus/cases/36-column-grant-rls-mediated/schema.sql +32 -0
- package/corpus/cases/36-policy-function-leakproof/case.json +24 -0
- package/corpus/cases/36-policy-function-leakproof/schema.sql +22 -0
- package/corpus/cases/37-sort-column-unindexed/case.json +24 -0
- package/corpus/cases/37-sort-column-unindexed/schema.sql +14 -0
- package/corpus/cases/37-unaudited-base-relation/case.json +30 -0
- package/corpus/cases/37-unaudited-base-relation/schema.sql +45 -0
- package/corpus/cases/38-foreign-key-with-index/case.json +22 -0
- package/corpus/cases/38-foreign-key-with-index/schema.sql +17 -0
- package/corpus/cases/38-invoker-view-unaudited-base/case.json +30 -0
- package/corpus/cases/38-invoker-view-unaudited-base/schema.sql +39 -0
- package/corpus/cases/39-definer-view-column-subset/case.json +30 -0
- package/corpus/cases/39-definer-view-column-subset/schema.sql +29 -0
- package/corpus/cases/39-policy-cast-with-expression-index/case.json +23 -0
- package/corpus/cases/39-policy-cast-with-expression-index/schema.sql +19 -0
- package/corpus/cases/40-definer-view-column-overreach/case.json +27 -0
- package/corpus/cases/40-definer-view-column-overreach/schema.sql +29 -0
- package/corpus/cases/40-stable-function-hoisted/case.json +23 -0
- package/corpus/cases/40-stable-function-hoisted/schema.sql +25 -0
- package/corpus/cases/41-index-prefix-not-redundant/case.json +21 -0
- package/corpus/cases/41-index-prefix-not-redundant/schema.sql +15 -0
- package/corpus/cases/41-unreadable-view-body/case.json +33 -0
- package/corpus/cases/41-unreadable-view-body/schema.sql +28 -0
- package/corpus/cases/42-invoker-view-unreadable-body/case.json +30 -0
- package/corpus/cases/42-invoker-view-unreadable-body/schema.sql +27 -0
- package/corpus/cases/42-search-column-indexed/case.json +21 -0
- package/corpus/cases/42-search-column-indexed/schema.sql +12 -0
- package/corpus/cases/43-anon-sequence-grant/case.json +27 -0
- package/corpus/cases/43-anon-sequence-grant/schema.sql +23 -0
- package/corpus/cases/44-serial-sequence-usage/case.json +27 -0
- package/corpus/cases/44-serial-sequence-usage/schema.sql +25 -0
- package/corpus/cases/45-anon-foreign-table/case.json +27 -0
- package/corpus/cases/45-anon-foreign-table/schema.sql +23 -0
- package/corpus/cases/46-unchecked-writable-view/case.json +41 -0
- package/corpus/cases/46-unchecked-writable-view/schema.sql +37 -0
- package/corpus/cases/47-checked-writable-view/case.json +37 -0
- package/corpus/cases/47-checked-writable-view/schema.sql +38 -0
- package/corpus/cases/48-definer-function-reach/case.json +38 -0
- package/corpus/cases/48-definer-function-reach/schema.sql +41 -0
- package/corpus/cases/49-invoker-function-no-reach/case.json +32 -0
- package/corpus/cases/49-invoker-function-no-reach/schema.sql +38 -0
- package/corpus/cases/50-instead-of-trigger-write/case.json +38 -0
- package/corpus/cases/50-instead-of-trigger-write/schema.sql +58 -0
- package/corpus/cases/51-invoker-trigger-no-write/case.json +33 -0
- package/corpus/cases/51-invoker-trigger-no-write/schema.sql +54 -0
- package/corpus/cases/52-revocable-execute-grant/case.json +30 -0
- package/corpus/cases/52-revocable-execute-grant/schema.sql +45 -0
- package/corpus/cases/53-policy-predicate-retains-execute/case.json +23 -0
- package/corpus/cases/53-policy-predicate-retains-execute/schema.sql +49 -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/ast/helpers.d.ts +16 -0
- package/esm/ast/helpers.js +36 -0
- package/esm/ast/parse.d.ts +31 -0
- package/esm/ast/parse.js +82 -0
- package/esm/ast/walk.d.ts +27 -0
- package/esm/ast/walk.js +72 -0
- package/esm/callgraph/baseline.d.ts +28 -0
- package/esm/callgraph/baseline.js +48 -0
- package/esm/callgraph/extract.d.ts +93 -0
- package/esm/callgraph/extract.js +333 -0
- package/esm/callgraph/graph.d.ts +82 -0
- package/esm/callgraph/graph.js +273 -0
- package/esm/checks/anti-patterns.d.ts +53 -0
- package/esm/checks/anti-patterns.js +240 -0
- package/esm/checks/column-grants.d.ts +20 -0
- package/esm/checks/column-grants.js +71 -0
- package/esm/checks/coverage.d.ts +43 -0
- package/esm/checks/coverage.js +104 -0
- package/esm/checks/definer-function.d.ts +91 -0
- package/esm/checks/definer-function.js +452 -0
- package/esm/checks/definer-view.d.ts +167 -0
- package/esm/checks/definer-view.js +382 -0
- package/esm/checks/indexes.d.ts +82 -0
- package/esm/checks/indexes.js +256 -0
- package/esm/checks/lattice.d.ts +179 -0
- package/esm/checks/lattice.js +475 -0
- package/esm/checks/object-acls.d.ts +32 -0
- package/esm/checks/object-acls.js +125 -0
- package/esm/checks/policy-index.d.ts +78 -0
- package/esm/checks/policy-index.js +342 -0
- package/esm/checks/revocable-grants.d.ts +140 -0
- package/esm/checks/revocable-grants.js +373 -0
- package/esm/checks/rls-flags.d.ts +25 -0
- package/esm/checks/rls-flags.js +77 -0
- package/esm/checks/role-reach.d.ts +329 -0
- package/esm/checks/role-reach.js +270 -0
- package/esm/checks/role-trust.d.ts +45 -0
- package/esm/checks/role-trust.js +86 -0
- package/esm/checks/set-role.d.ts +21 -0
- package/esm/checks/set-role.js +63 -0
- package/esm/checks/stats.d.ts +52 -0
- package/esm/checks/stats.js +184 -0
- package/esm/checks/view-exposure.d.ts +68 -0
- package/esm/checks/view-exposure.js +231 -0
- package/esm/checks/view-writes.d.ts +88 -0
- package/esm/checks/view-writes.js +344 -0
- package/esm/ci/baseline.d.ts +104 -0
- package/esm/ci/baseline.js +107 -0
- package/esm/ci/github.d.ts +47 -0
- package/esm/ci/github.js +161 -0
- package/esm/cli/audit.d.ts +3 -0
- package/esm/cli/audit.js +525 -0
- package/esm/cli/baseline.d.ts +3 -0
- package/esm/cli/baseline.js +102 -0
- package/esm/cli/commands.d.ts +2 -0
- package/esm/cli/commands.js +53 -0
- package/esm/cli/doctor.d.ts +3 -0
- package/esm/cli/doctor.js +67 -0
- package/esm/cli/eval.d.ts +3 -0
- package/esm/cli/eval.js +115 -0
- package/esm/cli/print-config.d.ts +3 -0
- package/esm/cli/print-config.js +52 -0
- package/esm/cli/shared.d.ts +43 -0
- package/esm/cli/shared.js +112 -0
- package/esm/cli.d.ts +2 -0
- package/esm/cli.js +67 -0
- package/esm/commands/audit.d.ts +65 -0
- package/esm/commands/audit.js +776 -0
- package/esm/commands/doctor.d.ts +19 -0
- package/esm/commands/doctor.js +178 -0
- 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 +44 -0
- package/esm/config/loader.js +172 -0
- package/esm/config/presets.d.ts +95 -0
- package/esm/config/presets.js +324 -0
- package/esm/config/resolve.d.ts +47 -0
- package/esm/config/resolve.js +180 -0
- package/esm/config/schema.d.ts +54 -0
- package/esm/config/schema.js +265 -0
- package/esm/config/types.d.ts +562 -0
- package/esm/config/validate.d.ts +14 -0
- package/esm/config/validate.js +122 -0
- 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/exposure/routines.d.ts +36 -0
- package/esm/exposure/routines.js +54 -0
- package/esm/index.d.ts +96 -0
- package/esm/index.js +57 -6
- package/esm/lint/index.d.ts +11 -0
- package/esm/lint/index.js +10 -0
- package/esm/perf/baseline.d.ts +40 -0
- package/esm/perf/baseline.js +83 -0
- package/esm/perf/explain.d.ts +46 -0
- package/esm/perf/explain.js +201 -0
- package/esm/pg/acl.d.ts +72 -0
- package/esm/pg/acl.js +186 -0
- package/esm/pg/behaviors.d.ts +114 -0
- package/esm/pg/behaviors.js +206 -0
- package/esm/pg/exposure.d.ts +74 -0
- package/esm/pg/exposure.js +198 -0
- package/esm/pg/functions.d.ts +52 -0
- package/esm/pg/functions.js +128 -0
- package/esm/pg/indexes.d.ts +192 -0
- package/esm/pg/indexes.js +395 -0
- package/esm/pg/introspect.d.ts +134 -0
- package/esm/pg/introspect.js +274 -0
- package/esm/pg/objects.d.ts +37 -0
- package/esm/pg/objects.js +108 -0
- package/esm/pg/paths.d.ts +114 -0
- package/esm/pg/paths.js +159 -0
- package/esm/pg/proc.d.ts +23 -0
- package/esm/pg/proc.js +55 -0
- package/esm/pg/reach-inputs.d.ts +47 -0
- package/esm/pg/reach-inputs.js +133 -0
- package/esm/pg/roles.d.ts +28 -0
- package/esm/pg/roles.js +50 -0
- package/esm/pg/stats.d.ts +65 -0
- package/esm/pg/stats.js +129 -0
- package/esm/pgpm-test.d.ts +22 -0
- package/esm/pgpm-test.js +39 -0
- package/esm/report/callgraph.d.ts +9 -0
- package/esm/report/callgraph.js +74 -0
- package/esm/report/compare.d.ts +143 -0
- package/esm/report/compare.js +195 -0
- package/esm/report/github.d.ts +74 -0
- package/esm/report/github.js +290 -0
- package/esm/report/json.d.ts +4 -0
- package/esm/report/json.js +3 -0
- package/esm/report/markdown.d.ts +25 -0
- package/esm/report/markdown.js +352 -0
- package/esm/report/pretty.d.ts +14 -0
- package/esm/report/pretty.js +318 -0
- package/esm/report/sarif.d.ts +40 -0
- package/esm/report/sarif.js +189 -0
- package/esm/report/view.d.ts +82 -0
- package/esm/report/view.js +126 -0
- package/esm/rules/registry.d.ts +66 -0
- package/esm/rules/registry.js +562 -0
- package/esm/score/score.d.ts +84 -0
- package/esm/score/score.js +233 -0
- package/esm/score/scorecards.d.ts +68 -0
- package/esm/score/scorecards.js +157 -0
- package/esm/types.d.ts +292 -0
- package/esm/types.js +26 -0
- package/esm/version.d.ts +8 -0
- package/esm/version.js +9 -0
- 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/exposure/routines.d.ts +36 -0
- package/exposure/routines.js +57 -0
- package/index.d.ts +96 -6
- package/index.js +229 -6
- package/lint/index.d.ts +11 -0
- package/lint/index.js +19 -0
- package/package.json +54 -48
- package/perf/baseline.d.ts +40 -0
- package/perf/baseline.js +92 -0
- package/perf/explain.d.ts +46 -0
- package/perf/explain.js +204 -0
- package/pg/acl.d.ts +72 -0
- package/pg/acl.js +191 -0
- package/pg/behaviors.d.ts +114 -0
- package/pg/behaviors.js +217 -0
- package/pg/exposure.d.ts +74 -0
- package/pg/exposure.js +205 -0
- package/pg/functions.d.ts +52 -0
- package/pg/functions.js +131 -0
- package/pg/indexes.d.ts +192 -0
- package/pg/indexes.js +400 -0
- package/pg/introspect.d.ts +134 -0
- package/pg/introspect.js +280 -0
- package/pg/objects.d.ts +37 -0
- package/pg/objects.js +111 -0
- package/pg/paths.d.ts +114 -0
- package/pg/paths.js +166 -0
- package/pg/proc.d.ts +23 -0
- package/pg/proc.js +58 -0
- package/pg/reach-inputs.d.ts +47 -0
- package/pg/reach-inputs.js +136 -0
- package/pg/roles.d.ts +28 -0
- package/pg/roles.js +54 -0
- package/pg/stats.d.ts +65 -0
- package/pg/stats.js +133 -0
- package/pgpm-test.d.ts +22 -0
- package/pgpm-test.js +42 -0
- package/report/callgraph.d.ts +9 -0
- package/report/callgraph.js +81 -0
- package/report/compare.d.ts +143 -0
- package/report/compare.js +205 -0
- package/report/github.d.ts +74 -0
- package/report/github.js +333 -0
- package/report/json.d.ts +4 -0
- package/report/json.js +6 -0
- package/report/markdown.d.ts +25 -0
- package/report/markdown.js +355 -0
- package/report/pretty.d.ts +14 -0
- package/report/pretty.js +324 -0
- package/report/sarif.d.ts +40 -0
- package/report/sarif.js +193 -0
- package/report/view.d.ts +82 -0
- package/report/view.js +132 -0
- package/rules/registry.d.ts +66 -0
- package/rules/registry.js +569 -0
- package/schema/safegres.schema.json +1119 -0
- package/score/score.d.ts +84 -0
- package/score/score.js +238 -0
- package/score/scorecards.d.ts +68 -0
- package/score/scorecards.js +162 -0
- package/types.d.ts +292 -0
- package/types.js +32 -0
- package/version.d.ts +8 -0
- package/version.js +12 -0
- package/LICENSE +0 -23
- package/admin.d.ts +0 -26
- package/admin.js +0 -182
- package/connect.d.ts +0 -19
- package/connect.js +0 -83
- package/dist/README.md +0 -557
- package/dist/package.json +0 -72
- package/esm/admin.js +0 -178
- package/esm/connect.js +0 -78
- package/esm/manager.js +0 -136
- package/esm/roles.js +0 -32
- package/esm/seed/adapters.js +0 -23
- package/esm/seed/csv.js +0 -44
- package/esm/seed/index.js +0 -16
- package/esm/seed/json.js +0 -18
- package/esm/seed/launchql.js +0 -19
- package/esm/seed/sqitch.js +0 -17
- package/esm/stream.js +0 -43
- package/esm/test-client.js +0 -150
- package/manager.d.ts +0 -25
- package/manager.js +0 -140
- package/roles.d.ts +0 -17
- package/roles.js +0 -38
- package/seed/adapters.d.ts +0 -4
- package/seed/adapters.js +0 -28
- package/seed/csv.d.ts +0 -9
- package/seed/csv.js +0 -49
- package/seed/index.d.ts +0 -16
- package/seed/index.js +0 -33
- package/seed/json.d.ts +0 -6
- package/seed/json.js +0 -21
- package/seed/launchql.d.ts +0 -2
- package/seed/launchql.js +0 -22
- package/seed/sqitch.d.ts +0 -2
- package/seed/sqitch.js +0 -20
- package/seed/types.d.ts +0 -13
- package/stream.d.ts +0 -2
- package/stream.js +0 -46
- package/test-client.d.ts +0 -49
- package/test-client.js +0 -154
- /package/{seed → config}/types.js +0 -0
- /package/esm/{seed → config}/types.js +0 -0
package/README.md
CHANGED
|
@@ -1,557 +1,970 @@
|
|
|
1
|
-
# safegres
|
|
1
|
+
# safegres
|
|
2
2
|
|
|
3
3
|
<p align="center" width="100%">
|
|
4
|
-
<img
|
|
4
|
+
<img src="https://raw.githubusercontent.com/Safegres/brand/refs/heads/main/safegres.svg" alt="safegres" width="120" />
|
|
5
5
|
</p>
|
|
6
6
|
|
|
7
7
|
<p align="center" width="100%">
|
|
8
|
-
<a href="https://github.com/
|
|
9
|
-
<img height="20" src="https://github.com/
|
|
10
|
-
</a>
|
|
11
|
-
<a href="https://github.com/launchql/launchql/blob/main/LICENSE">
|
|
12
|
-
<img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/>
|
|
13
|
-
</a>
|
|
14
|
-
<a href="https://www.npmjs.com/package/pgsql-test">
|
|
15
|
-
<img height="20" src="https://img.shields.io/github/package-json/v/launchql/launchql?filename=packages%2Fpgsql-test%2Fpackage.json"/>
|
|
8
|
+
<a href="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml">
|
|
9
|
+
<img height="20" src="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml/badge.svg" />
|
|
16
10
|
</a>
|
|
11
|
+
<a href="https://github.com/constructive-io/constructive/blob/main/LICENSE"><img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/></a>
|
|
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>
|
|
17
13
|
</p>
|
|
18
14
|
|
|
19
|
-
|
|
15
|
+
<p align="center"><strong>Safe <em>and</em> fast Postgres. Graded.</strong></p>
|
|
20
16
|
|
|
21
|
-
##
|
|
17
|
+
## Agent skill
|
|
22
18
|
|
|
23
|
-
|
|
24
|
-
npm install pgsql-test
|
|
25
|
-
```
|
|
19
|
+
The fastest start: give your coding agent the safegres skill and ask it to audit your database.
|
|
26
20
|
|
|
27
|
-
|
|
21
|
+
```bash
|
|
22
|
+
npx skills add constructive-io/constructive --skill safegres
|
|
23
|
+
```
|
|
28
24
|
|
|
29
|
-
|
|
30
|
-
* 🔄 **Per-test rollback** — every test runs in its own transaction or savepoint
|
|
31
|
-
* 🛡️ **RLS-friendly** — test with role-based auth via `.setContext()`
|
|
32
|
-
* 🌱 **Flexible seeding** — run `.sql` files, programmatic seeds, or even load fixtures
|
|
33
|
-
* 🧪 **Compatible with any async runner** — works with `Jest`, `Mocha`, etc.
|
|
34
|
-
* 🧹 **Auto teardown** — no residue, no reboots, just clean exits
|
|
25
|
+
## Install
|
|
35
26
|
|
|
36
|
-
|
|
27
|
+
```bash
|
|
28
|
+
npm i -D safegres
|
|
29
|
+
npx safegres doctor # connection, catalog visibility, and what's missing
|
|
30
|
+
npx safegres audit --perf # two scores, from the catalog
|
|
31
|
+
```
|
|
37
32
|
|
|
38
|
-
|
|
33
|
+
Standard libpq environment (`PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`), a full
|
|
34
|
+
`--connection <url>`, or per-field flags. No agent, no traffic, no application framework, nothing
|
|
35
|
+
executed and nothing written — safe to point at a production replica.
|
|
39
36
|
|
|
40
|
-
*
|
|
41
|
-
|
|
37
|
+
*No database handy? See [CI](#ci-in-one-job): one service container plus the migration command you
|
|
38
|
+
already have.*
|
|
42
39
|
|
|
40
|
+
## Features
|
|
43
41
|
|
|
44
|
-
|
|
42
|
+
* 🛡️ **Complete RLS Auditing** – Grants, RLS enforcement, policy coverage and policy behavior, across every schema and role in the database
|
|
43
|
+
* 🎯 **Operation-Level Coverage** – Every granted `SELECT`/`INSERT`/`UPDATE`/`DELETE` checked against the policies that actually cover it, per role
|
|
44
|
+
* 🔍 **Risky Policy Detection** – Permissive checks, volatile and session-dependent predicates, definer escalation and role bypass, found in the parsed AST rather than by regex
|
|
45
|
+
* ⚡ **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
|
|
46
|
+
* 🧭 **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
|
|
47
|
+
* 📊 **Security Scoring & Grades** – Scores, grades, severity counts and per-rule deductions with the payoff of fixing each one
|
|
48
|
+
* 🐘 **Pure PostgreSQL Analysis** – Reads the catalog and parses SQL; no agent, no traffic, no ORM, no application changes, nothing executed
|
|
49
|
+
* ⚙️ **Built for CI** – Summaries, Markdown, JSON and SARIF, GitHub job summaries, annotations and a sticky PR comment, with configurable failure thresholds
|
|
50
|
+
* Δ **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
|
|
51
|
+
* 🧪 **Graded Against a Corpus** – `safegres eval` scores the auditor itself on fixtures with known answers, sealed against config that could game the number
|
|
52
|
+
* 📦 **CLI and Library APIs** – Run it from the command line, or import the analysis, report and renderers into your own tooling
|
|
45
53
|
|
|
46
|
-
|
|
47
|
-
2. [Features](#features)
|
|
48
|
-
3. [Quick Start](#-quick-start)
|
|
49
|
-
4. [`getConnections()` Overview](#getconnections-overview)
|
|
50
|
-
5. [PgTestClient API Overview](#pgtestclient-api-overview)
|
|
51
|
-
6. [Usage Examples](#usage-examples)
|
|
52
|
-
* [Basic Setup](#-basic-setup)
|
|
53
|
-
* [Role-Based Context](#-role-based-context)
|
|
54
|
-
* [Seeding System](#-seeding-system)
|
|
55
|
-
* [SQL File Seeding](#-sql-file-seeding)
|
|
56
|
-
* [Programmatic Seeding](#-programmatic-seeding)
|
|
57
|
-
* [CSV Seeding](#️-csv-seeding)
|
|
58
|
-
* [JSON Seeding](#️-json-seeding)
|
|
59
|
-
* [Sqitch Seeding](#️-sqitch-seeding)
|
|
60
|
-
* [LaunchQL Seeding](#-launchql-seeding)
|
|
61
|
-
7. [`getConnections() Options` ](#getconnections-options)
|
|
62
|
-
8. [Disclaimer](#disclaimer)
|
|
54
|
+
## What you get
|
|
63
55
|
|
|
56
|
+
```
|
|
57
|
+
safegres 1.16.1 (2026-02-14T09:31:07.884Z)
|
|
64
58
|
|
|
65
|
-
|
|
59
|
+
exposure: 2 schema(s) via config — 41/318 tables exposed
|
|
66
60
|
|
|
67
|
-
|
|
68
|
-
|
|
61
|
+
score: 91.4 (A) — model: density
|
|
62
|
+
top deductions: A2 −10 (×1) R3 −4 (×1)
|
|
63
|
+
by rule: A2 B (+8.4) R3 A (+3.5)
|
|
64
|
+
unscored: A4 (×3) A6 (×7) — zero-weight, fixing these cannot move the score
|
|
69
65
|
|
|
70
|
-
|
|
66
|
+
perf score: 78.1 (C) — model: density
|
|
67
|
+
top deductions: X2 −12 (×3) X1 −8 (×2) X9 −4 (×1)
|
|
68
|
+
by rule: X2 C (+11.2) X1 D (+7.6) X9 B (+3.8)
|
|
71
69
|
|
|
72
|
-
|
|
73
|
-
({ db, teardown } = await getConnections());
|
|
74
|
-
await db.query(`SELECT 1`); // ✅ Ready to run queries
|
|
75
|
-
});
|
|
70
|
+
summary: 0 critical 1 high 4 medium 3 low 7 info
|
|
76
71
|
|
|
77
|
-
|
|
72
|
+
[HIGH] A2 app_public.webhook_deliveries
|
|
73
|
+
Roles [authenticated] have grants on app_public.webhook_deliveries but RLS is disabled
|
|
74
|
+
[MED ] X2 app_public.posts (posts_tenant)
|
|
75
|
+
Policy "posts_tenant" on app_public.posts filters by tenant_id, which is not the
|
|
76
|
+
leading column of any index
|
|
78
77
|
```
|
|
79
78
|
|
|
80
|
-
|
|
79
|
+
Also available as `--format json`, `json-pretty`, `markdown` (job summaries and PR comments) and
|
|
80
|
+
`sarif` (GitHub code scanning).
|
|
81
|
+
|
|
82
|
+
## How it works
|
|
83
|
+
|
|
84
|
+
safegres is a static analyzer for a live PostgreSQL database. It reads the system catalog and
|
|
85
|
+
parses every policy predicate and function body into an AST, then answers two questions the
|
|
86
|
+
catalog already contains the answers to — *who can actually reach this row*, and *what does
|
|
87
|
+
checking that cost per row* — and scores them on **two independent axes**.
|
|
88
|
+
|
|
89
|
+
Two scores, never mixed. Hardening a table can't show up as a performance regression; adding an
|
|
90
|
+
index can't show up as a security win. That is the whole design: the trade you make between them
|
|
91
|
+
becomes visible instead of netting out to a single number.
|
|
92
|
+
|
|
93
|
+
Three analyses over three different representations. None of them run your queries.
|
|
94
|
+
|
|
95
|
+
**1. The catalog, as a relation.** `pg_class`, `pg_namespace`, `pg_policy`, `pg_roles`,
|
|
96
|
+
`pg_auth_members`, `pg_index` and `aclexplode()` are joined into one snapshot per relation:
|
|
97
|
+
grants, RLS flags, policies with their expressions, indexes, ownership.
|
|
98
|
+
|
|
99
|
+
**2. Policy predicates, as ASTs.** Every `USING` / `WITH CHECK` expression is parsed with
|
|
100
|
+
[`pgsql-parser`](https://github.com/constructive-io/pgsql-parser) (libpg_query — the *actual* PostgreSQL
|
|
101
|
+
grammar, not a regex) and traversed. That is what makes the interesting rules possible: a literal
|
|
102
|
+
`true` in a write policy (A7), a cast wrapping the policy's own column so no plain index can serve
|
|
103
|
+
it (X3), a non-`IMMUTABLE` call whose arguments reference no column of the row and which sits
|
|
104
|
+
outside an uncorrelated scalar sub-select — i.e. a per-row call the planner will not hoist into an
|
|
105
|
+
InitPlan (X9). Structural detection, not a list of known function names: a GUC-reading helper
|
|
106
|
+
somebody adds next year is caught without configuration.
|
|
107
|
+
|
|
108
|
+
**3. Effective access, as a lattice.** ACL rows are not access. A privilege also arrives via
|
|
109
|
+
`GRANT … TO PUBLIC` and via role inheritance (`pg_auth_members`, INHERIT-following), and it is
|
|
110
|
+
only *reachable* if the role also holds `USAGE` on the schema. safegres computes the effective
|
|
111
|
+
cell each `(relation, role, privilege)` triple lands in, with `pg_has_role` membership semantics,
|
|
112
|
+
and reports the ones that are inconsistent in either direction:
|
|
113
|
+
|
|
114
|
+
| grant | RLS | policy | verdict |
|
|
115
|
+
| --- | --- | --- | --- |
|
|
116
|
+
| yes | on | yes | normal — policy-mediated access |
|
|
117
|
+
| yes | on | no | dead grant (L1 indirect; A4/A5 direct) |
|
|
118
|
+
| yes | off | — | unmediated (A2 table-level; L5 per untrusted role, indirect) |
|
|
119
|
+
| no | — | yes | dead policy (L2) |
|
|
120
|
+
|
|
121
|
+
The same closure answers the direct question — *what can role X actually reach?* — as
|
|
122
|
+
`report.roleAccess`, with provenance (`direct`, `PUBLIC`, `member of <role>`) per relation.
|
|
123
|
+
|
|
124
|
+
Two optional analyses go further: `--explain` plans each finding's query shape with
|
|
125
|
+
`EXPLAIN (GENERIC_PLAN)` and lets the planner **refute** the tool's own static claim; `--call-graph`
|
|
126
|
+
computes the transitive closure of function calls from the exposed entry points and enumerates the
|
|
127
|
+
trust boundaries on the way.
|
|
128
|
+
|
|
129
|
+
## What it checks
|
|
130
|
+
|
|
131
|
+
45 rules across two dimensions — 34 security, 11 performance — plus four opt-in runtime-statistics
|
|
132
|
+
rules and a four-rule source-level convention linter. The prefix letter is a family, **not** the
|
|
133
|
+
dimension: `P1`/`P1b` are performance, `P5` is security.
|
|
134
|
+
|
|
135
|
+
### Security (34 rules)
|
|
136
|
+
|
|
137
|
+
| Code | Severity | Direction | Check |
|
|
138
|
+
| --- | --- | --- | --- |
|
|
139
|
+
| A1 | low | fail-closed | RLS enabled but **0 policies** (deny-all — confirm the lock is intended) |
|
|
140
|
+
| A2 | high | fail-open | Grants exist on a table with **RLS disabled** |
|
|
141
|
+
| A3 | low | fail-open | RLS enabled but **`FORCE ROW LEVEL SECURITY` not set** (table-owner bypass) |
|
|
142
|
+
| A4 | low | fail-closed | INSERT/UPDATE/DELETE grant with **no covering policy** — writes denied at runtime |
|
|
143
|
+
| A5 | low | fail-closed | SELECT grant with **no policy** — queries silently return 0 rows |
|
|
144
|
+
| A6 | info | fail-closed | UPDATE has `USING` but **no `WITH CHECK`** (row-smuggling surface) |
|
|
145
|
+
| A7 | critical | fail-open | Trivially-permissive **write** policy (literal `true`) |
|
|
146
|
+
| A8 | low | fail-open | Trivially-permissive **SELECT** policy (`USING (true)`) |
|
|
147
|
+
| P5 | high | fail-open | Policy references **`session_user`** / `current_user` / `pg_has_role(...)` |
|
|
148
|
+
| R1 | critical | fail-open | An **untrusted role** holds a write privilege † |
|
|
149
|
+
| R2 | high | fail-open | A permissive write policy applies to an untrusted role or PUBLIC † |
|
|
150
|
+
| R3 | medium | fail-open | An RLS table has grants **TO PUBLIC** |
|
|
151
|
+
| L1 | low | fail-closed | **Dead indirect grant** — arrives via PUBLIC/inheritance, no policy admits it |
|
|
152
|
+
| L2 | low | fail-closed | **Dead policy** — applies to a role holding no corresponding grant |
|
|
153
|
+
| L3 | low | fail-closed | **Unreachable grant** — object privilege without schema `USAGE` |
|
|
154
|
+
| L4 | info | neutral | **Dead schema `USAGE`** — reaches no relation and no function |
|
|
155
|
+
| L5 | info | fail-open | An untrusted role reaches an **RLS-off table** via PUBLIC/inheritance † |
|
|
156
|
+
| L6 | info | neutral | **Unaddressable grant** — an API role holds privileges on a relation its API cannot name ‡ |
|
|
157
|
+
| L7 | info | fail-open | **Role escalation** — an untrusted role can `SET ROLE` to a role with more reach † |
|
|
158
|
+
| L8 | info | fail-open | **DEFINER view bypass** — an untrusted role reads a base relation, and named columns of it, as the view's owner † |
|
|
159
|
+
| L9 | info | fail-open | **DEFINER view write** — an auto-updatable definer view writes a base relation as its owner † |
|
|
160
|
+
| L10 | info | fail-open | **Rewrite-rule bypass** — a rule on a view writes a relation as the view's owner, `security_invoker` notwithstanding † |
|
|
161
|
+
| L11 | info | fail-open | **Materialized-view snapshot** — stored rows serve an untrusted role what the base relation's grants and policies would not † |
|
|
162
|
+
| 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 † |
|
|
163
|
+
| L13 | info | fail-open | **Column-level grant** — an untrusted role reaches a relation through `pg_attribute.attacl`, which no relation ACL shows † |
|
|
164
|
+
| L14 | info | neutral | **Unaudited base relation** — a definer view reads a relation in a schema the audit never introspected † |
|
|
165
|
+
| L15 | info | neutral | **Unreadable body** — an untrusted role reaches through a definer view or function whose body the analysis could not follow † |
|
|
166
|
+
| L16 | info | fail-open | **Sequence privilege** — an untrusted role can advance or read a sequence, which no policy filters † |
|
|
167
|
+
| L17 | info | fail-open | **Foreign-table grant** — an untrusted role reaches a relation that cannot carry RLS at all † |
|
|
168
|
+
| L18 | info | fail-open | **Writable filtering view without `WITH CHECK OPTION`** — an untrusted role writes rows the view's own filter excludes † |
|
|
169
|
+
| L19 | info | fail-open | **Definer-function reach** — an untrusted role touches a relation by executing a `SECURITY DEFINER` function, which runs as its owner † |
|
|
170
|
+
| L20 | info | fail-open | **`INSTEAD OF` trigger write** — a write against a view becomes a trigger function's body, and a definer one lands it as the function's owner † |
|
|
171
|
+
| L21 | info | fail-closed | **Revocable grant** — a role holds `EXECUTE` that no reachable path exercises † |
|
|
172
|
+
| W1 | medium | — | **No exposure surface configured** — whole database assumed reachable, score capped |
|
|
173
|
+
|
|
174
|
+
† The untrusted-role rules are no-ops until there is an untrusted role: named outright
|
|
175
|
+
(`"R1": ["critical", { "roles": ["anonymous"] }]`) or resolved from the exposure surface
|
|
176
|
+
(`rolesFrom: "anon"`, which `safegres:recommended` and the stack presets set — see
|
|
177
|
+
[Configuration](#configuration)). They cost nothing on databases without an untrusted-role model.
|
|
178
|
+
|
|
179
|
+
‡ L6 needs an adapter that can compute [API reach](#api-reach--the-relations-the-api-can-actually-name);
|
|
180
|
+
without one nothing is unaddressable and it never fires.
|
|
181
|
+
|
|
182
|
+
**Exposure is asked per subject.** A relation finding is exposed when the API can address the
|
|
183
|
+
relation. A *routine* finding — the convention rules, L19, L20 — is exposed when an untrusted role
|
|
184
|
+
can **call the function**: `USAGE` on its schema and `EXECUTE` on the function, directly, by
|
|
185
|
+
inheritance, or through Postgres's default `EXECUTE TO PUBLIC`. Grading a definer function by the
|
|
186
|
+
schema it sits in is not conservative, it is wrong in the dangerous direction: the function is
|
|
187
|
+
private precisely so that calling it is the only way in.
|
|
188
|
+
|
|
189
|
+
**Direction is the load-bearing idea.** `fail-open` findings are exposure — the untrusted side
|
|
190
|
+
reaches more than intended. `fail-closed` findings are *denied by Postgres at runtime*: an
|
|
191
|
+
availability and hygiene concern, not a leak. They contribute **zero** to the score by default
|
|
192
|
+
(`scoring.failClosedWeight`). safegres does not cry wolf about a grant the database already refuses.
|
|
193
|
+
|
|
194
|
+
### Convention (source-level lint, 4 rules — `safegres:constructive`)
|
|
195
|
+
|
|
196
|
+
House-style rules that read function **definitions** (`pg_get_functiondef`), not the catalog. They
|
|
197
|
+
are pure `source → findings` — they come from [`@pgsql/lint`](https://www.npmjs.com/package/@pgsql/lint),
|
|
198
|
+
which has no `pg` dependency — and are **off outside the `safegres:constructive` preset**, which
|
|
199
|
+
enables them. A function on a non-exposed schema costs nothing, like every other rule.
|
|
200
|
+
|
|
201
|
+
| Code | Severity | Direction | Check |
|
|
202
|
+
| --- | --- | --- | --- |
|
|
203
|
+
| C1 | high | fail-open | Function **sets `search_path`** (house rule: never set it — fully-qualify instead) |
|
|
204
|
+
| C2 | medium | neutral | Function uses a **`#variable_conflict`** directive |
|
|
205
|
+
| C3 | low | neutral | Function has an **unqualified relation reference** (relies on `search_path`) |
|
|
206
|
+
| C4 | high | fail-open | Function uses **dynamic SQL** (`EXECUTE` / `EXECUTE … USING` / `FOR … IN EXECUTE`) |
|
|
207
|
+
|
|
208
|
+
C3 ships at `low` (adoption severity — ratchet to error once the tree is clean). C4 cannot be
|
|
209
|
+
statically proven read-only, so every dynamic-SQL site is flagged and must be **waived inline with a
|
|
210
|
+
categorized reason** (`lookup-only`, `codegen`); a reasonless waiver does not suppress it.
|
|
211
|
+
|
|
212
|
+
**Inline suppressions** (ESLint/Prettier style), written as SQL comments inside the body:
|
|
213
|
+
|
|
214
|
+
```sql
|
|
215
|
+
-- safegres-disable-next-line no-dynamic-sql -- lookup-only: building an IN-list of integers
|
|
216
|
+
EXECUTE format('SELECT ... WHERE id = ANY(%L)', ids);
|
|
217
|
+
|
|
218
|
+
EXECUTE 'REFRESH MATERIALIZED VIEW app.mv'; -- safegres-disable-line no-dynamic-sql -- codegen: fixed DDL
|
|
219
|
+
|
|
220
|
+
-- safegres-disable no-dynamic-sql -- lookup-only: this whole block probes the catalog
|
|
221
|
+
...
|
|
222
|
+
-- safegres-enable no-dynamic-sql
|
|
223
|
+
|
|
224
|
+
-- safegres-disable-file no-set-search-path -- vendored extension shim
|
|
225
|
+
```
|
|
81
226
|
|
|
82
|
-
|
|
83
|
-
|
|
227
|
+
The `pgsql-lint-` prefix (as written for the standalone `pgsql-lint` CLI) is read the same way, so
|
|
228
|
+
one waiver covers both tools. A directive with no rule id applies to every convention rule. Waived
|
|
229
|
+
findings are **not dropped** — they surface as `acknowledged` (accepted-risk) findings carrying their reason, off the score.
|
|
230
|
+
|
|
231
|
+
### Performance (11 rules, `--perf`)
|
|
232
|
+
|
|
233
|
+
| Code | Severity | Check |
|
|
234
|
+
| --- | --- | --- |
|
|
235
|
+
| X1 | medium | **Foreign key with no covering index** — joins and cascading deletes seq-scan the child |
|
|
236
|
+
| X2 | medium | **RLS policy filters on a column that leads no index** — the security qual seq-scans, for every caller |
|
|
237
|
+
| X3 | medium | **Policy casts or wraps its own column** (`tenant_id::text = …`) with no matching expression index |
|
|
238
|
+
| X4 | low | **Policy calls a non-LEAKPROOF function** — the qual can't be pushed below joins or subquery scans |
|
|
239
|
+
| X5 | low | **Redundant index** — exact duplicate of, or leading-column prefix of, another |
|
|
240
|
+
| X6 | low | **No primary key** and no usable replica identity |
|
|
241
|
+
| X7 | medium | **Search column with no index the search can use** — `tsvector` without GIN/GiST, `vector` without HNSW/IVFFlat |
|
|
242
|
+
| X8 | info | **Sort-shaped column leads no index** (`timestamptz`/`date`) — heuristic, scores 0 |
|
|
243
|
+
| X9 | medium | **Policy calls a STABLE function per row** — not wrapped in a scalar sub-select, so no InitPlan |
|
|
244
|
+
| P1 | high | Policy body calls a **VOLATILE function** — re-evaluated per row |
|
|
245
|
+
| P1b | medium | Policy body calls a **`SECURITY DEFINER` function** — the planner cannot inline it |
|
|
246
|
+
|
|
247
|
+
X2/X3/X4/X9 are the checks a generic index linter structurally cannot make, because they require
|
|
248
|
+
the policy predicate's AST. RLS quals are evaluated **before** user quals, on every candidate row,
|
|
249
|
+
for every caller — so an unindexed or cast-wrapped policy column is a whole-table tax rather than
|
|
250
|
+
one slow query. This is where the two dimensions physically intersect, and why they belong in one
|
|
251
|
+
tool.
|
|
252
|
+
|
|
253
|
+
X9, measured on 200k rows with a policy function that counts its own invocations:
|
|
254
|
+
|
|
255
|
+
| Policy qual | Calls | Time |
|
|
256
|
+
| --- | --- | --- |
|
|
257
|
+
| `other_id = current_principal_id()` (Filter) | 200,000 | 424 ms |
|
|
258
|
+
| `other_id = (SELECT current_principal_id())` (InitPlan) | 1 | 22 ms |
|
|
259
|
+
|
|
260
|
+
Four more rules (`S1`–`S4`) read runtime counters rather than the schema and are opt-in with
|
|
261
|
+
`--stats`. Full rationale for every rule, including the access-path signals behind X1 and the
|
|
262
|
+
plan-dependence caveat on X9: **[docs/rules.md](https://github.com/constructive-io/constructive/blob/main/packages/safegres/docs/rules.md)**.
|
|
263
|
+
|
|
264
|
+
## Exposure surface
|
|
265
|
+
|
|
266
|
+
A database-wide score is meaningless if most of the database isn't reachable. Declare the
|
|
267
|
+
**exposure surface** and safegres partitions every finding:
|
|
268
|
+
|
|
269
|
+
- **Exposed** findings (on API-reachable schemas) drive the score.
|
|
270
|
+
- **Internal** findings are reported as unscored advisories (`--exposed-only` hides them).
|
|
271
|
+
- **No exposure configured** → `W1`, and the score is capped at 80/B (`scoring.unknownExposureCap`).
|
|
272
|
+
|
|
273
|
+
```jsonc
|
|
274
|
+
{
|
|
275
|
+
"exposure": {
|
|
276
|
+
"schemas": ["app_public", "app_hidden"] // static surface
|
|
277
|
+
// or, on a Constructive database:
|
|
278
|
+
// "resolver": "constructive" // introspects routing_public.apis → api_schemas
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
```
|
|
84
282
|
|
|
85
|
-
|
|
86
|
-
|
|
283
|
+
The score improves by being **explicit** (declaring exposure and intent) or by **fixing** a leak —
|
|
284
|
+
never by renaming. A `*_public` schema name is not a declaration; the config is.
|
|
285
|
+
CLI: `--exposure-schemas <csv>`, `--exposed-only`.
|
|
286
|
+
|
|
287
|
+
Two related declarations, both of which mark findings *acknowledged* (reported as info, excluded
|
|
288
|
+
from the score): `public.read` for deliberately open reads (pricing tables, a public directory)
|
|
289
|
+
and `perf.ignore` for accepted performance debt. Extension-owned relations are skipped by default,
|
|
290
|
+
and an extension that creates objects at runtime can be skipped wholesale with
|
|
291
|
+
`extensions.ignore` — see [docs/rules.md](https://github.com/constructive-io/constructive/blob/main/packages/safegres/docs/rules.md#extension-objects).
|
|
292
|
+
|
|
293
|
+
### Planes: the other ways in
|
|
294
|
+
|
|
295
|
+
An API is one way into a database, not the only one. Declare the others as **planes** and each
|
|
296
|
+
gets its own grade — while the headline score stays exactly what it was:
|
|
297
|
+
|
|
298
|
+
```jsonc
|
|
299
|
+
{
|
|
300
|
+
"exposure": {
|
|
301
|
+
"schemas": ["app_public"],
|
|
302
|
+
"planes": [
|
|
303
|
+
{ "name": "direct:reporting", "kind": "role", "roles": ["reporting"] },
|
|
304
|
+
{ "name": "internal", "kind": "schema", "schemas": ["app_private"] }
|
|
305
|
+
]
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
```
|
|
87
309
|
|
|
88
|
-
|
|
89
|
-
|
|
310
|
+
```
|
|
311
|
+
score 87 (B) security ← still the declared API surface, unchanged
|
|
312
|
+
other access planes — advisory, not part of the score above:
|
|
313
|
+
direct:reporting [role] (reporting): 41 (F) — 12 relation(s), reached via grant
|
|
314
|
+
internal [schema] (app_private): 63 (D) — 9 relation(s)
|
|
90
315
|
```
|
|
91
316
|
|
|
92
|
-
|
|
317
|
+
A role plane is resolved through the same effective-access lattice the `L*` rules use — direct
|
|
318
|
+
grants, `PUBLIC`, and role inheritance — so it answers the question a reviewer actually asks: *if
|
|
319
|
+
this connection string leaks, what does it reach?* Roles with `BYPASSRLS` or superuser are
|
|
320
|
+
reported as not graded rather than given a meaningless F.
|
|
93
321
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
* `admin`: a `DbAdmin` utility for managing database state, extensions, roles, and templates
|
|
97
|
-
* `teardown()`: a function that shuts down the test environment and database pool
|
|
98
|
-
* `manager`: a shared connection pool manager (`PgTestConnector`) behind both clients
|
|
322
|
+
Secondary planes never move the headline and never touch finding identity, so adding one cannot
|
|
323
|
+
invalidate a baseline. They gate nothing unless you ask — `failOn.planes` takes a name or glob:
|
|
99
324
|
|
|
100
|
-
|
|
325
|
+
```jsonc
|
|
326
|
+
{ "failOn": { "grade": "B", "planes": { "direct:*": { "grade": "D" } } } }
|
|
327
|
+
```
|
|
101
328
|
|
|
102
|
-
|
|
329
|
+
`--plane direct:reporting` (or `--plane '*'`) expands one in the terminal and markdown output;
|
|
330
|
+
`--format json` always carries them all in `report.planes`.
|
|
103
331
|
|
|
104
|
-
|
|
105
|
-
* Easy switching of role-based contexts for RLS testing
|
|
106
|
-
* A clean, high-level API for integration testing PostgreSQL systems
|
|
332
|
+
### Adapters
|
|
107
333
|
|
|
108
|
-
|
|
334
|
+
Where the exposed surface comes from is an interface, not a hard-coded integration. An adapter is
|
|
335
|
+
an object — no plugin resolution, nothing load-bearing about a package name:
|
|
109
336
|
|
|
110
337
|
```ts
|
|
111
|
-
|
|
112
|
-
|
|
338
|
+
// safegres.config.ts
|
|
339
|
+
import type { SafegresConfig } from 'safegres';
|
|
340
|
+
import { constructiveAdapter } from 'safegres/adapters';
|
|
113
341
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
342
|
+
import { myGatewayAdapter } from './my-gateway-adapter';
|
|
343
|
+
|
|
344
|
+
const config: SafegresConfig = {
|
|
345
|
+
exposure: { adapters: [constructiveAdapter, myGatewayAdapter] }
|
|
346
|
+
};
|
|
347
|
+
export default config;
|
|
348
|
+
```
|
|
117
349
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
350
|
+
```ts
|
|
351
|
+
interface ExposureAdapter {
|
|
352
|
+
name: string;
|
|
353
|
+
detect(exec: QueryExecutor): Promise<boolean>; // is this stack present?
|
|
354
|
+
resolve(exec: QueryExecutor): Promise<PlaneInput[]>; // one or more planes
|
|
355
|
+
reach?(exec: QueryExecutor, ctx: ReachContext): Promise<ApiReach>; // optional precision
|
|
356
|
+
}
|
|
121
357
|
```
|
|
122
358
|
|
|
123
|
-
|
|
359
|
+
Built-ins ship for `constructive`, `postgrest`, `supabase`, `hasura` and `graphile` — each reading
|
|
360
|
+
the signal its stack actually leaves in the catalog (see [Configuration](#configuration)), and
|
|
361
|
+
each emitting a primary `api` plane plus whatever secondary planes it can prove: one `api:<name>`
|
|
362
|
+
per API for Constructive, a `direct:<authenticator>` role plane for PostgREST, `app_private` as an
|
|
363
|
+
internal plane for graphile-starter. `postgraphile` is the exception: it contributes no plane at
|
|
364
|
+
all and only supplies [reach](#api-reach--the-relations-the-api-can-actually-name). JSON configs may name a built-in
|
|
365
|
+
(`"adapters": ["supabase"]`); anything else is an error rather than a silent no-op — a typo'd
|
|
366
|
+
adapter would otherwise present as an unexposed database. The old `"resolver": "constructive"`
|
|
367
|
+
still works.
|
|
368
|
+
|
|
369
|
+
### API reach — the relations the API can actually name
|
|
370
|
+
|
|
371
|
+
A plane made of schemas answers *is this relation in the API's schemas?*, which over-counts: a
|
|
372
|
+
generated API exposes fields, and a schema routinely holds relations it deliberately does not
|
|
373
|
+
surface — join tables, denormalized shadows, machine-only back-pointers. `reach()` is where an
|
|
374
|
+
adapter narrows a plane from its schemas to its **relations**. The built-in `postgraphile` adapter
|
|
375
|
+
reads the `@behavior` / `@forwardBehavior` / `@backwardBehavior` smart tags to do it, and both
|
|
376
|
+
`graphile` and `constructive` delegate to it — those two answer *which schemas are served*, which
|
|
377
|
+
is a different question from *what the served schemas expose*:
|
|
378
|
+
|
|
379
|
+
```jsonc
|
|
380
|
+
{ "exposure": { "schemas": ["app_public"], "adapters": ["postgraphile"] } }
|
|
381
|
+
```
|
|
124
382
|
|
|
125
|
-
|
|
383
|
+
Three properties keep it from quietly deleting findings:
|
|
384
|
+
|
|
385
|
+
- **Only an explicit denial counts.** Presets grant most behaviors by default, so the *absence* of
|
|
386
|
+
`+list` says nothing. Silence is never read as denial.
|
|
387
|
+
- **Unreachable means unreachable by every route.** A relation with no root entry is still
|
|
388
|
+
addressable by traversing a relation field from one that has, so reach is graph traversal over
|
|
389
|
+
foreign keys, not a per-table test. Hiding one reverse relation is one missing path, not proof.
|
|
390
|
+
- **A role plane is never narrowed.** The API not exposing a table says nothing about a role
|
|
391
|
+
holding a direct connection. Behavior only ever refines `api`/`schema` planes.
|
|
392
|
+
|
|
393
|
+
Anything subtracted is listed in `report.exposure.unaddressable` rather than silently dropped, and
|
|
394
|
+
`"reach": false` turns the whole thing off. Where an API-edge role still holds privileges on a
|
|
395
|
+
relation its own API cannot name, **L6** reports the grant — unless some RLS policy predicate
|
|
396
|
+
references the relation, since a grant a policy subqueries under the querying role is load-bearing
|
|
397
|
+
however invisible it is to the API.
|
|
398
|
+
|
|
399
|
+
## CI in one job
|
|
400
|
+
|
|
401
|
+
One service container, your existing migration command, one audit:
|
|
402
|
+
|
|
403
|
+
```yaml
|
|
404
|
+
jobs:
|
|
405
|
+
database-audit:
|
|
406
|
+
runs-on: ubuntu-latest
|
|
407
|
+
services:
|
|
408
|
+
postgres:
|
|
409
|
+
image: postgres:16
|
|
410
|
+
env: { POSTGRES_PASSWORD: postgres }
|
|
411
|
+
options: --health-cmd pg_isready --health-interval 5s --health-retries 10
|
|
412
|
+
ports: ['5432:5432']
|
|
413
|
+
env:
|
|
414
|
+
PGHOST: localhost
|
|
415
|
+
PGUSER: postgres
|
|
416
|
+
PGPASSWORD: postgres
|
|
417
|
+
PGDATABASE: app_audit
|
|
418
|
+
steps:
|
|
419
|
+
- uses: actions/checkout@v4
|
|
420
|
+
# …install deps, then apply your schema with whatever already builds it:
|
|
421
|
+
# rails db:schema:load · manage.py migrate · prisma migrate deploy
|
|
422
|
+
# drizzle-kit push · sqitch deploy · pgpm deploy · psql -f schema.sql
|
|
423
|
+
- run: npx safegres audit --perf --format markdown >> "$GITHUB_STEP_SUMMARY"
|
|
424
|
+
- run: |
|
|
425
|
+
npx safegres audit --perf --summary \
|
|
426
|
+
--fail-on-grade B \
|
|
427
|
+
--perf-baseline ci/safegres-perf-baseline.json --fail-on-new-perf
|
|
428
|
+
```
|
|
126
429
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
430
|
+
Everything a job repeats every run belongs in the config file instead, so the job is one word. A
|
|
431
|
+
repository whose schema is a pgpm workspace does not need a database at all — `source.pgpm`
|
|
432
|
+
deploys it into an ephemeral one:
|
|
433
|
+
|
|
434
|
+
```jsonc
|
|
435
|
+
// .safegresrc.json — paths are relative to this file
|
|
436
|
+
{
|
|
437
|
+
"extends": "safegres:constructive",
|
|
438
|
+
"source": { "pgpm": "application/app" },
|
|
439
|
+
"perf": { "enabled": true, "baseline": "ci/perf-baseline.json", "failOnNew": true },
|
|
440
|
+
"outputs": { "dir": "safegres-reports" },
|
|
441
|
+
"failOn": { "grade": "B" }
|
|
442
|
+
}
|
|
443
|
+
```
|
|
132
444
|
|
|
133
|
-
|
|
445
|
+
```yaml
|
|
446
|
+
- run: npx safegres audit # or: "audit": "safegres lint" in package.json
|
|
447
|
+
```
|
|
134
448
|
|
|
135
|
-
|
|
449
|
+
Or the first-party action, which installs the CLI, runs that config, and turns the report into a
|
|
450
|
+
job summary, annotations and a sticky PR comment:
|
|
136
451
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
452
|
+
```yaml
|
|
453
|
+
- uses: constructive-io/constructive/packages/safegres@safegres-action-v1
|
|
454
|
+
with:
|
|
455
|
+
comment: true # sticky PR comment (needs pull-requests: write)
|
|
456
|
+
upload-sarif: true # code scanning (needs security-events: write)
|
|
457
|
+
```
|
|
140
458
|
|
|
141
|
-
|
|
459
|
+
`safegres-action-v1` is a moving tag on the action's latest v1; `@main` tracks the monorepo tip.
|
|
460
|
+
The reports are uploaded as the `safegres-reports` artifact by default — that artifact *is* the
|
|
461
|
+
next run's baseline, so a job that suppresses it (`upload-reports: false`) can never show a delta.
|
|
142
462
|
|
|
143
|
-
|
|
463
|
+
Auditing a pgpm workspace (`source.pgpm`) adds `version: local`, which runs the repo's own safegres
|
|
464
|
+
rather than installing one: that path deploys through `pgsql-test`, an optional peer only the
|
|
465
|
+
workspace has.
|
|
144
466
|
|
|
145
|
-
|
|
146
|
-
|
|
467
|
+
On a pull request it also measures the delta for you: it picks the base branch's most recent run
|
|
468
|
+
that *is* a sane comparison — succeeded, younger than 48 hours, and audited a commit in this
|
|
469
|
+
branch's merge-base history — downloads its report, and names the run it chose in the summary and
|
|
470
|
+
the comment (`actions: read`). "The last successful run" is not that: an unvalidated `gh run list
|
|
471
|
+
--limit 1` once handed a PR a 13.6-day-old baseline and billed it for ~150 merges it never
|
|
472
|
+
contained. Nothing qualifying is not a failure — no deltas, a line saying why, gates unchanged.
|
|
147
473
|
|
|
148
|
-
|
|
149
|
-
|
|
474
|
+
It exposes `security-score`, `security-grade` and `perf-score` as step outputs — on a failing run
|
|
475
|
+
too, which is when they get read. Everything else stays in the config file; see
|
|
476
|
+
**[docs/reporting.md](https://github.com/constructive-io/constructive/blob/main/packages/safegres/docs/reporting.md#the-action)**.
|
|
150
477
|
|
|
151
|
-
|
|
152
|
-
|
|
478
|
+
`outputs.dir` writes `safegres.json`, `safegres.md` and `safegres.sarif` into one directory — name
|
|
479
|
+
an individual file (`outputs.json`) only when the name matters. Directories are created as needed,
|
|
480
|
+
and a flag still wins over the file for a one-off run (`safegres audit --out reports`). Naming a
|
|
481
|
+
connection wins too, so the same config audits a database you already have:
|
|
482
|
+
`safegres audit --database staging`.
|
|
153
483
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
484
|
+
Scores lead the markdown, then severity counts, then a table per dimension; internal advisories
|
|
485
|
+
and accepted baseline debt fold into `<details>`. Pipe it to `gh pr comment --body-file -` to post
|
|
486
|
+
it on the pull request instead. Library callers get the same renderer as `renderMarkdown(report)`.
|
|
157
487
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
`);
|
|
161
|
-
});
|
|
488
|
+
`--format sarif` turns findings into GitHub code-scanning alerts, and `--compare` renders the
|
|
489
|
+
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)**.
|
|
162
490
|
|
|
163
|
-
|
|
491
|
+
## The ratchet
|
|
164
492
|
|
|
165
|
-
|
|
166
|
-
|
|
493
|
+
An established schema will not be clean on the first run — but it can refuse to get worse. Commit
|
|
494
|
+
today's performance findings as accepted debt and gate on what is *not* in the baseline:
|
|
167
495
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
496
|
+
```bash
|
|
497
|
+
safegres audit --write-perf-baseline ci/safegres-perf-baseline.json # snapshot (implies --perf)
|
|
498
|
+
safegres audit --perf-baseline ci/safegres-perf-baseline.json # diff: new / accepted / fixed
|
|
499
|
+
safegres audit --perf-baseline ci/safegres-perf-baseline.json --fail-on-new-perf # gate
|
|
172
500
|
```
|
|
173
501
|
|
|
174
|
-
|
|
502
|
+
```
|
|
503
|
+
performance vs baseline:
|
|
175
504
|
|
|
505
|
+
1 new perf finding since the baseline:
|
|
506
|
+
[medium] X1 app_public.comments — foreign key with no covering index
|
|
507
|
+
(14 accepted, 2 fixed)
|
|
508
|
+
```
|
|
176
509
|
|
|
177
|
-
|
|
510
|
+
Entries are keyed by finding *identity* — `code` + relation + policy + subject (the constraint,
|
|
511
|
+
index, expression, column or function it is about) — never by message text, so rewording a rule or
|
|
512
|
+
retuning a severity in a later release cannot invalidate a committed baseline. Findings that
|
|
513
|
+
disappear are reported as fixed; re-baseline to lock the win in.
|
|
178
514
|
|
|
179
|
-
|
|
515
|
+
On an established schema this is the enforceable gate, and a perf *grade* gate is not: a score
|
|
516
|
+
dominated by inherited debt either fails forever or means nothing. Security has no baseline —
|
|
517
|
+
gate it on a grade you can hold today and raise it as the score climbs.
|
|
180
518
|
|
|
181
|
-
|
|
519
|
+
The same mechanism exists for call-graph trust boundaries (`--write-baseline`, `--baseline`,
|
|
520
|
+
`--fail-on-new-boundaries`).
|
|
182
521
|
|
|
183
|
-
|
|
184
|
-
db.setContext({
|
|
185
|
-
role: 'authenticated',
|
|
186
|
-
'jwt.claims.user_id': '123',
|
|
187
|
-
'jwt.claims.org_id': 'acme'
|
|
188
|
-
});
|
|
189
|
-
```
|
|
522
|
+
## Sealed runs
|
|
190
523
|
|
|
191
|
-
|
|
524
|
+
Every knob above is deliberate when a team declares its intent in CI, and is the cheapest possible
|
|
525
|
+
cheat when a *score* is the thing being evaluated — of an agent's migration, of a template, of a
|
|
526
|
+
submission. Turning off the rule and re-baselining the debt both raise the number without touching
|
|
527
|
+
the database.
|
|
192
528
|
|
|
193
|
-
|
|
529
|
+
So every report carries the ruler it was measured with:
|
|
194
530
|
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
afterEach(() => db.afterEach());
|
|
203
|
-
|
|
204
|
-
it('runs as authenticated', async () => {
|
|
205
|
-
const res = await db.query(`SELECT current_setting('role', true) AS role`);
|
|
206
|
-
expect(res.rows[0].role).toBe('authenticated');
|
|
207
|
-
});
|
|
208
|
-
});
|
|
531
|
+
```jsonc
|
|
532
|
+
"provenance": {
|
|
533
|
+
"version": "1.17.0",
|
|
534
|
+
"fingerprint": "sha256:8f14e45fceea167a…", // over the *resolved* rules, overrides,
|
|
535
|
+
"sealed": true, // scoring weights, exposure and ignores
|
|
536
|
+
"preset": "strict"
|
|
537
|
+
}
|
|
209
538
|
```
|
|
210
539
|
|
|
211
|
-
|
|
540
|
+
`--sealed` grades under a built-in preset alone: no config-file discovery, and `--config`,
|
|
541
|
+
`--rule`, `--exposure-schemas` and every baseline flag are *refused* rather than ignored — a run
|
|
542
|
+
that silently dropped a flag would report a number for rules nobody chose. A harness pins the
|
|
543
|
+
answer it expects:
|
|
212
544
|
|
|
213
|
-
|
|
545
|
+
```bash
|
|
546
|
+
safegres audit --sealed --preset strict --verify-fingerprint sha256:8f14e45fceea167a… --format json
|
|
547
|
+
```
|
|
214
548
|
|
|
215
|
-
|
|
549
|
+
The fingerprint covers the resolved rule set rather than the config text, so it is invariant to how
|
|
550
|
+
a posture was spelled (preset vs. explicit rules, key order) and sensitive to anything that changes
|
|
551
|
+
it — including the safegres version, because a rule's meaning can change without its configuration
|
|
552
|
+
changing. Reports whose fingerprints differ are not comparable, and `--verify-fingerprint` exits
|
|
553
|
+
non-zero rather than let one be read as if it were.
|
|
216
554
|
|
|
217
|
-
|
|
555
|
+
None of this constrains ordinary use: an unsealed run still gets a fingerprint, and `sealed: false`
|
|
556
|
+
is simply the honest statement that local configuration participated.
|
|
218
557
|
|
|
219
|
-
|
|
220
|
-
- Authenticated vs. anonymous user access
|
|
221
|
-
- Per-user data filtering
|
|
222
|
-
- Admin privilege bypass behavior
|
|
223
|
-
- Custom claim-based restrictions (organization membership, admin status)
|
|
558
|
+
## The evaluation corpus
|
|
224
559
|
|
|
225
|
-
|
|
560
|
+
A sealed score says the ruler did not move. It does not say the ruler is right. That is what the
|
|
561
|
+
corpus in [`corpus/`](corpus/README.md) is for: small schemas, each with one deliberate flaw and a
|
|
562
|
+
written-down answer — the findings a correct audit must produce, the false positives it must not,
|
|
563
|
+
and the one-sentence fix. Many come in pairs: 18 cases pin what must *not* fire, and six of those
|
|
564
|
+
are the earlier case with its flaw fixed — an answer key consisting entirely of silence and a score
|
|
565
|
+
of 100, because a rule that survives its own fix is worse than a rule that never existed.
|
|
226
566
|
|
|
227
|
-
|
|
567
|
+
`safegres eval` is the whole loop in one command: it deploys each case into the connected
|
|
568
|
+
database, audits it under a sealed preset, grades the report against the answer key, drops the
|
|
569
|
+
case's schemas again, and exits non-zero if any case fails.
|
|
228
570
|
|
|
229
|
-
|
|
571
|
+
```console
|
|
572
|
+
$ safegres eval --database scratch
|
|
573
|
+
PASS 01-anon-write-grant security 1.2 (F ) R1
|
|
574
|
+
PASS 17-foreign-key-without-index perf 71.2 (C ) X1
|
|
575
|
+
FAIL 18-policy-column-unindexed perf 100 (A+) missed X2
|
|
230
576
|
|
|
231
|
-
|
|
232
|
-
const { db, teardown } = await getConnections(getConnectionOptions, seedAdapters);
|
|
577
|
+
41/42 cases passed · recall 98% · precision 100%
|
|
233
578
|
```
|
|
234
579
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
> ✨ **Default Behavior:** If no `SeedAdapter[]` is passed, LaunchQL seeding is assumed. This makes `pgsql-test` zero-config for LaunchQL-based projects.
|
|
245
|
-
|
|
246
|
-
This composable system allows you to mix-and-match data setup strategies for flexible, realistic, and fast database tests.
|
|
247
|
-
|
|
248
|
-
### 🔌 SQL File Seeding
|
|
580
|
+
| Flag | |
|
|
581
|
+
| --- | --- |
|
|
582
|
+
| `--preset <name>` | the preset every case is graded under (default `recommended`) |
|
|
583
|
+
| `--corpus <dir>` | your own corpus of `<id>/{case.json,schema.sql}` |
|
|
584
|
+
| `--case <id>` | run one case, or an id prefix — comma-separated |
|
|
585
|
+
| `--list` | print the corpus without touching a database |
|
|
586
|
+
| `--json` | the `EvalReport`: per-case recall, precision, score, fingerprint |
|
|
587
|
+
| `--keep` | leave the case schemas behind, to poke at one by hand |
|
|
249
588
|
|
|
250
|
-
|
|
589
|
+
A config file may set `eval.corpus`, `eval.preset` and `eval.cases` — *what* to run. It cannot
|
|
590
|
+
retune the rules for a run: cases are always graded by the named preset alone, or the corpus would
|
|
591
|
+
be grading itself. The same loop is a library call (`runEval`), and its pieces are public:
|
|
251
592
|
|
|
252
593
|
```ts
|
|
253
|
-
import
|
|
254
|
-
import { getConnections, seed } from 'pgsql-test';
|
|
594
|
+
import { audit, gradeCase, loadConfig, loadCorpus } from 'safegres';
|
|
255
595
|
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
({ db, teardown } = await getConnections({}, [
|
|
263
|
-
seed.sqlfile([
|
|
264
|
-
sql('schema.sql'),
|
|
265
|
-
sql('fixtures.sql')
|
|
266
|
-
])
|
|
267
|
-
]));
|
|
268
|
-
});
|
|
596
|
+
const { config } = loadConfig({ sealed: true, preset: 'recommended' });
|
|
597
|
+
for (const c of loadCorpus()) {
|
|
598
|
+
await client.query(c.sql);
|
|
599
|
+
const { missed, falsePositives } = gradeCase(await audit(client, { config, ...c }), c);
|
|
600
|
+
}
|
|
601
|
+
```
|
|
269
602
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
603
|
+
Cases are data — `schema.sql` plus a `case.json` answer key — so a harness that never runs safegres
|
|
604
|
+
can still use them. Three uses: safegres's own regression suite, worked examples short enough to
|
|
605
|
+
read, and an agent evaluation — hand the agent a case, ask for a fix, and require the expected
|
|
606
|
+
findings to be *gone* with the dimension back at 100 rather than merely a better number.
|
|
607
|
+
|
|
608
|
+
## Configuration
|
|
609
|
+
|
|
610
|
+
Configured like a linter. Discovered by walking up from the current directory:
|
|
611
|
+
`safegres.config.{ts,js,mjs,cjs}`, `.safegresrc{,.json,.yaml,.yml,.js}`, `safegres.json`, or a
|
|
612
|
+
`"safegres"` key in `package.json` (via
|
|
613
|
+
[confstash](https://github.com/constructive-io/dev-utils/tree/main/packages/confstash)).
|
|
614
|
+
Precedence: **CLI > project config > preset > built-in defaults**.
|
|
615
|
+
|
|
616
|
+
```jsonc
|
|
617
|
+
// .safegresrc.json
|
|
618
|
+
{
|
|
619
|
+
"extends": "safegres:recommended",
|
|
620
|
+
"exposure": { "schemas": ["app_public"], "roles": ["anonymous", "authenticated"] },
|
|
621
|
+
"public": { "read": ["app_public.plans*", "app_public.event_types"] },
|
|
622
|
+
"extensions": { "ignore": ["pg_partman"] },
|
|
623
|
+
"rules": {
|
|
624
|
+
"A3": "info", // demote — still reported, contributes nothing
|
|
625
|
+
"A5": "high", // retune a severity
|
|
626
|
+
"A7": "off", // disable outright
|
|
627
|
+
"P*": "medium" // prefix wildcard (exact codes win)
|
|
628
|
+
},
|
|
629
|
+
"overrides": [
|
|
630
|
+
{ "tables": ["app_public.audit_*"], "rules": { "A2": "off" } }
|
|
631
|
+
],
|
|
632
|
+
"perf": { "enabled": true, "ignore": ["app_public.audit_*"], "rules": { "X6": "off" } },
|
|
633
|
+
"scoring": { "densityK": 0.17, "maxRuleDensity": 0.5, "unknownExposureCap": 80 },
|
|
634
|
+
"failOn": { "grade": "B", "perfGrade": "B" }
|
|
635
|
+
}
|
|
273
636
|
```
|
|
274
637
|
|
|
275
|
-
|
|
638
|
+
A typed `safegres.config.ts` with `defineConfig` from `confstash` works identically.
|
|
276
639
|
|
|
277
|
-
|
|
640
|
+
Presets come in three kinds, and they compose. A **stack** preset knows how your framework
|
|
641
|
+
declares exposure and what its role names mean; a **posture** preset says how harshly to read
|
|
642
|
+
what it finds; both are just partial configs, so `extends` takes an array:
|
|
278
643
|
|
|
279
|
-
```
|
|
280
|
-
|
|
644
|
+
```jsonc
|
|
645
|
+
{ "extends": ["safegres:supabase", "safegres:multi-tenant"] }
|
|
646
|
+
```
|
|
281
647
|
|
|
282
|
-
|
|
283
|
-
|
|
648
|
+
| Stack | Resolves exposure from | Treats as untrusted |
|
|
649
|
+
| --- | --- | --- |
|
|
650
|
+
| `safegres:constructive` | `routing_public.apis` → `api_schemas` → `metaschema_public.schema` | `anonymous` |
|
|
651
|
+
| `safegres:postgrest` | `pgrst.db_schemas` in `pg_db_role_setting` | `pgrst.db_anon_role` |
|
|
652
|
+
| `safegres:supabase` | the GUCs if self-hosted, else Supabase's fixed surface | `anon`, `authenticated` |
|
|
653
|
+
| `safegres:hasura` | tracked tables in `hdb_catalog` | `anonymous`, `public` |
|
|
654
|
+
| `safegres:graphile` | the `graphile-starter` layout (`app_public` + `app_hidden`) | `<app>_visitor` |
|
|
655
|
+
|
|
656
|
+
Each reads a real catalog signal — not a schema name. Two carry a caveat worth knowing:
|
|
657
|
+
|
|
658
|
+
- **`graphile`** — PostGraphile's schema list is a process argument that leaves no trace in the
|
|
659
|
+
database, so this preset resolves the starter layout by convention. Naming it *is* the
|
|
660
|
+
declaration that the convention holds; when it doesn't, `exposure.schemas` still wins.
|
|
661
|
+
- **`supabase`** — Supabase configures PostgREST outside the database, so the GUCs are usually
|
|
662
|
+
absent. Its adapter falls back to the platform's fixed surface, but only after proving it is
|
|
663
|
+
looking at Supabase (`auth.users` plus the `anon`/`authenticated`/`service_role` trio). The
|
|
664
|
+
fallback lives in that adapter alone: plain `postgrest` never guesses, and an unconfigured
|
|
665
|
+
PostgREST resolves nothing — reported as unknown exposure, not as a surface.
|
|
666
|
+
|
|
667
|
+
Untrusted roles are usually *resolved*, not named. `pgrst.db_anon_role` and graphile's
|
|
668
|
+
`<app>_visitor` are per-deployment, so those presets point the rules at the surface instead of
|
|
669
|
+
guessing a name:
|
|
670
|
+
|
|
671
|
+
```jsonc
|
|
672
|
+
{ "rules": { "R1": ["critical", { "rolesFrom": "anon" }] } }
|
|
673
|
+
```
|
|
284
674
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
675
|
+
An adapter resolves two role sets, because "at the API edge" and "reachable without credentials"
|
|
676
|
+
are different questions:
|
|
677
|
+
|
|
678
|
+
| `rolesFrom` | Roles | Use when |
|
|
679
|
+
| --- | --- | --- |
|
|
680
|
+
| `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. |
|
|
681
|
+
| `exposure` | Every role at the edge, signed-in ones included | A surface where no role should be writing directly. |
|
|
682
|
+
|
|
683
|
+
Both union with any explicit `roles`, so a preset can name a platform default *and* pick up a
|
|
684
|
+
custom one. Rules that ask for neither stay inert — resolved roles never leak into a rule that
|
|
685
|
+
didn't opt in. `report.exposure.anonRoles` carries the anonymous subset, and the pretty renderer
|
|
686
|
+
marks it inline (`api roles: authenticated, anonymous (anon)`).
|
|
687
|
+
|
|
688
|
+
| Posture | Behavior |
|
|
689
|
+
| --- | --- |
|
|
690
|
+
| `safegres:recommended` | Every rule at its default severity (the no-config behavior) |
|
|
691
|
+
| `safegres:strict` | Everything escalated; fail-closed counts 25%; `failOn: high` |
|
|
692
|
+
| `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 |
|
|
693
|
+
| `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` |
|
|
694
|
+
| `safegres:minimal` | Structural flags only (A1–A3) — fast smoke check |
|
|
695
|
+
|
|
696
|
+
Presets **retune**, they don't delete: a rule that doesn't apply to a stack is demoted to `info`
|
|
697
|
+
(zero weight, so the score is unchanged) rather than switched off, so it stays in the report and
|
|
698
|
+
stays re-tunable. `minimal` is the deliberate exception — being a smoke check is its whole job.
|
|
699
|
+
|
|
700
|
+
`extends` also takes a **path**, which is how a repository with more than one audit job keeps one
|
|
701
|
+
copy of its rules. The gated PR job and the nightly advisory run against a deployed database
|
|
702
|
+
differ by their gates and their baseline, not by their 19-entry `public.read` list:
|
|
703
|
+
|
|
704
|
+
```jsonc
|
|
705
|
+
// ci/nightly/.safegresrc.json
|
|
706
|
+
{ "extends": "../../safegres.base.json",
|
|
707
|
+
"perf": { "baseline": "ci/nightly/perf.json" },
|
|
708
|
+
"failOn": { "grade": "D" } }
|
|
294
709
|
```
|
|
295
710
|
|
|
296
|
-
|
|
711
|
+
### Your own preset
|
|
297
712
|
|
|
298
|
-
|
|
713
|
+
A preset is a partial config file, so a team's own preset is just a file it `extends`. That is
|
|
714
|
+
a path inside one repo, or an npm package when several repos share it. A bare name that is not
|
|
715
|
+
a built-in is resolved as an installed package (its `main`, JSON or JS):
|
|
299
716
|
|
|
300
|
-
```
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
beforeAll(async () => {
|
|
310
|
-
({ db, teardown } = await getConnections({}, [
|
|
311
|
-
// Create schema
|
|
312
|
-
seed.fn(async ({ pg }) => {
|
|
313
|
-
await pg.query(`
|
|
314
|
-
CREATE TABLE users (
|
|
315
|
-
id SERIAL PRIMARY KEY,
|
|
316
|
-
name TEXT NOT NULL
|
|
317
|
-
);
|
|
318
|
-
|
|
319
|
-
CREATE TABLE posts (
|
|
320
|
-
id SERIAL PRIMARY KEY,
|
|
321
|
-
user_id INT REFERENCES users(id),
|
|
322
|
-
content TEXT NOT NULL
|
|
323
|
-
);
|
|
324
|
-
`);
|
|
325
|
-
}),
|
|
326
|
-
// Load from CSV
|
|
327
|
-
seed.csv({
|
|
328
|
-
users: csv('users.csv'),
|
|
329
|
-
posts: csv('posts.csv')
|
|
330
|
-
}),
|
|
331
|
-
// Adjust SERIAL sequences to avoid conflicts
|
|
332
|
-
seed.fn(async ({ pg }) => {
|
|
333
|
-
await pg.query(`SELECT setval(pg_get_serial_sequence('users', 'id'), (SELECT MAX(id) FROM users));`);
|
|
334
|
-
await pg.query(`SELECT setval(pg_get_serial_sequence('posts', 'id'), (SELECT MAX(id) FROM posts));`);
|
|
335
|
-
})
|
|
336
|
-
]));
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
afterAll(() => teardown());
|
|
340
|
-
|
|
341
|
-
it('has loaded rows', async () => {
|
|
342
|
-
const res = await db.query('SELECT COUNT(*) FROM users');
|
|
343
|
-
expect(+res.rows[0].count).toBeGreaterThan(0);
|
|
344
|
-
});
|
|
345
|
-
```
|
|
346
|
-
|
|
347
|
-
## 🗃️ JSON Seeding
|
|
348
|
-
|
|
349
|
-
You can seed tables using in-memory JSON objects. This is useful when you want fast, inline fixtures without managing external files.
|
|
717
|
+
```jsonc
|
|
718
|
+
// @acme/safegres-preset/safegres.json (package.json: "main": "safegres.json")
|
|
719
|
+
{
|
|
720
|
+
"extends": ["safegres:postgrest", "safegres:multi-tenant"],
|
|
721
|
+
"exposure": { "anonRoles": ["web_anon"] },
|
|
722
|
+
"rules": { "A5": "high" },
|
|
723
|
+
"failOn": { "grade": "B" }
|
|
724
|
+
}
|
|
725
|
+
```
|
|
350
726
|
|
|
351
|
-
```
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
let teardown;
|
|
356
|
-
|
|
357
|
-
beforeAll(async () => {
|
|
358
|
-
({ db, teardown } = await getConnections({}, [
|
|
359
|
-
// Create schema
|
|
360
|
-
seed.fn(async ({ pg }) => {
|
|
361
|
-
await pg.query(`
|
|
362
|
-
CREATE SCHEMA custom;
|
|
363
|
-
CREATE TABLE custom.users (
|
|
364
|
-
id SERIAL PRIMARY KEY,
|
|
365
|
-
name TEXT NOT NULL
|
|
366
|
-
);
|
|
367
|
-
|
|
368
|
-
CREATE TABLE custom.posts (
|
|
369
|
-
id SERIAL PRIMARY KEY,
|
|
370
|
-
user_id INT REFERENCES custom.users(id),
|
|
371
|
-
content TEXT NOT NULL
|
|
372
|
-
);
|
|
373
|
-
`);
|
|
374
|
-
}),
|
|
375
|
-
// Seed with in-memory JSON
|
|
376
|
-
seed.json({
|
|
377
|
-
'custom.users': [
|
|
378
|
-
{ id: 1, name: 'Alice' },
|
|
379
|
-
{ id: 2, name: 'Bob' }
|
|
380
|
-
],
|
|
381
|
-
'custom.posts': [
|
|
382
|
-
{ id: 1, user_id: 1, content: 'Hello world!' },
|
|
383
|
-
{ id: 2, user_id: 2, content: 'Graphile is cool!' }
|
|
384
|
-
]
|
|
385
|
-
}),
|
|
386
|
-
// Fix SERIAL sequences
|
|
387
|
-
seed.fn(async ({ pg }) => {
|
|
388
|
-
await pg.query(`SELECT setval(pg_get_serial_sequence('custom.users', 'id'), (SELECT MAX(id) FROM custom.users));`);
|
|
389
|
-
await pg.query(`SELECT setval(pg_get_serial_sequence('custom.posts', 'id'), (SELECT MAX(id) FROM custom.posts));`);
|
|
390
|
-
})
|
|
391
|
-
]));
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
afterAll(() => teardown());
|
|
395
|
-
|
|
396
|
-
it('has loaded rows', async () => {
|
|
397
|
-
const res = await db.query('SELECT COUNT(*) FROM custom.users');
|
|
398
|
-
expect(+res.rows[0].count).toBeGreaterThan(0);
|
|
399
|
-
});
|
|
400
|
-
```
|
|
401
|
-
|
|
402
|
-
## 🏗️ Sqitch Seeding
|
|
403
|
-
|
|
404
|
-
*Note: While compatible with Sqitch syntax, LaunchQL uses its own high-performance [TypeScript-based deploy engine.](#-launchql-seeding) that we encourage using for sqitch projects*
|
|
405
|
-
|
|
406
|
-
You can seed your test database using a Sqitch project but with significantly improved performance by leveraging LaunchQL's TypeScript deployment engine:
|
|
727
|
+
```jsonc
|
|
728
|
+
// each project's .safegresrc.json
|
|
729
|
+
{ "extends": "@acme/safegres-preset", "public": { "read": ["api.plans"] } }
|
|
730
|
+
```
|
|
407
731
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
import { getConnections, seed } from 'pgsql-test';
|
|
732
|
+
The project file wins over the preset, key by key; `overrides` unions across the chain.
|
|
733
|
+
`safegres print-config --explain` prints the merged result and where each value came from.
|
|
411
734
|
|
|
412
|
-
|
|
735
|
+
A path in an inherited file resolves against **the file that wrote it**, so a base file's
|
|
736
|
+
`"baseline": "ci/perf.json"` keeps meaning the base file's `ci/`, whichever job inherits it —
|
|
737
|
+
the same rule as a discovered config, applied per key. Objects merge per key and arrays replace,
|
|
738
|
+
except `overrides`, which is a list of scoped exceptions and so unions across the chain:
|
|
739
|
+
inheriting a config can't silently drop the exceptions that came with it. `--sealed` reaches none
|
|
740
|
+
of this — it does no discovery at all, so there is no file to extend from.
|
|
413
741
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
});
|
|
419
|
-
```
|
|
742
|
+
The file is **validated on load**, against a schema derived from the same declaration the editor
|
|
743
|
+
completes against — an unknown key is an error naming the key it thinks you meant, not a silent
|
|
744
|
+
no-op, because `"failon": { "grade": "B" }` otherwise reads as a passing build rather than as a
|
|
745
|
+
typo. Point an editor at the schema for completion and inline documentation:
|
|
420
746
|
|
|
421
|
-
|
|
747
|
+
```jsonc
|
|
748
|
+
{ "$schema": "https://raw.githubusercontent.com/constructive-io/constructive/main/packages/safegres/schema/safegres.schema.json" }
|
|
749
|
+
```
|
|
422
750
|
|
|
423
|
-
|
|
751
|
+
It also ships in the package (`safegres/schema/safegres.schema.json`), and
|
|
752
|
+
`safegres print-config --schema` writes it to stdout for an offline copy.
|
|
424
753
|
|
|
425
|
-
|
|
754
|
+
CLI: `--config <path>`, `--preset <name>`, `--rule CODE=off|severity` (repeatable).
|
|
426
755
|
|
|
427
|
-
|
|
428
|
-
import { getConnections } from 'pgsql-test';
|
|
756
|
+
## Scoring
|
|
429
757
|
|
|
430
|
-
|
|
758
|
+
Each dimension is scored independently by the same function over disjoint finding sets. The
|
|
759
|
+
default **density** model normalizes by the exposed surface, so a large schema doesn't saturate
|
|
760
|
+
to 0/F:
|
|
431
761
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
});
|
|
762
|
+
```
|
|
763
|
+
score = 100 · exp(−k · riskPoints / exposedTables)
|
|
435
764
|
```
|
|
436
765
|
|
|
437
|
-
|
|
766
|
+
`riskPoints` is the severity-weighted sum (critical 25, high 10, medium 4, low 1, info 0) of
|
|
767
|
+
*exposed, fail-open, non-acknowledged* findings; `k` defaults to 0.17 (≈ one critical per ten
|
|
768
|
+
exposed tables lands at a C). Non-exposed findings score 0; fail-closed findings score 0 unless
|
|
769
|
+
`scoring.failClosedWeight` is raised; unknown exposure caps the score; any exposed critical floors
|
|
770
|
+
the grade at C (`scoring.floorOnCritical`). Grades: A+ 97 · A 90 · B 80 · C 65 · D 50 · F below.
|
|
771
|
+
The legacy flat-deduction model is `scoring.model: "weighted"`.
|
|
772
|
+
|
|
773
|
+
Two corrections keep a rule's **shape** out of the arithmetic, because a rule's fan-out is a
|
|
774
|
+
property of how it reports, not of how much risk it found.
|
|
775
|
+
|
|
776
|
+
- **Points are charged per unit of repair.** A rule that emits one finding per (relation ×
|
|
777
|
+
function) pair — L19 does, and on one real database that is 853 findings over 166 functions —
|
|
778
|
+
costs what fixing it costs: 166 revokes. The finding count is unchanged in the report; the
|
|
779
|
+
deduction reads `−664 (×853 → 166 fixes)`.
|
|
780
|
+
- **No single rule can decide the grade.** Each rule's contribution is capped at
|
|
781
|
+
`scoring.maxRuleDensity` (default 0.5) of the points that would score an F alone, so one rule
|
|
782
|
+
at its ceiling costs two grade bands and an F still takes breadth. `false` disables it.
|
|
783
|
+
|
|
784
|
+
Every report carries its own arithmetic: per-rule points, grade, and the **payoff** — how far the
|
|
785
|
+
score would move if that rule's findings went away. Gate with `--fail-on <severity>`,
|
|
786
|
+
`--fail-on-score <n>`, `--fail-on-grade <g>` and their `--fail-on-perf-*` counterparts.
|
|
787
|
+
|
|
788
|
+
### Scorecards: one report, several questions
|
|
789
|
+
|
|
790
|
+
"How secure is this database" is not one question, and a single grade answers it for at most one
|
|
791
|
+
reader. A platform team gates on what an unauthenticated caller reaches; an application team gates
|
|
792
|
+
on house style; a reviewer wants the number no preset softened. A **scorecard** is that question
|
|
793
|
+
written down — a selector over the findings, plus the weighting to grade what it selects:
|
|
794
|
+
|
|
795
|
+
```jsonc
|
|
796
|
+
{
|
|
797
|
+
"scorecards": {
|
|
798
|
+
"anon-surface": {
|
|
799
|
+
"description": "What an unauthenticated caller reaches.",
|
|
800
|
+
"select": { "roles": ["anonymous"], "direction": "fail-open", "exposure": "all" },
|
|
801
|
+
"perRuleWeights": { "L19": 10 },
|
|
802
|
+
"floorOnCritical": "C"
|
|
803
|
+
},
|
|
804
|
+
"sql-conventions": {
|
|
805
|
+
"select": { "rules": ["C*"], "exposure": "all", "denominator": "all" }
|
|
806
|
+
}
|
|
807
|
+
},
|
|
808
|
+
"failOn": { "scorecards": { "anon-surface": { "grade": "A" } } }
|
|
809
|
+
}
|
|
810
|
+
```
|
|
438
811
|
|
|
439
|
-
|
|
812
|
+
Selectors narrow on `rules` (with `C*` wildcards) and `exclude`, `dimension`, `roles`, `planes`,
|
|
813
|
+
`schemas`, `direction`, `minSeverity`, `exposure`, `acknowledged`, `severities` and `denominator`.
|
|
814
|
+
Every scoring key is available per card, and `failOn.scorecards` gates on the one that matters to
|
|
815
|
+
you rather than on somebody else's headline.
|
|
816
|
+
|
|
817
|
+
Two cards always run and cannot be redefined into something flattering:
|
|
818
|
+
|
|
819
|
+
- **`default`** — the Safegres score: the headline, exposure-scoped and preset-tuned, i.e. graded
|
|
820
|
+
the way you actually use the database. Naming it in the config cannot move it.
|
|
821
|
+
- **`raw`** — every finding at its *declared* (registry) severity, exposure ignored,
|
|
822
|
+
acknowledgements included, fail-closed findings weighted like anything else. Not flattering, and
|
|
823
|
+
not meant to be: it is the number that is comparable between two databases and that no
|
|
824
|
+
configuration talked down.
|
|
825
|
+
|
|
826
|
+
A scorecard decides what a number is *about* — never what is reported. `report.findings` is always
|
|
827
|
+
the complete set, so the JSON is the raw data and the scores are queries over it.
|
|
828
|
+
|
|
829
|
+
## Commands
|
|
830
|
+
|
|
831
|
+
```bash
|
|
832
|
+
safegres audit # audit the connected database (default command)
|
|
833
|
+
safegres lint # alias for audit, for a package.json script
|
|
834
|
+
safegres perf # audit + the performance dimension (= audit --perf)
|
|
835
|
+
safegres doctor # diagnose config, parser, connection, catalog visibility, exposure
|
|
836
|
+
safegres eval # grade the auditor against a corpus with known answers
|
|
837
|
+
safegres baseline # CI: pick the report this run's delta is measured against
|
|
838
|
+
safegres print-config # the resolved effective config (--explain for per-key provenance)
|
|
839
|
+
```
|
|
440
840
|
|
|
841
|
+
| Group | Flags |
|
|
842
|
+
| --- | --- |
|
|
843
|
+
| Connection | `--connection <url>`, `--host`, `--port`, `--user`, `--password`, `--database`, `--pgpm [dir]` |
|
|
844
|
+
| Config | `--config <path>`, `--preset <name>`, `--rule CODE=sev` |
|
|
845
|
+
| Exposure | `--exposure-schemas <csv>`, `--exposed-only` |
|
|
846
|
+
| Scope | `--schemas`, `--exclude-schemas`, `--roles`, `--exclude-roles`, `--ignore-extensions`, `--audit-extension-owned` |
|
|
847
|
+
| Performance | `--perf`, `--stats`, `--explain`, `--perf-baseline <f>`, `--write-perf-baseline <f>`, `--fail-on-new-perf` |
|
|
848
|
+
| Reporting | `--format pretty\|json\|json-pretty\|markdown\|sarif`, `--out <dir>`, `--sarif-sources <dir>`, `--summary`/`-q`, `--verbose`, `--compare <f>`, `--compare-ref <label>`, `--write-snapshot <f>` |
|
|
849
|
+
| Delta provenance | `--compare-sha <sha>`, `--compare-run-id <id>`, `--compare-run-url <url>`, `--compare-age <age>`, `--compare-skipped <why>` |
|
|
850
|
+
| Call graph | `--call-graph`, `--baseline <f>`, `--write-baseline <f>`, `--fail-on-new-boundaries` |
|
|
851
|
+
| Gating | `--fail-on <severity>`, `--fail-on-score <n>`, `--fail-on-grade <g>`, `--fail-on-perf-score`, `--fail-on-perf-grade`, `--report-only` |
|
|
852
|
+
| Misc | `--skip-ast`, `--no-color`, `--help`, `--version` |
|
|
853
|
+
|
|
854
|
+
The paths among those — `--pgpm`, the two baselines, and every `--write-*` — have config-file
|
|
855
|
+
equivalents (`source.pgpm`, `perf.baseline`, `callGraph.baseline`, `outputs.dir`/`outputs.*`), so CI can carry
|
|
856
|
+
them in version control rather than in a command line.
|
|
857
|
+
|
|
858
|
+
## Going further
|
|
859
|
+
|
|
860
|
+
- **[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
|
|
861
|
+
signals behind X1, the X7/X8/X9 arguments, extension objects.
|
|
862
|
+
- **[docs/reporting.md](https://github.com/constructive-io/constructive/blob/main/packages/safegres/docs/reporting.md)** — SARIF and code scanning, `--compare` deltas and snapshots.
|
|
863
|
+
- **[docs/advanced.md](https://github.com/constructive-io/constructive/blob/main/packages/safegres/docs/advanced.md)** — runtime statistics (`--stats`), planner proof
|
|
864
|
+
(`--explain`), library use, pgpm workspaces.
|
|
865
|
+
- **[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
|
|
866
|
+
its baseline.
|
|
867
|
+
|
|
868
|
+
## Library use
|
|
441
869
|
|
|
442
870
|
```ts
|
|
443
|
-
import
|
|
444
|
-
import {
|
|
871
|
+
import { Client } from 'pg';
|
|
872
|
+
import { getPgEnvOptions } from 'pg-env';
|
|
873
|
+
import { audit, renderPretty } from 'safegres';
|
|
445
874
|
|
|
446
|
-
const
|
|
875
|
+
const client = new Client(getPgEnvOptions());
|
|
876
|
+
await client.connect();
|
|
447
877
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
]));
|
|
452
|
-
});
|
|
878
|
+
const report = await audit(client, { perf: true });
|
|
879
|
+
console.log(renderPretty(report));
|
|
880
|
+
console.log(report.score.grade, report.perf?.score.grade);
|
|
453
881
|
```
|
|
454
882
|
|
|
455
|
-
##
|
|
456
|
-
|
|
457
|
-
LaunchQL provides the best of both worlds:
|
|
883
|
+
## Requirements
|
|
458
884
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
885
|
+
- **PostgreSQL 14+.** `--explain` needs **16+** (`GENERIC_PLAN`); on older servers it reports
|
|
886
|
+
`perf.explain.unavailable` and leaves findings untouched.
|
|
887
|
+
- **A connection.** No extension is required. `pg_stat_statements` is optional and only powers S4;
|
|
888
|
+
its absence is a note in the report, never an error.
|
|
889
|
+
- **Catalog visibility.** Any role can run the audit, but a role that is neither superuser nor
|
|
890
|
+
`BYPASSRLS` may not see every policy and grant — `safegres doctor` says so explicitly. For a
|
|
891
|
+
score you can gate on, connect as the owner (in CI, that is the default anyway).
|
|
892
|
+
- **`--stats` describes a workload**, so it is meaningless against a freshly provisioned CI
|
|
893
|
+
database. The `X*` and `A*`/`L*`/`R*` rules are deterministic and work fine on an empty one.
|
|
894
|
+
- safegres **reads**. It never creates, alters, or drops anything, and `--explain` plans without
|
|
895
|
+
executing.
|
|
463
896
|
|
|
464
|
-
|
|
897
|
+
---
|
|
465
898
|
|
|
466
|
-
##
|
|
899
|
+
## Education and Tutorials
|
|
467
900
|
|
|
468
|
-
|
|
901
|
+
1. 🚀 [Quickstart: Getting Up and Running](https://constructive.io/learn/quickstart)
|
|
902
|
+
Get started with modular databases in minutes. Install prerequisites and deploy your first module.
|
|
469
903
|
|
|
470
|
-
|
|
904
|
+
2. 📦 [Modular PostgreSQL Development with Database Packages](https://constructive.io/learn/modular-postgres)
|
|
905
|
+
Learn to organize PostgreSQL projects with pgpm workspaces and reusable database modules.
|
|
471
906
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
| `db.extensions` | `string[]` | `[]` | Array of PostgreSQL extensions to include in the test database |
|
|
475
|
-
| `db.cwd` | `string` | `process.cwd()` | Working directory used for LaunchQL/Sqitch projects |
|
|
476
|
-
| `db.connection.user` | `string` | `'app_user'` | User for simulating RLS via `setContext()` |
|
|
477
|
-
| `db.connection.password` | `string` | `'app_password'` | Password for RLS test user |
|
|
478
|
-
| `db.connection.role` | `string` | `'anonymous'` | Default role used during `setContext()` |
|
|
479
|
-
| `db.template` | `string` | `undefined` | Template database used for faster test DB creation |
|
|
480
|
-
| `db.rootDb` | `string` | `'postgres'` | Root database used for administrative operations (e.g., creating databases) |
|
|
481
|
-
| `db.prefix` | `string` | `'db-'` | Prefix used when generating test database names |
|
|
907
|
+
3. ✏️ [Authoring Database Changes](https://constructive.io/learn/authoring-database-changes)
|
|
908
|
+
Master the workflow for adding, organizing, and managing database changes with pgpm.
|
|
482
909
|
|
|
483
|
-
|
|
910
|
+
4. 🧪 [End-to-End PostgreSQL Testing with TypeScript](https://constructive.io/learn/e2e-postgres-testing)
|
|
911
|
+
Master end-to-end PostgreSQL testing with ephemeral databases, RLS testing, and CI/CD automation.
|
|
484
912
|
|
|
485
|
-
|
|
913
|
+
5. ⚡ [Supabase Testing](https://constructive.io/learn/supabase)
|
|
914
|
+
Use TypeScript-first tools to test Supabase projects with realistic RLS, policies, and auth contexts.
|
|
486
915
|
|
|
487
|
-
|
|
916
|
+
6. 💧 [Drizzle ORM Testing](https://constructive.io/learn/drizzle-testing)
|
|
917
|
+
Run full-stack tests with Drizzle ORM, including database setup, teardown, and RLS enforcement.
|
|
488
918
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
| `pg.user` | `string` | `'postgres'` | Superuser for PostgreSQL |
|
|
492
|
-
| `pg.password` | `string` | `'password'` | Password for the PostgreSQL superuser |
|
|
493
|
-
| `pg.host` | `string` | `'localhost'` | Hostname for PostgreSQL |
|
|
494
|
-
| `pg.port` | `number` | `5423` | Port for PostgreSQL |
|
|
495
|
-
| `pg.database` | `string` | `'postgres'` | Default database used when connecting initially |
|
|
919
|
+
7. 🔧 [Troubleshooting](https://constructive.io/learn/troubleshooting)
|
|
920
|
+
Common issues and solutions for pgpm, PostgreSQL, and testing.
|
|
496
921
|
|
|
497
|
-
|
|
922
|
+
## Related Constructive Tooling
|
|
498
923
|
|
|
499
|
-
|
|
500
|
-
const { conn, db, teardown } = await getConnections({
|
|
501
|
-
pg: { user: 'postgres', password: 'secret' },
|
|
502
|
-
db: {
|
|
503
|
-
extensions: ['uuid-ossp'],
|
|
504
|
-
cwd: '/path/to/project',
|
|
505
|
-
connection: { user: 'test_user', password: 'secret', role: 'authenticated' },
|
|
506
|
-
template: 'test_template',
|
|
507
|
-
prefix: 'test_',
|
|
508
|
-
rootDb: 'postgres'
|
|
509
|
-
}
|
|
510
|
-
});
|
|
511
|
-
```
|
|
924
|
+
### 📦 Package Management
|
|
512
925
|
|
|
513
|
-
|
|
926
|
+
* [pgpm](https://github.com/constructive-io/constructive/tree/main/pgpm/pgpm): **🖥️ PostgreSQL Package Manager** for modular Postgres development. Works with database workspaces, scaffolding, migrations, seeding, and installing database packages.
|
|
514
927
|
|
|
515
928
|
### 🧪 Testing
|
|
516
929
|
|
|
517
|
-
* [
|
|
518
|
-
* [
|
|
519
|
-
* [
|
|
930
|
+
* [pgsql-test](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-test): **📊 Isolated testing environments** with per-test transaction rollbacks—ideal for integration tests, complex migrations, and RLS simulation.
|
|
931
|
+
* [pglite-test](https://github.com/constructive-io/constructive/tree/main/postgres/pglite-test): **🪶 Drop-in pgsql-test replacement backed by PGlite** — in-process Postgres, no server required, instance-per-suite isolation.
|
|
932
|
+
* [pgsql-seed](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-seed): **🌱 PostgreSQL seeding utilities** for CSV, JSON, SQL data loading, and pgpm deployment.
|
|
933
|
+
* [supabase-test](https://github.com/constructive-io/constructive/tree/main/postgres/supabase-test): **🧪 Supabase-native test harness** preconfigured for the local Supabase stack—per-test rollbacks, JWT/role context helpers, and CI/GitHub Actions ready.
|
|
934
|
+
* [graphile-test](https://github.com/constructive-io/constructive/tree/main/graphile/graphile-test): **🔐 Authentication mocking** for Graphile-focused test helpers and emulating row-level security contexts.
|
|
935
|
+
* [pg-query-context](https://github.com/constructive-io/constructive/tree/main/postgres/pg-query-context): **🔒 Session context injection** to add session-local context (e.g., `SET LOCAL`) into queries—ideal for setting `role`, `jwt.claims`, and other session settings.
|
|
520
936
|
|
|
521
937
|
### 🧠 Parsing & AST
|
|
522
938
|
|
|
523
|
-
* [
|
|
524
|
-
* [
|
|
525
|
-
* [
|
|
526
|
-
* [@pgsql/enums](https://
|
|
527
|
-
* [@pgsql/types](https://
|
|
528
|
-
* [@pgsql/utils](https://
|
|
529
|
-
|
|
939
|
+
* [pgsql-parser](https://www.npmjs.com/package/pgsql-parser): **🔄 SQL conversion engine** that interprets and converts PostgreSQL syntax.
|
|
940
|
+
* [libpg-query-node](https://www.npmjs.com/package/libpg-query): **🌉 Node.js bindings** for `libpg_query`, converting SQL into parse trees.
|
|
941
|
+
* [pg-proto-parser](https://www.npmjs.com/package/pg-proto-parser): **📦 Protobuf parser** for parsing PostgreSQL Protocol Buffers definitions to generate TypeScript interfaces, utility functions, and JSON mappings for enums.
|
|
942
|
+
* [@pgsql/enums](https://www.npmjs.com/package/@pgsql/enums): **🏷️ TypeScript enums** for PostgreSQL AST for safe and ergonomic parsing logic.
|
|
943
|
+
* [@pgsql/types](https://www.npmjs.com/package/@pgsql/types): **📝 Type definitions** for PostgreSQL AST nodes in TypeScript.
|
|
944
|
+
* [@pgsql/utils](https://www.npmjs.com/package/@pgsql/utils): **🛠️ AST utilities** for constructing and transforming PostgreSQL syntax trees.
|
|
945
|
+
|
|
946
|
+
### 📚 Documentation & Skills
|
|
530
947
|
|
|
531
|
-
|
|
948
|
+
* [constructive-skills](https://github.com/constructive-io/constructive-skills): **📖 Platform documentation and AI agent skills** — feature catalog, blueprint reference, SDK guides (i18n, billing, limits, events, uploads, security, entities, search, AI), and deployment guides.
|
|
532
949
|
|
|
533
|
-
|
|
534
|
-
* [launchql/explorer](https://github.com/launchql/launchql/tree/main/packages/explorer): **🔎 Visual API explorer** with GraphiQL for browsing across all databases and schemas—useful for debugging, documentation, and API prototyping.
|
|
950
|
+
Install skills for AI coding agents:
|
|
535
951
|
|
|
536
|
-
|
|
952
|
+
```bash
|
|
953
|
+
# All platform skills (security, blueprints, codegen, billing, etc.)
|
|
954
|
+
npx skills add constructive-io/constructive-skills
|
|
537
955
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
* [launchql/upload-names](https://github.com/launchql/launchql/tree/main/packages/upload-names): **📂 Collision-resistant filenames** utility for structured and unique file names for uploads.
|
|
956
|
+
# Individual repo skills (pgpm, safegres, testing, CLI, search, etc.)
|
|
957
|
+
npx skills add constructive-io/constructive --skill pgpm
|
|
958
|
+
npx skills add constructive-io/constructive --skill safegres
|
|
959
|
+
npx skills add constructive-io/constructive --skill constructive-testing
|
|
960
|
+
```
|
|
544
961
|
|
|
545
|
-
|
|
962
|
+
## Credits
|
|
546
963
|
|
|
547
|
-
|
|
548
|
-
* [launchql/launchql-gen](https://github.com/launchql/launchql/tree/main/packages/launchql-gen): **✨ Auto-generated GraphQL** mutations and queries dynamically built from introspected schema data.
|
|
549
|
-
* [@launchql/query-builder](https://github.com/launchql/launchql/tree/main/packages/query-builder): **🏗️ SQL constructor** providing a robust TypeScript-based query builder for dynamic generation of `SELECT`, `INSERT`, `UPDATE`, `DELETE`, and stored procedure calls—supports advanced SQL features like `JOIN`, `GROUP BY`, and schema-qualified queries.
|
|
550
|
-
* [@launchql/query](https://github.com/launchql/launchql/tree/main/packages/query): **🧩 Fluent GraphQL builder** for PostGraphile schemas. ⚡ Schema-aware via introspection, 🧩 composable and ergonomic for building deeply nested queries.
|
|
964
|
+
**🛠 Built by the [Constructive](https://constructive.io) team — creators of modular Postgres tooling for secure, composable backends. If you like our work, contribute on [GitHub](https://github.com/constructive-io).**
|
|
551
965
|
|
|
552
966
|
## Disclaimer
|
|
553
967
|
|
|
554
968
|
AS DESCRIBED IN THE LICENSES, THE SOFTWARE IS PROVIDED "AS IS", AT YOUR OWN RISK, AND WITHOUT WARRANTIES OF ANY KIND.
|
|
555
969
|
|
|
556
970
|
No developer or entity involved in creating this software will be liable for any claims or damages whatsoever associated with your use, inability to use, or your interaction with other users of the code, including any direct, indirect, incidental, special, exemplary, punitive or consequential damages, or loss of profits, cryptocurrencies, tokens, or anything else of value.
|
|
557
|
-
|