Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
139 changes: 139 additions & 0 deletions pkg/detectors/waveapps/waveapps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package waveapps

import (
"context"
"encoding/json"
"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/detectorspb"
)

// graphQLResponse represents the response from the Waveapps GraphQL API.
type graphQLResponse struct {
Data struct {
User struct {
ID string `json:"id"`
} `json:"user"`
} `json:"data"`
Errors []interface{} `json:"errors"`
}

type Scanner struct {
client *http.Client
}

const waveappsURL = "https://gql.waveapps.com/graphql/public"

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

defaultClient = common.SaneHttpClient()

// Wave payment tokens have distinct prefixes: wave_sn_prod_ or wave_ci_prod_
// These are Waveapps (waveapps.com) API token types, not country codes.
// Ref: https://developer.waveapps.com/hc/en-us/articles/360018856751-Authentication
keyPat = regexp.MustCompile(`\b(wave_(?:sn|ci)_prod_[A-Za-z0-9_-]{30,})\b`)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regex \b boundary incompatible with - in character class

Low Severity

The regex \b(wave_(?:sn|ci)_prod_[A-Za-z0-9_-]{30,})\b includes - in the character class but uses \b word boundaries. Since - is not a word character (only [A-Za-z0-9_] are), if a valid token ends with -, the trailing \b will fail to match (non-word to non-word is not a boundary), causing the engine to backtrack and silently truncate the captured token. The truncated value stored in Raw would then fail verification, producing a false-negative or a misleading raw secret value.

Fix in Cursor Fix in Web

)

// Keywords are used for efficiently pre-filtering chunks.
func (s Scanner) Keywords() []string {
return []string{"wave_sn_prod_", "wave_ci_prod_"}
}

func (s Scanner) getClient() *http.Client {
if s.client != nil {
return s.client
}
return defaultClient
}

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

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

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

if verify {
client := s.getClient()
isVerified, verificationErr := verifyWaveapps(ctx, client, resMatch)
s1.Verified = isVerified
s1.SetVerificationError(verificationErr, resMatch)
}
Comment on lines +72 to +77
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new detector includes a verification path (verifyWaveapps + the verify branch in FromData), but the added tests only cover pattern matching (verify=false). This leaves verification behavior (verified/unverified, timeout/unexpected-status handling, and SetVerificationError behavior) untested. Consider adding a waveapps_integration_test.go (like many other detectors) that injects a stub http.Client (e.g., common.ConstantResponseHttpClient / common.SaneHttpClientTimeOut) to cover success and error cases without needing real Wave tokens.

Copilot uses AI. Check for mistakes.

results = append(results, s1)
}

return results, nil
}

func verifyWaveapps(ctx context.Context, client *http.Client, token string) (bool, error) {
// Use a simple user query to verify the token is valid.
payload := strings.NewReader(`{"query":"{ user { id } }"}`)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, waveappsURL, payload)
if err != nil {
return false, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")

res, err := client.Do(req)
if err != nil {
return false, err
}
defer func() {
_, _ = io.Copy(io.Discard, res.Body)
res.Body.Close()
}()

body, err := io.ReadAll(res.Body)
if err != nil {
return false, err
}

switch res.StatusCode {
case http.StatusOK:
var resp graphQLResponse
if err := json.Unmarshal(body, &resp); err != nil {
return false, err
}

// If GraphQL returned errors, the token is invalid.
if len(resp.Errors) > 0 {
return false, nil
}

// A valid token returns a non-empty user ID.
if resp.Data.User.ID != "" {
return true, nil
}
return false, nil
case http.StatusUnauthorized, http.StatusForbidden:
return false, nil
default:
return false, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode)
}
}

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

func (s Scanner) Description() string {
return "Waveapps is a financial software platform for small businesses. Waveapps API tokens can be used to access payment and invoicing data via their GraphQL API."
}
161 changes: 161 additions & 0 deletions pkg/detectors/waveapps/waveapps_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
//go:build detectors
// +build detectors

package waveapps

import (
"context"
"fmt"
"testing"
"time"

"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"

"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb"
)

func TestWaveapps_FromChunk(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors5")
if err != nil {
t.Fatalf("could not get test secrets from GCP: %s", err)
}
secret := testSecrets.MustGetField("WAVEAPPS")
inactiveSecret := testSecrets.MustGetField("WAVEAPPS_INACTIVE")

type args struct {
ctx context.Context
data []byte
verify bool
}
tests := []struct {
name string
s Scanner
args args
want []detectors.Result
wantErr bool
wantVerificationErr bool
}{
{
name: "found, verified",
s: Scanner{},
args: args{
ctx: ctx,
data: []byte(fmt.Sprintf("You can find a waveapps secret %s within", secret)),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detectorspb.DetectorType_Waveapps,
Verified: true,
},
},
wantErr: false,
wantVerificationErr: false,
},
{
name: "found, unverified",
s: Scanner{},
args: args{
ctx: ctx,
data: []byte(fmt.Sprintf("You can find a waveapps secret %s within but not valid", inactiveSecret)),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detectorspb.DetectorType_Waveapps,
Verified: false,
},
},
wantErr: false,
wantVerificationErr: false,
},
{
name: "not found",
s: Scanner{},
args: args{
ctx: context.Background(),
data: []byte("You cannot find the secret within"),
verify: true,
},
want: nil,
wantErr: false,
wantVerificationErr: false,
},
{
name: "found, would be verified if not for timeout",
s: Scanner{client: common.SaneHttpClientTimeOut(1 * time.Microsecond)},
args: args{
ctx: context.Background(),
data: []byte(fmt.Sprintf("You can find a waveapps secret %s within", secret)),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detectorspb.DetectorType_Waveapps,
Verified: false,
},
},
wantErr: false,
wantVerificationErr: true,
},
{
name: "found, verified but unexpected api surface",
s: Scanner{client: common.ConstantResponseHttpClient(500, "")},
args: args{
ctx: context.Background(),
data: []byte(fmt.Sprintf("You can find a waveapps secret %s within", secret)),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detectorspb.DetectorType_Waveapps,
Verified: false,
},
},
wantErr: false,
wantVerificationErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.s.FromData(tt.args.ctx, tt.args.verify, tt.args.data)
if (err != nil) != tt.wantErr {
t.Errorf("Waveapps.FromData() error = %v, wantErr %v", err, tt.wantErr)
return
}
for i := range got {
if len(got[i].Raw) == 0 {
t.Fatalf("no raw secret present: \n %+v", got[i])
}
if (got[i].VerificationError() != nil) != tt.wantVerificationErr {
t.Fatalf("wantVerificationError = %v, verification error = %v", tt.wantVerificationErr, got[i].VerificationError())
}
}
ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "verificationError")
if diff := cmp.Diff(got, tt.want, ignoreOpts); diff != "" {
t.Errorf("Waveapps.FromData() %s diff: (-got +want)\n%s", tt.name, diff)
}
})
}
}

func BenchmarkFromData(benchmark *testing.B) {
ctx := context.Background()
s := Scanner{}
for name, data := range detectors.MustGetBenchmarkData() {
benchmark.Run(name, func(b *testing.B) {
b.ResetTimer()
for n := 0; n < b.N; n++ {
_, err := s.FromData(ctx, false, data)
if err != nil {
b.Fatal(err)
}
}
})
}
}
Loading