-
Notifications
You must be signed in to change notification settings - Fork 2.5k
feat(detectors): add BcryptHash detector #5055
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| package bcrypthash | ||
|
|
||
| import ( | ||
| "context" | ||
| "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 ( | ||
| // Bcrypt hash format: $2a$, $2b$, or $2y$ followed by cost (2 digits) and 53 base64 chars | ||
| // Example: $2a$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW | ||
| bcryptPat = regexp.MustCompile(`\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}\b`) | ||
| ) | ||
|
|
||
| func (s Scanner) Keywords() []string { | ||
| return []string{"$2a$", "$2b$", "$2y$", "bcrypt"} | ||
| } | ||
|
|
||
| func (s Scanner) Type() detector_typepb.DetectorType { | ||
| return detector_typepb.DetectorType_BcryptHash | ||
| } | ||
|
|
||
| func (s Scanner) Description() string { | ||
| return "Bcrypt hashes found in code may indicate leaked password hashes or authentication credentials. While bcrypt is a secure hashing algorithm, exposed hashes can be targeted by attackers." | ||
| } | ||
|
|
||
| func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) { | ||
| dataStr := string(data) | ||
|
|
||
| uniqueMatches := make(map[string]struct{}) | ||
| for _, match := range bcryptPat.FindAllString(dataStr, -1) { | ||
| uniqueMatches[strings.TrimSpace(match)] = struct{}{} | ||
| } | ||
|
|
||
| for hash := range uniqueMatches { | ||
| r := detectors.Result{ | ||
| DetectorType: s.Type(), | ||
| Raw: []byte(hash), | ||
| SecretParts: map[string]string{ | ||
| "hash": hash, | ||
| }, | ||
| ExtraData: map[string]string{ | ||
| "info": "Bcrypt hash detected. Ensure this is not a leaked password hash.", | ||
| }, | ||
| } | ||
|
|
||
| // Bcrypt hashes cannot be verified without the original password | ||
| // They are one-way hashes, 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) | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. False positive checker incorrectly filters valid bcrypt hashesHigh Severity The Reviewed by Cursor Bugbot for commit f2fefc5. Configure here. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,164 @@ | ||
| package bcrypthash | ||
|
|
||
| import ( | ||
| "context" | ||
| "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 TestBcryptHash_Pattern(t *testing.T) { | ||
| d := Scanner{} | ||
| ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d}) | ||
|
|
||
| // Valid bcrypt hashes for testing | ||
| validHashA := "$2a$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW" | ||
| validHashB := "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy" | ||
| validHashY := "$2y$10$nOUIs5kJ7naTuTFkBy1veuK0kSxUFXfuaOKdOKf9xYT0KKIGSJwFa" | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| input string | ||
| want []string | ||
| }{ | ||
| { | ||
| name: "valid bcrypt hash $2a$", | ||
| input: `PASSWORD_HASH="` + validHashA + `"`, | ||
| want: []string{validHashA}, | ||
| }, | ||
| { | ||
| name: "valid bcrypt hash $2b$", | ||
| input: `BCRYPT_HASH=` + validHashB, | ||
| want: []string{validHashB}, | ||
| }, | ||
| { | ||
| name: "valid bcrypt hash $2y$", | ||
| input: `User password hash: ` + validHashY, | ||
| want: []string{validHashY}, | ||
| }, | ||
| { | ||
| name: "multiple bcrypt hashes", | ||
| input: ` | ||
| admin: ` + validHashA + ` | ||
| user: ` + validHashB, | ||
| want: []string{validHashA, validHashB}, | ||
| }, | ||
| { | ||
| name: "deduplication - repeated hash", | ||
| input: validHashA + ` and ` + validHashA, | ||
| want: []string{validHashA}, | ||
| }, | ||
| { | ||
| name: "invalid - wrong prefix", | ||
| input: `$3a$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW`, | ||
| want: nil, | ||
| }, | ||
| { | ||
| name: "invalid - too short", | ||
| input: `$2a$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi`, | ||
| want: nil, | ||
| }, | ||
| { | ||
| name: "invalid - invalid characters", | ||
| input: `$2a$12$R9h!cIPz0gi@URNNX3kh2OPST9#PgBkqquzi$Ss7KIUgO2t0jWMUW`, | ||
| want: nil, | ||
| }, | ||
| { | ||
| name: "invalid - wrong cost format", | ||
| input: `$2a$1$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW`, | ||
| 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 TestBcryptHash_FromData(t *testing.T) { | ||
| validHash := "$2a$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW" | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| input string | ||
| wantResult []detectors.Result | ||
| }{ | ||
| { | ||
| name: "bcrypt hash detected", | ||
| input: `PASSWORD_HASH="` + validHash + `"`, | ||
| wantResult: []detectors.Result{ | ||
| { | ||
| DetectorType: detector_typepb.DetectorType_BcryptHash, | ||
| Raw: []byte(validHash), | ||
| Verified: false, // Bcrypt hashes cannot be verified | ||
| }, | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| 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 TestBcryptHash_Type(t *testing.T) { | ||
| s := Scanner{} | ||
| require.Equal(t, detector_typepb.DetectorType_BcryptHash, s.Type()) | ||
| } | ||
|
|
||
| func TestBcryptHash_Keywords(t *testing.T) { | ||
| s := Scanner{} | ||
| require.NotEmpty(t, s.Keywords()) | ||
| require.Contains(t, s.Keywords(), "$2a$") | ||
| require.Contains(t, s.Keywords(), "bcrypt") | ||
| } |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1054,4 +1054,5 @@ enum DetectorType { | |
| GitLabOauth2 = 1050; | ||
| SpectralOps = 1051; | ||
| AWSAppSync = 1052; | ||
| BcryptHash = 1055; | ||
| } | ||


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.
Regex
\bboundary misses hashes ending in.High Severity
The
\bword boundary at the end ofbcryptPatfails to match valid bcrypt hashes whose 53rd character is.or/, since those are non-word characters. When followed by another non-word character (quote, space, newline, end-of-string), no word boundary exists and the regex won't match. Due to bcrypt's modified base64 encoding,.is one of only four possible values for the last hash character, meaning roughly 25% of valid bcrypt hashes go undetected in typical code contexts like quoted strings.Reviewed by Cursor Bugbot for commit f2fefc5. Configure here.