Skip to content

Commit b2ba219

Browse files
[Feat] Added Figma (PAT) Analyzer (#3974)
* added figma analyzer * fixed lint issue * fixed lint issue (2) * added endpoint config from json * simplified scope extraction and endpoint configuration * updated with lint issue fixes --------- Co-authored-by: Kashif Khan <[email protected]>
1 parent 9a59a7a commit b2ba219

File tree

13 files changed

+637
-0
lines changed

13 files changed

+637
-0
lines changed

pkg/analyzer/analyzers/analyzers.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ const (
9292
AnalyzerTypeAirtablePat
9393
AnalyzerTypeGroq
9494
AnalyzerTypeLaunchDarkly
95+
AnalyzerTypeFigma
9596
// Add new items here with AnalyzerType prefix
9697
)
9798

@@ -129,6 +130,7 @@ var analyzerTypeStrings = map[AnalyzerType]string{
129130
AnalyzerTypeAirtablePat: "AirtablePat",
130131
AnalyzerTypeGroq: "Groq",
131132
AnalyzerTypeLaunchDarkly: "LaunchDarkly",
133+
AnalyzerTypeFigma: "Figma",
132134
// Add new mappings here
133135
}
134136

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"files:read": {
3+
"url": "https://api.figma.com/v1/me",
4+
"method": "GET",
5+
"expected_status_code_with_scope": 200,
6+
"expected_status_code_without_scope": 403
7+
},
8+
"library_analytics:read": {
9+
"url": "https://api.figma.com/v1/analytics/libraries/0/component/actions",
10+
"method": "GET",
11+
"expected_status_code_with_scope": 400,
12+
"expected_status_code_without_scope": 403
13+
},
14+
"file_dev_resources:write": {
15+
"url": "https://api.figma.com/v1/dev_resources",
16+
"method": "POST",
17+
"expected_status_code_with_scope": 400,
18+
"expected_status_code_without_scope": 403
19+
},
20+
"file_variables:read": {
21+
"url": "https://api.figma.com/v1/files/0/variables/published",
22+
"method": "GET",
23+
"expected_status_code_with_scope": 404,
24+
"expected_status_code_without_scope": 403
25+
},
26+
"webhooks:write": {
27+
"url": "https://api.figma.com/v2/webhooks",
28+
"method": "POST",
29+
"expected_status_code_with_scope": 400,
30+
"expected_status_code_without_scope": 403
31+
}
32+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"AnalyzerType":32,"Bindings":[{"Resource":{"Name":"Source Integration","FullyQualifiedName":"1287160752716166666","Type":"user","Metadata":{"email":"[email protected]","img_url":"https://www.gravatar.com/avatar/48da7f448c34d4271a51d2ccf058f473?size=240&default=https%3A%2F%2Fs3-alpha.figma.com%2Fstatic%2Fuser_s_v2.png"},"Parent":null},"Permission":{"Value":"files:read","Parent":null}}],"UnboundedResources":null,"Metadata":null}

