// Package alert owns Bell's small, deterministic rule and Alert domain. package alert import ( "context" "crypto/sha256" "encoding/json" "errors" "fmt" "io" "os" "regexp" "strings" "time" "unicode/utf8" ) const ( DefaultPageSize = 16 MaxPageSize = 100 MaxRulesFile = 256 << 10 ) var ( ErrNotFound = errors.New("alert not found") ErrIdempotencyConflict = errors.New("idempotency key was used for another command") ruleKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{2,63}$`) eventKindPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{2,63}$`) actorRefPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:@/-]*$`) idempotencyKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]*$`) ) type RuleSpec struct { TenantID int64 `json:"tenant_id"` SiteID *int64 `json:"site_id,omitempty"` RuleKey string `json:"rule_key"` DisplayName string `json:"display_name"` EventKind string `json:"event_kind"` MinimumSeverity string `json:"minimum_severity"` Enabled bool `json:"enabled"` EffectiveFrom time.Time `json:"effective_from"` } func (r RuleSpec) Validate() error { if r.TenantID < 1 || (r.SiteID != nil && *r.SiteID < 1) { return errors.New("rule scope must use positive identifiers") } if !ruleKeyPattern.MatchString(r.RuleKey) || !eventKindPattern.MatchString(r.EventKind) { return errors.New("rule key or event kind is invalid") } if strings.TrimSpace(r.DisplayName) == "" || utf8.RuneCountInString(r.DisplayName) > 120 || r.EffectiveFrom.IsZero() { return errors.New("rule name or effective time is invalid") } if _, ok := SeverityRank(r.MinimumSeverity); !ok { return errors.New("rule minimum severity is invalid") } return nil } func (r RuleSpec) Digest() ([sha256.Size]byte, error) { value, err := json.Marshal(r) if err != nil { return [sha256.Size]byte{}, err } return sha256.Sum256(value), nil } type rulesDocument struct { Version int `json:"version"` Rules []RuleSpec `json:"rules"` } func LoadRules(path string) ([]RuleSpec, error) { file, err := os.Open(path) if err != nil { return nil, errors.New("open Bell alert rules file") } defer file.Close() info, err := file.Stat() if err != nil || info.Size() > MaxRulesFile { return nil, errors.New("Bell alert rules file is too large") } decoder := json.NewDecoder(file) decoder.DisallowUnknownFields() var document rulesDocument if err := decoder.Decode(&document); err != nil { return nil, errors.New("decode Bell alert rules file") } var trailing any if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { return nil, errors.New("Bell alert rules file contains multiple JSON values") } if document.Version != 1 || len(document.Rules) < 1 || len(document.Rules) > 256 { return nil, errors.New("Bell alert rules file must contain 1 to 256 v1 rules") } seen := make(map[string]bool, len(document.Rules)) for _, rule := range document.Rules { if err := rule.Validate(); err != nil { return nil, fmt.Errorf("invalid Bell alert rule %q: %w", rule.RuleKey, err) } key := fmt.Sprintf("%d:%s", rule.TenantID, rule.RuleKey) if seen[key] { return nil, fmt.Errorf("duplicate Bell alert rule %q", rule.RuleKey) } seen[key] = true } return document.Rules, nil } func SeverityRank(value string) (int, bool) { for rank, severity := range []string{"low", "medium", "high", "critical"} { if value == severity { return rank, true } } return 0, false } func ValidActorRef(value string) bool { return len(value) <= 80 && actorRefPattern.MatchString(value) } func ValidIdempotencyKey(value string) bool { return len(value) >= 8 && len(value) <= 128 && idempotencyKeyPattern.MatchString(value) } type Summary struct { ID string `json:"id"` Severity string `json:"severity"` Title string `json:"title"` State string `json:"state"` RuleKey string `json:"rule_key"` RuleVersion int `json:"rule_version"` CreatedAt time.Time `json:"created_at"` } type EventRef struct { ID string `json:"id"` DeviceID int64 `json:"device_id"` Kind string `json:"kind"` Severity string `json:"severity"` OccurredAt time.Time `json:"occurred_at"` } type Transition struct { Sequence int `json:"sequence"` FromState *string `json:"from_state"` ToState string `json:"to_state"` ActorRef string `json:"actor_ref"` Note *string `json:"note"` OccurredAt time.Time `json:"occurred_at"` } type Detail struct { Summary Events []EventRef `json:"events"` Transitions []Transition `json:"transitions"` EvidenceStatus string `json:"evidence_status"` DeliveryStatus string `json:"delivery_status"` } type Page struct { Items []Summary `json:"items"` NextCursor *string `json:"next_cursor"` } type CommandResponse struct { AlertID string `json:"alert_id"` State string `json:"state"` ActorRef string `json:"actor_ref"` OccurredAt time.Time `json:"occurred_at"` Code string `json:"code,omitempty"` } type Repository interface { AlertReady(context.Context) error PublishRule(context.Context, RuleSpec) (bool, error) EvaluateNext(context.Context) (bool, error) ListAlerts(context.Context, int64, int64, string, int, string) (Page, error) GetAlert(context.Context, int64, int64, string) (Detail, error) Command(context.Context, int64, int64, string, string, string, string, *string) (int, CommandResponse, error) } func Publish(ctx context.Context, repository Repository, rules []RuleSpec) error { for _, rule := range rules { if _, err := repository.PublishRule(ctx, rule); err != nil { return fmt.Errorf("publish Bell alert rule %q: %w", rule.RuleKey, err) } } return nil } func RunWorker(ctx context.Context, repository Repository, onError func(error)) { idle := time.NewTicker(250 * time.Millisecond) defer idle.Stop() for { worked, err := repository.EvaluateNext(ctx) if err != nil { if ctx.Err() != nil { return } if onError != nil { onError(err) } select { case <-ctx.Done(): return case <-time.After(time.Second): } continue } if worked { continue } select { case <-ctx.Done(): return case <-idle.C: } } }