@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.
Files changed (483) hide show
  1. package/README.md +825 -412
  2. package/adapters.d.ts +8 -0
  3. package/adapters.js +18 -0
  4. package/ast/helpers.d.ts +16 -0
  5. package/ast/helpers.js +40 -0
  6. package/ast/parse.d.ts +31 -0
  7. package/ast/parse.js +87 -0
  8. package/ast/walk.d.ts +27 -0
  9. package/ast/walk.js +79 -0
  10. package/callgraph/baseline.d.ts +28 -0
  11. package/callgraph/baseline.js +55 -0
  12. package/callgraph/extract.d.ts +93 -0
  13. package/callgraph/extract.js +340 -0
  14. package/callgraph/graph.d.ts +82 -0
  15. package/callgraph/graph.js +276 -0
  16. package/checks/anti-patterns.d.ts +53 -0
  17. package/checks/anti-patterns.js +247 -0
  18. package/checks/column-grants.d.ts +20 -0
  19. package/checks/column-grants.js +74 -0
  20. package/checks/coverage.d.ts +43 -0
  21. package/checks/coverage.js +110 -0
  22. package/checks/definer-function.d.ts +91 -0
  23. package/checks/definer-function.js +458 -0
  24. package/checks/definer-view.d.ts +167 -0
  25. package/checks/definer-view.js +392 -0
  26. package/checks/indexes.d.ts +82 -0
  27. package/checks/indexes.js +263 -0
  28. package/checks/lattice.d.ts +179 -0
  29. package/checks/lattice.js +487 -0
  30. package/checks/object-acls.d.ts +32 -0
  31. package/checks/object-acls.js +129 -0
  32. package/checks/policy-index.d.ts +78 -0
  33. package/checks/policy-index.js +349 -0
  34. package/checks/revocable-grants.d.ts +140 -0
  35. package/checks/revocable-grants.js +377 -0
  36. package/checks/rls-flags.d.ts +25 -0
  37. package/checks/rls-flags.js +82 -0
  38. package/checks/role-reach.d.ts +329 -0
  39. package/checks/role-reach.js +277 -0
  40. package/checks/role-trust.d.ts +45 -0
  41. package/checks/role-trust.js +91 -0
  42. package/checks/set-role.d.ts +21 -0
  43. package/checks/set-role.js +66 -0
  44. package/checks/stats.d.ts +52 -0
  45. package/checks/stats.js +192 -0
  46. package/checks/view-exposure.d.ts +68 -0
  47. package/checks/view-exposure.js +236 -0
  48. package/checks/view-writes.d.ts +88 -0
  49. package/checks/view-writes.js +350 -0
  50. package/ci/baseline.d.ts +104 -0
  51. package/ci/baseline.js +111 -0
  52. package/ci/github.d.ts +47 -0
  53. package/ci/github.js +197 -0
  54. package/cli/audit.d.ts +3 -0
  55. package/cli/audit.js +560 -0
  56. package/cli/baseline.d.ts +3 -0
  57. package/cli/baseline.js +104 -0
  58. package/cli/commands.d.ts +2 -0
  59. package/cli/commands.js +60 -0
  60. package/cli/doctor.d.ts +3 -0
  61. package/cli/doctor.js +72 -0
  62. package/cli/eval.d.ts +3 -0
  63. package/cli/eval.js +153 -0
  64. package/cli/print-config.d.ts +3 -0
  65. package/cli/print-config.js +54 -0
  66. package/cli/shared.d.ts +43 -0
  67. package/cli/shared.js +153 -0
  68. package/cli.d.ts +2 -0
  69. package/cli.js +69 -0
  70. package/commands/audit.d.ts +65 -0
  71. package/commands/audit.js +779 -0
  72. package/commands/doctor.d.ts +19 -0
  73. package/commands/doctor.js +181 -0
  74. package/commands/eval.d.ts +67 -0
  75. package/commands/eval.js +93 -0
  76. package/config/fingerprint.d.ts +40 -0
  77. package/config/fingerprint.js +71 -0
  78. package/config/loader.d.ts +44 -0
  79. package/config/loader.js +210 -0
  80. package/config/presets.d.ts +95 -0
  81. package/config/presets.js +327 -0
  82. package/config/resolve.d.ts +47 -0
  83. package/config/resolve.js +192 -0
  84. package/config/schema.d.ts +54 -0
  85. package/config/schema.js +269 -0
  86. package/config/types.d.ts +562 -0
  87. package/config/validate.d.ts +14 -0
  88. package/config/validate.js +125 -0
  89. package/corpus/README.md +83 -0
  90. package/corpus/bootstrap.sql +14 -0
  91. package/corpus/cases/01-anon-write-grant/case.json +26 -0
  92. package/corpus/cases/01-anon-write-grant/schema.sql +19 -0
  93. package/corpus/cases/02-grant-to-public/case.json +25 -0
  94. package/corpus/cases/02-grant-to-public/schema.sql +18 -0
  95. package/corpus/cases/03-rls-off-reachable-by-anon/case.json +30 -0
  96. package/corpus/cases/03-rls-off-reachable-by-anon/schema.sql +13 -0
  97. package/corpus/cases/04-permissive-write-policy/case.json +30 -0
  98. package/corpus/cases/04-permissive-write-policy/schema.sql +21 -0
  99. package/corpus/cases/05-public-read-policy/case.json +25 -0
  100. package/corpus/cases/05-public-read-policy/schema.sql +16 -0
  101. package/corpus/cases/06-update-without-with-check/case.json +25 -0
  102. package/corpus/cases/06-update-without-with-check/schema.sql +19 -0
  103. package/corpus/cases/07-rls-enabled-no-policies/case.json +29 -0
  104. package/corpus/cases/07-rls-enabled-no-policies/schema.sql +14 -0
  105. package/corpus/cases/08-select-grant-no-policy/case.json +25 -0
  106. package/corpus/cases/08-select-grant-no-policy/schema.sql +20 -0
  107. package/corpus/cases/09-write-grant-no-policy/case.json +24 -0
  108. package/corpus/cases/09-write-grant-no-policy/schema.sql +18 -0
  109. package/corpus/cases/10-rls-not-forced/case.json +24 -0
  110. package/corpus/cases/10-rls-not-forced/schema.sql +18 -0
  111. package/corpus/cases/11-session-user-in-policy/case.json +24 -0
  112. package/corpus/cases/11-session-user-in-policy/schema.sql +18 -0
  113. package/corpus/cases/12-volatile-function-in-policy/case.json +24 -0
  114. package/corpus/cases/12-volatile-function-in-policy/schema.sql +21 -0
  115. package/corpus/cases/13-security-definer-policy-wrapper/case.json +24 -0
  116. package/corpus/cases/13-security-definer-policy-wrapper/schema.sql +21 -0
  117. package/corpus/cases/14-dead-policy/case.json +24 -0
  118. package/corpus/cases/14-dead-policy/schema.sql +18 -0
  119. package/corpus/cases/15-unreachable-grant/case.json +24 -0
  120. package/corpus/cases/15-unreachable-grant/schema.sql +18 -0
  121. package/corpus/cases/16-anon-permissive-write-policy/case.json +34 -0
  122. package/corpus/cases/16-anon-permissive-write-policy/schema.sql +17 -0
  123. package/corpus/cases/17-foreign-key-without-index/case.json +24 -0
  124. package/corpus/cases/17-foreign-key-without-index/schema.sql +18 -0
  125. package/corpus/cases/18-policy-column-unindexed/case.json +24 -0
  126. package/corpus/cases/18-policy-column-unindexed/schema.sql +23 -0
  127. package/corpus/cases/19-policy-column-cast/case.json +24 -0
  128. package/corpus/cases/19-policy-column-cast/schema.sql +19 -0
  129. package/corpus/cases/20-stable-function-per-row/case.json +24 -0
  130. package/corpus/cases/20-stable-function-per-row/schema.sql +25 -0
  131. package/corpus/cases/21-table-without-primary-key/case.json +24 -0
  132. package/corpus/cases/21-table-without-primary-key/schema.sql +14 -0
  133. package/corpus/cases/22-redundant-index/case.json +24 -0
  134. package/corpus/cases/22-redundant-index/schema.sql +16 -0
  135. package/corpus/cases/23-tsvector-without-index/case.json +24 -0
  136. package/corpus/cases/23-tsvector-without-index/schema.sql +13 -0
  137. package/corpus/cases/24-anon-set-role-escalation/case.json +29 -0
  138. package/corpus/cases/24-anon-set-role-escalation/schema.sql +30 -0
  139. package/corpus/cases/25-definer-view-bypass/case.json +31 -0
  140. package/corpus/cases/25-definer-view-bypass/schema.sql +36 -0
  141. package/corpus/cases/26-invoker-view-no-bypass/case.json +31 -0
  142. package/corpus/cases/26-invoker-view-no-bypass/schema.sql +44 -0
  143. package/corpus/cases/27-definer-view-write/case.json +36 -0
  144. package/corpus/cases/27-definer-view-write/schema.sql +40 -0
  145. package/corpus/cases/28-invoker-view-no-write/case.json +31 -0
  146. package/corpus/cases/28-invoker-view-no-write/schema.sql +37 -0
  147. package/corpus/cases/29-rewrite-rule-bypass/case.json +36 -0
  148. package/corpus/cases/29-rewrite-rule-bypass/schema.sql +52 -0
  149. package/corpus/cases/30-instead-nothing-rule/case.json +31 -0
  150. package/corpus/cases/30-instead-nothing-rule/schema.sql +44 -0
  151. package/corpus/cases/31-matview-snapshot/case.json +32 -0
  152. package/corpus/cases/31-matview-snapshot/schema.sql +36 -0
  153. package/corpus/cases/32-matview-no-rls-no-bypass/case.json +30 -0
  154. package/corpus/cases/32-matview-no-rls-no-bypass/schema.sql +18 -0
  155. package/corpus/cases/33-leaky-filter-view/case.json +36 -0
  156. package/corpus/cases/33-leaky-filter-view/schema.sql +29 -0
  157. package/corpus/cases/34-barrier-view-no-leak/case.json +32 -0
  158. package/corpus/cases/34-barrier-view-no-leak/schema.sql +29 -0
  159. package/corpus/cases/35-column-grant-projection/case.json +27 -0
  160. package/corpus/cases/35-column-grant-projection/schema.sql +21 -0
  161. package/corpus/cases/35-policy-function-not-leakproof/case.json +24 -0
  162. package/corpus/cases/35-policy-function-not-leakproof/schema.sql +27 -0
  163. package/corpus/cases/36-column-grant-rls-mediated/case.json +27 -0
  164. package/corpus/cases/36-column-grant-rls-mediated/schema.sql +32 -0
  165. package/corpus/cases/36-policy-function-leakproof/case.json +24 -0
  166. package/corpus/cases/36-policy-function-leakproof/schema.sql +22 -0
  167. package/corpus/cases/37-sort-column-unindexed/case.json +24 -0
  168. package/corpus/cases/37-sort-column-unindexed/schema.sql +14 -0
  169. package/corpus/cases/37-unaudited-base-relation/case.json +30 -0
  170. package/corpus/cases/37-unaudited-base-relation/schema.sql +45 -0
  171. package/corpus/cases/38-foreign-key-with-index/case.json +22 -0
  172. package/corpus/cases/38-foreign-key-with-index/schema.sql +17 -0
  173. package/corpus/cases/38-invoker-view-unaudited-base/case.json +30 -0
  174. package/corpus/cases/38-invoker-view-unaudited-base/schema.sql +39 -0
  175. package/corpus/cases/39-definer-view-column-subset/case.json +30 -0
  176. package/corpus/cases/39-definer-view-column-subset/schema.sql +29 -0
  177. package/corpus/cases/39-policy-cast-with-expression-index/case.json +23 -0
  178. package/corpus/cases/39-policy-cast-with-expression-index/schema.sql +19 -0
  179. package/corpus/cases/40-definer-view-column-overreach/case.json +27 -0
  180. package/corpus/cases/40-definer-view-column-overreach/schema.sql +29 -0
  181. package/corpus/cases/40-stable-function-hoisted/case.json +23 -0
  182. package/corpus/cases/40-stable-function-hoisted/schema.sql +25 -0
  183. package/corpus/cases/41-index-prefix-not-redundant/case.json +21 -0
  184. package/corpus/cases/41-index-prefix-not-redundant/schema.sql +15 -0
  185. package/corpus/cases/41-unreadable-view-body/case.json +33 -0
  186. package/corpus/cases/41-unreadable-view-body/schema.sql +28 -0
  187. package/corpus/cases/42-invoker-view-unreadable-body/case.json +30 -0
  188. package/corpus/cases/42-invoker-view-unreadable-body/schema.sql +27 -0
  189. package/corpus/cases/42-search-column-indexed/case.json +21 -0
  190. package/corpus/cases/42-search-column-indexed/schema.sql +12 -0
  191. package/corpus/cases/43-anon-sequence-grant/case.json +27 -0
  192. package/corpus/cases/43-anon-sequence-grant/schema.sql +23 -0
  193. package/corpus/cases/44-serial-sequence-usage/case.json +27 -0
  194. package/corpus/cases/44-serial-sequence-usage/schema.sql +25 -0
  195. package/corpus/cases/45-anon-foreign-table/case.json +27 -0
  196. package/corpus/cases/45-anon-foreign-table/schema.sql +23 -0
  197. package/corpus/cases/46-unchecked-writable-view/case.json +41 -0
  198. package/corpus/cases/46-unchecked-writable-view/schema.sql +37 -0
  199. package/corpus/cases/47-checked-writable-view/case.json +37 -0
  200. package/corpus/cases/47-checked-writable-view/schema.sql +38 -0
  201. package/corpus/cases/48-definer-function-reach/case.json +38 -0
  202. package/corpus/cases/48-definer-function-reach/schema.sql +41 -0
  203. package/corpus/cases/49-invoker-function-no-reach/case.json +32 -0
  204. package/corpus/cases/49-invoker-function-no-reach/schema.sql +38 -0
  205. package/corpus/cases/50-instead-of-trigger-write/case.json +38 -0
  206. package/corpus/cases/50-instead-of-trigger-write/schema.sql +58 -0
  207. package/corpus/cases/51-invoker-trigger-no-write/case.json +33 -0
  208. package/corpus/cases/51-invoker-trigger-no-write/schema.sql +54 -0
  209. package/corpus/cases/52-revocable-execute-grant/case.json +30 -0
  210. package/corpus/cases/52-revocable-execute-grant/schema.sql +45 -0
  211. package/corpus/cases/53-policy-predicate-retains-execute/case.json +23 -0
  212. package/corpus/cases/53-policy-predicate-retains-execute/schema.sql +49 -0
  213. package/corpus/index.d.ts +79 -0
  214. package/corpus/index.js +125 -0
  215. package/esm/adapters.d.ts +8 -0
  216. package/esm/adapters.js +6 -0
  217. package/esm/ast/helpers.d.ts +16 -0
  218. package/esm/ast/helpers.js +36 -0
  219. package/esm/ast/parse.d.ts +31 -0
  220. package/esm/ast/parse.js +82 -0
  221. package/esm/ast/walk.d.ts +27 -0
  222. package/esm/ast/walk.js +72 -0
  223. package/esm/callgraph/baseline.d.ts +28 -0
  224. package/esm/callgraph/baseline.js +48 -0
  225. package/esm/callgraph/extract.d.ts +93 -0
  226. package/esm/callgraph/extract.js +333 -0
  227. package/esm/callgraph/graph.d.ts +82 -0
  228. package/esm/callgraph/graph.js +273 -0
  229. package/esm/checks/anti-patterns.d.ts +53 -0
  230. package/esm/checks/anti-patterns.js +240 -0
  231. package/esm/checks/column-grants.d.ts +20 -0
  232. package/esm/checks/column-grants.js +71 -0
  233. package/esm/checks/coverage.d.ts +43 -0
  234. package/esm/checks/coverage.js +104 -0
  235. package/esm/checks/definer-function.d.ts +91 -0
  236. package/esm/checks/definer-function.js +452 -0
  237. package/esm/checks/definer-view.d.ts +167 -0
  238. package/esm/checks/definer-view.js +382 -0
  239. package/esm/checks/indexes.d.ts +82 -0
  240. package/esm/checks/indexes.js +256 -0
  241. package/esm/checks/lattice.d.ts +179 -0
  242. package/esm/checks/lattice.js +475 -0
  243. package/esm/checks/object-acls.d.ts +32 -0
  244. package/esm/checks/object-acls.js +125 -0
  245. package/esm/checks/policy-index.d.ts +78 -0
  246. package/esm/checks/policy-index.js +342 -0
  247. package/esm/checks/revocable-grants.d.ts +140 -0
  248. package/esm/checks/revocable-grants.js +373 -0
  249. package/esm/checks/rls-flags.d.ts +25 -0
  250. package/esm/checks/rls-flags.js +77 -0
  251. package/esm/checks/role-reach.d.ts +329 -0
  252. package/esm/checks/role-reach.js +270 -0
  253. package/esm/checks/role-trust.d.ts +45 -0
  254. package/esm/checks/role-trust.js +86 -0
  255. package/esm/checks/set-role.d.ts +21 -0
  256. package/esm/checks/set-role.js +63 -0
  257. package/esm/checks/stats.d.ts +52 -0
  258. package/esm/checks/stats.js +184 -0
  259. package/esm/checks/view-exposure.d.ts +68 -0
  260. package/esm/checks/view-exposure.js +231 -0
  261. package/esm/checks/view-writes.d.ts +88 -0
  262. package/esm/checks/view-writes.js +344 -0
  263. package/esm/ci/baseline.d.ts +104 -0
  264. package/esm/ci/baseline.js +107 -0
  265. package/esm/ci/github.d.ts +47 -0
  266. package/esm/ci/github.js +161 -0
  267. package/esm/cli/audit.d.ts +3 -0
  268. package/esm/cli/audit.js +525 -0
  269. package/esm/cli/baseline.d.ts +3 -0
  270. package/esm/cli/baseline.js +102 -0
  271. package/esm/cli/commands.d.ts +2 -0
  272. package/esm/cli/commands.js +53 -0
  273. package/esm/cli/doctor.d.ts +3 -0
  274. package/esm/cli/doctor.js +67 -0
  275. package/esm/cli/eval.d.ts +3 -0
  276. package/esm/cli/eval.js +115 -0
  277. package/esm/cli/print-config.d.ts +3 -0
  278. package/esm/cli/print-config.js +52 -0
  279. package/esm/cli/shared.d.ts +43 -0
  280. package/esm/cli/shared.js +112 -0
  281. package/esm/cli.d.ts +2 -0
  282. package/esm/cli.js +67 -0
  283. package/esm/commands/audit.d.ts +65 -0
  284. package/esm/commands/audit.js +776 -0
  285. package/esm/commands/doctor.d.ts +19 -0
  286. package/esm/commands/doctor.js +178 -0
  287. package/esm/commands/eval.d.ts +67 -0
  288. package/esm/commands/eval.js +90 -0
  289. package/esm/config/fingerprint.d.ts +40 -0
  290. package/esm/config/fingerprint.js +68 -0
  291. package/esm/config/loader.d.ts +44 -0
  292. package/esm/config/loader.js +172 -0
  293. package/esm/config/presets.d.ts +95 -0
  294. package/esm/config/presets.js +324 -0
  295. package/esm/config/resolve.d.ts +47 -0
  296. package/esm/config/resolve.js +180 -0
  297. package/esm/config/schema.d.ts +54 -0
  298. package/esm/config/schema.js +265 -0
  299. package/esm/config/types.d.ts +562 -0
  300. package/esm/config/validate.d.ts +14 -0
  301. package/esm/config/validate.js +122 -0
  302. package/esm/corpus/index.d.ts +79 -0
  303. package/esm/corpus/index.js +85 -0
  304. package/esm/exposure/adapters.d.ts +152 -0
  305. package/esm/exposure/adapters.js +463 -0
  306. package/esm/exposure/planes.d.ts +59 -0
  307. package/esm/exposure/planes.js +144 -0
  308. package/esm/exposure/reach.d.ts +78 -0
  309. package/esm/exposure/reach.js +105 -0
  310. package/esm/exposure/routines.d.ts +36 -0
  311. package/esm/exposure/routines.js +54 -0
  312. package/esm/index.d.ts +96 -0
  313. package/esm/index.js +57 -6
  314. package/esm/lint/index.d.ts +11 -0
  315. package/esm/lint/index.js +10 -0
  316. package/esm/perf/baseline.d.ts +40 -0
  317. package/esm/perf/baseline.js +83 -0
  318. package/esm/perf/explain.d.ts +46 -0
  319. package/esm/perf/explain.js +201 -0
  320. package/esm/pg/acl.d.ts +72 -0
  321. package/esm/pg/acl.js +186 -0
  322. package/esm/pg/behaviors.d.ts +114 -0
  323. package/esm/pg/behaviors.js +206 -0
  324. package/esm/pg/exposure.d.ts +74 -0
  325. package/esm/pg/exposure.js +198 -0
  326. package/esm/pg/functions.d.ts +52 -0
  327. package/esm/pg/functions.js +128 -0
  328. package/esm/pg/indexes.d.ts +192 -0
  329. package/esm/pg/indexes.js +395 -0
  330. package/esm/pg/introspect.d.ts +134 -0
  331. package/esm/pg/introspect.js +274 -0
  332. package/esm/pg/objects.d.ts +37 -0
  333. package/esm/pg/objects.js +108 -0
  334. package/esm/pg/paths.d.ts +114 -0
  335. package/esm/pg/paths.js +159 -0
  336. package/esm/pg/proc.d.ts +23 -0
  337. package/esm/pg/proc.js +55 -0
  338. package/esm/pg/reach-inputs.d.ts +47 -0
  339. package/esm/pg/reach-inputs.js +133 -0
  340. package/esm/pg/roles.d.ts +28 -0
  341. package/esm/pg/roles.js +50 -0
  342. package/esm/pg/stats.d.ts +65 -0
  343. package/esm/pg/stats.js +129 -0
  344. package/esm/pgpm-test.d.ts +22 -0
  345. package/esm/pgpm-test.js +39 -0
  346. package/esm/report/callgraph.d.ts +9 -0
  347. package/esm/report/callgraph.js +74 -0
  348. package/esm/report/compare.d.ts +143 -0
  349. package/esm/report/compare.js +195 -0
  350. package/esm/report/github.d.ts +74 -0
  351. package/esm/report/github.js +290 -0
  352. package/esm/report/json.d.ts +4 -0
  353. package/esm/report/json.js +3 -0
  354. package/esm/report/markdown.d.ts +25 -0
  355. package/esm/report/markdown.js +352 -0
  356. package/esm/report/pretty.d.ts +14 -0
  357. package/esm/report/pretty.js +318 -0
  358. package/esm/report/sarif.d.ts +40 -0
  359. package/esm/report/sarif.js +189 -0
  360. package/esm/report/view.d.ts +82 -0
  361. package/esm/report/view.js +126 -0
  362. package/esm/rules/registry.d.ts +66 -0
  363. package/esm/rules/registry.js +562 -0
  364. package/esm/score/score.d.ts +84 -0
  365. package/esm/score/score.js +233 -0
  366. package/esm/score/scorecards.d.ts +68 -0
  367. package/esm/score/scorecards.js +157 -0
  368. package/esm/types.d.ts +292 -0
  369. package/esm/types.js +26 -0
  370. package/esm/version.d.ts +8 -0
  371. package/esm/version.js +9 -0
  372. package/exposure/adapters.d.ts +152 -0
  373. package/exposure/adapters.js +468 -0
  374. package/exposure/planes.d.ts +59 -0
  375. package/exposure/planes.js +151 -0
  376. package/exposure/reach.d.ts +78 -0
  377. package/exposure/reach.js +109 -0
  378. package/exposure/routines.d.ts +36 -0
  379. package/exposure/routines.js +57 -0
  380. package/index.d.ts +96 -6
  381. package/index.js +229 -6
  382. package/lint/index.d.ts +11 -0
  383. package/lint/index.js +19 -0
  384. package/package.json +54 -48
  385. package/perf/baseline.d.ts +40 -0
  386. package/perf/baseline.js +92 -0
  387. package/perf/explain.d.ts +46 -0
  388. package/perf/explain.js +204 -0
  389. package/pg/acl.d.ts +72 -0
  390. package/pg/acl.js +191 -0
  391. package/pg/behaviors.d.ts +114 -0
  392. package/pg/behaviors.js +217 -0
  393. package/pg/exposure.d.ts +74 -0
  394. package/pg/exposure.js +205 -0
  395. package/pg/functions.d.ts +52 -0
  396. package/pg/functions.js +131 -0
  397. package/pg/indexes.d.ts +192 -0
  398. package/pg/indexes.js +400 -0
  399. package/pg/introspect.d.ts +134 -0
  400. package/pg/introspect.js +280 -0
  401. package/pg/objects.d.ts +37 -0
  402. package/pg/objects.js +111 -0
  403. package/pg/paths.d.ts +114 -0
  404. package/pg/paths.js +166 -0
  405. package/pg/proc.d.ts +23 -0
  406. package/pg/proc.js +58 -0
  407. package/pg/reach-inputs.d.ts +47 -0
  408. package/pg/reach-inputs.js +136 -0
  409. package/pg/roles.d.ts +28 -0
  410. package/pg/roles.js +54 -0
  411. package/pg/stats.d.ts +65 -0
  412. package/pg/stats.js +133 -0
  413. package/pgpm-test.d.ts +22 -0
  414. package/pgpm-test.js +42 -0
  415. package/report/callgraph.d.ts +9 -0
  416. package/report/callgraph.js +81 -0
  417. package/report/compare.d.ts +143 -0
  418. package/report/compare.js +205 -0
  419. package/report/github.d.ts +74 -0
  420. package/report/github.js +333 -0
  421. package/report/json.d.ts +4 -0
  422. package/report/json.js +6 -0
  423. package/report/markdown.d.ts +25 -0
  424. package/report/markdown.js +355 -0
  425. package/report/pretty.d.ts +14 -0
  426. package/report/pretty.js +324 -0
  427. package/report/sarif.d.ts +40 -0
  428. package/report/sarif.js +193 -0
  429. package/report/view.d.ts +82 -0
  430. package/report/view.js +132 -0
  431. package/rules/registry.d.ts +66 -0
  432. package/rules/registry.js +569 -0
  433. package/schema/safegres.schema.json +1119 -0
  434. package/score/score.d.ts +84 -0
  435. package/score/score.js +238 -0
  436. package/score/scorecards.d.ts +68 -0
  437. package/score/scorecards.js +162 -0
  438. package/types.d.ts +292 -0
  439. package/types.js +32 -0
  440. package/version.d.ts +8 -0
  441. package/version.js +12 -0
  442. package/LICENSE +0 -23
  443. package/admin.d.ts +0 -26
  444. package/admin.js +0 -182
  445. package/connect.d.ts +0 -19
  446. package/connect.js +0 -83
  447. package/dist/README.md +0 -557
  448. package/dist/package.json +0 -72
  449. package/esm/admin.js +0 -178
  450. package/esm/connect.js +0 -78
  451. package/esm/manager.js +0 -136
  452. package/esm/roles.js +0 -32
  453. package/esm/seed/adapters.js +0 -23
  454. package/esm/seed/csv.js +0 -44
  455. package/esm/seed/index.js +0 -16
  456. package/esm/seed/json.js +0 -18
  457. package/esm/seed/launchql.js +0 -19
  458. package/esm/seed/sqitch.js +0 -17
  459. package/esm/stream.js +0 -43
  460. package/esm/test-client.js +0 -150
  461. package/manager.d.ts +0 -25
  462. package/manager.js +0 -140
  463. package/roles.d.ts +0 -17
  464. package/roles.js +0 -38
  465. package/seed/adapters.d.ts +0 -4
  466. package/seed/adapters.js +0 -28
  467. package/seed/csv.d.ts +0 -9
  468. package/seed/csv.js +0 -49
  469. package/seed/index.d.ts +0 -16
  470. package/seed/index.js +0 -33
  471. package/seed/json.d.ts +0 -6
  472. package/seed/json.js +0 -21
  473. package/seed/launchql.d.ts +0 -2
  474. package/seed/launchql.js +0 -22
  475. package/seed/sqitch.d.ts +0 -2
  476. package/seed/sqitch.js +0 -20
  477. package/seed/types.d.ts +0 -13
  478. package/stream.d.ts +0 -2
  479. package/stream.js +0 -46
  480. package/test-client.d.ts +0 -49
  481. package/test-client.js +0 -154
  482. /package/{seed → config}/types.js +0 -0
  483. /package/esm/{seed → config}/types.js +0 -0
