|
| 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 | +}); |
0 commit comments