@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
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exposure adapters: how safegres learns what a stack exposes.
|
|
3
|
+
*
|
|
4
|
+
* An adapter is a value, not a module name. Built-ins are named exports of
|
|
5
|
+
* this module, a custom one is an object you construct in your own repo, and
|
|
6
|
+
* both are passed the same way — nothing is resolved by package name, and
|
|
7
|
+
* there is no plugin loader to reason about:
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { constructiveAdapter, definePlanes } from 'safegres/adapters';
|
|
11
|
+
*
|
|
12
|
+
* export default defineConfig({
|
|
13
|
+
* exposure: { adapters: [constructiveAdapter, myAdapter], schemas: ['app_public'] }
|
|
14
|
+
* });
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* `detect()` answers "is this stack present in this database?" and `resolve()`
|
|
18
|
+
* returns the planes it exposes — one per API where the stack has more than
|
|
19
|
+
* one, because "the admin API grades A and the public API grades C" is
|
|
20
|
+
* strictly more useful than their union.
|
|
21
|
+
*/
|
|
22
|
+
import { introspectBehaviors, SYSTEM_SCHEMAS } from '../pg/behaviors';
|
|
23
|
+
import { computeApiReach } from './reach';
|
|
24
|
+
/**
|
|
25
|
+
* Constructive routing-plane introspection: an API exposes a set of schemas
|
|
26
|
+
* via `routing_public.apis` → `routing_public.api_schemas` →
|
|
27
|
+
* `metaschema_public.schema` (and the platform plane via `platform_apis` →
|
|
28
|
+
* `platform_api_schemas`). API-edge roles come from `apis.role_name` /
|
|
29
|
+
* `apis.anon_role`.
|
|
30
|
+
*
|
|
31
|
+
* Emits one `api` plane per API, plus a primary plane unioning them: the union
|
|
32
|
+
* is what the headline score has always been computed against, and the
|
|
33
|
+
* per-API planes are what let a reader see which API carries the debt.
|
|
34
|
+
*/
|
|
35
|
+
export const constructiveAdapter = {
|
|
36
|
+
name: 'constructive',
|
|
37
|
+
async detect(exec) {
|
|
38
|
+
const { rows } = await exec.query(`SELECT
|
|
39
|
+
to_regclass('routing_public.apis') IS NOT NULL
|
|
40
|
+
AND to_regclass('routing_public.api_schemas') IS NOT NULL
|
|
41
|
+
AND to_regclass('metaschema_public.schema') IS NOT NULL AS ok`);
|
|
42
|
+
return rows[0]?.ok === true;
|
|
43
|
+
},
|
|
44
|
+
async resolve(exec) {
|
|
45
|
+
const { rows: hasPlatform } = await exec.query(`SELECT
|
|
46
|
+
to_regclass('routing_public.platform_apis') IS NOT NULL
|
|
47
|
+
AND to_regclass('routing_public.platform_api_schemas') IS NOT NULL AS ok`);
|
|
48
|
+
const sources = [
|
|
49
|
+
`SELECT a.name::text AS api, s.schema_name, a.role_name, a.anon_role
|
|
50
|
+
FROM routing_public.apis a
|
|
51
|
+
JOIN routing_public.api_schemas aps ON aps.api_id = a.id
|
|
52
|
+
JOIN metaschema_public.schema s ON s.id = aps.schema_id`
|
|
53
|
+
];
|
|
54
|
+
if (hasPlatform[0]?.ok) {
|
|
55
|
+
sources.push(`SELECT a.name::text AS api, s.schema_name, a.role_name, a.anon_role
|
|
56
|
+
FROM routing_public.platform_apis a
|
|
57
|
+
JOIN routing_public.platform_api_schemas aps ON aps.api_id = a.id
|
|
58
|
+
JOIN metaschema_public.schema s ON s.id = aps.schema_id`);
|
|
59
|
+
}
|
|
60
|
+
const { rows } = await exec.query(sources.join(' UNION ALL '));
|
|
61
|
+
const entryOf = () => ({
|
|
62
|
+
schemas: new Set(),
|
|
63
|
+
roles: new Set(),
|
|
64
|
+
anonRoles: new Set()
|
|
65
|
+
});
|
|
66
|
+
const byApi = new Map();
|
|
67
|
+
const all = entryOf();
|
|
68
|
+
for (const r of rows) {
|
|
69
|
+
if (!r.schema_name)
|
|
70
|
+
continue;
|
|
71
|
+
const key = r.api ?? 'api';
|
|
72
|
+
const entry = byApi.get(key) ?? entryOf();
|
|
73
|
+
entry.schemas.add(r.schema_name);
|
|
74
|
+
all.schemas.add(r.schema_name);
|
|
75
|
+
for (const role of [r.role_name, r.anon_role]) {
|
|
76
|
+
if (role) {
|
|
77
|
+
entry.roles.add(role);
|
|
78
|
+
all.roles.add(role);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// `role_name` is the signed-in role (`authenticated`); only `anon_role`
|
|
82
|
+
// is reachable without credentials.
|
|
83
|
+
if (r.anon_role) {
|
|
84
|
+
entry.anonRoles.add(r.anon_role);
|
|
85
|
+
all.anonRoles.add(r.anon_role);
|
|
86
|
+
}
|
|
87
|
+
byApi.set(key, entry);
|
|
88
|
+
}
|
|
89
|
+
if (all.schemas.size === 0)
|
|
90
|
+
return [];
|
|
91
|
+
const planes = [
|
|
92
|
+
{
|
|
93
|
+
name: 'api',
|
|
94
|
+
kind: 'api',
|
|
95
|
+
primary: true,
|
|
96
|
+
schemas: sorted(all.schemas),
|
|
97
|
+
roles: sorted(all.roles),
|
|
98
|
+
anonRoles: sorted(all.anonRoles)
|
|
99
|
+
}
|
|
100
|
+
];
|
|
101
|
+
// One plane per API, but only where there is more than one to tell apart:
|
|
102
|
+
// on a single-API database they would restate the primary plane.
|
|
103
|
+
if (byApi.size > 1) {
|
|
104
|
+
for (const [api, entry] of [...byApi.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
105
|
+
planes.push({
|
|
106
|
+
name: `api:${api}`,
|
|
107
|
+
kind: 'api',
|
|
108
|
+
schemas: sorted(entry.schemas),
|
|
109
|
+
roles: sorted(entry.roles),
|
|
110
|
+
anonRoles: sorted(entry.anonRoles)
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return planes;
|
|
115
|
+
},
|
|
116
|
+
// A Constructive API *is* a PostGraphile API, so the reach question has the
|
|
117
|
+
// same answer. Delegated rather than duplicated, and defined below the
|
|
118
|
+
// delegate — see `postgraphileAdapter`.
|
|
119
|
+
reach: (exec, context) => postgraphileAdapter.reach(exec, context)
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* PostGraphile behaviors as relation-level reach.
|
|
123
|
+
*
|
|
124
|
+
* Deliberately *not* folded into {@link constructiveAdapter}, even though
|
|
125
|
+
* Constructive is a PostGraphile stack: behaviors are a Graphile convention
|
|
126
|
+
* that any Graphile database follows, and each adapter should say one true
|
|
127
|
+
* thing. This one contributes no planes — it has no idea which schemas an API
|
|
128
|
+
* serves — and only narrows the planes another adapter, or the config, already
|
|
129
|
+
* established.
|
|
130
|
+
*/
|
|
131
|
+
export const postgraphileAdapter = {
|
|
132
|
+
name: 'postgraphile',
|
|
133
|
+
async detect(exec) {
|
|
134
|
+
const { rows } = await exec.query(`SELECT EXISTS (
|
|
135
|
+
SELECT 1 FROM pg_description
|
|
136
|
+
WHERE description ~ '(^|\n)@(behavior|forwardBehavior|backwardBehavior)\\s'
|
|
137
|
+
) AS ok`);
|
|
138
|
+
return rows[0]?.ok === true;
|
|
139
|
+
},
|
|
140
|
+
async resolve() {
|
|
141
|
+
return [];
|
|
142
|
+
},
|
|
143
|
+
async reach(exec, context) {
|
|
144
|
+
const behaviors = await introspectBehaviors(exec, {
|
|
145
|
+
schemas: context.schemas,
|
|
146
|
+
excludeSchemas: context.excludeSchemas
|
|
147
|
+
});
|
|
148
|
+
// `schemas` empty means "the whole database", so the filter is built
|
|
149
|
+
// rather than passed as an always-bound parameter: an unreferenced
|
|
150
|
+
// placeholder has no inferable type and Postgres rejects the statement.
|
|
151
|
+
const params = [[...SYSTEM_SCHEMAS]];
|
|
152
|
+
const filters = [`n.nspname <> ALL ($1::text[])`];
|
|
153
|
+
if (context.schemas.length > 0) {
|
|
154
|
+
params.push(context.schemas);
|
|
155
|
+
filters.push(`n.nspname = ANY ($${params.length}::text[])`);
|
|
156
|
+
}
|
|
157
|
+
if (context.excludeSchemas && context.excludeSchemas.length > 0) {
|
|
158
|
+
params.push(context.excludeSchemas);
|
|
159
|
+
filters.push(`n.nspname <> ALL ($${params.length}::text[])`);
|
|
160
|
+
}
|
|
161
|
+
const { rows } = await exec.query(`SELECT n.nspname || '.' || c.relname AS relation,
|
|
162
|
+
co.conname AS constraint_name,
|
|
163
|
+
CASE WHEN co.oid IS NULL THEN NULL
|
|
164
|
+
ELSE fn.nspname || '.' || fc.relname END AS references
|
|
165
|
+
FROM pg_class c
|
|
166
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
167
|
+
LEFT JOIN pg_constraint co ON co.conrelid = c.oid AND co.contype = 'f'
|
|
168
|
+
LEFT JOIN pg_class fc ON fc.oid = co.confrelid
|
|
169
|
+
LEFT JOIN pg_namespace fn ON fn.oid = fc.relnamespace
|
|
170
|
+
WHERE c.relkind IN ('r', 'p')
|
|
171
|
+
AND ${filters.join('\n AND ')}`, params);
|
|
172
|
+
const relations = new Set();
|
|
173
|
+
const edges = [];
|
|
174
|
+
for (const row of rows) {
|
|
175
|
+
relations.add(row.relation);
|
|
176
|
+
if (row.constraint_name && row.references) {
|
|
177
|
+
edges.push({ from: row.relation, to: row.references, constraint: row.constraint_name });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return computeApiReach({ relations: [...relations].sort(), edges, behaviors });
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* PostgREST exposure, read from the catalog rather than inferred from a
|
|
185
|
+
* schema name: PostgREST serves exactly `pgrst.db_schemas`, which is set as a
|
|
186
|
+
* role or database GUC (`ALTER ROLE authenticator SET pgrst.db_schemas = …`)
|
|
187
|
+
* and therefore lands in `pg_db_role_setting`. `pgrst.db_anon_role` names the
|
|
188
|
+
* role an unauthenticated request runs as, and the connecting role
|
|
189
|
+
* (`authenticator` by convention) is the one that can `SET ROLE` to it.
|
|
190
|
+
*
|
|
191
|
+
* Supabase is PostgREST underneath, so this covers it too; the difference is
|
|
192
|
+
* the role vocabulary, which the `supabase` preset supplies.
|
|
193
|
+
*/
|
|
194
|
+
export const postgrestAdapter = {
|
|
195
|
+
name: 'postgrest',
|
|
196
|
+
async detect(exec) {
|
|
197
|
+
return (await postgrestSettings(exec)).size > 0;
|
|
198
|
+
},
|
|
199
|
+
async resolve(exec) {
|
|
200
|
+
const settings = await postgrestSettings(exec);
|
|
201
|
+
const schemas = splitList(settings.get('pgrst.db_schemas'));
|
|
202
|
+
if (schemas.length === 0)
|
|
203
|
+
return [];
|
|
204
|
+
const anon = settings.get('pgrst.db_anon_role');
|
|
205
|
+
const { rows } = await exec.query(`SELECT r.rolname::text
|
|
206
|
+
FROM pg_db_role_setting s
|
|
207
|
+
JOIN pg_roles r ON r.oid = s.setrole
|
|
208
|
+
WHERE array_to_string(s.setconfig, ',') LIKE '%pgrst.%'`);
|
|
209
|
+
// The authenticator is the connecting role, not an API-edge role: it holds
|
|
210
|
+
// no data privileges of its own, so grading it would grade nothing.
|
|
211
|
+
const authenticators = rows.map((r) => r.rolname);
|
|
212
|
+
return [
|
|
213
|
+
{
|
|
214
|
+
name: 'api',
|
|
215
|
+
kind: 'api',
|
|
216
|
+
primary: true,
|
|
217
|
+
schemas,
|
|
218
|
+
roles: anon ? [anon] : [],
|
|
219
|
+
// db_anon_role is by definition the unauthenticated one.
|
|
220
|
+
anonRoles: anon ? [anon] : []
|
|
221
|
+
},
|
|
222
|
+
...authenticators.map((role) => ({
|
|
223
|
+
name: `direct:${role}`,
|
|
224
|
+
kind: 'role',
|
|
225
|
+
roles: [role]
|
|
226
|
+
}))
|
|
227
|
+
];
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
/**
|
|
231
|
+
* Supabase runs PostgREST, but configures it *outside* the database — the
|
|
232
|
+
* schema list is a platform setting, so `pgrst.db_schemas` is usually absent
|
|
233
|
+
* and `postgrestAdapter` correctly resolves nothing. Hence a separate
|
|
234
|
+
* adapter: it prefers the GUCs when a self-hosted stack sets them, and only
|
|
235
|
+
* falls back to the platform's fixed surface once it has *proved* it is
|
|
236
|
+
* looking at Supabase — `auth.users` plus the three roles every project has.
|
|
237
|
+
*
|
|
238
|
+
* The fallback is deliberately confined here. `postgrestAdapter` never
|
|
239
|
+
* guesses: an unconfigured PostgREST resolves no planes, which surfaces as
|
|
240
|
+
* unknown exposure (a capped score and a W1) rather than a wrong surface
|
|
241
|
+
* presented as fact.
|
|
242
|
+
*/
|
|
243
|
+
export const supabaseAdapter = {
|
|
244
|
+
name: 'supabase',
|
|
245
|
+
async detect(exec) {
|
|
246
|
+
const { rows } = await exec.query(`SELECT to_regclass('auth.users') IS NOT NULL
|
|
247
|
+
AND (SELECT count(*) FROM pg_roles
|
|
248
|
+
WHERE rolname IN ('anon', 'authenticated', 'service_role')) = 3 AS ok`);
|
|
249
|
+
return rows[0]?.ok === true;
|
|
250
|
+
},
|
|
251
|
+
async resolve(exec) {
|
|
252
|
+
const configured = await postgrestAdapter.resolve(exec);
|
|
253
|
+
if (configured.length > 0)
|
|
254
|
+
return configured;
|
|
255
|
+
// Supabase's default exposure, and only reachable once detect() has
|
|
256
|
+
// confirmed this really is a Supabase database.
|
|
257
|
+
const { rows } = await exec.query(`SELECT nspname::text FROM pg_namespace
|
|
258
|
+
WHERE nspname IN ('public', 'graphql_public', 'storage')`);
|
|
259
|
+
const schemas = rows.map((r) => r.nspname).sort();
|
|
260
|
+
if (schemas.length === 0)
|
|
261
|
+
return [];
|
|
262
|
+
return [
|
|
263
|
+
{
|
|
264
|
+
name: 'api',
|
|
265
|
+
kind: 'api',
|
|
266
|
+
primary: true,
|
|
267
|
+
schemas,
|
|
268
|
+
roles: ['anon', 'authenticated'],
|
|
269
|
+
// `authenticated` still requires a sign-up; `anon` requires nothing.
|
|
270
|
+
anonRoles: ['anon']
|
|
271
|
+
},
|
|
272
|
+
// service_role bypasses RLS by design; planes.ts reports it as skipped
|
|
273
|
+
// rather than inventing an ordinary grade for it.
|
|
274
|
+
{ name: 'direct:service_role', kind: 'role', roles: ['service_role'] }
|
|
275
|
+
];
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
/**
|
|
279
|
+
* Hasura reads its exposed surface out of its own metadata: v2+ keeps one
|
|
280
|
+
* JSON document in `hdb_catalog.hdb_metadata`, older versions a row per
|
|
281
|
+
* tracked table in `hdb_catalog.hdb_table`. Only *tracked* tables are served,
|
|
282
|
+
* so the schemas that contain them are the surface.
|
|
283
|
+
*/
|
|
284
|
+
export const hasuraAdapter = {
|
|
285
|
+
name: 'hasura',
|
|
286
|
+
async detect(exec) {
|
|
287
|
+
const { rows } = await exec.query(`SELECT to_regclass('hdb_catalog.hdb_metadata') IS NOT NULL
|
|
288
|
+
OR to_regclass('hdb_catalog.hdb_table') IS NOT NULL AS ok`);
|
|
289
|
+
return rows[0]?.ok === true;
|
|
290
|
+
},
|
|
291
|
+
async resolve(exec) {
|
|
292
|
+
const schemas = new Set();
|
|
293
|
+
const { rows: modern } = await exec.query(`SELECT to_regclass('hdb_catalog.hdb_metadata') IS NOT NULL AS ok`);
|
|
294
|
+
if (modern[0]?.ok) {
|
|
295
|
+
const { rows } = await exec.query(`SELECT DISTINCT t.value -> 'table' ->> 'schema' AS schema_name
|
|
296
|
+
FROM hdb_catalog.hdb_metadata m
|
|
297
|
+
CROSS JOIN LATERAL jsonb_array_elements(m.metadata -> 'sources') AS s(value)
|
|
298
|
+
CROSS JOIN LATERAL jsonb_array_elements(s.value -> 'tables') AS t(value)`);
|
|
299
|
+
for (const r of rows)
|
|
300
|
+
if (r.schema_name)
|
|
301
|
+
schemas.add(r.schema_name);
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
const { rows } = await exec.query('SELECT DISTINCT table_schema::text AS schema_name FROM hdb_catalog.hdb_table');
|
|
305
|
+
for (const r of rows)
|
|
306
|
+
if (r.schema_name)
|
|
307
|
+
schemas.add(r.schema_name);
|
|
308
|
+
}
|
|
309
|
+
if (schemas.size === 0)
|
|
310
|
+
return [];
|
|
311
|
+
return [{ name: 'api', kind: 'api', primary: true, schemas: sorted(schemas) }];
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
/**
|
|
315
|
+
* PostGraphile publishes whatever schemas it was started with — a CLI/library
|
|
316
|
+
* argument, invisible from inside the database. So this adapter resolves the
|
|
317
|
+
* `graphile-starter` layout (`app_public` served, `app_hidden`/`app_private`
|
|
318
|
+
* not) and nothing else.
|
|
319
|
+
*
|
|
320
|
+
* That is naming-as-intent, which safegres otherwise refuses. The difference
|
|
321
|
+
* is consent: listing this adapter *is* the declaration that the convention
|
|
322
|
+
* holds here. When it doesn't, list `exposure.schemas` instead — explicit
|
|
323
|
+
* always wins.
|
|
324
|
+
*
|
|
325
|
+
* Reach is a separate question — *which schemas are served* versus *which
|
|
326
|
+
* relations the served schemas expose* — and is delegated to
|
|
327
|
+
* {@link postgraphileAdapter}, which answers it for any Graphile stack
|
|
328
|
+
* regardless of schema layout.
|
|
329
|
+
*/
|
|
330
|
+
export const graphileAdapter = {
|
|
331
|
+
name: 'graphile',
|
|
332
|
+
async detect(exec) {
|
|
333
|
+
const { rows } = await exec.query("SELECT to_regnamespace('app_public') IS NOT NULL AS ok");
|
|
334
|
+
return rows[0]?.ok === true;
|
|
335
|
+
},
|
|
336
|
+
async resolve(exec) {
|
|
337
|
+
const { rows } = await exec.query(`SELECT nspname::text
|
|
338
|
+
FROM pg_namespace
|
|
339
|
+
WHERE nspname IN ('app_public', 'app_hidden', 'app_private')`);
|
|
340
|
+
const present = new Set(rows.map((r) => r.nspname));
|
|
341
|
+
if (!present.has('app_public'))
|
|
342
|
+
return [];
|
|
343
|
+
const visitors = await graphileVisitorRoles(exec);
|
|
344
|
+
const planes = [
|
|
345
|
+
{
|
|
346
|
+
name: 'api',
|
|
347
|
+
kind: 'api',
|
|
348
|
+
primary: true,
|
|
349
|
+
// app_hidden is reachable through app_public's views and functions —
|
|
350
|
+
// served indirectly, so it belongs to the surface.
|
|
351
|
+
schemas: ['app_public', ...(present.has('app_hidden') ? ['app_hidden'] : [])],
|
|
352
|
+
roles: visitors,
|
|
353
|
+
// PostGraphile runs *every* request as the visitor role, signed in or
|
|
354
|
+
// not — the caller is a JWT claim — so it is anonymous-reachable.
|
|
355
|
+
anonRoles: visitors
|
|
356
|
+
}
|
|
357
|
+
];
|
|
358
|
+
if (present.has('app_private')) {
|
|
359
|
+
planes.push({ name: 'internal', kind: 'schema', schemas: ['app_private'] });
|
|
360
|
+
}
|
|
361
|
+
return planes;
|
|
362
|
+
},
|
|
363
|
+
reach: (exec, context) => postgraphileAdapter.reach(exec, context)
|
|
364
|
+
};
|
|
365
|
+
/**
|
|
366
|
+
* The role PostGraphile runs requests as. graphile-starter names it
|
|
367
|
+
* `<app>_visitor` — project-specific, so it cannot be hardcoded — and reaches
|
|
368
|
+
* it by `SET ROLE` from `<app>_authenticator`. Membership is the reliable
|
|
369
|
+
* signal: the authenticator is a LOGIN role whose only purpose is to become
|
|
370
|
+
* the visitor, so its grantees *are* the request roles. The `%_visitor`
|
|
371
|
+
* suffix is the fallback for a database whose authenticator is provisioned
|
|
372
|
+
* elsewhere (a managed connection pooler, a role created outside migrations).
|
|
373
|
+
*
|
|
374
|
+
* This matters because every unauthenticated request arrives as this role:
|
|
375
|
+
* logged-in or not, PostGraphile uses the same role and distinguishes callers
|
|
376
|
+
* by JWT claims, so it is by definition untrusted.
|
|
377
|
+
*/
|
|
378
|
+
async function graphileVisitorRoles(exec) {
|
|
379
|
+
// Roles an `%_authenticator` may SET ROLE to — the request roles by
|
|
380
|
+
// construction, whatever they are named.
|
|
381
|
+
const { rows: granted } = await exec.query(`SELECT DISTINCT member.rolname::text AS rolname
|
|
382
|
+
FROM pg_auth_members m
|
|
383
|
+
JOIN pg_roles authenticator ON authenticator.oid = m.roleid
|
|
384
|
+
JOIN pg_roles member ON member.oid = m.member
|
|
385
|
+
WHERE authenticator.rolname LIKE '%\\_authenticator'`);
|
|
386
|
+
if (granted.length > 0)
|
|
387
|
+
return granted.map((r) => r.rolname).sort();
|
|
388
|
+
const { rows: bySuffix } = await exec.query(`SELECT rolname::text FROM pg_roles
|
|
389
|
+
WHERE rolname = 'visitor' OR rolname LIKE '%\\_visitor'`);
|
|
390
|
+
return bySuffix.map((r) => r.rolname).sort();
|
|
391
|
+
}
|
|
392
|
+
/** Every adapter a config may name with a string. */
|
|
393
|
+
export const BUILTIN_ADAPTERS = {
|
|
394
|
+
constructive: constructiveAdapter,
|
|
395
|
+
graphile: graphileAdapter,
|
|
396
|
+
hasura: hasuraAdapter,
|
|
397
|
+
postgraphile: postgraphileAdapter,
|
|
398
|
+
postgrest: postgrestAdapter,
|
|
399
|
+
supabase: supabaseAdapter
|
|
400
|
+
};
|
|
401
|
+
/**
|
|
402
|
+
* `pgrst.*` GUCs from `pg_db_role_setting`, whichever role or database they
|
|
403
|
+
* were set on. Reading the catalog rather than `current_setting()` is what
|
|
404
|
+
* makes this work from an audit connection that is not the API's.
|
|
405
|
+
*/
|
|
406
|
+
async function postgrestSettings(exec) {
|
|
407
|
+
const { rows } = await exec.query(`SELECT unnest(setconfig) AS setting FROM pg_db_role_setting`);
|
|
408
|
+
const settings = new Map();
|
|
409
|
+
for (const { setting } of rows) {
|
|
410
|
+
const eq = setting.indexOf('=');
|
|
411
|
+
if (eq < 0)
|
|
412
|
+
continue;
|
|
413
|
+
const key = setting.slice(0, eq).trim().toLowerCase();
|
|
414
|
+
if (key.startsWith('pgrst.'))
|
|
415
|
+
settings.set(key, setting.slice(eq + 1).trim());
|
|
416
|
+
}
|
|
417
|
+
return settings;
|
|
418
|
+
}
|
|
419
|
+
function splitList(value) {
|
|
420
|
+
if (!value)
|
|
421
|
+
return [];
|
|
422
|
+
return [
|
|
423
|
+
...new Set(value
|
|
424
|
+
.split(',')
|
|
425
|
+
.map((s) => s.trim().replace(/^"|"$/g, ''))
|
|
426
|
+
.filter(Boolean))
|
|
427
|
+
].sort();
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Wrap a fixed set of planes as an adapter — the escape hatch for a surface
|
|
431
|
+
* you know but cannot introspect (a schema list your deployment tooling
|
|
432
|
+
* knows, an API gateway's config read at build time).
|
|
433
|
+
*/
|
|
434
|
+
export function definePlanes(name, planes) {
|
|
435
|
+
return {
|
|
436
|
+
name,
|
|
437
|
+
detect: async () => planes.length > 0,
|
|
438
|
+
resolve: async () => planes
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Coerce whatever a config carried in `exposure.adapters` into adapters.
|
|
443
|
+
* Strings resolve against the built-in table only — an unknown name is an
|
|
444
|
+
* error rather than a silent no-op, because a typo'd adapter name would
|
|
445
|
+
* otherwise present as an unexposed database.
|
|
446
|
+
*/
|
|
447
|
+
export function resolveAdapters(entries) {
|
|
448
|
+
if (!entries || entries.length === 0)
|
|
449
|
+
return [];
|
|
450
|
+
return entries.map((entry) => {
|
|
451
|
+
if (typeof entry !== 'string')
|
|
452
|
+
return entry;
|
|
453
|
+
const builtin = BUILTIN_ADAPTERS[entry];
|
|
454
|
+
if (!builtin) {
|
|
455
|
+
throw new Error(`unknown exposure adapter "${entry}" — built-ins: ${Object.keys(BUILTIN_ADAPTERS).join(', ')}`
|
|
456
|
+
+ '; a custom adapter is passed as an object, not by name');
|
|
457
|
+
}
|
|
458
|
+
return builtin;
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
function sorted(values) {
|
|
462
|
+
return [...values].sort();
|
|
463
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plane reach and per-plane scoring.
|
|
3
|
+
*
|
|
4
|
+
* A plane is an access path: a set of schemas (`api`, `schema`) or a set of
|
|
5
|
+
* roles (`role`). Reach is the set of relations a plane can touch — for a
|
|
6
|
+
* schema plane that is membership, for a role plane it is the effective-grant
|
|
7
|
+
* closure the lattice rules already compute (direct grants, grants TO PUBLIC,
|
|
8
|
+
* and role inheritance). Every plane is then scored by the *same* function on
|
|
9
|
+
* the *same* findings; only the finding set and the density denominator
|
|
10
|
+
* differ.
|
|
11
|
+
*/
|
|
12
|
+
import type { RoleGraph } from '../checks/lattice';
|
|
13
|
+
import type { ScoringConfig } from '../config/types';
|
|
14
|
+
import type { ResolvedPlane } from '../pg/exposure';
|
|
15
|
+
import type { TableSnapshot } from '../pg/introspect';
|
|
16
|
+
import type { Finding, PlaneReport } from '../types';
|
|
17
|
+
import type { ApiReach, UnreachableRelation } from './reach';
|
|
18
|
+
/** A plane with its reach resolved against the catalog. */
|
|
19
|
+
export interface PlaneReach {
|
|
20
|
+
plane: ResolvedPlane;
|
|
21
|
+
/** `schema.table` keys the plane can touch. */
|
|
22
|
+
relations: Set<string>;
|
|
23
|
+
/** Schemas the plane touches (computed for role planes, declared otherwise). */
|
|
24
|
+
schemas: string[];
|
|
25
|
+
/** Most direct provenance across the plane's relations (role planes). */
|
|
26
|
+
reachedVia?: 'grant' | 'PUBLIC' | 'inheritance';
|
|
27
|
+
/** Why a declared plane was not graded. */
|
|
28
|
+
skipped?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Relations in the plane's schemas that its API cannot address, subtracted
|
|
31
|
+
* from `relations`. Reported so the subtraction is auditable rather than
|
|
32
|
+
* invisible.
|
|
33
|
+
*/
|
|
34
|
+
unaddressable?: UnreachableRelation[];
|
|
35
|
+
}
|
|
36
|
+
export declare function relationKey(schema: string, table: string): string;
|
|
37
|
+
/**
|
|
38
|
+
* Resolve what each plane can reach.
|
|
39
|
+
*
|
|
40
|
+
* A `role` plane for a superuser or `BYPASSRLS` role is refused rather than
|
|
41
|
+
* graded: RLS does not apply to it, so it reaches everything and grades 0 by
|
|
42
|
+
* construction — a number that teaches nothing and would read as a finding.
|
|
43
|
+
*/
|
|
44
|
+
export declare function resolvePlaneReach(planes: ResolvedPlane[], tables: TableSnapshot[], graph: RoleGraph, apiReach?: ApiReach): PlaneReach[];
|
|
45
|
+
/**
|
|
46
|
+
* True when a finding is reachable on a plane. A finding with no relation
|
|
47
|
+
* (e.g. W1, an audit-level advisory) belongs to every plane; a schema-level
|
|
48
|
+
* finding (L4) belongs to the planes that touch the schema.
|
|
49
|
+
*/
|
|
50
|
+
export declare function onPlane(finding: Finding, reach: PlaneReach): boolean;
|
|
51
|
+
/** Stamp `finding.planes` with every plane the finding is reachable on. */
|
|
52
|
+
export declare function stampPlanes(findings: Finding[], reaches: PlaneReach[]): void;
|
|
53
|
+
/**
|
|
54
|
+
* Score one secondary plane. Findings are re-stamped `exposed: true` because
|
|
55
|
+
* "exposed" is a statement about the *primary* surface: a relation that is
|
|
56
|
+
* internal to the API is not internal to a role holding a direct connection
|
|
57
|
+
* to it, which is the entire point of grading the plane separately.
|
|
58
|
+
*/
|
|
59
|
+
export declare function scorePlane(reach: PlaneReach, securityFindings: Finding[], scoring: ScoringConfig | undefined, exposureKnown: boolean): PlaneReport;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plane reach and per-plane scoring.
|
|
3
|
+
*
|
|
4
|
+
* A plane is an access path: a set of schemas (`api`, `schema`) or a set of
|
|
5
|
+
* roles (`role`). Reach is the set of relations a plane can touch — for a
|
|
6
|
+
* schema plane that is membership, for a role plane it is the effective-grant
|
|
7
|
+
* closure the lattice rules already compute (direct grants, grants TO PUBLIC,
|
|
8
|
+
* and role inheritance). Every plane is then scored by the *same* function on
|
|
9
|
+
* the *same* findings; only the finding set and the density denominator
|
|
10
|
+
* differ.
|
|
11
|
+
*/
|
|
12
|
+
import { effectiveGrants } from '../checks/lattice';
|
|
13
|
+
import { computeScore } from '../score/score';
|
|
14
|
+
import { summarize } from '../types';
|
|
15
|
+
export function relationKey(schema, table) {
|
|
16
|
+
return `${schema}.${table}`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Resolve what each plane can reach.
|
|
20
|
+
*
|
|
21
|
+
* A `role` plane for a superuser or `BYPASSRLS` role is refused rather than
|
|
22
|
+
* graded: RLS does not apply to it, so it reaches everything and grades 0 by
|
|
23
|
+
* construction — a number that teaches nothing and would read as a finding.
|
|
24
|
+
*/
|
|
25
|
+
export function resolvePlaneReach(planes, tables, graph, apiReach) {
|
|
26
|
+
return planes.map((plane) => {
|
|
27
|
+
// A role plane is grant-truth and no API declaration narrows it: the
|
|
28
|
+
// GraphQL API not exposing a table says nothing about a role holding a
|
|
29
|
+
// direct connection to the database. This is the whole reason relation
|
|
30
|
+
// reach is safe to apply at all.
|
|
31
|
+
if (plane.kind === 'role')
|
|
32
|
+
return roleReach(plane, tables, graph);
|
|
33
|
+
const schemas = new Set(plane.schemas);
|
|
34
|
+
const relations = new Set(tables.filter((t) => schemas.has(t.schema)).map((t) => relationKey(t.schema, t.name)));
|
|
35
|
+
const unaddressable = (apiReach?.unreachable ?? []).filter((r) => relations.has(relationKey(r.schema, r.table)));
|
|
36
|
+
for (const r of unaddressable)
|
|
37
|
+
relations.delete(relationKey(r.schema, r.table));
|
|
38
|
+
return {
|
|
39
|
+
plane,
|
|
40
|
+
relations,
|
|
41
|
+
schemas: [...schemas].sort(),
|
|
42
|
+
...(unaddressable.length > 0 ? { unaddressable } : {})
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
function roleReach(plane, tables, graph) {
|
|
47
|
+
const unsafe = plane.roles.filter((role) => {
|
|
48
|
+
const attrs = graph.get(role);
|
|
49
|
+
return attrs?.bypassRls === true || attrs?.isSuper === true;
|
|
50
|
+
});
|
|
51
|
+
if (unsafe.length > 0) {
|
|
52
|
+
return {
|
|
53
|
+
plane,
|
|
54
|
+
relations: new Set(),
|
|
55
|
+
schemas: [],
|
|
56
|
+
skipped: `${unsafe.join(', ')} bypasses row-level security — every relation is unmediated for it, `
|
|
57
|
+
+ 'so a grade would say nothing about the schema'
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const relations = new Set();
|
|
61
|
+
const schemas = new Set();
|
|
62
|
+
let via;
|
|
63
|
+
for (const table of tables) {
|
|
64
|
+
for (const role of plane.roles) {
|
|
65
|
+
const grants = effectiveGrants(table, role, graph);
|
|
66
|
+
if (grants.length === 0)
|
|
67
|
+
continue;
|
|
68
|
+
relations.add(relationKey(table.schema, table.name));
|
|
69
|
+
schemas.add(table.schema);
|
|
70
|
+
for (const grant of grants)
|
|
71
|
+
via = mostDirect(via, viaOf(grant.via));
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { plane, relations, schemas: [...schemas].sort(), ...(via ? { reachedVia: via } : {}) };
|
|
76
|
+
}
|
|
77
|
+
function viaOf(via) {
|
|
78
|
+
if (via === 'direct')
|
|
79
|
+
return 'grant';
|
|
80
|
+
if (via === 'PUBLIC')
|
|
81
|
+
return 'PUBLIC';
|
|
82
|
+
return 'inheritance';
|
|
83
|
+
}
|
|
84
|
+
const VIA_RANK = {
|
|
85
|
+
grant: 0,
|
|
86
|
+
PUBLIC: 1,
|
|
87
|
+
inheritance: 2
|
|
88
|
+
};
|
|
89
|
+
function mostDirect(a, b) {
|
|
90
|
+
return a === undefined || VIA_RANK[b] < VIA_RANK[a] ? b : a;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* True when a finding is reachable on a plane. A finding with no relation
|
|
94
|
+
* (e.g. W1, an audit-level advisory) belongs to every plane; a schema-level
|
|
95
|
+
* finding (L4) belongs to the planes that touch the schema.
|
|
96
|
+
*/
|
|
97
|
+
export function onPlane(finding, reach) {
|
|
98
|
+
if (!finding.schema)
|
|
99
|
+
return true;
|
|
100
|
+
if (!finding.table)
|
|
101
|
+
return reach.schemas.includes(finding.schema);
|
|
102
|
+
return reach.relations.has(relationKey(finding.schema, finding.table));
|
|
103
|
+
}
|
|
104
|
+
/** Stamp `finding.planes` with every plane the finding is reachable on. */
|
|
105
|
+
export function stampPlanes(findings, reaches) {
|
|
106
|
+
const graded = reaches.filter((r) => !r.skipped);
|
|
107
|
+
if (graded.length === 0)
|
|
108
|
+
return;
|
|
109
|
+
for (const finding of findings) {
|
|
110
|
+
const names = graded.filter((r) => onPlane(finding, r)).map((r) => r.plane.name);
|
|
111
|
+
if (names.length > 0)
|
|
112
|
+
finding.planes = names;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Score one secondary plane. Findings are re-stamped `exposed: true` because
|
|
117
|
+
* "exposed" is a statement about the *primary* surface: a relation that is
|
|
118
|
+
* internal to the API is not internal to a role holding a direct connection
|
|
119
|
+
* to it, which is the entire point of grading the plane separately.
|
|
120
|
+
*/
|
|
121
|
+
export function scorePlane(reach, securityFindings, scoring, exposureKnown) {
|
|
122
|
+
const findings = securityFindings
|
|
123
|
+
.filter((f) => onPlane(f, reach))
|
|
124
|
+
.map((f) => ({ ...f, exposed: true }));
|
|
125
|
+
const { plane } = reach;
|
|
126
|
+
return {
|
|
127
|
+
name: plane.name,
|
|
128
|
+
kind: plane.kind,
|
|
129
|
+
primary: plane.primary,
|
|
130
|
+
source: plane.source,
|
|
131
|
+
schemas: reach.schemas,
|
|
132
|
+
...(plane.roles.length > 0 ? { roles: plane.roles } : {}),
|
|
133
|
+
exposedTables: reach.relations.size,
|
|
134
|
+
...(reach.unaddressable && reach.unaddressable.length > 0
|
|
135
|
+
? { unaddressableTables: reach.unaddressable.length }
|
|
136
|
+
: {}),
|
|
137
|
+
...(reach.reachedVia ? { reachedVia: reach.reachedVia } : {}),
|
|
138
|
+
score: computeScore(findings, scoring, {
|
|
139
|
+
exposedTables: reach.relations.size,
|
|
140
|
+
exposureKnown
|
|
141
|
+
}),
|
|
142
|
+
summary: summarize(findings)
|
|
143
|
+
};
|
|
144
|
+
}
|