Skip to content

Commit 5df1c26

Browse files
authored
Merge pull request #1555 from constructive-io/feat/safegres-markdown-report
feat(safegres): markdown report format for CI job summaries and PR comments
2 parents 270ea97 + 7293eab commit 5df1c26

6 files changed

Lines changed: 313 additions & 1 deletion

File tree

.agents/skills/safegres/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ safegres audit --perf-baseline .safegres-perf.json --fail-on-new-perf # ratche
222222
safegres audit --stats # + runtime-statistics rules S1-S4 (implies --perf)
223223
safegres audit --explain # + prove findings with EXPLAIN (implies --perf, PG16+)
224224
safegres audit --format json # machine-readable
225+
safegres audit --format markdown >> "$GITHUB_STEP_SUMMARY" # CI job summary / PR comment
225226
safegres audit --exposed-only # hide internal advisories
226227
safegres doctor # config/parser/connection/catalog + exposure + stale public.read checks
227228
safegres print-config # resolved effective config

packages/safegres/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,24 @@ Pretty output prints the exposure line, score, and the exposed findings. Interna
3434
- `--verbose` — expand the internal advisories instead of collapsing them to a count.
3535
- `--exposed-only` — drop internal findings entirely.
3636
- `--format json` / `--format json-pretty` — machine-readable output (always carries every finding).
37+
- `--format markdown` — the same report as GitHub-flavoured markdown, for a job summary or a PR comment (see [CI](#ci)).
38+
39+
### CI
40+
41+
A plain audit is one command and one gate; `--format markdown` writes the report where a reviewer will actually see it:
42+
43+
```yaml
44+
- name: Audit RLS
45+
run: |
46+
npx safegres audit --format markdown >> "$GITHUB_STEP_SUMMARY"
47+
npx safegres audit --fail-on-grade B --summary
48+
env:
49+
PGHOST: localhost
50+
PGUSER: postgres
51+
PGPASSWORD: postgres
52+
```
53+
54+
Scores lead, then the severity counts, then a table per dimension; internal (non-exposed) advisories and accepted baseline debt fold into `<details>` so the summary stays skimmable. To post it as a PR comment instead, pipe it to `gh pr comment --body-file -`. The same renderer is available to library callers as `renderMarkdown(report)`.
3755

3856
## What it checks
3957

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { renderMarkdown } from '../src/report/markdown';
2+
import { computeScore } from '../src/score/score';
3+
import type { Finding, Report } from '../src/types';
4+
import { summarize } from '../src/types';
5+
6+
function finding(over: Partial<Finding> = {}): Finding {
7+
return {
8+
code: 'A2',
9+
severity: 'high',
10+
category: 'flags',
11+
exposed: true,
12+
schema: 'app_public',
13+
table: 'widgets',
14+
message: 'grants exist on a table with RLS disabled',
15+
...over
16+
};
17+
}
18+
19+
function makeReport(over: Partial<Report> = {}): Report {
20+
const findings = over.findings ?? [
21+
finding(),
22+
finding({ schema: 'db_migrate', table: 'log', exposed: false })
23+
];
24+
return {
25+
version: '1.0.0',
26+
generatedAt: '2026-01-01T00:00:00.000Z',
27+
summary: summarize(findings),
28+
findings,
29+
score: computeScore(findings, undefined, { exposedTables: 10, exposureKnown: true }),
30+
...over
31+
};
32+
}
33+
34+
describe('renderMarkdown', () => {
35+
it('leads with a score table and severity counts', () => {
36+
const out = renderMarkdown(makeReport());
37+
expect(out).toContain('## safegres');
38+
expect(out).toMatch(/\| Dimension \| Score \| Grade \| Top deductions \|/);
39+
expect(out).toMatch(/\| Security \| \*\*[\d.]+\*\* \| \*\*[A-D+]+\*\* \|/);
40+
expect(out).toContain('| 🔴 critical | 🟠 high |');
41+
});
42+
43+
it('renders findings as a table and collapses internal advisories', () => {
44+
const out = renderMarkdown(makeReport());
45+
expect(out).toContain('| 🟠 high | `A2` | app_public.widgets |');
46+
expect(out).toContain('<details><summary>1 internal advisory');
47+
// still present, but behind the fold
48+
expect(out).toContain('db_migrate.log');
49+
});
50+
51+
it('--verbose lifts the internal advisories out of the fold', () => {
52+
const out = renderMarkdown(makeReport(), { verbose: true });
53+
expect(out).not.toContain('<details>');
54+
expect(out).toContain('db_migrate.log');
55+
});
56+
57+
it('--summary stops after the counts', () => {
58+
const out = renderMarkdown(makeReport(), { summary: true });
59+
expect(out).toContain('| 🔴 critical |');
60+
expect(out).not.toContain('app_public.widgets');
61+
});
62+
63+
it('warns when the exposure surface is unknown', () => {
64+
const out = renderMarkdown(
65+
makeReport({
66+
exposure: {
67+
known: false,
68+
source: 'none',
69+
schemas: [],
70+
exposedTables: 0,
71+
totalTables: 12
72+
}
73+
})
74+
);
75+
expect(out).toContain('> [!WARNING]');
76+
expect(out).toContain('score is capped');
77+
});
78+
79+
it('keeps a message containing a pipe inside its cell', () => {
80+
const out = renderMarkdown(
81+
makeReport({ findings: [finding({ message: 'policy uses a | b\n wrapped' })] })
82+
);
83+
expect(out).toContain('policy uses a \\| b wrapped');
84+
expect(out.split('\n').filter((l) => l.startsWith('| 🟠'))).toHaveLength(1);
85+
});
86+
87+
it('renders the perf dimension, its provenance, and the baseline diff', () => {
88+
const perfFinding = finding({
89+
code: 'X1',
90+
severity: 'medium',
91+
category: 'index',
92+
dimension: 'perf',
93+
table: 'posts',
94+
message: 'foreign key posts_author_id_fkey has no covering index'
95+
});
96+
const report = makeReport({
97+
findings: [finding(), perfFinding],
98+
perf: {
99+
findings: [perfFinding],
100+
summary: summarize([perfFinding]),
101+
score: computeScore([perfFinding], undefined, { exposedTables: 10, exposureKnown: true }),
102+
stats: { source: 'live', tables: 4, statsReset: '2026-01-01T00:00:00.000Z', scored: true },
103+
explain: { probed: 3, confirmed: 1, refuted: 1, inconclusive: 1 },
104+
diff: { added: [perfFinding], removed: [], accepted: [] }
105+
}
106+
});
107+
const out = renderMarkdown(report);
108+
expect(out).toContain('| Performance | ');
109+
expect(out).toContain('### Performance findings');
110+
expect(out).toContain('| 🟡 medium | `X1` | app_public.posts |');
111+
// the security table must not carry the perf finding
112+
const securitySection = out.slice(
113+
out.indexOf('### Security findings'),
114+
out.indexOf('### Performance findings')
115+
);
116+
expect(securitySection).not.toContain('X1');
117+
expect(out).toContain('Runtime statistics: 4 tables, counters since 2026-01-01');
118+
expect(out).toContain('Planner proof: 1 confirmed, 1 refuted, 1 inconclusive of 3 probed');
119+
expect(out).toContain('**1 new** since the baseline');
120+
});
121+
122+
it('says so when there is nothing to report', () => {
123+
const out = renderMarkdown(makeReport({ findings: [], score: undefined }));
124+
expect(out).toContain('No findings.');
125+
});
126+
});

packages/safegres/src/cli/audit.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { loadConfig } from '../config/loader';
88
import type { Grade } from '../config/types';
99
import { diffPerf, parsePerfBaseline, serializePerfBaseline, toPerfBaseline } from '../perf/baseline';
1010
import { renderJson } from '../report/json';
11+
import { renderMarkdown } from '../report/markdown';
1112
import { renderPretty } from '../report/pretty';
1213
import { meetsGrade } from '../score/score';
1314
import type { Report, Severity } from '../types';
@@ -98,7 +99,8 @@ Audit options:
9899
--exclude-schemas <csv> Skip these schemas
99100
--roles <csv> Audit grants only for these roles (default: all)
100101
--exclude-roles <csv> Skip grants for these roles
101-
--format <fmt> "pretty" (default) | "json" | "json-pretty"
102+
--format <fmt> "pretty" (default) | "json" | "json-pretty" | "markdown"
103+
(markdown is for CI: a GitHub job summary or PR comment)
102104
--summary, -q Print only exposure, score, and severity counts (no findings)
103105
--verbose Expand internal (non-exposed) advisories (listed as a count otherwise)
104106
--fail-on <severity> Exit non-zero if any finding >= severity
@@ -216,6 +218,13 @@ export default async (
216218
case 'json-pretty':
217219
output = renderJson(report, { pretty: true });
218220
break;
221+
case 'markdown':
222+
case 'md':
223+
output = renderMarkdown(report, {
224+
summary: argv.summary === true,
225+
verbose: argv.verbose === true
226+
});
227+
break;
219228
case 'pretty':
220229
output = renderPretty(report, {
221230
color: colorEnabled,

packages/safegres/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ export {
121121
} from './perf/baseline';
122122
export { renderCallGraph, renderCallGraphDiff } from './report/callgraph';
123123
export { renderJson } from './report/json';
124+
export type { RenderMarkdownOptions } from './report/markdown';
125+
export { renderMarkdown } from './report/markdown';
124126
export { renderPretty } from './report/pretty';
125127
export type { RuleMeta } from './rules/registry';
126128
export { dimensionOf, expandRuleSelector, isKnownRule, RULES, RULES_BY_CODE } from './rules/registry';
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/**
2+
* Markdown rendering (`--format markdown`), for the places CI reads a report:
3+
* a GitHub job summary (`$GITHUB_STEP_SUMMARY`) or a PR comment.
4+
*
5+
* The pretty renderer is a terminal artifact — ANSI colors, fixed-width
6+
* severity labels, a long scrollable list. A PR comment is read once, by
7+
* someone deciding whether to look further, so this renders the same report
8+
* as tables: scores first, then findings grouped by rule, with the noisy
9+
* parts (internal advisories, accepted debt) collapsed into `<details>`.
10+
*/
11+
12+
import type { Score } from '../score/score';
13+
import type { Finding, Report, Severity } from '../types';
14+
15+
const SEV_ICON: Record<Severity, string> = {
16+
critical: '🔴',
17+
high: '🟠',
18+
medium: '🟡',
19+
low: '🔵',
20+
info: '⚪'
21+
};
22+
23+
export interface RenderMarkdownOptions {
24+
/** Scores and counts only — no per-finding tables. */
25+
summary?: boolean;
26+
/** Expand internal (non-exposed) advisories instead of collapsing them. */
27+
verbose?: boolean;
28+
/** Heading text. Default `safegres`. */
29+
title?: string;
30+
}
31+
32+
export function renderMarkdown(report: Report, options: RenderMarkdownOptions = {}): string {
33+
const out: string[] = [`## ${options.title ?? 'safegres'}`, ''];
34+
35+
out.push(...scoreTable(report), '');
36+
37+
if (report.exposure) {
38+
const { known, source, exposedTables, totalTables, roles } = report.exposure;
39+
out.push(
40+
known
41+
? `Exposure (${source}): **${exposedTables}/${totalTables}** tables reachable`
42+
+ `${roles && roles.length > 0 ? ` via \`${roles.join('`, `')}\`` : ''}.`
43+
: '> [!WARNING]\n> Exposure unknown — the entire database is assumed reachable and the score is capped.',
44+
''
45+
);
46+
}
47+
48+
out.push(countsTable(report), '');
49+
if (options.summary) return out.join('\n');
50+
51+
const security = report.perf
52+
? report.findings.filter((f) => f.dimension !== 'perf')
53+
: report.findings;
54+
out.push(...findingSection('Security findings', security, options), '');
55+
56+
if (report.perf) {
57+
out.push(...findingSection('Performance findings', report.perf.findings, options));
58+
const { stats, explain, diff } = report.perf;
59+
const notes: string[] = [];
60+
if (stats) {
61+
notes.push(
62+
`Runtime statistics: ${stats.tables} tables, counters since ${stats.statsReset ?? 'server start'}`
63+
+ `${stats.scored ? '' : ' (advisory — `includeStats` is false)'}.`
64+
);
65+
for (const note of stats.notes ?? []) notes.push(note);
66+
}
67+
if (explain) {
68+
notes.push(
69+
explain.unavailable
70+
?? `Planner proof: ${explain.confirmed} confirmed, ${explain.refuted} refuted, `
71+
+ `${explain.inconclusive} inconclusive of ${explain.probed} probed.`
72+
);
73+
}
74+
if (notes.length > 0) out.push('', notes.map((n) => `_${n}_`).join(' \n'));
75+
76+
if (diff) {
77+
out.push('', '### Performance vs baseline', '');
78+
out.push(
79+
diff.added.length === 0
80+
? `No new perf debt (${diff.accepted.length} accepted, ${diff.removed.length} fixed).`
81+
: `**${diff.added.length} new** since the baseline `
82+
+ `(${diff.accepted.length} accepted, ${diff.removed.length} fixed):`
83+
);
84+
if (diff.added.length > 0) out.push('', findingTable(diff.added));
85+
}
86+
out.push('');
87+
}
88+
89+
return out.join('\n');
90+
}
91+
92+
function scoreTable(report: Report): string[] {
93+
const rows: string[] = [];
94+
if (report.score) rows.push(scoreRow('Security', report.score));
95+
if (report.perf) rows.push(scoreRow('Performance', report.perf.score));
96+
if (rows.length === 0) return [];
97+
return ['| Dimension | Score | Grade | Top deductions |', '| --- | --- | --- | --- |', ...rows];
98+
}
99+
100+
function scoreRow(label: string, score: Score): string {
101+
const top = score.deductions
102+
.slice(0, 3)
103+
.map((d) => `\`${d.code}\` −${d.points}${d.count})`)
104+
.join(', ');
105+
const capped = score.cappedByUnknownExposure ? ' (capped)' : '';
106+
return `| ${label} | **${score.value}**${capped} | **${score.grade}** | ${top || '—'} |`;
107+
}
108+
109+
function countsTable(report: Report): string {
110+
const s = report.summary;
111+
return [
112+
'| 🔴 critical | 🟠 high | 🟡 medium | 🔵 low | ⚪ info |',
113+
'| --- | --- | --- | --- | --- |',
114+
`| ${s.critical} | ${s.high} | ${s.medium} | ${s.low} | ${s.info} |`
115+
].join('\n');
116+
}
117+
118+
function findingSection(
119+
heading: string,
120+
findings: Finding[],
121+
options: RenderMarkdownOptions
122+
): string[] {
123+
const out = [`### ${heading}`, ''];
124+
const exposed = findings.filter((f) => f.exposed !== false);
125+
const internal = findings.filter((f) => f.exposed === false);
126+
127+
if (exposed.length === 0) out.push('No findings.');
128+
else out.push(findingTable(exposed));
129+
130+
if (internal.length > 0) {
131+
const table = findingTable(internal);
132+
out.push(
133+
'',
134+
options.verbose
135+
? table
136+
: `<details><summary>${internal.length} internal `
137+
+ `advisor${internal.length === 1 ? 'y' : 'ies'} — not exposed via any API, `
138+
+ `excluded from the score</summary>\n\n${table}\n\n</details>`
139+
);
140+
}
141+
return out;
142+
}
143+
144+
function findingTable(findings: Array<Pick<Finding, 'code' | 'severity' | 'schema' | 'table' | 'policy' | 'message'>>): string {
145+
const rows = findings.map((f) => {
146+
const where = [f.schema, f.table].filter(Boolean).join('.') || '—';
147+
const policy = f.policy ? ` \`${f.policy}\`` : '';
148+
return `| ${SEV_ICON[f.severity]} ${f.severity} | \`${f.code}\` | ${where}${policy} | ${escapeCell(f.message)} |`;
149+
});
150+
return ['| Severity | Rule | Relation | Finding |', '| --- | --- | --- | --- |', ...rows].join('\n');
151+
}
152+
153+
/** Keep a message inside its table cell: no pipes, no line breaks. */
154+
function escapeCell(value: string): string {
155+
return value.replace(/\|/g, '\\|').replace(/\s*\n\s*/g, ' ');
156+
}

0 commit comments

Comments
 (0)