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" {