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
58 changes: 56 additions & 2 deletions pkg/detectors/nvapi/nvapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package nvapi

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -67,6 +68,20 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
return
}

type callerInfoResponse struct {
Type string `json:"type"`
User struct {
Name string `json:"name"`
Email string `json:"email"`
Roles []struct {
Org struct {
DisplayName string `json:"displayName"`
} `json:"org"`
OrgRoles []string `json:"orgRoles"`
} `json:"roles"`
} `json:"user"`
}

func verifyMatch(ctx context.Context, client *http.Client, token string) (bool, map[string]string, error) {
data := url.Values{}
data.Set("credentials", token)
Expand All @@ -89,8 +104,47 @@ func verifyMatch(ctx context.Context, client *http.Client, token string) (bool,

switch res.StatusCode {
case http.StatusOK:
// If the endpoint returns useful information, we can return it as a map.
return true, nil, nil
var response callerInfoResponse
if err := json.NewDecoder(res.Body).Decode(&response); err != nil {
return true, nil, nil
}

extraData := map[string]string{
"type": response.Type,
"user_name": response.User.Name,
"user_email": response.User.Email,
}

// Collect distinct org display names and roles across all roles
orgDisplayNames := make(map[string]struct{})
orgRoles := make(map[string]struct{})

for _, role := range response.User.Roles {
if role.Org.DisplayName != "" {
orgDisplayNames[role.Org.DisplayName] = struct{}{}
}
for _, r := range role.OrgRoles {
orgRoles[r] = struct{}{}
}
}

if len(orgDisplayNames) > 0 {
names := make([]string, 0, len(orgDisplayNames))
for name := range orgDisplayNames {
names = append(names, name)
}
extraData["org_display_names"] = strings.Join(names, ", ")
}

if len(orgRoles) > 0 {
roles := make([]string, 0, len(orgRoles))
for role := range orgRoles {
roles = append(roles, role)
}
extraData["org_roles"] = strings.Join(roles, ", ")
}

return true, extraData, nil
case http.StatusUnauthorized:
// The secret is determinately not verified (nothing to do)
return false, nil, nil
Expand Down
172 changes: 172 additions & 0 deletions pkg/detectors/nvapi/nvapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ import (
"testing"

"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"

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

func TestNvapi_Pattern(t *testing.T) {
Expand Down Expand Up @@ -67,3 +70,172 @@ func TestNvapi_Pattern(t *testing.T) {
})
}
}

func TestNvapi_Verification(t *testing.T) {
validToken := "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"

// Mock response with multiple roles containing duplicate orgs and roles
mockResponse := `{
"type": "PERSONAL_KEY",
"user": {
"name": "testuser@example.com",
"email": "testuser@example.com",
"roles": [
{
"org": {"displayName": "Test-Org"},
"orgRoles": ["ROLE_A", "ROLE_B", "ROLE_C"]
},
{
"org": {"displayName": "Test-Org"},
"orgRoles": ["ROLE_A", "ROLE_B", "ROLE_D"]
},
{
"org": {"displayName": "Another-Org"},
"orgRoles": ["ROLE_C", "ROLE_E"]
}
]
}
}`

tests := []struct {
name string
s Scanner
input string
verify bool
want []detectors.Result
wantErr bool
}{
{
name: "found, verified with extraData",
s: Scanner{client: common.ConstantResponseHttpClient(200, mockResponse)},
input: "nvapi_token = '" + validToken + "'",
verify: true,
want: []detectors.Result{
{
DetectorType: detectorspb.DetectorType_NVAPI,
Verified: true,
ExtraData: map[string]string{
"type": "PERSONAL_KEY",
"user_name": "testuser@example.com",
"user_email": "testuser@example.com",
"org_display_names": "Test-Org, Another-Org",
"org_roles": "ROLE_A, ROLE_B, ROLE_C, ROLE_D, ROLE_E",
},
},
},
wantErr: false,
},
{
name: "found, unverified (401)",
s: Scanner{client: common.ConstantResponseHttpClient(401, "")},
input: "nvapi_token = '" + validToken + "'",
verify: true,
want: []detectors.Result{
{
DetectorType: detectorspb.DetectorType_NVAPI,
Verified: false,
},
},
wantErr: false,
},
{
name: "found, no verification",
s: Scanner{},
input: "nvapi_token = '" + validToken + "'",
verify: false,
want: []detectors.Result{
{
DetectorType: detectorspb.DetectorType_NVAPI,
Verified: false,
},
},
wantErr: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.s.FromData(context.Background(), tt.verify, []byte(tt.input))
if (err != nil) != tt.wantErr {
t.Errorf("Scanner.FromData() error = %v, wantErr %v", err, tt.wantErr)
return
}

if len(got) != len(tt.want) {
t.Errorf("Scanner.FromData() got %d results, want %d", len(got), len(tt.want))
return
}

// For ExtraData comparison, we need to check that all expected keys exist
// with the correct values, but the order of comma-separated values may vary
for i := range got {
if got[i].Verified != tt.want[i].Verified {
t.Errorf("Verified = %v, want %v", got[i].Verified, tt.want[i].Verified)
}
if got[i].DetectorType != tt.want[i].DetectorType {
t.Errorf("DetectorType = %v, want %v", got[i].DetectorType, tt.want[i].DetectorType)
}

if tt.want[i].ExtraData != nil {
if got[i].ExtraData == nil {
t.Errorf("ExtraData is nil, want %v", tt.want[i].ExtraData)
continue
}

// Check non-list fields exactly
for _, key := range []string{"type", "user_name", "user_email"} {
if got[i].ExtraData[key] != tt.want[i].ExtraData[key] {
t.Errorf("ExtraData[%s] = %v, want %v", key, got[i].ExtraData[key], tt.want[i].ExtraData[key])
}
}

// Check that org_display_names and org_roles contain all expected values (order may vary)
for _, key := range []string{"org_display_names", "org_roles"} {
gotValues := parseCommaSeparated(got[i].ExtraData[key])
wantValues := parseCommaSeparated(tt.want[i].ExtraData[key])
if diff := cmp.Diff(wantValues, gotValues, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
t.Errorf("ExtraData[%s] diff: (-want +got)\n%s", key, diff)
}
}
}
}
})
}
}

func parseCommaSeparated(s string) []string {
if s == "" {
return nil
}
var result []string
for _, v := range splitAndTrim(s, ",") {
if v != "" {
result = append(result, v)
}
}
return result
}

func splitAndTrim(s, sep string) []string {
var result []string
start := 0
for i := 0; i < len(s); i++ {
if i+len(sep) <= len(s) && s[i:i+len(sep)] == sep {
result = append(result, trim(s[start:i]))
start = i + len(sep)
}
}
result = append(result, trim(s[start:]))
return result
}

func trim(s string) string {
start, end := 0, len(s)
for start < end && s[start] == ' ' {
start++
}
for end > start && s[end-1] == ' ' {
end--
}
return s[start:end]
}
Loading