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.
236 lines
8.0 KiB
236 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}
|
|
}
|
|
}
|
|
|