* 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
997 lines
28 KiB
Go
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)
|
|
}
|
|
}
|