// Package output removes credentials from diagnostics before they reach an operator terminal. package output import ( "bytes" "encoding/json" "errors" "io" "regexp" "sort" "strings" "github.com/aritmolab/thothii/tools/tht/internal/safeio" "github.com/compose-spec/compose-go/v2/dotenv" ) var credentialField = regexp.MustCompile(`(?im)((?:"|')?[\w.-]*(?:password|token|key|secret|credential)[\w.-]*(?:"|')?\s*[:=]\s*)(?:"(?:\\.|[^"\\\r\n])*"|'[^'\r\n]*'|[^\s,;}]+)`) var dotenvAssignment = regexp.MustCompile(`(?m)^\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_.-]*\s*=`) const maxSecretFileBytes = 64 * 1024 const maxSecretSourceFiles = 32 const maxSecretSourceBytes = 256 * 1024 const maxSecretValuesPerFile = 1024 const maxSecretValues = 4096 const maxJSONSecretDepth = 32 const maxDiagnosticDetailBytes = 512 // Sanitize redacts common credential fields and every supplied secret value. func Sanitize(text string, secretValues []string) string { text = credentialField.ReplaceAllString(text, "${1}[REDACTED]") values := append([]string(nil), secretValues...) sort.Slice(values, func(i, j int) bool { return len(values[i]) > len(values[j]) }) for _, value := range values { if value != "" { text = strings.ReplaceAll(text, value, "[REDACTED]") if encoded, err := json.Marshal(value); err == nil { text = strings.ReplaceAll(text, string(encoded), "[REDACTED]") } } } return text } // SanitizeDetail redacts the complete subprocess detail before normalizing and bounding the text // that may be displayed at the CLI boundary. func SanitizeDetail(text string, secretValues []string) string { detail := strings.Join(strings.Fields(Sanitize(text, secretValues)), " ") if len(detail) <= maxDiagnosticDetailBytes { return detail } var bounded strings.Builder for _, character := range detail { encoded := string(character) if bounded.Len()+len(encoded) > maxDiagnosticDetailBytes { break } bounded.WriteString(encoded) } return bounded.String() } // SecretValuesFromFiles reads non-empty secret-file contents without exposing them to callers. func SecretValuesFromFiles(paths []string) ([]string, error) { if len(paths) > maxSecretSourceFiles { return nil, errors.New("declared secret file could not be read") } values := make([]string, 0, len(paths)) seen := make(map[string]struct{}) var totalBytes int64 for _, path := range paths { contents, size, err := readSecretFile(path) if err != nil { return nil, err } totalBytes += size if totalBytes > maxSecretSourceBytes { return nil, errors.New("declared secret file could not be read") } extracted, err := extractSecretValues(contents) if err != nil { return nil, errors.New("declared secret file could not be read") } for _, value := range extracted { if value == "" { continue } if _, exists := seen[value]; exists { continue } values = append(values, value) seen[value] = struct{}{} if len(values) > maxSecretValues { return nil, errors.New("declared secret file could not be read") } } } return values, nil } func readSecretFile(path string) ([]byte, int64, error) { contents, err := safeio.ReadCanonicalRegular(path, maxSecretFileBytes) if err != nil { return nil, 0, errors.New("declared secret file could not be read") } return contents, int64(len(contents)), nil } func extractSecretValues(contents []byte) ([]string, error) { whole := strings.TrimRight(string(contents), "\r\n") trimmed := bytes.TrimSpace(contents) if len(trimmed) == 0 { return nil, nil } values := make([]string, 0, 8) if whole != "" { values = append(values, whole) } // PEM files (including OpenSSH private keys) can contain base64 lines that look // like dotenv assignments. Keep the complete document opaque instead of trying // to parse it as a dotenv bundle. if bytes.HasPrefix(trimmed, []byte("-----BEGIN ")) { return values, nil } if trimmed[0] == '{' || trimmed[0] == '[' { var document any decoder := json.NewDecoder(bytes.NewReader(trimmed)) decoder.UseNumber() if err := decoder.Decode(&document); err != nil { return nil, err } var extra any if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { if err == nil { return nil, errors.New("secret JSON contains multiple documents") } return nil, err } count := 0 if err := collectJSONSecretValues(document, 0, &count, &values); err != nil { return nil, err } return values, nil } if dotenvAssignment.Match(trimmed) { parsed, err := dotenv.Parse(bytes.NewReader(contents)) if err != nil || len(parsed) > maxSecretValuesPerFile { return nil, errors.New("secret dotenv bundle is invalid") } keys := make([]string, 0, len(parsed)) for key := range parsed { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { if parsed[key] != "" { values = append(values, parsed[key]) } } } return values, nil } func collectJSONSecretValues(value any, depth int, count *int, values *[]string) error { if depth > maxJSONSecretDepth { return errors.New("secret JSON nesting is too deep") } switch typed := value.(type) { case map[string]any: keys := make([]string, 0, len(typed)) for key := range typed { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { if err := collectJSONSecretValues(typed[key], depth+1, count, values); err != nil { return err } } case []any: for _, item := range typed { if err := collectJSONSecretValues(item, depth+1, count, values); err != nil { return err } } default: *count++ if *count > maxSecretValuesPerFile { return errors.New("secret JSON contains too many scalar values") } if scalar, ok := typed.(string); ok && scalar != "" { *values = append(*values, scalar) } } return nil }