@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,91 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.checkUntrustedRoleWrites = checkUntrustedRoleWrites;
|
|
4
|
+
exports.checkUntrustedRolePolicies = checkUntrustedRolePolicies;
|
|
5
|
+
exports.checkPublicGrants = checkPublicGrants;
|
|
6
|
+
const WRITE_PRIVILEGES = ['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE'];
|
|
7
|
+
/**
|
|
8
|
+
* R1: an untrusted role holds a write privilege on a table. Even with
|
|
9
|
+
* airtight policies, write access for e.g. `anonymous` is almost always a
|
|
10
|
+
* grant mistake — unauthenticated actors can INSERT/UPDATE/DELETE.
|
|
11
|
+
*/
|
|
12
|
+
function checkUntrustedRoleWrites(table, options = {}) {
|
|
13
|
+
const untrusted = new Set(options.roles ?? []);
|
|
14
|
+
if (untrusted.size === 0)
|
|
15
|
+
return [];
|
|
16
|
+
const out = [];
|
|
17
|
+
for (const grant of table.grants) {
|
|
18
|
+
if (!untrusted.has(grant.role))
|
|
19
|
+
continue;
|
|
20
|
+
if (!WRITE_PRIVILEGES.includes(grant.privilege))
|
|
21
|
+
continue;
|
|
22
|
+
out.push({
|
|
23
|
+
code: 'R1',
|
|
24
|
+
severity: 'critical',
|
|
25
|
+
category: 'anti-pattern',
|
|
26
|
+
schema: table.schema,
|
|
27
|
+
table: table.name,
|
|
28
|
+
role: grant.role,
|
|
29
|
+
privilege: grant.privilege,
|
|
30
|
+
message: `Untrusted role ${grant.role} has ${grant.privilege} grant on ${table.schema}.${table.name}`,
|
|
31
|
+
hint: `Revoke ${grant.privilege} from ${grant.role} unless unauthenticated writes to this table are intentional (e.g. a public signup or event-ingest table).`
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* R2: a permissive policy makes write operations pass RLS for an untrusted
|
|
38
|
+
* role (directly or via PUBLIC). Pairs with R1: the grant is the door, the
|
|
39
|
+
* policy is the unlocked latch.
|
|
40
|
+
*/
|
|
41
|
+
function checkUntrustedRolePolicies(table, options = {}) {
|
|
42
|
+
const untrusted = new Set(options.roles ?? []);
|
|
43
|
+
if (untrusted.size === 0 || !table.rlsEnabled)
|
|
44
|
+
return [];
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const policy of table.policies) {
|
|
47
|
+
if (!policy.permissive)
|
|
48
|
+
continue;
|
|
49
|
+
if (policy.cmd === 'SELECT')
|
|
50
|
+
continue;
|
|
51
|
+
const applies = policy.roles.filter((r) => r === 'PUBLIC' || untrusted.has(r));
|
|
52
|
+
if (applies.length === 0)
|
|
53
|
+
continue;
|
|
54
|
+
const via = policy.roles.includes('PUBLIC') ? 'PUBLIC (all roles)' : applies.join(', ');
|
|
55
|
+
out.push({
|
|
56
|
+
code: 'R2',
|
|
57
|
+
severity: 'high',
|
|
58
|
+
category: 'anti-pattern',
|
|
59
|
+
schema: table.schema,
|
|
60
|
+
table: table.name,
|
|
61
|
+
policy: policy.name,
|
|
62
|
+
message: `Permissive ${policy.cmd} policy ${policy.name} on ${table.schema}.${table.name} applies to untrusted role via ${via}`,
|
|
63
|
+
hint: 'Scope the policy TO specific trusted roles instead of PUBLIC/untrusted roles, or verify unauthenticated writes are intended.'
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* R3: a table with RLS enabled has grants TO PUBLIC. PUBLIC includes every
|
|
70
|
+
* present and future role, which silently widens access as roles are added
|
|
71
|
+
* and defeats role-scoped policy reasoning.
|
|
72
|
+
*/
|
|
73
|
+
function checkPublicGrants(table) {
|
|
74
|
+
if (!table.rlsEnabled)
|
|
75
|
+
return [];
|
|
76
|
+
const publicPrivs = table.grants.filter((g) => g.role === 'PUBLIC').map((g) => g.privilege);
|
|
77
|
+
if (publicPrivs.length === 0)
|
|
78
|
+
return [];
|
|
79
|
+
return [
|
|
80
|
+
{
|
|
81
|
+
code: 'R3',
|
|
82
|
+
severity: 'medium',
|
|
83
|
+
category: 'anti-pattern',
|
|
84
|
+
schema: table.schema,
|
|
85
|
+
table: table.name,
|
|
86
|
+
role: 'PUBLIC',
|
|
87
|
+
message: `Table ${table.schema}.${table.name} has RLS enabled but grants ${publicPrivs.join(', ')} to PUBLIC`,
|
|
88
|
+
hint: 'Grant to specific roles instead of PUBLIC — PUBLIC includes every current and future role, including untrusted ones.'
|
|
89
|
+
}
|
|
90
|
+
];
|
|
91
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L7: an untrusted role can `SET ROLE` to a role with materially greater
|
|
3
|
+
* reach.
|
|
4
|
+
*
|
|
5
|
+
* The A/R/L rules all reason about privileges a role holds *passively* — a
|
|
6
|
+
* direct grant, a grant TO PUBLIC, or one it INHERITs. On PG16+ a membership
|
|
7
|
+
* can also confer the ability to `SET ROLE` to a role *without* inheriting its
|
|
8
|
+
* privileges (`pg_auth_members.set_option = true`, `inherit_option = false`),
|
|
9
|
+
* and that path is invisible to every passive-grant check: nothing the catalog
|
|
10
|
+
* shows for the untrusted role names the relation, yet an unauthenticated
|
|
11
|
+
* caller can assume the target role and execute with its full privileges.
|
|
12
|
+
*
|
|
13
|
+
* This flags, per relation, the privileges an untrusted role gains only by
|
|
14
|
+
* assuming another role. It is catalog-proven and, at its shipping severity
|
|
15
|
+
* (`info`), signal-only — see the registry entry for the intended severity
|
|
16
|
+
* once the rule has proven itself in the field.
|
|
17
|
+
*/
|
|
18
|
+
import type { TableSnapshot } from '../pg/introspect';
|
|
19
|
+
import type { Finding } from '../types';
|
|
20
|
+
import type { LatticeRoleOptions, RoleGraph } from './lattice';
|
|
21
|
+
export declare function checkSetRoleEscalation(table: TableSnapshot, graph: RoleGraph, options?: LatticeRoleOptions): Finding[];
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* L7: an untrusted role can `SET ROLE` to a role with materially greater
|
|
4
|
+
* reach.
|
|
5
|
+
*
|
|
6
|
+
* The A/R/L rules all reason about privileges a role holds *passively* — a
|
|
7
|
+
* direct grant, a grant TO PUBLIC, or one it INHERITs. On PG16+ a membership
|
|
8
|
+
* can also confer the ability to `SET ROLE` to a role *without* inheriting its
|
|
9
|
+
* privileges (`pg_auth_members.set_option = true`, `inherit_option = false`),
|
|
10
|
+
* and that path is invisible to every passive-grant check: nothing the catalog
|
|
11
|
+
* shows for the untrusted role names the relation, yet an unauthenticated
|
|
12
|
+
* caller can assume the target role and execute with its full privileges.
|
|
13
|
+
*
|
|
14
|
+
* This flags, per relation, the privileges an untrusted role gains only by
|
|
15
|
+
* assuming another role. It is catalog-proven and, at its shipping severity
|
|
16
|
+
* (`info`), signal-only — see the registry entry for the intended severity
|
|
17
|
+
* once the rule has proven itself in the field.
|
|
18
|
+
*/
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.checkSetRoleEscalation = checkSetRoleEscalation;
|
|
21
|
+
const role_reach_1 = require("./role-reach");
|
|
22
|
+
function checkSetRoleEscalation(table, graph, options = {}) {
|
|
23
|
+
const untrusted = options.roles ?? [];
|
|
24
|
+
if (untrusted.length === 0)
|
|
25
|
+
return [];
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const { role, cells } of (0, role_reach_1.computeRoleReach)([table], graph, untrusted)) {
|
|
28
|
+
const passive = new Set(cells.filter((c) => c.effectiveRole === role).flatMap((c) => c.privileges));
|
|
29
|
+
// One finding per (untrusted role, assumed role) that adds reach: collapse
|
|
30
|
+
// the per-target cells so a role reachable by two SET ROLE hops is reported
|
|
31
|
+
// once, on the most privilege it grants.
|
|
32
|
+
for (const cell of cells) {
|
|
33
|
+
if (cell.effectiveRole === role)
|
|
34
|
+
continue;
|
|
35
|
+
const gained = cell.privileges.filter((p) => !passive.has(p));
|
|
36
|
+
if (gained.length === 0)
|
|
37
|
+
continue;
|
|
38
|
+
const target = cell.effectiveRole;
|
|
39
|
+
const attrs = graph.get(target);
|
|
40
|
+
const bypassesRls = !!attrs?.bypassRls && !graph.get(role)?.bypassRls;
|
|
41
|
+
const privileges = gained.join(', ');
|
|
42
|
+
out.push({
|
|
43
|
+
code: 'L7',
|
|
44
|
+
severity: 'info',
|
|
45
|
+
category: 'anti-pattern',
|
|
46
|
+
schema: table.schema,
|
|
47
|
+
table: table.name,
|
|
48
|
+
role,
|
|
49
|
+
privilege: privileges,
|
|
50
|
+
message: `Untrusted role ${role} can SET ROLE to ${target}, gaining ${privileges} on `
|
|
51
|
+
+ `${table.schema}.${table.name} that it holds no passive grant for`
|
|
52
|
+
+ (bypassesRls ? ` (and ${target} bypasses RLS)` : ''),
|
|
53
|
+
hint: `SET ROLE grants ${role} the full privileges of ${target} on demand — passive-grant `
|
|
54
|
+
+ `checks (A2/R1/L5) cannot see it. Revoke the membership, or set it \`WITH SET FALSE\` `
|
|
55
|
+
+ `so ${role} inherits only what is intended and cannot assume ${target}.`,
|
|
56
|
+
context: {
|
|
57
|
+
effectiveRole: target,
|
|
58
|
+
targetBypassesRls: bypassesRls,
|
|
59
|
+
targetIsSuperuser: !!attrs?.isSuper,
|
|
60
|
+
gained
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime-statistics checks (`--stats`, `S*`).
|
|
3
|
+
*
|
|
4
|
+
* These read the cumulative statistics views rather than the catalog, so
|
|
5
|
+
* unlike the `X*` rules they describe a workload rather than a schema: the
|
|
6
|
+
* same database can pass or fail depending on what traffic it has served.
|
|
7
|
+
* Every rule therefore takes a floor, so a cold or freshly-reset database
|
|
8
|
+
* produces silence instead of noise.
|
|
9
|
+
*/
|
|
10
|
+
import type { StatementUsage, StatsSnapshot, TableUsage } from '../pg/stats';
|
|
11
|
+
import type { Finding } from '../types';
|
|
12
|
+
export interface StatsThresholds {
|
|
13
|
+
/** Ignore tables with fewer live rows than this. Default 1000. */
|
|
14
|
+
minRows: number;
|
|
15
|
+
/** S1 fires when seqScans exceed indexScans by this factor. Default 10. */
|
|
16
|
+
seqScanRatio: number;
|
|
17
|
+
/** S2 ignores indexes smaller than this many bytes. Default 1 MiB. */
|
|
18
|
+
minIndexBytes: number;
|
|
19
|
+
/** S3 fires above this dead/live tuple ratio. Default 0.2. */
|
|
20
|
+
deadTupleRatio: number;
|
|
21
|
+
/** S4 fires for statements at or above this share of total time. Default 0.05. */
|
|
22
|
+
minTimeShare: number;
|
|
23
|
+
/** S4 reports at most this many statements. Default 5. */
|
|
24
|
+
topStatements: number;
|
|
25
|
+
}
|
|
26
|
+
export declare const DEFAULT_STATS_THRESHOLDS: StatsThresholds;
|
|
27
|
+
/**
|
|
28
|
+
* S1: a table the planner reaches by sequential scan far more often than by
|
|
29
|
+
* index. On a table above the row floor that is a missing index, a policy
|
|
30
|
+
* predicate the planner can't use, or a query shape nothing covers.
|
|
31
|
+
*/
|
|
32
|
+
export declare function checkSeqScanDominant(table: TableUsage, thresholds: StatsThresholds): Finding | null;
|
|
33
|
+
/**
|
|
34
|
+
* S2: an index the planner has never chosen. It still costs write throughput,
|
|
35
|
+
* disk, and vacuum time. Constraint-backed and unique indexes are exempt:
|
|
36
|
+
* they exist to enforce a constraint, not to be scanned.
|
|
37
|
+
*/
|
|
38
|
+
export declare function checkUnusedIndexes(table: TableUsage, thresholds: StatsThresholds): Finding[];
|
|
39
|
+
/**
|
|
40
|
+
* S3: dead tuples the vacuum is not keeping up with. Bloat inflates every
|
|
41
|
+
* scan of the table and every index on it, and the planner's row estimates
|
|
42
|
+
* drift with it.
|
|
43
|
+
*/
|
|
44
|
+
export declare function checkDeadTuples(table: TableUsage, thresholds: StatsThresholds): Finding | null;
|
|
45
|
+
/**
|
|
46
|
+
* S4: the statements the database actually spends its time in, restricted to
|
|
47
|
+
* ones touching a table in scope. This is not a defect — it is the ranked
|
|
48
|
+
* list to read the other findings against, so it is `info` by default.
|
|
49
|
+
*/
|
|
50
|
+
export declare function checkTopStatements(statements: StatementUsage[], tables: TableUsage[], thresholds: StatsThresholds): Finding[];
|
|
51
|
+
/** Every stats finding for one snapshot. */
|
|
52
|
+
export declare function checkStats(snapshot: StatsSnapshot, thresholds?: StatsThresholds): Finding[];
|
package/checks/stats.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Runtime-statistics checks (`--stats`, `S*`).
|
|
4
|
+
*
|
|
5
|
+
* These read the cumulative statistics views rather than the catalog, so
|
|
6
|
+
* unlike the `X*` rules they describe a workload rather than a schema: the
|
|
7
|
+
* same database can pass or fail depending on what traffic it has served.
|
|
8
|
+
* Every rule therefore takes a floor, so a cold or freshly-reset database
|
|
9
|
+
* produces silence instead of noise.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.DEFAULT_STATS_THRESHOLDS = void 0;
|
|
13
|
+
exports.checkSeqScanDominant = checkSeqScanDominant;
|
|
14
|
+
exports.checkUnusedIndexes = checkUnusedIndexes;
|
|
15
|
+
exports.checkDeadTuples = checkDeadTuples;
|
|
16
|
+
exports.checkTopStatements = checkTopStatements;
|
|
17
|
+
exports.checkStats = checkStats;
|
|
18
|
+
exports.DEFAULT_STATS_THRESHOLDS = {
|
|
19
|
+
minRows: 1000,
|
|
20
|
+
seqScanRatio: 10,
|
|
21
|
+
minIndexBytes: 1024 * 1024,
|
|
22
|
+
deadTupleRatio: 0.2,
|
|
23
|
+
minTimeShare: 0.05,
|
|
24
|
+
topStatements: 5
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* S1: a table the planner reaches by sequential scan far more often than by
|
|
28
|
+
* index. On a table above the row floor that is a missing index, a policy
|
|
29
|
+
* predicate the planner can't use, or a query shape nothing covers.
|
|
30
|
+
*/
|
|
31
|
+
function checkSeqScanDominant(table, thresholds) {
|
|
32
|
+
if (table.liveTuples < thresholds.minRows)
|
|
33
|
+
return null;
|
|
34
|
+
if (table.seqScans === 0)
|
|
35
|
+
return null;
|
|
36
|
+
if (table.seqScans < table.indexScans * thresholds.seqScanRatio)
|
|
37
|
+
return null;
|
|
38
|
+
// A table with no index at all is X6/X1 territory; the stats add nothing.
|
|
39
|
+
if (table.indexes.length === 0)
|
|
40
|
+
return null;
|
|
41
|
+
const perScan = Math.round(table.seqTuplesRead / Math.max(table.seqScans, 1));
|
|
42
|
+
return {
|
|
43
|
+
code: 'S1',
|
|
44
|
+
severity: 'medium',
|
|
45
|
+
category: 'index',
|
|
46
|
+
schema: table.schema,
|
|
47
|
+
table: table.name,
|
|
48
|
+
message: `${table.schema}.${table.name} is scanned sequentially ${table.seqScans} times vs ${table.indexScans} index scans (${table.liveTuples} live rows, ~${perScan} rows read per scan)`,
|
|
49
|
+
hint: 'Find the query shape behind the scans (pg_stat_statements, auto_explain) and index for it — at this row count every seq scan reads the whole table.',
|
|
50
|
+
context: {
|
|
51
|
+
seqScans: table.seqScans,
|
|
52
|
+
indexScans: table.indexScans,
|
|
53
|
+
liveTuples: table.liveTuples
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* S2: an index the planner has never chosen. It still costs write throughput,
|
|
59
|
+
* disk, and vacuum time. Constraint-backed and unique indexes are exempt:
|
|
60
|
+
* they exist to enforce a constraint, not to be scanned.
|
|
61
|
+
*/
|
|
62
|
+
function checkUnusedIndexes(table, thresholds) {
|
|
63
|
+
const findings = [];
|
|
64
|
+
for (const index of table.indexes) {
|
|
65
|
+
if (index.scans > 0)
|
|
66
|
+
continue;
|
|
67
|
+
if (index.unique || index.constraint)
|
|
68
|
+
continue;
|
|
69
|
+
if (index.sizeBytes < thresholds.minIndexBytes)
|
|
70
|
+
continue;
|
|
71
|
+
findings.push({
|
|
72
|
+
code: 'S2',
|
|
73
|
+
severity: 'low',
|
|
74
|
+
category: 'index',
|
|
75
|
+
schema: table.schema,
|
|
76
|
+
table: table.name,
|
|
77
|
+
message: `Index ${index.name} on ${table.schema}.${table.name} has never been scanned (${formatBytes(index.sizeBytes)})`,
|
|
78
|
+
hint: `DROP INDEX ${table.schema}.${index.name}; — confirm the counters cover a representative window first (see perf.stats.since).`,
|
|
79
|
+
context: { index: index.name, sizeBytes: index.sizeBytes }
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return findings;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* S3: dead tuples the vacuum is not keeping up with. Bloat inflates every
|
|
86
|
+
* scan of the table and every index on it, and the planner's row estimates
|
|
87
|
+
* drift with it.
|
|
88
|
+
*/
|
|
89
|
+
function checkDeadTuples(table, thresholds) {
|
|
90
|
+
if (table.liveTuples < thresholds.minRows)
|
|
91
|
+
return null;
|
|
92
|
+
const ratio = table.deadTuples / Math.max(table.liveTuples, 1);
|
|
93
|
+
if (ratio < thresholds.deadTupleRatio)
|
|
94
|
+
return null;
|
|
95
|
+
return {
|
|
96
|
+
code: 'S3',
|
|
97
|
+
severity: 'low',
|
|
98
|
+
category: 'index',
|
|
99
|
+
schema: table.schema,
|
|
100
|
+
table: table.name,
|
|
101
|
+
message: `${table.schema}.${table.name} is ${Math.round(ratio * 100)}% dead tuples (${table.deadTuples} dead / ${table.liveTuples} live${table.lastVacuum ? `, last vacuumed ${table.lastVacuum}` : ', never vacuumed'})`,
|
|
102
|
+
hint: 'VACUUM (ANALYZE) the table, then tune autovacuum for it (autovacuum_vacuum_scale_factor) — bloat is read by every scan and every index on the table.',
|
|
103
|
+
context: {
|
|
104
|
+
deadTuples: table.deadTuples,
|
|
105
|
+
liveTuples: table.liveTuples,
|
|
106
|
+
lastVacuum: table.lastVacuum
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* S4: the statements the database actually spends its time in, restricted to
|
|
112
|
+
* ones touching a table in scope. This is not a defect — it is the ranked
|
|
113
|
+
* list to read the other findings against, so it is `info` by default.
|
|
114
|
+
*/
|
|
115
|
+
function checkTopStatements(statements, tables, thresholds) {
|
|
116
|
+
const totalTime = statements.reduce((sum, s) => sum + s.totalTimeMs, 0);
|
|
117
|
+
if (totalTime <= 0)
|
|
118
|
+
return [];
|
|
119
|
+
const findings = [];
|
|
120
|
+
for (const statement of statements) {
|
|
121
|
+
if (findings.length >= thresholds.topStatements)
|
|
122
|
+
break;
|
|
123
|
+
const share = statement.totalTimeMs / totalTime;
|
|
124
|
+
if (share < thresholds.minTimeShare)
|
|
125
|
+
continue;
|
|
126
|
+
const matched = tables.filter((t) => statementTouches(statement.query, t));
|
|
127
|
+
if (matched.length === 0)
|
|
128
|
+
continue;
|
|
129
|
+
const relations = matched.map((t) => `${t.schema}.${t.name}`);
|
|
130
|
+
findings.push({
|
|
131
|
+
code: 'S4',
|
|
132
|
+
severity: 'info',
|
|
133
|
+
category: 'index',
|
|
134
|
+
schema: matched[0].schema,
|
|
135
|
+
table: matched[0].name,
|
|
136
|
+
message: `${Math.round(share * 100)}% of database execution time is spent in one statement over ${relations.join(', ')} (${statement.calls} calls, ${Math.round(statement.meanTimeMs)}ms mean)`,
|
|
137
|
+
hint: `EXPLAIN (ANALYZE, BUFFERS) this statement before acting on any other finding for these tables:\n ${truncate(statement.query, 300)}`,
|
|
138
|
+
context: {
|
|
139
|
+
statement: truncate(statement.query, 300),
|
|
140
|
+
calls: statement.calls,
|
|
141
|
+
totalTimeMs: Math.round(statement.totalTimeMs),
|
|
142
|
+
meanTimeMs: Math.round(statement.meanTimeMs),
|
|
143
|
+
relations
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return findings;
|
|
148
|
+
}
|
|
149
|
+
/** Every stats finding for one snapshot. */
|
|
150
|
+
function checkStats(snapshot, thresholds = exports.DEFAULT_STATS_THRESHOLDS) {
|
|
151
|
+
const findings = [];
|
|
152
|
+
for (const table of snapshot.tables) {
|
|
153
|
+
const s1 = checkSeqScanDominant(table, thresholds);
|
|
154
|
+
if (s1)
|
|
155
|
+
findings.push(s1);
|
|
156
|
+
findings.push(...checkUnusedIndexes(table, thresholds));
|
|
157
|
+
const s3 = checkDeadTuples(table, thresholds);
|
|
158
|
+
if (s3)
|
|
159
|
+
findings.push(s3);
|
|
160
|
+
}
|
|
161
|
+
if (snapshot.statements) {
|
|
162
|
+
findings.push(...checkTopStatements(snapshot.statements, snapshot.tables, thresholds));
|
|
163
|
+
}
|
|
164
|
+
return findings;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Whether a normalised statement references a table. `pg_stat_statements`
|
|
168
|
+
* stores text, not parsed relations, so this matches the qualified name or
|
|
169
|
+
* the bare name on a word boundary — good enough to rank hotspots, and
|
|
170
|
+
* deliberately not used for anything that changes a severity.
|
|
171
|
+
*/
|
|
172
|
+
function statementTouches(query, table) {
|
|
173
|
+
const qualified = new RegExp(`\\b${escapeRegExp(table.schema)}\\.${escapeRegExp(table.name)}\\b`, 'i');
|
|
174
|
+
if (qualified.test(query))
|
|
175
|
+
return true;
|
|
176
|
+
const bare = new RegExp(`\\b(from|join|into|update)\\s+"?${escapeRegExp(table.name)}"?\\b`, 'i');
|
|
177
|
+
return bare.test(query);
|
|
178
|
+
}
|
|
179
|
+
function escapeRegExp(value) {
|
|
180
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
181
|
+
}
|
|
182
|
+
function truncate(value, max) {
|
|
183
|
+
const collapsed = value.replace(/\s+/g, ' ').trim();
|
|
184
|
+
return collapsed.length <= max ? collapsed : `${collapsed.slice(0, max - 1)}…`;
|
|
185
|
+
}
|
|
186
|
+
function formatBytes(bytes) {
|
|
187
|
+
if (bytes >= 1024 * 1024 * 1024)
|
|
188
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GiB`;
|
|
189
|
+
if (bytes >= 1024 * 1024)
|
|
190
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
|
|
191
|
+
return `${Math.round(bytes / 1024)} KiB`;
|
|
192
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
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 type { ViewSnapshot } from '../pg/indexes';
|
|
27
|
+
import type { TableSnapshot } from '../pg/introspect';
|
|
28
|
+
import type { Finding } from '../types';
|
|
29
|
+
import { type SuppressedView } from './definer-view';
|
|
30
|
+
import { type LatticeRoleOptions, type RoleGraph } from './lattice';
|
|
31
|
+
import { type ViewReachInput } from './role-reach';
|
|
32
|
+
export interface ViewExposureAnalysis {
|
|
33
|
+
/** Materialized views whose bodies resolved — L11 inputs. */
|
|
34
|
+
matviews: ViewReachInput[];
|
|
35
|
+
/** Filtering definer views without `security_barrier` — L12 inputs. */
|
|
36
|
+
leaky: ViewReachInput[];
|
|
37
|
+
/** Views left out, with why. An unread body is not a clean bill. */
|
|
38
|
+
suppressed: SuppressedView[];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Resolve both populations in one pass over the view bodies.
|
|
42
|
+
*
|
|
43
|
+
* A materialized view is deliberately absent from the relation index: it is a
|
|
44
|
+
* terminal relation for anything reading *through* it (its rows are stored),
|
|
45
|
+
* so it must never be followed as a nested view. Its own body is still read,
|
|
46
|
+
* to learn which relations the refresh copied from.
|
|
47
|
+
*/
|
|
48
|
+
export declare function analyzeViewExposure(views: ViewSnapshot[], tables: TableSnapshot[]): Promise<ViewExposureAnalysis>;
|
|
49
|
+
/**
|
|
50
|
+
* L11: an untrusted role reads a table's rows out of a materialized view.
|
|
51
|
+
*
|
|
52
|
+
* Fires where the role can SELECT the matview and either holds no SELECT on
|
|
53
|
+
* the relation the refresh read, or holds one but is subject to policies the
|
|
54
|
+
* stored rows never passed through. The second case is the one catalog-only
|
|
55
|
+
* analysis is worst at: the ACL says the role may read the table, RLS says it
|
|
56
|
+
* may read three rows of it, and the matview hands it all of them.
|
|
57
|
+
*/
|
|
58
|
+
export declare function checkMatviewSnapshot(matviews: ViewReachInput[], tables: TableSnapshot[], graph: RoleGraph, options?: LatticeRoleOptions): Finding[];
|
|
59
|
+
/**
|
|
60
|
+
* L12: a definer view filters rows for an untrusted role but is not a barrier.
|
|
61
|
+
*
|
|
62
|
+
* Fires where the role reaches a relation *only* through a view whose body has
|
|
63
|
+
* a `WHERE`, and the view is not `security_barrier`. The finding is about the
|
|
64
|
+
* rows the view excludes: they are the ones the author meant to withhold, and
|
|
65
|
+
* without the barrier a cheap leaky qual supplied by the caller is evaluated
|
|
66
|
+
* against them.
|
|
67
|
+
*/
|
|
68
|
+
export declare function checkLeakyFilterView(views: ViewReachInput[], tables: TableSnapshot[], graph: RoleGraph, options?: LatticeRoleOptions): Finding[];
|