Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions pkg/detectors/bcrypthash/bcrypt_hash.go
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`)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regex \b boundary misses hashes ending in .

High Severity

The \b word boundary at the end of bcryptPat fails 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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f2fefc5. Configure here.

)

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)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive checker incorrectly filters valid bcrypt hashes

High Severity

The IsFalsePositive method runs the default word-list check (IsKnownFalsePositive with wordCheck=true) against the raw bcrypt hash. Since bcrypt hashes are 53+ characters of pseudo-random base64, they very likely contain common English substrings (e.g., "from", "name", "data", "type") from the false-positive word lists, causing legitimate hashes to be silently dropped. Every other CustomFalsePositiveChecker in the codebase returns false, "" to bypass this check — this implementation needs to do the same.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f2fefc5. Configure here.

164 changes: 164 additions & 0 deletions pkg/detectors/bcrypthash/bcrypt_hash_test.go
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")
}
2 changes: 2 additions & 0 deletions pkg/engine/defaults/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions pkg/pb/detector_typepb/detector_type.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions proto/detector_type.proto
Original file line number Diff line number Diff line change
Expand Up @@ -1054,4 +1054,5 @@ enum DetectorType {
GitLabOauth2 = 1050;
SpectralOps = 1051;
AWSAppSync = 1052;
BcryptHash = 1055;
}