Documentation
¶
Overview ¶
Package drift implements OpenWatch's compliance drift detector.
Spec: specs/system/drift-detector.spec.yaml (status: approved)
Architectural choices:
Pure consumer of the transaction log (B.1c). The detector reads prior + current state from host_rule_state and the transactions for this scan_id. It does not write to either table.
No baselines table. The Python-era baselines table is explicitly dropped — the prior host_rule_state aggregate IS the baseline. Spec C-07 / AC-12 source-inspects to enforce this.
Percentage-point math. A compliance score drop from 80% to 70% is a 10pp delta — NOT a 12.5pp delta (which would be the percent-of-percent miscalculation). Spec C-03.
Classify is a pure function. Given (prior_score, current_score, thresholds), it returns one of four Kind values deterministically. No I/O, no side effects, trivially testable without a database. Spec C-01.
Audit on non-stable kinds only. Stable scans (delta below the minor threshold) DO NOT emit compliance.drift.detected. Operators see audit traffic only when something actually changed. Spec C-04.
Thresholds from policy. Defaults are major=10pp, minor=5pp, improvement=5pp. Operators override via policy.AlertThresholds. ValidateThresholds rejects any value outside (0, 100] or any configuration where major < minor.
Single read transaction. DetectForScan wraps the prior+current score computations in one DB transaction so a concurrent writer cannot produce a torn view. Spec C-06.
Index ¶
Constants ¶
This section is empty.
Variables ¶
var AllKinds = []Kind{ DriftStable, DriftMinorWorsening, DriftMajorWorsening, DriftImprovement, }
AllKinds is the closed set, in registration order. Used by AC-13's reflection-style check.
var ErrInvalidThresholds = errors.New("drift: invalid thresholds")
ErrInvalidThresholds is returned by ValidateThresholds when any value falls outside (0, 100] or major < minor.
Functions ¶
func Score ¶ added in v0.8.0
func Score(passed, failed int) compliance.Score
Score returns the host's compliance score from confirmed verdicts, or an absent score when nothing produced one.
It delegates to internal/compliance so drift and every other surface share one definition. Skipped and error outcomes are excluded from the denominator, which the old doc comment said only of skipped.
This replaced ComplianceScore, which returned 0 for an empty denominator and called it a "conservative default". It was not conservative: 0 is a real verdict meaning every evaluated rule failed, so a host nothing could assess was indistinguishable from a host that failed everything, and the difference was a major-drift alert routed to an operator (bugs/OW-023).
func TypeForAudit ¶
TypeForAudit converts a Kind to the detail.drift_type string the compliance.drift.detected event uses ({major, minor, improvement}). Returns "" for DriftStable (which doesn't emit).
func ValidateThresholds ¶
func ValidateThresholds(t Thresholds) error
ValidateThresholds checks that every value is in (0, 100] and that MajorWorseningPP >= MinorWorseningPP. Spec AC-07.
Types ¶
type Kind ¶
type Kind string
Kind classifies a per-host score delta. Closed enum per spec AC-13. Maps 1:1 to compliance.drift.detected detail.drift_type values (where "stable" is not emitted — see spec C-04).
func Classify ¶
func Classify(prior, current float64, t Thresholds) Kind
Classify maps a score delta to a Kind given the active thresholds. Pure function — no I/O, no side effects, deterministic.
Spec ACs satisfied here:
- AC-01 (C-01, C-02, C-03): pure-function classifier returning a closed-enum Kind from percentage-point math.
- AC-05 (C-04): scores within all thresholds return DriftStable.
- AC-06 (C-05): the function uses the passed thresholds, NOT hardcoded values.
Math:
delta := current - prior (positive = improvement, negative = worsening) if delta >= ImprovementPP → Improvement if delta <= -MajorWorseningPP → MajorWorsening if delta <= -MinorWorseningPP (and not major) → MinorWorsening otherwise → Stable
Comparisons use >= / <= so the threshold value itself fires the classification (a 5pp gain matches Improvement when ImprovementPP=5).
type Report ¶
type Report struct {
HostID uuid.UUID
ScanID uuid.UUID
Kind Kind
PriorScore float64
CurrentScore float64
// CurrentScorePresent is false when no rule produced a verdict, in which
// case CurrentScore is meaningless rather than zero percent.
CurrentScorePresent bool
// PriorScorePresent is false when no prior score is reconstructible.
//
// It is NOT the same fact as HasPriorBaseline. AC-19 is the case that
// separates them: an all-skipped rescan has a baseline (transactions exist)
// and no reconstructible prior percentage, because reconstructPriorCounts
// inverts pass and fail transitions only. Without this flag such a report
// exposes a plausible PriorScore of 0.
PriorScorePresent bool
// ComparisonPresent is true only when both scores exist and a delta was
// actually computed. When false, ScoreDelta is the zero value and means
// nothing: no comparison was made, rather than a comparison that found no
// change. A consumer rendering "0.0pp" from an absent comparison would be
// the same defect the score type exists to prevent, one field along.
ComparisonPresent bool
ScoreDelta float64 // current - prior; negative when worsening
// HasPriorBaseline is false on the first-ever scan against this
// host. Kind is forced to DriftStable in that case (no baseline to
// drift from). Spec AC-08.
HasPriorBaseline bool
// Per-severity transition counts. Spec C-08 / AC-09.
CriticalBecameFailing int
HighBecameFailing int
MediumBecameFailing int
LowBecameFailing int
CriticalBecamePassing int
HighBecamePassing int
MediumBecamePassing int
LowBecamePassing int
}
Report is what DetectForScan returns. Carries the classified kind, the raw scores + delta, and per-severity transition counts so the alert router (B.3) can route by severity.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service computes drift for a given (scanID, hostID) pair against the transaction log. Construct once at boot via NewService.
func NewService ¶
func NewService(pool *pgxpool.Pool, emit EmitFunc, thresholds Thresholds, bus *eventbus.Bus) *Service
NewService wires the detector. emit is audit.Emit in production, a fake recorder in tests. thresholds defaults to DefaultThresholds when unset (any value 0); production wires from policy.AlertThresholds.
v1.1.0: bus may be nil. When nil, the service still emits audit events but skips bus publishes. Spec system-drift-detector C-10.
func (*Service) DetectForScan ¶
DetectForScan computes drift for hostID against scanID's transactions.
Prior score: pulled from host_rule_state EXCLUDING rules whose last_scan_id == this scanID. This way, the "prior" view is the state before this scan landed (writer.Apply already moved host_rule_state to the new state, so we have to reconstruct).
Current score: this host's host_rule_state rows carrying THIS scanID (post-Apply). That is the corpus the scan evaluated. Rules the host once carried but this scan no longer ships are excluded, so a retired rule's frozen verdict cannot drag the current score.
Per-severity transition counts: read from the transactions table filtered to this scanID + state_changed change_kind.
Spec ACs satisfied:
- AC-08 (C-01, C-06): no prior data → DriftStable with HasPriorBaseline=false. The first-ever scan against a host cannot drift.
- AC-09 (C-08): per-severity transition counts populated from transactions.
- AC-10/11 (C-04): emission gates on Kind != DriftStable.
func (*Service) Thresholds ¶
func (s *Service) Thresholds() Thresholds
Thresholds returns the active thresholds — useful for tests and observability.
type Thresholds ¶
type Thresholds struct {
// MajorWorseningPP is the score-drop threshold (pp) for the major
// classification. A drop ≥ this value is major worsening.
MajorWorseningPP float64
// MinorWorseningPP is the score-drop threshold (pp) for minor.
// A drop ≥ this AND below major is minor worsening.
MinorWorseningPP float64
// ImprovementPP is the score-gain threshold (pp) for improvement.
// A gain ≥ this value is classified as improvement.
ImprovementPP float64
}
Thresholds defines the percentage-point boundaries between drift kinds. All values are percentage points (pp), not percent-of-percent. Spec C-05 / AC-07: validated via ValidateThresholds.
func DefaultThresholds ¶
func DefaultThresholds() Thresholds
DefaultThresholds returns the spec-defined defaults. Spec C-05.