110 lines
4.6 KiB
Go
110 lines
4.6 KiB
Go
// 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
|
|
}
|