Files
trufflehog/pkg/detectors/billomat/billomat.go
Miccah ad2f9a4b98 Populate SecretParts on all detectors (#4919)
* Populate SecretParts on single-part detectors

Adds SecretParts: map[string]string{"key": <secret>} to every detector
package that constructs detectors.Result with a single captured secret
value. This is the single-part half of the SecretParts migration (the
linter's common case, ~695 packages).

* Populate SecretParts on multi-part detectors
2026-04-24 08:32:51 -07:00

142 lines
4.0 KiB
Go

package billomat
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
regexp "github.com/wasilibs/go-re2"
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
)
type Scanner struct {
detectors.DefaultMultiPartCredentialProvider
}
// Ensure the Scanner satisfies the interface at compile time.
var _ detectors.Detector = (*Scanner)(nil)
var (
client = common.SaneHttpClient()
// Make sure that your group is surrounded in boundary characters such as below to reduce false positives.
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.
// Use identifiers in the secret preferably, or the provider name.
func (s Scanner) Keywords() []string {
return []string{"billomat"}
}
func (s Scanner) Type() detector_typepb.DetectorType {
return detector_typepb.DetectorType_Billomat
}
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: detector_typepb.DetectorType_Billomat,
Raw: []byte(apiKey),
SecretParts: map[string]string{
"key": apiKey,
"id": id,
},
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, http.MethodGet, fmt.Sprintf("https://%s.billomat.net/api/v2/clients/myself", id), http.NoBody)
if err != nil {
return false, err
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("X-BillomatApiKey", key)
resp, err := client.Do(req)
if err != nil {
return false, err
}
defer func() {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}()
switch resp.StatusCode {
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)
}
}