-
Notifications
You must be signed in to change notification settings - Fork 2.2k
Add draft detector for the Smartling API Secrets [INS-38] #4459
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 4 commits
2e6136c
ec6d295
f5f042d
af7a50b
764a36c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) { | ||
| 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") | ||
|
||
| 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." | ||
| } | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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) | ||
| } | ||
| } | ||
| }) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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]stringsince it's alwaysnilUh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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.