199 lines
7.1 KiB
TypeScript
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} |