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
150 changes: 150 additions & 0 deletions pkg/detectors/persona/persona.go
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}
Copy link
Copy Markdown
Contributor

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.

}
Comment on lines +61 to +69
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

As pointed out, no need to populate AnalysisInfo here.

This whole block can be simplified to:

s1.Verified = isVerified
s1.ExtraData = extraData
s1.SetVerificationError(err, keyMatch)

}

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
Copy link
Copy Markdown
Contributor

@nabeelalam nabeelalam Mar 31, 2026

Choose a reason for hiding this comment

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

According to the Persona API error codes documentation a 403 Forbidden response indicates that credentials are valid but lack permission for this call.

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."
}
216 changes: 216 additions & 0 deletions pkg/detectors/persona/persona_test.go
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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())
}
})
}
}
2 changes: 2 additions & 0 deletions pkg/engine/defaults/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,7 @@ import (
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/peopledatalabs"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/pepipost"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/percy"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/persona"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/photoroom"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/phraseaccesstoken"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/pinata"
Expand Down Expand Up @@ -1436,6 +1437,7 @@ func buildDetectorList() []detectors.Detector {
&peopledatalabs.Scanner{},
&pepipost.Scanner{},
&percy.Scanner{},
&persona.Scanner{},
&photoroom.Scanner{},
&phraseaccesstoken.Scanner{},
&pinata.Scanner{},
Expand Down
Loading
Loading