From 086d3e151dd937ff6eb44f04ce1a538bdfbceba9 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 13 Jun 2026 16:01:22 +0900 Subject: [PATCH] checker: add honeypot-path collector and rules Probes 20 known-bad paths (/.env, /.git/config, /actuator/env, etc.) that CT-log scanners hit immediately after a new certificate is issued. Critical credential/source-leak paths raise StatusCrit; other exposed paths raise StatusWarn; 401/403 responses raise StatusInfo. Fixes: https://git.nemunai.re/happyDomain/checker-http/issues/1 --- checker/collector.go | 56 +++++++++++++ checker/collector_honeypot.go | 109 ++++++++++++++++++++++++ checker/collector_wellknown.go | 50 +---------- checker/rules_honeypot.go | 81 ++++++++++++++++++ checker/rules_honeypot_test.go | 147 +++++++++++++++++++++++++++++++++ 5 files changed, 397 insertions(+), 46 deletions(-) create mode 100644 checker/collector_honeypot.go create mode 100644 checker/rules_honeypot.go create mode 100644 checker/rules_honeypot_test.go diff --git a/checker/collector.go b/checker/collector.go index a10cf20..8d314ed 100644 --- a/checker/collector.go +++ b/checker/collector.go @@ -6,6 +6,11 @@ package checker import ( "context" + "crypto/tls" + "io" + "net" + "net/http" + "net/url" "time" ) @@ -34,3 +39,54 @@ type Collector interface { Key() string Collect(ctx context.Context, t Target) (any, error) } + +// PathProbe is the common result of a single HTTPS path probe. It is +// embedded by collector-specific probe types that may add extra fields +// (e.g. HoneypotProbe adds Critical). +type PathProbe struct { + URL string `json:"url"` + StatusCode int `json:"status_code,omitempty"` + Bytes int `json:"bytes,omitempty"` + Error string `json:"error,omitempty"` +} + +// fetchHTTPSPath issues a single GET against the given path using client, +// reads up to limit bytes (just to measure size), and returns a PathProbe. +func fetchHTTPSPath(ctx context.Context, client *http.Client, host, path, ua string, limit int64) PathProbe { + u := (&url.URL{Scheme: "https", Host: host, Path: path}).String() + probe := PathProbe{URL: u} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + probe.Error = err.Error() + return probe + } + req.Header.Set("User-Agent", ua) + resp, err := client.Do(req) + if err != nil { + probe.Error = err.Error() + return probe + } + defer resp.Body.Close() + probe.StatusCode = resp.StatusCode + n, _ := io.Copy(io.Discard, io.LimitReader(resp.Body, limit)) + probe.Bytes = int(n) + return probe +} + +// newPinnedHTTPSTransport returns an http.Transport that dials every request +// to ip:443 and presents host as the TLS ServerName. The caller must defer +// the returned cleanup func to drain idle connections. +func newPinnedHTTPSTransport(ip, host string, timeout time.Duration) (*http.Transport, func()) { + addr := net.JoinHostPort(ip, "443") + dialer := &net.Dialer{Timeout: timeout} + t := &http.Transport{ + DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { + return dialer.DialContext(ctx, network, addr) + }, + TLSClientConfig: &tls.Config{ServerName: host}, + TLSHandshakeTimeout: timeout, + ResponseHeaderTimeout: timeout, + DisableKeepAlives: true, + } + return t, t.CloseIdleConnections +} diff --git a/checker/collector_honeypot.go b/checker/collector_honeypot.go new file mode 100644 index 0000000..b6e80ab --- /dev/null +++ b/checker/collector_honeypot.go @@ -0,0 +1,109 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. + +package checker + +import ( + "context" + "fmt" + "net/http" + "sync" +) + +const ObservationKeyHoneypot = "honeypot" + +// honeypotPaths is the curated list of paths that internet scanners probe +// immediately after a new TLS certificate appears in CT logs. Any path +// returning a non-404/410 response is worth reporting to the site owner. +// +// /.well-known/security.txt is intentionally absent — it is already +// evaluated by securityTxtRule via the wellknown collector. +var honeypotPaths = []struct { + path string + critical bool // true → leak of credentials or source code +}{ + // Credentials / source leaks — critical + {"/.env", true}, + {"/.git/config", true}, + {"/.vscode/sftp.json", true}, + {"/.DS_Store", true}, + // Debug / metrics endpoints + {"/actuator/env", true}, + {"/server-status", false}, + {"/debug/default/view", false}, + {"/trace.axd", false}, + {"/@vite/env", false}, + {"/info.php", false}, + // Admin panels + {"/console/", false}, + {"/server", false}, + {"/login.action", false}, + {"/telescope/requests", false}, + // APIs / registries + {"/v2/_catalog", false}, + // CMS entry points + {"/xmlrpc.php", false}, + {"/wp-json/", false}, + {"/?rest_route=/wp/v2/users/", false}, + // Config dumps + {"/config.json", false}, + {"/version", false}, +} + +// HoneypotData is the observation written under ObservationKeyHoneypot. +type HoneypotData struct { + Probes map[string]HoneypotProbe `json:"probes"` +} + +// HoneypotProbe is the outcome of a single path probe. +type HoneypotProbe struct { + PathProbe + Critical bool `json:"critical,omitempty"` +} + +// honeypotCollector probes known-bad paths on the first HTTPS IP without +// following redirects. A redirect is itself a finding. +type honeypotCollector struct{} + +func (honeypotCollector) Key() string { return ObservationKeyHoneypot } + +func (honeypotCollector) Collect(ctx context.Context, t Target) (any, error) { + if len(t.IPs) == 0 { + return nil, fmt.Errorf("no IPs to probe") + } + transport, cleanup := newPinnedHTTPSTransport(t.IPs[0], t.Host, t.Timeout) + defer cleanup() + // No redirect following: a redirect to /login is itself a finding. + client := &http.Client{ + Transport: transport, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + + out := HoneypotData{Probes: make(map[string]HoneypotProbe, len(honeypotPaths))} + var mu sync.Mutex + var wg sync.WaitGroup + sem := make(chan struct{}, MaxConcurrentProbes) + for _, hp := range honeypotPaths { + wg.Add(1) + sem <- struct{}{} + go func(hp struct { + path string + critical bool + }) { + defer wg.Done() + defer func() { <-sem }() + probe := HoneypotProbe{ + PathProbe: fetchHTTPSPath(ctx, client, t.Host, hp.path, t.UserAgent, 4<<10), + Critical: hp.critical, + } + mu.Lock() + out.Probes[hp.path] = probe + mu.Unlock() + }(hp) + } + wg.Wait() + return &out, nil +} + +func init() { RegisterCollector(honeypotCollector{}) } diff --git a/checker/collector_wellknown.go b/checker/collector_wellknown.go index 4ac5349..5cd26ca 100644 --- a/checker/collector_wellknown.go +++ b/checker/collector_wellknown.go @@ -6,12 +6,8 @@ package checker import ( "context" - "crypto/tls" "fmt" - "io" - "net" "net/http" - "net/url" ) // ObservationKeyWellKnown is the Extensions[] key under which @@ -28,12 +24,7 @@ type WellKnownData struct { } // WellKnownProbe is a single (URI → outcome) entry. -type WellKnownProbe struct { - URL string `json:"url"` - StatusCode int `json:"status_code,omitempty"` - Bytes int `json:"bytes,omitempty"` - Error string `json:"error,omitempty"` -} +type WellKnownProbe = PathProbe // wellknownCollector probes a small, fixed set of standardised URIs // served at the apex of the host. Today it covers: @@ -51,49 +42,16 @@ func (wellknownCollector) Collect(ctx context.Context, t Target) (any, error) { if len(t.IPs) == 0 { return nil, fmt.Errorf("no IPs to probe") } - addr := net.JoinHostPort(t.IPs[0], "443") - dialer := &net.Dialer{Timeout: t.Timeout} - transport := &http.Transport{ - DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { - return dialer.DialContext(ctx, network, addr) - }, - TLSClientConfig: &tls.Config{ServerName: t.Host}, - TLSHandshakeTimeout: t.Timeout, - ResponseHeaderTimeout: t.Timeout, - DisableKeepAlives: true, - } - defer transport.CloseIdleConnections() + transport, cleanup := newPinnedHTTPSTransport(t.IPs[0], t.Host, t.Timeout) + defer cleanup() client := &http.Client{Transport: transport} uris := []string{"/.well-known/security.txt", "/robots.txt"} out := WellKnownData{URIs: make(map[string]WellKnownProbe, len(uris))} for _, path := range uris { - out.URIs[path] = fetchOne(ctx, client, t.Host, path, t.UserAgent) + out.URIs[path] = fetchHTTPSPath(ctx, client, t.Host, path, t.UserAgent, 64<<10) } return &out, nil } -func fetchOne(ctx context.Context, client *http.Client, host, path, ua string) WellKnownProbe { - u := (&url.URL{Scheme: "https", Host: host, Path: path}).String() - probe := WellKnownProbe{URL: u} - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - if err != nil { - probe.Error = err.Error() - return probe - } - req.Header.Set("User-Agent", ua) - resp, err := client.Do(req) - if err != nil { - probe.Error = err.Error() - return probe - } - defer resp.Body.Close() - probe.StatusCode = resp.StatusCode - // Cap the read so a misconfigured server can't pull megabytes for a - // "did this exist?" probe. - body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) - probe.Bytes = len(body) - return probe -} - func init() { RegisterCollector(wellknownCollector{}) } diff --git a/checker/rules_honeypot.go b/checker/rules_honeypot.go new file mode 100644 index 0000000..4a1ba3c --- /dev/null +++ b/checker/rules_honeypot.go @@ -0,0 +1,81 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. + +package checker + +import ( + "context" + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func init() { RegisterRule(&honeypotRule{}) } + +// honeypotRule reports any honeypot path that returned a non-404/410 +// response, signalling a potentially exposed sensitive endpoint. +type honeypotRule struct{} + +func (r *honeypotRule) Name() string { return "http.honeypot" } +func (r *honeypotRule) Description() string { + return "Reports sensitive paths (/.env, /.git/config, /actuator/env, …) that are reachable from the internet." +} + +func (r *honeypotRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadHTTPData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + hp, ok, err := LoadExtension[HoneypotData](data, ObservationKeyHoneypot) + if err != nil { + return []sdk.CheckState{{Status: sdk.StatusError, Code: "http.honeypot.decode_error", Message: err.Error()}} + } + if !ok { + return []sdk.CheckState{unknownState("http.honeypot.no_data", "Honeypot collector did not run.")} + } + + var states []sdk.CheckState + successfulProbes := 0 + for path, probe := range hp.Probes { + if probe.Error != "" || probe.StatusCode == 0 { + continue + } + successfulProbes++ + + st := sdk.CheckState{ + Subject: path, + Meta: map[string]any{"url": probe.URL, "status_code": probe.StatusCode}, + } + switch { + case probe.StatusCode == 200 || (probe.StatusCode >= 301 && probe.StatusCode <= 308): + st.Status = sdk.StatusWarn + st.Code = "http.honeypot.exposed" + st.Message = fmt.Sprintf("%s is accessible (HTTP %d, %d bytes).", path, probe.StatusCode, probe.Bytes) + if probe.Critical { + st.Status = sdk.StatusCrit + st.Code = "http.honeypot.critical_exposed" + } + case probe.StatusCode == 401 || probe.StatusCode == 403: + st.Status = sdk.StatusInfo + st.Code = "http.honeypot.protected" + st.Message = fmt.Sprintf("%s exists but is access-controlled (HTTP %d).", path, probe.StatusCode) + default: + continue + } + states = append(states, st) + } + + if successfulProbes == 0 { + return []sdk.CheckState{unknownState("http.honeypot.no_response", "All honeypot probes failed or timed out.")} + } + if len(states) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusOK, + Code: "http.honeypot.clean", + Subject: data.Domain, + Message: "No sensitive honeypot paths are reachable.", + }} + } + return states +} diff --git a/checker/rules_honeypot_test.go b/checker/rules_honeypot_test.go new file mode 100644 index 0000000..74acfa4 --- /dev/null +++ b/checker/rules_honeypot_test.go @@ -0,0 +1,147 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. + +package checker + +import ( + "encoding/json" + "testing" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func honeypotData(t *testing.T, probes map[string]HoneypotProbe) map[string]json.RawMessage { + t.Helper() + raw, err := json.Marshal(HoneypotData{Probes: probes}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return map[string]json.RawMessage{ObservationKeyHoneypot: raw} +} + +func TestHoneypotRule_Clean(t *testing.T) { + probes := map[string]HoneypotProbe{ + "/.env": {PathProbe: PathProbe{StatusCode: 404}}, + "/.git/config": {PathProbe: PathProbe{StatusCode: 404}}, + "/actuator/env": {PathProbe: PathProbe{StatusCode: 404}}, + } + data := &HTTPData{ + Domain: "example.test", + Probes: []HTTPProbe{httpsProbe("a:443")}, + Extensions: honeypotData(t, probes), + } + states := runRule(t, &honeypotRule{}, data, nil) + mustStatus(t, states, sdk.StatusOK) + if !hasCode(states, "http.honeypot.clean") { + t.Errorf("expected clean, got %+v", states) + } +} + +func TestHoneypotRule_CriticalExposed(t *testing.T) { + probes := map[string]HoneypotProbe{ + "/.env": {PathProbe: PathProbe{StatusCode: 200, Bytes: 512}, Critical: true}, + } + data := &HTTPData{ + Domain: "example.test", + Probes: []HTTPProbe{httpsProbe("a:443")}, + Extensions: honeypotData(t, probes), + } + states := runRule(t, &honeypotRule{}, data, nil) + mustStatus(t, states, sdk.StatusCrit) + if !hasCode(states, "http.honeypot.critical_exposed") { + t.Errorf("expected critical_exposed, got %+v", states) + } +} + +func TestHoneypotRule_WarnExposed(t *testing.T) { + probes := map[string]HoneypotProbe{ + "/server-status": {PathProbe: PathProbe{StatusCode: 200, Bytes: 1024}}, + } + data := &HTTPData{ + Domain: "example.test", + Probes: []HTTPProbe{httpsProbe("a:443")}, + Extensions: honeypotData(t, probes), + } + states := runRule(t, &honeypotRule{}, data, nil) + mustStatus(t, states, sdk.StatusWarn) + if !hasCode(states, "http.honeypot.exposed") { + t.Errorf("expected exposed, got %+v", states) + } +} + +func TestHoneypotRule_Redirect(t *testing.T) { + probes := map[string]HoneypotProbe{ + "/console/": {PathProbe: PathProbe{StatusCode: 301}}, + } + data := &HTTPData{ + Domain: "example.test", + Probes: []HTTPProbe{httpsProbe("a:443")}, + Extensions: honeypotData(t, probes), + } + states := runRule(t, &honeypotRule{}, data, nil) + mustStatus(t, states, sdk.StatusWarn) + if !hasCode(states, "http.honeypot.exposed") { + t.Errorf("expected exposed for redirect, got %+v", states) + } +} + +func TestHoneypotRule_Protected(t *testing.T) { + probes := map[string]HoneypotProbe{ + "/.git/config": {PathProbe: PathProbe{StatusCode: 403}, Critical: true}, + } + data := &HTTPData{ + Domain: "example.test", + Probes: []HTTPProbe{httpsProbe("a:443")}, + Extensions: honeypotData(t, probes), + } + states := runRule(t, &honeypotRule{}, data, nil) + mustStatus(t, states, sdk.StatusInfo) + if !hasCode(states, "http.honeypot.protected") { + t.Errorf("expected protected, got %+v", states) + } +} + +func TestHoneypotRule_NoCollectorData(t *testing.T) { + data := &HTTPData{ + Domain: "example.test", + Probes: []HTTPProbe{httpsProbe("a:443")}, + } + states := runRule(t, &honeypotRule{}, data, nil) + mustStatus(t, states, sdk.StatusUnknown) + if !hasCode(states, "http.honeypot.no_data") { + t.Errorf("expected no_data, got %+v", states) + } +} + +func TestHoneypotRule_DecodeError(t *testing.T) { + data := &HTTPData{ + Domain: "example.test", + Probes: []HTTPProbe{httpsProbe("a:443")}, + Extensions: map[string]json.RawMessage{ + ObservationKeyHoneypot: json.RawMessage(`"not an object"`), + }, + } + states := runRule(t, &honeypotRule{}, data, nil) + if states[0].Status != sdk.StatusError || states[0].Code != "http.honeypot.decode_error" { + t.Errorf("expected decode_error, got %+v", states) + } +} + +func TestHoneypotRule_IgnoresErrors(t *testing.T) { + // All-error probes must return Unknown, not OK, to avoid false-green. + probes := map[string]HoneypotProbe{ + "/.env": {PathProbe: PathProbe{Error: "connection refused"}}, + "/wp-json/": {}, + } + data := &HTTPData{ + Domain: "example.test", + Probes: []HTTPProbe{httpsProbe("a:443")}, + Extensions: honeypotData(t, probes), + } + states := runRule(t, &honeypotRule{}, data, nil) + mustStatus(t, states, sdk.StatusUnknown) + if !hasCode(states, "http.honeypot.no_response") { + t.Errorf("expected no_response when all probes errored, got %+v", states) + } +}