Files
vantage/web/lib/vulnPackages.ts
T
mrhid6 d1769fc886
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m36s
feat: Updated vuln style
2026-08-06 16:35:04 +01:00

199 lines
7.1 KiB
TypeScript

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<string, PackageServer>();
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<string, PackageGroup>();
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;
}