Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions pkg/detectors/newrelicinsightsquerykey/newrelicinsightsquerykey.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package newrelicinsightsquerykey

import (
"context"
"errors"
"fmt"
"io"
"net/http"
"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 {
detectors.DefaultMultiPartCredentialProvider

client *http.Client
}
Comment thread
cursor[bot] marked this conversation as resolved.

// Ensure the Scanner satisfies the interfaces at compile time.
var _ detectors.Detector = (*Scanner)(nil)

var (
defaultClient = common.SaneHttpClient()
keyPat = regexp.MustCompile(`\b(NRIQ-[a-zA-Z0-9-_]{25})`)
accountIDPat = regexp.MustCompile(detectors.PrefixRegex([]string{"relic", "account", "id"}) + `\b(\d{4,10})\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{"nriq-"} }

func (s Scanner) Type() detector_typepb.DetectorType {
return detector_typepb.DetectorType_NewRelicInsightsQueryKey
}

func (s Scanner) Description() string {
return "A New Relic Insights Query Key is a read-only API key used to execute NRQL queries against your account's event data via the legacy Insights Query API. It allows secure retrieval of analytics data without permitting any data ingestion or modification."
}

func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
dataStr := string(data)

keyMatches := keyPat.FindAllStringSubmatch(dataStr, -1)
accountIDMatches := accountIDPat.FindAllStringSubmatch(dataStr, -1)
uniqueAccountIDMatches := make(map[string]struct{})
for _, match := range accountIDMatches {
uniqueAccountIDMatches[match[1]] = struct{}{}
}

for _, keyMatch := range keyMatches {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Key matches not deduplicated unlike other multi-part detectors

Medium Severity

Account ID matches are deduplicated into uniqueAccountIDMatches, but keyMatches is iterated directly from FindAllStringSubmatch without deduplication. Other multi-part detectors in the codebase (e.g., adobeio, airbrakeprojectkey, airship) consistently deduplicate both parts into maps before the nested loops. This inconsistency means duplicate keys in scanned data produce duplicate results and redundant verification API calls.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intended. The idea is to report multiple results if the key appears in multiple places, so that the user can remove it from all of those places.

Account ID matches are deduplicated because they are not the primary credential.

for accountID := range uniqueAccountIDMatches {
keyResMatch := strings.TrimSpace(keyMatch[1])
accountIDResMatch := strings.TrimSpace(accountID)

s1 := detectors.Result{
DetectorType: s.Type(),
Raw: []byte(keyResMatch),
RawV2: []byte(keyResMatch + accountIDResMatch),
Redacted: keyResMatch[:8] + "...",
}

if verify && accountIDResMatch != "" {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in what scenarios, accountIDResMatch can be empty at this point?

isVerified, extraData, verificationErr := s.verify(ctx, keyResMatch, accountIDResMatch)
s1.Verified = isVerified
s1.ExtraData = extraData
s1.SetVerificationError(verificationErr)
}

results = append(results, s1)
}

}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No results emitted when account ID is absent

High Severity

When no account ID is found in the data chunk, uniqueAccountIDMatches is empty, so the inner for accountID := range uniqueAccountIDMatches loop never executes. This means the detector silently produces zero results even when valid NRIQ- keys are present. The PR description states the key is the actual credential and account ID is only needed for verification. Other multi-part detectors in this codebase (e.g., Atlassian v2) handle this by inserting an empty string entry into the map when no secondary matches are found, ensuring unverified results are still reported.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Account ID is required for verification. The atlassian detector you mentioned captures the organization_id only to pass it into AnalysisInfo. It's not needed for verification

Copy link
Copy Markdown
Contributor

@shahzadhaider1 shahzadhaider1 Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, the key is useless without an account ID? It can't be used to access any sort of data on its own?


return results, nil
}

// verify checks if the provided key is valid by making a request to the New Relic Insights Query API.
// It checks both the US and EU endpoints before returning an error.
// Account ID is required to verify as the API endpoint is account-specific.
func (s Scanner) verify(ctx context.Context, key string, accountID string) (bool, map[string]string, error) {
regionUrls := map[string]string{
"us": fmt.Sprintf("https://insights-api.newrelic.com/v1/accounts/%s/query?nrql=SELECT%%201", accountID),
"eu": fmt.Sprintf("https://insights-api.eu.newrelic.com/v1/accounts/%s/query?nrql=SELECT%%201", accountID),
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(optional) suggestion: use net/url insted of fmt.Sprintf()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that would unnecessarily complicate the code. We prefer net/url when we are concatenating user inputted URLs. Here we are simply inserting the account ID (which also comes via a strict regex so it does not have any chances of being malformed) into a URL.

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, regionUrl string) (bool, error) {
req, err := http.NewRequestWithContext(
ctx, http.MethodGet, regionUrl, http.NoBody)
if err != nil {
return false, fmt.Errorf("error constructing request: %w", err)
}
req.Header.Set("X-Query-Key", key)

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.StatusOK:
return true, nil
case http.StatusUnauthorized:
return false, nil
default:
return false, fmt.Errorf("unexpected status code: %d", res.StatusCode)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
//go:build detectors
// +build detectors

package newrelicinsightsquerykey

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 TestNewRelicInsightsQueryKey_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_INSIGHTS_QUERY_KEY")
accountID := testSecrets.MustGetField("NEW_RELIC_INSIGHTS_ACCOUNT_ID")
keyEU := testSecrets.MustGetField("NEW_RELIC_INSIGHTS_QUERY_KEY_EU")
accountIDEU := testSecrets.MustGetField("NEW_RELIC_INSIGHTS_ACCOUNT_ID_EU")
keyInactive := "NRIQ-Xc_V8HruIZ271_l9FQm-_nJ7_"

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 insights query key %s and account ID %s within", key, accountID)),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detector_typepb.DetectorType_NewRelicInsightsQueryKey,
Verified: true,
ExtraData: map[string]string{
"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 insights query key %s and account ID %s within", keyEU, accountIDEU)),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detector_typepb.DetectorType_NewRelicInsightsQueryKey,
Verified: true,
ExtraData: map[string]string{
"region": "eu",
},
},
},
wantErr: false,
},
{
name: "found, unverified",
s: Scanner{},
args: args{
ctx: context.Background(),
data: []byte(fmt.Sprintf("You can find a new relic insights query key %s and account ID %s within", keyInactive, accountID)), // the secret would satisfy the regex but not pass validation
verify: true,
},
want: []detectors.Result{
{
DetectorType: detector_typepb.DetectorType_NewRelicInsightsQueryKey,
Verified: false,
},
},
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("NewRelicInsightsQueryKey.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].RawV2) == 0 {
t.Fatalf("no rawV2 secret present: \n %+v", got[i])
}
got[i].RawV2 = 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("NewRelicInsightsQueryKey.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)
}
}
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package newrelicinsightsquerykey

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 = "NRIQ-Xc_V8HruIZ271_l9FQm-_nJ7_"
invalidPattern = "NRIQ-Xc_V8HruIZ271_l9FQm-_nJ7"
accountID = "7746934"
)

func TestNewRelicInsightsQueryKey_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 insights query key = '%s' account ID = '%s'", validPattern, accountID),
want: []string{validPattern + accountID},
},
{
name: "invalid pattern",
input: fmt.Sprintf("new relic insights query key = '%s' account ID = '%s'", invalidPattern, accountID),
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)
}
})
}
}
2 changes: 2 additions & 0 deletions pkg/engine/defaults/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,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/newrelicinsightsquerykey"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/newrelicpersonalapikey"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/newsapi"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/newscatcher"
Expand Down Expand Up @@ -1375,6 +1376,7 @@ func buildDetectorList() []detectors.Detector {
&netlifyv2.Scanner{},
&netsuite.Scanner{},
&neutrinoapi.Scanner{},
&newrelicinsightsquerykey.Scanner{},
&newrelicpersonalapikey.Scanner{},
&newsapi.Scanner{},
&newscatcher.Scanner{},
Expand Down
Loading
Loading