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.
Files changed (64) hide show
  1. package/README.md +62 -0
  2. package/checks/anti-patterns.js +1 -1
  3. package/checks/role-trust.d.ts +31 -0
  4. package/checks/role-trust.js +91 -0
  5. package/cli/audit.js +34 -38
  6. package/cli/commands.js +7 -1
  7. package/cli/doctor.d.ts +3 -0
  8. package/cli/doctor.js +72 -0
  9. package/cli/print-config.d.ts +3 -0
  10. package/cli/print-config.js +48 -0
  11. package/cli/shared.d.ts +13 -0
  12. package/cli/shared.js +67 -0
  13. package/commands/audit.d.ts +8 -0
  14. package/commands/audit.js +20 -7
  15. package/commands/doctor.d.ts +19 -0
  16. package/commands/doctor.js +133 -0
  17. package/config/loader.d.ts +21 -0
  18. package/config/loader.js +75 -0
  19. package/config/presets.d.ts +19 -0
  20. package/config/presets.js +56 -0
  21. package/config/resolve.d.ts +38 -0
  22. package/config/resolve.js +136 -0
  23. package/config/types.d.ts +58 -0
  24. package/config/types.js +2 -0
  25. package/esm/checks/anti-patterns.js +1 -1
  26. package/esm/checks/role-trust.d.ts +31 -0
  27. package/esm/checks/role-trust.js +86 -0
  28. package/esm/cli/audit.js +29 -33
  29. package/esm/cli/commands.js +7 -1
  30. package/esm/cli/doctor.d.ts +3 -0
  31. package/esm/cli/doctor.js +67 -0
  32. package/esm/cli/print-config.d.ts +3 -0
  33. package/esm/cli/print-config.js +46 -0
  34. package/esm/cli/shared.d.ts +13 -0
  35. package/esm/cli/shared.js +61 -0
  36. package/esm/commands/audit.d.ts +8 -0
  37. package/esm/commands/audit.js +20 -7
  38. package/esm/commands/doctor.d.ts +19 -0
  39. package/esm/commands/doctor.js +130 -0
  40. package/esm/config/loader.d.ts +21 -0
  41. package/esm/config/loader.js +71 -0
  42. package/esm/config/presets.d.ts +19 -0
  43. package/esm/config/presets.js +53 -0
  44. package/esm/config/resolve.d.ts +38 -0
  45. package/esm/config/resolve.js +125 -0
  46. package/esm/config/types.d.ts +58 -0
  47. package/esm/config/types.js +1 -0
  48. package/esm/index.d.ts +18 -4
  49. package/esm/index.js +10 -3
  50. package/esm/report/pretty.js +21 -7
  51. package/esm/rules/registry.d.ts +23 -0
  52. package/esm/rules/registry.js +105 -0
  53. package/esm/score/score.d.ts +29 -0
  54. package/esm/score/score.js +67 -0
  55. package/esm/types.d.ts +2 -0
  56. package/index.d.ts +18 -4
  57. package/index.js +42 -9
  58. package/package.json +6 -5
  59. package/report/pretty.js +21 -7
  60. package/rules/registry.d.ts +23 -0
  61. package/rules/registry.js +110 -0
  62. package/score/score.d.ts +29 -0
  63. package/score/score.js +72 -0
  64. 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.
@@ -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 client = buildClient(argv);
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 failOn = typeof argv['fail-on'] === 'string' ? argv['fail-on'] : undefined;
97
- if (failOn) {
98
- if (!(failOn in types_1.SEVERITY_ORDER)) {
99
- log.error(`Unknown --fail-on severity: ${failOn}`);
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, failOn))) {
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);
@@ -0,0 +1,3 @@
1
+ import { CLIOptions, Inquirerer, ParsedArgs } from 'inquirerer';
2
+ declare const _default: (argv: ParsedArgs, _prompter: Inquirerer, _options: CLIOptions) => Promise<void>;
3
+ export default _default;
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,3 @@
1
+ import { CLIOptions, Inquirerer, ParsedArgs } from 'inquirerer';
2
+ declare const _default: (argv: ParsedArgs, _prompter: Inquirerer, _options: CLIOptions) => Promise<void>;
3
+ export default _default;
@@ -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
+ };
@@ -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
+ }
@@ -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 anti_patterns_1 = require("../checks/anti-patterns");
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
- const findings = [];
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 (!options.skipAstChecks) {
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) {