mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-26 02:06:49 +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:
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user