mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 14:33:19 +00:00
fix: keep security posture accurate for secret-only scans and any-severity KEVs (#1475)
The Security overview and exploit-intel surfaces picked the latest scan per image without restricting to scans that ran the vulnerability scanner, and counted known-exploited (KEV) findings only among Critical/High. Two effects: - A newer secret-only node scan became the latest scan for an image and clobbered its Critical/High/fixable/KEV posture to zero, which could read a false Secure state. - A Medium or Low severity KEV that the pre-deploy gate blocks on produced zero overview and exploit-intel rows, so the page disagreed with the gate. Posture queries now select the latest vulnerability-bearing scan per image, the image summary sources its vulnerability counts from that scan via a LEFT JOIN while still counting secret and misconfiguration findings from the latest scan overall, and knownExploited is counted from a dedicated any-severity KEV query that mirrors the gate.
This commit is contained in:
@@ -154,6 +154,136 @@ describe('countEligibleBlockPolicies (replica)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getImageScanSummaries', () => {
|
||||
function seedScan(o: { imageRef: string; scannersUsed: string; scannedAt: number; critical?: number; high?: number; secret?: number; misconfig?: number }): number {
|
||||
return db().createVulnerabilityScan({
|
||||
node_id: 1,
|
||||
image_ref: o.imageRef,
|
||||
image_digest: `sha256:${o.imageRef}-${Math.random().toString(16).slice(2)}`,
|
||||
scanned_at: o.scannedAt,
|
||||
total_vulnerabilities: (o.critical ?? 0) + (o.high ?? 0),
|
||||
critical_count: o.critical ?? 0,
|
||||
high_count: o.high ?? 0,
|
||||
medium_count: 0,
|
||||
low_count: 0,
|
||||
unknown_count: 0,
|
||||
fixable_count: 0,
|
||||
secret_count: o.secret ?? 0,
|
||||
misconfig_count: o.misconfig ?? 0,
|
||||
scanners_used: o.scannersUsed,
|
||||
highest_severity: (o.critical ?? 0) > 0 ? 'CRITICAL' : (o.high ?? 0) > 0 ? 'HIGH' : null,
|
||||
os_info: null,
|
||||
trivy_version: null,
|
||||
scan_duration_ms: null,
|
||||
triggered_by: 'manual',
|
||||
status: 'completed',
|
||||
error: null,
|
||||
stack_context: o.imageRef.startsWith('stack:') ? o.imageRef.slice(6) : null,
|
||||
});
|
||||
}
|
||||
|
||||
it('sources vuln counts and scan_id from the latest vuln scan, keeping a newer secret-only scan\'s secrets', () => {
|
||||
const vulnScan = seedScan({ imageRef: 'app:1', scannersUsed: 'vuln', scannedAt: 1000, critical: 2, high: 1 });
|
||||
// A newer secret-only node scan must not erase the vulnerability posture, and
|
||||
// the badge's scan_id must open the scan its counts came from (the vuln scan).
|
||||
seedScan({ imageRef: 'app:1', scannersUsed: 'secret', scannedAt: 2000, secret: 5 });
|
||||
|
||||
const summary = db().getImageScanSummaries(1)['app:1'];
|
||||
expect(summary.critical).toBe(2);
|
||||
expect(summary.high).toBe(1);
|
||||
expect(summary.secret_count).toBe(5);
|
||||
expect(summary.scan_id).toBe(vulnScan);
|
||||
});
|
||||
|
||||
it('keeps a compose/config scan row with its misconfig count and zero vuln counts', () => {
|
||||
const configScan = seedScan({ imageRef: 'stack:web', scannersUsed: 'config', scannedAt: 1000, misconfig: 3 });
|
||||
const summary = db().getImageScanSummaries(1)['stack:web'];
|
||||
expect(summary.critical).toBe(0);
|
||||
expect(summary.misconfig_count).toBe(3);
|
||||
expect(summary.scan_id).toBe(configScan);
|
||||
});
|
||||
|
||||
it('treats a combined vuln,secret scan as vulnerability-bearing', () => {
|
||||
const scan = seedScan({ imageRef: 'both:1', scannersUsed: 'vuln,secret', scannedAt: 1000, critical: 1, secret: 2 });
|
||||
const summary = db().getImageScanSummaries(1)['both:1'];
|
||||
expect(summary.critical).toBe(1);
|
||||
expect(summary.secret_count).toBe(2);
|
||||
expect(summary.scan_id).toBe(scan);
|
||||
});
|
||||
|
||||
it('reports zero vuln counts but keeps secrets for an image only ever scanned for secrets', () => {
|
||||
const secretScan = seedScan({ imageRef: 'sec:1', scannersUsed: 'secret', scannedAt: 1000, secret: 4 });
|
||||
const summary = db().getImageScanSummaries(1)['sec:1'];
|
||||
expect(summary.critical).toBe(0);
|
||||
expect(summary.high).toBe(0);
|
||||
expect(summary.secret_count).toBe(4);
|
||||
// No vuln-bearing scan exists, so scan_id falls back to the latest scan overall.
|
||||
expect(summary.scan_id).toBe(secretScan);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLatestKevFindingsForNode', () => {
|
||||
function rawDb() {
|
||||
return (db() as unknown as { db: { prepare: (s: string) => { run: () => void } } }).db;
|
||||
}
|
||||
beforeEach(() => {
|
||||
rawDb().prepare('DELETE FROM vulnerability_details').run();
|
||||
rawDb().prepare('DELETE FROM cve_intel').run();
|
||||
rawDb().prepare('DELETE FROM vulnerability_scans').run();
|
||||
});
|
||||
|
||||
function seedVulnScan(o: { imageRef: string; scannersUsed: string; scannedAt: number; nodeId?: number }): number {
|
||||
return db().createVulnerabilityScan({
|
||||
node_id: o.nodeId ?? 1, image_ref: o.imageRef, image_digest: `sha256:${o.imageRef}-${Math.random().toString(16).slice(2)}`,
|
||||
scanned_at: o.scannedAt, 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: o.scannersUsed, highest_severity: null, os_info: null, trivy_version: null,
|
||||
scan_duration_ms: null, triggered_by: 'manual', status: 'completed', error: null, stack_context: null,
|
||||
});
|
||||
}
|
||||
const detail = (id: string, severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW') => ({
|
||||
vulnerability_id: id, pkg_name: `p-${id}`, installed_version: '1', fixed_version: null,
|
||||
severity, title: null, description: null, primary_url: null,
|
||||
});
|
||||
|
||||
it('returns KEV findings at any severity from the latest vuln scan, restricted to vuln-bearing scanners', () => {
|
||||
const now = Date.now();
|
||||
const vulnScan = seedVulnScan({ imageRef: 'app:1', scannersUsed: 'vuln', scannedAt: now - 1000 });
|
||||
db().insertVulnerabilityDetails(vulnScan, [detail('CVE-LOW-KEV', 'LOW'), detail('CVE-CRIT-PLAIN', 'CRITICAL')]);
|
||||
// A newer secret-only scan also carries a KEV detail; it must be ignored.
|
||||
const secretScan = seedVulnScan({ imageRef: 'app:1', scannersUsed: 'secret', scannedAt: now });
|
||||
db().insertVulnerabilityDetails(secretScan, [detail('CVE-SECRET-KEV', 'HIGH')]);
|
||||
db().replaceKev([
|
||||
{ cve_id: 'CVE-LOW-KEV', date_added: '2024-01-01' },
|
||||
{ cve_id: 'CVE-SECRET-KEV', date_added: '2024-01-01' },
|
||||
], now);
|
||||
|
||||
const ids = db().getLatestKevFindingsForNode(1).items.map((i) => i.vulnerability_id);
|
||||
expect(ids).toContain('CVE-LOW-KEV'); // any-severity KEV from the vuln scan
|
||||
expect(ids).not.toContain('CVE-CRIT-PLAIN'); // Critical but not KEV
|
||||
expect(ids).not.toContain('CVE-SECRET-KEV'); // KEV but on a secret-only scan
|
||||
});
|
||||
|
||||
it('flags truncated when the row cap is hit', () => {
|
||||
const now = Date.now();
|
||||
const scan = seedVulnScan({ imageRef: 'many:1', scannersUsed: 'vuln', scannedAt: now });
|
||||
db().insertVulnerabilityDetails(scan, [detail('CVE-K1', 'HIGH'), detail('CVE-K2', 'HIGH'), detail('CVE-K3', 'HIGH')]);
|
||||
db().replaceKev([{ cve_id: 'CVE-K1', date_added: null }, { cve_id: 'CVE-K2', date_added: null }, { cve_id: 'CVE-K3', date_added: null }], now);
|
||||
const res = db().getLatestKevFindingsForNode(1, 2);
|
||||
expect(res.truncated).toBe(true);
|
||||
expect(res.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('is node-scoped', () => {
|
||||
const now = Date.now();
|
||||
const scan = seedVulnScan({ imageRef: 'other:1', scannersUsed: 'vuln', scannedAt: now, nodeId: 2 });
|
||||
db().insertVulnerabilityDetails(scan, [detail('CVE-OTHER', 'CRITICAL')]);
|
||||
db().replaceKev([{ cve_id: 'CVE-OTHER', date_added: null }], now);
|
||||
expect(db().getLatestKevFindingsForNode(1).items).toHaveLength(0);
|
||||
expect(db().getLatestKevFindingsForNode(2).items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDailyRiskTrend', () => {
|
||||
it('sums latest-per-image critical/high per day and orders days ascending', () => {
|
||||
const day1 = dayStartMs(3);
|
||||
|
||||
@@ -239,6 +239,85 @@ describe('GET /api/security/overview', () => {
|
||||
expect(res.body).toMatchObject({ knownExploited: 1, fixableCriticalHigh: 0, posture: 'Action needed' });
|
||||
});
|
||||
|
||||
it('preserves vulnerability posture when a newer secret-only scan exists for the same image', async () => {
|
||||
const now = Date.now();
|
||||
// Vulnerability scan: one unfixable Critical that is known-exploited.
|
||||
const vulnScan = db().createVulnerabilityScan({
|
||||
node_id: 1, image_ref: 'app:1', image_digest: 'sha256:app-vuln', scanned_at: now - 1000,
|
||||
total_vulnerabilities: 1, critical_count: 1, 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: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null,
|
||||
triggered_by: 'manual', status: 'completed', error: null, stack_context: null,
|
||||
});
|
||||
db().insertVulnerabilityDetails(vulnScan, [{
|
||||
vulnerability_id: 'CVE-2024-5001', pkg_name: 'libkev', installed_version: '1', fixed_version: null,
|
||||
severity: 'CRITICAL', title: null, description: null, primary_url: null,
|
||||
}]);
|
||||
db().replaceKev([{ cve_id: 'CVE-2024-5001', date_added: '2024-01-01' }], now);
|
||||
// Newer secret-only node scan for the SAME image: no vuln details, carries a secret.
|
||||
db().createVulnerabilityScan({
|
||||
node_id: 1, image_ref: 'app:1', image_digest: 'sha256:app-secret', scanned_at: now,
|
||||
total_vulnerabilities: 0, critical_count: 0, high_count: 0, medium_count: 0, low_count: 0,
|
||||
unknown_count: 0, fixable_count: 0, secret_count: 1, misconfig_count: 0, scanners_used: 'secret',
|
||||
highest_severity: null, os_info: null, trivy_version: null, scan_duration_ms: null,
|
||||
triggered_by: 'manual', status: 'completed', error: null, stack_context: null,
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
// The secret-only scan must not erase the vulnerability posture, and its
|
||||
// secret finding is still counted.
|
||||
expect(res.body).toMatchObject({
|
||||
critical: 1,
|
||||
knownExploited: 1,
|
||||
secrets: 1,
|
||||
posture: 'Action needed',
|
||||
});
|
||||
});
|
||||
|
||||
it('counts a Low/Medium known-exploited finding in knownExploited and posture (matches the deploy gate)', async () => {
|
||||
const now = Date.now();
|
||||
const scanId = db().createVulnerabilityScan({
|
||||
node_id: 1, image_ref: 'lowkev:1', image_digest: 'sha256:lowkev', scanned_at: now,
|
||||
total_vulnerabilities: 1, critical_count: 0, high_count: 0, medium_count: 0, low_count: 1,
|
||||
unknown_count: 0, fixable_count: 0, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln',
|
||||
highest_severity: 'LOW', os_info: null, trivy_version: null, scan_duration_ms: null,
|
||||
triggered_by: 'manual', status: 'completed', error: null, stack_context: null,
|
||||
});
|
||||
db().insertVulnerabilityDetails(scanId, [{
|
||||
vulnerability_id: 'CVE-2024-5002', pkg_name: 'liblow', installed_version: '1', fixed_version: null,
|
||||
severity: 'LOW', title: null, description: null, primary_url: null,
|
||||
}]);
|
||||
// The deploy gate blocks a KEV at any severity; the overview must agree.
|
||||
db().replaceKev([{ cve_id: 'CVE-2024-5002', date_added: '2024-01-01' }], now);
|
||||
|
||||
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ knownExploited: 1, posture: 'Action needed' });
|
||||
});
|
||||
|
||||
it('excludes a suppressed (accepted) KEV from knownExploited', async () => {
|
||||
const now = Date.now();
|
||||
const scanId = db().createVulnerabilityScan({
|
||||
node_id: 1, image_ref: 'suppkev:1', image_digest: 'sha256:suppkev', scanned_at: now,
|
||||
total_vulnerabilities: 2, critical_count: 0, high_count: 0, medium_count: 2, low_count: 0,
|
||||
unknown_count: 0, fixable_count: 0, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln',
|
||||
highest_severity: 'MEDIUM', os_info: null, trivy_version: null, scan_duration_ms: null,
|
||||
triggered_by: 'manual', status: 'completed', error: null, stack_context: null,
|
||||
});
|
||||
db().insertVulnerabilityDetails(scanId, [
|
||||
{ vulnerability_id: 'CVE-2024-KEVA', pkg_name: 'a', installed_version: '1', fixed_version: null, severity: 'MEDIUM', title: null, description: null, primary_url: null },
|
||||
{ vulnerability_id: 'CVE-2024-KEVB', pkg_name: 'b', installed_version: '1', fixed_version: null, severity: 'MEDIUM', title: null, description: null, primary_url: null },
|
||||
]);
|
||||
db().replaceKev([{ cve_id: 'CVE-2024-KEVA', date_added: '2024-01-01' }, { cve_id: 'CVE-2024-KEVB', date_added: '2024-01-01' }], now);
|
||||
// KEVA is accepted (dismissed); only the live KEVB remains actionable.
|
||||
db().createCveSuppression({ cve_id: 'CVE-2024-KEVA', pkg_name: null, image_pattern: null, reason: 'accepted', created_by: 'admin', created_at: now, expires_at: null, replicated_from_control: 0, status: 'accepted' });
|
||||
|
||||
const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.knownExploited).toBe(1);
|
||||
});
|
||||
|
||||
it('reads Secure when a scan completed with nothing actionable or severe', async () => {
|
||||
db().createVulnerabilityScan({
|
||||
node_id: 1, image_ref: 'clean:1', image_digest: 'sha256:clean', scanned_at: Date.now(),
|
||||
@@ -436,6 +515,29 @@ describe('GET /api/security/overview/exploit-intel', () => {
|
||||
expect(res.body.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('includes a Medium/Low known-exploited finding alongside Critical/High', async () => {
|
||||
const now = Date.now();
|
||||
const scanId = db().createVulnerabilityScan({
|
||||
node_id: 1, image_ref: 'mix:1', image_digest: 'sha256:mix', scanned_at: now,
|
||||
total_vulnerabilities: 2, critical_count: 0, high_count: 0, medium_count: 1, low_count: 1,
|
||||
unknown_count: 0, fixable_count: 0, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln',
|
||||
highest_severity: 'MEDIUM', os_info: null, trivy_version: null, scan_duration_ms: null,
|
||||
triggered_by: 'manual', status: 'completed', error: null, stack_context: null,
|
||||
});
|
||||
db().insertVulnerabilityDetails(scanId, [
|
||||
{ vulnerability_id: 'CVE-2024-MEDK', pkg_name: 'm', installed_version: '1', fixed_version: null, severity: 'MEDIUM', title: null, description: null, primary_url: null, cvss_score: 5.5 },
|
||||
{ vulnerability_id: 'CVE-2024-LOWN', pkg_name: 'l', installed_version: '1', fixed_version: null, severity: 'LOW', title: null, description: null, primary_url: null, cvss_score: 3.1 },
|
||||
]);
|
||||
// Only the Medium finding is known-exploited; the Low non-KEV stays out.
|
||||
db().replaceKev([{ cve_id: 'CVE-2024-MEDK', date_added: '2024-01-01' }], now);
|
||||
|
||||
const res = await request(app).get('/api/security/overview/exploit-intel').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
const ids = (res.body.items as Array<{ vulnerability_id: string }>).map((i) => i.vulnerability_id);
|
||||
expect(ids).toContain('CVE-2024-MEDK');
|
||||
expect(ids).not.toContain('CVE-2024-LOWN');
|
||||
});
|
||||
|
||||
it('requires authentication', async () => {
|
||||
const res = await request(app).get('/api/security/overview/exploit-intel');
|
||||
expect(res.status).toBe(401);
|
||||
|
||||
@@ -736,7 +736,6 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v
|
||||
let accepted = 0;
|
||||
let notAffected = 0;
|
||||
let needsReview = 0;
|
||||
let knownExploited = 0;
|
||||
for (const [imageRef, group] of critHighByImage) {
|
||||
for (const e of applySuppressions(group, imageRef, cveSuppressions)) {
|
||||
if (e.triage_status === 'needs_review') needsReview += 1;
|
||||
@@ -748,7 +747,24 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v
|
||||
}
|
||||
// Not dismissed (no decision, needs_review, or affected): still actionable.
|
||||
if (e.fixed_version) fixableCriticalHigh += 1;
|
||||
if (intel.get(e.vulnerability_id)?.kev) knownExploited += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// A known-exploited (KEV) finding gates a deploy at ANY severity, so the
|
||||
// posture fact counts non-suppressed KEV findings across all severities, not
|
||||
// just Critical/High. Sourced from its own latest-scan query so a Low/Medium
|
||||
// KEV that the gate would block on is never invisible on the overview.
|
||||
const kevFindings = db.getLatestKevFindingsForNode(req.nodeId);
|
||||
const kevByImage = new Map<string, typeof kevFindings.items>();
|
||||
for (const f of kevFindings.items) {
|
||||
const group = kevByImage.get(f.image_ref);
|
||||
if (group) group.push(f);
|
||||
else kevByImage.set(f.image_ref, [f]);
|
||||
}
|
||||
let knownExploited = 0;
|
||||
for (const [imageRef, group] of kevByImage) {
|
||||
for (const e of applySuppressions(group, imageRef, cveSuppressions)) {
|
||||
if (!e.suppressed) knownExploited += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -858,7 +874,7 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v
|
||||
notAffected,
|
||||
actionable,
|
||||
posture,
|
||||
posturePartial: critHigh.truncated || highMisconfigs.truncated,
|
||||
posturePartial: critHigh.truncated || kevFindings.truncated || highMisconfigs.truncated,
|
||||
postureReasons,
|
||||
primaryAction,
|
||||
};
|
||||
@@ -899,10 +915,10 @@ interface ExploitIntelFinding {
|
||||
}
|
||||
|
||||
// Node-scoped, auth-only (Community). Returns the latest-scan Critical/High
|
||||
// findings that are still actionable (dismissed triage decisions filtered out),
|
||||
// enriched at read time with KEV/EPSS intel. Powers the Top exploit-risk list
|
||||
// and the CVSS-by-EPSS quadrant on the Security overview. Bounded; `truncated`
|
||||
// flags a capped node.
|
||||
// findings (plus known-exploited findings at any severity) that are still
|
||||
// actionable (dismissed triage decisions filtered out), enriched at read time
|
||||
// with KEV/EPSS intel. Powers the Top exploit-risk list and the CVSS-by-EPSS
|
||||
// quadrant on the Security overview. Bounded; `truncated` flags a capped node.
|
||||
securityRouter.get('/overview/exploit-intel', authMiddleware, (req: Request, res: Response): void => {
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
@@ -4963,21 +4963,47 @@ export class DatabaseService {
|
||||
}
|
||||
|
||||
public getImageScanSummaries(nodeId: number): Record<string, ScanSummary> {
|
||||
// The per-image row is the latest scan overall (so a secret-only or
|
||||
// compose/config scan still contributes its secret/misconfig counts and
|
||||
// staleness), but the vulnerability counts are sourced from the latest
|
||||
// VULN-bearing scan via a LEFT JOIN. Without this, a newer secret-only
|
||||
// node scan would clobber an image's Critical/High posture to zero.
|
||||
const placeholders = VULN_BEARING_SCANNER_SETS.map(() => '?').join(', ');
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT vs.image_ref, vs.id as scan_id, vs.highest_severity, vs.total_vulnerabilities,
|
||||
vs.critical_count, vs.high_count, vs.medium_count, vs.low_count,
|
||||
vs.unknown_count, vs.fixable_count, vs.secret_count, vs.misconfig_count, vs.scanned_at
|
||||
FROM vulnerability_scans vs
|
||||
`SELECT base.image_ref, base.scanned_at,
|
||||
base.secret_count, base.misconfig_count,
|
||||
COALESCE(vuln.scan_id, base.id) AS scan_id,
|
||||
COALESCE(vuln.highest_severity, base.highest_severity) AS highest_severity,
|
||||
COALESCE(vuln.total_vulnerabilities, 0) AS total_vulnerabilities,
|
||||
COALESCE(vuln.critical_count, 0) AS critical_count,
|
||||
COALESCE(vuln.high_count, 0) AS high_count,
|
||||
COALESCE(vuln.medium_count, 0) AS medium_count,
|
||||
COALESCE(vuln.low_count, 0) AS low_count,
|
||||
COALESCE(vuln.unknown_count, 0) AS unknown_count,
|
||||
COALESCE(vuln.fixable_count, 0) AS fixable_count
|
||||
FROM vulnerability_scans base
|
||||
INNER JOIN (
|
||||
SELECT image_ref, MAX(scanned_at) AS max_scanned
|
||||
FROM vulnerability_scans
|
||||
WHERE node_id = ? AND status = 'completed'
|
||||
GROUP BY image_ref
|
||||
) latest ON latest.image_ref = vs.image_ref AND latest.max_scanned = vs.scanned_at
|
||||
WHERE vs.node_id = ? AND vs.status = 'completed'`,
|
||||
) latest ON latest.image_ref = base.image_ref AND latest.max_scanned = base.scanned_at
|
||||
LEFT JOIN (
|
||||
SELECT v.image_ref, v.id AS scan_id, v.highest_severity, v.total_vulnerabilities, v.critical_count,
|
||||
v.high_count, v.medium_count, v.low_count, v.unknown_count, v.fixable_count
|
||||
FROM vulnerability_scans v
|
||||
INNER JOIN (
|
||||
SELECT image_ref, MAX(scanned_at) AS max_scanned
|
||||
FROM vulnerability_scans
|
||||
WHERE node_id = ? AND status = 'completed' AND scanners_used IN (${placeholders})
|
||||
GROUP BY image_ref
|
||||
) lv ON lv.image_ref = v.image_ref AND lv.max_scanned = v.scanned_at
|
||||
WHERE v.node_id = ? AND v.status = 'completed' AND v.scanners_used IN (${placeholders})
|
||||
) vuln ON vuln.image_ref = base.image_ref
|
||||
WHERE base.node_id = ? AND base.status = 'completed'`,
|
||||
)
|
||||
.all(nodeId, nodeId) as Array<{
|
||||
.all(nodeId, nodeId, ...VULN_BEARING_SCANNER_SETS, nodeId, ...VULN_BEARING_SCANNER_SETS, nodeId) as Array<{
|
||||
image_ref: string;
|
||||
scan_id: number;
|
||||
highest_severity: VulnSeverity | null;
|
||||
@@ -5029,6 +5055,7 @@ export class DatabaseService {
|
||||
items: Array<{ image_ref: string; vulnerability_id: string; pkg_name: string; fixed_version: string | null }>;
|
||||
truncated: boolean;
|
||||
} {
|
||||
const placeholders = VULN_BEARING_SCANNER_SETS.map(() => '?').join(', ');
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT vs.image_ref, vd.vulnerability_id, vd.pkg_name, vd.fixed_version
|
||||
@@ -5037,14 +5064,55 @@ export class DatabaseService {
|
||||
INNER JOIN (
|
||||
SELECT image_ref, MAX(scanned_at) AS max_scanned
|
||||
FROM vulnerability_scans
|
||||
WHERE node_id = ? AND status = 'completed'
|
||||
WHERE node_id = ? AND status = 'completed' AND scanners_used IN (${placeholders})
|
||||
GROUP BY image_ref
|
||||
) latest ON latest.image_ref = vs.image_ref AND latest.max_scanned = vs.scanned_at
|
||||
WHERE vs.node_id = ? AND vs.status = 'completed'
|
||||
WHERE vs.node_id = ? AND vs.status = 'completed' AND vs.scanners_used IN (${placeholders})
|
||||
AND vd.severity IN ('CRITICAL', 'HIGH')
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(nodeId, nodeId, limit + 1) as Array<{
|
||||
.all(nodeId, ...VULN_BEARING_SCANNER_SETS, nodeId, ...VULN_BEARING_SCANNER_SETS, limit + 1) as Array<{
|
||||
image_ref: string;
|
||||
vulnerability_id: string;
|
||||
pkg_name: string;
|
||||
fixed_version: string | null;
|
||||
}>;
|
||||
const truncated = rows.length > limit;
|
||||
return { items: truncated ? rows.slice(0, limit) : rows, truncated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Known-exploited (CISA KEV) findings at ANY severity from the latest
|
||||
* completed vuln-bearing scan per image, for the `knownExploited` posture
|
||||
* fact. The deploy gate blocks a KEV regardless of severity, so this is not
|
||||
* restricted to Critical/High the way the fixable/triage helpers are.
|
||||
* Suppression filtering is applied by the caller at read time. Same bounded
|
||||
* shape as `getLatestCritHighVulnFindingsForNode`.
|
||||
*/
|
||||
public getLatestKevFindingsForNode(
|
||||
nodeId: number,
|
||||
limit = 5000,
|
||||
): {
|
||||
items: Array<{ image_ref: string; vulnerability_id: string; pkg_name: string; fixed_version: string | null }>;
|
||||
truncated: boolean;
|
||||
} {
|
||||
const placeholders = VULN_BEARING_SCANNER_SETS.map(() => '?').join(', ');
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT vs.image_ref, vd.vulnerability_id, vd.pkg_name, vd.fixed_version
|
||||
FROM vulnerability_details vd
|
||||
INNER JOIN vulnerability_scans vs ON vs.id = vd.scan_id
|
||||
INNER JOIN cve_intel ci ON ci.cve_id = vd.vulnerability_id AND ci.kev = 1
|
||||
INNER JOIN (
|
||||
SELECT image_ref, MAX(scanned_at) AS max_scanned
|
||||
FROM vulnerability_scans
|
||||
WHERE node_id = ? AND status = 'completed' AND scanners_used IN (${placeholders})
|
||||
GROUP BY image_ref
|
||||
) latest ON latest.image_ref = vs.image_ref AND latest.max_scanned = vs.scanned_at
|
||||
WHERE vs.node_id = ? AND vs.status = 'completed' AND vs.scanners_used IN (${placeholders})
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(nodeId, ...VULN_BEARING_SCANNER_SETS, nodeId, ...VULN_BEARING_SCANNER_SETS, limit + 1) as Array<{
|
||||
image_ref: string;
|
||||
vulnerability_id: string;
|
||||
pkg_name: string;
|
||||
@@ -5084,11 +5152,14 @@ export class DatabaseService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Critical/High findings from the latest completed scan per image, with the
|
||||
* severity + CVSS the overview's exploit-intel charts need. Same bounded
|
||||
* shape as getLatestCritHighVulnFindingsForNode (single latest-per-image
|
||||
* JOIN, capped, `truncated` flagged). Intel (KEV/EPSS) and suppression
|
||||
* filtering are applied by the caller at read time.
|
||||
* Critical/High findings (plus known-exploited findings at any severity)
|
||||
* from the latest completed scan per image, with the severity + CVSS the
|
||||
* overview's exploit-intel charts need. Same bounded shape as
|
||||
* getLatestCritHighVulnFindingsForNode (single latest-per-image JOIN, capped,
|
||||
* `truncated` flagged). A KEV finding gates a deploy regardless of its
|
||||
* severity, so a Medium/Low KEV is selected here (via the cve_intel join) to
|
||||
* match the gate. Intel (KEV/EPSS) and suppression filtering are applied by
|
||||
* the caller at read time.
|
||||
*/
|
||||
public getLatestCritHighFindingsWithCvssForNode(
|
||||
nodeId: number,
|
||||
@@ -5105,23 +5176,25 @@ export class DatabaseService {
|
||||
}>;
|
||||
truncated: boolean;
|
||||
} {
|
||||
const placeholders = VULN_BEARING_SCANNER_SETS.map(() => '?').join(', ');
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT vs.image_ref, vs.id AS scan_id, vd.vulnerability_id, vd.pkg_name,
|
||||
vd.severity, vd.cvss_score, vd.fixed_version
|
||||
FROM vulnerability_details vd
|
||||
INNER JOIN vulnerability_scans vs ON vs.id = vd.scan_id
|
||||
LEFT JOIN cve_intel ci ON ci.cve_id = vd.vulnerability_id
|
||||
INNER JOIN (
|
||||
SELECT image_ref, MAX(scanned_at) AS max_scanned
|
||||
FROM vulnerability_scans
|
||||
WHERE node_id = ? AND status = 'completed'
|
||||
WHERE node_id = ? AND status = 'completed' AND scanners_used IN (${placeholders})
|
||||
GROUP BY image_ref
|
||||
) latest ON latest.image_ref = vs.image_ref AND latest.max_scanned = vs.scanned_at
|
||||
WHERE vs.node_id = ? AND vs.status = 'completed'
|
||||
AND vd.severity IN ('CRITICAL', 'HIGH')
|
||||
WHERE vs.node_id = ? AND vs.status = 'completed' AND vs.scanners_used IN (${placeholders})
|
||||
AND (vd.severity IN ('CRITICAL', 'HIGH') OR ci.kev = 1)
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(nodeId, nodeId, limit + 1) as Array<{
|
||||
.all(nodeId, ...VULN_BEARING_SCANNER_SETS, nodeId, ...VULN_BEARING_SCANNER_SETS, limit + 1) as Array<{
|
||||
image_ref: string;
|
||||
scan_id: number;
|
||||
vulnerability_id: string;
|
||||
|
||||
Reference in New Issue
Block a user