Skip to content

Commit b1daae3

Browse files
added gitlab oauth detector
1 parent 7f4e37d commit b1daae3

6 files changed

Lines changed: 492 additions & 7 deletions

File tree

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package gitlaboauth2
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"strings"
10+
11+
regexp "github.com/wasilibs/go-re2"
12+
13+
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
14+
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
15+
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb"
16+
)
17+
18+
type Scanner struct {
19+
client *http.Client
20+
detectors.EndpointSetter
21+
}
22+
23+
// Ensure the Scanner satisfies the interfaces at compile time.
24+
var _ detectors.Detector = (*Scanner)(nil)
25+
var _ detectors.EndpointCustomizer = (*Scanner)(nil)
26+
27+
func (Scanner) CloudEndpoint() string { return "https://gitlab.com" }
28+
29+
var (
30+
defaultClient = common.SaneHttpClient()
31+
clientIdPat = regexp.MustCompile(
32+
detectors.PrefixRegex([]string{"application_id", "client_id", "app_id", "id"}) + `\b([0-9a-f]{64})\b`)
33+
clientSecretPat = regexp.MustCompile(`\b(gloas-[0-9a-f]{64})\b`)
34+
)
35+
36+
func (s Scanner) getClient() *http.Client {
37+
if s.client != nil {
38+
return s.client
39+
}
40+
return defaultClient
41+
}
42+
43+
// Keywords are used for efficiently pre-filtering chunks.
44+
func (s Scanner) Keywords() []string {
45+
return []string{"gloas-"}
46+
}
47+
48+
// FromData will find and optionally verify GitLab OAuth secrets in a given set of bytes.
49+
func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
50+
dataStr := string(data)
51+
52+
uniqueIdMatches := make(map[string]struct{})
53+
for _, match := range clientIdPat.FindAllStringSubmatch(dataStr, -1) {
54+
uniqueIdMatches[match[1]] = struct{}{}
55+
}
56+
57+
uniqueSecretMatches := make(map[string]struct{})
58+
for _, match := range clientSecretPat.FindAllStringSubmatch(dataStr, -1) {
59+
uniqueSecretMatches[match[1]] = struct{}{}
60+
}
61+
62+
for clientId := range uniqueIdMatches {
63+
for clientSecret := range uniqueSecretMatches {
64+
for _, endpoint := range s.Endpoints() {
65+
s1 := detectors.Result{
66+
DetectorType: detectorspb.DetectorType_GitLabOauth2,
67+
Raw: []byte(clientSecret),
68+
RawV2: []byte(clientId + clientSecret + endpoint),
69+
}
70+
71+
if verify {
72+
isVerified, verificationErr := verifyMatch(
73+
ctx, s.getClient(), endpoint, clientId, clientSecret,
74+
)
75+
s1.Verified = isVerified
76+
s1.SetVerificationError(verificationErr, clientSecret)
77+
78+
if s1.Verified {
79+
// if secret is verified with one endpoint, break the loop to continue to next secret
80+
results = append(results, s1)
81+
break
82+
}
83+
}
84+
85+
results = append(results, s1)
86+
}
87+
}
88+
}
89+
90+
return
91+
}
92+
93+
func verifyMatch(ctx context.Context, client *http.Client, endpoint string, clientId string, clientSecret string) (bool, error) {
94+
url := endpoint + "/oauth/token"
95+
payload := strings.NewReader("grant_type=client_credentials&client_id=" + clientId +
96+
"&client_secret=" + clientSecret)
97+
98+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, payload)
99+
if err != nil {
100+
return false, err
101+
}
102+
103+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
104+
105+
res, err := client.Do(req)
106+
if err != nil {
107+
return false, err
108+
}
109+
defer func() {
110+
_, _ = io.Copy(io.Discard, res.Body)
111+
_ = res.Body.Close()
112+
}()
113+
114+
// GitLab validates credentials before checking grant type.
115+
//
116+
// - If credentials are valid but grant type is unsupported, returns 400 with "invalid_scope"
117+
// - If credentials are invalid, returns 401 with "invalid_client"
118+
//
119+
// Note: Apps with `api` scope may return 422, which we treat as unverified (not verified or invalid)
120+
// to avoid false positives.
121+
switch res.StatusCode {
122+
case http.StatusBadRequest:
123+
bodyBytes, err := io.ReadAll(res.Body)
124+
if err != nil {
125+
return false, err
126+
}
127+
128+
var errResp struct {
129+
Error string `json:"error"`
130+
}
131+
if err := json.Unmarshal(bodyBytes, &errResp); err != nil {
132+
return false, err
133+
}
134+
135+
if errResp.Error == "invalid_scope" {
136+
return true, nil
137+
}
138+
139+
return false, fmt.Errorf("unexpected error in response: %s", errResp.Error)
140+
141+
case http.StatusUnauthorized:
142+
return false, nil
143+
144+
default:
145+
return false, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode)
146+
}
147+
}
148+
149+
func (s Scanner) Type() detectorspb.DetectorType {
150+
return detectorspb.DetectorType_GitLabOauth2
151+
}
152+
153+
func (s Scanner) Description() string {
154+
return "GitLab is a web-based DevOps lifecycle tool that provides a Git repository manager providing wiki, issue-tracking, and CI/CD pipeline features. GitLab OAuth application credentials can be used to access GitLab APIs on behalf of users."
155+
}
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
//go:build detectors
2+
// +build detectors
3+
4+
package gitlaboauth2
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"testing"
10+
"time"
11+
12+
"github.com/google/go-cmp/cmp"
13+
"github.com/google/go-cmp/cmp/cmpopts"
14+
15+
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
16+
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
17+
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb"
18+
)
19+
20+
func TestGitlabOauth_FromChunk(t *testing.T) {
21+
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
22+
defer cancel()
23+
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors6")
24+
if err != nil {
25+
t.Fatalf("could not get test secrets from GCP: %s", err)
26+
}
27+
28+
clientId := testSecrets.MustGetField("GITLABOAUTH_CLIENT_ID")
29+
clientSecret := testSecrets.MustGetField("GITLABOAUTH_CLIENT_SECRET")
30+
inactiveClientId := testSecrets.MustGetField("GITLABOAUTH_CLIENT_ID_INACTIVE")
31+
inactiveClientSecret := testSecrets.MustGetField("GITLABOAUTH_CLIENT_SECRET_INACTIVE")
32+
33+
type args struct {
34+
ctx context.Context
35+
data []byte
36+
verify bool
37+
}
38+
tests := []struct {
39+
name string
40+
s Scanner
41+
args args
42+
want []detectors.Result
43+
wantErr bool
44+
wantVerificationErr bool
45+
}{
46+
{
47+
name: "found, verified",
48+
s: Scanner{},
49+
args: args{
50+
ctx: context.Background(),
51+
data: []byte(fmt.Sprintf(`
52+
gitlab:
53+
client_id: %s
54+
client_secret: %s
55+
`, clientId, clientSecret)),
56+
verify: true,
57+
},
58+
want: []detectors.Result{
59+
{
60+
DetectorType: detectorspb.DetectorType_GitLabOauth2,
61+
Verified: true,
62+
},
63+
},
64+
wantErr: false,
65+
wantVerificationErr: false,
66+
},
67+
{
68+
name: "found, unverified",
69+
s: Scanner{},
70+
args: args{
71+
ctx: context.Background(),
72+
data: []byte(fmt.Sprintf(`
73+
gitlab:
74+
client_id: %s
75+
client_secret: %s
76+
`, inactiveClientId, inactiveClientSecret)),
77+
verify: true,
78+
},
79+
want: []detectors.Result{
80+
{
81+
DetectorType: detectorspb.DetectorType_GitLabOauth2,
82+
Verified: false,
83+
},
84+
},
85+
wantErr: false,
86+
wantVerificationErr: false,
87+
},
88+
{
89+
name: "not found",
90+
s: Scanner{},
91+
args: args{
92+
ctx: context.Background(),
93+
data: []byte("You cannot find the secret within"),
94+
verify: true,
95+
},
96+
want: nil,
97+
wantErr: false,
98+
wantVerificationErr: false,
99+
},
100+
{
101+
name: "found, would be verified if not for timeout",
102+
s: Scanner{client: common.SaneHttpClientTimeOut(1 * time.Microsecond)},
103+
args: args{
104+
ctx: context.Background(),
105+
data: []byte(fmt.Sprintf(`
106+
gitlab:
107+
client_id: %s
108+
client_secret: %s
109+
`, clientId, clientSecret)),
110+
verify: true,
111+
},
112+
want: []detectors.Result{
113+
{
114+
DetectorType: detectorspb.DetectorType_GitLabOauth2,
115+
Verified: false,
116+
},
117+
},
118+
wantErr: false,
119+
wantVerificationErr: true,
120+
},
121+
{
122+
name: "found, verified but unexpected api surface",
123+
s: Scanner{client: common.ConstantResponseHttpClient(404, "")},
124+
args: args{
125+
ctx: context.Background(),
126+
data: []byte(fmt.Sprintf(`
127+
gitlab:
128+
client_id: %s
129+
client_secret: %s
130+
`, clientId, clientSecret)),
131+
verify: true,
132+
},
133+
want: []detectors.Result{
134+
{
135+
DetectorType: detectorspb.DetectorType_GitLabOauth2,
136+
Verified: false,
137+
},
138+
},
139+
wantErr: false,
140+
wantVerificationErr: true,
141+
},
142+
}
143+
for _, tt := range tests {
144+
t.Run(tt.name, func(t *testing.T) {
145+
tt.s.SetCloudEndpoint(tt.s.CloudEndpoint())
146+
tt.s.UseCloudEndpoint(true)
147+
got, err := tt.s.FromData(tt.args.ctx, tt.args.verify, tt.args.data)
148+
if (err != nil) != tt.wantErr {
149+
t.Errorf("GitlabOauth.FromData() error = %v, wantErr %v", err, tt.wantErr)
150+
return
151+
}
152+
for i := range got {
153+
if len(got[i].Raw) == 0 {
154+
t.Fatalf("no raw secret present: \n %+v", got[i])
155+
}
156+
if (got[i].VerificationError() != nil) != tt.wantVerificationErr {
157+
t.Fatalf("wantVerificationError = %v, verification error = %v", tt.wantVerificationErr, got[i].VerificationError())
158+
}
159+
}
160+
ignoreOpts := cmpopts.IgnoreFields(detectors.Result{},
161+
"Raw", "RawV2", "verificationError", "ExtraData", "primarySecret")
162+
if diff := cmp.Diff(got, tt.want, ignoreOpts); diff != "" {
163+
t.Errorf("GitlabOauth.FromData() %s diff: (-got +want)\n%s", tt.name, diff)
164+
}
165+
})
166+
}
167+
}
168+
169+
func BenchmarkFromData(benchmark *testing.B) {
170+
ctx := context.Background()
171+
s := Scanner{}
172+
s.SetCloudEndpoint(s.CloudEndpoint())
173+
s.UseCloudEndpoint(true)
174+
for name, data := range detectors.MustGetBenchmarkData() {
175+
benchmark.Run(name, func(b *testing.B) {
176+
b.ResetTimer()
177+
for n := 0; n < b.N; n++ {
178+
_, err := s.FromData(ctx, false, data)
179+
if err != nil {
180+
b.Fatal(err)
181+
}
182+
}
183+
})
184+
}
185+
}

0 commit comments

Comments
 (0)