Skip to content

Commit 2f1baea

Browse files
sahil9001amanfcpshahzadhaider1
authored
[detector] feat: added rootly detector (#3414)
* [detector] feat: added rootly detector * [wip]feat: detector rootly * feat: added correct rootly detector * feat: added correct unit tests * feat: added correct unit tests * feat: added correct unit tests * feat: added correct unit test * feat: added `VerificationError` abstraction Signed-off-by: Sahil Silare <[email protected]> * feat: addressed review comments Signed-off-by: Sahil Silare <[email protected]> * Changed verification URL modified status code handling and added comments fixed GCP cred access in integration tests * Refactor HTTP request in Rootly detector to use http.MethodGet and http.NoBody for improved clarity and consistency. * Incorporated feedback * fixed missing bracket --------- Signed-off-by: Sahil Silare <[email protected]> Co-authored-by: Aman Ullah <[email protected]> Co-authored-by: Shahzad Haider <[email protected]>
1 parent d612d32 commit 2f1baea

File tree

6 files changed

+305
-6
lines changed

6 files changed

+305
-6
lines changed

pkg/detectors/rootly/rootly.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package rootly
2+
3+
import (
4+
"context"
5+
"net/http"
6+
7+
"fmt"
8+
9+
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
10+
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
11+
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb"
12+
regexp "github.com/wasilibs/go-re2"
13+
)
14+
15+
type Scanner struct{}
16+
17+
// Ensure the Scanner satisfies the interface at compile time.
18+
var _ detectors.Detector = (*Scanner)(nil)
19+
20+
var (
21+
client = common.SaneHttpClient()
22+
23+
// Make sure that your group is surrounded in boundary characters such as below to reduce false positives.
24+
keyPat = regexp.MustCompile(`\b(rootly_[a-f0-9]{64})\b`)
25+
)
26+
27+
// Keywords are used for efficiently pre-filtering chunks.
28+
// Use identifiers in the secret preferably, or the provider name.
29+
func (s Scanner) Keywords() []string {
30+
return []string{"rootly_"}
31+
}
32+
33+
// FromData will find and optionally verify Rootly secrets in a given set of bytes.
34+
func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
35+
dataStr := string(data)
36+
uniqueMatches := make(map[string]struct{})
37+
for _, match := range keyPat.FindAllStringSubmatch(dataStr, -1) {
38+
uniqueMatches[match[1]] = struct{}{}
39+
}
40+
41+
for match := range uniqueMatches {
42+
s1 := detectors.Result{
43+
DetectorType: detectorspb.DetectorType_Rootly,
44+
Raw: []byte(match),
45+
}
46+
47+
if verify {
48+
isVerified, verificationErr := verifyMatch(ctx, client, match)
49+
s1.Verified = isVerified
50+
s1.SetVerificationError(verificationErr, match)
51+
}
52+
53+
results = append(results, s1)
54+
}
55+
56+
return results, nil
57+
}
58+
59+
func verifyMatch(ctx context.Context, client *http.Client, token string) (bool, error) {
60+
61+
// this endpoint returns 200 if results exist and 404 if incidents do not exist or that token is not authorized
62+
// considering both 200 and 404 as positive results i.e. the token is valid
63+
// /user/me endpoint does not verify Team and Global API Keys returning 422 error. (There are 3 types of API keys in Rootly, Global, Team and Personal)
64+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.rootly.com/v1/incidents", http.NoBody)
65+
if err != nil {
66+
return false, err
67+
}
68+
req.Header.Add("Authorization", "Bearer "+token)
69+
res, err := client.Do(req)
70+
71+
if err != nil {
72+
return false, err
73+
}
74+
defer res.Body.Close()
75+
76+
switch res.StatusCode {
77+
case http.StatusOK, http.StatusNotFound:
78+
return true, nil
79+
case http.StatusUnauthorized:
80+
return false, nil
81+
default:
82+
return false, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode)
83+
}
84+
}
85+
86+
func (s Scanner) Type() detectorspb.DetectorType {
87+
return detectorspb.DetectorType_Rootly
88+
}
89+
90+
func (s Scanner) Description() string {
91+
return "Rootly is an incident management platform. Rootly API keys can be used to access and manage incident data and other services."
92+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
//go:build detectors
2+
// +build detectors
3+
4+
package rootly
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"testing"
10+
"time"
11+
12+
"github.com/kylelemons/godebug/pretty"
13+
14+
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
15+
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
16+
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb"
17+
)
18+
19+
func TestRootly_FromChunk(t *testing.T) {
20+
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
21+
defer cancel()
22+
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors6")
23+
if err != nil {
24+
t.Fatalf("could not get test secrets from GCP: %s", err)
25+
}
26+
secret := testSecrets.MustGetField("ROOTLY")
27+
inactiveSecret := testSecrets.MustGetField("ROOTLY_INACTIVE")
28+
29+
type args struct {
30+
ctx context.Context
31+
data []byte
32+
verify bool
33+
}
34+
tests := []struct {
35+
name string
36+
s Scanner
37+
args args
38+
want []detectors.Result
39+
wantErr bool
40+
}{
41+
{
42+
name: "found, verified",
43+
s: Scanner{},
44+
args: args{
45+
ctx: context.Background(),
46+
data: []byte(fmt.Sprintf("You can find a rootly secret %s within", secret)),
47+
verify: true,
48+
},
49+
want: []detectors.Result{
50+
{
51+
DetectorType: detectorspb.DetectorType_Rootly,
52+
Verified: true,
53+
},
54+
},
55+
wantErr: false,
56+
},
57+
{
58+
name: "found, unverified",
59+
s: Scanner{},
60+
args: args{
61+
ctx: context.Background(),
62+
data: []byte(fmt.Sprintf("You can find a rootly secret %s within but not valid", inactiveSecret)), // the secret would satisfy the regex but not pass validation
63+
verify: true,
64+
},
65+
want: []detectors.Result{
66+
{
67+
DetectorType: detectorspb.DetectorType_Rootly,
68+
Verified: false,
69+
},
70+
},
71+
wantErr: false,
72+
},
73+
{
74+
name: "not found",
75+
s: Scanner{},
76+
args: args{
77+
ctx: context.Background(),
78+
data: []byte("You cannot find the secret within"),
79+
verify: true,
80+
},
81+
want: nil,
82+
wantErr: false,
83+
},
84+
}
85+
for _, tt := range tests {
86+
t.Run(tt.name, func(t *testing.T) {
87+
s := Scanner{}
88+
got, err := s.FromData(tt.args.ctx, tt.args.verify, tt.args.data)
89+
if (err != nil) != tt.wantErr {
90+
t.Errorf("Rootly.FromData() error = %v, wantErr %v", err, tt.wantErr)
91+
return
92+
}
93+
for i := range got {
94+
if len(got[i].Raw) == 0 {
95+
t.Fatalf("no raw secret present: \n %+v", got[i])
96+
}
97+
got[i].Raw = nil
98+
}
99+
if diff := pretty.Compare(got, tt.want); diff != "" {
100+
t.Errorf("Rootly.FromData() %s diff: (-got +want)\n%s", tt.name, diff)
101+
}
102+
})
103+
}
104+
}
105+
106+
func BenchmarkFromData(benchmark *testing.B) {
107+
ctx := context.Background()
108+
s := Scanner{}
109+
for name, data := range detectors.MustGetBenchmarkData() {
110+
benchmark.Run(name, func(b *testing.B) {
111+
b.ResetTimer()
112+
for n := 0; n < b.N; n++ {
113+
_, err := s.FromData(ctx, false, data)
114+
if err != nil {
115+
b.Fatal(err)
116+
}
117+
}
118+
})
119+
}
120+
}

