package delivery import ( "net/url" "strings" "sneak.berlin/go/webhooker/internal/database" ) // RedactionMarker stands in for a target credential found in // text the target's remote peer chose. const RedactionMarker = "(redacted)" // Redactor removes one target's own credential material from // text that target's remote peer chose: a delivery response // body, or a delivery error stored before the delivery path // learned to mask the URLs it embeds. // // It matches literally, against strings taken from the // target's stored configuration, so it guesses nothing about // what a secret looks like. That also bounds what it can // promise: it removes the credential this service handed the // remote, and it cannot remove a secret the remote invented. // // The zero Redactor removes nothing, which is what a caller // holding no target for a delivery gets. type Redactor struct { secrets []string } // NewRedactor builds the redactor for one target. func NewRedactor(t *database.Target) Redactor { return Redactor{secrets: targetSecrets(t)} } // Redact replaces every occurrence of the target's credential // material in s. func (r Redactor) Redact(s string) string { if s == "" { return s } for _, secret := range r.secrets { s = strings.ReplaceAll(s, secret, RedactionMarker) } return s } // targetSecrets returns the credential-bearing strings a // target's configuration carries, longest first so that // replacing one never leaves a fragment of a longer one // behind. // // Only the destination URL contributes. Its path, query and // userinfo are the credential for both target types that have // one — an incoming-webhook URL is a bearer token, which is // why MaskURL elides exactly those parts — and they are the // material this service actually sends, so a remote that // echoes the request back echoes them. // // Configured HTTP headers are deliberately not included. // Their values are as often routine as secret (Accept, // User-Agent), and redacting them from remote text would // replace ordinary response content with the marker. A remote // that echoes an Authorization header into its response body // is therefore not covered. func targetSecrets(t *database.Target) []string { if t == nil { return nil } switch t.Type { case database.TargetTypeSlack: cfg, err := parseSlackConfig(t.Config) if err != nil { return nil } return urlSecrets(cfg.WebhookURL) case database.TargetTypeHTTP: cfg, err := parseHTTPConfig(t.Config) if err != nil { return nil } return urlSecrets(cfg.URL) case database.TargetTypeDatabase, database.TargetTypeLog: // Neither has a destination URL, so neither has // anything to redact. return nil default: return nil } } // urlSecrets returns the substrings of a destination URL that // must not survive into a rendered page: the whole URL, the // parts of it MaskURL elides, and any userinfo. // // No length floor is applied to the path. A short path is // treated as a credential exactly like a long one, because // the field takes an arbitrary URL and no segment can be // assumed non-secret — the same rule MaskURL applies. func urlSecrets(raw string) []string { raw = strings.TrimSpace(raw) if raw == "" { return nil } secrets := []string{raw} parsed, err := url.Parse(raw) if err != nil { return secrets } if parsed.Path != "" && parsed.Path != "/" { requestURI := parsed.RequestURI() secrets = append(secrets, requestURI) if escaped := parsed.EscapedPath(); escaped != requestURI { secrets = append(secrets, escaped) } } if parsed.User != nil { secrets = append(secrets, parsed.User.String()) if pw, ok := parsed.User.Password(); ok && pw != "" { secrets = append(secrets, pw) } } return secrets }