From e58aec687ee50d6d444361ff3a5cf55bf10a215a Mon Sep 17 00:00:00 2001 From: Mateusz Wos Date: Wed, 9 Sep 2026 17:33:58 +0200 Subject: [PATCH] feat(detectors): add Resend detector (#5103) Adds a TruffleHog detector for Resend (https://resend.com), an email API for transactional and marketing email. Key format: re_[1-9A-HJ-NP-Za-km-z]{8}_[1-9A-HJ-NP-Za-km-z]{24} (base58 alphabet, excluding the ambiguous characters 0, O, I, and l). Co-authored-by: Shahzad Haider <76992801+shahzadhaider1@users.noreply.github.com> Co-authored-by: meredith <4412188+mariduv@users.noreply.github.com> --- main.go | 1 + pkg/detectors/resend/resend.go | 149 ++++++++++++++ .../resend/resend_integration_test.go | 188 ++++++++++++++++++ pkg/detectors/resend/resend_test.go | 91 +++++++++ pkg/engine/defaults/defaults.go | 4 + pkg/engine/defaults/defaults_test.go | 1 + pkg/feature/feature.go | 1 + pkg/pb/detector_typepb/detector_type.pb.go | 18 +- proto/detector_type.proto | 1 + 9 files changed, 447 insertions(+), 7 deletions(-) create mode 100644 pkg/detectors/resend/resend.go create mode 100644 pkg/detectors/resend/resend_integration_test.go create mode 100644 pkg/detectors/resend/resend_test.go diff --git a/main.go b/main.go index d7d55eb47..16c16e0da 100644 --- a/main.go +++ b/main.go @@ -580,6 +580,7 @@ func run(state overseer.State, logSync func() error) { feature.NewRelicMobileAppTokenDetectorEnabled.Store(true) feature.MSTeamsWebhookV2DetectorEnabled.Store(true) feature.SolarwindsDetectorEnabled.Store(true) + feature.ResendDetectorEnabled.Store(true) feature.WeightsAndBiasesV2DetectorEnabled.Store(true) feature.HumioAPITokenDetectorEnabled.Store(true) diff --git a/pkg/detectors/resend/resend.go b/pkg/detectors/resend/resend.go new file mode 100644 index 000000000..ef34931a6 --- /dev/null +++ b/pkg/detectors/resend/resend.go @@ -0,0 +1,149 @@ +package resend + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + regexp "github.com/wasilibs/go-re2" + + "github.com/trufflesecurity/trufflehog/v3/pkg/common" + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" + "github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb" +) + +type Scanner struct { + client *http.Client +} + +// Ensure the Scanner satisfies the interface at compile time. +var _ detectors.Detector = (*Scanner)(nil) + +// GET /api-keys verifies both Resend permission tiers with a single +// read-only request: +// - full_access -> 200 +// - sending_access -> 401 with body {"name":"restricted_api_key"} +// +// See https://resend.com/docs/api-reference/api-keys/list-api-keys and +// https://resend.com/docs/api-reference/errors. +const verifyURL = "https://api.resend.com/api-keys" + +var ( + defaultClient = common.SaneHttpClient() + + // Resend API keys: prefix `re_`, 8 chars of base58, + // underscore, then 24 chars from the same alphabet. + keyPat = regexp.MustCompile(`\b(re_[1-9A-HJ-NP-Za-km-z]{8}_[1-9A-HJ-NP-Za-km-z]{24})\b`) +) + +// Keywords are used for efficiently pre-filtering chunks. +// Use identifiers in the secret preferably, or the provider name. +func (s Scanner) Keywords() []string { + return []string{"re_"} +} + +// FromData will find and optionally verify Resend secrets in a given set of bytes. +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 keyPat.FindAllStringSubmatch(dataStr, -1) { + uniqueMatches[match[1]] = struct{}{} + } + + for token := range uniqueMatches { + s1 := detectors.Result{ + DetectorType: detector_typepb.DetectorType_Resend, + Raw: []byte(token), + SecretParts: map[string]string{"key": token}, + } + + if verify { + client := s.getClient() + isVerified, extraData, verificationErr := verifyMatch(ctx, client, token) + s1.Verified = isVerified + s1.ExtraData = extraData + s1.SetVerificationError(verificationErr, token) + } + + results = append(results, s1) + } + + return results, nil +} + +func (s Scanner) getClient() *http.Client { + if s.client != nil { + return s.client + } + return defaultClient +} + +type resendError struct { + Name string `json:"name"` + Message string `json:"message"` +} + +func verifyMatch(ctx context.Context, client *http.Client, token string) (bool, map[string]string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, verifyURL, nil) + if err != nil { + return false, nil, err + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + res, err := client.Do(req) + if err != nil { + return false, nil, err + } + defer func() { + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() + }() + + extraData := map[string]string{ + "rotation_guide": "https://resend.com/docs/dashboard/api-keys/introduction", + } + + switch res.StatusCode { + case http.StatusOK: + // Active key with `full_access` permission. + extraData["permission"] = "full_access" + return true, extraData, nil + + case http.StatusUnauthorized: + // Resend returns 401 for two distinct cases. We must look at the + // error name to tell them apart: + // - restricted_api_key: active `sending_access` key trying to + // reach a non-send route. The key is real and can send mail. + // - missing_api_key: no Authorization header (should not + // happen here since we set one above). + var body resendError + if err := json.NewDecoder(res.Body).Decode(&body); err != nil { + return false, nil, fmt.Errorf("failed to decode 401 response body: %w", err) + } + if body.Name == "restricted_api_key" { + extraData["permission"] = "sending_access" + return true, extraData, nil + } + return false, nil, nil + + case http.StatusBadRequest, http.StatusForbidden: + // 400 validation_error: unknown, malformed, or soft-deleted key. + // 403 suspended_api_key / restricted_api_key ("not active"): + // the key exists but is already disabled; no rotation needed. + return false, nil, nil + + default: + return false, nil, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode) + } +} + +func (s Scanner) Type() detector_typepb.DetectorType { + return detector_typepb.DetectorType_Resend +} + +func (s Scanner) Description() string { + return "Resend is an email API for transactional and marketing email. Resend API keys can be used to send email, manage domains and audiences, and access other resources on the account." +} diff --git a/pkg/detectors/resend/resend_integration_test.go b/pkg/detectors/resend/resend_integration_test.go new file mode 100644 index 000000000..5c58a21c4 --- /dev/null +++ b/pkg/detectors/resend/resend_integration_test.go @@ -0,0 +1,188 @@ +//go:build detectors +// +build detectors + +package resend + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/trufflesecurity/trufflehog/v3/pkg/common" + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" + "github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb" +) + +func TestResend_FromChunk(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors5") + if err != nil { + t.Fatalf("could not get test secrets from GCP: %s", err) + } + secret := testSecrets.MustGetField("RESEND_API_KEY") + sendingAccessSecret := testSecrets.MustGetField("RESEND_API_KEY_SENDING_ACCESS") + inactiveSecret := testSecrets.MustGetField("RESEND_API_KEY_INACTIVE") + + type args struct { + ctx context.Context + data []byte + verify bool + } + tests := []struct { + name string + s Scanner + args args + want []detectors.Result + wantErr bool + wantVerificationErr bool + }{ + { + name: "found, verified (full_access)", + s: Scanner{}, + args: args{ + ctx: context.Background(), + data: []byte(fmt.Sprintf("You can find a resend secret %s within", secret)), + verify: true, + }, + want: []detectors.Result{ + { + DetectorType: detector_typepb.DetectorType_Resend, + Verified: true, + ExtraData: map[string]string{ + "permission": "full_access", + "rotation_guide": "https://resend.com/docs/dashboard/api-keys/introduction", + }, + }, + }, + wantErr: false, + wantVerificationErr: false, + }, + { + name: "found, verified (sending_access)", + s: Scanner{}, + args: args{ + ctx: context.Background(), + data: []byte(fmt.Sprintf("You can find a resend secret %s within", sendingAccessSecret)), + verify: true, + }, + want: []detectors.Result{ + { + DetectorType: detector_typepb.DetectorType_Resend, + Verified: true, + ExtraData: map[string]string{ + "permission": "sending_access", + "rotation_guide": "https://resend.com/docs/dashboard/api-keys/introduction", + }, + }, + }, + wantErr: false, + wantVerificationErr: false, + }, + { + name: "found, unverified", + s: Scanner{}, + args: args{ + ctx: context.Background(), + data: []byte(fmt.Sprintf("You can find a resend secret %s within but not valid", inactiveSecret)), + verify: true, + }, + want: []detectors.Result{ + { + DetectorType: detector_typepb.DetectorType_Resend, + Verified: false, + }, + }, + wantErr: false, + wantVerificationErr: false, + }, + { + name: "not found", + s: Scanner{}, + args: args{ + ctx: context.Background(), + data: []byte("You cannot find the secret within"), + verify: true, + }, + want: nil, + wantErr: false, + wantVerificationErr: false, + }, + { + name: "found, would be verified if not for timeout", + s: Scanner{client: common.SaneHttpClientTimeOut(1 * time.Microsecond)}, + args: args{ + ctx: context.Background(), + data: []byte(fmt.Sprintf("You can find a resend secret %s within", secret)), + verify: true, + }, + want: []detectors.Result{ + { + DetectorType: detector_typepb.DetectorType_Resend, + Verified: false, + }, + }, + wantErr: false, + wantVerificationErr: true, + }, + { + name: "found, verified but unexpected api surface", + s: Scanner{client: common.ConstantResponseHttpClient(404, "")}, + args: args{ + ctx: context.Background(), + data: []byte(fmt.Sprintf("You can find a resend secret %s within", secret)), + verify: true, + }, + want: []detectors.Result{ + { + DetectorType: detector_typepb.DetectorType_Resend, + Verified: false, + }, + }, + wantErr: false, + wantVerificationErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.s.FromData(tt.args.ctx, tt.args.verify, tt.args.data) + if (err != nil) != tt.wantErr { + t.Errorf("Resend.FromData() error = %v, wantErr %v", err, tt.wantErr) + return + } + for i := range got { + if len(got[i].Raw) == 0 { + t.Fatalf("no raw secret present: \n %+v", got[i]) + } + if (got[i].VerificationError() != nil) != tt.wantVerificationErr { + t.Errorf("Resend.FromData() error = %v, wantVerificationErr %v", got[i].VerificationError(), tt.wantVerificationErr) + } + } + ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "verificationError", "SecretParts") + ignoreUnexported := cmpopts.IgnoreUnexported(detectors.Result{}) + if diff := cmp.Diff(got, tt.want, ignoreOpts, ignoreUnexported); diff != "" { + t.Errorf("Resend.FromData() %s diff: (-got +want)\n%s", tt.name, diff) + } + }) + } +} + +func BenchmarkFromData(benchmark *testing.B) { + ctx := context.Background() + s := Scanner{} + for name, data := range detectors.MustGetBenchmarkData() { + benchmark.Run(name, func(b *testing.B) { + b.ResetTimer() + for n := 0; n < b.N; n++ { + _, err := s.FromData(ctx, false, data) + if err != nil { + b.Fatal(err) + } + } + }) + } +} diff --git a/pkg/detectors/resend/resend_test.go b/pkg/detectors/resend/resend_test.go new file mode 100644 index 000000000..dff632aea --- /dev/null +++ b/pkg/detectors/resend/resend_test.go @@ -0,0 +1,91 @@ +package resend + +import ( + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" + "github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick" +) + +var ( + validPattern = "re_1234abcd_aBcDeFgHiJkLmNoPqRsTuVwX" + invalidPattern = "re_1234abcd_aBcDeFgHiJk0OIlmNoPqRsTu" + keyword = "resend" +) + +func TestResend_Pattern(t *testing.T) { + d := Scanner{} + ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d}) + tests := []struct { + name string + input string + want []string + }{ + { + name: "valid pattern - with keyword resend", + input: fmt.Sprintf("%s token = '%s'", keyword, validPattern), + want: []string{validPattern}, + }, + { + name: "valid pattern - ignore duplicate", + input: fmt.Sprintf("%s token = '%s' | '%s'", keyword, validPattern, validPattern), + want: []string{validPattern}, + }, + { + name: "invalid pattern - contains disallowed base58 chars (0/O/I/l)", + input: fmt.Sprintf("%s = '%s'", keyword, invalidPattern), + want: []string{}, + }, + { + name: "invalid pattern - wrong length", + input: fmt.Sprintf("%s = 're_1234abcd_short'", keyword), + want: []string{}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + matchedDetectors := ahoCorasickCore.FindDetectorMatches([]byte(test.input)) + if len(matchedDetectors) == 0 { + t.Errorf("keywords '%v' not matched by: %s", d.Keywords(), test.input) + return + } + + results, err := d.FromData(context.Background(), false, []byte(test.input)) + if err != nil { + t.Errorf("error = %v", err) + return + } + + if len(results) != len(test.want) { + if len(results) == 0 { + t.Errorf("did not receive result") + } else { + t.Errorf("expected %d results, only received %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) + } + }) + } +} diff --git a/pkg/engine/defaults/defaults.go b/pkg/engine/defaults/defaults.go index 7be066f92..ee5dc6351 100644 --- a/pkg/engine/defaults/defaults.go +++ b/pkg/engine/defaults/defaults.go @@ -645,6 +645,7 @@ import ( "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/replicate" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/replyio" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/requestfinance" + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/resend" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/restpackhtmltopdfapi" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/restpackscreenshotapi" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/rev" @@ -1567,6 +1568,7 @@ func buildDetectorList() []detectors.Detector { &replicate.Scanner{}, &replyio.Scanner{}, &requestfinance.Scanner{}, + &resend.Scanner{}, // &restpack.Scanner{}, &restpackhtmltopdfapi.Scanner{}, &restpackscreenshotapi.Scanner{}, @@ -1898,6 +1900,8 @@ func buildDetectorList() []detectors.Detector { return !feature.SolarwindsDetectorEnabled.Load() case *humioapitoken.Scanner: return !feature.HumioAPITokenDetectorEnabled.Load() + case *resend.Scanner: + return !feature.ResendDetectorEnabled.Load() case *weightsandbiasesv2.Scanner: return !feature.WeightsAndBiasesV2DetectorEnabled.Load() default: diff --git a/pkg/engine/defaults/defaults_test.go b/pkg/engine/defaults/defaults_test.go index f60e2b9d1..7155a22a1 100644 --- a/pkg/engine/defaults/defaults_test.go +++ b/pkg/engine/defaults/defaults_test.go @@ -152,6 +152,7 @@ var excludedFromDefaultList = map[detector_typepb.DetectorType]struct{}{ detector_typepb.DetectorType_NewRelicMobileAppToken: {}, detector_typepb.DetectorType_SolarWindsObservability: {}, detector_typepb.DetectorType_HumioAPIToken: {}, + detector_typepb.DetectorType_Resend: {}, // Reserved / special types. detector_typepb.DetectorType_CustomRegex: {}, // added dynamically via engine config, not via buildDetectorList() diff --git a/pkg/feature/feature.go b/pkg/feature/feature.go index fadd73d03..b8d01e8a3 100644 --- a/pkg/feature/feature.go +++ b/pkg/feature/feature.go @@ -50,6 +50,7 @@ var ( NewRelicMobileAppTokenDetectorEnabled atomic.Bool MSTeamsWebhookV2DetectorEnabled atomic.Bool SolarwindsDetectorEnabled atomic.Bool + ResendDetectorEnabled atomic.Bool WeightsAndBiasesV2DetectorEnabled atomic.Bool HumioAPITokenDetectorEnabled atomic.Bool ) diff --git a/pkg/pb/detector_typepb/detector_type.pb.go b/pkg/pb/detector_typepb/detector_type.pb.go index cc9bc229f..630eb70d8 100644 --- a/pkg/pb/detector_typepb/detector_type.pb.go +++ b/pkg/pb/detector_typepb/detector_type.pb.go @@ -1126,6 +1126,7 @@ const ( DetectorType_NewRelicMobileAppToken DetectorType = 1068 DetectorType_SolarWindsObservability DetectorType = 1069 DetectorType_HumioAPIToken DetectorType = 1070 + DetectorType_Resend DetectorType = 1071 ) // Enum value maps for DetectorType. @@ -2198,6 +2199,7 @@ var ( 1068: "NewRelicMobileAppToken", 1069: "SolarWindsObservability", 1070: "HumioAPIToken", + 1071: "Resend", } DetectorType_value = map[string]int32{ "Alibaba": 0, @@ -3267,6 +3269,7 @@ var ( "NewRelicMobileAppToken": 1068, "SolarWindsObservability": 1069, "HumioAPIToken": 1070, + "Resend": 1071, } ) @@ -3302,7 +3305,7 @@ var File_detector_type_proto protoreflect.FileDescriptor var file_detector_type_proto_rawDesc = []byte{ 0x0a, 0x13, 0x64, 0x65, 0x74, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0d, 0x64, 0x65, 0x74, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, - 0x74, 0x79, 0x70, 0x65, 0x2a, 0x87, 0x8c, 0x01, 0x0a, 0x0c, 0x44, 0x65, 0x74, 0x65, 0x63, 0x74, + 0x74, 0x79, 0x70, 0x65, 0x2a, 0x94, 0x8c, 0x01, 0x0a, 0x0c, 0x44, 0x65, 0x74, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x41, 0x6c, 0x69, 0x62, 0x61, 0x62, 0x61, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x41, 0x4d, 0x51, 0x50, 0x10, 0x01, 0x12, 0x07, 0x0a, 0x03, 0x41, 0x57, 0x53, 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x41, 0x7a, 0x75, 0x72, 0x65, 0x10, @@ -4422,12 +4425,13 @@ var file_detector_type_proto_rawDesc = []byte{ 0x6c, 0x65, 0x41, 0x70, 0x70, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x10, 0xac, 0x08, 0x12, 0x1c, 0x0a, 0x17, 0x53, 0x6f, 0x6c, 0x61, 0x72, 0x57, 0x69, 0x6e, 0x64, 0x73, 0x4f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x10, 0xad, 0x08, 0x12, 0x12, 0x0a, 0x0d, 0x48, - 0x75, 0x6d, 0x69, 0x6f, 0x41, 0x50, 0x49, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x10, 0xae, 0x08, 0x42, - 0x41, 0x5a, 0x3f, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x74, 0x72, - 0x75, 0x66, 0x66, 0x6c, 0x65, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x2f, 0x74, 0x72, - 0x75, 0x66, 0x66, 0x6c, 0x65, 0x68, 0x6f, 0x67, 0x2f, 0x76, 0x33, 0x2f, 0x70, 0x6b, 0x67, 0x2f, - 0x70, 0x62, 0x2f, 0x64, 0x65, 0x74, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x74, 0x79, 0x70, 0x65, - 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x75, 0x6d, 0x69, 0x6f, 0x41, 0x50, 0x49, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x10, 0xae, 0x08, 0x12, + 0x0b, 0x0a, 0x06, 0x52, 0x65, 0x73, 0x65, 0x6e, 0x64, 0x10, 0xaf, 0x08, 0x42, 0x41, 0x5a, 0x3f, + 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x74, 0x72, 0x75, 0x66, 0x66, + 0x6c, 0x65, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x2f, 0x74, 0x72, 0x75, 0x66, 0x66, + 0x6c, 0x65, 0x68, 0x6f, 0x67, 0x2f, 0x76, 0x33, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x62, 0x2f, + 0x64, 0x65, 0x74, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x70, 0x62, 0x62, + 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/proto/detector_type.proto b/proto/detector_type.proto index eca3c0257..b8ed9ffeb 100644 --- a/proto/detector_type.proto +++ b/proto/detector_type.proto @@ -1072,4 +1072,5 @@ enum DetectorType { NewRelicMobileAppToken = 1068; SolarWindsObservability = 1069; HumioAPIToken = 1070; + Resend = 1071; }