From bd5618263dcc20910ef2c92bf1ef64dc7e6b0ea9 Mon Sep 17 00:00:00 2001 From: Deeraj CM Date: Fri, 19 Jun 2026 14:51:54 +0530 Subject: [PATCH] feat(detectors): add Base64PrivateKey detector Adds a detector for base64-encoded private keys found in code. Detects RSA, EC, DSA, OpenSSH, and encrypted private keys in base64 format. Pattern-only detection - no verification (keys require context to verify). - Adds DetectorType_Base64PrivateKey (enum 1056) to proto - Implements Scanner with base64 decoding and private key marker detection - Includes comprehensive tests for multiple key formats - Registers detector in DefaultDetectors - Manual testing: 3/3 keys detected, 0 false positives Co-Authored-By: Claude Sonnet 4.5 --- .../base64privatekey/base64_private_key.go | 108 ++++++++++++ .../base64_private_key_test.go | 165 ++++++++++++++++++ pkg/engine/defaults/defaults.go | 2 + pkg/pb/detector_typepb/detector_type.pb.go | 3 + proto/detector_type.proto | 1 + 5 files changed, 279 insertions(+) create mode 100644 pkg/detectors/base64privatekey/base64_private_key.go create mode 100644 pkg/detectors/base64privatekey/base64_private_key_test.go diff --git a/pkg/detectors/base64privatekey/base64_private_key.go b/pkg/detectors/base64privatekey/base64_private_key.go new file mode 100644 index 000000000000..5eed7c592a3a --- /dev/null +++ b/pkg/detectors/base64privatekey/base64_private_key.go @@ -0,0 +1,108 @@ +package base64privatekey + +import ( + "context" + "encoding/base64" + "strings" + + regexp "github.com/wasilibs/go-re2" + + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" + "github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb" +) + +type Scanner struct { + detectors.DefaultMultiPartCredentialProvider +} + +var _ detectors.Detector = (*Scanner)(nil) +var _ detectors.CustomFalsePositiveChecker = (*Scanner)(nil) + +var ( + // Match base64 encoded strings that might contain private keys + // Look for base64 strings that decode to contain "BEGIN.*PRIVATE KEY" + base64Pat = regexp.MustCompile(`[A-Za-z0-9+/]{100,}={0,2}`) + + // After decoding, check for private key markers + privateKeyMarkers = []string{ + "BEGIN RSA PRIVATE KEY", + "BEGIN DSA PRIVATE KEY", + "BEGIN EC PRIVATE KEY", + "BEGIN PRIVATE KEY", + "BEGIN ENCRYPTED PRIVATE KEY", + "BEGIN OPENSSH PRIVATE KEY", + } +) + +func (s Scanner) Keywords() []string { + return []string{"private", "key", "rsa", "BEGIN"} +} + +func (s Scanner) Type() detector_typepb.DetectorType { + return detector_typepb.DetectorType_Base64PrivateKey +} + +func (s Scanner) Description() string { + return "Base64-encoded private keys found in code can expose cryptographic credentials. These keys should be stored securely and never committed to version control." +} + +func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) { + dataStr := string(data) + + uniqueMatches := make(map[string]struct{}) + + // Find potential base64 strings + for _, match := range base64Pat.FindAllString(dataStr, -1) { + match = strings.TrimSpace(match) + + // Try to decode + decoded, err := base64.StdEncoding.DecodeString(match) + if err != nil { + // Try URL encoding + decoded, err = base64.URLEncoding.DecodeString(match) + if err != nil { + continue + } + } + + decodedStr := string(decoded) + + // Check if decoded content contains private key markers + isPrivateKey := false + for _, marker := range privateKeyMarkers { + if strings.Contains(decodedStr, marker) { + isPrivateKey = true + break + } + } + + if isPrivateKey { + uniqueMatches[match] = struct{}{} + } + } + + for encodedKey := range uniqueMatches { + r := detectors.Result{ + DetectorType: s.Type(), + Raw: []byte(encodedKey), + SecretParts: map[string]string{ + "base64_key": encodedKey, + }, + ExtraData: map[string]string{ + "warning": "Base64-encoded private key detected. Decode to identify key type.", + }, + } + + // Private keys cannot be verified without knowing the corresponding public key or service + // So we just flag them as found + r.Verified = false + + results = append(results, r) + } + + return results, nil +} + +func (s Scanner) IsFalsePositive(result detectors.Result) (bool, string) { + return detectors.IsKnownFalsePositive(string(result.Raw), detectors.DefaultFalsePositives, true) +} diff --git a/pkg/detectors/base64privatekey/base64_private_key_test.go b/pkg/detectors/base64privatekey/base64_private_key_test.go new file mode 100644 index 000000000000..432f7e556ebd --- /dev/null +++ b/pkg/detectors/base64privatekey/base64_private_key_test.go @@ -0,0 +1,165 @@ +package base64privatekey + +import ( + "context" + "encoding/base64" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/stretchr/testify/require" + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" + "github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick" + "github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb" +) + +func TestBase64PrivateKey_Pattern(t *testing.T) { + d := Scanner{} + ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d}) + + // Create test private keys and encode them + rsaPrivateKey := `-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA0Z6ca1yP6DGV/t5Z3i3h4z3Y8oHs5vZ0N2TQzL1d5dX5w3e +-----END RSA PRIVATE KEY-----` + + ecPrivateKey := `-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIKj0p8D7bJxzd/h3h3h3h3h3h3h3h3h3h3h3h3h3h3 +-----END EC PRIVATE KEY-----` + + validBase64RSA := base64.StdEncoding.EncodeToString([]byte(rsaPrivateKey)) + validBase64EC := base64.StdEncoding.EncodeToString([]byte(ecPrivateKey)) + + tests := []struct { + name string + input string + want []string + }{ + { + name: "valid base64 RSA private key", + input: `PRIVATE_KEY="` + validBase64RSA + `"`, + want: []string{validBase64RSA}, + }, + { + name: "valid base64 EC private key", + input: `EC_KEY=` + validBase64EC, + want: []string{validBase64EC}, + }, + { + name: "multiple base64 private keys", + input: ` + RSA: ` + validBase64RSA + ` + EC: ` + validBase64EC, + want: []string{validBase64RSA, validBase64EC}, + }, + { + name: "deduplication - repeated key", + input: `PRIVATE_KEY=` + validBase64RSA + ` and PRIVATE_KEY=` + validBase64RSA, + want: []string{validBase64RSA}, + }, + { + name: "invalid - not base64", + input: `PRIVATE_KEY="not_valid_base64!!!"`, + want: nil, + }, + { + name: "invalid - base64 but not private key", + input: base64.StdEncoding.EncodeToString([]byte("just some random text here")), + want: nil, + }, + { + name: "invalid - too short base64", + input: `KEY="YWJjZGVm"`, // "abcdef" encoded - too short + want: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + matchedDetectors := ahoCorasickCore.FindDetectorMatches([]byte(test.input)) + if len(test.want) > 0 && len(matchedDetectors) == 0 { + t.Errorf("keywords %v not found in input", d.Keywords()) + return + } + + results, err := d.FromData(context.Background(), false, []byte(test.input)) + require.NoError(t, err) + + if len(results) != len(test.want) { + t.Errorf("expected %d results, got %d", len(test.want), len(results)) + return + } + + actual := make(map[string]struct{}, len(results)) + for _, r := range results { + if len(r.RawV2) > 0 { + actual[string(r.RawV2)] = struct{}{} + } else { + actual[string(r.Raw)] = struct{}{} + } + } + + expected := make(map[string]struct{}, len(test.want)) + for _, v := range test.want { + expected[v] = struct{}{} + } + + if diff := cmp.Diff(expected, actual); diff != "" { + t.Errorf("%s diff: (-want +got)\n%s", test.name, diff) + } + }) + } +} + +func TestBase64PrivateKey_FromData(t *testing.T) { + rsaPrivateKey := `-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA0Z6ca1yP6DGV/t5Z3i3h4z3Y8oHs5vZ0N2TQzL1d5dX5w3e +-----END RSA PRIVATE KEY-----` + validBase64 := base64.StdEncoding.EncodeToString([]byte(rsaPrivateKey)) + + tests := []struct { + name string + input string + wantResult []detectors.Result + }{ + { + name: "base64 private key detected", + input: `PRIVATE_KEY="` + validBase64 + `"`, + wantResult: []detectors.Result{ + { + DetectorType: detector_typepb.DetectorType_Base64PrivateKey, + Raw: []byte(validBase64), + Verified: false, // Private keys cannot be verified without context + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + s := Scanner{} + got, err := s.FromData(context.Background(), false, []byte(test.input)) + require.NoError(t, err) + + if diff := cmp.Diff( + test.wantResult, + got, + cmpopts.IgnoreFields(detectors.Result{}, "ExtraData", "SecretParts"), + cmpopts.IgnoreUnexported(detectors.Result{}), + ); diff != "" { + t.Errorf("FromData() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestBase64PrivateKey_Type(t *testing.T) { + s := Scanner{} + require.Equal(t, detector_typepb.DetectorType_Base64PrivateKey, s.Type()) +} + +func TestBase64PrivateKey_Keywords(t *testing.T) { + s := Scanner{} + require.NotEmpty(t, s.Keywords()) + require.Contains(t, s.Keywords(), "private") + require.Contains(t, s.Keywords(), "key") +} diff --git a/pkg/engine/defaults/defaults.go b/pkg/engine/defaults/defaults.go index f3604c038def..8745ea5c9659 100644 --- a/pkg/engine/defaults/defaults.go +++ b/pkg/engine/defaults/defaults.go @@ -94,6 +94,7 @@ import ( bannerbearv1 "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/bannerbear/v1" bannerbearv2 "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/bannerbear/v2" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/baremetrics" + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/base64privatekey" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/beamer" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/beebole" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/besttime" @@ -972,6 +973,7 @@ func buildDetectorList() []detectors.Detector { &bannerbearv1.Scanner{}, &bannerbearv2.Scanner{}, &baremetrics.Scanner{}, + &base64privatekey.Scanner{}, &beamer.Scanner{}, &beebole.Scanner{}, // Besnappy appears to be abandoned. The domain has expired. API returns 200 OK for all secrets causing FPs diff --git a/pkg/pb/detector_typepb/detector_type.pb.go b/pkg/pb/detector_typepb/detector_type.pb.go index 11428679df44..8d4a95f8f632 100644 --- a/pkg/pb/detector_typepb/detector_type.pb.go +++ b/pkg/pb/detector_typepb/detector_type.pb.go @@ -1106,6 +1106,7 @@ const ( DetectorType_GitLabOauth2 DetectorType = 1050 DetectorType_SpectralOps DetectorType = 1051 DetectorType_AWSAppSync DetectorType = 1052 + DetectorType_Base64PrivateKey DetectorType = 1056 ) // Enum value maps for DetectorType. @@ -2160,6 +2161,7 @@ var ( 1050: "GitLabOauth2", 1051: "SpectralOps", 1052: "AWSAppSync", + 1056: "Base64PrivateKey", } DetectorType_value = map[string]int32{ "Alibaba": 0, @@ -3211,6 +3213,7 @@ var ( "GitLabOauth2": 1050, "SpectralOps": 1051, "AWSAppSync": 1052, + "Base64PrivateKey": 1056, } ) diff --git a/proto/detector_type.proto b/proto/detector_type.proto index 6423f9ede368..8be06a33d7ed 100644 --- a/proto/detector_type.proto +++ b/proto/detector_type.proto @@ -1054,4 +1054,5 @@ enum DetectorType { GitLabOauth2 = 1050; SpectralOps = 1051; AWSAppSync = 1052; + Base64PrivateKey = 1056; }