// Package logpolicy defines the bounded capture and link-observation rules // shared by the management API prototype and the future tera-logd adapter. package logpolicy import ( "errors" "regexp" "sort" "strings" "time" "unicode" ) const ( MaxRetentionDays = 365 MaxCommandCharacters = 1024 MaxReturnCharacters = 4096 MaxSerialPayloadBytes = 4 << 10 DefaultLinkPollInterval = 1 * time.Minute DefaultKeepaliveInterval = 3 * time.Minute DefaultSnapshotInterval = 30 * time.Minute ) var ( secretAssignment = regexp.MustCompile(`(?i)\b(password|passwd|pwd|token|secret|community|authorization)(\s*(?:=|:)\s*)("[^"]*"|'[^']*'|[^\s,;]+)`) secretFlag = regexp.MustCompile(`(?i)(--(?:password|passwd|pwd|token|secret|community|authorization)\s+)("[^"]*"|'[^']*'|[^\s,;]+)`) secretOperand = regexp.MustCompile(`(?i)\b((?:auth\.radius\.key|auth\.tacacs\.secret|snmpd\.(?:ro|rw)_(?:password|privacy)|warn\.(?:mail|trap)\.(?:password|community))\s+)("[^"]*"|'[^']*'|[^\s,;]+)`) bearerCredential = regexp.MustCompile(`(?i)\bbearer\s+[A-Za-z0-9._~+/=-]+`) serialResource = regexp.MustCompile(`^CH(?:0[1-9]|[1-3][0-9]|4[0-8])$`) ) // Sanitize converts text to a single injection-safe log line, removes common // credential forms, and enforces a Unicode-character bound. func Sanitize(value string, maximum int) (string, bool) { return sanitizeText(value, maximum, false) } // SanitizeMultiline preserves serial whitespace for a text-only UI while applying // the same credential redaction as single-line operational logs. func SanitizeMultiline(value string, maximum int) (string, bool) { return sanitizeText(value, maximum, true) } func sanitizeText(value string, maximum int, multiline bool) (string, bool) { redacted := false if strings.Contains(strings.ToUpper(value), "PRIVATE KEY-----") { value = "[REDACTED PRIVATE KEY MATERIAL]" redacted = true } next := secretAssignment.ReplaceAllString(value, `${1}${2}[REDACTED]`) if next != value { redacted = true value = next } next = bearerCredential.ReplaceAllString(value, "Bearer [REDACTED]") if next != value { redacted = true value = next } next = secretFlag.ReplaceAllString(value, `${1}[REDACTED]`) if next != value { redacted = true value = next } next = secretOperand.ReplaceAllString(value, `${1}[REDACTED]`) if next != value { redacted = true value = next } value = strings.Map(func(r rune) rune { if multiline && (r == '\n' || r == '\r' || r == '\t') { return r } if unicode.IsControl(r) { return ' ' } return r }, value) if !multiline { value = strings.Join(strings.Fields(value), " ") } if maximum > 0 { runes := []rune(value) if len(runes) > maximum { value = string(runes[:maximum]) + " …[truncated]" redacted = true } } return value, redacted } type KeywordRule struct { RuleID string Keyword string } // MatchCommand performs case-insensitive literal matching against command text // only. Result/output text is deliberately outside the alarm matching boundary. func MatchCommand(command string, rules []KeywordRule) []string { command = strings.ToLower(strings.TrimSpace(command)) if command == "" { return nil } matches := make([]string, 0) seen := make(map[string]bool) for _, rule := range rules { keyword := strings.ToLower(strings.TrimSpace(rule.Keyword)) if rule.RuleID == "" || keyword == "" || !strings.Contains(command, keyword) || seen[rule.RuleID] { continue } seen[rule.RuleID] = true matches = append(matches, rule.RuleID) } sort.Strings(matches) return matches } type LinkObservation struct { Kind string Resource string State string Signal string ObservedAt time.Time } type LinkEvent struct { Kind string Resource string State string PreviousState string Signal string EventType string AlarmTriggered bool Timestamp time.Time } type linkState struct { state string signal string lastSnapshot time.Time } type LinkTracker struct { snapshotInterval time.Duration snapshotsEnabled bool states map[string]linkState } func NewLinkTracker(snapshotInterval time.Duration) *LinkTracker { if snapshotInterval <= 0 { snapshotInterval = DefaultSnapshotInterval } return &LinkTracker{snapshotInterval: snapshotInterval, snapshotsEnabled: true, states: make(map[string]linkState)} } // SetSnapshotInterval updates the periodic snapshot cadence without discarding // the last observed state for each resource. func (t *LinkTracker) SetSnapshotInterval(snapshotInterval time.Duration) { if t == nil { return } if snapshotInterval <= 0 { snapshotInterval = DefaultSnapshotInterval } t.snapshotInterval = snapshotInterval } func (t *LinkTracker) SetSnapshotsEnabled(enabled bool) { if t != nil { t.snapshotsEnabled = enabled } } // Observe returns nil when a repeated state is inside the snapshot interval. // The first observation is a snapshot; only an observed up-to-down transition // is alarmable. func (t *LinkTracker) Observe(observation LinkObservation) (*LinkEvent, error) { observation.Kind = strings.ToLower(strings.TrimSpace(observation.Kind)) observation.Resource = strings.TrimSpace(observation.Resource) observation.State = strings.ToLower(strings.TrimSpace(observation.State)) observation.Signal = strings.ToLower(strings.TrimSpace(observation.Signal)) if observation.Kind != "serial" && observation.Kind != "ethernet" { return nil, errors.New("link kind must be serial or ethernet") } if observation.Resource == "" || len([]rune(observation.Resource)) > 64 { return nil, errors.New("link resource must be between 1 and 64 characters") } if observation.Kind == "serial" && !serialResource.MatchString(observation.Resource) { return nil, errors.New("serial link resource must be canonical CH01 through CH48") } if observation.State != "up" && observation.State != "down" && observation.State != "unknown" { return nil, errors.New("link state must be up, down, or unknown") } if observation.Kind == "serial" && observation.Signal != "dcd" && observation.Signal != "dsr" && observation.Signal != "cts" && observation.Signal != "cr" && observation.Signal != "unknown" { return nil, errors.New("serial link signal must be dcd, dsr, cts, cr, or unknown") } if observation.Kind == "ethernet" && observation.Signal != "carrier" && observation.Signal != "unknown" { return nil, errors.New("ethernet link signal must be carrier or unknown") } if observation.Signal == "unknown" && observation.State != "unknown" { return nil, errors.New("unknown link signal requires unknown state") } if observation.ObservedAt.IsZero() { return nil, errors.New("link observation timestamp is required") } if t == nil { return nil, errors.New("link tracker is required") } if t.states == nil { t.states = make(map[string]linkState) } // DCD and DSR are independent physical signals. Keeping one state per // resource would let a DSR sample overwrite DCD and suppress later changes. key := observation.Kind + "/" + observation.Resource + "/" + observation.Signal previous, found := t.states[key] if !found && !t.snapshotsEnabled { t.states[key] = linkState{state: observation.State, signal: observation.Signal, lastSnapshot: observation.ObservedAt} return nil, nil } eventType := "snapshot" if found && previous.state == observation.State && (!t.snapshotsEnabled || observation.ObservedAt.Sub(previous.lastSnapshot) < t.snapshotInterval) { return nil, nil } if found && previous.state != observation.State { eventType = "transition" } event := &LinkEvent{ Kind: observation.Kind, Resource: observation.Resource, State: observation.State, Signal: observation.Signal, EventType: eventType, Timestamp: observation.ObservedAt.UTC(), } if found { event.PreviousState = previous.state } event.AlarmTriggered = event.EventType == "transition" && event.PreviousState == "up" && event.State == "down" t.states[key] = linkState{state: observation.State, signal: observation.Signal, lastSnapshot: observation.ObservedAt} return event, nil } func (t *LinkTracker) Seed(event LinkEvent) { if t == nil || event.Kind == "" || event.Resource == "" || event.State == "" || event.Timestamp.IsZero() { return } if t.states == nil { t.states = make(map[string]linkState) } key := strings.ToLower(event.Kind) + "/" + event.Resource + "/" + strings.ToLower(event.Signal) current, exists := t.states[key] if !exists || event.Timestamp.After(current.lastSnapshot) { t.states[key] = linkState{state: strings.ToLower(event.State), signal: strings.ToLower(event.Signal), lastSnapshot: event.Timestamp} } }