supply chain vuln UI — backend endpoints + frontend rendering for CVE/advisory detail
This commit is contained in:
parent
a19dcf4f14
commit
2064a5035f
11 changed files with 1013 additions and 229 deletions
|
|
@ -10,6 +10,8 @@ import {
|
|||
} from 'lucide-react';
|
||||
import { useDashboardStats } from '@/hooks/useStats';
|
||||
import { useDriftedSoftware, useRecentDriftEvents } from '@/hooks/useUpstream';
|
||||
import { advisoryUrl, cveSeverityBadge, cn } from '@/lib/utils';
|
||||
import type { TopThreat } from '@/types';
|
||||
|
||||
// Severity ranks: eol (100) > vuln (90) > failed (80) > major drift (70) > minor drift (40) > patch (20) > metadata (10)
|
||||
const SEVERITY_RANK: Record<string, number> = {
|
||||
|
|
@ -29,8 +31,47 @@ interface AlertItem {
|
|||
detail?: string;
|
||||
href?: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
extra?: React.ReactNode; // rendered below the detail line, inside the link
|
||||
}
|
||||
|
||||
// TopThreatLines renders the worst open advisories inline so the operator
|
||||
// sees what the threat count actually means without clicking through.
|
||||
const TopThreatLines: React.FC<{ threats: TopThreat[]; total: number }> = ({ threats, total }) => (
|
||||
<div className="mt-1.5 space-y-1">
|
||||
{threats.map((t) => {
|
||||
const sev = cveSeverityBadge(t.severity);
|
||||
return (
|
||||
<div key={t.id} className="flex items-center gap-2 text-xs flex-wrap">
|
||||
<span className={cn('badge border text-[10px] font-bold px-1.5 py-0.5', sev.cls)}>
|
||||
{sev.label}
|
||||
</span>
|
||||
{t.known_exploited && (
|
||||
<span className="badge border text-[10px] font-bold px-1.5 py-0.5 bg-red-100 text-red-800 border-red-300">
|
||||
KEV
|
||||
</span>
|
||||
)}
|
||||
{t.cvss_score !== undefined && t.cvss_score > 0 && (
|
||||
<span className="text-orange-800 font-medium">CVSS {t.cvss_score.toFixed(1)}</span>
|
||||
)}
|
||||
<a
|
||||
href={advisoryUrl(t.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-mono font-semibold text-orange-900 hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{t.id}
|
||||
</a>
|
||||
<span className="text-orange-700 truncate">{t.packages}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{total > threats.length && (
|
||||
<p className="text-[11px] text-orange-700">+ {total - threats.length} more advisories</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const AttentionPanel: React.FC = () => {
|
||||
const { data: stats } = useDashboardStats();
|
||||
const { data: drifted } = useDriftedSoftware();
|
||||
|
|
@ -58,8 +99,11 @@ const AttentionPanel: React.FC = () => {
|
|||
severity: 'vuln',
|
||||
title: `${n} open threat${n === 1 ? '' : 's'} in installed packages`,
|
||||
detail: 'Installed versions have active security advisories — patch to remediate',
|
||||
href: '/updates',
|
||||
href: '/updates?vuln=true',
|
||||
icon: ShieldAlert,
|
||||
extra: stats.top_threats?.length ? (
|
||||
<TopThreatLines threats={stats.top_threats} total={n} />
|
||||
) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -145,6 +189,7 @@ const AttentionPanel: React.FC = () => {
|
|||
{a.detail}
|
||||
</p>
|
||||
)}
|
||||
{a.extra}
|
||||
</div>
|
||||
<ChevronRight className={`w-4 h-4 flex-shrink-0 mt-1 ${eol ? 'text-red-400' : vuln ? 'text-orange-400' : 'text-amber-400'}`} />
|
||||
</Link>
|
||||
|
|
|
|||
178
web/src/components/VulnerabilityList.tsx
Normal file
178
web/src/components/VulnerabilityList.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import React from 'react';
|
||||
import { AlertTriangle, ExternalLink, Shield, ShieldAlert } from 'lucide-react';
|
||||
import { advisoryUrl, cveSeverityBadge, formatRelativeTime } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { osvVulnerabilityUrl, type VulnerabilityDisplay } from '@/lib/vulnerabilities';
|
||||
|
||||
interface VulnerabilityListProps {
|
||||
vulnerabilities: VulnerabilityDisplay[];
|
||||
checkedAt?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const cvssBadgeClass = (score?: number): string => {
|
||||
if (score === undefined) return 'bg-gray-100 text-gray-600 border-gray-300';
|
||||
if (score >= 9) return 'bg-red-100 text-red-800 border-red-300';
|
||||
if (score >= 7) return 'bg-orange-100 text-orange-800 border-orange-300';
|
||||
if (score >= 4) return 'bg-amber-100 text-amber-800 border-amber-300';
|
||||
if (score > 0) return 'bg-yellow-50 text-yellow-700 border-yellow-200';
|
||||
return 'bg-gray-100 text-gray-600 border-gray-300';
|
||||
};
|
||||
|
||||
const VulnerabilityList: React.FC<VulnerabilityListProps> = ({
|
||||
vulnerabilities,
|
||||
checkedAt,
|
||||
title = 'Security Advisories',
|
||||
}) => {
|
||||
if (vulnerabilities.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="card border-amber-200">
|
||||
<div className="flex items-center justify-between mb-3 gap-3">
|
||||
<h2 className="text-sm font-medium text-gray-700 inline-flex items-center gap-2">
|
||||
<Shield className="h-4 w-4 text-amber-600" />
|
||||
{title}
|
||||
<span className="text-xs text-gray-500 font-normal">({vulnerabilities.length})</span>
|
||||
</h2>
|
||||
{checkedAt && (
|
||||
<span className="text-xs text-gray-500 flex-shrink-0">
|
||||
checked {formatRelativeTime(checkedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{vulnerabilities.map((v, index) => {
|
||||
const sev = cveSeverityBadge(v.severity);
|
||||
const ranges = v.affected_ranges.slice(0, 4);
|
||||
const hiddenRangeCount = Math.max(0, v.affected_ranges.length - ranges.length);
|
||||
|
||||
return (
|
||||
<li key={`${v.id}-${index}`} className="bg-amber-50/40 border border-amber-100 rounded p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={cn('badge border text-[10px] font-bold px-1.5 py-0.5', sev.cls)}>
|
||||
{sev.label}
|
||||
</span>
|
||||
{v.cvss_score !== undefined ? (
|
||||
<span
|
||||
className={cn(
|
||||
'badge border text-[10px] font-bold px-1.5 py-0.5',
|
||||
cvssBadgeClass(v.cvss_score)
|
||||
)}
|
||||
title={v.cvss_vector || 'CVSS base score'}
|
||||
>
|
||||
CVSS {v.cvss_score.toFixed(1)}
|
||||
</span>
|
||||
) : v.cvss_vector ? (
|
||||
<span
|
||||
className="badge border text-[10px] font-bold px-1.5 py-0.5 bg-gray-100 text-gray-600 border-gray-300"
|
||||
title={v.cvss_vector}
|
||||
>
|
||||
CVSS
|
||||
</span>
|
||||
) : null}
|
||||
{v.known_exploited && (
|
||||
<span className="badge border text-[10px] font-bold px-1.5 py-0.5 bg-red-100 text-red-800 border-red-300">
|
||||
<ShieldAlert className="h-3 w-3 mr-1" />
|
||||
KEV
|
||||
</span>
|
||||
)}
|
||||
{v.advisory_type && (
|
||||
<span className="text-[10px] text-gray-500 font-medium">
|
||||
{v.advisory_type}
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href={advisoryUrl(v.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-semibold text-amber-900 font-mono inline-flex items-center gap-1 hover:text-amber-700 hover:underline"
|
||||
>
|
||||
{v.id}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
<a
|
||||
href={osvVulnerabilityUrl(v.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-indigo-600 hover:text-indigo-800 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
OSV
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{v.summary && (
|
||||
<p className="text-xs text-gray-700 mt-1">{v.summary}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500 flex-wrap">
|
||||
{v.fixed_version && (
|
||||
<span>
|
||||
fixed in <span className="font-mono text-green-700">{v.fixed_version}</span>
|
||||
</span>
|
||||
)}
|
||||
{v.published && (
|
||||
<span>published {formatRelativeTime(v.published)}</span>
|
||||
)}
|
||||
{v.source && (
|
||||
<span>{v.source === 'agent' ? 'agent reported' : v.source}</span>
|
||||
)}
|
||||
{v.cvss_vector && (
|
||||
<span className="font-mono text-gray-400" title="CVSS vector">
|
||||
{v.cvss_vector}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ranges.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span className="text-gray-500">affected</span>
|
||||
{ranges.map((range) => (
|
||||
<span
|
||||
key={range}
|
||||
className="font-mono text-[11px] text-gray-700 bg-white border border-amber-100 rounded px-1.5 py-0.5"
|
||||
>
|
||||
{range}
|
||||
</span>
|
||||
))}
|
||||
{hiddenRangeCount > 0 && (
|
||||
<span className="text-gray-500">+{hiddenRangeCount} more</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{v.aliases.length > 0 && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
aliases:{' '}
|
||||
{v.aliases.map((alias, aliasIndex) => (
|
||||
<React.Fragment key={alias}>
|
||||
{aliasIndex > 0 && ', '}
|
||||
<a
|
||||
href={advisoryUrl(alias)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-mono text-indigo-600 hover:text-indigo-800 hover:underline"
|
||||
>
|
||||
{alias}
|
||||
</a>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<p className="text-xs text-gray-500 mt-3">Source: OSV.dev</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VulnerabilityList;
|
||||
332
web/src/lib/vulnerabilities.ts
Normal file
332
web/src/lib/vulnerabilities.ts
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
export interface VulnerabilityDisplay {
|
||||
id: string;
|
||||
summary?: string;
|
||||
aliases: string[];
|
||||
severity?: string;
|
||||
cvss_vector?: string;
|
||||
cvss_score?: number;
|
||||
fixed_version?: string;
|
||||
published?: string;
|
||||
advisory_type?: string;
|
||||
affected_ranges: string[];
|
||||
known_exploited: boolean;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface OSVSeverityRecord {
|
||||
score?: string;
|
||||
}
|
||||
|
||||
interface OSVEventRecord {
|
||||
introduced?: string;
|
||||
fixed?: string;
|
||||
last_affected?: string;
|
||||
limit?: string;
|
||||
}
|
||||
|
||||
const stringValue = (value: unknown): string | undefined => {
|
||||
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
||||
};
|
||||
|
||||
const numberValue = (value: unknown): number | undefined => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const objectValue = (value: unknown): Record<string, unknown> | undefined => {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const parseRawList = (raw: unknown): unknown[] => {
|
||||
if (!raw) return [];
|
||||
if (Array.isArray(raw)) return raw;
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const osvVulnerabilityUrl = (id: string): string => {
|
||||
return `https://osv.dev/vulnerability/${encodeURIComponent(id)}`;
|
||||
};
|
||||
|
||||
export const parseVulnerabilityList = (raw: unknown): VulnerabilityDisplay[] => {
|
||||
const seen = new Set<string>();
|
||||
const result: VulnerabilityDisplay[] = [];
|
||||
|
||||
for (const item of parseRawList(raw)) {
|
||||
const normalized = normalizeVulnerability(item);
|
||||
if (!normalized || seen.has(normalized.id)) continue;
|
||||
seen.add(normalized.id);
|
||||
result.push(normalized);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const normalizeVulnerability = (raw: unknown): VulnerabilityDisplay | null => {
|
||||
const record = objectValue(raw);
|
||||
if (!record) return null;
|
||||
|
||||
const id = stringValue(record.id);
|
||||
if (!id) return null;
|
||||
|
||||
const databaseSpecific = objectValue(record.database_specific);
|
||||
const cvssVector = extractCVSSVector(record);
|
||||
const cvssScore =
|
||||
numberValue(record.cvss_score) ??
|
||||
numberValue(record.cvssScore) ??
|
||||
numberValue(databaseSpecific?.cvss_score) ??
|
||||
numberValue(databaseSpecific?.cvssScore) ??
|
||||
parseCVSS3BaseScore(cvssVector);
|
||||
|
||||
return {
|
||||
id,
|
||||
summary: stringValue(record.summary) ?? stringValue(record.description),
|
||||
aliases: normalizeAliases(record.aliases),
|
||||
severity: extractSeverity(record, databaseSpecific, cvssScore),
|
||||
cvss_vector: cvssVector,
|
||||
cvss_score: cvssScore,
|
||||
fixed_version: stringValue(record.fixed_version) ?? firstFixedVersion(record.affected),
|
||||
published: stringValue(record.published),
|
||||
advisory_type: stringValue(record.advisory_type) ?? advisoryType(id),
|
||||
affected_ranges: normalizeAffectedRanges(record.affected_ranges, record.affected),
|
||||
known_exploited: isKnownExploited(record, databaseSpecific),
|
||||
source: stringValue(record.source),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAliases = (raw: unknown): string[] => {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter((entry): entry is string => typeof entry === 'string' && entry.trim() !== '');
|
||||
};
|
||||
|
||||
const extractCVSSVector = (record: Record<string, unknown>): string | undefined => {
|
||||
const explicit = stringValue(record.cvss_vector) ?? stringValue(record.cvssVector);
|
||||
if (explicit?.startsWith('CVSS:')) return explicit;
|
||||
|
||||
const severity = record.severity;
|
||||
if (typeof severity === 'string' && severity.startsWith('CVSS:')) return severity;
|
||||
if (!Array.isArray(severity)) return undefined;
|
||||
|
||||
const vectors = severity
|
||||
.map((entry) => objectValue(entry) as OSVSeverityRecord | undefined)
|
||||
.map((entry) => stringValue(entry?.score))
|
||||
.filter((score): score is string => !!score && score.startsWith('CVSS:'));
|
||||
|
||||
return vectors.find((score) => score.startsWith('CVSS:4')) ?? vectors[0];
|
||||
};
|
||||
|
||||
const extractSeverity = (
|
||||
record: Record<string, unknown>,
|
||||
databaseSpecific: Record<string, unknown> | undefined,
|
||||
cvssScore: number | undefined
|
||||
): string | undefined => {
|
||||
const direct = stringValue(record.severity);
|
||||
if (direct && !direct.startsWith('CVSS:')) return direct.toUpperCase();
|
||||
|
||||
const databaseSeverity =
|
||||
stringValue(databaseSpecific?.severity) ??
|
||||
stringValue(databaseSpecific?.severity_rating) ??
|
||||
stringValue(databaseSpecific?.severityRating);
|
||||
if (databaseSeverity) return databaseSeverity.toUpperCase();
|
||||
|
||||
return severityFromCVSSScore(cvssScore);
|
||||
};
|
||||
|
||||
const severityFromCVSSScore = (score: number | undefined): string | undefined => {
|
||||
if (score === undefined) return undefined;
|
||||
if (score >= 9) return 'CRITICAL';
|
||||
if (score >= 7) return 'HIGH';
|
||||
if (score >= 4) return 'MEDIUM';
|
||||
if (score > 0) return 'LOW';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const parseCVSS3BaseScore = (vector?: string): number | undefined => {
|
||||
if (!vector || !vector.startsWith('CVSS:3.')) return undefined;
|
||||
|
||||
const metrics: Record<string, string> = {};
|
||||
for (const part of vector.split('/')) {
|
||||
const [key, value] = part.split(':');
|
||||
if (key && value) metrics[key] = value;
|
||||
}
|
||||
|
||||
const av = { N: 0.85, A: 0.62, L: 0.55, P: 0.2 }[metrics.AV];
|
||||
const ac = { L: 0.77, H: 0.44 }[metrics.AC];
|
||||
const scope = metrics.S;
|
||||
const pr = (scope === 'C'
|
||||
? { N: 0.85, L: 0.68, H: 0.5 }
|
||||
: { N: 0.85, L: 0.62, H: 0.27 })[metrics.PR];
|
||||
const ui = { N: 0.85, R: 0.62 }[metrics.UI];
|
||||
const c = { H: 0.56, L: 0.22, N: 0 }[metrics.C];
|
||||
const i = { H: 0.56, L: 0.22, N: 0 }[metrics.I];
|
||||
const a = { H: 0.56, L: 0.22, N: 0 }[metrics.A];
|
||||
|
||||
if (
|
||||
av === undefined ||
|
||||
ac === undefined ||
|
||||
pr === undefined ||
|
||||
ui === undefined ||
|
||||
c === undefined ||
|
||||
i === undefined ||
|
||||
a === undefined ||
|
||||
(scope !== 'U' && scope !== 'C')
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const exploitability = 8.22 * av * ac * pr * ui;
|
||||
const impactSubScore = 1 - (1 - c) * (1 - i) * (1 - a);
|
||||
const impact =
|
||||
scope === 'U'
|
||||
? 6.42 * impactSubScore
|
||||
: 7.52 * (impactSubScore - 0.029) - 3.25 * Math.pow(impactSubScore - 0.02, 15);
|
||||
|
||||
if (impact <= 0) return 0;
|
||||
const rawScore =
|
||||
scope === 'U'
|
||||
? Math.min(impact + exploitability, 10)
|
||||
: Math.min(1.08 * (impact + exploitability), 10);
|
||||
return Math.ceil((rawScore - 1e-10) * 10) / 10;
|
||||
};
|
||||
|
||||
const firstFixedVersion = (affected: unknown): string | undefined => {
|
||||
if (!Array.isArray(affected)) return undefined;
|
||||
|
||||
for (const affectedEntry of affected) {
|
||||
const ranges = objectValue(affectedEntry)?.ranges;
|
||||
if (!Array.isArray(ranges)) continue;
|
||||
for (const range of ranges) {
|
||||
const events = objectValue(range)?.events;
|
||||
if (!Array.isArray(events)) continue;
|
||||
for (const event of events) {
|
||||
const fixed = stringValue(objectValue(event)?.fixed);
|
||||
if (fixed) return fixed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const normalizeAffectedRanges = (displayRanges: unknown, affected: unknown): string[] => {
|
||||
if (Array.isArray(displayRanges)) {
|
||||
return displayRanges.filter(
|
||||
(entry): entry is string => typeof entry === 'string' && entry.trim() !== ''
|
||||
);
|
||||
}
|
||||
return formatAffectedRanges(affected);
|
||||
};
|
||||
|
||||
const formatAffectedRanges = (affected: unknown): string[] => {
|
||||
if (!Array.isArray(affected)) return [];
|
||||
|
||||
const ranges: string[] = [];
|
||||
for (const affectedEntry of affected) {
|
||||
const affectedRecord = objectValue(affectedEntry);
|
||||
if (!affectedRecord || !Array.isArray(affectedRecord.ranges)) continue;
|
||||
|
||||
for (const rangeEntry of affectedRecord.ranges) {
|
||||
const rangeRecord = objectValue(rangeEntry);
|
||||
if (!rangeRecord || !Array.isArray(rangeRecord.events)) continue;
|
||||
|
||||
const type = stringValue(rangeRecord.type);
|
||||
let introduced: string | undefined;
|
||||
let emitted = false;
|
||||
|
||||
for (const event of rangeRecord.events) {
|
||||
const eventRecord = objectValue(event) as OSVEventRecord | undefined;
|
||||
if (!eventRecord) continue;
|
||||
|
||||
if (eventRecord.introduced !== undefined) {
|
||||
introduced = eventRecord.introduced;
|
||||
}
|
||||
if (eventRecord.fixed) {
|
||||
ranges.push(formatRange(type, introduced, '<', eventRecord.fixed));
|
||||
emitted = true;
|
||||
introduced = undefined;
|
||||
} else if (eventRecord.last_affected) {
|
||||
ranges.push(formatRange(type, introduced, '<=', eventRecord.last_affected));
|
||||
emitted = true;
|
||||
} else if (eventRecord.limit) {
|
||||
ranges.push(formatRange(type, introduced, '<', eventRecord.limit));
|
||||
emitted = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!emitted && introduced) {
|
||||
ranges.push(formatRange(type, introduced, undefined, undefined));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ranges;
|
||||
};
|
||||
|
||||
const formatRange = (
|
||||
type: string | undefined,
|
||||
introduced: string | undefined,
|
||||
upperOp: '<' | '<=' | undefined,
|
||||
upperVersion: string | undefined
|
||||
): string => {
|
||||
const lower = introduced && introduced !== '0' ? `>= ${introduced}` : 'all prior versions';
|
||||
const body = upperOp && upperVersion ? `${lower}, ${upperOp} ${upperVersion}` : `${lower} and later`;
|
||||
return type ? `${type}: ${body}` : body;
|
||||
};
|
||||
|
||||
const isKnownExploited = (
|
||||
record: Record<string, unknown>,
|
||||
databaseSpecific: Record<string, unknown> | undefined
|
||||
): boolean => {
|
||||
const keys = [
|
||||
'known_exploited',
|
||||
'knownExploited',
|
||||
'known_exploited_vulnerability',
|
||||
'knownExploitedVulnerability',
|
||||
'cisa_kev',
|
||||
'cisaKev',
|
||||
'cisa_known_exploited',
|
||||
'cisaKnownExploited',
|
||||
'cisaExploitAdd',
|
||||
'cisaActionDue',
|
||||
'cisaRequiredAction',
|
||||
'cisaVulnerabilityName',
|
||||
'kev',
|
||||
];
|
||||
|
||||
return keys.some((key) => truthy(record[key]) || truthy(databaseSpecific?.[key]));
|
||||
};
|
||||
|
||||
const truthy = (value: unknown): boolean => {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') return value > 0;
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized !== '' && !['false', 'no', 'none', 'unknown', '0'].includes(normalized);
|
||||
}
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
if (value && typeof value === 'object') return Object.keys(value).length > 0;
|
||||
return false;
|
||||
};
|
||||
|
||||
const advisoryType = (id: string): string => {
|
||||
const up = id.toUpperCase();
|
||||
if (up.startsWith('ALSA-')) return 'AlmaLinux advisory';
|
||||
if (up.startsWith('RHSA-')) return 'Red Hat advisory';
|
||||
if (up.startsWith('USN-')) return 'Ubuntu advisory';
|
||||
if (up.startsWith('GHSA-')) return 'GitHub advisory';
|
||||
if (up.startsWith('CVE-')) return 'CVE';
|
||||
return 'security advisory';
|
||||
};
|
||||
|
|
@ -31,13 +31,13 @@ import { useRecentCommands } from '@/hooks/useCommands';
|
|||
import {
|
||||
formatBytes,
|
||||
formatRelativeTime,
|
||||
advisoryUrl,
|
||||
cveSeverityBadge,
|
||||
} from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { StatusBadge, SeverityBadge } from '@/components/primitives';
|
||||
import toast from 'react-hot-toast';
|
||||
import DependencyClosureTree from '@/components/DependencyClosureTree';
|
||||
import VulnerabilityList from '@/components/VulnerabilityList';
|
||||
import { parseVulnerabilityList } from '@/lib/vulnerabilities';
|
||||
import type { PackageFleetAgent } from '@/types';
|
||||
|
||||
const PackageDetail: React.FC = () => {
|
||||
|
|
@ -130,7 +130,7 @@ const PackageDetail: React.FC = () => {
|
|||
|
||||
const agents = agentsData?.agents ?? [];
|
||||
const versions = versionsData?.versions ?? [];
|
||||
const vulns = vulnsData?.vulnerabilities ?? summary.vulnerabilities ?? [];
|
||||
const vulns = parseVulnerabilityList(vulnsData?.vulnerabilities ?? summary.vulnerabilities ?? []);
|
||||
const pendingAgents = agents.filter((a) => a.can_approve);
|
||||
const approvedAgents = agents.filter((a) => a.can_install);
|
||||
const failedAgents = agents.filter((a) => a.can_retry);
|
||||
|
|
@ -441,74 +441,7 @@ const PackageDetail: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Supply Chain */}
|
||||
{vulns.length > 0 && (
|
||||
<div className="card">
|
||||
<h2 className="text-sm font-medium text-gray-700 inline-flex items-center gap-2 mb-3">
|
||||
<Shield className="h-4 w-4 text-gray-500" />
|
||||
Supply Chain
|
||||
<span className="text-xs text-gray-500 font-normal">({vulns.length} vulnerabilities)</span>
|
||||
</h2>
|
||||
<ul className="space-y-2">
|
||||
{vulns.map((v) => {
|
||||
const sev = cveSeverityBadge(v.severity);
|
||||
return (
|
||||
<li key={v.id} className="bg-amber-50/40 border border-amber-100 rounded p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={cn('badge border text-[10px] font-bold px-1.5 py-0.5', sev.cls)}>
|
||||
{sev.label}
|
||||
</span>
|
||||
<a
|
||||
href={advisoryUrl(v.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-semibold text-amber-900 font-mono inline-flex items-center gap-1 hover:text-amber-700 hover:underline"
|
||||
>
|
||||
{v.id}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
{v.summary && (
|
||||
<p className="text-xs text-gray-700 mt-1">{v.summary}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500 flex-wrap">
|
||||
{v.fixed_version && (
|
||||
<span>
|
||||
fixed in{' '}
|
||||
<span className="font-mono text-green-700">{v.fixed_version}</span>
|
||||
</span>
|
||||
)}
|
||||
{v.aliases && v.aliases.length > 0 && (
|
||||
<span>
|
||||
aliases:{' '}
|
||||
{v.aliases.map((alias, i) => (
|
||||
<React.Fragment key={alias}>
|
||||
{i > 0 && ', '}
|
||||
<a
|
||||
href={advisoryUrl(alias)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-mono text-indigo-600 hover:underline"
|
||||
>
|
||||
{alias}
|
||||
</a>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<p className="text-xs text-gray-400 mt-3">Source: OSV.dev</p>
|
||||
</div>
|
||||
)}
|
||||
<VulnerabilityList vulnerabilities={vulns} title="Supply Chain" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -29,11 +29,13 @@ import { useQueryClient } from '@tanstack/react-query';
|
|||
import { useUpdates, useUpdate, usePackages, usePackageFleet, usePackageVersions, useUpdateLifecycle, useApproveUpdate, useRejectUpdate, useInstallUpdate, useApproveMultipleUpdates, useRetryCommand, useReopenUpdate, useResolveUpdate, useCancelCommand } from '@/hooks/useUpdates';
|
||||
import { useRecentCommands } from '@/hooks/useCommands';
|
||||
import type { UpdatePackage } from '@/types';
|
||||
import { getPackageTypeIcon, formatBytes, formatRelativeTime, advisoryUrl, cveSeverityBadge } from '@/lib/utils';
|
||||
import { getPackageTypeIcon, formatBytes, formatRelativeTime } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import toast from 'react-hot-toast';
|
||||
import { updateApi } from '@/lib/api';
|
||||
import DependencyClosureTree from '@/components/DependencyClosureTree';
|
||||
import VulnerabilityList from '@/components/VulnerabilityList';
|
||||
import { parseVulnerabilityList } from '@/lib/vulnerabilities';
|
||||
|
||||
type UpdatesTab = 'updates' | 'commands';
|
||||
|
||||
|
|
@ -388,26 +390,7 @@ const Updates: React.FC = () => {
|
|||
|
||||
// Update detail view
|
||||
if (id && selectedUpdate) {
|
||||
// Parse vulnerabilities — server stores as JSON string in metadata
|
||||
let vulns: Array<{
|
||||
id: string;
|
||||
summary?: string;
|
||||
aliases?: string[];
|
||||
severity?: string;
|
||||
cvss_vector?: string;
|
||||
fixed_version?: string;
|
||||
published?: string;
|
||||
advisory_type?: string;
|
||||
}> = [];
|
||||
const rawVulns = selectedUpdate.metadata?.supply_chain_vulns;
|
||||
if (rawVulns) {
|
||||
try {
|
||||
const parsed = typeof rawVulns === 'string' ? JSON.parse(rawVulns) : rawVulns;
|
||||
if (Array.isArray(parsed)) vulns = parsed;
|
||||
} catch {
|
||||
// Fail-open: bad JSON shouldn't break the page
|
||||
}
|
||||
}
|
||||
const vulns = parseVulnerabilityList(selectedUpdate.metadata?.supply_chain_vulns);
|
||||
|
||||
const dependencies: string[] = Array.isArray(selectedUpdate.metadata?.dependencies)
|
||||
? selectedUpdate.metadata.dependencies
|
||||
|
|
@ -689,91 +672,7 @@ const Updates: React.FC = () => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Security Advisories */}
|
||||
{vulns.length > 0 && (
|
||||
<div className="card border-amber-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-medium text-gray-700 inline-flex items-center gap-2">
|
||||
<Shield className="h-4 w-4 text-amber-600" />
|
||||
Security Advisories
|
||||
<span className="text-xs text-gray-500 font-normal">({vulns.length})</span>
|
||||
</h2>
|
||||
{checkedAt && (
|
||||
<span className="text-xs text-gray-500">
|
||||
checked {formatRelativeTime(checkedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{vulns.map((v) => {
|
||||
const sev = cveSeverityBadge(v.severity);
|
||||
return (
|
||||
<li key={v.id} className="bg-amber-50/40 border border-amber-100 rounded p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={cn('badge border text-[10px] font-bold px-1.5 py-0.5', sev.cls)}>
|
||||
{sev.label}
|
||||
</span>
|
||||
{v.advisory_type && (
|
||||
<span className="text-[10px] text-gray-500 font-medium">
|
||||
{v.advisory_type}
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href={advisoryUrl(v.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-semibold text-amber-900 font-mono inline-flex items-center gap-1 hover:text-amber-700 hover:underline"
|
||||
>
|
||||
{v.id}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
{v.summary && (
|
||||
<p className="text-xs text-gray-700 mt-1">{v.summary}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500 flex-wrap">
|
||||
{v.fixed_version && (
|
||||
<span>fixed in <span className="font-mono text-green-700">{v.fixed_version}</span></span>
|
||||
)}
|
||||
{v.published && (
|
||||
<span>published {formatRelativeTime(v.published)}</span>
|
||||
)}
|
||||
{v.cvss_vector && (
|
||||
<span className="font-mono text-gray-400" title="CVSS vector">{v.cvss_vector}</span>
|
||||
)}
|
||||
</div>
|
||||
{v.aliases && v.aliases.length > 0 && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
aliases:{' '}
|
||||
{v.aliases.map((alias, i) => (
|
||||
<React.Fragment key={alias}>
|
||||
{i > 0 && ', '}
|
||||
<a
|
||||
href={advisoryUrl(alias)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-mono text-indigo-600 hover:text-indigo-800 hover:underline"
|
||||
>
|
||||
{alias}
|
||||
</a>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<p className="text-xs text-gray-500 mt-3">
|
||||
Source: OSV.dev
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<VulnerabilityList vulnerabilities={vulns} checkedAt={checkedAt} />
|
||||
|
||||
{/* Supply Chain — what RedFlag collected and pinned */}
|
||||
{hasSupplyChainData && (
|
||||
|
|
@ -1856,4 +1755,4 @@ const Updates: React.FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export default Updates;
|
||||
export default Updates;
|
||||
|
|
|
|||
|
|
@ -249,6 +249,15 @@ export interface UpdateLog {
|
|||
created_at: string;
|
||||
}
|
||||
|
||||
// One row of the dashboard's worst-open-threats summary
|
||||
export interface TopThreat {
|
||||
id: string;
|
||||
severity?: string;
|
||||
cvss_score?: number;
|
||||
known_exploited?: boolean;
|
||||
packages: string;
|
||||
}
|
||||
|
||||
// Dashboard stats
|
||||
export interface DashboardStats {
|
||||
total_agents: number;
|
||||
|
|
@ -261,6 +270,7 @@ export interface DashboardStats {
|
|||
failed_updates: number;
|
||||
available_fix_count: number; // distinct advisories on available version (remediation)
|
||||
open_threat_count: number; // distinct advisories on installed version (threat)
|
||||
top_threats?: TopThreat[]; // worst open threats, KEV then CVSS order
|
||||
critical_updates: number;
|
||||
high_updates: number;
|
||||
medium_updates: number;
|
||||
|
|
@ -306,6 +316,12 @@ export interface VulnerabilityEntry {
|
|||
source?: string;
|
||||
fixed_version?: string;
|
||||
aliases?: string[];
|
||||
cvss_vector?: string;
|
||||
cvss_score?: number;
|
||||
published?: string;
|
||||
advisory_type?: string;
|
||||
affected_ranges?: string[];
|
||||
known_exploited?: boolean;
|
||||
}
|
||||
|
||||
export interface PackageSummary {
|
||||
|
|
@ -767,4 +783,4 @@ export interface SecurityOverview {
|
|||
};
|
||||
alerts: string[];
|
||||
recommendations: string[];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue