Add Confluence Data Center PAT detector (#4886)
Lint / golangci-lint (push) Waiting to run
Lint / man-page-staleness (push) Waiting to run
Lint / semgrep (push) Waiting to run
Scan for secrets / test (push) Waiting to run
Test / test (push) Waiting to run
Test / test-community (push) Waiting to run
Release / mark-latest (push) Canceled after 0s
Release / Release (push) Canceled after 0s

* add Confluence Data Center detector
This commit is contained in:
Amaan Ullah
2026-04-21 13:09:41 +05:00
committed by GitHub
parent 4d2a8ef686
commit 65725840a0
6 changed files with 384 additions and 7 deletions
@@ -0,0 +1,204 @@
package confluencedatacenter
import (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"strings"
regexp "github.com/wasilibs/go-re2"
"github.com/trufflesecurity/trufflehog/v3/pkg/cache/simple"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
)
type Scanner struct {
detectors.DefaultMultiPartCredentialProvider
detectors.EndpointSetter
client *http.Client
}
var (
_ detectors.Detector = (*Scanner)(nil)
_ detectors.EndpointCustomizer = (*Scanner)(nil)
)
func (Scanner) CloudEndpoint() string { return "" }
var (
defaultClient = detectors.DetectorHttpClientWithLocalAddresses
keywords = []string{"confluence", "atlassian", "wiki"}
// 44-char base64 PAT; decoded form must match the structural check below.
tokenPat = regexp.MustCompile(detectors.PrefixRegex(keywords) + `\b([MNO][A-Za-z0-9+/]{43})(?:[^A-Za-z0-9+/=]|\z)`)
// Self-hosted instance URL: scheme + host + optional port. Keyword-scoped
// so unrelated URLs in the same chunk don't get paired with tokens.
urlPat = regexp.MustCompile(detectors.PrefixRegex(keywords) + `\b(https?://[a-zA-Z0-9.\-]+(?::\d+)?)\b`)
invalidHosts = simple.NewCache[struct{}]()
errNoHost = errors.New("no such host")
)
func (s Scanner) Keywords() []string {
return keywords
}
func (s Scanner) Type() detector_typepb.DetectorType {
return detector_typepb.DetectorType_ConfluenceDataCenter
}
func (s Scanner) Description() string {
return "Confluence Data Center is Atlassian's self-hosted wiki product. Personal Access Tokens (PATs) authenticate via Bearer auth against the REST API and grant access scoped to the issuing user."
}
func (s Scanner) getClient() *http.Client {
if s.client != nil {
return s.client
}
return defaultClient
}
// isStructuralPAT decodes a candidate base64 string and checks that it matches
// the "<numeric id>:<random bytes>" structure used by Confluence 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) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
dataStr := string(data)
uniqueTokens := make(map[string]struct{})
for _, m := range tokenPat.FindAllStringSubmatch(dataStr, -1) {
if _, seen := uniqueTokens[m[1]]; seen {
continue
}
if isStructuralPAT(m[1]) {
uniqueTokens[m[1]] = struct{}{}
}
}
if len(uniqueTokens) == 0 {
return nil, nil
}
foundURLs := make([]string, 0)
for _, m := range urlPat.FindAllStringSubmatch(dataStr, -1) {
foundURLs = append(foundURLs, m[1])
}
uniqueURLs := make(map[string]struct{})
for _, endpoint := range s.Endpoints(foundURLs...) {
uniqueURLs[strings.TrimRight(endpoint, "/")] = struct{}{}
}
// Filter hosts cached as unreachable from prior calls once up front.
// invalidHosts may also grow during this call (see the verify branch
// below); those are skipped lazily inside the inner loop.
liveURLs := make([]string, 0, len(uniqueURLs))
for u := range uniqueURLs {
if !invalidHosts.Exists(u) {
liveURLs = append(liveURLs, u)
}
}
for token := range uniqueTokens {
emitted := false
for _, baseURL := range liveURLs {
if invalidHosts.Exists(baseURL) {
continue
}
r := detectors.Result{
DetectorType: detector_typepb.DetectorType_ConfluenceDataCenter,
Raw: []byte(token),
RawV2: []byte(fmt.Sprintf("%s:%s", token, baseURL)),
ExtraData: map[string]string{
"base_url": baseURL,
},
}
if verify {
isVerified, vErr := verifyPAT(ctx, s.getClient(), baseURL, token)
r.Verified = isVerified
if vErr != nil {
if errors.Is(vErr, errNoHost) {
invalidHosts.Set(baseURL, struct{}{})
}
r.SetVerificationError(vErr, token)
}
}
results = append(results, r)
emitted = true
}
if !emitted {
// No reachable URL in context — emit an unverified token-only
// result and annotate why we couldn't verify.
results = append(results, detectors.Result{
DetectorType: detector_typepb.DetectorType_ConfluenceDataCenter,
Raw: []byte(token),
RawV2: []byte(token),
ExtraData: map[string]string{
"verification_note": "no reachable Confluence Data Center URL found in context; token reported unverified",
},
})
}
}
return results, nil
}
func verifyPAT(ctx context.Context, client *http.Client, baseURL, token string) (bool, error) {
endpoint := strings.TrimRight(baseURL, "/") + "/rest/api/user/current"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
if err != nil {
return false, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
if err != nil {
if strings.Contains(err.Error(), "no such host") {
return false, errNoHost
}
return false, err
}
defer func() {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}()
switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusUnauthorized:
// Auth header outright rejected — unambiguously an invalid credential.
return false, nil
default:
// 403 included: /rest/api/user/current should always be readable by a
// valid PAT, so a Forbidden here signals something unexpected rather
// than a definitively invalid token.
return false, fmt.Errorf("unexpected HTTP response status %d", resp.StatusCode)
}
}
@@ -0,0 +1,165 @@
package confluencedatacenter
import (
"context"
"fmt"
"net/http"
"testing"
"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"
)
// Real-format sample PATs: each decodes to "<numeric id>:<random bytes>".
const (
validPAT1 = "NTk3MjQzOTIyNTAwOtFOuTsHRIp1E81GApKpC2xpEzfz"
validPAT2 = "NDc4MjM3OTUxMzk2OopoSkTDTnBcWIw0Wa4bico9zOLK"
// 44-char base64 that passes tokenPat (leading [MNO]) but decodes to
// bytes with no colon, so it must be rejected by isStructuralPAT rather
// than by the regex. Exercises the structural post-filter's reject path.
nonStructural = "MAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
)
func TestConfluenceDataCenter_Pattern(t *testing.T) {
d := Scanner{}
d.UseFoundEndpoints(true)
ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d})
tests := []struct {
name string
input string
want []string
}{
{
name: "token + instance URL in one chunk",
input: fmt.Sprintf(`
CONFLUENCE_URL=https://wiki.example.com:8443
confluence_token=%s
`, validPAT1),
want: []string{validPAT1 + ":https://wiki.example.com:8443"},
},
{
name: "token + REST API URL pattern",
input: fmt.Sprintf(`
# confluence bearer token: %s
confluence host: https://confluence.corp.local/rest/api/user/current
`, validPAT1),
want: []string{validPAT1 + ":https://confluence.corp.local"},
},
{
name: "token with no URL in context (token-only)",
input: fmt.Sprintf(`
# confluence personal access token for CI
TOKEN=%s
`, validPAT2),
want: []string{validPAT2},
},
{
name: "structural post-filter rejects non-PAT base64",
input: fmt.Sprintf(`
confluence key: %s
`, nonStructural),
want: []string{},
},
{
name: "multiple tokens + multiple URLs => Cartesian product",
input: fmt.Sprintf(`
confluence prod: https://wiki.prod.corp/wiki/
confluence stg: https://wiki.stg.corp/rest/api
confluence_a=%s
confluence_b=%s
`, validPAT1, validPAT2),
want: []string{
validPAT1 + ":https://wiki.prod.corp",
validPAT1 + ":https://wiki.stg.corp",
validPAT2 + ":https://wiki.prod.corp",
validPAT2 + ":https://wiki.stg.corp",
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
matched := ahoCorasickCore.FindDetectorMatches([]byte(test.input))
if len(test.want) > 0 && len(matched) == 0 {
t.Fatalf("keywords %v not matched by aho-corasick in input", d.Keywords())
}
results, err := d.FromData(context.Background(), false, []byte(test.input))
require.NoError(t, err)
if len(results) != len(test.want) {
t.Fatalf("mismatch in result count: expected %d, got %d (%+v)", len(test.want), len(results), results)
}
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 TestConfluenceDataCenter_Verification(t *testing.T) {
const baseURL = "https://wiki.internal.corp"
cases := []struct {
name string
status int
wantVerified bool
wantVerifyErr bool
}{
{"200 verified", http.StatusOK, true, false},
{"401 invalid", http.StatusUnauthorized, false, false},
{"403 unexpected", http.StatusForbidden, false, true},
{"500 unknown", http.StatusInternalServerError, false, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
client := common.SaneHttpClient()
d := Scanner{client: client}
d.UseFoundEndpoints(true)
defer gock.Off()
defer gock.RestoreClient(client)
gock.InterceptClient(client)
gock.New(baseURL).
Get("/rest/api/user/current").
MatchHeader("Authorization", "Bearer "+validPAT1).
Reply(tc.status)
input := fmt.Sprintf("confluence url=%s\nconfluence token=%s\n", baseURL, validPAT1)
results, err := d.FromData(context.Background(), true, []byte(input))
require.NoError(t, err)
require.Len(t, results, 1)
r := results[0]
assert.Equal(t, tc.wantVerified, r.Verified)
if tc.wantVerifyErr {
assert.Error(t, r.VerificationError())
} else {
assert.NoError(t, r.VerificationError())
}
})
}
}
+2
View File
@@ -185,6 +185,7 @@ import (
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/commercejs"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/commodities"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/companyhub"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/confluencedatacenter"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/confluent"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/contentfulpersonalaccesstoken"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/conversiontools"
@@ -1056,6 +1057,7 @@ func buildDetectorList() []detectors.Detector {
&commercejs.Scanner{},
&commodities.Scanner{},
&companyhub.Scanner{},
&confluencedatacenter.Scanner{},
&confluent.Scanner{},
&contentfulpersonalaccesstoken.Scanner{},
&conversiontools.Scanner{},
+1
View File
@@ -1380,6 +1380,7 @@ func TestEngineInitializesCloudProviderDetectors(t *testing.T) {
detector_typepb.DetectorType_TableauPersonalAccessToken: {},
detector_typepb.DetectorType_HashiCorpVaultAuth: {},
detector_typepb.DetectorType_JiraDataCenterPAT: {},
detector_typepb.DetectorType_ConfluenceDataCenter: {},
// these do not have any cloud endpoint
}
+11 -7
View File
@@ -1100,6 +1100,7 @@ const (
DetectorType_ShopifyOAuth DetectorType = 1044
DetectorType_BitbucketDataCenter DetectorType = 1045
DetectorType_JiraDataCenterPAT DetectorType = 1046
DetectorType_ConfluenceDataCenter DetectorType = 1047
)
// Enum value maps for DetectorType.
@@ -2148,6 +2149,7 @@ var (
1044: "ShopifyOAuth",
1045: "BitbucketDataCenter",
1046: "JiraDataCenterPAT",
1047: "ConfluenceDataCenter",
}
DetectorType_value = map[string]int32{
"Alibaba": 0,
@@ -3193,6 +3195,7 @@ var (
"ShopifyOAuth": 1044,
"BitbucketDataCenter": 1045,
"JiraDataCenterPAT": 1046,
"ConfluenceDataCenter": 1047,
}
)
@@ -3228,7 +3231,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, 0xf1, 0x87, 0x01, 0x0a, 0x0c, 0x44, 0x65, 0x74, 0x65, 0x63, 0x74,
0x74, 0x79, 0x70, 0x65, 0x2a, 0x8c, 0x88, 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,
@@ -4315,12 +4318,13 @@ var file_detector_type_proto_rawDesc = []byte{
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, 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,
0x74, 0x65, 0x72, 0x50, 0x41, 0x54, 0x10, 0x96, 0x08, 0x12, 0x19, 0x0a, 0x14, 0x43, 0x6f, 0x6e,
0x66, 0x6c, 0x75, 0x65, 0x6e, 0x63, 0x65, 0x44, 0x61, 0x74, 0x61, 0x43, 0x65, 0x6e, 0x74, 0x65,
0x72, 0x10, 0x97, 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
@@ -1048,4 +1048,5 @@ enum DetectorType {
ShopifyOAuth = 1044;
BitbucketDataCenter = 1045;
JiraDataCenterPAT = 1046;
ConfluenceDataCenter = 1047;
}