Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 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
132 changes: 132 additions & 0 deletions pkg/detectors/smartling/smartling.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package smartling

import (
"bytes"
"context"
"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"
)

type Scanner struct {
client *http.Client
}

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

var (
defaultClient = common.SaneHttpClient()
// Make sure that your group is surrounded in boundary characters such as below to reduce false positives.

// TODO: These regexes are not guaranteed to be correct as we don't have access to a smartling account yet
// These are our best guesses based on publicly available information.
//
// We have screenshots containing the User identifier and secret in guides in Smartling's official documentation, as well as other third party documentations:
// - https://help.smartling.com/hc/en-us/articles/360009611313-AEM-Touch-Connector-Configuration-5-0x#:~:text=the%20AEM%20locale%20as%20the%20source%20locale.
// - https://help.smartling.com/hc/en-us/articles/360008158133-WordPress-Connector-Installation-Setup#:~:text=Click%20Account%20Settings%20%3E%20API%20%3E%20v%202.0
// - https://help.smartling.com/hc/en-us/articles/360007935194-AEM-Classic-Connector-Installation-and-Configuration#:~:text=In%20the%20General%20Settings%20tab
// - https://docs.blackbird.io/apps/smartling/#:~:text=Smartling%20via%20Blackbird.-,Connecting,-Navigate%20to%20apps

userIdPat = regexp.MustCompile(detectors.PrefixRegex([]string{"smartling", "user", "id"}) + `\b([a-z]{30})\b`)
secretPat = regexp.MustCompile(detectors.PrefixRegex([]string{"smartling", "secret", "key"}) + `\b([A-Za-z0-9._^-]{55})\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{"smartling"}
}

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

uniqueMatchesUserId := make(map[string]struct{})
for _, match := range userIdPat.FindAllStringSubmatch(dataStr, -1) {
uniqueMatchesUserId[match[1]] = struct{}{}
}
uniqueMatchesSecret := make(map[string]struct{})
for _, match := range secretPat.FindAllStringSubmatch(dataStr, -1) {
uniqueMatchesSecret[match[1]] = struct{}{}
}

for userId := range uniqueMatchesUserId {
for secret := range uniqueMatchesSecret {
s1 := detectors.Result{
DetectorType: detectorspb.DetectorType_Smartling,
Raw: []byte(userId),
RawV2: []byte(fmt.Sprintf("%s:%s", userId, secret)),
}

if verify {
client := s.client
if client == nil {
client = defaultClient
}

isVerified, extraData, verificationErr := verifyMatch(ctx, client, userId, secret)
s1.Verified = isVerified
s1.ExtraData = extraData
s1.SetVerificationError(verificationErr, userId, secret)
s1.SetPrimarySecretValue(secret)
}

results = append(results, s1)
}

}

return
}

func verifyMatch(ctx context.Context, client *http.Client, userId string, secret string) (bool, map[string]string, error) {
Copy link
Contributor

Choose a reason for hiding this comment

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

The verification function doesn't really need to return a map[string]string since it's always nil

Copy link
Contributor Author

@mustansir14 mustansir14 Sep 17, 2025

Choose a reason for hiding this comment

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

Good catch. I'll remove it.

body := []byte(fmt.Sprintf(`{"userIdentifier":"%s","userSecret":"%s"}`, userId, secret))

req, err := http.NewRequestWithContext(
ctx, http.MethodPost, "https://api.smartling.com/auth-api/v2/authenticate", bytes.NewReader(body))
if err != nil {
return false, nil, err
}
req.Header.Set("Content-Type", "application/json")

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

switch res.StatusCode {
case http.StatusOK:
// The secret is verified
return true, nil, nil
case http.StatusUnauthorized:
// The secret is determinately not verified (nothing to do)
return false, nil, nil
case http.StatusBadRequest:
// The request was malformed. We can't verify this secret, but it might be valid.
return false, nil, fmt.Errorf("received HTTP 400 Bad Request from Smartling API")
case http.StatusTooManyRequests:
// We have been rate limited. We can't verify this secret, but it might be valid.
return false, nil, fmt.Errorf("received HTTP 429 Too Many Requests from Smartling API")
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not quite sure why we need to describe these cases, since they are covered in default?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is done to provide specific errors for each case, and also as a measure to include all response status codes specified in the documentation.

Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm.. I don't see any specific info in either of the error messages not covered in the default case since the status code is being logged in it as well. This is definitely a nit though, so feel free to ignore.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm, makes sense. I'll remove them.

default:
return false, nil, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode)
}
}

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

func (s Scanner) Description() string {
return "Smartling is a cloud-based translation technology and language services company headquartered in New York City."
}
204 changes: 204 additions & 0 deletions pkg/detectors/smartling/smartling_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
//go:build detectors
// +build detectors

package smartling

import (
"bytes"
"context"
"fmt"
"net/http"
"testing"
"time"

"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"gopkg.in/h2non/gock.v1"

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

func TestSmartling_FromChunk(t *testing.T) {

// TODO: These are dummy credentials because we are mocking the API call to Smartling
// Replace this with getting actual credentials from GCP vault once we have access to
// an actual Smartling account and can make direct API calls
userId := "bxraiavfkoirvbzhlhvwggnzuouwjp"
secret := "aprb039cirfv071nqagvb22f3rWW^7qc802dle3rqeija66dkb1ulaa"
inactiveUserId := "einnwiufkduvcizimcnwnnggymexoy"
inactiveSecret := "tjl02sv8fvefol38535m5cguwoHa.966dmn82jcaprs3ulqqtb7v23c"

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
// TODO: Remove this once we have access to an actual Smartling account and can make direct API calls
gockSetup func()
}{
{
name: "found, verified",
s: Scanner{client: common.SaneHttpClient()},
Comment on lines +46 to +50
Copy link
Contributor

Choose a reason for hiding this comment

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

In case you want to keep the mock tests later on, you can inject common.ConstantResponseHttpClient with the required status code and response body, so you won't have to use gock.

args: args{
ctx: context.Background(),
data: []byte(fmt.Sprintf("You can find a smartling userId %s and secret %s within", userId, secret)),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detectorspb.DetectorType_Smartling,
Verified: true,
},
},
wantErr: false,
wantVerificationErr: false,
// TODO: Remove this once we have access to an actual Smartling account and can make direct API calls
gockSetup: func() {
// Expected request body
body := []byte(fmt.Sprintf(`{"userIdentifier":"%s","userSecret":"%s"}`, userId, secret))

gock.New("https://api.smartling.com").
Post("/auth-api/v2/authenticate").
Body(bytes.NewReader(body)).
Reply(http.StatusOK).
JSON(map[string]string{
"response": "ok",
})
},
},
{
name: "found, unverified",
s: Scanner{client: common.SaneHttpClient()},
args: args{
ctx: context.Background(),
data: []byte(fmt.Sprintf("You can find a smartling userId %s and secret %s within but not valid", inactiveUserId, inactiveSecret)), // the secret would satisfy the regex but not pass validation
verify: true,
},
want: []detectors.Result{
{
DetectorType: detectorspb.DetectorType_Smartling,
Verified: false,
},
},
wantErr: false,
wantVerificationErr: false,
// TODO: Remove this once we have access to an actual Smartling account and can make direct API calls
gockSetup: func() {
// Expected request body
body := []byte(fmt.Sprintf(`{"userIdentifier":"%s","userSecret":"%s"}`, inactiveUserId, inactiveSecret))

gock.New("https://api.smartling.com").
Post("/auth-api/v2/authenticate").
Body(bytes.NewReader(body)).
Reply(http.StatusUnauthorized).
JSON(map[string]string{
"response": "unauthorized",
})
},
},
{
name: "not found",
s: Scanner{client: common.SaneHttpClient()},
args: args{
ctx: context.Background(),
data: []byte("You cannot find the secret within"),
verify: true,
},
want: nil,
wantErr: false,
wantVerificationErr: false,
// TODO: Remove this once we have access to an actual Smartling account and can make direct API calls
gockSetup: func() {},
},
{
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 smartling userId %s and secret %s within", userId, secret)),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detectorspb.DetectorType_Smartling,
Verified: false,
},
},
wantErr: false,
wantVerificationErr: true,
// TODO: Remove this once we have access to an actual Smartling account and can make direct API calls
gockSetup: func() {},
},
{
name: "found, verified but unexpected api surface",
s: Scanner{client: common.ConstantResponseHttpClient(404, "")},
args: args{
ctx: context.Background(),
data: []byte(fmt.Sprintf("You can find a smartling userId %s and secret %s within", userId, secret)),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detectorspb.DetectorType_Smartling,
Verified: false,
},
},
wantErr: false,
wantVerificationErr: true,
// TODO: Remove this once we have access to an actual Smartling account and can make direct API calls
gockSetup: func() {},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// TODO: Remove this once we have access to an actual Smartling account and can make direct API calls
defer gock.Off()
defer gock.RestoreClient(tt.s.client)
gock.InterceptClient(tt.s.client)
tt.gockSetup()

got, err := tt.s.FromData(tt.args.ctx, tt.args.verify, tt.args.data)
if (err != nil) != tt.wantErr {
t.Errorf("Smartling.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", "RawV2", "primarySecret")
if diff := cmp.Diff(got, tt.want, ignoreOpts); diff != "" {
t.Errorf("Smartling.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