safegres 1.5.0 → 1.8.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 +156 -11
- package/checks/anti-patterns.d.ts +14 -8
- package/checks/anti-patterns.js +39 -20
- package/checks/role-trust.d.ts +31 -0
- package/checks/role-trust.js +91 -0
- package/cli/audit.js +103 -65
- package/cli/commands.js +7 -1
- package/cli/doctor.d.ts +3 -0
- package/cli/doctor.js +72 -0
- package/cli/print-config.d.ts +3 -0
- package/cli/print-config.js +48 -0
- package/cli/shared.d.ts +13 -0
- package/cli/shared.js +67 -0
- package/commands/audit.d.ts +13 -0
- package/commands/audit.js +69 -7
- package/commands/doctor.d.ts +19 -0
- package/commands/doctor.js +181 -0
- package/config/loader.d.ts +21 -0
- package/config/loader.js +75 -0
- package/config/presets.d.ts +28 -0
- package/config/presets.js +69 -0
- package/config/resolve.d.ts +38 -0
- package/config/resolve.js +142 -0
- package/config/types.d.ts +117 -0
- package/config/types.js +2 -0
- package/esm/checks/anti-patterns.d.ts +14 -8
- package/esm/checks/anti-patterns.js +39 -20
- package/esm/checks/role-trust.d.ts +31 -0
- package/esm/checks/role-trust.js +86 -0
- package/esm/cli/audit.js +104 -66
- package/esm/cli/commands.js +7 -1
- package/esm/cli/doctor.d.ts +3 -0
- package/esm/cli/doctor.js +67 -0
- package/esm/cli/print-config.d.ts +3 -0
- package/esm/cli/print-config.js +46 -0
- package/esm/cli/shared.d.ts +13 -0
- package/esm/cli/shared.js +61 -0
- package/esm/commands/audit.d.ts +13 -0
- package/esm/commands/audit.js +69 -7
- package/esm/commands/doctor.d.ts +19 -0
- package/esm/commands/doctor.js +178 -0
- package/esm/config/loader.d.ts +21 -0
- package/esm/config/loader.js +71 -0
- package/esm/config/presets.d.ts +28 -0
- package/esm/config/presets.js +66 -0
- package/esm/config/resolve.d.ts +38 -0
- package/esm/config/resolve.js +131 -0
- package/esm/config/types.d.ts +117 -0
- package/esm/config/types.js +1 -0
- package/esm/index.d.ts +20 -4
- package/esm/index.js +11 -3
- package/esm/pg/exposure.d.ts +34 -0
- package/esm/pg/exposure.js +92 -0
- package/esm/pgpm-test.d.ts +22 -0
- package/esm/pgpm-test.js +39 -0
- package/esm/report/pretty.js +44 -8
- package/esm/rules/registry.d.ts +29 -0
- package/esm/rules/registry.js +134 -0
- package/esm/score/score.d.ts +39 -0
- package/esm/score/score.js +150 -0
- package/esm/types.d.ts +41 -0
- package/index.d.ts +20 -4
- package/index.js +46 -9
- package/package.json +14 -5
- package/pg/exposure.d.ts +34 -0
- package/pg/exposure.js +97 -0
- package/pgpm-test.d.ts +22 -0
- package/pgpm-test.js +42 -0
- package/report/pretty.js +44 -8
- package/rules/registry.d.ts +29 -0
- package/rules/registry.js +139 -0
- package/score/score.d.ts +39 -0
- package/score/score.js +155 -0
- package/types.d.ts +41 -0
package/README.md
CHANGED
|
@@ -28,20 +28,131 @@ Per-field overrides (`--host`, `--port`, `--user`, `--password`, `--database`) a
|
|
|
28
28
|
|
|
29
29
|
## What it checks
|
|
30
30
|
|
|
31
|
-
| Code | Severity | Category | Check |
|
|
32
|
-
| --- | --- | --- | --- |
|
|
33
|
-
| A1 |
|
|
34
|
-
| A2 | high | flags | Grants exist on a table with **RLS disabled** |
|
|
35
|
-
| A3 |
|
|
36
|
-
| A4 |
|
|
37
|
-
| A5 |
|
|
38
|
-
| A6 | info | coverage | UPDATE has `USING` but **no `WITH CHECK`** (row-smuggling surface) |
|
|
39
|
-
| A7 |
|
|
40
|
-
|
|
|
41
|
-
|
|
|
31
|
+
| Code | Severity | Direction | Category | Check |
|
|
32
|
+
| --- | --- | --- | --- | --- |
|
|
33
|
+
| A1 | low | fail-closed | flags | RLS enabled but **0 policies** (deny-all — confirm the lock is intended) |
|
|
34
|
+
| A2 | high | fail-open | flags | Grants exist on a table with **RLS disabled** |
|
|
35
|
+
| A3 | low | fail-open | flags | RLS enabled but **`FORCE ROW LEVEL SECURITY` not set** (table owner bypass) |
|
|
36
|
+
| A4 | low | fail-closed | coverage | INSERT / UPDATE / DELETE grant with **no covering policy** — writes are denied at runtime |
|
|
37
|
+
| A5 | low | fail-closed | coverage | SELECT grant with **no policy** — queries silently return 0 rows |
|
|
38
|
+
| A6 | info | fail-closed | coverage | UPDATE has `USING` but **no `WITH CHECK`** (row-smuggling surface) |
|
|
39
|
+
| A7 | critical | fail-open | anti-pattern | Trivially-permissive **WRITE** policy (INSERT/UPDATE/DELETE/ALL with literal `true`) |
|
|
40
|
+
| A8 | low | fail-open | anti-pattern | Trivially-permissive **SELECT** policy (`USING (true)` — confirm public-read is intended) |
|
|
41
|
+
| P1 | high | neutral | anti-pattern | Policy body calls a **VOLATILE function** (per-row evaluation) |
|
|
42
|
+
| P5 | high | fail-open | anti-pattern | Policy body references **`session_user`** / `current_user` / `pg_has_role(...)` |
|
|
43
|
+
| R1 | critical | fail-open | anti-pattern | An **untrusted role** (options: `{ roles: [...] }`) holds a write privilege |
|
|
44
|
+
| R2 | high | fail-open | anti-pattern | A permissive write policy applies to an untrusted role or PUBLIC |
|
|
45
|
+
| R3 | medium | fail-open | anti-pattern | An RLS table has grants **TO PUBLIC** (includes all current/future roles) |
|
|
46
|
+
| W1 | medium | — | meta | No exposure surface configured — whole database assumed reachable, score capped |
|
|
47
|
+
|
|
48
|
+
**Direction matters**: `fail-open` findings are actual exposure (the untrusted side can reach more than intended). `fail-closed` findings are denied at runtime — an availability/hygiene concern, not a leak — and contribute **nothing to the score** by default (tune with `scoring.failClosedWeight`).
|
|
42
49
|
|
|
43
50
|
Coverage is aggregated `(table, role) → { hasUsing, hasWithCheck }` across every applicable permissive policy (FOR ALL + PUBLIC-role policies considered). Roles with `BYPASSRLS` are suppressed.
|
|
44
51
|
|
|
52
|
+
R1/R2 are no-ops until a role list is configured — e.g. `"R1": ["critical", { "roles": ["anonymous"] }]` — so they cost nothing on databases without an untrusted-role model. The `safegres:constructive` preset configures them for `anonymous`.
|
|
53
|
+
|
|
54
|
+
## Exposure surface
|
|
55
|
+
|
|
56
|
+
A database-wide score is meaningless if most of the database isn't reachable through the app's APIs. Declare (or auto-resolve) the **exposure surface** and safegres partitions findings:
|
|
57
|
+
|
|
58
|
+
- **Exposed** findings (on API-reachable schemas) drive the score.
|
|
59
|
+
- **Internal** findings are reported as unscored *internal advisories* (hide entirely with `--exposed-only`).
|
|
60
|
+
- **No exposure configured** → a `W1` warning is emitted and the score is capped at 80/B (`scoring.unknownExposureCap`).
|
|
61
|
+
|
|
62
|
+
```jsonc
|
|
63
|
+
{
|
|
64
|
+
"exposure": {
|
|
65
|
+
"schemas": ["app_public", "app_hidden"] // static surface
|
|
66
|
+
// or, on a Constructive database:
|
|
67
|
+
// "resolver": "constructive" // introspects routing_public.apis → api_schemas
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
CLI: `--exposure-schemas <csv>`, `--exposed-only`. The `safegres:constructive` preset sets `exposure.resolver: "constructive"` so the surface is discovered automatically from the routing plane (including API roles from `role_name`/`anon_role`).
|
|
73
|
+
|
|
74
|
+
## Declared public surface
|
|
75
|
+
|
|
76
|
+
Some open reads are deliberate — pricing tables, reference data, a public user directory. Declare them and safegres treats them as intent instead of findings:
|
|
77
|
+
|
|
78
|
+
```jsonc
|
|
79
|
+
{
|
|
80
|
+
"public": {
|
|
81
|
+
"read": [
|
|
82
|
+
"app_public.plans*", // schema.table globs
|
|
83
|
+
"app_public.event_types",
|
|
84
|
+
"app_public.users" // deliberate public directory
|
|
85
|
+
]
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
- An open SELECT policy (`USING (true)` — rule A8) on a declared table is **acknowledged**: reported as info, excluded from the score.
|
|
91
|
+
- An open read on any *undeclared* table stays a scored finding — even in a `*_public`-named schema. Naming is never treated as intent; the config declaration is.
|
|
92
|
+
- `safegres doctor` warns about stale `public.read` patterns that no longer match any table.
|
|
93
|
+
|
|
94
|
+
## Configuration
|
|
95
|
+
|
|
96
|
+
safegres is configurable like a linter. Config is discovered by walking up from the current directory: `safegres.config.{ts,js,mjs,cjs}`, `.safegresrc{,.json,.yaml,.yml,.js}`, `safegres.json`, or a `"safegres"` key in package.json (via [confstash](https://github.com/constructive-io/dev-utils/tree/main/packages/confstash)).
|
|
97
|
+
|
|
98
|
+
```jsonc
|
|
99
|
+
// .safegresrc.json
|
|
100
|
+
{
|
|
101
|
+
"extends": "safegres:recommended",
|
|
102
|
+
"excludeSchemas": ["archive"],
|
|
103
|
+
"rules": {
|
|
104
|
+
"A3": "off", // disable a rule
|
|
105
|
+
"A5": "high", // retune a severity
|
|
106
|
+
"P*": "medium" // prefix wildcards
|
|
107
|
+
},
|
|
108
|
+
"overrides": [
|
|
109
|
+
{ "tables": ["public.audit_*"], "rules": { "A2": "off" } }
|
|
110
|
+
],
|
|
111
|
+
"scoring": { "weights": { "medium": 2 } },
|
|
112
|
+
"failOn": { "severity": "high", "grade": "B" }
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Or typed:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
// safegres.config.ts
|
|
120
|
+
import { defineConfig } from 'confstash';
|
|
121
|
+
|
|
122
|
+
export default defineConfig({
|
|
123
|
+
extends: 'safegres:constructive',
|
|
124
|
+
rules: { A6: 'low' }
|
|
125
|
+
});
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Presets
|
|
129
|
+
|
|
130
|
+
| Preset | Behavior |
|
|
131
|
+
| --- | --- |
|
|
132
|
+
| `safegres:recommended` | Every rule at its default severity (the no-config behavior) |
|
|
133
|
+
| `safegres:strict` | Everything escalated; fail-closed findings count 25% toward the score, `failOn: high` |
|
|
134
|
+
| `safegres:constructive` | Auto-resolves exposure from the routing plane; R1/R2 watch `anonymous`; leak surfaces (A2, P5) critical; A3 off (API roles never own tables) |
|
|
135
|
+
| `safegres:minimal` | Structural flags only (A1–A3) — fast CI smoke check |
|
|
136
|
+
|
|
137
|
+
CLI: `--config <path>`, `--preset <name>`, `--rule CODE=off|severity` (repeatable).
|
|
138
|
+
|
|
139
|
+
### Scoring
|
|
140
|
+
|
|
141
|
+
Every report includes a config-driven score (0–100 + grade). The default **density** model normalizes by the exposed surface so large schemas don't saturate to 0/F:
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
score = 100 · exp(−k · riskPoints / exposedTables)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
where `riskPoints` is the severity-weighted sum (critical 25, high 10, medium 4, low 1, info 0) of *exposed, fail-open* findings, and `k` defaults to 0.17 (≈ one critical per 10 exposed tables lands at a C). Non-exposed findings score 0; fail-closed findings score 0 unless `scoring.failClosedWeight` is raised; unknown exposure caps the score (`scoring.unknownExposureCap`, default 80). Any exposed critical floors the grade at C (`scoring.floorOnCritical`). The legacy flat-deduction model is available via `scoring.model: "weighted"`. Tune via `scoring.weights`, `scoring.perRuleWeights`, `scoring.densityK`, `scoring.gradeBands`. Gate CI with `--fail-on-score <n>` / `--fail-on-grade <g>` or `failOn` in config.
|
|
148
|
+
|
|
149
|
+
### Other commands
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
safegres doctor # diagnose config, parser, connection, catalog access, blind spots
|
|
153
|
+
safegres print-config # show the resolved effective config (--explain for per-key provenance)
|
|
154
|
+
```
|
|
155
|
+
|
|
45
156
|
## Library use
|
|
46
157
|
|
|
47
158
|
```ts
|
|
@@ -60,6 +171,40 @@ console.log(renderPretty(report));
|
|
|
60
171
|
console.log(`${report.findings.length} findings`);
|
|
61
172
|
```
|
|
62
173
|
|
|
174
|
+
## pgpm projects
|
|
175
|
+
|
|
176
|
+
For pgpm workspaces, safegres can deploy the workspace into an ephemeral test
|
|
177
|
+
database and audit it — no running database or connection flags required
|
|
178
|
+
(needs the optional peer dependency `pgsql-test`):
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
safegres audit --pgpm # nearest pgpm module/workspace from cwd
|
|
182
|
+
safegres audit --pgpm ./packages/my-db
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Or as a jest test via the `safegres/pgpm-test` entrypoint:
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
import { auditPgpmWorkspace } from 'safegres/pgpm-test';
|
|
189
|
+
|
|
190
|
+
it('passes the security audit', async () => {
|
|
191
|
+
const report = await auditPgpmWorkspace();
|
|
192
|
+
expect(report.score.grade).toBe('A+');
|
|
193
|
+
});
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Both discover the project's safegres config (`safegres.config.js`,
|
|
197
|
+
`.safegresrc*`, …) by walking up from the workspace directory. pgpm projects
|
|
198
|
+
usually don't have Constructive routing metadata, so declare the exposed
|
|
199
|
+
surface statically:
|
|
200
|
+
|
|
201
|
+
```json
|
|
202
|
+
{
|
|
203
|
+
"extends": "safegres:recommended",
|
|
204
|
+
"exposure": { "schemas": ["app_public"] }
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
63
208
|
---
|
|
64
209
|
|
|
65
210
|
## Education and Tutorials
|
|
@@ -21,16 +21,22 @@ export declare function checkVolatileFunctions(table: TableSnapshot, expr: PgAst
|
|
|
21
21
|
*/
|
|
22
22
|
export declare function checkSessionUserGating(table: TableSnapshot, expr: PgAstNode, policyName: string): Finding[];
|
|
23
23
|
/**
|
|
24
|
-
* Anti-
|
|
24
|
+
* Anti-patterns A7 / A8: trivially-permissive policy body.
|
|
25
25
|
*
|
|
26
|
-
* A permissive policy whose body is the literal `true`
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
26
|
+
* A permissive policy whose body is the literal `true` adds zero security —
|
|
27
|
+
* it's equivalent to not having RLS at all for the covered command. This is
|
|
28
|
+
* different from an intentional *restrictive* `true` (which would require
|
|
29
|
+
* all rows to satisfy it). We only flag `permissive = true` here because
|
|
30
|
+
* Postgres defaults to PERMISSIVE and `USING (true)` is the most common
|
|
31
|
+
* accidental "fail-open" shape.
|
|
32
32
|
*
|
|
33
|
-
*
|
|
33
|
+
* The verb matters enormously:
|
|
34
|
+
* - A7 (critical): a WRITE verb (INSERT/UPDATE/DELETE/ALL) with literal
|
|
35
|
+
* `true` lets every applicable role mutate every row — and, because
|
|
36
|
+
* permissive policies OR together, it silently defeats any carefully
|
|
37
|
+
* scoped policies on the same table.
|
|
38
|
+
* - A8 (low): a SELECT-only `USING (true)` is the standard public-read
|
|
39
|
+
* reference-table pattern; flag it for confirmation, not alarm.
|
|
34
40
|
*
|
|
35
41
|
* Input: the parsed USING and WITH CHECK ASTs (may be null if empty).
|
|
36
42
|
*/
|
package/checks/anti-patterns.js
CHANGED
|
@@ -5,9 +5,9 @@ exports.checkSessionUserGating = checkSessionUserGating;
|
|
|
5
5
|
exports.checkTriviallyPermissive = checkTriviallyPermissive;
|
|
6
6
|
exports.collectFunctionNames = collectFunctionNames;
|
|
7
7
|
exports.parseOrNull = parseOrNull;
|
|
8
|
+
const helpers_1 = require("../ast/helpers");
|
|
8
9
|
const parse_1 = require("../ast/parse");
|
|
9
10
|
const walk_1 = require("../ast/walk");
|
|
10
|
-
const helpers_1 = require("../ast/helpers");
|
|
11
11
|
/**
|
|
12
12
|
* Function names we consider "safe" (stable) for policy predicates, even when
|
|
13
13
|
* pg_proc marks them volatile. These are the well-known Postgres session
|
|
@@ -139,16 +139,22 @@ function checkSessionUserGating(table, expr, policyName) {
|
|
|
139
139
|
return out;
|
|
140
140
|
}
|
|
141
141
|
/**
|
|
142
|
-
* Anti-
|
|
142
|
+
* Anti-patterns A7 / A8: trivially-permissive policy body.
|
|
143
143
|
*
|
|
144
|
-
* A permissive policy whose body is the literal `true`
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
144
|
+
* A permissive policy whose body is the literal `true` adds zero security —
|
|
145
|
+
* it's equivalent to not having RLS at all for the covered command. This is
|
|
146
|
+
* different from an intentional *restrictive* `true` (which would require
|
|
147
|
+
* all rows to satisfy it). We only flag `permissive = true` here because
|
|
148
|
+
* Postgres defaults to PERMISSIVE and `USING (true)` is the most common
|
|
149
|
+
* accidental "fail-open" shape.
|
|
150
150
|
*
|
|
151
|
-
*
|
|
151
|
+
* The verb matters enormously:
|
|
152
|
+
* - A7 (critical): a WRITE verb (INSERT/UPDATE/DELETE/ALL) with literal
|
|
153
|
+
* `true` lets every applicable role mutate every row — and, because
|
|
154
|
+
* permissive policies OR together, it silently defeats any carefully
|
|
155
|
+
* scoped policies on the same table.
|
|
156
|
+
* - A8 (low): a SELECT-only `USING (true)` is the standard public-read
|
|
157
|
+
* reference-table pattern; flag it for confirmation, not alarm.
|
|
152
158
|
*
|
|
153
159
|
* Input: the parsed USING and WITH CHECK ASTs (may be null if empty).
|
|
154
160
|
*/
|
|
@@ -181,17 +187,30 @@ function checkTriviallyPermissive(table, policy, usingAst, withCheckAst) {
|
|
|
181
187
|
if (trivialClauses.length !== presentClauses.length)
|
|
182
188
|
return null;
|
|
183
189
|
const clauseList = trivialClauses.join(' and ');
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
190
|
+
const isWrite = policy.cmd !== 'SELECT';
|
|
191
|
+
return isWrite
|
|
192
|
+
? {
|
|
193
|
+
code: 'A7',
|
|
194
|
+
severity: 'critical',
|
|
195
|
+
category: 'anti-pattern',
|
|
196
|
+
schema: table.schema,
|
|
197
|
+
table: table.name,
|
|
198
|
+
policy: policy.name,
|
|
199
|
+
message: `Policy "${policy.name}" (FOR ${policy.cmd}) on ${table.schema}.${table.name} is trivially permissive (${clauseList} = true) — every applicable role can write every row`,
|
|
200
|
+
hint: 'Permissive policies OR together, so a literal-true write policy defeats every scoped policy on the table. Tighten the predicate or drop the policy.',
|
|
201
|
+
context: { cmd: policy.cmd, clauses: trivialClauses }
|
|
202
|
+
}
|
|
203
|
+
: {
|
|
204
|
+
code: 'A8',
|
|
205
|
+
severity: 'low',
|
|
206
|
+
category: 'anti-pattern',
|
|
207
|
+
schema: table.schema,
|
|
208
|
+
table: table.name,
|
|
209
|
+
policy: policy.name,
|
|
210
|
+
message: `Policy "${policy.name}" (FOR SELECT) on ${table.schema}.${table.name} is trivially permissive (${clauseList} = true) — all rows readable by applicable roles`,
|
|
211
|
+
hint: 'This is the standard public-read reference-table pattern. Confirm the table holds no per-tenant data; otherwise tighten the predicate.',
|
|
212
|
+
context: { cmd: policy.cmd, clauses: trivialClauses }
|
|
213
|
+
};
|
|
195
214
|
}
|
|
196
215
|
/**
|
|
197
216
|
* Walk a policy expression, collecting unique function `(schema, name)` tuples
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { TableSnapshot } from '../pg/introspect';
|
|
2
|
+
import type { Finding } from '../types';
|
|
3
|
+
/**
|
|
4
|
+
* Role-trust rules (R-series): findings driven by *who* is granted access,
|
|
5
|
+
* not just whether coverage exists. R1/R2 take a configurable list of
|
|
6
|
+
* untrusted roles (e.g. `anonymous`) via rule options; with no roles
|
|
7
|
+
* configured they are no-ops, so they cost nothing on databases without
|
|
8
|
+
* such a role model.
|
|
9
|
+
*/
|
|
10
|
+
export interface RoleTrustOptions {
|
|
11
|
+
/** Role names considered untrusted (exact match). */
|
|
12
|
+
roles?: string[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* R1: an untrusted role holds a write privilege on a table. Even with
|
|
16
|
+
* airtight policies, write access for e.g. `anonymous` is almost always a
|
|
17
|
+
* grant mistake — unauthenticated actors can INSERT/UPDATE/DELETE.
|
|
18
|
+
*/
|
|
19
|
+
export declare function checkUntrustedRoleWrites(table: TableSnapshot, options?: RoleTrustOptions): Finding[];
|
|
20
|
+
/**
|
|
21
|
+
* R2: a permissive policy makes write operations pass RLS for an untrusted
|
|
22
|
+
* role (directly or via PUBLIC). Pairs with R1: the grant is the door, the
|
|
23
|
+
* policy is the unlocked latch.
|
|
24
|
+
*/
|
|
25
|
+
export declare function checkUntrustedRolePolicies(table: TableSnapshot, options?: RoleTrustOptions): Finding[];
|
|
26
|
+
/**
|
|
27
|
+
* R3: a table with RLS enabled has grants TO PUBLIC. PUBLIC includes every
|
|
28
|
+
* present and future role, which silently widens access as roles are added
|
|
29
|
+
* and defeats role-scoped policy reasoning.
|
|
30
|
+
*/
|
|
31
|
+
export declare function checkPublicGrants(table: TableSnapshot): Finding[];
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.checkUntrustedRoleWrites = checkUntrustedRoleWrites;
|
|
4
|
+
exports.checkUntrustedRolePolicies = checkUntrustedRolePolicies;
|
|
5
|
+
exports.checkPublicGrants = checkPublicGrants;
|
|
6
|
+
const WRITE_PRIVILEGES = ['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE'];
|
|
7
|
+
/**
|
|
8
|
+
* R1: an untrusted role holds a write privilege on a table. Even with
|
|
9
|
+
* airtight policies, write access for e.g. `anonymous` is almost always a
|
|
10
|
+
* grant mistake — unauthenticated actors can INSERT/UPDATE/DELETE.
|
|
11
|
+
*/
|
|
12
|
+
function checkUntrustedRoleWrites(table, options = {}) {
|
|
13
|
+
const untrusted = new Set(options.roles ?? []);
|
|
14
|
+
if (untrusted.size === 0)
|
|
15
|
+
return [];
|
|
16
|
+
const out = [];
|
|
17
|
+
for (const grant of table.grants) {
|
|
18
|
+
if (!untrusted.has(grant.role))
|
|
19
|
+
continue;
|
|
20
|
+
if (!WRITE_PRIVILEGES.includes(grant.privilege))
|
|
21
|
+
continue;
|
|
22
|
+
out.push({
|
|
23
|
+
code: 'R1',
|
|
24
|
+
severity: 'critical',
|
|
25
|
+
category: 'anti-pattern',
|
|
26
|
+
schema: table.schema,
|
|
27
|
+
table: table.name,
|
|
28
|
+
role: grant.role,
|
|
29
|
+
privilege: grant.privilege,
|
|
30
|
+
message: `Untrusted role ${grant.role} has ${grant.privilege} grant on ${table.schema}.${table.name}`,
|
|
31
|
+
hint: `Revoke ${grant.privilege} from ${grant.role} unless unauthenticated writes to this table are intentional (e.g. a public signup or event-ingest table).`
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* R2: a permissive policy makes write operations pass RLS for an untrusted
|
|
38
|
+
* role (directly or via PUBLIC). Pairs with R1: the grant is the door, the
|
|
39
|
+
* policy is the unlocked latch.
|
|
40
|
+
*/
|
|
41
|
+
function checkUntrustedRolePolicies(table, options = {}) {
|
|
42
|
+
const untrusted = new Set(options.roles ?? []);
|
|
43
|
+
if (untrusted.size === 0 || !table.rlsEnabled)
|
|
44
|
+
return [];
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const policy of table.policies) {
|
|
47
|
+
if (!policy.permissive)
|
|
48
|
+
continue;
|
|
49
|
+
if (policy.cmd === 'SELECT')
|
|
50
|
+
continue;
|
|
51
|
+
const applies = policy.roles.filter((r) => r === 'PUBLIC' || untrusted.has(r));
|
|
52
|
+
if (applies.length === 0)
|
|
53
|
+
continue;
|
|
54
|
+
const via = policy.roles.includes('PUBLIC') ? 'PUBLIC (all roles)' : applies.join(', ');
|
|
55
|
+
out.push({
|
|
56
|
+
code: 'R2',
|
|
57
|
+
severity: 'high',
|
|
58
|
+
category: 'anti-pattern',
|
|
59
|
+
schema: table.schema,
|
|
60
|
+
table: table.name,
|
|
61
|
+
policy: policy.name,
|
|
62
|
+
message: `Permissive ${policy.cmd} policy ${policy.name} on ${table.schema}.${table.name} applies to untrusted role via ${via}`,
|
|
63
|
+
hint: 'Scope the policy TO specific trusted roles instead of PUBLIC/untrusted roles, or verify unauthenticated writes are intended.'
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* R3: a table with RLS enabled has grants TO PUBLIC. PUBLIC includes every
|
|
70
|
+
* present and future role, which silently widens access as roles are added
|
|
71
|
+
* and defeats role-scoped policy reasoning.
|
|
72
|
+
*/
|
|
73
|
+
function checkPublicGrants(table) {
|
|
74
|
+
if (!table.rlsEnabled)
|
|
75
|
+
return [];
|
|
76
|
+
const publicPrivs = table.grants.filter((g) => g.role === 'PUBLIC').map((g) => g.privilege);
|
|
77
|
+
if (publicPrivs.length === 0)
|
|
78
|
+
return [];
|
|
79
|
+
return [
|
|
80
|
+
{
|
|
81
|
+
code: 'R3',
|
|
82
|
+
severity: 'medium',
|
|
83
|
+
category: 'anti-pattern',
|
|
84
|
+
schema: table.schema,
|
|
85
|
+
table: table.name,
|
|
86
|
+
role: 'PUBLIC',
|
|
87
|
+
message: `Table ${table.schema}.${table.name} has RLS enabled but grants ${publicPrivs.join(', ')} to PUBLIC`,
|
|
88
|
+
hint: 'Grant to specific roles instead of PUBLIC — PUBLIC includes every current and future role, including untrusted ones.'
|
|
89
|
+
}
|
|
90
|
+
];
|
|
91
|
+
}
|
package/cli/audit.js
CHANGED
|
@@ -1,19 +1,40 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const logger_1 = require("@pgpmjs/logger");
|
|
4
|
-
const pg_1 = require("pg");
|
|
5
|
-
const pg_env_1 = require("pg-env");
|
|
6
4
|
const audit_1 = require("../commands/audit");
|
|
5
|
+
const loader_1 = require("../config/loader");
|
|
7
6
|
const json_1 = require("../report/json");
|
|
8
7
|
const pretty_1 = require("../report/pretty");
|
|
8
|
+
const score_1 = require("../score/score");
|
|
9
9
|
const types_1 = require("../types");
|
|
10
|
+
const shared_1 = require("./shared");
|
|
10
11
|
const log = new logger_1.Logger('safegres');
|
|
12
|
+
/**
|
|
13
|
+
* `--pgpm` mode needs the optional peer dependency `pgsql-test`, so the
|
|
14
|
+
* helper module is loaded lazily with a friendly error when it's missing.
|
|
15
|
+
*/
|
|
16
|
+
function importPgpmTest() {
|
|
17
|
+
try {
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
19
|
+
return require('../pgpm-test');
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
if (err.code === 'MODULE_NOT_FOUND') {
|
|
23
|
+
log.error('--pgpm requires the optional peer dependency "pgsql-test" — install it (e.g. `npm i -D pgsql-test`) and retry');
|
|
24
|
+
process.exit(2);
|
|
25
|
+
}
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
11
29
|
const usage = `
|
|
12
30
|
safegres audit — pure-PostgreSQL RLS auditor
|
|
13
31
|
|
|
14
32
|
safegres audit [OPTIONS]
|
|
15
33
|
|
|
16
34
|
Connection (priority order, top wins):
|
|
35
|
+
--pgpm [dir] No connection needed: deploy the pgpm workspace at
|
|
36
|
+
[dir] (default: nearest from cwd) into an ephemeral
|
|
37
|
+
test database and audit it (requires pgsql-test)
|
|
17
38
|
--connection <url> Full PostgreSQL connection string
|
|
18
39
|
--host <host> PostgreSQL host (else PGHOST, default localhost)
|
|
19
40
|
--port <port> PostgreSQL port (else PGPORT, default 5432)
|
|
@@ -21,6 +42,17 @@ Connection (priority order, top wins):
|
|
|
21
42
|
--password <pw> PostgreSQL password (else PGPASSWORD,default password)
|
|
22
43
|
--database <db> PostgreSQL database (else PGDATABASE,default postgres)
|
|
23
44
|
|
|
45
|
+
Configuration:
|
|
46
|
+
--config <path> Explicit config file (else discovered: safegres.config.{ts,js,mjs,cjs},
|
|
47
|
+
.safegresrc{,.json,.yaml,.yml,.js}, safegres.json, package.json "safegres")
|
|
48
|
+
--preset <name> Apply a built-in preset (recommended|strict|constructive|minimal)
|
|
49
|
+
--rule <CODE=SETTING> Retune a rule (repeatable), e.g. --rule A3=off --rule A5=high
|
|
50
|
+
|
|
51
|
+
Exposure (what the score is computed against):
|
|
52
|
+
--exposure-schemas <csv> Declare the API-exposed schemas; findings outside
|
|
53
|
+
them become unscored internal advisories
|
|
54
|
+
--exposed-only Hide internal (non-exposed) findings from output
|
|
55
|
+
|
|
24
56
|
Audit options:
|
|
25
57
|
--schemas <csv> Limit to these schemas (default: all non-system)
|
|
26
58
|
--exclude-schemas <csv> Skip these schemas
|
|
@@ -29,35 +61,12 @@ Audit options:
|
|
|
29
61
|
--format <fmt> "pretty" (default) | "json" | "json-pretty"
|
|
30
62
|
--fail-on <severity> Exit non-zero if any finding >= severity
|
|
31
63
|
(critical|high|medium|low|info; default: none)
|
|
64
|
+
--fail-on-score <n> Exit non-zero if the score is below n (0-100)
|
|
65
|
+
--fail-on-grade <g> Exit non-zero if the grade is below g (A+|A|B|C|D)
|
|
32
66
|
--skip-ast Skip AST-level anti-pattern checks (faster)
|
|
33
67
|
--no-color Disable ANSI colors in pretty output
|
|
34
68
|
--help, -h Show this help message
|
|
35
69
|
`;
|
|
36
|
-
function csvList(value) {
|
|
37
|
-
if (typeof value !== 'string' || value.length === 0)
|
|
38
|
-
return undefined;
|
|
39
|
-
return value
|
|
40
|
-
.split(',')
|
|
41
|
-
.map((p) => p.trim())
|
|
42
|
-
.filter(Boolean);
|
|
43
|
-
}
|
|
44
|
-
function buildClient(argv) {
|
|
45
|
-
if (typeof argv.connection === 'string' && argv.connection.length > 0) {
|
|
46
|
-
return new pg_1.Client({ connectionString: argv.connection });
|
|
47
|
-
}
|
|
48
|
-
const overrides = {};
|
|
49
|
-
if (typeof argv.host === 'string')
|
|
50
|
-
overrides.host = argv.host;
|
|
51
|
-
if (typeof argv.port === 'number')
|
|
52
|
-
overrides.port = argv.port;
|
|
53
|
-
if (typeof argv.user === 'string')
|
|
54
|
-
overrides.user = argv.user;
|
|
55
|
-
if (typeof argv.password === 'string')
|
|
56
|
-
overrides.password = argv.password;
|
|
57
|
-
if (typeof argv.database === 'string')
|
|
58
|
-
overrides.database = argv.database;
|
|
59
|
-
return new pg_1.Client((0, pg_env_1.getPgEnvOptions)(overrides));
|
|
60
|
-
}
|
|
61
70
|
exports.default = async (argv, _prompter, _options) => {
|
|
62
71
|
if (argv.help || argv.h) {
|
|
63
72
|
process.stdout.write(usage);
|
|
@@ -65,46 +74,75 @@ exports.default = async (argv, _prompter, _options) => {
|
|
|
65
74
|
}
|
|
66
75
|
// minimist parses `--no-color` as `color: false`.
|
|
67
76
|
const colorEnabled = argv.color !== false;
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
process.exit(2);
|
|
77
|
+
const pgpmCwd = typeof argv.pgpm === 'string' ? argv.pgpm : undefined;
|
|
78
|
+
const { config } = (0, loader_1.loadConfig)({ cwd: pgpmCwd, ...(0, shared_1.configParamsFromArgv)(argv) });
|
|
79
|
+
const exposureSchemas = (0, shared_1.csvList)(argv['exposure-schemas']);
|
|
80
|
+
const auditOptions = {
|
|
81
|
+
schemas: (0, shared_1.csvList)(argv.schemas),
|
|
82
|
+
excludeSchemas: (0, shared_1.csvList)(argv['exclude-schemas']),
|
|
83
|
+
includeRoles: (0, shared_1.csvList)(argv.roles),
|
|
84
|
+
excludeRoles: (0, shared_1.csvList)(argv['exclude-roles']),
|
|
85
|
+
skipAstChecks: argv['skip-ast'] === true,
|
|
86
|
+
exposure: exposureSchemas
|
|
87
|
+
? { ...config.exposure, schemas: exposureSchemas }
|
|
88
|
+
: undefined,
|
|
89
|
+
config
|
|
90
|
+
};
|
|
91
|
+
let report;
|
|
92
|
+
if (argv.pgpm) {
|
|
93
|
+
const { auditPgpmWorkspace } = importPgpmTest();
|
|
94
|
+
report = await auditPgpmWorkspace({ ...auditOptions, cwd: pgpmCwd });
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
const client = (0, shared_1.buildClient)(argv);
|
|
98
|
+
await client.connect();
|
|
99
|
+
try {
|
|
100
|
+
report = await (0, audit_1.audit)(client, auditOptions);
|
|
93
101
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const failOn = typeof argv['fail-on'] === 'string' ? argv['fail-on'] : undefined;
|
|
97
|
-
if (failOn) {
|
|
98
|
-
if (!(failOn in types_1.SEVERITY_ORDER)) {
|
|
99
|
-
log.error(`Unknown --fail-on severity: ${failOn}`);
|
|
100
|
-
process.exit(2);
|
|
101
|
-
}
|
|
102
|
-
if (report.findings.some((f) => (0, types_1.meetsThreshold)(f.severity, failOn))) {
|
|
103
|
-
process.exit(1);
|
|
104
|
-
}
|
|
102
|
+
finally {
|
|
103
|
+
await client.end();
|
|
105
104
|
}
|
|
106
105
|
}
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
if (argv['exposed-only'] === true) {
|
|
107
|
+
report.findings = report.findings.filter((f) => f.exposed !== false);
|
|
108
|
+
report.summary = (0, types_1.summarize)(report.findings);
|
|
109
|
+
}
|
|
110
|
+
const fmt = typeof argv.format === 'string' ? argv.format : 'pretty';
|
|
111
|
+
let output;
|
|
112
|
+
switch (fmt) {
|
|
113
|
+
case 'json':
|
|
114
|
+
output = (0, json_1.renderJson)(report);
|
|
115
|
+
break;
|
|
116
|
+
case 'json-pretty':
|
|
117
|
+
output = (0, json_1.renderJson)(report, { pretty: true });
|
|
118
|
+
break;
|
|
119
|
+
case 'pretty':
|
|
120
|
+
output = (0, pretty_1.renderPretty)(report, { color: colorEnabled });
|
|
121
|
+
break;
|
|
122
|
+
default:
|
|
123
|
+
log.error(`Unknown --format: ${fmt}`);
|
|
124
|
+
process.exit(2);
|
|
125
|
+
}
|
|
126
|
+
process.stdout.write(output);
|
|
127
|
+
process.stdout.write('\n');
|
|
128
|
+
const failOnSeverity = typeof argv['fail-on'] === 'string' ? argv['fail-on'] : config.failOn?.severity;
|
|
129
|
+
if (failOnSeverity) {
|
|
130
|
+
if (!(failOnSeverity in types_1.SEVERITY_ORDER)) {
|
|
131
|
+
log.error(`Unknown --fail-on severity: ${failOnSeverity}`);
|
|
132
|
+
process.exit(2);
|
|
133
|
+
}
|
|
134
|
+
if (report.findings.some((f) => (0, types_1.meetsThreshold)(f.severity, failOnSeverity))) {
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const failOnScore = typeof argv['fail-on-score'] === 'number' ? argv['fail-on-score'] : config.failOn?.score;
|
|
139
|
+
if (failOnScore != null && report.score && report.score.value < failOnScore) {
|
|
140
|
+
log.error(`score ${report.score.value} is below --fail-on-score ${failOnScore}`);
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
const failOnGrade = typeof argv['fail-on-grade'] === 'string' ? argv['fail-on-grade'] : config.failOn?.grade;
|
|
144
|
+
if (failOnGrade && report.score && !(0, score_1.meetsGrade)(report.score.grade, failOnGrade)) {
|
|
145
|
+
log.error(`grade ${report.score.grade} is below --fail-on-grade ${failOnGrade}`);
|
|
146
|
+
process.exit(1);
|
|
109
147
|
}
|
|
110
148
|
};
|