import { severityRank } from "@/components/vulnerabilities/SeverityVisuals"; import type { Severity, VulnFinding, VulnGroup } from "@/lib/api"; /* * Rolls the CVE-grouped board up to the thing an operator actually does about * it: upgrade one package on one server. * * Two CVEs on the same apache2 are not two jobs. They are one upgrade, to * whichever fixed version is highest — and listing them separately with fix * targets of 8.12 and 8.15 invites patching to 8.12 and believing the work is * done. The highest target supersedes every lower one, so that is the number * the row shows. * * This is a display rollup only. Findings stay individual in the database and * in the API, because acceptance, first_seen and remediation history are all * per-CVE and none of them survive a merge. */ /** compareVersions orders two Debian-style version strings. Negative if a < b. */ export function compareVersions(a: string, b: string): number { const pa = splitVersion(a); const pb = splitVersion(b); if (pa.epoch !== pb.epoch) return pa.epoch - pb.epoch; const up = verrevcmp(pa.upstream, pb.upstream); if (up !== 0) return up; return verrevcmp(pa.revision, pb.revision); } function splitVersion(v: string): { epoch: number; upstream: string; revision: string } { let rest = v.trim(); let epoch = 0; const colon = rest.indexOf(":"); if (colon > 0) { const n = Number(rest.slice(0, colon)); if (Number.isFinite(n)) { epoch = n; rest = rest.slice(colon + 1); } } const dash = rest.lastIndexOf("-"); if (dash >= 0) return { epoch, upstream: rest.slice(0, dash), revision: rest.slice(dash + 1) }; return { epoch, upstream: rest, revision: "" }; } /* * dpkg's verrevcmp. The two rules worth stating, because both are silent * wrong answers under a naive compare: `~` sorts *before* the empty string, so * 1.0~rc1 precedes 1.0; and digit runs compare numerically, so 8.12 precedes * 8.15 rather than following it the way a string compare would have it. * * RPM and APK versions are compared with the same routine. It is not their * exact algorithm, but it agrees with them on the shapes distributions actually * ship, and the alternative is three parsers in the browser to decide which of * two numbers to print. */ function verrevcmp(a: string, b: string): number { let i = 0; let j = 0; while (i < a.length || j < b.length) { // Non-digit run, character by character in dpkg's order. while ((i < a.length && !isDigit(a[i])) || (j < b.length && !isDigit(b[j]))) { const ca = i < a.length ? order(a[i]) : 0; const cb = j < b.length ? order(b[j]) : 0; if (ca !== cb) return ca - cb; i++; j++; } // Leading zeroes carry no value, so 007 and 7 are the same number. while (a[i] === "0") i++; while (b[j] === "0") j++; let na = 0; let nb = 0; while (i + na < a.length && isDigit(a[i + na])) na++; while (j + nb < b.length && isDigit(b[j + nb])) nb++; // A longer digit run is a larger number, no parsing required — which // also keeps versions past 2^53 honest. if (na !== nb) return na - nb; const cmp = a.slice(i, i + na).localeCompare(b.slice(j, j + nb)); if (cmp !== 0) return cmp; i += na; j += nb; } return 0; } function isDigit(c: string | undefined): boolean { return c !== undefined && c >= "0" && c <= "9"; } function order(c: string): number { if (c === "~") return -1; if (/[a-zA-Z]/.test(c)) return c.charCodeAt(0); return c.charCodeAt(0) + 256; } /** One package on one server: the unit of remediation. */ export interface PackageServer { server_id: string; package_name: string; installed_version: string; /** The highest fixed version across this row's findings. Absent means no * vendor fix exists for any of them. */ target?: string; /** True when the findings disagree on the fix version, which is the case * the rollup exists for. */ superseded: boolean; severity: Severity; findings: VulnFinding[]; } /** One package across every server carrying it. */ export interface PackageGroup { package_name: string; severity: Severity; cve_count: number; server_count: number; servers: PackageServer[]; } export function groupByPackage(groups: VulnGroup[]): PackageGroup[] { const rows = new Map(); for (const g of groups) { for (const f of g.findings) { const key = `${f.package_name}${f.server_id}`; const row = rows.get(key); if (!row) { rows.set(key, { server_id: f.server_id, package_name: f.package_name, installed_version: f.installed_version, target: f.fixed_in, superseded: false, severity: f.severity, findings: [f], }); continue; } row.findings.push(f); if (severityRank(f.severity) > severityRank(row.severity)) row.severity = f.severity; if (f.fixed_in) { if (!row.target) { row.target = f.fixed_in; // A row that had no fix and now has one is not superseding // anything; the earlier CVE simply cannot be patched. } else if (compareVersions(f.fixed_in, row.target) > 0) { row.target = f.fixed_in; row.superseded = true; } else if (f.fixed_in !== row.target) { row.superseded = true; } } } } const packages = new Map(); for (const row of rows.values()) { row.findings.sort((x, y) => severityRank(y.severity) - severityRank(x.severity) || x.cve_id.localeCompare(y.cve_id)); const pkg = packages.get(row.package_name); if (!pkg) { packages.set(row.package_name, { package_name: row.package_name, severity: row.severity, cve_count: 0, server_count: 0, servers: [row], }); continue; } if (severityRank(row.severity) > severityRank(pkg.severity)) pkg.severity = row.severity; pkg.servers.push(row); } const out = [...packages.values()]; for (const pkg of out) { // Counted distinctly: the same CVE on twelve servers is one CVE, and a // count of twelve would restate the server count in a second column. pkg.cve_count = new Set(pkg.servers.flatMap((s) => s.findings.map((f) => f.cve_id))).size; pkg.server_count = pkg.servers.length; pkg.servers.sort((x, y) => x.server_id.localeCompare(y.server_id)); } out.sort( (x, y) => severityRank(y.severity) - severityRank(x.severity) || y.cve_count - x.cve_count || x.package_name.localeCompare(y.package_name), ); return out; }