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

@ -29,19 +29,20 @@ func NewStatsHandler(agentQueries *queries.AgentQueries, updateQueries *queries.
// DashboardStats represents dashboard statistics
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"`
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"`
LowUpdates int `json:"low_updates"`
UpdatesByType map[string]int `json:"updates_by_type"`
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"`
AvailableFixCount int `json:"available_fix_count"` // distinct advisories on available version (remediation)
OpenThreatCount int `json:"open_threat_count"` // distinct advisories on installed version (threat)
TopThreats []queries.TopThreat `json:"top_threats,omitempty"` // worst open threats, KEV then CVSS order
CriticalUpdates int `json:"critical_updates"`
ImportantUpdates int `json:"high_updates"`
ModerateUpdates int `json:"medium_updates"`
LowUpdates int `json:"low_updates"`
UpdatesByType map[string]int `json:"updates_by_type"`
}
// GetDashboardStats returns dashboard statistics using aggregate queries (F-B1-6 fix)
@ -88,6 +89,11 @@ func (h *StatsHandler) GetDashboardStats(c *gin.Context) {
if n, err := h.updateQueries.GetOpenThreatCount(); err == nil {
stats.OpenThreatCount = n
}
if stats.OpenThreatCount > 0 {
if top, err := h.updateQueries.GetTopOpenThreats(3); err == nil {
stats.TopThreats = top
}
}
c.JSON(http.StatusOK, stats)
}
}

View file

