From f2cd191b97098913a07522227d2b5e40e57252f4 Mon Sep 17 00:00:00 2001 From: Luc Georges Date: Fri, 3 Jul 2026 12:08:49 +0200 Subject: [PATCH] feat: add OpenRouter detector (#4500) * feat: add OpenRouter detector closes #4499 * refactor: correct doc comment referencing OpenAI to OpenRouter * refactor: remove unnecessary `AnalysisInfo` field * fix: data field types Signed-off-by: Luc Georges * fix: handle float fmt correctly Signed-off-by: Luc Georges * fix detector type add feature flag for openrouter detector * fix integration tests --------- Signed-off-by: Luc Georges Co-authored-by: Shahzad Haider Co-authored-by: Shahzad Haider <76992801+shahzadhaider1@users.noreply.github.com> Co-authored-by: Muneeb Ullah Khan <100969065+MuneebUllahKhan222@users.noreply.github.com> --- main.go | 1 + pkg/detectors/openrouter/openrouter.go | 138 ++++++++++++++++++ .../openrouter/openrouter_integration_test.go | 125 ++++++++++++++++ pkg/detectors/openrouter/openrouter_test.go | 78 ++++++++++ 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, 360 insertions(+), 7 deletions(-) create mode 100644 pkg/detectors/openrouter/openrouter.go create mode 100644 pkg/detectors/openrouter/openrouter_integration_test.go create mode 100644 pkg/detectors/openrouter/openrouter_test.go diff --git a/main.go b/main.go index c70bec21c..9504bbc29 100644 --- a/main.go +++ b/main.go @@ -551,6 +551,7 @@ func run(state overseer.State, logSync func() error) { feature.PgAnalyzeReadKeyDetectorEnabled.Store(true) feature.RedHatPyxisDetectorEnabled.Store(true) feature.OctopusDeployDetectorEnabled.Store(true) + feature.OpenRouterDetectorEnabled.Store(true) conf := &config.Config{} if *configFilename != "" { diff --git a/pkg/detectors/openrouter/openrouter.go b/pkg/detectors/openrouter/openrouter.go new file mode 100644 index 000000000..cc9b25b19 --- /dev/null +++ b/pkg/detectors/openrouter/openrouter.go @@ -0,0 +1,138 @@ +package openrouter + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + + 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) + +var ( + defaultClient = common.SaneHttpClient() + + keyPat = regexp.MustCompile(`\b(sk-or-v1-[0-9a-f]{64})\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{"sk-or-v1-"} +} + +// FromData will find and optionally verify OpenRouter 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: s.Type(), + // NOTE: we redact the same way it is done in the `Label` field + Redacted: token[:12] + "..." + token[70:], + Raw: []byte(token), + SecretParts: map[string]string{"key": token}, + } + + if verify { + client := s.client + if client == nil { + client = defaultClient + } + + verified, extraData, verificationErr := verifyToken(ctx, client, token) + s1.Verified = verified + s1.ExtraData = extraData + s1.SetVerificationError(verificationErr) + } + + results = append(results, s1) + } + + return results, err +} + +func verifyToken(ctx context.Context, client *http.Client, token string) (bool, map[string]string, error) { + req, err := http.NewRequestWithContext(ctx, "GET", "https://openrouter.ai/api/v1/key", 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() + }() + + switch res.StatusCode { + case http.StatusOK: + var keyResponse keyResponse + if err = json.NewDecoder(res.Body).Decode(&keyResponse); err != nil { + return false, nil, err + } + + key := keyResponse.Data + extraData := map[string]string{ + "label": key.Label, + "limit": fmtFloatPtr(key.Limit), + "usage": fmt.Sprintf("%f", key.Usage), + "is_free_tier": strconv.FormatBool(key.IsFreeTier), + "limit_remaining": fmtFloatPtr(key.LimitRemaining), + } + return true, extraData, nil + case http.StatusUnauthorized: + // Invalid + 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_OpenRouter +} + +func (s Scanner) Description() string { + return "OpenRouter provides a unified API that gives you access to hundreds of AI models through a single endpoint, while automatically handling fallbacks and selecting the most cost-effective options." +} + +func fmtFloatPtr(f *float64) string { + if f == nil { + return "" + } + return fmt.Sprintf("%f", *f) +} + +type keyResponse struct { + Data key `json:"data"` +} + +type key struct { + Label string `json:"label"` + Limit *float64 `json:"limit"` + Usage float64 `json:"usage"` + IsFreeTier bool `json:"is_free_tier"` + LimitRemaining *float64 `json:"limit_remaining"` +} diff --git a/pkg/detectors/openrouter/openrouter_integration_test.go b/pkg/detectors/openrouter/openrouter_integration_test.go new file mode 100644 index 000000000..7084f3c4e --- /dev/null +++ b/pkg/detectors/openrouter/openrouter_integration_test.go @@ -0,0 +1,125 @@ +//go:build detectors +// +build detectors + +package openrouter + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/kylelemons/godebug/pretty" + "github.com/trufflesecurity/trufflehog/v3/pkg/common" + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" + "github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb" +) + +func TestOpenRouter_FromChunk(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors6") + if err != nil { + t.Fatalf("could not get test secrets from GCP: %s", err) + } + + secret := testSecrets.MustGetField("OPENROUTER") + inactiveSecret := testSecrets.MustGetField("OPENROUTER_INACTIVE") + + type args struct { + ctx context.Context + data []byte + verify bool + } + + tests := []struct { + name string + s Scanner + args args + want []detectors.Result + wantErr bool + }{ + { + name: "Found, unverified OpenRouter token sk-or-v1-", + s: Scanner{}, + args: args{ + ctx: context.Background(), + data: []byte(fmt.Sprintf("You can find an OpenRouter secret %s within", inactiveSecret)), + verify: true, + }, + want: []detectors.Result{ + { + DetectorType: detector_typepb.DetectorType_OpenRouter, + Redacted: inactiveSecret[:12] + "..." + inactiveSecret[70:], + Verified: false, + }, + }, + wantErr: false, + }, + { + name: "Found, verified OpenRouter token sk-or-v1-", + s: Scanner{}, + args: args{ + ctx: context.Background(), + data: []byte(fmt.Sprintf("You can find an OpenRouter secret %s within", secret)), + verify: true, + }, + want: []detectors.Result{ + { + DetectorType: detector_typepb.DetectorType_OpenRouter, + Verified: true, + Redacted: secret[:12] + "..." + secret[70:], + }, + }, + wantErr: 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, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := Scanner{} + got, err := s.FromData(tt.args.ctx, tt.args.verify, tt.args.data) + if (err != nil) != tt.wantErr { + t.Errorf("OpenRouter.FromData() error = %v, wantErr %v", err, tt.wantErr) + return + } + for i := range got { + if len(got[i].Raw) == 0 { + t.Fatal("no raw secret present") + } + got[i].Raw = nil + got[i].ExtraData = nil + got[i].SecretParts = nil + } + if diff := pretty.Compare(got, tt.want); diff != "" { + t.Errorf("OpenRouter.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/openrouter/openrouter_test.go b/pkg/detectors/openrouter/openrouter_test.go new file mode 100644 index 000000000..9e9036215 --- /dev/null +++ b/pkg/detectors/openrouter/openrouter_test.go @@ -0,0 +1,78 @@ +package openrouter + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" + "github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick" +) + +func TestOpenRouter_Pattern(t *testing.T) { + d := Scanner{} + ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d}) + tests := []struct { + name string + input string + want []string + }{ + { + name: "API key", + input: `OPENROUTER_API_KEY = "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"`, + want: []string{"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"}, + }, + { + name: "invalid pattern", + input: ` + [INFO] Sending request to the openrouter API + [DEBUG] Using Key=sk-or-v1-a2Cy8xCLyvrAf7lZKfhQhyCr4RAID9D + [ERROR] Response received: 401 UnAuthorized + `, + want: []string{}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + detectorMatches := ahoCorasickCore.FindDetectorMatches([]byte(test.input)) + if len(detectorMatches) == 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 7ab43748d..e8c4d32dd 100644 --- a/pkg/engine/defaults/defaults.go +++ b/pkg/engine/defaults/defaults.go @@ -535,6 +535,7 @@ import ( "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/openai" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/openaiadmin" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/opencagedata" + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/openrouter" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/openuv" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/openvpn" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/openweather" @@ -1436,6 +1437,7 @@ func buildDetectorList() []detectors.Detector { &openai.Scanner{}, &openaiadmin.Scanner{}, &opencagedata.Scanner{}, + &openrouter.Scanner{}, &openuv.Scanner{}, &openvpn.Scanner{}, &openweather.Scanner{}, @@ -1816,6 +1818,8 @@ func buildDetectorList() []detectors.Detector { return !feature.RedHatPyxisDetectorEnabled.Load() case *octopusdeploy.Scanner: return !feature.OctopusDeployDetectorEnabled.Load() + case *openrouter.Scanner: + return !feature.OpenRouterDetectorEnabled.Load() default: return false } diff --git a/pkg/engine/defaults/defaults_test.go b/pkg/engine/defaults/defaults_test.go index 51e654ed6..450ce2ed5 100644 --- a/pkg/engine/defaults/defaults_test.go +++ b/pkg/engine/defaults/defaults_test.go @@ -138,6 +138,7 @@ var excludedFromDefaultList = map[detector_typepb.DetectorType]struct{}{ detector_typepb.DetectorType_PgAnalyzeReadKey: {}, detector_typepb.DetectorType_RedHatPyxis: {}, detector_typepb.DetectorType_OctopusDeploy: {}, + detector_typepb.DetectorType_OpenRouter: {}, // 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 4b8c9a654..38485cd39 100644 --- a/pkg/feature/feature.go +++ b/pkg/feature/feature.go @@ -30,6 +30,7 @@ var ( RedHatPyxisDetectorEnabled atomic.Bool OctopusDeployDetectorEnabled atomic.Bool DropUnverifiedJWTResults atomic.Bool + OpenRouterDetectorEnabled atomic.Bool ) type AtomicString struct { diff --git a/pkg/pb/detector_typepb/detector_type.pb.go b/pkg/pb/detector_typepb/detector_type.pb.go index d535a7686..7f5d9a3b2 100644 --- a/pkg/pb/detector_typepb/detector_type.pb.go +++ b/pkg/pb/detector_typepb/detector_type.pb.go @@ -1110,6 +1110,7 @@ const ( DetectorType_PgAnalyzeReadKey DetectorType = 1054 DetectorType_RedHatPyxis DetectorType = 1055 DetectorType_OctopusDeploy DetectorType = 1056 + DetectorType_OpenRouter DetectorType = 1057 ) // Enum value maps for DetectorType. @@ -2168,6 +2169,7 @@ var ( 1054: "PgAnalyzeReadKey", 1055: "RedHatPyxis", 1056: "OctopusDeploy", + 1057: "OpenRouter", } DetectorType_value = map[string]int32{ "Alibaba": 0, @@ -3223,6 +3225,7 @@ var ( "PgAnalyzeReadKey": 1054, "RedHatPyxis": 1055, "OctopusDeploy": 1056, + "OpenRouter": 1057, } ) @@ -3258,7 +3261,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, 0xb6, 0x89, 0x01, 0x0a, 0x0c, 0x44, 0x65, 0x74, 0x65, 0x63, 0x74, + 0x74, 0x79, 0x70, 0x65, 0x2a, 0xc7, 0x89, 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, @@ -4357,12 +4360,13 @@ var file_detector_type_proto_rawDesc = []byte{ 0x08, 0x12, 0x15, 0x0a, 0x10, 0x50, 0x67, 0x41, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x52, 0x65, 0x61, 0x64, 0x4b, 0x65, 0x79, 0x10, 0x9e, 0x08, 0x12, 0x10, 0x0a, 0x0b, 0x52, 0x65, 0x64, 0x48, 0x61, 0x74, 0x50, 0x79, 0x78, 0x69, 0x73, 0x10, 0x9f, 0x08, 0x12, 0x12, 0x0a, 0x0d, 0x4f, 0x63, - 0x74, 0x6f, 0x70, 0x75, 0x73, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x10, 0xa0, 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, + 0x74, 0x6f, 0x70, 0x75, 0x73, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x10, 0xa0, 0x08, 0x12, 0x0f, + 0x0a, 0x0a, 0x4f, 0x70, 0x65, 0x6e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x10, 0xa1, 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 fef1be406..3ecef863c 100644 --- a/proto/detector_type.proto +++ b/proto/detector_type.proto @@ -1058,4 +1058,5 @@ enum DetectorType { PgAnalyzeReadKey = 1054; RedHatPyxis = 1055; OctopusDeploy = 1056; + OpenRouter = 1057; }