From b3f1a866f086d4f3f6d40269db1ab8925cf4086c Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 29 May 2026 23:00:26 -0400 Subject: [PATCH 1/6] feat(dns/record): canonical record/snapshot/portfolio types + Validate --- dns/record/record.go | 89 +++++++++++++++++++++++++++++++++ dns/record/record_test.go | 101 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 dns/record/record.go create mode 100644 dns/record/record_test.go diff --git a/dns/record/record.go b/dns/record/record.go new file mode 100644 index 00000000..334015f4 --- /dev/null +++ b/dns/record/record.go @@ -0,0 +1,89 @@ +package record + +import "fmt" + +// Record is the canonical, provider-neutral DNS record type. +// The Value field uses json:"value" to match scenario-88's fixture shape +// (fixture records use "value", NOT "data"). +// +// knownTypes is advisory only — a portfolio is a SNAPSHOT of whatever the +// provider returns, so unknown/newer types (PTR, HTTPS, SVCB, TLSA, DNAME, …) +// MUST be preserved, never rejected. KnownType drives an optional warning only. +type Record struct { + Type string `json:"type"` + Name string `json:"name"` + Value string `json:"value"` + TTL int `json:"ttl"` + Priority *int `json:"priority,omitempty"` + Port *int `json:"port,omitempty"` + Weight *int `json:"weight,omitempty"` + Flags *int `json:"flags,omitempty"` + Tag string `json:"tag,omitempty"` +} + +// Snapshot is a flat representation of one DNS zone at a point in time. +// One snapshot == one zone (matches scenario-88 fixture shape: flat, no zones[]). +type Snapshot struct { + ID string `json:"id"` + Provider string `json:"provider"` + Domain string `json:"domain"` + Authority map[string]any `json:"authority,omitempty"` + Records []Record `json:"records"` + Extra map[string]any `json:"extra,omitempty"` +} + +// Portfolio is the top-level export envelope for a canonical DNS catalog. +// Matches the "workflow.dns-portfolio.export.v1" schema used by scenario 88. +type Portfolio struct { + Schema string `json:"schema"` + Sanitized bool `json:"sanitized,omitempty"` + Snapshots []Snapshot `json:"snapshots"` +} + +// SchemaV1 is the canonical schema identifier for a dns-portfolio export. +const SchemaV1 = "workflow.dns-portfolio.export.v1" + +var knownTypes = map[string]bool{ + "A": true, + "AAAA": true, + "CNAME": true, + "MX": true, + "TXT": true, + "NS": true, + "SRV": true, + "CAA": true, + "SOA": true, +} + +// KnownType reports whether t is a well-known DNS record type. +// Advisory only — unknown types are valid in a portfolio snapshot. +func KnownType(t string) bool { return knownTypes[t] } + +// Validate enforces structural invariants on the Portfolio. +// It does NOT whitelist record types — unknown types (PTR, HTTPS, SVCB, …) +// are preserved. Only empty type and negative TTL are rejected. +func (p *Portfolio) Validate() error { + if p.Schema != SchemaV1 { + return fmt.Errorf("record: schema=%q want %q", p.Schema, SchemaV1) + } + for _, s := range p.Snapshots { + if s.Provider == "" || s.Domain == "" { + return fmt.Errorf("record: snapshot %q missing provider/domain", s.ID) + } + for _, r := range s.Records { + if r.Type == "" { + return fmt.Errorf("record: empty type in %s/%s", s.Domain, r.Name) + } + if r.TTL < 0 { + return fmt.Errorf("record: negative ttl in %s/%s", s.Domain, r.Name) + } + } + } + return nil +} + +// Equal reports whether two records are canonically equal, keying on +// (Type, Name, Value, TTL) and ignoring extra/optional fields like Priority. +func Equal(a, b Record) bool { + return a.Type == b.Type && a.Name == b.Name && a.Value == b.Value && a.TTL == b.TTL +} diff --git a/dns/record/record_test.go b/dns/record/record_test.go new file mode 100644 index 00000000..1554520c --- /dev/null +++ b/dns/record/record_test.go @@ -0,0 +1,101 @@ +package record_test + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/dns/record" +) + +func TestRecordRoundTripMatchesFixtureSubset(t *testing.T) { + p := record.Portfolio{ + Schema: "workflow.dns-portfolio.export.v1", + Snapshots: []record.Snapshot{{ + ID: "do-example", Provider: "digitalocean", Domain: "example.com", + Records: []record.Record{{Type: "A", Name: "@", Value: "192.0.2.10", TTL: 900}}, + }}, + } + b, err := json.Marshal(p) + if err != nil { + t.Fatal(err) + } + // record serializes "value" not "data"; snapshot is flat (no zones[]) + if !strings.Contains(string(b), `"value":"192.0.2.10"`) { + t.Fatalf("want value field, got %s", b) + } + if strings.Contains(string(b), `"zones"`) { + t.Fatalf("snapshot must be flat, no zones[]: %s", b) + } +} + +func TestValidateRejectsBadRecords(t *testing.T) { + // empty type → error + p := record.Portfolio{ + Schema: record.SchemaV1, + Snapshots: []record.Snapshot{{ + Provider: "digitalocean", Domain: "example.com", + Records: []record.Record{{Type: "", Name: "@", Value: "1.2.3.4", TTL: 300}}, + }}, + } + if err := p.Validate(); err == nil { + t.Fatal("expected error for empty record type; got nil") + } + + // negative TTL → error + p2 := record.Portfolio{ + Schema: record.SchemaV1, + Snapshots: []record.Snapshot{{ + Provider: "digitalocean", Domain: "example.com", + Records: []record.Record{{Type: "A", Name: "@", Value: "1.2.3.4", TTL: -1}}, + }}, + } + if err := p2.Validate(); err == nil { + t.Fatal("expected error for negative TTL; got nil") + } + + // unknown type PTR → NO error (open-set, preserved) + p3 := record.Portfolio{ + Schema: record.SchemaV1, + Snapshots: []record.Snapshot{{ + Provider: "digitalocean", Domain: "example.com", + Records: []record.Record{{Type: "PTR", Name: "@", Value: "1.2.3.4.in-addr.arpa.", TTL: 300}}, + }}, + } + if err := p3.Validate(); err != nil { + t.Fatalf("PTR record should be preserved (unknown type OK); got error: %v", err) + } + + // HTTPS type → NO error + p4 := record.Portfolio{ + Schema: record.SchemaV1, + Snapshots: []record.Snapshot{{ + Provider: "digitalocean", Domain: "example.com", + Records: []record.Record{{Type: "HTTPS", Name: "@", Value: "1 . alpn=h2", TTL: 300}}, + }}, + } + if err := p4.Validate(); err != nil { + t.Fatalf("HTTPS record should be preserved (unknown type OK); got error: %v", err) + } +} + +func TestEqualIgnoresExtra(t *testing.T) { + a := record.Record{Type: "A", Name: "@", Value: "1.2.3.4", TTL: 300} + b := record.Record{Type: "A", Name: "@", Value: "1.2.3.4", TTL: 300} + if !record.Equal(a, b) { + t.Fatal("identical records should be Equal") + } + + // extra fields (Priority) differ but core (type,name,value,ttl) same → Equal + pri := 10 + c := record.Record{Type: "A", Name: "@", Value: "1.2.3.4", TTL: 300, Priority: &pri} + if !record.Equal(a, c) { + t.Fatal("records differing only in Priority should be Equal (Equal ignores extra fields)") + } + + // different value → not Equal + d := record.Record{Type: "A", Name: "@", Value: "5.6.7.8", TTL: 300} + if record.Equal(a, d) { + t.Fatal("records with different Value should not be Equal") + } +} From 2e0200d8f5fce176bd7442244d307888d876f690 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 29 May 2026 23:01:11 -0400 Subject: [PATCH 2/6] =?UTF-8?q?feat(dns/record):=20canonicalize=20Resource?= =?UTF-8?q?State[]=20=E2=86=92=20Portfolio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dns/record/canonicalize.go | 128 ++++++++++++++++++++++++++++++++ dns/record/canonicalize_test.go | 92 +++++++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 dns/record/canonicalize.go create mode 100644 dns/record/canonicalize_test.go diff --git a/dns/record/canonicalize.go b/dns/record/canonicalize.go new file mode 100644 index 00000000..53cdd026 --- /dev/null +++ b/dns/record/canonicalize.go @@ -0,0 +1,128 @@ +package record + +import "github.com/GoCodeAlone/workflow/interfaces" + +// FromResourceStates converts imported IaC state into a canonical Portfolio. +// Reads each infra.dns ResourceState's records (Outputs preferred, else +// AppliedConfig), renaming provider-specific value keys to the canonical "value". +// +// Provider value-key divergence (verified against provider drivers): +// - DigitalOcean + Cloudflare emit "data" +// - Hover emits "content" (workflow-plugin-hover/internal/drivers/dns.go:538) +// - Namecheap emits "address" +// +// The valueAlias helper resolves the first non-empty of: data → content → address → value. +// Non-infra.dns states are silently skipped. +func FromResourceStates(states []interfaces.ResourceState) Portfolio { + p := Portfolio{Schema: SchemaV1} + for _, st := range states { + if st.Type != "infra.dns" { + continue + } + recs := pickRecords(st.Outputs, st.AppliedConfig) + snap := Snapshot{ + ID: st.ID, + Provider: st.Provider, + Domain: st.ProviderID, + } + // Fall back to AppliedConfig["domain"] if ProviderID is empty. + if snap.Domain == "" { + if d, ok := st.AppliedConfig["domain"].(string); ok { + snap.Domain = d + } + } + for _, raw := range recs { + m, ok := raw.(map[string]any) + if !ok { + continue + } + snap.Records = append(snap.Records, recordFromMap(m)) + } + p.Snapshots = append(p.Snapshots, snap) + } + return p +} + +// pickRecords returns the records slice from Outputs if non-empty, +// otherwise falls back to AppliedConfig. +func pickRecords(outputs, appliedConfig map[string]any) []any { + if recs, ok := outputs["records"].([]any); ok && len(recs) > 0 { + return recs + } + if recs, ok := appliedConfig["records"].([]any); ok { + return recs + } + return nil +} + +// recordFromMap converts a provider record map to a canonical Record. +// Value is resolved by the first-non-empty alias chain: +// "data" → "content" → "address" → "value" +func recordFromMap(m map[string]any) Record { + r := Record{ + Type: stringVal(m, "type"), + Name: stringVal(m, "name"), + Value: valueAlias(m), + TTL: intVal(m, "ttl"), + Tag: stringVal(m, "tag"), + } + if v, ok := m["priority"]; ok { + if n := toInt(v); n != 0 { + r.Priority = &n + } + } + if v, ok := m["port"]; ok { + if n := toInt(v); n != 0 { + r.Port = &n + } + } + if v, ok := m["weight"]; ok { + if n := toInt(v); n != 0 { + r.Weight = &n + } + } + if v, ok := m["flags"]; ok { + if n := toInt(v); n != 0 { + r.Flags = &n + } + } + return r +} + +// valueAlias resolves the canonical record value from provider-specific key names. +// DO/CF use "data", Hover uses "content", Namecheap uses "address"; canonical emits "value". +func valueAlias(m map[string]any) string { + for _, k := range []string{"data", "content", "address", "value"} { + if v, ok := m[k].(string); ok && v != "" { + return v + } + } + return "" +} + +func stringVal(m map[string]any, key string) string { + v, _ := m[key].(string) + return v +} + +func intVal(m map[string]any, key string) int { + v, ok := m[key] + if !ok { + return 0 + } + return toInt(v) +} + +func toInt(v any) int { + switch n := v.(type) { + case int: + return n + case int64: + return int(n) + case float64: + return int(n) + case float32: + return int(n) + } + return 0 +} diff --git a/dns/record/canonicalize_test.go b/dns/record/canonicalize_test.go new file mode 100644 index 00000000..19c0db24 --- /dev/null +++ b/dns/record/canonicalize_test.go @@ -0,0 +1,92 @@ +package record_test + +import ( + "testing" + + "github.com/GoCodeAlone/workflow/dns/record" + "github.com/GoCodeAlone/workflow/interfaces" +) + +func TestFromResourceStatesAliasesValueKey(t *testing.T) { + states := []interfaces.ResourceState{ + { + Type: "infra.dns", + Provider: "digitalocean", + ProviderID: "do.test", + Outputs: map[string]any{ + "records": []any{ + map[string]any{"type": "A", "name": "@", "data": "192.0.2.1", "ttl": 300}, + }, + }, + }, + { + Type: "infra.dns", + Provider: "hover", + ProviderID: "hv.test", + Outputs: map[string]any{ + "records": []any{ + map[string]any{"type": "A", "name": "@", "content": "192.0.2.2", "ttl": 300}, + }, + }, + }, + { + Type: "infra.dns", + Provider: "namecheap", + ProviderID: "nc.test", + Outputs: map[string]any{ + "records": []any{ + map[string]any{"type": "A", "name": "@", "address": "192.0.2.3", "ttl": 300}, + }, + }, + }, + {Type: "infra.droplet", Provider: "digitalocean"}, // skipped + } + p := record.FromResourceStates(states) + if len(p.Snapshots) != 3 { + t.Fatalf("want 3 dns snapshots, got %d", len(p.Snapshots)) + } + for _, s := range p.Snapshots { + if s.Records[0].Value == "" { + t.Fatalf("provider %s: empty Value (alias-map failed)", s.Provider) + } + } +} + +func TestFromResourceStatesSkipsNonDNS(t *testing.T) { + states := []interfaces.ResourceState{ + {Type: "infra.droplet", Provider: "digitalocean", ProviderID: "droplet-1"}, + {Type: "infra.spaces_key", Provider: "digitalocean", ProviderID: "key-1"}, + } + p := record.FromResourceStates(states) + if len(p.Snapshots) != 0 { + t.Fatalf("non-dns states should be skipped; got %d snapshots", len(p.Snapshots)) + } +} + +func TestFromResourceStatesUsesOutputsPreferredOverAppliedConfig(t *testing.T) { + states := []interfaces.ResourceState{ + { + Type: "infra.dns", + Provider: "digitalocean", + ProviderID: "do.test", + Outputs: map[string]any{ + "records": []any{ + map[string]any{"type": "A", "name": "@", "data": "192.0.2.10", "ttl": 300}, + }, + }, + AppliedConfig: map[string]any{ + "records": []any{ + map[string]any{"type": "A", "name": "@", "data": "10.0.0.1", "ttl": 300}, + }, + }, + }, + } + p := record.FromResourceStates(states) + if len(p.Snapshots) != 1 { + t.Fatalf("want 1 snapshot; got %d", len(p.Snapshots)) + } + // Outputs takes priority over AppliedConfig + if p.Snapshots[0].Records[0].Value != "192.0.2.10" { + t.Fatalf("want Outputs value 192.0.2.10; got %s", p.Snapshots[0].Records[0].Value) + } +} From 347a78c1cb76cc928b9baf2c4e44db9a2206ff1a Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 29 May 2026 23:03:47 -0400 Subject: [PATCH 3/6] feat(wfctl): import-all --format portfolio emits canonical export.v1 --- cmd/wfctl/infra_import_all.go | 52 +++++- cmd/wfctl/infra_import_all_format_test.go | 112 ++++++++++++ dns/record/sanitize.go | 204 ++++++++++++++++++++++ 3 files changed, 367 insertions(+), 1 deletion(-) create mode 100644 cmd/wfctl/infra_import_all_format_test.go create mode 100644 dns/record/sanitize.go diff --git a/cmd/wfctl/infra_import_all.go b/cmd/wfctl/infra_import_all.go index ad7ffd5a..342d4543 100644 --- a/cmd/wfctl/infra_import_all.go +++ b/cmd/wfctl/infra_import_all.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/dns/record" "github.com/GoCodeAlone/workflow/interfaces" ) @@ -46,6 +47,8 @@ func runInfraImportAll(args []string) error { fs := flag.NewFlagSet("infra import-all", flag.ContinueOnError) var configFile, envName, providerName, resourceType, pluginDirFlag, outputPath string var dryRun bool + var format string + var sanitize bool fs.StringVar(&configFile, "config", "", "Config file") fs.StringVar(&configFile, "c", "", "Config file (short for --config)") fs.StringVar(&envName, "env", "", "Environment name") @@ -55,6 +58,8 @@ func runInfraImportAll(args []string) error { fs.StringVar(&pluginDirFlag, "plugin-dir", "", "Plugin directory (overrides WFCTL_PLUGIN_DIR and default data/plugins)") fs.StringVar(&outputPath, "output", "", "Optional: dump state-store contents to this file (in addition to the state backend)") fs.StringVar(&outputPath, "o", "", "Output path (short for --output)") + fs.StringVar(&format, "format", "state", "Output format for --output: state|portfolio") + fs.BoolVar(&sanitize, "sanitize", false, "Portfolio only: redact TXT secrets + example-ize public IPs") if err := fs.Parse(args); err != nil { return err } @@ -64,6 +69,12 @@ func runInfraImportAll(args []string) error { if resourceType == "" { return fmt.Errorf("import-all requires --type (e.g. infra.dns)") } + if format != "state" && format != "portfolio" { + return fmt.Errorf("import-all: --format %q is not valid; must be state or portfolio", format) + } + if sanitize && format != "portfolio" { + return fmt.Errorf("import-all: --sanitize requires --format portfolio") + } // Plugin-dir flag follows the same scoped-override pattern used by // runInfraImport: temporarily set the package-level @@ -109,7 +120,13 @@ func runInfraImportAll(args []string) error { n, dispatchErr := runInfraImportAllWithDeps(ctx, provider, providerType, store, resourceType, dryRun) if outputPath != "" { - if werr := dumpStateToFile(ctx, store, outputPath); werr != nil { + var werr error + if format == "portfolio" { + werr = dumpPortfolioToFile(ctx, store, outputPath, sanitize) + } else { + werr = dumpStateToFile(ctx, store, outputPath) + } + if werr != nil { // Output dump is auxiliary; surface as a warning rather than // overwriting the dispatch error. Operators care about the // import result first; the dump is a debug-trail bonus. @@ -315,3 +332,36 @@ func dumpStateToFile(ctx context.Context, store infraStateStore, path string) er } return nil } + +// dumpPortfolioToFile converts the state-store contents to a canonical +// dns-portfolio export and writes it to path as JSON. +// +// This is an auxiliary dump (mirroring dumpStateToFile's auxiliary role): +// errors are surfaced as warnings by the caller and do not change the +// command exit code — the import + state persistence already succeeded. +// +// The portfolio is validated (structural only — unknown record types are +// preserved, per the design's open-set snapshot contract) before writing. +// If sanitize is true, TXT secrets + public IPs are redacted so the file +// can be committed to a public repository. +func dumpPortfolioToFile(ctx context.Context, store infraStateStore, path string, sanitize bool) error { + states, err := store.ListResources(ctx) + if err != nil { + return fmt.Errorf("list resources: %w", err) + } + p := record.FromResourceStates(states) + if sanitize { + record.Sanitize(&p) + } + if err := p.Validate(); err != nil { + return fmt.Errorf("portfolio validate: %w", err) + } + data, err := json.MarshalIndent(p, "", " ") + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + return nil +} diff --git a/cmd/wfctl/infra_import_all_format_test.go b/cmd/wfctl/infra_import_all_format_test.go new file mode 100644 index 00000000..fe8e566c --- /dev/null +++ b/cmd/wfctl/infra_import_all_format_test.go @@ -0,0 +1,112 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/dns/record" + "github.com/GoCodeAlone/workflow/interfaces" +) + +// TestDumpPortfolioToFile pins the --format portfolio output contract: +// dumpPortfolioToFile produces a JSON file whose top-level "schema" is +// "workflow.dns-portfolio.export.v1" and whose snapshots[0].records[0].value +// is set (alias-map collapsed the provider "data" key). +func TestDumpPortfolioToFile(t *testing.T) { + store := &fakeStateStore{} + _ = store.SaveResource(context.Background(), interfaces.ResourceState{ + ID: "do-example-com", + Name: "do-example-com", + Type: "infra.dns", + Provider: "digitalocean", + ProviderID: "example.com", + Outputs: map[string]any{ + "records": []any{ + map[string]any{"type": "A", "name": "@", "data": "192.0.2.10", "ttl": 300}, + }, + }, + }) + dir := t.TempDir() + out := filepath.Join(dir, "portfolio.json") + if err := dumpPortfolioToFile(context.Background(), store, out, false); err != nil { + t.Fatalf("dumpPortfolioToFile: %v", err) + } + data, err := os.ReadFile(out) + if err != nil { + t.Fatalf("read: %v", err) + } + // Must have the canonical schema field + if !strings.Contains(string(data), record.SchemaV1) { + t.Errorf("portfolio missing schema %q: %s", record.SchemaV1, data) + } + // Must use "value" key, not "data" + if !strings.Contains(string(data), `"value"`) { + t.Errorf("portfolio missing 'value' key: %s", data) + } + if strings.Contains(string(data), `"resources"`) { + t.Errorf("portfolio format must not have 'resources' key (that is --format state): %s", data) + } + // Verify snapshots[0].records[0].value is set + var p record.Portfolio + if err := json.Unmarshal(data, &p); err != nil { + t.Fatalf("unmarshal portfolio: %v", err) + } + if len(p.Snapshots) != 1 { + t.Fatalf("want 1 snapshot; got %d", len(p.Snapshots)) + } + if len(p.Snapshots[0].Records) == 0 { + t.Fatal("want records in snapshot; got none") + } + if p.Snapshots[0].Records[0].Value == "" { + t.Fatal("snapshots[0].records[0].value is empty") + } +} + +// TestDumpStateToFile_StillWorksAsDefault pins that --format state (the +// default) still produces {"resources":[...]} format unchanged. +func TestDumpStateToFile_StillWorksAsDefault(t *testing.T) { + store := &fakeStateStore{} + _ = store.SaveResource(context.Background(), interfaces.ResourceState{ + Name: "alpha", Type: "infra.dns", ProviderID: "alpha.test", + }) + dir := t.TempDir() + out := filepath.Join(dir, "state.json") + if err := dumpStateToFile(context.Background(), store, out); err != nil { + t.Fatalf("dumpStateToFile: %v", err) + } + data, err := os.ReadFile(out) + if err != nil { + t.Fatalf("read: %v", err) + } + if !strings.Contains(string(data), `"resources"`) { + t.Errorf("state format missing 'resources' key: %s", data) + } +} + +// TestImportAllFormatFlagRejectsUnknown pins that --format with an +// unrecognized value returns an error. +func TestImportAllFormatFlagRejectsUnknown(t *testing.T) { + err := runInfraImportAll([]string{"--provider", "do", "--type", "infra.dns", "--format", "xml"}) + if err == nil { + t.Fatal("want error for unknown --format; got nil") + } + if !strings.Contains(err.Error(), "format") { + t.Errorf("error should mention 'format'; got: %v", err) + } +} + +// TestImportAllSanitizeFlagRequiresPortfolioFormat pins that --sanitize +// is rejected unless --format portfolio is also set. +func TestImportAllSanitizeFlagRequiresPortfolioFormat(t *testing.T) { + err := runInfraImportAll([]string{"--provider", "do", "--type", "infra.dns", "--sanitize"}) + if err == nil { + t.Fatal("want error for --sanitize without --format portfolio; got nil") + } + if !strings.Contains(err.Error(), "sanitize") { + t.Errorf("error should mention 'sanitize'; got: %v", err) + } +} diff --git a/dns/record/sanitize.go b/dns/record/sanitize.go new file mode 100644 index 00000000..97786ff0 --- /dev/null +++ b/dns/record/sanitize.go @@ -0,0 +1,204 @@ +package record + +// Sanitize replaces sensitive data in p in-place so the portfolio can be +// committed to a public repository: +// +// - A/AAAA record values that are public (routable) IPs are replaced with +// RFC-5737 (192.0.2.x/198.51.100.x/203.0.113.x) or RFC-3849 +// (2001:db8::) example ranges. +// - TXT record data that looks like a secret (DKIM p= base64, long base64 +// strings) is replaced with "[redacted]". +// - _workflow-dns-policy TXT records (heritage=wfinfra-v1) are left intact +// because they are policy declarations, not secrets. +// - Private/reserved IP ranges (RFC-1918, loopback, link-local) are left +// as-is; they are not public. +// +// Sanitize sets p.Sanitized = true. +func Sanitize(p *Portfolio) { + for si := range p.Snapshots { + for ri := range p.Snapshots[si].Records { + r := &p.Snapshots[si].Records[ri] + switch r.Type { + case "A": + if isPublicIPv4(r.Value) { + r.Value = exampleIPv4(si, ri) + } + case "AAAA": + if isPublicIPv6(r.Value) { + r.Value = "2001:db8::" + } + case "TXT": + if isWFInfraPolicyTXT(r.Value) { + // Leave _workflow-dns-policy TXT intact. + continue + } + if looksLikeSecret(r.Value) { + r.Value = "[redacted]" + } + } + } + } + p.Sanitized = true +} + +// isWFInfraPolicyTXT reports whether a TXT value is the wfinfra-v1 policy record. +// These must never be redacted — they are policy declarations, not secrets. +func isWFInfraPolicyTXT(v string) bool { + return len(v) >= 16 && v[:16] == "heritage=wfinfra" +} + +// looksLikeSecret returns true for TXT values that are likely secrets: +// DKIM public-key records (contain "p=") or long base64-like strings. +func looksLikeSecret(v string) bool { + // DKIM key record: contains "p=" followed by a long base64 blob. + if containsSubstring(v, "p=") && len(v) > 80 { + return true + } + // Any TXT value that is entirely base64-like and long is treated as a secret. + if len(v) > 100 && isBase64Like(v) { + return true + } + return false +} + +// exampleIPv4 returns an RFC-5737 example IP, varied by snapshot and record +// index so multiple sanitized records get distinct addresses. +func exampleIPv4(snapIdx, recIdx int) string { + // RFC-5737 blocks: 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 + // Cycle through octets 1-254 using (snapIdx*16 + recIdx + 1) % 254. + octet := (snapIdx*16+recIdx+1)%254 + 1 + switch snapIdx % 3 { + case 0: + return "192.0.2." + itoa(octet) + case 1: + return "198.51.100." + itoa(octet) + default: + return "203.0.113." + itoa(octet) + } +} + +// isPublicIPv4 returns true when v is a dotted-decimal IPv4 address that is +// NOT in a private/reserved range (RFC-1918, loopback, link-local, RFC-5737). +func isPublicIPv4(v string) bool { + parts := splitIPv4(v) + if len(parts) != 4 { + return false + } + a, b := parts[0], parts[1] + // Already an example IP (RFC-5737): leave it alone. + if a == 192 && b == 0 && parts[2] == 2 { + return false + } + if a == 198 && b == 51 && parts[2] == 100 { + return false + } + if a == 203 && b == 0 && parts[2] == 113 { + return false + } + // Private ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, loopback, + // link-local (169.254.0.0/16), RFC-3927, broadcast (255.255.255.255). + if a == 10 { + return false + } + if a == 172 && b >= 16 && b <= 31 { + return false + } + if a == 192 && b == 168 { + return false + } + if a == 127 { + return false + } + if a == 169 && b == 254 { + return false + } + if a == 0 { + return false + } + return true +} + +// isPublicIPv6 returns true for a global-unicast IPv6 address that is not +// already in the RFC-3849 documentation range (2001:db8::/32). +func isPublicIPv6(v string) bool { + if len(v) == 0 { + return false + } + // Already a documentation address. + if len(v) >= 7 && v[:7] == "2001:db" { + return false + } + // Loopback (::1) and link-local (fe80::) are not public. + if v == "::1" || (len(v) >= 4 && (v[:4] == "fe80" || v[:4] == "FE80")) { + return false + } + // Treat any other colon-containing string as a public IPv6 address. + for _, c := range v { + if c == ':' { + return true + } + } + return false +} + +// ── small helpers (no imports needed) ──────────────────────────────────────── + +func splitIPv4(s string) []int { + var parts []int + cur := 0 + hasCur := false + for i := 0; i <= len(s); i++ { + if i == len(s) || s[i] == '.' { + if hasCur { + parts = append(parts, cur) + } + cur = 0 + hasCur = false + } else if s[i] >= '0' && s[i] <= '9' { + cur = cur*10 + int(s[i]-'0') + hasCur = true + } else { + return nil // not a valid dotted-decimal + } + } + return parts +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + buf := [20]byte{} + pos := len(buf) + for n > 0 { + pos-- + buf[pos] = byte('0' + n%10) + n /= 10 + } + return string(buf[pos:]) +} + +func containsSubstring(s, sub string) bool { + if len(sub) == 0 { + return true + } + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +func isBase64Like(s string) bool { + // Returns true if the string consists mostly of base64 characters + // (A-Z, a-z, 0-9, +, /, =) with at most 5% non-base64 chars. + base64Chars := 0 + for _, c := range s { + if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '+' || c == '/' || c == '=' { + base64Chars++ + } + } + return base64Chars*100/len(s) >= 95 +} From 52f2382a1111a109c9ac9a16629e736a187b9c02 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 29 May 2026 23:09:58 -0400 Subject: [PATCH 4/6] feat(dns/record): Sanitize for shareable portfolios --- dns/record/sanitize_test.go | 185 ++++++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 dns/record/sanitize_test.go diff --git a/dns/record/sanitize_test.go b/dns/record/sanitize_test.go new file mode 100644 index 00000000..6f539a86 --- /dev/null +++ b/dns/record/sanitize_test.go @@ -0,0 +1,185 @@ +package record_test + +import ( + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/dns/record" +) + +// TestSanitizeSetsFlag pins that Sanitize always sets p.Sanitized = true. +func TestSanitizeSetsFlag(t *testing.T) { + p := record.Portfolio{ + Schema: record.SchemaV1, + Snapshots: []record.Snapshot{}, + } + if p.Sanitized { + t.Fatal("portfolio.Sanitized should start false") + } + record.Sanitize(&p) + if !p.Sanitized { + t.Fatal("Sanitize must set p.Sanitized = true") + } +} + +// TestSanitizeRedactsPublicIPv4 pins that A records with public IPs are +// replaced with an RFC-5737 example address (192.0.2.x etc.). +func TestSanitizeRedactsPublicIPv4(t *testing.T) { + p := record.Portfolio{ + Schema: record.SchemaV1, + Snapshots: []record.Snapshot{{ + Provider: "digitalocean", + Domain: "example.com", + Records: []record.Record{ + {Type: "A", Name: "@", Value: "198.51.100.25", TTL: 300}, // public IP + }, + }}, + } + record.Sanitize(&p) + got := p.Snapshots[0].Records[0].Value + // Must be an RFC-5737 example address + if !isExampleIP(got) { + t.Errorf("after Sanitize, A record value = %q; want an RFC-5737 example IP", got) + } +} + +// TestSanitizeRedactsPublicIPv6 pins that AAAA records with public IPv6 are +// replaced with 2001:db8:: (RFC-3849). +func TestSanitizeRedactsPublicIPv6(t *testing.T) { + p := record.Portfolio{ + Schema: record.SchemaV1, + Snapshots: []record.Snapshot{{ + Provider: "digitalocean", + Domain: "example.com", + Records: []record.Record{ + {Type: "AAAA", Name: "@", Value: "2607:f8b0:4004:c07::64", TTL: 300}, + }, + }}, + } + record.Sanitize(&p) + got := p.Snapshots[0].Records[0].Value + if got != "2001:db8::" { + t.Errorf("after Sanitize, AAAA record value = %q; want 2001:db8::", got) + } +} + +// TestSanitizePreservesPrivateIPs pins that private/RFC-1918 addresses are +// left unchanged (they're not sensitive public IPs). +func TestSanitizePreservesPrivateIPs(t *testing.T) { + cases := []string{"10.0.0.1", "172.16.5.1", "192.168.1.1", "127.0.0.1"} + for _, ip := range cases { + p := record.Portfolio{ + Schema: record.SchemaV1, + Snapshots: []record.Snapshot{{ + Provider: "digitalocean", + Domain: "example.com", + Records: []record.Record{{Type: "A", Name: "@", Value: ip, TTL: 300}}, + }}, + } + record.Sanitize(&p) + got := p.Snapshots[0].Records[0].Value + if got != ip { + t.Errorf("private IP %s was changed to %s; private/reserved IPs should be preserved", ip, got) + } + } +} + +// TestSanitizeRedactsDKIMTXT pins that TXT records containing a DKIM public +// key (p= blob, long) are replaced with "[redacted]". +func TestSanitizeRedactsDKIMTXT(t *testing.T) { + dkimValue := "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2a8h/THISIS/AFAKE/DKIMKEY/FORTEST/PURPOSESONLY/BASE64ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZ" + p := record.Portfolio{ + Schema: record.SchemaV1, + Snapshots: []record.Snapshot{{ + Provider: "digitalocean", + Domain: "example.com", + Records: []record.Record{ + {Type: "TXT", Name: "_domainkey", Value: dkimValue, TTL: 3600}, + }, + }}, + } + record.Sanitize(&p) + got := p.Snapshots[0].Records[0].Value + if got != "[redacted]" { + t.Errorf("DKIM TXT value = %q; want [redacted]", got) + } +} + +// TestSanitizePreservesWFInfraPolicyTXT pins that _workflow-dns-policy TXT +// records (heritage=wfinfra-v1) are NOT redacted — they are policy +// declarations, not secrets. +func TestSanitizePreservesWFInfraPolicyTXT(t *testing.T) { + policyValue := "heritage=wfinfra-v1 o=gocodealone p=* t=A,CNAME" + p := record.Portfolio{ + Schema: record.SchemaV1, + Snapshots: []record.Snapshot{{ + Provider: "digitalocean", + Domain: "example.com", + Records: []record.Record{ + {Type: "TXT", Name: "_workflow-dns-policy", Value: policyValue, TTL: 300}, + }, + }}, + } + record.Sanitize(&p) + got := p.Snapshots[0].Records[0].Value + if got != policyValue { + t.Errorf("_workflow-dns-policy TXT was changed to %q; must be preserved", got) + } +} + +// TestSanitizePreservesPlainTXT pins that plain TXT records (SPF, DMARC, +// site verification without long base64 blobs) are NOT redacted. +func TestSanitizePreservesPlainTXT(t *testing.T) { + cases := []string{ + "v=spf1 include:_spf.google.com ~all", + "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com", + "google-site-verification=abc123short", + } + for _, v := range cases { + p := record.Portfolio{ + Schema: record.SchemaV1, + Snapshots: []record.Snapshot{{ + Provider: "digitalocean", + Domain: "example.com", + Records: []record.Record{{Type: "TXT", Name: "@", Value: v, TTL: 3600}}, + }}, + } + record.Sanitize(&p) + got := p.Snapshots[0].Records[0].Value + if got == "[redacted]" { + t.Errorf("plain TXT %q was redacted; only secret TXT values should be redacted", v) + } + } +} + +// TestSanitizePreservesExistingExampleIPs pins that RFC-5737 example IPs +// already in the fixture are NOT changed (idempotent for fixture data). +func TestSanitizePreservesExistingExampleIPs(t *testing.T) { + exampleIPs := []string{"192.0.2.10", "198.51.100.25", "203.0.113.40"} + for _, ip := range exampleIPs { + p := record.Portfolio{ + Schema: record.SchemaV1, + Snapshots: []record.Snapshot{{ + Provider: "digitalocean", + Domain: "example.com", + Records: []record.Record{{Type: "A", Name: "@", Value: ip, TTL: 300}}, + }}, + } + record.Sanitize(&p) + got := p.Snapshots[0].Records[0].Value + if !isExampleIP(got) { + t.Errorf("existing example IP %s should remain an example IP; got %s", ip, got) + } + // The value shouldn't change (already sanitized) + if got != ip { + t.Logf("note: existing example IP %s was changed to %s (re-mapped example range, still valid)", ip, got) + } + } +} + +// isExampleIP returns true if the IP is in an RFC-5737 documentation range. +func isExampleIP(ip string) bool { + return strings.HasPrefix(ip, "192.0.2.") || + strings.HasPrefix(ip, "198.51.100.") || + strings.HasPrefix(ip, "203.0.113.") +} From b1251f0a91fef1d88c00f80ea4a4bff06a054486 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 29 May 2026 23:11:14 -0400 Subject: [PATCH 5/6] fix(dns/record): golangci-lint: range-pointer + switch-rewrite --- dns/record/canonicalize.go | 3 ++- dns/record/sanitize.go | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/dns/record/canonicalize.go b/dns/record/canonicalize.go index 53cdd026..d422f272 100644 --- a/dns/record/canonicalize.go +++ b/dns/record/canonicalize.go @@ -15,7 +15,8 @@ import "github.com/GoCodeAlone/workflow/interfaces" // Non-infra.dns states are silently skipped. func FromResourceStates(states []interfaces.ResourceState) Portfolio { p := Portfolio{Schema: SchemaV1} - for _, st := range states { + for i := range states { + st := &states[i] if st.Type != "infra.dns" { continue } diff --git a/dns/record/sanitize.go b/dns/record/sanitize.go index 97786ff0..df9fecf7 100644 --- a/dns/record/sanitize.go +++ b/dns/record/sanitize.go @@ -148,16 +148,17 @@ func splitIPv4(s string) []int { cur := 0 hasCur := false for i := 0; i <= len(s); i++ { - if i == len(s) || s[i] == '.' { + switch { + case i == len(s) || s[i] == '.': if hasCur { parts = append(parts, cur) } cur = 0 hasCur = false - } else if s[i] >= '0' && s[i] <= '9' { + case s[i] >= '0' && s[i] <= '9': cur = cur*10 + int(s[i]-'0') hasCur = true - } else { + default: return nil // not a valid dotted-decimal } } From 3ae9b0629e7481decd83443cd5857fd3ec589b7b Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 29 May 2026 23:33:58 -0400 Subject: [PATCH 6/6] fix(dns/record): code-review C-1/C-2/I-1/I-2/I-3/M-2 - C-1: TestSanitizeRedactsPublicIPv4/v6 use genuinely-public IPs (8.8.8.8, 2606:4700:4700::1111) + assert output differs from input (no longer vacuous) - C-2: looksLikeSecret no longer false-redacts long DMARC (>80 char p=reject); DKIM requires v=DKIM1 + a long base64-ish p= blob (hasLongBase64PField) - I-1: canonicalize stores priority/port/weight/flags when KEY present even if zero (RFC-7505 null-MX priority=0, SRV weight/port=0 no longer dropped) - I-2: isPublicIPv4/v6 rewritten on net.ParseIP + stdlib predicates; excludes RFC-6598 CGNAT (100.64/10), IPv6 ULA (fc00::/7), broadcast; redacts routable 2001:dbc/2001:db0 (no longer over-broad 2001:db prefix); RFC-5737/3849 spared - I-3: TestDumpPortfolioToFile_WithSanitize asserts Sanitized==true + example IP - M-2: isWFInfraPolicyTXT guards on record NAME (_workflow-dns-policy) too --- cmd/wfctl/infra_import_all_format_test.go | 46 +++++ dns/record/canonicalize.go | 23 ++- dns/record/canonicalize_test.go | 66 +++++++ dns/record/sanitize.go | 207 ++++++++++++---------- dns/record/sanitize_test.go | 195 +++++++++++++++++--- 5 files changed, 408 insertions(+), 129 deletions(-) diff --git a/cmd/wfctl/infra_import_all_format_test.go b/cmd/wfctl/infra_import_all_format_test.go index fe8e566c..9a3c508f 100644 --- a/cmd/wfctl/infra_import_all_format_test.go +++ b/cmd/wfctl/infra_import_all_format_test.go @@ -66,6 +66,52 @@ func TestDumpPortfolioToFile(t *testing.T) { } } +// TestDumpPortfolioToFile_WithSanitize pins I-3: dumpPortfolioToFile(...,true) +// with a genuinely-public IP in a record produces a portfolio with +// Sanitized==true and the record value redacted to an RFC-5737 example IP. +func TestDumpPortfolioToFile_WithSanitize(t *testing.T) { + store := &fakeStateStore{} + _ = store.SaveResource(context.Background(), interfaces.ResourceState{ + ID: "do-example-com", + Name: "do-example-com", + Type: "infra.dns", + Provider: "digitalocean", + ProviderID: "example.com", + Outputs: map[string]any{ + "records": []any{ + // 8.8.8.8 is genuinely public (Google DNS), NOT RFC-5737. + map[string]any{"type": "A", "name": "@", "data": "8.8.8.8", "ttl": 300}, + }, + }, + }) + dir := t.TempDir() + out := filepath.Join(dir, "portfolio.json") + if err := dumpPortfolioToFile(context.Background(), store, out, true); err != nil { + t.Fatalf("dumpPortfolioToFile sanitize: %v", err) + } + data, err := os.ReadFile(out) + if err != nil { + t.Fatalf("read: %v", err) + } + var p record.Portfolio + if err := json.Unmarshal(data, &p); err != nil { + t.Fatalf("unmarshal portfolio: %v", err) + } + if !p.Sanitized { + t.Error("sanitized portfolio must have sanitized==true") + } + if len(p.Snapshots) != 1 || len(p.Snapshots[0].Records) == 0 { + t.Fatalf("want 1 snapshot with records; got %d snapshots", len(p.Snapshots)) + } + got := p.Snapshots[0].Records[0].Value + if got == "8.8.8.8" { + t.Error("public IP 8.8.8.8 was NOT redacted in sanitized dump") + } + if !strings.HasPrefix(got, "192.0.2.") && !strings.HasPrefix(got, "198.51.100.") && !strings.HasPrefix(got, "203.0.113.") { + t.Errorf("sanitized A value = %q; want an RFC-5737 example IP", got) + } +} + // TestDumpStateToFile_StillWorksAsDefault pins that --format state (the // default) still produces {"resources":[...]} format unchanged. func TestDumpStateToFile_StillWorksAsDefault(t *testing.T) { diff --git a/dns/record/canonicalize.go b/dns/record/canonicalize.go index d422f272..fa7e7a76 100644 --- a/dns/record/canonicalize.go +++ b/dns/record/canonicalize.go @@ -67,25 +67,24 @@ func recordFromMap(m map[string]any) Record { TTL: intVal(m, "ttl"), Tag: stringVal(m, "tag"), } + // I-1: store the value when the KEY is present regardless of its numeric + // value — a present zero is meaningful (RFC-7505 null-MX priority=0, + // SRV weight=0, port=0). Dropping zeros would silently corrupt the record. if v, ok := m["priority"]; ok { - if n := toInt(v); n != 0 { - r.Priority = &n - } + n := toInt(v) + r.Priority = &n } if v, ok := m["port"]; ok { - if n := toInt(v); n != 0 { - r.Port = &n - } + n := toInt(v) + r.Port = &n } if v, ok := m["weight"]; ok { - if n := toInt(v); n != 0 { - r.Weight = &n - } + n := toInt(v) + r.Weight = &n } if v, ok := m["flags"]; ok { - if n := toInt(v); n != 0 { - r.Flags = &n - } + n := toInt(v) + r.Flags = &n } return r } diff --git a/dns/record/canonicalize_test.go b/dns/record/canonicalize_test.go index 19c0db24..89af4c9e 100644 --- a/dns/record/canonicalize_test.go +++ b/dns/record/canonicalize_test.go @@ -90,3 +90,69 @@ func TestFromResourceStatesUsesOutputsPreferredOverAppliedConfig(t *testing.T) { t.Fatalf("want Outputs value 192.0.2.10; got %s", p.Snapshots[0].Records[0].Value) } } + +// TestFromResourceStatesPreservesZeroValues pins I-1: a present key with a +// zero value (null-MX RFC-7505 priority=0, SRV weight=0, SRV port=0) must +// round-trip as a non-nil pointer to 0 — NOT be dropped to nil. The old +// `if n:=toInt(v); n!=0` logic silently lost these legitimate zeros. +func TestFromResourceStatesPreservesZeroValues(t *testing.T) { + states := []interfaces.ResourceState{ + { + Type: "infra.dns", + Provider: "digitalocean", + ProviderID: "do.test", + Outputs: map[string]any{ + "records": []any{ + // RFC-7505 null MX: priority 0, target "." + map[string]any{"type": "MX", "name": "@", "data": ".", "ttl": 300, "priority": 0}, + // SRV with zero weight + zero port (valid wire values) + map[string]any{"type": "SRV", "name": "_sip._tcp", "data": "sip.example.com.", "ttl": 300, "priority": 0, "weight": 0, "port": 0}, + }, + }, + }, + } + p := record.FromResourceStates(states) + if len(p.Snapshots) != 1 || len(p.Snapshots[0].Records) != 2 { + t.Fatalf("want 1 snapshot with 2 records; got %d snapshots", len(p.Snapshots)) + } + mx := p.Snapshots[0].Records[0] + if mx.Priority == nil { + t.Errorf("null-MX priority dropped to nil; want &0 (RFC-7505)") + } else if *mx.Priority != 0 { + t.Errorf("MX priority = %d; want 0", *mx.Priority) + } + srv := p.Snapshots[0].Records[1] + if srv.Priority == nil || *srv.Priority != 0 { + t.Errorf("SRV priority = %v; want &0", srv.Priority) + } + if srv.Weight == nil || *srv.Weight != 0 { + t.Errorf("SRV weight = %v; want &0", srv.Weight) + } + if srv.Port == nil || *srv.Port != 0 { + t.Errorf("SRV port = %v; want &0", srv.Port) + } +} + +// TestFromResourceStatesOmitsAbsentOptionalFields pins the complement of I-1: +// when an optional key is ABSENT from the provider map, its pointer stays nil +// (so json omitempty drops it). Only present-with-zero should become &0. +func TestFromResourceStatesOmitsAbsentOptionalFields(t *testing.T) { + states := []interfaces.ResourceState{ + { + Type: "infra.dns", + Provider: "digitalocean", + ProviderID: "do.test", + Outputs: map[string]any{ + "records": []any{ + map[string]any{"type": "A", "name": "@", "data": "192.0.2.1", "ttl": 300}, + }, + }, + }, + } + p := record.FromResourceStates(states) + r := p.Snapshots[0].Records[0] + if r.Priority != nil || r.Port != nil || r.Weight != nil || r.Flags != nil { + t.Errorf("absent optional fields should stay nil; got priority=%v port=%v weight=%v flags=%v", + r.Priority, r.Port, r.Weight, r.Flags) + } +} diff --git a/dns/record/sanitize.go b/dns/record/sanitize.go index df9fecf7..eeb13c3f 100644 --- a/dns/record/sanitize.go +++ b/dns/record/sanitize.go @@ -1,17 +1,23 @@ package record +import ( + "net" + "strings" +) + // Sanitize replaces sensitive data in p in-place so the portfolio can be // committed to a public repository: // // - A/AAAA record values that are public (routable) IPs are replaced with // RFC-5737 (192.0.2.x/198.51.100.x/203.0.113.x) or RFC-3849 // (2001:db8::) example ranges. -// - TXT record data that looks like a secret (DKIM p= base64, long base64 -// strings) is replaced with "[redacted]". -// - _workflow-dns-policy TXT records (heritage=wfinfra-v1) are left intact -// because they are policy declarations, not secrets. -// - Private/reserved IP ranges (RFC-1918, loopback, link-local) are left -// as-is; they are not public. +// - TXT record data that looks like a secret (DKIM public key, long base64 +// blobs) is replaced with "[redacted]". +// - _workflow-dns-policy TXT records (identified by record NAME and/or the +// heritage=wfinfra-v1 value prefix) are left intact — they are policy +// declarations, not secrets. +// - Private/reserved IP ranges (RFC-1918, RFC-6598 CGNAT, loopback, +// link-local, IPv6 ULA, RFC-5737/3849 documentation) are left as-is. // // Sanitize sets p.Sanitized = true. func Sanitize(p *Portfolio) { @@ -28,7 +34,7 @@ func Sanitize(p *Portfolio) { r.Value = "2001:db8::" } case "TXT": - if isWFInfraPolicyTXT(r.Value) { + if isWFInfraPolicyTXT(r.Name, r.Value) { // Leave _workflow-dns-policy TXT intact. continue } @@ -41,30 +47,64 @@ func Sanitize(p *Portfolio) { p.Sanitized = true } -// isWFInfraPolicyTXT reports whether a TXT value is the wfinfra-v1 policy record. -// These must never be redacted — they are policy declarations, not secrets. -func isWFInfraPolicyTXT(v string) bool { - return len(v) >= 16 && v[:16] == "heritage=wfinfra" +// isWFInfraPolicyTXT reports whether a TXT record is the wfinfra-v1 ownership +// policy record. These must never be redacted — they are policy declarations, +// not secrets. M-2: the record NAME (_workflow-dns-policy) is the authoritative +// identifier; the heritage= value prefix is a secondary signal. +func isWFInfraPolicyTXT(name, value string) bool { + if strings.HasPrefix(name, "_workflow-dns-policy") { + return true + } + return strings.HasPrefix(value, "heritage=wfinfra") } // looksLikeSecret returns true for TXT values that are likely secrets: -// DKIM public-key records (contain "p=") or long base64-like strings. +// DKIM public-key records or long base64-like blobs. +// +// C-2: a `p=` substring alone is NOT enough — legitimate DMARC records carry +// `p=reject|quarantine|none` policy keywords and can exceed 80 chars. We only +// treat a value as a DKIM secret when it self-identifies as DKIM (v=DKIM1) AND +// carries a long base64-ish `p=` blob, or when the whole value is a long +// base64 blob. func looksLikeSecret(v string) bool { - // DKIM key record: contains "p=" followed by a long base64 blob. - if containsSubstring(v, "p=") && len(v) > 80 { + // DKIM key record: explicit v=DKIM1 tag + a long base64-ish public key. + if strings.Contains(v, "v=DKIM1") && hasLongBase64PField(v) { return true } - // Any TXT value that is entirely base64-like and long is treated as a secret. + // Any TXT value that is mostly base64 and long is treated as a secret. if len(v) > 100 && isBase64Like(v) { return true } return false } +// hasLongBase64PField reports whether the value contains a `p=` field whose +// blob is a long base64-ish key (>= 32 chars of base64 alphabet). This +// distinguishes a DKIM public key (`p=MIIB...long...`) from a DMARC policy +// keyword (`p=reject`). +func hasLongBase64PField(v string) bool { + idx := strings.Index(v, "p=") + if idx < 0 { + return false + } + blob := v[idx+2:] + // Trim leading whitespace. + blob = strings.TrimLeft(blob, " \t") + // Read the base64-ish run (byte-wise; base64 alphabet is all ASCII). + n := 0 + for i := 0; i < len(blob); i++ { + if !isBase64Char(blob[i]) { + break + } + n++ + } + return n >= 32 +} + // exampleIPv4 returns an RFC-5737 example IP, varied by snapshot and record // index so multiple sanitized records get distinct addresses. func exampleIPv4(snapIdx, recIdx int) string { - // RFC-5737 blocks: 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 + // RFC-5737 blocks: 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24. // Cycle through octets 1-254 using (snapIdx*16 + recIdx + 1) % 254. octet := (snapIdx*16+recIdx+1)%254 + 1 switch snapIdx % 3 { @@ -77,93 +117,79 @@ func exampleIPv4(snapIdx, recIdx int) string { } } -// isPublicIPv4 returns true when v is a dotted-decimal IPv4 address that is -// NOT in a private/reserved range (RFC-1918, loopback, link-local, RFC-5737). +// isPublicIPv4 reports whether v is a routable IPv4 address that should be +// redacted. I-2: uses net.ParseIP + the stdlib special-range predicates and +// explicitly excludes RFC-5737 (documentation), RFC-6598 (CGNAT 100.64/10), +// and the all-zeros/broadcast addresses. func isPublicIPv4(v string) bool { - parts := splitIPv4(v) - if len(parts) != 4 { - return false - } - a, b := parts[0], parts[1] - // Already an example IP (RFC-5737): leave it alone. - if a == 192 && b == 0 && parts[2] == 2 { - return false - } - if a == 198 && b == 51 && parts[2] == 100 { + ip := net.ParseIP(v) + if ip == nil { return false } - if a == 203 && b == 0 && parts[2] == 113 { - return false - } - // Private ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, loopback, - // link-local (169.254.0.0/16), RFC-3927, broadcast (255.255.255.255). - if a == 10 { - return false + ip4 := ip.To4() + if ip4 == nil { + return false // not an IPv4 address } - if a == 172 && b >= 16 && b <= 31 { + if ip4.IsPrivate() || ip4.IsLoopback() || ip4.IsLinkLocalUnicast() || + ip4.IsLinkLocalMulticast() || ip4.IsMulticast() || ip4.IsUnspecified() { return false } - if a == 192 && b == 168 { + // RFC-5737 documentation ranges — already example IPs. + if inRFC5737(ip4) { return false } - if a == 127 { + // RFC-6598 CGNAT: 100.64.0.0/10 (100.64.0.0 – 100.127.255.255). + if ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 { return false } - if a == 169 && b == 254 { - return false - } - if a == 0 { + // Limited broadcast. + if ip4[0] == 255 && ip4[1] == 255 && ip4[2] == 255 && ip4[3] == 255 { return false } return true } -// isPublicIPv6 returns true for a global-unicast IPv6 address that is not -// already in the RFC-3849 documentation range (2001:db8::/32). +func inRFC5737(ip4 net.IP) bool { + switch { + case ip4[0] == 192 && ip4[1] == 0 && ip4[2] == 2: // 192.0.2.0/24 + return true + case ip4[0] == 198 && ip4[1] == 51 && ip4[2] == 100: // 198.51.100.0/24 + return true + case ip4[0] == 203 && ip4[1] == 0 && ip4[2] == 113: // 203.0.113.0/24 + return true + } + return false +} + +// rfc3849 is the IPv6 documentation prefix 2001:db8::/32 (RFC-3849). +var rfc3849Net = func() *net.IPNet { + _, n, _ := net.ParseCIDR("2001:db8::/32") + return n +}() + +// isPublicIPv6 reports whether v is a routable IPv6 address that should be +// redacted. I-2: uses net.ParseIP; redacts only a global-unicast, +// non-private address that is NOT in the RFC-3849 documentation range. +// ULA (fc00::/7) is IsPrivate; loopback/link-local are excluded too. func isPublicIPv6(v string) bool { - if len(v) == 0 { + ip := net.ParseIP(v) + if ip == nil { return false } - // Already a documentation address. - if len(v) >= 7 && v[:7] == "2001:db" { - return false + if ip.To4() != nil { + return false // an IPv4 address, not IPv6 } - // Loopback (::1) and link-local (fe80::) are not public. - if v == "::1" || (len(v) >= 4 && (v[:4] == "fe80" || v[:4] == "FE80")) { + if !ip.IsGlobalUnicast() || ip.IsPrivate() { return false } - // Treat any other colon-containing string as a public IPv6 address. - for _, c := range v { - if c == ':' { - return true - } + // Already an RFC-3849 documentation address. + if rfc3849Net != nil && rfc3849Net.Contains(ip) { + return false } - return false + return true } -// ── small helpers (no imports needed) ──────────────────────────────────────── - -func splitIPv4(s string) []int { - var parts []int - cur := 0 - hasCur := false - for i := 0; i <= len(s); i++ { - switch { - case i == len(s) || s[i] == '.': - if hasCur { - parts = append(parts, cur) - } - cur = 0 - hasCur = false - case s[i] >= '0' && s[i] <= '9': - cur = cur*10 + int(s[i]-'0') - hasCur = true - default: - return nil // not a valid dotted-decimal - } - } - return parts -} +// ── small helpers ───────────────────────────────────────────────────────── func itoa(n int) string { if n == 0 { @@ -179,25 +205,20 @@ func itoa(n int) string { return string(buf[pos:]) } -func containsSubstring(s, sub string) bool { - if len(sub) == 0 { - return true - } - for i := 0; i <= len(s)-len(sub); i++ { - if s[i:i+len(sub)] == sub { - return true - } - } - return false +func isBase64Char(c byte) bool { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '+' || c == '/' || c == '=' } func isBase64Like(s string) bool { + if len(s) == 0 { + return false + } // Returns true if the string consists mostly of base64 characters - // (A-Z, a-z, 0-9, +, /, =) with at most 5% non-base64 chars. + // with at most 5% non-base64 chars. base64Chars := 0 - for _, c := range s { - if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || - (c >= '0' && c <= '9') || c == '+' || c == '/' || c == '=' { + for i := 0; i < len(s); i++ { + if isBase64Char(s[i]) { base64Chars++ } } diff --git a/dns/record/sanitize_test.go b/dns/record/sanitize_test.go index 6f539a86..70220b71 100644 --- a/dns/record/sanitize_test.go +++ b/dns/record/sanitize_test.go @@ -22,52 +22,77 @@ func TestSanitizeSetsFlag(t *testing.T) { } } -// TestSanitizeRedactsPublicIPv4 pins that A records with public IPs are -// replaced with an RFC-5737 example address (192.0.2.x etc.). +// TestSanitizeRedactsPublicIPv4 pins that A records with a GENUINELY-PUBLIC IP +// (8.8.8.8 — Google DNS, not RFC-5737) are replaced with an RFC-5737 example +// address. C-1: input MUST be a real public IP so a no-op Sanitize fails this +// test; we assert the output (a) differs from input AND (b) is an example IP. func TestSanitizeRedactsPublicIPv4(t *testing.T) { + const publicIP = "8.8.8.8" // genuinely public, NOT RFC-5737 p := record.Portfolio{ Schema: record.SchemaV1, Snapshots: []record.Snapshot{{ Provider: "digitalocean", Domain: "example.com", Records: []record.Record{ - {Type: "A", Name: "@", Value: "198.51.100.25", TTL: 300}, // public IP + {Type: "A", Name: "@", Value: publicIP, TTL: 300}, }, }}, } record.Sanitize(&p) got := p.Snapshots[0].Records[0].Value - // Must be an RFC-5737 example address + if got == publicIP { + t.Errorf("public IP %s was NOT redacted (Sanitize was a no-op)", publicIP) + } if !isExampleIP(got) { t.Errorf("after Sanitize, A record value = %q; want an RFC-5737 example IP", got) } } -// TestSanitizeRedactsPublicIPv6 pins that AAAA records with public IPv6 are -// replaced with 2001:db8:: (RFC-3849). +// TestSanitizeRedactsPublicIPv6 pins that AAAA records with a genuinely-public +// IPv6 (2606:4700:4700::1111 — Cloudflare, and 2607:f8b0... Google) are +// replaced with 2001:db8:: (RFC-3849). C-1: input must differ from output. func TestSanitizeRedactsPublicIPv6(t *testing.T) { - p := record.Portfolio{ - Schema: record.SchemaV1, - Snapshots: []record.Snapshot{{ - Provider: "digitalocean", - Domain: "example.com", - Records: []record.Record{ - {Type: "AAAA", Name: "@", Value: "2607:f8b0:4004:c07::64", TTL: 300}, - }, - }}, + cases := []string{ + "2606:4700:4700::1111", // Cloudflare public resolver + "2607:f8b0:4004:c07::64", // Google } - record.Sanitize(&p) - got := p.Snapshots[0].Records[0].Value - if got != "2001:db8::" { - t.Errorf("after Sanitize, AAAA record value = %q; want 2001:db8::", got) + for _, in := range cases { + p := record.Portfolio{ + Schema: record.SchemaV1, + Snapshots: []record.Snapshot{{ + Provider: "digitalocean", + Domain: "example.com", + Records: []record.Record{ + {Type: "AAAA", Name: "@", Value: in, TTL: 300}, + }, + }}, + } + record.Sanitize(&p) + got := p.Snapshots[0].Records[0].Value + if got == in { + t.Errorf("public IPv6 %s was NOT redacted (Sanitize was a no-op)", in) + } + if got != "2001:db8::" { + t.Errorf("after Sanitize, AAAA record value = %q; want 2001:db8::", got) + } } } -// TestSanitizePreservesPrivateIPs pins that private/RFC-1918 addresses are -// left unchanged (they're not sensitive public IPs). +// TestSanitizePreservesPrivateIPs pins that private/reserved addresses are +// left unchanged (they're not sensitive public IPs). I-2: include RFC-6598 +// CGNAT (100.64.0.0/10), link-local (169.254), loopback, and IPv6 ULA +// (fc00::/7) + the routable-but-documentation-adjacent 2001:dbc. func TestSanitizePreservesPrivateIPs(t *testing.T) { - cases := []string{"10.0.0.1", "172.16.5.1", "192.168.1.1", "127.0.0.1"} - for _, ip := range cases { + v4 := []string{ + "10.0.0.1", // RFC-1918 + "172.16.5.1", // RFC-1918 + "192.168.1.1", // RFC-1918 + "127.0.0.1", // loopback + "169.254.10.1", // link-local + "100.64.0.1", // RFC-6598 CGNAT + "100.127.0.1", // RFC-6598 CGNAT upper edge + } + for _, ip := range v4 { p := record.Portfolio{ Schema: record.SchemaV1, Snapshots: []record.Snapshot{{ @@ -79,7 +104,58 @@ func TestSanitizePreservesPrivateIPs(t *testing.T) { record.Sanitize(&p) got := p.Snapshots[0].Records[0].Value if got != ip { - t.Errorf("private IP %s was changed to %s; private/reserved IPs should be preserved", ip, got) + t.Errorf("reserved IPv4 %s was changed to %s; private/reserved IPs should be preserved", ip, got) + } + } + + v6 := []string{ + "::1", // loopback + "fe80::1", // link-local + "fc00::1", // ULA (fc00::/7) + "fd12:3456:789a:1::1", // ULA (fd00::/8) + } + for _, ip := range v6 { + p := record.Portfolio{ + Schema: record.SchemaV1, + Snapshots: []record.Snapshot{{ + Provider: "digitalocean", + Domain: "example.com", + Records: []record.Record{{Type: "AAAA", Name: "@", Value: ip, TTL: 300}}, + }}, + } + record.Sanitize(&p) + got := p.Snapshots[0].Records[0].Value + if got != ip { + t.Errorf("reserved IPv6 %s was changed to %s; ULA/loopback/link-local must be preserved", ip, got) + } + } +} + +// TestSanitizeRedacts2001dbc pins I-2's over-broad-prefix bug: the old +// `v[:7]=="2001:db"` logic WRONGLY preserved routable 2001:dbc/2001:db0 +// addresses (they share the "2001:db" prefix but are NOT in RFC-3849's +// 2001:db8::/32). With net.ParseIP these are global-unicast → must be redacted. +func TestSanitizeRedacts2001dbc(t *testing.T) { + cases := []string{ + "2001:dbc:dead:beef::1", // 2001:dbc — routable, NOT 2001:db8::/32 + "2001:db0::1", // 2001:db0 — routable, NOT 2001:db8::/32 + } + for _, in := range cases { + p := record.Portfolio{ + Schema: record.SchemaV1, + Snapshots: []record.Snapshot{{ + Provider: "digitalocean", + Domain: "example.com", + Records: []record.Record{{Type: "AAAA", Name: "@", Value: in, TTL: 300}}, + }}, + } + record.Sanitize(&p) + got := p.Snapshots[0].Records[0].Value + if got == in { + t.Errorf("routable IPv6 %s was NOT redacted (over-broad 2001:db prefix bug)", in) + } + if got != "2001:db8::" { + t.Errorf("after Sanitize, %s → %q; want 2001:db8::", in, got) } } } @@ -105,6 +181,54 @@ func TestSanitizeRedactsDKIMTXT(t *testing.T) { } } +// TestSanitizePreservesLongDMARC pins C-2: a long (>80-char) legitimate DMARC +// TXT with a `p=` policy keyword (reject/quarantine/none) must NOT be redacted. +// The old `containsSubstring(v,"p=") && len(v)>80` heuristic false-redacted it. +func TestSanitizePreservesLongDMARC(t *testing.T) { + cases := []string{ + "v=DMARC1; p=reject; rua=mailto:dmarc-agg@example.com; ruf=mailto:dmarc-forensic@example.com; fo=1; pct=100; adkim=s; aspf=s", + "v=DMARC1; p=quarantine; sp=reject; rua=mailto:reports@example.com; ruf=mailto:forensics@example.com; pct=100", + "v=DMARC1; p=none; rua=mailto:postmaster@example.com; ruf=mailto:postmaster@example.com; adkim=r; aspf=r; ri=86400", + } + for _, v := range cases { + if len(v) <= 80 { + t.Fatalf("test bug: DMARC value must be >80 chars to exercise C-2; got %d", len(v)) + } + p := record.Portfolio{ + Schema: record.SchemaV1, + Snapshots: []record.Snapshot{{ + Provider: "digitalocean", + Domain: "example.com", + Records: []record.Record{{Type: "TXT", Name: "_dmarc", Value: v, TTL: 3600}}, + }}, + } + record.Sanitize(&p) + got := p.Snapshots[0].Records[0].Value + if got != v { + t.Errorf("long DMARC TXT was changed to %q; legitimate DMARC must be preserved", got) + } + } +} + +// TestSanitizeRedactsRealDKIM pins C-2's positive half: a genuine DKIM key +// record (v=DKIM1 + long base64 p= blob) IS redacted. +func TestSanitizeRedactsRealDKIM(t *testing.T) { + dkim := "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2a8h0vftMQtZsqHXkv9TestFakeKeyBase64DataMoreAndMoreAndMoreAAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJKKKKLLLLMMMMNNNN" + p := record.Portfolio{ + Schema: record.SchemaV1, + Snapshots: []record.Snapshot{{ + Provider: "digitalocean", + Domain: "example.com", + Records: []record.Record{{Type: "TXT", Name: "selector1._domainkey", Value: dkim, TTL: 3600}}, + }}, + } + record.Sanitize(&p) + got := p.Snapshots[0].Records[0].Value + if got != "[redacted]" { + t.Errorf("real DKIM TXT = %q; want [redacted]", got) + } +} + // TestSanitizePreservesWFInfraPolicyTXT pins that _workflow-dns-policy TXT // records (heritage=wfinfra-v1) are NOT redacted — they are policy // declarations, not secrets. @@ -127,6 +251,29 @@ func TestSanitizePreservesWFInfraPolicyTXT(t *testing.T) { } } +// TestSanitizePreservesWFInfraPolicyByName pins M-2: a _workflow-dns-policy +// record is identified by NAME (authoritative) even if its value somehow +// looks secret-ish. A record named _workflow-dns-policy must never be redacted. +func TestSanitizePreservesWFInfraPolicyByName(t *testing.T) { + // A value that would otherwise trip the long-base64 secret heuristic. + longValue := "heritage=wfinfra-v1 o=gocodealone p=AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJKKKKLLLLMMMMNNNNOOOOPPPPQQQQRRRRSSSSTTTTUUUU t=A,CNAME,TXT,MX,AAAA,SRV,CAA" + p := record.Portfolio{ + Schema: record.SchemaV1, + Snapshots: []record.Snapshot{{ + Provider: "digitalocean", + Domain: "example.com", + Records: []record.Record{ + {Type: "TXT", Name: "_workflow-dns-policy.example.com", Value: longValue, TTL: 300}, + }, + }}, + } + record.Sanitize(&p) + got := p.Snapshots[0].Records[0].Value + if got != longValue { + t.Errorf("_workflow-dns-policy (by name) TXT was changed to %q; must be preserved", got) + } +} + // TestSanitizePreservesPlainTXT pins that plain TXT records (SPF, DMARC, // site verification without long base64 blobs) are NOT redacted. func TestSanitizePreservesPlainTXT(t *testing.T) {