Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ import (

type Scanner struct{}

// Ensure the Scanner satisfies the interface at compile time.
// Ensure the Scanner satisfies the interfaces at compile time.
var _ detectors.Detector = (*Scanner)(nil)
var _ detectors.Versioner = (*Scanner)(nil)

func (Scanner) Version() int { return 1 }

var (
client = common.SaneHttpClient()
Expand Down Expand Up @@ -45,19 +48,7 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
}

if verify {
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.cloudflare.com/client/v4/user/tokens/verify", nil)
if err != nil {
continue
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", resMatch))
res, err := client.Do(req)
if err == nil {
defer res.Body.Close()
if res.StatusCode >= 200 && res.StatusCode < 300 {
s1.Verified = true
}
}
s1.Verified = VerifyUserToken(ctx, client, resMatch)
}

results = append(results, s1)
Expand All @@ -66,6 +57,22 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
return results, nil
}

// VerifyUserToken checks if a Cloudflare user API token is valid.
func VerifyUserToken(ctx context.Context, client *http.Client, token string) bool {
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.cloudflare.com/client/v4/user/tokens/verify", nil)
if err != nil {
return false
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
res, err := client.Do(req)
if err != nil {
return false
}
defer res.Body.Close()
return res.StatusCode >= 200 && res.StatusCode < 300
}

func (s Scanner) Type() detector_typepb.DetectorType {
return detector_typepb.DetectorType_CloudflareApiToken
}
Expand Down
117 changes: 117 additions & 0 deletions pkg/detectors/cloudflareapitoken/v2/cloudflareapitoken.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package cloudflareapitoken

import (
"context"
"fmt"
"net/http"
"strings"

regexp "github.com/wasilibs/go-re2"

"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
v1 "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/cloudflareapitoken/v1"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
)

type Scanner struct {
v1.Scanner
}

// Ensure the Scanner satisfies the interfaces at compile time.
var _ detectors.Detector = (*Scanner)(nil)
var _ detectors.Versioner = (*Scanner)(nil)

func (Scanner) Version() int { return 2 }

var (
client = common.SaneHttpClient()

// 2026+ formats: cfut_ (user token) and cfat_ (account token), self-identifying.
keyV2Pat = regexp.MustCompile(`\b(cf[ua]t_[a-zA-Z0-9]{40}[a-f0-9]{8})\b`)
// Cloudflare account ID pattern for cfat_ token verification.
accountIDPat = regexp.MustCompile(`\b([a-f0-9]{32})\b`)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Broad account ID pattern may cause excessive verification requests

Medium Severity

The accountIDPat regex \b([a-f0-9]{32})\b matches any standalone 32-character hex string in the data chunk — including MD5 hashes, dashless UUIDs, and other hex values that aren't Cloudflare account IDs. When verify=true and a cfat_ token is found, a separate HTTP verification request is issued to Cloudflare's API for each candidate account ID. In data chunks rich in hex strings (e.g., files listing checksums), this could produce a large number of unnecessary outbound API calls, slowing down scanning and risking rate-limiting by Cloudflare.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f32604a. Configure here.

)

// Keywords are used for efficiently pre-filtering chunks.
func (s Scanner) Keywords() []string {
return []string{"cfut_", "cfat_"}
}

// FromData will find and optionally verify CloudflareApiToken secrets in a given set of bytes.
func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
dataStr := string(data)

matches := keyV2Pat.FindAllStringSubmatch(dataStr, -1)

// Extract account IDs from surrounding data for cfat_ verification.
uniqueAccountIDs := make(map[string]struct{})
for _, match := range accountIDPat.FindAllStringSubmatch(dataStr, -1) {
uniqueAccountIDs[match[1]] = struct{}{}
}

for _, match := range matches {
resMatch := strings.TrimSpace(match[1])

if verify {
if strings.HasPrefix(resMatch, "cfat_") {
// Account tokens require per-account verification.
for accountID := range uniqueAccountIDs {
s1 := detectors.Result{
DetectorType: detector_typepb.DetectorType_CloudflareApiToken,
Raw: []byte(resMatch),
RawV2: []byte(resMatch + accountID),
}
s1.Verified = verifyAccountToken(ctx, resMatch, accountID)
results = append(results, s1)
}
if len(uniqueAccountIDs) == 0 {
// No account ID found; still report the token unverified.
results = append(results, detectors.Result{
DetectorType: detector_typepb.DetectorType_CloudflareApiToken,
Raw: []byte(resMatch),
})
}
} else {
// cfut_ tokens use the user token verification endpoint.
s1 := detectors.Result{
DetectorType: detector_typepb.DetectorType_CloudflareApiToken,
Raw: []byte(resMatch),
}
s1.Verified = v1.VerifyUserToken(ctx, client, resMatch)
results = append(results, s1)
}
} else {
results = append(results, detectors.Result{
DetectorType: detector_typepb.DetectorType_CloudflareApiToken,
Raw: []byte(resMatch),
})
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Account ID pairing skipped when verify is false

Medium Severity

When verify is false, cfat_ tokens produce a single result without any RawV2 (account ID pairing), but when verify is true, the same token produces N results each with a distinct RawV2 containing the account ID. This is inconsistent with cloudflareglobalapikey/v2, which pairs keys with emails regardless of the verify flag. Because RawV2 is typically used for deduplication, this means cfat_ tokens scanned without verification lose their account-scoping context entirely.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4329719. Configure here.

}

return results, nil
}

func verifyAccountToken(ctx context.Context, token, accountID string) bool {
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/tokens/verify", accountID)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return false
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
res, err := client.Do(req)
if err != nil {
return false
}
defer res.Body.Close()
return res.StatusCode >= 200 && res.StatusCode < 300
}

func (s Scanner) Type() detector_typepb.DetectorType {
return detector_typepb.DetectorType_CloudflareApiToken
}

func (s Scanner) Description() string {
return "Cloudflare is a web infrastructure and website security company. Cloudflare API tokens (cfut_/cfat_ prefixed, 2026+ format) can be used to manage and interact with Cloudflare services."
}
80 changes: 80 additions & 0 deletions pkg/detectors/cloudflareapitoken/v2/cloudflareapitoken_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package cloudflareapitoken

import (
"context"
"testing"

"github.com/google/go-cmp/cmp"

"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick"
)

func TestCloudFlareAPITokenV2_Pattern(t *testing.T) {
d := Scanner{}
ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d})

tests := []struct {
name string
input string
want []string
}{
{
name: "valid v2 user token - no keyword proximity needed",
input: "token: cfut_ZE4CrcFhEIDXk9vL2sTLeARsFp2ZZYbydVDhhIUq8573bbfe",
want: []string{"cfut_ZE4CrcFhEIDXk9vL2sTLeARsFp2ZZYbydVDhhIUq8573bbfe"},
},
{
name: "valid v2 account token - no keyword proximity needed",
input: "token: cfat_ZE4CrcFhEIDXk9vL2sTLeARsFp2ZZYbydVDhhIUq8573bbfe",
want: []string{"cfat_ZE4CrcFhEIDXk9vL2sTLeARsFp2ZZYbydVDhhIUq8573bbfe"},
},
{
name: "no match for legacy format",
input: "cfut_: kOjD1yceduu2jxL2uuwT9dkOIudU3_54sLCEud6j",
want: nil,
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
matchedDetectors := ahoCorasickCore.FindDetectorMatches([]byte(test.input))
if len(matchedDetectors) == 0 && test.want != nil {
t.Errorf("keywords '%v' not matched by: %s", d.Keywords(), test.input)
return
}

results, err := d.FromData(context.Background(), false, []byte(test.input))
if err != nil {
t.Errorf("error = %v", err)
return
}

if len(results) != len(test.want) {
if len(results) == 0 {
t.Errorf("did not receive result")
} else {
t.Errorf("expected %d results, only received %d", len(test.want), len(results))
}
return
}

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)
}
})
}
}
5 changes: 4 additions & 1 deletion pkg/detectors/cloudflarecakey/cloudflarecakey.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ var _ detectors.Detector = (*Scanner)(nil)
var (
client = common.SaneHttpClient()

// origin ca keys documentation: https://developers.cloudflare.com/fundamentals/api/get-started/ca-keys/
// Origin CA keys (aka "Service Keys") are deprecated as of 2026-03-19:
// https://developers.cloudflare.com/changelog/post/2026-03-19-service-key-authentication-deprecated/
//
// Reference: https://developers.cloudflare.com/fundamentals/api/get-started/ca-keys/
keyPat = regexp.MustCompile(`\b(v1\.0-[A-Za-z0-9-]{171})\b`)
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@ type Scanner struct {
detectors.DefaultMultiPartCredentialProvider
}

// Ensure the Scanner satisfies the interface at compile time.
// Ensure the Scanner satisfies the interfaces at compile time.
var _ detectors.Detector = (*Scanner)(nil)
var _ detectors.Versioner = (*Scanner)(nil)

func (Scanner) Version() int { return 1 }

var (
client = common.SaneHttpClient()

apiKeyPat = regexp.MustCompile(detectors.PrefixRegex([]string{"cloudflare"}) + `\b([A-Za-z0-9_-]{37})\b`)
// Pre-2026 format: lowercase hex, 37-45 chars, requires "cloudflare" keyword nearby.
apiKeyPat = regexp.MustCompile(detectors.PrefixRegex([]string{"cloudflare"}) + `\b([a-f0-9]{37,45})\b`)

emailPat = regexp.MustCompile(common.EmailPattern)
)
Expand Down Expand Up @@ -56,21 +60,7 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
}

