-
Notifications
You must be signed in to change notification settings - Fork 2.5k
feat(detectors): add Base64PrivateKey detector #5056
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
deerajcm
wants to merge
1
commit into
trufflesecurity:main
Choose a base branch
from
deerajcm:add-base64-private-key-detector
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+279
−0
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
165 changes: 165 additions & 0 deletions
165
pkg/detectors/base64privatekey/base64_private_key_test.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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") | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1054,4 +1054,5 @@ enum DetectorType { | |
| GitLabOauth2 = 1050; | ||
| SpectralOps = 1051; | ||
| AWSAppSync = 1052; | ||
| Base64PrivateKey = 1056; | ||
| } | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
URLEncoding fallback can never succeed for regex matches
Medium Severity
The
base64.URLEncodingfallback on decode failure is unreachable dead logic. The regexbase64Patonly matches standard base64 characters (+,/), butURLEncodingexpects URL-safe characters (-,_). If the match contains+or/, URL decoding rejects them as invalid. If it doesn't, both encodings behave identically. Either way,URLEncodingcan never succeed whenStdEncodingfails for these matches. The most likely failure mode forStdEncodingis missing padding, so the correct fallback isbase64.RawStdEncoding, which tolerates unpadded input. Without this, base64-encoded private keys stored without=padding are silently skipped.Additional Locations (1)
pkg/detectors/base64privatekey/base64_private_key.go#L23-L24Reviewed by Cursor Bugbot for commit bd56182. Configure here.