core/finding.go
Rama main · réplica de lectura del repositorio original.
// Package core defines the two contracts every Hexward product is built on:
// the normalised Finding and the Collector interface. Nothing in this package
// knows about TLS, CVEs, firewalls, or any specific product — that intelligence
// lives in each product's Collector. The core stays generic so that all six
// products share one scheduler, one store, one notifier, one dashboard.
package core
import (
"crypto/sha256"
"encoding/hex"
"time"
)
// Severity ranks a finding's urgency. Ordered constants let the store and UI
// sort and threshold without a separate lookup table.
type Severity string
const (
SeverityCritical Severity = "critical"
SeverityHigh Severity = "high"
SeverityMedium Severity = "medium"
SeverityLow Severity = "low"
SeverityInfo Severity = "info"
)
// rank gives a total order for severities (higher = more urgent). Unknown
// severities sort lowest so a malformed collector can never mask a real critical.
func (s Severity) rank() int {
switch s {
case SeverityCritical:
return 4
case SeverityHigh:
return 3
case SeverityMedium:
return 2
case SeverityLow:
return 1
case SeverityInfo:
return 0
default:
return -1
}
}
// Valid reports whether s is one of the known severities.
func (s Severity) Valid() bool { return s.rank() >= 0 }
// FindingStatus is the core-owned lifecycle state. Collectors never set this;
// the store's reconcile logic drives every transition.
type FindingStatus string
const (
StatusOpen FindingStatus = "open"
StatusResolved FindingStatus = "resolved"
StatusAcknowledged FindingStatus = "acknowledged"
StatusSuppressed FindingStatus = "suppressed"
)
// MaxEvidenceBytes caps the serialised Evidence blob so a pathological collector
// cannot bloat the database. Oversized evidence is truncated with a marker at
// reconcile time (decision #3 in the contracts spec).
const MaxEvidenceBytes = 64 * 1024
// Finding is the normalised unit of everything Hexward Core reasons about.
// A Collector returns []Finding from Collect(); the core owns everything after.
//
// Field ownership (see contracts spec §1.3):
// - Collector fills: Fingerprint, Target, Check, Title, Severity,
// Remediation, Evidence.
// - Core fills/owns: ID, Module, Status, FirstSeen, LastSeen, ResolvedAt.
//
// A Collector leaves the core-owned fields zero; the store populates them.
type Finding struct {
// Fingerprint is a stable, collector-computed identity for "the same
// problem on the same target". It MUST be deterministic and MUST NOT
// embed values that change benignly over time (days remaining, timestamps).
// See Fingerprint().
Fingerprint string `json:"fingerprint"`
// Collector-owned descriptive fields.
Target string `json:"target"` // canonical target, e.g. "example.com:443"
Check string `json:"check"` // rule id, e.g. "tls.expiry"
Title string `json:"title"` // one human-facing line
Severity Severity `json:"severity"` //
Remediation string `json:"remediation"` // REQUIRED: what to do now
Evidence map[string]any `json:"evidence,omitempty"`
// Core-owned fields. Collectors leave these zero.
ID string `json:"id"`
Module string `json:"module"`
Status FindingStatus `json:"status"`
GroupID *string `json:"group_id,omitempty"` // nullable; MSP/tenant grouping (decision #4)
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
}
// ValidateForIngest checks the collector-owned fields the core requires before
// a finding can be stored. It returns nil when the finding is well-formed.
// This is the "fail loud" guard: a finding without remediation or title is a
// bug in the collector, not something to silently persist.
func (f Finding) ValidateForIngest() error {
switch {
case f.Fingerprint == "":
return &IngestError{Field: "fingerprint", Reason: "empty"}
case f.Target == "":
return &IngestError{Field: "target", Reason: "empty"}
case f.Check == "":
return &IngestError{Field: "check", Reason: "empty"}
case f.Title == "":
return &IngestError{Field: "title", Reason: "empty"}
case f.Remediation == "":
return &IngestError{Field: "remediation", Reason: "empty (every finding must tell the user what to do)"}
case !f.Severity.Valid():
return &IngestError{Field: "severity", Reason: "unknown value: " + string(f.Severity)}
}
return nil
}
// IngestError describes why a finding was rejected at reconcile time.
type IngestError struct {
Field string
Reason string
}
func (e *IngestError) Error() string {
return "invalid finding: " + e.Field + ": " + e.Reason
}
// Fingerprint builds a deterministic 128-bit identity from the parts that
// distinguish one finding from another. The discriminator is the minimal thing
// that separates two findings of the same check on the same target (e.g. the
// cipher name for a weak-cipher check; empty for a single-instance check like
// expiry). Callers MUST NOT pass values that change benignly over time.
func Fingerprint(module, target, check, discriminator string) string {
h := sha256.Sum256([]byte(module + "|" + target + "|" + check + "|" + discriminator))
return hex.EncodeToString(h[:16]) // 128 bits is plenty; keeps the column small
}