From f2fefc54463e83cec29dbb37a9c73e30982ff1e0 Mon Sep 17 00:00:00 2001 From: Deeraj CM Date: Fri, 19 Jun 2026 11:31:01 +0530 Subject: [PATCH] feat(detectors): add BcryptHash detector Adds a detector for bcrypt password hashes found in code. Detects bcrypt hash formats: $2a$, $2b$, and $2y$ variants. No verification is performed as bcrypt is a one-way hash. - Adds DetectorType_BcryptHash (enum 1055) to proto - Implements Scanner with pattern matching for bcrypt hashes - Includes comprehensive tests for all bcrypt variants - Registers detector in DefaultDetectors Co-Authored-By: Claude Sonnet 4.5 --- pkg/detectors/bcrypthash/bcrypt_hash.go | 70 ++++++++ pkg/detectors/bcrypthash/bcrypt_hash_test.go | 164 +++++++++++++++++++ pkg/engine/defaults/defaults.go | 2 + pkg/pb/detector_typepb/detector_type.pb.go | 3 + proto/detector_type.proto | 1 + 5 files changed, 240 insertions(+) create mode 100644 pkg/detectors/bcrypthash/bcrypt_hash.go create mode 100644 pkg/detectors/bcrypthash/bcrypt_hash_test.go diff --git a/pkg/detectors/bcrypthash/bcrypt_hash.go b/pkg/detectors/bcrypthash/bcrypt_hash.go new file mode 100644 index 000000000000..a102b5a0dd17 --- /dev/null +++ b/pkg/detectors/bcrypthash/bcrypt_hash.go @@ -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) +} diff --git a/pkg/detectors/bcrypthash/bcrypt_hash_test.go b/pkg/detectors/bcrypthash/bcrypt_hash_test.go new file mode 100644 index 000000000000..a4fa3cb77f81 --- /dev/null +++ b/pkg/detectors/bcrypthash/bcrypt_hash_test.go @@ -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") +} diff --git a/pkg/engine/defaults/defaults.go b/pkg/engine/defaults/defaults.go index f3604c038def..ca0c8f9337c0 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/bcrypthash" "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{}, + &bcrypthash.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..64ce3fab7c90 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_BcryptHash DetectorType = 1055 ) // Enum value maps for DetectorType. @@ -2160,6 +2161,7 @@ var ( 1050: "GitLabOauth2", 1051: "SpectralOps", 1052: "AWSAppSync", + 1055: "BcryptHash", } DetectorType_value = map[string]int32{ "Alibaba": 0, @@ -3211,6 +3213,7 @@ var ( "GitLabOauth2": 1050, "SpectralOps": 1051, "AWSAppSync": 1052, + "BcryptHash": 1055, } ) diff --git a/proto/detector_type.proto b/proto/detector_type.proto index 6423f9ede368..fc19441a8ce1 100644 --- a/proto/detector_type.proto +++ b/proto/detector_type.proto @@ -1054,4 +1054,5 @@ enum DetectorType { GitLabOauth2 = 1050; SpectralOps = 1051; AWSAppSync = 1052; + BcryptHash = 1055; }