You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: .agents/skills/safegres/SKILL.md
+3-3Lines changed: 3 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -88,7 +88,7 @@ The score only improves by being **explicit** (declaring exposure and intent) or
88
88
| L15 | info | neutral |**Unreadable body** — an untrusted role reaches through a definer view *or function* whose body could not be followed |
89
89
| L16 | info | fail-open |**Sequence privilege** — `nextval`/`setval`/`last_value` reach, which no policy filters |
90
90
| L17 | info | fail-open |**Foreign-table grant** — a relation Postgres will not let you protect with RLS at all |
91
-
| L18 |—|—|*(claimed by a concurrent PR: `WITH CHECK OPTION` on writable views)*|
91
+
| L18 |info|fail-open|**Writable filtering view without `WITH CHECK OPTION`** — writes escape the view's own row filter|
92
92
| L19 | info | fail-open |**Definer-function reach** — a SECURITY DEFINER body touches a relation as its owner, and EXECUTE is the only grant the caller needed |
93
93
| L20 | info | fail-open |**`INSTEAD OF` trigger write** — the write becomes a trigger function's body, and a definer one lands it as the function owner |
94
94
| W1 | medium | meta | No exposure surface configured — DB assumed reachable, score capped |
@@ -102,7 +102,7 @@ The score only improves by being **explicit** (declaring exposure and intent) or
102
102
103
103
Reach is modelled as cells in `checks/role-reach.ts`, each carrying the role the access **executes as** (`effectiveRole`), the **path** of edges it arrived by (`grant` / `setrole` / `view` / `matview` / `rule` / `function` / `trigger`), and a **proof** bit: `catalog` (an ACL row or `pg_auth_members`) or `ast` (read out of a SQL body). `opaque-tainted` marks a proven read whose downstream effects are unknown, and L15 has two producers: a definer view, or a definer function, whose body executes SQL the analysis cannot see through yields a cell naming the *view or function itself*, since what lies past it cannot be named.
104
104
105
-
**L8–L12 are the AST half: a view is not what its definition looks like.** A view without `security_invoker` runs as its owner, so a caller's SELECT on the view reads base relations under the *owner's* privileges (L8), and if the view is auto-updatable, writes land the same way (L9). Rewrite rules are worse: their actions are **not** governed by `security_invoker`, so L10 fires on invoker views where L9 does not. A materialized view stores rows computed at REFRESH time, so the bases are never consulted and their policies never run (L11). And a filtering view that is not `security_barrier` lets a leaky caller predicate be pushed below the filter (L12).
105
+
**L8–L12 are the AST half: a view is not what its definition looks like.** A view without `security_invoker` runs as its owner, so a caller's SELECT on the view reads base relations under the *owner's* privileges (L8), and if the view is auto-updatable, writes land the same way (L9). Rewrite rules are worse: their actions are **not** governed by `security_invoker`, so L10 fires on invoker views where L9 does not. A materialized view stores rows computed at REFRESH time, so the bases are never consulted and their policies never run (L11). And a filtering view that is not `security_barrier` lets a leaky caller predicate be pushed below the filter (L12) — or, on the write side, lets a writer store rows outside it, because `WITH CHECK OPTION` is not the default (L18).
106
106
107
107
**Which columns escape is a catalog fact, not a parsing problem.**`ViewSnapshot.columnDeps` reads the `pg_depend` rows the rewriter wrote for the view's `_RETURN` rule: `SELECT *` arrives expanded, a column used only in a `WHERE` counts as read, and a nested view depends on the *inner view's* columns. L8 puts that set in the message and in `context.columns`, and suppresses itself when every escaping column is one the role already holds by column grant **and** the base has no RLS — with RLS on, the owner reads rows the caller's policies hide, so the projection is beside the point. An absent column set is unknown, never narrow.
108
108
@@ -120,7 +120,7 @@ New L-rules ship `info` and **score-neutral** on purpose: the honest severity of
120
120
121
121
Perf-dimension rules (only collected with `--perf`, scored on their own axis; `S*` additionally need `--stats`): **X1** FK with no covering index (medium), **X2** policy filters on a column that leads no index (medium), **X3** policy casts/wraps its own column with no matching expression index (medium), **X4** policy calls a non-LEAKPROOF function (low), **X5** redundant/duplicate index (low), **X6** no primary key and no usable replica identity (low), **X7** search column with no index the search can use — `tsvector` w/o GIN/GiST, `vector` w/o HNSW/IVFFlat (medium), **X8** sort-shaped `timestamptz`/`date` column leading no index (info, heuristic), **X9** policy calls a STABLE function per row because it is not wrapped in a scalar sub-select (medium), plus P1/P1b and the runtime-statistics rules **S1**-**S4**.
122
122
123
-
**Direction is the key idea:**`fail-open` = real exposure (untrusted side reaches more than intended). `fail-closed` = denied at runtime (hygiene/availability, not a leak) — contributes **0** to the score by default. R1/R2 and the untrusted-role L-rules are no-ops until you configure a role list: `"L8": ["info", { "roles": ["anonymous"] }]`, or `{ "rolesFrom": "anon" }` to take them from `exposure.anonRoles`, which is what `safegres:recommended` does for L5 and L7–L17. `safegres:constructive` sets R1/R2 for `anonymous`.
123
+
**Direction is the key idea:**`fail-open` = real exposure (untrusted side reaches more than intended). `fail-closed` = denied at runtime (hygiene/availability, not a leak) — contributes **0** to the score by default. R1/R2 and the untrusted-role L-rules are no-ops until you configure a role list: `"L8": ["info", { "roles": ["anonymous"] }]`, or `{ "rolesFrom": "anon" }` to take them from `exposure.anonRoles`, which is what `safegres:recommended` does for L5 and L7–L20. `safegres:constructive` sets R1/R2 for `anonymous`.
Copy file name to clipboardExpand all lines: packages/safegres/README.md
+1Lines changed: 1 addition & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -153,6 +153,7 @@ family, **not** the dimension: `P1`/`P1b` are performance, `P5` is security.
153
153
| L15 | info | neutral |**Unreadable body** — an untrusted role reaches through a definer view or function whose body the analysis could not follow † |
154
154
| L16 | info | fail-open |**Sequence privilege** — an untrusted role can advance or read a sequence, which no policy filters † |
155
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 † |
156
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 † |
157
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 † |
"title": "A writable view's row filter is a read filter: no WITH CHECK OPTION, so writes escape it",
3
+
"dimension": "security",
4
+
"exposure": {
5
+
"schemas": [
6
+
"c_unchecked_view_write"
7
+
],
8
+
"roles": [
9
+
"corpus_anon",
10
+
"corpus_user"
11
+
],
12
+
"anonRoles": [
13
+
"corpus_anon"
14
+
]
15
+
},
16
+
"expect": [
17
+
{
18
+
"code": "L18",
19
+
"relation": "c_unchecked_view_write.notes",
20
+
"note": "the view filters on `tenant` and carries no WITH CHECK OPTION, so corpus_anon's INSERT/UPDATE through it stores rows for any tenant \u2014 rows the view will not then show it"
21
+
},
22
+
{
23
+
"code": "L9",
24
+
"relation": "c_unchecked_view_write.notes",
25
+
"note": "the same view is auto-updatable and not security_invoker, so the write reaches `notes` as c_unchecked_write_owner at all \u2014 L9 is *whether* the write lands, L18 is whether the view's own condition constrains it"
26
+
},
27
+
{
28
+
"code": "A3",
29
+
"relation": "c_unchecked_view_write.notes",
30
+
"note": "RLS is not FORCEd, which is why the owner's rewritten write is not filtered by the table's own policy either"
31
+
}
32
+
],
33
+
"forbid": [
34
+
"L8",
35
+
"L10",
36
+
"L11",
37
+
"L12"
38
+
],
39
+
"worstSeverity": "low",
40
+
"fix": "Recreate c_unchecked_view_write.tenant_notes WITH LOCAL CHECK OPTION so a row written through it must satisfy the condition it is served under, or put the tenant predicate in an RLS policy's WITH CHECK clause on `notes`, which applies however the row arrives. Do not revoke corpus_anon's INSERT on the view \u2014 that grant is what the API serves."
0 commit comments