Update Cloudflare detectors for 2026+ prefixed credential formats (include upstream PR changes) (#5111)

* Update Cloudflare detectors for 2026+ prefixed credential formats

 ## Context/Background

Cloudflare is rolling out new prefixed credential formats in 2026.
The new formats (`cfk_`, `cfut_`, `cfat_`) are self-identifying via
prefix and do not need keyword proximity matching. Per project
convention, new token formats are added via the `Versioner` interface
with a `v1`/`v2` directory split.

Additionally, CA keys (Service Keys) are now deprecated.

 ## Changes in this commit

- cloudflareglobalapikey: split into `v1`/`v2` with `Versioner`
  interface. v1 fixes legacy regex to `[a-f0-9]{37,45}` (lowercase
  hex). v2 adds `cfk_` prefixed format detection.
- cloudflareapitoken: split into `v1`/`v2` with `Versioner`
  interface. v2 adds `cfut_`/`cfat_` prefixed format detection.
  `cfat_` (account tokens) route verification through the
  account-scoped `/accounts/:id/tokens/verify` endpoint, extracting
  account IDs from surrounding data.
- cloudflarecakey: add deprecation notice with changelog link.
- defaults.go: updated imports and registrations for versioned
  scanners.

* Address PR feedback on Cloudflare detectors

 ## Context/Background

Addressing reviewer feedback from @MuneebUllahKhan222 and Cursor
Bugbot on PR #4830.

 ## Changes in this commit

- Verification functions now return `(bool, error)` with proper
  indeterminate/determinate handling. Callsites use
  `SetVerificationError`. Status checks use exact `http.StatusOK`
  instead of range. Response bodies properly drained via
  `io.Copy(io.Discard, ...)`.
- Extracted shared verification functions into `common.go` at each
  detector package root (following the AWS detector pattern). v1 and
  v2 both import from the parent package instead of v2 importing v1.
- Added integration tests for both v2 detectors with coverage for
  `cfk_`, `cfut_`, and `cfat_` (including account-scoped
  verification).
- `cfat_` tokens now pair with account IDs in `RawV2` regardless of
  the `verify` flag, consistent with how `cfk_` pairs with emails.

* Resolved Comments

---------

Co-authored-by: Nicholas Comer <[email protected]>
This commit is contained in:
Kashif Khan
2026-07-15 11:14:06 +05:00
committed by GitHub
co-authored by Nicholas Comer
parent 3cbcd39c7e
commit e1f9e5d1d5
18 changed files with 974 additions and 42 deletions
+2
View File
@@ -560,6 +560,8 @@ func run(state overseer.State, logSync func() error) {
feature.LobDetectorEnabled.Store(true)
feature.HashiCorpVaultBatchTokenDetectorEnabled.Store(true)
feature.HashiCorpVaultTokenDetectorEnabled.Store(true)
feature.CloudflareApiTokenV2DetectorEnabled.Store(true)
feature.CloudflareGlobalApiKeyV2DetectorEnabled.Store(true)
conf := &config.Config{}
if *configFilename != "" {
@@ -0,0 +1,68 @@
package cloudflareapitoken
import (
"context"
"fmt"
"io"
"net/http"
)
// VerifyUserToken checks if a Cloudflare user API token is valid.
// Returns (true, nil) if verified, (false, nil) for determinate auth
// failures, and (false, err) for indeterminate failures.
func VerifyUserToken(ctx context.Context, client *http.Client, token string) (bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.cloudflare.com/client/v4/user/tokens/verify", nil)
if err != nil {
return false, err
}
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, err
}
defer func() {
_, _ = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
}()
switch res.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusUnauthorized, http.StatusForbidden:
return false, nil
default:
return false, fmt.Errorf("unexpected status code: %d", res.StatusCode)
}
}
// VerifyAccountToken checks if a Cloudflare account API token is
// valid for the given account ID. Returns (true, nil) if verified,
// (false, nil) for determinate auth failures, and (false, err) for
// indeterminate failures.
func VerifyAccountToken(ctx context.Context, client *http.Client, token, accountID string) (bool, error) {
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/tokens/verify", accountID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return false, err
}
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, err
}
defer func() {
_, _ = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
}()
switch res.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusUnauthorized, http.StatusForbidden:
return false, nil
default:
return false, fmt.Errorf("unexpected status code: %d", res.StatusCode)
}
}
@@ -2,21 +2,23 @@ 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"
cfapitoken "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/cloudflareapitoken"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
)
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()
@@ -46,19 +48,9 @@ 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 func() { _ = res.Body.Close() }()
if res.StatusCode >= 200 && res.StatusCode < 300 {
s1.Verified = true
}
}
isVerified, verificationErr := cfapitoken.VerifyUserToken(ctx, client, resMatch)
s1.Verified = isVerified
s1.SetVerificationError(verificationErr, resMatch)
}
results = append(results, s1)
@@ -0,0 +1,107 @@
package cloudflareapitoken
import (
"context"
"strings"
regexp "github.com/wasilibs/go-re2"
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
cfapitoken "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/cloudflareapitoken"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
)
type Scanner struct {
detectors.DefaultMultiPartCredentialProvider
}
// 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.
keyPat = 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`)
)
// 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 := keyPat.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 strings.HasPrefix(resMatch, "cfat_") {
// Account tokens: pair with each nearby account ID.
if len(uniqueAccountIDs) == 0 {
// No account ID found; still report the token.
results = append(results, detectors.Result{
DetectorType: detector_typepb.DetectorType_CloudflareApiToken,
Raw: []byte(resMatch),
SecretParts: map[string]string{"key": resMatch},
})
continue
}
for accountID := range uniqueAccountIDs {
s1 := detectors.Result{
DetectorType: detector_typepb.DetectorType_CloudflareApiToken,
Raw: []byte(resMatch),
RawV2: []byte(resMatch + accountID),
SecretParts: map[string]string{
"key": resMatch,
"account_id": accountID,
},
}
if verify {
isVerified, verificationErr := cfapitoken.VerifyAccountToken(ctx, client, resMatch, accountID)
s1.Verified = isVerified
s1.SetVerificationError(verificationErr, resMatch)
}
results = append(results, s1)
}
} else {
// cfut_ tokens use the user token verification endpoint.
s1 := detectors.Result{
DetectorType: detector_typepb.DetectorType_CloudflareApiToken,
Raw: []byte(resMatch),
SecretParts: map[string]string{"key": resMatch},
}
if verify {
isVerified, verificationErr := cfapitoken.VerifyUserToken(ctx, client, resMatch)
s1.Verified = isVerified
s1.SetVerificationError(verificationErr, resMatch)
}
results = append(results, s1)
}
}
return results, nil
}
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."
}
@@ -0,0 +1,157 @@
//go:build detectors
// +build detectors
package cloudflareapitoken
import (
"context"
"fmt"
"testing"
"time"
"github.com/kylelemons/godebug/pretty"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
)
func TestCloudflareApiTokenV2_FromChunk(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors6")
if err != nil {
t.Fatalf("could not get test secrets from GCP: %s", err)
}
userToken := testSecrets.MustGetField("CLOUDFLARE_USER_API_TOKEN_V2")
userTokenInactive := testSecrets.MustGetField("CLOUDFLARE_USER_API_TOKEN_V2_INACTIVE")
accountToken := testSecrets.MustGetField("CLOUDFLARE_ACCOUNT_API_TOKEN_V2")
accountID := testSecrets.MustGetField("CLOUDFLARE_ACCOUNT_API_TOKEN_V2_ACCOUNT_ID")
accountTokenInactive := testSecrets.MustGetField("CLOUDFLARE_ACCOUNT_API_TOKEN_V2_INACTIVE")
type args struct {
ctx context.Context
data []byte
verify bool
}
tests := []struct {
name string
s Scanner
args args
want []detectors.Result
wantErr bool
}{
{
name: "cfut_ found, verified",
s: Scanner{},
args: args{
ctx: context.Background(),
data: []byte(fmt.Sprintf("cloudflare token %s", userToken)),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detector_typepb.DetectorType_CloudflareApiToken,
Verified: true,
},
},
wantErr: false,
},
{
name: "cfut_ found, unverified",
s: Scanner{},
args: args{
ctx: context.Background(),
data: []byte(fmt.Sprintf("cloudflare token %s", userTokenInactive)),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detector_typepb.DetectorType_CloudflareApiToken,
Verified: false,
},
},
wantErr: false,
},
{
name: "cfat_ found, verified",
s: Scanner{},
args: args{
ctx: context.Background(),
data: []byte(fmt.Sprintf("cloudflare token %s account %s", accountToken, accountID)),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detector_typepb.DetectorType_CloudflareApiToken,
Verified: true,
},
},
wantErr: false,
},
{
name: "cfat_ found, unverified",
s: Scanner{},
args: args{
ctx: context.Background(),
data: []byte(fmt.Sprintf("cloudflare token %s account %s", accountTokenInactive, accountID)),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detector_typepb.DetectorType_CloudflareApiToken,
Verified: false,
},
},
wantErr: false,
},
{
name: "not found",
s: Scanner{},
args: args{
ctx: context.Background(),
data: []byte("You cannot find the secret within"),
verify: true,
},
want: nil,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := Scanner{}
got, err := s.FromData(tt.args.ctx, tt.args.verify, tt.args.data)
if (err != nil) != tt.wantErr {
t.Errorf("CloudflareApiTokenV2.FromData() error = %v, wantErr %v", err, tt.wantErr)
return
}
for i := range got {
if len(got[i].Raw) == 0 {
t.Fatalf("no raw secret present: \n %+v", got[i])
}
got[i].Raw = nil
got[i].RawV2 = nil
}
if diff := pretty.Compare(got, tt.want); diff != "" {
t.Errorf("CloudflareApiTokenV2.FromData() %s diff: (-got +want)\n%s", tt.name, diff)
}
})
}
}
func BenchmarkFromData(benchmark *testing.B) {
ctx := context.Background()
s := Scanner{}
for name, data := range detectors.MustGetBenchmarkData() {
benchmark.Run(name, func(b *testing.B) {
b.ResetTimer()
for n := 0; n < b.N; n++ {
_, err := s.FromData(ctx, false, data)
if err != nil {
b.Fatal(err)
}
}
})
}
}
@@ -0,0 +1,180 @@
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 - go http client",
input: `
func setupCloudflareClient() (*http.Client, error) {
req, err := http.NewRequest("GET", "https://api.cloudflare.com/client/v4/user", http.NoBody)
if err != nil {
return nil, err
}
// Rotated to the new self-identifying user token format during the March migration.
req.Header.Set("Authorization", "Bearer cfut_ZE4CrcFhEIDXk9vL2sTLeARsFp2ZZYbydVDhhIUq8573bbfe")
client := &http.Client{}
resp, _ := client.Do(req)
defer func() { _ = resp.Body.Close() }()
return client, nil
}
`,
want: []string{
"cfut_ZE4CrcFhEIDXk9vL2sTLeARsFp2ZZYbydVDhhIUq8573bbfe",
},
},
{
name: "valid v2 account token - wrangler config",
input: `
# wrangler.toml
name = "edge-worker"
main = "src/index.js"
compatibility_date = "2026-01-15"
[env.production]
account_id = "a4c123b1612dd272d1371c17149d4395"
# CF_API_TOKEN used by the deploy pipeline
CF_API_TOKEN = "cfat_OhbVrpoiVgRV5IfLBcbfnoGMbJmTPSIAoCLrZ3aW5da846a3"
`,
want: []string{
"cfat_OhbVrpoiVgRV5IfLBcbfnoGMbJmTPSIAoCLrZ3aW5da846a3a4c123b1612dd272d1371c17149d4395",
},
},
{
name: "no match for legacy format",
input: `
# .env.legacy
# Pre-2026 tokens don't carry the cfut_/cfat_ prefix and shouldn't match the new pattern.
CLOUDFLARE_API_TOKEN=kOjD1yceduu2jxL2uuwT9dkOIudU3_54sLCEud6j
`,
want: nil,
},
{
name: "valid pattern - key rotation script with multiple tokens",
input: `
#!/usr/bin/env bash
# rotate-cf-tokens.sh - retires the old worker token and provisions a replacement
set -euo pipefail
echo "Retiring old token..."
OLD_TOKEN="cfut_fygw2wMqZcUDIh7yfJs1ON43xKmTecQoXsf2o3gy8eb5bb68"
echo "Provisioning new token..."
NEW_TOKEN="cfut_S7RPeMOkIUpkDyr7OSJoRu1XXdo0cZuzren68K4Ta6fce484"
curl -s -X DELETE "https://api.cloudflare.com/client/v4/user/tokens/verify" \
-H "Authorization: Bearer ${OLD_TOKEN}"
curl -s -X PUT "https://api.cloudflare.com/client/v4/user/tokens/verify" \
-H "Authorization: Bearer ${NEW_TOKEN}"
`,
want: []string{
"cfut_fygw2wMqZcUDIh7yfJs1ON43xKmTecQoXsf2o3gy8eb5bb68",
"cfut_S7RPeMOkIUpkDyr7OSJoRu1XXdo0cZuzren68K4Ta6fce484",
},
},
{
name: "invalid pattern - too short",
input: `
func setupCloudflareClient() {
// Truncated token accidentally committed during a config export
token := "cfut_ZE4CrcFhEIDXk9vL2sTLe"
client.SetToken(token)
}
`,
want: nil,
},
{
name: "invalid pattern - too long",
input: `
func setupCloudflareClient() {
// Extra characters appended by a bad find-and-replace
token := "cfut_ZE4CrcFhEIDXk9vL2sTLeARsFp2ZZYbydVDhhIUq8573bbfeEXTRA"
client.SetToken(token)
}
`,
want: nil,
},
{
name: "invalid pattern - invalid characters",
input: `
func setupCloudflareClient() {
// Token was mangled when pasted from a Slack message with markdown formatting
token := "cfut_ZE4CrcFhEIDXk9vL2sTLeARsFp2ZZYbydVDhhIUq8573bbf!"
client.SetToken(token)
}
`,
want: nil,
},
{
name: "invalid pattern - keyword only",
input: `
// TODO: load the real token instead of reading cfut_ from env
func isCloudflareUserToken(s string) bool {
return strings.HasPrefix(s, "cfut_")
}
`,
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)
}
})
}
}
@@ -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`)
)
@@ -0,0 +1,39 @@
package cloudflareglobalapikey
import (
"context"
"fmt"
"io"
"net/http"
)
// VerifyGlobalAPIKey checks if a Cloudflare Global API Key is valid.
// Returns (true, nil) if verified, (false, nil) for determinate auth
// failures, and (false, err) for indeterminate failures.
func VerifyGlobalAPIKey(ctx context.Context, client *http.Client, apiKey, email string) (bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.cloudflare.com/client/v4/user", nil)
if err != nil {
return false, err
}
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, err
}
defer func() {
_, _ = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
}()
switch res.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusUnauthorized, http.StatusForbidden:
return false, nil
default:
return false, fmt.Errorf("unexpected status code: %d", res.StatusCode)
}
}
@@ -2,13 +2,13 @@ package cloudflareglobalapikey
import (
"context"
"net/http"
"strings"
regexp "github.com/wasilibs/go-re2"
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
cfglobalapikey "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/cloudflareglobalapikey"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
)
@@ -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)
)
@@ -60,21 +64,9 @@ 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 func() { _ = res.Body.Close() }()
if res.StatusCode >= 200 && res.StatusCode < 300 {
s1.Verified = true
}
}
isVerified, verificationErr := cfglobalapikey.VerifyGlobalAPIKey(ctx, client, apiKeyRes, emailMatch)
s1.Verified = isVerified
s1.SetVerificationError(verificationErr, apiKeyRes)
}
results = append(results, s1)
@@ -12,8 +12,8 @@ import (
)
var (
validPattern = "abcD123efg456HIJklmn789OPQ_rstUVWxYZ-012 / [email protected]"
invalidPattern = "abcD123efg456HIJklmn789OPQ_rstUVWxYZ-012/testing@go"
validPattern = "abcdef1234567890abcdef1234567890abcdef0 / [email protected]"
invalidPattern = "abcdef1234567890abcdef1234567890abcdef0/testing@go"
)
func TestCloudFlareGlobalAPIKey_Pattern(t *testing.T) {
@@ -28,7 +28,7 @@ func TestCloudFlareGlobalAPIKey_Pattern(t *testing.T) {
{
name: "valid pattern",
input: fmt.Sprintf("cloudflare: %s", validPattern),
want: []string{"abcD123efg456HIJklmn789OPQ_rstUVWxYZ-[email protected]"},
want: []string{"abcdef1234567890abcdef1234567890abcdef0[email protected]"},
},
{
name: "valid pattern - key out of prefix range",
@@ -0,0 +1,94 @@
package cloudflareglobalapikey
import (
"context"
"strings"
regexp "github.com/wasilibs/go-re2"
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
cfglobalapikey "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/cloudflareglobalapikey"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
)
type Scanner struct {
detectors.DefaultMultiPartCredentialProvider
}
// 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+ format: cfk_ prefix, sufficiently unique to match without keyword proximity.
apiKeyPat = regexp.MustCompile(`\b(cfk_[a-zA-Z0-9]{40}[a-f0-9]{8})\b`)
emailPat = regexp.MustCompile(common.EmailPattern)
)
// Keywords are used for efficiently pre-filtering chunks.
func (s Scanner) Keywords() []string {
return []string{"cfk_"}
}
// FromData will find and optionally verify CloudflareGlobalApiKey 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)
apiKeyMatches := apiKeyPat.FindAllStringSubmatch(dataStr, -1)
uniqueEmailMatches := make(map[string]struct{})
for _, match := range emailPat.FindAllStringSubmatch(dataStr, -1) {
uniqueEmailMatches[strings.TrimSpace(match[1])] = struct{}{}
}
for _, apiKeyMatch := range apiKeyMatches {
apiKeyRes := strings.TrimSpace(apiKeyMatch[1])
if len(uniqueEmailMatches) == 0 {
// No email found; still report the token unverified.
results = append(results, detectors.Result{
DetectorType: detector_typepb.DetectorType_CloudflareGlobalApiKey,
Raw: []byte(apiKeyRes),
SecretParts: map[string]string{"key": apiKeyRes},
})
continue
}
for emailMatch := range uniqueEmailMatches {
s1 := detectors.Result{
DetectorType: detector_typepb.DetectorType_CloudflareGlobalApiKey,
Redacted: emailMatch,
Raw: []byte(apiKeyRes),
RawV2: []byte(apiKeyRes + emailMatch),
SecretParts: map[string]string{
"key": apiKeyRes,
"email": emailMatch,
},
}
if verify {
isVerified, verificationErr := cfglobalapikey.VerifyGlobalAPIKey(ctx, client, apiKeyRes, emailMatch)
s1.Verified = isVerified
s1.SetVerificationError(verificationErr, apiKeyRes)
}
results = append(results, s1)
}
}
return results, nil
}
func (s Scanner) Type() detector_typepb.DetectorType {
return detector_typepb.DetectorType_CloudflareGlobalApiKey
}
func (s Scanner) Description() string {
return "Cloudflare is a web infrastructure and website security company. Cloudflare API keys (cfk_ prefixed, 2026+ format) can be used to access and modify these services."
}
@@ -0,0 +1,125 @@
//go:build detectors
// +build detectors
package cloudflareglobalapikey
import (
"context"
"fmt"
"testing"
"time"
"github.com/kylelemons/godebug/pretty"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
)
func TestCloudflareGlobalApiKeyV2_FromChunk(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors6")
if err != nil {
t.Fatalf("could not get test secrets from GCP: %s", err)
}
globalApiKey := testSecrets.MustGetField("CLOUDFLARE_GLOBAL_API_KEY_V2")
globalApiKeyEmail := testSecrets.MustGetField("CLOUDFLARE_GLOBAL_API_KEY_V2_EMAIL")
inactiveGlobalApiKey := testSecrets.MustGetField("CLOUDFLARE_GLOBAL_API_KEY_V2_INACTIVE")
type args struct {
ctx context.Context
data []byte
verify bool
}
tests := []struct {
name string
s Scanner
args args
want []detectors.Result
wantErr bool
}{
{
name: "found, verified",
s: Scanner{},
args: args{
ctx: context.Background(),
data: []byte(fmt.Sprintf("cloudflare key %s with email %s", globalApiKey, globalApiKeyEmail)),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detector_typepb.DetectorType_CloudflareGlobalApiKey,
Redacted: globalApiKeyEmail,
Verified: true,
},
},
wantErr: false,
},
{
name: "found, unverified",
s: Scanner{},
args: args{
ctx: context.Background(),
data: []byte(fmt.Sprintf("cloudflare key %s with email %s", inactiveGlobalApiKey, globalApiKeyEmail)),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detector_typepb.DetectorType_CloudflareGlobalApiKey,
Redacted: globalApiKeyEmail,
Verified: false,
},
},
wantErr: false,
},
{
name: "not found",
s: Scanner{},
args: args{
ctx: context.Background(),
data: []byte("You cannot find the secret within"),
verify: true,
},
want: nil,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := Scanner{}
got, err := s.FromData(tt.args.ctx, tt.args.verify, tt.args.data)
if (err != nil) != tt.wantErr {
t.Errorf("CloudflareGlobalApiKeyV2.FromData() error = %v, wantErr %v", err, tt.wantErr)
return
}
for i := range got {
if len(got[i].Raw) == 0 {
t.Fatalf("no raw secret present: \n %+v", got[i])
}
got[i].Raw = nil
got[i].RawV2 = nil
}
if diff := pretty.Compare(got, tt.want); diff != "" {
t.Errorf("CloudflareGlobalApiKeyV2.FromData() %s diff: (-got +want)\n%s", tt.name, diff)
}
})
}
}
func BenchmarkFromData(benchmark *testing.B) {
ctx := context.Background()
s := Scanner{}
for name, data := range detectors.MustGetBenchmarkData() {
benchmark.Run(name, func(b *testing.B) {
b.ResetTimer()
for n := 0; n < b.N; n++ {
_, err := s.FromData(ctx, false, data)
if err != nil {
b.Fatal(err)
}
}
})
}
}
@@ -0,0 +1,163 @@
package cloudflareglobalapikey
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 TestCloudFlareGlobalAPIKeyV2_Pattern(t *testing.T) {
d := Scanner{}
ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d})
tests := []struct {
name string
input string
want []string
}{
{
name: "valid v2 pattern - curl script with account email",
input: `
#!/usr/bin/env bash
# purge-cache.sh - flushes the edge cache for the production zone
set -euo pipefail
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
-H "X-Auth-Email: [email protected]" \
-H "X-Auth-Key: cfk_ZE4CrcFhEIDXk9vL2sTLeARsFp2ZZYbydVDhhIUq8573bbfe" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}'
`,
want: []string{
"cfk_ZE4CrcFhEIDXk9vL2sTLeARsFp2ZZYbydVDhhIUq8573bbfetestuser1005@example.com",
},
},
{
name: "valid v2 pattern - env file, no email nearby still emits result",
input: `
# .env.production
APP_NAME=edge-cache-worker
CLOUDFLARE_API_KEY=cfk_ZE4CrcFhEIDXk9vL2sTLeARsFp2ZZYbydVDhhIUq8573bbfe
LOG_LEVEL=info
`,
want: []string{
"cfk_ZE4CrcFhEIDXk9vL2sTLeARsFp2ZZYbydVDhhIUq8573bbfe",
},
},
{
name: "no match for legacy format",
input: `
# .env.legacy
# Pre-2026 global API keys are bare 37-char hex strings without a prefix.
CLOUDFLARE_API_KEY=abcdef1234567890abcdef1234567890abcdef0
[email protected]
`,
want: nil,
},
{
name: "valid pattern - ansible playbook rotating keys across environments",
input: `
---
- name: Rotate Cloudflare global API keys
hosts: localhost
vars:
staging_key: "cfk_fygw2wMqZcUDIh7yfJs1ON43xKmTecQoXsf2o3gy8eb5bb68"
production_key: "cfk_S7RPeMOkIUpkDyr7OSJoRu1XXdo0cZuzren68K4Ta6fce484"
tasks:
- name: Update staging secret store
command: "vault kv put secret/staging cf_key={{ staging_key }}"
- name: Update production secret store
command: "vault kv put secret/production cf_key={{ production_key }}"
`,
want: []string{
"cfk_fygw2wMqZcUDIh7yfJs1ON43xKmTecQoXsf2o3gy8eb5bb68",
"cfk_S7RPeMOkIUpkDyr7OSJoRu1XXdo0cZuzren68K4Ta6fce484",
},
},
{
name: "invalid pattern - too short",
input: `
func setupCloudflareClient() {
// Truncated key accidentally committed during a config export
key := "cfk_ZE4CrcFhEIDXk9vL2sTLe"
client.SetGlobalKey(key)
}
`,
want: nil,
},
{
name: "invalid pattern - too long",
input: `
func setupCloudflareClient() {
// Extra characters appended by a bad find-and-replace
key := "cfk_ZE4CrcFhEIDXk9vL2sTLeARsFp2ZZYbydVDhhIUq8573bbfeEXTRA"
client.SetGlobalKey(key)
}
`,
want: nil,
},
{
name: "invalid pattern - invalid characters",
input: `
func setupCloudflareClient() {
// Key was mangled when pasted from a Slack message with markdown formatting
key := "cfk_ZE4CrcFhEIDXk9vL2sTLeARsFp2ZZYbydVDhhIUq8573bbf!"
client.SetGlobalKey(key)
}
`,
want: nil,
},
{
name: "invalid pattern - keyword only",
input: `
// TODO: load the real key instead of reading cfk_ from env
func isCloudflareGlobalKey(s string) bool {
return strings.HasPrefix(s, "cfk_")
}
`,
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) {
t.Errorf("expected %d results, got %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)
}
})
}
}
+12 -4
View File
@@ -168,9 +168,11 @@ import (
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/closecrm"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/cloudconvert"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/cloudelements"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/cloudflareapitoken"
cloudflareapitokenv1 "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/cloudflareapitoken/v1"
cloudflareapitokenv2 "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/cloudflareapitoken/v2"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/cloudflarecakey"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/cloudflareglobalapikey"
cloudflareglobalapikeyv1 "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/cloudflareglobalapikey/v1"
cloudflareglobalapikeyv2 "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/cloudflareglobalapikey/v2"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/cloudimage"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/cloudinary"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/cloudmersive"
@@ -1062,9 +1064,11 @@ func buildDetectorList() []detectors.Detector {
&closecrm.Scanner{},
&cloudconvert.Scanner{},
&cloudelements.Scanner{},
&cloudflareapitoken.Scanner{},
&cloudflareapitokenv1.Scanner{},
&cloudflareapitokenv2.Scanner{},
&cloudflarecakey.Scanner{},
&cloudflareglobalapikey.Scanner{},
&cloudflareglobalapikeyv1.Scanner{},
&cloudflareglobalapikeyv2.Scanner{},
&cloudimage.Scanner{},
&cloudinary.Scanner{},
&cloudmersive.Scanner{},
@@ -1848,6 +1852,10 @@ func buildDetectorList() []detectors.Detector {
return !feature.HashiCorpVaultBatchTokenDetectorEnabled.Load()
case *hashicorpvaulttoken.Scanner:
return !feature.HashiCorpVaultTokenDetectorEnabled.Load()
case *cloudflareapitokenv2.Scanner:
return !feature.CloudflareApiTokenV2DetectorEnabled.Load()
case *cloudflareglobalapikeyv2.Scanner:
return !feature.CloudflareGlobalApiKeyV2DetectorEnabled.Load()
default:
return false
}
+2
View File
@@ -38,6 +38,8 @@ var (
LobDetectorEnabled atomic.Bool
HashiCorpVaultBatchTokenDetectorEnabled atomic.Bool
HashiCorpVaultTokenDetectorEnabled atomic.Bool
CloudflareApiTokenV2DetectorEnabled atomic.Bool
CloudflareGlobalApiKeyV2DetectorEnabled atomic.Bool
)
type AtomicString struct {