diff --git a/pkg/detectors/lob/lob.go b/pkg/detectors/lob/lob.go index 7662ce29f..755bc79f4 100644 --- a/pkg/detectors/lob/lob.go +++ b/pkg/detectors/lob/lob.go @@ -2,6 +2,8 @@ package lob import ( "context" + "encoding/json" + "errors" "fmt" "net/http" "strings" @@ -23,8 +25,9 @@ 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. - keyPat = regexp.MustCompile(`\b((live|test)_[a-zA-Z0-9_]{35})\b`) + // Secret keys are a live_/test_ prefix followed by 35 lowercase hex characters. Publishable keys + // ((live|test)_pub_ + 31 hex) are excluded, as they are intended to be embedded in client-side code. + keyPat = regexp.MustCompile(`\b((live|test)_[a-f0-9]{35})\b`) ) func (s Scanner) getClient() *http.Client { @@ -86,10 +89,27 @@ func (s Scanner) verify(ctx context.Context, key string) (bool, error) { } defer func() { _ = res.Body.Close() }() switch res.StatusCode { - case http.StatusForbidden, http.StatusUnprocessableEntity: - // 403 indicates key is active but no billing method on file + case http.StatusUnprocessableEntity: // 422 indicates key is active but request body is invalid return true, nil + case http.StatusForbidden: + var body struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.NewDecoder(res.Body).Decode(&body); err != nil { + return false, errors.New("invalid API response") + } + switch body.Error.Code { + case "billing_address_required", "feature_limit_reached": + // The key authenticated and it is the account, not the key, that is rejected. + return true, nil + case "invalid_api_key": + return false, nil + default: + return false, fmt.Errorf("unexpected error code: <%s> in the API response body", body.Error.Code) + } case http.StatusUnauthorized: return false, nil default: diff --git a/pkg/detectors/lob/lob_test.go b/pkg/detectors/lob/lob_test.go index aea6564af..9c128084a 100644 --- a/pkg/detectors/lob/lob_test.go +++ b/pkg/detectors/lob/lob_test.go @@ -3,6 +3,9 @@ package lob import ( "context" "fmt" + "io" + "net/http" + "strings" "testing" "github.com/google/go-cmp/cmp" @@ -12,9 +15,10 @@ import ( ) var ( - validPattern = "live_0979969b3f6cc23ed67e9b650bfaf64f710" - validPatternTest = "test_0979969b3f6cc23ed67e9b650bfaf64f710" - invalidPattern = "live_0979969b3f6cc23ed67e9b650bfaf64f71" + validPattern = "live_0979969b3f6cc23ed67e9b650bfaf64f710" + validPatternTest = "test_0979969b3f6cc23ed67e9b650bfaf64f710" + invalidPattern = "live_0979969b3f6cc23ed67e9b650bfaf64f71" + publishablePattern = "live_pub_0979969b3f6cc23ed67e9b650bfaf64" ) func TestLob_Pattern(t *testing.T) { @@ -45,6 +49,21 @@ func TestLob_Pattern(t *testing.T) { input: fmt.Sprintf("'%s'", invalidPattern), want: []string{}, }, + { + name: "snake_case identifier of key length", + input: "def test_calculate_total_price_with_tax_rate():", + want: []string{}, + }, + { + name: "camelCase identifier of key length", + input: "func test_someVeryLongCamelCaseFunctionNameXy() {}", + want: []string{}, + }, + { + name: "publishable key", + input: fmt.Sprintf("token = '%s'", publishablePattern), + want: []string{}, + }, } for _, test := range tests { @@ -89,3 +108,100 @@ func TestLob_Pattern(t *testing.T) { }) } } + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +func TestLob_Verify(t *testing.T) { + lobError := func(code string) string { + return fmt.Sprintf(`{"error":{"message":"...","status_code":403,"code":%q}}`, code) + } + + tests := []struct { + name string + status int + contentType string + body string + want bool + wantErr bool + }{ + { + name: "active key", + status: http.StatusUnprocessableEntity, + body: `{"error":{"message":"...","status_code":422,"code":"invalid"}}`, + want: true, + }, + { + name: "invalid key", + status: http.StatusUnauthorized, + body: `{"error":{"message":"...","status_code":401,"code":"invalid_api_key"}}`, + want: false, + }, + { + name: "active key without a billing address", + status: http.StatusForbidden, + body: lobError("billing_address_required"), + want: true, + }, + { + name: "active key past its free request quota", + status: http.StatusForbidden, + body: lobError("feature_limit_reached"), + want: true, + }, + { + name: "invalid key reported as forbidden", + status: http.StatusForbidden, + body: lobError("invalid_api_key"), + want: false, + }, + { + name: "unrecognized error code", + status: http.StatusForbidden, + body: lobError("some_future_code"), + wantErr: true, + }, + { + name: "forbidden without an API response", + status: http.StatusForbidden, + contentType: "text/html; charset=UTF-8", + body: "