diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 5c0036325..b3812e154 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -10128,6 +10128,38 @@ "kind": "file" } ] + }, + { + "id": "telemetry-test-binary-production-pings", + "summary": "Go test binaries reported themselves to the production telemetry receiver as live installations. pkg/server tests boot the real server through Run() with the version literal \"test-version\", which internal/updates normalizes to 0.0.0-test-version, and each test runs against its own t.TempDir(), so every run minted a fresh install ID. The startup ping waits two minutes and never fired in a short test, but the service-health failure reporter added on 2026-08-29 sends synchronously from a deferred handler as soon as Run() returns an error, so every CI shard containing pkg/server posted one ping. The receiver recorded 317 single-ping installs between 2026-08-29 and 2026-09-03, 311 from linux/amd64 CI runners and 3 from a maintainer workstation, still arriving at roughly 60 a day. The canonical clean denominator excludes single-ping installs and was unaffected, but raw install counts and the operator-evidence Patrol blocked-cause read counted them as real installations. Resolved by refusing production-endpoint sends from a test binary in internal/telemetry, opting the server tests and the CI test job out of telemetry, and excluding development builds from the operator-evidence read.", + "owner": "project-owner", + "status": "triaged", + "recorded_at": "2026-09-03", + "lane_ids": [ + "L14" + ], + "subsystem_ids": [ + "security-privacy" + ], + "proposed_resolution": "lane-expansion", + "coverage_impact": 3, + "evidence": [ + { + "repo": "pulse", + "path": "internal/telemetry/telemetry.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "pkg/server/server.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "pkg/server/server_test.go", + "kind": "file" + } + ] } ], "candidate_lanes": [ diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index b94254816..102138e19 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -445,6 +445,15 @@ the `white_label` branding entitlement. runs per event so runtime mock toggles take effect immediately — because a mock-mode snapshot describes the synthetic fixture fleet rather than a real installation. + A Go test binary is the same kind of suppression boundary: while + `testing.Testing()` reports true, `internal/telemetry` must not post to the + production receiver on any path, including the synchronous service-health + failure event `pkg/server.Run` sends from its deferred handler. A test that + boots the real server runs against a throwaway data directory, so it mints + a fresh install ID on every run and the receiver counts it as a distinct + live installation. The guard compares the resolved endpoint against + `productionPingEndpoint`, so tests that redirect `pingEndpoint` at a local + server keep asserting on real ping content. Pulse Intelligence external-agent/MCP telemetry may expose only content-free adapter-origin usage and capability-class counters for context, event stream, provisioning, operator state, finding, and action requests. It must diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 770777c07..545200824 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -80,6 +80,14 @@ // While mock/demo fixture mode is enabled, outbound pings are suppressed // entirely: a mock-mode boot (e2e, CI, qual runs, demo containers) would // otherwise report the synthetic fixture fleet as a real installation. +// +// # Test binaries +// +// Pings to the production endpoint are suppressed inside a Go test binary for +// the same reason: a test that boots the real server is not an installation, +// and its throwaway data directory mints a new install ID on every run. Tests +// that need to assert on ping content redirect pingEndpoint to a local server, +// which the guard deliberately allows. package telemetry import ( @@ -96,6 +104,7 @@ import ( "runtime" "strings" "sync" + "testing" "time" "github.com/google/uuid" @@ -104,12 +113,19 @@ import ( "github.com/rs/zerolog/log" ) +// productionPingEndpoint is the live receiver for outbound usage telemetry. +const productionPingEndpoint = "https://license.pulserelay.pro/v1/telemetry/ping" + // pingEndpoint is the URL that receives outbound usage telemetry pings. // It is a var (not const) so that tests can redirect it to a local server. -var pingEndpoint = "https://license.pulserelay.pro/v1/telemetry/ping" +var pingEndpoint = productionPingEndpoint var errInstallIDUnavailable = errors.New("telemetry install id unavailable") +// errProductionEndpointUnderTest reports a ping suppressed because a test +// binary tried to reach the live receiver. +var errProductionEndpointUnderTest = errors.New("telemetry: refusing to post to the production endpoint from a test binary") + const ( // heartbeatInterval is the base interval between daily pings. // Each cycle adds random jitter of ±maxHeartbeatJitter to prevent @@ -1731,6 +1747,15 @@ func buildPingAt(cfg Config, event string, now time.Time) (Ping, error) { // send posts a ping to the telemetry endpoint. Errors are observable in debug // logs but never affect normal Pulse operation. func send(ctx context.Context, ping Ping) error { + // A test binary is not a real installation. Any test that boots the real + // server (pkg/server.Run and anything like it) runs against a throwaway + // data directory, so it mints a fresh install ID per run and would be + // counted as a distinct live install. Telemetry's own tests redirect + // pingEndpoint at a local server and are unaffected by this guard. + if testing.Testing() && pingEndpoint == productionPingEndpoint { + return errProductionEndpointUnderTest + } + body, err := json.Marshal(ping) if err != nil { return err diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index a1c07aae7..3f9f77359 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -2,6 +2,7 @@ package telemetry import ( "encoding/json" + "errors" "fmt" "io" "io/fs" @@ -1366,3 +1367,61 @@ func TestTelemetryPrivacyDocsDiscloseSetupChoiceAndPayloadChanges(t *testing.T) } } } + +// The production telemetry receiver must be unreachable from a Go test binary. +// A test that boots the real server runs against a throwaway data directory, +// so it mints a fresh install ID on every run and lands at the receiver as a +// distinct live installation. Regression guard for the 317 single-ping +// 0.0.0-test-version installs that pkg/server tests reported between +// 2026-08-29 and 2026-09-03. +func TestSendRefusesProductionEndpointUnderTest(t *testing.T) { + if pingEndpoint != productionPingEndpoint { + t.Fatalf("pingEndpoint = %q, want the production endpoint by default", pingEndpoint) + } + + err := send(context.Background(), Ping{Event: "startup"}) + if !errors.Is(err, errProductionEndpointUnderTest) { + t.Fatalf("send() to the production endpoint = %v, want errProductionEndpointUnderTest", err) + } +} + +// SendServiceHealthEvent is the path pkg/server.Run takes when startup fails, +// and it sends synchronously rather than after the two-minute startup delay, +// which is why the failing server tests reported and the passing ones did not. +func TestSendServiceHealthEventRefusesProductionEndpointUnderTest(t *testing.T) { + cfg := Config{ + Version: "test-version", + DataDir: t.TempDir(), + Enabled: true, + } + + err := SendServiceHealthEvent(context.Background(), cfg, "startup", ServiceHealthObservation{ + Observed: true, + FailureCategory: ServiceHealthFailureListener, + }) + if !errors.Is(err, errProductionEndpointUnderTest) { + t.Fatalf("SendServiceHealthEvent() = %v, want errProductionEndpointUnderTest", err) + } +} + +// A redirected endpoint is how telemetry's own tests assert on ping content, +// so the guard must not block it. +func TestSendAllowsRedirectedEndpointUnderTest(t *testing.T) { + var received atomic.Int32 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + received.Add(1) + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + origEndpoint := pingEndpoint + pingEndpoint = ts.URL + defer func() { pingEndpoint = origEndpoint }() + + if err := send(context.Background(), Ping{Event: "startup"}); err != nil { + t.Fatalf("send() to a redirected endpoint: %v", err) + } + if got := received.Load(); got != 1 { + t.Fatalf("redirected endpoint received %d pings, want 1", got) + } +} diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index f725d24ac..e8562fa97 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -294,6 +294,9 @@ func waitForHTTPStatus(t *testing.T, url string, want int) { func TestServerRun_Shutdown(t *testing.T) { // Setup minimal environment tmpDir := t.TempDir() + // Run() reports startup failures over outbound telemetry; a test boot is + // not an installation, so opt this process out at the config layer too. + t.Setenv("PULSE_TELEMETRY", "false") t.Setenv("PULSE_DATA_DIR", tmpDir) t.Setenv("PULSE_CONFIG_PATH", tmpDir) t.Setenv("BIND_ADDRESS", "127.0.0.1") @@ -325,6 +328,9 @@ func TestServerRun_Shutdown(t *testing.T) { } func TestServerRunFailsFastWhenFrontendPortIsAlreadyBound(t *testing.T) { + // Run() reports startup failures over outbound telemetry; a test boot is + // not an installation, so opt this process out at the config layer too. + t.Setenv("PULSE_TELEMETRY", "false") listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) @@ -347,6 +353,9 @@ func TestServerRunFailsFastWhenFrontendPortIsAlreadyBound(t *testing.T) { } func TestServerRunKeepsFrontendWhenMetricsPortConflicts(t *testing.T) { + // Run() reports startup failures over outbound telemetry; a test boot is + // not an installation, so opt this process out at the config layer too. + t.Setenv("PULSE_TELEMETRY", "false") port := availableTCPPort(t) tmpDir := t.TempDir() @@ -381,6 +390,9 @@ func TestServerRunKeepsFrontendWhenMetricsPortConflicts(t *testing.T) { func TestServerRun_RejectsWildcardTrustedProxyCIDR(t *testing.T) { tmpDir := t.TempDir() + // Run() reports startup failures over outbound telemetry; a test boot is + // not an installation, so opt this process out at the config layer too. + t.Setenv("PULSE_TELEMETRY", "false") t.Setenv("PULSE_DATA_DIR", tmpDir) t.Setenv("PULSE_CONFIG_PATH", tmpDir) t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "0.0.0.0/0")