all the changes
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m23s

This commit is contained in:
mats committed 2026-09-19 23:25:59 +02:00
1 parent 94f30165f9
commit f30adf05af
35 files changed
+2979 -1607

No files matched your search

+107
View File
@@ -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
}
+43
View File
@@ -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)
}
}