diff --git a/multiplayer-next.md b/multiplayer-next.md index dee94e21..4c25c920 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1449,6 +1449,8 @@ The control plane now exports bounded Prometheus-compatible API request counters That local gate now also runs `go test -race ./...`, `go vet ./...`, and each declared domain fuzz target for a bounded 2-second interval, aligning the one-command gate with the separately recorded 8.46 verification requirements. +Observability redaction now adds content-aware protection on top of denylisted field names: bearer values, compact JWT-like strings, PEM material, and long opaque mixed alphanumeric values are redacted recursively through arbitrary nested maps and string slices. Unknown-key credential canaries pass without leaking; false-positive risk is limited to custom long opaque fields, while canonical correlation IDs remain outside the free-form field map. + The three declared domain fuzz targets have now each completed a bounded 4-second run (`FuzzQueueCreateDoesNotPanic`, `FuzzResultDigestIsDeterministic`, and `FuzzSyncEventApplicationDoesNotPanic`) with no failures; this closes the locally runnable fuzz portion of 8.46. Live Redis failover, further transaction races, and cloud/runtime gates remain explicitly unverified. The real ENet integration gate now passes with `GODOT_BIN=/Applications/Godot.app/Contents/MacOS/Godot bash scripts/verify_enet_integration.sh`, covering the `net`, `match-net`, `clock`, `lobby`, and `networked match` process scenarios. The default `GODOT_BIN` remains the portable `godot` PATH lookup for CI; this machine requires the explicit app-bundle path. diff --git a/server/observability/log.go b/server/observability/log.go index fb08de07..c6de09b1 100644 --- a/server/observability/log.go +++ b/server/observability/log.go @@ -45,19 +45,57 @@ func redact(key string, value any) any { } } switch typed := value.(type) { + case string: + if looksLikeCredential(typed) { + return "[REDACTED]" + } + return typed case map[string]any: copy := make(map[string]any, len(typed)) for key, value := range typed { copy[key] = redact(key, value) } return copy + case map[string]string: + copy := make(map[string]string, len(typed)) + for key, value := range typed { + redacted := redact(key, value) + copy[key] = redacted.(string) + } + return copy case []any: copy := make([]any, len(typed)) for i, value := range typed { copy[i] = redact("item", value) } return copy + case []string: + copy := make([]string, len(typed)) + for i, value := range typed { + copy[i] = redact("item", value).(string) + } + return copy default: return value } } + +func looksLikeCredential(value string) bool { + trimmed := strings.TrimSpace(value) + if strings.HasPrefix(strings.ToLower(trimmed), "bearer ") || strings.Contains(trimmed, "-----BEGIN ") { + return true + } + parts := strings.Split(trimmed, ".") + if len(parts) == 3 && len(parts[0]) >= 8 && len(parts[1]) >= 8 && len(parts[2]) >= 8 { + return true // compact JWT-like credential + } + if len(trimmed) < 40 || strings.ContainsAny(trimmed, " \t\r\n") { + return false + } + hasLetter, hasDigit := false, false + for _, ch := range trimmed { + hasLetter = hasLetter || (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') + hasDigit = hasDigit || (ch >= '0' && ch <= '9') + } + return hasLetter && hasDigit +} diff --git a/server/observability/log_test.go b/server/observability/log_test.go index e2c98fff..666f3ec2 100644 --- a/server/observability/log_test.go +++ b/server/observability/log_test.go @@ -2,6 +2,7 @@ package observability import ( "encoding/json" + "strings" "testing" "time" ) @@ -30,3 +31,20 @@ func TestEncodeRejectsUnnamedEvents(t *testing.T) { t.Fatal("unnamed event accepted") } } + +func TestEncodeRedactsCredentialLookingValuesUnderUnknownKeys(t *testing.T) { + payload, err := Encode(Event{Event: "test", Fields: map[string]any{ + "unexpected": "workload-secret-value-12345678901234567890", + "nested": map[string]string{"opaque": "Bearer should-not-appear"}, + "items": []string{"eyJhbGciOiJIUzI1NiJ9.payload-value.signature-value"}, + }}) + if err != nil { + t.Fatal(err) + } + text := string(payload) + for _, secret := range []string{"workload-secret-value", "Bearer should-not-appear", "eyJhbGciOiJIUzI1NiJ9"} { + if strings.Contains(text, secret) { + t.Fatalf("credential leaked under unknown key: %s", payload) + } + } +}