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 +39 -1
- package/checks/indexes.d.ts +38 -0
- package/checks/indexes.js +136 -0
- package/checks/policy-index.d.ts +62 -0
- package/checks/policy-index.js +249 -0
- package/cli/audit.js +21 -0
- package/cli/commands.js +2 -0
- package/commands/audit.d.ts +6 -0
- package/commands/audit.js +70 -5
- package/config/resolve.d.ts +12 -3
- package/config/resolve.js +46 -3
- package/config/types.d.ts +29 -0
- package/esm/checks/indexes.d.ts +38 -0
- package/esm/checks/indexes.js +131 -0
- package/esm/checks/policy-index.d.ts +62 -0
- package/esm/checks/policy-index.js +243 -0
- package/esm/cli/audit.js +21 -0
- package/esm/cli/commands.js +2 -0
- package/esm/commands/audit.d.ts +6 -0
- package/esm/commands/audit.js +71 -6
- package/esm/config/resolve.d.ts +12 -3
- package/esm/config/resolve.js +46 -4
- package/esm/config/types.d.ts +29 -0
- package/esm/index.d.ts +8 -3
- package/esm/index.js +5 -2
- package/esm/pg/indexes.d.ts +53 -0
- package/esm/pg/indexes.js +123 -0
- package/esm/pg/proc.d.ts +2 -0
- package/esm/pg/proc.js +3 -1
- package/esm/report/pretty.js +49 -16
- package/esm/rules/registry.d.ts +10 -2
- package/esm/rules/registry.js +60 -0
- package/esm/types.d.ts +24 -1
- package/index.d.ts +8 -3
- package/index.js +15 -1
- package/package.json +6 -4
- package/pg/indexes.d.ts +53 -0
- package/pg/indexes.js +126 -0
- package/pg/proc.d.ts +2 -0
- package/pg/proc.js +3 -1
- package/report/pretty.js +49 -16
- package/rules/registry.d.ts +10 -2
- package/rules/registry.js +61 -0
- package/types.d.ts +24 -1
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
|
};
|
package/commands/audit.d.ts
CHANGED
|
@@ -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.
|