Files
mats 2cf2cebf5c
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 2m15s
fix
2026-09-20 11:39:20 +02:00

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
}