251 lines
8.1 KiB
TypeScript
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);
|
|
}
|