222 lines
6.2 KiB
Go
222 lines
6.2 KiB
Go
// 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:
|
|
}
|
|
}
|
|
}
|