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.
Files changed (46) hide show
  1. package/README.md +101 -17
  2. package/checks/anti-patterns.d.ts +14 -8
  3. package/checks/anti-patterns.js +38 -19
  4. package/cli/audit.js +89 -47
  5. package/commands/audit.d.ts +6 -1
  6. package/commands/audit.js +50 -1
  7. package/commands/doctor.js +48 -0
  8. package/config/presets.d.ts +13 -4
  9. package/config/presets.js +21 -8
  10. package/config/resolve.js +6 -0
  11. package/config/types.d.ts +60 -1
  12. package/esm/checks/anti-patterns.d.ts +14 -8
  13. package/esm/checks/anti-patterns.js +38 -19
  14. package/esm/cli/audit.js +90 -48
  15. package/esm/commands/audit.d.ts +6 -1
  16. package/esm/commands/audit.js +51 -2
  17. package/esm/commands/doctor.js +49 -1
  18. package/esm/config/presets.d.ts +13 -4
  19. package/esm/config/presets.js +21 -8
  20. package/esm/config/resolve.js +6 -0
  21. package/esm/config/types.d.ts +60 -1
  22. package/esm/index.d.ts +4 -2
  23. package/esm/index.js +1 -0
  24. package/esm/pg/exposure.d.ts +34 -0
  25. package/esm/pg/exposure.js +92 -0
  26. package/esm/pgpm-test.d.ts +22 -0
  27. package/esm/pgpm-test.js +39 -0
  28. package/esm/report/pretty.js +25 -3
  29. package/esm/rules/registry.d.ts +7 -1
  30. package/esm/rules/registry.js +38 -9
  31. package/esm/score/score.d.ts +19 -9
  32. package/esm/score/score.js +93 -10
  33. package/esm/types.d.ts +39 -0
  34. package/index.d.ts +4 -2
  35. package/index.js +5 -1
  36. package/package.json +11 -3
  37. package/pg/exposure.d.ts +34 -0
  38. package/pg/exposure.js +97 -0
  39. package/pgpm-test.d.ts +22 -0
  40. package/pgpm-test.js +42 -0
  41. package/report/pretty.js +25 -3
  42. package/rules/registry.d.ts +7 -1
  43. package/rules/registry.js +38 -9
  44. package/score/score.d.ts +19 -9
  45. package/score/score.js +93 -10
  46. 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 | 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(...)` |
42
- | R1 | critical | anti-pattern | An **untrusted role** (options: `{ roles: [...] }`) holds a write privilege |
43
- | R2 | high | anti-pattern | A permissive write policy applies to an untrusted role or PUBLIC |
44
- | R3 | medium | anti-pattern | An RLS table has grants **TO PUBLIC** (includes all current/future roles) |
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` | Coverage gaps escalated (A4 critical, A5 high), `failOn: high` |
90
- | `safegres:constructive` | Constructive's role model: R1/R2 watch `anonymous`, leak surfaces (A2, A4, A7, P5) critical |
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): weighted deductions per finding severity (critical 25, high 10, medium 4, low 1, info 0 by default), capped per rule, with any critical finding flooring the grade at C. Tune via `scoring.weights`, `scoring.perRuleWeights`, `scoring.maxDeductionPerRule`, `scoring.gradeBands`, `scoring.floorOnCritical`. Gate CI with `--fail-on-score <n>` / `--fail-on-grade <g>` or `failOn` in config.
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-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
  */
@@ -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
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 { config } = (0, loader_1.loadConfig)((0, shared_1.configParamsFromArgv)(argv));
53
- const client = (0, shared_1.buildClient)(argv);
54
- await client.connect();
55
- try {
56
- const report = await (0, audit_1.audit)(client, {
57
- schemas: (0, shared_1.csvList)(argv.schemas),
58
- excludeSchemas: (0, shared_1.csvList)(argv['exclude-schemas']),
59
- includeRoles: (0, shared_1.csvList)(argv.roles),
60
- excludeRoles: (0, shared_1.csvList)(argv['exclude-roles']),
61
- skipAstChecks: argv['skip-ast'] === true,
62
- config
63
- });
64
- const fmt = typeof argv.format === 'string' ? argv.format : 'pretty';
65
- let output;
66
- switch (fmt) {
67
- case 'json':
68
- output = (0, json_1.renderJson)(report);
69
- break;
70
- case 'json-pretty':
71
- output = (0, json_1.renderJson)(report, { pretty: true });
72
- break;
73
- case 'pretty':
74
- output = (0, pretty_1.renderPretty)(report, { color: colorEnabled });
75
- break;
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
- process.stdout.write(output);
81
- process.stdout.write('\n');
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
- const failOnScore = typeof argv['fail-on-score'] === 'number' ? argv['fail-on-score'] : config.failOn?.score;
93
- if (failOnScore != null && report.score && report.score.value < failOnScore) {
94
- log.error(`score ${report.score.value} is below --fail-on-score ${failOnScore}`);
95
- process.exit(1);
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
- const failOnGrade = typeof argv['fail-on-grade'] === 'string' ? argv['fail-on-grade'] : config.failOn?.grade;
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
- finally {
104
- await client.end();
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
  };
@@ -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) {
@@ -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) {
@@ -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
- /** Everything on and escalated; coverage gaps are treated as critical. */
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 role model (RLS-first, anonymous/authenticated/
13
- * administrator): untrusted-role rules watch `anonymous`, and anything that
14
- * can leak rows across the role boundary is critical.
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. */