diff --git a/internal/app/audit.go b/internal/app/audit.go index df70cb3..55980a0 100644 --- a/internal/app/audit.go +++ b/internal/app/audit.go @@ -95,8 +95,10 @@ type auditRecorder struct { } var ( - sensitiveAssignmentRE = regexp.MustCompile(`(?i)\b(password|passwd|pwd|token|secret|api[_-]?key|access[_-]?key)=([^\s&;]+)`) - sensitiveFlagRE = regexp.MustCompile(`(?i)(--(?:password|passwd|token|secret|api-key|access-key)(?:=|\s+))([^\s]+)`) + sensitiveQuotedAssignmentRE = regexp.MustCompile(`(?i)\b(password|passwd|pwd|token|secret|api[_-]?key|access[_-]?key)=("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')`) + sensitiveQuotedFlagRE = regexp.MustCompile(`(?i)(--(?:password|passwd|token|secret|api-key|access-key)(?:=|\s+))("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')`) + sensitiveAssignmentRE = regexp.MustCompile(`(?i)\b(password|passwd|pwd|token|secret|api[_-]?key|access[_-]?key)=([^\s&;]+)`) + sensitiveFlagRE = regexp.MustCompile(`(?i)(--(?:password|passwd|token|secret|api-key|access-key)(?:=|\s+))([^\s]+)`) ) func newAuditRecorder(config *sshclient.Config) *auditRecorder { @@ -361,6 +363,8 @@ func redactSensitiveText(value string) string { if value == "" { return "" } + value = sensitiveQuotedAssignmentRE.ReplaceAllString(value, "$1=") + value = sensitiveQuotedFlagRE.ReplaceAllString(value, "$1") value = sensitiveAssignmentRE.ReplaceAllString(value, "$1=") value = sensitiveFlagRE.ReplaceAllString(value, "$1") return value diff --git a/internal/app/audit_test.go b/internal/app/audit_test.go index ef2f181..12f4439 100644 --- a/internal/app/audit_test.go +++ b/internal/app/audit_test.go @@ -107,6 +107,67 @@ func TestRun_BlockedCommandWritesRedactedAuditEvent(t *testing.T) { } } +//nolint:gosec // test inputs intentionally contain credential-like command arguments. +func TestRedactSensitiveTextCoversQuotedAndUnquotedValues(t *testing.T) { + tests := []struct { + name string + input string + want string + forbidden []string + }{ + { + name: "quoted assignment with spaces", + input: `deploy password="alpha bravo" tail`, + want: `deploy password= tail`, + forbidden: []string{"alpha", "bravo"}, + }, + { + name: "single quoted assignment with spaces", + input: `deploy token='charlie delta' tail`, + want: `deploy token= tail`, + forbidden: []string{"charlie", "delta"}, + }, + { + name: "quoted flag with spaces", + input: `curl --token "echo foxtrot" done`, + want: `curl --token done`, + forbidden: []string{"echo", "foxtrot"}, + }, + { + name: "quoted equals flag with spaces", + input: `curl --api-key="golf hotel" done`, + want: `curl --api-key= done`, + forbidden: []string{"golf", "hotel"}, + }, + { + name: "unquoted assignment", + input: `deploy access_key=india tail`, + want: `deploy access_key= tail`, + forbidden: []string{"india"}, + }, + { + name: "unquoted flag", + input: `curl --secret juliet done`, + want: `curl --secret done`, + forbidden: []string{"juliet"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := redactSensitiveText(tt.input) + if got != tt.want { + t.Fatalf("redactSensitiveText() = %q, want %q", got, tt.want) + } + for _, forbidden := range tt.forbidden { + if strings.Contains(got, forbidden) { + t.Fatalf("redactSensitiveText() leaked %q in %q", forbidden, got) + } + } + }) + } +} + func TestRun_DryRunDoesNotWriteAuditEvent(t *testing.T) { t.Setenv("HOME", t.TempDir()) auditDir := filepath.Join(t.TempDir(), "audit")