Files
trufflehog/pkg/custom_detectors/custom_detectors_test.go
Amaan Ullah 0ec3634f6c Fix line numbers for duplicate secrets within a chunk (#4910)
* fix line numbers for duplicate secrets within a chunk

* fix-test: use cmpopts.IgnoreUnexported instead of cmpopts.IgnoreFields

* refactor: unify FragmentLineOffset offset/ignore-tag logic

* refactor: extract effectiveSecret helper for chunk-offset callers
2026-05-22 15:45:59 +05:00

997 lines
28 KiB
Go

package custom_detectors
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/stretchr/testify/assert"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/custom_detectorspb"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
"github.com/trufflesecurity/trufflehog/v3/pkg/protoyaml"
)
func TestCustomRegexTemplateParsing(t *testing.T) {
testCustomRegexTemplateYaml := `name: Internal bi tool
keywords:
- secret_v1_
- pat_v2_
regex:
id_pat_example: ([a-zA-Z0-9]{32})
secret_pat_example: ([a-zA-Z0-9]{32})
verify:
- endpoint: http://localhost:8000/{id_pat_example}
unsafe: true
headers:
- 'Authorization: Bearer {secret_pat_example.0}'
successRanges:
- 200-250
- '288'`
var got custom_detectorspb.CustomRegex
assert.NoError(t, protoyaml.UnmarshalStrict([]byte(testCustomRegexTemplateYaml), &got))
assert.Equal(t, "Internal bi tool", got.Name)
assert.Equal(t, []string{"secret_v1_", "pat_v2_"}, got.Keywords)
assert.Equal(t, map[string]string{
"id_pat_example": "([a-zA-Z0-9]{32})",
"secret_pat_example": "([a-zA-Z0-9]{32})",
}, got.Regex)
assert.Equal(t, 1, len(got.Verify))
assert.Equal(t, "http://localhost:8000/{id_pat_example}", got.Verify[0].Endpoint)
assert.Equal(t, true, got.Verify[0].Unsafe)
assert.Equal(t, []string{"Authorization: Bearer {secret_pat_example.0}"}, got.Verify[0].Headers)
assert.Equal(t, []string{"200-250", "288"}, got.Verify[0].SuccessRanges)
}
func TestCustomRegexTemplateParsingWithRotatedRanges(t *testing.T) {
testYaml := `name: test
keywords:
- secret
regex:
token: ([a-zA-Z0-9]{32})
verify:
- endpoint: http://localhost:8000/
unsafe: true
headers:
- 'Authorization: Bearer token'
successRanges:
- '200'
rotatedRanges:
- '401'
- 403-404`
var got custom_detectorspb.CustomRegex
assert.NoError(t, protoyaml.UnmarshalStrict([]byte(testYaml), &got))
assert.Equal(t, 1, len(got.Verify))
assert.Equal(t, []string{"200"}, got.Verify[0].SuccessRanges)
assert.Equal(t, []string{"401", "403-404"}, got.Verify[0].RotatedRanges)
}
func TestCustomRegexWebhookParsing(t *testing.T) {
testCustomRegexWebhookYaml := `name: Internal bi tool
keywords:
- secret_v1_
- pat_v2_
regex:
id_pat_example: ([a-zA-Z0-9]{32})
secret_pat_example: ([a-zA-Z0-9]{32})
verify:
- endpoint: http://localhost:8000/
unsafe: true
headers:
- 'Authorization: Bearer token'`
var got custom_detectorspb.CustomRegex
assert.NoError(t, protoyaml.UnmarshalStrict([]byte(testCustomRegexWebhookYaml), &got))
assert.Equal(t, "Internal bi tool", got.Name)
assert.Equal(t, []string{"secret_v1_", "pat_v2_"}, got.Keywords)
assert.Equal(t, map[string]string{
"id_pat_example": "([a-zA-Z0-9]{32})",
"secret_pat_example": "([a-zA-Z0-9]{32})",
}, got.Regex)
assert.Equal(t, 1, len(got.Verify))
assert.Equal(t, "http://localhost:8000/", got.Verify[0].Endpoint)
assert.Equal(t, true, got.Verify[0].Unsafe)
assert.Equal(t, []string{"Authorization: Bearer token"}, got.Verify[0].Headers)
}
// TestCustomDetectorsParsing tests the full `detectors` configuration.
func TestCustomDetectorsParsing(t *testing.T) {
// TODO: Support both template and webhook.
testYamlConfig := `detectors:
- name: Internal bi tool
keywords:
- secret_v1_
- pat_v2_
regex:
id_pat_example: ([a-zA-Z0-9]{32})
secret_pat_example: ([a-zA-Z0-9]{32})
verify:
- endpoint: http://localhost:8000/
unsafe: true
headers:
- 'Authorization: Bearer token'`
var messages custom_detectorspb.CustomDetectors
assert.NoError(t, protoyaml.UnmarshalStrict([]byte(testYamlConfig), &messages))
assert.Equal(t, 1, len(messages.Detectors))
got := messages.Detectors[0]
assert.Equal(t, "Internal bi tool", got.Name)
assert.Equal(t, []string{"secret_v1_", "pat_v2_"}, got.Keywords)
assert.Equal(t, map[string]string{
"id_pat_example": "([a-zA-Z0-9]{32})",
"secret_pat_example": "([a-zA-Z0-9]{32})",
}, got.Regex)
assert.Equal(t, 1, len(got.Verify))
assert.Equal(t, "http://localhost:8000/", got.Verify[0].Endpoint)
assert.Equal(t, true, got.Verify[0].Unsafe)
assert.Equal(t, []string{"Authorization: Bearer token"}, got.Verify[0].Headers)
}
func TestFromData_InvalidRegEx(t *testing.T) {
c := &CustomRegexWebhook{
&custom_detectorspb.CustomRegex{
Name: "Internal bi tool",
Keywords: []string{"secret_v1_", "pat_v2_"},
Regex: map[string]string{
"test": "!!?(?:?)[a-zA-Z0-9]{32}", // invalid regex
},
},
}
_, err := c.FromData(context.Background(), false, []byte("test"))
assert.Error(t, err)
}
func TestProductIndices(t *testing.T) {
tests := []struct {
name string
input []int
want [][]int
}{
{
name: "zero",
input: []int{3, 0},
want: nil,
},
{
name: "one input",
input: []int{3},
want: [][]int{{0}, {1}, {2}},
},
{
name: "two inputs",
input: []int{3, 2},
want: [][]int{
{0, 0}, {1, 0}, {2, 0},
{0, 1}, {1, 1}, {2, 1},
},
},
{
name: "three inputs",
input: []int{3, 2, 3},
want: [][]int{
{0, 0, 0}, {1, 0, 0}, {2, 0, 0},
{0, 1, 0}, {1, 1, 0}, {2, 1, 0},
{0, 0, 1}, {1, 0, 1}, {2, 0, 1},
{0, 1, 1}, {1, 1, 1}, {2, 1, 1},
{0, 0, 2}, {1, 0, 2}, {2, 0, 2},
{0, 1, 2}, {1, 1, 2}, {2, 1, 2},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := productIndices(tt.input...)
assert.Equal(t, tt.want, got)
})
}
}
func TestProductIndicesMax(t *testing.T) {
got := productIndices(2, 3, 4, 5, 6)
assert.GreaterOrEqual(t, 2*3*4*5*6, maxTotalMatches)
assert.Equal(t, maxTotalMatches, len(got))
}
func TestPermutateMatches(t *testing.T) {
tests := []struct {
name string
input map[string][][]string
want []map[string][]string
}{
{
name: "two matches",
input: map[string][][]string{"foo": {{"matchA"}, {"matchB"}}, "bar": {{"matchC"}}},
want: []map[string][]string{
{"foo": {"matchA"}, "bar": {"matchC"}},
{"foo": {"matchB"}, "bar": {"matchC"}},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := permutateMatches(tt.input)
assert.Equal(t, tt.want, got)
})
}
}
func TestDetector(t *testing.T) {
detector, err := NewWebhookCustomRegex(&custom_detectorspb.CustomRegex{
Name: "test",
// "password" is normally flagged as a false positive, but CustomRegex
// should allow the user to decide and report it as a result.
Keywords: []string{"password"},
Regex: map[string]string{"regex": "password=\"(.*)\""},
})
assert.NoError(t, err)
results, err := detector.FromData(context.Background(), false, []byte(`password="123456"`))
assert.NoError(t, err)
assert.Equal(t, 1, len(results))
assert.Equal(t, results[0].Raw, []byte(`123456`))
}
func TestDetectorPrimarySecret(t *testing.T) {
detector, err := NewWebhookCustomRegex(&custom_detectorspb.CustomRegex{
Name: "test",
Keywords: []string{"secret"},
Regex: map[string]string{"id": "id_[A-Z0-9]{10}_yy", "secret": "secret_[A-Z0-9]{10}_yy"},
PrimaryRegexName: "secret",
})
assert.NoError(t, err)
results, err := detector.FromData(context.Background(), false, []byte(`
// getData returns id and secret
func getData()(string, string){
return "id_ALPHA10100_yy", "secret_YI7C90ACY1_yy"
}
`))
assert.NoError(t, err)
assert.Equal(t, 1, len(results))
assert.Equal(t, "secret_YI7C90ACY1_yy", results[0].GetPrimarySecretValue())
}
func TestDetectorPrimarySecretFullMatch(t *testing.T) {
tests := []struct {
name string
input *custom_detectorspb.CustomRegex
chunk []byte
want string
}{
{
name: "primary regex full match",
input: &custom_detectorspb.CustomRegex{
Name: "test",
Keywords: []string{"secret"},
Regex: map[string]string{"secret": `secret *= *"([^"\r\n]+)"`},
PrimaryRegexName: "secret",
},
chunk: []byte(`
// some code
secret="mysecret"
// some code
`),
want: `secret="mysecret"`,
},
{
name: "primary regex full match multiline",
input: &custom_detectorspb.CustomRegex{
Name: "test",
Keywords: []string{"secret"},
Regex: map[string]string{"secret": `secret *= *"([^"]+)"`},
PrimaryRegexName: "secret",
},
chunk: []byte(`
// some code
secret="mysecret
thatspansmultiplelines"
// some code
`),
want: `secret="mysecret
thatspansmultiplelines"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
detector, err := NewWebhookCustomRegex(tt.input)
assert.NoError(t, err)
results, err := detector.FromData(context.Background(), false, tt.chunk)
assert.NoError(t, err)
assert.Equal(t, 1, len(results))
assert.Equal(t, tt.want, results[0].GetPrimarySecretValue())
})
}
}
func TestDetectorValidations(t *testing.T) {
type args struct {
CustomRegex *custom_detectorspb.CustomRegex
Data string
}
tests := []struct {
name string
input args
want []detectors.Result
}{
{
name: "custom validation - contains digit",
input: args{
CustomRegex: &custom_detectorspb.CustomRegex{
Name: "test",
Keywords: []string{"password"},
Regex: map[string]string{"password": `([A-Za-z0-9!@#$%^&*()_+=\-]{12,})`},
Validations: map[string]*custom_detectorspb.ValidationConfig{
"password": {
ContainsDigit: true,
},
},
},
Data: `This is custom example
This file has a random text and maybe a secret
Password: MyStr0ngP@ssword!
End of file`,
},
want: []detectors.Result{
{
DetectorType: detector_typepb.DetectorType_CustomRegex,
DetectorName: "test",
Verified: false,
Raw: []byte("MyStr0ngP@ssword!"),
},
},
},
{
name: "custom validation - does not contains digit",
input: args{
CustomRegex: &custom_detectorspb.CustomRegex{
Name: "test",
Keywords: []string{"password"},
Regex: map[string]string{"password": `([A-Za-z0-9!@#$%^&*()_+=\-]{12,})`},
Validations: map[string]*custom_detectorspb.ValidationConfig{
"password": {
ContainsDigit: true,
},
},
},
Data: `This is custom example
This file has a random text and maybe a secret
Password: MyStrongPassword!
End of file`,
},
want: nil,
},
{
name: "custom validation - contains lowercase",
input: args{
CustomRegex: &custom_detectorspb.CustomRegex{
Name: "test",
Keywords: []string{"password"},
Regex: map[string]string{"password": `([A-Za-z0-9!@#$%^&*()_+=\-]{12,})`},
Validations: map[string]*custom_detectorspb.ValidationConfig{
"password": {
ContainsLowercase: true,
},
},
},
Data: `This is custom example
This file has a random text and maybe a secret
Password: MyStrongPassword!
End of file`,
},
want: []detectors.Result{
{
DetectorType: detector_typepb.DetectorType_CustomRegex,
DetectorName: "test",
Verified: false,
Raw: []byte("MyStrongPassword!"),
},
},
},
{
name: "custom validation - does not contains lowercase",
input: args{
CustomRegex: &custom_detectorspb.CustomRegex{
Name: "test",
Keywords: []string{"password"},
Regex: map[string]string{"password": `([A-Za-z0-9!@#$%^&*()_+=\-]{12,})`},
Validations: map[string]*custom_detectorspb.ValidationConfig{
"password": {
ContainsLowercase: true,
},
},
},
Data: `This is custom example
This file has a random text and maybe a secret
Password: MYSTRONGPASSWORD!
End of file`,
},
want: nil,
},
{
name: "custom validation - contains uppercase",
input: args{
CustomRegex: &custom_detectorspb.CustomRegex{
Name: "test",
Keywords: []string{"password"},
Regex: map[string]string{"password": `([A-Za-z0-9!@#$%^&*()_+=\-]{12,})`},
Validations: map[string]*custom_detectorspb.ValidationConfig{
"password": {
ContainsUppercase: true,
},
},
},
Data: `This is custom example
This file has a random text and maybe a secret
Password: MyStrongPassword!
End of file`,
},
want: []detectors.Result{
{
DetectorType: detector_typepb.DetectorType_CustomRegex,
DetectorName: "test",
Verified: false,
Raw: []byte("MyStrongPassword!"),
},
},
},
{
name: "custom validation - does not contains uppercase",
input: args{
CustomRegex: &custom_detectorspb.CustomRegex{
Name: "test",
Keywords: []string{"password"},
Regex: map[string]string{"password": `([A-Za-z0-9!@#$%^&*()_+=\-]{12,})`},
Validations: map[string]*custom_detectorspb.ValidationConfig{
"password": {
ContainsUppercase: true,
},
},
},
Data: `This is custom example
This file has a random text and maybe a secret
Password: mystrongpassword!
End of file`,
},
want: nil,
},
{
name: "custom validation - contains special character",
input: args{
CustomRegex: &custom_detectorspb.CustomRegex{
Name: "test",
Keywords: []string{"password"},
Regex: map[string]string{"password": `([A-Za-z0-9!@#$%^&*()_+=\-]{12,})`},
Validations: map[string]*custom_detectorspb.ValidationConfig{
"password": {
ContainsSpecialChar: true,
},
},
},
Data: `This is custom example
This file has a random text and maybe a secret
Password: MyStr@ngP@ssword!
End of file`,
},
want: []detectors.Result{
{
DetectorType: detector_typepb.DetectorType_CustomRegex,
DetectorName: "test",
Verified: false,
Raw: []byte("MyStr@ngP@ssword!"),
},
},
},
{
name: "custom validation - does not contains special character",
input: args{
CustomRegex: &custom_detectorspb.CustomRegex{
Name: "test",
Keywords: []string{"password"},
Regex: map[string]string{"password": `([A-Za-z0-9!@#$%^&*()_+=\-]{12,})`},
Validations: map[string]*custom_detectorspb.ValidationConfig{
"password": {
ContainsSpecialChar: true,
},
},
},
Data: `This is custom example
This file has a random text and maybe a secret
Password: MyStrongPassword
End of file`,
},
want: nil,
},
{
name: "custom validation - contains uppercase and special characters",
input: args{
CustomRegex: &custom_detectorspb.CustomRegex{
Name: "test",
Keywords: []string{"password"},
Regex: map[string]string{"password": `([A-Za-z0-9!@#$%^&*()_+=\-]{12,})`},
Validations: map[string]*custom_detectorspb.ValidationConfig{
"password": {
ContainsUppercase: true,
ContainsSpecialChar: true,
},
},
},
Data: `This is custom example
This file has a random text and maybe a secret
Password: MyStrongP@ssword
End of file`,
},
want: []detectors.Result{
{
DetectorType: detector_typepb.DetectorType_CustomRegex,
DetectorName: "test",
Verified: false,
Raw: []byte("MyStrongP@ssword"),
},
},
},
{
name: "custom validation - contains uppercase but does not contain special characters",
input: args{
CustomRegex: &custom_detectorspb.CustomRegex{
Name: "test",
Keywords: []string{"password"},
Regex: map[string]string{"password": `([A-Za-z0-9!@#$%^&*()_+=\-]{12,})`},
Validations: map[string]*custom_detectorspb.ValidationConfig{
"password": {
ContainsUppercase: true,
ContainsSpecialChar: true,
},
},
},
Data: `This is custom example
This file has a random text and maybe a secret
Password: MyStrongPassword
End of file`,
},
want: nil,
},
{
name: "custom validation - wrong regex name in validations",
input: args{
CustomRegex: &custom_detectorspb.CustomRegex{
Name: "test",
Keywords: []string{"password"},
Regex: map[string]string{"password": `([A-Za-z0-9!@#$%^&*()_+=\-]{12,})`},
Validations: map[string]*custom_detectorspb.ValidationConfig{
"wrong": {
ContainsUppercase: true,
},
},
},
Data: `This is custom example
This file has a random text and maybe a secret
Password: mystrongp@ssword
End of file`,
},
want: []detectors.Result{
{
DetectorType: detector_typepb.DetectorType_CustomRegex,
DetectorName: "test",
Verified: false,
Raw: []byte("mystrongp@ssword"),
},
},
},
{
name: "custom validation - multiple regex validations",
input: args{
CustomRegex: &custom_detectorspb.CustomRegex{
Name: "test",
Keywords: []string{"password", "api_key"},
Regex: map[string]string{
"password": `([A-Za-z0-9!@#$%^&*()_+=\-]{12,})`,
"api_key": `([a-f0-9_-]{32})`,
},
Validations: map[string]*custom_detectorspb.ValidationConfig{
"password": {
ContainsUppercase: true,
ContainsSpecialChar: true,
},
"api_key": {
ContainsSpecialChar: true,
},
},
},
Data: `This is custom example
This file has a random text and maybe a secret
Password: MyStrongP@ssword
API_Key: c392c9837d69b44c764cbf260b-e6184 // should be detected
API_Key: c392c9837d69b44c764cbf260be6184 // should be filtered by validation
End of file`,
},
want: []detectors.Result{
{
DetectorType: detector_typepb.DetectorType_CustomRegex,
DetectorName: "test",
Verified: false,
Raw: []byte("c392c9837d69b44c764cbf260b-e6184MyStrongP@ssword"),
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
detector, err := NewWebhookCustomRegex(tt.input.CustomRegex)
assert.NoError(t, err)
results, err := detector.FromData(context.Background(), false, []byte(tt.input.Data))
assert.NoError(t, err)
ignoreOpts := cmp.Options{
cmpopts.IgnoreUnexported(detectors.Result{}),
cmpopts.IgnoreFields(detectors.Result{}, "ExtraData"),
}
if diff := cmp.Diff(results, tt.want, ignoreOpts); diff != "" {
t.Errorf("CustomDetector.FromData() %s diff: (-got +want)\n%s", tt.name, diff)
}
})
}
}
func TestNewWebhookCustomRegex_Validation(t *testing.T) {
t.Parallel()
// A known-good baseline; each test case mutates exactly one thing to trigger a specific validator.
base := func() *custom_detectorspb.CustomRegex {
return &custom_detectorspb.CustomRegex{
Name: "ok",
Keywords: []string{"kw"},
Regex: map[string]string{
"main": `\btoken_[a-z]+\b`,
},
PrimaryRegexName: "main",
ExcludeRegexesCapture: []string{
`^skip_.*$`,
},
ExcludeRegexesMatch: []string{
`^ignore_.*$`,
},
Verify: []*custom_detectorspb.VerifierConfig{
{
Endpoint: "https://example.com/verify",
Unsafe: false,
Headers: []string{"Authorization: Bearer x"},
},
},
}
}
tests := []struct {
name string
mutate func(*custom_detectorspb.CustomRegex)
wantErr bool
wantErrSubstr string // substring expected in error
}{
{
name: "Validate everything ok",
mutate: func(pb *custom_detectorspb.CustomRegex) {},
},
{
name: "ValidateKeywords: no keywords",
mutate: func(pb *custom_detectorspb.CustomRegex) {
pb.Keywords = nil
},
wantErr: true,
wantErrSubstr: "no keywords",
},
{
name: "ValidateKeywords: empty keyword",
mutate: func(pb *custom_detectorspb.CustomRegex) {
pb.Keywords = []string{""}
},
wantErr: true,
wantErrSubstr: "empty keyword",
},
{
name: "ValidateRegex: no regex",
mutate: func(pb *custom_detectorspb.CustomRegex) {
pb.Regex = nil
},
wantErr: true,
wantErrSubstr: "no regex",
},
{
name: "ValidateRegex: invalid regex in map",
mutate: func(pb *custom_detectorspb.CustomRegex) {
pb.Regex = map[string]string{"main": "("} // invalid
},
wantErr: true,
wantErrSubstr: "regex 'main':",
},
{
name: "ValidateRegexSlice: invalid exclude_regexes_capture",
mutate: func(pb *custom_detectorspb.CustomRegex) {
pb.ExcludeRegexesCapture = []string{"("} // invalid
},
wantErr: true,
wantErrSubstr: "regex '1':",
},
{
name: "ValidateRegexSlice: invalid exclude_regexes_match",
mutate: func(pb *custom_detectorspb.CustomRegex) {
pb.ExcludeRegexesMatch = []string{"("} // invalid
},
wantErr: true,
wantErrSubstr: "regex '1':",
},
{
name: "ValidatePrimaryRegexName: unknown primary regex name",
mutate: func(pb *custom_detectorspb.CustomRegex) {
pb.PrimaryRegexName = "does-not-exist"
},
wantErr: true,
wantErrSubstr: `unknown primary regex name: "does-not-exist"`,
},
{
name: "ValidateVerifyEndpoint: empty endpoint",
mutate: func(pb *custom_detectorspb.CustomRegex) {
pb.Verify = []*custom_detectorspb.VerifierConfig{
{Endpoint: "", Unsafe: false, Headers: []string{"A: b"}},
}
},
wantErr: true,
wantErrSubstr: "no endpoint",
},
{
name: "ValidateVerifyEndpoint: http endpoint without unsafe=true",
mutate: func(pb *custom_detectorspb.CustomRegex) {
pb.Verify = []*custom_detectorspb.VerifierConfig{
{Endpoint: "http://example.com/verify", Unsafe: false, Headers: []string{"A: b"}},
}
},
wantErr: true,
wantErrSubstr: "http endpoint must have unsafe=true",
},
{
name: "ValidateVerifyHeaders: header missing colon",
mutate: func(pb *custom_detectorspb.CustomRegex) {
pb.Verify = []*custom_detectorspb.VerifierConfig{
{Endpoint: "https://example.com/verify", Unsafe: false, Headers: []string{"Authorization Bearer x"}},
}
},
wantErr: true,
wantErrSubstr: `must contain a colon`,
},
{
name: "ValidateVerifyRanges: invalid successRanges",
mutate: func(pb *custom_detectorspb.CustomRegex) {
pb.Verify = []*custom_detectorspb.VerifierConfig{
{Endpoint: "https://example.com/verify", Headers: []string{"A: b"}, SuccessRanges: []string{"abc"}},
}
},
wantErr: true,
wantErrSubstr: "unable to convert http code to int",
},
{
name: "ValidateVerifyRanges: invalid rotatedRanges",
mutate: func(pb *custom_detectorspb.CustomRegex) {
pb.Verify = []*custom_detectorspb.VerifierConfig{
{Endpoint: "https://example.com/verify", Headers: []string{"A: b"}, RotatedRanges: []string{"999"}},
}
},
wantErr: true,
wantErrSubstr: "invalid http status code",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
pb := base()
tt.mutate(pb)
got, err := NewWebhookCustomRegex(pb)
if (err != nil) != tt.wantErr {
t.Fatalf("expected error=%v, got error=%v (result=%#v)", tt.wantErr, err != nil, got)
}
if tt.wantErr && got != nil {
t.Fatalf("expected nil result on error, got=%#v", got)
}
if tt.wantErr && !strings.Contains(err.Error(), tt.wantErrSubstr) {
t.Fatalf("error mismatch:\n got: %q\n want substring: %q", err.Error(), tt.wantErrSubstr)
}
})
}
}
func TestNewWebhookCustomRegex_EnsurePrimaryRegexNameSet(t *testing.T) {
t.Parallel()
pb := &custom_detectorspb.CustomRegex{
Name: "test",
Keywords: []string{"kw"},
Regex: map[string]string{
"regex_a": `regex_a`,
"regex_b": `regex_b`,
},
// PrimaryRegexName is not set.
}
detector, err := NewWebhookCustomRegex(pb)
assert.NoError(t, err)
assert.Equal(t, "regex_a", detector.GetPrimaryRegexName(), "expected PrimaryRegexName to be set to regex_a")
}
func TestVerificationWithConfigurableRanges(t *testing.T) {
t.Parallel()
tests := []struct {
name string
serverStatus int
successRanges []string
rotatedRanges []string
wantVerified bool
wantVerifyErr bool
}{
{
name: "backward compat: no ranges, 200 -> verified",
serverStatus: 200,
wantVerified: true,
wantVerifyErr: false,
},
{
name: "backward compat: no ranges, 401 -> unverified",
serverStatus: 401,
wantVerified: false,
wantVerifyErr: false,
},
{
name: "successRanges match -> verified",
serverStatus: 201,
successRanges: []string{"200-202"},
wantVerified: true,
wantVerifyErr: false,
},
{
name: "rotatedRanges match -> not verified, no error",
serverStatus: 401,
successRanges: []string{"200"},
rotatedRanges: []string{"401", "403"},
wantVerified: false,
wantVerifyErr: false,
},
{
name: "neither match -> not verified, verification error",
serverStatus: 500,
successRanges: []string{"200"},
rotatedRanges: []string{"401"},
wantVerified: false,
wantVerifyErr: true,
},
{
name: "successRanges range boundary inclusive",
serverStatus: 250,
successRanges: []string{"200-250"},
wantVerified: true,
wantVerifyErr: false,
},
{
name: "only successRanges: non-match means rotated",
serverStatus: 401,
successRanges: []string{"200"},
wantVerified: false,
wantVerifyErr: false,
},
{
name: "only rotatedRanges: non-match means live",
serverStatus: 200,
rotatedRanges: []string{"401", "403"},
wantVerified: true,
wantVerifyErr: false,
},
{
name: "only rotatedRanges: match means rotated",
serverStatus: 401,
rotatedRanges: []string{"401"},
wantVerified: false,
wantVerifyErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tt.serverStatus)
_, _ = w.Write([]byte(`{"status":"ok"}`))
}))
defer ts.Close()
detector, err := NewWebhookCustomRegex(&custom_detectorspb.CustomRegex{
Name: "test",
Keywords: []string{"secret"},
Regex: map[string]string{"token": `(secret_[a-zA-Z0-9]{10})`},
Verify: []*custom_detectorspb.VerifierConfig{
{
Endpoint: ts.URL,
Unsafe: true,
Headers: []string{"Authorization: Bearer test"},
SuccessRanges: tt.successRanges,
RotatedRanges: tt.rotatedRanges,
},
},
})
assert.NoError(t, err)
results, err := detector.FromData(context.Background(), true, []byte("secret_ABCDEFGHIJ"))
assert.NoError(t, err)
assert.Equal(t, 1, len(results), "expected exactly one result")
result := results[0]
assert.Equal(t, tt.wantVerified, result.Verified, "Verified mismatch")
if tt.wantVerifyErr {
assert.NotNil(t, result.VerificationError(), "expected a verification error")
} else {
assert.Nil(t, result.VerificationError(), "expected no verification error")
}
})
}
}
func TestVerificationMixedRangedAndLegacyVerifiers(t *testing.T) {
t.Parallel()
// Verifier 1 has ranges configured but returns a status matching neither.
// Verifier 2 is legacy (no ranges) and returns 200.
// The result should be Verified=true with NO verification error.
tsRanged := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
}))
defer tsRanged.Close()
tsLegacy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
_, _ = w.Write([]byte(`ok`))
}))
defer tsLegacy.Close()
detector, err := NewWebhookCustomRegex(&custom_detectorspb.CustomRegex{
Name: "test",
Keywords: []string{"secret"},
Regex: map[string]string{"token": `(secret_[a-zA-Z0-9]{10})`},
Verify: []*custom_detectorspb.VerifierConfig{
{
Endpoint: tsRanged.URL,
Unsafe: true,
Headers: []string{"A: b"},
SuccessRanges: []string{"200"},
RotatedRanges: []string{"401"},
},
{
Endpoint: tsLegacy.URL,
Unsafe: true,
Headers: []string{"A: b"},
},
},
})
assert.NoError(t, err)
results, err := detector.FromData(context.Background(), true, []byte("secret_ABCDEFGHIJ"))
assert.NoError(t, err)
assert.Equal(t, 1, len(results))
assert.True(t, results[0].Verified, "expected Verified=true from legacy fallback")
assert.Nil(t, results[0].VerificationError(), "legacy success must not produce a spurious verification error")
}
func BenchmarkProductIndices(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = productIndices(3, 2, 6)
}
}