-
Notifications
You must be signed in to change notification settings - Fork 2.3k
[INS-345] Add New Relic Insights Query Key detector #4781
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
713d4e0
d542b03
7345494
ba20a09
e5e8561
95d9e64
bb6bb24
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| } | ||
|
|
||
| // 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 { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Key matches not deduplicated unlike other multi-part detectorsMedium Severity Account ID matches are deduplicated into
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 != "" { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. in what scenarios, |
||
| isVerified, extraData, verificationErr := s.verify(ctx, keyResMatch, accountIDResMatch) | ||
| s1.Verified = isVerified | ||
| s1.ExtraData = extraData | ||
| s1.SetVerificationError(verificationErr) | ||
| } | ||
|
|
||
| results = append(results, s1) | ||
| } | ||
|
|
||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No results emitted when account ID is absentHigh Severity When no account ID is found in the data chunk,
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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), | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (optional) suggestion: use
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think that would unnecessarily complicate the code. We prefer |
||
| 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) | ||
| } | ||
| }) | ||
| } | ||
| } |


Uh oh!
There was an error while loading. Please reload this page.