-
Notifications
You must be signed in to change notification settings - Fork 2.3k
Update Cloudflare detectors for 2026+ prefixed credential formats #4830
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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`) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Broad account ID pattern may cause excessive verification requestsMedium Severity The Additional Locations (1)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), | ||
| }) | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Account ID pairing skipped when verify is falseMedium Severity When Additional Locations (1)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." | ||
| } | ||
| 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) | ||
| } | ||
| }) | ||
| } | ||
| } |


Uh oh!
There was an error while loading. Please reload this page.