All checks were successful
check / check (push) Successful in 4m16s
Expanding a delivery on the event log page now shows each recorded attempt: attempt number, outcome, status code, duration, error and response body. Previously a failure rendered as "target: failed" and diagnosing it meant opening the per-webhook SQLite file by hand. The response body is cut by SQLite via substr over a blob cast, the same projection the event body uses, so an oversized stored response never becomes a Go string. The page reports the cut with a marker. Response bodies and errors are remote content, so both go through a new delivery.Redactor that strips the target's own destination URL, path, query and userinfo, plus the values of credential-shaped request headers, before rendering. A cut body goes through RedactCut as well, which drops any tail that is a proper prefix of a secret: the remote chooses the padding in front of a credential it echoes, so it chooses where the cut falls inside that credential. Target configuration keeps reaching the template only as a TargetView. Redactors are built from an unscoped target load. Deleting a target only soft deletes the row while its deliveries survive, and a scoped load would leave exactly those deliveries rendering unredacted. The views the page lists stay scoped. Attempt loading is chunked so the IN clause cannot exceed SQLite's bound-parameter limit, its error is reported rather than discarded, and the page renders at most 20 attempts per delivery, counting what it leaves out. static/css/tailwind.css is regenerated with the repo's pinned tailwindcss for the utility classes the new markup uses.
238 lines
6.2 KiB
Go
238 lines
6.2 KiB
Go
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,
|
|
)
|
|
}
|
|
}
|