@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,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The grant/RLS/policy lattice (L-series).
|
|
3
|
+
*
|
|
4
|
+
* The A/R rules reason from grants exactly as the catalog stores them, which
|
|
5
|
+
* misses two ways access actually arrives: `GRANT TO PUBLIC` (one ACL row
|
|
6
|
+
* that applies to every role) and role inheritance (zero ACL rows on the
|
|
7
|
+
* member). The lattice rules evaluate the *effective* cell each
|
|
8
|
+
* (relation, role, privilege) lands in:
|
|
9
|
+
*
|
|
10
|
+
* | grant | RLS | policy | verdict |
|
|
11
|
+
* |-------|-----|--------|-------------------------------------------|
|
|
12
|
+
* | yes | on | yes | normal — policy-mediated access |
|
|
13
|
+
* | yes | on | no | dead grant (L1, indirect; A4/A5, direct) |
|
|
14
|
+
* | yes | off | — | unmediated (L5 for untrusted, indirect) |
|
|
15
|
+
* | no | — | yes | dead policy (L2) |
|
|
16
|
+
*
|
|
17
|
+
* plus schema-USAGE composition: an object grant the grantee cannot reach
|
|
18
|
+
* because USAGE is missing (L3), and USAGE that reaches no object (L4).
|
|
19
|
+
*
|
|
20
|
+
* Every L rule ships signal-only: L1–L3 are fail-closed (zero score weight by
|
|
21
|
+
* default), L4/L5 default to `info`. Escalate via config once the findings
|
|
22
|
+
* prove themselves.
|
|
23
|
+
*/
|
|
24
|
+
import { CLAUSE_REQUIRED, POLICY_CMDS } from './coverage';
|
|
25
|
+
const RLS_PRIVILEGES = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'];
|
|
26
|
+
/**
|
|
27
|
+
* Every privilege `role` effectively holds on `table`: direct grants, grants
|
|
28
|
+
* TO PUBLIC, and grants to roles it inherits from. Deduplicated per privilege
|
|
29
|
+
* with the most direct provenance winning (direct > PUBLIC > inherited).
|
|
30
|
+
*
|
|
31
|
+
* Takes anything carrying an ACL, not just a table: a view's own grants
|
|
32
|
+
* compose the same way, and the view-ownership reach edge asks this same
|
|
33
|
+
* question of them.
|
|
34
|
+
*/
|
|
35
|
+
export function effectiveGrants(table, role, graph) {
|
|
36
|
+
const byPrivilege = new Map();
|
|
37
|
+
const rank = (via) => via === 'direct' ? 0 : via === 'PUBLIC' ? 1 : 2;
|
|
38
|
+
const record = (privilege, via) => {
|
|
39
|
+
const existing = byPrivilege.get(privilege);
|
|
40
|
+
if (existing === undefined || rank(via) < rank(existing)) {
|
|
41
|
+
byPrivilege.set(privilege, via);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const ancestors = new Set(graph.get(role)?.inheritsFrom ?? []);
|
|
45
|
+
for (const g of table.grants) {
|
|
46
|
+
if (g.role === role)
|
|
47
|
+
record(g.privilege, 'direct');
|
|
48
|
+
else if (g.role === 'PUBLIC')
|
|
49
|
+
record(g.privilege, 'PUBLIC');
|
|
50
|
+
else if (ancestors.has(g.role))
|
|
51
|
+
record(g.privilege, `member of ${g.role}`);
|
|
52
|
+
}
|
|
53
|
+
return [...byPrivilege.entries()]
|
|
54
|
+
.map(([privilege, via]) => ({ privilege, via }))
|
|
55
|
+
.sort((a, b) => a.privilege.localeCompare(b.privilege));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* How `role` comes to hold EXECUTE on a function, or `null` when it does not.
|
|
59
|
+
*
|
|
60
|
+
* A function ACL carries one privilege, so the closure {@link effectiveGrants}
|
|
61
|
+
* performs per privilege collapses to a single provenance — but it is the same
|
|
62
|
+
* closure: a grant to PUBLIC and a grant to a role the caller INHERITs from
|
|
63
|
+
* both confer EXECUTE without naming the caller anywhere.
|
|
64
|
+
*/
|
|
65
|
+
export function effectiveExecute(fn, role, graph) {
|
|
66
|
+
const ancestors = new Set(graph.get(role)?.inheritsFrom ?? []);
|
|
67
|
+
let best = null;
|
|
68
|
+
const rank = (via) => (via === 'direct' ? 0 : via === 'PUBLIC' ? 1 : 2);
|
|
69
|
+
for (const g of fn.grants) {
|
|
70
|
+
const via = g.role === role ? 'direct'
|
|
71
|
+
: g.role === 'PUBLIC' ? 'PUBLIC'
|
|
72
|
+
: ancestors.has(g.role) ? `member of ${g.role}`
|
|
73
|
+
: null;
|
|
74
|
+
if (via && (best === null || rank(via) < rank(best)))
|
|
75
|
+
best = via;
|
|
76
|
+
}
|
|
77
|
+
return best;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Every privilege `role` effectively holds on individual *columns* of `table`,
|
|
81
|
+
* composed exactly like {@link effectiveGrants} — direct, PUBLIC, inherited.
|
|
82
|
+
*
|
|
83
|
+
* A column grant lives in `pg_attribute.attacl` and never appears in the
|
|
84
|
+
* relation's ACL, so a role can hold nothing at all by `effectiveGrants` and
|
|
85
|
+
* still read the columns that matter. Privileges the role already holds on the
|
|
86
|
+
* whole relation are dropped: the column grant then adds no reach, and
|
|
87
|
+
* reporting it would double-count the same access.
|
|
88
|
+
*/
|
|
89
|
+
export function effectiveColumnGrants(table, role, graph) {
|
|
90
|
+
if (table.columnGrants.length === 0)
|
|
91
|
+
return [];
|
|
92
|
+
const wholeRelation = new Set(effectiveGrants(table, role, graph).map((g) => g.privilege));
|
|
93
|
+
const rank = (via) => (via === 'direct' ? 0 : via === 'PUBLIC' ? 1 : 2);
|
|
94
|
+
const ancestors = new Set(graph.get(role)?.inheritsFrom ?? []);
|
|
95
|
+
const byPrivilege = new Map();
|
|
96
|
+
for (const g of table.columnGrants) {
|
|
97
|
+
if (wholeRelation.has(g.privilege))
|
|
98
|
+
continue;
|
|
99
|
+
const via = g.role === role ? 'direct'
|
|
100
|
+
: g.role === 'PUBLIC' ? 'PUBLIC'
|
|
101
|
+
: ancestors.has(g.role) ? `member of ${g.role}`
|
|
102
|
+
: null;
|
|
103
|
+
if (!via)
|
|
104
|
+
continue;
|
|
105
|
+
const entry = byPrivilege.get(g.privilege);
|
|
106
|
+
if (!entry)
|
|
107
|
+
byPrivilege.set(g.privilege, { via, columns: new Set([g.column]) });
|
|
108
|
+
else {
|
|
109
|
+
entry.columns.add(g.column);
|
|
110
|
+
if (rank(via) < rank(entry.via))
|
|
111
|
+
entry.via = via;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return [...byPrivilege.entries()]
|
|
115
|
+
.map(([privilege, e]) => ({ privilege, via: e.via, columns: [...e.columns].sort() }))
|
|
116
|
+
.sort((a, b) => a.privilege.localeCompare(b.privilege));
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Whether a policy applies to `role` at runtime: named directly, via PUBLIC,
|
|
120
|
+
* or via a role it inherits from (`pg_has_role(..., 'member')` semantics).
|
|
121
|
+
*/
|
|
122
|
+
function policyAppliesToRole(p, role, graph) {
|
|
123
|
+
if (p.roles.includes('PUBLIC') || p.roles.includes(role))
|
|
124
|
+
return true;
|
|
125
|
+
const ancestors = graph.get(role)?.inheritsFrom ?? [];
|
|
126
|
+
return ancestors.some((a) => p.roles.includes(a));
|
|
127
|
+
}
|
|
128
|
+
function coveredForRole(table, role, privilege, graph) {
|
|
129
|
+
const clause = CLAUSE_REQUIRED[privilege];
|
|
130
|
+
if (!clause)
|
|
131
|
+
return true;
|
|
132
|
+
return table.policies.some((p) => p.permissive
|
|
133
|
+
&& POLICY_CMDS[p.cmd].includes(privilege)
|
|
134
|
+
&& policyAppliesToRole(p, role, graph)
|
|
135
|
+
&& (clause === 'USING' ? p.using != null : p.withCheck != null));
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* L1: an *indirect* grant (TO PUBLIC, or inherited from a granted role) on an
|
|
139
|
+
* RLS-enabled table with no applicable permissive policy — a dead grant that
|
|
140
|
+
* A4/A5 cannot see because no direct ACL row names the role. Default-deny
|
|
141
|
+
* admits nothing today; the risk is the grant silently activating the day a
|
|
142
|
+
* broad policy is added.
|
|
143
|
+
*/
|
|
144
|
+
export function checkIndirectCoverageGaps(table, graph) {
|
|
145
|
+
if (!table.rlsEnabled)
|
|
146
|
+
return [];
|
|
147
|
+
const out = [];
|
|
148
|
+
// PUBLIC grants: dead if no permissive policy provides the clause for anyone.
|
|
149
|
+
for (const g of table.grants) {
|
|
150
|
+
if (g.role !== 'PUBLIC')
|
|
151
|
+
continue;
|
|
152
|
+
const clause = CLAUSE_REQUIRED[g.privilege];
|
|
153
|
+
if (!clause)
|
|
154
|
+
continue;
|
|
155
|
+
const covered = table.policies.some((p) => p.permissive
|
|
156
|
+
&& POLICY_CMDS[p.cmd].includes(g.privilege)
|
|
157
|
+
&& (clause === 'USING' ? p.using != null : p.withCheck != null));
|
|
158
|
+
if (covered)
|
|
159
|
+
continue;
|
|
160
|
+
out.push({
|
|
161
|
+
code: 'L1',
|
|
162
|
+
severity: 'low',
|
|
163
|
+
category: 'coverage',
|
|
164
|
+
schema: table.schema,
|
|
165
|
+
table: table.name,
|
|
166
|
+
role: 'PUBLIC',
|
|
167
|
+
privilege: g.privilege,
|
|
168
|
+
message: `${g.privilege} is granted TO PUBLIC on ${table.schema}.${table.name} but no permissive policy can admit it — a dead grant under RLS default-deny`,
|
|
169
|
+
hint: 'Revoke the PUBLIC grant: it admits nothing today, and it silently activates for every role the day a broad policy is added.'
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
// Inherited grants: evaluate roles that inherit from anything, skipping
|
|
173
|
+
// bypass roles (policies don't apply) and privileges the role also holds
|
|
174
|
+
// directly (A4/A5 own that report).
|
|
175
|
+
for (const [role, attrs] of graph) {
|
|
176
|
+
if (attrs.inheritsFrom.length === 0 || attrs.bypassRls)
|
|
177
|
+
continue;
|
|
178
|
+
if (role === table.owner)
|
|
179
|
+
continue;
|
|
180
|
+
const direct = new Set(table.grants.filter((g) => g.role === role).map((g) => g.privilege));
|
|
181
|
+
for (const eff of effectiveGrants(table, role, graph)) {
|
|
182
|
+
if (eff.via === 'direct' || eff.via === 'PUBLIC')
|
|
183
|
+
continue;
|
|
184
|
+
if (direct.has(eff.privilege))
|
|
185
|
+
continue;
|
|
186
|
+
if (!CLAUSE_REQUIRED[eff.privilege])
|
|
187
|
+
continue;
|
|
188
|
+
if (coveredForRole(table, role, eff.privilege, graph))
|
|
189
|
+
continue;
|
|
190
|
+
out.push({
|
|
191
|
+
code: 'L1',
|
|
192
|
+
severity: 'low',
|
|
193
|
+
category: 'coverage',
|
|
194
|
+
schema: table.schema,
|
|
195
|
+
table: table.name,
|
|
196
|
+
role,
|
|
197
|
+
privilege: eff.privilege,
|
|
198
|
+
message: `Role ${role} holds ${eff.privilege} on ${table.schema}.${table.name} (${eff.via}) but no applicable permissive policy can admit it — a dead inherited grant`,
|
|
199
|
+
hint: `Either add a policy covering ${role} (or the granted role) or revoke ${eff.privilege} from the granted role — today the grant admits nothing.`
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* L2: a permissive policy names a role that holds no grant the policy could
|
|
207
|
+
* mediate — authorization written for a role that cannot reach the table.
|
|
208
|
+
* Either the grant was forgotten (a broken feature) or the policy is stale.
|
|
209
|
+
*/
|
|
210
|
+
export function checkDeadPolicies(table, graph) {
|
|
211
|
+
if (!table.rlsEnabled)
|
|
212
|
+
return [];
|
|
213
|
+
const out = [];
|
|
214
|
+
const anyGrantee = table.grants.some((g) => g.role !== table.owner)
|
|
215
|
+
|| table.columnGrants.some((g) => g.role !== table.owner);
|
|
216
|
+
for (const policy of table.policies) {
|
|
217
|
+
if (!policy.permissive)
|
|
218
|
+
continue;
|
|
219
|
+
const privileges = POLICY_CMDS[policy.cmd];
|
|
220
|
+
if (policy.roles.includes('PUBLIC')) {
|
|
221
|
+
if (!anyGrantee) {
|
|
222
|
+
out.push(deadPolicyFinding(table, policy, 'PUBLIC', `Permissive policy ${policy.name} on ${table.schema}.${table.name} applies to PUBLIC but no role holds any grant on the table`));
|
|
223
|
+
}
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
for (const role of policy.roles) {
|
|
227
|
+
const attrs = graph.get(role);
|
|
228
|
+
if (attrs?.bypassRls)
|
|
229
|
+
continue; // policies never apply to it anyway
|
|
230
|
+
// A column grant is a grant: the policy it mediates is not dead just
|
|
231
|
+
// because the privilege is scoped to a projection of the relation.
|
|
232
|
+
const held = [
|
|
233
|
+
...effectiveGrants(table, role, graph),
|
|
234
|
+
...effectiveColumnGrants(table, role, graph)
|
|
235
|
+
].map((e) => e.privilege);
|
|
236
|
+
if (privileges.some((p) => held.includes(p)))
|
|
237
|
+
continue;
|
|
238
|
+
out.push(deadPolicyFinding(table, policy, role, `Permissive ${policy.cmd} policy ${policy.name} on ${table.schema}.${table.name} applies to ${role}, but ${role} holds no ${privileges.join('/')} grant (directly, via PUBLIC, or by inheritance)`));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
function deadPolicyFinding(table, policy, role, message) {
|
|
244
|
+
return {
|
|
245
|
+
code: 'L2',
|
|
246
|
+
severity: 'low',
|
|
247
|
+
category: 'coverage',
|
|
248
|
+
schema: table.schema,
|
|
249
|
+
table: table.name,
|
|
250
|
+
policy: policy.name,
|
|
251
|
+
role,
|
|
252
|
+
message,
|
|
253
|
+
hint: 'A policy is not a grant: without the underlying privilege the role gets nothing. Grant the privilege if the access is intended, or drop the stale policy.'
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* L3: an object grant the grantee cannot use because it lacks USAGE on the
|
|
258
|
+
* schema — direct, via PUBLIC, or inherited. The grant is unreachable today
|
|
259
|
+
* and activates silently the day USAGE is granted.
|
|
260
|
+
*/
|
|
261
|
+
export function checkUnreachableGrants(table, schemaAcls, graph) {
|
|
262
|
+
const acl = schemaAcls.get(table.schema);
|
|
263
|
+
if (!acl)
|
|
264
|
+
return [];
|
|
265
|
+
const usageRoles = new Set(acl.grants.filter((g) => g.privilege === 'USAGE').map((g) => g.role));
|
|
266
|
+
const byRole = new Map();
|
|
267
|
+
for (const g of table.grants) {
|
|
268
|
+
if (g.role === 'PUBLIC')
|
|
269
|
+
continue; // per-role USAGE cannot be asserted for PUBLIC grantees
|
|
270
|
+
const attrs = graph.get(g.role);
|
|
271
|
+
if (!attrs || attrs.isSuper)
|
|
272
|
+
continue; // superusers skip ACL checks entirely
|
|
273
|
+
if (g.role === acl.owner)
|
|
274
|
+
continue;
|
|
275
|
+
if (usageRoles.has('PUBLIC') || usageRoles.has(g.role))
|
|
276
|
+
continue;
|
|
277
|
+
if (attrs.inheritsFrom.some((a) => usageRoles.has(a) || a === acl.owner))
|
|
278
|
+
continue;
|
|
279
|
+
byRole.set(g.role, [...(byRole.get(g.role) ?? []), g.privilege]);
|
|
280
|
+
}
|
|
281
|
+
return [...byRole.entries()].map(([role, privileges]) => ({
|
|
282
|
+
code: 'L3',
|
|
283
|
+
severity: 'low',
|
|
284
|
+
category: 'coverage',
|
|
285
|
+
schema: table.schema,
|
|
286
|
+
table: table.name,
|
|
287
|
+
role,
|
|
288
|
+
privilege: [...new Set(privileges)].sort().join(', '),
|
|
289
|
+
message: `Role ${role} is granted ${[...new Set(privileges)].sort().join(', ')} on ${table.schema}.${table.name} but has no USAGE on schema ${table.schema} — the grant is unreachable`,
|
|
290
|
+
hint: 'Grant USAGE on the schema if the access is intended, or revoke the unreachable object grant so it cannot activate silently later.'
|
|
291
|
+
}));
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* L4: schema USAGE that opens the door to nothing — the role reaches no
|
|
295
|
+
* relation (directly, via PUBLIC, or by inheritance) and can EXECUTE no
|
|
296
|
+
* function in the schema. Advisory only: sequences, types and future objects
|
|
297
|
+
* are not modeled, so this is a review candidate, not a proof.
|
|
298
|
+
*
|
|
299
|
+
* `views` matters more than its optionality suggests: a role whose only
|
|
300
|
+
* reachable object in the schema is a view holds USAGE that is load-bearing,
|
|
301
|
+
* and L8 exists precisely because that view can be the role's entire read
|
|
302
|
+
* path. Called without them, L4 sees a table-only schema and reports a grant
|
|
303
|
+
* the API depends on as revokable — so pass them wherever they are available.
|
|
304
|
+
*/
|
|
305
|
+
export function checkDeadSchemaUsage(schemaAcls, tables, graph, views = []) {
|
|
306
|
+
const tablesBySchema = new Map();
|
|
307
|
+
for (const t of tables) {
|
|
308
|
+
tablesBySchema.set(t.schema, [...(tablesBySchema.get(t.schema) ?? []), t]);
|
|
309
|
+
}
|
|
310
|
+
const viewsBySchema = new Map();
|
|
311
|
+
for (const v of views) {
|
|
312
|
+
viewsBySchema.set(v.schema, [...(viewsBySchema.get(v.schema) ?? []), v]);
|
|
313
|
+
}
|
|
314
|
+
const out = [];
|
|
315
|
+
for (const acl of schemaAcls) {
|
|
316
|
+
const schemaTables = tablesBySchema.get(acl.schema) ?? [];
|
|
317
|
+
const executes = new Set(acl.executeRoles);
|
|
318
|
+
for (const g of acl.grants) {
|
|
319
|
+
if (g.privilege !== 'USAGE' || g.role === 'PUBLIC')
|
|
320
|
+
continue;
|
|
321
|
+
const attrs = graph.get(g.role);
|
|
322
|
+
if (!attrs || attrs.isSuper)
|
|
323
|
+
continue;
|
|
324
|
+
if (g.role === acl.owner)
|
|
325
|
+
continue;
|
|
326
|
+
const reachesRelation = [...schemaTables, ...(viewsBySchema.get(acl.schema) ?? [])].some((r) => effectiveGrants(r, g.role, graph).length > 0)
|
|
327
|
+
// A column grant is reach the relation's own ACL does not show, and
|
|
328
|
+
// the USAGE it needs is every bit as load-bearing.
|
|
329
|
+
|| schemaTables.some((t) => effectiveColumnGrants(t, g.role, graph).length > 0);
|
|
330
|
+
if (reachesRelation)
|
|
331
|
+
continue;
|
|
332
|
+
const reachesFunction = executes.has('PUBLIC')
|
|
333
|
+
|| executes.has(g.role)
|
|
334
|
+
|| attrs.inheritsFrom.some((a) => executes.has(a));
|
|
335
|
+
if (reachesFunction)
|
|
336
|
+
continue;
|
|
337
|
+
out.push({
|
|
338
|
+
code: 'L4',
|
|
339
|
+
severity: 'info',
|
|
340
|
+
category: 'coverage',
|
|
341
|
+
schema: acl.schema,
|
|
342
|
+
role: g.role,
|
|
343
|
+
privilege: 'USAGE',
|
|
344
|
+
message: `Role ${g.role} has USAGE on schema ${acl.schema} but reaches no relation and can execute no function in it — dead schema USAGE`,
|
|
345
|
+
hint: 'Advisory: sequences, types and default privileges are not modeled. If nothing in the schema is meant for this role, revoke USAGE to keep the surface airtight.'
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* L6: an API-edge role holds privileges on a relation its own API cannot
|
|
353
|
+
* address.
|
|
354
|
+
*
|
|
355
|
+
* The composition the two halves of safegres were built for: the lattice knows
|
|
356
|
+
* what a role effectively holds, exposure reach knows what the generated API
|
|
357
|
+
* can name, and the difference is grant surface that exists for nothing. It is
|
|
358
|
+
* not a leak — the role would have to connect directly to use it — which is
|
|
359
|
+
* exactly why it is the kind of finding that survives for years.
|
|
360
|
+
*/
|
|
361
|
+
export function checkUnaddressableGrant(table, graph, unaddressable, options) {
|
|
362
|
+
const key = `${table.schema}.${table.name}`;
|
|
363
|
+
if (!unaddressable.has(key) || options.policyReferenced.has(key))
|
|
364
|
+
return [];
|
|
365
|
+
const out = [];
|
|
366
|
+
for (const role of options.roles) {
|
|
367
|
+
const grants = effectiveGrants(table, role, graph).filter((e) => RLS_PRIVILEGES.includes(e.privilege));
|
|
368
|
+
if (grants.length === 0)
|
|
369
|
+
continue;
|
|
370
|
+
const privileges = grants.map((e) => e.privilege).join(', ');
|
|
371
|
+
out.push({
|
|
372
|
+
code: 'L6',
|
|
373
|
+
severity: 'info',
|
|
374
|
+
category: 'coverage',
|
|
375
|
+
schema: table.schema,
|
|
376
|
+
table: table.name,
|
|
377
|
+
role,
|
|
378
|
+
privilege: privileges,
|
|
379
|
+
message: `API role ${role} holds ${privileges} on ${key}, which the generated API declares it cannot address — the grant serves no request`,
|
|
380
|
+
hint: 'Revoke the grant, or drop the behavior denial if the relation is meant to be exposed. No policy predicate references this relation, so nothing else depends on the grant.'
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* L5: an untrusted role reaches an RLS-disabled table *indirectly* — through
|
|
387
|
+
* a grant TO PUBLIC or by inheriting from a granted role. Direct grants are
|
|
388
|
+
* A2/R1 territory; the indirect paths are exactly the ones that slip past
|
|
389
|
+
* exact-name matching (e.g. a world-readable table reached by `anonymous`
|
|
390
|
+
* through PUBLIC).
|
|
391
|
+
*/
|
|
392
|
+
export function checkUntrustedIndirectAccess(table, graph, options = {}) {
|
|
393
|
+
const untrusted = options.roles ?? [];
|
|
394
|
+
if (untrusted.length === 0 || table.rlsEnabled)
|
|
395
|
+
return [];
|
|
396
|
+
const out = [];
|
|
397
|
+
for (const role of untrusted) {
|
|
398
|
+
const indirect = effectiveGrants(table, role, graph).filter((e) => e.via !== 'direct' && RLS_PRIVILEGES.includes(e.privilege));
|
|
399
|
+
if (indirect.length === 0)
|
|
400
|
+
continue;
|
|
401
|
+
const privileges = indirect.map((e) => e.privilege).join(', ');
|
|
402
|
+
const vias = [...new Set(indirect.map((e) => e.via))].join(', ');
|
|
403
|
+
out.push({
|
|
404
|
+
code: 'L5',
|
|
405
|
+
severity: 'info',
|
|
406
|
+
category: 'anti-pattern',
|
|
407
|
+
schema: table.schema,
|
|
408
|
+
table: table.name,
|
|
409
|
+
role,
|
|
410
|
+
privilege: privileges,
|
|
411
|
+
message: `Untrusted role ${role} reaches ${table.schema}.${table.name} (${privileges}) via ${vias} — RLS is disabled, so every row is visible`,
|
|
412
|
+
hint: 'Narrow the grant to the trusted roles that need it, or enable RLS. PUBLIC and inherited grants apply to untrusted roles even though no ACL row names them.'
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
return out;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* The direct answer to "what can role X see?": every relation the role
|
|
419
|
+
* effectively reaches, classified by whether RLS mediates the access.
|
|
420
|
+
*/
|
|
421
|
+
export function computeRoleAccess(tables, graph, roles) {
|
|
422
|
+
const out = [];
|
|
423
|
+
for (const role of roles) {
|
|
424
|
+
const attrs = graph.get(role);
|
|
425
|
+
const entry = {
|
|
426
|
+
role,
|
|
427
|
+
accessibleTables: 0,
|
|
428
|
+
unmediated: [],
|
|
429
|
+
mediated: 0,
|
|
430
|
+
dead: 0
|
|
431
|
+
};
|
|
432
|
+
for (const table of tables) {
|
|
433
|
+
const grants = effectiveGrants(table, role, graph).filter((e) => RLS_PRIVILEGES.includes(e.privilege));
|
|
434
|
+
// Column grants are reported only when the relation is reachable by
|
|
435
|
+
// nothing else — otherwise the whole-relation row already covers them.
|
|
436
|
+
const columnGrants = grants.length > 0
|
|
437
|
+
? []
|
|
438
|
+
: effectiveColumnGrants(table, role, graph).filter((e) => RLS_PRIVILEGES.includes(e.privilege));
|
|
439
|
+
if (grants.length === 0 && columnGrants.length === 0)
|
|
440
|
+
continue;
|
|
441
|
+
const columns = columnGrants.length > 0
|
|
442
|
+
? [...new Set(columnGrants.flatMap((g) => g.columns))].sort()
|
|
443
|
+
: undefined;
|
|
444
|
+
const effective = grants.length > 0 ? grants : columnGrants;
|
|
445
|
+
const privileges = effective.map((g) => g.privilege);
|
|
446
|
+
const via = effective.reduce((best, g) => (rankVia(g.via) < rankVia(best) ? g.via : best), effective[0].via);
|
|
447
|
+
if (!table.rlsEnabled || attrs?.bypassRls || (role === table.owner && !table.rlsForced)) {
|
|
448
|
+
entry.accessibleTables += 1;
|
|
449
|
+
entry.unmediated.push({
|
|
450
|
+
schema: table.schema,
|
|
451
|
+
table: table.name,
|
|
452
|
+
privileges,
|
|
453
|
+
via,
|
|
454
|
+
...(columns ? { columns } : {}),
|
|
455
|
+
access: 'unmediated'
|
|
456
|
+
});
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
const usable = privileges.some((p) => coveredForRole(table, role, p, graph));
|
|
460
|
+
if (usable) {
|
|
461
|
+
entry.accessibleTables += 1;
|
|
462
|
+
entry.mediated += 1;
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
entry.dead += 1;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
entry.unmediated.sort((a, b) => a.schema === b.schema ? a.table.localeCompare(b.table) : a.schema.localeCompare(b.schema));
|
|
469
|
+
out.push(entry);
|
|
470
|
+
}
|
|
471
|
+
return out;
|
|
472
|
+
}
|
|
473
|
+
function rankVia(via) {
|
|
474
|
+
return via === 'direct' ? 0 : via === 'PUBLIC' ? 1 : 2;
|
|
475
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L16/L17: privileges on the relations that are not tables.
|
|
3
|
+
*
|
|
4
|
+
* Every rule in this package grades a table, a view or a schema, so two kinds
|
|
5
|
+
* of object have carried real privileges past the audit untouched.
|
|
6
|
+
*
|
|
7
|
+
* **L16 — sequences.** `USAGE` or `UPDATE` on a sequence is the right to call
|
|
8
|
+
* `nextval` and `setval`: an untrusted role can burn the identifier space of
|
|
9
|
+
* whatever the sequence feeds, or reset the counter so the next insert
|
|
10
|
+
* collides with an existing row. `SELECT` is the right to read `last_value`,
|
|
11
|
+
* which is a live row-count estimate for the owning table and the classic way
|
|
12
|
+
* an "how many customers do they have" question gets answered through an API
|
|
13
|
+
* that exposes none of them. None of it is row-filterable: RLS does not apply
|
|
14
|
+
* to sequences.
|
|
15
|
+
*
|
|
16
|
+
* **L17 — foreign tables.** A foreign table cannot carry RLS *at all* —
|
|
17
|
+
* Postgres rejects `ALTER FOREIGN TABLE ... ENABLE ROW LEVEL SECURITY` (18) —
|
|
18
|
+
* so a grant on one is unconditionally unfiltered. That makes it strictly
|
|
19
|
+
* worse than the A2 shape it resembles: A2 says "grants and no RLS", and its
|
|
20
|
+
* remedy is to add RLS, which here does not exist as an option.
|
|
21
|
+
*
|
|
22
|
+
* Both are `info` and score-neutral to start, and neither recommends a revoke
|
|
23
|
+
* it cannot justify. That constraint has teeth on sequences in particular: a
|
|
24
|
+
* role holding `INSERT` on a table with a `serial` column *must* hold `USAGE`
|
|
25
|
+
* on its sequence, so the `OWNED BY` link is reported alongside the grant and
|
|
26
|
+
* the remedy leads with identity columns rather than with `REVOKE`.
|
|
27
|
+
*/
|
|
28
|
+
import type { ObjectAclSnapshot } from '../pg/objects';
|
|
29
|
+
import type { Finding } from '../types';
|
|
30
|
+
import { type LatticeRoleOptions, type RoleGraph } from './lattice';
|
|
31
|
+
export declare function checkUntrustedSequenceGrants(objects: ObjectAclSnapshot[], graph: RoleGraph, options?: LatticeRoleOptions): Finding[];
|
|
32
|
+
export declare function checkUntrustedForeignTableGrants(objects: ObjectAclSnapshot[], graph: RoleGraph, options?: LatticeRoleOptions): Finding[];
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L16/L17: privileges on the relations that are not tables.
|
|
3
|
+
*
|
|
4
|
+
* Every rule in this package grades a table, a view or a schema, so two kinds
|
|
5
|
+
* of object have carried real privileges past the audit untouched.
|
|
6
|
+
*
|
|
7
|
+
* **L16 — sequences.** `USAGE` or `UPDATE` on a sequence is the right to call
|
|
8
|
+
* `nextval` and `setval`: an untrusted role can burn the identifier space of
|
|
9
|
+
* whatever the sequence feeds, or reset the counter so the next insert
|
|
10
|
+
* collides with an existing row. `SELECT` is the right to read `last_value`,
|
|
11
|
+
* which is a live row-count estimate for the owning table and the classic way
|
|
12
|
+
* an "how many customers do they have" question gets answered through an API
|
|
13
|
+
* that exposes none of them. None of it is row-filterable: RLS does not apply
|
|
14
|
+
* to sequences.
|
|
15
|
+
*
|
|
16
|
+
* **L17 — foreign tables.** A foreign table cannot carry RLS *at all* —
|
|
17
|
+
* Postgres rejects `ALTER FOREIGN TABLE ... ENABLE ROW LEVEL SECURITY` (18) —
|
|
18
|
+
* so a grant on one is unconditionally unfiltered. That makes it strictly
|
|
19
|
+
* worse than the A2 shape it resembles: A2 says "grants and no RLS", and its
|
|
20
|
+
* remedy is to add RLS, which here does not exist as an option.
|
|
21
|
+
*
|
|
22
|
+
* Both are `info` and score-neutral to start, and neither recommends a revoke
|
|
23
|
+
* it cannot justify. That constraint has teeth on sequences in particular: a
|
|
24
|
+
* role holding `INSERT` on a table with a `serial` column *must* hold `USAGE`
|
|
25
|
+
* on its sequence, so the `OWNED BY` link is reported alongside the grant and
|
|
26
|
+
* the remedy leads with identity columns rather than with `REVOKE`.
|
|
27
|
+
*/
|
|
28
|
+
import { effectiveGrants } from './lattice';
|
|
29
|
+
/** Advancing the counter, versus reading where it has got to. */
|
|
30
|
+
const WRITE_PRIVILEGES = ['USAGE', 'UPDATE'];
|
|
31
|
+
export function checkUntrustedSequenceGrants(objects, graph, options = {}) {
|
|
32
|
+
const roles = options.roles ?? [];
|
|
33
|
+
const sequences = objects.filter((o) => o.kind === 'sequence');
|
|
34
|
+
if (roles.length === 0 || sequences.length === 0)
|
|
35
|
+
return [];
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const seq of sequences) {
|
|
38
|
+
for (const role of roles) {
|
|
39
|
+
const grants = effectiveGrants(seq, role, graph);
|
|
40
|
+
if (grants.length === 0)
|
|
41
|
+
continue;
|
|
42
|
+
const privileges = [...new Set(grants.map((g) => g.privilege))].sort();
|
|
43
|
+
const advances = privileges.some((p) => WRITE_PRIVILEGES.includes(p));
|
|
44
|
+
const reads = privileges.includes('SELECT');
|
|
45
|
+
const effects = [];
|
|
46
|
+
if (advances) {
|
|
47
|
+
effects.push('call `nextval`/`setval` — consuming the identifier space, or resetting the '
|
|
48
|
+
+ 'counter so the next insert collides');
|
|
49
|
+
}
|
|
50
|
+
if (reads) {
|
|
51
|
+
effects.push('read `last_value`, a live estimate of how many rows the owning table has '
|
|
52
|
+
+ 'taken');
|
|
53
|
+
}
|
|
54
|
+
out.push({
|
|
55
|
+
code: 'L16',
|
|
56
|
+
severity: 'info',
|
|
57
|
+
category: 'anti-pattern',
|
|
58
|
+
schema: seq.schema,
|
|
59
|
+
table: seq.name,
|
|
60
|
+
role,
|
|
61
|
+
privilege: privileges[0],
|
|
62
|
+
message: `Untrusted role ${role} holds ${privileges.join(', ')} on sequence `
|
|
63
|
+
+ `${seq.schema}.${seq.name} (${grants[0].via}), so it can ${effects.join(', and ')}`
|
|
64
|
+
+ (seq.ownedBy ? ` — the sequence feeds ${seq.ownedBy}` : ''),
|
|
65
|
+
hint: seq.ownedBy
|
|
66
|
+
? `Check whether ${role} inserts into ${seq.ownedBy.slice(0, seq.ownedBy.lastIndexOf('.'))} `
|
|
67
|
+
+ 'before touching this: a role writing a `serial` column needs USAGE, and revoking it '
|
|
68
|
+
+ 'breaks the insert. `GENERATED ... AS IDENTITY` needs no grant on the sequence at '
|
|
69
|
+
+ 'all, which is the fix that does not trade one break for another. Drop SELECT '
|
|
70
|
+
+ 'separately — reading `last_value` is never needed to insert.'
|
|
71
|
+
: 'Nothing row-filters a sequence: RLS does not apply to one. If the role does not '
|
|
72
|
+
+ 'insert into a table this sequence feeds, the grant confers only the ability to '
|
|
73
|
+
+ 'consume or read the counter — confirm that before revoking, since an `OWNED BY` '
|
|
74
|
+
+ 'link is not the only way an insert can depend on it (a DEFAULT can name it).',
|
|
75
|
+
context: {
|
|
76
|
+
objectKind: 'sequence',
|
|
77
|
+
privileges,
|
|
78
|
+
via: grants[0].via,
|
|
79
|
+
...(seq.ownedBy ? { ownedBy: seq.ownedBy } : {})
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
export function checkUntrustedForeignTableGrants(objects, graph, options = {}) {
|
|
87
|
+
const roles = options.roles ?? [];
|
|
88
|
+
const foreign = objects.filter((o) => o.kind === 'foreign table');
|
|
89
|
+
if (roles.length === 0 || foreign.length === 0)
|
|
90
|
+
return [];
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const ft of foreign) {
|
|
93
|
+
for (const role of roles) {
|
|
94
|
+
const grants = effectiveGrants(ft, role, graph);
|
|
95
|
+
if (grants.length === 0)
|
|
96
|
+
continue;
|
|
97
|
+
const privileges = [...new Set(grants.map((g) => g.privilege))].sort();
|
|
98
|
+
out.push({
|
|
99
|
+
code: 'L17',
|
|
100
|
+
severity: 'info',
|
|
101
|
+
category: 'anti-pattern',
|
|
102
|
+
schema: ft.schema,
|
|
103
|
+
table: ft.name,
|
|
104
|
+
role,
|
|
105
|
+
privilege: privileges[0],
|
|
106
|
+
message: `Untrusted role ${role} holds ${privileges.join(', ')} on foreign table `
|
|
107
|
+
+ `${ft.schema}.${ft.name}${ft.server ? ` (server ${ft.server})` : ''} `
|
|
108
|
+
+ `(${grants[0].via}) — a foreign table cannot carry RLS, so every row the remote side `
|
|
109
|
+
+ 'returns is returned',
|
|
110
|
+
hint: 'Postgres rejects `ENABLE ROW LEVEL SECURITY` on a foreign table, so unlike an ordinary '
|
|
111
|
+
+ 'grant-without-RLS this one cannot be fixed by adding a policy. Expose it through a '
|
|
112
|
+
+ 'view that carries the filter (and grant on the view instead), or move the filter to '
|
|
113
|
+
+ 'the foreign table\'s own options/remote side. Confirm the API does not serve this '
|
|
114
|
+
+ 'relation before revoking.',
|
|
115
|
+
context: {
|
|
116
|
+
objectKind: 'foreign table',
|
|
117
|
+
privileges,
|
|
118
|
+
via: grants[0].via,
|
|
119
|
+
...(ft.server ? { server: ft.server } : {})
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|