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 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) and score it on
93
- its own 0-100 axis (report.perf)
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 ? true : undefined,
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) and score it on
58
- its own 0-100 axis (report.perf)
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 ? true : undefined,
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
+ }
@@ -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.isKnownRule = exports.expandRuleSelector = exports.dimensionOf = exports.renderPretty = exports.renderJson = exports.renderCallGraphDiff = exports.renderCallGraph = 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 = 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.13.0",
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.1.0",
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.1"
71
+ "pgsql-test": "^5.9.2"
72
72
  },
73
- "gitHead": "6c59df639625959f2db55ee1c6a3768c580045f0"
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;
@@ -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;