diff --git a/backend/src/__tests__/sarif-exporter.test.ts b/backend/src/__tests__/sarif-exporter.test.ts new file mode 100644 index 00000000..39841c46 --- /dev/null +++ b/backend/src/__tests__/sarif-exporter.test.ts @@ -0,0 +1,251 @@ +/** + * Unit tests for the SARIF 2.1.0 exporter. + * + * Locks in the schema-visible surface that external tools (GitHub code + * scanning, Defender for Cloud) rely on: level/severity mapping, rule-id + * namespacing, and the `suppressions[]` contract for accepted CVEs. + */ +import { describe, it, expect } from 'vitest'; +import { generateSarif } from '../services/SarifExporter'; +import type { + VulnerabilityScan, + VulnerabilityDetail, + SecretFinding, + MisconfigFinding, +} from '../services/DatabaseService'; + +function mkScan(overrides: Partial = {}): VulnerabilityScan { + return { + id: 1, + node_id: 1, + image_ref: 'alpine:3.19', + image_digest: 'sha256:dead', + scanned_at: 1_700_000_000_000, + total_vulnerabilities: 0, + critical_count: 0, + high_count: 0, + medium_count: 0, + low_count: 0, + unknown_count: 0, + fixable_count: 0, + secret_count: 0, + misconfig_count: 0, + scanners_used: 'vuln', + highest_severity: null, + os_info: null, + trivy_version: '0.56.0', + scan_duration_ms: 1000, + triggered_by: 'manual', + status: 'completed', + error: null, + stack_context: null, + ...overrides, + }; +} + +function vuln(overrides: Partial = {}): VulnerabilityDetail { + return { + id: 1, + scan_id: 1, + vulnerability_id: 'CVE-2024-0001', + pkg_name: 'openssl', + installed_version: '3.0.0', + fixed_version: '3.0.1', + severity: 'HIGH', + title: 'OpenSSL memory corruption', + description: null, + primary_url: 'https://nvd.nist.gov/vuln/CVE-2024-0001', + ...overrides, + }; +} + +describe('generateSarif', () => { + it('emits a schema-compliant root envelope', () => { + const doc = generateSarif(mkScan(), [], [], []); + expect(doc.$schema).toContain('sarif-schema-2.1.0.json'); + expect(doc.version).toBe('2.1.0'); + expect(doc.runs).toHaveLength(1); + expect(doc.runs[0].tool.driver.name).toBe('Trivy (via Sencho)'); + expect(doc.runs[0].tool.driver.version).toBe('0.56.0'); + }); + + it('maps severities to SARIF levels and security-severity scores', () => { + const details = [ + vuln({ vulnerability_id: 'CVE-A', severity: 'CRITICAL' }), + vuln({ vulnerability_id: 'CVE-B', severity: 'HIGH' }), + vuln({ vulnerability_id: 'CVE-C', severity: 'MEDIUM' }), + vuln({ vulnerability_id: 'CVE-D', severity: 'LOW' }), + vuln({ vulnerability_id: 'CVE-E', severity: 'UNKNOWN' }), + ]; + const doc = generateSarif(mkScan(), details, [], []); + const levels = doc.runs[0].results.map((r) => r.level); + expect(levels).toEqual(['error', 'error', 'warning', 'note', 'none']); + const scores = doc.runs[0].results.map((r) => r.properties?.['security-severity']); + expect(scores).toEqual(['9.8', '7.5', '5.0', '2.5', '0.0']); + }); + + it('namespaces secret and misconfig rule IDs to avoid collisions', () => { + const secrets: SecretFinding[] = [ + { + id: 1, + scan_id: 1, + rule_id: 'aws-access-key-id', + category: 'AWS', + severity: 'CRITICAL', + title: 'AWS Access Key', + target: 'app/.env', + start_line: 4, + end_line: 4, + match_excerpt: 'AKIA1234...', + }, + ]; + const misconfigs: MisconfigFinding[] = [ + { + id: 1, + scan_id: 1, + rule_id: 'DS002', + check_id: 'AVD-DS-0002', + severity: 'HIGH', + title: 'Running as root', + message: 'Specify a non-root user', + resolution: 'Set user:', + target: 'docker-compose.yml', + primary_url: null, + }, + ]; + const doc = generateSarif(mkScan(), [vuln()], secrets, misconfigs); + const ruleIds = doc.runs[0].tool.driver.rules.map((r) => r.id); + expect(ruleIds).toContain('CVE-2024-0001'); + expect(ruleIds).toContain('SECRET:aws-access-key-id'); + expect(ruleIds).toContain('MISCONFIG:DS002'); + const resultRuleIds = doc.runs[0].results.map((r) => r.ruleId); + expect(resultRuleIds).toEqual([ + 'CVE-2024-0001', + 'SECRET:aws-access-key-id', + 'MISCONFIG:DS002', + ]); + }); + + it('attaches SARIF suppressions[] when a finding is marked suppressed', () => { + const suppressed = { + ...vuln(), + suppressed: true, + suppression_id: 7, + suppression_reason: 'Not exploitable in our config', + }; + const doc = generateSarif(mkScan(), [suppressed], [], []); + const result = doc.runs[0].results[0]; + expect(result.suppressions).toHaveLength(1); + expect(result.suppressions?.[0]).toEqual({ + kind: 'external', + status: 'accepted', + justification: 'Not exploitable in our config', + }); + }); + + it('omits suppressions when finding is not suppressed', () => { + const doc = generateSarif(mkScan(), [vuln()], [], []); + expect(doc.runs[0].results[0].suppressions).toBeUndefined(); + }); + + it('uses "Suppressed in Sencho" as justification fallback when reason is blank', () => { + const suppressed = { ...vuln(), suppressed: true, suppression_reason: ' ' }; + const doc = generateSarif(mkScan(), [suppressed], [], []); + expect(doc.runs[0].results[0].suppressions?.[0].justification).toBe('Suppressed in Sencho'); + }); + + it('deduplicates rules across findings with the same CVE id', () => { + const details = [ + vuln({ pkg_name: 'openssl' }), + vuln({ pkg_name: 'libcrypto' }), + ]; + const doc = generateSarif(mkScan(), details, [], []); + expect(doc.runs[0].tool.driver.rules).toHaveLength(1); + expect(doc.runs[0].results).toHaveLength(2); + }); + + it('embeds package identity as a logical location on vulnerability results', () => { + const doc = generateSarif(mkScan({ image_ref: 'nginx:1.25' }), [vuln()], [], []); + const loc = doc.runs[0].results[0].locations[0]; + expect(loc.physicalLocation.artifactLocation.uri).toBe('nginx:1.25'); + expect(loc.logicalLocations?.[0]).toMatchObject({ + name: 'openssl', + fullyQualifiedName: 'openssl@3.0.0', + kind: 'package', + }); + }); + + it('places secrets at target:line via physicalLocation.region', () => { + const secret: SecretFinding = { + id: 1, + scan_id: 1, + rule_id: 'gh-token', + category: 'GitHub', + severity: 'HIGH', + title: 'GitHub token', + target: 'src/config.ts', + start_line: 42, + end_line: null, + match_excerpt: 'ghp_abcd...', + }; + const doc = generateSarif(mkScan(), [], [secret], []); + const loc = doc.runs[0].results[0].locations[0]; + expect(loc.physicalLocation.artifactLocation.uri).toBe('src/config.ts'); + expect(loc.physicalLocation.region).toEqual({ startLine: 42, endLine: 42 }); + }); + + it('does not embed the secret match excerpt in the SARIF message', () => { + const secret: SecretFinding = { + id: 1, + scan_id: 1, + rule_id: 'gh-token', + category: 'GitHub', + severity: 'CRITICAL', + title: 'GitHub token', + target: 'src/config.ts', + start_line: 1, + end_line: 1, + match_excerpt: 'ghp_abcd...', + }; + const doc = generateSarif(mkScan(), [], [secret], []); + expect(doc.runs[0].results[0].message.text).toBe('GitHub token'); + const serialized = JSON.stringify(doc); + expect(serialized).not.toContain('ghp_abcd'); + }); + + it('populates helpUri from primary_url when set, omits it otherwise', () => { + const withUrl = generateSarif( + mkScan(), + [vuln({ primary_url: 'https://nvd.nist.gov/x' })], + [], + [], + ); + expect(withUrl.runs[0].tool.driver.rules[0].helpUri).toBe('https://nvd.nist.gov/x'); + + const withoutUrl = generateSarif(mkScan(), [vuln({ primary_url: null })], [], []); + expect(withoutUrl.runs[0].tool.driver.rules[0].helpUri).toBeUndefined(); + }); + + it('emits misconfig Fix segment in the result message when resolution is set', () => { + const m: MisconfigFinding = { + id: 1, + scan_id: 1, + rule_id: 'DS002', + check_id: 'AVD-DS-0002', + severity: 'HIGH', + title: 'Running as root', + message: 'Specify a non-root user', + resolution: 'Set user: 1000', + target: 'docker-compose.yml', + primary_url: null, + }; + const doc = generateSarif(mkScan(), [], [], [m]); + expect(doc.runs[0].results[0].message.text).toContain('Fix: Set user: 1000'); + }); + + it('handles an empty scan without throwing', () => { + const doc = generateSarif(mkScan(), [], [], []); + expect(doc.runs[0].results).toEqual([]); + expect(doc.runs[0].tool.driver.rules).toEqual([]); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index 9c1b241f..eeb63c1f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -75,6 +75,7 @@ import TrivyInstaller from './services/TrivyInstaller'; import { severityRank } from './utils/severity'; import { validateImageRef } from './utils/image-ref'; import { applySuppressions } from './utils/suppression-filter'; +import { generateSarif } from './services/SarifExporter'; import semver from 'semver'; import { CronExpressionParser } from 'cron-parser'; import { isValidStackName, isValidRemoteUrl, isPathWithinBase, isValidCidr, isValidIPv4, isValidDockerResourceId } from './utils/validation'; @@ -7684,6 +7685,55 @@ app.post('/api/security/sbom', authMiddleware, async (req: Request, res: Respons } }); +app.get( + '/api/security/scans/:scanId/sarif', + authMiddleware, + (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + if (!requirePaid(req, res)) return; + const scanId = Number(req.params.scanId); + if (!Number.isFinite(scanId)) { + res.status(400).json({ error: 'Invalid scan id' }); return; + } + const db = DatabaseService.getInstance(); + const scan = db.getVulnerabilityScan(scanId); + if (!scan || scan.node_id !== req.nodeId) { + res.status(404).json({ error: 'Scan not found' }); return; + } + if (scan.status !== 'completed') { + res.status(409).json({ error: 'Scan not complete' }); return; + } + const fetchAll = ( + q: (opts: { limit?: number; offset?: number }) => { items: T[]; total: number }, + ): T[] => { + const pageSize = 1000; + const collected: T[] = []; + let offset = 0; + while (true) { + const page = q({ limit: pageSize, offset }); + collected.push(...page.items); + if (collected.length >= page.total || page.items.length === 0) break; + offset += page.items.length; + } + return collected; + }; + try { + const details = fetchAll((opts) => db.getVulnerabilityDetails(scanId, opts)); + const secrets = fetchAll((opts) => db.getSecretFindings(scanId, opts)); + const misconfigs = fetchAll((opts) => db.getMisconfigFindings(scanId, opts)); + const suppressed = applySuppressions(details, scan.image_ref, db.getCveSuppressions()); + const sarif = generateSarif(scan, suppressed, secrets, misconfigs); + const safeName = scan.image_ref.replace(/[^a-zA-Z0-9._-]/g, '_') || `scan-${scanId}`; + res.setHeader('Content-Type', 'application/sarif+json'); + res.setHeader('Content-Disposition', `attachment; filename="${safeName}.sarif.json"`); + res.send(JSON.stringify(sarif)); + } catch (error) { + console.error('[Security] SARIF export failed:', error); + res.status(500).json({ error: (error as Error).message || 'Failed to generate SARIF' }); + } + }, +); + app.get('/api/security/policies', authMiddleware, (req: Request, res: Response): void => { if (!requirePaid(req, res)) return; res.json(DatabaseService.getInstance().getScanPolicies()); diff --git a/backend/src/services/SarifExporter.ts b/backend/src/services/SarifExporter.ts new file mode 100644 index 00000000..038de4b6 --- /dev/null +++ b/backend/src/services/SarifExporter.ts @@ -0,0 +1,249 @@ +/** + * Builds SARIF 2.1.0 documents from stored Trivy scan results. + * + * The exporter does not shell out to `trivy --format sarif`: emitting from the + * DB keeps the output consistent with what the UI shows (same suppressions, + * same counts, no rescan latency) and supports stack misconfig scans. + * + * Rule IDs are namespaced so secrets and misconfigs don't collide with CVEs in + * a flat result list: ``, `SECRET:`, `MISCONFIG:`. + */ +import type { + VulnerabilityScan, + VulnerabilityDetail, + SecretFinding, + MisconfigFinding, + VulnSeverity, +} from './DatabaseService'; +import type { SuppressionDecision } from '../utils/suppression-filter'; + +export interface SarifSuppression { + kind: 'external'; + status: 'accepted'; + justification: string; +} + +export interface SarifRule { + id: string; + name: string; + shortDescription: { text: string }; + fullDescription?: { text: string }; + helpUri?: string; + properties: { + 'security-severity': string; + tags: string[]; + }; +} + +export interface SarifResult { + ruleId: string; + level: 'error' | 'warning' | 'note' | 'none'; + message: { text: string }; + locations: Array<{ + physicalLocation: { + artifactLocation: { uri: string }; + region?: { startLine: number; endLine?: number }; + }; + logicalLocations?: Array<{ name: string; fullyQualifiedName: string; kind: string }>; + }>; + suppressions?: SarifSuppression[]; + properties?: { 'security-severity': string; tags?: string[] }; +} + +export interface SarifDocument { + $schema: string; + version: '2.1.0'; + runs: Array<{ + tool: { + driver: { + name: string; + informationUri: string; + version?: string; + rules: SarifRule[]; + }; + }; + results: SarifResult[]; + }>; +} + +type SuppressedVulnerability = VulnerabilityDetail & Partial; + +const SEVERITY_TO_LEVEL: Record = { + CRITICAL: 'error', + HIGH: 'error', + MEDIUM: 'warning', + LOW: 'note', + UNKNOWN: 'none', +}; + +const SEVERITY_TO_SCORE: Record = { + CRITICAL: '9.8', + HIGH: '7.5', + MEDIUM: '5.0', + LOW: '2.5', + UNKNOWN: '0.0', +}; + +const SARIF_SCHEMA = + 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json'; + +function toSuppressions(decision: Partial): SarifSuppression[] | undefined { + if (!decision.suppressed) return undefined; + return [ + { + kind: 'external', + status: 'accepted', + justification: decision.suppression_reason?.trim() || 'Suppressed in Sencho', + }, + ]; +} + +function vulnRule(detail: VulnerabilityDetail): SarifRule { + return { + id: detail.vulnerability_id, + name: detail.vulnerability_id, + shortDescription: { text: detail.title || detail.vulnerability_id }, + fullDescription: detail.description ? { text: detail.description } : undefined, + helpUri: detail.primary_url || undefined, + properties: { + 'security-severity': SEVERITY_TO_SCORE[detail.severity], + tags: ['security', 'vulnerability'], + }, + }; +} + +function secretRule(finding: SecretFinding): SarifRule { + const id = `SECRET:${finding.rule_id}`; + return { + id, + name: id, + shortDescription: { text: finding.title || finding.rule_id }, + properties: { + 'security-severity': SEVERITY_TO_SCORE[finding.severity], + tags: ['security', 'secret', finding.category ?? 'unknown'], + }, + }; +} + +function misconfigRule(finding: MisconfigFinding): SarifRule { + const id = `MISCONFIG:${finding.rule_id}`; + return { + id, + name: id, + shortDescription: { text: finding.title || finding.rule_id }, + fullDescription: finding.message ? { text: finding.message } : undefined, + helpUri: finding.primary_url || undefined, + properties: { + 'security-severity': SEVERITY_TO_SCORE[finding.severity], + tags: ['security', 'misconfiguration'], + }, + }; +} + +function vulnResult( + detail: SuppressedVulnerability, + imageRef: string, +): SarifResult { + const messageParts = [ + detail.title || detail.vulnerability_id, + `Package: ${detail.pkg_name} ${detail.installed_version}`, + ]; + if (detail.fixed_version) messageParts.push(`Fixed in: ${detail.fixed_version}`); + return { + ruleId: detail.vulnerability_id, + level: SEVERITY_TO_LEVEL[detail.severity], + message: { text: messageParts.join(' | ') }, + locations: [ + { + physicalLocation: { artifactLocation: { uri: imageRef } }, + logicalLocations: [ + { + name: detail.pkg_name, + fullyQualifiedName: `${detail.pkg_name}@${detail.installed_version}`, + kind: 'package', + }, + ], + }, + ], + suppressions: toSuppressions(detail), + properties: { 'security-severity': SEVERITY_TO_SCORE[detail.severity] }, + }; +} + +function secretResult(finding: SecretFinding): SarifResult { + const region = + finding.start_line != null + ? { startLine: finding.start_line, endLine: finding.end_line ?? finding.start_line } + : undefined; + return { + ruleId: `SECRET:${finding.rule_id}`, + level: SEVERITY_TO_LEVEL[finding.severity], + message: { text: finding.title || finding.rule_id }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: finding.target }, + region, + }, + }, + ], + properties: { 'security-severity': SEVERITY_TO_SCORE[finding.severity] }, + }; +} + +function misconfigResult(finding: MisconfigFinding): SarifResult { + const parts = [finding.title || finding.rule_id]; + if (finding.message) parts.push(finding.message); + if (finding.resolution) parts.push(`Fix: ${finding.resolution}`); + return { + ruleId: `MISCONFIG:${finding.rule_id}`, + level: SEVERITY_TO_LEVEL[finding.severity], + message: { text: parts.join(' | ') }, + locations: [ + { physicalLocation: { artifactLocation: { uri: finding.target } } }, + ], + properties: { 'security-severity': SEVERITY_TO_SCORE[finding.severity] }, + }; +} + +export function generateSarif( + scan: VulnerabilityScan, + vulnerabilities: SuppressedVulnerability[], + secrets: SecretFinding[], + misconfigs: MisconfigFinding[], +): SarifDocument { + const rules = new Map(); + for (const v of vulnerabilities) if (!rules.has(v.vulnerability_id)) rules.set(v.vulnerability_id, vulnRule(v)); + for (const s of secrets) { + const id = `SECRET:${s.rule_id}`; + if (!rules.has(id)) rules.set(id, secretRule(s)); + } + for (const m of misconfigs) { + const id = `MISCONFIG:${m.rule_id}`; + if (!rules.has(id)) rules.set(id, misconfigRule(m)); + } + + const results: SarifResult[] = [ + ...vulnerabilities.map((v) => vulnResult(v, scan.image_ref)), + ...secrets.map((s) => secretResult(s)), + ...misconfigs.map((m) => misconfigResult(m)), + ]; + + return { + $schema: SARIF_SCHEMA, + version: '2.1.0', + runs: [ + { + tool: { + driver: { + name: 'Trivy (via Sencho)', + informationUri: 'https://github.com/aquasecurity/trivy', + version: scan.trivy_version ?? undefined, + rules: Array.from(rules.values()), + }, + }, + results, + }, + ], + }; +} diff --git a/docs/features/vulnerability-scanning.mdx b/docs/features/vulnerability-scanning.mdx index 3bac169c..7aa8a7e9 100644 --- a/docs/features/vulnerability-scanning.mdx +++ b/docs/features/vulnerability-scanning.mdx @@ -27,6 +27,7 @@ The Trivy CLI must be available on the machine running Sencho. Trivy is not bund | Scan history and comparison | | ✓ | ✓ | | Secret detection in image filesystems | | ✓ | ✓ | | Compose file misconfiguration scanning | | ✓ | ✓ | +| SARIF export (code scanning integration) | | ✓ | ✓ | ## On-demand scanning @@ -77,6 +78,7 @@ From the drawer header you can: - **Re-scan**: kick off a fresh scan, ignoring the digest cache. - **Download SBOM**: export a Software Bill of Materials in SPDX JSON or CycloneDX format (Skipper and Admiral). - **Export CSV**: export the full vulnerability list for offline review. +- **Export SARIF**: download the full scan (vulnerabilities, secrets, and misconfigs) as SARIF 2.1.0 for upload to GitHub code scanning or other SARIF-aware tooling (Skipper and Admiral). ## Post-deploy automated scanning @@ -209,6 +211,32 @@ From any stack page, click **Scan config** next to the Deploy controls. Sencho r Config scans are stored in the same history as image scans with an `image_ref` of `stack:`, so they appear on the Scan history page and can be exported as CSV. +## SARIF export + + + SARIF export requires a **Skipper** or **Admiral** license. + + +SARIF (Static Analysis Results Interchange Format) is the standard format supported by GitHub code scanning, Microsoft Defender for Cloud, and most security dashboards. Sencho generates SARIF 2.1.0 documents directly from the stored scan results so the download matches what you see in the drawer (same findings, same suppression state) without re-running Trivy. + +From the scan drawer header, click **SARIF** to download the report. The file is named after the image reference with a `.sarif.json` extension. + +What the export contains: + +- **Vulnerabilities**: one SARIF result per CVE, with `security-severity` scored 9.8 (CRITICAL), 7.5 (HIGH), 5.0 (MEDIUM), 2.5 (LOW), or 0.0 (UNKNOWN). The affected package appears as a logical location (`@`). +- **Secrets**: rule IDs are namespaced as `SECRET:`. Results point at the file and line number where the match was found. +- **Misconfigs**: rule IDs are namespaced as `MISCONFIG:`. Results point at the Compose file that triggered the check. +- **Suppressions**: CVEs you have suppressed in Sencho are emitted with a SARIF `suppressions` entry of kind `external` and status `accepted`, so code-scanning dashboards can dismiss them with the justification you recorded. + +Typical upload flow for GitHub code scanning: + +```yaml +- name: Upload SARIF to GitHub + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: sencho-scan.sarif.json +``` + ## Scan history Every scan Sencho runs is stored with its full vulnerability detail. Scan records are automatically pruned after 90 days to keep the database compact. The history is used to power: @@ -299,3 +327,7 @@ The button is only shown when Trivy is available on the stack's node, the curren ### Compose misconfiguration scan returns 404 The scanner needs to locate a Compose file in the stack directory. If the stack was created outside Sencho and the working directory does not contain a file named `compose.yml`, `compose.yaml`, `docker-compose.yml`, or `docker-compose.yaml`, the scan returns 404. Name the file accordingly or keep the stack under Sencho's managed compose directory. + +### SARIF download returns 409 "Scan not complete" + +SARIF export requires a completed scan. If a scan failed, timed out, or is still running, the button downloads nothing and the server returns a 409. Trigger a fresh scan from the Resources Hub or the stack page, wait for the drawer to populate, then export again. diff --git a/frontend/src/components/VulnerabilityScanSheet.tsx b/frontend/src/components/VulnerabilityScanSheet.tsx index 7c836475..096c4311 100644 --- a/frontend/src/components/VulnerabilityScanSheet.tsx +++ b/frontend/src/components/VulnerabilityScanSheet.tsx @@ -345,6 +345,30 @@ export function VulnerabilityScanSheet({ URL.revokeObjectURL(url); }, [scan, details]); + const exportSarif = useCallback(async () => { + if (!scan) return; + try { + const res = await apiFetch(`/security/scans/${scan.id}/sarif`); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body?.error || 'Failed to generate SARIF'); + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${scan.image_ref.replace(/[^a-z0-9]+/gi, '_')}.sarif.json`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + toast.success('SARIF downloaded'); + } catch (err) { + const error = err as { message?: string; error?: string; data?: { error?: string } }; + toast.error(error?.message || error?.error || error?.data?.error || 'SARIF export failed'); + } + }, [scan]); + return ( !open && onClose()}> @@ -459,6 +483,18 @@ export function VulnerabilityScanSheet({ CSV + {canGenerateSbom && ( + + )} {canCompare && (