safegres 1.18.0 → 1.19.0

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 (182) hide show
  1. package/README.md +120 -6
  2. package/callgraph/extract.d.ts +21 -0
  3. package/callgraph/extract.js +91 -10
  4. package/checks/column-grants.d.ts +20 -0
  5. package/checks/column-grants.js +74 -0
  6. package/checks/definer-function.d.ts +91 -0
  7. package/checks/definer-function.js +458 -0
  8. package/checks/definer-view.d.ts +61 -3
  9. package/checks/definer-view.js +172 -16
  10. package/checks/lattice.d.ts +35 -0
  11. package/checks/lattice.js +95 -7
  12. package/checks/object-acls.d.ts +32 -0
  13. package/checks/object-acls.js +129 -0
  14. package/checks/revocable-grants.d.ts +140 -0
  15. package/checks/revocable-grants.js +377 -0
  16. package/checks/role-reach.d.ts +140 -2
  17. package/checks/role-reach.js +122 -3
  18. package/checks/view-exposure.js +8 -4
  19. package/checks/view-writes.d.ts +26 -3
  20. package/checks/view-writes.js +63 -7
  21. package/cli/audit.js +15 -1
  22. package/cli/eval.js +41 -1
  23. package/cli/print-config.js +6 -0
  24. package/cli/shared.d.ts +7 -5
  25. package/cli/shared.js +19 -15
  26. package/commands/audit.js +179 -11
  27. package/config/loader.d.ts +17 -0
  28. package/config/loader.js +120 -4
  29. package/config/presets.js +53 -2
  30. package/config/schema.d.ts +54 -0
  31. package/config/schema.js +263 -0
  32. package/config/types.d.ts +112 -2
  33. package/config/validate.d.ts +14 -0
  34. package/config/validate.js +125 -0
  35. package/corpus/README.md +13 -5
  36. package/corpus/cases/35-column-grant-projection/case.json +27 -0
  37. package/corpus/cases/35-column-grant-projection/schema.sql +21 -0
  38. package/corpus/cases/35-policy-function-not-leakproof/case.json +24 -0
  39. package/corpus/cases/35-policy-function-not-leakproof/schema.sql +27 -0
  40. package/corpus/cases/36-column-grant-rls-mediated/case.json +27 -0
  41. package/corpus/cases/36-column-grant-rls-mediated/schema.sql +32 -0
  42. package/corpus/cases/36-policy-function-leakproof/case.json +24 -0
  43. package/corpus/cases/36-policy-function-leakproof/schema.sql +22 -0
  44. package/corpus/cases/37-sort-column-unindexed/case.json +24 -0
  45. package/corpus/cases/37-sort-column-unindexed/schema.sql +14 -0
  46. package/corpus/cases/37-unaudited-base-relation/case.json +30 -0
  47. package/corpus/cases/37-unaudited-base-relation/schema.sql +45 -0
  48. package/corpus/cases/38-foreign-key-with-index/case.json +22 -0
  49. package/corpus/cases/38-foreign-key-with-index/schema.sql +17 -0
  50. package/corpus/cases/38-invoker-view-unaudited-base/case.json +30 -0
  51. package/corpus/cases/38-invoker-view-unaudited-base/schema.sql +39 -0
  52. package/corpus/cases/39-definer-view-column-subset/case.json +30 -0
  53. package/corpus/cases/39-definer-view-column-subset/schema.sql +29 -0
  54. package/corpus/cases/39-policy-cast-with-expression-index/case.json +23 -0
  55. package/corpus/cases/39-policy-cast-with-expression-index/schema.sql +19 -0
  56. package/corpus/cases/40-definer-view-column-overreach/case.json +27 -0
  57. package/corpus/cases/40-definer-view-column-overreach/schema.sql +29 -0
  58. package/corpus/cases/40-stable-function-hoisted/case.json +23 -0
  59. package/corpus/cases/40-stable-function-hoisted/schema.sql +25 -0
  60. package/corpus/cases/41-index-prefix-not-redundant/case.json +21 -0
  61. package/corpus/cases/41-index-prefix-not-redundant/schema.sql +15 -0
  62. package/corpus/cases/41-unreadable-view-body/case.json +33 -0
  63. package/corpus/cases/41-unreadable-view-body/schema.sql +28 -0
  64. package/corpus/cases/42-invoker-view-unreadable-body/case.json +30 -0
  65. package/corpus/cases/42-invoker-view-unreadable-body/schema.sql +27 -0
  66. package/corpus/cases/42-search-column-indexed/case.json +21 -0
  67. package/corpus/cases/42-search-column-indexed/schema.sql +12 -0
  68. package/corpus/cases/43-anon-sequence-grant/case.json +27 -0
  69. package/corpus/cases/43-anon-sequence-grant/schema.sql +23 -0
  70. package/corpus/cases/44-serial-sequence-usage/case.json +27 -0
  71. package/corpus/cases/44-serial-sequence-usage/schema.sql +25 -0
  72. package/corpus/cases/45-anon-foreign-table/case.json +27 -0
  73. package/corpus/cases/45-anon-foreign-table/schema.sql +23 -0
  74. package/corpus/cases/46-unchecked-writable-view/case.json +41 -0
  75. package/corpus/cases/46-unchecked-writable-view/schema.sql +37 -0
  76. package/corpus/cases/47-checked-writable-view/case.json +37 -0
  77. package/corpus/cases/47-checked-writable-view/schema.sql +38 -0
  78. package/corpus/cases/48-definer-function-reach/case.json +38 -0
  79. package/corpus/cases/48-definer-function-reach/schema.sql +41 -0
  80. package/corpus/cases/49-invoker-function-no-reach/case.json +32 -0
  81. package/corpus/cases/49-invoker-function-no-reach/schema.sql +38 -0
  82. package/corpus/cases/50-instead-of-trigger-write/case.json +38 -0
  83. package/corpus/cases/50-instead-of-trigger-write/schema.sql +58 -0
  84. package/corpus/cases/51-invoker-trigger-no-write/case.json +33 -0
  85. package/corpus/cases/51-invoker-trigger-no-write/schema.sql +54 -0
  86. package/corpus/cases/52-revocable-execute-grant/case.json +30 -0
  87. package/corpus/cases/52-revocable-execute-grant/schema.sql +45 -0
  88. package/corpus/cases/53-policy-predicate-retains-execute/case.json +23 -0
  89. package/corpus/cases/53-policy-predicate-retains-execute/schema.sql +49 -0
  90. package/esm/callgraph/extract.d.ts +21 -0
  91. package/esm/callgraph/extract.js +90 -10
  92. package/esm/checks/column-grants.d.ts +20 -0
  93. package/esm/checks/column-grants.js +71 -0
  94. package/esm/checks/definer-function.d.ts +91 -0
  95. package/esm/checks/definer-function.js +452 -0
  96. package/esm/checks/definer-view.d.ts +61 -3
  97. package/esm/checks/definer-view.js +171 -17
  98. package/esm/checks/lattice.d.ts +35 -0
  99. package/esm/checks/lattice.js +93 -7
  100. package/esm/checks/object-acls.d.ts +32 -0
  101. package/esm/checks/object-acls.js +125 -0
  102. package/esm/checks/revocable-grants.d.ts +140 -0
  103. package/esm/checks/revocable-grants.js +373 -0
  104. package/esm/checks/role-reach.d.ts +140 -2
  105. package/esm/checks/role-reach.js +121 -4
  106. package/esm/checks/view-exposure.js +8 -4
  107. package/esm/checks/view-writes.d.ts +26 -3
  108. package/esm/checks/view-writes.js +63 -8
  109. package/esm/cli/audit.js +16 -2
  110. package/esm/cli/eval.js +9 -2
  111. package/esm/cli/print-config.js +6 -0
  112. package/esm/cli/shared.d.ts +7 -5
  113. package/esm/cli/shared.js +19 -15
  114. package/esm/commands/audit.js +182 -14
  115. package/esm/config/loader.d.ts +17 -0
  116. package/esm/config/loader.js +86 -4
  117. package/esm/config/presets.js +53 -2
  118. package/esm/config/schema.d.ts +54 -0
  119. package/esm/config/schema.js +259 -0
  120. package/esm/config/types.d.ts +112 -2
  121. package/esm/config/validate.d.ts +14 -0
  122. package/esm/config/validate.js +122 -0
  123. package/esm/exposure/routines.d.ts +36 -0
  124. package/esm/exposure/routines.js +54 -0
  125. package/esm/index.d.ts +13 -5
  126. package/esm/index.js +9 -3
  127. package/esm/pg/acl.d.ts +9 -0
  128. package/esm/pg/acl.js +22 -0
  129. package/esm/pg/functions.d.ts +8 -1
  130. package/esm/pg/functions.js +36 -3
  131. package/esm/pg/indexes.d.ts +38 -0
  132. package/esm/pg/indexes.js +81 -0
  133. package/esm/pg/introspect.d.ts +24 -2
  134. package/esm/pg/introspect.js +49 -0
  135. package/esm/pg/objects.d.ts +37 -0
  136. package/esm/pg/objects.js +108 -0
  137. package/esm/pg/reach-inputs.d.ts +47 -0
  138. package/esm/pg/reach-inputs.js +133 -0
  139. package/esm/report/markdown.js +39 -7
  140. package/esm/report/pretty.js +38 -2
  141. package/esm/report/view.d.ts +8 -1
  142. package/esm/report/view.js +5 -0
  143. package/esm/rules/registry.d.ts +27 -0
  144. package/esm/rules/registry.js +142 -4
  145. package/esm/score/score.d.ts +19 -0
  146. package/esm/score/score.js +67 -13
  147. package/esm/score/scorecards.d.ts +68 -0
  148. package/esm/score/scorecards.js +157 -0
  149. package/esm/types.d.ts +15 -0
  150. package/esm/version.d.ts +8 -1
  151. package/esm/version.js +9 -2
  152. package/exposure/routines.d.ts +36 -0
  153. package/exposure/routines.js +57 -0
  154. package/index.d.ts +13 -5
  155. package/index.js +26 -3
  156. package/package.json +12 -12
  157. package/pg/acl.d.ts +9 -0
  158. package/pg/acl.js +23 -0
  159. package/pg/functions.d.ts +8 -1
  160. package/pg/functions.js +36 -3
  161. package/pg/indexes.d.ts +38 -0
  162. package/pg/indexes.js +81 -0
  163. package/pg/introspect.d.ts +24 -2
  164. package/pg/introspect.js +49 -0
  165. package/pg/objects.d.ts +37 -0
  166. package/pg/objects.js +111 -0
  167. package/pg/reach-inputs.d.ts +47 -0
  168. package/pg/reach-inputs.js +136 -0
  169. package/report/markdown.js +39 -7
  170. package/report/pretty.js +38 -2
  171. package/report/view.d.ts +8 -1
  172. package/report/view.js +5 -0
  173. package/rules/registry.d.ts +27 -0
  174. package/rules/registry.js +143 -4
  175. package/schema/safegres.schema.json +1109 -0
  176. package/score/score.d.ts +19 -0
  177. package/score/score.js +67 -13
  178. package/score/scorecards.d.ts +68 -0
  179. package/score/scorecards.js +162 -0
  180. package/types.d.ts +15 -0
  181. package/version.d.ts +8 -1
  182. package/version.js +9 -2
