Files
dockhand/src/lib/utils/vulnerability.ts
T
2026-07-11 15:31:52 +02:00

251 lines
8.1 KiB
TypeScript

/**
* Shared vulnerability/severity helpers used by the per-image scan view and
* the aggregated Vulnerabilities dashboard.
*/
export interface Vulnerability {
id: string;
severity: string;
package: string;
version: string;
fixedVersion?: string;
description?: string;
link?: string;
}
/** Sort order: lower = more severe. Unknown/unmapped sort last. */
export const SEVERITY_ORDER: Record<string, number> = {
critical: 0,
high: 1,
medium: 2,
low: 3,
negligible: 4,
unknown: 5
};
export function severityRank(severity: string): number {
return SEVERITY_ORDER[severity.toLowerCase()] ?? SEVERITY_ORDER.unknown;
}
/** Tailwind classes for a severity Badge (bg + text + border). */
export function getSeverityColor(severity: string): string {
switch (severity.toLowerCase()) {
case 'critical':
return 'bg-red-500/10 text-red-500 border-red-500/30';
case 'high':
return 'bg-orange-500/10 text-orange-500 border-orange-500/30';
case 'medium':
return 'bg-yellow-500/10 text-yellow-600 border-yellow-500/30';
case 'low':
return 'bg-blue-500/10 text-blue-500 border-blue-500/30';
case 'negligible':
case 'unknown':
default:
return 'bg-gray-500/10 text-gray-500 border-gray-500/30';
}
}
/** The four severities shown as summary pills, most severe first. */
export const SUMMARY_SEVERITIES = ['critical', 'high', 'medium', 'low'] as const;
export type SummarySeverity = (typeof SUMMARY_SEVERITIES)[number];
/** Title-case a severity label (e.g. "critical" -> "Critical"). */
export function severityLabel(severity: string): string {
return severity.charAt(0).toUpperCase() + severity.slice(1);
}
/** A container reference attached to a finding. */
export interface FindingContainer {
id: string;
name: string;
}
/** A single vulnerability finding as returned by /api/vulnerabilities. */
export interface Finding {
key: string;
cve: string;
package: string;
severity: string;
installedVersion: string;
fixedVersion: string;
imageId: string;
imageName: string;
description?: string;
link?: string;
scannedAt?: string;
containers?: FindingContainer[];
stacks?: string[];
}
/** Aggregate counts for the dashboard summary row / header badge. */
export interface VulnerabilitySummary {
total: number;
critical: number;
high: number;
medium: number;
low: number;
imagesScanned: number;
totalImages: number;
}
/** A zeroed summary — shared so the 7-field shape isn't re-typed per endpoint. */
export const EMPTY_SUMMARY: VulnerabilitySummary = {
total: 0, critical: 0, high: 0, medium: 0, low: 0, imagesScanned: 0, totalImages: 0
};
/**
* Minimal finding shape needed for filtering. Both the dashboard grid (client)
* and the export endpoint (server) filter against this, so the two can't drift.
*/
export interface FilterableFinding {
cve: string;
package: string;
severity: string;
imageName: string;
containers?: { name: string }[];
stacks?: string[];
}
export interface FindingFilter {
/** Free-text query matched against cve/package/image/container/stack names. */
q?: string;
/** Lowercase severity values; empty = no severity filter. */
severities?: string[];
images?: string[];
containers?: string[];
stacks?: string[];
}
/** A persisted scan row shape (subset used for flattening into findings). */
export interface ScanRow {
imageId: string;
imageName: string;
scannedAt: string;
vulnerabilities: any;
}
/**
* Flatten persisted scan rows into deduped per-CVE findings. Shared by the
* env-wide aggregation and the per-image export so both produce the same shape.
* `enrich` optionally attaches containers/stacks by imageId.
*/
export function flattenScansToFindings(
scans: ScanRow[],
enrich?: {
containersByImage?: Map<string, FindingContainer[]>;
stacksByImage?: Map<string, Set<string>>;
}
): Finding[] {
const seen = new Set<string>();
const findings: Finding[] = [];
for (const scan of scans) {
const containers = enrich?.containersByImage?.get(scan.imageId);
const stacks = enrich?.stacksByImage?.get(scan.imageId);
// Legacy rows were double-JSON-encoded; parse once more if still a string.
let vulns: any[] = scan.vulnerabilities as any;
if (typeof vulns === 'string') {
try { vulns = JSON.parse(vulns); } catch { vulns = []; }
}
if (!Array.isArray(vulns)) vulns = [];
for (const v of vulns) {
const key = `${scan.imageId}|${v.id}|${v.package}|${v.version}`;
if (seen.has(key)) continue;
seen.add(key);
findings.push({
key,
cve: v.id,
package: v.package,
severity: v.severity,
installedVersion: v.version,
fixedVersion: v.fixedVersion || '',
imageId: scan.imageId,
imageName: scan.imageName,
description: v.description,
link: v.link,
scannedAt: scan.scannedAt,
containers: containers && containers.length ? containers : undefined,
stacks: stacks && stacks.size ? Array.from(stacks) : undefined
});
}
}
return findings;
}
/** Filter findings by the dashboard's search + multi-selects. Pure, shared client/server. */
export function filterFindings<T extends FilterableFinding>(findings: T[], filter: FindingFilter): T[] {
const q = (filter.q ?? '').toLowerCase().trim();
const sevSet = filter.severities?.length ? new Set(filter.severities) : null;
const imgSet = filter.images?.length ? new Set(filter.images) : null;
const containerSet = filter.containers?.length ? new Set(filter.containers) : null;
const stackSet = filter.stacks?.length ? new Set(filter.stacks) : null;
// Fast path: nothing to filter — return the input without copying.
if (!q && !sevSet && !imgSet && !containerSet && !stackSet) return findings;
return findings.filter((f) => {
if (sevSet && !sevSet.has(f.severity.toLowerCase())) return false;
if (imgSet && !imgSet.has(f.imageName)) return false;
if (containerSet && !(f.containers ?? []).some((c) => containerSet.has(c.name))) return false;
if (stackSet && !(f.stacks ?? []).some((s) => stackSet.has(s))) return false;
if (q) {
const containerNames = (f.containers ?? []).map((c) => c.name).join(' ');
const stackNames = (f.stacks ?? []).join(' ');
const hay = `${f.cve} ${f.package} ${f.imageName} ${containerNames} ${stackNames}`.toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}
export type SortField =
| 'cve' | 'package' | 'severity' | 'image'
| 'installed' | 'fixed' | 'container' | 'stack' | 'scannedAt';
/** The min (alphabetically first) name in a list — used to sort by container/stack. */
function minName(names: string[]): string {
let min: string | undefined;
for (const n of names) if (min === undefined || n < min) min = n;
return min ?? '';
}
/**
* Sort findings by a column. Pure, shared client/server. Returns a new array.
*
* Uses a Schwartzian transform: the sort key for each row is computed ONCE up
* front, not inside the comparator. This matters for the container/stack fields,
* whose key requires reducing a list — doing that per comparison would allocate
* and re-scan O(N log N) times instead of O(N).
*/
export function sortFindings<T extends Finding>(findings: T[], field: SortField, direction: 'asc' | 'desc'): T[] {
const dir = direction === 'asc' ? 1 : -1;
// Numeric key for severity; string key for everything else.
const numericKey = field === 'severity';
const keyFor = (f: T): number | string => {
switch (field) {
case 'severity': return severityRank(f.severity);
case 'cve': return f.cve;
case 'package': return f.package;
case 'image': return f.imageName;
case 'installed': return f.installedVersion;
case 'fixed': return f.fixedVersion || '';
case 'container': return minName((f.containers ?? []).map((c) => c.name));
case 'stack': return minName(f.stacks ?? []);
case 'scannedAt': return f.scannedAt ?? '';
default: return '';
}
};
// Version-ish fields compare numerically within the string (1.2.10 > 1.2.9).
const numericStr = field === 'installed' || field === 'fixed';
const decorated = findings.map((f) => ({ f, key: keyFor(f) }));
decorated.sort((a, b) => {
const cmp = numericKey
? (a.key as number) - (b.key as number)
: (a.key as string).localeCompare(b.key as string, undefined, numericStr ? { numeric: true } : undefined);
return cmp * dir;
});
return decorated.map((d) => d.f);
}