diff --git a/caddytest/integration/caddyfile_adapt/log_filters.txt b/caddytest/integration/caddyfile_adapt/log_filters.txt index 5253529f..776fa68d 100644 --- a/caddytest/integration/caddyfile_adapt/log_filters.txt +++ b/caddytest/integration/caddyfile_adapt/log_filters.txt @@ -8,18 +8,21 @@ log { uri query { replace foo REDACTED delete bar + hash baz } request>headers>Authorization replace REDACTED request>headers>Server delete request>headers>Cookie cookie { replace foo REDACTED delete bar + hash baz } request>remote_ip ip_mask { ipv4 24 ipv6 32 } request>headers>Regexp regexp secret REDACTED + request>headers>Hash hash } } } @@ -52,10 +55,17 @@ log { { "name": "bar", "type": "delete" + }, + { + "name": "baz", + "type": "hash" } ], "filter": "cookie" }, + "request\u003eheaders\u003eHash": { + "filter": "hash" + }, "request\u003eheaders\u003eRegexp": { "filter": "regexp", "regexp": "secret", @@ -79,6 +89,10 @@ log { { "parameter": "bar", "type": "delete" + }, + { + "parameter": "baz", + "type": "hash" } ], "filter": "query" diff --git a/modules/logging/filters.go b/modules/logging/filters.go index ded08acb..af64cc4b 100644 --- a/modules/logging/filters.go +++ b/modules/logging/filters.go @@ -15,7 +15,9 @@ package logging import ( + "crypto/sha256" "errors" + "fmt" "net" "net/http" "net/url" @@ -34,6 +36,7 @@ func init() { caddy.RegisterModule(QueryFilter{}) caddy.RegisterModule(CookieFilter{}) caddy.RegisterModule(RegexpFilter{}) + caddy.RegisterModule(HashFilter{}) } // LogFieldFilter can filter (or manipulate) @@ -65,6 +68,35 @@ func (DeleteFilter) Filter(in zapcore.Field) zapcore.Field { return in } +// hash returns the first 4 bytes of the SHA-256 hash of the given data as hexadecimal +func hash(s string) string { + return fmt.Sprintf("%.4x", sha256.Sum256([]byte(s))) +} + +// HashFilter is a Caddy log field filter that +// replaces the field with the initial 4 bytes of the SHA-256 hash of the content. +type HashFilter struct { +} + +// CaddyModule returns the Caddy module information. +func (HashFilter) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "caddy.logging.encoders.filter.hash", + New: func() caddy.Module { return new(HashFilter) }, + } +} + +// UnmarshalCaddyfile sets up the module from Caddyfile tokens. +func (f *HashFilter) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { + return nil +} + +// Filter filters the input field with the replacement value. +func (f *HashFilter) Filter(in zapcore.Field) zapcore.Field { + in.String = hash(in.String) + return in +} + // ReplaceFilter is a Caddy log field filter that // replaces the field with the indicated string. type ReplaceFilter struct { @@ -195,15 +227,19 @@ func (m IPMaskFilter) Filter(in zapcore.Field) zapcore.Field { type filterAction string const ( - // Replace value(s) of query parameter(s). + // Replace value(s). replaceAction filterAction = "replace" - // Delete query parameter(s). + + // Hash value(s). + hashAction filterAction = "hash" + + // Delete. deleteAction filterAction = "delete" ) func (a filterAction) IsValid() error { switch a { - case replaceAction, deleteAction: + case replaceAction, deleteAction, hashAction: return nil } @@ -211,7 +247,7 @@ func (a filterAction) IsValid() error { } type queryFilterAction struct { - // `replace` to replace the value(s) associated with the parameter(s) or `delete` to remove them entirely. + // `replace` to replace the value(s) associated with the parameter(s), `hash` to replace them with the 4 initial bytes of the SHA-256 of their content or `delete` to remove them entirely. Type filterAction `json:"type"` // The name of the query parameter. @@ -224,9 +260,9 @@ type queryFilterAction struct { // QueryFilter is a Caddy log field filter that filters // query parameters from a URL. // -// This filter updates the logged URL string to remove or replace query -// parameters containing sensitive data. For instance, it can be used -// to redact any kind of secrets which were passed as query parameters, +// This filter updates the logged URL string to remove, replace or hash +// query parameters containing sensitive data. For instance, it can be +// used to redact any kind of secrets which were passed as query parameters, // such as OAuth access tokens, session IDs, magic link tokens, etc. type QueryFilter struct { // A list of actions to apply to the query parameters of the URL. @@ -271,6 +307,14 @@ func (m *QueryFilter) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } qfa.Value = d.Val() + case "hash": + if !d.NextArg() { + return d.ArgErr() + } + + qfa.Type = hashAction + qfa.Parameter = d.Val() + case "delete": if !d.NextArg() { return d.ArgErr() @@ -304,6 +348,11 @@ func (m QueryFilter) Filter(in zapcore.Field) zapcore.Field { q[a.Parameter][i] = a.Value } + case hashAction: + for i := range q[a.Parameter] { + q[a.Parameter][i] = hash(a.Value) + } + case deleteAction: q.Del(a.Parameter) } @@ -316,7 +365,7 @@ func (m QueryFilter) Filter(in zapcore.Field) zapcore.Field { } type cookieFilterAction struct { - // `replace` to replace the value of the cookie or `delete` to remove it entirely. + // `replace` to replace the value of the cookie, `hash` to replace it with the 4 initial bytes of the SHA-256 of its content or `delete` to remove it entirely. Type filterAction `json:"type"` // The name of the cookie. @@ -330,7 +379,7 @@ type cookieFilterAction struct { // cookies. // // This filter updates the logged HTTP header string -// to remove or replace cookies containing sensitive data. For instance, +// to remove, replace or hash cookies containing sensitive data. For instance, // it can be used to redact any kind of secrets, such as session IDs. // // If several actions are configured for the same cookie name, only the first @@ -378,6 +427,14 @@ func (m *CookieFilter) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } cfa.Value = d.Val() + case "hash": + if !d.NextArg() { + return d.ArgErr() + } + + cfa.Type = hashAction + cfa.Name = d.Val() + case "delete": if !d.NextArg() { return d.ArgErr() @@ -415,6 +472,11 @@ OUTER: transformedRequest.AddCookie(c) continue OUTER + case hashAction: + c.Value = hash(c.Value) + transformedRequest.AddCookie(c) + continue OUTER + case deleteAction: continue OUTER } diff --git a/modules/logging/filters_test.go b/modules/logging/filters_test.go index 99289c3e..ecf1d877 100644 --- a/modules/logging/filters_test.go +++ b/modules/logging/filters_test.go @@ -13,14 +13,15 @@ func TestQueryFilter(t *testing.T) { {replaceAction, "notexist", "REDACTED"}, {deleteAction, "bar", ""}, {deleteAction, "notexist", ""}, + {hashAction, "hash", ""}, }} if f.Validate() != nil { t.Fatalf("the filter must be valid") } - out := f.Filter(zapcore.Field{String: "/path?foo=a&foo=b&bar=c&bar=d&baz=e"}) - if out.String != "/path?baz=e&foo=REDACTED&foo=REDACTED" { + out := f.Filter(zapcore.Field{String: "/path?foo=a&foo=b&bar=c&bar=d&baz=e&hash=hashed"}) + if out.String != "/path?baz=e&foo=REDACTED&foo=REDACTED&hash=e3b0c442" { t.Fatalf("query parameters have not been filtered: %s", out.String) } } @@ -45,10 +46,11 @@ func TestCookieFilter(t *testing.T) { f := CookieFilter{[]cookieFilterAction{ {replaceAction, "foo", "REDACTED"}, {deleteAction, "bar", ""}, + {hashAction, "hash", ""}, }} - out := f.Filter(zapcore.Field{String: "foo=a; foo=b; bar=c; bar=d; baz=e"}) - if out.String != "foo=REDACTED; foo=REDACTED; baz=e" { + out := f.Filter(zapcore.Field{String: "foo=a; foo=b; bar=c; bar=d; baz=e; hash=hashed"}) + if out.String != "foo=REDACTED; foo=REDACTED; baz=e; hash=1a06df82" { t.Fatalf("cookies have not been filtered: %s", out.String) } } @@ -78,3 +80,12 @@ func TestRegexpFilter(t *testing.T) { t.Fatalf("field has not been filtered: %s", out.String) } } + +func TestHashFilter(t *testing.T) { + f := HashFilter{} + + out := f.Filter(zapcore.Field{String: "foo"}) + if out.String != "2c26b46b" { + t.Fatalf("field has not been filtered: %s", out.String) + } +}