package/README.md CHANGED
@@ -143,11 +143,19 @@ family, **not** the dimension: `P1`/`P1b` are performance, `P5` is security.
143
143
  | L4 | info | neutral | **Dead schema `USAGE`** — reaches no relation and no function |
144
144
  | L5 | info | fail-open | An untrusted role reaches an **RLS-off table** via PUBLIC/inheritance † |
145
145
  | L6 | info | neutral | **Unaddressable grant** — an API role holds privileges on a relation its API cannot name ‡ |
146
- | L8 | info | fail-open | **DEFINER view bypass** — an untrusted role reads a base relation as the view's owner † |
146
+ | L8 | info | fail-open | **DEFINER view bypass** — an untrusted role reads a base relation, and named columns of it, as the view's owner † |
147
147
  | L9 | info | fail-open | **DEFINER view write** — an auto-updatable definer view writes a base relation as its owner † |
148
148
  | L10 | info | fail-open | **Rewrite-rule bypass** — a rule on a view writes a relation as the view's owner, `security_invoker` notwithstanding † |
149
149
  | L11 | info | fail-open | **Materialized-view snapshot** — stored rows serve an untrusted role what the base relation's grants and policies would not † |
150
150
  | L12 | info | fail-open | **Non-barrier filtering view** — a view is an untrusted role's only path to a relation, but its row filter is not a boundary † |
