safegres 1.23.0 → 1.25.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
@@ -435,13 +435,20 @@ Or the first-party action, which installs the CLI, runs that config, and turns t
435
435
  job summary, annotations and a sticky PR comment:
436
436
 
437
437
  ```yaml
438
- - uses: constructive-io/constructive/packages/safegres@main
438
+ - uses: constructive-io/constructive/packages/safegres@safegres-action-v1
439
439
  with:
440
- out: safegres-reports
441
440
  comment: true # sticky PR comment (needs pull-requests: write)
442
441
  upload-sarif: true # code scanning (needs security-events: write)
443
442
  ```
444
443
 
444
+ `safegres-action-v1` is a moving tag on the action's latest v1; `@main` tracks the monorepo tip.
445
+ The reports are uploaded as the `safegres-reports` artifact by default — that artifact *is* the
446
+ next run's baseline, so a job that suppresses it (`upload-reports: false`) can never show a delta.
447
+
448
+ Auditing a pgpm workspace (`source.pgpm`) adds `version: local`, which runs the repo's own safegres
449
+ rather than installing one: that path deploys through `pgsql-test`, an optional peer only the
450
+ workspace has.
451
+
445
452
  On a pull request it also measures the delta for you: it picks the base branch's most recent run
446
453
  that *is* a sane comparison — succeeded, younger than 48 hours, and audited a commit in this
447
454
  branch's merge-base history — downloads its report, and names the run it chose in the summary and
package/config/schema.js CHANGED
@@ -131,7 +131,13 @@ const FAIL_ON = {
131
131
  };
132
132
  const GITHUB_COMMENT = {
133
133
  sticky: bool('Reuse one comment per PR instead of appending. Default true.'),
134
- sections: list('Sections to include. Default scores, delta, new-findings.', str('Section', ['scores', 'delta', 'new-findings', 'findings', 'planes']))
134
+ sections: list('Sections to include. Default scores, delta, new-findings. `report` adds the '
135
+ + 'markdown report (`findings` is its old name).', str('Section', ['scores', 'delta', 'new-findings', 'report', 'findings', 'planes'])),
136
+ detail: str('How much of the report the report section carries. Default summary.', [
137
+ 'summary',
138
+ 'normal',
139
+ 'verbose'
140
+ ])
135
141
  };
