feat(security): export scan results as SARIF 2.1.0 (#652)

Adds a new SarifExporter service that builds a SARIF 2.1.0 document
from the stored scan findings (vulnerabilities, secrets, misconfigs).
Rule IDs are namespaced to avoid collisions in a flat result list.
Suppressions carry through as SARIF suppressions[] entries so GitHub
code scanning and Defender for Cloud see the same accepted status
shown in the UI.

Exposed via GET /api/security/scans/:id/sarif, admin + paid-tier
gated to match the SBOM export precedent. A SARIF button appears
in the scan sheet next to SBOM and CSV for paid tiers.
This commit is contained in:
Anso
2026-04-17 08:17:36 -04:00
committed by GitHub
parent a95bf1ff33
commit 12bbf86dc4
5 changed files with 618 additions and 0 deletions
@@ -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> = {}): 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> = {}): 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([]);
});
});
+50
View File
@@ -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 = <T,>(
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());
+249
View File
@@ -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: `<CVE-...>`, `SECRET:<rule>`, `MISCONFIG:<rule>`.
*/
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<SuppressionDecision>;
const SEVERITY_TO_LEVEL: Record<VulnSeverity, 'error' | 'warning' | 'note' | 'none'> = {
CRITICAL: 'error',
HIGH: 'error',
MEDIUM: 'warning',
LOW: 'note',
UNKNOWN: 'none',
};
const SEVERITY_TO_SCORE: Record<VulnSeverity, string> = {
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<SuppressionDecision>): 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<string, SarifRule>();
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,
},
],
};
}