Files
MeshTender/internal/analytics/classify_test.go
T

188 lines
8.4 KiB
Go

package analytics
import "testing"
const browserUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"
func TestClassify(t *testing.T) {
cases := []struct {
name string
path string
status int
ua string
want string
}{
// Real traffic.
{"page view", "/orgs/example-mesh", 200, browserUA, KindVisit},
{"redirect", "/", 301, browserUA, KindVisit},
{"form post", "/orgs/new", 302, browserUA, KindVisit},
{"server error is still a visit", "/dashboard", 500, browserUA, KindVisit},
{"forbidden is still a visit", "/admin/users", 403, browserUA, KindVisit},
// Scanner traffic, all pulled from production logs. Every one arrived with
// a browser user agent, which is why the UA check alone never caught them.
{"php config", "/wp_mail_smtp.ini", 404, browserUA, KindProbe},
{"php index", "/index.php", 404, browserUA, KindProbe},
{"phpinfo", "/includes/phpinfo.php", 404, browserUA, KindProbe},
{"firebase key", "/firebase-key.json", 404, browserUA, KindProbe},
{"cli config", "/.vultr-cli.yaml", 404, browserUA, KindProbe},
{"tomcat manager", "/manager/html", 404, browserUA, KindProbe},
{"dotenv", "/.env", 404, browserUA, KindProbe},
{"git config", "/.git/config", 404, browserUA, KindProbe},
{"wordpress login", "/wp-login.php", 404, browserUA, KindProbe},
{"uppercase replay", "/WP-ADMIN/SETUP-CONFIG.PHP", 404, browserUA, KindProbe},
// Missed by the first version of this list — the reason it was rewritten.
{"laravel ignition rce", "/index.php/_ignition/execute-solution", 404, browserUA, KindProbe},
{"graphql", "/graphql", 404, browserUA, KindProbe},
{"graphql under api", "/api/graphql", 404, browserUA, KindProbe},
{"apache status", "/server-status", 404, browserUA, KindProbe},
{"docker registry", "/v2/_catalog", 404, browserUA, KindProbe},
{"vite env", "/@vite/env", 404, browserUA, KindProbe},
{"aspnet trace", "/trace.axd", 404, browserUA, KindProbe},
{"struts action", "/login.action", 404, browserUA, KindProbe},
{"cpanel proxy", "/___proxy_subdomain_cpanel", 404, browserUA, KindProbe},
{"symfony profiler", "/_profiler/phpinfo", 404, browserUA, KindProbe},
{"ds_store", "/.DS_Store", 404, browserUA, KindProbe},
{"stripe dotfile", "/.stripe/", 404, browserUA, KindProbe},
{"bare api", "/api", 404, browserUA, KindProbe},
{"bare console", "/console/", 404, browserUA, KindProbe},
{"root config.json", "/config.json", 404, browserUA, KindProbe},
{"unfilled wildcard", "/workspaces/*", 404, browserUA, KindProbe},
{"unfilled wildcard 2", "/webhook-waiting/*", 404, browserUA, KindProbe},
// .env variants — an exact-name list can't keep up, the dotfile rule can.
{"env tilde", "/.env~", 404, browserUA, KindProbe},
{"env copy", "/.env_copy", 404, browserUA, KindProbe},
{"env backup2", "/.env.backup2", 404, browserUA, KindProbe},
{"nested env", "/backend/.env", 404, browserUA, KindProbe},
// A scanner sweeping the www host gets a 301, not a 404. Gating probes on
// 404 alone filed all of this as ordinary traffic.
{"probe redirected by www host", "/.env", 301, browserUA, KindProbe},
{"probe on a 500", "/wp-login.php", 500, browserUA, KindProbe},
// Bots.
{"googlebot", "/orgs", 200, "Googlebot/2.1 (+http://www.google.com/bot.html)", KindBot},
{"empty ua", "/orgs", 200, "", KindBot},
{"uptime monitor", "/", 200, "Better Uptime Monitor", KindBot},
// A scanner that also calls itself a bot is still a scanner.
{"bot ua on a probe path", "/wp-login.php", 404, "evilbot/1.0", KindProbe},
// Honest 404s stay visible as their own kind rather than being written off
// as attacks — a broken link is something worth fixing.
{"stale link", "/orgs/closed-club", 404, browserUA, KindNotFound},
{"typo", "/repeaterz", 404, browserUA, KindNotFound},
{"crawler on a dead link", "/orgs/gone", 404, "Googlebot/2.1", KindBot},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := classify(c.path, c.status, c.ua); got != c.want {
t.Errorf("classify(%q, %d, %q) = %q, want %q", c.path, c.status, c.ua, got, c.want)
}
})
}
}
// TestClassifyProductionCorpus runs the classifier over paths taken verbatim
// from production traffic. The same corpus was checked against migration 0045's
// SQL backfill, which found zero disagreements — keeping this list here is what
// stops the Go rules and the SQL rules drifting apart unnoticed.
func TestClassifyProductionCorpus(t *testing.T) {
probes := []string{
"/xmlrpc.php", "/info.php", "/test.php", "/.aws/credentials", "/actuator/env",
"/.vscode/sftp.json", "/api/gql", "/graphql/api", "/debug/default/view",
"/___proxy_subdomain_whm/login", "/stats/prometheus", "/.linode-cli",
"/config.js", "/aws.config.js", "/info", "/env", "/server", "/phpinfo",
"/.env.save", "/.env.bak", "/.env.prod", "/.env.dev", "/.env.old", "/.env.example",
"/app/.env", "/api/.env", "/backend/.env",
"/ecp/Current/exporttool/microsoft.exchange.ediscovery.exporttool.application",
// Backup/editor suffixes appended AFTER a real extension.
"/phpinfo.php~", "/phpinfo.php.save", "/config.json.save",
// Credential JSON under other stacks' names — no fixed list keeps up,
// which is why .json is scoped by prefix instead.
"/gcp-credentials.json", "/google-credentials.json", "/firebase-adminsdk.json",
"/aws-ses.json", "/aws.json", "/env.json", "/config/production.json",
"/appsettings.Development.json", "/appsettings.Production.json",
// Loose source and infra files.
"/aws-config.js", "/app.js", "/index.js", "/server.js", "/env.js", "/js/config.js",
"/settings.py", "/terraform.tfstate", "/Dockerfile", "/_environment", "/old/",
"/s/230313e28343e2333313e28363/_/;/META-INF/maven/com.atlassian.jira/jira-webapp-dist/pom.properties",
}
for _, p := range probes {
if got := classify(p, 404, browserUA); got != KindProbe {
t.Errorf("classify(%q, 404) = %q, want %q", p, got, KindProbe)
}
}
// Seen in production 404s and NOT attacks: these must stay visible as broken
// links so they can be fixed.
// Real people looking for pages we don't have, and crawlers on the auth host.
// These must stay readable as gaps to fill, not get written off as attacks.
notFound := []string{
"/about", "/contact", "/contact-us", "/login",
"/login/sitemap.xml", "/login/robots.txt",
"/orgs/example-mesh/config/edit", "/repeaters/Nf5YgD1sJw6k/console",
}
for _, p := range notFound {
if got := classify(p, 404, browserUA); got != KindNotFound {
t.Errorf("classify(%q, 404) = %q, want %q", p, got, KindNotFound)
}
}
}
// TestOwnRoutesAreNotProbes guards the signature list against our own URL space.
// The rules match extensions, filenames, and dotfiles, and MeshTender genuinely
// serves .json endpoints, has "config" all through the org routes, and has a
// /console under each repeater — a loose rule here would file a member's stale
// bookmark under "attacker".
func TestOwnRoutesAreNotProbes(t *testing.T) {
ours := []string{
"/orgs/example-mesh/repeaters.json",
"/repeaters/Ab3xKp9QmR2t/config.json",
"/orgs/example-mesh/config",
"/orgs/example-mesh/config/profiles/new",
"/orgs/example-mesh/config/regions/12/area",
"/orgs/example-mesh/config/root-flood",
"/orgs/example-mesh/my-commands",
"/repeaters/Ab3xKp9QmR2t/console",
"/repeaters/Zq7WnT4vLh8c/console",
"/account/passkeys/rename",
"/api/login/discoverable/begin",
"/catalog/heltec-v3",
"/invite/:token",
"/build",
}
for _, p := range ours {
if isProbePath(p) {
t.Errorf("isProbePath(%q) = true, but that's one of our own routes", p)
}
if got := classify(p, 404, browserUA); got != KindNotFound {
t.Errorf("classify(%q, 404) = %q, want %q — a 404 on our own route is a broken link, not an attack", p, got, KindNotFound)
}
}
}
// TestConventionalPathsAreNotProbes: these 404 in production today and are worth
// serving, not worth calling attacks. Filing them under "probe" would bury the
// signal that we ought to add them — real iOS devices and search engines are
// asking. .well-known in particular has to survive the dotfile rule.
func TestConventionalPathsAreNotProbes(t *testing.T) {
conventional := []string{
"/apple-touch-icon.png",
"/apple-touch-icon-precomposed.png",
"/favicon.png",
"/sitemap.xml",
"/robots.txt",
"/llms.txt",
"/.well-known/security.txt",
"/.well-known/change-password",
}
for _, p := range conventional {
if isProbePath(p) {
t.Errorf("isProbePath(%q) = true, but that's a web convention worth serving", p)
}
}
}