|
| 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