package delivery_test import ( "net/url" "testing" "github.com/stretchr/testify/assert" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/delivery" ) // The secret path segments of a Slack incoming webhook URL. const ( redactSecretPath = "/services/T11111111/B11111111/" + "YYYYYYYYYYYYYYYYYYYYYYYY" redactWebhookURL = "https://hooks.slack.com" + redactSecretPath ) func TestRedactor_RemovesSlackWebhookURL(t *testing.T) { t.Parallel() r := delivery.NewRedactor(&database.Target{ Type: database.TargetTypeSlack, Config: `{"webhookUrl":"` + redactWebhookURL + `"}`, }) got := r.Redact("no_service for " + redactWebhookURL) assert.NotContains(t, got, redactSecretPath) assert.NotContains(t, got, "T11111111") // One marker, not a marker with the host left in front of // it: the whole URL is replaced before the path it // contains, which is what sorting the secrets longest // first buys. assert.Equal( t, "no_service for "+delivery.RedactionMarker, got, ) } // TestRedactor_RemovesSecretSeveredByACut covers the input the // redactor exists for: text cut to a byte budget with the // credential straddling the cut. The remote chooses the // padding, so it chooses where the cut lands inside the // credential, and the severed prefix that remains equals no // secret. func TestRedactor_RemovesSecretSeveredByACut(t *testing.T) { t.Parallel() r := delivery.NewRedactor(&database.Target{ Type: database.TargetTypeSlack, Config: `{"webhookUrl":"` + redactWebhookURL + `"}`, }) // Every cut position inside the credential, not just a // convenient one. for n := 1; n < len(redactWebhookURL); n++ { severed := redactWebhookURL[:n] cut := "padding " + severed got := r.RedactCut(cut) assert.Equal( t, "padding "+delivery.RedactionMarker, got, "cut after %d bytes of the credential", n, ) } } // TestRedactor_RedactsCredentialShapedHeaderValues pins the // class-based header rule: a header whose name says credential // has its value redacted, and a routine header does not, so // ordinary response content survives. func TestRedactor_RedactsCredentialShapedHeaderValues( t *testing.T, ) { t.Parallel() r := delivery.NewRedactor(&database.Target{ Type: database.TargetTypeHTTP, Config: `{"url":"https://example.com/in",` + `"headers":{` + `"Authorization":"Bearer AAAAAAAAAAAA",` + `"Cookie":"session=BBBBBBBBBBBB",` + `"X-Api-Key":"CCCCCCCCCCCC",` + `"X-Hub-Signature":"sha256=DDDDDDDDDDDD",` + `"Accept":"application/json",` + `"User-Agent":"webhooker/1.0"}}`, }) for _, secret := range []string{ "Bearer AAAAAAAAAAAA", "session=BBBBBBBBBBBB", "CCCCCCCCCCCC", "sha256=DDDDDDDDDDDD", } { got := r.Redact("echo: " + secret) assert.Equal( t, "echo: "+delivery.RedactionMarker, got, secret, ) } const routine = "Accept: application/json, " + "User-Agent: webhooker/1.0" assert.Equal(t, routine, r.Redact(routine)) } // TestRedactor_IgnoresVeryShortHeaderValues pins the floor // under a header value. Redacting a two-byte value would put // the marker through every response that happens to contain // those bytes. func TestRedactor_IgnoresVeryShortHeaderValues(t *testing.T) { t.Parallel() r := delivery.NewRedactor(&database.Target{ Type: database.TargetTypeHTTP, Config: `{"url":"https://example.com/in",` + `"headers":{"X-Api-Key":"ab"}}`, }) const response = "rabbit" assert.Equal(t, response, r.Redact(response)) } // TestRedactor_RemovesBarePath covers a remote that echoes // only the request path rather than the whole URL. The path // segments are the credential on their own. func TestRedactor_RemovesBarePath(t *testing.T) { t.Parallel() r := delivery.NewRedactor(&database.Target{ Type: database.TargetTypeSlack, Config: `{"webhookUrl":"` + redactWebhookURL + `"}`, }) got := r.Redact("POST " + redactSecretPath + " 404") assert.NotContains(t, got, redactSecretPath) assert.Equal( t, "POST "+delivery.RedactionMarker+" 404", got, ) } // TestRedactor_RemovesHTTPURLQueryAndUserinfo covers the HTTP // target, whose destination is an arbitrary URL: the query // string and the userinfo carry credentials as readily as the // path does. func TestRedactor_RemovesHTTPURLQueryAndUserinfo(t *testing.T) { t.Parallel() // Assembled rather than written out, so the literal is // not itself a credential-shaped string. dest := url.URL{ Scheme: "https", User: url.UserPassword("user", "hunter2"), Host: "example.com", Path: "/in", RawQuery: "token=s3cr3t", } raw := dest.String() r := delivery.NewRedactor(&database.Target{ Type: database.TargetTypeHTTP, Config: `{"url":"` + raw + `"}`, }) for _, echoed := range []string{ raw, "/in?token=s3cr3t", "hunter2", } { got := r.Redact("rejected: " + echoed) assert.NotContains(t, got, "s3cr3t", echoed) assert.NotContains(t, got, "hunter2", echoed) assert.Contains( t, got, delivery.RedactionMarker, echoed, ) } } // TestRedactor_LeavesUnrelatedTextAlone pins that the // redactor matches literally: it does not guess at what a // secret looks like, so ordinary response content survives. func TestRedactor_LeavesUnrelatedTextAlone(t *testing.T) { t.Parallel() const response = "ok=false error=channel_not_found" r := delivery.NewRedactor(&database.Target{ Type: database.TargetTypeSlack, Config: `{"webhookUrl":"` + redactWebhookURL + `"}`, }) assert.Equal(t, response, r.Redact(response)) } // TestRedactor_ZeroValueAndConfiglessTargets pins that a // caller with no target, an unparseable config, or a target // type with no destination URL gets a redactor that changes // nothing rather than one that panics. func TestRedactor_ZeroValueAndConfiglessTargets(t *testing.T) { t.Parallel() const text = "some response body" var zero delivery.Redactor assert.Equal(t, text, zero.Redact(text)) assert.Equal(t, text, delivery.NewRedactor(nil).Redact(text)) for _, tgt := range []database.Target{ {Type: database.TargetTypeLog}, {Type: database.TargetTypeDatabase}, {Type: database.TargetTypeSlack, Config: "not json"}, {Type: database.TargetTypeHTTP, Config: ""}, } { assert.Equal( t, text, delivery.NewRedactor(&tgt).Redact(text), tgt.Type, ) } }