From a837e1bfad8f8f15c791da731db46a6ccc6e725a Mon Sep 17 00:00:00 2001 From: favonia Date: Sun, 7 Jul 2024 09:56:07 +0300 Subject: [PATCH] feat(pp): add redaction facilities --- cmd/ddns/ddns.go | 2 +- internal/api/cloudflare.go | 39 +++++++++++--- internal/config/config_print.go | 12 +++-- internal/config/config_print_test.go | 7 +++ internal/config/config_read.go | 22 ++++++-- internal/config/env_base.go | 23 +++++++++ internal/mocks/mock_pp.go | 76 ++++++++++++++++++++++++++++ internal/pp/base.go | 14 +++++ internal/pp/fmt.go | 27 +++++++--- internal/pp/redaction.go | 38 ++++++++++++++ 10 files changed, 235 insertions(+), 25 deletions(-) create mode 100644 internal/pp/redaction.go diff --git a/cmd/ddns/ddns.go b/cmd/ddns/ddns.go index 6be4c6b2..af81c99a 100644 --- a/cmd/ddns/ddns.go +++ b/cmd/ddns/ddns.go @@ -69,7 +69,7 @@ func main() { func realMain() int { //nolint:funlen ppfmt := pp.New(os.Stdout) - if !config.ReadEmoji("EMOJI", &ppfmt) || !config.ReadQuiet("QUIET", &ppfmt) { + if !config.InitializePP(&ppfmt) { ppfmt.Infof(pp.EmojiUserError, "Bye!") return 1 } diff --git a/internal/api/cloudflare.go b/internal/api/cloudflare.go index 871f0941..98ba698a 100644 --- a/internal/api/cloudflare.go +++ b/internal/api/cloudflare.go @@ -196,7 +196,11 @@ func (h *CloudflareHandle) ListRecords(ctx context.Context, ppfmt pp.PP, Type: ipNet.RecordType(), }) if err != nil { - ppfmt.Warningf(pp.EmojiError, "Failed to retrieve records of %q: %v", domain.Describe(), err) + ppfmt.Warningf(pp.EmojiError, "Failed to retrieve records of %q: %s", + pp.Redact(ppfmt, pp.Domains, domain.Describe(), "(redacted)"), + // Redact error messages out of fear + pp.Redact(ppfmt, pp.Domains|pp.IPs|pp.DNSResourceIDs, err.Error(), "(error message redacted)"), + ) return nil, false, false } @@ -204,7 +208,12 @@ func (h *CloudflareHandle) ListRecords(ctx context.Context, ppfmt pp.PP, for i := range rs { rmap[rs[i].ID], err = netip.ParseAddr(rs[i].Content) if err != nil { - ppfmt.Warningf(pp.EmojiImpossible, "Failed to parse the IP address in records of %q: %v", domain.Describe(), err) + ppfmt.Warningf(pp.EmojiImpossible, + "Failed to parse the IP address in records of %q: %s", + pp.Redact(ppfmt, pp.Domains, domain.Describe(), "(redacted)"), + // Redact error messages out of fear + pp.Redact(ppfmt, pp.Domains|pp.IPs|pp.DNSResourceIDs, err.Error(), "(error message redacted)"), + ) return nil, false, false } } @@ -224,8 +233,13 @@ func (h *CloudflareHandle) DeleteRecord(ctx context.Context, ppfmt pp.PP, } if err := h.cf.DeleteDNSRecord(ctx, cloudflare.ZoneIdentifier(zone), id); err != nil { - ppfmt.Warningf(pp.EmojiError, "Failed to delete a stale %s record of %q (ID: %s): %v", - ipNet.RecordType(), domain.Describe(), id, err) + ppfmt.Warningf(pp.EmojiError, "Failed to delete a stale %s record of %q (ID: %s): %s", + ipNet.RecordType(), + pp.Redact(ppfmt, pp.Domains, domain.Describe(), "(redacted)"), + pp.Redact(ppfmt, pp.DNSResourceIDs, id, "redacted"), + // Redact error messages out of fear + pp.Redact(ppfmt, pp.Domains|pp.IPs|pp.DNSResourceIDs, err.Error(), "(error message redacted)"), + ) h.cache.listRecords[ipNet].Delete(domain.DNSNameASCII()) @@ -255,8 +269,13 @@ func (h *CloudflareHandle) UpdateRecord(ctx context.Context, ppfmt pp.PP, } if _, err := h.cf.UpdateDNSRecord(ctx, cloudflare.ZoneIdentifier(zone), params); err != nil { - ppfmt.Warningf(pp.EmojiError, "Failed to update a stale %s record of %q (ID: %s): %v", - ipNet.RecordType(), domain.Describe(), id, err) + ppfmt.Warningf(pp.EmojiError, "Failed to update a stale %s record of %q (ID: %s): %s", + ipNet.RecordType(), + pp.Redact(ppfmt, pp.Domains, domain.Describe(), "(redacted)"), + pp.Redact(ppfmt, pp.DNSResourceIDs, id, "redacted"), + // Redact error messages out of fear + pp.Redact(ppfmt, pp.Domains|pp.IPs|pp.DNSResourceIDs, err.Error(), "(error message redacted)"), + ) h.cache.listRecords[ipNet].Delete(domain.DNSNameASCII()) @@ -291,8 +310,12 @@ func (h *CloudflareHandle) CreateRecord(ctx context.Context, ppfmt pp.PP, res, err := h.cf.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(zone), params) if err != nil { - ppfmt.Warningf(pp.EmojiError, "Failed to add a new %s record of %q: %v", - ipNet.RecordType(), domain.Describe(), err) + ppfmt.Warningf(pp.EmojiError, "Failed to add a new %s record of %q: %s", + ipNet.RecordType(), + pp.Redact(ppfmt, pp.Domains, domain.Describe(), "(redacted)"), + // Redact error messages out of fear + pp.Redact(ppfmt, pp.Domains|pp.IPs|pp.DNSResourceIDs, err.Error(), "(error message redacted)"), + ) h.cache.listRecords[ipNet].Delete(domain.DNSNameASCII()) diff --git a/internal/config/config_print.go b/internal/config/config_print.go index 0b662228..14f3abd3 100644 --- a/internal/config/config_print.go +++ b/internal/config/config_print.go @@ -70,11 +70,13 @@ func (c *Config) Print(ppfmt pp.PP) { section("Domains and IP providers:") if c.Provider[ipnet.IP4] != nil { - item("IPv4 domains:", "%s", describeDomains(c.Domains[ipnet.IP4])) + item("IPv4 domains:", "%s", + pp.Redact(ppfmt, pp.Domains, describeDomains(c.Domains[ipnet.IP4]), "(redacted)")) item("IPv4 provider:", "%s", provider.Name(c.Provider[ipnet.IP4])) } if c.Provider[ipnet.IP6] != nil { - item("IPv6 domains:", "%s", describeDomains(c.Domains[ipnet.IP6])) + item("IPv6 domains:", "%s", + pp.Redact(ppfmt, pp.Domains, describeDomains(c.Domains[ipnet.IP6]), "(redacted)")) item("IPv6 provider:", "%s", provider.Name(c.Provider[ipnet.IP6])) } @@ -89,8 +91,10 @@ func (c *Config) Print(ppfmt pp.PP) { item("TTL:", "%s", c.TTL.Describe()) { _, inverseMap := getInverseMap(c.Proxied) - item("Proxied domains:", "%s", describeDomains(inverseMap[true])) - item("Unproxied domains:", "%s", describeDomains(inverseMap[false])) + item("Proxied domains:", "%s", + pp.Redact(ppfmt, pp.Domains, describeDomains(inverseMap[true]), "(redacted)")) + item("Unproxied domains:", "%s", + pp.Redact(ppfmt, pp.Domains, describeDomains(inverseMap[false]), "(redacted)")) } item("Record comment:", "%s", describeComment(c.RecordComment)) diff --git a/internal/config/config_print_test.go b/internal/config/config_print_test.go index ad156079..4867149c 100644 --- a/internal/config/config_print_test.go +++ b/internal/config/config_print_test.go @@ -32,8 +32,10 @@ func TestPrintDefault(t *testing.T) { mockPP.EXPECT().IncIndent().Return(mockPP), mockPP.EXPECT().IncIndent().Return(innerMockPP), mockPP.EXPECT().Infof(pp.EmojiConfig, "Domains and IP providers:"), + mockPP.EXPECT().ShouldRedact(pp.PrivateDataTypeDomains).Return(false), printItem(innerMockPP, "IPv4 domains:", "(none)"), printItem(innerMockPP, "IPv4 provider:", "cloudflare.trace"), + mockPP.EXPECT().ShouldRedact(pp.PrivateDataTypeDomains).Return(false), printItem(innerMockPP, "IPv6 domains:", "(none)"), printItem(innerMockPP, "IPv6 provider:", "cloudflare.trace"), mockPP.EXPECT().Infof(pp.EmojiConfig, "Scheduling:"), @@ -44,6 +46,7 @@ func TestPrintDefault(t *testing.T) { printItem(innerMockPP, "Cache expiration:", "6h0m0s"), mockPP.EXPECT().Infof(pp.EmojiConfig, "Parameters of new DNS records:"), printItem(innerMockPP, "TTL:", "1 (auto)"), + mockPP.EXPECT().ShouldRedact(pp.PrivateDataTypeDomains).Return(false), printItem(innerMockPP, "Proxied domains:", "(none)"), printItem(innerMockPP, "Unproxied domains:", "(none)"), printItem(innerMockPP, "Record comment:", "(empty)"), @@ -68,8 +71,10 @@ func TestPrintValues(t *testing.T) { mockPP.EXPECT().IncIndent().Return(mockPP), mockPP.EXPECT().IncIndent().Return(innerMockPP), mockPP.EXPECT().Infof(pp.EmojiConfig, "Domains and IP providers:"), + mockPP.EXPECT().ShouldRedact(pp.PrivateDataTypeDomains).Return(false), printItem(innerMockPP, "IPv4 domains:", "test4.org, *.test4.org"), printItem(innerMockPP, "IPv4 provider:", "cloudflare.trace"), + mockPP.EXPECT().ShouldRedact(pp.PrivateDataTypeDomains).Return(false), printItem(innerMockPP, "IPv6 domains:", "test6.org, *.test6.org"), printItem(innerMockPP, "IPv6 provider:", "cloudflare.trace"), mockPP.EXPECT().Infof(pp.EmojiConfig, "Scheduling:"), @@ -80,6 +85,7 @@ func TestPrintValues(t *testing.T) { printItem(innerMockPP, "Cache expiration:", "6h0m0s"), mockPP.EXPECT().Infof(pp.EmojiConfig, "Parameters of new DNS records:"), printItem(innerMockPP, "TTL:", "30000"), + mockPP.EXPECT().ShouldRedact(pp.PrivateDataTypeDomains).Return(false), printItem(innerMockPP, "Proxied domains:", "a, b"), printItem(innerMockPP, "Unproxied domains:", "c, d"), printItem(innerMockPP, "Record comment:", "\"Created by Cloudflare DDNS\""), @@ -146,6 +152,7 @@ func TestPrintEmpty(t *testing.T) { printItem(innerMockPP, "Cache expiration:", "0s"), mockPP.EXPECT().Infof(pp.EmojiConfig, "Parameters of new DNS records:"), printItem(innerMockPP, "TTL:", "0"), + mockPP.EXPECT().ShouldRedact(pp.PrivateDataTypeDomains).Return(false), printItem(innerMockPP, "Proxied domains:", "(none)"), printItem(innerMockPP, "Unproxied domains:", "(none)"), printItem(innerMockPP, "Record comment:", "(empty)"), diff --git a/internal/config/config_read.go b/internal/config/config_read.go index c78c26a8..e3521a70 100644 --- a/internal/config/config_read.go +++ b/internal/config/config_read.go @@ -8,6 +8,10 @@ import ( "github.com/favonia/cloudflare-ddns/internal/provider" ) +func InitializePP(ppfmt *pp.PP) bool { + return ReadEmoji("EMOJI", ppfmt) && ReadQuiet("QUIET", ppfmt) && ReadRedaction("LOG_REDACTION", ppfmt) +} + // ReadEnv calls the relevant readers to read all relevant environment variables except TZ // and update relevant fields. One should subsequently call [Config.NormalizeConfig] // to maintain invariants across different fields. @@ -112,9 +116,11 @@ func (c *Config) NormalizeConfig(ppfmt pp.PP) bool { continue } - ppfmt.Warningf(pp.EmojiUserWarning, - "Domain %q is ignored because it is only for %s but %s is disabled", - domain.Describe(), ipNet.Describe(), ipNet.Describe()) + if !ppfmt.ShouldRedact(pp.Domains) { + ppfmt.Warningf(pp.EmojiUserWarning, + "Domain %q is ignored because it is only for %s but %s is disabled", + domain.Describe(), ipNet.Describe(), ipNet.Describe()) + } } } @@ -123,8 +129,16 @@ func (c *Config) NormalizeConfig(ppfmt pp.PP) bool { if !ok { return false } + allProxied := true for dom := range activeDomainSet { - proxiedMap[dom] = proxiedPred(dom) + proxied := proxiedPred(dom) + allProxied = allProxied && proxied + proxiedMap[dom] = proxied + } + // Warn about LOG_REDACTION=ip and PROXIED=false + if ppfmt.ShouldRedact(pp.IPs) && !allProxied { + ppfmt.Warningf(pp.EmojiUserWarning, + "Some domains are not proxied by Cloudflare; their DNS records can leak IP addresses") } // Part 3: override the old values diff --git a/internal/config/env_base.go b/internal/config/env_base.go index 970ee609..3e2621b2 100644 --- a/internal/config/env_base.go +++ b/internal/config/env_base.go @@ -81,6 +81,29 @@ func ReadQuiet(key string, ppfmt *pp.PP) bool { return true } +// ReadRedaction reads an environment variable as the redaction mask. +func ReadRedaction(key string, ppfmt *pp.PP) bool { + valRedaction := Getenv(key) + if valRedaction == "" { + return true + } + + switch valRedaction { + case "min": + *ppfmt = (*ppfmt).SetRedactMask(pp.RedactNone) + return true + case "token": + *ppfmt = (*ppfmt).SetRedactMask(pp.RedactTokens) + return true + case "max": + *ppfmt = (*ppfmt).SetRedactMask(pp.RedactMaximum) + return true + default: + (*ppfmt).Errorf(pp.EmojiUserError, "%s (%q) is not a supported redaction mode", key, valRedaction) + return false + } +} + // ReadBool reads an environment variable as a boolean value. func ReadBool(ppfmt pp.PP, key string, field *bool) bool { val := Getenv(key) diff --git a/internal/mocks/mock_pp.go b/internal/mocks/mock_pp.go index 14e8e199..d6bcbbba 100644 --- a/internal/mocks/mock_pp.go +++ b/internal/mocks/mock_pp.go @@ -275,6 +275,44 @@ func (c *PPSetEmojiCall) DoAndReturn(f func(bool) pp.PP) *PPSetEmojiCall { return c } +// SetRedactMask mocks base method. +func (m *MockPP) SetRedactMask(arg0 pp.RedactMask) pp.PP { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetRedactMask", arg0) + ret0, _ := ret[0].(pp.PP) + return ret0 +} + +// SetRedactMask indicates an expected call of SetRedactMask. +func (mr *MockPPMockRecorder) SetRedactMask(arg0 any) *PPSetRedactMaskCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetRedactMask", reflect.TypeOf((*MockPP)(nil).SetRedactMask), arg0) + return &PPSetRedactMaskCall{Call: call} +} + +// PPSetRedactMaskCall wrap *gomock.Call +type PPSetRedactMaskCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *PPSetRedactMaskCall) Return(arg0 pp.PP) *PPSetRedactMaskCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *PPSetRedactMaskCall) Do(f func(pp.RedactMask) pp.PP) *PPSetRedactMaskCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *PPSetRedactMaskCall) DoAndReturn(f func(pp.RedactMask) pp.PP) *PPSetRedactMaskCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + // SetVerbosity mocks base method. func (m *MockPP) SetVerbosity(arg0 pp.Verbosity) pp.PP { m.ctrl.T.Helper() @@ -313,6 +351,44 @@ func (c *PPSetVerbosityCall) DoAndReturn(f func(pp.Verbosity) pp.PP) *PPSetVerbo return c } +// ShouldRedact mocks base method. +func (m *MockPP) ShouldRedact(arg0 pp.PrivateDataType) bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ShouldRedact", arg0) + ret0, _ := ret[0].(bool) + return ret0 +} + +// ShouldRedact indicates an expected call of ShouldRedact. +func (mr *MockPPMockRecorder) ShouldRedact(arg0 any) *PPShouldRedactCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ShouldRedact", reflect.TypeOf((*MockPP)(nil).ShouldRedact), arg0) + return &PPShouldRedactCall{Call: call} +} + +// PPShouldRedactCall wrap *gomock.Call +type PPShouldRedactCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *PPShouldRedactCall) Return(arg0 bool) *PPShouldRedactCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *PPShouldRedactCall) Do(f func(pp.PrivateDataType) bool) *PPShouldRedactCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *PPShouldRedactCall) DoAndReturn(f func(pp.PrivateDataType) bool) *PPShouldRedactCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + // Warningf mocks base method. func (m *MockPP) Warningf(arg0 pp.Emoji, arg1 string, arg2 ...any) { m.ctrl.T.Helper() diff --git a/internal/pp/base.go b/internal/pp/base.go index d74873fb..74995d7e 100644 --- a/internal/pp/base.go +++ b/internal/pp/base.go @@ -14,6 +14,12 @@ type PP interface { // IsEnabledFor checks whether a message of a certain level will be displayed. IsEnabledFor(v Verbosity) bool + // SetRedactMask sets the mask to determine the redaction. + SetRedactMask(m RedactMask) PP + + // ShouldRedact(t) returns whether data of type t should be redacted. + ShouldRedact(t PrivateDataType) bool + // IncIndent returns a new pretty-printer with more indentation. IncIndent() PP @@ -29,3 +35,11 @@ type PP interface { // Errorf formats and prints a message at the error level. Errorf(emoji Emoji, format string, args ...any) } + +func Redact(pp PP, t PrivateDataType, orig string, redacted string) string { + if pp.ShouldRedact(t) { + return redacted + } else { + return orig + } +} diff --git a/internal/pp/fmt.go b/internal/pp/fmt.go index 4b957091..5ee65622 100644 --- a/internal/pp/fmt.go +++ b/internal/pp/fmt.go @@ -7,19 +7,21 @@ import ( ) type formatter struct { - writer io.Writer - emoji bool - indent int - verbosity Verbosity + writer io.Writer + emoji bool + indent int + verbosity Verbosity + redactionMask RedactMask } // New creates a new pretty printer. func New(writer io.Writer) PP { return formatter{ - writer: writer, - emoji: true, - indent: 0, - verbosity: DefaultVerbosity, + writer: writer, + emoji: true, + indent: 0, + verbosity: DefaultVerbosity, + redactionMask: DefaultRedactMask, } } @@ -37,6 +39,15 @@ func (f formatter) IsEnabledFor(v Verbosity) bool { return v >= f.verbosity } +func (f formatter) SetRedactMask(m RedactMask) PP { + f.redactionMask = m + return f +} + +func (f formatter) ShouldRedact(t PrivateDataType) bool { + return f.redactionMask&RedactMask(t) > 0 +} + func (f formatter) IncIndent() PP { f.indent++ return f diff --git a/internal/pp/redaction.go b/internal/pp/redaction.go new file mode 100644 index 00000000..eb5f9214 --- /dev/null +++ b/internal/pp/redaction.go @@ -0,0 +1,38 @@ +package pp + +type PrivateDataType uint32 + +const ( + Tokens PrivateDataType = 1 << iota + // Tokens (special strings that grant access or prove identities). + + IPs + // IP addresses. + + Domains + // Domain names. + + LinuxIDs + // User IDs and group IDs. + + DNSResourceIDs + // IDs of records, zones, etc. that are not tokens. +) + +type RedactMask uint32 + +const ( + RedactNone RedactMask = 0 + // This level reveals everything. + + RedactTokens RedactMask = RedactMask(Tokens) + // This level removes token-like information. + + RedactMaximum RedactMask = ^RedactMask(0) + // This level removes information that cannot be easily + // owned by other people at the same time, including + // token-like information, domain names, IP addresses, + // zone IDs, and record IDs. + + DefaultRedactMask RedactMask = RedactTokens +)