// Package triage assigns a cause-level category to a normalized error // pattern. The categories are deliberately not restatements of status or // severity: they answer "why did this happen and who should care". // // Rules are deterministic and ordered; the first match wins. Structured flags // (timeout, cancellation, retry) are preferred over message text. package triage import ( "regexp" "strings" ) type Category string const ( ScannerProbe Category = "scanner_probe" DependencyUnreachable Category = "dependency_unreachable" ClientCredentialIssue Category = "client_credential_issue" StaleReference Category = "stale_reference" ServiceDefect Category = "service_defect" CapacitySaturation Category = "capacity_saturation" ExpectedLifecycle Category = "expected_lifecycle" NeedsReview Category = "needs_review" ) // Input is the non-PII slice of a normalized event the rules look at. type Input struct { Service string Kind string Status int Route string Title string Message string IsTimeout bool IsCancellation bool IsRetry bool } type Result struct { Category Category `json:"category"` Rule string `json:"rule"` // which rule fired, for review and tuning } var ( // Probe families: secrets/dotfiles, config and backup file types, source // control, CMS/admin paths, path traversal. Deliberately path-only. probeRoute = regexp.MustCompile(`(?i)(\.env\b|\.git\b|\.htpasswd|\.htaccess|\.npmrc|\.claude|\.docker|\.kube|\.aws|\.ssh|` + `wp-|/wp/|/wp$|wordpress|phpmyadmin|phpinfo|cgi-bin|xmlrpc|userfiles|@fs/|etc/passwd|proc/self|\.\./|%2e%2e|` + `\.php\b|\.codex|\.cursor|\.windsurf|\.aider|\.clinerules|\.roo(/|code|$)|\.continue/|\.gemini|\.idea/|\.vscode/|copilot-instructions|claude\.md|agents\.md|mcp\.json|` + `_debugbar|debug/pprof|server-info|actuator|__env|\.bash_|\.profile$|graphql/console|` + `credentials|secrets?\b|firebase|serviceaccount|/config(/|\.)|settings\.(js|json|py)$|bootstrap\.properties|` + `\.(ya?ml|toml|properties|sql|bak|old|swp|zip|tar|tgz|gz|ini|conf|cfg|key|pem)$)`) bugText = regexp.MustCompile(`(?i)panic|nil pointer|index out of range|typeerror|referenceerror|deadlock|unhandled|chunkloaderror|segfault`) capText = regexp.MustCompile(`(?i)timeout|deadline exceeded|\bpool\b|memory limit|\boom(killed)?\b|too many|queue full|exhaust`) depText = regexp.MustCompile(`(?i)dial|connection refused|\beof\b|no such host|upstream|unavailable|broken pipe|reset by peer`) lifeText = regexp.MustCompile(`(?i)healthz|readyz|/ping|keepalive|vital|\bcls\b|ttfb|fcp|lcp|snapshot|replay|unchanged|session\.load`) ) // IsProbePath reports whether a request path looks like an automated scan. // It is the single definition shared by triage and IP-blocking analysis. func IsProbePath(path string) bool { return probeRoute.MatchString(path) } // Classify returns the category for one pattern's example event. func Classify(in Input) Result { blob := strings.Join([]string{in.Title, in.Message, in.Route}, " ") switch { case probeRoute.MatchString(in.Route): return Result{ScannerProbe, "probe-route"} case in.IsCancellation: return Result{ExpectedLifecycle, "cancellation-flag"} case in.IsRetry: return Result{ExpectedLifecycle, "retry-flag"} case bugText.MatchString(blob): return Result{ServiceDefect, "bug-text"} case in.IsTimeout: return Result{CapacitySaturation, "timeout-flag"} case capText.MatchString(blob): return Result{CapacitySaturation, "capacity-text"} case depText.MatchString(blob): return Result{DependencyUnreachable, "dependency-text"} case in.Status == 401: return Result{ClientCredentialIssue, "status-401"} case in.Status == 502 || in.Status == 503 || in.Status == 504: return Result{DependencyUnreachable, "status-5xx-upstream"} case in.Status >= 500: return Result{ServiceDefect, "status-5xx"} case in.Status == 404 || in.Status == 410: return Result{StaleReference, "status-404"} case lifeText.MatchString(blob): return Result{ExpectedLifecycle, "lifecycle-text"} case in.Status == 499 || (in.Status >= 100 && in.Status < 400): return Result{ExpectedLifecycle, "benign-status"} case in.Status == 0 && (in.Kind == "span" || in.Kind == "log"): return Result{ExpectedLifecycle, "no-status-signal"} } return Result{NeedsReview, "no-rule"} } // Actionable reports whether alerts in this category should page a human by // default. Probes are a security channel rather than an ops one, and the rest // are expected noise or harmless. func (c Category) Actionable() bool { switch c { case ScannerProbe, StaleReference, ExpectedLifecycle: return false } return true }