199 lines
5.4 KiB
Go
199 lines
5.4 KiB
Go
// 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)
|
|
}
|
|
|
|
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
|
|
}
|