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:
parent
5683bc15a4
commit
c71fc093db
10 changed files with 393 additions and 51 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
Loading…
Reference in a new issue