// Package redaction sanitizes diagnostic representations without modifying domain data. package redaction import ( "bytes" "encoding/json" "fmt" "sort" "strings" "gopkg.in/yaml.v3" ) // Redactor replaces configured values in a single pass, longest matches first. // Its zero value is usable and leaves strings unchanged. type Redactor struct{ replacer *strings.Replacer } func New(secrets []string) Redactor { values := map[string]bool{} for _, secret := range secrets { if secret == "" { continue } values[secret] = true // Provider content may itself be serialized JSON inside a string field. encoded, _ := json.Marshal(secret) values[string(encoded[1:len(encoded)-1])] = true } keys := make([]string, 0, len(values)) for value := range values { keys = append(keys, value) } sort.Slice(keys, func(i, j int) bool { if len(keys[i]) == len(keys[j]) { return keys[i] < keys[j] } return len(keys[i]) > len(keys[j]) }) pairs := make([]string, 0, 2*len(keys)) for _, key := range keys { pairs = append(pairs, key, "[REDACTED]") } return Redactor{replacer: strings.NewReplacer(pairs...)} } func (r Redactor) String(value string) string { if r.replacer == nil { return value } return r.replacer.Replace(value) } // JSON serializes first, then redacts only decoded string values and keys. // Numbers retain their exact representation. Failure never returns original data. func (r Redactor) JSON(value any) ([]byte, error) { raw, err := json.Marshal(value) if err != nil { return nil, fmt.Errorf("serialize diagnostic: %s", r.String(err.Error())) } decoder := json.NewDecoder(bytes.NewReader(raw)) decoder.UseNumber() var decoded any if err := decoder.Decode(&decoded); err != nil { return nil, fmt.Errorf("decode diagnostic: %s", r.String(err.Error())) } safe, err := r.walk(decoded) if err != nil { return nil, err } return json.MarshalIndent(safe, "", " ") } func (r Redactor) walk(value any) (any, error) { switch v := value.(type) { case string: return r.String(v), nil case []any: for i := range v { var err error v[i], err = r.walk(v[i]) if err != nil { return nil, err } } case map[string]any: safe := make(map[string]any, len(v)) for key, item := range v { safeKey := r.String(key) if _, exists := safe[safeKey]; exists { return nil, fmt.Errorf("redaction would produce duplicate object keys") } safeItem, err := r.walk(item) if err != nil { return nil, err } safe[safeKey] = safeItem } return safe, nil } return value, nil } // YAML preserves scalar types while sanitizing string nodes, including map keys. func (r Redactor) YAML(raw []byte) ([]byte, error) { var node yaml.Node if err := yaml.Unmarshal(raw, &node); err != nil { return nil, fmt.Errorf("decode diagnostic YAML: %s", r.String(err.Error())) } var visit func(*yaml.Node) visit = func(n *yaml.Node) { if n.Kind == yaml.ScalarNode && n.Tag == "!!str" { n.Value = r.String(n.Value) } n.HeadComment = r.String(n.HeadComment) n.LineComment = r.String(n.LineComment) n.FootComment = r.String(n.FootComment) for _, child := range n.Content { visit(child) } } visit(&node) return yaml.Marshal(&node) }