-
Notifications
You must be signed in to change notification settings - Fork 2.3k
Add Persona (withpersona.com) API key detector #4851
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 all commits
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,150 @@ | ||
| package persona | ||
|
|
||
| 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" | ||
| ) | ||
|
|
||
| type Scanner struct { | ||
| client *http.Client | ||
| } | ||
|
|
||
| // Ensure the Scanner satisfies the interface at compile time. | ||
| var _ detectors.Detector = (*Scanner)(nil) | ||
|
|
||
| var ( | ||
| defaultClient = common.SaneHttpClient() | ||
| keyPat = regexp.MustCompile(`\b(persona_(?:sandbox|production)_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b`) | ||
| ) | ||
|
|
||
| func (s Scanner) Keywords() []string { | ||
| return []string{"persona_sandbox_", "persona_production_"} | ||
| } | ||
|
|
||
| func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) { | ||
| dataStr := string(data) | ||
|
|
||
| keys := keyPat.FindAllStringSubmatch(dataStr, -1) | ||
|
|
||
| for _, key := range keys { | ||
| keyMatch := strings.TrimSpace(key[1]) | ||
|
|
||
| s1 := detectors.Result{ | ||
| DetectorType: detectorspb.DetectorType_Persona, | ||
| Raw: []byte(keyMatch), | ||
| ExtraData: make(map[string]string), | ||
| } | ||
|
|
||
| if strings.Contains(keyMatch, "_sandbox_") { | ||
| s1.ExtraData["Type"] = "Sandbox Key" | ||
| } else { | ||
| s1.ExtraData["Type"] = "Production Key" | ||
| } | ||
|
|
||
| if verify { | ||
| client := s.client | ||
| if client == nil { | ||
| client = defaultClient | ||
| } | ||
|
|
||
| isVerified, extraData, err := verifyPersonaKey(ctx, client, keyMatch) | ||
| s1.Verified = isVerified | ||
| s1.SetVerificationError(err, keyMatch) | ||
|
|
||
| if isVerified { | ||
| for k, v := range extraData { | ||
| s1.ExtraData[k] = v | ||
| } | ||
| s1.AnalysisInfo = map[string]string{"key": keyMatch} | ||
| } | ||
|
Comment on lines
+61
to
+69
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. As pointed out, no need to populate This whole block can be simplified to: |
||
| } | ||
|
|
||
| results = append(results, s1) | ||
| } | ||
|
|
||
| return results, nil | ||
| } | ||
|
|
||
| func verifyPersonaKey(ctx context.Context, client *http.Client, key string) (bool, map[string]string, error) { | ||
| req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://withpersona.com/api/v1/api-keys/permissions", http.NoBody) | ||
| if err != nil { | ||
| return false, nil, nil | ||
|
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. Is there a reason we’re not returning the error here? |
||
| } | ||
|
|
||
| req.Header.Set("Authorization", "Bearer "+key) | ||
| req.Header.Set("Persona-Version", "2023-01-05") | ||
|
|
||
| res, err := client.Do(req) | ||
| if err != nil { | ||
| return false, nil, err | ||
| } | ||
| defer func() { _ = res.Body.Close() }() | ||
|
|
||
| switch res.StatusCode { | ||
| case http.StatusOK: | ||
| extraData := make(map[string]string) | ||
|
|
||
| if orgID := res.Header.Get("Persona-Organization-Id"); orgID != "" { | ||
| extraData["Organization"] = orgID | ||
| } | ||
| if envID := res.Header.Get("Persona-Environment-Id"); envID != "" { | ||
| extraData["Environment"] = envID | ||
| } | ||
|
|
||
| body, err := io.ReadAll(res.Body) | ||
| if err == nil { | ||
| var resp apiKeyResponse | ||
| if json.Unmarshal(body, &resp) == nil { | ||
| if resp.Data.ID != "" { | ||
| extraData["ID"] = resp.Data.ID | ||
| } | ||
| if resp.Data.Attributes.Name != "" { | ||
| extraData["Name"] = resp.Data.Attributes.Name | ||
| } | ||
| if len(resp.Data.Attributes.Permissions) > 0 { | ||
| extraData["Permissions"] = strings.Join(resp.Data.Attributes.Permissions, ", ") | ||
| } | ||
| if resp.Data.Attributes.ExpiresAt != "" { | ||
| extraData["Expires_At"] = resp.Data.Attributes.ExpiresAt | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return true, extraData, nil | ||
|
|
||
| case http.StatusUnauthorized, http.StatusForbidden: | ||
| return false, nil, nil | ||
|
Comment on lines
+125
to
+126
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. According to the Persona API error codes documentation a I'm not sure how accurate it would be to unverify these credentials in this case. |
||
|
|
||
| default: | ||
| return false, nil, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode) | ||
| } | ||
| } | ||
|
|
||
| type apiKeyResponse struct { | ||
| Data struct { | ||
| ID string `json:"id"` | ||
| Attributes struct { | ||
| Name string `json:"name"` | ||
| Permissions []string `json:"permissions"` | ||
| ExpiresAt string `json:"expires_at"` | ||
| } `json:"attributes"` | ||
| } `json:"data"` | ||
| } | ||
|
|
||
| func (s Scanner) Type() detectorspb.DetectorType { | ||
| return detectorspb.DetectorType_Persona | ||
| } | ||
|
|
||
| func (s Scanner) Description() string { | ||
| return "Persona is an identity verification platform. API keys can be used to access their identity verification and management services." | ||
| } | ||
|
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. Thanks for the tests. This is great. One small suggestion: it would be nice to follow the convention of separating unit tests and integration tests (you can check out this example for reference). Also, for handling keys in tests, the “Testing the Detector” guide is a helpful resource to follow. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,216 @@ | ||
| package persona | ||
|
|
||
| import ( | ||
| "context" | ||
| "io" | ||
| "net/http" | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "github.com/google/go-cmp/cmp" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
|
|
||
| "github.com/trufflesecurity/trufflehog/v3/pkg/common" | ||
| "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" | ||
| "github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick" | ||
| ) | ||
|
|
||
| func TestPersona_Pattern(t *testing.T) { | ||
| d := Scanner{} | ||
| ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d}) | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| input string | ||
| want []string | ||
| }{ | ||
| { | ||
| name: "valid sandbox key", | ||
| input: `persona_api_key = "persona_sandbox_550e8400-e29b-41d4-a716-446655440000"`, | ||
| want: []string{"persona_sandbox_550e8400-e29b-41d4-a716-446655440000"}, | ||
| }, | ||
| { | ||
| name: "valid production key", | ||
| input: `PERSONA_KEY=persona_production_abcdef01-2345-6789-abcd-ef0123456789`, | ||
| want: []string{"persona_production_abcdef01-2345-6789-abcd-ef0123456789"}, | ||
| }, | ||
| { | ||
| name: "both keys in same input", | ||
| input: ` | ||
| sandbox: persona_sandbox_550e8400-e29b-41d4-a716-446655440000 | ||
| production: persona_production_abcdef01-2345-6789-abcd-ef0123456789 | ||
| `, | ||
| want: []string{ | ||
| "persona_sandbox_550e8400-e29b-41d4-a716-446655440000", | ||
| "persona_production_abcdef01-2345-6789-abcd-ef0123456789", | ||
| }, | ||
| }, | ||
| { | ||
| name: "truncated UUID - invalid", | ||
| input: `key = persona_sandbox_550e8400-e29b-41d4-a716`, | ||
| want: nil, | ||
| }, | ||
| { | ||
| name: "uppercase hex - invalid", | ||
| input: `key = persona_sandbox_550E8400-E29B-41D4-A716-446655440000`, | ||
| want: nil, | ||
| }, | ||
| { | ||
| name: "wrong prefix - invalid", | ||
| input: `key = persona_staging_550e8400-e29b-41d4-a716-446655440000`, | ||
| want: nil, | ||
| }, | ||
| } | ||
|
|
||
| for _, test := range tests { | ||
| t.Run(test.name, func(t *testing.T) { | ||
| matchedDetectors := ahoCorasickCore.FindDetectorMatches([]byte(test.input)) | ||
| if len(matchedDetectors) == 0 { | ||
| if len(test.want) > 0 { | ||
| t.Errorf("test %q failed: expected keywords %v to be found in the input", test.name, d.Keywords()) | ||
| } | ||
| return | ||
| } | ||
|
|
||
| results, err := d.FromData(context.Background(), false, []byte(test.input)) | ||
| require.NoError(t, err) | ||
|
|
||
| if len(results) != len(test.want) { | ||
| t.Errorf("mismatch in result count: expected %d, got %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) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestPersona_Verification(t *testing.T) { | ||
| responseBody := `{"data":{"type":"api-key","id":"api_TvSRN76WJ6KsTzZFd4DEUDDF","attributes":{"name":"My Test Key","permissions":["account.read","inquiry.read","inquiry.write"],"expires_at":"2026-12-31T00:00:00.000Z"}}}` | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| input string | ||
| client *http.Client | ||
| wantVerified bool | ||
| wantExtraData map[string]string | ||
| wantErr bool | ||
| }{ | ||
| { | ||
| name: "verified with full response", | ||
| input: "persona_production_abcdef01-2345-6789-abcd-ef0123456789", | ||
| client: &http.Client{ | ||
| Transport: common.FakeTransport{ | ||
| CreateResponse: func(req *http.Request) (*http.Response, error) { | ||
| resp := &http.Response{ | ||
| Request: req, | ||
| StatusCode: http.StatusOK, | ||
| Body: io.NopCloser(strings.NewReader(responseBody)), | ||
| Header: make(http.Header), | ||
| } | ||
| resp.Header.Set("Persona-Organization-Id", "org_abc123") | ||
| resp.Header.Set("Persona-Environment-Id", "env_xyz789") | ||
| return resp, nil | ||
| }, | ||
| }, | ||
| }, | ||
| wantVerified: true, | ||
| wantExtraData: map[string]string{ | ||
| "Type": "Production Key", | ||
| "ID": "api_TvSRN76WJ6KsTzZFd4DEUDDF", | ||
| "Organization": "org_abc123", | ||
| "Environment": "env_xyz789", | ||
| "Name": "My Test Key", | ||
| "Permissions": "account.read, inquiry.read, inquiry.write", | ||
| "Expires_At": "2026-12-31T00:00:00.000Z", | ||
| }, | ||
| }, | ||
| { | ||
| name: "verified sandbox key with minimal response", | ||
| input: "persona_sandbox_abcdef01-2345-6789-abcd-ef0123456789", | ||
| client: &http.Client{ | ||
| Transport: common.FakeTransport{ | ||
| CreateResponse: func(req *http.Request) (*http.Response, error) { | ||
| return &http.Response{ | ||
| Request: req, | ||
| StatusCode: http.StatusOK, | ||
| Body: io.NopCloser(strings.NewReader(`{"data":{"type":"api-key","id":"api_min123","attributes":{"permissions":["account.read"]}}}`)), | ||
| Header: make(http.Header), | ||
| }, nil | ||
| }, | ||
| }, | ||
| }, | ||
| wantVerified: true, | ||
| wantExtraData: map[string]string{ | ||
| "Type": "Sandbox Key", | ||
| "ID": "api_min123", | ||
| "Permissions": "account.read", | ||
| }, | ||
| }, | ||
| { | ||
| name: "unverified - 401", | ||
| input: "persona_production_abcdef01-2345-6789-abcd-ef0123456789", | ||
| client: common.ConstantResponseHttpClient(http.StatusUnauthorized, `{"errors":[{"title":"Must be authenticated"}]}`), | ||
| wantVerified: false, | ||
| wantExtraData: map[string]string{ | ||
| "Type": "Production Key", | ||
| }, | ||
| }, | ||
| { | ||
| name: "unverified - 403", | ||
| input: "persona_production_abcdef01-2345-6789-abcd-ef0123456789", | ||
| client: common.ConstantResponseHttpClient(http.StatusForbidden, ""), | ||
| wantVerified: false, | ||
| wantExtraData: map[string]string{ | ||
| "Type": "Production Key", | ||
| }, | ||
| }, | ||
| { | ||
| name: "error - unexpected status", | ||
| input: "persona_production_abcdef01-2345-6789-abcd-ef0123456789", | ||
| client: common.ConstantResponseHttpClient(http.StatusInternalServerError, ""), | ||
| wantVerified: false, | ||
| wantExtraData: map[string]string{ | ||
| "Type": "Production Key", | ||
| }, | ||
| wantErr: true, | ||
| }, | ||
| } | ||
|
|
||
| for _, test := range tests { | ||
| t.Run(test.name, func(t *testing.T) { | ||
| d := Scanner{client: test.client} | ||
|
|
||
| results, err := d.FromData(context.Background(), true, []byte(test.input)) | ||
| require.NoError(t, err) | ||
| require.Len(t, results, 1) | ||
|
|
||
| r := results[0] | ||
| assert.Equal(t, test.wantVerified, r.Verified) | ||
| assert.Equal(t, test.wantExtraData, r.ExtraData) | ||
|
|
||
| if test.wantErr { | ||
| assert.Error(t, r.VerificationError()) | ||
| } else { | ||
| assert.NoError(t, r.VerificationError()) | ||
| } | ||
| }) | ||
| } | ||
| } |
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.
No need for analysis, as there is currently no Analyzer available for this secret type.