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.
Files changed (74) hide show
  1. package/README.md +156 -11
  2. package/checks/anti-patterns.d.ts +14 -8
  3. package/checks/anti-patterns.js +39 -20
  4. package/checks/role-trust.d.ts +31 -0
  5. package/checks/role-trust.js +91 -0
  6. package/cli/audit.js +103 -65
  7. package/cli/commands.js +7 -1
  8. package/cli/doctor.d.ts +3 -0
  9. package/cli/doctor.js +72 -0
  10. package/cli/print-config.d.ts +3 -0
  11. package/cli/print-config.js +48 -0
  12. package/cli/shared.d.ts +13 -0
  13. package/cli/shared.js +67 -0
  14. package/commands/audit.d.ts +13 -0
  15. package/commands/audit.js +69 -7
  16. package/commands/doctor.d.ts +19 -0
  17. package/commands/doctor.js +181 -0
  18. package/config/loader.d.ts +21 -0
  19. package/config/loader.js +75 -0
  20. package/config/presets.d.ts +28 -0
  21. package/config/presets.js +69 -0
  22. package/config/resolve.d.ts +38 -0
  23. package/config/resolve.js +142 -0
  24. package/config/types.d.ts +117 -0
  25. package/config/types.js +2 -0
  26. package/esm/checks/anti-patterns.d.ts +14 -8
  27. package/esm/checks/anti-patterns.js +39 -20
  28. package/esm/checks/role-trust.d.ts +31 -0
  29. package/esm/checks/role-trust.js +86 -0
  30. package/esm/cli/audit.js +104 -66
  31. package/esm/cli/commands.js +7 -1
  32. package/esm/cli/doctor.d.ts +3 -0
  33. package/esm/cli/doctor.js +67 -0
  34. package/esm/cli/print-config.d.ts +3 -0
  35. package/esm/cli/print-config.js +46 -0
  36. package/esm/cli/shared.d.ts +13 -0
  37. package/esm/cli/shared.js +61 -0
  38. package/esm/commands/audit.d.ts +13 -0
  39. package/esm/commands/audit.js +69 -7
  40. package/esm/commands/doctor.d.ts +19 -0
  41. package/esm/commands/doctor.js +178 -0
  42. package/esm/config/loader.d.ts +21 -0
  43. package/esm/config/loader.js +71 -0
  44. package/esm/config/presets.d.ts +28 -0
  45. package/esm/config/presets.js +66 -0
  46. package/esm/config/resolve.d.ts +38 -0
  47. package/esm/config/resolve.js +131 -0
  48. package/esm/config/types.d.ts +117 -0
  49. package/esm/config/types.js +1 -0
  50. package/esm/index.d.ts +20 -4
  51. package/esm/index.js +11 -3
  52. package/esm/pg/exposure.d.ts +34 -0
  53. package/esm/pg/exposure.js +92 -0
  54. package/esm/pgpm-test.d.ts +22 -0
  55. package/esm/pgpm-test.js +39 -0
  56. package/esm/report/pretty.js +44 -8
  57. package/esm/rules/registry.d.ts +29 -0
  58. package/esm/rules/registry.js +134 -0
  59. package/esm/score/score.d.ts +39 -0
  60. package/esm/score/score.js +150 -0
  61. package/esm/types.d.ts +41 -0
  62. package/index.d.ts +20 -4
  63. package/index.js +46 -9
  64. package/package.json +14 -5
  65. package/pg/exposure.d.ts +34 -0
  66. package/pg/exposure.js +97 -0
  67. package/pgpm-test.d.ts +22 -0
  68. package/pgpm-test.js +42 -0
  69. package/report/pretty.js +44 -8
  70. package/rules/registry.d.ts +29 -0
  71. package/rules/registry.js +139 -0
  72. package/score/score.d.ts +39 -0
  73. package/score/score.js +155 -0
  74. 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 | critical | flags | RLS enabled but **0 policies** (effectively deny-all) |