151
+ | L13 | info | fail-open | **Column-level grant** — an untrusted role reaches a relation through `pg_attribute.attacl`, which no relation ACL shows † |
152
+ | L14 | info | neutral | **Unaudited base relation** — a definer view reads a relation in a schema the audit never introspected † |
153
+ | L15 | info | neutral | **Unreadable body** — an untrusted role reaches through a definer view or function whose body the analysis could not follow † |
154
+ | L16 | info | fail-open | **Sequence privilege** — an untrusted role can advance or read a sequence, which no policy filters † |
155
+ | L17 | info | fail-open | **Foreign-table grant** — an untrusted role reaches a relation that cannot carry RLS at all † |
156
+ | L18 | info | fail-open | **Writable filtering view without `WITH CHECK OPTION`** — an untrusted role writes rows the view's own filter excludes † |
157
+ | L19 | info | fail-open | **Definer-function reach** — an untrusted role touches a relation by executing a `SECURITY DEFINER` function, which runs as its owner † |
158
+ | L20 | info | fail-open | **`INSTEAD OF` trigger write** — a write against a view becomes a trigger function's body, and a definer one lands it as the function's owner † |
151
159
  | W1 | medium | — | **No exposure surface configured** — whole database assumed reachable, score capped |
152
160
 
153
161
  † R1/R2/L5 are no-ops until you name the untrusted roles:
@@ -157,6 +165,13 @@ untrusted-role model; the `safegres:constructive` preset configures them for `an
157
165
  ‡ L6 needs an adapter that can compute [API reach](#api-reach--the-relations-the-api-can-actually-name);
158
166
  without one nothing is unaddressable and it never fires.
159
167
 
168
+ **Exposure is asked per subject.** A relation finding is exposed when the API can address the
169
+ relation. A *routine* finding — the convention rules, L19, L20 — is exposed when an untrusted role
170
+ can **call the function**: `USAGE` on its schema and `EXECUTE` on the function, directly, by
171
+ inheritance, or through Postgres's default `EXECUTE TO PUBLIC`. Grading a definer function by the
172
+ schema it sits in is not conservative, it is wrong in the dangerous direction: the function is
173
+ private precisely so that calling it is the only way in.
174
+
160
175
  **Direction is the load-bearing idea.** `fail-open` findings are exposure — the untrusted side
161
176
  reaches more than intended. `fail-closed` findings are *denied by Postgres at runtime*: an
162
177
  availability and hygiene concern, not a leak. They contribute **zero** to the score by default
@@ -416,6 +431,21 @@ deploys it into an ephemeral one:
416
431
  - run: npx safegres audit # or: "audit": "safegres lint" in package.json
417
432
  ```
418
433
 
