safegres 1.12.0 → 1.13.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 CHANGED
@@ -47,7 +47,7 @@ Pretty output prints the exposure line, score, and the exposed findings. Interna
47
47
  | A6 | info | fail-closed | coverage | UPDATE has `USING` but **no `WITH CHECK`** (row-smuggling surface) |
48
48
  | A7 | critical | fail-open | anti-pattern | Trivially-permissive **WRITE** policy (INSERT/UPDATE/DELETE/ALL with literal `true`) |
49
49
  | A8 | low | fail-open | anti-pattern | Trivially-permissive **SELECT** policy (`USING (true)` — confirm public-read is intended) |
50
- | P1 | high | neutral | anti-pattern | Policy body calls a **VOLATILE function** (per-row evaluation) |
50
+ | P1 | high | neutral | anti-pattern | Policy body calls a **VOLATILE function** (per-row evaluation) — *scored on the [perf axis](#performance-dimension---perf)* |
51
51
  | P5 | high | fail-open | anti-pattern | Policy body references **`session_user`** / `current_user` / `pg_has_role(...)` |
52
52
  | R1 | critical | fail-open | anti-pattern | An **untrusted role** (options: `{ roles: [...] }`) holds a write privilege |
53
53
  | R2 | high | fail-open | anti-pattern | A permissive write policy applies to an untrusted role or PUBLIC |
@@ -100,6 +100,44 @@ Some open reads are deliberate — pricing tables, reference data, a public user
100
100
  - 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.
101
101
  - `safegres doctor` warns about stale `public.read` patterns that no longer match any table.
102
102
 
103
+ ## Performance dimension (`--perf`)
104
+
105
+ A slow database is a different problem from an unsafe one, so safegres scores them separately: `safegres perf` (or `safegres audit --perf`) adds `report.perf` with its own findings, summary, and 0-100 score. It is **off by default** — a plain `audit` behaves exactly as before, and no perf finding ever touches the security score.
106
+
107
+ | Code | Severity | Category | Check |
108
+ | --- | --- | --- | --- |
109
+ | X1 | medium | index | **Foreign key with no covering index** — joins and cascading deletes seq-scan the child table |
110
+ | X2 | medium | index | **RLS policy filters on an unindexed column** — the security qual seq-scans on every query against the table |
111
+ | X3 | medium | index | **Policy casts or wraps its own column** (`tenant_id::text = …`, `lower(email) = …`) with no matching expression index |
112
+ | X4 | low | index | **Policy calls a non-LEAKPROOF function** — the qual can't be pushed below joins or subquery scans |
113
+ | X5 | low | index | **Redundant index** — an exact duplicate of, or a leading-column prefix of, another index |
114
+ | X6 | low | index | **No primary key** and no usable replica identity — rows cannot be addressed by updates, deletes, or logical replication |
115
+ | P1 | high | anti-pattern | Policy body calls a **VOLATILE function** — re-evaluated per row |
116
+ | P1b | medium | anti-pattern | Policy body calls a **STABLE function** in a per-row position |
117
+
118
+ Every check is pure catalog + AST analysis: deterministic, workload-free, and safe to run against an empty CI database. An index covers a foreign key only when its *leading* columns are the FK's columns and it covers every row — partial and expression indexes don't count, because the planner can't use them for the referential-integrity lookup. Constraint-backed, unique, partial, and expression indexes are never reported as redundant.
119
+
120
+ X2–X4 are the checks a generic index linter can't make, because they read the policy predicate. RLS quals are evaluated *before* user quals, on every candidate row, for every caller — so an unindexed or cast-wrapped policy column is a whole-table tax rather than a slow query. X2 requires the policy column to be the *leading* column of some index (a trailing position can't serve the qual alone); X3 looks for an expression index matching the exact wrapped shape; X4 skips built-ins, whose leakproofness is a property of the server rather than a schema choice.
121
+
122
+ ```bash
123
+ safegres perf --database mydb
124
+ safegres audit --database mydb --perf --fail-on-perf-grade B
125
+ ```
126
+
127
+ ```jsonc
128
+ {
129
+ "perf": {
130
+ "enabled": true,
131
+ "rules": { "X6": "off" }, // perf-dimension codes only
132
+ "ignore": ["app_public.audit_*"], // declared-intentional perf debt
133
+ "scoring": { "densityK": 0.17 }
134
+ },
135
+ "failOn": { "perfGrade": "B" }
136
+ }
137
+ ```
138
+
139
+ Tables matched by `perf.ignore` are acknowledged — reported as info, excluded from the perf score — the same way `public.read` works for open reads. Perf findings live in `report.findings` alongside the security ones (so `--fail-on <severity>` still sees them), but only they feed `report.perf.score`, and only security findings feed `report.score`.
140
+
103
141
  ## Call graph (`--call-graph`)