@@ -0,0 +1,231 @@
1
+ /**
2
+ * L11 and L12: the two things a *readable* view does that its owner and its
3
+ * body, taken separately, do not explain.
4
+ *
5
+ * **L11 — a materialized view is a snapshot, not a query.** Its rows were
6
+ * computed once, by whoever ran `REFRESH`, and are then handed to every reader
7
+ * of the matview verbatim. The base relations are not consulted at read time,
8
+ * so their ACLs never apply and — the part that matters — their RLS policies
9
+ * never run. A matview cannot carry policies of its own either: RLS attaches
10
+ * to tables, and `security_invoker` is a view-only reloption. So a SELECT
11
+ * grant on a matview over an RLS-protected table is an unconditional grant on
12
+ * the rows that were visible at refresh time.
13
+ *
14
+ * **L12 — a view's `WHERE` is a filter, not a boundary.** Without
15
+ * `security_barrier` the planner may push the *caller's* qual below the view's
16
+ * own, so a leaky operator or a `COST 0.0001` function evaluates against rows
17
+ * the view was written to hide. The rows are not returned, but they are seen —
18
+ * and a function that raises them, writes them, or times differently on them
19
+ * exfiltrates them. This only matters where the view is the only path to the
20
+ * relation; if the caller can read the base table directly there is nothing
21
+ * for the view to hide.
22
+ *
23
+ * Both ship the same way as the rest of the L-series: `info`, score-neutral,
24
+ * and never recommending a revoke. The remedies are properties of the view.
25
+ */
26
+ import { bodyFiltersRows } from '../callgraph/extract';
27
+ import { buildRelationIndex, readBodies, resolveViewBases } from './definer-view';
28
+ import { effectiveGrants } from './lattice';
29
+ import { computeViewReach } from './role-reach';
30
+ /**
31
+ * Resolve both populations in one pass over the view bodies.
32
+ *
33
+ * A materialized view is deliberately absent from the relation index: it is a
34
+ * terminal relation for anything reading *through* it (its rows are stored),
35
+ * so it must never be followed as a nested view. Its own body is still read,
36
+ * to learn which relations the refresh copied from.
37
+ */
38
+ export async function analyzeViewExposure(views, tables) {
39
+ const queryable = views.filter((v) => !v.materialized);
40
+ const index = buildRelationIndex(queryable, tables);
41
+ const bodies = await readBodies(views);
42
+ const matviews = [];
43
+ const leaky = [];
44
+ const suppressed = [];
45
+ for (const view of views) {
46
+ const label = `${view.schema}.${view.name}`;
47
+ if (view.materialized) {
48
+ const { bases, opaque } = resolveViewBases(view, index, bodies);
49
+ if (opaque) {
50
+ suppressed.push({ view: label, reason: opaque });
51
+ continue;
52
+ }
53
+ // Relations outside the audited schemas carry no ACL or policy to
54
+ // compare the stored rows against; L14 reports the reach instead.
55
+ const graded = bases.filter((b) => !b.external);
56
+ if (graded.length === 0)
57
+ continue;
58
+ matviews.push({
59
+ schema: view.schema,
60
+ name: view.name,
61
+ owner: view.owner,
62
+ grants: view.grants,
63
+ baseRelations: graded,
64
+ materialized: true
65
+ });
66
+ continue;
67
+ }
68
+ // An invoker view confers nothing, so its `WHERE` is never the only thing
69
+ // standing between a caller and a relation: the caller needs its own
70
+ // grant on the base to read the view at all.
71
+ if (view.securityInvoker || view.securityBarrier)
72
+ continue;
73
+ const filters = await bodyFiltersRows(view.definition);
74
+ if (filters === null) {
75
+ suppressed.push({ view: label, reason: 'body could not be parsed to look for a row filter' });
76
+ continue;
77
+ }
78
+ if (!filters)
79
+ continue;
80
+ const { bases, opaque } = resolveViewBases(view, index, bodies);
81
+ if (opaque) {
82
+ suppressed.push({ view: label, reason: opaque });
83
+ continue;
84
+ }
85
+ const gradedBases = bases.filter((b) => !b.external);
86
+ if (gradedBases.length === 0)
87
+ continue;
88
+ leaky.push({
89
+ schema: view.schema,
90
+ name: view.name,
91
+ owner: view.owner,
92
+ grants: view.grants,
93
+ baseRelations: gradedBases
94
+ });
95
+ }
96
+ return { matviews, leaky, suppressed };
97
+ }
98
+ /** Is `role` actually filtered by `table`'s policies? */
99
+ function subjectToRls(table, role, graph) {
100
+ if (!table.rlsEnabled)
101
+ return false;
102
+ if (graph.get(role)?.bypassRls)
103
+ return false;
104
+ // The owner is exempt unless the table FORCEs policies on itself.
105
+ return !(table.owner === role && !table.rlsForced);
106
+ }
107
+ /**
108
+ * L11: an untrusted role reads a table's rows out of a materialized view.
109
+ *
110
+ * Fires where the role can SELECT the matview and either holds no SELECT on
111
+ * the relation the refresh read, or holds one but is subject to policies the
112
+ * stored rows never passed through. The second case is the one catalog-only
113
+ * analysis is worst at: the ACL says the role may read the table, RLS says it
114
+ * may read three rows of it, and the matview hands it all of them.
115
+ */
116
+ export function checkMatviewSnapshot(matviews, tables, graph, options = {}) {
117
+ const untrusted = options.roles ?? [];
118
+ if (untrusted.length === 0 || matviews.length === 0)
119
+ return [];
120
+ const byKey = new Map(tables.map((t) => [`${t.schema}.${t.name}`, t]));
121
+ const out = [];
122
+ for (const { role, cells } of computeViewReach(matviews, graph, untrusted)) {
123
+ for (const cell of cells) {
124
+ const base = byKey.get(`${cell.schema}.${cell.table}`);
125
+ if (!base)
126
+ continue;
127
+ const hasSelect = effectiveGrants(base, role, graph).some((g) => g.privilege === 'SELECT');
128
+ const filtered = subjectToRls(base, role, graph);
129
+ // Reachable in its own right and not row-filtered: the snapshot shows
130
+ // the role nothing it could not select from the table itself.
131
+ if (hasSelect && !filtered)
132
+ continue;
133
+ const matview = cell.path.find((e) => e.kind === 'matview');
134
+ if (!matview)
135
+ continue;
136
+ const refresher = cell.effectiveRole;
137
+ out.push({
138
+ code: 'L11',
139
+ severity: 'info',
140
+ category: 'anti-pattern',
141
+ schema: base.schema,
142
+ table: base.name,
143
+ role,
144
+ privilege: 'SELECT',
145
+ message: hasSelect
146
+ ? `Untrusted role ${role} reads ${base.schema}.${base.name} through materialized view `
147
+ + `${matview.view} without the row filter its policies apply — the rows were stored by `
148
+ + `${refresher} at REFRESH time`
149
+ : `Untrusted role ${role} reads ${base.schema}.${base.name} through materialized view `
150
+ + `${matview.view}, which stores rows computed as ${refresher} — ${role} holds no SELECT `
151
+ + `on the base relation`,
152
+ hint: `A materialized view is a stored copy: reading it never consults ${base.schema}.`
153
+ + `${base.name}, so neither its grants nor its RLS policies apply, and the matview cannot `
154
+ + `carry policies or \`security_invoker\` of its own. Replace it with a plain `
155
+ + `\`security_invoker\` view if the freshness is not what it is for, materialize only the `
156
+ + `columns and rows that are safe to hand out unconditionally, or keep it in a schema the `
157
+ + `API does not expose and serve a filtered view from it. Do not revoke the SELECT on the `
158
+ + `matview — that grant is what the API serves.`,
159
+ context: {
160
+ matview: matview.view,
161
+ effectiveRole: refresher,
162
+ baseRlsEnabled: base.rlsEnabled,
163
+ rlsBypassed: filtered,
164
+ holdsBaseSelect: hasSelect,
165
+ proof: cell.proof
166
+ }
167
+ });
168
+ }
169
+ }
170
+ return out;
171
+ }
172
+ /**
173
+ * L12: a definer view filters rows for an untrusted role but is not a barrier.
174
+ *
175
+ * Fires where the role reaches a relation *only* through a view whose body has
176
+ * a `WHERE`, and the view is not `security_barrier`. The finding is about the
177
+ * rows the view excludes: they are the ones the author meant to withhold, and
178
+ * without the barrier a cheap leaky qual supplied by the caller is evaluated
179
+ * against them.
180
+ */
181
+ export function checkLeakyFilterView(views, tables, graph, options = {}) {
182
+ const untrusted = options.roles ?? [];
183
+ if (untrusted.length === 0 || views.length === 0)
184
+ return [];
185
+ const byKey = new Map(tables.map((t) => [`${t.schema}.${t.name}`, t]));
186
+ const out = [];
187
+ for (const { role, cells } of computeViewReach(views, graph, untrusted)) {
188
+ for (const cell of cells) {
189
+ if (cell.effectiveRole === role)
190
+ continue;
191
+ const base = byKey.get(`${cell.schema}.${cell.table}`);
192
+ if (!base)
193
+ continue;
194
+ // The caller can read the relation directly, so the view's WHERE is a
195
+ // convenience, not a boundary, and pushing a qual past it reveals
196
+ // nothing new.
197
+ if (effectiveGrants(base, role, graph).some((g) => g.privilege === 'SELECT'))
198
+ continue;
199
+ const hops = cell.path.filter((e) => e.kind === 'view');
200
+ const outermost = hops[0];
201
+ if (!outermost)
202
+ continue;
203
+ out.push({
204
+ code: 'L12',
205
+ severity: 'info',
206
+ category: 'anti-pattern',
207
+ schema: base.schema,
208
+ table: base.name,
209
+ role,
210
+ privilege: 'SELECT',
211
+ message: `View ${outermost.view} is the only path untrusted role ${role} has to `
212
+ + `${base.schema}.${base.name}, and its row filter is not a security barrier — a leaky `
213
+ + `qual from ${role} can be evaluated against the rows the view excludes`,
214
+ hint: `Recreate the view \`WITH (security_barrier = true)\` so the planner cannot push a `
215
+ + `caller-supplied qual below its own \`WHERE\`. A cheap function or a leaky operator is `
216
+ + `otherwise evaluated on every row of ${base.schema}.${base.name}, including the hidden `
217
+ + `ones, which is enough to read them out through errors, notices or timing. Where the `
218
+ + `filter is per-caller, an RLS policy on the base relation is the stronger form — policy `
219
+ + `quals already get barrier treatment. Do not revoke the SELECT on the view.`,
220
+ context: {
221
+ view: outermost.view,
222
+ effectiveRole: cell.effectiveRole,
223
+ viaViews: hops.map((h) => h.view),
224
+ baseRlsEnabled: base.rlsEnabled,
225
+ proof: cell.proof
226
+ }
227
+ });
228
+ }
229
+ }
230
+ return out;
231
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * L9, L10 and L18: what a view does *beyond* its SELECT.
3
+ *
4
+ * L8 models the read edge — a non-`security_invoker` view hands its readers
5
+ * the owner's privileges on the relations its body names. Two write paths
6
+ * escape that model entirely, and both were verified against PostgreSQL 18
7
+ * rather than inferred:
8
+ *
9
+ * - **L9, auto-update.** A simple view is updatable: Postgres rewrites an
10
+ * INSERT/UPDATE/DELETE on the view onto its single base relation. On a
11
+ * definer view that rewrite is permission-checked against the *owner*, so
12
+ * INSERT on the view is INSERT on the base table the caller cannot touch.
13
+ * `security_invoker = true` closes it (the write is then checked against
14
+ * the caller and denied). The body alone cannot prove the write lands:
15
+ * `pg_relation_is_updatable` is the catalog half of the proof.
16
+ *
17
+ * - **L10, rewrite rules.** A rule other than the view's own `_RETURN` rule
18
+ * is invisible to `pg_get_viewdef`: `ON INSERT ... DO INSTEAD INSERT INTO
19
+ * audit` writes a relation the view body never names. Rule actions are
20
+ * checked against the rule's table owner, and — unlike the view's own base
21
+ * relations — `security_invoker` does **not** govern them. An invoker view
22
+ * with such a rule still writes `audit` as the view owner.
23
+ *
24
+ * - **L18, an unchecked write.** `WITH CHECK OPTION` is not the default, so
25
+ * a view whose `WHERE` decides which rows a role may *see* says nothing
26
+ * about which rows it may *write*: `INSERT INTO tenant_rows VALUES (...)`
27
+ * through a `WHERE tenant_id = current_tenant()` view stores a row for
28
+ * another tenant, which the writer then cannot see. It is the write-side
29
+ * twin of L12 — the view's filter is a read filter, not a boundary.
30
+ *
31
+ * All three keep L8's conservatism. An `INSTEAD OF` trigger sends the write into a
32
+ * function body, which auto-update cannot place and L20 follows instead
33
+ * (`definer-function.ts`); a multi-relation body
34
+ * is not auto-updatable in a way we can pin to one target, and an unreadable
35
+ * rule action is unknown — all three suppress rather than guess. And, as in
36
+ * L8, the fix is never a revoke: the grant on the view is what the API serves.
37
+ */
38
+ import type { ViewSnapshot } from '../pg/indexes';
39
+ import type { TableSnapshot } from '../pg/introspect';
40
+ import type { Finding } from '../types';
41
+ import { type SuppressedView } from './definer-view';
42
+ import { type LatticeRoleOptions, type RoleGraph } from './lattice';
43
+ import { type ViewWriteInput } from './role-reach';
44
+ export interface ViewWriteAnalysis {
45
+ /** Definer views whose writes are auto-rewritten onto a base relation. */
46
+ autoUpdatable: ViewWriteInput[];
47
+ /** Views carrying rewrite rules whose actions reach other relations. */
48
+ ruleDriven: ViewWriteInput[];
49
+ /**
50
+ * The subset of {@link autoUpdatable} whose body filters rows and which
51
+ * carries no `WITH CHECK OPTION` — L18 inputs.
52
+ */
53
+ unchecked: ViewWriteInput[];
54
+ /** Views deliberately left out, with why. */
55
+ suppressed: SuppressedView[];
56
+ }
57
+ /**
58
+ * Resolve, for every view in the snapshot, the relations a write against it
59
+ * actually lands on — through auto-update rewriting and through rewrite rules.
60
+ */
61
+ export declare function analyzeViewWrites(views: ViewSnapshot[], tables: TableSnapshot[]): Promise<ViewWriteAnalysis>;
62
+ /**
63
+ * L9: an untrusted role writes a base relation through an auto-updatable
64
+ * definer view.
65
+ *
66
+ * Fires once per (role, view, base relation, command) where the role holds
67
+ * the command on the view, the view executes as someone else, and the role
68
+ * holds no such privilege on the relation the write lands on.
69
+ */
70
+ export declare function checkDefinerViewWrite(views: ViewWriteInput[], tables: TableSnapshot[], graph: RoleGraph, options?: LatticeRoleOptions): Finding[];
71
+ /**
72
+ * L10: an untrusted role writes a relation through a rewrite rule on a view.
73
+ *
74
+ * Unlike L9 this fires on `security_invoker` views too: `security_invoker`
75
+ * governs the view's own base relations, not the relations a rule's actions
76
+ * name, which are checked against the rule's table owner either way.
77
+ */
78
+ export declare function checkViewRuleBypass(views: ViewWriteInput[], tables: TableSnapshot[], graph: RoleGraph, options?: LatticeRoleOptions): Finding[];
79
+ /**
80
+ * L18: an untrusted role writes rows a filtering view will not show it.
81
+ *
82
+ * Fires on the L9 population narrowed to filtering views with no `WITH CHECK
83
+ * OPTION`, minus DELETE — which removes rows the view already showed rather
84
+ * than producing new ones. It overlaps L9 by design and answers a different
85
+ * question: L9 is *whether* the role can write the relation at all, L18 is
86
+ * whether the view's own condition constrains what it writes.
87
+ */
88
+ export declare function checkUncheckedViewWrite(views: ViewWriteInput[], tables: TableSnapshot[], graph: RoleGraph, options?: LatticeRoleOptions): Finding[];
@@ -0,0 +1,344 @@
1
+ /**
2
+ * L9, L10 and L18: what a view does *beyond* its SELECT.
3
+ *
4
+ * L8 models the read edge — a non-`security_invoker` view hands its readers
5
+ * the owner's privileges on the relations its body names. Two write paths
6
+ * escape that model entirely, and both were verified against PostgreSQL 18
7
+ * rather than inferred:
8
+ *
9
+ * - **L9, auto-update.** A simple view is updatable: Postgres rewrites an
10
+ * INSERT/UPDATE/DELETE on the view onto its single base relation. On a
11
+ * definer view that rewrite is permission-checked against the *owner*, so
12
+ * INSERT on the view is INSERT on the base table the caller cannot touch.
13
+ * `security_invoker = true` closes it (the write is then checked against
14
+ * the caller and denied). The body alone cannot prove the write lands:
15
+ * `pg_relation_is_updatable` is the catalog half of the proof.
16
+ *
17
+ * - **L10, rewrite rules.** A rule other than the view's own `_RETURN` rule
18
+ * is invisible to `pg_get_viewdef`: `ON INSERT ... DO INSTEAD INSERT INTO
19
+ * audit` writes a relation the view body never names. Rule actions are
20
+ * checked against the rule's table owner, and — unlike the view's own base
21
+ * relations — `security_invoker` does **not** govern them. An invoker view
22
+ * with such a rule still writes `audit` as the view owner.
23
+ *
24
+ * - **L18, an unchecked write.** `WITH CHECK OPTION` is not the default, so
25
+ * a view whose `WHERE` decides which rows a role may *see* says nothing
26
+ * about which rows it may *write*: `INSERT INTO tenant_rows VALUES (...)`
27
+ * through a `WHERE tenant_id = current_tenant()` view stores a row for
28
+ * another tenant, which the writer then cannot see. It is the write-side
29
+ * twin of L12 — the view's filter is a read filter, not a boundary.
30
+ *
31
+ * All three keep L8's conservatism. An `INSTEAD OF` trigger sends the write into a
32
+ * function body, which auto-update cannot place and L20 follows instead
33
+ * (`definer-function.ts`); a multi-relation body
34
+ * is not auto-updatable in a way we can pin to one target, and an unreadable
35
+ * rule action is unknown — all three suppress rather than guess. And, as in
36
+ * L8, the fix is never a revoke: the grant on the view is what the API serves.
37
+ */
38
+ import { bodyFiltersRows, extractAccess, extractQuery } from '../callgraph/extract';
39
+ import { buildRelationIndex, resolveRelation } from './definer-view';
40
+ import { effectiveGrants } from './lattice';
41
+ import { computeViewWriteReach } from './role-reach';
42
+ /** How deep a chain of updatable views on views is followed before giving up. */
43
+ const MAX_VIEW_DEPTH = 8;
44
+ /**
45
+ * Resolve, for every view in the snapshot, the relations a write against it
46
+ * actually lands on — through auto-update rewriting and through rewrite rules.
47
+ */
48
+ export async function analyzeViewWrites(views, tables) {
49
+ const queryable = views.filter((v) => !v.materialized);
50
+ const index = buildRelationIndex(queryable, tables);
51
+ const autoUpdatable = [];
52
+ const ruleDriven = [];
53
+ const unchecked = [];
54
+ const suppressed = [];
55
+ for (const view of queryable) {
56
+ const name = `${view.schema}.${view.name}`;
57
+ const auto = await autoUpdateEdges(view, index, suppressed);
58
+ if (auto.length > 0) {
59
+ const input = {
60
+ schema: view.schema,
61
+ name: view.name,
62
+ owner: view.owner,
63
+ grants: view.grants,
64
+ writeEdges: auto
65
+ };
66
+ autoUpdatable.push(input);
67
+ if (view.checkOption === 'none') {
68
+ // A view with no `WHERE` excludes no rows, so there is nothing a write
69
+ // could land outside of; an unparseable body is unknown, not clean.
70
+ const filters = await bodyFiltersRows(view.definition);
71
+ if (filters === null) {
72
+ suppressed.push({
73
+ view: name,
74
+ reason: 'body could not be parsed to look for a row filter'
75
+ });
76
+ }
77
+ else if (filters) {
78
+ // WITH CHECK OPTION constrains the rows a write *produces*, so only
79
+ // the commands that produce rows can escape it.
80
+ const writes = auto.filter((e) => e.privilege !== 'DELETE');
81
+ if (writes.length > 0)
82
+ unchecked.push({ ...input, writeEdges: writes });
83
+ }
84
+ }
85
+ }
86
+ const fromRules = [];
87
+ for (const rule of view.rules) {
88
+ if (rule.event === 'SELECT')
89
+ continue;
90
+ const edges = await ruleEdges(view, rule, index);
91
+ if (edges === null) {
92
+ suppressed.push({
93
+ view: name,
94
+ reason: `rule ${rule.name} has an action this analysis cannot follow`
95
+ });
96
+ continue;
97
+ }
98
+ fromRules.push(...edges);
99
+ }
100
+ if (fromRules.length > 0) {
101
+ ruleDriven.push({
102
+ schema: view.schema,
103
+ name: view.name,
104
+ owner: view.owner,
105
+ grants: view.grants,
106
+ writeEdges: fromRules
107
+ });
108
+ }
109
+ }
110
+ return { autoUpdatable, ruleDriven, unchecked, suppressed };
111
+ }
112
+ /**
113
+ * The base relation an auto-updatable definer view's writes are rewritten
114
+ * onto, if it can be proven.
115
+ *
116
+ * Auto-update only applies when nothing else has taken over the write path,
117
+ * so a view with rules or `INSTEAD OF` triggers is not handled here — the
118
+ * catalog's updatability bitmask counts those too, and attributing their
119
+ * writes to the body's relation would be a guess.
120
+ */
121
+ async function autoUpdateEdges(view, index, suppressed) {
122
+ const name = `${view.schema}.${view.name}`;
123
+ if (view.securityInvoker)
124
+ return []; // the write is checked against the caller
125
+ if (view.writable.length === 0)
126
+ return [];
127
+ if (view.insteadOfTriggers) {
128
+ suppressed.push({
129
+ view: name,
130
+ reason: 'INSTEAD OF triggers decide where the write lands, in a trigger function body — L20, not auto-update'
131
+ });
132
+ return [];
133
+ }
134
+ if (view.rules.some((r) => r.event !== 'SELECT'))
135
+ return []; // rule path, see ruleEdges
136
+ const hops = [{ view: name, owner: view.owner }];
137
+ let current = view;
138
+ for (;;) {
139
+ if (hops.length > MAX_VIEW_DEPTH) {
140
+ suppressed.push({ view: name, reason: `view chain deeper than ${MAX_VIEW_DEPTH} hops` });
141
+ return [];
142
+ }
143
+ const body = await extractQuery(current.definition);
144
+ if (body.opaque) {
145
+ suppressed.push({ view: name, reason: body.opaqueReason ?? 'body could not be read' });
146
+ return [];
147
+ }
148
+ const resolved = body.tables
149
+ .map((ref) => resolveRelation(ref, current.schema, index))
150
+ .filter((r) => r !== null);
151
+ // Auto-update needs exactly one target. Anything else — a join, a body
152
+ // whose references we could not pin down — is not a write we can place.
153
+ if (resolved.length !== 1)
154
+ return [];
155
+ const target = resolved[0];
156
+ // A relation outside the audited schemas cannot be graded: its owner, its
157
+ // ACL and its RLS are all unknown, so the write is not placeable here.
158
+ if (target.kind === 'external')
159
+ return [];
160
+ if (target.kind === 'table') {
161
+ return view.writable.map((privilege) => ({
162
+ schema: target.schema,
163
+ table: target.name,
164
+ via: privilege,
165
+ privilege,
166
+ hops: [...hops]
167
+ }));
168
+ }
169
+ const nested = target.view;
170
+ if (`${nested.schema}.${nested.name}` === `${current.schema}.${current.name}`)
171
+ return [];
172
+ if (nested.materialized || nested.insteadOfTriggers)
173
+ return [];
174
+ // The inner view re-owns the write unless it defers to the caller, in
175
+ // which case whichever owner is already in force stays in force.
176
+ const owner = nested.securityInvoker ? hops[hops.length - 1].owner : nested.owner;
177
+ hops.push({ view: `${nested.schema}.${nested.name}`, owner });
178
+ current = nested;
179
+ }
180
+ }
181
+ /**
182
+ * The relations a rewrite rule's actions reach, or `null` when the action
183
+ * cannot be followed.
184
+ *
185
+ * The rule's own view is dropped from the result: `pg_get_ruledef` names it in
186
+ * the `ON ... TO <view>` clause, and that reference is the trigger, not a
187
+ * target. `DO INSTEAD NOTHING` therefore yields nothing at all, which is the
188
+ * correct answer for a read-only view — the commonest rule in the wild.
189
+ */
190
+ async function ruleEdges(view, rule, index) {
191
+ const self = `${view.schema}.${view.name}`;
192
+ const { accesses, opaque } = await extractAccess(rule.definition);
193
+ if (opaque)
194
+ return null;
195
+ const edges = [];
196
+ for (const access of accesses) {
197
+ // Reads inside a rule action are the view's own SELECT path, which L8
198
+ // already models; the escalation a rule adds is the write.
199
+ if (access.privilege === 'SELECT')
200
+ continue;
201
+ const target = resolveRelation(access, view.schema, index);
202
+ if (!target)
203
+ continue;
204
+ if (target.kind === 'external')
205
+ continue;
206
+ if (target.kind === 'view') {
207
+ // The write recurses into another view's rewrite path; proving where it
208
+ // finally lands is more than this analysis can do.
209
+ return null;
210
+ }
211
+ if (`${target.schema}.${target.name}` === self)
212
+ continue;
213
+ edges.push({
214
+ schema: target.schema,
215
+ table: target.name,
216
+ via: rule.event,
217
+ privilege: access.privilege,
218
+ hops: [{ view: self, owner: view.owner }],
219
+ rule: rule.name
220
+ });
221
+ }
222
+ return edges;
223
+ }
224
+ /**
225
+ * L9: an untrusted role writes a base relation through an auto-updatable
226
+ * definer view.
227
+ *
228
+ * Fires once per (role, view, base relation, command) where the role holds
229
+ * the command on the view, the view executes as someone else, and the role
230
+ * holds no such privilege on the relation the write lands on.
231
+ */
232
+ export function checkDefinerViewWrite(views, tables, graph, options = {}) {
233
+ return writeFindings(views, tables, graph, options, 'L9', (ctx) => ({
234
+ message: `Untrusted role ${ctx.role} can ${ctx.privilege} ${ctx.target} through view ${ctx.view}, `
235
+ + `which executes as its owner ${ctx.owner} — ${ctx.role} holds no ${ctx.privilege} on the `
236
+ + `base relation`
237
+ + (ctx.rlsBypassed ? `, and ${ctx.owner} is not subject to its RLS policies` : ''),
238
+ hint: `The view is auto-updatable, so Postgres rewrites the ${ctx.privilege} onto ${ctx.target} and `
239
+ + `checks it against ${ctx.owner}, not ${ctx.role}. Recreate the view `
240
+ + `\`WITH (security_invoker = true)\` so the caller's own grants and policies apply, give it an `
241
+ + `owner whose reach matches what the view is meant to expose, or make it non-updatable. Do not `
242
+ + `revoke the grant on the view — that grant is what the API serves.`
243
+ }));
244
+ }
245
+ /**
246
+ * L10: an untrusted role writes a relation through a rewrite rule on a view.
247
+ *
248
+ * Unlike L9 this fires on `security_invoker` views too: `security_invoker`
249
+ * governs the view's own base relations, not the relations a rule's actions
250
+ * name, which are checked against the rule's table owner either way.
251
+ */
252
+ export function checkViewRuleBypass(views, tables, graph, options = {}) {
253
+ return writeFindings(views, tables, graph, options, 'L10', (ctx) => ({
254
+ message: `Untrusted role ${ctx.role} can ${ctx.privilege} ${ctx.target} through rule ${ctx.rule} on view `
255
+ + `${ctx.view} — the rule's action runs as the view owner ${ctx.owner}, and ${ctx.role} holds no `
256
+ + `${ctx.privilege} on ${ctx.target}`
257
+ + (ctx.rlsBypassed ? `, whose RLS policies ${ctx.owner} is not subject to` : ''),
258
+ hint: `Rewrite rules are not shown by \`pg_get_viewdef\` and are not governed by \`security_invoker\`: `
259
+ + `their actions are permission-checked against the owner of the relation the rule is on. Move `
260
+ + `the action into a function the caller must be granted EXECUTE on, or give the view an owner `
261
+ + `whose reach matches what the rule is meant to do. Do not revoke the grant on the view — that `
262
+ + `grant is what the API serves.`
263
+ }));
264
+ }
265
+ /**
266
+ * L18: an untrusted role writes rows a filtering view will not show it.
267
+ *
268
+ * Fires on the L9 population narrowed to filtering views with no `WITH CHECK
269
+ * OPTION`, minus DELETE — which removes rows the view already showed rather
270
+ * than producing new ones. It overlaps L9 by design and answers a different
271
+ * question: L9 is *whether* the role can write the relation at all, L18 is
272
+ * whether the view's own condition constrains what it writes.
273
+ */
274
+ export function checkUncheckedViewWrite(views, tables, graph, options = {}) {
275
+ return writeFindings(views, tables, graph, options, 'L18', (ctx) => ({
276
+ message: `Untrusted role ${ctx.role} can ${ctx.privilege} rows into ${ctx.target} through view `
277
+ + `${ctx.view} that the view's own row filter excludes — the view has no WITH CHECK OPTION, `
278
+ + `so its \`WHERE\` governs reads only`,
279
+ hint: `Recreate the view \`WITH LOCAL CHECK OPTION\` (or \`CASCADED\`, to enforce every underlying `
280
+ + `view's condition too) so a row written through it must satisfy the condition it is served `
281
+ + `under. Where the filter is per-caller, an RLS policy with a \`WITH CHECK\` clause on `
282
+ + `${ctx.target} is the stronger form — it applies however the row arrives. Do not revoke the `
283
+ + `${ctx.privilege} on the view; that grant is what the API serves.`
284
+ }));
285
+ }
286
+ function writeFindings(views, tables, graph, options, code, render) {
287
+ const untrusted = options.roles ?? [];
288
+ if (untrusted.length === 0 || views.length === 0)
289
+ return [];
290
+ const byKey = new Map(tables.map((t) => [`${t.schema}.${t.name}`, t]));
291
+ const out = [];
292
+ for (const { role, cells } of computeViewWriteReach(views, graph, untrusted)) {
293
+ for (const cell of cells) {
294
+ if (cell.effectiveRole === role)
295
+ continue;
296
+ const base = byKey.get(`${cell.schema}.${cell.table}`);
297
+ if (!base)
298
+ continue;
299
+ const privilege = cell.privileges[0];
300
+ // Already writable in its own right: the view launders nothing.
301
+ if (effectiveGrants(base, role, graph).some((g) => g.privilege === privilege))
302
+ continue;
303
+ const viewHops = cell.path.filter((e) => e.kind === 'view');
304
+ const ruleEdge = cell.path.find((e) => e.kind === 'rule');
305
+ const grantEdge = cell.path.find((e) => e.kind === 'grant');
306
+ const owner = cell.effectiveRole;
307
+ const ownerAttrs = graph.get(owner);
308
+ const rlsBypassed = base.rlsEnabled
309
+ && (!!ownerAttrs?.bypassRls || (base.owner === owner && !base.rlsForced));
310
+ const target = `${base.schema}.${base.name}`;
311
+ const { message, hint } = render({
312
+ role,
313
+ view: viewHops[0].view,
314
+ owner,
315
+ target,
316
+ privilege,
317
+ ...(ruleEdge ? { rule: ruleEdge.rule } : {}),
318
+ rlsBypassed
319
+ });
320
+ out.push({
321
+ code,
322
+ severity: 'info',
323
+ category: 'anti-pattern',
324
+ schema: base.schema,
325
+ table: base.name,
326
+ role,
327
+ privilege,
328
+ message,
329
+ hint,
330
+ context: {
331
+ view: viewHops[0].view,
332
+ effectiveRole: owner,
333
+ viaViews: viewHops.map((h) => h.view),
334
+ ...(ruleEdge ? { rule: ruleEdge.rule } : {}),
335
+ viewPrivilege: grantEdge?.privilege,
336
+ baseRlsEnabled: base.rlsEnabled,
337
+ rlsBypassed,
338
+ proof: cell.proof
339
+ }
340
+ });
341
+ }
342
+ }
343
+ return out;
344
+ }