434
+ Or the first-party action, which installs the CLI, runs that config, and turns the report into a
435
+ job summary, annotations and a sticky PR comment:
436
+
437
+ ```yaml
438
+ - uses: constructive-io/constructive/packages/safegres@main
439
+ with:
440
+ out: safegres-reports
441
+ comment: true # sticky PR comment (needs pull-requests: write)
442
+ upload-sarif: true # code scanning (needs security-events: write)
443
+ ```
444
+
445
+ It exposes `security-score`, `security-grade` and `perf-score` as step outputs — on a failing run
446
+ too, which is when they get read. Everything else stays in the config file; see
447
+ **[docs/reporting.md](https://github.com/constructive-io/constructive/blob/main/packages/safegres/docs/reporting.md#the-action)**.
448
+
419
449
  `outputs.dir` writes `safegres.json`, `safegres.md` and `safegres.sarif` into one directory — name
420
450
  an individual file (`outputs.json`) only when the name matters. Directories are created as needed,
421
451
  and a flag still wins over the file for a one-off run (`safegres audit --out reports`). Naming a
@@ -499,9 +529,11 @@ is simply the honest statement that local configuration participated.
499
529
  ## The evaluation corpus
500
530
 
501
531
  A sealed score says the ruler did not move. It does not say the ruler is right. That is what the
502
- corpus in [`corpus/`](corpus/README.md) is for: ~20 small schemas, each with one deliberate flaw
503
- and a written-down answer — the findings a correct audit must produce, the false positives it must
504
- not, and the one-sentence fix.
532
+ corpus in [`corpus/`](corpus/README.md) is for: small schemas, each with one deliberate flaw and a
533
+ written-down answer — the findings a correct audit must produce, the false positives it must not,
534
+ and the one-sentence fix. Many come in pairs: 18 cases pin what must *not* fire, and six of those
535
+ are the earlier case with its flaw fixed — an answer key consisting entirely of silence and a score
536
+ of 100, because a rule that survives its own fix is worse than a rule that never existed.
505
537
 
506
538
  `safegres eval` is the whole loop in one command: it deploys each case into the connected
507
539
  database, audits it under a sealed preset, grades the report against the answer key, drops the
@@ -513,7 +545,7 @@ $ safegres eval --database scratch
513
545
  PASS 17-foreign-key-without-index perf 71.2 (C ) X1
514
546
  FAIL 18-policy-column-unindexed perf 100 (A+) missed X2
515
547
 
516
- 25/26 cases passed · recall 98% · precision 100%
548
+ 41/42 cases passed · recall 98% · precision 100%
517
549
  ```
518
550
 
519
551
  | Flag | |
@@ -569,7 +601,7 @@ Precedence: **CLI > project config > preset > built-in defaults**.
569
601
  { "tables": ["app_public.audit_*"], "rules": { "A2": "off" } }
570
602
  ],
571
603
  "perf": { "enabled": true, "ignore": ["app_public.audit_*"], "rules": { "X6": "off" } },
572
- "scoring": { "densityK": 0.17, "unknownExposureCap": 80 },
604
+ "scoring": { "densityK": 0.17, "maxRuleDensity": 0.5, "unknownExposureCap": 80 },
573
605
  "failOn": { "grade": "B", "perfGrade": "B" }
574
606
  }
575
607
  ```
@@ -636,6 +668,36 @@ Presets **retune**, they don't delete: a rule that doesn't apply to a stack is d
636
668
  (zero weight, so the score is unchanged) rather than switched off, so it stays in the report and
637
669
  stays re-tunable. `minimal` is the deliberate exception — being a smoke check is its whole job.
638
670
 
671
+ `extends` also takes a **path**, which is how a repository with more than one audit job keeps one
672
+ copy of its rules. The gated PR job and the nightly advisory run against a deployed database
673
+ differ by their gates and their baseline, not by their 19-entry `public.read` list:
674
+
675
+ ```jsonc
676
+ // ci/nightly/.safegresrc.json
677
+ { "extends": "../../safegres.base.json",
678
+ "perf": { "baseline": "ci/nightly/perf.json" },
679
+ "failOn": { "grade": "D" } }
680
+ ```
681
+
682
+ A path in an inherited file resolves against **the file that wrote it**, so a base file's
683
+ `"baseline": "ci/perf.json"` keeps meaning the base file's `ci/`, whichever job inherits it —
684
+ the same rule as a discovered config, applied per key. Objects merge per key and arrays replace,
685
+ except `overrides`, which is a list of scoped exceptions and so unions across the chain:
686
+ inheriting a config can't silently drop the exceptions that came with it. `--sealed` reaches none
687
+ of this — it does no discovery at all, so there is no file to extend from.
688
+
689
+ The file is **validated on load**, against a schema derived from the same declaration the editor
690
+ completes against — an unknown key is an error naming the key it thinks you meant, not a silent
691
+ no-op, because `"failon": { "grade": "B" }` otherwise reads as a passing build rather than as a
692
+ typo. Point an editor at the schema for completion and inline documentation:
693
+
694
+ ```jsonc
695
+ { "$schema": "https://raw.githubusercontent.com/constructive-io/constructive/main/packages/safegres/schema/safegres.schema.json" }
696
+ ```
697
+
698
+ It also ships in the package (`safegres/schema/safegres.schema.json`), and
699
+ `safegres print-config --schema` writes it to stdout for an offline copy.
700
+
639
701
  CLI: `--config <path>`, `--preset <name>`, `--rule CODE=off|severity` (repeatable).
640
702
 
641
703
  ## Scoring
@@ -655,10 +717,62 @@ exposed tables lands at a C). Non-exposed findings score 0; fail-closed findings
655
717
  the grade at C (`scoring.floorOnCritical`). Grades: A+ 97 · A 90 · B 80 · C 65 · D 50 · F below.
656
718
  The legacy flat-deduction model is `scoring.model: "weighted"`.
657
719
 
720
+ Two corrections keep a rule's **shape** out of the arithmetic, because a rule's fan-out is a
721
+ property of how it reports, not of how much risk it found.
722
+
723
+ - **Points are charged per unit of repair.** A rule that emits one finding per (relation ×
724
+ function) pair — L19 does, and on one real database that is 853 findings over 166 functions —
725
+ costs what fixing it costs: 166 revokes. The finding count is unchanged in the report; the
726
+ deduction reads `−664 (×853 → 166 fixes)`.
727
+ - **No single rule can decide the grade.** Each rule's contribution is capped at
728
+ `scoring.maxRuleDensity` (default 0.5) of the points that would score an F alone, so one rule
729
+ at its ceiling costs two grade bands and an F still takes breadth. `false` disables it.
730
+
658
731
  Every report carries its own arithmetic: per-rule points, grade, and the **payoff** — how far the
659
732
  score would move if that rule's findings went away. Gate with `--fail-on <severity>`,
660
733
  `--fail-on-score <n>`, `--fail-on-grade <g>` and their `--fail-on-perf-*` counterparts.
661
734
 