136
142
  const GITHUB = {
137
143
  summary: list('Scores in the job summary, in order (security, perf, planes:<glob>)', str('Score')),
package/config/types.d.ts CHANGED
@@ -479,8 +479,23 @@ export interface GithubReportConfig {
479
479
  export interface GithubCommentConfig {
480
480
  /** Reuse one comment per PR instead of appending. Default `true`. */
481
481
  sticky?: boolean;
482
- /** Sections to include. Default `['scores', 'delta', 'new-findings']`. */
483
- sections?: Array<'scores' | 'delta' | 'new-findings' | 'findings' | 'planes'>;
482
+ /**
483
+ * Sections to include, in this order: `scores` (badges + exposure), `planes`,
484
+ * `delta`, `new-findings` (the perf ratchet verdict and what it caught), and
485
+ * `report` — the same markdown report the job summary carries, at `detail`.
486
+ * `findings` is the old name for `report`. Default
487
+ * `['scores', 'delta', 'new-findings']`; add `report` to review the audit
488
+ * without leaving the PR.
489
+ */
490
+ sections?: Array<'scores' | 'delta' | 'new-findings' | 'report' | 'findings' | 'planes'>;
491
+ /**
492
+ * How much of the report the `report` section carries. Default `summary` —
493
+ * scores, exposure, counts, the delta and the ratchet verdict, with no
494
+ * per-finding tables, because a comment is read on the way past and GitHub
495
+ * rejects a body over 64 KB. `normal`/`verbose` add the finding tables and
496
+ * degrade back to `summary` when the body would not fit.
497
+ */
498
+ detail?: 'summary' | 'normal' | 'verbose';
484
499
  }
485
500
  /**
486
501
  * The full safegres configuration, loadable from `safegres.config.{ts,js,mjs,cjs}`,
@@ -127,7 +127,13 @@ const FAIL_ON = {
127
127
  };
128
128
  const GITHUB_COMMENT = {
129
129
  sticky: bool('Reuse one comment per PR instead of appending. Default true.'),
130
- sections: list('Sections to include. Default scores, delta, new-findings.', str('Section', ['scores', 'delta', 'new-findings', 'findings', 'planes']))
130
+ sections: list('Sections to include. Default scores, delta, new-findings. `report` adds the '
131
+ + 'markdown report (`findings` is its old name).', str('Section', ['scores', 'delta', 'new-findings', 'report', 'findings', 'planes'])),
132
+ detail: str('How much of the report the report section carries. Default summary.', [
133
+ 'summary',
134
+ 'normal',
135
+ 'verbose'
136
+ ])
131
137
  };
132
138
  const GITHUB = {
133
139
  summary: list('Scores in the job summary, in order (security, perf, planes:<glob>)', str('Score')),
@@ -479,8 +479,23 @@ export interface GithubReportConfig {
479
479
  export interface GithubCommentConfig {
480
480
  /** Reuse one comment per PR instead of appending. Default `true`. */
481
481
  sticky?: boolean;
482
- /** Sections to include. Default `['scores', 'delta', 'new-findings']`. */
483
- sections?: Array<'scores' | 'delta' | 'new-findings' | 'findings' | 'planes'>;
482
+ /**
483
+ * Sections to include, in this order: `scores` (badges + exposure), `planes`,
484
+ * `delta`, `new-findings` (the perf ratchet verdict and what it caught), and
485
+ * `report` — the same markdown report the job summary carries, at `detail`.
486
+ * `findings` is the old name for `report`. Default
487
+ * `['scores', 'delta', 'new-findings']`; add `report` to review the audit
488
+ * without leaving the PR.
489
+ */
490
+ sections?: Array<'scores' | 'delta' | 'new-findings' | 'report' | 'findings' | 'planes'>;
491
+ /**
492
+ * How much of the report the `report` section carries. Default `summary` —
493
+ * scores, exposure, counts, the delta and the ratchet verdict, with no
494
+ * per-finding tables, because a comment is read on the way past and GitHub
495
+ * rejects a body over 64 KB. `normal`/`verbose` add the finding tables and
496
+ * degrade back to `summary` when the body would not fit.
497
+ */
498
+ detail?: 'summary' | 'normal' | 'verbose';
484
499
  }
485
500
  /**
486
501
  * The full safegres configuration, loadable from `safegres.config.{ts,js,mjs,cjs}`,
@@ -34,9 +34,22 @@ export declare function gradeBadge(label: string, grade: Grade, badges: boolean)
34
34
  */
35
35
  export declare function renderGithubSummary(report: Report, options?: GithubRenderOptions): string;
36
36
  /**
37
- * The sticky PR comment: shorter than the summary by design. A comment is read
38
- * on the way past, so it carries the scores, the movement, and what is new —
39
- * the full finding tables live in the job summary and the SARIF alerts.
37
+ * GitHub rejects an issue comment body over 65536 characters, and a comment
38
+ * that fails to post is worse than a short one — so a comment carrying the
39
+ * report degrades (verbose → normal → summary → a pointer at the job summary)
40
+ * rather than being truncated at a byte boundary by the API.
41
+ */
42
+ export declare const COMMENT_MAX_CHARS = 65536;
43
+ /**
44
+ * The sticky PR comment: shorter than the summary by default. A comment is read
45
+ * on the way past, so out of the box it carries the scores, the movement, and
46
+ * what is new, with the full finding tables in the job summary and the SARIF
47
+ * alerts.
48
+ *
49
+ * `comment.sections` chooses what it says. `report` puts the whole markdown
50
+ * report in the comment at `comment.detail` (default `summary`: scores,
51
+ * exposure, counts, the delta and the ratchet verdict — no per-finding tables),
52
+ * which is the same render the job summary uses, so the two cannot disagree.
40
53
  */
41
54
  export declare function renderGithubComment(report: Report, options?: GithubRenderOptions): string;
42
55
  /**
@@ -72,22 +72,53 @@ export function renderGithubSummary(report, options = {}) {
72
72
  return out.join('\n');
73
73
  }
74
74
  /**
75
- * The sticky PR comment: shorter than the summary by design. A comment is read
76
- * on the way past, so it carries the scores, the movement, and what is new —
77
- * the full finding tables live in the job summary and the SARIF alerts.
75
+ * GitHub rejects an issue comment body over 65536 characters, and a comment
76
+ * that fails to post is worse than a short one — so a comment carrying the
77
+ * report degrades (verbose → normal → summary → a pointer at the job summary)
78
+ * rather than being truncated at a byte boundary by the API.
79
+ */
80
+ export const COMMENT_MAX_CHARS = 65536;
81
+ /**
82
+ * The sticky PR comment: shorter than the summary by default. A comment is read
83
+ * on the way past, so out of the box it carries the scores, the movement, and
84
+ * what is new, with the full finding tables in the job summary and the SARIF
85
+ * alerts.
86
+ *
87
+ * `comment.sections` chooses what it says. `report` puts the whole markdown
88
+ * report in the comment at `comment.detail` (default `summary`: scores,
89
+ * exposure, counts, the delta and the ratchet verdict — no per-finding tables),
90
+ * which is the same render the job summary uses, so the two cannot disagree.
78
91
  */
79
92
  export function renderGithubComment(report, options = {}) {
93
+ const wanted = options.config?.comment?.detail ?? 'summary';
94
+ const chain = wanted === 'verbose' ? ['verbose', 'normal', 'summary'] : wanted === 'normal' ? ['normal', 'summary'] : ['summary'];
95
+ for (const detail of chain) {
96
+ const body = commentBody(report, options, detail);
97
+ if (body.length <= COMMENT_MAX_CHARS)
98
+ return body;
99
+ }
100
+ return commentBody(report, options, null);
101
+ }
102
+ /**
103
+ * One comment body. `detail` is the report section's detail, or null to drop
104
+ * the report and say where it went — which is how the size cap is honored.
105
+ */
106
+ function commentBody(report, options, detail) {
80
107
  const config = options.config ?? {};
81
108
  const badges = config.badges !== false;
82
109
  const sections = new Set(config.comment?.sections ?? ['scores', 'delta', 'new-findings']);
83
110
  const selectors = config.summary ?? ['security', 'perf'];
84
111
  const view = selectView(report, { planes: planePatterns(selectors), ...options.view });
112
+ // `findings` is the old name for `report`; both mean "the markdown report".
113
+ const wantsReport = sections.has('report') || sections.has('findings');
114
+ const withReport = wantsReport && detail != null;
85
115
  const out = [COMMENT_MARKER, '## safegres', ''];
86
116
  if (sections.has('scores')) {
87
117
  out.push(selectedScores(view.scores, selectors)
88
118
  .map((s) => scoreBadge(s.label, s.score, badges))
89
119
  .join(' '), '');
90
- if (report.exposure) {
120
+ // Everything below is in the report too, so it is said once.
121
+ if (report.exposure && !withReport) {
91
122
  out.push(report.exposure.known
92
123
  ? `Exposure (${report.exposure.source}): **${report.exposure.exposedTables}/`
93
124
  + `${report.exposure.totalTables}** tables reachable.`
@@ -100,7 +131,9 @@ export function renderGithubComment(report, options = {}) {
100
131
  ? `| \`${p.name}\` | ${p.kind} | — | not graded |`
101
132
  : `| \`${p.name}\` | ${p.kind} | ${p.exposedTables} | **${p.score.grade}** (${p.score.value}) |`), '');
102
133
  }
103
- if (sections.has('delta') && report.comparison) {
134
+ // The report renders the comparison (or the reason there is none) itself.
135
+ const wantsDelta = sections.has('delta') && !withReport;
136
+ if (wantsDelta && report.comparison) {
104
137
  const cmp = report.comparison;
105
138
  const since = describeBaseline(cmp.previous);
106
139
  if (cmp.unchanged) {
@@ -121,18 +154,30 @@ export function renderGithubComment(report, options = {}) {
121
154
  out.push('');
122
155
  }
123
156
  }
124
- else if (sections.has('delta') && report.comparisonSkipped) {
157
+ else if (wantsDelta && report.comparisonSkipped) {
125
158
  out.push(`_No delta baseline: ${report.comparisonSkipped}. `
126
159
  + 'The absolute score and the perf baseline still gate this run._', '');
127
160
  }
128
161
  if (sections.has('new-findings')) {
129
- const added = report.perf?.diff?.added ?? [];
162
+ const diff = report.perf?.diff;
163
+ // The ratchet's verdict, even when it is "nothing new": a comment that only
164
+ // speaks up on new debt cannot be distinguished from one that never ran.
165
+ // The report carries this line itself, so it is not repeated under it.
166
+ if (diff && !withReport) {
167
+ out.push(`Perf baseline: **${diff.added.length} new**, ${diff.accepted.length} accepted, `
168
+ + `${diff.removed.length} resolved.`, '');
169
+ }
170
+ const added = diff?.added ?? [];
130
171
  if (added.length > 0) {
131
172
  out.push(`**${added.length} new performance finding${added.length === 1 ? '' : 's'} since the baseline**`, '', ...added.slice(0, 10).map((f) => `- \`${f.code}\` ${location(f)} — ${f.message}`), '');
132
173
  }
133
174
  }
134
- if (sections.has('findings')) {
135
- out.push(renderMarkdown(report, { title: 'Findings', view: options.view }), '');
175
+ if (withReport) {
176
+ out.push(renderMarkdown(report, { title: 'Report', view: { detail, ...options.view } }), '');
177
+ }
178
+ else if (wantsReport) {
179
+ out.push('_The report is too large for a PR comment — it is in the job summary and the '
180
+ + 'reports artifact._', '');
136
181
  }
137
182
  return out.join('\n');
138
183
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safegres",
3
- "version": "1.23.0",
3
+ "version": "1.25.0",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "Postgres security and performance auditor: RLS, grants and effective-access analysis plus index and policy-cost checks, scored on two independent axes. One connection, no framework.",
6
6
  "main": "index.js",
@@ -57,16 +57,16 @@
57
57
  "safegres"
58
58
  ],
59
59
  "dependencies": {
60
- "@inquirerer/utils": "^3.3.12",
61
- "@pgpmjs/logger": "^2.25.0",
60
+ "@inquirerer/utils": "^3.3.13",
61
+ "@pgpmjs/logger": "^2.25.1",
62
62
  "@pgsql/lint": "^18.2.5",
63
63
  "@pgsql/traverse": "^18.7.7",
64
64
  "@pgsql/types": "^18.0.0",
65
65
  "confstash": "^0.1.2",
66
- "inquirerer": "^4.9.3",
66
+ "inquirerer": "^4.9.4",
67
67
  "libpg-query": "^18.1.4",
68
68
  "pg": "^8.21.0",
69
- "pg-env": "^1.31.0",
69
+ "pg-env": "^1.31.1",
70
70
  "pgsql-deparser": "^18.3.6",
71
71
  "pgsql-parser": "^18.2.6",
72
72
  "yanse": "^0.2.2"
@@ -82,8 +82,8 @@
82
82
  "devDependencies": {
83
83
  "@types/node": "^22.0.0",
84
84
  "@types/pg": "^8.20.4",
85
- "makage": "^0.3.0",
86
- "pgsql-test": "^5.13.1"
85
+ "makage": "^0.8.0",
86
+ "pgsql-test": "^5.13.3"
87
87
  },
88
- "gitHead": "bfded396aef6eccc75fc6f289a9f8dc46df8a678"
88
+ "gitHead": "5e0bfead2856d1a062b495d343a43e543343f9b8"
89
89
  }
@@ -34,9 +34,22 @@ export declare function gradeBadge(label: string, grade: Grade, badges: boolean)
34
34
  */
35
35
  export declare function renderGithubSummary(report: Report, options?: GithubRenderOptions): string;
36
36
  /**
37
- * The sticky PR comment: shorter than the summary by design. A comment is read
38
- * on the way past, so it carries the scores, the movement, and what is new —
39
- * the full finding tables live in the job summary and the SARIF alerts.
37
+ * GitHub rejects an issue comment body over 65536 characters, and a comment
38
+ * that fails to post is worse than a short one — so a comment carrying the
39
+ * report degrades (verbose → normal → summary → a pointer at the job summary)
40
+ * rather than being truncated at a byte boundary by the API.
41
+ */
42
+ export declare const COMMENT_MAX_CHARS = 65536;
43
+ /**
44
+ * The sticky PR comment: shorter than the summary by default. A comment is read
45
+ * on the way past, so out of the box it carries the scores, the movement, and
46
+ * what is new, with the full finding tables in the job summary and the SARIF
47
+ * alerts.
48
+ *
49
+ * `comment.sections` chooses what it says. `report` puts the whole markdown
50
+ * report in the comment at `comment.detail` (default `summary`: scores,
51
+ * exposure, counts, the delta and the ratchet verdict — no per-finding tables),
52
+ * which is the same render the job summary uses, so the two cannot disagree.
40
53
  */
41
54
  export declare function renderGithubComment(report: Report, options?: GithubRenderOptions): string;
42
55
  /**
package/report/github.js CHANGED
@@ -45,7 +45,7 @@ var __importStar = (this && this.__importStar) || (function () {
45
45
  };
46
46
  })();
47
47
  Object.defineProperty(exports, "__esModule", { value: true });
48
- exports.COMMENT_MARKER = void 0;
48
+ exports.COMMENT_MAX_CHARS = exports.COMMENT_MARKER = void 0;
49
49
  exports.scoreBadge = scoreBadge;
50
50
  exports.gradeBadge = gradeBadge;
51
51
  exports.renderGithubSummary = renderGithubSummary;
@@ -115,22 +115,53 @@ function renderGithubSummary(report, options = {}) {
115
115
  return out.join('\n');
116
116
  }
117
117
  /**
118
- * The sticky PR comment: shorter than the summary by design. A comment is read
119
- * on the way past, so it carries the scores, the movement, and what is new —
120
- * the full finding tables live in the job summary and the SARIF alerts.
118
+ * GitHub rejects an issue comment body over 65536 characters, and a comment
119
+ * that fails to post is worse than a short one — so a comment carrying the
120
+ * report degrades (verbose → normal → summary → a pointer at the job summary)
121
+ * rather than being truncated at a byte boundary by the API.
122
+ */
123
+ exports.COMMENT_MAX_CHARS = 65536;
124
+ /**
125
+ * The sticky PR comment: shorter than the summary by default. A comment is read
126
+ * on the way past, so out of the box it carries the scores, the movement, and
127
+ * what is new, with the full finding tables in the job summary and the SARIF
128
+ * alerts.
129
+ *
130
+ * `comment.sections` chooses what it says. `report` puts the whole markdown
131
+ * report in the comment at `comment.detail` (default `summary`: scores,
132
+ * exposure, counts, the delta and the ratchet verdict — no per-finding tables),
133
+ * which is the same render the job summary uses, so the two cannot disagree.
121
134
  */
122
135
  function renderGithubComment(report, options = {}) {
136
+ const wanted = options.config?.comment?.detail ?? 'summary';
137
+ const chain = wanted === 'verbose' ? ['verbose', 'normal', 'summary'] : wanted === 'normal' ? ['normal', 'summary'] : ['summary'];
138
+ for (const detail of chain) {
139
+ const body = commentBody(report, options, detail);
140
+ if (body.length <= exports.COMMENT_MAX_CHARS)
141
+ return body;
142
+ }
143
+ return commentBody(report, options, null);
144
+ }
145
+ /**
146
+ * One comment body. `detail` is the report section's detail, or null to drop
147
+ * the report and say where it went — which is how the size cap is honored.
148
+ */
149
+ function commentBody(report, options, detail) {
123
150
  const config = options.config ?? {};
124
151
  const badges = config.badges !== false;
125
152
  const sections = new Set(config.comment?.sections ?? ['scores', 'delta', 'new-findings']);
126
153
  const selectors = config.summary ?? ['security', 'perf'];
127
154
  const view = (0, view_1.selectView)(report, { planes: planePatterns(selectors), ...options.view });
155
+ // `findings` is the old name for `report`; both mean "the markdown report".
156
+ const wantsReport = sections.has('report') || sections.has('findings');
157
+ const withReport = wantsReport && detail != null;
128
158
  const out = [exports.COMMENT_MARKER, '## safegres', ''];
129
159
  if (sections.has('scores')) {
130
160
  out.push(selectedScores(view.scores, selectors)
131
161
  .map((s) => scoreBadge(s.label, s.score, badges))
132
162
  .join(' '), '');
133
- if (report.exposure) {
163
+ // Everything below is in the report too, so it is said once.
164
+ if (report.exposure && !withReport) {
134
165
  out.push(report.exposure.known
135
166
  ? `Exposure (${report.exposure.source}): **${report.exposure.exposedTables}/`
136
167
  + `${report.exposure.totalTables}** tables reachable.`
@@ -143,7 +174,9 @@ function renderGithubComment(report, options = {}) {
143
174
  ? `| \`${p.name}\` | ${p.kind} | — | not graded |`
144
175
  : `| \`${p.name}\` | ${p.kind} | ${p.exposedTables} | **${p.score.grade}** (${p.score.value}) |`), '');
145
176
  }
146
- if (sections.has('delta') && report.comparison) {
177
+ // The report renders the comparison (or the reason there is none) itself.
178
+ const wantsDelta = sections.has('delta') && !withReport;
179
+ if (wantsDelta && report.comparison) {
147
180
  const cmp = report.comparison;
148
181
  const since = (0, compare_1.describeBaseline)(cmp.previous);
149
182
  if (cmp.unchanged) {
@@ -164,18 +197,30 @@ function renderGithubComment(report, options = {}) {
164
197
  out.push('');
165
198
  }
166
199
  }
167
- else if (sections.has('delta') && report.comparisonSkipped) {
200
+ else if (wantsDelta && report.comparisonSkipped) {
168
201
  out.push(`_No delta baseline: ${report.comparisonSkipped}. `
169
202
  + 'The absolute score and the perf baseline still gate this run._', '');
170
203
  }
171
204
  if (sections.has('new-findings')) {
172
- const added = report.perf?.diff?.added ?? [];
205
+ const diff = report.perf?.diff;
206
+ // The ratchet's verdict, even when it is "nothing new": a comment that only
207
+ // speaks up on new debt cannot be distinguished from one that never ran.
208
+ // The report carries this line itself, so it is not repeated under it.
209
+ if (diff && !withReport) {
210
+ out.push(`Perf baseline: **${diff.added.length} new**, ${diff.accepted.length} accepted, `
211
+ + `${diff.removed.length} resolved.`, '');
212
+ }
213
+ const added = diff?.added ?? [];
173
214
  if (added.length > 0) {
174
215
  out.push(`**${added.length} new performance finding${added.length === 1 ? '' : 's'} since the baseline**`, '', ...added.slice(0, 10).map((f) => `- \`${f.code}\` ${location(f)} — ${f.message}`), '');
175
216
  }
176
217
  }
177
- if (sections.has('findings')) {
178
- out.push((0, markdown_1.renderMarkdown)(report, { title: 'Findings', view: options.view }), '');
218
+ if (withReport) {
219
+ out.push((0, markdown_1.renderMarkdown)(report, { title: 'Report', view: { detail, ...options.view } }), '');
220
+ }
221
+ else if (wantsReport) {
222
+ out.push('_The report is too large for a PR comment — it is in the job summary and the '
223
+ + 'reports artifact._', '');
179
224
  }
180
225
  return out.join('\n');
181
226
  }
@@ -1044,12 +1044,22 @@
1044
1044
  "scores",
1045
1045
  "delta",
1046
1046
  "new-findings",
1047
+ "report",
1047
1048
  "findings",
1048
1049
  "planes"
1049
1050
  ],
1050
1051
  "description": "Section"
1051
1052
  },
1052
- "description": "Sections to include. Default scores, delta, new-findings."
1053
+ "description": "Sections to include. Default scores, delta, new-findings. `report` adds the markdown report (`findings` is its old name)."
1054
+ },
1055
+ "detail": {
1056
+ "type": "string",
1057
+ "enum": [
1058
+ "summary",
1059
+ "normal",
1060
+ "verbose"
1061
+ ],
1062
+ "description": "How much of the report the report section carries. Default summary."
1053
1063
  }
1054
1064
  },
1055
1065
  "description": "Sticky PR comment"