-
Couldn't load subscription status.
- Fork 2.1k
[detector] feat: Rootly Webhook Detector #4492
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 6 commits
77dc6ea
43e1651
824f19b
2aa6571
367a490
1410ebe
0f7ec8c
3a2f7bf
5b735e3
4321561
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,108 @@ | ||
| package rootlywebhook | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "context" | ||
| "fmt" | ||
| "net/http" | ||
|
|
||
| "github.com/trufflesecurity/trufflehog/v3/pkg/common" | ||
| "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" | ||
| "github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb" | ||
| regexp "github.com/wasilibs/go-re2" | ||
| ) | ||
|
|
||
| type Scanner struct{} | ||
|
|
||
| // Ensure the Scanner satisfies the interface at compile time. | ||
| var _ detectors.Detector = (*Scanner)(nil) | ||
|
|
||
| var ( | ||
| client = common.SaneHttpClient() | ||
|
|
||
| // Rootly webhook tokens are 64 character hex strings | ||
| keyPat = regexp.MustCompile(`\b([a-f0-9]{64})\b`) | ||
| ) | ||
|
|
||
| // Keywords are used for efficiently pre-filtering chunks. | ||
| // Use identifiers in the secret preferably, or the provider name. | ||
| func (s Scanner) Keywords() []string { | ||
| return []string{"webhooks.rootly.com"} | ||
| } | ||
|
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 great and will greatly reduce noise but I think it is more restrictive than it should be. What I mean is: That’s great for catching leaked webhook endpoints, but it won’t catch files that only contain the secret, which is often stored separately (in .env, YAML, or JSON). Many developers store only the secret like this: or In those cases, the detector won’t trigger because So, my recommendation would be to use a combination of provider and contextual keywords to improve coverage. For example, we can include 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. Since the string |
||
|
|
||
| // FromData will find and optionally verify RootlyWebhook 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{}) | ||
|
|
||
| // Look for potential webhook tokens | ||
| for _, match := range keyPat.FindAllStringSubmatch(dataStr, -1) { | ||
| uniqueMatches[match[1]] = struct{}{} | ||
| } | ||
|
|
||
| for match := range uniqueMatches { | ||
| s1 := detectors.Result{ | ||
| DetectorType: detectorspb.DetectorType_RootlyWebhook, | ||
| Raw: []byte(match), | ||
| } | ||
|
|
||
| if verify { | ||
| isVerified, verificationErr := verifyMatch(ctx, client, match) | ||
| s1.Verified = isVerified | ||
| s1.SetVerificationError(verificationErr, match) | ||
| } | ||
|
|
||
| results = append(results, s1) | ||
| } | ||
|
|
||
| return results, nil | ||
| } | ||
|
|
||
| func verifyMatch(ctx context.Context, client *http.Client, token string) (bool, error) { | ||
| // We don't want to actually create alerts in Rootly. To verify tokens without spamming them, | ||
| // we send a payload that typically causes a 500 error (parsing issue) but still validates the auth. | ||
| // The expected scenario is 500 which means the key is working but the payload format causes an error. | ||
| // In case 200 comes, it means an actual alert has been created in Rootly (hopefully this never happens). | ||
| payload := bytes.NewReader([]byte(`{"rootly":["TruffleHog"]}`)) | ||
shahzadhaider1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://webhooks.rootly.com/webhooks/incoming/generic_webhooks", payload) | ||
| if err != nil { | ||
| return false, err | ||
| } | ||
|
|
||
| req.Header.Add("Authorization", "Bearer "+token) | ||
| req.Header.Add("Content-Type", "application/json") | ||
|
|
||
| res, err := client.Do(req) | ||
| if err != nil { | ||
| return false, err | ||
| } | ||
| defer res.Body.Close() | ||
|
|
||
| switch res.StatusCode { | ||
| case http.StatusOK: | ||
| // 200: Successfully processed the webhook - this means an actual alert was created in Rootly. | ||
| // Hopefully this never happens, but we at least know the token is verified. | ||
| return true, nil | ||
shahzadhaider1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| case http.StatusInternalServerError: | ||
| // 500: Auth is valid but there was a server error (e.g., parsing issue with our test payload) | ||
| // This is the expected response that indicates the token is valid without creating alerts. | ||
| return true, nil | ||
|
Comment on lines
+87
to
+90
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 makes sense in principle, but I’m concerned about treating Would it make sense to only treat 500 as valid when the response body contains a known, stable parsing-error signature? Basically, some logic to distinguish between “500 due to a genuine server issue/outage” and “500 due to our test payload being malformed.” Does that sound reasonable? 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. +1 This is a valid concern. 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. There is no exact message from the server side tho. I'll try to communicate with Rootly team to see if they can provide an endpoint for checking webhook validity. |
||
| case http.StatusNotFound: | ||
| // 404: Integration/webhook not found - token is invalid | ||
| return false, nil | ||
| case http.StatusUnauthorized: | ||
| // 401: Unauthorized - token is invalid | ||
| return false, nil | ||
| default: | ||
| return false, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode) | ||
| } | ||
| } | ||
|
|
||
| func (s Scanner) Type() detectorspb.DetectorType { | ||
| return detectorspb.DetectorType_RootlyWebhook | ||
| } | ||
|
|
||
| func (s Scanner) Description() string { | ||
| return "Rootly webhook tokens are used to create alerts using incoming webhook requests to its incident management platform." | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| //go:build detectors | ||
| // +build detectors | ||
|
|
||
| package rootlywebhook | ||
|
|
||
| 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/detectorspb" | ||
| ) | ||
|
|
||
| func TestRootlyWebhook_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) | ||
| } | ||
| secret := testSecrets.MustGetField("ROOTLYWEBHOOK_TOKEN") | ||
| inactiveSecret := testSecrets.MustGetField("ROOTLYWEBHOOK_INACTIVE") | ||
|
|
||
| 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("rootly webhook token: %s", secret)), | ||
| verify: true, | ||
| }, | ||
| want: []detectors.Result{ | ||
| { | ||
| DetectorType: detectorspb.DetectorType_RootlyWebhook, | ||
| Verified: true, | ||
| }, | ||
| }, | ||
| wantErr: false, | ||
| }, | ||
| { | ||
| name: "found, unverified", | ||
| s: Scanner{}, | ||
| args: args{ | ||
| ctx: context.Background(), | ||
| data: []byte(fmt.Sprintf("rootly webhook secret %s but not valid", inactiveSecret)), | ||
| verify: true, | ||
| }, | ||
| want: []detectors.Result{ | ||
| { | ||
| DetectorType: detectorspb.DetectorType_RootlyWebhook, | ||
| 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("RootlyWebhook.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 diff := pretty.Compare(got, tt.want); diff != "" { | ||
| t.Errorf("RootlyWebhook.FromData() %s diff: (-got +want)\n%s", tt.name, diff) | ||
| } | ||
| }) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| package rootlywebhook | ||
|
|
||
| import ( | ||
| "context" | ||
| "testing" | ||
|
|
||
| "github.com/google/go-cmp/cmp" | ||
|
|
||
| "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" | ||
| "github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick" | ||
| ) | ||
|
|
||
| func TestRootlyWebhook_Pattern(t *testing.T) { | ||
| d := Scanner{} | ||
| ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d}) | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| input string | ||
| want []string | ||
| }{ | ||
| { | ||
| name: "valid pattern", | ||
| input: "https://webhooks.rootly.com/webhooks/incoming/generic_webhooks?secret=84942ab61f62d34f98511711fd59cedc35bb3a217e6a2399d50a62c01fc4ee9a", | ||
| want: []string{"84942ab61f62d34f98511711fd59cedc35bb3a217e6a2399d50a62c01fc4ee9a"}, | ||
| }, | ||
| { | ||
| name: "valid pattern 2", | ||
| input: "curl -H \"Authorization: Bearer 84942ab61f62d34f98511711fd59cedc35bb3a217e6a2399d50a62c01fc4ee9a\" https://webhooks.rootly.com/webhooks/incoming/generic_webhooks", | ||
| want: []string{"84942ab61f62d34f98511711fd59cedc35bb3a217e6a2399d50a62c01fc4ee9a"}, | ||
| }, | ||
| { | ||
| name: "invalid pattern - short", | ||
| input: "84942ab61f62d34f98511711fd59cedc35bb3a217e6a2399d50a62c01fc4ee9", // 63 chars | ||
| want: nil, | ||
| }, | ||
| } | ||
|
|
||
| for _, test := range tests { | ||
| t.Run(test.name, func(t *testing.T) { | ||
| matchedDetectors := ahoCorasickCore.FindDetectorMatches([]byte(test.input)) | ||
| if len(matchedDetectors) == 0 && len(test.want) > 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) | ||
| } | ||
| }) | ||
| } | ||
| } |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Uh oh!
There was an error while loading. Please reload this page.