Skip to content

Commit 03e8af1

Browse files
authored
[Feat] DigitalOcean Analyzer (#3932)
* digitalocean analyzer implementation * optimize checkPermissions function to concurrently run tests * fixed conflict issues. better commenting updated the go-pretty import updated the expected output. * remove unused functions.
1 parent 8724d50 commit 03e8af1

File tree

10 files changed

+1971
-27
lines changed

10 files changed

+1971
-27
lines changed

pkg/analyzer/analyzers/analyzers.go

Lines changed: 28 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -85,37 +85,39 @@ const (
8585
AnalyzerTypePrivateKey
8686
AnalyzerTypeNotion
8787
AnalyzerTypeAirtable
88+
AnalyzerTypeDigitalOcean
8889
// Add new items here with AnalyzerType prefix
8990
)
9091

9192
// analyzerTypeStrings maps the enum to its string representation.
9293
var analyzerTypeStrings = map[AnalyzerType]string{
93-
AnalyzerTypeInvalid: "Invalid",
94-
AnalyzerTypeAirbrake: "Airbrake",
95-
AnalyzerTypeAirtable: "Airtable",
96-
AnalyzerAnthropic: "Anthropic",
97-
AnalyzerTypeAsana: "Asana",
98-
AnalyzerTypeBitbucket: "Bitbucket",
99-
AnalyzerTypeDockerHub: "DockerHub",
100-
AnalyzerTypeGitHub: "GitHub",
101-
AnalyzerTypeGitLab: "GitLab",
102-
AnalyzerTypeHuggingFace: "HuggingFace",
103-
AnalyzerTypeMailchimp: "Mailchimp",
104-
AnalyzerTypeMailgun: "Mailgun",
105-
AnalyzerTypeMySQL: "MySQL",
106-
AnalyzerTypeOpenAI: "OpenAI",
107-
AnalyzerTypeOpsgenie: "Opsgenie",
108-
AnalyzerTypePostgres: "Postgres",
109-
AnalyzerTypePostman: "Postman",
110-
AnalyzerTypeSendgrid: "Sendgrid",
111-
AnalyzerTypeShopify: "Shopify",
112-
AnalyzerTypeSlack: "Slack",
113-
AnalyzerTypeSourcegraph: "Sourcegraph",
114-
AnalyzerTypeSquare: "Square",
115-
AnalyzerTypeStripe: "Stripe",
116-
AnalyzerTypeTwilio: "Twilio",
117-
AnalyzerTypePrivateKey: "PrivateKey",
118-
AnalyzerTypeNotion: "Notion",
94+
AnalyzerTypeInvalid: "Invalid",
95+
AnalyzerTypeAirbrake: "Airbrake",
96+
AnalyzerTypeAirtable: "Airtable",
97+
AnalyzerAnthropic: "Anthropic",
98+
AnalyzerTypeAsana: "Asana",
99+
AnalyzerTypeBitbucket: "Bitbucket",
100+
AnalyzerTypeDockerHub: "DockerHub",
101+
AnalyzerTypeGitHub: "GitHub",
102+
AnalyzerTypeGitLab: "GitLab",
103+
AnalyzerTypeHuggingFace: "HuggingFace",
104+
AnalyzerTypeMailchimp: "Mailchimp",
105+
AnalyzerTypeMailgun: "Mailgun",
106+
AnalyzerTypeMySQL: "MySQL",
107+
AnalyzerTypeOpenAI: "OpenAI",
108+
AnalyzerTypeOpsgenie: "Opsgenie",
109+
AnalyzerTypePostgres: "Postgres",
110+
AnalyzerTypePostman: "Postman",
111+
AnalyzerTypeSendgrid: "Sendgrid",
112+
AnalyzerTypeShopify: "Shopify",
113+
AnalyzerTypeSlack: "Slack",
114+
AnalyzerTypeSourcegraph: "Sourcegraph",
115+
AnalyzerTypeSquare: "Square",
116+
AnalyzerTypeStripe: "Stripe",
117+
AnalyzerTypeTwilio: "Twilio",
118+
AnalyzerTypePrivateKey: "PrivateKey",
119+
AnalyzerTypeNotion: "Notion",
120+
AnalyzerTypeDigitalOcean: "DigitalOcean",
119121
// Add new mappings here
120122
}
121123

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
//go:generate generate_permissions permissions.yaml permissions.go digitalocean
2+
3+
package digitalocean
4+
5+
import (
6+
"bytes"
7+
_ "embed"
8+
"encoding/json"
9+
"errors"
10+
"fmt"
11+
"io"
12+
"net/http"
13+
"os"
14+
"sync"
15+
16+
"github.com/fatih/color"
17+
"github.com/jedib0t/go-pretty/v6/table"
18+
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers"
19+
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config"
20+
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
21+
)
22+
23+
var _ analyzers.Analyzer = (*Analyzer)(nil)
24+
25+
// to avoid rate limiting
26+
const MAX_CONCURRENT_TESTS = 10
27+
28+
type Analyzer struct {
29+
Cfg *config.Config
30+
}
31+
32+
func (Analyzer) Type() analyzers.AnalyzerType { return analyzers.AnalyzerTypeDigitalOcean }
33+
34+
func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) {
35+
key, ok := credInfo["key"]
36+
if !ok {
37+
return nil, errors.New("missing key in credInfo")
38+
}
39+
info, err := AnalyzePermissions(a.Cfg, key)
40+
if err != nil {
41+
return nil, err
42+
}
43+
return secretInfoToAnalyzerResult(info), nil
44+
}
45+
46+
func secretInfoToAnalyzerResult(info *SecretInfo) *analyzers.AnalyzerResult {
47+
if info == nil {
48+
return nil
49+
}
50+
result := analyzers.AnalyzerResult{
51+
AnalyzerType: analyzers.AnalyzerTypeDigitalOcean,
52+
Metadata: nil,
53+
Bindings: make([]analyzers.Binding, len(info.Permissions)),
54+
}
55+
56+
resource := analyzers.Resource{
57+
Name: info.User.Name,
58+
FullyQualifiedName: info.User.UUID,
59+
Type: "User",
60+
Metadata: map[string]any{
61+
"email": info.User.Email,
62+
"status": info.User.Status,
63+
},
64+
}
65+
66+
for idx, permission := range info.Permissions {
67+
result.Bindings[idx] = analyzers.Binding{
68+
Resource: resource,
69+
Permission: analyzers.Permission{
70+
Value: permission,
71+
},
72+
}
73+
}
74+
75+
return &result
76+
}
77+
78+
//go:embed scopes.json
79+
var scopesConfig []byte
80+
81+
type HttpStatusTest struct {
82+
Endpoint string `json:"endpoint"`
83+
Method string `json:"method"`
84+
Payload interface{} `json:"payload"`
85+
ValidStatuses []int `json:"valid_status_code"`
86+
InvalidStatuses []int `json:"invalid_status_code"`
87+
}
88+
89+
func StatusContains(status int, vals []int) bool {
90+
for _, v := range vals {
91+
if status == v {
92+
return true
93+
}
94+
}
95+
return false
96+
}
97+
98+
func (h *HttpStatusTest) RunTest(cfg *config.Config, headers map[string]string) (bool, error) {
99+
// If body data, marshal to JSON
100+
var data io.Reader
101+
if h.Payload != nil {
102+
jsonData, err := json.Marshal(h.Payload)
103+
if err != nil {
104+
return false, err
105+
}
106+
data = bytes.NewBuffer(jsonData)
107+
}
108+
109+
client := analyzers.NewAnalyzeClient(cfg)
110+
111+
req, err := http.NewRequest(h.Method, h.Endpoint, data)
112+
if err != nil {
113+
return false, err
114+
}
115+
116+
// Add custom headers if provided
117+
for key, value := range headers {
118+
req.Header.Set(key, value)
119+
}
120+
121+
// Execute HTTP Request
122+
resp, err := client.Do(req)
123+
if err != nil {
124+
return false, err
125+
}
126+
defer resp.Body.Close()
127+
128+
// Check response status code
129+
switch {
130+
case StatusContains(resp.StatusCode, h.ValidStatuses):
131+
return true, nil
132+
case StatusContains(resp.StatusCode, h.InvalidStatuses):
133+
return false, nil
134+
default:
135+
return false, errors.New("error checking response status code")
136+
}
137+
}
138+
139+
type Scope struct {
140+
Name string `json:"name"`
141+
HttpTest HttpStatusTest `json:"test"`
142+
}
143+
144+
func readInScopes() ([]Scope, error) {
145+
var scopes []Scope
146+
if err := json.Unmarshal(scopesConfig, &scopes); err != nil {
147+
return nil, err
148+
}
149+
150+
return scopes, nil
151+
}
152+
153+
func checkPermissions(cfg *config.Config, key string) ([]string, error) {
154+
scopes, err := readInScopes()
155+
if err != nil {
156+
return nil, fmt.Errorf("reading in scopes: %w", err)
157+
}
158+
159+
var (
160+
permissions = make([]string, 0, len(scopes))
161+
mu sync.Mutex
162+
wg sync.WaitGroup
163+
slots = make(chan struct{}, MAX_CONCURRENT_TESTS)
164+
errCh = make(chan error, 1)
165+
)
166+
167+
for _, scope := range scopes {
168+
wg.Add(1)
169+
go func(scope Scope) {
170+
defer wg.Done()
171+
172+
// acquire a slot
173+
slots <- struct{}{}
174+
defer func() { <-slots }()
175+
176+
status, err := scope.HttpTest.RunTest(cfg, map[string]string{"Authorization": "Bearer " + key})
177+
if err != nil {
178+
// send first error and ignore the rest
179+
select {
180+
case errCh <- fmt.Errorf("Scope %s: %w", scope.Name, err):
181+
default:
182+
}
183+
return
184+
}
185+
if status {
186+
mu.Lock()
187+
permissions = append(permissions, scope.Name)
188+
mu.Unlock()
189+
}
190+
}(scope)
191+
}
192+
193+
// wait for all goroutines to finish or an error to occur
194+
go func() {
195+
wg.Wait()
196+
close(errCh)
197+
}()
198+
199+
if err := <-errCh; err != nil {
200+
return nil, err
201+
}
202+
203+
return permissions, nil
204+
}
205+
206+
type user struct {
207+
Email string `json:"email"`
208+
Name string `json:"name"`
209+
UUID string `json:"uuid"`
210+
Status string `json:"status"`
211+
}
212+
213+
type userJSON struct {
214+
Account user `json:"account"`
215+
}
216+
217+
func getUser(cfg *config.Config, token string) (*user, error) {
218+
// Create new HTTP request
219+
client := analyzers.NewAnalyzeClient(cfg)
220+
req, err := http.NewRequest("GET", "https://api.digitalocean.com/v2/account", nil)
221+
if err != nil {
222+
return nil, err
223+
}
224+
225+
// Add custom headers if provided
226+
req.Header.Set("Authorization", "Bearer "+token)
227+
228+
// Execute HTTP Request
229+
resp, err := client.Do(req)
230+
if err != nil {
231+
return nil, err
232+
}
233+
defer resp.Body.Close()
234+
235+
switch resp.StatusCode {
236+
case http.StatusOK:
237+
// Decode response body
238+
var response userJSON
239+
err = json.NewDecoder(resp.Body).Decode(&response)
240+
if err != nil {
241+
return nil, err
242+
}
243+
244+
return &response.Account, nil
245+
case http.StatusUnauthorized:
246+
return nil, errors.New("invalid token")
247+
default:
248+
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
249+
}
250+
}
251+
252+
type SecretInfo struct {
253+
User user
254+
Permissions []string
255+
}
256+
257+
func AnalyzeAndPrintPermissions(cfg *config.Config, key string) {
258+
info, err := AnalyzePermissions(cfg, key)
259+
if err != nil {
260+
color.Red("[x] Error : %s", err.Error())
261+
return
262+
}
263+
264+
color.Green("[!] Valid DigitalOcean API key\n\n")
265+
266+
color.Yellow("[i] User: %s (%s)\n\n", info.User.Name, info.User.Email)
267+
268+
printPermissions(info.Permissions)
269+
}
270+
271+
func AnalyzePermissions(cfg *config.Config, key string) (*SecretInfo, error) {
272+
var info = &SecretInfo{}
273+
274+
user, err := getUser(cfg, key)
275+
if err != nil {
276+
return nil, err
277+
}
278+
info.User = *user
279+
280+
permissions, err := checkPermissions(cfg, key)
281+
if err != nil {
282+
return nil, err
283+
}
284+
285+
if len(permissions) == 0 {
286+
return nil, fmt.Errorf("invalid DigitalOcean API key")
287+
}
288+
289+
info.Permissions = permissions
290+
291+
return info, nil
292+
}
293+
294+
func printPermissions(permissions []string) {
295+
color.Yellow("[i] Permissions:")
296+
t := table.NewWriter()
297+
t.SetOutputMirror(os.Stdout)
298+
t.AppendHeader(table.Row{"Permission"})
299+
for _, permission := range permissions {
300+
t.AppendRow(table.Row{color.GreenString(permission)})
301+
}
302+
t.Render()
303+
}

0 commit comments

Comments
 (0)