@safegres/test 0.0.1 → 1.30.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +825 -412
- package/adapters.d.ts +8 -0
- package/adapters.js +18 -0
- package/ast/helpers.d.ts +16 -0
- package/ast/helpers.js +40 -0
- package/ast/parse.d.ts +31 -0
- package/ast/parse.js +87 -0
- package/ast/walk.d.ts +27 -0
- package/ast/walk.js +79 -0
- package/callgraph/baseline.d.ts +28 -0
- package/callgraph/baseline.js +55 -0
- package/callgraph/extract.d.ts +93 -0
- package/callgraph/extract.js +340 -0
- package/callgraph/graph.d.ts +82 -0
- package/callgraph/graph.js +276 -0
- package/checks/anti-patterns.d.ts +53 -0
- package/checks/anti-patterns.js +247 -0
- package/checks/column-grants.d.ts +20 -0
- package/checks/column-grants.js +74 -0
- package/checks/coverage.d.ts +43 -0
- package/checks/coverage.js +110 -0
- package/checks/definer-function.d.ts +91 -0
- package/checks/definer-function.js +458 -0
- package/checks/definer-view.d.ts +167 -0
- package/checks/definer-view.js +392 -0
- package/checks/indexes.d.ts +82 -0
- package/checks/indexes.js +263 -0
- package/checks/lattice.d.ts +179 -0
- package/checks/lattice.js +487 -0
- package/checks/object-acls.d.ts +32 -0
- package/checks/object-acls.js +129 -0
- package/checks/policy-index.d.ts +78 -0
- package/checks/policy-index.js +349 -0
- package/checks/revocable-grants.d.ts +140 -0
- package/checks/revocable-grants.js +377 -0
- package/checks/rls-flags.d.ts +25 -0
- package/checks/rls-flags.js +82 -0
- package/checks/role-reach.d.ts +329 -0
- package/checks/role-reach.js +277 -0
- package/checks/role-trust.d.ts +45 -0
- package/checks/role-trust.js +91 -0
- package/checks/set-role.d.ts +21 -0
- package/checks/set-role.js +66 -0
- package/checks/stats.d.ts +52 -0
- package/checks/stats.js +192 -0
- package/checks/view-exposure.d.ts +68 -0
- package/checks/view-exposure.js +236 -0
- package/checks/view-writes.d.ts +88 -0
- package/checks/view-writes.js +350 -0
- package/ci/baseline.d.ts +104 -0
- package/ci/baseline.js +111 -0
- package/ci/github.d.ts +47 -0
- package/ci/github.js +197 -0
- package/cli/audit.d.ts +3 -0
- package/cli/audit.js +560 -0
- package/cli/baseline.d.ts +3 -0
- package/cli/baseline.js +104 -0
- package/cli/commands.d.ts +2 -0
- package/cli/commands.js +60 -0
- package/cli/doctor.d.ts +3 -0
- package/cli/doctor.js +72 -0
- package/cli/eval.d.ts +3 -0
- package/cli/eval.js +153 -0
- package/cli/print-config.d.ts +3 -0
- package/cli/print-config.js +54 -0
- package/cli/shared.d.ts +43 -0
- package/cli/shared.js +153 -0
- package/cli.d.ts +2 -0
- package/cli.js +69 -0
- package/commands/audit.d.ts +65 -0
- package/commands/audit.js +779 -0
- package/commands/doctor.d.ts +19 -0
- package/commands/doctor.js +181 -0
- package/commands/eval.d.ts +67 -0
- package/commands/eval.js +93 -0
- package/config/fingerprint.d.ts +40 -0
- package/config/fingerprint.js +71 -0
- package/config/loader.d.ts +44 -0
- package/config/loader.js +210 -0
- package/config/presets.d.ts +95 -0
- package/config/presets.js +327 -0
- package/config/resolve.d.ts +47 -0
- package/config/resolve.js +192 -0
- package/config/schema.d.ts +54 -0
- package/config/schema.js +269 -0
- package/config/types.d.ts +562 -0
- package/config/validate.d.ts +14 -0
- package/config/validate.js +125 -0
- package/corpus/README.md +83 -0
- package/corpus/bootstrap.sql +14 -0
- package/corpus/cases/01-anon-write-grant/case.json +26 -0
- package/corpus/cases/01-anon-write-grant/schema.sql +19 -0
- package/corpus/cases/02-grant-to-public/case.json +25 -0
- package/corpus/cases/02-grant-to-public/schema.sql +18 -0
- package/corpus/cases/03-rls-off-reachable-by-anon/case.json +30 -0
- package/corpus/cases/03-rls-off-reachable-by-anon/schema.sql +13 -0
- package/corpus/cases/04-permissive-write-policy/case.json +30 -0
- package/corpus/cases/04-permissive-write-policy/schema.sql +21 -0
- package/corpus/cases/05-public-read-policy/case.json +25 -0
- package/corpus/cases/05-public-read-policy/schema.sql +16 -0
- package/corpus/cases/06-update-without-with-check/case.json +25 -0
- package/corpus/cases/06-update-without-with-check/schema.sql +19 -0
- package/corpus/cases/07-rls-enabled-no-policies/case.json +29 -0
- package/corpus/cases/07-rls-enabled-no-policies/schema.sql +14 -0
- package/corpus/cases/08-select-grant-no-policy/case.json +25 -0
- package/corpus/cases/08-select-grant-no-policy/schema.sql +20 -0
- package/corpus/cases/09-write-grant-no-policy/case.json +24 -0
- package/corpus/cases/09-write-grant-no-policy/schema.sql +18 -0
- package/corpus/cases/10-rls-not-forced/case.json +24 -0
- package/corpus/cases/10-rls-not-forced/schema.sql +18 -0
- package/corpus/cases/11-session-user-in-policy/case.json +24 -0
- package/corpus/cases/11-session-user-in-policy/schema.sql +18 -0
- package/corpus/cases/12-volatile-function-in-policy/case.json +24 -0
- package/corpus/cases/12-volatile-function-in-policy/schema.sql +21 -0
- package/corpus/cases/13-security-definer-policy-wrapper/case.json +24 -0
- package/corpus/cases/13-security-definer-policy-wrapper/schema.sql +21 -0
- package/corpus/cases/14-dead-policy/case.json +24 -0
- package/corpus/cases/14-dead-policy/schema.sql +18 -0
- package/corpus/cases/15-unreachable-grant/case.json +24 -0
- package/corpus/cases/15-unreachable-grant/schema.sql +18 -0
- package/corpus/cases/16-anon-permissive-write-policy/case.json +34 -0
- package/corpus/cases/16-anon-permissive-write-policy/schema.sql +17 -0
- package/corpus/cases/17-foreign-key-without-index/case.json +24 -0
- package/corpus/cases/17-foreign-key-without-index/schema.sql +18 -0
- package/corpus/cases/18-policy-column-unindexed/case.json +24 -0
- package/corpus/cases/18-policy-column-unindexed/schema.sql +23 -0
- package/corpus/cases/19-policy-column-cast/case.json +24 -0
- package/corpus/cases/19-policy-column-cast/schema.sql +19 -0
- package/corpus/cases/20-stable-function-per-row/case.json +24 -0
- package/corpus/cases/20-stable-function-per-row/schema.sql +25 -0
- package/corpus/cases/21-table-without-primary-key/case.json +24 -0
- package/corpus/cases/21-table-without-primary-key/schema.sql +14 -0
- package/corpus/cases/22-redundant-index/case.json +24 -0
- package/corpus/cases/22-redundant-index/schema.sql +16 -0
- package/corpus/cases/23-tsvector-without-index/case.json +24 -0
- package/corpus/cases/23-tsvector-without-index/schema.sql +13 -0
- package/corpus/cases/24-anon-set-role-escalation/case.json +29 -0
- package/corpus/cases/24-anon-set-role-escalation/schema.sql +30 -0
- package/corpus/cases/25-definer-view-bypass/case.json +31 -0
- package/corpus/cases/25-definer-view-bypass/schema.sql +36 -0
- package/corpus/cases/26-invoker-view-no-bypass/case.json +31 -0
- package/corpus/cases/26-invoker-view-no-bypass/schema.sql +44 -0
- package/corpus/cases/27-definer-view-write/case.json +36 -0
- package/corpus/cases/27-definer-view-write/schema.sql +40 -0
- package/corpus/cases/28-invoker-view-no-write/case.json +31 -0
- package/corpus/cases/28-invoker-view-no-write/schema.sql +37 -0
- package/corpus/cases/29-rewrite-rule-bypass/case.json +36 -0
- package/corpus/cases/29-rewrite-rule-bypass/schema.sql +52 -0
- package/corpus/cases/30-instead-nothing-rule/case.json +31 -0
- package/corpus/cases/30-instead-nothing-rule/schema.sql +44 -0
- package/corpus/cases/31-matview-snapshot/case.json +32 -0
- package/corpus/cases/31-matview-snapshot/schema.sql +36 -0
- package/corpus/cases/32-matview-no-rls-no-bypass/case.json +30 -0
- package/corpus/cases/32-matview-no-rls-no-bypass/schema.sql +18 -0
- package/corpus/cases/33-leaky-filter-view/case.json +36 -0
- package/corpus/cases/33-leaky-filter-view/schema.sql +29 -0
- package/corpus/cases/34-barrier-view-no-leak/case.json +32 -0
- package/corpus/cases/34-barrier-view-no-leak/schema.sql +29 -0
- package/corpus/cases/35-column-grant-projection/case.json +27 -0
- package/corpus/cases/35-column-grant-projection/schema.sql +21 -0
- package/corpus/cases/35-policy-function-not-leakproof/case.json +24 -0
- package/corpus/cases/35-policy-function-not-leakproof/schema.sql +27 -0
- package/corpus/cases/36-column-grant-rls-mediated/case.json +27 -0
- package/corpus/cases/36-column-grant-rls-mediated/schema.sql +32 -0
- package/corpus/cases/36-policy-function-leakproof/case.json +24 -0
- package/corpus/cases/36-policy-function-leakproof/schema.sql +22 -0
- package/corpus/cases/37-sort-column-unindexed/case.json +24 -0
- package/corpus/cases/37-sort-column-unindexed/schema.sql +14 -0
- package/corpus/cases/37-unaudited-base-relation/case.json +30 -0
- package/corpus/cases/37-unaudited-base-relation/schema.sql +45 -0
- package/corpus/cases/38-foreign-key-with-index/case.json +22 -0
- package/corpus/cases/38-foreign-key-with-index/schema.sql +17 -0
- package/corpus/cases/38-invoker-view-unaudited-base/case.json +30 -0
- package/corpus/cases/38-invoker-view-unaudited-base/schema.sql +39 -0
- package/corpus/cases/39-definer-view-column-subset/case.json +30 -0
- package/corpus/cases/39-definer-view-column-subset/schema.sql +29 -0
- package/corpus/cases/39-policy-cast-with-expression-index/case.json +23 -0
- package/corpus/cases/39-policy-cast-with-expression-index/schema.sql +19 -0
- package/corpus/cases/40-definer-view-column-overreach/case.json +27 -0
- package/corpus/cases/40-definer-view-column-overreach/schema.sql +29 -0
- package/corpus/cases/40-stable-function-hoisted/case.json +23 -0
- package/corpus/cases/40-stable-function-hoisted/schema.sql +25 -0
- package/corpus/cases/41-index-prefix-not-redundant/case.json +21 -0
- package/corpus/cases/41-index-prefix-not-redundant/schema.sql +15 -0
- package/corpus/cases/41-unreadable-view-body/case.json +33 -0
- package/corpus/cases/41-unreadable-view-body/schema.sql +28 -0
- package/corpus/cases/42-invoker-view-unreadable-body/case.json +30 -0
- package/corpus/cases/42-invoker-view-unreadable-body/schema.sql +27 -0
- package/corpus/cases/42-search-column-indexed/case.json +21 -0
- package/corpus/cases/42-search-column-indexed/schema.sql +12 -0
- package/corpus/cases/43-anon-sequence-grant/case.json +27 -0
- package/corpus/cases/43-anon-sequence-grant/schema.sql +23 -0
- package/corpus/cases/44-serial-sequence-usage/case.json +27 -0
- package/corpus/cases/44-serial-sequence-usage/schema.sql +25 -0
- package/corpus/cases/45-anon-foreign-table/case.json +27 -0
- package/corpus/cases/45-anon-foreign-table/schema.sql +23 -0
- package/corpus/cases/46-unchecked-writable-view/case.json +41 -0
- package/corpus/cases/46-unchecked-writable-view/schema.sql +37 -0
- package/corpus/cases/47-checked-writable-view/case.json +37 -0
- package/corpus/cases/47-checked-writable-view/schema.sql +38 -0
- package/corpus/cases/48-definer-function-reach/case.json +38 -0
- package/corpus/cases/48-definer-function-reach/schema.sql +41 -0
- package/corpus/cases/49-invoker-function-no-reach/case.json +32 -0
- package/corpus/cases/49-invoker-function-no-reach/schema.sql +38 -0
- package/corpus/cases/50-instead-of-trigger-write/case.json +38 -0
- package/corpus/cases/50-instead-of-trigger-write/schema.sql +58 -0
- package/corpus/cases/51-invoker-trigger-no-write/case.json +33 -0
- package/corpus/cases/51-invoker-trigger-no-write/schema.sql +54 -0
- package/corpus/cases/52-revocable-execute-grant/case.json +30 -0
- package/corpus/cases/52-revocable-execute-grant/schema.sql +45 -0
- package/corpus/cases/53-policy-predicate-retains-execute/case.json +23 -0
- package/corpus/cases/53-policy-predicate-retains-execute/schema.sql +49 -0
- package/corpus/index.d.ts +79 -0
- package/corpus/index.js +125 -0
- package/esm/adapters.d.ts +8 -0
- package/esm/adapters.js +6 -0
- package/esm/ast/helpers.d.ts +16 -0
- package/esm/ast/helpers.js +36 -0
- package/esm/ast/parse.d.ts +31 -0
- package/esm/ast/parse.js +82 -0
- package/esm/ast/walk.d.ts +27 -0
- package/esm/ast/walk.js +72 -0
- package/esm/callgraph/baseline.d.ts +28 -0
- package/esm/callgraph/baseline.js +48 -0
- package/esm/callgraph/extract.d.ts +93 -0
- package/esm/callgraph/extract.js +333 -0
- package/esm/callgraph/graph.d.ts +82 -0
- package/esm/callgraph/graph.js +273 -0
- package/esm/checks/anti-patterns.d.ts +53 -0
- package/esm/checks/anti-patterns.js +240 -0
- package/esm/checks/column-grants.d.ts +20 -0
- package/esm/checks/column-grants.js +71 -0
- package/esm/checks/coverage.d.ts +43 -0
- package/esm/checks/coverage.js +104 -0
- package/esm/checks/definer-function.d.ts +91 -0
- package/esm/checks/definer-function.js +452 -0
- package/esm/checks/definer-view.d.ts +167 -0
- package/esm/checks/definer-view.js +382 -0
- package/esm/checks/indexes.d.ts +82 -0
- package/esm/checks/indexes.js +256 -0
- package/esm/checks/lattice.d.ts +179 -0
- package/esm/checks/lattice.js +475 -0
- package/esm/checks/object-acls.d.ts +32 -0
- package/esm/checks/object-acls.js +125 -0
- package/esm/checks/policy-index.d.ts +78 -0
- package/esm/checks/policy-index.js +342 -0
- package/esm/checks/revocable-grants.d.ts +140 -0
- package/esm/checks/revocable-grants.js +373 -0
- package/esm/checks/rls-flags.d.ts +25 -0
- package/esm/checks/rls-flags.js +77 -0
- package/esm/checks/role-reach.d.ts +329 -0
- package/esm/checks/role-reach.js +270 -0
- package/esm/checks/role-trust.d.ts +45 -0
- package/esm/checks/role-trust.js +86 -0
- package/esm/checks/set-role.d.ts +21 -0
- package/esm/checks/set-role.js +63 -0
- package/esm/checks/stats.d.ts +52 -0
- package/esm/checks/stats.js +184 -0
- package/esm/checks/view-exposure.d.ts +68 -0
- package/esm/checks/view-exposure.js +231 -0
- package/esm/checks/view-writes.d.ts +88 -0
- package/esm/checks/view-writes.js +344 -0
- package/esm/ci/baseline.d.ts +104 -0
- package/esm/ci/baseline.js +107 -0
- package/esm/ci/github.d.ts +47 -0
- package/esm/ci/github.js +161 -0
- package/esm/cli/audit.d.ts +3 -0
- package/esm/cli/audit.js +525 -0
- package/esm/cli/baseline.d.ts +3 -0
- package/esm/cli/baseline.js +102 -0
- package/esm/cli/commands.d.ts +2 -0
- package/esm/cli/commands.js +53 -0
- package/esm/cli/doctor.d.ts +3 -0
- package/esm/cli/doctor.js +67 -0
- package/esm/cli/eval.d.ts +3 -0
- package/esm/cli/eval.js +115 -0
- package/esm/cli/print-config.d.ts +3 -0
- package/esm/cli/print-config.js +52 -0
- package/esm/cli/shared.d.ts +43 -0
- package/esm/cli/shared.js +112 -0
- package/esm/cli.d.ts +2 -0
- package/esm/cli.js +67 -0
- package/esm/commands/audit.d.ts +65 -0
- package/esm/commands/audit.js +776 -0
- package/esm/commands/doctor.d.ts +19 -0
- package/esm/commands/doctor.js +178 -0
- package/esm/commands/eval.d.ts +67 -0
- package/esm/commands/eval.js +90 -0
- package/esm/config/fingerprint.d.ts +40 -0
- package/esm/config/fingerprint.js +68 -0
- package/esm/config/loader.d.ts +44 -0
- package/esm/config/loader.js +172 -0
- package/esm/config/presets.d.ts +95 -0
- package/esm/config/presets.js +324 -0
- package/esm/config/resolve.d.ts +47 -0
- package/esm/config/resolve.js +180 -0
- package/esm/config/schema.d.ts +54 -0
- package/esm/config/schema.js +265 -0
- package/esm/config/types.d.ts +562 -0
- package/esm/config/validate.d.ts +14 -0
- package/esm/config/validate.js +122 -0
- package/esm/corpus/index.d.ts +79 -0
- package/esm/corpus/index.js +85 -0
- package/esm/exposure/adapters.d.ts +152 -0
- package/esm/exposure/adapters.js +463 -0
- package/esm/exposure/planes.d.ts +59 -0
- package/esm/exposure/planes.js +144 -0
- package/esm/exposure/reach.d.ts +78 -0
- package/esm/exposure/reach.js +105 -0
- package/esm/exposure/routines.d.ts +36 -0
- package/esm/exposure/routines.js +54 -0
- package/esm/index.d.ts +96 -0
- package/esm/index.js +57 -6
- package/esm/lint/index.d.ts +11 -0
- package/esm/lint/index.js +10 -0
- package/esm/perf/baseline.d.ts +40 -0
- package/esm/perf/baseline.js +83 -0
- package/esm/perf/explain.d.ts +46 -0
- package/esm/perf/explain.js +201 -0
- package/esm/pg/acl.d.ts +72 -0
- package/esm/pg/acl.js +186 -0
- package/esm/pg/behaviors.d.ts +114 -0
- package/esm/pg/behaviors.js +206 -0
- package/esm/pg/exposure.d.ts +74 -0
- package/esm/pg/exposure.js +198 -0
- package/esm/pg/functions.d.ts +52 -0
- package/esm/pg/functions.js +128 -0
- package/esm/pg/indexes.d.ts +192 -0
- package/esm/pg/indexes.js +395 -0
- package/esm/pg/introspect.d.ts +134 -0
- package/esm/pg/introspect.js +274 -0
- package/esm/pg/objects.d.ts +37 -0
- package/esm/pg/objects.js +108 -0
- package/esm/pg/paths.d.ts +114 -0
- package/esm/pg/paths.js +159 -0
- package/esm/pg/proc.d.ts +23 -0
- package/esm/pg/proc.js +55 -0
- package/esm/pg/reach-inputs.d.ts +47 -0
- package/esm/pg/reach-inputs.js +133 -0
- package/esm/pg/roles.d.ts +28 -0
- package/esm/pg/roles.js +50 -0
- package/esm/pg/stats.d.ts +65 -0
- package/esm/pg/stats.js +129 -0
- package/esm/pgpm-test.d.ts +22 -0
- package/esm/pgpm-test.js +39 -0
- package/esm/report/callgraph.d.ts +9 -0
- package/esm/report/callgraph.js +74 -0
- package/esm/report/compare.d.ts +143 -0
- package/esm/report/compare.js +195 -0
- package/esm/report/github.d.ts +74 -0
- package/esm/report/github.js +290 -0
- package/esm/report/json.d.ts +4 -0
- package/esm/report/json.js +3 -0
- package/esm/report/markdown.d.ts +25 -0
- package/esm/report/markdown.js +352 -0
- package/esm/report/pretty.d.ts +14 -0
- package/esm/report/pretty.js +318 -0
- package/esm/report/sarif.d.ts +40 -0
- package/esm/report/sarif.js +189 -0
- package/esm/report/view.d.ts +82 -0
- package/esm/report/view.js +126 -0
- package/esm/rules/registry.d.ts +66 -0
- package/esm/rules/registry.js +562 -0
- package/esm/score/score.d.ts +84 -0
- package/esm/score/score.js +233 -0
- package/esm/score/scorecards.d.ts +68 -0
- package/esm/score/scorecards.js +157 -0
- package/esm/types.d.ts +292 -0
- package/esm/types.js +26 -0
- package/esm/version.d.ts +8 -0
- package/esm/version.js +9 -0
- package/exposure/adapters.d.ts +152 -0
- package/exposure/adapters.js +468 -0
- package/exposure/planes.d.ts +59 -0
- package/exposure/planes.js +151 -0
- package/exposure/reach.d.ts +78 -0
- package/exposure/reach.js +109 -0
- package/exposure/routines.d.ts +36 -0
- package/exposure/routines.js +57 -0
- package/index.d.ts +96 -6
- package/index.js +229 -6
- package/lint/index.d.ts +11 -0
- package/lint/index.js +19 -0
- package/package.json +54 -48
- package/perf/baseline.d.ts +40 -0
- package/perf/baseline.js +92 -0
- package/perf/explain.d.ts +46 -0
- package/perf/explain.js +204 -0
- package/pg/acl.d.ts +72 -0
- package/pg/acl.js +191 -0
- package/pg/behaviors.d.ts +114 -0
- package/pg/behaviors.js +217 -0
- package/pg/exposure.d.ts +74 -0
- package/pg/exposure.js +205 -0
- package/pg/functions.d.ts +52 -0
- package/pg/functions.js +131 -0
- package/pg/indexes.d.ts +192 -0
- package/pg/indexes.js +400 -0
- package/pg/introspect.d.ts +134 -0
- package/pg/introspect.js +280 -0
- package/pg/objects.d.ts +37 -0
- package/pg/objects.js +111 -0
- package/pg/paths.d.ts +114 -0
- package/pg/paths.js +166 -0
- package/pg/proc.d.ts +23 -0
- package/pg/proc.js +58 -0
- package/pg/reach-inputs.d.ts +47 -0
- package/pg/reach-inputs.js +136 -0
- package/pg/roles.d.ts +28 -0
- package/pg/roles.js +54 -0
- package/pg/stats.d.ts +65 -0
- package/pg/stats.js +133 -0
- package/pgpm-test.d.ts +22 -0
- package/pgpm-test.js +42 -0
- package/report/callgraph.d.ts +9 -0
- package/report/callgraph.js +81 -0
- package/report/compare.d.ts +143 -0
- package/report/compare.js +205 -0
- package/report/github.d.ts +74 -0
- package/report/github.js +333 -0
- package/report/json.d.ts +4 -0
- package/report/json.js +6 -0
- package/report/markdown.d.ts +25 -0
- package/report/markdown.js +355 -0
- package/report/pretty.d.ts +14 -0
- package/report/pretty.js +324 -0
- package/report/sarif.d.ts +40 -0
- package/report/sarif.js +193 -0
- package/report/view.d.ts +82 -0
- package/report/view.js +132 -0
- package/rules/registry.d.ts +66 -0
- package/rules/registry.js +569 -0
- package/schema/safegres.schema.json +1119 -0
- package/score/score.d.ts +84 -0
- package/score/score.js +238 -0
- package/score/scorecards.d.ts +68 -0
- package/score/scorecards.js +162 -0
- package/types.d.ts +292 -0
- package/types.js +32 -0
- package/version.d.ts +8 -0
- package/version.js +12 -0
- package/LICENSE +0 -23
- package/admin.d.ts +0 -26
- package/admin.js +0 -182
- package/connect.d.ts +0 -19
- package/connect.js +0 -83
- package/dist/README.md +0 -557
- package/dist/package.json +0 -72
- package/esm/admin.js +0 -178
- package/esm/connect.js +0 -78
- package/esm/manager.js +0 -136
- package/esm/roles.js +0 -32
- package/esm/seed/adapters.js +0 -23
- package/esm/seed/csv.js +0 -44
- package/esm/seed/index.js +0 -16
- package/esm/seed/json.js +0 -18
- package/esm/seed/launchql.js +0 -19
- package/esm/seed/sqitch.js +0 -17
- package/esm/stream.js +0 -43
- package/esm/test-client.js +0 -150
- package/manager.d.ts +0 -25
- package/manager.js +0 -140
- package/roles.d.ts +0 -17
- package/roles.js +0 -38
- package/seed/adapters.d.ts +0 -4
- package/seed/adapters.js +0 -28
- package/seed/csv.d.ts +0 -9
- package/seed/csv.js +0 -49
- package/seed/index.d.ts +0 -16
- package/seed/index.js +0 -33
- package/seed/json.d.ts +0 -6
- package/seed/json.js +0 -21
- package/seed/launchql.d.ts +0 -2
- package/seed/launchql.js +0 -22
- package/seed/sqitch.d.ts +0 -2
- package/seed/sqitch.js +0 -20
- package/seed/types.d.ts +0 -13
- package/stream.d.ts +0 -2
- package/stream.js +0 -46
- package/test-client.d.ts +0 -49
- package/test-client.js +0 -154
- /package/{seed → config}/types.js +0 -0
- /package/esm/{seed → config}/types.js +0 -0
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Script A driver: pure-Postgres RLS audit.
|
|
3
|
+
*
|
|
4
|
+
* Ingests a catalog snapshot, runs every check, and returns a structured report.
|
|
5
|
+
*/
|
|
6
|
+
import { buildCallGraph } from '../callgraph/graph';
|
|
7
|
+
import { checkSessionUserGating, checkTriviallyPermissive, checkVolatileFunctions, collectFunctionNames, parseOrNull } from '../checks/anti-patterns';
|
|
8
|
+
import { checkUntrustedColumnGrants } from '../checks/column-grants';
|
|
9
|
+
import { checkCoverageGaps, checkUpdateWithCheckCoverage } from '../checks/coverage';
|
|
10
|
+
import { analyzeFunctionBodies, checkDefinerFunctionReach, checkInsteadOfTriggerWrite, checkUnreadableFunctionReach } from '../checks/definer-function';
|
|
11
|
+
import { analyzeViewBodies, checkDefinerViewBypass, checkUnauditedViewReach, checkUnreadableViewReach } from '../checks/definer-view';
|
|
12
|
+
import { checkMissingPrimaryKey, checkRedundantIndexes, checkUnindexedForeignKeys, checkUnindexedSearchColumns, checkUnindexedSortColumns } from '../checks/indexes';
|
|
13
|
+
import { checkDeadPolicies, checkDeadSchemaUsage, checkIndirectCoverageGaps, checkUnaddressableGrant, checkUnreachableGrants, checkUntrustedIndirectAccess, computeRoleAccess } from '../checks/lattice';
|
|
14
|
+
import { checkUntrustedForeignTableGrants, checkUntrustedSequenceGrants } from '../checks/object-acls';
|
|
15
|
+
import { checkNonLeakproofPolicyFunctions, checkPolicyColumnCasts, checkUnhoistedPolicyFunctions, checkUnindexedPolicyColumns, collectPredicateColumns } from '../checks/policy-index';
|
|
16
|
+
import { analyzeRevocableGrants, revocableGrantFindings } from '../checks/revocable-grants';
|
|
17
|
+
import { checkGrantsWithoutRls, checkRlsEnabledNoPolicies, checkRlsNotForced } from '../checks/rls-flags';
|
|
18
|
+
import { checkPublicGrants, checkUntrustedRolePolicies, checkUntrustedRoleWrites } from '../checks/role-trust';
|
|
19
|
+
import { checkSetRoleEscalation } from '../checks/set-role';
|
|
20
|
+
import { checkStats, DEFAULT_STATS_THRESHOLDS } from '../checks/stats';
|
|
21
|
+
import { analyzeViewExposure, checkLeakyFilterView, checkMatviewSnapshot } from '../checks/view-exposure';
|
|
22
|
+
import { analyzeViewWrites, checkDefinerViewWrite, checkUncheckedViewWrite, checkViewRuleBypass } from '../checks/view-writes';
|
|
23
|
+
import { configFingerprint } from '../config/fingerprint';
|
|
24
|
+
import { allAstRulesDisabled, applyRulesToFindings, matchTablePattern, resolveRules, rulesForTable } from '../config/resolve';
|
|
25
|
+
import { resolvePlaneReach, scorePlane, stampPlanes } from '../exposure/planes';
|
|
26
|
+
import { resolveRoutineReach } from '../exposure/routines';
|
|
27
|
+
import { LINT_RULES, LINT_RULES_BY_ID, lintDefinition } from '../lint';
|
|
28
|
+
import { proveFindings } from '../perf/explain';
|
|
29
|
+
import { introspectRoleGraph, introspectSchemaAcls } from '../pg/acl';
|
|
30
|
+
import { resolveExposure, resolvePlanes, resolveReach } from '../pg/exposure';
|
|
31
|
+
import { introspectFunctions } from '../pg/functions';
|
|
32
|
+
import { introspectIndexes, introspectViews } from '../pg/indexes';
|
|
33
|
+
import { asExecutor, introspectTables } from '../pg/introspect';
|
|
34
|
+
import { introspectObjectAcls } from '../pg/objects';
|
|
35
|
+
import { classifyPaths } from '../pg/paths';
|
|
36
|
+
import { lookupVolatility } from '../pg/proc';
|
|
37
|
+
import { introspectReachInputs } from '../pg/reach-inputs';
|
|
38
|
+
import { listAuditableRoles, resolveRoles } from '../pg/roles';
|
|
39
|
+
import { introspectStats } from '../pg/stats';
|
|
40
|
+
import { dimensionOf, RULES_BY_CODE, subjectOf } from '../rules/registry';
|
|
41
|
+
import { computeScore } from '../score/score';
|
|
42
|
+
import { computeScorecards, RESERVED_SCORECARDS } from '../score/scorecards';
|
|
43
|
+
import { summarize } from '../types';
|
|
44
|
+
import { version as PKG_VERSION } from '../version';
|
|
45
|
+
export async function audit(client, options = {}) {
|
|
46
|
+
const exec = asExecutor(client);
|
|
47
|
+
const config = options.config ?? {};
|
|
48
|
+
const resolved = resolveRules(config);
|
|
49
|
+
const extensions = options.extensions ?? config.extensions;
|
|
50
|
+
// Function definitions are read by two independent features (the convention
|
|
51
|
+
// linter and the call graph); introspect them at most once.
|
|
52
|
+
let functionsCache;
|
|
53
|
+
const getFunctions = async () => {
|
|
54
|
+
if (!functionsCache) {
|
|
55
|
+
functionsCache = await introspectFunctions(exec, {
|
|
56
|
+
schemas: options.schemas ?? config.schemas,
|
|
57
|
+
excludeSchemas: options.excludeSchemas ?? config.excludeSchemas,
|
|
58
|
+
extensions
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return functionsCache;
|
|
62
|
+
};
|
|
63
|
+
const statsEnabled = options.stats ?? config.perf?.stats?.enabled ?? false;
|
|
64
|
+
const explainEnabled = options.explain ?? config.perf?.explain?.enabled ?? false;
|
|
65
|
+
// Both tiers are refinements of the perf dimension: asking for either turns
|
|
66
|
+
// it on, but neither is reachable without opting into perf in the first place.
|
|
67
|
+
const perfEnabled = (options.perf ?? config.perf?.enabled ?? false) || statsEnabled || explainEnabled;
|
|
68
|
+
const skipAst = options.skipAstChecks || allAstRulesDisabled(resolved, { perf: perfEnabled });
|
|
69
|
+
// Resolve role set.
|
|
70
|
+
const allRoles = await listAuditableRoles(exec);
|
|
71
|
+
const resolution = resolveRoles(allRoles, options.includeRoles ?? config.roles, options.excludeRoles ?? config.excludeRoles);
|
|
72
|
+
const exposureConfig = options.exposure ?? config.exposure;
|
|
73
|
+
const exposure = await resolveExposure(exec, exposureConfig);
|
|
74
|
+
const exposedSchemas = new Set(exposure.schemas);
|
|
75
|
+
const planes = await resolvePlanes(exec, exposureConfig, exposure);
|
|
76
|
+
// Relation-level precision within those schemas: what the generated API can
|
|
77
|
+
// actually name. Schema membership is the coarse answer; this is the fine
|
|
78
|
+
// one, and only an adapter that can prove it contributes.
|
|
79
|
+
const apiReach = exposure.known
|
|
80
|
+
? await resolveReach(exec, exposureConfig, {
|
|
81
|
+
schemas: exposure.schemas,
|
|
82
|
+
excludeSchemas: options.excludeSchemas ?? config.excludeSchemas
|
|
83
|
+
})
|
|
84
|
+
: undefined;
|
|
85
|
+
const unaddressable = new Set((apiReach?.unreachable ?? []).map((r) => `${r.schema}.${r.table}`));
|
|
86
|
+
const snapshot = await introspectTables(exec, {
|
|
87
|
+
schemas: options.schemas ?? config.schemas,
|
|
88
|
+
excludeSchemas: options.excludeSchemas ?? config.excludeSchemas,
|
|
89
|
+
roles: resolution.roles,
|
|
90
|
+
extensions
|
|
91
|
+
});
|
|
92
|
+
const isExposed = (schema, table) => {
|
|
93
|
+
if (!exposedSchemas.has(schema))
|
|
94
|
+
return false;
|
|
95
|
+
return table === undefined || !unaddressable.has(`${schema}.${table}`);
|
|
96
|
+
};
|
|
97
|
+
const exposedTables = exposure.known
|
|
98
|
+
? snapshot.filter((t) => isExposed(t.schema, t.name)).length
|
|
99
|
+
: snapshot.length;
|
|
100
|
+
// Effective-access inputs for the lattice rules: the INHERIT-following
|
|
101
|
+
// role graph and the schema USAGE ACLs. Both are single cheap queries.
|
|
102
|
+
const roleGraph = await introspectRoleGraph(exec);
|
|
103
|
+
const schemaAcls = await introspectSchemaAcls(exec, {
|
|
104
|
+
schemas: options.schemas ?? config.schemas,
|
|
105
|
+
excludeSchemas: options.excludeSchemas ?? config.excludeSchemas
|
|
106
|
+
});
|
|
107
|
+
const schemaAclsByName = new Map(schemaAcls.map((a) => [a.schema, a]));
|
|
108
|
+
// Which schemas the audit actually looked at — the same include/exclude
|
|
109
|
+
// filters the table snapshot used. A view body naming anything outside this
|
|
110
|
+
// set reaches past the audit rather than reaching nothing (L14).
|
|
111
|
+
const auditedSchemas = new Set(schemaAcls.map((a) => a.schema));
|
|
112
|
+
// Roles requests arrive as, and the relations some policy predicate names.
|
|
113
|
+
// L6 needs both: the first is whose grants it is talking about, the second
|
|
114
|
+
// is the veto that stops it recommending a revoke of a load-bearing grant.
|
|
115
|
+
const apiRoles = exposure.roles ?? [];
|
|
116
|
+
const policyReferenced = policyReferencedRelations(snapshot);
|
|
117
|
+
let findings = [];
|
|
118
|
+
// --- Performance dimension (opt-in): index hygiene ---
|
|
119
|
+
const indexSnapshot = perfEnabled
|
|
120
|
+
? await introspectIndexes(exec, {
|
|
121
|
+
schemas: options.schemas ?? config.schemas,
|
|
122
|
+
excludeSchemas: options.excludeSchemas ?? config.excludeSchemas,
|
|
123
|
+
extensions
|
|
124
|
+
})
|
|
125
|
+
: [];
|
|
126
|
+
const indexesByTable = new Map(indexSnapshot.map((t) => [`${t.schema}.${t.name}`, t]));
|
|
127
|
+
// Views are read for three independent reasons: their bodies refute "nothing
|
|
128
|
+
// queries this column" for the perf paths, their owners carry the L8 reach
|
|
129
|
+
// edge, and their ACLs keep L4 from calling schema USAGE dead when a view is
|
|
130
|
+
// the only thing in the schema the role can reach. One introspection serves
|
|
131
|
+
// all three.
|
|
132
|
+
const definerViewRoles = withExposedRoles(resolved.rules.get('L8')?.options, exposure)?.roles ?? [];
|
|
133
|
+
const viewWriteRoles = withExposedRoles(resolved.rules.get('L9')?.options, exposure)?.roles ?? [];
|
|
134
|
+
const ruleBypassRoles = withExposedRoles(resolved.rules.get('L10')?.options, exposure)?.roles ?? [];
|
|
135
|
+
const uncheckedWriteRoles = withExposedRoles(resolved.rules.get('L18')?.options, exposure)?.roles ?? [];
|
|
136
|
+
const matviewRoles = withExposedRoles(resolved.rules.get('L11')?.options, exposure)?.roles ?? [];
|
|
137
|
+
const leakyViewRoles = withExposedRoles(resolved.rules.get('L12')?.options, exposure)?.roles ?? [];
|
|
138
|
+
const unauditedReachRoles = withExposedRoles(resolved.rules.get('L14')?.options, exposure)?.roles ?? [];
|
|
139
|
+
const unreadableViewRoles = withExposedRoles(resolved.rules.get('L15')?.options, exposure)?.roles ?? [];
|
|
140
|
+
// Function bodies: L19 is the reach a definer function's body confers, L20
|
|
141
|
+
// the write an INSTEAD OF trigger's definer function performs, and L15
|
|
142
|
+
// reports the bodies neither could read.
|
|
143
|
+
const definerFunctionRoles = withExposedRoles(resolved.rules.get('L19')?.options, exposure)?.roles ?? [];
|
|
144
|
+
const triggerWriteRoles = withExposedRoles(resolved.rules.get('L20')?.options, exposure)?.roles ?? [];
|
|
145
|
+
// Sequences and foreign tables: one introspection, two rules, and no reason
|
|
146
|
+
// to run it when neither is configured with a role list.
|
|
147
|
+
const sequenceRoles = withExposedRoles(resolved.rules.get('L16')?.options, exposure)?.roles ?? [];
|
|
148
|
+
const foreignTableRoles = withExposedRoles(resolved.rules.get('L17')?.options, exposure)?.roles ?? [];
|
|
149
|
+
// L21 (`granted − reachable`) runs on the untrusted roles by default — its
|
|
150
|
+
// question is precisely "which of anonymous's grants are load-bearing?" — so
|
|
151
|
+
// it reads its role list from `exposure.anonRoles` unless the config overrides
|
|
152
|
+
// it. It reads function/view bodies, so it is gated on `skipAst` like the
|
|
153
|
+
// other body rules.
|
|
154
|
+
const revocableRoles = withExposedRoles({ rolesFrom: 'anon', ...resolved.rules.get('L21')?.options }, exposure)?.roles ?? [];
|
|
155
|
+
const revocableEnabled = !skipAst && revocableRoles.length > 0 && resolved.rules.get('L21')?.enabled !== false;
|
|
156
|
+
// L8 and L14 are two answers from one body pass: the base relations that
|
|
157
|
+
// were graded, and the ones that could not be because they are out of scope.
|
|
158
|
+
const viewBodiesEnabled = !skipAst
|
|
159
|
+
&& ((definerViewRoles.length > 0 && resolved.rules.get('L8')?.enabled !== false)
|
|
160
|
+
|| (unauditedReachRoles.length > 0 && resolved.rules.get('L14')?.enabled !== false)
|
|
161
|
+
|| (unreadableViewRoles.length > 0 && resolved.rules.get('L15')?.enabled !== false));
|
|
162
|
+
const functionBodiesEnabled = !skipAst
|
|
163
|
+
&& ((definerFunctionRoles.length > 0 && resolved.rules.get('L19')?.enabled !== false)
|
|
164
|
+
|| (triggerWriteRoles.length > 0 && resolved.rules.get('L20')?.enabled !== false)
|
|
165
|
+
|| (unreadableViewRoles.length > 0 && resolved.rules.get('L15')?.enabled !== false));
|
|
166
|
+
const viewExposureEnabled = !skipAst
|
|
167
|
+
&& ((matviewRoles.length > 0 && resolved.rules.get('L11')?.enabled !== false)
|
|
168
|
+
|| (leakyViewRoles.length > 0 && resolved.rules.get('L12')?.enabled !== false));
|
|
169
|
+
const viewWritesEnabled = !skipAst
|
|
170
|
+
&& ((viewWriteRoles.length > 0 && resolved.rules.get('L9')?.enabled !== false)
|
|
171
|
+
|| (ruleBypassRoles.length > 0 && resolved.rules.get('L10')?.enabled !== false)
|
|
172
|
+
|| (uncheckedWriteRoles.length > 0 && resolved.rules.get('L18')?.enabled !== false));
|
|
173
|
+
const needsViews = (perfEnabled && config.perf?.paths?.infer !== false)
|
|
174
|
+
|| resolved.rules.get('L4')?.enabled !== false
|
|
175
|
+
|| viewBodiesEnabled
|
|
176
|
+
|| viewWritesEnabled
|
|
177
|
+
|| viewExposureEnabled
|
|
178
|
+
|| functionBodiesEnabled
|
|
179
|
+
|| revocableEnabled;
|
|
180
|
+
const viewSnapshot = needsViews
|
|
181
|
+
? await introspectViews(exec, {
|
|
182
|
+
schemas: options.schemas ?? config.schemas,
|
|
183
|
+
excludeSchemas: options.excludeSchemas ?? config.excludeSchemas
|
|
184
|
+
})
|
|
185
|
+
: [];
|
|
186
|
+
// Evidence about which foreign keys anything reads. Reported alongside the
|
|
187
|
+
// findings; by default it changes neither, because no signal available on an
|
|
188
|
+
// empty database proves a path is unreachable.
|
|
189
|
+
const onWriteOncePointer = config.perf?.paths?.onWriteOncePointer ?? 'report';
|
|
190
|
+
const paths = perfEnabled && config.perf?.paths?.infer !== false
|
|
191
|
+
? classifyPaths(indexSnapshot, snapshot, viewSnapshot.map((v) => v.definition), {
|
|
192
|
+
minPointers: config.perf?.paths?.minPointers,
|
|
193
|
+
hiddenBackwardRelations: new Set(apiReach?.hiddenBackwardRelations ?? [])
|
|
194
|
+
})
|
|
195
|
+
: new Map();
|
|
196
|
+
for (const table of snapshot) {
|
|
197
|
+
// --- RLS flags (structural) ---
|
|
198
|
+
const a1 = checkRlsEnabledNoPolicies(table);
|
|
199
|
+
if (a1)
|
|
200
|
+
findings.push(a1);
|
|
201
|
+
const a2 = checkGrantsWithoutRls(table);
|
|
202
|
+
if (a2)
|
|
203
|
+
findings.push(a2);
|
|
204
|
+
const a3 = checkRlsNotForced(table);
|
|
205
|
+
if (a3)
|
|
206
|
+
findings.push(a3);
|
|
207
|
+
// --- Grant-vs-policy coverage ---
|
|
208
|
+
findings.push(...checkCoverageGaps(table));
|
|
209
|
+
findings.push(...checkUpdateWithCheckCoverage(table));
|
|
210
|
+
// --- Role-trust (options-driven; per-table overrides can retune roles) ---
|
|
211
|
+
const tableRules = rulesForTable(resolved, table.schema, table.name);
|
|
212
|
+
findings.push(...checkUntrustedRoleWrites(table, withExposedRoles(tableRules.get('R1')?.options, exposure)));
|
|
213
|
+
findings.push(...checkUntrustedRolePolicies(table, withExposedRoles(tableRules.get('R2')?.options, exposure)));
|
|
214
|
+
findings.push(...checkPublicGrants(table));
|
|
215
|
+
// --- Grant/RLS/policy lattice (effective access: PUBLIC + inheritance) ---
|
|
216
|
+
findings.push(...checkIndirectCoverageGaps(table, roleGraph));
|
|
217
|
+
findings.push(...checkDeadPolicies(table, roleGraph));
|
|
218
|
+
findings.push(...checkUnreachableGrants(table, schemaAclsByName, roleGraph));
|
|
219
|
+
findings.push(...checkUntrustedIndirectAccess(table, roleGraph, withExposedRoles(tableRules.get('L5')?.options, exposure)));
|
|
220
|
+
if (unaddressable.size > 0 && apiRoles.length > 0) {
|
|
221
|
+
findings.push(...checkUnaddressableGrant(table, roleGraph, unaddressable, {
|
|
222
|
+
roles: apiRoles,
|
|
223
|
+
policyReferenced
|
|
224
|
+
}));
|
|
225
|
+
}
|
|
226
|
+
findings.push(...checkSetRoleEscalation(table, roleGraph, withExposedRoles(tableRules.get('L7')?.options, exposure)));
|
|
227
|
+
findings.push(...checkUntrustedColumnGrants(table, roleGraph, withExposedRoles(tableRules.get('L13')?.options, exposure)));
|
|
228
|
+
// --- AST-level anti-patterns (and, with perf on, policy-aware index rules) ---
|
|
229
|
+
if (!skipAst) {
|
|
230
|
+
findings.push(...(await auditTableAst(exec, table, perfEnabled ? indexesByTable.get(`${table.schema}.${table.name}`) : undefined)));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
findings.push(...checkDeadSchemaUsage(schemaAcls, snapshot, roleGraph, viewSnapshot));
|
|
234
|
+
// L16/L17 grade relations that are not tables, so they sit outside the
|
|
235
|
+
// per-table loop entirely.
|
|
236
|
+
const objectAclsEnabled = (sequenceRoles.length > 0 && resolved.rules.get('L16')?.enabled !== false)
|
|
237
|
+
|| (foreignTableRoles.length > 0 && resolved.rules.get('L17')?.enabled !== false);
|
|
238
|
+
if (objectAclsEnabled) {
|
|
239
|
+
const objects = await introspectObjectAcls(exec, {
|
|
240
|
+
schemas: options.schemas ?? config.schemas,
|
|
241
|
+
excludeSchemas: options.excludeSchemas ?? config.excludeSchemas
|
|
242
|
+
});
|
|
243
|
+
if (sequenceRoles.length > 0 && resolved.rules.get('L16')?.enabled !== false) {
|
|
244
|
+
findings.push(...checkUntrustedSequenceGrants(objects, roleGraph, { roles: sequenceRoles }));
|
|
245
|
+
}
|
|
246
|
+
if (foreignTableRoles.length > 0 && resolved.rules.get('L17')?.enabled !== false) {
|
|
247
|
+
findings.push(...checkUntrustedForeignTableGrants(objects, roleGraph, { roles: foreignTableRoles }));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// L8 is per-view, not per-table: a view body names its own base relations.
|
|
251
|
+
if (viewBodiesEnabled) {
|
|
252
|
+
const viewBodies = await analyzeViewBodies(viewSnapshot, snapshot, auditedSchemas);
|
|
253
|
+
if (definerViewRoles.length > 0 && resolved.rules.get('L8')?.enabled !== false) {
|
|
254
|
+
findings.push(...checkDefinerViewBypass(viewBodies.views, snapshot, roleGraph, { roles: definerViewRoles }));
|
|
255
|
+
}
|
|
256
|
+
if (unauditedReachRoles.length > 0 && resolved.rules.get('L14')?.enabled !== false) {
|
|
257
|
+
findings.push(...checkUnauditedViewReach(viewBodies.views, roleGraph, { roles: unauditedReachRoles }));
|
|
258
|
+
}
|
|
259
|
+
if (unreadableViewRoles.length > 0 && resolved.rules.get('L15')?.enabled !== false) {
|
|
260
|
+
findings.push(...checkUnreadableViewReach(viewBodies.views, roleGraph, { roles: unreadableViewRoles }));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// L9/L10/L18 are the write half of the same question, and share one analysis.
|
|
264
|
+
if (viewWritesEnabled) {
|
|
265
|
+
const writes = await analyzeViewWrites(viewSnapshot, snapshot);
|
|
266
|
+
if (viewWriteRoles.length > 0 && resolved.rules.get('L9')?.enabled !== false) {
|
|
267
|
+
findings.push(...checkDefinerViewWrite(writes.autoUpdatable, snapshot, roleGraph, { roles: viewWriteRoles }));
|
|
268
|
+
}
|
|
269
|
+
if (ruleBypassRoles.length > 0 && resolved.rules.get('L10')?.enabled !== false) {
|
|
270
|
+
findings.push(...checkViewRuleBypass(writes.ruleDriven, snapshot, roleGraph, { roles: ruleBypassRoles }));
|
|
271
|
+
}
|
|
272
|
+
if (uncheckedWriteRoles.length > 0 && resolved.rules.get('L18')?.enabled !== false) {
|
|
273
|
+
findings.push(...checkUncheckedViewWrite(writes.unchecked, snapshot, roleGraph, {
|
|
274
|
+
roles: uncheckedWriteRoles
|
|
275
|
+
}));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// L19/L20 are the function half of "SQL bodies confer privilege": what a
|
|
279
|
+
// definer function's body reaches, and where an INSTEAD OF trigger's write
|
|
280
|
+
// actually lands. One body pass serves both, plus L15's coverage report.
|
|
281
|
+
if (functionBodiesEnabled) {
|
|
282
|
+
const bodies = await analyzeFunctionBodies(await getFunctions(), viewSnapshot, snapshot, auditedSchemas);
|
|
283
|
+
if (definerFunctionRoles.length > 0 && resolved.rules.get('L19')?.enabled !== false) {
|
|
284
|
+
findings.push(...checkDefinerFunctionReach(bodies.functions, snapshot, roleGraph, schemaAclsByName, {
|
|
285
|
+
roles: definerFunctionRoles
|
|
286
|
+
}));
|
|
287
|
+
}
|
|
288
|
+
if (triggerWriteRoles.length > 0 && resolved.rules.get('L20')?.enabled !== false) {
|
|
289
|
+
findings.push(...checkInsteadOfTriggerWrite(bodies.triggers, snapshot, roleGraph, {
|
|
290
|
+
roles: triggerWriteRoles
|
|
291
|
+
}));
|
|
292
|
+
}
|
|
293
|
+
if (unreadableViewRoles.length > 0 && resolved.rules.get('L15')?.enabled !== false) {
|
|
294
|
+
findings.push(...checkUnreadableFunctionReach(bodies.functions, roleGraph, schemaAclsByName, {
|
|
295
|
+
roles: unreadableViewRoles
|
|
296
|
+
}));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// L11/L12 are the other two things a readable view does: store rows, and
|
|
300
|
+
// filter them without being a boundary. One body pass serves both.
|
|
301
|
+
if (viewExposureEnabled) {
|
|
302
|
+
const exposureViews = await analyzeViewExposure(viewSnapshot, snapshot);
|
|
303
|
+
if (matviewRoles.length > 0 && resolved.rules.get('L11')?.enabled !== false) {
|
|
304
|
+
findings.push(...checkMatviewSnapshot(exposureViews.matviews, snapshot, roleGraph, { roles: matviewRoles }));
|
|
305
|
+
}
|
|
306
|
+
if (leakyViewRoles.length > 0 && resolved.rules.get('L12')?.enabled !== false) {
|
|
307
|
+
findings.push(...checkLeakyFilterView(exposureViews.leaky, snapshot, roleGraph, { roles: leakyViewRoles }));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// L21: the EXECUTE grants each untrusted role holds that no reachable path
|
|
311
|
+
// exercises. Its own reachability closure — policy predicates, trigger
|
|
312
|
+
// bodies, write-time expressions and views — needs the trigger/expression
|
|
313
|
+
// inputs no other rule reads. The revocable recommendations enter the finding
|
|
314
|
+
// stream; the retained-and-suppressed proof lists ride on the report.
|
|
315
|
+
let revocableGrants;
|
|
316
|
+
if (revocableEnabled) {
|
|
317
|
+
const reachInputs = await introspectReachInputs(exec, {
|
|
318
|
+
schemas: options.schemas ?? config.schemas,
|
|
319
|
+
excludeSchemas: options.excludeSchemas ?? config.excludeSchemas
|
|
320
|
+
});
|
|
321
|
+
revocableGrants = await analyzeRevocableGrants({
|
|
322
|
+
roles: revocableRoles,
|
|
323
|
+
functions: await getFunctions(),
|
|
324
|
+
tables: snapshot,
|
|
325
|
+
views: viewSnapshot,
|
|
326
|
+
graph: roleGraph,
|
|
327
|
+
schemaAcls: schemaAclsByName,
|
|
328
|
+
reachInputs,
|
|
329
|
+
exposedSchemas,
|
|
330
|
+
exposureKnown: exposure.known,
|
|
331
|
+
unaddressable
|
|
332
|
+
});
|
|
333
|
+
findings.push(...revocableGrantFindings(revocableGrants));
|
|
334
|
+
}
|
|
335
|
+
const statsSnapshot = statsEnabled
|
|
336
|
+
? await introspectStats(exec, {
|
|
337
|
+
schemas: options.schemas ?? config.schemas,
|
|
338
|
+
excludeSchemas: options.excludeSchemas ?? config.excludeSchemas,
|
|
339
|
+
extensions,
|
|
340
|
+
statementLimit: config.perf?.stats?.topStatements
|
|
341
|
+
})
|
|
342
|
+
: null;
|
|
343
|
+
if (perfEnabled) {
|
|
344
|
+
for (const table of indexSnapshot) {
|
|
345
|
+
findings.push(...checkUnindexedForeignKeys(table, paths, onWriteOncePointer));
|
|
346
|
+
findings.push(...checkRedundantIndexes(table));
|
|
347
|
+
findings.push(...checkUnindexedSearchColumns(table));
|
|
348
|
+
findings.push(...checkUnindexedSortColumns(table));
|
|
349
|
+
const x6 = checkMissingPrimaryKey(table);
|
|
350
|
+
if (x6)
|
|
351
|
+
findings.push(x6);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (statsSnapshot) {
|
|
355
|
+
findings.push(...checkStats(statsSnapshot, statsThresholds(config)));
|
|
356
|
+
}
|
|
357
|
+
// --- Convention linter (C*): source-level rules over function definitions ---
|
|
358
|
+
const enabledLintRules = LINT_RULES.filter((r) => resolved.rules.get(r.code)?.enabled !== false);
|
|
359
|
+
if (enabledLintRules.length > 0) {
|
|
360
|
+
const lintRuleIds = enabledLintRules.map((r) => r.id);
|
|
361
|
+
for (const fn of await getFunctions()) {
|
|
362
|
+
if (!fn.definition)
|
|
363
|
+
continue;
|
|
364
|
+
const subject = `${fn.schema}.${fn.name}(${fn.args})`;
|
|
365
|
+
const { problems, suppressed } = await lintDefinition(fn.definition, fn.language, subject, { rules: lintRuleIds });
|
|
366
|
+
for (const p of problems)
|
|
367
|
+
findings.push(lintFinding(fn, subject, p));
|
|
368
|
+
for (const s of suppressed)
|
|
369
|
+
findings.push(lintFinding(fn, subject, s, true));
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
findings = applyRulesToFindings(resolved, findings);
|
|
373
|
+
// Routine findings are graded by whether an untrusted role can *call the
|
|
374
|
+
// function*, not by whether its schema is on the API surface — see
|
|
375
|
+
// `RuleMeta.subject`. Resolved only when such a finding exists, so an audit
|
|
376
|
+
// with the routine rules off still costs one query less.
|
|
377
|
+
const routineRoles = [...new Set([...apiRoles, ...(exposure.anonRoles ?? [])])];
|
|
378
|
+
let routineReach;
|
|
379
|
+
const isRoutineExposed = async (schema, name) => {
|
|
380
|
+
if (!routineReach) {
|
|
381
|
+
routineReach = resolveRoutineReach(await getFunctions(), {
|
|
382
|
+
roles: routineRoles,
|
|
383
|
+
graph: roleGraph,
|
|
384
|
+
schemaAcls: schemaAclsByName,
|
|
385
|
+
exposedSchemas
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
return routineReach.has(`${schema}.${name}`);
|
|
389
|
+
};
|
|
390
|
+
// Stamp direction (from the registry) and exposure on every finding.
|
|
391
|
+
const publicRead = config.public?.read ?? [];
|
|
392
|
+
const perfIgnore = config.perf?.ignore ?? [];
|
|
393
|
+
for (const f of findings) {
|
|
394
|
+
const meta = RULES_BY_CODE.get(f.code);
|
|
395
|
+
if (meta && f.direction === undefined)
|
|
396
|
+
f.direction = meta.direction;
|
|
397
|
+
if (meta && f.dimension === undefined)
|
|
398
|
+
f.dimension = dimensionOf(meta);
|
|
399
|
+
if (exposure.known && f.schema) {
|
|
400
|
+
// L14 names a relation outside the audited schemas by construction, so
|
|
401
|
+
// grading it by its own schema would file every finding as an internal
|
|
402
|
+
// advisory. What is exposed is the view an API role reads it through.
|
|
403
|
+
const viewRef = f.code === 'L14'
|
|
404
|
+
? f.context?.view
|
|
405
|
+
: undefined;
|
|
406
|
+
// A routine finding names a function somewhere in the finding — in
|
|
407
|
+
// `context.function` for the reach rules, in `schema`/`table` for the
|
|
408
|
+
// convention linter, which files a function under the fields a relation
|
|
409
|
+
// finding uses.
|
|
410
|
+
const routineRef = meta && subjectOf(meta) === 'routine'
|
|
411
|
+
? f.context?.function?.replace(/\(.*$/, '')
|
|
412
|
+
?? (f.table ? `${f.schema}.${f.table}` : undefined)
|
|
413
|
+
: undefined;
|
|
414
|
+
if (routineRef) {
|
|
415
|
+
const dot = routineRef.lastIndexOf('.');
|
|
416
|
+
f.exposed = await isRoutineExposed(routineRef.slice(0, dot), routineRef.slice(dot + 1));
|
|
417
|
+
}
|
|
418
|
+
else if (viewRef) {
|
|
419
|
+
f.exposed = isExposed(viewRef.slice(0, viewRef.lastIndexOf('.')), viewRef.slice(viewRef.lastIndexOf('.') + 1));
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
f.exposed = isExposed(f.schema, f.table);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// A key that looks like a write-once provisioning pointer, where the
|
|
426
|
+
// reviewer has chosen to read the finding rather than gate on it. Applied
|
|
427
|
+
// here because severities are restamped from the rule registry above.
|
|
428
|
+
if (onWriteOncePointer === 'demote' && f.code === 'X1'
|
|
429
|
+
&& f.context?.pathAssessment === 'write-once-shaped') {
|
|
430
|
+
f.acknowledged = true;
|
|
431
|
+
f.severity = 'info';
|
|
432
|
+
f.message += ' — write-once-shaped (perf.paths.onWriteOncePointer)';
|
|
433
|
+
}
|
|
434
|
+
// Declared-intentional perf debt: cold tables, tiny lookups, anything the
|
|
435
|
+
// planner will seq-scan regardless. Reported, but off the perf score.
|
|
436
|
+
if (f.dimension === 'perf' && f.schema && f.table
|
|
437
|
+
&& perfIgnore.some((p) => matchTablePattern(p, `${f.schema}.${f.table}`))) {
|
|
438
|
+
f.acknowledged = true;
|
|
439
|
+
f.severity = 'info';
|
|
440
|
+
f.message += ' — acknowledged (perf.ignore)';
|
|
441
|
+
f.hint = 'This table is declared in `perf.ignore`, so the finding is treated as intentional and does not affect the perf score.';
|
|
442
|
+
}
|
|
443
|
+
// Declared-public reads: an open SELECT on a table listed in
|
|
444
|
+
// `public.read` is intent, not a finding — acknowledge it (info,
|
|
445
|
+
// excluded from the score). Undeclared open reads stay scored.
|
|
446
|
+
if (f.code === 'A8' && f.schema && f.table
|
|
447
|
+
&& publicRead.some((p) => matchTablePattern(p, `${f.schema}.${f.table}`))) {
|
|
448
|
+
f.acknowledged = true;
|
|
449
|
+
f.severity = 'info';
|
|
450
|
+
f.message += ' — declared public read (public.read)';
|
|
451
|
+
f.hint = 'This table is declared in `public.read`, so the open read is treated as intentional and does not affect the score.';
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
// W1: no exposure surface — the whole database is assumed reachable.
|
|
455
|
+
if (!exposure.known && resolved.rules.get('W1')?.enabled !== false) {
|
|
456
|
+
findings.push({
|
|
457
|
+
code: 'W1',
|
|
458
|
+
severity: resolved.rules.get('W1')?.severity ?? 'medium',
|
|
459
|
+
category: 'meta',
|
|
460
|
+
direction: 'neutral',
|
|
461
|
+
message: 'No exposure surface configured — the audit assumes the entire database is reachable and the score is capped',
|
|
462
|
+
hint: 'Declare `exposure.schemas` (or use `exposure.resolver: "constructive"` on a Constructive database) so the score reflects what the exposed APIs can actually reach.'
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
// Planner proof runs last: it reads the findings the rules produced and
|
|
466
|
+
// acknowledges the ones the planner disagrees with, before scoring.
|
|
467
|
+
let explainReport;
|
|
468
|
+
if (explainEnabled) {
|
|
469
|
+
explainReport = await proveFindings(exec, findings.filter((f) => f.dimension === 'perf' && !f.acknowledged), indexesByTable, { minRows: config.perf?.explain?.minRows });
|
|
470
|
+
}
|
|
471
|
+
findings.sort(compareFindings);
|
|
472
|
+
const exposureReport = {
|
|
473
|
+
known: exposure.known,
|
|
474
|
+
source: exposure.source,
|
|
475
|
+
plane: planes[0].name,
|
|
476
|
+
schemas: exposure.schemas,
|
|
477
|
+
...(exposure.roles ? { roles: exposure.roles } : {}),
|
|
478
|
+
...(exposure.anonRoles && exposure.anonRoles.length > 0
|
|
479
|
+
? { anonRoles: exposure.anonRoles }
|
|
480
|
+
: {}),
|
|
481
|
+
exposedTables,
|
|
482
|
+
totalTables: snapshot.length,
|
|
483
|
+
...(apiReach && apiReach.unreachable.length > 0
|
|
484
|
+
? { unaddressable: apiReach.unreachable.filter((r) => exposedSchemas.has(r.schema)) }
|
|
485
|
+
: {})
|
|
486
|
+
};
|
|
487
|
+
const securityFindings = findings.filter((f) => f.dimension !== 'perf');
|
|
488
|
+
const perfFindings = findings.filter((f) => f.dimension === 'perf');
|
|
489
|
+
const report = {
|
|
490
|
+
version: PKG_VERSION,
|
|
491
|
+
generatedAt: new Date().toISOString(),
|
|
492
|
+
provenance: {
|
|
493
|
+
version: PKG_VERSION,
|
|
494
|
+
fingerprint: configFingerprint(config, PKG_VERSION),
|
|
495
|
+
sealed: options.sealed === true,
|
|
496
|
+
...(options.preset ? { preset: options.preset } : {})
|
|
497
|
+
},
|
|
498
|
+
summary: summarize(findings),
|
|
499
|
+
findings,
|
|
500
|
+
score: computeScore(securityFindings, config.scoring, {
|
|
501
|
+
exposedTables,
|
|
502
|
+
exposureKnown: exposure.known
|
|
503
|
+
}),
|
|
504
|
+
exposure: exposureReport
|
|
505
|
+
};
|
|
506
|
+
// Access planes. The primary plane's score is `report.score` — computed
|
|
507
|
+
// above, against the exposure surface — so declaring planes can never move
|
|
508
|
+
// the headline number; the secondaries answer what the headline cannot.
|
|
509
|
+
const reaches = resolvePlaneReach(planes, snapshot, roleGraph, apiReach);
|
|
510
|
+
stampPlanes(findings, reaches);
|
|
511
|
+
if (reaches.length > 1) {
|
|
512
|
+
report.planes = reaches.map((reach) => {
|
|
513
|
+
if (reach.skipped) {
|
|
514
|
+
return {
|
|
515
|
+
...scorePlane(reach, [], config.scoring, exposure.known),
|
|
516
|
+
skipped: reach.skipped
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
if (reach.plane.primary) {
|
|
520
|
+
return {
|
|
521
|
+
...scorePlane(reach, securityFindings, config.scoring, exposure.known),
|
|
522
|
+
exposedTables,
|
|
523
|
+
score: report.score,
|
|
524
|
+
summary: summarize(securityFindings.filter((f) => f.exposed !== false))
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
return scorePlane(reach, securityFindings, config.scoring, exposure.known);
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
// Per-role exposure: computed for the configured untrusted roles (L5/R1
|
|
531
|
+
// options), so the report answers "what can anonymous access?" directly
|
|
532
|
+
// even when no finding fires.
|
|
533
|
+
const untrustedRoles = [...new Set([
|
|
534
|
+
...(withExposedRoles(resolved.rules.get('L5')?.options, exposure)?.roles ?? []),
|
|
535
|
+
...(withExposedRoles(resolved.rules.get('R1')?.options, exposure)?.roles ?? [])
|
|
536
|
+
])].sort();
|
|
537
|
+
if (untrustedRoles.length > 0) {
|
|
538
|
+
const roleAccess = {
|
|
539
|
+
roles: computeRoleAccess(snapshot, roleGraph, untrustedRoles)
|
|
540
|
+
};
|
|
541
|
+
report.roleAccess = roleAccess;
|
|
542
|
+
}
|
|
543
|
+
if (revocableGrants)
|
|
544
|
+
report.revocableGrants = revocableGrants;
|
|
545
|
+
if (perfEnabled) {
|
|
546
|
+
// `S*` findings are scored by default — opting into `--stats` is the
|
|
547
|
+
// opt-in — but can be demoted to advisories for a deterministic grade.
|
|
548
|
+
const includeStats = config.perf?.scoring?.includeStats ?? true;
|
|
549
|
+
const scorable = includeStats
|
|
550
|
+
? perfFindings
|
|
551
|
+
: perfFindings.filter((f) => !f.code.startsWith('S'));
|
|
552
|
+
const perf = {
|
|
553
|
+
findings: perfFindings,
|
|
554
|
+
summary: summarize(perfFindings),
|
|
555
|
+
score: computeScore(scorable, config.perf?.scoring, {
|
|
556
|
+
exposedTables,
|
|
557
|
+
exposureKnown: exposure.known
|
|
558
|
+
})
|
|
559
|
+
};
|
|
560
|
+
if (paths.size > 0) {
|
|
561
|
+
const all = [...paths.values()];
|
|
562
|
+
const shaped = all.filter((p) => p.assessment === 'write-once-shaped');
|
|
563
|
+
const declaredHidden = all.filter((p) => p.signals.some((s) => s.name === 'behavior-hidden')).length;
|
|
564
|
+
perf.paths = {
|
|
565
|
+
total: paths.size,
|
|
566
|
+
read: all.filter((p) => p.assessment === 'read').length,
|
|
567
|
+
writeOnceShaped: shaped.length,
|
|
568
|
+
tables: new Set(shaped.map((p) => `${p.schema}.${p.table}`)).size,
|
|
569
|
+
onWriteOncePointer,
|
|
570
|
+
...(declaredHidden > 0 ? { declaredHidden } : {})
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
if (statsSnapshot) {
|
|
574
|
+
const stats = {
|
|
575
|
+
source: 'live',
|
|
576
|
+
tables: statsSnapshot.tables.length,
|
|
577
|
+
statsReset: statsSnapshot.statsReset,
|
|
578
|
+
scored: includeStats
|
|
579
|
+
};
|
|
580
|
+
if (statsSnapshot.statementsUnavailable) {
|
|
581
|
+
stats.notes = [statsSnapshot.statementsUnavailable];
|
|
582
|
+
}
|
|
583
|
+
perf.stats = stats;
|
|
584
|
+
}
|
|
585
|
+
if (explainReport)
|
|
586
|
+
perf.explain = explainReport;
|
|
587
|
+
report.perf = perf;
|
|
588
|
+
}
|
|
589
|
+
// Scorecards last: they are named questions asked of the finished finding
|
|
590
|
+
// set, so every stamp a scorecard can select on (exposure, planes, perf
|
|
591
|
+
// dimension) is already on the findings by now.
|
|
592
|
+
report.scorecards = computeScorecards(findings, report.score, { ...RESERVED_SCORECARDS, ...(config.scorecards ?? {}) }, { exposedTables, totalTables: snapshot.length, exposureKnown: exposure.known });
|
|
593
|
+
if (options.callGraph) {
|
|
594
|
+
const functions = await getFunctions();
|
|
595
|
+
report.callGraph = await buildCallGraph({
|
|
596
|
+
functions,
|
|
597
|
+
tables: snapshot,
|
|
598
|
+
exposedSchemas: exposure.known ? exposure.schemas : undefined,
|
|
599
|
+
apiRoles: exposure.roles && exposure.roles.length > 0
|
|
600
|
+
? exposure.roles
|
|
601
|
+
: ['anonymous', 'authenticated']
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
return report;
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Materializes `rolesFrom: 'exposure'` into concrete role names. An adapter
|
|
608
|
+
* resolves the API-edge roles from the catalog (a Constructive API's
|
|
609
|
+
* `anon_role`, PostgREST's `pgrst.db_anon_role`, the role a graphile
|
|
610
|
+
* authenticator can `SET ROLE` to); those roles are exactly the untrusted
|
|
611
|
+
* ones, and their names are per-deployment, so a preset names the *source*
|
|
612
|
+
* instead of the roles. Explicit `roles` still apply — the two union.
|
|
613
|
+
*/
|
|
614
|
+
function withExposedRoles(options, exposure) {
|
|
615
|
+
if (!options?.rolesFrom)
|
|
616
|
+
return options;
|
|
617
|
+
const resolved = options.rolesFrom === 'anon' ? exposure.anonRoles : exposure.roles;
|
|
618
|
+
const roles = [...new Set([...(options.roles ?? []), ...(resolved ?? [])])].sort();
|
|
619
|
+
return { ...options, roles };
|
|
620
|
+
}
|
|
621
|
+
/** Stats floors, config over defaults. */
|
|
622
|
+
function statsThresholds(config) {
|
|
623
|
+
const stats = config.perf?.stats ?? {};
|
|
624
|
+
return {
|
|
625
|
+
minRows: stats.minRows ?? DEFAULT_STATS_THRESHOLDS.minRows,
|
|
626
|
+
seqScanRatio: stats.seqScanRatio ?? DEFAULT_STATS_THRESHOLDS.seqScanRatio,
|
|
627
|
+
minIndexBytes: stats.minIndexBytes ?? DEFAULT_STATS_THRESHOLDS.minIndexBytes,
|
|
628
|
+
deadTupleRatio: stats.deadTupleRatio ?? DEFAULT_STATS_THRESHOLDS.deadTupleRatio,
|
|
629
|
+
minTimeShare: stats.minTimeShare ?? DEFAULT_STATS_THRESHOLDS.minTimeShare,
|
|
630
|
+
topStatements: stats.topStatements ?? DEFAULT_STATS_THRESHOLDS.topStatements
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
async function auditTableAst(exec, table, indexes) {
|
|
634
|
+
if (table.policies.length === 0)
|
|
635
|
+
return [];
|
|
636
|
+
// Collect all function names referenced across this table's policies so we
|
|
637
|
+
// can resolve volatility in one round-trip.
|
|
638
|
+
const funcNames = [];
|
|
639
|
+
const parsed = [];
|
|
640
|
+
for (const p of table.policies) {
|
|
641
|
+
const using = await parseOrNull(p.using, `${table.schema}.${table.name}.${p.name} USING`);
|
|
642
|
+
const withCheck = await parseOrNull(p.withCheck, `${table.schema}.${table.name}.${p.name} WITH CHECK`);
|
|
643
|
+
parsed.push({ policy: p, using, withCheck });
|
|
644
|
+
if (using)
|
|
645
|
+
funcNames.push(...collectFunctionNames(using));
|
|
646
|
+
if (withCheck)
|
|
647
|
+
funcNames.push(...collectFunctionNames(withCheck));
|
|
648
|
+
}
|
|
649
|
+
let volatility;
|
|
650
|
+
try {
|
|
651
|
+
volatility = await lookupVolatility(exec, funcNames);
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
volatility = new Map();
|
|
655
|
+
}
|
|
656
|
+
const findings = [];
|
|
657
|
+
const predicateColumns = new Map();
|
|
658
|
+
for (const { policy, using, withCheck } of parsed) {
|
|
659
|
+
const trivial = checkTriviallyPermissive(table, policy, using, withCheck);
|
|
660
|
+
if (trivial)
|
|
661
|
+
findings.push(trivial);
|
|
662
|
+
if (using) {
|
|
663
|
+
findings.push(...checkVolatileFunctions(table, using, volatility, policy.name));
|
|
664
|
+
findings.push(...checkSessionUserGating(table, using, policy.name));
|
|
665
|
+
}
|
|
666
|
+
if (withCheck) {
|
|
667
|
+
findings.push(...checkVolatileFunctions(table, withCheck, volatility, policy.name));
|
|
668
|
+
findings.push(...checkSessionUserGating(table, withCheck, policy.name));
|
|
669
|
+
}
|
|
670
|
+
if (!indexes)
|
|
671
|
+
continue;
|
|
672
|
+
// --- Policy-aware perf rules (X2/X3/X4/X9) ---
|
|
673
|
+
const cols = [];
|
|
674
|
+
if (using) {
|
|
675
|
+
cols.push(...collectPredicateColumns(using, 'USING', table.name));
|
|
676
|
+
findings.push(...checkNonLeakproofPolicyFunctions(table, using, volatility, policy.name));
|
|
677
|
+
findings.push(...checkUnhoistedPolicyFunctions(table, using, volatility, policy.name, 'USING'));
|
|
678
|
+
}
|
|
679
|
+
if (withCheck) {
|
|
680
|
+
cols.push(...collectPredicateColumns(withCheck, 'WITH CHECK', table.name));
|
|
681
|
+
findings.push(...checkNonLeakproofPolicyFunctions(table, withCheck, volatility, policy.name));
|
|
682
|
+
findings.push(...checkUnhoistedPolicyFunctions(table, withCheck, volatility, policy.name, 'WITH CHECK'));
|
|
683
|
+
}
|
|
684
|
+
if (cols.length > 0)
|
|
685
|
+
predicateColumns.set(policy.name, cols);
|
|
686
|
+
}
|
|
687
|
+
if (indexes && predicateColumns.size > 0) {
|
|
688
|
+
findings.push(...checkUnindexedPolicyColumns(table, indexes, predicateColumns));
|
|
689
|
+
findings.push(...checkPolicyColumnCasts(table, indexes, predicateColumns));
|
|
690
|
+
}
|
|
691
|
+
return dedupe(findings);
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Relations named by any RLS policy predicate in the snapshot.
|
|
695
|
+
*
|
|
696
|
+
* Whole-word token matching over the predicate text, matching both the bare
|
|
697
|
+
* and the schema-qualified name. Deliberately over-eager: a spurious match
|
|
698
|
+
* costs one unreported L6, a missed one costs a recommendation to revoke a
|
|
699
|
+
* grant that authorization depends on.
|
|
700
|
+
*/
|
|
701
|
+
function policyReferencedRelations(tables) {
|
|
702
|
+
const tokens = new Set();
|
|
703
|
+
for (const table of tables) {
|
|
704
|
+
for (const policy of table.policies) {
|
|
705
|
+
for (const clause of [policy.using, policy.withCheck]) {
|
|
706
|
+
if (!clause)
|
|
707
|
+
continue;
|
|
708
|
+
for (const match of clause.matchAll(/[A-Za-z_][A-Za-z0-9_$]*(?:\.[A-Za-z_][A-Za-z0-9_$]*)?/g)) {
|
|
709
|
+
tokens.add(match[0].toLowerCase());
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
const referenced = new Set();
|
|
715
|
+
for (const table of tables) {
|
|
716
|
+
const key = `${table.schema}.${table.name}`;
|
|
717
|
+
if (tokens.has(key.toLowerCase()) || tokens.has(table.name.toLowerCase())) {
|
|
718
|
+
referenced.add(key);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return referenced;
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Map a source-level lint problem onto a `Finding`. The function's name rides
|
|
725
|
+
* in the `table` slot so overrides, exposure and sorting key on it the same
|
|
726
|
+
* way they do for table findings. Suppressed problems are emitted as
|
|
727
|
+
* acknowledged findings — visible as accepted risk, off the score — carrying
|
|
728
|
+
* their waiver reason and scope in `context`.
|
|
729
|
+
*/
|
|
730
|
+
function lintFinding(fn, subject, problem, suppressed = false) {
|
|
731
|
+
const meta = LINT_RULES_BY_ID.get(problem.ruleId);
|
|
732
|
+
return {
|
|
733
|
+
code: meta.code,
|
|
734
|
+
severity: RULES_BY_CODE.get(meta.code).defaultSeverity,
|
|
735
|
+
category: 'convention',
|
|
736
|
+
schema: fn.schema,
|
|
737
|
+
table: fn.name,
|
|
738
|
+
message: `${problem.message} in ${subject}`,
|
|
739
|
+
...(problem.hint ? { hint: problem.hint } : {}),
|
|
740
|
+
...(suppressed ? { acknowledged: true } : {}),
|
|
741
|
+
context: {
|
|
742
|
+
...problem.context,
|
|
743
|
+
function: subject,
|
|
744
|
+
line: problem.line,
|
|
745
|
+
...(suppressed
|
|
746
|
+
? {
|
|
747
|
+
suppressed: true,
|
|
748
|
+
reason: problem.reason,
|
|
749
|
+
suppressionScope: problem.scope
|
|
750
|
+
}
|
|
751
|
+
: {})
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
function compareFindings(a, b) {
|
|
756
|
+
const order = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
757
|
+
if (order[a.severity] !== order[b.severity])
|
|
758
|
+
return order[a.severity] - order[b.severity];
|
|
759
|
+
if (a.schema !== b.schema)
|
|
760
|
+
return (a.schema ?? '').localeCompare(b.schema ?? '');
|
|
761
|
+
if (a.table !== b.table)
|
|
762
|
+
return (a.table ?? '').localeCompare(b.table ?? '');
|
|
763
|
+
return a.code.localeCompare(b.code);
|
|
764
|
+
}
|
|
765
|
+
function dedupe(findings) {
|
|
766
|
+
const seen = new Set();
|
|
767
|
+
const out = [];
|
|
768
|
+
for (const f of findings) {
|
|
769
|
+
const key = [f.code, f.schema, f.table, f.policy, f.message].join('::');
|
|
770
|
+
if (seen.has(key))
|
|
771
|
+
continue;
|
|
772
|
+
seen.add(key);
|
|
773
|
+
out.push(f);
|
|
774
|
+
}
|
|
775
|
+
return out;
|
|
776
|
+
}
|