@ -249,6 +249,11 @@ func (q *UpdateQueries) ListAggregatedPackages(search, packageType, status, sort
WHEN 'critical' THEN 4 WHEN 'high' THEN 3
WHEN 'medium' THEN 2 WHEN 'moderate' THEN 2
WHEN 'low' THEN 1 ELSE 0 END)`
vulnArrayExpr := `CASE
WHEN jsonb_typeof(NULLIF(metadata->>'supply_chain_vulns', '')::jsonb) = 'array'
THEN NULLIF(metadata->>'supply_chain_vulns', '')::jsonb
ELSE '[]'::jsonb
END`
// Whitelist sort columns; default to most-recently-discovered.
orderCol := "last_discovered_at"
@ -292,8 +297,8 @@ func (q *UpdateQueries) ListAggregatedPackages(search, packageType, status, sort
MAX(current_version) AS sample_current_version,
MAX(available_version) AS sample_available_version,
%s AS max_severity_rank,
bool_or(metadata ? 'supply_chain_vulns') AS has_vulns,
COALESCE(MAX(jsonb_array_length(NULLIF(metadata->>'supply_chain_vulns', '')::jsonb)), 0) AS vuln_count,
bool_or(jsonb_array_length(%s) > 0) AS has_vulns,
COALESCE(MAX(jsonb_array_length(%s)), 0) AS vuln_count,
COUNT(*) FILTER (WHERE expected_sha256 IS NOT NULL) AS hash_pinned_count,
COUNT(*) FILTER (WHERE status = 'pending') AS pending_count,
COUNT(*) FILTER (WHERE status = 'approved') AS approved_count,
@ -309,7 +314,7 @@ func (q *UpdateQueries) ListAggregatedPackages(search, packageType, status, sort
%s
ORDER BY %s %s, package_name ASC
LIMIT %d OFFSET %d`,
severityRank, whereClause, havingClause, orderCol, dir, pageSize, offset)
severityRank, vulnArrayExpr, vulnArrayExpr, whereClause, havingClause, orderCol, dir, pageSize, offset)
var rows []AggregatedPackage
if err := q.db.Select(&rows, query, args...); err != nil {
@ -1361,6 +1366,48 @@ func (q *UpdateQueries) GetOpenThreatCount() (int, error) {
return count, nil
}
// TopThreat is one row of the dashboard's worst-open-threats summary — a
// distinct advisory affecting installed versions, with the packages it hits.
type TopThreat struct {
ID string `db:"id" json:"id"`
Severity string `db:"severity" json:"severity,omitempty"`
CVSSScore float64 `db:"cvss_score" json:"cvss_score,omitempty"`
KnownExploited bool `db:"known_exploited" json:"known_exploited,omitempty"`
Packages string `db:"packages" json:"packages"`
}
// GetTopOpenThreats returns the worst distinct advisories on installed
// versions — KEV entries first, then by CVSS score. Same active-row predicate
// as GetOpenThreatCount.
func (q *UpdateQueries) GetTopOpenThreats(limit int) ([]TopThreat, error) {
query := `
SELECT vuln->>'id' AS id,
COALESCE(MAX(NULLIF(vuln->>'severity','')), '') AS severity,
COALESCE(MAX((NULLIF(vuln->>'cvss_score',''))::float8), 0) AS cvss_score,
BOOL_OR(COALESCE((vuln->>'known_exploited')::boolean, false)) AS known_exploited,
STRING_AGG(DISTINCT package_name, ', ') AS packages
FROM current_package_state,
jsonb_array_elements(
CASE WHEN jsonb_typeof((metadata->>'installed_vulns')::jsonb) = 'array'
THEN (metadata->>'installed_vulns')::jsonb
ELSE '[]'::jsonb
END
) AS vuln
WHERE metadata IS NOT NULL
AND metadata ? 'installed_vulns'
AND metadata->>'installed_vulns' NOT IN ('[]', '')
AND status NOT IN ('installed', 'ignored', 'failed')
GROUP BY vuln->>'id'
ORDER BY known_exploited DESC, cvss_score DESC, id DESC
LIMIT $1
`
var threats []TopThreat
if err := q.db.Select(&threats, query, limit); err != nil {
return nil, fmt.Errorf("get top open threats: %w", err)
}
return threats, nil
}
// GetUpdateLogs retrieves installation logs for a specific update
func (q *UpdateQueries) GetUpdateLogs(updateID uuid.UUID, limit int) ([]models.UpdateLog, error) {
var logs []models.UpdateLog

View file

@ -252,13 +252,19 @@ type UpdateStats struct {
// VulnerabilityEntry represents a single CVE/vulnerability from agent-reported or
// OSV-sourced data.
type VulnerabilityEntry struct {
ID string `json:"id"`
Summary string `json:"summary,omitempty"`
Severity string `json:"severity,omitempty"`
Description string `json:"description,omitempty"`
Source string `json:"source,omitempty"` // "osv" or "agent"
FixedVersion string `json:"fixed_version,omitempty"`
Aliases []string `json:"aliases,omitempty"`
ID string `json:"id"`
Summary string `json:"summary,omitempty"`
Severity string `json:"severity,omitempty"`
Description string `json:"description,omitempty"`
Source string `json:"source,omitempty"` // "osv" or "agent"
FixedVersion string `json:"fixed_version,omitempty"`
Aliases []string `json:"aliases,omitempty"`
CVSSVector string `json:"cvss_vector,omitempty"`
CVSSScore float64 `json:"cvss_score,omitempty"`
Published string `json:"published,omitempty"`
AdvisoryType string `json:"advisory_type,omitempty"`
AffectedRanges []string `json:"affected_ranges,omitempty"`
KnownExploited bool `json:"known_exploited,omitempty"`
}
// reservedMetadataKeys are internal keys excluded from DisplayMetadata.

View file

@ -5,7 +5,9 @@ import (
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"regexp"
"strings"
"sync"
"time"
@ -65,8 +67,10 @@ type OSVRange struct {
// OSVEvent is a single boundary in a range (introduced or fixed).
type OSVEvent struct {
Introduced string `json:"introduced"`
Fixed string `json:"fixed"`
Introduced string `json:"introduced"`
Fixed string `json:"fixed"`
LastAffected string `json:"last_affected"`
Limit string `json:"limit"`
}
// SupplyChainCheckResult is returned by the vulnerability check.
@ -80,14 +84,17 @@ type SupplyChainCheckResult struct {
// Severity is qualitative (CRITICAL/HIGH/MODERATE/LOW) when OSV provides it;
// CVSSVector is the raw v3/v4 vector for operators who want the dimensions.
type VulnerabilityInfo struct {
ID string `json:"id"`
Summary string `json:"summary"`
Aliases []string `json:"aliases"`
Severity string `json:"severity,omitempty"`
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.
ID string `json:"id"`
Summary string `json:"summary"`
Aliases []string `json:"aliases"`
Severity string `json:"severity,omitempty"`
CVSSVector string `json:"cvss_vector,omitempty"`
CVSSScore float64 `json:"cvss_score,omitempty"`
FixedVersion string `json:"fixed_version,omitempty"`
Published string `json:"published,omitempty"`
AdvisoryType string `json:"advisory_type,omitempty"` // "AlmaLinux advisory", "CVE", etc.
AffectedRanges []string `json:"affected_ranges,omitempty"`
KnownExploited bool `json:"known_exploited,omitempty"`
}
// AdvisoryType returns a human-readable label for an advisory ID prefix.
@ -95,8 +102,12 @@ func AdvisoryType(id string) string {
switch {
case strings.HasPrefix(id, "ALSA-"):
return "AlmaLinux advisory"
case strings.HasPrefix(id, "ALBA-"), strings.HasPrefix(id, "ALEA-"):
return "AlmaLinux erratum (non-security)"
case strings.HasPrefix(id, "RHSA-"):
return "Red Hat advisory"
case strings.HasPrefix(id, "RHBA-"), strings.HasPrefix(id, "RHEA-"):
return "Red Hat erratum (non-security)"
case strings.HasPrefix(id, "USN-"):
return "Ubuntu advisory"
case strings.HasPrefix(id, "GHSA-"):
@ -108,16 +119,43 @@ func AdvisoryType(id string) string {
}
}
// nonSecurityErrata matches RPM-family errata that carry no security content:
// bugfix (*BA) and enhancement (*EA) advisories from AlmaLinux, Red Hat,
// Rocky, and Oracle. OSV returns them alongside security advisories; counting
// them as threats inflates the dashboard with non-threats.
var nonSecurityErrata = regexp.MustCompile(`^(AL|RH|RL|EL)(BA|EA)-`)
// IsSecurityAdvisory reports whether an OSV record ID names an actual
// security advisory rather than a bugfix/enhancement erratum.
func IsSecurityAdvisory(id string) bool {
return !nonSecurityErrata.MatchString(id)
}
// filterSecurityVulns drops non-security errata from an OSV result set.
func filterSecurityVulns(vulns []OSVVuln) []OSVVuln {
kept := vulns[:0:0]
for _, v := range vulns {
if IsSecurityAdvisory(v.ID) {
kept = append(kept, v)
} else {
log.Printf("[INFO] [supply_chain] erratum_filtered id=%s (non-security)", v.ID)
}
}
return kept
}
// toVulnerabilityInfo maps a raw OSV record into the display struct, pulling
// qualitative severity, the first CVSS vector, the first fixed version, and the
// 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,
AdvisoryType: AdvisoryType(v.ID),
ID: v.ID,
Summary: v.Summary,
Aliases: v.Aliases,
Published: v.Published,
AdvisoryType: AdvisoryType(v.ID),
AffectedRanges: affectedRanges(v.Affected),
KnownExploited: knownExploited(v.DatabaseSpecific),
}
// Qualitative severity: GHSA puts it in database_specific.severity.
@ -136,6 +174,12 @@ func toVulnerabilityInfo(v OSVVuln) VulnerabilityInfo {
}
}
}
if score, ok := cvss3BaseScore(info.CVSSVector); ok {
info.CVSSScore = score
if info.Severity == "" {
info.Severity = severityFromCVSSScore(score)
}
}
// First fixed version across affected ranges.
for _, a := range v.Affected {
@ -158,6 +202,174 @@ func toVulnerabilityInfo(v OSVVuln) VulnerabilityInfo {
return info
}
func cvss3BaseScore(vector string) (float64, bool) {
if !strings.HasPrefix(vector, "CVSS:3.") {
return 0, false
}
metrics := make(map[string]string)
for _, part := range strings.Split(vector, "/") {
key, value, ok := strings.Cut(part, ":")
if ok {
metrics[key] = value
}
}
avMap := map[string]float64{"N": 0.85, "A": 0.62, "L": 0.55, "P": 0.2}
acMap := map[string]float64{"L": 0.77, "H": 0.44}
uiMap := map[string]float64{"N": 0.85, "R": 0.62}
impactMap := map[string]float64{"H": 0.56, "L": 0.22, "N": 0}
scope := metrics["S"]
if scope != "U" && scope != "C" {
return 0, false
}
prMap := map[string]float64{"N": 0.85, "L": 0.62, "H": 0.27}
if scope == "C" {
prMap = map[string]float64{"N": 0.85, "L": 0.68, "H": 0.5}
}
av, okAV := avMap[metrics["AV"]]
ac, okAC := acMap[metrics["AC"]]
pr, okPR := prMap[metrics["PR"]]
ui, okUI := uiMap[metrics["UI"]]
c, okC := impactMap[metrics["C"]]
i, okI := impactMap[metrics["I"]]
a, okA := impactMap[metrics["A"]]
if !okAV || !okAC || !okPR || !okUI || !okC || !okI || !okA {
return 0, false
}
exploitability := 8.22 * av * ac * pr * ui
impactSubScore := 1 - (1-c)*(1-i)*(1-a)
impact := 6.42 * impactSubScore
if scope == "C" {
impact = 7.52*(impactSubScore-0.029) - 3.25*math.Pow(impactSubScore-0.02, 15)
}
if impact <= 0 {
return 0, true
}
score := impact + exploitability
if scope == "C" {
score = 1.08 * score
}
if score > 10 {
score = 10
}
return math.Ceil((score-1e-10)*10) / 10, true
}
func severityFromCVSSScore(score float64) string {
switch {
case score >= 9:
return "CRITICAL"
case score >= 7:
return "HIGH"
case score >= 4:
return "MEDIUM"
case score > 0:
return "LOW"
default:
return ""
}
}
func affectedRanges(affected []OSVAffected) []string {
var ranges []string
for _, a := range affected {
for _, r := range a.Ranges {
var introduced string
emitted := false
for _, e := range r.Events {
if e.Introduced != "" {
introduced = e.Introduced
}
switch {
case e.Fixed != "":
ranges = append(ranges, formatAffectedRange(r.Type, introduced, "<", e.Fixed))
introduced = ""
emitted = true
case e.LastAffected != "":
ranges = append(ranges, formatAffectedRange(r.Type, introduced, "<=", e.LastAffected))
emitted = true
case e.Limit != "":
ranges = append(ranges, formatAffectedRange(r.Type, introduced, "<", e.Limit))
emitted = true
}
}
if !emitted && introduced != "" {
ranges = append(ranges, formatAffectedRange(r.Type, introduced, "", ""))
}
}
}
return ranges
}
func formatAffectedRange(rangeType, introduced, upperOp, upperVersion string) string {
lower := "all prior versions"
if introduced != "" && introduced != "0" {
lower = ">= " + introduced
}
body := lower + " and later"
if upperOp != "" && upperVersion != "" {
body = lower + ", " + upperOp + " " + upperVersion
}
if rangeType != "" {
return rangeType + ": " + body
}
return body
}
func knownExploited(databaseSpecific map[string]interface{}) bool {
keys := []string{
"known_exploited",
"knownExploited",
"known_exploited_vulnerability",
"knownExploitedVulnerability",
"cisa_kev",
"cisaKev",
"cisa_known_exploited",
"cisaKnownExploited",
"cisaExploitAdd",
"cisaActionDue",
"cisaRequiredAction",
"cisaVulnerabilityName",
"kev",
}
for _, key := range keys {
if truthy(databaseSpecific[key]) {
return true
}
}
return false
}
func truthy(v interface{}) bool {
switch value := v.(type) {
case bool:
return value
case string:
normalized := strings.ToLower(strings.TrimSpace(value))
return normalized != "" &&
normalized != "false" &&
normalized != "no" &&
normalized != "none" &&
normalized != "unknown" &&
normalized != "0"
case float64:
return value > 0
case int:
return value > 0
case []interface{}:
return len(value) > 0
case map[string]interface{}:
return len(value) > 0
default:
return false
}
}
var osvHTTPClient = httpx.NewClient(30 * time.Second)
// osvBreaker wraps OSV.dev calls (SCALE-001 S8). Both the batch and single-query
@ -328,17 +540,22 @@ func osvBatchRun(reqs []OSVCheckRequest, store OSVStoreFunc) {
errorKey: nil,
}
if len(result.Vulns) > 0 {
securityVulns := filterSecurityVulns(result.Vulns)
if len(securityVulns) > 0 {
// Enrich ALSA/RHSA/USN advisories with full details from OSV.
enriched := enrichAdvisoryVulns(result.Vulns)
vulnJSON, err := json.Marshal(enriched)
enriched := enrichAdvisoryVulns(securityVulns)
vulns := make([]VulnerabilityInfo, len(enriched))
for i, v := range enriched {
vulns[i] = toVulnerabilityInfo(v)
}
vulnJSON, err := json.Marshal(vulns)
if err != nil {
log.Printf("[WARNING] [supply_chain] vuln_marshal_failed pkg=%s error=%v", r.PkgName, err)
continue
}
meta[vulnsKey] = string(vulnJSON)
log.Printf("[SECURITY] [supply_chain] vulns_found namespace=%s pkg=%s type=%s ecosystem=%s count=%d",
r.Namespace, r.PkgName, r.PkgType, EcosystemFromPackageType(r.PkgType), len(result.Vulns))
r.Namespace, r.PkgName, r.PkgType, EcosystemFromPackageType(r.PkgType), len(securityVulns))
} else {
log.Printf("[INFO] [supply_chain] clean namespace=%s pkg=%s type=%s ecosystem=%s version=%s",
r.Namespace, r.PkgName, r.PkgType, EcosystemFromPackageType(r.PkgType), r.Version)
@ -511,10 +728,11 @@ func osvClosureBatch(ecosystem string, batch []ClosurePkg) (found []ClosureVuln,
}
for i, e := range batch {
if len(batchResp.Results[i].Vulns) > 0 {
found = append(found, ClosureVuln{Name: e.Name, Version: e.Version, Vulns: batchResp.Results[i].Vulns})
securityVulns := filterSecurityVulns(batchResp.Results[i].Vulns)
if len(securityVulns) > 0 {
found = append(found, ClosureVuln{Name: e.Name, Version: e.Version, Vulns: securityVulns})
log.Printf("[SECURITY] [supply_chain] closure_vuln pkg=%s version=%s ecosystem=%s count=%d",
e.Name, e.Version, ecosystem, len(batchResp.Results[i].Vulns))
e.Name, e.Version, ecosystem, len(securityVulns))
}
}
return found, true
@ -561,14 +779,15 @@ func CheckOSVVulnerabilities(pkgName, ecosystem, version string) *SupplyChainChe
return nil
}
if len(result.Vulns) == 0 {
securityVulns := filterSecurityVulns(result.Vulns)
if len(securityVulns) == 0 {
return &SupplyChainCheckResult{
CheckedAt: time.Now().UTC(),
}
}
vulns := make([]VulnerabilityInfo, len(result.Vulns))
for i, v := range result.Vulns {
vulns := make([]VulnerabilityInfo, len(securityVulns))
for i, v := range securityVulns {
vulns[i] = toVulnerabilityInfo(v)
}

View file

@ -0,0 +1,103 @@
package services
import "testing"
func TestToVulnerabilityInfoEnrichesCVSSAffectedRangesAndKEV(t *testing.T) {
info := toVulnerabilityInfo(OSVVuln{
ID: "CVE-2026-0001",
Summary: "demo vulnerability",
Severity: []OSVSeverity{
{Type: "CVSS_V3", Score: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"},
},
Affected: []OSVAffected{
{
Ranges: []OSVRange{
{
Type: "SEMVER",
Events: []OSVEvent{
{Introduced: "1.0.0"},
{Fixed: "1.2.3"},
},
},
},
},
},
DatabaseSpecific: map[string]interface{}{"cisa_kev": true},
})
if info.CVSSVector != "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" {
t.Fatalf("CVSSVector = %q", info.CVSSVector)
}
if info.CVSSScore != 9.8 {
t.Fatalf("CVSSScore = %v, want 9.8", info.CVSSScore)
}
if info.Severity != "CRITICAL" {
t.Fatalf("Severity = %q, want CRITICAL", info.Severity)
}
if info.FixedVersion != "1.2.3" {
t.Fatalf("FixedVersion = %q, want 1.2.3", info.FixedVersion)
}
if len(info.AffectedRanges) != 1 || info.AffectedRanges[0] != "SEMVER: >= 1.0.0, < 1.2.3" {
t.Fatalf("AffectedRanges = %#v", info.AffectedRanges)
}
if !info.KnownExploited {
t.Fatal("KnownExploited = false, want true")
}
}
func TestToVulnerabilityInfoKeepsDatabaseSpecificSeverity(t *testing.T) {
info := toVulnerabilityInfo(OSVVuln{
ID: "GHSA-demo",
Severity: []OSVSeverity{
{Type: "CVSS_V3", Score: "CVSS:3.1/AV:L/AC:H/PR:H/UI:R/S:U/C:L/I:N/A:N"},
},
DatabaseSpecific: map[string]interface{}{"severity": "high"},
})
if info.Severity != "HIGH" {
t.Fatalf("Severity = %q, want HIGH", info.Severity)
}
if info.CVSSScore == 0 {
t.Fatal("CVSSScore was not parsed")
}
}
func TestIsSecurityAdvisoryFiltersErrata(t *testing.T) {
tests := []struct {
id string
want bool
}{
{"ALSA-2023:4838", true}, // AlmaLinux security
{"ALBA-2022:2032", false}, // AlmaLinux bugfix
{"ALEA-2022:1985", false}, // AlmaLinux enhancement
{"RHSA-2024:0001", true},
{"RHBA-2024:0001", false},
{"RHEA-2024:0001", false},
{"RLBA-2024:0001", false}, // Rocky bugfix
{"ELBA-2024:0001", false}, // Oracle bugfix
{"CVE-2024-12345", true},
{"GHSA-xxxx-yyyy-zzzz", true},
{"USN-6000-1", true},
}
for _, tt := range tests {
if got := IsSecurityAdvisory(tt.id); got != tt.want {
t.Errorf("IsSecurityAdvisory(%q) = %v, want %v", tt.id, got, tt.want)
}
}
}
func TestFilterSecurityVulns(t *testing.T) {
in := []OSVVuln{
{ID: "ALBA-2022:2032"},
{ID: "ALSA-2023:4838"},
{ID: "ALEA-2022:1985"},
{ID: "CVE-2024-12345"},
}
out := filterSecurityVulns(in)
if len(out) != 2 {
t.Fatalf("expected 2 security vulns, got %d", len(out))
}
if out[0].ID != "ALSA-2023:4838" || out[1].ID != "CVE-2024-12345" {
t.Fatalf("unexpected survivors: %v, %v", out[0].ID, out[1].ID)
}
}