safegres 1.4.2 → 1.7.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 +62 -0
- package/checks/anti-patterns.js +1 -1
- package/checks/role-trust.d.ts +31 -0
- package/checks/role-trust.js +91 -0
- package/cli/audit.js +34 -38
- package/cli/commands.js +7 -1
- package/cli/doctor.d.ts +3 -0
- package/cli/doctor.js +72 -0
- package/cli/print-config.d.ts +3 -0
- package/cli/print-config.js +48 -0
- package/cli/shared.d.ts +13 -0
- package/cli/shared.js +67 -0
- package/commands/audit.d.ts +8 -0
- package/commands/audit.js +20 -7
- package/commands/doctor.d.ts +19 -0
- package/commands/doctor.js +133 -0
- package/config/loader.d.ts +21 -0
- package/config/loader.js +75 -0
- package/config/presets.d.ts +19 -0
- package/config/presets.js +56 -0
- package/config/resolve.d.ts +38 -0
- package/config/resolve.js +136 -0
- package/config/types.d.ts +58 -0
- package/config/types.js +2 -0
- package/esm/checks/anti-patterns.js +1 -1
- package/esm/checks/role-trust.d.ts +31 -0
- package/esm/checks/role-trust.js +86 -0
- package/esm/cli/audit.js +29 -33
- package/esm/cli/commands.js +7 -1
- package/esm/cli/doctor.d.ts +3 -0
- package/esm/cli/doctor.js +67 -0
- package/esm/cli/print-config.d.ts +3 -0
- package/esm/cli/print-config.js +46 -0
- package/esm/cli/shared.d.ts +13 -0
- package/esm/cli/shared.js +61 -0
- package/esm/commands/audit.d.ts +8 -0
- package/esm/commands/audit.js +20 -7
- package/esm/commands/doctor.d.ts +19 -0
- package/esm/commands/doctor.js +130 -0
- package/esm/config/loader.d.ts +21 -0
- package/esm/config/loader.js +71 -0
- package/esm/config/presets.d.ts +19 -0
- package/esm/config/presets.js +53 -0
- package/esm/config/resolve.d.ts +38 -0
- package/esm/config/resolve.js +125 -0
- package/esm/config/types.d.ts +58 -0
- package/esm/config/types.js +1 -0
- package/esm/index.d.ts +18 -4
- package/esm/index.js +10 -3
- package/esm/report/pretty.js +21 -7
- package/esm/rules/registry.d.ts +23 -0
- package/esm/rules/registry.js +105 -0
- package/esm/score/score.d.ts +29 -0
- package/esm/score/score.js +67 -0
- package/esm/types.d.ts +2 -0
- package/index.d.ts +18 -4
- package/index.js +42 -9
- package/package.json +6 -5
- package/report/pretty.js +21 -7
- package/rules/registry.d.ts +23 -0
- package/rules/registry.js +110 -0
- package/score/score.d.ts +29 -0
- package/score/score.js +72 -0
- package/types.d.ts +2 -0
package/README.md
CHANGED
|
@@ -39,9 +39,70 @@ Per-field overrides (`--host`, `--port`, `--user`, `--password`, `--database`) a
|
|
|
39
39
|
| A7 | high | anti-pattern | Trivially-permissive policy (`USING (true)` / `WITH CHECK (true)`) |
|
|
40
40
|
| P1 | high | anti-pattern | Policy body calls a **VOLATILE function** (per-row evaluation) |
|
|
41
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) |
|
|
42
45
|
|
|
43
46
|
Coverage is aggregated `(table, role) → { hasUsing, hasWithCheck }` across every applicable permissive policy (FOR ALL + PUBLIC-role policies considered). Roles with `BYPASSRLS` are suppressed.
|
|
44
47
|
|
|
48
|
+
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
|
+
|
|
50
|
+
## Configuration
|
|
51
|
+
|
|
52
|
+
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)).
|
|
53
|
+
|
|
54
|
+
```jsonc
|
|
55
|
+
// .safegresrc.json
|
|
56
|
+
{
|
|
57
|
+
"extends": "safegres:recommended",
|
|
58
|
+
"excludeSchemas": ["archive"],
|
|
59
|
+
"rules": {
|
|
60
|
+
"A3": "off", // disable a rule
|
|
61
|
+
"A5": "high", // retune a severity
|
|
62
|
+
"P*": "medium" // prefix wildcards
|
|
63
|
+
},
|
|
64
|
+
"overrides": [
|
|
65
|
+
{ "tables": ["public.audit_*"], "rules": { "A2": "off" } }
|
|
66
|
+
],
|
|
67
|
+
"scoring": { "weights": { "medium": 2 } },
|
|
68
|
+
"failOn": { "severity": "high", "grade": "B" }
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Or typed:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
// safegres.config.ts
|
|
76
|
+
import { defineConfig } from 'confstash';
|
|
77
|
+
|
|
78
|
+
export default defineConfig({
|
|
79
|
+
extends: 'safegres:constructive',
|
|
80
|
+
rules: { A6: 'low' }
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Presets
|
|
85
|
+
|
|
86
|
+
| Preset | Behavior |
|
|
87
|
+
| --- | --- |
|
|
88
|
+
| `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 |
|
|
91
|
+
| `safegres:minimal` | Structural flags only (A1–A3) — fast CI smoke check |
|
|
92
|
+
|
|
93
|
+
CLI: `--config <path>`, `--preset <name>`, `--rule CODE=off|severity` (repeatable).
|
|
94
|
+
|
|
95
|
+
### Scoring
|
|
96
|
+
|
|
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.
|
|
98
|
+
|
|
99
|
+
### Other commands
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
safegres doctor # diagnose config, parser, connection, catalog access, blind spots
|
|
103
|
+
safegres print-config # show the resolved effective config (--explain for per-key provenance)
|
|
104
|
+
```
|
|
105
|
+
|
|
45
106
|
## Library use
|
|
46
107
|
|
|
47
108
|
```ts
|
|
@@ -94,6 +155,7 @@ Common issues and solutions for pgpm, PostgreSQL, and testing.
|
|
|
94
155
|
### 🧪 Testing
|
|
95
156
|
|
|
96
157
|
* [pgsql-test](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-test): **📊 Isolated testing environments** with per-test transaction rollbacks—ideal for integration tests, complex migrations, and RLS simulation.
|
|
158
|
+
* [pglite-test](https://github.com/constructive-io/constructive/tree/main/postgres/pglite-test): **🪶 Drop-in pgsql-test replacement backed by PGlite** — in-process Postgres, no server required, instance-per-suite isolation.
|
|
97
159
|
* [pgsql-seed](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-seed): **🌱 PostgreSQL seeding utilities** for CSV, JSON, SQL data loading, and pgpm deployment.
|
|
98
160
|
* [supabase-test](https://github.com/constructive-io/constructive/tree/main/postgres/supabase-test): **🧪 Supabase-native test harness** preconfigured for the local Supabase stack—per-test rollbacks, JWT/role context helpers, and CI/GitHub Actions ready.
|
|
99
161
|
* [graphile-test](https://github.com/constructive-io/constructive/tree/main/graphile/graphile-test): **🔐 Authentication mocking** for Graphile-focused test helpers and emulating row-level security contexts.
|
package/checks/anti-patterns.js
CHANGED
|
@@ -5,9 +5,9 @@ exports.checkSessionUserGating = checkSessionUserGating;
|
|
|
5
5
|
exports.checkTriviallyPermissive = checkTriviallyPermissive;
|
|
6
6
|
exports.collectFunctionNames = collectFunctionNames;
|
|
7
7
|
exports.parseOrNull = parseOrNull;
|
|
8
|
+
const helpers_1 = require("../ast/helpers");
|
|
8
9
|
const parse_1 = require("../ast/parse");
|
|
9
10
|
const walk_1 = require("../ast/walk");
|
|
10
|
-
const helpers_1 = require("../ast/helpers");
|
|
11
11
|
/**
|
|
12
12
|
* Function names we consider "safe" (stable) for policy predicates, even when
|
|
13
13
|
* pg_proc marks them volatile. These are the well-known Postgres session
|
|
@@ -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,12 +1,13 @@
|
|
|
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');
|
|
11
12
|
const usage = `
|
|
12
13
|
safegres audit — pure-PostgreSQL RLS auditor
|
|
@@ -21,6 +22,12 @@ Connection (priority order, top wins):
|
|
|
21
22
|
--password <pw> PostgreSQL password (else PGPASSWORD,default password)
|
|
22
23
|
--database <db> PostgreSQL database (else PGDATABASE,default postgres)
|
|
23
24
|
|
|
25
|
+
Configuration:
|
|
26
|
+
--config <path> Explicit config file (else discovered: safegres.config.{ts,js,mjs,cjs},
|
|
27
|
+
.safegresrc{,.json,.yaml,.yml,.js}, safegres.json, package.json "safegres")
|
|
28
|
+
--preset <name> Apply a built-in preset (recommended|strict|constructive|minimal)
|
|
29
|
+
--rule <CODE=SETTING> Retune a rule (repeatable), e.g. --rule A3=off --rule A5=high
|
|
30
|
+
|
|
24
31
|
Audit options:
|
|
25
32
|
--schemas <csv> Limit to these schemas (default: all non-system)
|
|
26
33
|
--exclude-schemas <csv> Skip these schemas
|
|
@@ -29,35 +36,12 @@ Audit options:
|
|
|
29
36
|
--format <fmt> "pretty" (default) | "json" | "json-pretty"
|
|
30
37
|
--fail-on <severity> Exit non-zero if any finding >= severity
|
|
31
38
|
(critical|high|medium|low|info; default: none)
|
|
39
|
+
--fail-on-score <n> Exit non-zero if the score is below n (0-100)
|
|
40
|
+
--fail-on-grade <g> Exit non-zero if the grade is below g (A+|A|B|C|D)
|
|
32
41
|
--skip-ast Skip AST-level anti-pattern checks (faster)
|
|
33
42
|
--no-color Disable ANSI colors in pretty output
|
|
34
43
|
--help, -h Show this help message
|
|
35
44
|
`;
|
|
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
45
|
exports.default = async (argv, _prompter, _options) => {
|
|
62
46
|
if (argv.help || argv.h) {
|
|
63
47
|
process.stdout.write(usage);
|
|
@@ -65,15 +49,17 @@ exports.default = async (argv, _prompter, _options) => {
|
|
|
65
49
|
}
|
|
66
50
|
// minimist parses `--no-color` as `color: false`.
|
|
67
51
|
const colorEnabled = argv.color !== false;
|
|
68
|
-
const
|
|
52
|
+
const { config } = (0, loader_1.loadConfig)((0, shared_1.configParamsFromArgv)(argv));
|
|
53
|
+
const client = (0, shared_1.buildClient)(argv);
|
|
69
54
|
await client.connect();
|
|
70
55
|
try {
|
|
71
56
|
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
|
|
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
|
|
77
63
|
});
|
|
78
64
|
const fmt = typeof argv.format === 'string' ? argv.format : 'pretty';
|
|
79
65
|
let output;
|
|
@@ -93,16 +79,26 @@ exports.default = async (argv, _prompter, _options) => {
|
|
|
93
79
|
}
|
|
94
80
|
process.stdout.write(output);
|
|
95
81
|
process.stdout.write('\n');
|
|
96
|
-
const
|
|
97
|
-
if (
|
|
98
|
-
if (!(
|
|
99
|
-
log.error(`Unknown --fail-on severity: ${
|
|
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}`);
|
|
100
86
|
process.exit(2);
|
|
101
87
|
}
|
|
102
|
-
if (report.findings.some((f) => (0, types_1.meetsThreshold)(f.severity,
|
|
88
|
+
if (report.findings.some((f) => (0, types_1.meetsThreshold)(f.severity, failOnSeverity))) {
|
|
103
89
|
process.exit(1);
|
|
104
90
|
}
|
|
105
91
|
}
|
|
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);
|
|
96
|
+
}
|
|
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}`);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
106
102
|
}
|
|
107
103
|
finally {
|
|
108
104
|
await client.end();
|
package/cli/commands.js
CHANGED
|
@@ -7,6 +7,8 @@ exports.commands = void 0;
|
|
|
7
7
|
const logger_1 = require("@pgpmjs/logger");
|
|
8
8
|
const inquirerer_1 = require("inquirerer");
|
|
9
9
|
const audit_1 = __importDefault(require("./audit"));
|
|
10
|
+
const doctor_1 = __importDefault(require("./doctor"));
|
|
11
|
+
const print_config_1 = __importDefault(require("./print-config"));
|
|
10
12
|
const log = new logger_1.Logger('safegres');
|
|
11
13
|
const usage = `
|
|
12
14
|
safegres — pure-PostgreSQL RLS auditor
|
|
@@ -16,12 +18,16 @@ Usage:
|
|
|
16
18
|
|
|
17
19
|
Commands:
|
|
18
20
|
audit Audit grants, RLS flags, policy coverage, and anti-patterns
|
|
21
|
+
doctor Diagnose environment, connection, and configuration
|
|
22
|
+
print-config Show the resolved effective configuration
|
|
19
23
|
help Show this help message
|
|
20
24
|
|
|
21
25
|
Run \`safegres <command> --help\` for command-specific options.
|
|
22
26
|
`;
|
|
23
27
|
const commandMap = {
|
|
24
|
-
audit: audit_1.default
|
|
28
|
+
audit: audit_1.default,
|
|
29
|
+
doctor: doctor_1.default,
|
|
30
|
+
'print-config': print_config_1.default
|
|
25
31
|
};
|
|
26
32
|
const commands = async (argv, prompter, options) => {
|
|
27
33
|
const { first: command, newArgv } = (0, inquirerer_1.extractFirst)(argv);
|
package/cli/doctor.d.ts
ADDED
package/cli/doctor.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const yanse_1 = __importDefault(require("yanse"));
|
|
7
|
+
const doctor_1 = require("../commands/doctor");
|
|
8
|
+
const shared_1 = require("./shared");
|
|
9
|
+
const usage = `
|
|
10
|
+
safegres doctor — diagnose the environment, connection, and configuration
|
|
11
|
+
|
|
12
|
+
safegres doctor [OPTIONS]
|
|
13
|
+
|
|
14
|
+
Connection (same flags as \`safegres audit\`):
|
|
15
|
+
--connection <url> Full PostgreSQL connection string
|
|
16
|
+
--host / --port / --user / --password / --database
|
|
17
|
+
|
|
18
|
+
Configuration:
|
|
19
|
+
--config <path> Explicit config file
|
|
20
|
+
--preset <name> Apply a built-in preset
|
|
21
|
+
--rule <CODE=SETTING> Retune a rule (repeatable)
|
|
22
|
+
|
|
23
|
+
Options:
|
|
24
|
+
--no-color Disable ANSI colors
|
|
25
|
+
--help, -h Show this help message
|
|
26
|
+
|
|
27
|
+
Checks: config discovery & rule validation, pgsql-parser availability,
|
|
28
|
+
connection, catalog access (pg_policy), audit blind spots (BYPASSRLS),
|
|
29
|
+
and whether any tables actually have RLS enabled.
|
|
30
|
+
`;
|
|
31
|
+
const STATUS_LABEL = {
|
|
32
|
+
ok: 'OK ',
|
|
33
|
+
warn: 'WARN',
|
|
34
|
+
fail: 'FAIL'
|
|
35
|
+
};
|
|
36
|
+
exports.default = async (argv, _prompter, _options) => {
|
|
37
|
+
if (argv.help || argv.h) {
|
|
38
|
+
process.stdout.write(usage);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const colorEnabled = argv.color !== false;
|
|
42
|
+
const paint = (status, s) => {
|
|
43
|
+
if (!colorEnabled)
|
|
44
|
+
return s;
|
|
45
|
+
if (status === 'ok')
|
|
46
|
+
return yanse_1.default.green(s);
|
|
47
|
+
if (status === 'warn')
|
|
48
|
+
return yanse_1.default.yellow(s);
|
|
49
|
+
return yanse_1.default.bold(yanse_1.default.red(s));
|
|
50
|
+
};
|
|
51
|
+
let client = (0, shared_1.buildClient)(argv);
|
|
52
|
+
try {
|
|
53
|
+
await client.connect();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
client = null;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const report = await (0, doctor_1.doctor)(client, (0, shared_1.configParamsFromArgv)(argv));
|
|
60
|
+
for (const check of report.checks) {
|
|
61
|
+
process.stdout.write(`[${paint(check.status, STATUS_LABEL[check.status])}] ${check.name.padEnd(12)} ${check.detail}\n`);
|
|
62
|
+
}
|
|
63
|
+
process.stdout.write('\n');
|
|
64
|
+
process.stdout.write(report.ok ? 'doctor: all checks passed\n' : 'doctor: some checks failed\n');
|
|
65
|
+
if (!report.ok)
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
if (client)
|
|
70
|
+
await client.end();
|
|
71
|
+
}
|
|
72
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const loader_1 = require("../config/loader");
|
|
4
|
+
const resolve_1 = require("../config/resolve");
|
|
5
|
+
const registry_1 = require("../rules/registry");
|
|
6
|
+
const shared_1 = require("./shared");
|
|
7
|
+
const usage = `
|
|
8
|
+
safegres print-config — show the resolved effective configuration
|
|
9
|
+
|
|
10
|
+
safegres print-config [OPTIONS]
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
--config <path> Explicit config file
|
|
14
|
+
--preset <name> Apply a built-in preset
|
|
15
|
+
--rule <CODE=SETTING> Retune a rule (repeatable)
|
|
16
|
+
--explain Show per-key provenance (which layer set each value)
|
|
17
|
+
--help, -h Show this help message
|
|
18
|
+
`;
|
|
19
|
+
exports.default = async (argv, _prompter, _options) => {
|
|
20
|
+
if (argv.help || argv.h) {
|
|
21
|
+
process.stdout.write(usage);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const params = (0, shared_1.configParamsFromArgv)(argv);
|
|
25
|
+
if (argv.explain === true) {
|
|
26
|
+
const loader = (0, loader_1.safegresConfigLoader)();
|
|
27
|
+
const explained = loader.explainSync({
|
|
28
|
+
configFile: params.configFile,
|
|
29
|
+
overrides: params.overrides
|
|
30
|
+
});
|
|
31
|
+
for (const e of explained) {
|
|
32
|
+
process.stdout.write(`${e.path} = ${JSON.stringify(e.value)} (${e.source}: ${e.origin})\n`);
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const { config, filepath, isEmpty } = (0, loader_1.loadConfig)(params);
|
|
37
|
+
const resolved = (0, resolve_1.resolveRules)(config);
|
|
38
|
+
const effectiveRules = {};
|
|
39
|
+
for (const rule of registry_1.RULES) {
|
|
40
|
+
const r = resolved.rules.get(rule.code);
|
|
41
|
+
effectiveRules[rule.code] = r.enabled ? r.severity : 'off';
|
|
42
|
+
}
|
|
43
|
+
process.stdout.write(`${JSON.stringify({
|
|
44
|
+
source: filepath ?? (isEmpty ? '(no config file — defaults)' : undefined),
|
|
45
|
+
config,
|
|
46
|
+
effectiveRules
|
|
47
|
+
}, null, 2)}\n`);
|
|
48
|
+
};
|
package/cli/shared.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ParsedArgs } from 'inquirerer';
|
|
2
|
+
import { Client } from 'pg';
|
|
3
|
+
import type { LoadConfigParams } from '../config/loader';
|
|
4
|
+
import type { RulesConfig } from '../config/types';
|
|
5
|
+
export declare function csvList(value: unknown): string[] | undefined;
|
|
6
|
+
export declare function buildClient(argv: ParsedArgs): Client;
|
|
7
|
+
/**
|
|
8
|
+
* Parse repeatable `--rule CODE=SETTING` flags into a RulesConfig, e.g.
|
|
9
|
+
* `--rule A3=off --rule A5=high --rule 'P*'=off`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function parseRuleFlags(value: unknown): RulesConfig | undefined;
|
|
12
|
+
/** Build config-loading params from shared CLI flags. */
|
|
13
|
+
export declare function configParamsFromArgv(argv: ParsedArgs): LoadConfigParams;
|
package/cli/shared.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.csvList = csvList;
|
|
4
|
+
exports.buildClient = buildClient;
|
|
5
|
+
exports.parseRuleFlags = parseRuleFlags;
|
|
6
|
+
exports.configParamsFromArgv = configParamsFromArgv;
|
|
7
|
+
const pg_1 = require("pg");
|
|
8
|
+
const pg_env_1 = require("pg-env");
|
|
9
|
+
function csvList(value) {
|
|
10
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
11
|
+
return undefined;
|
|
12
|
+
return value
|
|
13
|
+
.split(',')
|
|
14
|
+
.map((p) => p.trim())
|
|
15
|
+
.filter(Boolean);
|
|
16
|
+
}
|
|
17
|
+
function buildClient(argv) {
|
|
18
|
+
if (typeof argv.connection === 'string' && argv.connection.length > 0) {
|
|
19
|
+
return new pg_1.Client({ connectionString: argv.connection });
|
|
20
|
+
}
|
|
21
|
+
const overrides = {};
|
|
22
|
+
if (typeof argv.host === 'string')
|
|
23
|
+
overrides.host = argv.host;
|
|
24
|
+
if (typeof argv.port === 'number')
|
|
25
|
+
overrides.port = argv.port;
|
|
26
|
+
if (typeof argv.user === 'string')
|
|
27
|
+
overrides.user = argv.user;
|
|
28
|
+
if (typeof argv.password === 'string')
|
|
29
|
+
overrides.password = argv.password;
|
|
30
|
+
if (typeof argv.database === 'string')
|
|
31
|
+
overrides.database = argv.database;
|
|
32
|
+
return new pg_1.Client((0, pg_env_1.getPgEnvOptions)(overrides));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Parse repeatable `--rule CODE=SETTING` flags into a RulesConfig, e.g.
|
|
36
|
+
* `--rule A3=off --rule A5=high --rule 'P*'=off`.
|
|
37
|
+
*/
|
|
38
|
+
function parseRuleFlags(value) {
|
|
39
|
+
if (value == null)
|
|
40
|
+
return undefined;
|
|
41
|
+
const entries = Array.isArray(value) ? value : [value];
|
|
42
|
+
const rules = {};
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
if (typeof entry !== 'string')
|
|
45
|
+
continue;
|
|
46
|
+
const eq = entry.indexOf('=');
|
|
47
|
+
if (eq <= 0) {
|
|
48
|
+
throw new Error(`Invalid --rule "${entry}": expected CODE=off|<severity>.`);
|
|
49
|
+
}
|
|
50
|
+
const code = entry.slice(0, eq).trim();
|
|
51
|
+
const setting = entry.slice(eq + 1).trim();
|
|
52
|
+
rules[code] = setting;
|
|
53
|
+
}
|
|
54
|
+
return Object.keys(rules).length > 0 ? rules : undefined;
|
|
55
|
+
}
|
|
56
|
+
/** Build config-loading params from shared CLI flags. */
|
|
57
|
+
function configParamsFromArgv(argv) {
|
|
58
|
+
const cliRules = parseRuleFlags(argv.rule);
|
|
59
|
+
const overrides = {};
|
|
60
|
+
if (cliRules)
|
|
61
|
+
overrides.rules = cliRules;
|
|
62
|
+
return {
|
|
63
|
+
configFile: typeof argv.config === 'string' ? argv.config : undefined,
|
|
64
|
+
preset: typeof argv.preset === 'string' ? argv.preset : undefined,
|
|
65
|
+
overrides: Object.keys(overrides).length > 0 ? overrides : undefined
|
|
66
|
+
};
|
|
67
|
+
}
|
package/commands/audit.d.ts
CHANGED
|
@@ -3,6 +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
7
|
import { type IntrospectOptions, type QueryExecutor } from '../pg/introspect';
|
|
7
8
|
import type { Report } from '../types';
|
|
8
9
|
export interface AuditOptions extends IntrospectOptions {
|
|
@@ -15,5 +16,12 @@ export interface AuditOptions extends IntrospectOptions {
|
|
|
15
16
|
* that only want grants + RLS-flag + coverage findings.
|
|
16
17
|
*/
|
|
17
18
|
skipAstChecks?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Merged safegres configuration (rules, overrides, scoring). Rule settings
|
|
21
|
+
* filter and retune findings; scoring settings drive the report score.
|
|
22
|
+
* Connection-independent option fields (`schemas`, `roles`, …) present on
|
|
23
|
+
* the config are used as fallbacks for the corresponding AuditOptions.
|
|
24
|
+
*/
|
|
25
|
+
config?: SafegresConfig;
|
|
18
26
|
}
|
|
19
27
|
export declare function audit(client: QueryExecutor, options?: AuditOptions): Promise<Report>;
|
package/commands/audit.js
CHANGED
|
@@ -6,25 +6,31 @@
|
|
|
6
6
|
*/
|
|
7
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
8
|
exports.audit = audit;
|
|
9
|
+
const anti_patterns_1 = require("../checks/anti-patterns");
|
|
9
10
|
const coverage_1 = require("../checks/coverage");
|
|
10
11
|
const rls_flags_1 = require("../checks/rls-flags");
|
|
11
|
-
const
|
|
12
|
+
const role_trust_1 = require("../checks/role-trust");
|
|
13
|
+
const resolve_1 = require("../config/resolve");
|
|
12
14
|
const introspect_1 = require("../pg/introspect");
|
|
13
15
|
const proc_1 = require("../pg/proc");
|
|
14
16
|
const roles_1 = require("../pg/roles");
|
|
17
|
+
const score_1 = require("../score/score");
|
|
15
18
|
const types_1 = require("../types");
|
|
16
19
|
const version_1 = require("../version");
|
|
17
20
|
async function audit(client, options = {}) {
|
|
18
21
|
const exec = (0, introspect_1.asExecutor)(client);
|
|
22
|
+
const config = options.config ?? {};
|
|
23
|
+
const resolved = (0, resolve_1.resolveRules)(config);
|
|
24
|
+
const skipAst = options.skipAstChecks || (0, resolve_1.allAstRulesDisabled)(resolved);
|
|
19
25
|
// Resolve role set.
|
|
20
26
|
const allRoles = await (0, roles_1.listAuditableRoles)(exec);
|
|
21
|
-
const resolution = (0, roles_1.resolveRoles)(allRoles, options.includeRoles, options.excludeRoles);
|
|
27
|
+
const resolution = (0, roles_1.resolveRoles)(allRoles, options.includeRoles ?? config.roles, options.excludeRoles ?? config.excludeRoles);
|
|
22
28
|
const snapshot = await (0, introspect_1.introspectTables)(exec, {
|
|
23
|
-
schemas: options.schemas,
|
|
24
|
-
excludeSchemas: options.excludeSchemas,
|
|
29
|
+
schemas: options.schemas ?? config.schemas,
|
|
30
|
+
excludeSchemas: options.excludeSchemas ?? config.excludeSchemas,
|
|
25
31
|
roles: resolution.roles
|
|
26
32
|
});
|
|
27
|
-
|
|
33
|
+
let findings = [];
|
|
28
34
|
for (const table of snapshot) {
|
|
29
35
|
// --- RLS flags (structural) ---
|
|
30
36
|
const a1 = (0, rls_flags_1.checkRlsEnabledNoPolicies)(table);
|
|
@@ -39,17 +45,24 @@ async function audit(client, options = {}) {
|
|
|
39
45
|
// --- Grant-vs-policy coverage ---
|
|
40
46
|
findings.push(...(0, coverage_1.checkCoverageGaps)(table));
|
|
41
47
|
findings.push(...(0, coverage_1.checkUpdateWithCheckCoverage)(table));
|
|
48
|
+
// --- Role-trust (options-driven; per-table overrides can retune roles) ---
|
|
49
|
+
const tableRules = (0, resolve_1.rulesForTable)(resolved, table.schema, table.name);
|
|
50
|
+
findings.push(...(0, role_trust_1.checkUntrustedRoleWrites)(table, tableRules.get('R1')?.options));
|
|
51
|
+
findings.push(...(0, role_trust_1.checkUntrustedRolePolicies)(table, tableRules.get('R2')?.options));
|
|
52
|
+
findings.push(...(0, role_trust_1.checkPublicGrants)(table));
|
|
42
53
|
// --- AST-level anti-patterns ---
|
|
43
|
-
if (!
|
|
54
|
+
if (!skipAst) {
|
|
44
55
|
findings.push(...(await auditTableAst(exec, table)));
|
|
45
56
|
}
|
|
46
57
|
}
|
|
58
|
+
findings = (0, resolve_1.applyRulesToFindings)(resolved, findings);
|
|
47
59
|
findings.sort(compareFindings);
|
|
48
60
|
return {
|
|
49
61
|
version: version_1.version,
|
|
50
62
|
generatedAt: new Date().toISOString(),
|
|
51
63
|
summary: (0, types_1.summarize)(findings),
|
|
52
|
-
findings
|
|
64
|
+
findings,
|
|
65
|
+
score: (0, score_1.computeScore)(findings, config.scoring)
|
|
53
66
|
};
|
|
54
67
|
}
|
|
55
68
|
async function auditTableAst(exec, table) {
|