735
+ ### Scorecards: one report, several questions
736
+
737
+ "How secure is this database" is not one question, and a single grade answers it for at most one
738
+ reader. A platform team gates on what an unauthenticated caller reaches; an application team gates
739
+ on house style; a reviewer wants the number no preset softened. A **scorecard** is that question
740
+ written down — a selector over the findings, plus the weighting to grade what it selects:
741
+
742
+ ```jsonc
743
+ {
744
+ "scorecards": {
745
+ "anon-surface": {
746
+ "description": "What an unauthenticated caller reaches.",
747
+ "select": { "roles": ["anonymous"], "direction": "fail-open", "exposure": "all" },
748
+ "perRuleWeights": { "L19": 10 },
749
+ "floorOnCritical": "C"
750
+ },
751
+ "sql-conventions": {
752
+ "select": { "rules": ["C*"], "exposure": "all", "denominator": "all" }
753
+ }
754
+ },
755
+ "failOn": { "scorecards": { "anon-surface": { "grade": "A" } } }
756
+ }
757
+ ```
758
+
759
+ Selectors narrow on `rules` (with `C*` wildcards) and `exclude`, `dimension`, `roles`, `planes`,
760
+ `schemas`, `direction`, `minSeverity`, `exposure`, `acknowledged`, `severities` and `denominator`.
761
+ Every scoring key is available per card, and `failOn.scorecards` gates on the one that matters to
762
+ you rather than on somebody else's headline.
763
+
764
+ Two cards always run and cannot be redefined into something flattering:
765
+
766
+ - **`default`** — the Safegres score: the headline, exposure-scoped and preset-tuned, i.e. graded
767
+ the way you actually use the database. Naming it in the config cannot move it.
768
+ - **`raw`** — every finding at its *declared* (registry) severity, exposure ignored,
769
+ acknowledgements included, fail-closed findings weighted like anything else. Not flattering, and
770
+ not meant to be: it is the number that is comparable between two databases and that no
771
+ configuration talked down.
772
+
773
+ A scorecard decides what a number is *about* — never what is reported. `report.findings` is always
774
+ the complete set, so the JSON is the raw data and the scores are queries over it.
775
+
662
776
  ## Commands
663
777
 
