mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 03:36:59 +00:00
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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user