Skip to content
Open
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
0a5a341
feat(detectors): add Nigerian fintech & betting credential detector
LloydCoder Dec 6, 2025
8403245
fix: remove unused import common import
LloydCoder Dec 8, 2025
8fa8dcb
feat: add separate Nigerian fintech detectors with verifiers
LloydCoder Mar 16, 2026
9d1c9bc
fix: address Cursor Bugbot findings in Nigerian fintech detectors
LloydCoder Mar 16, 2026
59281cc
fix: address remaining Cursor issues - optimize regex compilation and…
LloydCoder Mar 16, 2026
777f2f9
fix: final Cursor Bugbot issues - HTTP client pooling and verificatio…
LloydCoder Mar 17, 2026
d29092d
fix: final logic bug and import consistency
LloydCoder Mar 17, 2026
484f965
fix: final logic bug and import consistency
LloydCoder Mar 17, 2026
5ead2ef
fix: correct pb.go const block closure and add missing enum entries 1…
LloydCoder Mar 17, 2026
0806179
resolve merge conflicts - adjust enum values for new detectors
LloydCoder Mar 17, 2026
99dd4b7
Merge branch 'main' into lloydcoder-separate-detectors
LloydCoder Mar 17, 2026
e47177d
fix: add word boundaries to Paystack regex and return errors from all…
LloydCoder Mar 17, 2026
e637259
resolve merge conflicts - adjust enum values for new detectors
LloydCoder Mar 17, 2026
50af6f4
fix: capitalize Scanner type to match defaults.go and test expectations
LloydCoder Mar 19, 2026
3cad318
fix: correctly patch pb.go with Remita/Interswitch/Sportybet enum ent…
LloydCoder Mar 19, 2026
7032586
Merge branch 'main' into lloydcoder-separate-detectors
LloydCoder Mar 19, 2026
b7705ae
fix: regenerate detectors.pb.go from proto using protoc to fix rawDes…
LloydCoder Mar 19, 2026
78d502d
fix: remove phantom enum entries, regenerate pb.go with Remita=1040 I…
LloydCoder Mar 19, 2026
3c27606
fix: word boundaries on flutterwave, remove fragile body checks, fix …
LloydCoder Mar 19, 2026
e2614bb
fix: remove unused bytes and strings imports
LloydCoder Mar 19, 2026
bfff390
fix: add missing proto entries 1040-1043, regenerate pb.go, remove du…
LloydCoder Mar 19, 2026
82cd025
fix: correct proto enum IDs (Remita=1044, Interswitch=1045, Sportybet…
LloydCoder Mar 20, 2026
064104b
fix: remove duplicate interswitch keyword, drop unverifiable MAC key …
LloydCoder Mar 20, 2026
0c4c49e
fix: remove misleading macKey keyword that never matches interswitch …
LloydCoder Mar 20, 2026
b2d48a2
test: add pattern tests for interswitch, remita, and sportybet detectors
LloydCoder Mar 20, 2026
f668b00
fix: add case-insensitive flag to interswitch, remita, sportybet rege…
LloydCoder Mar 20, 2026
f21f2f5
fix: rename shadowed receiver variable, cap regex upper bound to 64 c…
LloydCoder Mar 20, 2026
78a60f8
fix: tighten regex lower bound to 40 chars to reduce false positives …
LloydCoder Mar 20, 2026
17df196
fix: update test inputs to 40-char keys, clean remita regex lookahead
LloydCoder Mar 20, 2026
b3a92a3
fix: remove lookahead from remita regex (go-re2 does not support look…
LloydCoder Mar 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 35 additions & 38 deletions pkg/detectors/flutterwave/flutterwave.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ package flutterwave
import (
"context"
"fmt"
"io"
"net/http"
"strings"

regexp "github.com/wasilibs/go-re2"

Expand All @@ -15,62 +15,59 @@ import (

type Scanner struct{}

// 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.

keyPat = regexp.MustCompile(`\b(FLWSECK-[0-9a-z]{32}-X)\b`)
client = common.SaneHttpClient()
flutterwaveKeyPattern = regexp.MustCompile(`\bFLWSECK(?:_TEST|_LIVE)?-[0-9a-zA-Z]{32}-X\b`)
keywords = []string{"flutterwave", "FLWSECK"}
)

// 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{"FLWSECK-"}
}
func (s Scanner) Keywords() []string { return keywords }

// FromData will find and optionally verify Flutterwave 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)

matches := flutterwaveKeyPattern.FindAllString(dataStr, -1)
for _, match := range matches {
resMatch := strings.TrimSpace(match[1])

s1 := detectors.Result{
result := detectors.Result{
DetectorType: detectorspb.DetectorType_Flutterwave,
Raw: []byte(resMatch),
Raw: []byte(match),
}

if verify {
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.flutterwave.com/v3/subaccounts", nil)
if err != nil {
continue
}
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", resMatch))
res, err := client.Do(req)
if err == nil {
defer res.Body.Close()
if res.StatusCode >= 200 && res.StatusCode < 300 {
s1.Verified = true
}
verified, verifyErr := verifyFlutterwave(ctx, match)
result.Verified = verified
if verifyErr != nil {
result.SetVerificationError(verifyErr, match)
}
}

results = append(results, s1)
results = append(results, result)
}

return results, nil
}

func (s Scanner) Type() detectorspb.DetectorType {
return detectorspb.DetectorType_Flutterwave
func verifyFlutterwave(ctx context.Context, key string) (bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.flutterwave.com/v3/transactions", nil)
if err != nil {
return false, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", key))
resp, err := client.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusUnauthorized, http.StatusForbidden:
return false, nil
default:
return false, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
}

func (s Scanner) Type() detectorspb.DetectorType { return detectorspb.DetectorType_Flutterwave }
func (s Scanner) Description() string {
return "Flutterwave is a payment technology company providing seamless and secure payment solutions for businesses. Flutterwave API keys can be used to access and manage payment services and transactions."
return "Detects Flutterwave secret API keys (FLWSECK format)"
}
84 changes: 84 additions & 0 deletions pkg/detectors/interswitch/interswitch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package interswitch

import (
"context"
"encoding/base64"
"fmt"
"io"
"net/http"

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/detectorspb"
)

var (
interswitchKeyPattern = regexp.MustCompile(`(?i)(?:interswitch|quickteller)[_-]?(?:api[_-])?(?:key|secret)["\s:=]+([0-9a-zA-Z]{32,64})`)
interswitchClient = common.SaneHttpClient()
)

type Scanner struct{}

var _ detectors.Detector = (*Scanner)(nil)

func (s Scanner) Keywords() []string {
return []string{"interswitch", "quickteller"}
}

func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
dataStr := string(data)
matches := interswitchKeyPattern.FindAllStringSubmatch(dataStr, -1)
for _, match := range matches {
if len(match) < 2 || match[1] == "" {
continue
}
key := match[1]
result := detectors.Result{
DetectorType: detectorspb.DetectorType_Interswitch,
Raw: []byte(key),
}
if verify {
verified, verifyErr := verifyInterswitchKey(ctx, key)
result.Verified = verified
if verifyErr != nil {
result.SetVerificationError(verifyErr, key)
}
}
results = append(results, result)
}
return results, nil
}

func verifyInterswitchKey(ctx context.Context, key string) (bool, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.interswitchng.com/api/v1/merchant/profile", nil)
if err != nil {
return false, err
}
auth := base64.StdEncoding.EncodeToString([]byte(key + ":"))
req.Header.Add("Authorization", "Basic "+auth)
req.Header.Add("Content-Type", "application/json")
resp, err := interswitchClient.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusUnauthorized, http.StatusForbidden:
return false, nil
default:
return false, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
}

func (s Scanner) Type() detectorspb.DetectorType {
return detectorspb.DetectorType_Interswitch
}

func (s Scanner) Description() string {
return "Detects Interswitch API keys"
}
63 changes: 63 additions & 0 deletions pkg/detectors/interswitch/interswitch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package interswitch

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 TestInterswitch_Pattern(t *testing.T) {
d := Scanner{}
ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d})
tests := []struct {
name string
input string
want []string
}{
{
name: "valid pattern",
input: "interswitch_api_key=abcdefghijklmnopqrstuvwxyz123456",
want: []string{"abcdefghijklmnopqrstuvwxyz123456"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
matchedDetectors := ahoCorasickCore.FindDetectorMatches([]byte(test.input))
if len(matchedDetectors) == 0 {
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)
}
})
}
}
79 changes: 49 additions & 30 deletions pkg/detectors/paystack/paystack.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,71 +3,90 @@ package paystack
import (
"context"
"fmt"
regexp "github.com/wasilibs/go-re2"
"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/detectorspb"
)

var (
paystackKeyPattern = regexp.MustCompile(`\b(sk_[a-z]+_[0-9a-zA-Z]{40})\b`)
paystackClient = common.SaneHttpClient()
)

type Scanner struct{}

// Ensure the Scanner satisfies the interface at compile time.
var _ detectors.Detector = (*Scanner)(nil)

var (
client = common.SaneHttpClient()
// TODO: support live key
keyPat = regexp.MustCompile(`\b(sk\_[a-z]{1,}\_[A-Za-z0-9]{40})\b`)
)

// 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{"paystack"}
return []string{"paystack", "sk_live", "sk_test"}
}

