Files
trufflehog/pkg/custom_detectors/custom_detectors.go
Brad Larsen 2bffafb280 Clean up use of wasilibs/go-re2 (#5273)
* perf: switch nearly all remaining uses of stdlib regexp to wasilibs/go-re2
* perf: upgrade github.com/wasilibs/go-re2 from v1.9.0 to v1.12.0
* gofmt modified files
* snowflake detector: hoist constant regex compilation to package-level variables
* add golangci lint to steer folks to go-re2 instead of regexp
2026-09-09 17:25:38 -04:00

484 lines
14 KiB
Go

package custom_detectors
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"maps"
"net/http"
"regexp" //nolint:depguard // used instead of github.com/wasilibs/go-re2 due to differences in utf-8 handling
"slices"
"strings"
"golang.org/x/sync/errgroup"
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"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"
)
// The maximum number of matches from one chunk. This const is used when
// permutating each regex match to protect the scanner from doing too much work
// for poorly defined regexps.
const maxTotalMatches = 100
// CustomRegexWebhook is a CustomRegex with webhook validation that is
// guaranteed to be valid (assuming the data is not changed after
// initialization).
type CustomRegexWebhook struct {
*custom_detectorspb.CustomRegex
}
// Ensure the Scanner satisfies the interface at compile time.
var _ detectors.Detector = (*CustomRegexWebhook)(nil)
var _ detectors.CustomFalsePositiveChecker = (*CustomRegexWebhook)(nil)
var _ detectors.MaxSecretSizeProvider = (*CustomRegexWebhook)(nil)
// NewWebhookCustomRegex initializes and validates a CustomRegexWebhook. An
// unexported type is intentionally returned here to ensure the values have
// been validated.
func NewWebhookCustomRegex(pb *custom_detectorspb.CustomRegex) (*CustomRegexWebhook, error) {
// TODO: Return all validation errors.
if err := ValidateKeywords(pb.Keywords); err != nil {
return nil, err
}
if err := ValidateRegex(pb.Regex); err != nil {
return nil, err
}
if err := ValidateRegexSlice(pb.ExcludeRegexesCapture); err != nil {
return nil, err
}
if err := ValidateRegexSlice(pb.ExcludeRegexesMatch); err != nil {
return nil, err
}
if err := ValidatePrimaryRegexName(pb.PrimaryRegexName, pb.Regex); err != nil {
return nil, err
}
for _, verify := range pb.Verify {
if err := ValidateVerifyEndpoint(verify.Endpoint, verify.Unsafe); err != nil {
return nil, err
}
if err := ValidateVerifyHeaders(verify.Headers); err != nil {
return nil, err
}
if err := ValidateVerifyRanges(verify.SuccessRanges); err != nil {
return nil, err
}
if err := ValidateVerifyRanges(verify.RotatedRanges); err != nil {
return nil, err
}
}
// Ensure primary regex name is set.
ensurePrimaryRegexNameSet(pb)
// TODO: Copy only necessary data out of pb.
return &CustomRegexWebhook{pb}, nil
}
var httpClient = common.SaneHttpClient()
func (c *CustomRegexWebhook) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
dataStr := string(data)
regexMatches := make(map[string][][]string, len(c.GetRegex()))
// Compile exclude regexes targeting the capture group
excludeRegexesCapture := make([]*regexp.Regexp, 0, len(c.GetExcludeRegexesCapture()))
for _, exclude := range c.GetExcludeRegexesCapture() {
regex, err := regexp.Compile(exclude)
if err != nil {
// This will only happen if the regex is invalid.
return nil, err
}
excludeRegexesCapture = append(excludeRegexesCapture, regex)
}
// Compile exclude regexes targeting the entire match
excludeRegexes := make([]*regexp.Regexp, 0, len(c.GetExcludeRegexesMatch()))
for _, exclude := range c.GetExcludeRegexesMatch() {
regex, err := regexp.Compile(exclude)
if err != nil {
// This will only happen if the regex is invalid.
return nil, err
}
excludeRegexes = append(excludeRegexes, regex)
}
// Find all submatches for each regex.
for name, regex := range c.GetRegex() {
regex, err := regexp.Compile(regex)
if err != nil {
// This will only happen if the regex is invalid.
return nil, err
}
regexMatches[name] = regex.FindAllStringSubmatch(dataStr, -1)
}
// Permutate each individual match.
// {
// "foo": [["match1"]]
// "bar": [["match2"], ["match3"]]
// }
// becomes
// [
// {"foo": ["match1"], "bar": ["match2"]},
// {"foo": ["match1"], "bar": ["match3"]},
// ]
matches := permutateMatches(regexMatches)
g := new(errgroup.Group)
// Create result object and test for verification.
resultsCh := make(chan detectors.Result, maxTotalMatches)
MatchLoop:
for _, match := range matches {
for key, values := range match {
// attempt to use capture group
secret := values[0]
if len(values) > 1 {
secret = values[1]
}
// check entropy
entropy := c.GetEntropy()
if entropy > 0.0 && detectors.StringShannonEntropy(secret) < float64(entropy) {
continue MatchLoop
}
// check for exclude words
for _, excludeWord := range c.GetExcludeWords() {
if strings.Contains(strings.ToLower(secret), excludeWord) {
continue MatchLoop
}
}
// exclude checks
for _, excludeMatch := range excludeRegexes {
if excludeMatch.MatchString(values[0]) {
continue MatchLoop
}
}
// exclude secret (capture group), or if no capture group is set,
// check against entire match.
for _, excludeSecret := range excludeRegexesCapture {
if excludeSecret.MatchString(secret) {
continue MatchLoop
}
}
if validations := c.GetValidations(); validations != nil {
validationRules := []struct {
enabled bool
validator func(string) bool
}{
{validations[key].GetContainsDigit(), ContainsDigit},
{validations[key].GetContainsLowercase(), ContainsLowercase},
{validations[key].GetContainsUppercase(), ContainsUppercase},
{validations[key].GetContainsSpecialChar(), ContainsSpecialChar},
}
for _, rule := range validationRules {
if rule.enabled && !rule.validator(secret) {
// skip this match if a validation rule is enabled but missing from the secret
continue MatchLoop
}
}
}
}
g.Go(func() error {
return c.createResults(ctx, match, verify, resultsCh)
})
}
// Ignore any errors and collect as many of the results as we can.
_ = g.Wait()
close(resultsCh)
for result := range resultsCh {
if result.ExtraData != nil {
result.ExtraData["name"] = c.GetName()
}
results = append(results, result)
}
return results, nil
}
func (c *CustomRegexWebhook) IsFalsePositive(_ detectors.Result) (bool, string) {
return false, ""
}
// custom max size for custom detector
func (c *CustomRegexWebhook) MaxSecretSize() int64 {
return 1000
}
func (c *CustomRegexWebhook) createResults(ctx context.Context, match map[string][]string, verify bool, results chan<- detectors.Result) error {
if common.IsDone(ctx) {
// TODO: Log we're possibly leaving out results.
return ctx.Err()
}
result := detectors.Result{
DetectorType: detector_typepb.DetectorType_CustomRegex,
DetectorName: c.GetName(),
ExtraData: map[string]string{},
}
var raw string
for _, key := range slices.Sorted(maps.Keys(match)) {
values := match[key]
// values[0] contains the entire regex match.
secret := values[0]
fullMatch := values[0]
if len(values) > 1 {
secret = values[1]
}
raw += secret
// We set the full regex match as the primary secret value.
// Reasoning:
// The engine calculates the line number using the match. When a primary secret is set, it uses that value instead of the raw secret.
// While the secret match itself is sufficient to calculate the line number, the same group match could appear elsewhere in the data.
// To avoid ambiguity, we store the full regex match as the primary secret value.
// This primary secret value is used only for identifying the exact line number and is not used anywhere else.
// Example:
// Full regex match: secret = ABC123
// Secret (raw): ABC123
// In this case, the primary secret value stores the full string `secret = ABC123`,
// allowing the engine to pinpoint the exact location and avoid matching redundant occurrences of `ABC123` in the data.
if c.PrimaryRegexName == key {
result.SetPrimarySecretValue(fullMatch)
}
}
result.Raw = []byte(raw)
if !verify {
select {
case <-ctx.Done():
return ctx.Err()
case results <- result:
return nil
}
}
// Verify via webhook.
jsonBody, err := json.Marshal(map[string]map[string][]string{
c.GetName(): match,
})
if err != nil {
// This should never happen, but if it does, return nil to not
// disrupt other verification.
return nil
}
var (
definitive bool
rangesInEffect bool
)
// Try each config until we get a definitive answer.
for _, verifyConfig := range c.GetVerify() {
if common.IsDone(ctx) {
return ctx.Err()
}
req, err := http.NewRequestWithContext(ctx, "POST", verifyConfig.GetEndpoint(), bytes.NewReader(jsonBody))
if err != nil {
continue
}
for _, header := range verifyConfig.GetHeaders() {
key, value, found := strings.Cut(header, ":")
if !found {
continue
}
req.Header.Add(key, strings.TrimLeft(value, "\t\n\v\f\r "))
}
if req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := httpClient.Do(req)
if err != nil {
continue
}
defer func() {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}()
successRanges := verifyConfig.GetSuccessRanges()
rotatedRanges := verifyConfig.GetRotatedRanges()
if len(successRanges) == 0 && len(rotatedRanges) == 0 {
// Backward compat: no ranges configured, use legacy behavior.
if resp.StatusCode == http.StatusOK {
result.Verified = true
definitive = true
storeResponseBody(resp, result.ExtraData)
break
}
// Legacy non-200 is a meaningful response (verifier said "no");
// mark definitive so a prior ranged verifier with rangesInEffect
// does not cause a spurious verification error.
definitive = true
continue
}
rangesInEffect = true
bothConfigured := len(successRanges) > 0 && len(rotatedRanges) > 0
if StatusCodeMatchesRanges(resp.StatusCode, successRanges) {
result.Verified = true
definitive = true
storeResponseBody(resp, result.ExtraData)
break
}
if StatusCodeMatchesRanges(resp.StatusCode, rotatedRanges) {
definitive = true
break
}
// Status matched neither configured range.
if !bothConfigured {
// Only one side was configured: the non-matching response is
// treated as the opposite state.
// successRanges only -> non-match means rotated
// rotatedRanges only -> non-match means live
definitive = true
if len(rotatedRanges) > 0 {
result.Verified = true
storeResponseBody(resp, result.ExtraData)
}
break
}
// Both configured but neither matched -- try the next verifier.
}
if rangesInEffect && !definitive {
result.SetVerificationError(errors.New("verification response status code did not match any configured successRanges or rotatedRanges"))
}
select {
case <-ctx.Done():
return ctx.Err()
case results <- result:
return nil
}
}
const maxResponseLen = 200
func storeResponseBody(resp *http.Response, extraData map[string]string) {
body, err := io.ReadAll(resp.Body)
if err != nil {
return
}
responseStr := string(body)
if len(responseStr) > maxResponseLen {
responseStr = responseStr[:maxResponseLen]
}
extraData["response"] = responseStr
}
func (c *CustomRegexWebhook) Keywords() []string {
return c.GetKeywords()
}
// productIndices produces a permutation of indices for each length. Example:
// productIndices(3, 2) -> [[0 0] [1 0] [2 0] [0 1] [1 1] [2 1]]. It returns
// a slice of length no larger than maxTotalMatches.
func productIndices(lengths ...int) [][]int {
count := 1
for _, l := range lengths {
count *= l
}
if count == 0 {
return nil
}
if count > maxTotalMatches {
count = maxTotalMatches
}
results := make([][]int, count)
for i := 0; i < count; i++ {
j := 1
result := make([]int, 0, len(lengths))
for _, l := range lengths {
result = append(result, (i/j)%l)
j *= l
}
results[i] = result
}
return results
}
// permutateMatches converts the list of all regex matches into all possible
// permutations selecting one from each named entry in the map. For example:
// {"foo": [matchA, matchB], "bar": [matchC]} becomes
//
// [{"foo": matchA, "bar": matchC}, {"foo": matchB, "bar": matchC}]
func permutateMatches(regexMatches map[string][][]string) []map[string][]string {
// Get a consistent order for names and their matching lengths.
// The lengths are used in calculating the permutation so order matters.
names := make([]string, 0, len(regexMatches))
lengths := make([]int, 0, len(regexMatches))
for key, value := range regexMatches {
names = append(names, key)
lengths = append(lengths, len(value))
}
// Permutate all the indices for each match. For example, if "foo" has
// [matchA, matchB] and "bar" has [matchC], we will get indices [0 0] [1 0].
permutationIndices := productIndices(lengths...)
// Build {"foo": matchA, "bar": matchC} and {"foo": matchB, "bar": matchC}
// from the indices.
var matches []map[string][]string
for _, permutation := range permutationIndices {
candidate := make(map[string][]string, len(permutationIndices))
for i, name := range names {
candidate[name] = regexMatches[name][permutation[i]]
}
matches = append(matches, candidate)
}
return matches
}
func (c *CustomRegexWebhook) Type() detector_typepb.DetectorType {
return detector_typepb.DetectorType_CustomRegex
}
const defaultDescription = "This is a user-defined detector with no description provided."
func (c *CustomRegexWebhook) Description() string {
if c.GetDescription() == "" {
return defaultDescription
}
return c.GetDescription()
}
// ensurePrimaryRegexNameSet sets the PrimaryRegexName field to the
// first regex name in sorted order if it is not already set.
// We're sorting to ensure deterministic behavior.
func ensurePrimaryRegexNameSet(pb *custom_detectorspb.CustomRegex) {
if pb.PrimaryRegexName == "" {
for _, name := range slices.Sorted(maps.Keys(pb.Regex)) {
pb.PrimaryRegexName = name
return
}
}
}