|
5 | 5 | "errors" |
6 | 6 | "net/http" |
7 | 7 | "net/http/httptest" |
| 8 | + "strings" |
8 | 9 | "testing" |
9 | 10 | "time" |
10 | 11 |
|
@@ -171,3 +172,227 @@ func (f fakeResult) Err() error { return f.err } |
171 | 172 | type fakeGRPC struct{ err error } |
172 | 173 |
|
173 | 174 | func (f fakeGRPC) HealthCheck(ctx context.Context) error { return f.err } |
| 175 | + |
| 176 | +// --------------------------------------------------------------------- |
| 177 | +// Security tests for scrub() — Wave-3 audit P1, 2026-05-21. |
| 178 | +// |
| 179 | +// The contract under test: |
| 180 | +// (1) scrub() MUST redact secrets BEFORE truncating to 80 chars. |
| 181 | +// Truncate-first leaks the secret in the first 80 chars of the |
| 182 | +// raw upstream message. |
| 183 | +// (2) Every known secret shape (DB password, URL credentials, Bearer |
| 184 | +// tokens, long hex strings, known service prefixes) is redacted. |
| 185 | +// (3) PingDB + PingRedis (the public callsites of scrub) propagate |
| 186 | +// redaction end-to-end — verified by piping a credential-bearing |
| 187 | +// error through PingRedis and asserting LastError. |
| 188 | +// |
| 189 | +// CLAUDE.md rule 18: registry-iterating, not hand-typed. The |
| 190 | +// secretLeakCases registry below walks every emit pattern; if a new |
| 191 | +// secret shape is added to secretPatterns it MUST be added here too |
| 192 | +// (the registry walk test catches the omission). |
| 193 | +// --------------------------------------------------------------------- |
| 194 | + |
| 195 | +// TestScrub_RedactsDBPassword — pq-style "password=abc123" must be redacted. |
| 196 | +// Username leak ('for user "instant"') is also redacted as semi-sensitive. |
| 197 | +func TestScrub_RedactsDBPassword(t *testing.T) { |
| 198 | + in := `pq: password authentication failed for user "instant" password=abc123def456` |
| 199 | + out := readiness.ScrubForTest(in) |
| 200 | + if strings.Contains(out, "abc123def456") { |
| 201 | + t.Fatalf("password leaked through scrub: %q", out) |
| 202 | + } |
| 203 | + if strings.Contains(out, `"instant"`) { |
| 204 | + t.Fatalf("username leaked through scrub: %q", out) |
| 205 | + } |
| 206 | + if !strings.Contains(out, "REDACTED") { |
| 207 | + t.Fatalf("want REDACTED marker, got %q", out) |
| 208 | + } |
| 209 | +} |
| 210 | + |
| 211 | +// TestScrub_RedactsURLCredentials — postgres://user:pass@host must |
| 212 | +// become postgres://REDACTED:REDACTED@host. This is the dial-tcp shape |
| 213 | +// pq emits when DATABASE_URL is logged through the connect path. |
| 214 | +func TestScrub_RedactsURLCredentials(t *testing.T) { |
| 215 | + in := `dial tcp postgres://admin:s3cr3tP4ss@db.example.com:5432: connection refused` |
| 216 | + out := readiness.ScrubForTest(in) |
| 217 | + if strings.Contains(out, "s3cr3tP4ss") { |
| 218 | + t.Fatalf("URL password leaked: %q", out) |
| 219 | + } |
| 220 | + if strings.Contains(out, "admin:") { |
| 221 | + t.Fatalf("URL username leaked: %q", out) |
| 222 | + } |
| 223 | + if !strings.Contains(out, "REDACTED") { |
| 224 | + t.Fatalf("want REDACTED marker, got %q", out) |
| 225 | + } |
| 226 | +} |
| 227 | + |
| 228 | +// TestScrub_RedactsBearer — Authorization: Bearer <token> must drop |
| 229 | +// the token. Covers Brevo (xkeysib-...) + Stripe-style sk- prefixes too. |
| 230 | +func TestScrub_RedactsBearer(t *testing.T) { |
| 231 | + in := `401 Authorization: Bearer xkeysib-abc123def456ghi789jkl012mno345pqr678 unauthorized` |
| 232 | + out := readiness.ScrubForTest(in) |
| 233 | + if strings.Contains(out, "xkeysib-abc123def456ghi789jkl012mno345pqr678") { |
| 234 | + t.Fatalf("bearer token leaked: %q", out) |
| 235 | + } |
| 236 | + if !strings.Contains(strings.ToLower(out), "redacted") { |
| 237 | + t.Fatalf("want redacted marker, got %q", out) |
| 238 | + } |
| 239 | +} |
| 240 | + |
| 241 | +// TestScrub_RedactsHexSecrets — any 32+ hex run is treated as a |
| 242 | +// suspected secret. Catches AES_KEY fragments, opaque tokens, HMAC hex. |
| 243 | +func TestScrub_RedactsHexSecrets(t *testing.T) { |
| 244 | + hex := "deadbeef0123456789abcdef0123456789abcdef" // 40 hex chars |
| 245 | + in := "error: signing failed with key " + hex + " (truncated)" |
| 246 | + out := readiness.ScrubForTest(in) |
| 247 | + if strings.Contains(out, hex) { |
| 248 | + t.Fatalf("hex secret leaked: %q", out) |
| 249 | + } |
| 250 | + if !strings.Contains(out, "REDACTED") { |
| 251 | + t.Fatalf("want REDACTED marker, got %q", out) |
| 252 | + } |
| 253 | +} |
| 254 | + |
| 255 | +// TestScrub_RedactsKnownPrefixes — service-shape tokens (xkeysib-, sk-, |
| 256 | +// rzp_) are redacted even outside an Authorization header. |
| 257 | +func TestScrub_RedactsKnownPrefixes(t *testing.T) { |
| 258 | + cases := []struct { |
| 259 | + name string |
| 260 | + in string |
| 261 | + secret string |
| 262 | + }{ |
| 263 | + {"brevo", `dial: xkeysib-ABC123DEFsecret leaked`, `xkeysib-ABC123DEFsecret`}, |
| 264 | + {"stripe", `auth failed: sk-livekey_abc123 invalid`, `sk-livekey_abc123`}, |
| 265 | + {"razorpay", `webhook error rzp_test_abc123def456 unauthorized`, `rzp_test_abc123def456`}, |
| 266 | + } |
| 267 | + for _, c := range cases { |
| 268 | + t.Run(c.name, func(t *testing.T) { |
| 269 | + out := readiness.ScrubForTest(c.in) |
| 270 | + if strings.Contains(out, c.secret) { |
| 271 | + t.Fatalf("%s secret leaked: %q", c.name, out) |
| 272 | + } |
| 273 | + }) |
| 274 | + } |
| 275 | +} |
| 276 | + |
| 277 | +// TestScrub_RedactsBeforeTruncating — the load-bearing security |
| 278 | +// invariant. The raw upstream message has a credential in chars 60-80; |
| 279 | +// truncate-first would leak it. Redact-first does not. |
| 280 | +func TestScrub_RedactsBeforeTruncating(t *testing.T) { |
| 281 | + // Length-tuned message: the password lands inside the first 80 chars |
| 282 | + // so a truncate-first implementation would surface it on the wire. |
| 283 | + in := `pq: connection failed at host db.internal password=hunter2letmein extra` |
| 284 | + if len(in) < 60 { |
| 285 | + t.Fatalf("test prerequisite: input must exceed truncation cutoff window") |
| 286 | + } |
| 287 | + out := readiness.ScrubForTest(in) |
| 288 | + if strings.Contains(out, "hunter2letmein") { |
| 289 | + t.Fatalf("truncate-first regression — password in output: %q", out) |
| 290 | + } |
| 291 | +} |
| 292 | + |
| 293 | +// TestScrub_TruncatesAfterRedaction — the 80-char cap still applies on |
| 294 | +// genuinely long non-secret messages. |
| 295 | +func TestScrub_TruncatesAfterRedaction(t *testing.T) { |
| 296 | + long := strings.Repeat("x", 200) |
| 297 | + out := readiness.ScrubForTest(long) |
| 298 | + if len(out) > 80 { |
| 299 | + t.Fatalf("scrub did not truncate non-secret long input: len=%d", len(out)) |
| 300 | + } |
| 301 | +} |
| 302 | + |
| 303 | +// TestScrub_TrimsWhitespace — preserve the existing behaviour of |
| 304 | +// stripping trailing newlines that some upstream errors include. |
| 305 | +func TestScrub_TrimsWhitespace(t *testing.T) { |
| 306 | + out := readiness.ScrubForTest(" upstream blew up \n") |
| 307 | + if out != "upstream blew up" { |
| 308 | + t.Fatalf("trim regression: %q", out) |
| 309 | + } |
| 310 | +} |
| 311 | + |
| 312 | +// TestScrub_PreservesNonSecretShape — a generic non-secret error is |
| 313 | +// not over-redacted. Operators still need to read these. |
| 314 | +func TestScrub_PreservesNonSecretShape(t *testing.T) { |
| 315 | + in := "context deadline exceeded" |
| 316 | + out := readiness.ScrubForTest(in) |
| 317 | + if out != in { |
| 318 | + t.Fatalf("over-redacted non-secret message: input=%q output=%q", in, out) |
| 319 | + } |
| 320 | +} |
| 321 | + |
| 322 | +// secretLeakCases is the registry-style truth table. Each row is a |
| 323 | +// (label, real-upstream-error, substring-that-MUST-NOT-survive). |
| 324 | +// CLAUDE.md rule 18: any new secret shape added to secretPatterns |
| 325 | +// must add its row here too. The test below iterates every row. |
| 326 | +var secretLeakCases = []struct { |
| 327 | + label string |
| 328 | + upstream string |
| 329 | + mustNotLeak []string |
| 330 | +}{ |
| 331 | + {"pq_password_kv", `pq: FATAL: password=topsecret123 invalid`, []string{"topsecret123"}}, |
| 332 | + {"pq_passwd_kv", `pq: FATAL: passwd=topsecret123 invalid`, []string{"topsecret123"}}, |
| 333 | + {"pq_pwd_kv", `pq: FATAL: pwd=topsecret123 invalid`, []string{"topsecret123"}}, |
| 334 | + {"pq_user_double_quote", `pq: password auth failed for user "dbadmin"`, []string{`"dbadmin"`}}, |
| 335 | + {"pq_user_single_quote", `pq: password auth failed for user 'dbadmin'`, []string{`'dbadmin'`}}, |
| 336 | + {"url_postgres", `dial postgres://app:p4ssw0rd@db:5432`, []string{"p4ssw0rd", "app:"}}, |
| 337 | + {"url_redis", `dial redis://user:r3disp4ss@cache:6379`, []string{"r3disp4ss"}}, |
| 338 | + {"url_mongo", `dial mongodb://root:m0ngop4ss@mongo:27017`, []string{"m0ngop4ss"}}, |
| 339 | + {"auth_bearer", `401: Authorization: Bearer xkeysib-veryverysecrettoken`, []string{"xkeysib-veryverysecrettoken"}}, |
| 340 | + {"auth_basic", `401: Authorization: Basic YWRtaW46cGFzc3dvcmQ=`, []string{"YWRtaW46cGFzc3dvcmQ="}}, |
| 341 | + {"prefix_brevo", `error sending mail with key xkeysib-abc123xyzdef`, []string{"xkeysib-abc123xyzdef"}}, |
| 342 | + {"prefix_stripe", `card error with sk-livekey_xyz789abc`, []string{"sk-livekey_xyz789abc"}}, |
| 343 | + {"prefix_razorpay", `webhook err rzp_live_secretkey123`, []string{"rzp_live_secretkey123"}}, |
| 344 | + {"hex_32", `signing key deadbeef0123456789abcdef01234567 leaked`, []string{"deadbeef0123456789abcdef01234567"}}, |
| 345 | + {"hex_64", `aes key ` + strings.Repeat("a1b2", 16) + ` invalid`, []string{strings.Repeat("a1b2", 16)}}, |
| 346 | +} |
| 347 | + |
| 348 | +// TestScrub_RegistryWalk iterates every known leak shape. CLAUDE.md |
| 349 | +// rule 18: this fails closed — a new secret shape added to |
| 350 | +// secretPatterns without a registry row trips review on the next PR |
| 351 | +// run (the new pattern has no coverage; the registry row asserts the |
| 352 | +// pattern actually masks the case). |
| 353 | +func TestScrub_RegistryWalk(t *testing.T) { |
| 354 | + for _, tc := range secretLeakCases { |
| 355 | + t.Run(tc.label, func(t *testing.T) { |
| 356 | + out := readiness.ScrubForTest(tc.upstream) |
| 357 | + for _, leak := range tc.mustNotLeak { |
| 358 | + if strings.Contains(out, leak) { |
| 359 | + t.Fatalf("%s — leak %q survived scrub: input=%q output=%q", tc.label, leak, tc.upstream, out) |
| 360 | + } |
| 361 | + } |
| 362 | + }) |
| 363 | + } |
| 364 | +} |
| 365 | + |
| 366 | +// TestPingRedis_RedactsCredentialsEndToEnd — exercises the public |
| 367 | +// callsite. A real go-redis error that contains a credential fragment |
| 368 | +// must NOT surface that fragment via LastError on the wire. |
| 369 | +// |
| 370 | +// This is the rule-18 "registry walk" of scrub() callsites — there |
| 371 | +// are two callers (PingDB, PingRedis) and one of them is testable via |
| 372 | +// the existing fakePinger plumbing. PingDB requires *sql.DB which is |
| 373 | +// not interface-typed; the per-pattern coverage above is the |
| 374 | +// substitute for a PingDB end-to-end. |
| 375 | +func TestPingRedis_RedactsCredentialsEndToEnd(t *testing.T) { |
| 376 | + badp := fakePinger{err: errors.New(`dial redis://user:s3cr3tPass@cache:6379: connection refused`)} |
| 377 | + res := readiness.PingRedis(badp, time.Second)(context.Background()) |
| 378 | + if res.Status != readiness.StatusFailed { |
| 379 | + t.Fatalf("want failed, got %q", res.Status) |
| 380 | + } |
| 381 | + if strings.Contains(res.LastError, "s3cr3tPass") { |
| 382 | + t.Fatalf("PingRedis leaked credential through LastError: %q", res.LastError) |
| 383 | + } |
| 384 | + if strings.Contains(res.LastError, "user:") { |
| 385 | + t.Fatalf("PingRedis leaked username through LastError: %q", res.LastError) |
| 386 | + } |
| 387 | +} |
| 388 | + |
| 389 | +// TestPingRedis_PreservesShortNonSecretError — defensive regression |
| 390 | +// check that the wrapping CheckResult still has a useful LastError |
| 391 | +// when the upstream error is short + non-secret. |
| 392 | +func TestPingRedis_PreservesShortNonSecretError(t *testing.T) { |
| 393 | + badp := fakePinger{err: errors.New("connection refused")} |
| 394 | + res := readiness.PingRedis(badp, time.Second)(context.Background()) |
| 395 | + if res.LastError != "connection refused" { |
| 396 | + t.Fatalf("want preserved non-secret error, got %q", res.LastError) |
| 397 | + } |
| 398 | +} |
0 commit comments