Fixed and Improved billomat detector (#4268)

This commit is contained in:
Kashif Khan
2025-06-27 21:48:35 +05:00
committed by GitHub
parent 8202d57f9a
commit 800f7c7e41
3 changed files with 100 additions and 80 deletions
+66 -34
View File
@@ -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)
@@ -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)
+33 -46
View File
@@ -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,
},
}