all the changes
This commit is contained in:
1 parent
94f30165f9
commit
f30adf05af
35 files changed
+2979
-1607
No files matched your search
@@ -0,0 +1,107 @@
|
||||
// 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|credentials|secrets?\b|firebase|serviceaccount|/config(/|\.)|settings\.(js|json)$|bootstrap\.properties|` +
|
||||
`\.(ya?ml|toml|properties|sql|bak|old|swp|zip|tar|tgz|gz|ini|conf|cfg)$)`)
|
||||
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 == 200 || in.Status == 202 || in.Status == 204 || (in.Status >= 300 && 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
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package triage
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestClassify(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in Input
|
||||
want Category
|
||||
}{
|
||||
{"wp 500 is a probe not an outage", Input{Status: 500, Route: "/wp"}, ScannerProbe},
|
||||
{"php 405 is a probe", Input{Status: 405, Route: "/index.php"}, ScannerProbe},
|
||||
{"ses.env", Input{Status: 404, Route: "/ses.env"}, ScannerProbe},
|
||||
{"yaml config 302", Input{Status: 302, Route: "/config/aws.yml"}, ScannerProbe},
|
||||
{"traversal", Input{Status: 404, Route: "/api/w/admins/get_log_file/../../../../proc/self/environ"}, ScannerProbe},
|
||||
{"credentials file", Input{Status: 404, Route: "/firebase-credentials.json"}, ScannerProbe},
|
||||
{"matrix rooms is not OOM", Input{Status: 200, Route: "/_matrix/client/r0/rooms/x/messages"}, ExpectedLifecycle},
|
||||
{"real api 404 stays stale", Input{Status: 404, Route: "/api/config"}, StaleReference},
|
||||
{"env 404", Input{Status: 404, Route: "/.env"}, ScannerProbe},
|
||||
{"git config 502", Input{Status: 502, Route: "/.git/config"}, ScannerProbe},
|
||||
{"poller 401", Input{Status: 401, Route: "/api/repos/available"}, ClientCredentialIssue},
|
||||
{"upstream 503", Input{Status: 503, Route: "/store/regions"}, DependencyUnreachable},
|
||||
{"real 500", Input{Status: 500, Route: "/"}, ServiceDefect},
|
||||
{"panic in message", Input{Message: "panic: nil pointer dereference"}, ServiceDefect},
|
||||
{"timeout flag", Input{IsTimeout: true, Message: "query"}, CapacitySaturation},
|
||||
{"dial error", Input{Message: "*websocket.dialerror: connection refused"}, DependencyUnreachable},
|
||||
{"missing commit", Input{Status: 404, Route: "/mats/repo/commit/{id}"}, StaleReference},
|
||||
{"cancellation", Input{IsCancellation: true, Message: "context canceled"}, ExpectedLifecycle},
|
||||
{"health", Input{Kind: "span", Status: 200, Route: "/readyz"}, ExpectedLifecycle},
|
||||
{"unknown", Input{Kind: "metric", Status: 418}, NeedsReview},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := Classify(c.in); got.Category != c.want {
|
||||
t.Errorf("%s: got %s (%s), want %s", c.name, got.Category, got.Rule, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeBeatsLifecycleFlags(t *testing.T) {
|
||||
if got := Classify(Input{Route: "/.env", IsRetry: true}); got.Category != ScannerProbe {
|
||||
t.Fatalf("got %s", got.Category)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user