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..9a3c508f --- /dev/null +++ b/cmd/wfctl/infra_import_all_format_test.go @@ -0,0 +1,158 @@ +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") + } +} + +// 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) { + 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/canonicalize.go b/dns/record/canonicalize.go new file mode 100644 index 00000000..fa7e7a76 --- /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 i := range states { + st := &states[i] + 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"), + } + // 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 { + n := toInt(v) + r.Priority = &n + } + if v, ok := m["port"]; ok { + n := toInt(v) + r.Port = &n + } + if v, ok := m["weight"]; ok { + n := toInt(v) + r.Weight = &n + } + if v, ok := m["flags"]; ok { + n := toInt(v) + 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..89af4c9e --- /dev/null +++ b/dns/record/canonicalize_test.go @@ -0,0 +1,158 @@ +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) + } +} + +// 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/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") + } +} diff --git a/dns/record/sanitize.go b/dns/record/sanitize.go new file mode 100644 index 00000000..eeb13c3f --- /dev/null +++ b/dns/record/sanitize.go @@ -0,0 +1,226 @@ +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 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) { + 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.Name, r.Value) { + // Leave _workflow-dns-policy TXT intact. + continue + } + if looksLikeSecret(r.Value) { + r.Value = "[redacted]" + } + } + } + } + p.Sanitized = true +} + +// 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 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: 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 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. + // 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 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 { + ip := net.ParseIP(v) + if ip == nil { + return false + } + ip4 := ip.To4() + if ip4 == nil { + return false // not an IPv4 address + } + if ip4.IsPrivate() || ip4.IsLoopback() || ip4.IsLinkLocalUnicast() || + ip4.IsLinkLocalMulticast() || ip4.IsMulticast() || ip4.IsUnspecified() { + return false + } + // RFC-5737 documentation ranges — already example IPs. + if inRFC5737(ip4) { + return false + } + // 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 + } + // Limited broadcast. + if ip4[0] == 255 && ip4[1] == 255 && ip4[2] == 255 && ip4[3] == 255 { + return false + } + return true +} + +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 { + ip := net.ParseIP(v) + if ip == nil { + return false + } + if ip.To4() != nil { + return false // an IPv4 address, not IPv6 + } + if !ip.IsGlobalUnicast() || ip.IsPrivate() { + return false + } + // Already an RFC-3849 documentation address. + if rfc3849Net != nil && rfc3849Net.Contains(ip) { + return false + } + return true +} + +// ── small helpers ───────────────────────────────────────────────────────── + +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 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 + // with at most 5% non-base64 chars. + base64Chars := 0 + for i := 0; i < len(s); i++ { + if isBase64Char(s[i]) { + base64Chars++ + } + } + return base64Chars*100/len(s) >= 95 +} diff --git a/dns/record/sanitize_test.go b/dns/record/sanitize_test.go new file mode 100644 index 00000000..70220b71 --- /dev/null +++ b/dns/record/sanitize_test.go @@ -0,0 +1,332 @@ +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 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: publicIP, TTL: 300}, + }, + }}, + } + record.Sanitize(&p) + got := p.Snapshots[0].Records[0].Value + 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 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) { + cases := []string{ + "2606:4700:4700::1111", // Cloudflare public resolver + "2607:f8b0:4004:c07::64", // Google + } + 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/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) { + 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{{ + 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("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) + } + } +} + +// 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) + } +} + +// 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. +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) + } +} + +// 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) { + 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.") +}