664
778
  ```bash
@@ -40,8 +40,29 @@ export interface ExtractedBody {
40
40
  opaque: boolean;
41
41
  /** Why the body is (partially) opaque, when it is. */
42
42
  opaqueReason?: string;
43
+ /**
44
+ * The body executes SQL this analysis cannot see, *alongside* references it
45
+ * could read: `calls`, `tables` and `settings` are correct but incomplete.
46
+ *
47
+ * This is the distinction {@link opaque} cannot make. `opaque` says the
48
+ * whole body is unknown and its references must be discarded; `tainted`
49
+ * says what was read is real and what was missed is unknowable, which is
50
+ * what a reach model needs in order to report the gap instead of the view
51
+ * disappearing from the analysis altogether.
52
+ */
53
+ tainted?: string;
43
54
  }
44
55
  export declare function extractBody(fn: FunctionSnapshot): Promise<ExtractedBody>;
56
+ /**
57
+ * {@link extractAccess} asked of a whole function rather than one statement:
58
+ * every relation the body touches, with the privilege the touch exercises.
59
+ *
60
+ * This is what a SECURITY DEFINER function's reach is made of — the body runs
61
+ * as the owner, so each `INSERT INTO t` in it is an INSERT on `t` that the
62
+ * caller never needed a grant for. A body that cannot be read is `opaque`,
63
+ * and an opaque body's access list is a fragment its caller must discard.
64
+ */
65
+ export declare function extractFunctionAccess(fn: FunctionSnapshot): Promise<ExtractedAccess>;
45
66
  /**
46
67
  * The same extraction over a standalone SQL statement — a view body, a policy
47
68
  * predicate, anything that is already plain SQL rather than a function.
@@ -12,6 +12,7 @@
12
12
  */
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.extractBody = extractBody;
15
+ exports.extractFunctionAccess = extractFunctionAccess;
15
16
  exports.extractQuery = extractQuery;
16
17
  exports.extractAccess = extractAccess;
17
18
  exports.bodyFiltersRows = bodyFiltersRows;
@@ -19,6 +20,12 @@ const libpg_query_1 = require("libpg-query");
19
20
  const pgsql_parser_1 = require("pgsql-parser");
20
21
  const walk_1 = require("../ast/walk");
21
22
  const EMPTY = { calls: [], tables: [], settings: [], opaque: false };
23
+ /**
24
+ * Functions that run SQL of their own. The relations they touch are in a
25
+ * string argument, not in this AST, so a body calling one has a relation set
26
+ * that is a lower bound rather than an answer.
27
+ */
28
+ const SQL_EXECUTING = new Set(['query_to_xml', 'dblink', 'dblink_exec', 'dblink_send_query']);
22
29
  /** Languages whose bodies we can statically analyze. */
23
30
  const ANALYZABLE = new Set(['sql', 'plpgsql']);
24
31
  async function extractBody(fn) {
@@ -34,19 +41,37 @@ async function extractBody(fn) {
34
41
  }
35
42
  // plpgsql: parse the full CREATE FUNCTION, then analyze every embedded
36
43
  // SQL expression/statement the PL/pgSQL parser hands back.
44
+ const embedded = await plpgsqlStatements(fn);
45
+ if ('opaqueReason' in embedded) {
46
+ return { ...EMPTY, opaque: true, opaqueReason: embedded.opaqueReason };
47
+ }
48
+ const out = { calls: [], tables: [], settings: [], opaque: false, ...embedded.flags };
49
+ for (const sql of embedded.statements)
50
+ mergeBody(out, await extractQuery(sql));
51
+ return finalize(out);
52
+ }
53
+ /**
54
+ * The SQL a PL/pgSQL body embeds, as statements ready to parse, plus whether
55
+ * the body also runs SQL nothing can see (`EXECUTE`).
56
+ *
57
+ * Both body walkers need this and they need it identically — the read/write
58
+ * one for the call graph, the per-privilege one for the reach model — so the
59
+ * PL/pgSQL half is factored out rather than written twice.
60
+ */
61
+ async function plpgsqlStatements(fn) {
37
62
  if (!fn.definition)
38
- return { ...EMPTY, opaque: true, opaqueReason: 'no function definition available' };
63
+ return { opaqueReason: 'no function definition available' };
39
64
  let parsed;
40
65
  try {
41
66
  parsed = await (0, libpg_query_1.parsePlPgSQL)(fn.definition);
42
67
  }
43
68
  catch {
44
- return { ...EMPTY, opaque: true, opaqueReason: 'PL/pgSQL body failed to parse' };
69
+ return { opaqueReason: 'PL/pgSQL body failed to parse' };
45
70
  }
46
- const out = { calls: [], tables: [], settings: [], opaque: false };
71
+ const flags = { calls: [], tables: [], settings: [], opaque: false };
47
72
  const exprs = [];
48
- collectPlpgsql(parsed, exprs, out);
49
- for (const e of exprs) {
73
+ collectPlpgsql(parsed, exprs, flags);
74
+ const statements = exprs.map((e) => {
50
75
  // parseMode 0 = full statement; 3 = assignment (`target := expr` or
51
76
  // `target = expr`) — strip the anchored target so the RHS parses. The
52
77
  // RHS may itself contain `:=` (named arguments), so only the leading
@@ -55,11 +80,60 @@ async function extractBody(fn) {
55
80
  if (e.parseMode === 3) {
56
81
  q = q.replace(/^\s*[a-zA-Z_"][\w$".]*(\[[^\]]*\])*\s*:?=\s*/, '');
57
82
  }
58
- const sql = e.parseMode === 0 ? q : `SELECT ${q}`;
59
- const part = await extractQuery(sql);
60
- mergeBody(out, part);
83
+ return e.parseMode === 0 ? q : `SELECT ${q}`;
84
+ });
85
+ return {
86
+ statements,
87
+ flags: { opaque: flags.opaque, ...(flags.opaqueReason ? { opaqueReason: flags.opaqueReason } : {}) }
88
+ };
89
+ }
90
+ /**
91
+ * {@link extractAccess} asked of a whole function rather than one statement:
92
+ * every relation the body touches, with the privilege the touch exercises.
93
+ *
94
+ * This is what a SECURITY DEFINER function's reach is made of — the body runs
95
+ * as the owner, so each `INSERT INTO t` in it is an INSERT on `t` that the
96
+ * caller never needed a grant for. A body that cannot be read is `opaque`,
97
+ * and an opaque body's access list is a fragment its caller must discard.
98
+ */
99
+ async function extractFunctionAccess(fn) {
100
+ if (!ANALYZABLE.has(fn.language)) {
101
+ if (fn.language === 'internal' || fn.language === 'c')
102
+ return { accesses: [], opaque: false };
103
+ return {
104
+ accesses: [],
105
+ opaque: true,
106
+ opaqueReason: `language "${fn.language}" is not statically analyzable`
107
+ };
61
108
  }
62
- return finalize(out);
109
+ if (fn.language === 'sql') {
110
+ if (!fn.source || fn.source.trim() === '')
111
+ return { accesses: [], opaque: false };
112
+ return extractAccess(fn.source);
113
+ }
114
+ const embedded = await plpgsqlStatements(fn);
115
+ if ('opaqueReason' in embedded) {
116
+ return { accesses: [], opaque: true, opaqueReason: embedded.opaqueReason };
117
+ }
118
+ const accesses = [];
119
+ const seen = new Set();
120
+ let opaque = embedded.flags.opaque;
121
+ let opaqueReason = embedded.flags.opaqueReason;
122
+ for (const sql of embedded.statements) {
123
+ const part = await extractAccess(sql);
124
+ if (part.opaque && !opaque) {
125
+ opaque = true;
126
+ opaqueReason = part.opaqueReason;
127
+ }
128
+ for (const a of part.accesses) {
129
+ const key = `${a.schema ?? ''}.${a.name}::${a.privilege}`;
130
+ if (seen.has(key))
131
+ continue;
132
+ seen.add(key);
133
+ accesses.push(a);
134
+ }
135
+ }
136
+ return { accesses, opaque, ...(opaqueReason ? { opaqueReason } : {}) };
63
137
  }
64
138
  /** Walk the PL/pgSQL JSON tree: collect embedded SQL, flag dynamic EXECUTE. */
65
139
  function collectPlpgsql(node, exprs, out) {
@@ -108,6 +182,11 @@ async function extractQuery(sql) {
108
182
  if (setting)
109
183
  out.settings.push(setting);
110
184
  }
185
+ // Not opaque: the rest of the body still reads correctly. Tainted: the
186
+ // relations this call reaches are in a string, and we do not follow it.
187
+ if (SQL_EXECUTING.has(ref.name)) {
188
+ out.tainted ??= `\`${ref.name}\` executes SQL this analysis cannot see`;
189
+ }
111
190
  }
112
191
  // Write targets: the relation of INSERT/UPDATE/DELETE statements.
113
192
  const writeOids = new Set();
@@ -230,6 +309,7 @@ function mergeBody(into, from) {
230
309
  into.opaque = true;
231
310
  into.opaqueReason = from.opaqueReason;
232
311
  }
312
+ into.tainted ??= from.tainted;
233
313
  }
