safegres 1.7.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 +101 -17
- package/checks/anti-patterns.d.ts +14 -8
- package/checks/anti-patterns.js +38 -19
- package/cli/audit.js +89 -47
- package/commands/audit.d.ts +6 -1
- package/commands/audit.js +50 -1
- package/commands/doctor.js +48 -0
- package/config/presets.d.ts +13 -4
- package/config/presets.js +21 -8
- package/config/resolve.js +6 -0
- package/config/types.d.ts +60 -1
- package/esm/checks/anti-patterns.d.ts +14 -8
- package/esm/checks/anti-patterns.js +38 -19
- package/esm/cli/audit.js +90 -48
- package/esm/commands/audit.d.ts +6 -1
- package/esm/commands/audit.js +51 -2
- package/esm/commands/doctor.js +49 -1
- package/esm/config/presets.d.ts +13 -4
- package/esm/config/presets.js +21 -8
- package/esm/config/resolve.js +6 -0
- package/esm/config/types.d.ts +60 -1
- package/esm/index.d.ts +4 -2
- package/esm/index.js +1 -0
- 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 +25 -3
- package/esm/rules/registry.d.ts +7 -1
- package/esm/rules/registry.js +38 -9
- package/esm/score/score.d.ts +19 -9
- package/esm/score/score.js +93 -10
- package/esm/types.d.ts +39 -0
- package/index.d.ts +4 -2
- package/index.js +5 -1
- package/package.json +11 -3
- 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 +25 -3
- package/rules/registry.d.ts +7 -1
- package/rules/registry.js +38 -9
- package/score/score.d.ts +19 -9
- package/score/score.js +93 -10
- package/types.d.ts +39 -0
package/README.md
CHANGED
|
@@ -28,25 +28,69 @@ 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
|
-
|
|
|
42
|
-
|
|
|
43
|
-
|
|
|
44
|
-
|
|
|
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`).
|
|
45
49
|
|
|
46
50
|
Coverage is aggregated `(table, role) → { hasUsing, hasWithCheck }` across every applicable permissive policy (FOR ALL + PUBLIC-role policies considered). Roles with `BYPASSRLS` are suppressed.
|
|
47
51
|
|
|
48
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`.
|
|
49
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
|
+
|
|
50
94
|
## Configuration
|
|
51
95
|
|
|
52
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)).
|
|
@@ -86,15 +130,21 @@ export default defineConfig({
|
|
|
86
130
|
| Preset | Behavior |
|
|
87
131
|
| --- | --- |
|
|
88
132
|
| `safegres:recommended` | Every rule at its default severity (the no-config behavior) |
|
|
89
|
-
| `safegres:strict` |
|
|
90
|
-
| `safegres:constructive` |
|
|
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) |
|
|
91
135
|
| `safegres:minimal` | Structural flags only (A1–A3) — fast CI smoke check |
|
|
92
136
|
|
|
93
137
|
CLI: `--config <path>`, `--preset <name>`, `--rule CODE=off|severity` (repeatable).
|
|
94
138
|
|
|
95
139
|
### Scoring
|
|
96
140
|
|
|
97
|
-
Every report includes a config-driven score (0–100 + grade)
|
|
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.
|
|
98
148
|
|
|
99
149
|
### Other commands
|
|
100
150
|
|
|
@@ -121,6 +171,40 @@ console.log(renderPretty(report));
|
|
|
121
171
|
console.log(`${report.findings.length} findings`);
|
|
122
172
|
```
|
|
123
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
|
+
|
|
124
208
|
---
|
|
125
209
|
|
|
126
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
|
@@ -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
|
package/cli/audit.js
CHANGED
|
@@ -9,12 +9,32 @@ const score_1 = require("../score/score");
|
|
|
9
9
|
const types_1 = require("../types");
|
|
10
10
|
const shared_1 = require("./shared");
|
|
11
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
|
+
}
|
|
12
29
|
const usage = `
|
|
13
30
|
safegres audit — pure-PostgreSQL RLS auditor
|
|
14
31
|
|
|
15
32
|
safegres audit [OPTIONS]
|
|
16
33
|
|
|
17
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)
|
|
18
38
|
--connection <url> Full PostgreSQL connection string
|
|
19
39
|
--host <host> PostgreSQL host (else PGHOST, default localhost)
|
|
20
40
|
--port <port> PostgreSQL port (else PGPORT, default 5432)
|
|
@@ -28,6 +48,11 @@ Configuration:
|
|
|
28
48
|
--preset <name> Apply a built-in preset (recommended|strict|constructive|minimal)
|
|
29
49
|
--rule <CODE=SETTING> Retune a rule (repeatable), e.g. --rule A3=off --rule A5=high
|
|
30
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
|
+
|
|
31
56
|
Audit options:
|
|
32
57
|
--schemas <csv> Limit to these schemas (default: all non-system)
|
|
33
58
|
--exclude-schemas <csv> Skip these schemas
|
|
@@ -49,58 +74,75 @@ exports.default = async (argv, _prompter, _options) => {
|
|
|
49
74
|
}
|
|
50
75
|
// minimist parses `--no-color` as `color: false`.
|
|
51
76
|
const colorEnabled = argv.color !== false;
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
config
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
default:
|
|
77
|
-
log.error(`Unknown --format: ${fmt}`);
|
|
78
|
-
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);
|
|
79
101
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const failOnSeverity = typeof argv['fail-on'] === 'string' ? argv['fail-on'] : config.failOn?.severity;
|
|
83
|
-
if (failOnSeverity) {
|
|
84
|
-
if (!(failOnSeverity in types_1.SEVERITY_ORDER)) {
|
|
85
|
-
log.error(`Unknown --fail-on severity: ${failOnSeverity}`);
|
|
86
|
-
process.exit(2);
|
|
87
|
-
}
|
|
88
|
-
if (report.findings.some((f) => (0, types_1.meetsThreshold)(f.severity, failOnSeverity))) {
|
|
89
|
-
process.exit(1);
|
|
90
|
-
}
|
|
102
|
+
finally {
|
|
103
|
+
await client.end();
|
|
91
104
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
105
|
+
}
|
|
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);
|
|
96
133
|
}
|
|
97
|
-
|
|
98
|
-
if (failOnGrade && report.score && !(0, score_1.meetsGrade)(report.score.grade, failOnGrade)) {
|
|
99
|
-
log.error(`grade ${report.score.grade} is below --fail-on-grade ${failOnGrade}`);
|
|
134
|
+
if (report.findings.some((f) => (0, types_1.meetsThreshold)(f.severity, failOnSeverity))) {
|
|
100
135
|
process.exit(1);
|
|
101
136
|
}
|
|
102
137
|
}
|
|
103
|
-
|
|
104
|
-
|
|
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);
|
|
105
147
|
}
|
|
106
148
|
};
|
package/commands/audit.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Ingests a catalog snapshot, runs every check, and returns a structured report.
|
|
5
5
|
*/
|
|
6
|
-
import type { SafegresConfig } from '../config/types';
|
|
6
|
+
import type { ExposureConfig, SafegresConfig } from '../config/types';
|
|
7
7
|
import { type IntrospectOptions, type QueryExecutor } from '../pg/introspect';
|
|
8
8
|
import type { Report } from '../types';
|
|
9
9
|
export interface AuditOptions extends IntrospectOptions {
|
|
@@ -16,6 +16,11 @@ export interface AuditOptions extends IntrospectOptions {
|
|
|
16
16
|
* that only want grants + RLS-flag + coverage findings.
|
|
17
17
|
*/
|
|
18
18
|
skipAstChecks?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* The exposed API surface. Overrides `config.exposure` when provided.
|
|
21
|
+
* Findings on non-exposed schemas contribute nothing to the score.
|
|
22
|
+
*/
|
|
23
|
+
exposure?: ExposureConfig;
|
|
19
24
|
/**
|
|
20
25
|
* Merged safegres configuration (rules, overrides, scoring). Rule settings
|
|
21
26
|
* filter and retune findings; scoring settings drive the report score.
|
package/commands/audit.js
CHANGED
|
@@ -11,9 +11,11 @@ const coverage_1 = require("../checks/coverage");
|
|
|
11
11
|
const rls_flags_1 = require("../checks/rls-flags");
|
|
12
12
|
const role_trust_1 = require("../checks/role-trust");
|
|
13
13
|
const resolve_1 = require("../config/resolve");
|
|
14
|
+
const exposure_1 = require("../pg/exposure");
|
|
14
15
|
const introspect_1 = require("../pg/introspect");
|
|
15
16
|
const proc_1 = require("../pg/proc");
|
|
16
17
|
const roles_1 = require("../pg/roles");
|
|
18
|
+
const registry_1 = require("../rules/registry");
|
|
17
19
|
const score_1 = require("../score/score");
|
|
18
20
|
const types_1 = require("../types");
|
|
19
21
|
const version_1 = require("../version");
|
|
@@ -25,11 +27,16 @@ async function audit(client, options = {}) {
|
|
|
25
27
|
// Resolve role set.
|
|
26
28
|
const allRoles = await (0, roles_1.listAuditableRoles)(exec);
|
|
27
29
|
const resolution = (0, roles_1.resolveRoles)(allRoles, options.includeRoles ?? config.roles, options.excludeRoles ?? config.excludeRoles);
|
|
30
|
+
const exposure = await (0, exposure_1.resolveExposure)(exec, options.exposure ?? config.exposure);
|
|
31
|
+
const exposedSchemas = new Set(exposure.schemas);
|
|
28
32
|
const snapshot = await (0, introspect_1.introspectTables)(exec, {
|
|
29
33
|
schemas: options.schemas ?? config.schemas,
|
|
30
34
|
excludeSchemas: options.excludeSchemas ?? config.excludeSchemas,
|
|
31
35
|
roles: resolution.roles
|
|
32
36
|
});
|
|
37
|
+
const exposedTables = exposure.known
|
|
38
|
+
? snapshot.filter((t) => exposedSchemas.has(t.schema)).length
|
|
39
|
+
: snapshot.length;
|
|
33
40
|
let findings = [];
|
|
34
41
|
for (const table of snapshot) {
|
|
35
42
|
// --- RLS flags (structural) ---
|
|
@@ -56,13 +63,55 @@ async function audit(client, options = {}) {
|
|
|
56
63
|
}
|
|
57
64
|
}
|
|
58
65
|
findings = (0, resolve_1.applyRulesToFindings)(resolved, findings);
|
|
66
|
+
// Stamp direction (from the registry) and exposure on every finding.
|
|
67
|
+
const publicRead = config.public?.read ?? [];
|
|
68
|
+
for (const f of findings) {
|
|
69
|
+
const meta = registry_1.RULES_BY_CODE.get(f.code);
|
|
70
|
+
if (meta && f.direction === undefined)
|
|
71
|
+
f.direction = meta.direction;
|
|
72
|
+
if (exposure.known && f.schema)
|
|
73
|
+
f.exposed = exposedSchemas.has(f.schema);
|
|
74
|
+
// Declared-public reads: an open SELECT on a table listed in
|
|
75
|
+
// `public.read` is intent, not a finding — acknowledge it (info,
|
|
76
|
+
// excluded from the score). Undeclared open reads stay scored.
|
|
77
|
+
if (f.code === 'A8' && f.schema && f.table
|
|
78
|
+
&& publicRead.some((p) => (0, resolve_1.matchTablePattern)(p, `${f.schema}.${f.table}`))) {
|
|
79
|
+
f.acknowledged = true;
|
|
80
|
+
f.severity = 'info';
|
|
81
|
+
f.message += ' — declared public read (public.read)';
|
|
82
|
+
f.hint = 'This table is declared in `public.read`, so the open read is treated as intentional and does not affect the score.';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// W1: no exposure surface — the whole database is assumed reachable.
|
|
86
|
+
if (!exposure.known && resolved.rules.get('W1')?.enabled !== false) {
|
|
87
|
+
findings.push({
|
|
88
|
+
code: 'W1',
|
|
89
|
+
severity: resolved.rules.get('W1')?.severity ?? 'medium',
|
|
90
|
+
category: 'meta',
|
|
91
|
+
direction: 'neutral',
|
|
92
|
+
message: 'No exposure surface configured — the audit assumes the entire database is reachable and the score is capped',
|
|
93
|
+
hint: 'Declare `exposure.schemas` (or use `exposure.resolver: "constructive"` on a Constructive database) so the score reflects what the exposed APIs can actually reach.'
|
|
94
|
+
});
|
|
95
|
+
}
|
|
59
96
|
findings.sort(compareFindings);
|
|
97
|
+
const exposureReport = {
|
|
98
|
+
known: exposure.known,
|
|
99
|
+
source: exposure.source,
|
|
100
|
+
schemas: exposure.schemas,
|
|
101
|
+
...(exposure.roles ? { roles: exposure.roles } : {}),
|
|
102
|
+
exposedTables,
|
|
103
|
+
totalTables: snapshot.length
|
|
104
|
+
};
|
|
60
105
|
return {
|
|
61
106
|
version: version_1.version,
|
|
62
107
|
generatedAt: new Date().toISOString(),
|
|
63
108
|
summary: (0, types_1.summarize)(findings),
|
|
64
109
|
findings,
|
|
65
|
-
score: (0, score_1.computeScore)(findings, config.scoring
|
|
110
|
+
score: (0, score_1.computeScore)(findings, config.scoring, {
|
|
111
|
+
exposedTables,
|
|
112
|
+
exposureKnown: exposure.known
|
|
113
|
+
}),
|
|
114
|
+
exposure: exposureReport
|
|
66
115
|
};
|
|
67
116
|
}
|
|
68
117
|
async function auditTableAst(exec, table) {
|
package/commands/doctor.js
CHANGED
|
@@ -9,12 +9,17 @@ exports.doctor = doctor;
|
|
|
9
9
|
const parse_1 = require("../ast/parse");
|
|
10
10
|
const loader_1 = require("../config/loader");
|
|
11
11
|
const resolve_1 = require("../config/resolve");
|
|
12
|
+
const exposure_1 = require("../pg/exposure");
|
|
12
13
|
const introspect_1 = require("../pg/introspect");
|
|
13
14
|
async function doctor(client, options = {}) {
|
|
14
15
|
const checks = [];
|
|
16
|
+
let exposureConfig;
|
|
17
|
+
let publicRead = [];
|
|
15
18
|
// --- configuration ---
|
|
16
19
|
try {
|
|
17
20
|
const loaded = (0, loader_1.loadConfig)(options);
|
|
21
|
+
exposureConfig = loaded.config.exposure;
|
|
22
|
+
publicRead = loaded.config.public?.read ?? [];
|
|
18
23
|
if (loaded.isEmpty) {
|
|
19
24
|
checks.push({
|
|
20
25
|
name: 'config',
|
|
@@ -126,6 +131,49 @@ async function doctor(client, options = {}) {
|
|
|
126
131
|
catch (err) {
|
|
127
132
|
checks.push({ name: 'rls', status: 'warn', detail: err.message });
|
|
128
133
|
}
|
|
134
|
+
// --- exposure surface ---
|
|
135
|
+
try {
|
|
136
|
+
const exposure = await (0, exposure_1.resolveExposure)(exec, exposureConfig);
|
|
137
|
+
if (exposure.known) {
|
|
138
|
+
checks.push({
|
|
139
|
+
name: 'exposure',
|
|
140
|
+
status: 'ok',
|
|
141
|
+
detail: `${exposure.schemas.length} exposed schema(s) via ${exposure.source}${exposure.roles && exposure.roles.length > 0 ? ` (api roles: ${exposure.roles.join(', ')})` : ''}`
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
checks.push({
|
|
146
|
+
name: 'exposure',
|
|
147
|
+
status: 'warn',
|
|
148
|
+
detail: 'no exposure surface configured — the audit assumes the whole database is reachable and caps the score. Declare `exposure.schemas` or use `exposure.resolver: "constructive"`.'
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
checks.push({ name: 'exposure', status: 'warn', detail: err.message });
|
|
154
|
+
}
|
|
155
|
+
// --- declared public surface ---
|
|
156
|
+
if (publicRead.length > 0) {
|
|
157
|
+
try {
|
|
158
|
+
const { rows } = await exec.query(`SELECT n.nspname || '.' || c.relname AS qualified
|
|
159
|
+
FROM pg_class c
|
|
160
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
161
|
+
WHERE c.relkind IN ('r','p')
|
|
162
|
+
AND n.nspname NOT IN ('pg_catalog','information_schema')`);
|
|
163
|
+
const qualified = rows.map((r) => r.qualified);
|
|
164
|
+
const stale = publicRead.filter((p) => !qualified.some((q) => (0, resolve_1.matchTablePattern)(p, q)));
|
|
165
|
+
checks.push({
|
|
166
|
+
name: 'public',
|
|
167
|
+
status: stale.length > 0 ? 'warn' : 'ok',
|
|
168
|
+
detail: stale.length > 0
|
|
169
|
+
? `stale \`public.read\` pattern(s) match no table: ${stale.join(', ')}`
|
|
170
|
+
: `${publicRead.length} \`public.read\` pattern(s), all match at least one table`
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
checks.push({ name: 'public', status: 'warn', detail: err.message });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
129
177
|
return finish(checks);
|
|
130
178
|
}
|
|
131
179
|
function finish(checks) {
|
package/config/presets.d.ts
CHANGED
|
@@ -6,12 +6,21 @@ import type { SafegresConfig } from './types';
|
|
|
6
6
|
*/
|
|
7
7
|
/** Today's default behavior: every rule at its registry default severity. */
|
|
8
8
|
export declare const recommended: SafegresConfig;
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Everything on and escalated. Fail-closed hygiene findings (dead grants,
|
|
11
|
+
* locked tables) are re-tuned upward and even contribute a fraction of
|
|
12
|
+
* their weight to the score.
|
|
13
|
+
*/
|
|
10
14
|
export declare const strict: SafegresConfig;
|
|
11
15
|
/**
|
|
12
|
-
* Tuned for Constructive's
|
|
13
|
-
*
|
|
14
|
-
*
|
|
16
|
+
* Tuned for Constructive's architecture:
|
|
17
|
+
* - the exposure surface auto-resolves from the routing plane
|
|
18
|
+
* (`routing_public.apis` → `api_schemas` → `metaschema_public.schema`),
|
|
19
|
+
* so only what the exposed APIs can reach drives the score;
|
|
20
|
+
* - untrusted-role rules watch `anonymous`; anything that can leak rows
|
|
21
|
+
* across the role boundary is critical;
|
|
22
|
+
* - A3 is off — API roles never own tables in the Constructive model, so
|
|
23
|
+
* non-FORCEd RLS is not an exposure.
|
|
15
24
|
*/
|
|
16
25
|
export declare const constructive: SafegresConfig;
|
|
17
26
|
/** Structural flags only — a fast CI smoke check. */
|