Ctrl K
ComunidadGuías…

core/finding.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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
}