pkg/detectors/rootly/rootly_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package rootly
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/google/go-cmp/cmp"
8+
9+
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
10+
"github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick"
11+
)
12+
13+
func TestRootly_Pattern(t *testing.T) {
14+
d := Scanner{}
15+
ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d})
16+
17+
tests := []struct {
18+
name string
19+
input string
20+
want []string
21+
}{
22+
{
23+
name: "valid pattern",
24+
input: "rootly_7f1e8738d7d6b540bc52e1bc24c6e2c109dc44642f9e5d583be7e5d04f8bd282",
25+
want: []string{"rootly_7f1e8738d7d6b540bc52e1bc24c6e2c109dc44642f9e5d583be7e5d04f8bd282"},
26+
},
27+
{
28+
name: "valid pattern - key out of prefix range",
29+
input: "rootly keyword is not close to the real key in the data ='rootly_7f1e8738d7d6b540bc52e1bc24c6e2c109dc44642f9e5d583be7e5d04f8bd282'",
30+
want: []string{"rootly_7f1e8738d7d6b540bc52e1bc24c6e2c109dc44642f9e5d583be7e5d04f8bd282"},
31+
},
32+
{
33+
name: "invalid pattern",
34+
input: "rootly_A$3b9f8c1e2d4f5b6c7d8e9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d",
35+
want: nil,
36+
},
37+
}
38+
39+
for _, test := range tests {
40+
t.Run(test.name, func(t *testing.T) {
41+
matchedDetectors := ahoCorasickCore.FindDetectorMatches([]byte(test.input))
42+
if len(matchedDetectors) == 0 {
43+
t.Errorf("keywords '%v' not matched by: %s", d.Keywords(), test.input)
44+
return
45+
}
46+
47+
results, err := d.FromData(context.Background(), false, []byte(test.input))
48+
if err != nil {
49+
t.Errorf("error = %v", err)
50+
return
51+
}
52+
53+
if len(results) != len(test.want) {
54+
if len(results) == 0 {
55+
t.Errorf("did not receive result")
56+
} else {
57+
t.Errorf("expected %d results, only received %d", len(test.want), len(results))
58+
}
59+
return
60+
}
61+
62+
actual := make(map[string]struct{}, len(results))
63+
for _, r := range results {
64+
if len(r.RawV2) > 0 {
65+
actual[string(r.RawV2)] = struct{}{}
66+
} else {
67+
actual[string(r.Raw)] = struct{}{}
68+
}
69+
}
70+
expected := make(map[string]struct{}, len(test.want))
71+
for _, v := range test.want {
72+
expected[v] = struct{}{}
73+
}
74+
75+
if diff := cmp.Diff(expected, actual); diff != "" {
76+
t.Errorf("%s diff: (-want +got)\n%s", test.name, diff)
77+
}
78+
})
79+
}
80+
}

pkg/engine/defaults/defaults.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,7 @@ import (
613613
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/roaring"
614614
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/robinhoodcrypto"
615615
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/rocketreach"
616+
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/rootly"
616617
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/route4me"
617618
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/rownd"
618619
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/rubygems"
@@ -1486,6 +1487,7 @@ func buildDetectorList() []detectors.Detector {
14861487
&robinhoodcrypto.Scanner{},
14871488
&rocketreach.Scanner{},
14881489
// &rockset.Scanner{},
1490+
&rootly.Scanner{},
14891491
&route4me.Scanner{},
14901492
&rownd.Scanner{},
14911493
&rubygems.Scanner{},

pkg/pb/detectorspb/detectors.pb.go

Lines changed: 10 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

proto/detectors.proto

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1044,6 +1044,7 @@ enum DetectorType {
10441044
AnypointOAuth2 = 1032;
10451045
WebexBot = 1033;
10461046
TableauPersonalAccessToken = 1034;
1047+
Rootly = 1035;
10471048
}
10481049

10491050
message Result {

0 commit comments

Comments
 (0)