// FromData will find and optionally verify Paystack 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)
matches := paystackKeyPattern.FindAllStringSubmatch(dataStr, -1)

for _, match := range matches {
resMatch := strings.TrimSpace(match[1])
if len(match) < 2 {
continue
}
key := match[1]

s1 := detectors.Result{
result := detectors.Result{
DetectorType: detectorspb.DetectorType_Paystack,
Raw: []byte(resMatch),
Raw: []byte(key),
}

if verify {
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.paystack.co/customer", nil)
if err != nil {
continue
}
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", resMatch))
res, err := client.Do(req)
if err == nil {
defer res.Body.Close()
if res.StatusCode >= 200 && res.StatusCode < 300 {
s1.Verified = true
}
verified, verifyErr := verifyPaystackKey(ctx, key)
result.Verified = verified
if verifyErr != nil {
result.SetVerificationError(verifyErr, key)
}
}

results = append(results, s1)
results = append(results, result)
}

return results, nil
}

func verifyPaystackKey(ctx context.Context, key string) (bool, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.paystack.co/balance", nil)
if err != nil {
return false, err
}

req.Header.Add("Authorization", "Bearer "+key)
req.Header.Add("Content-Type", "application/json")

resp, err := paystackClient.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()

_, _ = io.Copy(io.Discard, resp.Body)

switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusUnauthorized, http.StatusForbidden:
return false, nil
default:
return false, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
}

func (s Scanner) Type() detectorspb.DetectorType {
return detectorspb.DetectorType_Paystack
}

func (s Scanner) Description() string {
return "Paystack is a payment processing service. Paystack API keys can be used to access and manage payment transactions and customer data."
return "Detects Paystack API secret keys (sk_* format)"
}
Loading