Watch
1
0
Fork
You've already forked RedFlag
0

refactor: vulnerability → security advisory terminology

Rename CVE/vulnerability language to advisory/threat/fix across the stack:
- Dashboard: installed_cve_count→open_threat_count, security_update_count→available_fix_count
- Update detail: Known Vulnerabilities→Security Advisories
- AdvisoryType() helper for human-readable advisory ID prefixes
- clearVulnsOnInstall on installed transition with per-advisory security event logging
- StatsHandler takes checkInInterval for online/offline threshold
- AttentionPanel re-keyed on open-threats / available-fixes
This commit is contained in:
Fimeg 2026-06-11 01:18:39 -04:00
commit c71fc093db
10 changed files with 393 additions and 51 deletions

View file

@ -10,15 +10,20 @@ import (
// StatsHandler handles statistics for the dashboard
type StatsHandler struct {
agentQueries *queries.AgentQueries
updateQueries *queries.UpdateQueries
agentQueries *queries.AgentQueries
updateQueries *queries.UpdateQueries
checkInInterval int // seconds; used for online/offline threshold
}
// NewStatsHandler creates a new stats handler
func NewStatsHandler(agentQueries *queries.AgentQueries, updateQueries *queries.UpdateQueries) *StatsHandler {
func NewStatsHandler(agentQueries *queries.AgentQueries, updateQueries *queries.UpdateQueries, checkInInterval int) *StatsHandler {
if checkInInterval <= 0 {
checkInInterval = 300 // default 5 minutes
}
return &StatsHandler{
agentQueries: agentQueries,
updateQueries: updateQueries,
agentQueries: agentQueries,
updateQueries: updateQueries,
checkInInterval: checkInInterval,
}
}
@ -27,10 +32,11 @@ type DashboardStats struct {
TotalAgents int `json:"total_agents"`
OnlineAgents int `json:"online_agents"`
OfflineAgents int `json:"offline_agents"`
TotalUpdates int `json:"total_updates"`
PendingUpdates int `json:"pending_updates"`
FailedUpdates int `json:"failed_updates"`
SecurityUpdateCount int `json:"security_update_count"` // distinct advisories in available-version OSV check
InstalledCVECount int `json:"installed_cve_count"` // distinct advisories in installed-version OSV check
AvailableFixCount int `json:"available_fix_count"` // distinct advisories on available version (remediation)
OpenThreatCount int `json:"open_threat_count"` // distinct advisories on installed version (threat)
CriticalUpdates int `json:"critical_updates"`
ImportantUpdates int `json:"high_updates"`
ModerateUpdates int `json:"medium_updates"`
@ -52,9 +58,10 @@ func (h *StatsHandler) GetDashboardStats(c *gin.Context) {
UpdatesByType: make(map[string]int),
}
// Count online/offline agents
// Count online/offline agents using check-in interval (2x for threshold).
threshold := time.Duration(h.checkInInterval*2) * time.Second
for _, agent := range agents {
if time.Since(agent.LastSeen) <= 10*time.Minute {
if time.Since(agent.LastSeen) <= threshold {
stats.OnlineAgents++
} else {
stats.OfflineAgents++
@ -64,6 +71,7 @@ func (h *StatsHandler) GetDashboardStats(c *gin.Context) {
// Single aggregate query for all update stats (replaces N+1 per-agent loop)
updateStats, err := h.updateQueries.GetAllUpdateStats()
if err == nil {
stats.TotalUpdates = updateStats.TotalUpdates
stats.PendingUpdates = updateStats.PendingUpdates
stats.FailedUpdates = updateStats.FailedUpdates
stats.CriticalUpdates = updateStats.CriticalUpdates
@ -72,13 +80,13 @@ func (h *StatsHandler) GetDashboardStats(c *gin.Context) {
stats.LowUpdates = updateStats.LowUpdates
}
// Remediation count — distinct advisories in available-version OSV check.
if n, err := h.updateQueries.GetSecurityUpdateCount(); err == nil {
stats.SecurityUpdateCount = n
// Remediation count — distinct advisories on available version.
if n, err := h.updateQueries.GetAvailableFixCount(); err == nil {
stats.AvailableFixCount = n
}
// Threat count — distinct advisories in installed-version OSV check.
if n, err := h.updateQueries.GetInstalledCVECount(); err == nil {
stats.InstalledCVECount = n
// Threat count — distinct advisories on installed version.
if n, err := h.updateQueries.GetOpenThreatCount(); err == nil {
stats.OpenThreatCount = n
}
c.JSON(http.StatusOK, stats)

View file

@ -587,6 +587,17 @@ func (q *UpdateQueries) transitionStatus(tx *sqlx.Tx, sel statusSelector, to mod
if err := q.recordTerminalHistory(tx, cur, to, completedAt, meta); err != nil {
return cur, err
}
// Clear vulnerability metadata on installed transition — the threat is
// remediated. Log each cleared advisory to security_events for later
// grouping and audit.
if to == models.StatusInstalled {
if err := q.clearVulnsOnInstall(tx, cur); err != nil {
log.Printf("[WARNING] [server] [updates] vuln_clearance_failed pkg=%s/%s error=%v",
cur.PackageType, cur.PackageName, err)
// Non-fatal: transition already committed, clearance is best-effort.
}
}
}
return cur, nil
}
@ -607,6 +618,78 @@ func (q *UpdateQueries) recordTerminalHistory(tx *sqlx.Tx, cur models.UpdateStat
return nil
}
// clearVulnsOnInstall clears vulnerability metadata when a package transitions
// to installed (threat remediated). Logs each cleared advisory to security_events
// for later grouping and audit.
func (q *UpdateQueries) clearVulnsOnInstall(tx *sqlx.Tx, cur models.UpdateState) error {
if cur.Metadata == nil {
return nil
}
// Collect advisories before clearing.
var cleared []string
for _, key := range []string{"supply_chain_vulns", "installed_vulns"} {
raw, ok := cur.Metadata[key]
if !ok || raw == nil {
continue
}
var vulns []map[string]interface{}
switch v := raw.(type) {
case string:
if err := json.Unmarshal([]byte(v), &vulns); err != nil {
continue
}
case []interface{}:
for _, item := range v {
if m, ok := item.(map[string]interface{}); ok {
vulns = append(vulns, m)
}
}
}
for _, vuln := range vulns {
if id, ok := vuln["id"].(string); ok && id != "" {
cleared = append(cleared, id)
}
}
}
if len(cleared) == 0 {
return nil
}
// Clear the vuln metadata keys.
_, err := tx.Exec(`
UPDATE current_package_state
SET metadata = metadata - 'supply_chain_vulns' - 'installed_vulns'
- 'supply_chain_checked_at' - 'installed_checked_at'
- 'supply_chain_checked_version' - 'installed_checked_version'
WHERE id = $1`, cur.ID)
if err != nil {
return fmt.Errorf("clear vuln metadata: %w", err)
}
// Log each cleared advisory to security_events.
for _, advisoryID := range cleared {
_, err := tx.Exec(`
INSERT INTO security_events (timestamp, level, event_type, agent_id, message, trace_id, ip_address, details, metadata)
VALUES (NOW(), 'INFO', 'SECURITY_ADVISORY_CLEARED', $1, $2, '', '', $3, '{}'::jsonb)`,
cur.AgentID,
fmt.Sprintf("Advisory %s cleared — %s/%s updated to %s",
advisoryID, cur.PackageType, cur.PackageName, cur.AvailableVersion),
fmt.Sprintf(`{"advisory_id":"%s","package_type":"%s","package_name":"%s","version_to":"%s"}`,
advisoryID, cur.PackageType, cur.PackageName, cur.AvailableVersion))
if err != nil {
log.Printf("[WARNING] [server] [updates] advisory_clearance_log_failed advisory=%s error=%v",
advisoryID, err)
// Non-fatal: clearance already happened, logging is best-effort.
}
}
log.Printf("[INFO] [server] [updates] vulns_cleared pkg=%s/%s count=%d advisories=%v",
cur.PackageType, cur.PackageName, len(cleared), cleared)
return nil
}
// runTransition opens a transaction, applies a single transition, runs an optional
// follow-up write in the same transaction, and commits.
func (q *UpdateQueries) runTransition(sel statusSelector, to models.PackageStatus, after func(tx *sqlx.Tx, cur models.UpdateState) error) error {
@ -1215,10 +1298,10 @@ func (q *UpdateQueries) GetAllUpdateStats() (*models.UpdateStats, error) {
return stats, nil
}
// GetSecurityUpdateCount returns the number of distinct OSV advisory IDs present
// GetAvailableFixCount returns the number of distinct OSV advisory IDs present
// in supply_chain_vulns across all non-terminal package rows. Deduplicates by
// advisory ID so sub-packages sharing one advisory count as one, not many.
func (q *UpdateQueries) GetSecurityUpdateCount() (int, error) {
func (q *UpdateQueries) GetAvailableFixCount() (int, error) {
query := `
SELECT COUNT(DISTINCT vuln->>'id')
FROM current_package_state,
@ -1240,10 +1323,10 @@ func (q *UpdateQueries) GetSecurityUpdateCount() (int, error) {
return count, nil
}
// GetInstalledCVECount returns the number of distinct OSV advisory IDs found
// GetOpenThreatCount returns the number of distinct OSV advisory IDs found
// in installed_vulns — the threat check against the currently-installed version.
// Only counts rows in active states (not installed/ignored/failed).
func (q *UpdateQueries) GetInstalledCVECount() (int, error) {
func (q *UpdateQueries) GetOpenThreatCount() (int, error) {
query := `
SELECT COUNT(DISTINCT vuln->>'id')
FROM current_package_state,

View file

@ -35,6 +35,7 @@ var SecurityEventTypes = struct {
UnauthorizedAccessAttempt string
ConfigTamperingDetected string
AnomalousBehavior string
SecurityAdvisoryCleared string
}{
CmdSigned: "CMD_SIGNED",
CmdSignatureVerificationFailed: "CMD_SIGNATURE_VERIFICATION_FAILED",
@ -48,6 +49,7 @@ var SecurityEventTypes = struct {
UnauthorizedAccessAttempt: "UNAUTHORIZED_ACCESS_ATTEMPT",
ConfigTamperingDetected: "CONFIG_TAMPERING_DETECTED",
AnomalousBehavior: "ANOMALOUS_BEHAVIOR",
SecurityAdvisoryCleared: "SECURITY_ADVISORY_CLEARED",
}
// IsCritical returns true if the event is of critical severity

View file

@ -87,6 +87,25 @@ type VulnerabilityInfo struct {
CVSSVector string `json:"cvss_vector,omitempty"`
FixedVersion string `json:"fixed_version,omitempty"`
Published string `json:"published,omitempty"`
AdvisoryType string `json:"advisory_type,omitempty"` // "AlmaLinux advisory", "CVE", etc.
}
// AdvisoryType returns a human-readable label for an advisory ID prefix.
func AdvisoryType(id string) string {
switch {
case strings.HasPrefix(id, "ALSA-"):
return "AlmaLinux advisory"
case strings.HasPrefix(id, "RHSA-"):
return "Red Hat advisory"
case strings.HasPrefix(id, "USN-"):
return "Ubuntu advisory"
case strings.HasPrefix(id, "GHSA-"):
return "GitHub advisory"
case strings.HasPrefix(id, "CVE-"):
return "CVE"
default:
return "security advisory"
}
}
// toVulnerabilityInfo maps a raw OSV record into the display struct, pulling
@ -94,10 +113,11 @@ type VulnerabilityInfo struct {
// publish date out of the OSV schema's various nesting points.
func toVulnerabilityInfo(v OSVVuln) VulnerabilityInfo {
info := VulnerabilityInfo{
ID: v.ID,
Summary: v.Summary,
Aliases: v.Aliases,
Published: v.Published,
ID: v.ID,
Summary: v.Summary,
Aliases: v.Aliases,
Published: v.Published,
AdvisoryType: AdvisoryType(v.ID),
}
// Qualitative severity: GHSA puts it in database_specific.severity.
@ -309,7 +329,9 @@ func osvBatchRun(reqs []OSVCheckRequest, store OSVStoreFunc) {
}
if len(result.Vulns) > 0 {
vulnJSON, err := json.Marshal(result.Vulns)
// Enrich ALSA/RHSA/USN advisories with full details from OSV.
enriched := enrichAdvisoryVulns(result.Vulns)
vulnJSON, err := json.Marshal(enriched)
if err != nil {
log.Printf("[WARNING] [supply_chain] vuln_marshal_failed pkg=%s error=%v", r.PkgName, err)
continue
@ -328,6 +350,55 @@ func osvBatchRun(reqs []OSVCheckRequest, store OSVStoreFunc) {
}
}
// enrichAdvisoryVulns does a second OSV pass for ALSA/RHSA/USN advisories to
// resolve their aliases and summaries. OSV's batch endpoint returns advisory IDs
// but often without full details; the single-vuln endpoint (/v1/vulns/{id})
// returns the complete record including constituent CVEs.
func enrichAdvisoryVulns(vulns []OSVVuln) []OSVVuln {
enriched := make([]OSVVuln, 0, len(vulns))
for _, v := range vulns {
if needsEnrichment(v) {
if resolved, err := fetchVulnDetail(v.ID); err == nil {
enriched = append(enriched, resolved)
continue
}
// On failure, keep original — partial data is better than none.
log.Printf("[WARNING] [supply_chain] enrich_failed id=%s", v.ID)
}
enriched = append(enriched, v)
}
return enriched
}
// needsEnrichment returns true if a vuln record looks sparse — missing
// summary and aliases — and its ID is a known advisory prefix.
func needsEnrichment(v OSVVuln) bool {
if v.Summary != "" && len(v.Aliases) > 0 {
return false
}
return strings.HasPrefix(v.ID, "ALSA-") ||
strings.HasPrefix(v.ID, "RHSA-") ||
strings.HasPrefix(v.ID, "USN-")
}
// fetchVulnDetail queries OSV.dev for a single vulnerability ID.
func fetchVulnDetail(id string) (OSVVuln, error) {
url := fmt.Sprintf("https://api.osv.dev/v1/vulns/%s", id)
resp, err := osvHTTPClient.Get(url)
if err != nil {
return OSVVuln{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return OSVVuln{}, fmt.Errorf("osv status %d", resp.StatusCode)
}
var v OSVVuln
if err := json.NewDecoder(resp.Body).Decode(&v); err != nil {
return OSVVuln{}, err
}
return v, nil
}
// osvMetaKeys returns the metadata key names for a given check namespace.
func osvMetaKeys(namespace string) (checkedAt, checkedVersion, vulns, checkError string) {
if namespace == "installed" {

View file

@ -50,26 +50,26 @@ const AttentionPanel: React.FC = () => {
});
}
// 1b. Installed version has known CVEs — this is the threat number.
if (stats && stats.installed_cve_count > 0) {
const n = stats.installed_cve_count;
// 1b. Open threats — advisories affecting installed versions.
if (stats && stats.open_threat_count > 0) {
const n = stats.open_threat_count;
alerts.push({
key: 'installed-cves',
key: 'open-threats',
severity: 'vuln',
title: `${n} known CVE${n === 1 ? '' : 's'} in installed packages`,
detail: 'Installed versions have active advisories — patch to remediate',
title: `${n} open threat${n === 1 ? '' : 's'} in installed packages`,
detail: 'Installed versions have active security advisories — patch to remediate',
href: '/updates',
icon: ShieldAlert,
});
}
// 1c. Security updates available — remediation framing, not threat.
if (stats && stats.security_update_count > 0) {
const n = stats.security_update_count;
// 1c. Available fixes — advisories on available versions (remediation).
if (stats && stats.available_fix_count > 0) {
const n = stats.available_fix_count;
alerts.push({
key: 'security-updates',
key: 'available-fixes',
severity: 'patch',
title: `${n} security update${n === 1 ? '' : 's'} available`,
title: `${n} security fix${n === 1 ? '' : 'es'} available to apply`,
detail: 'Updates carry security advisories — review and approve to apply',
href: '/updates?vuln=true',
icon: ShieldAlert,

View file

@ -0,0 +1,105 @@
import { useState, useCallback } from 'react';
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import { useSearchParams } from 'react-router-dom';
export interface SortConfig {
sortBy: string;
sortOrder: 'asc' | 'desc';
}
interface UseColumnSortOptions {
/** Sync sort state to URL search params (default false). */
syncUrl?: boolean;
/** Default column to sort by. */
defaultSortBy?: string;
/** Default sort direction (default 'desc'). */
defaultOrder?: 'asc' | 'desc';
}
/**
* Reusable column-sort state hook. Extracted from Updates.tsx; use anywhere a
* table needs click-to-sort headers with URL persistence.
*
* Returns the sort config, a header click handler, a sort-icon renderer, and a
* generic sort-applier for arrays.
*/
export function useColumnSort(opts: UseColumnSortOptions = {}) {
const { syncUrl, defaultSortBy = '', defaultOrder = 'desc' } = opts;
const [searchParams, setSearchParams] = syncUrl ? useSearchParams() : [null, null] as any;
const [sortBy, setSortBy] = useState<string>(
syncUrl ? (searchParams.get('sort_by') || defaultSortBy) : defaultSortBy,
);
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(
syncUrl
? ((searchParams.get('sort_order') as 'asc' | 'desc') || defaultOrder)
: defaultOrder,
);
const handleSort = useCallback(
(column: string) => {
setSortBy((prev) => {
if (prev === column) {
const next = sortOrder === 'asc' ? 'desc' : 'asc';
setSortOrder(next);
if (syncUrl) {
const p = new URLSearchParams(searchParams);
p.set('sort_by', column);
p.set('sort_order', next);
setSearchParams(p, { replace: true });
}
return column;
}
setSortOrder('desc');
if (syncUrl) {
const p = new URLSearchParams(searchParams);
p.set('sort_by', column);
p.set('sort_order', 'desc');
setSearchParams(p, { replace: true });
}
return column;
});
},
[sortOrder, syncUrl, searchParams, setSearchParams],
);
const renderSortIcon = useCallback(
(column: string) => {
if (sortBy !== column) {
return <ArrowUpDown className="h-4 w-4 ml-1 text-gray-400" />;
}
return sortOrder === 'asc' ? (
<ArrowUp className="h-4 w-4 ml-1 text-primary-600" />
) : (
<ArrowDown className="h-4 w-4 ml-1 text-primary-600" />
);
},
[sortBy, sortOrder],
);
/**
* Sort an array of items by a column, given a value extractor. Handles
* strings (localeCompare), numbers, and dates; nulls sort last regardless
* of direction.
*/
const applySort = useCallback(
<T,>(items: T[], extract: (item: T) => string | number | Date | null | undefined): T[] => {
if (!sortBy) return items;
const dir = sortOrder === 'asc' ? 1 : -1;
return [...items].sort((a, b) => {
const va = extract(a);
const vb = extract(b);
// Nulls last
if (va == null && vb == null) return 0;
if (va == null) return 1;
if (vb == null) return -1;
if (va instanceof Date && vb instanceof Date) return (va.getTime() - vb.getTime()) * dir;
if (typeof va === 'number' && typeof vb === 'number') return (va - vb) * dir;
return String(va).localeCompare(String(vb), undefined, { numeric: true }) * dir;
});
},
[sortBy, sortOrder],
);
return { sortBy, sortOrder, handleSort, renderSortIcon, applySort } as const;
}

View file

@ -31,6 +31,7 @@ import {
CommandCard,
} from '@/components/primitives';
import { useDebounce } from '@/hooks/useDebounce';
import { useColumnSort } from '@/hooks/useColumnSort';
import { useAgents, useAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents';
import { useActiveCommands, useCancelCommand, useCaptureScreenshot, useCommand } from '@/hooks/useCommands';
import { useHeartbeatStatus } from '@/hooks/useHeartbeat';
@ -66,6 +67,10 @@ const Agents: React.FC = () => {
const debouncedSearchQuery = useDebounce(searchQuery, 300);
const [statusFilter, setStatusFilter] = useState<string>(searchParams.get('status') || '');
const [osFilter, setOsFilter] = useState<string>('');
const { sortBy, handleSort, renderSortIcon, applySort } = useColumnSort({
defaultSortBy: 'last_seen',
defaultOrder: 'asc',
});
const [selectedAgents, setSelectedAgents] = useState<string[]>([]);
const activeTab = parseAgentDetailTab(searchParams.get('tab'));
const [heartbeatDuration, setHeartbeatDuration] = useState<number>(10); // Default 10 minutes
@ -259,6 +264,19 @@ const Agents: React.FC = () => {
return agent.os_type.toLowerCase().includes(osFilter.toLowerCase());
});
// Sort agents client-side (fleet size doesn't warrant server-side pagination yet)
const sortedAgents = applySort(filteredAgents, (agent) => {
switch (sortBy) {
case 'hostname': return agent.hostname;
case 'status': return isOnline(agent.last_seen) ? 0 : 1; // online first when asc
case 'version': return agent.current_version || agent.agent_version || '';
case 'os': return agent.os_type;
case 'last_seen': return agent.last_seen ? new Date(agent.last_seen) : null;
case 'last_scan': return agent.last_scan ? new Date(agent.last_scan) : null;
default: return null;
}
});
// Handle agent selection
const handleSelectAgent = (agentId: string, checked: boolean) => {
if (checked) {
@ -270,7 +288,7 @@ const Agents: React.FC = () => {
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedAgents(filteredAgents.map(agent => agent.id));
setSelectedAgents(sortedAgents.map(agent => agent.id));
} else {
setSelectedAgents([]);
}
@ -1012,7 +1030,7 @@ const Agents: React.FC = () => {
<PageState
loading={isPending}
error={error ? 'Failed to load agents' : null}
empty={filteredAgents.length === 0}
empty={sortedAgents.length === 0}
emptyTitle="No agents found"
emptyMessage={
debouncedSearchQuery || statusFilter || osFilter
@ -1028,22 +1046,70 @@ const Agents: React.FC = () => {
<th className="table-header">
<input
type="checkbox"
checked={selectedAgents.length === filteredAgents.length}
checked={selectedAgents.length === sortedAgents.length && sortedAgents.length > 0}
onChange={(e) => handleSelectAll(e.target.checked)}
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</th>
<th className="table-header">Agent</th>
<th className="table-header">Status</th>
<th className="table-header">Version</th>
<th className="table-header">OS</th>
<th className="table-header">Last Check-in</th>
<th className="table-header">Last Scan</th>
<th className="table-header">
<button
onClick={() => handleSort('hostname')}
className="flex items-center hover:text-primary-600 font-medium"
>
Agent
{renderSortIcon('hostname')}
</button>
</th>
<th className="table-header">
<button
onClick={() => handleSort('status')}
className="flex items-center hover:text-primary-600 font-medium"
>
Status
{renderSortIcon('status')}
</button>
</th>
<th className="table-header">
<button
onClick={() => handleSort('version')}
className="flex items-center hover:text-primary-600 font-medium"
>
Version
{renderSortIcon('version')}
</button>
</th>
<th className="table-header">
<button
onClick={() => handleSort('os')}
className="flex items-center hover:text-primary-600 font-medium"
>
OS
{renderSortIcon('os')}
</button>
</th>
<th className="table-header">
<button
onClick={() => handleSort('last_seen')}
className="flex items-center hover:text-primary-600 font-medium"
>
Last Check-in
{renderSortIcon('last_seen')}
</button>
</th>
<th className="table-header">
<button
onClick={() => handleSort('last_scan')}
className="flex items-center hover:text-primary-600 font-medium"
>
Last Scan
{renderSortIcon('last_scan')}
</button>
</th>
<th className="table-header">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredAgents.map((agent) => (
{sortedAgents.map((agent) => (
<tr key={agent.id} className="hover:bg-gray-50 group">
<td className="table-cell">
<input

View file

@ -150,7 +150,7 @@ const Dashboard: React.FC = () => {
<div
className={`h-2 rounded-full ${severity.color}`}
style={{
width: `${(stats?.pending_updates ?? 0) > 0 ? (severity.value / (stats?.pending_updates ?? 1)) * 100 : 0}%`
width: `${(stats?.total_updates ?? 0) > 0 ? (severity.value / (stats?.total_updates ?? 1)) * 100 : 0}%`
}}
></div>
</div>

View file

@ -397,6 +397,7 @@ const Updates: React.FC = () => {
cvss_vector?: string;
fixed_version?: string;
published?: string;
advisory_type?: string;
}> = [];
const rawVulns = selectedUpdate.metadata?.supply_chain_vulns;
if (rawVulns) {
@ -510,7 +511,7 @@ const Updates: React.FC = () => {
{vulns.length > 0 && (
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2.5 py-0.5">
<Shield className="w-3.5 h-3.5" />
{vulns.length} known {vulns.length === 1 ? 'vulnerability' : 'vulnerabilities'}
{vulns.length} security {vulns.length === 1 ? 'advisory' : 'advisories'}
</span>
)}
{/* Supply Chain Gate indicator: show when this update uses capability-token path */}
@ -687,13 +688,13 @@ const Updates: React.FC = () => {
/>
)}
{/* Known Vulnerabilities */}
{/* 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" />
Known Vulnerabilities
Security Advisories
<span className="text-xs text-gray-500 font-normal">({vulns.length})</span>
</h2>
{checkedAt && (
@ -714,6 +715,11 @@ const Updates: React.FC = () => {
<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"

View file

@ -254,12 +254,13 @@ export interface DashboardStats {
total_agents: number;
online_agents: number;
offline_agents: number;
total_updates: number;
pending_updates: number;
approved_updates: number;
installed_updates: number;
failed_updates: number;
security_update_count: number; // distinct advisories on available version
installed_cve_count: number; // distinct advisories on installed version (threat)
available_fix_count: number; // distinct advisories on available version (remediation)
open_threat_count: number; // distinct advisories on installed version (threat)
critical_updates: number;
high_updates: number;
medium_updates: number;