diff --git a/pkg/detectors/confluencedatacenter/confluencedatacenter.go b/pkg/detectors/confluencedatacenter/confluencedatacenter.go new file mode 100644 index 000000000..ffbf1b772 --- /dev/null +++ b/pkg/detectors/confluencedatacenter/confluencedatacenter.go @@ -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 ":" 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) + } +} diff --git a/pkg/detectors/confluencedatacenter/confluencedatacenter_test.go b/pkg/detectors/confluencedatacenter/confluencedatacenter_test.go new file mode 100644 index 000000000..520d4699a --- /dev/null +++ b/pkg/detectors/confluencedatacenter/confluencedatacenter_test.go @@ -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 ":". +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()) + } + }) + } +} diff --git a/pkg/engine/defaults/defaults.go b/pkg/engine/defaults/defaults.go index c5e319fe4..f769af2c5 100644 --- a/pkg/engine/defaults/defaults.go +++ b/pkg/engine/defaults/defaults.go @@ -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{}, diff --git a/pkg/engine/engine_test.go b/pkg/engine/engine_test.go index 92e0ccc53..e5ca7ef95 100644 --- a/pkg/engine/engine_test.go +++ b/pkg/engine/engine_test.go @@ -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 } diff --git a/pkg/pb/detector_typepb/detector_type.pb.go b/pkg/pb/detector_typepb/detector_type.pb.go index 922b90eff..51f668528 100644 --- a/pkg/pb/detector_typepb/detector_type.pb.go +++ b/pkg/pb/detector_typepb/detector_type.pb.go @@ -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 ( diff --git a/proto/detector_type.proto b/proto/detector_type.proto index 645ff4ee3..ddd4c83c6 100644 --- a/proto/detector_type.proto +++ b/proto/detector_type.proto @@ -1048,4 +1048,5 @@ enum DetectorType { ShopifyOAuth = 1044; BitbucketDataCenter = 1045; JiraDataCenterPAT = 1046; + ConfluenceDataCenter = 1047; }