[INS-402] Add Jira Data Center PAT Detector (#4872)

* add jira data center pat detector

* fix lint and engine test

* fix merge

* make protos and fix imports after merge

* tighten regex

* bugbot fix

* embed DefaultMultiPartCredentialProvider

* make protos

* add improvements and bugbot fixes

* make protos after merge

* deduplicate tokens and use DetectorHttpClientWithNoLocalAddresses

* incorporated comments

* added extra endpoint at the right place

* bugbot fixes

* bugbot fix
This commit is contained in:
Mustansir
2026-04-17 22:34:18 +05:00
committed by GitHub
parent 0112444f77
commit 0d5afcefc5
6 changed files with 552 additions and 6 deletions
@@ -0,0 +1,192 @@
package jiradatacenterpat
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
regexp "github.com/wasilibs/go-re2"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
)
type Scanner struct {
client *http.Client
detectors.EndpointSetter
detectors.DefaultMultiPartCredentialProvider
}
// Ensure the Scanner satisfies the interfaces at compile time.
var (
_ detectors.Detector = (*Scanner)(nil)
_ detectors.EndpointCustomizer = (*Scanner)(nil)
_ detectors.MultiPartCredentialProvider = (*Scanner)(nil)
)
var (
defaultClient = detectors.DetectorHttpClientWithNoLocalAddresses
// PATs are base64-encoded strings of the form <12-digit-id>:<20-random-bytes> (33 bytes, 44 chars, no padding).
// Since the first byte is always an ASCII digit (0x30–0x39), the first base64 character is always M, N, or O.
// This is also verified by generating 25+ tokens.
// The trailing boundary (?:[^A-Za-z0-9+/=]|\z) is used instead of \b to correctly handle tokens ending in + or /.
patPat = regexp.MustCompile(detectors.PrefixRegex([]string{"jira", "atlassian"}) + `\b([MNO][A-Za-z0-9+/]{43})(?:[^A-Za-z0-9+/=]|\z)`)
urlPat = regexp.MustCompile(detectors.PrefixRegex([]string{"jira", "atlassian"}) + `(https?://[A-Za-z0-9][A-Za-z0-9.\-]*(?::\d{1,5})?)`)
)
func (s Scanner) getClient() *http.Client {
if s.client != nil {
return s.client
}
return defaultClient
}
// Keywords are used for efficiently pre-filtering chunks.
func (s Scanner) Keywords() []string {
return []string{"jira", "atlassian"}
}
// FromData will find and optionally verify Jira Data Center PAT 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)
tokens := make(map[string]struct{})
for _, match := range patPat.FindAllStringSubmatch(dataStr, -1) {
if isStructuralPAT(match[1]) {
tokens[match[1]] = struct{}{}
}
}
uniqueURLs := make(map[string]struct{})
for _, match := range urlPat.FindAllStringSubmatch(dataStr, -1) {
uniqueURLs[match[1]] = struct{}{}
}
foundURLs := make([]string, 0, len(uniqueURLs))
for url := range uniqueURLs {
foundURLs = append(foundURLs, url)
}
endpoints := make(map[string]struct{})
for _, endpoint := range s.Endpoints(foundURLs...) {
endpoints[endpoint] = struct{}{}
}
for token := range tokens {
if len(endpoints) == 0 {
results = append(results, detectors.Result{
DetectorType: detector_typepb.DetectorType_JiraDataCenterPAT,
Raw: []byte(token),
Redacted: token[:3] + "..." + token[len(token)-3:],
ExtraData: map[string]string{"message": "No Jira Data Center URL was found or configured. To verify this token, set the Jira instance base URL as a custom endpoint."},
})
continue
}
for endpoint := range endpoints {
s1 := detectors.Result{
DetectorType: detector_typepb.DetectorType_JiraDataCenterPAT,
Raw: []byte(token),
RawV2: []byte(token + ":" + endpoint),
Redacted: token[:3] + "..." + token[len(token)-3:],
}
if verify {
isVerified, extraData, verificationErr := verifyPAT(ctx, s.getClient(), endpoint, token)
s1.Verified = isVerified
s1.ExtraData = extraData
s1.SetVerificationError(verificationErr, token)
}
results = append(results, s1)
if s1.Verified {
break
}
}
}
return results, nil
}
// verifyPAT checks whether the token is valid by calling the /rest/api/2/myself endpoint,
// which returns the currently authenticated user.
// Docs: https://developer.atlassian.com/server/jira/platform/rest/v10002/api-group-myself/#api-api-2-myself-get
func verifyPAT(ctx context.Context, client *http.Client, baseURL, token string) (bool, map[string]string, error) {
u, err := detectors.ParseURLAndStripPathAndParams(baseURL)
if err != nil {
return false, nil, err
}
u.Path = "/rest/api/2/myself"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), http.NoBody)
if err != nil {
return false, nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
resp, err := client.Do(req)
if err != nil {
return false, nil, err
}
defer func() {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}()
switch resp.StatusCode {
case http.StatusOK:
var result map[string]any
extraData := map[string]string{
"endpoint": baseURL,
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
// 200 confirms the token is valid; failing to decode only means we can't extract extra data.
return true, extraData, nil
}
if name, ok := result["displayName"].(string); ok {
extraData["display_name"] = name
}
if email, ok := result["emailAddress"].(string); ok {
extraData["email_address"] = email
}
return true, extraData, nil
case http.StatusUnauthorized:
return false, nil, nil
default:
return false, nil, fmt.Errorf("unexpected HTTP response status %d", resp.StatusCode)
}
}
// isStructuralPAT decodes a candidate base64 string and checks that it matches
// the "<numeric id>:<random bytes>" structure used by Jira DC PATs:
// one or more ASCII digits, a colon, then at least one more byte.
func isStructuralPAT(candidate string) bool {
raw, err := base64.StdEncoding.DecodeString(candidate)
if err != nil {
return false
}
colon := bytes.IndexByte(raw, ':')
if colon <= 0 || colon == len(raw)-1 {
return false
}
for _, b := range raw[:colon] {
if b < '0' || b > '9' {
return false
}
}
return true
}
func (s Scanner) Type() detector_typepb.DetectorType {
return detector_typepb.DetectorType_JiraDataCenterPAT
}
func (s Scanner) Description() string {
return "Jira Data Center is a self-hosted version of Jira. Personal Access Tokens (PATs) are used to authenticate API requests to Jira Data Center instances."
}
@@ -0,0 +1,345 @@
package jiradatacenterpat
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/h2non/gock.v1"
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
)
const (
testToken = "NTg4OTI1Mzk1OTA1OiBb9S4WPEoK6cmOe6pq6VO0lt6M"
testEndpoint = "http://jira.example.com"
)
func TestJiraDataCenterPAT_Pattern(t *testing.T) {
d := Scanner{}
_ = d.SetConfiguredEndpoints("https://jira.example.com")
d.UseFoundEndpoints(true)
ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d})
tests := []struct {
name string
input string
want []string
}{
{
name: "valid PAT",
input: `jira_token: NTg4OTI1Mzk1OTA1OiBb9S4WPEoK6cmOe6pq6VO0lt6M`,
want: []string{"NTg4OTI1Mzk1OTA1OiBb9S4WPEoK6cmOe6pq6VO0lt6M:https://jira.example.com"},
},
{
name: "URL found near jira keyword",
input: `# jira server: http://jira.internal:8080` + "\n" + `jira token: NTg4OTI1Mzk1OTA1OiBb9S4WPEoK6cmOe6pq6VO0lt6M`,
want: []string{
"NTg4OTI1Mzk1OTA1OiBb9S4WPEoK6cmOe6pq6VO0lt6M:http://jira.internal:8080",
"NTg4OTI1Mzk1OTA1OiBb9S4WPEoK6cmOe6pq6VO0lt6M:https://jira.example.com",
},
},
{
name: "URL found near atlassian keyword",
input: `# atlassian server: http://jira.internal:8080` + "\n" + `atlassian token: NTg4OTI1Mzk1OTA1OiBb9S4WPEoK6cmOe6pq6VO0lt6M`,
want: []string{
"NTg4OTI1Mzk1OTA1OiBb9S4WPEoK6cmOe6pq6VO0lt6M:http://jira.internal:8080",
"NTg4OTI1Mzk1OTA1OiBb9S4WPEoK6cmOe6pq6VO0lt6M:https://jira.example.com",
},
},
{
name: "valid PAT ending with +",
input: `jira_token: MzE3MjgzNDMyNTczOmTaXorACdDy8aVJU6FotdRcz2y+`,
want: []string{"MzE3MjgzNDMyNTczOmTaXorACdDy8aVJU6FotdRcz2y+:https://jira.example.com"},
},
{
name: "not a match - invalid first character",
input: `jira_token: ATg4OTI1Mzk1OTA1OiBb9S4WPEoK6cmOe6pq6VO0lt6M`,
want: []string{},
},
{
name: "not a match - substring of longer base64 string",
input: `jira_token: MzE3MjgzNDMyNTczOmTaXorACdDy8aVJU6FotdRcz2y+AAAA`,
want: []string{},
},
{
name: "not a match - followed by base64 padding",
input: `jira_token: NTg4OTI1Mzk1OTA1OiBb9S4WPEoK6cmOe6pq6VO0lt6M=`,
want: []string{},
},
{
name: "too short - not a match",
input: `jira_token: NTg4OTI1Mzk1OTA1OiBb9S4WPEoK6cmOe6pq6VO0`,
want: []string{},
},
{
// Passes the regex (44 chars, starts with M) but decodes to "0a:xxx..."
// where the byte before the colon is not purely digits.
name: "not a match - fails structural check",
input: `jira_token: MGE6eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4`,
want: []string{},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
matchedDetectors := ahoCorasickCore.FindDetectorMatches([]byte(test.input))
if len(test.want) > 0 && len(matchedDetectors) == 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)
}
})
}
}
func TestJiraDataCenterPAT_FromData(t *testing.T) {
client := common.SaneHttpClient()
d := Scanner{client: client}
_ = d.SetConfiguredEndpoints(testEndpoint)
d.UseFoundEndpoints(false)
defer gock.Off()
defer gock.RestoreClient(client)
gock.InterceptClient(client)
tests := []struct {
name string
setup func()
data string
verify bool
wantResults int
wantVerified bool
wantVerificationErr bool
wantExtraData map[string]string
}{
{
name: "found, verified",
setup: func() {
gock.New(testEndpoint).
Get("/rest/api/2/myself").
MatchHeader("Authorization", fmt.Sprintf("Bearer %s", testToken)).
Reply(http.StatusOK).
JSON(map[string]any{
"displayName": "Test User",
"emailAddress": "[email protected]",
"endpoint": testEndpoint,
})
},
data: fmt.Sprintf("jira token: %s", testToken),
verify: true,
wantResults: 1,
wantVerified: true,
wantExtraData: map[string]string{
"display_name": "Test User",
"email_address": "[email protected]",
"endpoint": testEndpoint,
},
},
{
name: "found, verified - invalid json body",
setup: func() {
gock.New(testEndpoint).
Get("/rest/api/2/myself").
Reply(http.StatusOK).
BodyString("not json")
},
data: fmt.Sprintf("jira token: %s", testToken),
verify: true,
wantResults: 1,
wantVerified: true,
},
{
name: "found, unverified (401)",
setup: func() {
gock.New(testEndpoint).
Get("/rest/api/2/myself").
Reply(http.StatusUnauthorized)
},
data: fmt.Sprintf("jira token: %s", testToken),
verify: true,
wantResults: 1,
wantVerified: false,
},
{
name: "not found",
setup: func() {},
data: "jira config: nothing here",
verify: true,
wantResults: 0,
},
{
name: "found, verification error on unexpected status",
setup: func() {
gock.New(testEndpoint).
Get("/rest/api/2/myself").
Reply(http.StatusInternalServerError)
},
data: fmt.Sprintf("jira token: %s", testToken),
verify: true,
wantResults: 1,
wantVerified: false,
wantVerificationErr: true,
},
{
name: "found, verification error on timeout",
setup: func() {
gock.New(testEndpoint).
Get("/rest/api/2/myself").
Reply(http.StatusOK).
Delay(2 * time.Second)
},
data: fmt.Sprintf("jira token: %s", testToken),
verify: true,
wantResults: 1,
wantVerified: false,
wantVerificationErr: true,
},
{
name: "found, no verify",
setup: func() {},
data: fmt.Sprintf("jira token: %s", testToken),
verify: false,
wantResults: 1,
wantVerified: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gock.Flush()
tt.setup()
ctx := context.Background()
if tt.wantVerificationErr {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 100*time.Millisecond)
defer cancel()
}
results, err := d.FromData(ctx, tt.verify, []byte(tt.data))
require.NoError(t, err)
require.Len(t, results, tt.wantResults)
for _, result := range results {
assert.Equal(t, detector_typepb.DetectorType_JiraDataCenterPAT, result.DetectorType)
assert.NotEmpty(t, result.Raw)
assert.Equal(t, tt.wantVerified, result.Verified)
assert.Equal(t, tt.wantVerificationErr, result.VerificationError() != nil)
if tt.wantExtraData != nil {
assert.Equal(t, tt.wantExtraData, result.ExtraData)
}
}
})
}
}
func TestIsStructuralPAT(t *testing.T) {
encode := func(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
// helper to build a 33-byte payload with a numeric id and random suffix
numericIDPayload := func(id, suffix string) []byte {
return []byte(id + ":" + suffix)
}
tests := []struct {
name string
candidate string
want bool
}{
{
name: "valid real token",
candidate: testToken,
want: true,
},
{
name: "valid - digits before colon",
candidate: encode(numericIDPayload("123456789012", "01234567890123456789")),
want: true,
},
{
name: "invalid base64",
candidate: "!!!not-base64!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!",
want: false,
},
{
name: "no colon",
candidate: encode([]byte("588925395905012345678901234567890")),
want: false,
},
{
name: "colon at position 0",
candidate: encode([]byte(":01234567890123456789012345678901")),
want: false,
},
{
name: "colon at last position",
candidate: encode([]byte("58892539590501234567890123456789:")),
want: false,
},
{
name: "non-digit before colon",
// decodes to "0a:xxx..." — 'a' is not a digit
candidate: "MGE6eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, isStructuralPAT(tt.candidate))
})
}
}
func TestJiraDataCenterPAT_NoURL(t *testing.T) {
d := Scanner{client: common.SaneHttpClient()}
results, err := d.FromData(context.Background(), true, []byte(fmt.Sprintf("jira token: %s", testToken)))
require.NoError(t, err)
require.Len(t, results, 1)
assert.False(t, results[0].Verified)
assert.Equal(t, map[string]string{"message": "No Jira Data Center URL was found or configured. To verify this token, set the Jira instance base URL as a custom endpoint."}, results[0].ExtraData)
assert.Empty(t, results[0].RawV2)
}
+2
View File
@@ -397,6 +397,7 @@ import (
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/ipquality"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/ipstack"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/jdbc"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/jiradatacenterpat"
jiratokenv1 "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/jiratoken/v1"
jiratokenv2 "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/jiratoken/v2"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/jotform"
@@ -1274,6 +1275,7 @@ func buildDetectorList() []detectors.Detector {
&ipquality.Scanner{},
&ipstack.Scanner{},
&jdbc.Scanner{},
&jiradatacenterpat.Scanner{},
&jiratokenv1.Scanner{},
&jiratokenv2.Scanner{},
&jotform.Scanner{},
+1
View File
@@ -1379,6 +1379,7 @@ func TestEngineInitializesCloudProviderDetectors(t *testing.T) {
detector_typepb.DetectorType_ArtifactoryReferenceToken: {},
detector_typepb.DetectorType_TableauPersonalAccessToken: {},
detector_typepb.DetectorType_HashiCorpVaultAuth: {},
detector_typepb.DetectorType_JiraDataCenterPAT: {},
// these do not have any cloud endpoint
}
+11 -6
View File
@@ -1099,6 +1099,7 @@ const (
DetectorType_DatadogApikey DetectorType = 1043
DetectorType_ShopifyOAuth DetectorType = 1044
DetectorType_BitbucketDataCenter DetectorType = 1045
DetectorType_JiraDataCenterPAT DetectorType = 1046
)
// Enum value maps for DetectorType.
@@ -2146,6 +2147,7 @@ var (
1043: "DatadogApikey",
1044: "ShopifyOAuth",
1045: "BitbucketDataCenter",
1046: "JiraDataCenterPAT",
}
DetectorType_value = map[string]int32{
"Alibaba": 0,
@@ -3190,6 +3192,7 @@ var (
"DatadogApikey": 1043,
"ShopifyOAuth": 1044,
"BitbucketDataCenter": 1045,
"JiraDataCenterPAT": 1046,
}
)
@@ -3225,7 +3228,7 @@ var File_detector_type_proto protoreflect.FileDescriptor
var file_detector_type_proto_rawDesc = []byte{
0x0a, 0x13, 0x64, 0x65, 0x74, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0d, 0x64, 0x65, 0x74, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f,
0x74, 0x79, 0x70, 0x65, 0x2a, 0xd9, 0x87, 0x01, 0x0a, 0x0c, 0x44, 0x65, 0x74, 0x65, 0x63, 0x74,
0x74, 0x79, 0x70, 0x65, 0x2a, 0xf1, 0x87, 0x01, 0x0a, 0x0c, 0x44, 0x65, 0x74, 0x65, 0x63, 0x74,
0x6f, 0x72, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x41, 0x6c, 0x69, 0x62, 0x61, 0x62,
0x61, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x41, 0x4d, 0x51, 0x50, 0x10, 0x01, 0x12, 0x07, 0x0a,
0x03, 0x41, 0x57, 0x53, 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x41, 0x7a, 0x75, 0x72, 0x65, 0x10,
@@ -4311,11 +4314,13 @@ var file_detector_type_proto_rawDesc = []byte{
0x79, 0x10, 0x93, 0x08, 0x12, 0x11, 0x0a, 0x0c, 0x53, 0x68, 0x6f, 0x70, 0x69, 0x66, 0x79, 0x4f,
0x41, 0x75, 0x74, 0x68, 0x10, 0x94, 0x08, 0x12, 0x18, 0x0a, 0x13, 0x42, 0x69, 0x74, 0x62, 0x75,
0x63, 0x6b, 0x65, 0x74, 0x44, 0x61, 0x74, 0x61, 0x43, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x10, 0x95,
0x08, 0x42, 0x41, 0x5a, 0x3f, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f,
0x74, 0x72, 0x75, 0x66, 0x66, 0x6c, 0x65, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x2f,
0x74, 0x72, 0x75, 0x66, 0x66, 0x6c, 0x65, 0x68, 0x6f, 0x67, 0x2f, 0x76, 0x33, 0x2f, 0x70, 0x6b,
0x67, 0x2f, 0x70, 0x62, 0x2f, 0x64, 0x65, 0x74, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x74, 0x79,
0x70, 0x65, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
0x08, 0x12, 0x16, 0x0a, 0x11, 0x4a, 0x69, 0x72, 0x61, 0x44, 0x61, 0x74, 0x61, 0x43, 0x65, 0x6e,
0x74, 0x65, 0x72, 0x50, 0x41, 0x54, 0x10, 0x96, 0x08, 0x42, 0x41, 0x5a, 0x3f, 0x67, 0x69, 0x74,
0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x74, 0x72, 0x75, 0x66, 0x66, 0x6c, 0x65, 0x73,
0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x2f, 0x74, 0x72, 0x75, 0x66, 0x66, 0x6c, 0x65, 0x68,
0x6f, 0x67, 0x2f, 0x76, 0x33, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x62, 0x2f, 0x64, 0x65, 0x74,
0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x33,
}
var (
+1
View File
@@ -1047,4 +1047,5 @@ enum DetectorType {
DatadogApikey = 1043;
ShopifyOAuth = 1044;
BitbucketDataCenter = 1045;
JiraDataCenterPAT = 1046;
}