Skip to content

Commit c1f2e91

Browse files
LaunchDarkly Token Analyzer (#3948)
* initial commit * added more apis * added test cases * removed imposter print statement * updated some code * removed id from printResources * added nabeel suggestion and set analysis info * resolved ahrav comments * resolved ahrav comments * implemented ahrav's suggestion 🔥 * resolved linter error
1 parent 21fbe08 commit c1f2e91

File tree

11 files changed

+1589
-0
lines changed

11 files changed

+1589
-0
lines changed

pkg/analyzer/analyzers/analyzers.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"sort"
99

1010
"github.com/fatih/color"
11+
1112
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
1213
)
1314

@@ -90,6 +91,7 @@ const (
9091
AnalyzerTypeAirtableOAuth
9192
AnalyzerTypeAirtablePat
9293
AnalyzerTypeGroq
94+
AnalyzerTypeLaunchDarkly
9395
// Add new items here with AnalyzerType prefix
9496
)
9597

@@ -126,6 +128,7 @@ var analyzerTypeStrings = map[AnalyzerType]string{
126128
AnalyzerTypeAirtableOAuth: "AirtableOAuth",
127129
AnalyzerTypeAirtablePat: "AirtablePat",
128130
AnalyzerTypeGroq: "Groq",
131+
AnalyzerTypeLaunchDarkly: "LaunchDarkly",
129132
// Add new mappings here
130133
}
131134

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
//go:generate generate_permissions permissions.yaml permissions.go launchdarkly
2+
package launchdarkly
3+
4+
import (
5+
"errors"
6+
"fmt"
7+
"os"
8+
9+
"github.com/fatih/color"
10+
"github.com/jedib0t/go-pretty/v6/table"
11+
12+
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers"
13+
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config"
14+
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
15+
)
16+
17+
var _ analyzers.Analyzer = (*Analyzer)(nil)
18+
19+
type Analyzer struct {
20+
Cfg *config.Config
21+
}
22+
23+
func (a Analyzer) Type() analyzers.AnalyzerType {
24+
return analyzers.AnalyzerTypeLaunchDarkly
25+
}
26+
27+
func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) {
28+
// check if the `key` exist in the credentials info
29+
key, exist := credInfo["key"]
30+
if !exist {
31+
return nil, errors.New("key not found in credentials info")
32+
}
33+
34+
info, err := AnalyzePermissions(a.Cfg, key)
35+
if err != nil {
36+
return nil, err
37+
}
38+
39+
return secretInfoToAnalyzerResult(info), nil
40+
}
41+
42+
func AnalyzeAndPrintPermissions(cfg *config.Config, token string) {
43+
info, err := AnalyzePermissions(cfg, token)
44+
if err != nil {
45+
// just print the error in cli and continue as a partial success
46+
color.Red("[x] Error : %s", err.Error())
47+
}
48+
49+
if info == nil {
50+
color.Red("[x] Error : %s", "No information found")
51+
return
52+
}
53+
54+
color.Green("[i] Valid LaunchDarkly Token\n")
55+
printUser(info.User)
56+
printPermissionsType(info.User.Token)
57+
printResources(info.Resources)
58+
59+
color.Yellow("\n[!] Expires: Never")
60+
}
61+
62+
// AnalyzePermissions will collect all the scopes assigned to token along with resource it can access
63+
func AnalyzePermissions(cfg *config.Config, token string) (*SecretInfo, error) {
64+
// create the http client
65+
client := analyzers.NewAnalyzeClient(cfg)
66+
67+
var secretInfo = &SecretInfo{}
68+
69+
// capture user information in secretInfo
70+
if err := CaptureUserInformation(client, token, secretInfo); err != nil {
71+
return nil, fmt.Errorf("failed to fetch caller identity: %v", err)
72+
}
73+
74+
// capture resources in secretInfo
75+
if err := CaptureResources(client, token, secretInfo); err != nil {
76+
return nil, fmt.Errorf("failed to fetch resources: %v", err)
77+
}
78+
79+
return secretInfo, nil
80+
}
81+
82+
// secretInfoToAnalyzerResult translate secret info to Analyzer Result
83+
func secretInfoToAnalyzerResult(info *SecretInfo) *analyzers.AnalyzerResult {
84+
if info == nil {
85+
return nil
86+
}
87+
88+
result := analyzers.AnalyzerResult{
89+
AnalyzerType: analyzers.AnalyzerTypeElevenLabs,
90+
Metadata: map[string]any{},
91+
Bindings: make([]analyzers.Binding, 0),
92+
}
93+
94+
// extract information from resource to create bindings and append to result bindings
95+
for _, resource := range info.Resources {
96+
binding := analyzers.Binding{
97+
Resource: *secretInfoResourceToAnalyzerResource(resource),
98+
Permission: analyzers.Permission{
99+
Value: getPermissionType(info.User.Token),
100+
},
101+
}
102+
103+
if resource.ParentResource != nil {
104+
binding.Resource.Parent = secretInfoResourceToAnalyzerResource(*resource.ParentResource)
105+
}
106+
107+
result.Bindings = append(result.Bindings, binding)
108+
109+
}
110+
111+
return &result
112+
}
113+
114+
// secretInfoResourceToAnalyzerResource translate secret info resource to analyzer resource for binding
115+
func secretInfoResourceToAnalyzerResource(resource Resource) *analyzers.Resource {
116+
analyzerRes := analyzers.Resource{
117+
FullyQualifiedName: resource.ID,
118+
Name: resource.Name,
119+
Type: resource.Type,
120+
Metadata: map[string]any{},
121+
}
122+
123+
for key, value := range resource.MetaData {
124+
analyzerRes.Metadata[key] = value
125+
}
126+
127+
return &analyzerRes
128+
}
129+
130+
// getPermissionType return what type of permission is assigned to token
131+
func getPermissionType(token Token) string {
132+
switch {
133+
case token.Role != "":
134+
return token.Role
135+
case token.hasInlineRole():
136+
return "Inline Policy"
137+
case token.hasCustomRoles():
138+
return "Custom Roles"
139+
default:
140+
return ""
141+
}
142+
}
143+
144+
// printUser print User information from secret info to cli
145+
func printUser(user User) {
146+
// print caller information
147+
color.Green("\n[i] User Information:")
148+
callerTable := table.NewWriter()
149+
callerTable.SetOutputMirror(os.Stdout)
150+
callerTable.AppendHeader(table.Row{"Account ID", "Member ID", "Name", "Email", "Role"})
151+
callerTable.AppendRow(table.Row{color.GreenString(user.AccountID), color.GreenString(user.MemberID),
152+
color.GreenString(user.Name), color.GreenString(user.Email), color.GreenString(user.Role)})
153+
154+
callerTable.Render()
155+
156+
// print token information
157+
color.Green("\n[i] Token Information")
158+
tokenTable := table.NewWriter()
159+
tokenTable.SetOutputMirror(os.Stdout)
160+
161+
tokenTable.AppendHeader(table.Row{"ID", "Name", "Role", "Is Service Token", "Default API Version",
162+
"No of Custom Roles Assigned", "Has Inline Policy"})
163+
164+
tokenTable.AppendRow(table.Row{color.GreenString(user.Token.ID), color.GreenString(user.Token.Name), color.GreenString(user.Token.Role),
165+
color.GreenString(fmt.Sprintf("%t", user.Token.IsServiceToken)), color.GreenString(fmt.Sprintf("%d", user.Token.APIVersion)),
166+
color.GreenString(fmt.Sprintf("%d", len(user.Token.CustomRoles))), color.GreenString(fmt.Sprintf("%t", user.Token.hasInlineRole()))})
167+
168+
tokenTable.Render()
169+
170+
// print custom roles information
171+
if !user.Token.hasCustomRoles() {
172+
return
173+
}
174+
175+
// print token information
176+
color.Green("\n[i] Custom Roles Assigned to Token")
177+
rolesTable := table.NewWriter()
178+
rolesTable.SetOutputMirror(os.Stdout)
179+
rolesTable.AppendHeader(table.Row{"ID", "Key", "Name", "Base Permission", "Assigned to members", "Assigned to teams"})
180+
for _, customRole := range user.Token.CustomRoles {
181+
rolesTable.AppendRow(table.Row{color.GreenString(customRole.ID), color.GreenString(customRole.Key), color.GreenString(customRole.Name),
182+
color.GreenString(customRole.BasePermission), color.GreenString(fmt.Sprintf("%d", customRole.AssignedToMembers)),
183+
color.GreenString(fmt.Sprintf("%d", customRole.AssignedToTeams))})
184+
}
185+
rolesTable.Render()
186+
}
187+
188+
// printPermissionsType print permissions type token has
189+
func printPermissionsType(token Token) {
190+
// print permission type. It can be either admin, writer, reader or has inline policy or any custom roles assigned
191+
color.Green("\n[i] Permission Type: %s", getPermissionType(token))
192+
}
193+
194+
func printResources(resources []Resource) {
195+
// print resources
196+
color.Green("\n[i] Resources:")
197+
callerTable := table.NewWriter()
198+
callerTable.SetOutputMirror(os.Stdout)
199+
callerTable.AppendHeader(table.Row{"Name", "Type"})
200+
for _, resource := range resources {
201+
callerTable.AppendRow(table.Row{color.GreenString(resource.Name), color.GreenString(resource.Type)})
202+
}
203+
callerTable.Render()
204+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package launchdarkly
2+
3+
import (
4+
_ "embed"
5+
"encoding/json"
6+
"sort"
7+
"testing"
8+
"time"
9+
10+
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers"
11+
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config"
12+
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
13+
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
14+
)
15+
16+
//go:embed result_output.json
17+
var expectedOutput []byte
18+
19+
func TestAnalyzer_Analyze(t *testing.T) {
20+
ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5)
21+
defer cancel()
22+
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors3")
23+
if err != nil {
24+
t.Fatalf("could not get test secrets from GCP: %s", err)
25+
}
26+
27+
key := testSecrets.MustGetField("LAUNCHDARKLY_TOKEN")
28+
29+
tests := []struct {
30+
name string
31+
key string
32+
want []byte // JSON string
33+
wantErr bool
34+
}{
35+
{
36+
name: "valid LauncgDarkly token",
37+
key: key,
38+
want: expectedOutput,
39+
wantErr: false,
40+
},
41+
}
42+
43+
for _, tt := range tests {
44+
t.Run(tt.name, func(t *testing.T) {
45+
a := Analyzer{Cfg: &config.Config{}}
46+
got, err := a.Analyze(ctx, map[string]string{"key": tt.key})
47+
if (err != nil) != tt.wantErr {
48+
t.Errorf("Analyzer.Analyze() error = %v, wantErr %v", err, tt.wantErr)
49+
return
50+
}
51+
52+
// Bindings need to be in the same order to be comparable
53+
sortBindings(got.Bindings)
54+
55+
// Marshal the actual result to JSON
56+
gotJSON, err := json.Marshal(got)
57+
if err != nil {
58+
t.Fatalf("could not marshal got to JSON: %s", err)
59+
}
60+
61+
// Parse the expected JSON string
62+
var wantObj analyzers.AnalyzerResult
63+
if err := json.Unmarshal([]byte(tt.want), &wantObj); err != nil {
64+
t.Fatalf("could not unmarshal want JSON string: %s", err)
65+
}
66+
67+
// Bindings need to be in the same order to be comparable
68+
sortBindings(wantObj.Bindings)
69+
70+
// Marshal the expected result to JSON (to normalize)
71+
wantJSON, err := json.Marshal(wantObj)
72+
if err != nil {
73+
t.Fatalf("could not marshal want to JSON: %s", err)
74+
}
75+
76+
// Compare the JSON strings
77+
if string(gotJSON) != string(wantJSON) {
78+
// Pretty-print both JSON strings for easier comparison
79+
var gotIndented, wantIndented []byte
80+
gotIndented, err = json.MarshalIndent(got, "", " ")
81+
if err != nil {
82+
t.Fatalf("could not marshal got to indented JSON: %s", err)
83+
}
84+
wantIndented, err = json.MarshalIndent(wantObj, "", " ")
85+
if err != nil {
86+
t.Fatalf("could not marshal want to indented JSON: %s", err)
87+
}
88+
t.Errorf("Analyzer.Analyze() = %s, want %s", gotIndented, wantIndented)
89+
}
90+
})
91+
}
92+
}
93+
94+
// Helper function to sort bindings
95+
func sortBindings(bindings []analyzers.Binding) {
96+
sort.SliceStable(bindings, func(i, j int) bool {
97+
if bindings[i].Resource.Name == bindings[j].Resource.Name {
98+
return bindings[i].Permission.Value < bindings[j].Permission.Value
99+
}
100+
return bindings[i].Resource.Name < bindings[j].Resource.Name
101+
})
102+
}

0 commit comments

Comments
 (0)