Repository: gitleaks/gitleaks
Pull request #1854 (2025-05-13 02:03:34)

Attempts to introduce escaped unicode decoding
### Description:
Attempts to introduce escaped unicode decoding. This supports two kinds of escaped unicode; standard notation and common escape `\`, `\\` sequences.

### Checklist:

* [x] Does your PR pass tests?
* [x] Have you written new tests for your changes?
* [x] Have you lint your code locally prior to submission?


zricethezav: @bplaxco would love to see if this significantly slows down your benchmarks

Patch:
diff --git a/README.md b/README.md
index 7ba4589..7b86d43 100644
--- a/README.md
+++ b/README.md
@@ -144,7 +144,6 @@ Usage:
   gitleaks [command]
 
 Available Commands:
-  completion  generate the autocompletion script for the specified shell
   dir         scan directories or files for secrets
   git         scan git repositories for secrets
   help        Help about any command
@@ -160,6 +159,8 @@ Flags:
                                       3. env var GITLEAKS_CONFIG_TOML with the file content
                                       4. (target path)/.gitleaks.toml
                                       If none of the four options are used, then gitleaks will use the default config
+      --diagnostics string            enable diagnostics (comma-separated list: cpu,mem,trace). cpu=CPU profiling, mem=memory profiling, trace=execution tracing
+      --diagnostics-dir string        directory to store diagnostics output files (defaults to current directory)
       --enable-rule strings           only enable specific rules by id
       --exit-code int                 exit code when leaks have been encountered (default 1)
   -i, --gitleaks-ignore-path string   path to .gitleaksignore file or folder containing one (default ".")
@@ -171,7 +172,7 @@ Flags:
       --no-banner                     suppress banner
       --no-color                      turn off color for verbose output
       --redact uint[=100]             redact secrets from logs and stdout. To redact only parts of the secret just apply a percent value from 0..100. For example --redact=20 (default 100%)
-  -f, --report-format string          output format (json, csv, junit, sarif) (default "json")
+  -f, --report-format string          output format (json, csv, junit, sarif, template)
   -r, --report-path string            report file
       --report-template string        template file used to generate the report (implies --report-format=template)
   -v, --verbose                       show verbose output from scan
diff --git a/cmd/detect.go b/cmd/detect.go
index 494c495..f601ee0 100644
--- a/cmd/detect.go
+++ b/cmd/detect.go
@@ -55,6 +55,7 @@ func runDetect(cmd *cobra.Command, args []string) {
 
 	// setup config (aka, the thing that defines rules)
 	initConfig(source)
+	initDiagnostics()
 	cfg := Config(cmd)
 
 	// create detector
diff --git a/cmd/diagnostics.go b/cmd/diagnostics.go
new file mode 100644
index 0000000..64eacd1
--- /dev/null
+++ b/cmd/diagnostics.go
@@ -0,0 +1,209 @@
+package cmd
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"runtime"
+	"runtime/pprof"
+	"runtime/trace"
+	"strings"
+
+	"github.com/zricethezav/gitleaks/v8/logging"
+)
+
+// DiagnosticsManager manages various types of diagnostics
+type DiagnosticsManager struct {
+	Enabled      bool
+	DiagTypes    []string
+	OutputDir    string
+	cpuProfile   *os.File
+	memProfile   string
+	traceProfile *os.File
+}
+
+// NewDiagnosticsManager creates a new DiagnosticsManager instance
+func NewDiagnosticsManager(diagnosticsFlag string, diagnosticsDir string) (*DiagnosticsManager, error) {
+	if diagnosticsFlag == "" {
+		return &DiagnosticsManager{Enabled: false}, nil
+	}
+
+	dm := &DiagnosticsManager{
+		Enabled:   true,
+		DiagTypes: strings.Split(diagnosticsFlag, ","),
+		OutputDir: diagnosticsDir,
+	}
+
+	// If no output directory is specified, use the current directory
+	if dm.OutputDir == "" {
+		var err error
+		dm.OutputDir, err = os.Getwd()
+		if err != nil {
+			return nil, fmt.Errorf("failed to get current directory: %w", err)
+		}
+		logging.Debug().Msgf("No diagnostics directory specified, using current directory: %s", dm.OutputDir)
+	}
+
+	// Create the output directory if it doesn't exist
+	if err := os.MkdirAll(dm.OutputDir, 0755); err != nil {
+		return nil, fmt.Errorf("failed to create diagnostics directory: %w", err)
+	}
+
+	// Make sure the output directory is absolute
+	if !filepath.IsAbs(dm.OutputDir) {
+		absPath, err := filepath.Abs(dm.OutputDir)
+		if err != nil {
+			return nil, fmt.Errorf("failed to get absolute path for diagnostics directory: %w", err)
+		}
+		dm.OutputDir = absPath
+	}
+
+	logging.Debug().Msgf("Diagnostics enabled: %s", strings.Join(dm.DiagTypes, ","))
+	logging.Debug().Msgf("Diagnostics output directory: %s", dm.OutputDir)
+
+	return dm, nil
+}
+
+// StartDiagnostics starts all enabled diagnostics
+func (dm *DiagnosticsManager) StartDiagnostics() error {
+	if !dm.Enabled {
+		return nil
+	}
+
+	var err error
+
+	for _, diagType := range dm.DiagTypes {
+		diagType = strings.TrimSpace(diagType)
+		switch diagType {
+		case "cpu":
+			if err = dm.StartCPUProfile(); err != nil {
+				return err
+			}
+		case "mem":
+			if err = dm.SetupMemoryProfile(); err != nil {
+				return err
+			}
+		case "trace":
+			if err = dm.StartTraceProfile(); err != nil {
+				return err
+			}
+		default:
+			logging.Warn().Msgf("Unknown diagnostics type: %s", diagType)
+		}
+	}
+
+	return nil
+}
+
+// StopDiagnostics stops all started diagnostics
+func (dm *DiagnosticsManager) StopDiagnostics() {
+	if !dm.Enabled {
+		return
+	}
+
+	logging.Debug().Msg("Stopping diagnostics and writing profiling data...")
+
+	for _, diagType := range dm.DiagTypes {
+		diagType = strings.TrimSpace(diagType)
+		switch diagType {
+		case "cpu":
+			dm.StopCPUProfile()
+		case "mem":
+			dm.WriteMemoryProfile()
+		case "trace":
+			dm.StopTraceProfile()
+		}
+	}
+}
+
+// StartCPUProfile starts CPU profiling
+func (dm *DiagnosticsManager) StartCPUProfile() error {
+	cpuProfilePath := filepath.Join(dm.OutputDir, "cpu.pprof")
+	f, err := os.Create(cpuProfilePath)
+	if err != nil {
+		return fmt.Errorf("could not create CPU profile at %s: %w", cpuProfilePath, err)
+	}
+
+	if err := pprof.StartCPUProfile(f); err != nil {
+		f.Close()
+		return fmt.Errorf("could not start CPU profile: %w", err)
+	}
+
+	dm.cpuProfile = f
+	return nil
+}
+
+// StopCPUProfile stops CPU profiling
+func (dm *DiagnosticsManager) StopCPUProfile() {
+	if dm.cpuProfile != nil {
+		pprof.StopCPUProfile()
+		if err := dm.cpuProfile.Close(); err != nil {
+			logging.Error().Err(err).Msg("Error closing CPU profile file")
+		}
+		logging.Info().Msgf("CPU profile written to: %s", dm.cpuProfile.Name())
+		dm.cpuProfile = nil
+	}
+}
+
+// SetupMemoryProfile sets up memory profiling to be written when StopDiagnostics is called
+func (dm *DiagnosticsManager) SetupMemoryProfile() error {
+	memProfilePath := filepath.Join(dm.OutputDir, "mem.pprof")
+	dm.memProfile = memProfilePath
+	return nil
+}
+
+// WriteMemoryProfile writes the memory profile to disk
+func (dm *DiagnosticsManager) WriteMemoryProfile() {
+	if dm.memProfile == "" {
+		return
+	}
+
+	f, err := os.Create(dm.memProfile)
+	if err != nil {
+		logging.Error().Err(err).Msgf("Could not create memory profile at %s", dm.memProfile)
+		return
+	}
+
+	// Get memory profile
+	runtime.GC() // Run GC before taking the memory profile
+	if err := pprof.WriteHeapProfile(f); err != nil {
+		logging.Error().Err(err).Msg("Could not write memory profile")
+	} else {
+		logging.Info().Msgf("Memory profile written to: %s", dm.memProfile)
+	}
+
+	if err := f.Close(); err != nil {
+		logging.Error().Err(err).Msg("Error closing memory profile file")
+	}
+
+	dm.memProfile = ""
+}
+
+// StartTraceProfile starts execution tracing
+func (dm *DiagnosticsManager) StartTraceProfile() error {
+	traceProfilePath := filepath.Join(dm.OutputDir, "trace.out")
+	f, err := os.Create(traceProfilePath)
+	if err != nil {
+		return fmt.Errorf("could not create trace profile at %s: %w", traceProfilePath, err)
+	}
+
+	if err := trace.Start(f); err != nil {
+		f.Close()
+		return fmt.Errorf("could not start trace profile: %w", err)
+	}
+
+	dm.traceProfile = f
+	return nil
+}
+
+// StopTraceProfile stops execution tracing
+func (dm *DiagnosticsManager) StopTraceProfile() {
+	if dm.traceProfile != nil {
+		trace.Stop()
+		if err := dm.traceProfile.Close(); err != nil {
+			logging.Error().Err(err).Msg("Error closing trace profile file")
+		}
+		logging.Info().Msgf("Trace profile written to: %s", dm.traceProfile.Name())
+		dm.traceProfile = nil
+	}
+}
diff --git a/cmd/directory.go b/cmd/directory.go
index 0467d83..cda835a 100644
--- a/cmd/directory.go
+++ b/cmd/directory.go
@@ -31,7 +31,10 @@ func runDirectory(cmd *cobra.Command, args []string) {
 			source = "."
 		}
 	}
+
 	initConfig(source)
+	initDiagnostics()
+
 	var (
 		findings []report.Finding
 		err      error
diff --git a/cmd/git.go b/cmd/git.go
index 59e3fa3..04c93e9 100644
--- a/cmd/git.go
+++ b/cmd/git.go
@@ -42,6 +42,8 @@ func runGit(cmd *cobra.Command, args []string) {
 
 	// setup config (aka, the thing that defines rules)
 	initConfig(source)
+	initDiagnostics()
+
 	cfg := Config(cmd)
 
 	// create detector
diff --git a/cmd/protect.go b/cmd/protect.go
index 3cb57e2..c4d575e 100644
--- a/cmd/protect.go
+++ b/cmd/protect.go
@@ -33,6 +33,8 @@ func runProtect(cmd *cobra.Command, args []string) {
 
 	// setup config (aka, the thing that defines rules)
 	initConfig(source)
+	initDiagnostics()
+
 	cfg := Config(cmd)
 
 	// create detector
diff --git a/cmd/root.go b/cmd/root.go
index 5511c4a..f06e38a 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -38,11 +38,17 @@ order of precedence:
 4. (target path)/.gitleaks.toml
 If none of the four options are used, then gitleaks will use the default config`
 
