You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

237 lines
8.0 KiB

// 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) {
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 unicode.IsControl(r) {
return ' '
}
return r
}, value)
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}
}
}