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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
103
server/internal/services/supply_chain_vuln_test.go
Normal file
103
server/internal/services/supply_chain_vuln_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue