@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,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static body extraction for the call graph.
|
|
3
|
+
*
|
|
4
|
+
* Given a function's source, extract the function calls, table references
|
|
5
|
+
* (read vs write), and auth-context mutations it contains. Dynamic SQL
|
|
6
|
+
* (`EXECUTE format(...)`) and unparseable bodies are surfaced as `opaque`
|
|
7
|
+
* rather than silently dropped — static analysis ends there.
|
|
8
|
+
*
|
|
9
|
+
* A view body is the same question asked of a different object, so
|
|
10
|
+
* {@link extractQuery} exposes the plain-SQL half directly.
|
|
11
|
+
*/
|
|
12
|
+
import { parsePlPgSQL } from 'libpg-query';
|
|
13
|
+
import { parse } from 'pgsql-parser';
|
|
14
|
+
import { findAll, funcNameParts } from '../ast/walk';
|
|
15
|
+
const EMPTY = { calls: [], tables: [], settings: [], opaque: false };
|
|
16
|
+
/**
|
|
17
|
+
* Functions that run SQL of their own. The relations they touch are in a
|
|
18
|
+
* string argument, not in this AST, so a body calling one has a relation set
|
|
19
|
+
* that is a lower bound rather than an answer.
|
|
20
|
+
*/
|
|
21
|
+
const SQL_EXECUTING = new Set(['query_to_xml', 'dblink', 'dblink_exec', 'dblink_send_query']);
|
|
22
|
+
/** Languages whose bodies we can statically analyze. */
|
|
23
|
+
const ANALYZABLE = new Set(['sql', 'plpgsql']);
|
|
24
|
+
export async function extractBody(fn) {
|
|
25
|
+
if (!ANALYZABLE.has(fn.language)) {
|
|
26
|
+
if (fn.language === 'internal' || fn.language === 'c')
|
|
27
|
+
return EMPTY;
|
|
28
|
+
return { ...EMPTY, opaque: true, opaqueReason: `language "${fn.language}" is not statically analyzable` };
|
|
29
|
+
}
|
|
30
|
+
if (fn.language === 'sql') {
|
|
31
|
+
if (!fn.source || fn.source.trim() === '')
|
|
32
|
+
return EMPTY;
|
|
33
|
+
return extractQuery(fn.source);
|
|
34
|
+
}
|
|
35
|
+
// plpgsql: parse the full CREATE FUNCTION, then analyze every embedded
|
|
36
|
+
// SQL expression/statement the PL/pgSQL parser hands back.
|
|
37
|
+
const embedded = await plpgsqlStatements(fn);
|
|
38
|
+
if ('opaqueReason' in embedded) {
|
|
39
|
+
return { ...EMPTY, opaque: true, opaqueReason: embedded.opaqueReason };
|
|
40
|
+
}
|
|
41
|
+
const out = { calls: [], tables: [], settings: [], opaque: false, ...embedded.flags };
|
|
42
|
+
for (const sql of embedded.statements)
|
|
43
|
+
mergeBody(out, await extractQuery(sql));
|
|
44
|
+
return finalize(out);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The SQL a PL/pgSQL body embeds, as statements ready to parse, plus whether
|
|
48
|
+
* the body also runs SQL nothing can see (`EXECUTE`).
|
|
49
|
+
*
|
|
50
|
+
* Both body walkers need this and they need it identically — the read/write
|
|
51
|
+
* one for the call graph, the per-privilege one for the reach model — so the
|
|
52
|
+
* PL/pgSQL half is factored out rather than written twice.
|
|
53
|
+
*/
|
|
54
|
+
async function plpgsqlStatements(fn) {
|
|
55
|
+
if (!fn.definition)
|
|
56
|
+
return { opaqueReason: 'no function definition available' };
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = await parsePlPgSQL(fn.definition);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return { opaqueReason: 'PL/pgSQL body failed to parse' };
|
|
63
|
+
}
|
|
64
|
+
const flags = { calls: [], tables: [], settings: [], opaque: false };
|
|
65
|
+
const exprs = [];
|
|
66
|
+
collectPlpgsql(parsed, exprs, flags);
|
|
67
|
+
const statements = exprs.map((e) => {
|
|
68
|
+
// parseMode 0 = full statement; 3 = assignment (`target := expr` or
|
|
69
|
+
// `target = expr`) — strip the anchored target so the RHS parses. The
|
|
70
|
+
// RHS may itself contain `:=` (named arguments), so only the leading
|
|
71
|
+
// target is removed. Anything else is a bare expression.
|
|
72
|
+
let q = e.query;
|
|
73
|
+
if (e.parseMode === 3) {
|
|
74
|
+
q = q.replace(/^\s*[a-zA-Z_"][\w$".]*(\[[^\]]*\])*\s*:?=\s*/, '');
|
|
75
|
+
}
|
|
76
|
+
return e.parseMode === 0 ? q : `SELECT ${q}`;
|
|
77
|
+
});
|
|
78
|
+
return {
|
|
79
|
+
statements,
|
|
80
|
+
flags: { opaque: flags.opaque, ...(flags.opaqueReason ? { opaqueReason: flags.opaqueReason } : {}) }
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* {@link extractAccess} asked of a whole function rather than one statement:
|
|
85
|
+
* every relation the body touches, with the privilege the touch exercises.
|
|
86
|
+
*
|
|
87
|
+
* This is what a SECURITY DEFINER function's reach is made of — the body runs
|
|
88
|
+
* as the owner, so each `INSERT INTO t` in it is an INSERT on `t` that the
|
|
89
|
+
* caller never needed a grant for. A body that cannot be read is `opaque`,
|
|
90
|
+
* and an opaque body's access list is a fragment its caller must discard.
|
|
91
|
+
*/
|
|
92
|
+
export async function extractFunctionAccess(fn) {
|
|
93
|
+
if (!ANALYZABLE.has(fn.language)) {
|
|
94
|
+
if (fn.language === 'internal' || fn.language === 'c')
|
|
95
|
+
return { accesses: [], opaque: false };
|
|
96
|
+
return {
|
|
97
|
+
accesses: [],
|
|
98
|
+
opaque: true,
|
|
99
|
+
opaqueReason: `language "${fn.language}" is not statically analyzable`
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (fn.language === 'sql') {
|
|
103
|
+
if (!fn.source || fn.source.trim() === '')
|
|
104
|
+
return { accesses: [], opaque: false };
|
|
105
|
+
return extractAccess(fn.source);
|
|
106
|
+
}
|
|
107
|
+
const embedded = await plpgsqlStatements(fn);
|
|
108
|
+
if ('opaqueReason' in embedded) {
|
|
109
|
+
return { accesses: [], opaque: true, opaqueReason: embedded.opaqueReason };
|
|
110
|
+
}
|
|
111
|
+
const accesses = [];
|
|
112
|
+
const seen = new Set();
|
|
113
|
+
let opaque = embedded.flags.opaque;
|
|
114
|
+
let opaqueReason = embedded.flags.opaqueReason;
|
|
115
|
+
for (const sql of embedded.statements) {
|
|
116
|
+
const part = await extractAccess(sql);
|
|
117
|
+
if (part.opaque && !opaque) {
|
|
118
|
+
opaque = true;
|
|
119
|
+
opaqueReason = part.opaqueReason;
|
|
120
|
+
}
|
|
121
|
+
for (const a of part.accesses) {
|
|
122
|
+
const key = `${a.schema ?? ''}.${a.name}::${a.privilege}`;
|
|
123
|
+
if (seen.has(key))
|
|
124
|
+
continue;
|
|
125
|
+
seen.add(key);
|
|
126
|
+
accesses.push(a);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { accesses, opaque, ...(opaqueReason ? { opaqueReason } : {}) };
|
|
130
|
+
}
|
|
131
|
+
/** Walk the PL/pgSQL JSON tree: collect embedded SQL, flag dynamic EXECUTE. */
|
|
132
|
+
function collectPlpgsql(node, exprs, out) {
|
|
133
|
+
if (Array.isArray(node)) {
|
|
134
|
+
for (const item of node)
|
|
135
|
+
collectPlpgsql(item, exprs, out);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (!node || typeof node !== 'object')
|
|
139
|
+
return;
|
|
140
|
+
const rec = node;
|
|
141
|
+
if (rec.PLpgSQL_stmt_dynexecute) {
|
|
142
|
+
out.opaque = true;
|
|
143
|
+
out.opaqueReason = 'dynamic SQL (EXECUTE) — cannot follow statically';
|
|
144
|
+
// Still walk it: the format() expression itself may call functions.
|
|
145
|
+
}
|
|
146
|
+
const expr = rec.PLpgSQL_expr;
|
|
147
|
+
if (expr && typeof expr.query === 'string') {
|
|
148
|
+
exprs.push({ query: expr.query, parseMode: typeof expr.parseMode === 'number' ? expr.parseMode : 2 });
|
|
149
|
+
}
|
|
150
|
+
for (const value of Object.values(rec))
|
|
151
|
+
collectPlpgsql(value, exprs, out);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The same extraction over a standalone SQL statement — a view body, a policy
|
|
155
|
+
* predicate, anything that is already plain SQL rather than a function.
|
|
156
|
+
*/
|
|
157
|
+
export async function extractQuery(sql) {
|
|
158
|
+
let ast;
|
|
159
|
+
try {
|
|
160
|
+
ast = await parse(sql);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// Individual fragments can legitimately fail (PL/pgSQL variables in
|
|
164
|
+
// type positions, etc.) — treat as opaque rather than erroring out.
|
|
165
|
+
return { ...EMPTY, opaque: true, opaqueReason: 'SQL fragment failed to parse' };
|
|
166
|
+
}
|
|
167
|
+
const out = { calls: [], tables: [], settings: [], opaque: false };
|
|
168
|
+
for (const call of findAll(ast, 'FuncCall')) {
|
|
169
|
+
const ref = funcNameParts(call);
|
|
170
|
+
if (ref.name === '<unknown>')
|
|
171
|
+
continue;
|
|
172
|
+
out.calls.push(ref);
|
|
173
|
+
if (ref.name === 'set_config' && (!ref.schema || ref.schema === 'pg_catalog')) {
|
|
174
|
+
const setting = firstStringArg(call);
|
|
175
|
+
if (setting)
|
|
176
|
+
out.settings.push(setting);
|
|
177
|
+
}
|
|
178
|
+
// Not opaque: the rest of the body still reads correctly. Tainted: the
|
|
179
|
+
// relations this call reaches are in a string, and we do not follow it.
|
|
180
|
+
if (SQL_EXECUTING.has(ref.name)) {
|
|
181
|
+
out.tainted ??= `\`${ref.name}\` executes SQL this analysis cannot see`;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Write targets: the relation of INSERT/UPDATE/DELETE statements.
|
|
185
|
+
const writeOids = new Set();
|
|
186
|
+
for (const tag of ['InsertStmt', 'UpdateStmt', 'DeleteStmt']) {
|
|
187
|
+
for (const stmt of findAll(ast, tag)) {
|
|
188
|
+
const rel = stmt.relation;
|
|
189
|
+
if (rel)
|
|
190
|
+
writeOids.add(rel);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
for (const rv of findAll(ast, 'RangeVar')) {
|
|
194
|
+
const name = typeof rv.relname === 'string' ? rv.relname : undefined;
|
|
195
|
+
if (!name)
|
|
196
|
+
continue;
|
|
197
|
+
const schema = typeof rv.schemaname === 'string' ? rv.schemaname : undefined;
|
|
198
|
+
out.tables.push({ schema, name, write: writeOids.has(rv) });
|
|
199
|
+
}
|
|
200
|
+
// SET role / SET session_authorization / SET request.jwt… inside SQL bodies.
|
|
201
|
+
for (const set of findAll(ast, 'VariableSetStmt')) {
|
|
202
|
+
const name = typeof set.name === 'string' ? set.name : undefined;
|
|
203
|
+
if (name)
|
|
204
|
+
out.settings.push(name.toLowerCase());
|
|
205
|
+
}
|
|
206
|
+
return finalize(out);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* The same walk as {@link extractQuery}, but resolving each relation
|
|
210
|
+
* reference to the privilege it exercises instead of a read/write bit.
|
|
211
|
+
*
|
|
212
|
+
* Used for statements whose interesting content is *which grant* a reference
|
|
213
|
+
* needs — a rewrite rule's actions, where `INSERT INTO audit` means the rule
|
|
214
|
+
* needs INSERT on `audit` and nothing else tells you so.
|
|
215
|
+
*/
|
|
216
|
+
export async function extractAccess(sql) {
|
|
217
|
+
let ast;
|
|
218
|
+
try {
|
|
219
|
+
ast = await parse(sql);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return { accesses: [], opaque: true, opaqueReason: 'SQL fragment failed to parse' };
|
|
223
|
+
}
|
|
224
|
+
const byNode = new Map();
|
|
225
|
+
const commands = [
|
|
226
|
+
['InsertStmt', 'INSERT'],
|
|
227
|
+
['UpdateStmt', 'UPDATE'],
|
|
228
|
+
['DeleteStmt', 'DELETE']
|
|
229
|
+
];
|
|
230
|
+
for (const [tag, privilege] of commands) {
|
|
231
|
+
for (const stmt of findAll(ast, tag)) {
|
|
232
|
+
const rel = stmt.relation;
|
|
233
|
+
if (rel)
|
|
234
|
+
byNode.set(rel, privilege);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
let opaque = false;
|
|
238
|
+
let opaqueReason;
|
|
239
|
+
for (const call of findAll(ast, 'FuncCall')) {
|
|
240
|
+
const ref = funcNameParts(call);
|
|
241
|
+
// A rule action can hide its real target behind a function call; the
|
|
242
|
+
// relations that call touches are not in this AST.
|
|
243
|
+
if (ref.name === 'query_to_xml' || ref.name === 'dblink' || ref.name === 'dblink_exec') {
|
|
244
|
+
opaque = true;
|
|
245
|
+
opaqueReason ??= `\`${ref.name}\` executes SQL this analysis cannot see`;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const accesses = [];
|
|
249
|
+
const seen = new Set();
|
|
250
|
+
for (const rv of findAll(ast, 'RangeVar')) {
|
|
251
|
+
const name = typeof rv.relname === 'string' ? rv.relname : undefined;
|
|
252
|
+
if (!name)
|
|
253
|
+
continue;
|
|
254
|
+
const schema = typeof rv.schemaname === 'string' ? rv.schemaname : undefined;
|
|
255
|
+
const privilege = byNode.get(rv) ?? 'SELECT';
|
|
256
|
+
const key = `${schema ?? ''}.${name}::${privilege}`;
|
|
257
|
+
if (seen.has(key))
|
|
258
|
+
continue;
|
|
259
|
+
seen.add(key);
|
|
260
|
+
accesses.push({ ...(schema ? { schema } : {}), name, privilege });
|
|
261
|
+
}
|
|
262
|
+
return { accesses, opaque, ...(opaqueReason ? { opaqueReason } : {}) };
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Does this body restrict which rows come back?
|
|
266
|
+
*
|
|
267
|
+
* `null` when the SQL could not be parsed — "unknown", never "no". A view that
|
|
268
|
+
* filters is a view someone may be relying on as a row-level boundary, which
|
|
269
|
+
* is the precondition for the `security_barrier` question: without a `WHERE`
|
|
270
|
+
* there are no hidden rows for a leaky qual to reach.
|
|
271
|
+
*
|
|
272
|
+
* Only an explicit `WHERE`/`HAVING` counts. Row-limiting through a join,
|
|
273
|
+
* `DISTINCT` or `LIMIT` is real but is not the pattern that gets written as a
|
|
274
|
+
* security boundary, and treating it as one would fire on ordinary reporting
|
|
275
|
+
* views.
|
|
276
|
+
*/
|
|
277
|
+
export async function bodyFiltersRows(sql) {
|
|
278
|
+
let ast;
|
|
279
|
+
try {
|
|
280
|
+
ast = await parse(sql);
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
return findAll(ast, 'SelectStmt').some((s) => !!s.whereClause || !!s.havingClause);
|
|
286
|
+
}
|
|
287
|
+
function firstStringArg(call) {
|
|
288
|
+
const args = call.args;
|
|
289
|
+
if (!Array.isArray(args) || args.length === 0)
|
|
290
|
+
return null;
|
|
291
|
+
const first = args[0];
|
|
292
|
+
const aconst = first.A_Const;
|
|
293
|
+
const sval = aconst?.sval;
|
|
294
|
+
const v = sval?.sval;
|
|
295
|
+
return typeof v === 'string' ? v : null;
|
|
296
|
+
}
|
|
297
|
+
function mergeBody(into, from) {
|
|
298
|
+
into.calls.push(...from.calls);
|
|
299
|
+
into.tables.push(...from.tables);
|
|
300
|
+
into.settings.push(...from.settings);
|
|
301
|
+
if (from.opaque && !into.opaque) {
|
|
302
|
+
into.opaque = true;
|
|
303
|
+
into.opaqueReason = from.opaqueReason;
|
|
304
|
+
}
|
|
305
|
+
into.tainted ??= from.tainted;
|
|
306
|
+
}
|
|
307
|
+
function finalize(body) {
|
|
308
|
+
const callKeys = new Set();
|
|
309
|
+
const calls = body.calls.filter((c) => {
|
|
310
|
+
const k = `${c.schema ?? ''}.${c.name}`;
|
|
311
|
+
if (callKeys.has(k))
|
|
312
|
+
return false;
|
|
313
|
+
callKeys.add(k);
|
|
314
|
+
return true;
|
|
315
|
+
});
|
|
316
|
+
const tableKeys = new Map();
|
|
317
|
+
for (const t of body.tables) {
|
|
318
|
+
const k = `${t.schema ?? ''}.${t.name}`;
|
|
319
|
+
const existing = tableKeys.get(k);
|
|
320
|
+
if (existing)
|
|
321
|
+
existing.write = existing.write || t.write;
|
|
322
|
+
else
|
|
323
|
+
tableKeys.set(k, { ...t });
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
calls,
|
|
327
|
+
tables: [...tableKeys.values()],
|
|
328
|
+
settings: [...new Set(body.settings)],
|
|
329
|
+
opaque: body.opaque,
|
|
330
|
+
...(body.opaqueReason ? { opaqueReason: body.opaqueReason } : {}),
|
|
331
|
+
...(body.tainted ? { tainted: body.tainted } : {})
|
|
332
|
+
};
|
|
333
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Call-graph construction and trust-boundary classification.
|
|
3
|
+
*
|
|
4
|
+
* Starting from the exposed entry points (functions the API roles can
|
|
5
|
+
* EXECUTE), walk what those functions transitively call and touch, and emit
|
|
6
|
+
* an unscored audit checklist of trust boundaries for human review:
|
|
7
|
+
*
|
|
8
|
+
* CG1 trust hop — execution crosses into a SECURITY DEFINER
|
|
9
|
+
* CG2 RLS-bypass path — a DEFINER touches a table whose RLS its owner bypasses
|
|
10
|
+
* CG3 auth-context change — a reachable function mutates jwt claims / role
|
|
11
|
+
* CG4 internal reach — a non-exposed table is reached via a DEFINER hop
|
|
12
|
+
* CG5 opaque node — dynamic SQL / unparseable body; audit manually
|
|
13
|
+
*
|
|
14
|
+
* Plus two provable misconfigurations:
|
|
15
|
+
*
|
|
16
|
+
* CF1 SECURITY DEFINER without a pinned search_path (CWE-426)
|
|
17
|
+
* CF2 SECURITY DEFINER executable by anonymous / PUBLIC
|
|
18
|
+
*/
|
|
19
|
+
import type { FunctionSnapshot } from '../pg/functions';
|
|
20
|
+
import type { TableSnapshot } from '../pg/introspect';
|
|
21
|
+
export interface CallGraphOptions {
|
|
22
|
+
functions: FunctionSnapshot[];
|
|
23
|
+
tables: TableSnapshot[];
|
|
24
|
+
/** Exposed schemas; when undefined the exposure surface is unknown. */
|
|
25
|
+
exposedSchemas?: string[];
|
|
26
|
+
/** Roles the API connects as. EXECUTE for any of these (or PUBLIC) marks an entry point. */
|
|
27
|
+
apiRoles: string[];
|
|
28
|
+
}
|
|
29
|
+
export interface CallGraphNode {
|
|
30
|
+
id: string;
|
|
31
|
+
schema: string;
|
|
32
|
+
name: string;
|
|
33
|
+
securityDefiner: boolean;
|
|
34
|
+
owner: string;
|
|
35
|
+
ownerBypassesRls: boolean;
|
|
36
|
+
searchPathPinned: boolean;
|
|
37
|
+
language: string;
|
|
38
|
+
opaque: boolean;
|
|
39
|
+
opaqueReason?: string;
|
|
40
|
+
/** Auth-context settings this function writes (jwt claims, role, …). */
|
|
41
|
+
authSettings: string[];
|
|
42
|
+
/** Entry-point roles when this node is an entry (EXECUTE-granted API roles). */
|
|
43
|
+
entryRoles?: string[];
|
|
44
|
+
}
|
|
45
|
+
export interface CallGraphEdge {
|
|
46
|
+
from: string;
|
|
47
|
+
to: string;
|
|
48
|
+
kind: 'call' | 'read' | 'write';
|
|
49
|
+
}
|
|
50
|
+
export type ChecklistCode = 'CG1' | 'CG2' | 'CG3' | 'CG4' | 'CG5' | 'CF1' | 'CF2';
|
|
51
|
+
export interface ChecklistItem {
|
|
52
|
+
code: ChecklistCode;
|
|
53
|
+
/** Entry point this boundary is reachable from. */
|
|
54
|
+
entry: string;
|
|
55
|
+
/** Call path from the entry to the flagged node, entry first. */
|
|
56
|
+
path: string[];
|
|
57
|
+
/** The flagged function. */
|
|
58
|
+
fn: string;
|
|
59
|
+
/** The touched table, for CG2/CG4. */
|
|
60
|
+
table?: string;
|
|
61
|
+
message: string;
|
|
62
|
+
}
|
|
63
|
+
export interface CallGraphReport {
|
|
64
|
+
entries: Array<{
|
|
65
|
+
fn: string;
|
|
66
|
+
roles: string[];
|
|
67
|
+
securityDefiner: boolean;
|
|
68
|
+
}>;
|
|
69
|
+
nodes: CallGraphNode[];
|
|
70
|
+
edges: CallGraphEdge[];
|
|
71
|
+
checklist: ChecklistItem[];
|
|
72
|
+
stats: {
|
|
73
|
+
entryPoints: number;
|
|
74
|
+
reachableFunctions: number;
|
|
75
|
+
trustHops: number;
|
|
76
|
+
rlsBypassPaths: number;
|
|
77
|
+
authContextMutations: number;
|
|
78
|
+
internalReach: number;
|
|
79
|
+
opaqueNodes: number;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export declare function buildCallGraph(options: CallGraphOptions): Promise<CallGraphReport>;
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Call-graph construction and trust-boundary classification.
|
|
3
|
+
*
|
|
4
|
+
* Starting from the exposed entry points (functions the API roles can
|
|
5
|
+
* EXECUTE), walk what those functions transitively call and touch, and emit
|
|
6
|
+
* an unscored audit checklist of trust boundaries for human review:
|
|
7
|
+
*
|
|
8
|
+
* CG1 trust hop — execution crosses into a SECURITY DEFINER
|
|
9
|
+
* CG2 RLS-bypass path — a DEFINER touches a table whose RLS its owner bypasses
|
|
10
|
+
* CG3 auth-context change — a reachable function mutates jwt claims / role
|
|
11
|
+
* CG4 internal reach — a non-exposed table is reached via a DEFINER hop
|
|
12
|
+
* CG5 opaque node — dynamic SQL / unparseable body; audit manually
|
|
13
|
+
*
|
|
14
|
+
* Plus two provable misconfigurations:
|
|
15
|
+
*
|
|
16
|
+
* CF1 SECURITY DEFINER without a pinned search_path (CWE-426)
|
|
17
|
+
* CF2 SECURITY DEFINER executable by anonymous / PUBLIC
|
|
18
|
+
*/
|
|
19
|
+
import { extractBody } from './extract';
|
|
20
|
+
/** GUC names whose mutation means the function changes who the caller "is". */
|
|
21
|
+
const AUTH_SETTING_PATTERN = /jwt|claims|role|session_authorization|user_id|\bauth\b/i;
|
|
22
|
+
export async function buildCallGraph(options) {
|
|
23
|
+
const exposureKnown = options.exposedSchemas !== undefined;
|
|
24
|
+
const exposed = new Set(options.exposedSchemas ?? []);
|
|
25
|
+
const apiRoles = new Set([...options.apiRoles, 'PUBLIC']);
|
|
26
|
+
// Overloads collapse into one node per schema.name — we resolve calls by
|
|
27
|
+
// name, not by argument types.
|
|
28
|
+
const nodesById = new Map();
|
|
29
|
+
const fnsById = new Map();
|
|
30
|
+
const fnsByName = new Map();
|
|
31
|
+
for (const fn of options.functions) {
|
|
32
|
+
const id = `${fn.schema}.${fn.name}`;
|
|
33
|
+
const group = fnsById.get(id);
|
|
34
|
+
if (group)
|
|
35
|
+
group.push(fn);
|
|
36
|
+
else {
|
|
37
|
+
fnsById.set(id, [fn]);
|
|
38
|
+
const byName = fnsByName.get(fn.name);
|
|
39
|
+
if (byName)
|
|
40
|
+
byName.push(id);
|
|
41
|
+
else
|
|
42
|
+
fnsByName.set(fn.name, [id]);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const tablesById = new Map();
|
|
46
|
+
const tablesByName = new Map();
|
|
47
|
+
for (const t of options.tables) {
|
|
48
|
+
const id = `${t.schema}.${t.name}`;
|
|
49
|
+
tablesById.set(id, t);
|
|
50
|
+
const byName = tablesByName.get(t.name);
|
|
51
|
+
if (byName)
|
|
52
|
+
byName.push(id);
|
|
53
|
+
else
|
|
54
|
+
tablesByName.set(t.name, [id]);
|
|
55
|
+
}
|
|
56
|
+
// Entry points: functions in an exposed schema (or anywhere, when exposure
|
|
57
|
+
// is unknown) that an API role can EXECUTE.
|
|
58
|
+
const entries = [];
|
|
59
|
+
for (const [id, group] of fnsById) {
|
|
60
|
+
const fn = group[0];
|
|
61
|
+
if (exposureKnown && !exposed.has(fn.schema))
|
|
62
|
+
continue;
|
|
63
|
+
const roles = [...new Set(group.flatMap((g) => g.grants.map((gr) => gr.role)).filter((r) => apiRoles.has(r)))].sort();
|
|
64
|
+
if (roles.length === 0)
|
|
65
|
+
continue;
|
|
66
|
+
entries.push({ fn: id, roles, securityDefiner: group.some((g) => g.isSecurityDefiner) });
|
|
67
|
+
}
|
|
68
|
+
entries.sort((a, b) => a.fn.localeCompare(b.fn));
|
|
69
|
+
// Extract bodies lazily, memoized per node.
|
|
70
|
+
const bodies = new Map();
|
|
71
|
+
const getBody = async (id) => {
|
|
72
|
+
const cached = bodies.get(id);
|
|
73
|
+
if (cached)
|
|
74
|
+
return cached;
|
|
75
|
+
const group = fnsById.get(id) ?? [];
|
|
76
|
+
const merged = { calls: [], tables: [], settings: [], opaque: false };
|
|
77
|
+
for (const fn of group) {
|
|
78
|
+
const b = await extractBody(fn);
|
|
79
|
+
merged.calls.push(...b.calls);
|
|
80
|
+
merged.tables.push(...b.tables);
|
|
81
|
+
merged.settings.push(...b.settings);
|
|
82
|
+
if (b.opaque && !merged.opaque) {
|
|
83
|
+
merged.opaque = true;
|
|
84
|
+
merged.opaqueReason = b.opaqueReason;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
bodies.set(id, merged);
|
|
88
|
+
return merged;
|
|
89
|
+
};
|
|
90
|
+
const resolveCall = (ref) => {
|
|
91
|
+
if (ref.schema) {
|
|
92
|
+
return fnsById.has(`${ref.schema}.${ref.name}`) ? [`${ref.schema}.${ref.name}`] : [];
|
|
93
|
+
}
|
|
94
|
+
// Unqualified: candidates are every user function with that name. None →
|
|
95
|
+
// assume a builtin. Multiple → follow all (conservative over-approximation).
|
|
96
|
+
return fnsByName.get(ref.name) ?? [];
|
|
97
|
+
};
|
|
98
|
+
const resolveTable = (ref) => {
|
|
99
|
+
if (ref.schema) {
|
|
100
|
+
return tablesById.has(`${ref.schema}.${ref.name}`) ? [`${ref.schema}.${ref.name}`] : [];
|
|
101
|
+
}
|
|
102
|
+
return tablesByName.get(ref.name) ?? [];
|
|
103
|
+
};
|
|
104
|
+
const ensureNode = (id, body) => {
|
|
105
|
+
let node = nodesById.get(id);
|
|
106
|
+
if (node)
|
|
107
|
+
return node;
|
|
108
|
+
const group = fnsById.get(id) ?? [];
|
|
109
|
+
const fn = group[0];
|
|
110
|
+
node = {
|
|
111
|
+
id,
|
|
112
|
+
schema: fn.schema,
|
|
113
|
+
name: fn.name,
|
|
114
|
+
securityDefiner: group.some((g) => g.isSecurityDefiner),
|
|
115
|
+
owner: fn.owner,
|
|
116
|
+
ownerBypassesRls: fn.ownerBypassesRls,
|
|
117
|
+
searchPathPinned: group.every((g) => g.searchPathPinned),
|
|
118
|
+
language: fn.language,
|
|
119
|
+
opaque: body.opaque,
|
|
120
|
+
...(body.opaqueReason ? { opaqueReason: body.opaqueReason } : {}),
|
|
121
|
+
authSettings: body.settings.filter((s) => AUTH_SETTING_PATTERN.test(s)).sort()
|
|
122
|
+
};
|
|
123
|
+
nodesById.set(id, node);
|
|
124
|
+
return node;
|
|
125
|
+
};
|
|
126
|
+
const edges = [];
|
|
127
|
+
const edgeKeys = new Set();
|
|
128
|
+
const addEdge = (edge) => {
|
|
129
|
+
const key = `${edge.from}→${edge.to}:${edge.kind}`;
|
|
130
|
+
if (edgeKeys.has(key))
|
|
131
|
+
return;
|
|
132
|
+
edgeKeys.add(key);
|
|
133
|
+
edges.push(edge);
|
|
134
|
+
};
|
|
135
|
+
const checklist = [];
|
|
136
|
+
const itemKeys = new Set();
|
|
137
|
+
const addItem = (item) => {
|
|
138
|
+
// Dedupe on (code, fn, table) — the shortest path from the first entry
|
|
139
|
+
// that reaches the boundary is kept as the exemplar.
|
|
140
|
+
const key = `${item.code}::${item.fn}::${item.table ?? ''}`;
|
|
141
|
+
if (itemKeys.has(key))
|
|
142
|
+
return;
|
|
143
|
+
itemKeys.add(key);
|
|
144
|
+
checklist.push(item);
|
|
145
|
+
};
|
|
146
|
+
// BFS from each entry point; nodes are visited once globally for graph
|
|
147
|
+
// construction, but per-entry paths drive the checklist exemplars.
|
|
148
|
+
const globallyVisited = new Set();
|
|
149
|
+
for (const entry of entries) {
|
|
150
|
+
const queue = [{ id: entry.fn, path: [entry.fn] }];
|
|
151
|
+
const visited = new Set([entry.fn]);
|
|
152
|
+
while (queue.length > 0) {
|
|
153
|
+
const { id, path } = queue.shift();
|
|
154
|
+
const body = await getBody(id);
|
|
155
|
+
const node = ensureNode(id, body);
|
|
156
|
+
if (id === entry.fn)
|
|
157
|
+
node.entryRoles = entry.roles;
|
|
158
|
+
const firstVisit = !globallyVisited.has(id);
|
|
159
|
+
globallyVisited.add(id);
|
|
160
|
+
// Does the path so far cross a DEFINER boundary?
|
|
161
|
+
const pathHasDefiner = path.some((p) => nodesById.get(p)?.securityDefiner);
|
|
162
|
+
if (node.opaque) {
|
|
163
|
+
addItem({
|
|
164
|
+
code: 'CG5', entry: entry.fn, path, fn: id,
|
|
165
|
+
message: node.opaqueReason ?? 'body cannot be followed statically — audit manually'
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (node.authSettings.length > 0) {
|
|
169
|
+
addItem({
|
|
170
|
+
code: 'CG3', entry: entry.fn, path, fn: id,
|
|
171
|
+
message: `mutates auth context: ${node.authSettings.join(', ')}`
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
if (node.securityDefiner && firstVisit) {
|
|
175
|
+
if (!node.searchPathPinned) {
|
|
176
|
+
addItem({
|
|
177
|
+
code: 'CF1', entry: entry.fn, path, fn: id,
|
|
178
|
+
message: 'SECURITY DEFINER without a pinned search_path (CWE-426) — set `SET search_path` on the function'
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const group = fnsById.get(id) ?? [];
|
|
182
|
+
const wideRoles = [...new Set(group.flatMap((g) => g.grants.map((gr) => gr.role))
|
|
183
|
+
.filter((r) => r === 'PUBLIC' || r === 'anonymous'))].sort();
|
|
184
|
+
if (wideRoles.length > 0) {
|
|
185
|
+
addItem({
|
|
186
|
+
code: 'CF2', entry: entry.fn, path, fn: id,
|
|
187
|
+
message: `SECURITY DEFINER executable by ${wideRoles.join(', ')} — widest blast radius; confirm this is intended`
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Table edges.
|
|
192
|
+
for (const t of body.tables) {
|
|
193
|
+
for (const tableId of resolveTable(t)) {
|
|
194
|
+
const table = tablesById.get(tableId);
|
|
195
|
+
addEdge({ from: id, to: tableId, kind: t.write ? 'write' : 'read' });
|
|
196
|
+
const effectiveDefiner = node.securityDefiner || pathHasDefiner;
|
|
197
|
+
if (node.securityDefiner
|
|
198
|
+
&& (node.owner === table.owner || node.ownerBypassesRls)
|
|
199
|
+
&& !table.rlsForced) {
|
|
200
|
+
addItem({
|
|
201
|
+
code: 'CG2', entry: entry.fn, path, fn: id, table: tableId,
|
|
202
|
+
message: `RLS on ${tableId} does not apply on this path — ${id} is SECURITY DEFINER running as ${node.owner}`
|
|
203
|
+
+ (node.ownerBypassesRls ? ' (BYPASSRLS/superuser)' : ` (owner of ${tableId})`)
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
if (exposureKnown && !exposed.has(table.schema) && effectiveDefiner) {
|
|
207
|
+
addItem({
|
|
208
|
+
code: 'CG4', entry: entry.fn, path, fn: id, table: tableId,
|
|
209
|
+
message: `${t.write ? 'writes' : 'reads'} internal table ${tableId} via a SECURITY DEFINER path`
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Call edges.
|
|
215
|
+
for (const c of body.calls) {
|
|
216
|
+
for (const calleeId of resolveCall(c)) {
|
|
217
|
+
if (calleeId === id)
|
|
218
|
+
continue;
|
|
219
|
+
addEdge({ from: id, to: calleeId, kind: 'call' });
|
|
220
|
+
const calleeBody = await getBody(calleeId);
|
|
221
|
+
const callee = ensureNode(calleeId, calleeBody);
|
|
222
|
+
if (callee.securityDefiner) {
|
|
223
|
+
addItem({
|
|
224
|
+
code: 'CG1', entry: entry.fn, path: [...path, calleeId], fn: calleeId,
|
|
225
|
+
message: `trust hop — execution crosses into SECURITY DEFINER ${calleeId} (runs as ${callee.owner})`
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
if (!visited.has(calleeId)) {
|
|
229
|
+
visited.add(calleeId);
|
|
230
|
+
queue.push({ id: calleeId, path: [...path, calleeId] });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// The entry itself being a DEFINER is the first trust hop.
|
|
236
|
+
const entryNode = nodesById.get(entry.fn);
|
|
237
|
+
if (entryNode?.securityDefiner) {
|
|
238
|
+
addItem({
|
|
239
|
+
code: 'CG1', entry: entry.fn, path: [entry.fn], fn: entry.fn,
|
|
240
|
+
message: `trust hop — entry point is SECURITY DEFINER (runs as ${entryNode.owner}; callable by ${entry.roles.join(', ')})`
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
checklist.sort(compareItems);
|
|
245
|
+
const nodes = [...nodesById.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
246
|
+
edges.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to) || a.kind.localeCompare(b.kind));
|
|
247
|
+
const count = (code) => checklist.filter((i) => i.code === code).length;
|
|
248
|
+
return {
|
|
249
|
+
entries,
|
|
250
|
+
nodes,
|
|
251
|
+
edges,
|
|
252
|
+
checklist,
|
|
253
|
+
stats: {
|
|
254
|
+
entryPoints: entries.length,
|
|
255
|
+
reachableFunctions: globallyVisited.size,
|
|
256
|
+
trustHops: count('CG1'),
|
|
257
|
+
rlsBypassPaths: count('CG2'),
|
|
258
|
+
authContextMutations: count('CG3'),
|
|
259
|
+
internalReach: count('CG4'),
|
|
260
|
+
opaqueNodes: count('CG5')
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
const CODE_ORDER = {
|
|
265
|
+
CF1: 0, CF2: 1, CG2: 2, CG3: 3, CG1: 4, CG4: 5, CG5: 6
|
|
266
|
+
};
|
|
267
|
+
function compareItems(a, b) {
|
|
268
|
+
if (CODE_ORDER[a.code] !== CODE_ORDER[b.code])
|
|
269
|
+
return CODE_ORDER[a.code] - CODE_ORDER[b.code];
|
|
270
|
+
if (a.fn !== b.fn)
|
|
271
|
+
return a.fn.localeCompare(b.fn);
|
|
272
|
+
return (a.table ?? '').localeCompare(b.table ?? '');
|
|
273
|
+
}
|