if verify {
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.cloudflare.com/client/v4/user", nil)
if err != nil {
continue
}
req.Header.Add("X-Auth-Email", emailMatch)
req.Header.Add("X-Auth-Key", apiKeyRes)
req.Header.Add("Content-Type", "application/json")

res, err := client.Do(req)
if err == nil {
defer res.Body.Close()
if res.StatusCode >= 200 && res.StatusCode < 300 {
s1.Verified = true
}
}
s1.Verified = VerifyGlobalAPIKey(ctx, client, apiKeyRes, emailMatch)
}

results = append(results, s1)
Expand All @@ -80,6 +70,24 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
return results, nil
}

// VerifyGlobalAPIKey checks if a Cloudflare Global API Key is valid.
func VerifyGlobalAPIKey(ctx context.Context, client *http.Client, apiKey, email string) bool {
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.cloudflare.com/client/v4/user", nil)
if err != nil {
return false
}
req.Header.Add("X-Auth-Email", email)
req.Header.Add("X-Auth-Key", apiKey)
req.Header.Add("Content-Type", "application/json")

res, err := client.Do(req)
if err != nil {
return false
}
defer res.Body.Close()
return res.StatusCode >= 200 && res.StatusCode < 300
}

func (s Scanner) Type() detector_typepb.DetectorType {
return detector_typepb.DetectorType_CloudflareGlobalApiKey
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import (
)

var (
validPattern = "abcD123efg456HIJklmn789OPQ_rstUVWxYZ-012 / testuser1005@example.com"
invalidPattern = "abcD123efg456HIJklmn789OPQ_rstUVWxYZ-012/testing@go"
validPattern = "abcdef1234567890abcdef1234567890abcdef0 / testuser1005@example.com"
invalidPattern = "abcdef1234567890abcdef1234567890abcdef0/testing@go"
)

func TestCloudFlareGlobalAPIKey_Pattern(t *testing.T) {
Expand All @@ -28,7 +28,7 @@ func TestCloudFlareGlobalAPIKey_Pattern(t *testing.T) {
{
name: "valid pattern",
input: fmt.Sprintf("cloudflare: %s", validPattern),
want: []string{"abcD123efg456HIJklmn789OPQ_rstUVWxYZ-testuser1005@example.com"},
want: []string{"abcdef1234567890abcdef1234567890abcdef0testuser1005@example.com"},
},
{
name: "valid pattern - key out of prefix range",
Expand Down
Loading