Skip to content

Commit 842258a

Browse files
authored
Merge pull request #1557 from constructive-io/feat/safegres-sarif
feat(safegres): SARIF output for GitHub code scanning
2 parents 5df1c26 + 7b3ec46 commit 842258a

6 files changed

Lines changed: 425 additions & 2 deletions

File tree

.agents/skills/safegres/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ safegres audit --stats # + runtime-statistics rules S1-S4 (implies -
223223
safegres audit --explain # + prove findings with EXPLAIN (implies --perf, PG16+)
224224
safegres audit --format json # machine-readable
225225
safegres audit --format markdown >> "$GITHUB_STEP_SUMMARY" # CI job summary / PR comment
226+
safegres audit --format sarif --sarif-sources ./deploy # GitHub code scanning (upload-sarif)
226227
safegres audit --exposed-only # hide internal advisories
227228
safegres doctor # config/parser/connection/catalog + exposure + stale public.read checks
228229
safegres print-config # resolved effective config

packages/safegres/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Pretty output prints the exposure line, score, and the exposed findings. Interna
3535
- `--exposed-only` — drop internal findings entirely.
3636
- `--format json` / `--format json-pretty` — machine-readable output (always carries every finding).
3737
- `--format markdown` — the same report as GitHub-flavoured markdown, for a job summary or a PR comment (see [CI](#ci)).
38+
- `--format sarif` — SARIF 2.1.0 for GitHub code scanning (see [CI](#ci)).
3839

3940
### CI
4041

@@ -53,6 +54,20 @@ A plain audit is one command and one gate; `--format markdown` writes the report
5354
5455
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)`.
5556

57+
#### Code scanning (SARIF)
58+
59+
`--format sarif` emits SARIF 2.1.0, so findings become GitHub code-scanning alerts — Security tab, inline PR annotations, dismissals that stick:
60+
61+
```yaml
62+
- run: npx safegres audit --perf --format sarif --sarif-sources ./deploy > safegres.sarif
63+
- uses: github/codeql-action/upload-sarif@v3
64+
with: { sarif_file: safegres.sarif }
65+
```
66+
67+
An alert needs a file and a line, but safegres reads the catalog — a live database has no source location. `--sarif-sources <dir>` scans that directory's `.sql` for the `CREATE TABLE` / `CREATE POLICY` that defines each object, so a finding on `app_public.widgets` points at the migration that created it (policy findings resolve to the `CREATE POLICY` line). Findings that don't resolve are still emitted, without a location — GitHub drops those, other SARIF consumers keep them.
68+
69+
Results are fingerprinted by finding *identity* (code + relation + policy + subject, the same key the [perf baseline](#perf-baseline-the-ratchet) uses), never by message text, so rewording a rule in a later release doesn't close and reopen every alert. Perf rules are tagged `performance`, security rules `security`.
70+
5671
## What it checks
5772

5873
| Code | Severity | Direction | Category | Check |
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { mkdtempSync, mkdirSync, writeFileSync } from 'fs';
2+
import { tmpdir } from 'os';
3+
import { join } from 'path';
4+
5+
import { buildSourceIndex, renderSarif } from '../src/report/sarif';
6+
import { computeScore } from '../src/score/score';
7+
import type { Finding, Report } from '../src/types';
8+
import { summarize } from '../src/types';
9+
10+
function finding(over: Partial<Finding> = {}): Finding {
11+
return {
12+
code: 'A2',
13+
severity: 'high',
14+
category: 'flags',
15+
direction: 'fail-open',
16+
exposed: true,
17+
schema: 'app_public',
18+
table: 'widgets',
19+
message: 'grants exist on a table with RLS disabled',
20+
...over
21+
};
22+
}
23+
24+
function makeReport(findings: Finding[]): Report {
25+
return {
26+
version: '1.0.0',
27+
generatedAt: '2026-01-01T00:00:00.000Z',
28+
summary: summarize(findings),
29+
findings,
30+
score: computeScore(findings, undefined, { exposedTables: 10, exposureKnown: true })
31+
};
32+
}
33+
34+
function parse(findings: Finding[], options?: Parameters<typeof renderSarif>[1]) {
35+
return JSON.parse(renderSarif(makeReport(findings), options));
36+
}
37+
38+
describe('renderSarif', () => {
39+
it('emits a valid-shaped SARIF 2.1.0 run', () => {
40+
const sarif = parse([finding()]);
41+
expect(sarif.version).toBe('2.1.0');
42+
expect(sarif.$schema).toContain('sarif-schema-2.1.0');
43+
expect(sarif.runs).toHaveLength(1);
44+
expect(sarif.runs[0].tool.driver.name).toBe('safegres');
45+
expect(sarif.runs[0].tool.driver.semanticVersion).toBe('1.0.0');
46+
});
47+
48+
it('declares only the rules it emitted, with GitHub severity metadata', () => {
49+
const sarif = parse([finding(), finding({ code: 'A1', severity: 'low', direction: 'fail-closed' })]);
50+
const rules = sarif.runs[0].tool.driver.rules;
51+
expect(rules.map((r: { id: string }) => r.id).sort()).toEqual(['A1', 'A2']);
52+
const a2 = rules.find((r: { id: string }) => r.id === 'A2');
53+
expect(a2.defaultConfiguration.level).toBe('error');
54+
expect(a2.properties['security-severity']).toBe('7.0');
55+
expect(a2.properties.tags).toContain('security');
56+
expect(a2.shortDescription.text).toContain('RLS disabled');
57+
});
58+
59+
it('tags perf rules as performance so they are separable from security alerts', () => {
60+
const sarif = parse([
61+
finding({ code: 'X1', severity: 'medium', category: 'index', dimension: 'perf', direction: 'neutral' })
62+
]);
63+
const rule = sarif.runs[0].tool.driver.rules[0];
64+
expect(rule.properties.tags).toContain('performance');
65+
expect(sarif.runs[0].results[0].properties.dimension).toBe('perf');
66+
});
67+
68+
it('maps severities onto SARIF levels', () => {
69+
const sarif = parse([
70+
finding({ code: 'A7', severity: 'critical' }),
71+
finding({ code: 'R3', severity: 'medium' }),
72+
finding({ code: 'A6', severity: 'info' })
73+
]);
74+
expect(sarif.runs[0].results.map((r: { level: string }) => r.level)).toEqual(['error', 'warning', 'note']);
75+
});
76+
77+
it('fingerprints results by identity, not message text', () => {
78+
const before = parse([finding()]).runs[0].results[0].partialFingerprints;
79+
const after = parse([finding({ message: 'completely reworded in a later release' })])
80+
.runs[0].results[0].partialFingerprints;
81+
expect(after).toEqual(before);
82+
expect(before.safegresFindingKey).toContain('A2|app_public|widgets');
83+
});
84+
85+
it('carries both scores as run properties', () => {
86+
const report = makeReport([finding()]);
87+
const perfFinding = finding({ code: 'X1', dimension: 'perf', severity: 'medium', category: 'index' });
88+
report.perf = {
89+
findings: [perfFinding],
90+
summary: summarize([perfFinding]),
91+
score: computeScore([perfFinding], undefined, { exposedTables: 10, exposureKnown: true })
92+
};
93+
const sarif = JSON.parse(renderSarif(report));
94+
expect(sarif.runs[0].properties.grade).toBeDefined();
95+
expect(sarif.runs[0].properties.perfGrade).toBeDefined();
96+
});
97+
98+
it('emits an empty location list when nothing resolves', () => {
99+
const sarif = parse([finding()]);
100+
expect(sarif.runs[0].results[0].locations).toEqual([]);
101+
expect(sarif.runs[0].results[0].message.text).toBe(
102+
'app_public.widgets: grants exist on a table with RLS disabled'
103+
);
104+
});
105+
});
106+
107+
describe('buildSourceIndex', () => {
108+
let dir: string;
109+
110+
beforeAll(() => {
111+
dir = mkdtempSync(join(tmpdir(), 'safegres-sarif-'));
112+
mkdirSync(join(dir, 'deploy'), { recursive: true });
113+
mkdirSync(join(dir, 'node_modules'), { recursive: true });
114+
writeFileSync(
115+
join(dir, 'deploy', 'widgets.sql'),
116+
[
117+
'-- Deploy widgets',
118+
'BEGIN;',
119+
'CREATE TABLE app_public.widgets (',
120+
' id uuid primary key',
121+
');',
122+
'ALTER TABLE app_public.widgets ENABLE ROW LEVEL SECURITY;',
123+
'CREATE POLICY widgets_select ON app_public.widgets FOR SELECT USING (true);',
124+
'COMMIT;'
125+
].join('\n')
126+
);
127+
writeFileSync(
128+
join(dir, 'deploy', 'quoted.sql'),
129+
'CREATE TABLE IF NOT EXISTS "Mixed"."Case" (id int);\n'
130+
);
131+
// must be ignored
132+
writeFileSync(join(dir, 'node_modules', 'other.sql'), 'CREATE TABLE app_public.widgets (id int);\n');
133+
writeFileSync(join(dir, 'deploy', 'notes.md'), 'CREATE TABLE app_public.decoy (id int);\n');
134+
});
135+
136+
it('indexes tables and policies with POSIX-relative paths', () => {
137+
const index = buildSourceIndex(dir);
138+
expect(index.get('app_public.widgets')).toEqual({ file: 'deploy/widgets.sql', line: 3 });
139+
expect(index.get('app_public.widgets:widgets_select')).toEqual({ file: 'deploy/widgets.sql', line: 7 });
140+
});
141+
142+
it('skips node_modules and non-SQL files', () => {
143+
const index = buildSourceIndex(dir);
144+
expect(index.get('app_public.widgets')?.file).toBe('deploy/widgets.sql');
145+
expect(index.has('app_public.decoy')).toBe(false);
146+
});
147+
148+
it('preserves quoted identifiers and folds unquoted ones', () => {
149+
const index = buildSourceIndex(dir);
150+
expect(index.has('Mixed.Case')).toBe(true);
151+
});
152+
153+
it('points results at the policy line when the finding names a policy', () => {
154+
const sources = buildSourceIndex(dir);
155+
const sarif = parse(
156+
[
157+
finding({ code: 'A8', severity: 'low', category: 'anti-pattern', policy: 'widgets_select' }),
158+
finding()
159+
],
160+
{ sources }
161+
);
162+
const [policyResult, tableResult] = sarif.runs[0].results;
163+
expect(policyResult.locations[0].physicalLocation).toEqual({
164+
artifactLocation: { uri: 'deploy/widgets.sql' },
165+
region: { startLine: 7 }
166+
});
167+
expect(tableResult.locations[0].physicalLocation.region.startLine).toBe(3);
168+
});
169+
});

packages/safegres/src/cli/audit.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { diffPerf, parsePerfBaseline, serializePerfBaseline, toPerfBaseline } fr
1010
import { renderJson } from '../report/json';
1111
import { renderMarkdown } from '../report/markdown';
1212
import { renderPretty } from '../report/pretty';
13+
import { buildSourceIndex, renderSarif } from '../report/sarif';
1314
import { meetsGrade } from '../score/score';
1415
import type { Report, Severity } from '../types';
1516
import { meetsThreshold, SEVERITY_ORDER, summarize } from '../types';
@@ -99,8 +100,12 @@ Audit options:
99100
--exclude-schemas <csv> Skip these schemas
100101
--roles <csv> Audit grants only for these roles (default: all)
101102
--exclude-roles <csv> Skip grants for these roles
102-
--format <fmt> "pretty" (default) | "json" | "json-pretty" | "markdown"
103-
(markdown is for CI: a GitHub job summary or PR comment)
103+
--format <fmt> "pretty" (default) | "json" | "json-pretty" | "markdown" | "sarif"
104+
(markdown is for CI: a GitHub job summary or PR comment;
105+
sarif uploads to GitHub code scanning)
106+
--sarif-sources <dir> With --format sarif: scan <dir> for the CREATE TABLE /
107+
CREATE POLICY that defines each object, so alerts point
108+
at the SQL that produced the finding
104109
--summary, -q Print only exposure, score, and severity counts (no findings)
105110
--verbose Expand internal (non-exposed) advisories (listed as a count otherwise)
106111
--fail-on <severity> Exit non-zero if any finding >= severity
@@ -218,6 +223,13 @@ export default async (
218223
case 'json-pretty':
219224
output = renderJson(report, { pretty: true });
220225
break;
226+
case 'sarif':
227+
output = renderSarif(report, {
228+
sources: typeof argv['sarif-sources'] === 'string'
229+
? buildSourceIndex(argv['sarif-sources'])
230+
: undefined
231+
});
232+
break;
221233
case 'markdown':
222234
case 'md':
223235
output = renderMarkdown(report, {

packages/safegres/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ export { renderJson } from './report/json';
124124
export type { RenderMarkdownOptions } from './report/markdown';
125125
export { renderMarkdown } from './report/markdown';
126126
export { renderPretty } from './report/pretty';
127+
export type { BuildSourceIndexOptions, RenderSarifOptions, SourceIndex, SourceLocation } from './report/sarif';
128+
export { buildSourceIndex, renderSarif } from './report/sarif';
127129
export type { RuleMeta } from './rules/registry';
128130
export { dimensionOf, expandRuleSelector, isKnownRule, RULES, RULES_BY_CODE } from './rules/registry';
129131
export type { Score, ScoreContext, ScoreDeduction } from './score/score';

0 commit comments

Comments
 (0)