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.
- package/README.md +120 -6
- package/callgraph/extract.d.ts +21 -0
- package/callgraph/extract.js +91 -10
- package/checks/column-grants.d.ts +20 -0
- package/checks/column-grants.js +74 -0
- package/checks/definer-function.d.ts +91 -0
- package/checks/definer-function.js +458 -0
- package/checks/definer-view.d.ts +61 -3
- package/checks/definer-view.js +172 -16
- package/checks/lattice.d.ts +35 -0
- package/checks/lattice.js +95 -7
- package/checks/object-acls.d.ts +32 -0
- package/checks/object-acls.js +129 -0
- package/checks/revocable-grants.d.ts +140 -0
- package/checks/revocable-grants.js +377 -0
- package/checks/role-reach.d.ts +140 -2
- package/checks/role-reach.js +122 -3
- package/checks/view-exposure.js +8 -4
- package/checks/view-writes.d.ts +26 -3
- package/checks/view-writes.js +63 -7
- package/cli/audit.js +15 -1
- package/cli/eval.js +41 -1
- package/cli/print-config.js +6 -0
- package/cli/shared.d.ts +7 -5
- package/cli/shared.js +19 -15
- package/commands/audit.js +179 -11
- package/config/loader.d.ts +17 -0
- package/config/loader.js +120 -4
- package/config/presets.js +53 -2
- package/config/schema.d.ts +54 -0
- package/config/schema.js +263 -0
- package/config/types.d.ts +112 -2
- package/config/validate.d.ts +14 -0
- package/config/validate.js +125 -0
- package/corpus/README.md +13 -5
- package/corpus/cases/35-column-grant-projection/case.json +27 -0
- package/corpus/cases/35-column-grant-projection/schema.sql +21 -0
- package/corpus/cases/35-policy-function-not-leakproof/case.json +24 -0
- package/corpus/cases/35-policy-function-not-leakproof/schema.sql +27 -0
- package/corpus/cases/36-column-grant-rls-mediated/case.json +27 -0
- package/corpus/cases/36-column-grant-rls-mediated/schema.sql +32 -0
- package/corpus/cases/36-policy-function-leakproof/case.json +24 -0
- package/corpus/cases/36-policy-function-leakproof/schema.sql +22 -0
- package/corpus/cases/37-sort-column-unindexed/case.json +24 -0
- package/corpus/cases/37-sort-column-unindexed/schema.sql +14 -0
- package/corpus/cases/37-unaudited-base-relation/case.json +30 -0
- package/corpus/cases/37-unaudited-base-relation/schema.sql +45 -0
- package/corpus/cases/38-foreign-key-with-index/case.json +22 -0
- package/corpus/cases/38-foreign-key-with-index/schema.sql +17 -0
- package/corpus/cases/38-invoker-view-unaudited-base/case.json +30 -0
- package/corpus/cases/38-invoker-view-unaudited-base/schema.sql +39 -0
- package/corpus/cases/39-definer-view-column-subset/case.json +30 -0
- package/corpus/cases/39-definer-view-column-subset/schema.sql +29 -0
- package/corpus/cases/39-policy-cast-with-expression-index/case.json +23 -0
- package/corpus/cases/39-policy-cast-with-expression-index/schema.sql +19 -0
- package/corpus/cases/40-definer-view-column-overreach/case.json +27 -0
- package/corpus/cases/40-definer-view-column-overreach/schema.sql +29 -0
- package/corpus/cases/40-stable-function-hoisted/case.json +23 -0
- package/corpus/cases/40-stable-function-hoisted/schema.sql +25 -0
- package/corpus/cases/41-index-prefix-not-redundant/case.json +21 -0
- package/corpus/cases/41-index-prefix-not-redundant/schema.sql +15 -0
- package/corpus/cases/41-unreadable-view-body/case.json +33 -0
- package/corpus/cases/41-unreadable-view-body/schema.sql +28 -0
- package/corpus/cases/42-invoker-view-unreadable-body/case.json +30 -0
- package/corpus/cases/42-invoker-view-unreadable-body/schema.sql +27 -0
- package/corpus/cases/42-search-column-indexed/case.json +21 -0
- package/corpus/cases/42-search-column-indexed/schema.sql +12 -0
- package/corpus/cases/43-anon-sequence-grant/case.json +27 -0
- package/corpus/cases/43-anon-sequence-grant/schema.sql +23 -0
- package/corpus/cases/44-serial-sequence-usage/case.json +27 -0
- package/corpus/cases/44-serial-sequence-usage/schema.sql +25 -0
- package/corpus/cases/45-anon-foreign-table/case.json +27 -0
- package/corpus/cases/45-anon-foreign-table/schema.sql +23 -0
- package/corpus/cases/46-unchecked-writable-view/case.json +41 -0
- package/corpus/cases/46-unchecked-writable-view/schema.sql +37 -0
- package/corpus/cases/47-checked-writable-view/case.json +37 -0
- package/corpus/cases/47-checked-writable-view/schema.sql +38 -0
- package/corpus/cases/48-definer-function-reach/case.json +38 -0
- package/corpus/cases/48-definer-function-reach/schema.sql +41 -0
- package/corpus/cases/49-invoker-function-no-reach/case.json +32 -0
- package/corpus/cases/49-invoker-function-no-reach/schema.sql +38 -0
- package/corpus/cases/50-instead-of-trigger-write/case.json +38 -0
- package/corpus/cases/50-instead-of-trigger-write/schema.sql +58 -0
- package/corpus/cases/51-invoker-trigger-no-write/case.json +33 -0
- package/corpus/cases/51-invoker-trigger-no-write/schema.sql +54 -0
- package/corpus/cases/52-revocable-execute-grant/case.json +30 -0
- package/corpus/cases/52-revocable-execute-grant/schema.sql +45 -0
- package/corpus/cases/53-policy-predicate-retains-execute/case.json +23 -0
- package/corpus/cases/53-policy-predicate-retains-execute/schema.sql +49 -0
- package/esm/callgraph/extract.d.ts +21 -0
- package/esm/callgraph/extract.js +90 -10
- package/esm/checks/column-grants.d.ts +20 -0
- package/esm/checks/column-grants.js +71 -0
- package/esm/checks/definer-function.d.ts +91 -0
- package/esm/checks/definer-function.js +452 -0
- package/esm/checks/definer-view.d.ts +61 -3
- package/esm/checks/definer-view.js +171 -17
- package/esm/checks/lattice.d.ts +35 -0
- package/esm/checks/lattice.js +93 -7
- package/esm/checks/object-acls.d.ts +32 -0
- package/esm/checks/object-acls.js +125 -0
- package/esm/checks/revocable-grants.d.ts +140 -0
- package/esm/checks/revocable-grants.js +373 -0
- package/esm/checks/role-reach.d.ts +140 -2
- package/esm/checks/role-reach.js +121 -4
- package/esm/checks/view-exposure.js +8 -4
- package/esm/checks/view-writes.d.ts +26 -3
- package/esm/checks/view-writes.js +63 -8
- package/esm/cli/audit.js +16 -2
- package/esm/cli/eval.js +9 -2
- package/esm/cli/print-config.js +6 -0
- package/esm/cli/shared.d.ts +7 -5
- package/esm/cli/shared.js +19 -15
- package/esm/commands/audit.js +182 -14
- package/esm/config/loader.d.ts +17 -0
- package/esm/config/loader.js +86 -4
- package/esm/config/presets.js +53 -2
- package/esm/config/schema.d.ts +54 -0
- package/esm/config/schema.js +259 -0
- package/esm/config/types.d.ts +112 -2
- package/esm/config/validate.d.ts +14 -0
- package/esm/config/validate.js +122 -0
- package/esm/exposure/routines.d.ts +36 -0
- package/esm/exposure/routines.js +54 -0
- package/esm/index.d.ts +13 -5
- package/esm/index.js +9 -3
- package/esm/pg/acl.d.ts +9 -0
- package/esm/pg/acl.js +22 -0
- package/esm/pg/functions.d.ts +8 -1
- package/esm/pg/functions.js +36 -3
- package/esm/pg/indexes.d.ts +38 -0
- package/esm/pg/indexes.js +81 -0
- package/esm/pg/introspect.d.ts +24 -2
- package/esm/pg/introspect.js +49 -0
- package/esm/pg/objects.d.ts +37 -0
- package/esm/pg/objects.js +108 -0
- package/esm/pg/reach-inputs.d.ts +47 -0
- package/esm/pg/reach-inputs.js +133 -0
- package/esm/report/markdown.js +39 -7
- package/esm/report/pretty.js +38 -2
- package/esm/report/view.d.ts +8 -1
- package/esm/report/view.js +5 -0
- package/esm/rules/registry.d.ts +27 -0
- package/esm/rules/registry.js +142 -4
- package/esm/score/score.d.ts +19 -0
- package/esm/score/score.js +67 -13
- package/esm/score/scorecards.d.ts +68 -0
- package/esm/score/scorecards.js +157 -0
- package/esm/types.d.ts +15 -0
- package/esm/version.d.ts +8 -1
- package/esm/version.js +9 -2
- package/exposure/routines.d.ts +36 -0
- package/exposure/routines.js +57 -0
- package/index.d.ts +13 -5
- package/index.js +26 -3
- package/package.json +12 -12
- package/pg/acl.d.ts +9 -0
- package/pg/acl.js +23 -0
- package/pg/functions.d.ts +8 -1
- package/pg/functions.js +36 -3
- package/pg/indexes.d.ts +38 -0
- package/pg/indexes.js +81 -0
- package/pg/introspect.d.ts +24 -2
- package/pg/introspect.js +49 -0
- package/pg/objects.d.ts +37 -0
- package/pg/objects.js +111 -0
- package/pg/reach-inputs.d.ts +47 -0
- package/pg/reach-inputs.js +136 -0
- package/report/markdown.js +39 -7
- package/report/pretty.js +38 -2
- package/report/view.d.ts +8 -1
- package/report/view.js +5 -0
- package/rules/registry.d.ts +27 -0
- package/rules/registry.js +143 -4
- package/schema/safegres.schema.json +1109 -0
- package/score/score.d.ts +19 -0
- package/score/score.js +67 -13
- package/score/scorecards.d.ts +68 -0
- package/score/scorecards.js +162 -0
- package/types.d.ts +15 -0
- package/version.d.ts +8 -1
- 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:
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
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
|
package/callgraph/extract.d.ts
CHANGED
|
@@ -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.
|
package/callgraph/extract.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
69
|
+
return { opaqueReason: 'PL/pgSQL body failed to parse' };
|
|
45
70
|
}
|
|
46
|
-
const
|
|
71
|
+
const flags = { calls: [], tables: [], settings: [], opaque: false };
|
|
47
72
|
const exprs = [];
|
|
48
|
-
collectPlpgsql(parsed, exprs,
|
|
49
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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[];
|