234
314
  function finalize(body) {
235
315
  const callKeys = new Set();
@@ -254,6 +334,7 @@ function finalize(body) {
254
334
  tables: [...tableKeys.values()],
255
335
  settings: [...new Set(body.settings)],
256
336
  opaque: body.opaque,
257
- ...(body.opaqueReason ? { opaqueReason: body.opaqueReason } : {})
337
+ ...(body.opaqueReason ? { opaqueReason: body.opaqueReason } : {}),
338
+ ...(body.tainted ? { tainted: body.tainted } : {})
258
339
  };
259
340
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * L13: reach that exists only in `pg_attribute.attacl`.
3
+ *
4
+ * `GRANT SELECT (secret) ON t TO anonymous` writes nothing to the relation's
5
+ * ACL. Every grant query in this package read `relacl` alone, so a role whose
6
+ * entire access to a table is column-scoped was reported as reaching *nothing*
7
+ * — no A2, no R1, no L-series, and "0 relation(s) accessible" in the role
8
+ * access report. The privilege is real: the role selects those columns, and
9
+ * when the table has no RLS it selects every row of them.
10
+ *
11
+ * The finding is deliberately about the *invisibility* as much as the access.
12
+ * A column grant is a legitimate and often good way to expose a projection;
13
+ * what is not fine is that nothing else in the audit grades it. So L13 reports
14
+ * the reach, states whether RLS mediates it, and — as everywhere else — never
15
+ * recommends revoking the grant it cannot prove unused.
16
+ */
17
+ import type { TableSnapshot } from '../pg/introspect';
18
+ import type { Finding } from '../types';
19
+ import { type LatticeRoleOptions, type RoleGraph } from './lattice';
20
+ export declare function checkUntrustedColumnGrants(table: TableSnapshot, graph: RoleGraph, options?: LatticeRoleOptions): Finding[];
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ /**
3
+ * L13: reach that exists only in `pg_attribute.attacl`.
4
+ *
5
+ * `GRANT SELECT (secret) ON t TO anonymous` writes nothing to the relation's
6
+ * ACL. Every grant query in this package read `relacl` alone, so a role whose
7
+ * entire access to a table is column-scoped was reported as reaching *nothing*
8
+ * — no A2, no R1, no L-series, and "0 relation(s) accessible" in the role
9
+ * access report. The privilege is real: the role selects those columns, and
10
+ * when the table has no RLS it selects every row of them.
11
+ *
12
+ * The finding is deliberately about the *invisibility* as much as the access.
13
+ * A column grant is a legitimate and often good way to expose a projection;
14
+ * what is not fine is that nothing else in the audit grades it. So L13 reports
15
+ * the reach, states whether RLS mediates it, and — as everywhere else — never
16
+ * recommends revoking the grant it cannot prove unused.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.checkUntrustedColumnGrants = checkUntrustedColumnGrants;
20
+ const lattice_1 = require("./lattice");
21
+ /** SELECT/INSERT/UPDATE are the column privileges RLS has anything to say about. */
22
+ const MEDIATED = ['SELECT', 'INSERT', 'UPDATE'];
23
+ function checkUntrustedColumnGrants(table, graph, options = {}) {
24
+ const roles = options.roles ?? [];
25
+ if (roles.length === 0 || table.columnGrants.length === 0)
26
+ return [];
27
+ const out = [];
28
+ for (const role of roles) {
29
+ const grants = (0, lattice_1.effectiveColumnGrants)(table, role, graph).filter((g) => MEDIATED.includes(g.privilege));
30
+ if (grants.length === 0)
31
+ continue;
32
+ const attrs = graph.get(role);
33
+ // Same exemption test the rest of the lattice uses: a role RLS does not
34
+ // apply to reads every row of the columns it is granted.
35
+ const rlsApplies = table.rlsEnabled
36
+ && !attrs?.bypassRls
37
+ && !attrs?.isSuper
38
+ && !(role === table.owner && !table.rlsForced);
39
+ const columns = [...new Set(grants.flatMap((g) => g.columns))].sort();
40
+ const privileges = grants.map((g) => g.privilege).sort();
41
+ const via = grants[0].via;
42
+ out.push({
43
+ code: 'L13',
44
+ severity: 'info',
45
+ category: 'anti-pattern',
46
+ schema: table.schema,
47
+ table: table.name,
48
+ role,
49
+ privilege: privileges.join(', '),
50
+ message: `Untrusted role ${role} holds column-level ${privileges.join(', ')} on `
51
+ + `${table.schema}.${table.name} (${columns.join(', ')})`
52
+ + (via === 'direct' ? '' : ` via ${via}`)
53
+ + (rlsApplies
54
+ ? ' — mediated by RLS, but invisible to every rule that reads the relation ACL'
55
+ : ' — with no RLS to mediate it, so every row of those columns is readable'),
56
+ hint: `Column grants live in \`pg_attribute.attacl\`, not \`relacl\`: \`\\dp\` shows them in `
57
+ + `the "Column privileges" column and nothing else in this audit graded them until now. `
58
+ + (rlsApplies
59
+ ? 'Confirm the policies that mediate this relation are the ones you would want applied '
60
+ + 'to a projection of it.'
61
+ : 'If the projection is meant to be public this is correct as written; if not, enable '
62
+ + 'RLS on the relation — the column grant restricts *which columns*, never which rows.')
63
+ + ' Do not revoke the grant on the strength of this finding alone: nothing here proves it '
64
+ + 'unused.',
65
+ context: {
66
+ columns,
67
+ via,
68
+ rlsMediated: rlsApplies,
69
+ rlsEnabled: table.rlsEnabled
70
+ }
71
+ });
72
+ }
73
+ return out;
74
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * L19 and L20: privilege that arrives through a *function body*.
3
+ *
4
+ * L8 modelled the view half of "SQL bodies confer privilege". This is the
5
+ * other half, and the larger one:
6
+ *
7
+ * - **L19, SECURITY DEFINER functions.** A definer function executes as its
8
+ * owner, so every relation its body touches is touched with the owner's
9
+ * privileges — including tables the caller holds nothing on, and, when
10
+ * the owner owns the table or bypasses RLS, without the row filter the
11
+ * table's policies would have applied. EXECUTE on the function is the
12
+ * only grant the caller needs, and no ACL on the base relation names it.
13
+ * Verified on PostgreSQL 18: an anonymous role with EXECUTE read every
14
+ * row of an RLS-protected table it had no grant on, while the invoker
15
+ * twin of the same function was denied.
16
+ *
17
+ * - **L20, `INSTEAD OF` triggers.** A write against a view carrying one
18
+ * never reaches a base relation: it *becomes* the trigger function's
19
+ * body. L9 suppressed those views because nothing followed that body —
20
+ * this rule follows it. The body is permission-checked against the
21
+ * function's effective user, so the escalation exists only when the
22
+ * trigger function is SECURITY DEFINER; the view's own owner and its
23
+ * `security_invoker` setting do not govern it. Both halves were probed on
24
+ * PG 18: the invoker trigger function was denied on the relation its body
25
+ * wrote, the definer one wrote it as its owner.
26
+ *
27
+ * The conservatism is L8's, unchanged. A body that cannot be read is unknown,
28
+ * not empty: its fragmentary access list is discarded and the function is
29
+ * reported as a coverage gap (L15) rather than scanned clean. And the fix is
30
+ * never a revoke — EXECUTE on the function is what the API serves; the defect
31
+ * is what the body does with the owner's rights.
32
+ */
33
+ import { type SchemaAclInfo } from '../pg/acl';
34
+ import type { FunctionSnapshot } from '../pg/functions';
35
+ import type { ViewSnapshot } from '../pg/indexes';
36
+ import type { TableSnapshot } from '../pg/introspect';
37
+ import type { Finding } from '../types';
38
+ import { type SuppressedView } from './definer-view';
39
+ import { type LatticeRoleOptions, type RoleGraph } from './lattice';
40
+ import { type FunctionReachInput, type TriggerWriteInput } from './role-reach';
41
+ export interface FunctionBodyAnalysis {
42
+ /** Functions that execute as someone other than the caller, as reach inputs. */
43
+ functions: FunctionReachInput[];
44
+ /** `INSTEAD OF` triggers whose function re-owns the write. */
45
+ triggers: TriggerWriteInput[];
46
+ /** Bodies deliberately left out, with why — an unread body is not a clean bill. */
47
+ suppressed: SuppressedView[];
48
+ }
49
+ /**
50
+ * Read every SECURITY DEFINER function's body and resolve what it reaches.
51
+ *
52
+ * Calls are followed: an invoker function called from a definer still runs
53
+ * with the definer's owner in force, while an inner definer switches the
54
+ * executing role again — the same rule as nested views, because it is the
55
+ * same rule. Views read from a body are followed too, through the existing
56
+ * view walk, so a definer function selecting from a definer view reaches that
57
+ * view's bases as well.
58
+ */
59
+ export declare function analyzeFunctionBodies(functions: FunctionSnapshot[], views: ViewSnapshot[], tables: TableSnapshot[], auditedSchemas?: Iterable<string>): Promise<FunctionBodyAnalysis>;
60
+ /**
61
+ * L19: an untrusted role reaches a relation by executing a SECURITY DEFINER
62
+ * function.
63
+ *
64
+ * Fires once per (role, function, relation, privilege) where the role can
65
+ * EXECUTE the function, the body touches the relation as someone else, and
66
+ * the role holds no such privilege on the relation itself. The invoker twin
67
+ * of the same function produces nothing: its body runs as the caller, so the
68
+ * relation's own ACL and policies apply and there is no edge to report.
69
+ */
70
+ export declare function checkDefinerFunctionReach(functions: FunctionReachInput[], tables: TableSnapshot[], graph: RoleGraph, schemaAcls: Map<string, SchemaAclInfo>, options?: LatticeRoleOptions): Finding[];
71
+ /**
72
+ * L20: an untrusted role writes a relation through an `INSTEAD OF` trigger
73
+ * whose function is SECURITY DEFINER.
74
+ *
75
+ * This is the suppression L9 has carried since it shipped. A write against a
76
+ * view with `INSTEAD OF` triggers never reaches a base relation — Postgres
77
+ * runs the trigger function instead — and where that write lands is in the
78
+ * body. Following it makes the edge provable; where the body is unreadable,
79
+ * or the trigger function runs as the caller, the suppression stands.
80
+ */
81
+ export declare function checkInsteadOfTriggerWrite(triggers: TriggerWriteInput[], tables: TableSnapshot[], graph: RoleGraph, options?: LatticeRoleOptions): Finding[];
82
+ /**
83
+ * L15, asked of a function instead of a view: an untrusted role can execute a
84
+ * definer function whose body this analysis could not fully read.
85
+ *
86
+ * The same coverage statement the view producer makes, for the same reason —
87
+ * the execution is proven and the far end is unknown, so no grading rule saw
88
+ * it. Reporting the gap is the alternative to a silent clean bill; it is
89
+ * `info`, score-neutral, and recommends no revoke.
90
+ */
91
+ export declare function checkUnreadableFunctionReach(functions: FunctionReachInput[], graph: RoleGraph, schemaAcls: Map<string, SchemaAclInfo>, options?: LatticeRoleOptions): Finding[];