-
Couldn't load subscription status.
- Fork 2.1k
Grafana Detectors update #4411
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?
Grafana Detectors update #4411
Changes from 8 commits
7b63254
af3fce8
784fa1e
c64f812
98d2c50
6cfca06
563135c
64c40b0
c220ddf
caa150d
0ab4739
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,63 @@ | ||
| package grafanaapikey | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
|
|
||
| regexp "github.com/wasilibs/go-re2" | ||
|
|
||
| "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" | ||
| "github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb" | ||
| ) | ||
|
|
||
| type Scanner struct { | ||
| } | ||
|
|
||
| // Ensure the Scanner satisfies the interface at compile time. | ||
| var _ detectors.Detector = (*Scanner)(nil) | ||
|
|
||
| var ( | ||
| // Make sure that your group is surrounded in boundary characters such as below to reduce false positives. | ||
| keyPat = regexp.MustCompile(`\b(eyJrIjoi[A-Za-z0-9]{70,400}={0,2})\b`) | ||
| ) | ||
|
|
||
| // Keywords are used for efficiently pre-filtering chunks. | ||
| // Use identifiers in the secret preferably, or the provider name. | ||
| // Grafana uses "eyJrIjoi" as a prefix for api keys, see for example. | ||
| // https://github.com/grafana/pyroscope-dotnet/blob/0c17634653af09befa7bc07b2e1c420b5dc8578c/tracer/src/Datadog.Trace/Iast/Analyzers/HardcodedSecretsAnalyzer.cs#L173 | ||
| func (s Scanner) Keywords() []string { | ||
| return []string{"grafanaapikey", "eyJrIjoi"} | ||
| } | ||
|
|
||
| // FromData will find and optionally verify Grafanaapikey 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 match := range uniqueMatches { | ||
| res := detectors.Result{ | ||
| DetectorType: detectorspb.DetectorType_GrafanaAPIKey, | ||
| Raw: []byte(match), | ||
| } | ||
|
|
||
| if verify { | ||
| res.SetVerificationError(fmt.Errorf("no grafana instance detected to verify against"), match) | ||
|
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. We don't seem to be verifying the matched credentials 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. These kind of api keys are used for on-premise installations, so together with a custom endpoint that could be an IP address or a FQDN. Since it's hard to reliably identify it - there is no good way of verifying the matched credentials. 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. Verification logic has to exist in a detector. In docs, it is mentioned that Grafana can be hosted on a VPS or as a subdomain. Gitlab detector is the best example for this |
||
| } | ||
|
|
||
| results = append(results, res) | ||
| } | ||
|
|
||
| return | ||
| } | ||
|
|
||
| func (s Scanner) Type() detectorspb.DetectorType { | ||
| return detectorspb.DetectorType_GrafanaAPIKey | ||
| } | ||
|
|
||
| func (s Scanner) Description() string { | ||
| return "Grafana API keys are used to authenticate and interact with Grafana's API. These credentials can be used to access and modify Grafana resources and settings." | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,161 @@ | ||
| //go:build detectors | ||
| // +build detectors | ||
|
|
||
| package grafanaapikey | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/google/go-cmp/cmp" | ||
| "github.com/google/go-cmp/cmp/cmpopts" | ||
|
|
||
| "github.com/trufflesecurity/trufflehog/v3/pkg/common" | ||
| "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" | ||
| "github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb" | ||
| ) | ||
|
|
||
| func TestGrafanaapikey_FromChunk(t *testing.T) { | ||
| ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) | ||
| defer cancel() | ||
| testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors5") | ||
| if err != nil { | ||
| t.Fatalf("could not get test secrets from GCP: %s", err) | ||
| } | ||
| secret := testSecrets.MustGetField("GRAFANAAPIKEY") | ||
| inactiveSecret := testSecrets.MustGetField("GRAFANAAPIKEY_INACTIVE") | ||
|
|
||
| type args struct { | ||
| ctx context.Context | ||
| data []byte | ||
| verify bool | ||
| } | ||
| tests := []struct { | ||
| name string | ||
| s Scanner | ||
| args args | ||
| want []detectors.Result | ||
| wantErr bool | ||
| wantVerificationErr bool | ||
| }{ | ||
| { | ||
| name: "found, verified", | ||
| s: Scanner{}, | ||
| args: args{ | ||
| ctx: context.Background(), | ||
| data: []byte(fmt.Sprintf("You can find a grafanaapikey secret %s within", secret)), | ||
| verify: true, | ||
| }, | ||
| want: []detectors.Result{ | ||
| { | ||
| DetectorType: detectorspb.DetectorType_GrafanaAPIKey, | ||
| Verified: true, | ||
| }, | ||
| }, | ||
| wantErr: false, | ||
| wantVerificationErr: false, | ||
| }, | ||
| { | ||
| name: "found, unverified", | ||
| s: Scanner{}, | ||
| args: args{ | ||
| ctx: context.Background(), | ||
| data: []byte(fmt.Sprintf("You can find a grafanaapikey secret %s within but not valid", inactiveSecret)), // the secret would satisfy the regex but not pass validation | ||
| verify: true, | ||
| }, | ||
| want: []detectors.Result{ | ||
| { | ||
| DetectorType: detectorspb.DetectorType_GrafanaAPIKey, | ||
| Verified: false, | ||
| }, | ||
| }, | ||
| wantErr: false, | ||
| wantVerificationErr: 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, | ||
| wantVerificationErr: false, | ||
| }, | ||
| { | ||
| name: "found, would be verified if not for timeout", | ||
| s: Scanner{client: common.SaneHttpClientTimeOut(1 * time.Microsecond)}, | ||
| args: args{ | ||
| ctx: context.Background(), | ||
| data: []byte(fmt.Sprintf("You can find a grafanaapikey secret %s within", secret)), | ||
| verify: true, | ||
| }, | ||
| want: []detectors.Result{ | ||
| { | ||
| DetectorType: detectorspb.DetectorType_GrafanaAPIKey, | ||
| Verified: false, | ||
| }, | ||
| }, | ||
| wantErr: false, | ||
| wantVerificationErr: true, | ||
| }, | ||
| { | ||
| name: "found, verified but unexpected api surface", | ||
| s: Scanner{client: common.ConstantResponseHttpClient(404, "")}, | ||
| args: args{ | ||
| ctx: context.Background(), | ||
| data: []byte(fmt.Sprintf("You can find a grafanaapikey secret %s within", secret)), | ||
| verify: true, | ||
| }, | ||
| want: []detectors.Result{ | ||
| { | ||
| DetectorType: detectorspb.DetectorType_GrafanaAPIKey, | ||
| Verified: false, | ||
| }, | ||
| }, | ||
| wantErr: false, | ||
| wantVerificationErr: true, | ||
| }, | ||
| } | ||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| got, err := tt.s.FromData(tt.args.ctx, tt.args.verify, tt.args.data) | ||
| if (err != nil) != tt.wantErr { | ||
| t.Errorf("Grafanaapikey.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]) | ||
| } | ||
| if (got[i].VerificationError() != nil) != tt.wantVerificationErr { | ||
| t.Fatalf("wantVerificationError = %v, verification error = %v", tt.wantVerificationErr, got[i].VerificationError()) | ||
| } | ||
| } | ||
| ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "verificationError") | ||
| if diff := cmp.Diff(got, tt.want, ignoreOpts); diff != "" { | ||
| t.Errorf("Grafanaapikey.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,102 @@ | ||
| package grafanaapikey | ||
|
|
||
| import ( | ||
| "context" | ||
| "testing" | ||
|
|
||
| "github.com/google/go-cmp/cmp" | ||
| "github.com/stretchr/testify/require" | ||
|
|
||
| "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" | ||
| "github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick" | ||
| ) | ||
|
|
||
| func TestGrafanaapikey_Pattern(t *testing.T) { | ||
| d := Scanner{} | ||
| ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d}) | ||
| tests := []struct { | ||
| name string | ||
| input string | ||
| want []string | ||
| }{ | ||
| { | ||
| name: "valid pattern", | ||
| input: ` | ||
| [INFO] Sending request to the grafanaapikey API | ||
| [DEBUG] Using Key=eyJrIjoideadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef | ||
| [INFO] Response received: 200 OK | ||
| `, | ||
| want: []string{"eyJrIjoideadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"}, | ||
| }, | ||
| { | ||
| name: "valid pattern - xml", | ||
| input: ` | ||
| <com.cloudbees.plugins.credentials.impl.StringCredentialsImpl> | ||
| <scope>GLOBAL</scope> | ||
| <id>{grafanaapikey}</id> | ||
| <secret>{grafanaapikey AQAAABAAA eyJrIjoideadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef}</secret> | ||
| <description>configuration for production</description> | ||
| <creationDate>2023-05-18T14:32:10Z</creationDate> | ||
| <owner>jenkins-admin</owner> | ||
| </com.cloudbees.plugins.credentials.impl.StringCredentialsImpl> | ||
| `, | ||
| want: []string{"eyJrIjoideadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"}, | ||
| }, | ||
| { | ||
| name: "finds all matches", | ||
| input: ` | ||
| [INFO] Sending request to the grafanaapikey API | ||
| [DEBUG] Using Key=eyJrIjoideadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef | ||
| [ERROR] Response received 401 UnAuthorized | ||
| [DEBUG] Using grafanaapikey Key=eyJrIjoiBADBADBADBADBADBADBADBADBADBADBADBADBADBADBADBADBADBADBADBADBADBADBADBAD | ||
| [INFO] Response received: 200 OK | ||
| `, | ||
| want: []string{"eyJrIjoideadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", "eyJrIjoiBADBADBADBADBADBADBADBADBADBADBADBADBADBADBADBADBADBADBADBADBADBADBADBAD"}, | ||
| }, | ||
| { | ||
| name: "invalid pattern", | ||
| input: ` | ||
| [INFO] Sending request to the grafanaapikey API | ||
| [DEBUG] Using Key=jkekfjskekfjedieksmfjfieke | ||
| [ERROR] Response received: 401 UnAuthorized | ||
| `, | ||
| 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("test %q failed: expected keywords %v to be found in the input", test.name, d.Keywords()) | ||
| return | ||
| } | ||
|
|
||
| results, err := d.FromData(context.Background(), false, []byte(test.input)) | ||
| require.NoError(t, err) | ||
|
|
||
| if len(results) != len(test.want) { | ||
| t.Errorf("mismatch in result count: expected %d, got %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) | ||
| } | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can use
"eyJrIjoi"exclusively since it's always part of the key. A keyword match for"grafanaapikey"won't necessarily containeyJrIjoiin its input.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Doh. PEBKAC.... I'll update that :P