-var rootCmd = &cobra.Command{
-	Use:     "gitleaks",
-	Short:   "Gitleaks scans code, past or present, for secrets",
-	Version: Version,
-}
+var (
+	rootCmd = &cobra.Command{
+		Use:     "gitleaks",
+		Short:   "Gitleaks scans code, past or present, for secrets",
+		Version: Version,
+	}
+
+	// diagnostics manager is global to ensure it can be started before a scan begins
+	// and stopped after a scan completes
+	diagnosticsManager *DiagnosticsManager
+)
 
 const (
 	BYTE     = 1.0
@@ -71,6 +77,10 @@ func init() {
 	rootCmd.PersistentFlags().StringP("gitleaks-ignore-path", "i", ".", "path to .gitleaksignore file or folder containing one")
 	rootCmd.PersistentFlags().Int("max-decode-depth", 0, "allow recursive decoding up to this depth (default \"0\", no decoding is done)")
 
+	// Add diagnostics flags
+	rootCmd.PersistentFlags().String("diagnostics", "", "enable diagnostics (comma-separated list: cpu,mem,trace). cpu=CPU profiling, mem=memory profiling, trace=execution tracing")
+	rootCmd.PersistentFlags().String("diagnostics-dir", "", "directory to store diagnostics output files (defaults to current directory)")
+
 	err := viper.BindPFlag("config", rootCmd.PersistentFlags().Lookup("config"))
 	if err != nil {
 		logging.Fatal().Msgf("err binding config %s", err.Error())
@@ -169,6 +179,33 @@ func initConfig(source string) {
 	}
 }
 
+func initDiagnostics() {
+	// Initialize diagnostics manager
+	diagnosticsFlag, err := rootCmd.PersistentFlags().GetString("diagnostics")
+	if err != nil {
+		logging.Fatal().Err(err).Msg("Error getting diagnostics flag")
+	}
+
+	diagnosticsDir, err := rootCmd.PersistentFlags().GetString("diagnostics-dir")
+	if err != nil {
+		logging.Fatal().Err(err).Msg("Error getting diagnostics-dir flag")
+	}
+
+	var diagErr error
+	diagnosticsManager, diagErr = NewDiagnosticsManager(diagnosticsFlag, diagnosticsDir)
+	if diagErr != nil {
+		logging.Fatal().Err(diagErr).Msg("Error initializing diagnostics")
+	}
+
+	if diagnosticsManager.Enabled {
+		logging.Info().Msg("Starting diagnostics...")
+		if diagErr := diagnosticsManager.StartDiagnostics(); diagErr != nil {
+			logging.Fatal().Err(diagErr).Msg("Failed to start diagnostics")
+		}
+	}
+
+}
+
 func Execute() {
 	if err := rootCmd.Execute(); err != nil {
 		if strings.Contains(err.Error(), "unknown flag") {
@@ -379,6 +416,11 @@ func bytesConvert(bytes uint64) string {
 }
 
 func findingSummaryAndExit(detector *detect.Detector, findings []report.Finding, exitCode int, start time.Time, err error) {
+	if diagnosticsManager.Enabled {
+		logging.Debug().Msg("Finalizing diagnostics...")
+		diagnosticsManager.StopDiagnostics()
+	}
+
 	totalBytes := detector.TotalBytes.Load()
 	bytesMsg := fmt.Sprintf("scanned ~%d bytes (%s)", totalBytes, bytesConvert(totalBytes))
 	if err == nil {
diff --git a/cmd/stdin.go b/cmd/stdin.go
index 1875bb7..d5a89b7 100644
--- a/cmd/stdin.go
+++ b/cmd/stdin.go
@@ -25,6 +25,8 @@ func runStdIn(cmd *cobra.Command, _ []string) {
 
 	// setup config (aka, the thing that defines rules)
 	initConfig(".")
+	initDiagnostics()
+
 	cfg := Config(cmd)
 
 	// create detector
diff --git a/detect/codec/decoder.go b/detect/codec/decoder.go
index be7a81a..1a68d4f 100644
--- a/detect/codec/decoder.go
+++ b/detect/codec/decoder.go
@@ -2,6 +2,7 @@ package codec
 
 import (
 	"bytes"
+
 	"github.com/zricethezav/gitleaks/v8/logging"
 )
 
@@ -89,13 +90,15 @@ func (d *Decoder) findEncodedSegments(data string, predecessors []*EncodedSegmen
 		}
 
 		segments = append(segments, segment)
-		logging.Debug().Msgf(
-			"segment found: original=%s pos=%s: %q -> %q",
-			segment.original,
-			segment.encoded,
-			encodedValue,
-			segment.decodedValue,
-		)
+		logging.Debug().
+			Str("decoder", m.encoding.kind.String()).
+			Msgf(
+				"segment found: original=%s pos=%s: %q -> %q",
+				segment.original,
+				segment.encoded,
+				encodedValue,
+				segment.decodedValue,
+			)
 	}
 
 	return segments
diff --git a/detect/codec/encodings.go b/detect/codec/encodings.go
index 16d6178..d5b5eec 100644
--- a/detect/codec/encodings.go
+++ b/detect/codec/encodings.go
@@ -19,17 +19,22 @@ var (
 	// (e.g. base64). If two encoding matches overlap the decoder will use
 	// this order to determine which encoding should wait till the next pass.
 	encodings = []*encoding{
-		&encoding{
+		{
 			kind:    percentKind,
 			pattern: `%[0-9A-Fa-f]{2}(?:.*%[0-9A-Fa-f]{2})?`,
 			decode:  decodePercent,
 		},
-		&encoding{
+		{
+			kind:    unicodeKind,
+			pattern: `(?:(?:U\+[a-fA-F0-9]{4}(?:\s|$))+|(?i)(?:\\{1,2}u[a-fA-F0-9]{4})+)`,
+			decode:  decodeUnicode,
+		},
+		{
 			kind:    hexKind,
 			pattern: `[0-9A-Fa-f]{32,}`,
 			decode:  decodeHex,
 		},
-		&encoding{
+		{
 			kind:    base64Kind,
 			pattern: `[\w\/+-]{16,}={0,2}`,
 			decode:  decodeBase64,
@@ -40,6 +45,7 @@ var (
 // encodingNames is used to map the encodingKinds to their name
 var encodingNames = []string{
 	"percent",
+	"unicode",
 	"hex",
 	"base64",
 }
@@ -51,8 +57,9 @@ type encodingKind int
 var (
 	// make sure these go up by powers of 2
 	percentKind = encodingKind(1)
-	hexKind     = encodingKind(2)
-	base64Kind  = encodingKind(4)
+	unicodeKind = encodingKind(2)
+	hexKind     = encodingKind(4)
+	base64Kind  = encodingKind(8)
 )
 
 func (e encodingKind) String() string {
diff --git a/detect/codec/unicode.go b/detect/codec/unicode.go
new file mode 100644
index 0000000..69f7a84
--- /dev/null
+++ b/detect/codec/unicode.go
@@ -0,0 +1,260 @@
+package codec
+
+import (
+	"bytes"
+	"regexp"
+	"strconv"
+	"strings"
+	"unicode/utf8"
+)
+
+var (
+	// Standard Unicode notation (e.g., U+1234)
+	unicodeCodePointPat = regexp.MustCompile(`U\+([a-fA-F0-9]{4}).?`)
+
+	// Multiple code points pattern - used for continuous sequences like "U+0074 U+006F U+006B..."
+	unicodeMultiCodePointPat = regexp.MustCompile(`(?:U\+[a-fA-F0-9]{4}(?:\s|$))+`)
+
+	// Common escape sequence used in programming languages (e.g., \u1234)
+	unicodeEscapePat = regexp.MustCompile(`(?i)\\{1,2}u([a-fA-F0-9]{4})`)
+
+	// Multiple escape sequences pattern - used for continuous sequences like "\u0074\u006F\u006B..."
+	unicodeMultiEscapePat = regexp.MustCompile(`(?i)(?:\\{1,2}u[a-fA-F0-9]{4})+`)
+)
+
+// Unicode characters are encoded as 1 to 4 bytes per rune.
+const maxBytesPerRune = 4
+
+// decodeUnicode decodes Unicode escape sequences in the given string
+func decodeUnicode(encodedValue string) string {
+	// First, check if we have a continuous sequence of Unicode code points
+	if matches := unicodeMultiCodePointPat.FindAllString(encodedValue, -1); len(matches) > 0 {
+		// For each detected sequence of code points
+		for _, match := range matches {
+			// Decode the entire sequence at once
+			decodedSequence := decodeMultiCodePoint(match)
+
+			// If we successfully decoded something, replace it in the original string
+			if decodedSequence != "" && decodedSequence != match {
+				encodedValue = strings.Replace(encodedValue, match, decodedSequence, 1)
+			}
+		}
+		return encodedValue
+	}
+
+	// Next, check if we have a continuous sequence of escape sequences
+	if matches := unicodeMultiEscapePat.FindAllString(encodedValue, -1); len(matches) > 0 {
+		// For each detected sequence of escape sequences
+		for _, match := range matches {
+			// Decode the entire sequence at once
+			decodedSequence := decodeMultiEscape(match)
+
+			// If we successfully decoded something, replace it in the original string
+			if decodedSequence != "" && decodedSequence != match {
+				encodedValue = strings.Replace(encodedValue, match, decodedSequence, 1)
+			}
+		}
+		return encodedValue
+	}
+
+	// If no multi-patterns were matched, fall back to the original implementation
+	// for individual code points and escape sequences
+
+	// Create a copy of the input to work with
+	data := []byte(encodedValue)
+
+	// Store the result
+	var result []byte
+
+	// Check and decode Unicode code points (U+1234 format)
+	if unicodeCodePointPat.Match(data) {
+		result = decodeIndividualCodePoints(data)
+	}
+
+	// If no code points were found or we have a mix of formats,
+	// also check for Unicode escape sequences (\u1234 format)
+	if len(result) == 0 || unicodeEscapePat.Match(data) {
+		// If we already have some result from code point decoding,
+		// continue decoding escape sequences on that result
+		if len(result) > 0 {
+			result = decodeIndividualEscapes(result)
+		} else {
+			result = decodeIndividualEscapes(data)
+		}
+	}
+
+	// If nothing was decoded, return original string
+	if len(result) == 0 || bytes.Equal(result, data) {
+		return encodedValue
+	}
+
+	return string(result)
+}
+
+// decodeMultiCodePoint decodes a continuous sequence of Unicode code points (U+XXXX format)
+func decodeMultiCodePoint(sequence string) string {
+	// If the sequence is empty, return empty string
+	if sequence == "" {
+		return ""
+	}
+
+	// Split the sequence by whitespace to get individual code points
+	codePoints := strings.Fields(sequence)
+	if len(codePoints) == 0 {
+		return sequence
+	}
+
+	// Decode each code point and build the result
+	var decodedRunes []rune
+	for _, cp := range codePoints {
+		// Check if it follows the U+XXXX pattern
+		if !strings.HasPrefix(cp, "U+") || len(cp) < 6 {
+			continue
+		}
+
+		// Extract the hexadecimal value
+		hexValue := cp[2:]
+
+		// Parse the hexadecimal value to an integer
+		unicodeInt, err := strconv.ParseInt(hexValue, 16, 32)
+		if err != nil {
+			continue
+		}
+
+		// Convert to rune and add to result
+		decodedRunes = append(decodedRunes, rune(unicodeInt))
+	}
+
+	// If we didn't decode anything, return the original sequence
+	if len(decodedRunes) == 0 {
+		return sequence
+	}
+
+	// Return the decoded string
+	return string(decodedRunes)
+}
+
+// decodeMultiEscape decodes a continuous sequence of Unicode escape sequences (\uXXXX format)
+func decodeMultiEscape(sequence string) string {
+	// If the sequence is empty, return empty string
+	if sequence == "" {
+		return ""
+	}
+
+	// Find all escape sequences
+	escapes := unicodeEscapePat.FindAllStringSubmatch(sequence, -1)
+	if len(escapes) == 0 {
+		return sequence
+	}
+
+	// Decode each escape sequence and build the result
+	var decodedRunes []rune
+	for _, esc := range escapes {
+		// Extract the hexadecimal value
+		hexValue := esc[1]
+
+		// Parse the hexadecimal value to an integer
+		unicodeInt, err := strconv.ParseInt(hexValue, 16, 32)
+		if err != nil {
+			continue
+		}
+
+		// Convert to rune and add to result
+		decodedRunes = append(decodedRunes, rune(unicodeInt))
+	}
+
+	// If we didn't decode anything, return the original sequence
+	if len(decodedRunes) == 0 {
+		return sequence
+	}
+
+	// Return the decoded string
+	return string(decodedRunes)
+}
+
+// decodeIndividualCodePoints decodes individual Unicode code points (U+1234 format)
+// This is a fallback for when we don't have a continuous sequence of code points
+func decodeIndividualCodePoints(input []byte) []byte {
+	// Find all Unicode code point sequences in the input byte slice
+	indices := unicodeCodePointPat.FindAllSubmatchIndex(input, -1)
+
+	// If none found, return original input
+	if len(indices) == 0 {
+		return input
+	}
+
+	// Iterate over found indices in reverse order to avoid modifying the slice length
+	utf8Bytes := make([]byte, maxBytesPerRune)
+	for i := len(indices) - 1; i >= 0; i-- {
+		matches := indices[i]
+
+		startIndex := matches[0]
+		endIndex := matches[1]
+		hexStartIndex := matches[2]
+		hexEndIndex := matches[3]
+
+		// If the input is like `U+1234 U+5678` we should replace `U+1234 `.
+		// Otherwise, we should only replace `U+1234`.
+		if endIndex != hexEndIndex && endIndex < len(input) && input[endIndex-1] == ' ' {
+			endIndex = endIndex - 1
+		}
+
+		// Extract the hexadecimal value from the escape sequence
+		hexValue := string(input[hexStartIndex:hexEndIndex])
+
+		// Parse the hexadecimal value to an integer
+		unicodeInt, err := strconv.ParseInt(hexValue, 16, 32)
+		if err != nil {
+			// If there's an error, continue to the next escape sequence
+			continue
+		}
+
+		// Convert the Unicode code point to a UTF-8 representation
+		utf8Len := utf8.EncodeRune(utf8Bytes, rune(unicodeInt))
+
+		// Replace the escape sequence with the UTF-8 representation
+		input = append(input[:startIndex], append(utf8Bytes[:utf8Len], input[endIndex:]...)...)
+	}
+
+	return input
+}
+
+// decodeIndividualEscapes decodes individual Unicode escape sequences (\u1234 format)
+// This is a fallback for when we don't have a continuous sequence of escape sequences
+func decodeIndividualEscapes(input []byte) []byte {
+	// Find all Unicode escape sequences in the input byte slice
+	indices := unicodeEscapePat.FindAllSubmatchIndex(input, -1)
+
+	// If none found, return original input
+	if len(indices) == 0 {
+		return input
+	}
+
+	// Iterate over found indices in reverse order to avoid modifying the slice length
+	utf8Bytes := make([]byte, maxBytesPerRune)
+	for i := len(indices) - 1; i >= 0; i-- {
+		matches := indices[i]
+
+		startIndex := matches[0]
+		hexStartIndex := matches[2]
+		endIndex := matches[3]
+
+		// Extract the hexadecimal value from the escape sequence
+		hexValue := string(input[hexStartIndex:endIndex])
+
+		// Parse the hexadecimal value to an integer
+		unicodeInt, err := strconv.ParseInt(hexValue, 16, 32)
+		if err != nil {
+			// If there's an error, continue to the next escape sequence
+			continue
+		}
+
+		// Convert the Unicode code point to a UTF-8 representation
+		utf8Len := utf8.EncodeRune(utf8Bytes, rune(unicodeInt))
+
+		// Replace the escape sequence with the UTF-8 representation
+		input = append(input[:startIndex], append(utf8Bytes[:utf8Len], input[endIndex:]...)...)
+	}
+
+	return input
+}


Test patch:
diff --git a/detect/codec/decoder_test.go b/detect/codec/decoder_test.go
index 896232d..e6cd593 100644
--- a/detect/codec/decoder_test.go
+++ b/detect/codec/decoder_test.go
@@ -2,9 +2,10 @@ package codec
 
 import (
 	"encoding/hex"
-	"github.com/stretchr/testify/assert"
 	"net/url"
 	"testing"
+
+	"github.com/stretchr/testify/assert"
 )
 
 func TestDecode(t *testing.T) {
@@ -90,6 +91,21 @@ func TestDecode(t *testing.T) {
 			chunk:    `secret="466973684D617048756E6B79212121363334"`,
 			expected: `secret="FishMapHunky!!!634"`,
 		},
+		{
+			name:     "unicode encoded value",
+			chunk:    `secret=U+0061 U+0062 U+0063 U+0064 U+0065 U+0066`,
+			expected: "secret=abcdef",
+		},
+		{
+			name:     "unicode encoded value backslashed",
+			chunk:    `secret=\\u0068\\u0065\\u006c\\u006c\\u006f\\u0020\\u0077\\u006f\\u0072\\u006c\\u0064\\u0020\\u0064\\u0075\\u0064\\u0065`,
+			expected: "secret=hello world dude",
+		},
+		{
+			name:     "unicode encoded value backslashed mixed w/ hex",
+			chunk:    `secret=\u0068\u0065\u006c\u006c\u006f\u0020\u0077\u006f\u0072\u006c\u0064 6C6F76656C792070656F706C65206F66206561727468`,
+			expected: "secret=hello world lovely people of earth",
+		},
 	}
 
 	decoder := NewDecoder()