diff --git a/main.go b/main.go index 903f14bbe..53f9fb7dc 100644 --- a/main.go +++ b/main.go @@ -565,6 +565,7 @@ func run(state overseer.State, logSync func() error) { feature.CloudflareGlobalApiKeyV2DetectorEnabled.Store(true) feature.DuoDetectorEnabled.Store(true) feature.NewRelicLicenseKeyDetectorEnabled.Store(true) + feature.NewRelicBrowserKeyDetectorEnabled.Store(true) conf := &config.Config{} if *configFilename != "" { diff --git a/pkg/detectors/newrelicbrowserkey/newrelicbrowserkey.go b/pkg/detectors/newrelicbrowserkey/newrelicbrowserkey.go new file mode 100644 index 000000000..77a1bdefd --- /dev/null +++ b/pkg/detectors/newrelicbrowserkey/newrelicbrowserkey.go @@ -0,0 +1,135 @@ +package newrelicbrowserkey + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + 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 interfaces at compile time. +var _ detectors.Detector = (*Scanner)(nil) + +var ( + defaultClient = common.SaneHttpClient() + keyPat = regexp.MustCompile(`\b(NRBR-[0-9a-f]{19})\b`) +) + +func (s Scanner) getClient() *http.Client { + if s.client != nil { + return s.client + } + + return defaultClient +} + +// Keywords are used for efficiently pre-filtering chunks. +func (s Scanner) Keywords() []string { return []string{"nrbr-"} } + +func (s Scanner) Type() detector_typepb.DetectorType { + return detector_typepb.DetectorType_NewRelicBrowserKey +} + +func (s Scanner) Description() string { + return "A New Relic Browser API key is used to authenticate and enable browser monitoring, allowing New Relic to collect performance data, page views, and user interactions from web applications to track and optimize front-end performance." +} + +func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) { + dataStr := string(data) + + matches := keyPat.FindAllStringSubmatch(dataStr, -1) + for _, match := range matches { + resMatch := strings.TrimSpace(match[1]) + + s1 := detectors.Result{ + DetectorType: s.Type(), + Raw: []byte(resMatch), + Redacted: resMatch[:8] + "...", + SecretParts: map[string]string{"key": resMatch}, + } + + if verify { + isVerified, extraData, verificationErr := s.verify(ctx, resMatch) + s1.Verified = isVerified + s1.ExtraData = extraData + if _, ok := extraData["region"]; ok { + s1.SecretParts["region"] = extraData["region"] + } + s1.SetVerificationError(verificationErr) + } + + results = append(results, s1) + } + + return results, nil +} + +// verify checks if the key is valid by making a request to New Relic's Browser API endpoint. +// There are separate endpoints for US and EU region keys, so we check both. +// These endpoints are not documented anywhere because they are used internally by the New Relic Browser agent +// The endpoints were discovered by observing network traffic from the New Relic Browser agent +// A 400 Bad Request response indicates that the key is valid but the request is malformed (because we are not sending the expected payload) +// A 403 Forbidden response indicates that the key is invalid or revoked +func (s Scanner) verify(ctx context.Context, key string) (bool, map[string]string, error) { + regionUrls := map[string]string{ + "us": "https://bam.nr-data.net/1/", + "eu": "https://bam.eu01.nr-data.net/1/", + } + errs := make([]error, 0, len(regionUrls)) + for region, regionUrl := range regionUrls { + verified, err := s.verifyRegion(ctx, key, regionUrl) + if err != nil { + errs = append(errs, fmt.Errorf("error verifying region %s: %w", region, err)) + continue + } + if verified { + return true, map[string]string{"region": region}, nil + } + } + return false, nil, errors.Join(errs...) +} + +func (s Scanner) verifyRegion(ctx context.Context, key string, regionUrl string) (bool, error) { + fullUrl, err := url.JoinPath(regionUrl, key) + if err != nil { + return false, fmt.Errorf("error constructing URL: %w", err) + } + req, err := http.NewRequestWithContext( + ctx, http.MethodPost, fullUrl, http.NoBody) + if err != nil { + return false, fmt.Errorf("error constructing request: %w", err) + } + + client := s.getClient() + + res, err := client.Do(req) + if err != nil { + return false, fmt.Errorf("error making request: %w", err) + } + defer func() { + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() + }() + + switch res.StatusCode { + case http.StatusBadRequest: + return true, nil + case http.StatusForbidden: + return false, nil + default: + return false, fmt.Errorf("unexpected status code: %d", res.StatusCode) + } +} diff --git a/pkg/detectors/newrelicbrowserkey/newrelicbrowserkey_integration_test.go b/pkg/detectors/newrelicbrowserkey/newrelicbrowserkey_integration_test.go new file mode 100644 index 000000000..89d5516c9 --- /dev/null +++ b/pkg/detectors/newrelicbrowserkey/newrelicbrowserkey_integration_test.go @@ -0,0 +1,151 @@ +//go:build detectors +// +build detectors + +package newrelicbrowserkey + +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 TestNewRelicBrowserKey_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) + } + + key := testSecrets.MustGetField("NEW_RELIC_BROWSER_KEY") + keyEU := testSecrets.MustGetField("NEW_RELIC_BROWSER_KEY_EU") + keyInactive := "NRBR-cd83c5e6c53fe2edc1a" + + 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, verified", + s: Scanner{}, + args: args{ + ctx: context.Background(), + data: []byte(fmt.Sprintf("You can find a new relic browser key %s within", key)), + verify: true, + }, + want: []detectors.Result{ + { + DetectorType: detector_typepb.DetectorType_NewRelicBrowserKey, + Verified: true, + ExtraData: map[string]string{ + "region": "us", + }, + SecretParts: map[string]string{"key": key, "region": "us"}, + }, + }, + wantErr: false, + }, + { + name: "found eu, verified", + s: Scanner{}, + args: args{ + ctx: context.Background(), + data: []byte(fmt.Sprintf("You can find a new EU relic browser key %s within", keyEU)), + verify: true, + }, + want: []detectors.Result{ + { + DetectorType: detector_typepb.DetectorType_NewRelicBrowserKey, + Verified: true, + ExtraData: map[string]string{ + "region": "eu", + }, + SecretParts: map[string]string{"key": keyEU, "region": "eu"}, + }, + }, + wantErr: false, + }, + { + name: "found, unverified", + s: Scanner{}, + args: args{ + ctx: context.Background(), + data: []byte(fmt.Sprintf("You can find a new relic browser key %s within", keyInactive)), // the secret would satisfy the regex but not pass validation + verify: true, + }, + want: []detectors.Result{ + { + DetectorType: detector_typepb.DetectorType_NewRelicBrowserKey, + Verified: false, + SecretParts: map[string]string{"key": keyInactive}, + }, + }, + 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("NewRelicBrowserKey.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]) + } + got[i].Raw = nil + if len(got[i].Redacted) == 0 { + t.Fatalf("no redacted secret present: \n %+v", got[i]) + } + got[i].Redacted = "" + } + if diff := pretty.Compare(got, tt.want); diff != "" { + t.Errorf("NewRelicBrowserKey.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/newrelicbrowserkey/newrelicbrowserkey_test.go b/pkg/detectors/newrelicbrowserkey/newrelicbrowserkey_test.go new file mode 100644 index 000000000..7438bd2e1 --- /dev/null +++ b/pkg/detectors/newrelicbrowserkey/newrelicbrowserkey_test.go @@ -0,0 +1,80 @@ +package newrelicbrowserkey + +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 = "NRBR-cd83c5e6c53fe2edc1a" + invalidPattern = "NRBR-cd83c5e6c53fe2edc1" +) + +func TestNewRelicBrowserKey_Pattern(t *testing.T) { + d := Scanner{} + ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d}) + tests := []struct { + name string + input string + want []string + }{ + { + name: "valid pattern", + input: fmt.Sprintf("new relic browser key = '%s'", validPattern), + want: []string{validPattern}, + }, + { + name: "invalid pattern", + input: fmt.Sprintf("new relic browser key = '%s'", invalidPattern), + 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 dfcb44bae..8fe269d01 100644 --- a/pkg/engine/defaults/defaults.go +++ b/pkg/engine/defaults/defaults.go @@ -510,6 +510,7 @@ import ( netlifyv2 "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/netlify/v2" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/netsuite" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/neutrinoapi" + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/newrelicbrowserkey" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/newrelicinsightsinsertkey" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/newreliclicensekey" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/newrelicpersonalapikey" @@ -1424,6 +1425,7 @@ func buildDetectorList() []detectors.Detector { &netlifyv2.Scanner{}, &netsuite.Scanner{}, &neutrinoapi.Scanner{}, + &newrelicbrowserkey.Scanner{}, &newrelicinsightsinsertkey.Scanner{}, &newreliclicensekey.Scanner{}, &newrelicpersonalapikey.Scanner{}, @@ -1868,6 +1870,8 @@ func buildDetectorList() []detectors.Detector { return !feature.DuoDetectorEnabled.Load() case *newreliclicensekey.Scanner: return !feature.NewRelicLicenseKeyDetectorEnabled.Load() + case *newrelicbrowserkey.Scanner: + return !feature.NewRelicBrowserKeyDetectorEnabled.Load() default: return false } diff --git a/pkg/engine/defaults/defaults_test.go b/pkg/engine/defaults/defaults_test.go index 16b87ba1f..5e1bae884 100644 --- a/pkg/engine/defaults/defaults_test.go +++ b/pkg/engine/defaults/defaults_test.go @@ -146,6 +146,7 @@ var excludedFromDefaultList = map[detector_typepb.DetectorType]struct{}{ detector_typepb.DetectorType_HashiCorpVaultToken: {}, detector_typepb.DetectorType_Duo: {}, detector_typepb.DetectorType_NewRelicLicenseKey: {}, + detector_typepb.DetectorType_NewRelicBrowserKey: {}, // 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 a274c3a6f..d68b289af 100644 --- a/pkg/feature/feature.go +++ b/pkg/feature/feature.go @@ -43,6 +43,7 @@ var ( CloudflareGlobalApiKeyV2DetectorEnabled atomic.Bool DuoDetectorEnabled atomic.Bool NewRelicLicenseKeyDetectorEnabled atomic.Bool + NewRelicBrowserKeyDetectorEnabled 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 44773339b..6015daa5f 100644 --- a/pkg/pb/detector_typepb/detector_type.pb.go +++ b/pkg/pb/detector_typepb/detector_type.pb.go @@ -1118,6 +1118,7 @@ const ( DetectorType_HashiCorpVaultToken DetectorType = 1062 DetectorType_Duo DetectorType = 1063 DetectorType_NewRelicLicenseKey DetectorType = 1064 + DetectorType_NewRelicBrowserKey DetectorType = 1065 ) // Enum value maps for DetectorType. @@ -2184,6 +2185,7 @@ var ( 1062: "HashiCorpVaultToken", 1063: "Duo", 1064: "NewRelicLicenseKey", + 1065: "NewRelicBrowserKey", } DetectorType_value = map[string]int32{ "Alibaba": 0, @@ -3247,6 +3249,7 @@ var ( "HashiCorpVaultToken": 1062, "Duo": 1063, "NewRelicLicenseKey": 1064, + "NewRelicBrowserKey": 1065, } ) @@ -3282,7 +3285,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, 0xe2, 0x8a, 0x01, 0x0a, 0x0c, 0x44, 0x65, 0x74, 0x65, 0x63, 0x74, + 0x74, 0x79, 0x70, 0x65, 0x2a, 0xfb, 0x8a, 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, @@ -4392,12 +4395,13 @@ var file_detector_type_proto_rawDesc = []byte{ 0x13, 0x48, 0x61, 0x73, 0x68, 0x69, 0x43, 0x6f, 0x72, 0x70, 0x56, 0x61, 0x75, 0x6c, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x10, 0xa6, 0x08, 0x12, 0x08, 0x0a, 0x03, 0x44, 0x75, 0x6f, 0x10, 0xa7, 0x08, 0x12, 0x17, 0x0a, 0x12, 0x4e, 0x65, 0x77, 0x52, 0x65, 0x6c, 0x69, 0x63, 0x4c, 0x69, 0x63, - 0x65, 0x6e, 0x73, 0x65, 0x4b, 0x65, 0x79, 0x10, 0xa8, 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, + 0x65, 0x6e, 0x73, 0x65, 0x4b, 0x65, 0x79, 0x10, 0xa8, 0x08, 0x12, 0x17, 0x0a, 0x12, 0x4e, 0x65, + 0x77, 0x52, 0x65, 0x6c, 0x69, 0x63, 0x42, 0x72, 0x6f, 0x77, 0x73, 0x65, 0x72, 0x4b, 0x65, 0x79, + 0x10, 0xa9, 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 e41f78743..5942c7aee 100644 --- a/proto/detector_type.proto +++ b/proto/detector_type.proto @@ -1066,4 +1066,5 @@ enum DetectorType { HashiCorpVaultToken = 1062; Duo = 1063; NewRelicLicenseKey = 1064; + NewRelicBrowserKey = 1065; }