From 800f7c7e412e2f5ebf5ad81b4171e2e483bf99a8 Mon Sep 17 00:00:00 2001 From: Kashif Khan <70996046+kashifkhan0771@users.noreply.github.com> Date: Fri, 27 Jun 2025 21:48:35 +0500 Subject: [PATCH] Fixed and Improved billomat detector (#4268) --- pkg/detectors/billomat/billomat.go | 100 ++++++++++++------ .../billomat/billomat_integration_test.go | 1 + pkg/detectors/billomat/billomat_test.go | 79 ++++++-------- 3 files changed, 100 insertions(+), 80 deletions(-) diff --git a/pkg/detectors/billomat/billomat.go b/pkg/detectors/billomat/billomat.go index d746c31f6..6cc495e8b 100644 --- a/pkg/detectors/billomat/billomat.go +++ b/pkg/detectors/billomat/billomat.go @@ -2,6 +2,7 @@ package billomat import ( "context" + "errors" "fmt" "io" "net/http" @@ -25,8 +26,10 @@ var ( client = common.SaneHttpClient() // Make sure that your group is surrounded in boundary characters such as below to reduce false positives. - keyPat = regexp.MustCompile(detectors.PrefixRegex([]string{"billomat"}) + `\b([0-9a-z]{32})\b`) - idPat = regexp.MustCompile(detectors.PrefixRegex([]string{"billomat"}) + `\b([0-9a-z]{1,})\b`) + idPat = regexp.MustCompile(detectors.PrefixRegex([]string{"billomat"}) + `\b([0-9a-z]{4,20})\b`) // the Billomat ID must be between 4 and 20 characters long. + keyPat = regexp.MustCompile(detectors.PrefixRegex([]string{"billomat"}) + `\b([0-9a-f]{32})\b`) + + errAccountIDNotFound = errors.New("account id not found") ) // Keywords are used for efficiently pre-filtering chunks. @@ -35,37 +38,6 @@ func (s Scanner) Keywords() []string { return []string{"billomat"} } -// FromData will find and optionally verify Billomat 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) - idMatches := idPat.FindAllStringSubmatch(dataStr, -1) - - for _, match := range matches { - resMatch := strings.TrimSpace(match[1]) - for _, idMatch := range idMatches { - resId := strings.TrimSpace(idMatch[1]) - - s1 := detectors.Result{ - DetectorType: detectorspb.DetectorType_Billomat, - Raw: []byte(resMatch), - RawV2: []byte(resMatch + resId), - } - - if verify { - isVerified, verificationErr := verifyBillomat(ctx, client, resId, resMatch) - s1.Verified = isVerified - s1.SetVerificationError(verificationErr, resMatch) - } - - results = append(results, s1) - } - } - - return results, nil -} - func (s Scanner) Type() detectorspb.DetectorType { return detectorspb.DetectorType_Billomat } @@ -74,9 +46,52 @@ func (s Scanner) Description() string { return "Billomat is an online invoicing software. Billomat API keys can be used to access and manage invoices, clients, and other related data." } +// FromData will find and optionally verify Billomat 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) + + var uniqueIDs, uniqueAPIKeys = make(map[string]struct{}), make(map[string]struct{}) + + for _, match := range idPat.FindAllStringSubmatch(dataStr, -1) { + uniqueIDs[match[1]] = struct{}{} + } + + for _, match := range keyPat.FindAllStringSubmatch(dataStr, -1) { + uniqueAPIKeys[match[1]] = struct{}{} + } + + for apiKey := range uniqueAPIKeys { + for id := range uniqueIDs { + s1 := detectors.Result{ + DetectorType: detectorspb.DetectorType_Billomat, + Raw: []byte(apiKey), + RawV2: []byte(apiKey + id), + } + + if verify { + isVerified, verificationErr := verifyBillomat(ctx, client, id, apiKey) + s1.Verified = isVerified + if verificationErr != nil { + // remove the account ID if not found to prevent reuse during other API key checks. + if errors.Is(verificationErr, errAccountIDNotFound) { + delete(uniqueIDs, id) + continue + } + + s1.SetVerificationError(verificationErr, apiKey) + } + } + + results = append(results, s1) + } + } + + return results, nil +} + // docs: https://www.billomat.com/en/api/basics/authentication/ func verifyBillomat(ctx context.Context, client *http.Client, id, key string) (bool, error) { - req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://%s.billomat.net/api/v2/clients/myself", id), nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("https://%s.billomat.net/api/v2/clients/myself", id), http.NoBody) if err != nil { return false, err } @@ -98,6 +113,23 @@ func verifyBillomat(ctx context.Context, client *http.Client, id, key string) (b case http.StatusOK: return true, nil case http.StatusUnauthorized: + return false, nil + case http.StatusNotFound: // billomat api returns 404 if account id does not exist + // read the full response body + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return false, nil + } + + /* + The regex for capturing a Billomat ID is prone to false positives. + To minimize incorrect matches, we return an error if the captured account ID does not exist, + as this likely indicates the match was invalid. + */ + if strings.Contains(string(bodyBytes), "account not found") { + return false, errAccountIDNotFound + } + return false, nil default: return false, fmt.Errorf("unexpected status code: %d", resp.StatusCode) diff --git a/pkg/detectors/billomat/billomat_integration_test.go b/pkg/detectors/billomat/billomat_integration_test.go index 745e16291..d06708cfa 100644 --- a/pkg/detectors/billomat/billomat_integration_test.go +++ b/pkg/detectors/billomat/billomat_integration_test.go @@ -96,6 +96,7 @@ func TestBillomat_FromChunk(t *testing.T) { 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("Billomat.FromData() %s diff: (-got +want)\n%s", tt.name, diff) diff --git a/pkg/detectors/billomat/billomat_test.go b/pkg/detectors/billomat/billomat_test.go index 6ecf9c516..f011ff686 100644 --- a/pkg/detectors/billomat/billomat_test.go +++ b/pkg/detectors/billomat/billomat_test.go @@ -10,37 +10,6 @@ import ( "github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick" ) -var ( - validPattern = "billomatKey: xv3khh5klgzztdmptrgbqhkr0ucvr67i / billomatID: s2mels7c75tnsbs7ldu0wmjofzmugkg7vb" - complexPattern = ` - func main() { - url := "https://api.billomat.net/v2/s2mels7c75tnsbs7ldu0wmjofzmugkg7vb" - - // Create a new request with the secret as a header - req, err := http.NewRequest("GET", url, http.NoBody) - if err != nil { - fmt.Println("Error creating request:", err) - return - } - - req.Header.Set("X-BillomatApiKey", "xv3khh5klgzztdmptrgbqhkr0ucvr67i") - - // Perform the request - client := &http.Client{} - resp, _ := client.Do(req) - defer resp.Body.Close() - - // Check response status - if resp.StatusCode == http.StatusOK { - fmt.Println("Request successful!") - } else { - fmt.Println("Request failed with status:", resp.Status) - } - } - ` - invalidPattern = "billomat_creds: s2mels7c75tnsbs7ldu0wmjofzmugkg7vb" -) - func TestBilloMat_Pattern(t *testing.T) { d := Scanner{} ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d}) @@ -51,25 +20,43 @@ func TestBilloMat_Pattern(t *testing.T) { want []string }{ { - name: "valid pattern", - input: validPattern, + name: "valid pattern", + input: ` + func main() { + url := "https://api.billomat.net/v2/id/truffletest" + + // Create a new request with the secret as a header + req, err := http.NewRequest("GET", url, http.NoBody) + if err != nil { + fmt.Println("Error creating request:", err) + return + } + + req.Header.Set("X-BillomatApiKey", "c09761f99f39f79ae28eaaf8df20d7c9") + + // Perform the request + client := &http.Client{} + resp, _ := client.Do(req) + defer resp.Body.Close() + + // Check response status + if resp.StatusCode == http.StatusOK { + fmt.Println("Request successful!") + } else { + fmt.Println("Request failed with status:", resp.Status) + } + }`, want: []string{ - "xv3khh5klgzztdmptrgbqhkr0ucvr67is2mels7c75tnsbs7ldu0wmjofzmugkg7vb", - "xv3khh5klgzztdmptrgbqhkr0ucvr67ixv3khh5klgzztdmptrgbqhkr0ucvr67i", + "c09761f99f39f79ae28eaaf8df20d7c9truffletest", }, }, { - name: "valid pattern - complex", - input: complexPattern, - want: []string{ - "xv3khh5klgzztdmptrgbqhkr0ucvr67inet", - "xv3khh5klgzztdmptrgbqhkr0ucvr67ixv3khh5klgzztdmptrgbqhkr0ucvr67i", - }, - }, - { - name: "invalid pattern", - input: invalidPattern, - want: nil, + name: "invalid pattern", + input: ` + req.Header.Set("X-BillomatApiKey", "c09761h99f39f79ae28eaaf8df20d7c9") + billomatID := truffle-test + `, + want: nil, }, }