pkg/analyzer/analyzers/figma/figma.go

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
//go:generate generate_permissions permissions.yaml permissions.go figma
2+
3+
package figma
4+
5+
import (
6+
_ "embed"
7+
"encoding/json"
8+
"errors"
9+
"fmt"
10+
"io"
11+
"net/http"
12+
"os"
13+
"regexp"
14+
"strings"
15+
16+
"github.com/fatih/color"
17+
"github.com/jedib0t/go-pretty/v6/table"
18+
19+
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers"
20+
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config"
21+
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
22+
)
23+
24+
var _ analyzers.Analyzer = (*Analyzer)(nil)
25+
26+
type Analyzer struct {
27+
Cfg *config.Config
28+
}
29+
30+
func (Analyzer) Type() analyzers.AnalyzerType { return analyzers.AnalyzerTypeFigma }
31+
32+
type ScopeStatus string
33+
34+
const (
35+
StatusError ScopeStatus = "Error"
36+
StatusGranted ScopeStatus = "Granted"
37+
StatusDenied ScopeStatus = "Denied"
38+
StatusUnverified ScopeStatus = "Unverified"
39+
)
40+
41+
func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) {
42+
token, ok := credInfo["token"]
43+
if !ok {
44+
return nil, errors.New("token not found in credInfo")
45+
}
46+
info, err := AnalyzePermissions(a.Cfg, token)
47+
if err != nil {
48+
return nil, err
49+
}
50+
return MapToAnalyzerResult(info), nil
51+
}
52+
53+
func AnalyzeAndPrintPermissions(cfg *config.Config, token string) {
54+
info, err := AnalyzePermissions(cfg, token)
55+
if err != nil {
56+
color.Red("[x] Error : %s", err.Error())
57+
return
58+
}
59+
60+
color.Green("[!] Valid Figma Personal Access Token\n\n")
61+
PrintUserAndPermissions(info)
62+
}
63+
64+
func AnalyzePermissions(cfg *config.Config, token string) (*secretInfo, error) {
65+
client := analyzers.NewAnalyzeClient(cfg)
66+
allScopes := getAllScopes()
67+
scopeToEndpoints, err := getScopeEndpointsMap()
68+
if err != nil {
69+
return nil, err
70+
}
71+
72+
var info = &secretInfo{Scopes: map[Scope]ScopeStatus{}}
73+
for _, scope := range allScopes {
74+
info.Scopes[scope] = StatusUnverified
75+
}
76+
77+
for _, scope := range orderedScopeList {
78+
endpoint, err := getScopeEndpoint(scopeToEndpoints, scope)
79+
if err != nil {
80+
return nil, err
81+
}
82+
resp, err := callAPIEndpoint(client, token, endpoint)
83+
if err != nil {
84+
return nil, err
85+
}
86+
defer resp.Body.Close()
87+
body, err := io.ReadAll(resp.Body)
88+
if err != nil {
89+
return nil, err
90+
}
91+
92+
scopeStatus := determineScopeStatus(resp.StatusCode, endpoint)
93+
if scopeStatus == StatusGranted {
94+
if scope == ScopeFilesRead {
95+
if err := json.Unmarshal(body, &info.UserInfo); err != nil {
96+
return nil, fmt.Errorf("error decoding user info from response %v", err)
97+
}
98+
}
99+
info.Scopes[scope] = StatusGranted
100+
}
101+
// If the token does NOT have the scope, response will include all the scopes it does have
102+
if scopeStatus == StatusDenied {
103+
scopes, ok := extractScopesFromError(body)
104+
if !ok {
105+
return nil, fmt.Errorf("could not extract scopes from error message")
106+
}
107+
for scope := range info.Scopes {
108+
info.Scopes[scope] = StatusDenied
109+
}
110+
for _, scope := range scopes {
111+
info.Scopes[scope] = StatusGranted
112+
}
113+
// We have enough info to finish analysis
114+
break
115+
}
116+
}
117+
return info, nil
118+
}
119+
120+
// determineScopeStatus takes the API response status code and uses it along with the expected
121+
// status codes to dermine whether the access token has the required scope to perform that action.
122+
// It returns a ScopeStatus which can be Granted, Denied, or Unverified.
123+
func determineScopeStatus(statusCode int, endpoint endpoint) ScopeStatus {
124+
if statusCode == endpoint.ExpectedStatusCodeWithScope || statusCode == http.StatusOK {
125+
return StatusGranted
126+
}
127+
128+
if statusCode == endpoint.ExpectedStatusCodeWithoutScope {
129+
return StatusDenied
130+
}
131+
132+
// Can not determine scope as the expected error is unknown
133+
return StatusUnverified
134+
}
135+
136+
// Matches API response body with expected message pattern in case the token is missing a scope
137+
// If the responses match, we can extract all available scopes from the response msg
138+
func extractScopesFromError(body []byte) ([]Scope, bool) {
139+
filteredBody := filterErrorResponseBody(string(body))
140+
re := regexp.MustCompile(`Invalid scope(?:\(s\))?: ([a-zA-Z_:, ]+)\. This endpoint requires.*`)
141+
matches := re.FindStringSubmatch(filteredBody)
142+
if len(matches) > 1 {
143+
scopes := strings.Split(matches[1], ", ")
144+
return getScopesFromScopeStrings(scopes), true
145+
}
146+
return nil, false
147+
}
148+
149+
// The filterErrorResponseBody function cleans the provided "invalid permission" API
150+
// response message by removing the characters '"', '[', ']', '\', and '"'.
151+
func filterErrorResponseBody(msg string) string {
152+
result := strings.ReplaceAll(msg, "\\", "")
153+
result = strings.ReplaceAll(result, "\"", "")
154+
result = strings.ReplaceAll(result, "[", "")
155+
return strings.ReplaceAll(result, "]", "")
156+
}
157+
158+
func MapToAnalyzerResult(info *secretInfo) *analyzers.AnalyzerResult {
159+
if info == nil {
160+
return nil
161+
}
162+
163+
result := analyzers.AnalyzerResult{
164+
AnalyzerType: analyzers.AnalyzerTypeFigma,
165+
}
166+
var permissions []analyzers.Permission
167+
for scope, status := range info.Scopes {
168+
if status != StatusGranted {
169+
continue
170+
}
171+
permissions = append(permissions, analyzers.Permission{Value: string(scope)})
172+
}
173+
userResource := analyzers.Resource{
174+
Name: info.UserInfo.Handle,
175+
FullyQualifiedName: info.UserInfo.ID,
176+
Type: "user",
177+
Metadata: map[string]any{
178+
"email": info.UserInfo.Email,
179+
"img_url": info.UserInfo.ImgURL,
180+
},
181+
}
182+
183+
result.Bindings = analyzers.BindAllPermissions(userResource, permissions...)
184+
return &result
185+
}
186+
187+
func PrintUserAndPermissions(info *secretInfo) {
188+
color.Yellow("[i] User Info:")
189+
t1 := table.NewWriter()
190+
t1.SetOutputMirror(os.Stdout)
191+
t1.AppendHeader(table.Row{"ID", "Handle", "Email", "Image URL"})
192+
t1.AppendRow(table.Row{
193+
color.GreenString(info.UserInfo.ID),
194+
color.GreenString(info.UserInfo.Handle),
195+
color.GreenString(info.UserInfo.Email),
196+
color.GreenString(info.UserInfo.ImgURL),
197+
})
198+
t1.SetOutputMirror(os.Stdout)
199+
t1.Render()
200+
201+
color.Yellow("\n[i] Scopes:")
202+
t2 := table.NewWriter()
203+
t2.AppendHeader(table.Row{"Scope", "Status", "Actions"})
204+
for scope, status := range info.Scopes {
205+
actions := getScopeActions(scope)
206+
rows := []table.Row{}
207+
for i, action := range actions {
208+
var scopeCell string
209+
var statusCell string
210+
if i == 0 {
211+
scopeCell = color.GreenString(string(scope))
212+
statusCell = color.GreenString(string(status))
213+
}
214+
rows = append(rows, table.Row{scopeCell, statusCell, color.GreenString(action)})
215+
}
216+
t2.AppendRows(rows)
217+
t2.AppendSeparator()
218+
}
219+
t2.SetOutputMirror(os.Stdout)
220+
t2.Render()
221+
fmt.Printf("%s: https://www.figma.com/developers/api\n\n", color.GreenString("Ref"))
222+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package figma
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 expected_output.json
17+
var expectedOutput []byte
18+
19+
func TestAnalyzer_Analyze(t *testing.T) {
20+
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
21+
defer cancel()
22+
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors5")
23+
if err != nil {
24+
t.Fatalf("could not get test secrets from GCP: %s", err)
25+
}
26+
27+
tests := []struct {
28+
name string
29+
token string
30+
want string // JSON string
31+
wantErr bool
32+
}{
33+
{
34+
token: testSecrets.MustGetField("FIGMAPERSONALACCESSTOKEN_V2_TOKEN"),
35+
name: "valid Figma Personal Access Token",
36+
want: string(expectedOutput),
37+
wantErr: false,
38+
},
39+
}
40+
41+
for _, tt := range tests {
42+
t.Run(tt.name, func(t *testing.T) {
43+
a := Analyzer{Cfg: &config.Config{}}
44+
got, err := a.Analyze(ctx, map[string]string{"token": tt.token})
45+
if (err != nil) != tt.wantErr {
46+
t.Errorf("Analyzer.Analyze() error = %v, wantErr %v", err, tt.wantErr)
47+
return
48+
}
49+
50+
// bindings need to be in the same order to be comparable
51+
sortBindings(got.Bindings)
52+
53+
// Marshal the actual result to JSON
54+
gotJSON, err := json.Marshal(got)
55+
if err != nil {
56+
t.Fatalf("could not marshal got to JSON: %s", err)
57+
}
58+
59+
// Parse the expected JSON string
60+
var wantObj analyzers.AnalyzerResult
61+
if err := json.Unmarshal([]byte(tt.want), &wantObj); err != nil {
62+
t.Fatalf("could not unmarshal want JSON string: %s", err)
63+
}
64+
65+
// bindings need to be in the same order to be comparable
66+
sortBindings(wantObj.Bindings)
67+
68+
// Marshal the expected result to JSON (to normalize)
69+
wantJSON, err := json.Marshal(wantObj)
70+
if err != nil {
71+
t.Fatalf("could not marshal want to JSON: %s", err)
72+
}
73+
74+
// Compare the JSON strings
75+
if string(gotJSON) != string(wantJSON) {
76+
// Pretty-print both JSON strings for easier comparison
77+
var gotIndented, wantIndented []byte
78+
gotIndented, err = json.MarshalIndent(got, "", " ")
79+
if err != nil {
80+
t.Fatalf("could not marshal got to indented JSON: %s", err)
81+
}
82+
wantIndented, err = json.MarshalIndent(wantObj, "", " ")
83+
if err != nil {
84+
t.Fatalf("could not marshal want to indented JSON: %s", err)
85+
}
86+
t.Errorf("Analyzer.Analyze() = %s, want %s", gotIndented, wantIndented)
87+
}
88+
})
89+
}
90+
}
91+
92+
// Helper function to sort bindings
93+
func sortBindings(bindings []analyzers.Binding) {
94+
sort.SliceStable(bindings, func(i, j int) bool {
95+
if bindings[i].Resource.Name == bindings[j].Resource.Name {
96+
return bindings[i].Permission.Value < bindings[j].Permission.Value
97+
}
98+
return bindings[i].Resource.Name < bindings[j].Resource.Name
99+
})
100+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package figma
2+
3+
type userInfo struct {
4+
ID string `json:"id"`
5+
Handle string `json:"handle"`
6+
ImgURL string `json:"img_url"`
7+
Email string `json:"email"`
8+
}
9+
10+
type secretInfo struct {
11+
UserInfo userInfo
12+
Scopes map[Scope]ScopeStatus
13+
}
14+
15+
type endpoint struct {
16+
URL string `json:"url"`
17+
Method string `json:"method"`
18+
ExpectedStatusCodeWithScope int `json:"expected_status_code_with_scope"`
19+
ExpectedStatusCodeWithoutScope int `json:"expected_status_code_without_scope"`
20+
}

0 commit comments

Comments
 (0)