Skip to content
Open
108 changes: 108 additions & 0 deletions pkg/detectors/rootlywebhook/rootlywebhook.go
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(detectors.PrefixRegex([]string{"rootly", "webhook", "Authorization"}) + `\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", "rootly", "rootlywebhook", "rootly_webhook", "rootly_token", "rootly_webhook_token"}
}
Copy link
Contributor

Choose a reason for hiding this comment

The 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: webhooks.rootly.com will only appear if the webhook URL itself is present in the file or config (e.g. https://webhooks.rootly.com/incoming/...).

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:

ROOTLY_WEBHOOK_SECRET=abcdef123456

or

rootly:
  webhook_secret: "abcdef123456"

In those cases, the detector won’t trigger because webhooks.rootly.com isn’t present.

So, my recommendation would be to use a combination of provider and contextual keywords to improve coverage. For example, we can include rootly, rootly_webhook/rootlywebhook along with what we have already added. Let me know if this makes sense.

Copy link
Contributor

Choose a reason for hiding this comment

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

Since the string rootly is already a keyword, all other keywords are redundant since they all contain rootly.


// 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"]}`))

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
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
Copy link
Contributor

Choose a reason for hiding this comment

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

This makes sense in principle, but I’m concerned about treating 500 as a hard signal that a token is valid. 500 is a generic server error and could indicate a service outage or other problem. If Rootly returns 500 for many requests during an outage, we could end up marking a lot of secrets as verified incorrectly.

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?

Copy link
Contributor

Choose a reason for hiding this comment

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

+1 This is a valid concern.

Copy link
Author

Choose a reason for hiding this comment

The 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."
}
104 changes: 104 additions & 0 deletions pkg/detectors/rootlywebhook/rootlywebhook_integration_test.go
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)
}
})
}
}
80 changes: 80 additions & 0 deletions pkg/detectors/rootlywebhook/rootlywebhook_test.go
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)
}
})
}
}
2 changes: 2 additions & 0 deletions pkg/engine/defaults/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,7 @@ import (
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/robinhoodcrypto"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/rocketreach"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/rootly"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/rootlywebhook"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/route4me"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/rownd"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/rubygems"
Expand Down Expand Up @@ -1498,6 +1499,7 @@ func buildDetectorList() []detectors.Detector {
&rocketreach.Scanner{},
// &rockset.Scanner{},
&rootly.Scanner{},
&rootlywebhook.Scanner{},
&route4me.Scanner{},
&rownd.Scanner{},
&rubygems.Scanner{},
Expand Down
16 changes: 10 additions & 6 deletions pkg/pb/detectorspb/detectors.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions proto/detectors.proto
Original file line number Diff line number Diff line change
Expand Up @@ -1048,6 +1048,7 @@ enum DetectorType {
HashiCorpVaultAuth = 1036;
PhraseAccessToken = 1037;
Photoroom = 1038;
RootlyWebhook = 1039;
}

message Result {
Expand Down