Watch
1
0
Fork
You've already forked RedFlag
0

supply chain vuln UI — backend endpoints + frontend rendering for CVE/advisory detail

This commit is contained in:
Fimeg 2026-06-11 11:32:24 -04:00
commit 2064a5035f
11 changed files with 1013 additions and 229 deletions

View file

@ -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>

View 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;

View 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';
};

View file

@ -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>

View file

@ -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;

View file

@ -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[];
}
}