safegres 1.13.0 → 1.14.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 +20 -0
- package/cli/audit.js +35 -3
- package/esm/cli/audit.js +35 -3
- package/esm/index.d.ts +2 -0
- package/esm/index.js +1 -0
- package/esm/perf/baseline.d.ts +40 -0
- package/esm/perf/baseline.js +83 -0
- package/esm/report/pretty.js +16 -0
- package/esm/types.d.ts +5 -0
- package/index.d.ts +2 -0
- package/index.js +10 -2
- package/package.json +4 -4
- package/perf/baseline.d.ts +40 -0
- package/perf/baseline.js +92 -0
- package/report/pretty.js +16 -0
- package/types.d.ts +5 -0
package/README.md
CHANGED
|
@@ -138,6 +138,26 @@ safegres audit --database mydb --perf --fail-on-perf-grade B
|
|
|
138
138
|
|
|
139
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
140
|
|
|
141
|
+
### Perf baseline (the ratchet)
|
|
142
|
+
|
|
143
|
+
An established schema will not reach a clean perf report in one pass — but it can refuse to get worse. Commit today's findings as accepted debt, then gate CI on findings that are *not* in the baseline:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
safegres audit --write-perf-baseline .safegres-perf.json # snapshot (implies --perf)
|
|
147
|
+
safegres audit --perf-baseline .safegres-perf.json # diff: new vs accepted vs fixed
|
|
148
|
+
safegres audit --perf-baseline .safegres-perf.json --fail-on-new-perf # gate: exit 1 on new debt
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
performance vs baseline:
|
|
153
|
+
|
|
154
|
+
1 new perf finding since the baseline:
|
|
155
|
+
[medium] X1 app_public.comments — foreign key with no covering index
|
|
156
|
+
(14 accepted, 2 fixed)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Entries are identified by `code` + relation + policy + subject (the constraint, index, expression, column, or function the finding is about), so rewording a message or retuning a severity between safegres versions never invalidates a committed baseline, and two findings of the same code on the same table stay distinct. Findings that disappear are reported as fixed — re-run `--write-perf-baseline` to lock the win in and stop them regressing silently. The diff is also carried in JSON output as `perf.diff`, and available to library callers as `diffPerf(findings, baseline)`.
|
|
160
|
+
|
|
141
161
|
## Call graph (`--call-graph`)
|
|
142
162
|
|
|
143
163
|
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:
|
package/cli/audit.js
CHANGED
|
@@ -38,6 +38,7 @@ const fs = __importStar(require("fs"));
|
|
|
38
38
|
const baseline_1 = require("../callgraph/baseline");
|
|
39
39
|
const audit_1 = require("../commands/audit");
|
|
40
40
|
const loader_1 = require("../config/loader");
|
|
41
|
+
const baseline_2 = require("../perf/baseline");
|
|
41
42
|
const json_1 = require("../report/json");
|
|
42
43
|
const pretty_1 = require("../report/pretty");
|
|
43
44
|
const score_1 = require("../score/score");
|
|
@@ -89,10 +90,17 @@ Exposure (what the score is computed against):
|
|
|
89
90
|
--exposed-only Hide internal (non-exposed) findings from output
|
|
90
91
|
|
|
91
92
|
Performance dimension (optional; scored separately from security):
|
|
92
|
-
--perf Also audit index hygiene (X1/X5/X6)
|
|
93
|
-
|
|
93
|
+
--perf Also audit index hygiene (X1/X5/X6), policy-aware
|
|
94
|
+
index coverage (X2/X3/X4) and policy cost (P1/P1b),
|
|
95
|
+
scored on its own 0-100 axis (report.perf)
|
|
94
96
|
--fail-on-perf-score <n> Exit non-zero if the perf score is below n (0-100)
|
|
95
97
|
--fail-on-perf-grade <g> Exit non-zero if the perf grade is below g (A+|A|B|C|D)
|
|
98
|
+
--write-perf-baseline <file>
|
|
99
|
+
Snapshot today's perf findings to <file> as accepted
|
|
100
|
+
debt (implies --perf)
|
|
101
|
+
--perf-baseline <file> Diff perf findings against a committed baseline and
|
|
102
|
+
report only NEW debt (implies --perf)
|
|
103
|
+
--fail-on-new-perf Exit non-zero when --perf-baseline finds new debt
|
|
96
104
|
|
|
97
105
|
Call graph (unscored; human review):
|
|
98
106
|
--call-graph Analyze the functions reachable from the exposed entry
|
|
@@ -137,7 +145,11 @@ exports.default = async (argv, _prompter, _options) => {
|
|
|
137
145
|
includeRoles: (0, shared_1.csvList)(argv.roles),
|
|
138
146
|
excludeRoles: (0, shared_1.csvList)(argv['exclude-roles']),
|
|
139
147
|
skipAstChecks: argv['skip-ast'] === true,
|
|
140
|
-
perf: argv.perf === true
|
|
148
|
+
perf: argv.perf === true
|
|
149
|
+
|| typeof argv['perf-baseline'] === 'string'
|
|
150
|
+
|| typeof argv['write-perf-baseline'] === 'string'
|
|
151
|
+
? true
|
|
152
|
+
: undefined,
|
|
141
153
|
callGraph: argv['call-graph'] === true
|
|
142
154
|
|| typeof argv.baseline === 'string'
|
|
143
155
|
|| typeof argv['write-baseline'] === 'string',
|
|
@@ -165,6 +177,21 @@ exports.default = async (argv, _prompter, _options) => {
|
|
|
165
177
|
fs.writeFileSync(argv['write-baseline'], (0, baseline_1.serializeBaseline)((0, baseline_1.toBaseline)(report.callGraph)));
|
|
166
178
|
log.info(`wrote call-graph baseline: ${argv['write-baseline']}`);
|
|
167
179
|
}
|
|
180
|
+
if (typeof argv['write-perf-baseline'] === 'string' && report.perf) {
|
|
181
|
+
fs.writeFileSync(argv['write-perf-baseline'], (0, baseline_2.serializePerfBaseline)((0, baseline_2.toPerfBaseline)(report.perf.findings)));
|
|
182
|
+
log.info(`wrote perf baseline: ${argv['write-perf-baseline']}`);
|
|
183
|
+
}
|
|
184
|
+
if (typeof argv['perf-baseline'] === 'string' && report.perf) {
|
|
185
|
+
let raw;
|
|
186
|
+
try {
|
|
187
|
+
raw = fs.readFileSync(argv['perf-baseline'], 'utf8');
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
log.error(`cannot read --perf-baseline file: ${argv['perf-baseline']} (create one with --write-perf-baseline)`);
|
|
191
|
+
process.exit(2);
|
|
192
|
+
}
|
|
193
|
+
report.perf.diff = (0, baseline_2.diffPerf)(report.perf.findings, (0, baseline_2.parsePerfBaseline)(raw));
|
|
194
|
+
}
|
|
168
195
|
if (typeof argv.baseline === 'string' && report.callGraph) {
|
|
169
196
|
let raw;
|
|
170
197
|
try {
|
|
@@ -236,6 +263,11 @@ exports.default = async (argv, _prompter, _options) => {
|
|
|
236
263
|
log.error(`perf grade ${report.perf.score.grade} is below --fail-on-perf-grade ${failOnPerfGrade}`);
|
|
237
264
|
process.exit(1);
|
|
238
265
|
}
|
|
266
|
+
if (argv['fail-on-new-perf'] === true && report.perf?.diff && report.perf.diff.added.length > 0) {
|
|
267
|
+
const count = report.perf.diff.added.length;
|
|
268
|
+
log.error(`${count} new performance finding${count === 1 ? '' : 's'} since the perf baseline — fix them, or re-baseline with --write-perf-baseline to accept`);
|
|
269
|
+
process.exit(1);
|
|
270
|
+
}
|
|
239
271
|
if (argv['fail-on-new-boundaries'] === true
|
|
240
272
|
&& report.callGraphDiff
|
|
241
273
|
&& report.callGraphDiff.added.length > 0) {
|
package/esm/cli/audit.js
CHANGED
|
@@ -3,6 +3,7 @@ import * as fs from 'fs';
|
|
|
3
3
|
import { diffCallGraph, parseBaseline, serializeBaseline, toBaseline } from '../callgraph/baseline';
|
|
4
4
|
import { audit } from '../commands/audit';
|
|
5
5
|
import { loadConfig } from '../config/loader';
|
|
6
|
+
import { diffPerf, parsePerfBaseline, serializePerfBaseline, toPerfBaseline } from '../perf/baseline';
|
|
6
7
|
import { renderJson } from '../report/json';
|
|
7
8
|
import { renderPretty } from '../report/pretty';
|
|
8
9
|
import { meetsGrade } from '../score/score';
|
|
@@ -54,10 +55,17 @@ Exposure (what the score is computed against):
|
|
|
54
55
|
--exposed-only Hide internal (non-exposed) findings from output
|
|
55
56
|
|
|
56
57
|
Performance dimension (optional; scored separately from security):
|
|
57
|
-
--perf Also audit index hygiene (X1/X5/X6)
|
|
58
|
-
|
|
58
|
+
--perf Also audit index hygiene (X1/X5/X6), policy-aware
|
|
59
|
+
index coverage (X2/X3/X4) and policy cost (P1/P1b),
|
|
60
|
+
scored on its own 0-100 axis (report.perf)
|
|
59
61
|
--fail-on-perf-score <n> Exit non-zero if the perf score is below n (0-100)
|
|
60
62
|
--fail-on-perf-grade <g> Exit non-zero if the perf grade is below g (A+|A|B|C|D)
|
|
63
|
+
--write-perf-baseline <file>
|
|
64
|
+
Snapshot today's perf findings to <file> as accepted
|
|
65
|
+
debt (implies --perf)
|
|
66
|
+
--perf-baseline <file> Diff perf findings against a committed baseline and
|
|
67
|
+
report only NEW debt (implies --perf)
|
|
68
|
+
--fail-on-new-perf Exit non-zero when --perf-baseline finds new debt
|
|
61
69
|
|
|
62
70
|
Call graph (unscored; human review):
|
|
63
71
|
--call-graph Analyze the functions reachable from the exposed entry
|
|
@@ -102,7 +110,11 @@ export default async (argv, _prompter, _options) => {
|
|
|
102
110
|
includeRoles: csvList(argv.roles),
|
|
103
111
|
excludeRoles: csvList(argv['exclude-roles']),
|
|
104
112
|
skipAstChecks: argv['skip-ast'] === true,
|
|
105
|
-
perf: argv.perf === true
|
|
113
|
+
perf: argv.perf === true
|
|
114
|
+
|| typeof argv['perf-baseline'] === 'string'
|
|
115
|
+
|| typeof argv['write-perf-baseline'] === 'string'
|
|
116
|
+
? true
|
|
117
|
+
: undefined,
|
|
106
118
|
callGraph: argv['call-graph'] === true
|
|
107
119
|
|| typeof argv.baseline === 'string'
|
|
108
120
|
|| typeof argv['write-baseline'] === 'string',
|
|
@@ -130,6 +142,21 @@ export default async (argv, _prompter, _options) => {
|
|
|
130
142
|
fs.writeFileSync(argv['write-baseline'], serializeBaseline(toBaseline(report.callGraph)));
|
|
131
143
|
log.info(`wrote call-graph baseline: ${argv['write-baseline']}`);
|
|
132
144
|
}
|
|
145
|
+
if (typeof argv['write-perf-baseline'] === 'string' && report.perf) {
|
|
146
|
+
fs.writeFileSync(argv['write-perf-baseline'], serializePerfBaseline(toPerfBaseline(report.perf.findings)));
|
|
147
|
+
log.info(`wrote perf baseline: ${argv['write-perf-baseline']}`);
|
|
148
|
+
}
|
|
149
|
+
if (typeof argv['perf-baseline'] === 'string' && report.perf) {
|
|
150
|
+
let raw;
|
|
151
|
+
try {
|
|
152
|
+
raw = fs.readFileSync(argv['perf-baseline'], 'utf8');
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
log.error(`cannot read --perf-baseline file: ${argv['perf-baseline']} (create one with --write-perf-baseline)`);
|
|
156
|
+
process.exit(2);
|
|
157
|
+
}
|
|
158
|
+
report.perf.diff = diffPerf(report.perf.findings, parsePerfBaseline(raw));
|
|
159
|
+
}
|
|
133
160
|
if (typeof argv.baseline === 'string' && report.callGraph) {
|
|
134
161
|
let raw;
|
|
135
162
|
try {
|
|
@@ -201,6 +228,11 @@ export default async (argv, _prompter, _options) => {
|
|
|
201
228
|
log.error(`perf grade ${report.perf.score.grade} is below --fail-on-perf-grade ${failOnPerfGrade}`);
|
|
202
229
|
process.exit(1);
|
|
203
230
|
}
|
|
231
|
+
if (argv['fail-on-new-perf'] === true && report.perf?.diff && report.perf.diff.added.length > 0) {
|
|
232
|
+
const count = report.perf.diff.added.length;
|
|
233
|
+
log.error(`${count} new performance finding${count === 1 ? '' : 's'} since the perf baseline — fix them, or re-baseline with --write-perf-baseline to accept`);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
204
236
|
if (argv['fail-on-new-boundaries'] === true
|
|
205
237
|
&& report.callGraphDiff
|
|
206
238
|
&& report.callGraphDiff.added.length > 0) {
|
package/esm/index.d.ts
CHANGED
|
@@ -33,6 +33,8 @@ export type { ForeignKeyInfo, IndexInfo, TableIndexSnapshot } from './pg/indexes
|
|
|
33
33
|
export { introspectIndexes } from './pg/indexes';
|
|
34
34
|
export { type IntrospectOptions, introspectTables, type PgPrivilege, type PolicyCmd, type PolicyInfo, type QueryExecutor, type TableSnapshot } from './pg/introspect';
|
|
35
35
|
export { listAuditableRoles, resolveRoles } from './pg/roles';
|
|
36
|
+
export type { BaselineFinding, PerfBaseline, PerfDiff } from './perf/baseline';
|
|
37
|
+
export { diffPerf, findingKey, parsePerfBaseline, serializePerfBaseline, subjectOf, toBaselineFinding, toPerfBaseline } from './perf/baseline';
|
|
36
38
|
export { renderCallGraph, renderCallGraphDiff } from './report/callgraph';
|
|
37
39
|
export { renderJson } from './report/json';
|
|
38
40
|
export { renderPretty } from './report/pretty';
|
package/esm/index.js
CHANGED
|
@@ -21,6 +21,7 @@ export { introspectFunctions } from './pg/functions';
|
|
|
21
21
|
export { introspectIndexes } from './pg/indexes';
|
|
22
22
|
export { introspectTables } from './pg/introspect';
|
|
23
23
|
export { listAuditableRoles, resolveRoles } from './pg/roles';
|
|
24
|
+
export { diffPerf, findingKey, parsePerfBaseline, serializePerfBaseline, subjectOf, toBaselineFinding, toPerfBaseline } from './perf/baseline';
|
|
24
25
|
export { renderCallGraph, renderCallGraphDiff } from './report/callgraph';
|
|
25
26
|
export { renderJson } from './report/json';
|
|
26
27
|
export { renderPretty } from './report/pretty';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Perf baseline: a committed snapshot of accepted performance debt.
|
|
3
|
+
*
|
|
4
|
+
* The point is the ratchet. A large schema will not reach a clean perf report
|
|
5
|
+
* in one pass, but it can refuse to get worse: commit the current findings,
|
|
6
|
+
* then fail CI only on findings that are *not* in the baseline.
|
|
7
|
+
*
|
|
8
|
+
* Only the identity of a finding is stored — code, relation, policy, and the
|
|
9
|
+
* one context field that distinguishes findings of the same code on the same
|
|
10
|
+
* relation. Messages, severities and hints may be reworded between safegres
|
|
11
|
+
* versions without invalidating a committed baseline.
|
|
12
|
+
*/
|
|
13
|
+
import type { Finding } from '../types';
|
|
14
|
+
export interface PerfBaseline {
|
|
15
|
+
version: 1;
|
|
16
|
+
findings: BaselineFinding[];
|
|
17
|
+
}
|
|
18
|
+
export interface BaselineFinding {
|
|
19
|
+
code: string;
|
|
20
|
+
schema?: string;
|
|
21
|
+
table?: string;
|
|
22
|
+
policy?: string;
|
|
23
|
+
/** The rule-specific subject: constraint, index, column, expression, or function. */
|
|
24
|
+
subject?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface PerfDiff {
|
|
27
|
+
/** Findings present now but absent from the baseline — new debt. */
|
|
28
|
+
added: Finding[];
|
|
29
|
+
/** Baseline entries no longer produced — fixed, or the relation is gone. */
|
|
30
|
+
removed: BaselineFinding[];
|
|
31
|
+
/** Findings that matched the baseline — accepted debt, still reported. */
|
|
32
|
+
accepted: Finding[];
|
|
33
|
+
}
|
|
34
|
+
export declare function subjectOf(finding: Finding): string | undefined;
|
|
35
|
+
export declare function findingKey(entry: BaselineFinding): string;
|
|
36
|
+
export declare function toBaselineFinding(finding: Finding): BaselineFinding;
|
|
37
|
+
export declare function toPerfBaseline(findings: Finding[]): PerfBaseline;
|
|
38
|
+
export declare function diffPerf(findings: Finding[], baseline: PerfBaseline): PerfDiff;
|
|
39
|
+
export declare function parsePerfBaseline(raw: string): PerfBaseline;
|
|
40
|
+
export declare function serializePerfBaseline(baseline: PerfBaseline): string;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Perf baseline: a committed snapshot of accepted performance debt.
|
|
3
|
+
*
|
|
4
|
+
* The point is the ratchet. A large schema will not reach a clean perf report
|
|
5
|
+
* in one pass, but it can refuse to get worse: commit the current findings,
|
|
6
|
+
* then fail CI only on findings that are *not* in the baseline.
|
|
7
|
+
*
|
|
8
|
+
* Only the identity of a finding is stored — code, relation, policy, and the
|
|
9
|
+
* one context field that distinguishes findings of the same code on the same
|
|
10
|
+
* relation. Messages, severities and hints may be reworded between safegres
|
|
11
|
+
* versions without invalidating a committed baseline.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Context keys that identify *which* object a finding is about, in priority
|
|
15
|
+
* order (X3 carries both `expression` and `column`; the expression is the
|
|
16
|
+
* narrower identity). Rules that can only fire once per relation — X6 — set
|
|
17
|
+
* none of these and are keyed by code + relation alone.
|
|
18
|
+
*/
|
|
19
|
+
const SUBJECT_KEYS = ['constraint', 'index', 'expression', 'column', 'function'];
|
|
20
|
+
export function subjectOf(finding) {
|
|
21
|
+
const context = finding.context;
|
|
22
|
+
if (!context)
|
|
23
|
+
return undefined;
|
|
24
|
+
for (const key of SUBJECT_KEYS) {
|
|
25
|
+
const value = context[key];
|
|
26
|
+
if (typeof value === 'string' && value.length > 0)
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
export function findingKey(entry) {
|
|
32
|
+
return [entry.code, entry.schema ?? '', entry.table ?? '', entry.policy ?? '', entry.subject ?? ''].join('|');
|
|
33
|
+
}
|
|
34
|
+
export function toBaselineFinding(finding) {
|
|
35
|
+
const subject = subjectOf(finding);
|
|
36
|
+
return {
|
|
37
|
+
code: finding.code,
|
|
38
|
+
...(finding.schema ? { schema: finding.schema } : {}),
|
|
39
|
+
...(finding.table ? { table: finding.table } : {}),
|
|
40
|
+
...(finding.policy ? { policy: finding.policy } : {}),
|
|
41
|
+
...(subject ? { subject } : {})
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function toPerfBaseline(findings) {
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const finding of findings) {
|
|
48
|
+
const entry = toBaselineFinding(finding);
|
|
49
|
+
const key = findingKey(entry);
|
|
50
|
+
if (seen.has(key))
|
|
51
|
+
continue;
|
|
52
|
+
seen.add(key);
|
|
53
|
+
out.push(entry);
|
|
54
|
+
}
|
|
55
|
+
out.sort((a, b) => findingKey(a).localeCompare(findingKey(b)));
|
|
56
|
+
return { version: 1, findings: out };
|
|
57
|
+
}
|
|
58
|
+
export function diffPerf(findings, baseline) {
|
|
59
|
+
const baseKeys = new Set(baseline.findings.map(findingKey));
|
|
60
|
+
const currentKeys = new Set();
|
|
61
|
+
const added = [];
|
|
62
|
+
const accepted = [];
|
|
63
|
+
for (const finding of findings) {
|
|
64
|
+
const key = findingKey(toBaselineFinding(finding));
|
|
65
|
+
currentKeys.add(key);
|
|
66
|
+
if (baseKeys.has(key))
|
|
67
|
+
accepted.push(finding);
|
|
68
|
+
else
|
|
69
|
+
added.push(finding);
|
|
70
|
+
}
|
|
71
|
+
const removed = baseline.findings.filter((entry) => !currentKeys.has(findingKey(entry)));
|
|
72
|
+
return { added, removed, accepted };
|
|
73
|
+
}
|
|
74
|
+
export function parsePerfBaseline(raw) {
|
|
75
|
+
const data = JSON.parse(raw);
|
|
76
|
+
if (data.version !== 1 || !Array.isArray(data.findings)) {
|
|
77
|
+
throw new Error('invalid perf baseline: expected { version: 1, findings: [...] }');
|
|
78
|
+
}
|
|
79
|
+
return { version: 1, findings: data.findings };
|
|
80
|
+
}
|
|
81
|
+
export function serializePerfBaseline(baseline) {
|
|
82
|
+
return JSON.stringify(baseline, null, 2) + '\n';
|
|
83
|
+
}
|
package/esm/report/pretty.js
CHANGED
|
@@ -84,6 +84,22 @@ export function renderPretty(report, options = {}) {
|
|
|
84
84
|
if (security.length === 0) {
|
|
85
85
|
lines.push('no findings.');
|
|
86
86
|
}
|
|
87
|
+
if (report.perf?.diff) {
|
|
88
|
+
const { added, removed, accepted } = report.perf.diff;
|
|
89
|
+
lines.push('', 'performance vs baseline:', '');
|
|
90
|
+
if (added.length === 0) {
|
|
91
|
+
lines.push(`no new perf debt (${accepted.length} accepted, ${removed.length} fixed).`);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
lines.push(paint('high', `${added.length} new perf finding${added.length === 1 ? '' : 's'} since the baseline:`));
|
|
95
|
+
for (const f of added)
|
|
96
|
+
lines.push(renderFinding(f, paint));
|
|
97
|
+
lines.push(paint('info', ` (${accepted.length} accepted, ${removed.length} fixed)`));
|
|
98
|
+
}
|
|
99
|
+
if (removed.length > 0) {
|
|
100
|
+
lines.push(paint('info', ` fixed since the baseline: ${removed.map((r) => `${r.code} ${r.schema}.${r.table}`).join(', ')} — re-baseline to lock the win in`));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
87
103
|
if (report.perf) {
|
|
88
104
|
const perfExposed = report.perf.findings.filter((f) => f.exposed !== false);
|
|
89
105
|
const perfInternal = report.perf.findings.length - perfExposed.length;
|
package/esm/types.d.ts
CHANGED
|
@@ -90,6 +90,11 @@ export interface PerfReport {
|
|
|
90
90
|
summary: Summary;
|
|
91
91
|
/** Perf score — computed only over perf-dimension findings. */
|
|
92
92
|
score: import('./score/score').Score;
|
|
93
|
+
/**
|
|
94
|
+
* Diff against a committed perf baseline (`--perf-baseline`): which findings
|
|
95
|
+
* are new debt, which were fixed, which are accepted.
|
|
96
|
+
*/
|
|
97
|
+
diff?: import('./perf/baseline').PerfDiff;
|
|
93
98
|
}
|
|
94
99
|
export interface Report {
|
|
95
100
|
version: string;
|
package/index.d.ts
CHANGED
|
@@ -33,6 +33,8 @@ export type { ForeignKeyInfo, IndexInfo, TableIndexSnapshot } from './pg/indexes
|
|
|
33
33
|
export { introspectIndexes } from './pg/indexes';
|
|
34
34
|
export { type IntrospectOptions, introspectTables, type PgPrivilege, type PolicyCmd, type PolicyInfo, type QueryExecutor, type TableSnapshot } from './pg/introspect';
|
|
35
35
|
export { listAuditableRoles, resolveRoles } from './pg/roles';
|
|
36
|
+
export type { BaselineFinding, PerfBaseline, PerfDiff } from './perf/baseline';
|
|
37
|
+
export { diffPerf, findingKey, parsePerfBaseline, serializePerfBaseline, subjectOf, toBaselineFinding, toPerfBaseline } from './perf/baseline';
|
|
36
38
|
export { renderCallGraph, renderCallGraphDiff } from './report/callgraph';
|
|
37
39
|
export { renderJson } from './report/json';
|
|
38
40
|
export { renderPretty } from './report/pretty';
|
package/index.js
CHANGED
|
@@ -21,8 +21,8 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
21
21
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
22
22
|
};
|
|
23
23
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
-
exports.
|
|
25
|
-
exports.meetsGrade = exports.DEFAULT_WEIGHTS = exports.DEFAULT_GRADE_BANDS = exports.computeScore = exports.RULES_BY_CODE = exports.RULES = void 0;
|
|
24
|
+
exports.toPerfBaseline = exports.toBaselineFinding = exports.subjectOf = exports.serializePerfBaseline = exports.parsePerfBaseline = exports.findingKey = exports.diffPerf = exports.resolveRoles = exports.listAuditableRoles = exports.introspectTables = exports.introspectIndexes = exports.introspectFunctions = exports.UNKNOWN_EXPOSURE = exports.resolveExposure = exports.resolveConstructiveExposure = exports.rulesForTable = exports.ruleCodesForDimension = exports.resolveRules = exports.matchTablePattern = exports.defaultRuleMap = exports.ConfigValidationError = exports.applyRulesToFindings = exports.allAstRulesDisabled = exports.strict = exports.recommended = exports.PRESETS = exports.minimal = exports.constructive = exports.safegresConfigLoader = exports.loadConfig = exports.doctor = exports.audit = exports.checkUntrustedRoleWrites = exports.checkUntrustedRolePolicies = exports.checkPublicGrants = exports.collectPredicateColumns = exports.checkUnindexedPolicyColumns = exports.checkPolicyColumnCasts = exports.checkNonLeakproofPolicyFunctions = exports.checkUnindexedForeignKeys = exports.checkRedundantIndexes = exports.checkMissingPrimaryKey = exports.buildCallGraph = exports.toBaseline = exports.serializeBaseline = exports.parseBaseline = exports.diffCallGraph = exports.boundaryKey = exports.PolicyParseError = exports.parsePolicyExpression = void 0;
|
|
25
|
+
exports.meetsGrade = exports.DEFAULT_WEIGHTS = exports.DEFAULT_GRADE_BANDS = exports.computeScore = exports.RULES_BY_CODE = exports.RULES = exports.isKnownRule = exports.expandRuleSelector = exports.dimensionOf = exports.renderPretty = exports.renderJson = exports.renderCallGraphDiff = exports.renderCallGraph = void 0;
|
|
26
26
|
var parse_1 = require("./ast/parse");
|
|
27
27
|
Object.defineProperty(exports, "parsePolicyExpression", { enumerable: true, get: function () { return parse_1.parsePolicyExpression; } });
|
|
28
28
|
Object.defineProperty(exports, "PolicyParseError", { enumerable: true, get: function () { return parse_1.PolicyParseError; } });
|
|
@@ -82,6 +82,14 @@ Object.defineProperty(exports, "introspectTables", { enumerable: true, get: func
|
|
|
82
82
|
var roles_1 = require("./pg/roles");
|
|
83
83
|
Object.defineProperty(exports, "listAuditableRoles", { enumerable: true, get: function () { return roles_1.listAuditableRoles; } });
|
|
84
84
|
Object.defineProperty(exports, "resolveRoles", { enumerable: true, get: function () { return roles_1.resolveRoles; } });
|
|
85
|
+
var baseline_2 = require("./perf/baseline");
|
|
86
|
+
Object.defineProperty(exports, "diffPerf", { enumerable: true, get: function () { return baseline_2.diffPerf; } });
|
|
87
|
+
Object.defineProperty(exports, "findingKey", { enumerable: true, get: function () { return baseline_2.findingKey; } });
|
|
88
|
+
Object.defineProperty(exports, "parsePerfBaseline", { enumerable: true, get: function () { return baseline_2.parsePerfBaseline; } });
|
|
89
|
+
Object.defineProperty(exports, "serializePerfBaseline", { enumerable: true, get: function () { return baseline_2.serializePerfBaseline; } });
|
|
90
|
+
Object.defineProperty(exports, "subjectOf", { enumerable: true, get: function () { return baseline_2.subjectOf; } });
|
|
91
|
+
Object.defineProperty(exports, "toBaselineFinding", { enumerable: true, get: function () { return baseline_2.toBaselineFinding; } });
|
|
92
|
+
Object.defineProperty(exports, "toPerfBaseline", { enumerable: true, get: function () { return baseline_2.toPerfBaseline; } });
|
|
85
93
|
var callgraph_1 = require("./report/callgraph");
|
|
86
94
|
Object.defineProperty(exports, "renderCallGraph", { enumerable: true, get: function () { return callgraph_1.renderCallGraph; } });
|
|
87
95
|
Object.defineProperty(exports, "renderCallGraphDiff", { enumerable: true, get: function () { return callgraph_1.renderCallGraphDiff; } });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safegres",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
4
4
|
"author": "Constructive <developers@constructive.io>",
|
|
5
5
|
"description": "Pure-Postgres Row-Level Security auditor: grants, RLS enforcement, policy coverage, and risky SQL policy patterns, plus an optional index-hygiene performance score. No app framework required.",
|
|
6
6
|
"main": "index.js",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@inquirerer/utils": "^3.3.9",
|
|
47
47
|
"@pgpmjs/logger": "^2.23.0",
|
|
48
|
-
"@pgsql/traverse": "^18.
|
|
48
|
+
"@pgsql/traverse": "^18.4.0",
|
|
49
49
|
"@pgsql/types": "^18.0.0",
|
|
50
50
|
"confstash": "^0.1.0",
|
|
51
51
|
"inquirerer": "^4.9.1",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"@types/node": "^22.0.0",
|
|
69
69
|
"@types/pg": "^8.20.0",
|
|
70
70
|
"makage": "^0.3.0",
|
|
71
|
-
"pgsql-test": "^5.9.
|
|
71
|
+
"pgsql-test": "^5.9.2"
|
|
72
72
|
},
|
|
73
|
-
"gitHead": "
|
|
73
|
+
"gitHead": "a1e9ab2788365ab377102d3952f16b4b529a89a1"
|
|
74
74
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Perf baseline: a committed snapshot of accepted performance debt.
|
|
3
|
+
*
|
|
4
|
+
* The point is the ratchet. A large schema will not reach a clean perf report
|
|
5
|
+
* in one pass, but it can refuse to get worse: commit the current findings,
|
|
6
|
+
* then fail CI only on findings that are *not* in the baseline.
|
|
7
|
+
*
|
|
8
|
+
* Only the identity of a finding is stored — code, relation, policy, and the
|
|
9
|
+
* one context field that distinguishes findings of the same code on the same
|
|
10
|
+
* relation. Messages, severities and hints may be reworded between safegres
|
|
11
|
+
* versions without invalidating a committed baseline.
|
|
12
|
+
*/
|
|
13
|
+
import type { Finding } from '../types';
|
|
14
|
+
export interface PerfBaseline {
|
|
15
|
+
version: 1;
|
|
16
|
+
findings: BaselineFinding[];
|
|
17
|
+
}
|
|
18
|
+
export interface BaselineFinding {
|
|
19
|
+
code: string;
|
|
20
|
+
schema?: string;
|
|
21
|
+
table?: string;
|
|
22
|
+
policy?: string;
|
|
23
|
+
/** The rule-specific subject: constraint, index, column, expression, or function. */
|
|
24
|
+
subject?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface PerfDiff {
|
|
27
|
+
/** Findings present now but absent from the baseline — new debt. */
|
|
28
|
+
added: Finding[];
|
|
29
|
+
/** Baseline entries no longer produced — fixed, or the relation is gone. */
|
|
30
|
+
removed: BaselineFinding[];
|
|
31
|
+
/** Findings that matched the baseline — accepted debt, still reported. */
|
|
32
|
+
accepted: Finding[];
|
|
33
|
+
}
|
|
34
|
+
export declare function subjectOf(finding: Finding): string | undefined;
|
|
35
|
+
export declare function findingKey(entry: BaselineFinding): string;
|
|
36
|
+
export declare function toBaselineFinding(finding: Finding): BaselineFinding;
|
|
37
|
+
export declare function toPerfBaseline(findings: Finding[]): PerfBaseline;
|
|
38
|
+
export declare function diffPerf(findings: Finding[], baseline: PerfBaseline): PerfDiff;
|
|
39
|
+
export declare function parsePerfBaseline(raw: string): PerfBaseline;
|
|
40
|
+
export declare function serializePerfBaseline(baseline: PerfBaseline): string;
|
package/perf/baseline.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Perf baseline: a committed snapshot of accepted performance debt.
|
|
4
|
+
*
|
|
5
|
+
* The point is the ratchet. A large schema will not reach a clean perf report
|
|
6
|
+
* in one pass, but it can refuse to get worse: commit the current findings,
|
|
7
|
+
* then fail CI only on findings that are *not* in the baseline.
|
|
8
|
+
*
|
|
9
|
+
* Only the identity of a finding is stored — code, relation, policy, and the
|
|
10
|
+
* one context field that distinguishes findings of the same code on the same
|
|
11
|
+
* relation. Messages, severities and hints may be reworded between safegres
|
|
12
|
+
* versions without invalidating a committed baseline.
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.subjectOf = subjectOf;
|
|
16
|
+
exports.findingKey = findingKey;
|
|
17
|
+
exports.toBaselineFinding = toBaselineFinding;
|
|
18
|
+
exports.toPerfBaseline = toPerfBaseline;
|
|
19
|
+
exports.diffPerf = diffPerf;
|
|
20
|
+
exports.parsePerfBaseline = parsePerfBaseline;
|
|
21
|
+
exports.serializePerfBaseline = serializePerfBaseline;
|
|
22
|
+
/**
|
|
23
|
+
* Context keys that identify *which* object a finding is about, in priority
|
|
24
|
+
* order (X3 carries both `expression` and `column`; the expression is the
|
|
25
|
+
* narrower identity). Rules that can only fire once per relation — X6 — set
|
|
26
|
+
* none of these and are keyed by code + relation alone.
|
|
27
|
+
*/
|
|
28
|
+
const SUBJECT_KEYS = ['constraint', 'index', 'expression', 'column', 'function'];
|
|
29
|
+
function subjectOf(finding) {
|
|
30
|
+
const context = finding.context;
|
|
31
|
+
if (!context)
|
|
32
|
+
return undefined;
|
|
33
|
+
for (const key of SUBJECT_KEYS) {
|
|
34
|
+
const value = context[key];
|
|
35
|
+
if (typeof value === 'string' && value.length > 0)
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
function findingKey(entry) {
|
|
41
|
+
return [entry.code, entry.schema ?? '', entry.table ?? '', entry.policy ?? '', entry.subject ?? ''].join('|');
|
|
42
|
+
}
|
|
43
|
+
function toBaselineFinding(finding) {
|
|
44
|
+
const subject = subjectOf(finding);
|
|
45
|
+
return {
|
|
46
|
+
code: finding.code,
|
|
47
|
+
...(finding.schema ? { schema: finding.schema } : {}),
|
|
48
|
+
...(finding.table ? { table: finding.table } : {}),
|
|
49
|
+
...(finding.policy ? { policy: finding.policy } : {}),
|
|
50
|
+
...(subject ? { subject } : {})
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function toPerfBaseline(findings) {
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const finding of findings) {
|
|
57
|
+
const entry = toBaselineFinding(finding);
|
|
58
|
+
const key = findingKey(entry);
|
|
59
|
+
if (seen.has(key))
|
|
60
|
+
continue;
|
|
61
|
+
seen.add(key);
|
|
62
|
+
out.push(entry);
|
|
63
|
+
}
|
|
64
|
+
out.sort((a, b) => findingKey(a).localeCompare(findingKey(b)));
|
|
65
|
+
return { version: 1, findings: out };
|
|
66
|
+
}
|
|
67
|
+
function diffPerf(findings, baseline) {
|
|
68
|
+
const baseKeys = new Set(baseline.findings.map(findingKey));
|
|
69
|
+
const currentKeys = new Set();
|
|
70
|
+
const added = [];
|
|
71
|
+
const accepted = [];
|
|
72
|
+
for (const finding of findings) {
|
|
73
|
+
const key = findingKey(toBaselineFinding(finding));
|
|
74
|
+
currentKeys.add(key);
|
|
75
|
+
if (baseKeys.has(key))
|
|
76
|
+
accepted.push(finding);
|
|
77
|
+
else
|
|
78
|
+
added.push(finding);
|
|
79
|
+
}
|
|
80
|
+
const removed = baseline.findings.filter((entry) => !currentKeys.has(findingKey(entry)));
|
|
81
|
+
return { added, removed, accepted };
|
|
82
|
+
}
|
|
83
|
+
function parsePerfBaseline(raw) {
|
|
84
|
+
const data = JSON.parse(raw);
|
|
85
|
+
if (data.version !== 1 || !Array.isArray(data.findings)) {
|
|
86
|
+
throw new Error('invalid perf baseline: expected { version: 1, findings: [...] }');
|
|
87
|
+
}
|
|
88
|
+
return { version: 1, findings: data.findings };
|
|
89
|
+
}
|
|
90
|
+
function serializePerfBaseline(baseline) {
|
|
91
|
+
return JSON.stringify(baseline, null, 2) + '\n';
|
|
92
|
+
}
|
package/report/pretty.js
CHANGED
|
@@ -90,6 +90,22 @@ function renderPretty(report, options = {}) {
|
|
|
90
90
|
if (security.length === 0) {
|
|
91
91
|
lines.push('no findings.');
|
|
92
92
|
}
|
|
93
|
+
if (report.perf?.diff) {
|
|
94
|
+
const { added, removed, accepted } = report.perf.diff;
|
|
95
|
+
lines.push('', 'performance vs baseline:', '');
|
|
96
|
+
if (added.length === 0) {
|
|
97
|
+
lines.push(`no new perf debt (${accepted.length} accepted, ${removed.length} fixed).`);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
lines.push(paint('high', `${added.length} new perf finding${added.length === 1 ? '' : 's'} since the baseline:`));
|
|
101
|
+
for (const f of added)
|
|
102
|
+
lines.push(renderFinding(f, paint));
|
|
103
|
+
lines.push(paint('info', ` (${accepted.length} accepted, ${removed.length} fixed)`));
|
|
104
|
+
}
|
|
105
|
+
if (removed.length > 0) {
|
|
106
|
+
lines.push(paint('info', ` fixed since the baseline: ${removed.map((r) => `${r.code} ${r.schema}.${r.table}`).join(', ')} — re-baseline to lock the win in`));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
93
109
|
if (report.perf) {
|
|
94
110
|
const perfExposed = report.perf.findings.filter((f) => f.exposed !== false);
|
|
95
111
|
const perfInternal = report.perf.findings.length - perfExposed.length;
|
package/types.d.ts
CHANGED
|
@@ -90,6 +90,11 @@ export interface PerfReport {
|
|
|
90
90
|
summary: Summary;
|
|
91
91
|
/** Perf score — computed only over perf-dimension findings. */
|
|
92
92
|
score: import('./score/score').Score;
|
|
93
|
+
/**
|
|
94
|
+
* Diff against a committed perf baseline (`--perf-baseline`): which findings
|
|
95
|
+
* are new debt, which were fixed, which are accepted.
|
|
96
|
+
*/
|
|
97
|
+
diff?: import('./perf/baseline').PerfDiff;
|
|
93
98
|
}
|
|
94
99
|
export interface Report {
|
|
95
100
|
version: string;
|