Skip to content

Commit acb9826

Browse files
Improved JIRA detector (#4155)
* improved jira v1 detector * updated jira v2 detector
1 parent bd348fa commit acb9826

File tree

5 files changed

+90
-97
lines changed

5 files changed

+90
-97
lines changed

pkg/detectors/jiratoken/v1/jiratoken.go

Lines changed: 48 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package jiratoken
22

33
import (
4+
"bytes"
45
"context"
5-
b64 "encoding/base64"
6+
"encoding/json"
67
"fmt"
8+
"io"
79
"net/http"
810
"strings"
911

@@ -29,20 +31,22 @@ var (
2931
defaultClient = detectors.DetectorHttpClientWithLocalAddresses
3032

3133
// Make sure that your group is surrounded in boundary characters such as below to reduce false positives.
32-
tokenPat = regexp.MustCompile(detectors.PrefixRegex([]string{"jira"}) + `\b([a-zA-Z-0-9]{24})\b`)
33-
domainPat = regexp.MustCompile(detectors.PrefixRegex([]string{"jira"}) + `\b((?:[a-zA-Z0-9-]{1,24}\.)+[a-zA-Z0-9-]{2,24}\.[a-zA-Z0-9-]{2,16})\b`)
34-
emailPat = regexp.MustCompile(detectors.PrefixRegex([]string{"jira"}) + common.EmailPattern)
34+
tokenPat = regexp.MustCompile(detectors.PrefixRegex([]string{"atlassian", "confluence", "jira"}) + `\b([a-zA-Z-0-9]{24})\b`)
35+
domainPat = regexp.MustCompile(detectors.PrefixRegex([]string{"atlassian", "confluence", "jira"}) + `\b((?:[a-zA-Z0-9-]{1,24}\.)+[a-zA-Z0-9-]{2,24}\.[a-zA-Z0-9-]{2,16})\b`)
36+
emailPat = regexp.MustCompile(detectors.PrefixRegex([]string{"atlassian", "confluence", "jira"}) + common.EmailPattern)
3537
)
3638

37-
const (
38-
failedAuth = "AUTHENTICATED_FAILED"
39-
loginReasonHeaderKey = "X-Seraph-LoginReason"
40-
)
39+
func (s Scanner) getClient() *http.Client {
40+
if s.client != nil {
41+
return s.client
42+
}
43+
return defaultClient
44+
}
4145

4246
// Keywords are used for efficiently pre-filtering chunks.
4347
// Use identifiers in the secret preferably, or the provider name.
4448
func (s Scanner) Keywords() []string {
45-
return []string{"jira"}
49+
return []string{"atlassian", "confluence", "jira"}
4650
}
4751

4852
// FromData will find and optionally verify JiraToken secrets in a given set of bytes.
@@ -63,6 +67,12 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
6367
uniqueEmails[strings.ToLower(email[1])] = struct{}{}
6468
}
6569

70+
if len(uniqueDomains) == 0 {
71+
// reason: https://community.atlassian.com/forums/Jira-Product-Discovery-questions/Authorization-issues-with-GRAPHQL/qaq-p/2640943
72+
// In case we don't find any domain matches we can use this as the graphql API works with this domain if our authentication is valid
73+
uniqueDomains["api.atlassian.com"] = struct{}{}
74+
}
75+
6676
for email := range uniqueEmails {
6777
for token := range uniqueTokens {
6878
for domain := range uniqueDomains {
@@ -78,7 +88,7 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
7888

7989
if verify {
8090
client := s.getClient()
81-
isVerified, verificationErr := verifyJiratoken(ctx, client, email, domain, token)
91+
isVerified, verificationErr := VerifyJiraToken(ctx, client, email, domain, token)
8292
s1.Verified = isVerified
8393
s1.SetVerificationError(verificationErr, token)
8494
}
@@ -91,40 +101,47 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
91101
return results, nil
92102
}
93103

94-
func (s Scanner) getClient() *http.Client {
95-
if s.client != nil {
96-
return s.client
104+
func VerifyJiraToken(ctx context.Context, client *http.Client, email, domain, token string) (bool, error) {
105+
// wrap the query in a JSON body
106+
body := map[string]string{
107+
"query": `verify { me { user {name}}}`,
97108
}
98-
return defaultClient
99-
}
100109

101-
func verifyJiratoken(ctx context.Context, client *http.Client, email, domain, token string) (bool, error) {
102-
data := fmt.Sprintf("%s:%s", email, token)
103-
sEnc := b64.StdEncoding.EncodeToString([]byte(data))
104-
req, err := http.NewRequestWithContext(ctx, "GET", "https://"+domain+"/rest/api/3/dashboard", nil)
110+
// encode the body as JSON
111+
jsonBody, err := json.Marshal(body)
105112
if err != nil {
106113
return false, err
107114
}
108-
req.Header.Add("Accept", "application/json")
109-
req.Header.Add("Authorization", fmt.Sprintf("Basic %s", sEnc))
110-
res, err := client.Do(req)
115+
116+
// api docs: https://developer.atlassian.com/platform/atlassian-graphql-api/graphql/#authentication
117+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://"+domain+"/gateway/api/graphql", bytes.NewBuffer(jsonBody))
111118
if err != nil {
112119
return false, err
113120
}
114-
defer res.Body.Close()
115121

116-
// If the request is successful and the login reason is not failed authentication, then the token is valid.
117-
// This is because Jira returns a 200 status code even if the token is invalid.
118-
// Jira returns a default dashboard page.
119-
if !(res.StatusCode >= 200 && res.StatusCode < 300) {
120-
return false, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode)
122+
req.Header.Add("Accept", "application/json")
123+
req.Header.Add("Content-Type", "application/json")
124+
req.SetBasicAuth(email, token)
125+
126+
resp, err := client.Do(req)
127+
if err != nil {
128+
return false, err
121129
}
122130

123-
if res.Header.Get(loginReasonHeaderKey) != failedAuth {
131+
defer func() {
132+
_, _ = io.Copy(io.Discard, resp.Body)
133+
_ = resp.Body.Close()
134+
}()
135+
136+
// the API returns 200 if the token is valid
137+
switch resp.StatusCode {
138+
case http.StatusOK:
124139
return true, nil
140+
case http.StatusUnauthorized:
141+
return false, nil
142+
default:
143+
return false, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
125144
}
126-
127-
return false, nil
128145
}
129146

130147
func (s Scanner) Type() detectorspb.DetectorType {

pkg/detectors/jiratoken/v1/jiratoken_integration_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ func TestJiraToken_FromChunk(t *testing.T) {
154154
t.Fatalf("wantVerificationError = %v, verification error = %v", tt.wantVerificationErr, got[i].VerificationError())
155155
}
156156
}
157-
ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "RawV2", "verificationError")
157+
ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "RawV2", "verificationError", "primarySecret")
158158
if diff := cmp.Diff(got, tt.want, ignoreOpts); diff != "" {
159159
t.Errorf("JiraToken.FromData() %s diff: (-got +want)\n%s", tt.name, diff)
160160
}

pkg/detectors/jiratoken/v2/jiratoken_v2.go

Lines changed: 36 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@ package jiratoken
22

33
import (
44
"context"
5-
b64 "encoding/base64"
65
"fmt"
76
"net/http"
87
"strings"
98

109
regexp "github.com/wasilibs/go-re2"
1110

11+
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
1212
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
13+
v1 "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/jiratoken/v1"
1314
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb"
1415
)
1516

@@ -29,45 +30,55 @@ var (
2930

3031
// https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/
3132
// Tokens created after Jan 18 2023 use a variable length
32-
tokenPat = regexp.MustCompile(detectors.PrefixRegex([]string{"jira"}) + `\b([A-Za-z0-9+/=_-]+=[A-Za-z0-9]{8})\b`)
33-
domainPat = regexp.MustCompile(detectors.PrefixRegex([]string{"jira"}) + `\b((?:[a-zA-Z0-9-]{1,24}\.)+[a-zA-Z0-9-]{2,24}\.[a-zA-Z0-9-]{2,16})\b`)
34-
emailPat = regexp.MustCompile(detectors.PrefixRegex([]string{"jira"}) + `\b([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,})\b`)
33+
tokenPat = regexp.MustCompile(detectors.PrefixRegex([]string{"atlassian", "confluence", "jira"}) + `\b(ATATT[A-Za-z0-9+/=_-]+=[A-Za-z0-9]{8})\b`)
34+
domainPat = regexp.MustCompile(detectors.PrefixRegex([]string{"atlassian", "confluence", "jira"}) + `\b((?:[a-zA-Z0-9-]{1,24}\.)+[a-zA-Z0-9-]{2,24}\.[a-zA-Z0-9-]{2,16})\b`)
35+
emailPat = regexp.MustCompile(detectors.PrefixRegex([]string{"atlassian", "confluence", "jira"}) + common.EmailPattern)
3536
)
3637

37-
const (
38-
failedAuth = "AUTHENTICATED_FAILED"
39-
loginReasonHeaderKey = "X-Seraph-LoginReason"
40-
)
38+
func (s Scanner) getClient() *http.Client {
39+
if s.client != nil {
40+
return s.client
41+
}
42+
return defaultClient
43+
}
4144

4245
// Keywords are used for efficiently pre-filtering chunks.
4346
// Use identifiers in the secret preferably, or the provider name.
4447
func (s Scanner) Keywords() []string {
45-
return []string{"jira"}
48+
return []string{"atlassian", "confluence", "jira"}
4649
}
4750

4851
// FromData will find and optionally verify JiraToken secrets in a given set of bytes.
4952
func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
5053
dataStr := string(data)
5154

52-
tokens := tokenPat.FindAllStringSubmatch(dataStr, -1)
53-
domains := domainPat.FindAllStringSubmatch(dataStr, -1)
54-
emails := emailPat.FindAllStringSubmatch(dataStr, -1)
55+
var uniqueTokens, uniqueDomains, uniqueEmails = make(map[string]struct{}), make(map[string]struct{}), make(map[string]struct{})
5556

56-
for _, email := range emails {
57-
if len(email) != 2 {
58-
continue
59-
}
60-
resEmail := strings.TrimSpace(email[1])
57+
for _, token := range tokenPat.FindAllStringSubmatch(dataStr, -1) {
58+
uniqueTokens[token[1]] = struct{}{}
59+
}
60+
61+
for _, domain := range domainPat.FindAllStringSubmatch(dataStr, -1) {
62+
uniqueDomains[domain[1]] = struct{}{}
63+
}
6164

62-
for _, token := range tokens {
63-
resToken := strings.TrimSpace(token[1])
64-
for _, domain := range domains {
65-
resDomain := strings.TrimSpace(domain[1])
65+
for _, email := range emailPat.FindAllStringSubmatch(dataStr, -1) {
66+
uniqueEmails[strings.ToLower(email[1])] = struct{}{}
67+
}
6668

69+
if len(uniqueDomains) == 0 {
70+
// reason: https://community.atlassian.com/forums/Jira-Product-Discovery-questions/Authorization-issues-with-GRAPHQL/qaq-p/2640943
71+
// In case we don't find any domain matches we can use this as the graphql API works with this domain if our authentication is valid
72+
uniqueDomains["api.atlassian.com"] = struct{}{}
73+
}
74+
75+
for email := range uniqueEmails {
76+
for token := range uniqueTokens {
77+
for domain := range uniqueDomains {
6778
s1 := detectors.Result{
6879
DetectorType: detectorspb.DetectorType_JiraToken,
69-
Raw: []byte(resToken),
70-
RawV2: []byte(fmt.Sprintf("%s:%s:%s", resEmail, resToken, resDomain)),
80+
Raw: []byte(token),
81+
RawV2: []byte(fmt.Sprintf("%s:%s:%s", email, token, domain)),
7182
ExtraData: map[string]string{
7283
"rotation_guide": "https://howtorotate.com/docs/tutorials/atlassian/",
7384
"version": fmt.Sprintf("%d", s.Version()),
@@ -76,9 +87,9 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
7687

7788
if verify {
7889
client := s.getClient()
79-
isVerified, verificationErr := verifyJiratoken(ctx, client, resEmail, resDomain, resToken)
90+
isVerified, verificationErr := v1.VerifyJiraToken(ctx, client, email, domain, token)
8091
s1.Verified = isVerified
81-
s1.SetVerificationError(verificationErr, resToken)
92+
s1.SetVerificationError(verificationErr, token)
8293
}
8394

8495
results = append(results, s1)
@@ -89,42 +100,6 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
89100
return results, nil
90101
}
91102

92-
func (s Scanner) getClient() *http.Client {
93-
if s.client != nil {
94-
return s.client
95-
}
96-
return defaultClient
97-
}
98-
99-
func verifyJiratoken(ctx context.Context, client *http.Client, email, domain, token string) (bool, error) {
100-
data := fmt.Sprintf("%s:%s", email, token)
101-
sEnc := b64.StdEncoding.EncodeToString([]byte(data))
102-
req, err := http.NewRequestWithContext(ctx, "GET", "https://"+domain+"/rest/api/3/dashboard", nil)
103-
if err != nil {
104-
return false, err
105-
}
106-
req.Header.Add("Accept", "application/json")
107-
req.Header.Add("Authorization", fmt.Sprintf("Basic %s", sEnc))
108-
res, err := client.Do(req)
109-
if err != nil {
110-
return false, err
111-
}
112-
defer res.Body.Close()
113-
114-
// If the request is successful and the login reason is not failed authentication, then the token is valid.
115-
// This is because Jira returns a 200 status code even if the token is invalid.
116-
// Jira returns a default dashboard page.
117-
if !(res.StatusCode >= 200 && res.StatusCode < 300) {
118-
return false, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode)
119-
}
120-
121-
if res.Header.Get(loginReasonHeaderKey) != failedAuth {
122-
return true, nil
123-
}
124-
125-
return false, nil
126-
}
127-
128103
func (s Scanner) Type() detectorspb.DetectorType {
129104
return detectorspb.DetectorType_JiraToken
130105
}

pkg/detectors/jiratoken/v2/jiratoken_v2_integration_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ func TestJiraToken_FromChunk(t *testing.T) {
154154
t.Fatalf("wantVerificationError = %v, verification error = %v", tt.wantVerificationErr, got[i].VerificationError())
155155
}
156156
}
157-
ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "RawV2", "verificationError")
157+
ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "RawV2", "verificationError", "primarySecret")
158158
if diff := cmp.Diff(got, tt.want, ignoreOpts); diff != "" {
159159
t.Errorf("JiraToken.FromData() %s diff: (-got +want)\n%s", tt.name, diff)
160160
}

pkg/detectors/jiratoken/v2/jiratoken_v2_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package jiratoken
33
import (
44
"context"
55
"fmt"
6+
"strings"
67
"testing"
78

89
"github.com/google/go-cmp/cmp"
@@ -12,7 +13,7 @@ import (
1213
)
1314

1415
var (
15-
validTokenPattern = "9nsCADa7812Z7VoIsYJ0K4rFWLBfk=1rhOsLAW"
16+
validTokenPattern = "ATATT9nsCADa7812Z7VoIsYJ0K4rFWLBfk=1rhOsLAW"
1617
invalidTokenPattern = "9nsCA?a7812Z7VoI%YJ0K4rFWLBfk91rhOsLAW"
1718
validDomainPattern = "hereisavalidsubdomain.heresalongdomain.com"
1819
validDomainPattern2 = "jira.hereisavalidsubdomain.heresalongdomain.com"
@@ -33,12 +34,12 @@ func TestJiraToken_Pattern(t *testing.T) {
3334
{
3435
name: "valid pattern - with keyword jira",
3536
input: fmt.Sprintf("%s %s \n%s %s\n%s %s", keyword, validTokenPattern, keyword, validDomainPattern, keyword, validEmailPattern),
36-
want: []string{validEmailPattern + ":" + validTokenPattern + ":" + validDomainPattern},
37+
want: []string{strings.ToLower(validEmailPattern) + ":" + validTokenPattern + ":" + validDomainPattern},
3738
},
3839
{
3940
name: "valid pattern - with multiple subdomains",
4041
input: fmt.Sprintf("%s %s \n%s %s\n%s %s", keyword, validTokenPattern, keyword, validDomainPattern2, keyword, validEmailPattern),
41-
want: []string{validEmailPattern + ":" + validTokenPattern + ":" + validDomainPattern2},
42+
want: []string{strings.ToLower(validEmailPattern) + ":" + validTokenPattern + ":" + validDomainPattern2},
4243
},
4344
{
4445
name: "valid pattern - key out of prefix range",

0 commit comments

Comments
 (0)