diff --git a/main.go b/main.go index 0f50cb792..cb60b2334 100644 --- a/main.go +++ b/main.go @@ -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 != "" { diff --git a/pkg/detectors/cloudflareapitoken/common.go b/pkg/detectors/cloudflareapitoken/common.go new file mode 100644 index 000000000..18e50eed6 --- /dev/null +++ b/pkg/detectors/cloudflareapitoken/common.go @@ -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) + } +} diff --git a/pkg/detectors/cloudflareapitoken/cloudflareapitoken.go b/pkg/detectors/cloudflareapitoken/v1/cloudflareapitoken.go similarity index 76% rename from pkg/detectors/cloudflareapitoken/cloudflareapitoken.go rename to pkg/detectors/cloudflareapitoken/v1/cloudflareapitoken.go index e848cc3e8..a6c72c0d1 100644 --- a/pkg/detectors/cloudflareapitoken/cloudflareapitoken.go +++ b/pkg/detectors/cloudflareapitoken/v1/cloudflareapitoken.go @@ -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) diff --git a/pkg/detectors/cloudflareapitoken/cloudflareapitoken_integration_test.go b/pkg/detectors/cloudflareapitoken/v1/cloudflareapitoken_integration_test.go similarity index 100% rename from pkg/detectors/cloudflareapitoken/cloudflareapitoken_integration_test.go rename to pkg/detectors/cloudflareapitoken/v1/cloudflareapitoken_integration_test.go diff --git a/pkg/detectors/cloudflareapitoken/cloudflareapitoken_test.go b/pkg/detectors/cloudflareapitoken/v1/cloudflareapitoken_test.go similarity index 100% rename from pkg/detectors/cloudflareapitoken/cloudflareapitoken_test.go rename to pkg/detectors/cloudflareapitoken/v1/cloudflareapitoken_test.go diff --git a/pkg/detectors/cloudflareapitoken/v2/cloudflareapitoken.go b/pkg/detectors/cloudflareapitoken/v2/cloudflareapitoken.go new file mode 100644 index 000000000..f79b15cc2 --- /dev/null +++ b/pkg/detectors/cloudflareapitoken/v2/cloudflareapitoken.go @@ -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." +} diff --git a/pkg/detectors/cloudflareapitoken/v2/cloudflareapitoken_integration_test.go b/pkg/detectors/cloudflareapitoken/v2/cloudflareapitoken_integration_test.go new file mode 100644 index 000000000..39d8ee563 --- /dev/null +++ b/pkg/detectors/cloudflareapitoken/v2/cloudflareapitoken_integration_test.go @@ -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) + } + } + }) + } +} diff --git a/pkg/detectors/cloudflareapitoken/v2/cloudflareapitoken_test.go b/pkg/detectors/cloudflareapitoken/v2/cloudflareapitoken_test.go new file mode 100644 index 000000000..395c688b4 --- /dev/null +++ b/pkg/detectors/cloudflareapitoken/v2/cloudflareapitoken_test.go @@ -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) + } + }) + } +} diff --git a/pkg/detectors/cloudflarecakey/cloudflarecakey.go b/pkg/detectors/cloudflarecakey/cloudflarecakey.go index 4b9bc5fef..7d298acf6 100644 --- a/pkg/detectors/cloudflarecakey/cloudflarecakey.go +++ b/pkg/detectors/cloudflarecakey/cloudflarecakey.go @@ -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`) ) diff --git a/pkg/detectors/cloudflareglobalapikey/common.go b/pkg/detectors/cloudflareglobalapikey/common.go new file mode 100644 index 000000000..73dc5a9fd --- /dev/null +++ b/pkg/detectors/cloudflareglobalapikey/common.go @@ -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) + } +} diff --git a/pkg/detectors/cloudflareglobalapikey/cloudflareglobalapikey.go b/pkg/detectors/cloudflareglobalapikey/v1/cloudflareglobalapikey.go similarity index 78% rename from pkg/detectors/cloudflareglobalapikey/cloudflareglobalapikey.go rename to pkg/detectors/cloudflareglobalapikey/v1/cloudflareglobalapikey.go index 9ab63e450..8337ba0b1 100644 --- a/pkg/detectors/cloudflareglobalapikey/cloudflareglobalapikey.go +++ b/pkg/detectors/cloudflareglobalapikey/v1/cloudflareglobalapikey.go @@ -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) diff --git a/pkg/detectors/cloudflareglobalapikey/cloudflareglobalapikey_integration_test.go b/pkg/detectors/cloudflareglobalapikey/v1/cloudflareglobalapikey_integration_test.go similarity index 100% rename from pkg/detectors/cloudflareglobalapikey/cloudflareglobalapikey_integration_test.go rename to pkg/detectors/cloudflareglobalapikey/v1/cloudflareglobalapikey_integration_test.go diff --git a/pkg/detectors/cloudflareglobalapikey/cloudflareglobalapikey_test.go b/pkg/detectors/cloudflareglobalapikey/v1/cloudflareglobalapikey_test.go similarity index 88% rename from pkg/detectors/cloudflareglobalapikey/cloudflareglobalapikey_test.go rename to pkg/detectors/cloudflareglobalapikey/v1/cloudflareglobalapikey_test.go index fef882a30..ab849aec6 100644 --- a/pkg/detectors/cloudflareglobalapikey/cloudflareglobalapikey_test.go +++ b/pkg/detectors/cloudflareglobalapikey/v1/cloudflareglobalapikey_test.go @@ -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) { @@ -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", diff --git a/pkg/detectors/cloudflareglobalapikey/v2/cloudflareglobalapikey.go b/pkg/detectors/cloudflareglobalapikey/v2/cloudflareglobalapikey.go new file mode 100644 index 000000000..3fac32b0d --- /dev/null +++ b/pkg/detectors/cloudflareglobalapikey/v2/cloudflareglobalapikey.go @@ -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." +} diff --git a/pkg/detectors/cloudflareglobalapikey/v2/cloudflareglobalapikey_integration_test.go b/pkg/detectors/cloudflareglobalapikey/v2/cloudflareglobalapikey_integration_test.go new file mode 100644 index 000000000..3f18404d9 --- /dev/null +++ b/pkg/detectors/cloudflareglobalapikey/v2/cloudflareglobalapikey_integration_test.go @@ -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) + } + } + }) + } +} diff --git a/pkg/detectors/cloudflareglobalapikey/v2/cloudflareglobalapikey_test.go b/pkg/detectors/cloudflareglobalapikey/v2/cloudflareglobalapikey_test.go new file mode 100644 index 000000000..9b031da4e --- /dev/null +++ b/pkg/detectors/cloudflareglobalapikey/v2/cloudflareglobalapikey_test.go @@ -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: testuser1005@example.com" \ + -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 + CLOUDFLARE_EMAIL=testuser1005@example.com + `, + 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) + } + }) + } +} diff --git a/pkg/engine/defaults/defaults.go b/pkg/engine/defaults/defaults.go index f91a56545..c37875198 100644 --- a/pkg/engine/defaults/defaults.go +++ b/pkg/engine/defaults/defaults.go @@ -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 } diff --git a/pkg/feature/feature.go b/pkg/feature/feature.go index 4ebecbb0a..7ac3d3a18 100644 --- a/pkg/feature/feature.go +++ b/pkg/feature/feature.go @@ -38,6 +38,8 @@ var ( LobDetectorEnabled atomic.Bool HashiCorpVaultBatchTokenDetectorEnabled atomic.Bool HashiCorpVaultTokenDetectorEnabled atomic.Bool + CloudflareApiTokenV2DetectorEnabled atomic.Bool + CloudflareGlobalApiKeyV2DetectorEnabled atomic.Bool ) type AtomicString struct {