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
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:
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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{},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -1048,4 +1048,5 @@ enum DetectorType {
|
||||
ShopifyOAuth = 1044;
|
||||
BitbucketDataCenter = 1045;
|
||||
JiraDataCenterPAT = 1046;
|
||||
ConfluenceDataCenter = 1047;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user