Skip to content

Commit 05e2328

Browse files
[Detector]-Detector for tableau personal access token (#4261)
* add detector for tableau personal access token * add test for tableau detector * removed unnecessary checks * add cloud endpoint for tableau * cleanup: simplify map copying with maps.Copy * resolve comments * added correct detector type for tableau * updated tableau PAT key and corrected integration tests * resolved comments * updated test cases * resolved comments * fixed integration tests * removed redundant validation * resolved false positive issue * updated regex for pat-name * resolved comments * update regex for better token name extraction * simplify prefix regex for tableau pat name * merged main into origin/detector/tableau-personal-access-token --------- Co-authored-by: Amaan Ullah <[email protected]>
1 parent 98c36a4 commit 05e2328

File tree

7 files changed

+691
-7
lines changed

7 files changed

+691
-7
lines changed

pkg/detectors/tableau/tableau.go

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
package tableau
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"maps"
10+
"net/http"
11+
"strings"
12+
13+
regexp "github.com/wasilibs/go-re2"
14+
15+
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
16+
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
17+
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb"
18+
)
19+
20+
type Scanner struct {
21+
detectors.DefaultMultiPartCredentialProvider
22+
detectors.EndpointSetter
23+
client *http.Client
24+
}
25+
26+
// Ensure the Scanner satisfies the interface at compile time.
27+
var _ detectors.Detector = (*Scanner)(nil)
28+
var _ detectors.EndpointCustomizer = (*Scanner)(nil)
29+
30+
func (Scanner) CloudEndpoint() string { return "" }
31+
32+
var (
33+
defaultClient = common.SaneHttpClient()
34+
35+
// Simplified token name pattern using PrefixRegex
36+
tokenNamePat = regexp.MustCompile(detectors.PrefixRegex([]string{"pat", "token", "name", "tableau"}) + `([a-zA-Z][a-zA-Z0-9_-]{2,50})`)
37+
38+
// Pattern for Personal Access Token Secrets
39+
tokenSecretPat = regexp.MustCompile(`\b([A-Za-z0-9+/]{22}==:[A-Za-z0-9]{32})\b`)
40+
41+
// Simplified Tableau Server URLs pattern
42+
tableauURLPat = regexp.MustCompile(`\b([a-zA-Z0-9\-]+\.online\.tableau\.com)\b`)
43+
)
44+
45+
// Keywords are used for efficiently pre-filtering chunks.
46+
func (s Scanner) Keywords() []string {
47+
return []string{
48+
"tableau",
49+
"online.tableau.com",
50+
}
51+
}
52+
53+
func (s Scanner) getClient() *http.Client {
54+
if s.client != nil {
55+
return s.client
56+
}
57+
return defaultClient
58+
}
59+
60+
func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
61+
dataStr := string(data)
62+
63+
// Extract token names, secrets, and URLs separately
64+
tokenNames := extractTokenNames(dataStr)
65+
tokenSecrets := extractTokenSecrets(dataStr)
66+
foundURLs := extractTableauURLs(dataStr)
67+
68+
// If no names or secrets found, return empty results
69+
if len(tokenNames) == 0 || len(tokenSecrets) == 0 {
70+
return results, nil
71+
}
72+
73+
// Use maps to deduplicate endpoints
74+
var uniqueEndpoints = make(map[string]struct{})
75+
76+
// Add endpoints to the list
77+
for _, endpoint := range s.Endpoints(foundURLs...) {
78+
// Remove https:// prefix if present since we add it during verification
79+
endpoint = strings.TrimPrefix(endpoint, "https://")
80+
uniqueEndpoints[endpoint] = struct{}{}
81+
}
82+
83+
// Process each combination of token name, token secret, and endpoint
84+
for _, tokenName := range tokenNames {
85+
for _, tokenSecret := range tokenSecrets {
86+
for endpoint := range uniqueEndpoints {
87+
result := detectors.Result{
88+
DetectorType: detectorspb.DetectorType_TableauPersonalAccessToken,
89+
Raw: []byte(tokenName),
90+
RawV2: []byte(fmt.Sprintf("%s:%s:%s", tokenName, tokenSecret, endpoint)),
91+
ExtraData: make(map[string]string),
92+
}
93+
94+
if verify {
95+
client := s.getClient()
96+
isVerified, extraData, verificationErr := verifyTableauPAT(ctx, client, tokenName, tokenSecret, endpoint)
97+
result.Verified = isVerified
98+
maps.Copy(result.ExtraData, extraData)
99+
result.SetVerificationError(verificationErr, tokenName, tokenSecret, endpoint)
100+
}
101+
results = append(results, result)
102+
}
103+
}
104+
}
105+
106+
return results, nil
107+
}
108+
109+
// extractTokenNames finds all potential token names in the data
110+
func extractTokenNames(data string) []string {
111+
var names []string
112+
// Create a map of false positive terms
113+
falsePositives := map[detectors.FalsePositive]struct{}{
114+
detectors.FalsePositive("com"): {},
115+
}
116+
117+
for _, match := range tokenNamePat.FindAllStringSubmatch(data, -1) {
118+
if len(match) >= 2 {
119+
name := strings.TrimSpace(match[1])
120+
isFalsePositive, _ := detectors.IsKnownFalsePositive(name, falsePositives, false)
121+
if !isFalsePositive {
122+
names = append(names, name)
123+
}
124+
}
125+
}
126+
return names
127+
}
128+
129+
// extractTokenSecrets finds all potential token secrets in the data
130+
func extractTokenSecrets(data string) []string {
131+
var secrets []string
132+
for _, match := range tokenSecretPat.FindAllStringSubmatch(data, -1) {
133+
if len(match) >= 2 {
134+
secret := strings.TrimSpace(match[1])
135+
secrets = append(secrets, secret)
136+
}
137+
}
138+
return secrets
139+
}
140+
141+
// extractTableauURLs finds all potential Tableau server URLs in the data
142+
func extractTableauURLs(data string) []string {
143+
var urls []string
144+
145+
for _, match := range tableauURLPat.FindAllStringSubmatch(data, -1) {
146+
if len(match) >= 2 {
147+
url := strings.TrimSpace(match[1])
148+
if url != "" {
149+
urls = append(urls, url)
150+
}
151+
}
152+
}
153+
154+
return urls
155+
}
156+
157+
// TableauAuthRequest represents the authentication request structure
158+
type TableauAuthRequest struct {
159+
Credentials TableauCredentials `json:"credentials"`
160+
}
161+
162+
type TableauCredentials struct {
163+
PersonalAccessTokenName string `json:"personalAccessTokenName"`
164+
PersonalAccessTokenSecret string `json:"personalAccessTokenSecret"`
165+
Site interface{} `json:"site"`
166+
}
167+
168+
// TableauAuthResponse represents the authentication response structure
169+
type TableauAuthResponse struct {
170+
Credentials struct {
171+
Site struct {
172+
ID string `json:"id"`
173+
ContentURL string `json:"contentUrl"`
174+
} `json:"site"`
175+
User struct {
176+
ID string `json:"id"`
177+
} `json:"user"`
178+
Token string `json:"token"`
179+
} `json:"credentials"`
180+
}
181+
182+
// verifyTableauPAT verifies a Tableau Personal Access Token by attempting authentication
183+
func verifyTableauPAT(ctx context.Context, client *http.Client, tokenName, tokenSecret, endpoint string) (bool, map[string]string, error) {
184+
// Build the verification URL
185+
verifyURL := fmt.Sprintf("https://%s/api/3.26/auth/signin", endpoint)
186+
187+
// Prepare metadata early - before any potential errors
188+
extraData := map[string]string{
189+
"verification_endpoint": verifyURL,
190+
"verification_method": "tableau_pat_auth",
191+
"tableau_endpoint": endpoint,
192+
}
193+
194+
// Rest of your verification logic...
195+
authReq := TableauAuthRequest{
196+
Credentials: TableauCredentials{
197+
PersonalAccessTokenName: tokenName,
198+
PersonalAccessTokenSecret: tokenSecret,
199+
Site: map[string]interface{}{},
200+
},
201+
}
202+
203+
// Marshal to JSON
204+
jsonData, err := json.Marshal(authReq)
205+
if err != nil {
206+
return false, nil, fmt.Errorf("failed to marshal auth request: %v", err)
207+
}
208+
209+
// Create HTTP request
210+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, verifyURL, bytes.NewBuffer(jsonData))
211+
if err != nil {
212+
return false, nil, fmt.Errorf("failed to create request: %v", err)
213+
}
214+
req.Header.Set("Content-Type", "application/json")
215+
req.Header.Set("Accept", "application/json")
216+
217+
// Execute request
218+
resp, err := client.Do(req)
219+
if err != nil {
220+
// Check if it's a DNS/network error
221+
if strings.Contains(err.Error(), "no such host") ||
222+
strings.Contains(err.Error(), "dial tcp") ||
223+
strings.Contains(err.Error(), "connection refused") {
224+
extraData["network_error"] = "true"
225+
return false, extraData, nil // No error, just invalid endpoint
226+
}
227+
return false, nil, fmt.Errorf("request failed: %v", err)
228+
}
229+
defer func() {
230+
_, _ = io.Copy(io.Discard, resp.Body)
231+
_ = resp.Body.Close()
232+
}()
233+
234+
// Read the response
235+
bodyBytes, err := io.ReadAll(resp.Body)
236+
if err != nil {
237+
return false, nil, fmt.Errorf("failed to read response body: %v", err)
238+
}
239+
240+
// Status code handling...
241+
switch resp.StatusCode {
242+
case http.StatusOK:
243+
var authResp TableauAuthResponse
244+
if err := json.Unmarshal(bodyBytes, &authResp); err != nil {
245+
return true, extraData, err
246+
}
247+
return true, extraData, nil
248+
249+
case http.StatusUnauthorized, http.StatusBadRequest, http.StatusForbidden:
250+
return false, extraData, nil
251+
252+
default:
253+
return false, extraData, fmt.Errorf("unexpected HTTP response status %d", resp.StatusCode)
254+
}
255+
}
256+
257+
func (s Scanner) Type() detectorspb.DetectorType {
258+
return detectorspb.DetectorType_TableauPersonalAccessToken
259+
}
260+
261+
func (s Scanner) Description() string {
262+
return "Tableau is a data visualization and business intelligence platform. Personal Access Tokens (PATs) provide programmatic access to Tableau Server/Online APIs and can be used to authenticate applications and automate workflows."
263+
}

0 commit comments

Comments
 (0)