104
142
 
105
143
  RLS findings tell you what the *tables* allow. The call graph tells you what the *functions* reach: starting from the exposed entry points (functions the API roles can `EXECUTE`), safegres statically walks each body and lists every **trust boundary** on the way — unscored, because a public `SECURITY DEFINER` calling private functions is the intended pattern (that's how `sign_in` works). The output is a deterministic checklist for human review:
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Structural index-hygiene checks (perf dimension, `X*`).
3
+ *
4
+ * Everything here is pure catalog analysis — deterministic, workload-free,
5
+ * and safe to run in CI against an empty database.
6
+ */
7
+ import type { TableIndexSnapshot } from '../pg/indexes';
8
+ import type { Finding } from '../types';
9
+ /**
10
+ * X1: a foreign key with no index that can serve it.
11
+ *
12
+ * An index covers a FK when its *leading* columns are exactly the FK's
13
+ * columns (order among them is irrelevant for equality lookups) and it
14
+ * covers every row. Without one, `ON DELETE`/`ON UPDATE` checks on the
15
+ * referenced side and joins on the referencing side degrade to sequential
16
+ * scans.
17
+ *
18
+ * Partial and expression indexes do not count: the planner cannot rely on
19
+ * them for the referential-integrity lookup.
20
+ */
21
+ export declare function checkUnindexedForeignKeys(table: TableIndexSnapshot): Finding[];
22
+ /**
23
+ * X5: an index that another index already serves.
24
+ *
25
+ * Two shapes: an exact duplicate, or a non-unique index whose columns are a
26
+ * leading-column prefix of a wider index. Both cost write throughput and
27
+ * disk for nothing. Constraint-backed indexes (PK/UNIQUE/EXCLUDE) are never
28
+ * reported — dropping them would drop the constraint.
29
+ */
30
+ export declare function checkRedundantIndexes(table: TableIndexSnapshot): Finding[];
31
+ /**
32
+ * X6: no primary key and no usable replica identity.
33
+ *
34
+ * Rows cannot be addressed individually: updates and deletes have no cheap
35
+ * lookup path, logical replication and CDC cannot identify old rows, and
36
+ * realtime/change-feed features break.
37
+ */
38
+ export declare function checkMissingPrimaryKey(table: TableIndexSnapshot): Finding | null;
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ /**
3
+ * Structural index-hygiene checks (perf dimension, `X*`).
4
+ *
5
+ * Everything here is pure catalog analysis — deterministic, workload-free,
6
+ * and safe to run in CI against an empty database.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.checkUnindexedForeignKeys = checkUnindexedForeignKeys;
10
+ exports.checkRedundantIndexes = checkRedundantIndexes;
11
+ exports.checkMissingPrimaryKey = checkMissingPrimaryKey;
12
+ /**
13
+ * X1: a foreign key with no index that can serve it.
14
+ *
15
+ * An index covers a FK when its *leading* columns are exactly the FK's
16
+ * columns (order among them is irrelevant for equality lookups) and it
17
+ * covers every row. Without one, `ON DELETE`/`ON UPDATE` checks on the
18
+ * referenced side and joins on the referencing side degrade to sequential
19
+ * scans.
20
+ *
21
+ * Partial and expression indexes do not count: the planner cannot rely on
22
+ * them for the referential-integrity lookup.
23
+ */
24
+ function checkUnindexedForeignKeys(table) {
25
+ // Partitions inherit their parent's indexes; the parent carries the finding.
26
+ if (table.isPartition)
27
+ return [];
28
+ const findings = [];
29
+ for (const fk of table.foreignKeys) {
30
+ if (fk.columns.length === 0)
31
+ continue;
32
+ if (table.indexes.some((idx) => indexCoversColumns(idx, fk.columns)))
33
+ continue;
34
+ const cols = fk.columnNames.join(', ');
35
+ findings.push({
36
+ code: 'X1',
37
+ severity: 'medium',
38
+ category: 'index',
39
+ schema: table.schema,
40
+ table: table.name,
41
+ message: `Foreign key ${fk.name} on ${table.schema}.${table.name} (${cols}) has no covering index`,
42
+ hint: `CREATE INDEX ON ${table.schema}.${table.name} (${cols}); without it, deletes/updates on ${fk.references} and joins across this key scan the whole table.`,
43
+ context: { constraint: fk.name, columns: fk.columnNames, references: fk.references }
44
+ });
45
+ }
46
+ return findings;
47
+ }
48
+ /**
49
+ * X5: an index that another index already serves.
50
+ *
51
+ * Two shapes: an exact duplicate, or a non-unique index whose columns are a
52
+ * leading-column prefix of a wider index. Both cost write throughput and
53
+ * disk for nothing. Constraint-backed indexes (PK/UNIQUE/EXCLUDE) are never
54
+ * reported — dropping them would drop the constraint.
55
+ */
56
+ function checkRedundantIndexes(table) {
57
+ const findings = [];
58
+ const candidates = table.indexes.filter((i) => !i.primary && !i.constraint && !i.partial && !i.expression && !i.unique);
59
+ for (const idx of candidates) {
60
+ const covering = table.indexes.find((other) => {
61
+ if (other.name === idx.name)
62
+ return false;
63
+ if (other.partial || other.expression)
64
+ return false;
65
+ if (other.method !== idx.method)
66
+ return false;
67
+ if (!isPrefix(idx.columns, other.columns))
68
+ return false;
69
+ // Exact duplicates: report only one of the pair, deterministically.
70
+ if (idx.columns.length === other.columns.length && idx.name < other.name)
71
+ return false;
72
+ return true;
73
+ });
74
+ if (!covering)
75
+ continue;
76
+ const duplicate = idx.columns.length === covering.columns.length;
77
+ findings.push({
78
+ code: 'X5',
79
+ severity: 'low',
80
+ category: 'index',
81
+ schema: table.schema,
82
+ table: table.name,
83
+ message: `Index ${idx.name} (${idx.columnNames.join(', ')}) is ${duplicate ? 'a duplicate of' : 'a leading-column prefix of'} `
84
+ + `${covering.name} (${covering.columnNames.join(', ')})`,
85
+ hint: `DROP INDEX ${table.schema}.${idx.name}; every query it serves is served by ${covering.name}.`,
86
+ context: { index: idx.name, coveredBy: covering.name, duplicate }
87
+ });
88
+ }
89
+ return findings;
90
+ }
91
+ /**
92
+ * X6: no primary key and no usable replica identity.
93
+ *
94
+ * Rows cannot be addressed individually: updates and deletes have no cheap
95
+ * lookup path, logical replication and CDC cannot identify old rows, and
96
+ * realtime/change-feed features break.
97
+ */
98
+ function checkMissingPrimaryKey(table) {
99
+ if (table.isPartition)
100
+ return null;
101
+ if (table.hasPrimaryKey)
102
+ return null;
103
+ // `f` (FULL) and `i` (index) still give logical decoding an identity.
104
+ if (table.replicaIdentity === 'f' || table.replicaIdentity === 'i')
105
+ return null;
106
+ return {
107
+ code: 'X6',
108
+ severity: 'low',
109
+ category: 'index',
110
+ schema: table.schema,
111
+ table: table.name,
112
+ message: `${table.schema}.${table.name} has no primary key`,
113
+ hint: 'Add a primary key, or set REPLICA IDENTITY FULL / USING INDEX, so rows can be addressed by updates, deletes, and logical replication.',
114
+ context: { replicaIdentity: table.replicaIdentity }
115
+ };
116
+ }
117
+ /**
118
+ * True when `index` can serve an equality lookup on exactly `columns`:
119
+ * its leading columns are that same set, and it covers every row.
120
+ */
121
+ function indexCoversColumns(index, columns) {
122
+ if (index.partial || index.expression)
123
+ return false;
124
+ if (index.method !== 'btree')
125
+ return false;
126
+ if (index.columns.length < columns.length)
127
+ return false;
128
+ const leading = new Set(index.columns.slice(0, columns.length));
129
+ return columns.every((c) => leading.has(c));
130
+ }
131
+ /** True when `a` is a leading-column prefix of `b` (`a` may equal `b`). */
132
+ function isPrefix(a, b) {
133
+ if (a.length > b.length)
134
+ return false;
135
+ return a.every((col, i) => b[i] === col);
136
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Policy-aware performance checks (X2/X3/X4).
3
+ *
4
+ * These are the checks a generic index linter can't make: they read the RLS
5
+ * policy predicate and ask whether the *security* qual — the one Postgres
6
+ * evaluates first, on every candidate row, for every query against the table —
7
+ * can actually use an index.
8
+ *
9
+ * All three are static (catalog + AST only); no runtime statistics, no EXPLAIN.
10
+ */
11
+ import type { PgAstNode } from '../ast/parse';
12
+ import type { TableIndexSnapshot } from '../pg/indexes';
13
+ import type { TableSnapshot } from '../pg/introspect';
14
+ import type { ProcVolatility } from '../pg/proc';
15
+ import type { Finding } from '../types';
16
+ export type PolicyClause = 'USING' | 'WITH CHECK';
17
+ /** A column of the policy's own table, as referenced by the policy predicate. */
18
+ export interface PredicateColumn {
19
+ column: string;
20
+ clause: PolicyClause;
21
+ /**
22
+ * The cast type or function name wrapping the column reference, if any.
23
+ * `undefined` means the column is compared directly (index-usable).
24
+ */
25
+ wrappedBy?: string;
26
+ /** True when `wrappedBy` is a cast rather than a function call. */
27
+ isCast?: boolean;
28
+ }
29
+ /**
30
+ * Collect the policy's own columns that participate in a comparison, along
31
+ * with any cast/function wrapping them.
32
+ *
33
+ * Only single-part column references (`tenant_id`) and references qualified
34
+ * with the table's own name/alias (`posts.tenant_id`) count — a column from a
35
+ * subquery's table belongs to that table's policies, not this one.
36
+ */
37
+ export declare function collectPredicateColumns(expr: PgAstNode, clause: PolicyClause, tableName: string): PredicateColumn[];
38
+ /**
39
+ * X2: a policy predicate compares a column that has no index with that column
40
+ * in the leading position, so the security qual degrades to a sequential scan
41
+ * on every query touching the table.
42
+ */
43
+ export declare function checkUnindexedPolicyColumns(table: TableSnapshot, indexes: TableIndexSnapshot, columns: Map<string, PredicateColumn[]>): Finding[];
44
+ /**
45
+ * X3: the policy wraps its own column in a cast or function call
46
+ * (`tenant_id::text = ...`, `lower(email) = ...`). A plain b-tree index on the
47
+ * bare column can't serve that comparison — it needs a matching expression
48
+ * index, or the cast should move to the other side of the operator.
49
+ */
50
+ export declare function checkPolicyColumnCasts(table: TableSnapshot, indexes: TableIndexSnapshot, columns: Map<string, PredicateColumn[]>): Finding[];
51
+ /**
52
+ * X4: the policy predicate calls a non-LEAKPROOF function.
53
+ *
54
+ * Postgres assigns security quals a lower security level than user quals and
55
+ * will not push a non-leakproof qual below a join or subquery scan. The
56
+ * practical effect is that the policy filter is applied later and to more rows
57
+ * than necessary, and index scans on the underlying relation are given up.
58
+ *
59
+ * System functions are exempt: their leakproofness is a fixed property of the
60
+ * server, not something the schema author chose.
61
+ */
62
+ export declare function checkNonLeakproofPolicyFunctions(table: TableSnapshot, expr: PgAstNode, volatility: Map<string, ProcVolatility>, policyName: string): Finding[];
@@ -0,0 +1,249 @@
1
+ "use strict";
2
+ /**
3
+ * Policy-aware performance checks (X2/X3/X4).
4
+ *
5
+ * These are the checks a generic index linter can't make: they read the RLS
6
+ * policy predicate and ask whether the *security* qual — the one Postgres
7
+ * evaluates first, on every candidate row, for every query against the table —
8
+ * can actually use an index.
9
+ *
10
+ * All three are static (catalog + AST only); no runtime statistics, no EXPLAIN.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.collectPredicateColumns = collectPredicateColumns;
14
+ exports.checkUnindexedPolicyColumns = checkUnindexedPolicyColumns;
15
+ exports.checkPolicyColumnCasts = checkPolicyColumnCasts;
16
+ exports.checkNonLeakproofPolicyFunctions = checkNonLeakproofPolicyFunctions;
17
+ const traverse_1 = require("@pgsql/traverse");
18
+ const walk_1 = require("../ast/walk");
19
+ /**
20
+ * Comparison-ish nodes worth indexing for. We deliberately ignore `IS NULL`,
21
+ * boolean tests and the like: an index rarely helps there, so flagging them
22
+ * would be noise.
23
+ */
24
+ const COMPARISON_TAGS = new Set(['A_Expr', 'SubLink', 'NullTest', 'ScalarArrayOpExpr']);
25
+ /**
26
+ * Collect the policy's own columns that participate in a comparison, along
27
+ * with any cast/function wrapping them.
28
+ *
29
+ * Only single-part column references (`tenant_id`) and references qualified
30
+ * with the table's own name/alias (`posts.tenant_id`) count — a column from a
31
+ * subquery's table belongs to that table's policies, not this one.
32
+ */
33
+ function collectPredicateColumns(expr, clause, tableName) {
34
+ const out = [];
35
+ const seen = new Set();
36
+ (0, traverse_1.walk)(expr, (path) => {
37
+ if (path.tag !== 'ColumnRef')
38
+ return;
39
+ const parts = (0, walk_1.columnRefPath)(path.node);
40
+ const column = ownColumnName(parts, tableName);
41
+ if (!column)
42
+ return;
43
+ const { wrappedBy, isCast } = wrappingOf(path);
44
+ if (!inComparison(path))
45
+ return;
46
+ const key = `${clause}:${column}:${wrappedBy ?? ''}`;
47
+ if (seen.has(key))
48
+ return;
49
+ seen.add(key);
50
+ out.push({ column, clause, wrappedBy, isCast });
51
+ });
52
+ return out;
53
+ }
54
+ /**
55
+ * X2: a policy predicate compares a column that has no index with that column
56
+ * in the leading position, so the security qual degrades to a sequential scan
57
+ * on every query touching the table.
58
+ */
59
+ function checkUnindexedPolicyColumns(table, indexes, columns) {
60
+ const out = [];
61
+ const leading = leadingColumns(indexes);
62
+ for (const [policyName, cols] of columns) {
63
+ const reported = new Set();
64
+ for (const col of cols) {
65
+ // A wrapped column is X3's business — the raw column being indexed
66
+ // wouldn't help the wrapped comparison anyway.
67
+ if (col.wrappedBy)
68
+ continue;
69
+ if (leading.has(col.column))
70
+ continue;
71
+ if (reported.has(col.column))
72
+ continue;
73
+ reported.add(col.column);
74
+ out.push({
75
+ code: 'X2',
76
+ severity: 'medium',
77
+ category: 'index',
78
+ schema: table.schema,
79
+ table: table.name,
80
+ policy: policyName,
81
+ message: `Policy "${policyName}" on ${table.schema}.${table.name} filters by ${col.column}, which is not the leading column of any index`,
82
+ hint: `RLS quals run before user quals on every row the planner considers, so an unindexed policy column forces a sequential scan for all callers. CREATE INDEX ON ${table.schema}.${table.name} (${col.column}).`,
83
+ context: { column: col.column, clause: col.clause }
84
+ });
85
+ }
86
+ }
87
+ return out;
88
+ }
89
+ /**
90
+ * X3: the policy wraps its own column in a cast or function call
91
+ * (`tenant_id::text = ...`, `lower(email) = ...`). A plain b-tree index on the
92
+ * bare column can't serve that comparison — it needs a matching expression
93
+ * index, or the cast should move to the other side of the operator.
94
+ */
95
+ function checkPolicyColumnCasts(table, indexes, columns) {
96
+ const out = [];
97
+ const expressionDefs = indexes.indexes
98
+ .filter((i) => i.expression)
99
+ .map((i) => i.definition.toLowerCase());
100
+ for (const [policyName, cols] of columns) {
101
+ const reported = new Set();
102
+ for (const col of cols) {
103
+ if (!col.wrappedBy)
104
+ continue;
105
+ const key = `${col.column}:${col.wrappedBy}`;
106
+ if (reported.has(key))
107
+ continue;
108
+ reported.add(key);
109
+ if (expressionDefs.some((def) => defCovers(def, col)))
110
+ continue;
111
+ const shape = col.isCast
112
+ ? `${col.column}::${col.wrappedBy}`
113
+ : `${col.wrappedBy}(${col.column})`;
114
+ out.push({
115
+ code: 'X3',
116
+ severity: 'medium',
117
+ category: 'index',
118
+ schema: table.schema,
119
+ table: table.name,
120
+ policy: policyName,
121
+ message: `Policy "${policyName}" on ${table.schema}.${table.name} compares ${shape} — a plain index on ${col.column} cannot serve this`,
122
+ hint: col.isCast
123
+ ? `Cast the other side of the comparison instead, or add a matching expression index: CREATE INDEX ON ${table.schema}.${table.name} ((${shape})).`
124
+ : `Add a matching expression index: CREATE INDEX ON ${table.schema}.${table.name} ((${shape})).`,
125
+ context: { column: col.column, expression: shape, clause: col.clause }
126
+ });
127
+ }
128
+ }
129
+ return out;
130
+ }
131
+ /**
132
+ * X4: the policy predicate calls a non-LEAKPROOF function.
133
+ *
134
+ * Postgres assigns security quals a lower security level than user quals and
135
+ * will not push a non-leakproof qual below a join or subquery scan. The
136
+ * practical effect is that the policy filter is applied later and to more rows
137
+ * than necessary, and index scans on the underlying relation are given up.
138
+ *
139
+ * System functions are exempt: their leakproofness is a fixed property of the
140
+ * server, not something the schema author chose.
141
+ */
142
+ function checkNonLeakproofPolicyFunctions(table, expr, volatility, policyName) {
143
+ const out = [];
144
+ const seen = new Set();
145
+ (0, traverse_1.walk)(expr, (path) => {
146
+ if (path.tag !== 'FuncCall')
147
+ return;
148
+ const qualified = (0, walk_1.funcNameQualified)(path.node);
149
+ const bare = qualified.split('.').pop() ?? qualified;
150
+ const info = volatility.get(qualified) ?? volatility.get(bare);
151
+ if (!info)
152
+ return;
153
+ if (info.isSystem)
154
+ return;
155
+ if (info.isLeakproof)
156
+ return;
157
+ if (seen.has(info.name))
158
+ return;
159
+ seen.add(info.name);
160
+ out.push({
161
+ code: 'X4',
162
+ severity: 'low',
163
+ category: 'index',
164
+ schema: table.schema,
165
+ table: table.name,
166
+ policy: policyName,
167
+ message: `Policy "${policyName}" on ${table.schema}.${table.name} calls non-LEAKPROOF function ${info.name}`,
168
+ hint: 'Non-leakproof quals cannot be pushed below joins or subquery scans, so the policy filter is applied late and to more rows than necessary. If the function provably leaks nothing about its arguments (no error messages containing values), mark it LEAKPROOF.',
169
+ context: { function: info.name }
170
+ });
171
+ });
172
+ return out;
173
+ }
174
+ /** Columns that sit in the leading position of at least one index. */
175
+ function leadingColumns(indexes) {
176
+ const out = new Set();
177
+ for (const index of indexes.indexes) {
178
+ const first = index.columnNames[0];
179
+ if (first)
180
+ out.add(first);
181
+ }
182
+ return out;
183
+ }
184
+ /**
185
+ * Resolve a column reference to a bare column name on `tableName`, or null if
186
+ * it belongs to another relation (or is `*`).
187
+ */
188
+ function ownColumnName(parts, tableName) {
189
+ if (parts.length === 1)
190
+ return parts[0] === '*' ? null : parts[0];
191
+ if (parts.length === 2 && parts[0] === tableName)
192
+ return parts[1] === '*' ? null : parts[1];
193
+ return null;
194
+ }
195
+ /** Nearest enclosing cast or function call, if the column is wrapped by one. */
196
+ function wrappingOf(path) {
197
+ const parent = path.parent;
198
+ if (!parent)
199
+ return {};
200
+ if (parent.tag === 'TypeCast') {
201
+ const typeName = castTypeName(parent.node);
202
+ return typeName ? { wrappedBy: typeName, isCast: true } : {};
203
+ }
204
+ if (parent.tag === 'FuncCall') {
205
+ return { wrappedBy: (0, walk_1.funcNameQualified)(parent.node), isCast: false };
206
+ }
207
+ return {};
208
+ }
209
+ /** True when the column participates in a comparison rather than, say, a projection. */
210
+ function inComparison(path) {
211
+ let cursor = path.parent;
212
+ // Allow one or two wrapping levels (cast, function call) between the column
213
+ // and the comparison it feeds.
214
+ for (let depth = 0; cursor && depth < 3; depth++) {
215
+ if (COMPARISON_TAGS.has(cursor.tag))
216
+ return true;
217
+ cursor = cursor.parent;
218
+ }
219
+ return false;
220
+ }
221
+ function castTypeName(typeCast) {
222
+ const typeName = typeCast.typeName;
223
+ if (!typeName)
224
+ return null;
225
+ const names = typeName.names;
226
+ if (!Array.isArray(names) || names.length === 0)
227
+ return null;
228
+ const last = names[names.length - 1];
229
+ const str = (last.String ?? last.string);
230
+ if (!str)
231
+ return null;
232
+ const value = String((str.sval ?? str.str) ?? '');
233
+ return value.length > 0 ? value : null;
234
+ }
235
+ /**
236
+ * Heuristic match of an expression index definition against a wrapped column.
237
+ * `pg_get_indexdef` renders casts as `((col)::text)` and calls as `lower(col)`,
238
+ * so requiring both tokens is enough to avoid the common false positive
239
+ * without parsing the definition back into an AST.
240
+ */
241
+ function defCovers(definitionLower, col) {
242
+ const wrapper = (col.wrappedBy ?? '').toLowerCase();
243
+ const column = col.column.toLowerCase();
244
+ if (!wrapper)
245
+ return false;
246
+ if (col.isCast)
247
+ return definitionLower.includes(`::${wrapper}`) && definitionLower.includes(column);
248
+ return definitionLower.includes(`${wrapper}(`) && definitionLower.includes(column);
249
+ }
package/cli/audit.js CHANGED
@@ -88,6 +88,12 @@ Exposure (what the score is computed against):
88
88
  them become unscored internal advisories
89
89
  --exposed-only Hide internal (non-exposed) findings from output
90
90
 
91
+ Performance dimension (optional; scored separately from security):
92
+ --perf Also audit index hygiene (X1/X5/X6) and score it on
93
+ its own 0-100 axis (report.perf)
94
+ --fail-on-perf-score <n> Exit non-zero if the perf score is below n (0-100)
95
+ --fail-on-perf-grade <g> Exit non-zero if the perf grade is below g (A+|A|B|C|D)
96
+
91
97
  Call graph (unscored; human review):
92
98
  --call-graph Analyze the functions reachable from the exposed entry
93
99
  points and list trust boundaries: SECURITY DEFINER hops,
@@ -131,6 +137,7 @@ exports.default = async (argv, _prompter, _options) => {
131
137
  includeRoles: (0, shared_1.csvList)(argv.roles),
132
138
  excludeRoles: (0, shared_1.csvList)(argv['exclude-roles']),
133
139
  skipAstChecks: argv['skip-ast'] === true,
140
+ perf: argv.perf === true ? true : undefined,
134
141
  callGraph: argv['call-graph'] === true
135
142
  || typeof argv.baseline === 'string'
136
143
  || typeof argv['write-baseline'] === 'string',
@@ -215,6 +222,20 @@ exports.default = async (argv, _prompter, _options) => {
215
222
  log.error(`grade ${report.score.grade} is below --fail-on-grade ${failOnGrade}`);
216
223
  process.exit(1);
217
224
  }
225
+ const failOnPerfScore = typeof argv['fail-on-perf-score'] === 'number'
226
+ ? argv['fail-on-perf-score']
227
+ : config.failOn?.perfScore;
228
+ if (failOnPerfScore != null && report.perf && report.perf.score.value < failOnPerfScore) {
229
+ log.error(`perf score ${report.perf.score.value} is below --fail-on-perf-score ${failOnPerfScore}`);
230
+ process.exit(1);
231
+ }
232
+ const failOnPerfGrade = typeof argv['fail-on-perf-grade'] === 'string'
233
+ ? argv['fail-on-perf-grade']
234
+ : config.failOn?.perfGrade;
235
+ if (failOnPerfGrade && report.perf && !(0, score_1.meetsGrade)(report.perf.score.grade, failOnPerfGrade)) {
236
+ log.error(`perf grade ${report.perf.score.grade} is below --fail-on-perf-grade ${failOnPerfGrade}`);
237
+ process.exit(1);
238
+ }
218
239
  if (argv['fail-on-new-boundaries'] === true
219
240
  && report.callGraphDiff
220
241
  && report.callGraphDiff.added.length > 0) {
package/cli/commands.js CHANGED
@@ -18,6 +18,7 @@ Usage:
18
18
 
19
19
  Commands:
20
20
  audit Audit grants, RLS flags, policy coverage, and anti-patterns
21
+ perf Audit index hygiene and policy cost (audit --perf)
21
22
  doctor Diagnose environment, connection, and configuration
22
23
  print-config Show the resolved effective configuration
23
24
  help Show this help message
@@ -26,6 +27,7 @@ Run \`safegres <command> --help\` for command-specific options.
26
27
  `;
27
28
  const commandMap = {
28
29
  audit: audit_1.default,
30
+ perf: (argv, prompter, options) => (0, audit_1.default)({ ...argv, perf: true }, prompter, options),
29
31
  doctor: doctor_1.default,
30
32
  'print-config': print_config_1.default
31
33
  };
@@ -27,6 +27,12 @@ export interface AuditOptions extends IntrospectOptions {
27
27
  * exposed entry points. Adds `report.callGraph`.
28
28
  */
29
29
  callGraph?: boolean;
30
+ /**
31
+ * Collect and score the performance dimension: index-hygiene rules (`X*`)
32
+ * on top of the policy-cost rules (P1/P1b) the audit already runs. Adds
33
+ * `report.perf` with its own score. Overrides `config.perf.enabled`.
34
+ */
35
+ perf?: boolean;
30
36
  /**
31
37
  * Merged safegres configuration (rules, overrides, scoring). Rule settings
32
38
  * filter and retune findings; scoring settings drive the report score.