34
- | A2 | high | flags | Grants exist on a table with **RLS disabled** |
35
- | A3 | medium | flags | RLS enabled but **`FORCE ROW LEVEL SECURITY` not set** (table owner bypass) |
36
- | A4 | high | coverage | INSERT / UPDATE / DELETE grant with **no covering policy** for that verb |
37
- | A5 | medium | coverage | SELECT grant with **no policy** (silent empty result) |
38
- | A6 | info | coverage | UPDATE has `USING` but **no `WITH CHECK`** (row-smuggling surface) |
39
- | A7 | high | anti-pattern | Trivially-permissive policy (`USING (true)` / `WITH CHECK (true)`) |
40
- | P1 | high | anti-pattern | Policy body calls a **VOLATILE function** (per-row evaluation) |
41
- | P5 | high | anti-pattern | Policy body references **`session_user`** / `current_user` / `pg_has_role(...)` |
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-pattern A7: trivially-permissive policy body.
24
+ * Anti-patterns A7 / A8: trivially-permissive policy body.
25
25
  *
26
- * A permissive policy whose body is the literal `true` (and has no tightening
27
- * `WITH CHECK` clause) adds zero security — it's equivalent to not having RLS
28
- * at all for the covered command. This is different from an intentional
29
- * *restrictive* `true` (which would require all rows to satisfy it). We only
30
- * flag `permissive = true` here because Postgres defaults to PERMISSIVE and
31
- * `USING (true)` is the most common accidental "fail-open" shape.
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
- * Severity: HIGH — any auditor should see this as RLS-not-actually-enforced.
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
  */
@@ -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-pattern A7: trivially-permissive policy body.
142
+ * Anti-patterns A7 / A8: trivially-permissive policy body.
143
143
  *
144
- * A permissive policy whose body is the literal `true` (and has no tightening
145
- * `WITH CHECK` clause) adds zero security — it's equivalent to not having RLS
146
- * at all for the covered command. This is different from an intentional
147
- * *restrictive* `true` (which would require all rows to satisfy it). We only
148
- * flag `permissive = true` here because Postgres defaults to PERMISSIVE and
149
- * `USING (true)` is the most common accidental "fail-open" shape.
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
- * Severity: HIGH — any auditor should see this as RLS-not-actually-enforced.
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
- return {
185
- code: 'A7',
186
- severity: 'high',
187
- category: 'anti-pattern',
188
- schema: table.schema,
189
- table: table.name,
190
- policy: policy.name,
191
- message: `Policy "${policy.name}" on ${table.schema}.${table.name} is trivially permissive (${clauseList} = true)`,
192
- hint: 'A permissive policy whose body is the literal `true` imposes no constraint. Either tighten the predicate (reference the authenticated user / membership) or drop the policy and use a GRANT REVOKE model.',
193
- context: { cmd: policy.cmd, clauses: trivialClauses }
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 client = buildClient(argv);
69
- await client.connect();
70
- try {
71
- const report = await (0, audit_1.audit)(client, {
72
- schemas: csvList(argv.schemas),
73
- excludeSchemas: csvList(argv['exclude-schemas']),
74
- includeRoles: csvList(argv.roles),
75
- excludeRoles: csvList(argv['exclude-roles']),
76
- skipAstChecks: argv['skip-ast'] === true
77
- });
78
- const fmt = typeof argv.format === 'string' ? argv.format : 'pretty';
79
- let output;
80
- switch (fmt) {
81
- case 'json':
82
- output = (0, json_1.renderJson)(report);
83
- break;
84
- case 'json-pretty':
85
- output = (0, json_1.renderJson)(report, { pretty: true });
86
- break;
87
- case 'pretty':
88
- output = (0, pretty_1.renderPretty)(report, { color: colorEnabled });
89
- break;
90
- default:
91
- log.error(`Unknown --format: ${fmt}`);
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
- process.stdout.write(output);
95
- process.stdout.write('\n');
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
- finally {
108
- await client.end();
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
  };