[INS-348] Add New Relic Browser Key detector (#4769)

* add new relic browser key detector

* add build tags to integration test

* always try both region urls before returning error

* extract region verification logic to separate function

* fix detector type change after merge

* chore: add feat flag gating and regen protos

* chore: add secret parts and adjust tests accordingly

* chore: regen protos and resolve comments

---------

Co-authored-by: Muneeb Ullah Khan <[email protected]>
This commit is contained in:
Mustansir
2026-07-30 18:12:18 +05:00
committed by GitHub
co-authored by Muneeb Ullah Khan
parent f2d9ea762e
commit 5ca8517781
9 changed files with 385 additions and 7 deletions
+1
View File
@@ -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 != "" {
@@ -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)
}
}
@@ -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)
}
}
})
}
}
@@ -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)
}
})
}
}
+4
View File
@@ -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
}
+1
View File
@@ -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()
+1
View File
@@ -43,6 +43,7 @@ var (
CloudflareGlobalApiKeyV2DetectorEnabled atomic.Bool
DuoDetectorEnabled atomic.Bool
NewRelicLicenseKeyDetectorEnabled atomic.Bool
NewRelicBrowserKeyDetectorEnabled atomic.Bool
)
type AtomicString struct {
+11 -7
View File
@@ -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 (
+1
View File
@@ -1066,4 +1066,5 @@ enum DetectorType {
HashiCorpVaultToken = 1062;
Duo = 1063;
NewRelicLicenseKey = 1064;
NewRelicBrowserKey = 1065;
}