@safegres/test 0.0.2 → 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,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L11 and L12: the two things a *readable* view does that its owner and its
|
|
3
|
+
* body, taken separately, do not explain.
|
|
4
|
+
*
|
|
5
|
+
* **L11 — a materialized view is a snapshot, not a query.** Its rows were
|
|
6
|
+
* computed once, by whoever ran `REFRESH`, and are then handed to every reader
|
|
7
|
+
* of the matview verbatim. The base relations are not consulted at read time,
|
|
8
|
+
* so their ACLs never apply and — the part that matters — their RLS policies
|
|
9
|
+
* never run. A matview cannot carry policies of its own either: RLS attaches
|
|
10
|
+
* to tables, and `security_invoker` is a view-only reloption. So a SELECT
|
|
11
|
+
* grant on a matview over an RLS-protected table is an unconditional grant on
|
|
12
|
+
* the rows that were visible at refresh time.
|
|
13
|
+
*
|
|
14
|
+
* **L12 — a view's `WHERE` is a filter, not a boundary.** Without
|
|
15
|
+
* `security_barrier` the planner may push the *caller's* qual below the view's
|
|
16
|
+
* own, so a leaky operator or a `COST 0.0001` function evaluates against rows
|
|
17
|
+
* the view was written to hide. The rows are not returned, but they are seen —
|
|
18
|
+
* and a function that raises them, writes them, or times differently on them
|
|
19
|
+
* exfiltrates them. This only matters where the view is the only path to the
|
|
20
|
+
* relation; if the caller can read the base table directly there is nothing
|
|
21
|
+
* for the view to hide.
|
|
22
|
+
*
|
|
23
|
+
* Both ship the same way as the rest of the L-series: `info`, score-neutral,
|
|
24
|
+
* and never recommending a revoke. The remedies are properties of the view.
|
|
25
|
+
*/
|
|
26
|
+
import { bodyFiltersRows } from '../callgraph/extract';
|
|
27
|
+
import { buildRelationIndex, readBodies, resolveViewBases } from './definer-view';
|
|
28
|
+
import { effectiveGrants } from './lattice';
|
|
29
|
+
import { computeViewReach } from './role-reach';
|
|
30
|
+
/**
|
|
31
|
+
* Resolve both populations in one pass over the view bodies.
|
|
32
|
+
*
|
|
33
|
+
* A materialized view is deliberately absent from the relation index: it is a
|
|
34
|
+
* terminal relation for anything reading *through* it (its rows are stored),
|
|
35
|
+
* so it must never be followed as a nested view. Its own body is still read,
|
|
36
|
+
* to learn which relations the refresh copied from.
|
|
37
|
+
*/
|
|
38
|
+
export async function analyzeViewExposure(views, tables) {
|
|
39
|
+
const queryable = views.filter((v) => !v.materialized);
|
|
40
|
+
const index = buildRelationIndex(queryable, tables);
|
|
41
|
+
const bodies = await readBodies(views);
|
|
42
|
+
const matviews = [];
|
|
43
|
+
const leaky = [];
|
|
44
|
+
const suppressed = [];
|
|
45
|
+
for (const view of views) {
|
|
46
|
+
const label = `${view.schema}.${view.name}`;
|
|
47
|
+
if (view.materialized) {
|
|
48
|
+
const { bases, opaque } = resolveViewBases(view, index, bodies);
|
|
49
|
+
if (opaque) {
|
|
50
|
+
suppressed.push({ view: label, reason: opaque });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
// Relations outside the audited schemas carry no ACL or policy to
|
|
54
|
+
// compare the stored rows against; L14 reports the reach instead.
|
|
55
|
+
const graded = bases.filter((b) => !b.external);
|
|
56
|
+
if (graded.length === 0)
|
|
57
|
+
continue;
|
|
58
|
+
matviews.push({
|
|
59
|
+
schema: view.schema,
|
|
60
|
+
name: view.name,
|
|
61
|
+
owner: view.owner,
|
|
62
|
+
grants: view.grants,
|
|
63
|
+
baseRelations: graded,
|
|
64
|
+
materialized: true
|
|
65
|
+
});
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
// An invoker view confers nothing, so its `WHERE` is never the only thing
|
|
69
|
+
// standing between a caller and a relation: the caller needs its own
|
|
70
|
+
// grant on the base to read the view at all.
|
|
71
|
+
if (view.securityInvoker || view.securityBarrier)
|
|
72
|
+
continue;
|
|
73
|
+
const filters = await bodyFiltersRows(view.definition);
|
|
74
|
+
if (filters === null) {
|
|
75
|
+
suppressed.push({ view: label, reason: 'body could not be parsed to look for a row filter' });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (!filters)
|
|
79
|
+
continue;
|
|
80
|
+
const { bases, opaque } = resolveViewBases(view, index, bodies);
|
|
81
|
+
if (opaque) {
|
|
82
|
+
suppressed.push({ view: label, reason: opaque });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const gradedBases = bases.filter((b) => !b.external);
|
|
86
|
+
if (gradedBases.length === 0)
|
|
87
|
+
continue;
|
|
88
|
+
leaky.push({
|
|
89
|
+
schema: view.schema,
|
|
90
|
+
name: view.name,
|
|
91
|
+
owner: view.owner,
|
|
92
|
+
grants: view.grants,
|
|
93
|
+
baseRelations: gradedBases
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return { matviews, leaky, suppressed };
|
|
97
|
+
}
|
|
98
|
+
/** Is `role` actually filtered by `table`'s policies? */
|
|
99
|
+
function subjectToRls(table, role, graph) {
|
|
100
|
+
if (!table.rlsEnabled)
|
|
101
|
+
return false;
|
|
102
|
+
if (graph.get(role)?.bypassRls)
|
|
103
|
+
return false;
|
|
104
|
+
// The owner is exempt unless the table FORCEs policies on itself.
|
|
105
|
+
return !(table.owner === role && !table.rlsForced);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* L11: an untrusted role reads a table's rows out of a materialized view.
|
|
109
|
+
*
|
|
110
|
+
* Fires where the role can SELECT the matview and either holds no SELECT on
|
|
111
|
+
* the relation the refresh read, or holds one but is subject to policies the
|
|
112
|
+
* stored rows never passed through. The second case is the one catalog-only
|
|
113
|
+
* analysis is worst at: the ACL says the role may read the table, RLS says it
|
|
114
|
+
* may read three rows of it, and the matview hands it all of them.
|
|
115
|
+
*/
|
|
116
|
+
export function checkMatviewSnapshot(matviews, tables, graph, options = {}) {
|
|
117
|
+
const untrusted = options.roles ?? [];
|
|
118
|
+
if (untrusted.length === 0 || matviews.length === 0)
|
|
119
|
+
return [];
|
|
120
|
+
const byKey = new Map(tables.map((t) => [`${t.schema}.${t.name}`, t]));
|
|
121
|
+
const out = [];
|
|
122
|
+
for (const { role, cells } of computeViewReach(matviews, graph, untrusted)) {
|
|
123
|
+
for (const cell of cells) {
|
|
124
|
+
const base = byKey.get(`${cell.schema}.${cell.table}`);
|
|
125
|
+
if (!base)
|
|
126
|
+
continue;
|
|
127
|
+
const hasSelect = effectiveGrants(base, role, graph).some((g) => g.privilege === 'SELECT');
|
|
128
|
+
const filtered = subjectToRls(base, role, graph);
|
|
129
|
+
// Reachable in its own right and not row-filtered: the snapshot shows
|
|
130
|
+
// the role nothing it could not select from the table itself.
|
|
131
|
+
if (hasSelect && !filtered)
|
|
132
|
+
continue;
|
|
133
|
+
const matview = cell.path.find((e) => e.kind === 'matview');
|
|
134
|
+
if (!matview)
|
|
135
|
+
continue;
|
|
136
|
+
const refresher = cell.effectiveRole;
|
|
137
|
+
out.push({
|
|
138
|
+
code: 'L11',
|
|
139
|
+
severity: 'info',
|
|
140
|
+
category: 'anti-pattern',
|
|
141
|
+
schema: base.schema,
|
|
142
|
+
table: base.name,
|
|
143
|
+
role,
|
|
144
|
+
privilege: 'SELECT',
|
|
145
|
+
message: hasSelect
|
|
146
|
+
? `Untrusted role ${role} reads ${base.schema}.${base.name} through materialized view `
|
|
147
|
+
+ `${matview.view} without the row filter its policies apply — the rows were stored by `
|
|
148
|
+
+ `${refresher} at REFRESH time`
|
|
149
|
+
: `Untrusted role ${role} reads ${base.schema}.${base.name} through materialized view `
|
|
150
|
+
+ `${matview.view}, which stores rows computed as ${refresher} — ${role} holds no SELECT `
|
|
151
|
+
+ `on the base relation`,
|
|
152
|
+
hint: `A materialized view is a stored copy: reading it never consults ${base.schema}.`
|
|
153
|
+
+ `${base.name}, so neither its grants nor its RLS policies apply, and the matview cannot `
|
|
154
|
+
+ `carry policies or \`security_invoker\` of its own. Replace it with a plain `
|
|
155
|
+
+ `\`security_invoker\` view if the freshness is not what it is for, materialize only the `
|
|
156
|
+
+ `columns and rows that are safe to hand out unconditionally, or keep it in a schema the `
|
|
157
|
+
+ `API does not expose and serve a filtered view from it. Do not revoke the SELECT on the `
|
|
158
|
+
+ `matview — that grant is what the API serves.`,
|
|
159
|
+
context: {
|
|
160
|
+
matview: matview.view,
|
|
161
|
+
effectiveRole: refresher,
|
|
162
|
+
baseRlsEnabled: base.rlsEnabled,
|
|
163
|
+
rlsBypassed: filtered,
|
|
164
|
+
holdsBaseSelect: hasSelect,
|
|
165
|
+
proof: cell.proof
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* L12: a definer view filters rows for an untrusted role but is not a barrier.
|
|
174
|
+
*
|
|
175
|
+
* Fires where the role reaches a relation *only* through a view whose body has
|
|
176
|
+
* a `WHERE`, and the view is not `security_barrier`. The finding is about the
|
|
177
|
+
* rows the view excludes: they are the ones the author meant to withhold, and
|
|
178
|
+
* without the barrier a cheap leaky qual supplied by the caller is evaluated
|
|
179
|
+
* against them.
|
|
180
|
+
*/
|
|
181
|
+
export function checkLeakyFilterView(views, tables, graph, options = {}) {
|
|
182
|
+
const untrusted = options.roles ?? [];
|
|
183
|
+
if (untrusted.length === 0 || views.length === 0)
|
|
184
|
+
return [];
|
|
185
|
+
const byKey = new Map(tables.map((t) => [`${t.schema}.${t.name}`, t]));
|
|
186
|
+
const out = [];
|
|
187
|
+
for (const { role, cells } of computeViewReach(views, graph, untrusted)) {
|
|
188
|
+
for (const cell of cells) {
|
|
189
|
+
if (cell.effectiveRole === role)
|
|
190
|
+
continue;
|
|
191
|
+
const base = byKey.get(`${cell.schema}.${cell.table}`);
|
|
192
|
+
if (!base)
|
|
193
|
+
continue;
|
|
194
|
+
// The caller can read the relation directly, so the view's WHERE is a
|
|
195
|
+
// convenience, not a boundary, and pushing a qual past it reveals
|
|
196
|
+
// nothing new.
|
|
197
|
+
if (effectiveGrants(base, role, graph).some((g) => g.privilege === 'SELECT'))
|
|
198
|
+
continue;
|
|
199
|
+
const hops = cell.path.filter((e) => e.kind === 'view');
|
|
200
|
+
const outermost = hops[0];
|
|
201
|
+
if (!outermost)
|
|
202
|
+
continue;
|
|
203
|
+
out.push({
|
|
204
|
+
code: 'L12',
|
|
205
|
+
severity: 'info',
|
|
206
|
+
category: 'anti-pattern',
|
|
207
|
+
schema: base.schema,
|
|
208
|
+
table: base.name,
|
|
209
|
+
role,
|
|
210
|
+
privilege: 'SELECT',
|
|
211
|
+
message: `View ${outermost.view} is the only path untrusted role ${role} has to `
|
|
212
|
+
+ `${base.schema}.${base.name}, and its row filter is not a security barrier — a leaky `
|
|
213
|
+
+ `qual from ${role} can be evaluated against the rows the view excludes`,
|
|
214
|
+
hint: `Recreate the view \`WITH (security_barrier = true)\` so the planner cannot push a `
|
|
215
|
+
+ `caller-supplied qual below its own \`WHERE\`. A cheap function or a leaky operator is `
|
|
216
|
+
+ `otherwise evaluated on every row of ${base.schema}.${base.name}, including the hidden `
|
|
217
|
+
+ `ones, which is enough to read them out through errors, notices or timing. Where the `
|
|
218
|
+
+ `filter is per-caller, an RLS policy on the base relation is the stronger form — policy `
|
|
219
|
+
+ `quals already get barrier treatment. Do not revoke the SELECT on the view.`,
|
|
220
|
+
context: {
|
|
221
|
+
view: outermost.view,
|
|
222
|
+
effectiveRole: cell.effectiveRole,
|
|
223
|
+
viaViews: hops.map((h) => h.view),
|
|
224
|
+
baseRlsEnabled: base.rlsEnabled,
|
|
225
|
+
proof: cell.proof
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L9, L10 and L18: what a view does *beyond* its SELECT.
|
|
3
|
+
*
|
|
4
|
+
* L8 models the read edge — a non-`security_invoker` view hands its readers
|
|
5
|
+
* the owner's privileges on the relations its body names. Two write paths
|
|
6
|
+
* escape that model entirely, and both were verified against PostgreSQL 18
|
|
7
|
+
* rather than inferred:
|
|
8
|
+
*
|
|
9
|
+
* - **L9, auto-update.** A simple view is updatable: Postgres rewrites an
|
|
10
|
+
* INSERT/UPDATE/DELETE on the view onto its single base relation. On a
|
|
11
|
+
* definer view that rewrite is permission-checked against the *owner*, so
|
|
12
|
+
* INSERT on the view is INSERT on the base table the caller cannot touch.
|
|
13
|
+
* `security_invoker = true` closes it (the write is then checked against
|
|
14
|
+
* the caller and denied). The body alone cannot prove the write lands:
|
|
15
|
+
* `pg_relation_is_updatable` is the catalog half of the proof.
|
|
16
|
+
*
|
|
17
|
+
* - **L10, rewrite rules.** A rule other than the view's own `_RETURN` rule
|
|
18
|
+
* is invisible to `pg_get_viewdef`: `ON INSERT ... DO INSTEAD INSERT INTO
|
|
19
|
+
* audit` writes a relation the view body never names. Rule actions are
|
|
20
|
+
* checked against the rule's table owner, and — unlike the view's own base
|
|
21
|
+
* relations — `security_invoker` does **not** govern them. An invoker view
|
|
22
|
+
* with such a rule still writes `audit` as the view owner.
|
|
23
|
+
*
|
|
24
|
+
* - **L18, an unchecked write.** `WITH CHECK OPTION` is not the default, so
|
|
25
|
+
* a view whose `WHERE` decides which rows a role may *see* says nothing
|
|
26
|
+
* about which rows it may *write*: `INSERT INTO tenant_rows VALUES (...)`
|
|
27
|
+
* through a `WHERE tenant_id = current_tenant()` view stores a row for
|
|
28
|
+
* another tenant, which the writer then cannot see. It is the write-side
|
|
29
|
+
* twin of L12 — the view's filter is a read filter, not a boundary.
|
|
30
|
+
*
|
|
31
|
+
* All three keep L8's conservatism. An `INSTEAD OF` trigger sends the write into a
|
|
32
|
+
* function body, which auto-update cannot place and L20 follows instead
|
|
33
|
+
* (`definer-function.ts`); a multi-relation body
|
|
34
|
+
* is not auto-updatable in a way we can pin to one target, and an unreadable
|
|
35
|
+
* rule action is unknown — all three suppress rather than guess. And, as in
|
|
36
|
+
* L8, the fix is never a revoke: the grant on the view is what the API serves.
|
|
37
|
+
*/
|
|
38
|
+
import type { ViewSnapshot } from '../pg/indexes';
|
|
39
|
+
import type { TableSnapshot } from '../pg/introspect';
|
|
40
|
+
import type { Finding } from '../types';
|
|
41
|
+
import { type SuppressedView } from './definer-view';
|
|
42
|
+
import { type LatticeRoleOptions, type RoleGraph } from './lattice';
|
|
43
|
+
import { type ViewWriteInput } from './role-reach';
|
|
44
|
+
export interface ViewWriteAnalysis {
|
|
45
|
+
/** Definer views whose writes are auto-rewritten onto a base relation. */
|
|
46
|
+
autoUpdatable: ViewWriteInput[];
|
|
47
|
+
/** Views carrying rewrite rules whose actions reach other relations. */
|
|
48
|
+
ruleDriven: ViewWriteInput[];
|
|
49
|
+
/**
|
|
50
|
+
* The subset of {@link autoUpdatable} whose body filters rows and which
|
|
51
|
+
* carries no `WITH CHECK OPTION` — L18 inputs.
|
|
52
|
+
*/
|
|
53
|
+
unchecked: ViewWriteInput[];
|
|
54
|
+
/** Views deliberately left out, with why. */
|
|
55
|
+
suppressed: SuppressedView[];
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Resolve, for every view in the snapshot, the relations a write against it
|
|
59
|
+
* actually lands on — through auto-update rewriting and through rewrite rules.
|
|
60
|
+
*/
|
|
61
|
+
export declare function analyzeViewWrites(views: ViewSnapshot[], tables: TableSnapshot[]): Promise<ViewWriteAnalysis>;
|
|
62
|
+
/**
|
|
63
|
+
* L9: an untrusted role writes a base relation through an auto-updatable
|
|
64
|
+
* definer view.
|
|
65
|
+
*
|
|
66
|
+
* Fires once per (role, view, base relation, command) where the role holds
|
|
67
|
+
* the command on the view, the view executes as someone else, and the role
|
|
68
|
+
* holds no such privilege on the relation the write lands on.
|
|
69
|
+
*/
|
|
70
|
+
export declare function checkDefinerViewWrite(views: ViewWriteInput[], tables: TableSnapshot[], graph: RoleGraph, options?: LatticeRoleOptions): Finding[];
|
|
71
|
+
/**
|
|
72
|
+
* L10: an untrusted role writes a relation through a rewrite rule on a view.
|
|
73
|
+
*
|
|
74
|
+
* Unlike L9 this fires on `security_invoker` views too: `security_invoker`
|
|
75
|
+
* governs the view's own base relations, not the relations a rule's actions
|
|
76
|
+
* name, which are checked against the rule's table owner either way.
|
|
77
|
+
*/
|
|
78
|
+
export declare function checkViewRuleBypass(views: ViewWriteInput[], tables: TableSnapshot[], graph: RoleGraph, options?: LatticeRoleOptions): Finding[];
|
|
79
|
+
/**
|
|
80
|
+
* L18: an untrusted role writes rows a filtering view will not show it.
|
|
81
|
+
*
|
|
82
|
+
* Fires on the L9 population narrowed to filtering views with no `WITH CHECK
|
|
83
|
+
* OPTION`, minus DELETE — which removes rows the view already showed rather
|
|
84
|
+
* than producing new ones. It overlaps L9 by design and answers a different
|
|
85
|
+
* question: L9 is *whether* the role can write the relation at all, L18 is
|
|
86
|
+
* whether the view's own condition constrains what it writes.
|
|
87
|
+
*/
|
|
88
|
+
export declare function checkUncheckedViewWrite(views: ViewWriteInput[], tables: TableSnapshot[], graph: RoleGraph, options?: LatticeRoleOptions): Finding[];
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L9, L10 and L18: what a view does *beyond* its SELECT.
|
|
3
|
+
*
|
|
4
|
+
* L8 models the read edge — a non-`security_invoker` view hands its readers
|
|
5
|
+
* the owner's privileges on the relations its body names. Two write paths
|
|
6
|
+
* escape that model entirely, and both were verified against PostgreSQL 18
|
|
7
|
+
* rather than inferred:
|
|
8
|
+
*
|
|
9
|
+
* - **L9, auto-update.** A simple view is updatable: Postgres rewrites an
|
|
10
|
+
* INSERT/UPDATE/DELETE on the view onto its single base relation. On a
|
|
11
|
+
* definer view that rewrite is permission-checked against the *owner*, so
|
|
12
|
+
* INSERT on the view is INSERT on the base table the caller cannot touch.
|
|
13
|
+
* `security_invoker = true` closes it (the write is then checked against
|
|
14
|
+
* the caller and denied). The body alone cannot prove the write lands:
|
|
15
|
+
* `pg_relation_is_updatable` is the catalog half of the proof.
|
|
16
|
+
*
|
|
17
|
+
* - **L10, rewrite rules.** A rule other than the view's own `_RETURN` rule
|
|
18
|
+
* is invisible to `pg_get_viewdef`: `ON INSERT ... DO INSTEAD INSERT INTO
|
|
19
|
+
* audit` writes a relation the view body never names. Rule actions are
|
|
20
|
+
* checked against the rule's table owner, and — unlike the view's own base
|
|
21
|
+
* relations — `security_invoker` does **not** govern them. An invoker view
|
|
22
|
+
* with such a rule still writes `audit` as the view owner.
|
|
23
|
+
*
|
|
24
|
+
* - **L18, an unchecked write.** `WITH CHECK OPTION` is not the default, so
|
|
25
|
+
* a view whose `WHERE` decides which rows a role may *see* says nothing
|
|
26
|
+
* about which rows it may *write*: `INSERT INTO tenant_rows VALUES (...)`
|
|
27
|
+
* through a `WHERE tenant_id = current_tenant()` view stores a row for
|
|
28
|
+
* another tenant, which the writer then cannot see. It is the write-side
|
|
29
|
+
* twin of L12 — the view's filter is a read filter, not a boundary.
|
|
30
|
+
*
|
|
31
|
+
* All three keep L8's conservatism. An `INSTEAD OF` trigger sends the write into a
|
|
32
|
+
* function body, which auto-update cannot place and L20 follows instead
|
|
33
|
+
* (`definer-function.ts`); a multi-relation body
|
|
34
|
+
* is not auto-updatable in a way we can pin to one target, and an unreadable
|
|
35
|
+
* rule action is unknown — all three suppress rather than guess. And, as in
|
|
36
|
+
* L8, the fix is never a revoke: the grant on the view is what the API serves.
|
|
37
|
+
*/
|
|
38
|
+
import { bodyFiltersRows, extractAccess, extractQuery } from '../callgraph/extract';
|
|
39
|
+
import { buildRelationIndex, resolveRelation } from './definer-view';
|
|
40
|
+
import { effectiveGrants } from './lattice';
|
|
41
|
+
import { computeViewWriteReach } from './role-reach';
|
|
42
|
+
/** How deep a chain of updatable views on views is followed before giving up. */
|
|
43
|
+
const MAX_VIEW_DEPTH = 8;
|
|
44
|
+
/**
|
|
45
|
+
* Resolve, for every view in the snapshot, the relations a write against it
|
|
46
|
+
* actually lands on — through auto-update rewriting and through rewrite rules.
|
|
47
|
+
*/
|
|
48
|
+
export async function analyzeViewWrites(views, tables) {
|
|
49
|
+
const queryable = views.filter((v) => !v.materialized);
|
|
50
|
+
const index = buildRelationIndex(queryable, tables);
|
|
51
|
+
const autoUpdatable = [];
|
|
52
|
+
const ruleDriven = [];
|
|
53
|
+
const unchecked = [];
|
|
54
|
+
const suppressed = [];
|
|
55
|
+
for (const view of queryable) {
|
|
56
|
+
const name = `${view.schema}.${view.name}`;
|
|
57
|
+
const auto = await autoUpdateEdges(view, index, suppressed);
|
|
58
|
+
if (auto.length > 0) {
|
|
59
|
+
const input = {
|
|
60
|
+
schema: view.schema,
|
|
61
|
+
name: view.name,
|
|
62
|
+
owner: view.owner,
|
|
63
|
+
grants: view.grants,
|
|
64
|
+
writeEdges: auto
|
|
65
|
+
};
|
|
66
|
+
autoUpdatable.push(input);
|
|
67
|
+
if (view.checkOption === 'none') {
|
|
68
|
+
// A view with no `WHERE` excludes no rows, so there is nothing a write
|
|
69
|
+
// could land outside of; an unparseable body is unknown, not clean.
|
|
70
|
+
const filters = await bodyFiltersRows(view.definition);
|
|
71
|
+
if (filters === null) {
|
|
72
|
+
suppressed.push({
|
|
73
|
+
view: name,
|
|
74
|
+
reason: 'body could not be parsed to look for a row filter'
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
else if (filters) {
|
|
78
|
+
// WITH CHECK OPTION constrains the rows a write *produces*, so only
|
|
79
|
+
// the commands that produce rows can escape it.
|
|
80
|
+
const writes = auto.filter((e) => e.privilege !== 'DELETE');
|
|
81
|
+
if (writes.length > 0)
|
|
82
|
+
unchecked.push({ ...input, writeEdges: writes });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const fromRules = [];
|
|
87
|
+
for (const rule of view.rules) {
|
|
88
|
+
if (rule.event === 'SELECT')
|
|
89
|
+
continue;
|
|
90
|
+
const edges = await ruleEdges(view, rule, index);
|
|
91
|
+
if (edges === null) {
|
|
92
|
+
suppressed.push({
|
|
93
|
+
view: name,
|
|
94
|
+
reason: `rule ${rule.name} has an action this analysis cannot follow`
|
|
95
|
+
});
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
fromRules.push(...edges);
|
|
99
|
+
}
|
|
100
|
+
if (fromRules.length > 0) {
|
|
101
|
+
ruleDriven.push({
|
|
102
|
+
schema: view.schema,
|
|
103
|
+
name: view.name,
|
|
104
|
+
owner: view.owner,
|
|
105
|
+
grants: view.grants,
|
|
106
|
+
writeEdges: fromRules
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { autoUpdatable, ruleDriven, unchecked, suppressed };
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The base relation an auto-updatable definer view's writes are rewritten
|
|
114
|
+
* onto, if it can be proven.
|
|
115
|
+
*
|
|
116
|
+
* Auto-update only applies when nothing else has taken over the write path,
|
|
117
|
+
* so a view with rules or `INSTEAD OF` triggers is not handled here — the
|
|
118
|
+
* catalog's updatability bitmask counts those too, and attributing their
|
|
119
|
+
* writes to the body's relation would be a guess.
|
|
120
|
+
*/
|
|
121
|
+
async function autoUpdateEdges(view, index, suppressed) {
|
|
122
|
+
const name = `${view.schema}.${view.name}`;
|
|
123
|
+
if (view.securityInvoker)
|
|
124
|
+
return []; // the write is checked against the caller
|
|
125
|
+
if (view.writable.length === 0)
|
|
126
|
+
return [];
|
|
127
|
+
if (view.insteadOfTriggers) {
|
|
128
|
+
suppressed.push({
|
|
129
|
+
view: name,
|
|
130
|
+
reason: 'INSTEAD OF triggers decide where the write lands, in a trigger function body — L20, not auto-update'
|
|
131
|
+
});
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
if (view.rules.some((r) => r.event !== 'SELECT'))
|
|
135
|
+
return []; // rule path, see ruleEdges
|
|
136
|
+
const hops = [{ view: name, owner: view.owner }];
|
|
137
|
+
let current = view;
|
|
138
|
+
for (;;) {
|
|
139
|
+
if (hops.length > MAX_VIEW_DEPTH) {
|
|
140
|
+
suppressed.push({ view: name, reason: `view chain deeper than ${MAX_VIEW_DEPTH} hops` });
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
const body = await extractQuery(current.definition);
|
|
144
|
+
if (body.opaque) {
|
|
145
|
+
suppressed.push({ view: name, reason: body.opaqueReason ?? 'body could not be read' });
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
const resolved = body.tables
|
|
149
|
+
.map((ref) => resolveRelation(ref, current.schema, index))
|
|
150
|
+
.filter((r) => r !== null);
|
|
151
|
+
// Auto-update needs exactly one target. Anything else — a join, a body
|
|
152
|
+
// whose references we could not pin down — is not a write we can place.
|
|
153
|
+
if (resolved.length !== 1)
|
|
154
|
+
return [];
|
|
155
|
+
const target = resolved[0];
|
|
156
|
+
// A relation outside the audited schemas cannot be graded: its owner, its
|
|
157
|
+
// ACL and its RLS are all unknown, so the write is not placeable here.
|
|
158
|
+
if (target.kind === 'external')
|
|
159
|
+
return [];
|
|
160
|
+
if (target.kind === 'table') {
|
|
161
|
+
return view.writable.map((privilege) => ({
|
|
162
|
+
schema: target.schema,
|
|
163
|
+
table: target.name,
|
|
164
|
+
via: privilege,
|
|
165
|
+
privilege,
|
|
166
|
+
hops: [...hops]
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
const nested = target.view;
|
|
170
|
+
if (`${nested.schema}.${nested.name}` === `${current.schema}.${current.name}`)
|
|
171
|
+
return [];
|
|
172
|
+
if (nested.materialized || nested.insteadOfTriggers)
|
|
173
|
+
return [];
|
|
174
|
+
// The inner view re-owns the write unless it defers to the caller, in
|
|
175
|
+
// which case whichever owner is already in force stays in force.
|
|
176
|
+
const owner = nested.securityInvoker ? hops[hops.length - 1].owner : nested.owner;
|
|
177
|
+
hops.push({ view: `${nested.schema}.${nested.name}`, owner });
|
|
178
|
+
current = nested;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The relations a rewrite rule's actions reach, or `null` when the action
|
|
183
|
+
* cannot be followed.
|
|
184
|
+
*
|
|
185
|
+
* The rule's own view is dropped from the result: `pg_get_ruledef` names it in
|
|
186
|
+
* the `ON ... TO <view>` clause, and that reference is the trigger, not a
|
|
187
|
+
* target. `DO INSTEAD NOTHING` therefore yields nothing at all, which is the
|
|
188
|
+
* correct answer for a read-only view — the commonest rule in the wild.
|
|
189
|
+
*/
|
|
190
|
+
async function ruleEdges(view, rule, index) {
|
|
191
|
+
const self = `${view.schema}.${view.name}`;
|
|
192
|
+
const { accesses, opaque } = await extractAccess(rule.definition);
|
|
193
|
+
if (opaque)
|
|
194
|
+
return null;
|
|
195
|
+
const edges = [];
|
|
196
|
+
for (const access of accesses) {
|
|
197
|
+
// Reads inside a rule action are the view's own SELECT path, which L8
|
|
198
|
+
// already models; the escalation a rule adds is the write.
|
|
199
|
+
if (access.privilege === 'SELECT')
|
|
200
|
+
continue;
|
|
201
|
+
const target = resolveRelation(access, view.schema, index);
|
|
202
|
+
if (!target)
|
|
203
|
+
continue;
|
|
204
|
+
if (target.kind === 'external')
|
|
205
|
+
continue;
|
|
206
|
+
if (target.kind === 'view') {
|
|
207
|
+
// The write recurses into another view's rewrite path; proving where it
|
|
208
|
+
// finally lands is more than this analysis can do.
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
if (`${target.schema}.${target.name}` === self)
|
|
212
|
+
continue;
|
|
213
|
+
edges.push({
|
|
214
|
+
schema: target.schema,
|
|
215
|
+
table: target.name,
|
|
216
|
+
via: rule.event,
|
|
217
|
+
privilege: access.privilege,
|
|
218
|
+
hops: [{ view: self, owner: view.owner }],
|
|
219
|
+
rule: rule.name
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return edges;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* L9: an untrusted role writes a base relation through an auto-updatable
|
|
226
|
+
* definer view.
|
|
227
|
+
*
|
|
228
|
+
* Fires once per (role, view, base relation, command) where the role holds
|
|
229
|
+
* the command on the view, the view executes as someone else, and the role
|
|
230
|
+
* holds no such privilege on the relation the write lands on.
|
|
231
|
+
*/
|
|
232
|
+
export function checkDefinerViewWrite(views, tables, graph, options = {}) {
|
|
233
|
+
return writeFindings(views, tables, graph, options, 'L9', (ctx) => ({
|
|
234
|
+
message: `Untrusted role ${ctx.role} can ${ctx.privilege} ${ctx.target} through view ${ctx.view}, `
|
|
235
|
+
+ `which executes as its owner ${ctx.owner} — ${ctx.role} holds no ${ctx.privilege} on the `
|
|
236
|
+
+ `base relation`
|
|
237
|
+
+ (ctx.rlsBypassed ? `, and ${ctx.owner} is not subject to its RLS policies` : ''),
|
|
238
|
+
hint: `The view is auto-updatable, so Postgres rewrites the ${ctx.privilege} onto ${ctx.target} and `
|
|
239
|
+
+ `checks it against ${ctx.owner}, not ${ctx.role}. Recreate the view `
|
|
240
|
+
+ `\`WITH (security_invoker = true)\` so the caller's own grants and policies apply, give it an `
|
|
241
|
+
+ `owner whose reach matches what the view is meant to expose, or make it non-updatable. Do not `
|
|
242
|
+
+ `revoke the grant on the view — that grant is what the API serves.`
|
|
243
|
+
}));
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* L10: an untrusted role writes a relation through a rewrite rule on a view.
|
|
247
|
+
*
|
|
248
|
+
* Unlike L9 this fires on `security_invoker` views too: `security_invoker`
|
|
249
|
+
* governs the view's own base relations, not the relations a rule's actions
|
|
250
|
+
* name, which are checked against the rule's table owner either way.
|
|
251
|
+
*/
|
|
252
|
+
export function checkViewRuleBypass(views, tables, graph, options = {}) {
|
|
253
|
+
return writeFindings(views, tables, graph, options, 'L10', (ctx) => ({
|
|
254
|
+
message: `Untrusted role ${ctx.role} can ${ctx.privilege} ${ctx.target} through rule ${ctx.rule} on view `
|
|
255
|
+
+ `${ctx.view} — the rule's action runs as the view owner ${ctx.owner}, and ${ctx.role} holds no `
|
|
256
|
+
+ `${ctx.privilege} on ${ctx.target}`
|
|
257
|
+
+ (ctx.rlsBypassed ? `, whose RLS policies ${ctx.owner} is not subject to` : ''),
|
|
258
|
+
hint: `Rewrite rules are not shown by \`pg_get_viewdef\` and are not governed by \`security_invoker\`: `
|
|
259
|
+
+ `their actions are permission-checked against the owner of the relation the rule is on. Move `
|
|
260
|
+
+ `the action into a function the caller must be granted EXECUTE on, or give the view an owner `
|
|
261
|
+
+ `whose reach matches what the rule is meant to do. Do not revoke the grant on the view — that `
|
|
262
|
+
+ `grant is what the API serves.`
|
|
263
|
+
}));
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* L18: an untrusted role writes rows a filtering view will not show it.
|
|
267
|
+
*
|
|
268
|
+
* Fires on the L9 population narrowed to filtering views with no `WITH CHECK
|
|
269
|
+
* OPTION`, minus DELETE — which removes rows the view already showed rather
|
|
270
|
+
* than producing new ones. It overlaps L9 by design and answers a different
|
|
271
|
+
* question: L9 is *whether* the role can write the relation at all, L18 is
|
|
272
|
+
* whether the view's own condition constrains what it writes.
|
|
273
|
+
*/
|
|
274
|
+
export function checkUncheckedViewWrite(views, tables, graph, options = {}) {
|
|
275
|
+
return writeFindings(views, tables, graph, options, 'L18', (ctx) => ({
|
|
276
|
+
message: `Untrusted role ${ctx.role} can ${ctx.privilege} rows into ${ctx.target} through view `
|
|
277
|
+
+ `${ctx.view} that the view's own row filter excludes — the view has no WITH CHECK OPTION, `
|
|
278
|
+
+ `so its \`WHERE\` governs reads only`,
|
|
279
|
+
hint: `Recreate the view \`WITH LOCAL CHECK OPTION\` (or \`CASCADED\`, to enforce every underlying `
|
|
280
|
+
+ `view's condition too) so a row written through it must satisfy the condition it is served `
|
|
281
|
+
+ `under. Where the filter is per-caller, an RLS policy with a \`WITH CHECK\` clause on `
|
|
282
|
+
+ `${ctx.target} is the stronger form — it applies however the row arrives. Do not revoke the `
|
|
283
|
+
+ `${ctx.privilege} on the view; that grant is what the API serves.`
|
|
284
|
+
}));
|
|
285
|
+
}
|
|
286
|
+
function writeFindings(views, tables, graph, options, code, render) {
|
|
287
|
+
const untrusted = options.roles ?? [];
|
|
288
|
+
if (untrusted.length === 0 || views.length === 0)
|
|
289
|
+
return [];
|
|
290
|
+
const byKey = new Map(tables.map((t) => [`${t.schema}.${t.name}`, t]));
|
|
291
|
+
const out = [];
|
|
292
|
+
for (const { role, cells } of computeViewWriteReach(views, graph, untrusted)) {
|
|
293
|
+
for (const cell of cells) {
|
|
294
|
+
if (cell.effectiveRole === role)
|
|
295
|
+
continue;
|
|
296
|
+
const base = byKey.get(`${cell.schema}.${cell.table}`);
|
|
297
|
+
if (!base)
|
|
298
|
+
continue;
|
|
299
|
+
const privilege = cell.privileges[0];
|
|
300
|
+
// Already writable in its own right: the view launders nothing.
|
|
301
|
+
if (effectiveGrants(base, role, graph).some((g) => g.privilege === privilege))
|
|
302
|
+
continue;
|
|
303
|
+
const viewHops = cell.path.filter((e) => e.kind === 'view');
|
|
304
|
+
const ruleEdge = cell.path.find((e) => e.kind === 'rule');
|
|
305
|
+
const grantEdge = cell.path.find((e) => e.kind === 'grant');
|
|
306
|
+
const owner = cell.effectiveRole;
|
|
307
|
+
const ownerAttrs = graph.get(owner);
|
|
308
|
+
const rlsBypassed = base.rlsEnabled
|
|
309
|
+
&& (!!ownerAttrs?.bypassRls || (base.owner === owner && !base.rlsForced));
|
|
310
|
+
const target = `${base.schema}.${base.name}`;
|
|
311
|
+
const { message, hint } = render({
|
|
312
|
+
role,
|
|
313
|
+
view: viewHops[0].view,
|
|
314
|
+
owner,
|
|
315
|
+
target,
|
|
316
|
+
privilege,
|
|
317
|
+
...(ruleEdge ? { rule: ruleEdge.rule } : {}),
|
|
318
|
+
rlsBypassed
|
|
319
|
+
});
|
|
320
|
+
out.push({
|
|
321
|
+
code,
|
|
322
|
+
severity: 'info',
|
|
323
|
+
category: 'anti-pattern',
|
|
324
|
+
schema: base.schema,
|
|
325
|
+
table: base.name,
|
|
326
|
+
role,
|
|
327
|
+
privilege,
|
|
328
|
+
message,
|
|
329
|
+
hint,
|
|
330
|
+
context: {
|
|
331
|
+
view: viewHops[0].view,
|
|
332
|
+
effectiveRole: owner,
|
|
333
|
+
viaViews: viewHops.map((h) => h.view),
|
|
334
|
+
...(ruleEdge ? { rule: ruleEdge.rule } : {}),
|
|
335
|
+
viewPrivilege: grantEdge?.privilege,
|
|
336
|
+
baseRlsEnabled: base.rlsEnabled,
|
|
337
|
+
rlsBypassed,
|
|
338
|
+
proof: cell.proof
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return out;
|
|
344
|
+
}
|