[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]>
This commit is contained in:
Nabeel Alam
2025-04-03 19:14:48 +05:00
committed by GitHub
co-authored by Kashif Khan
parent 9a59a7a52a
commit b2ba219e24
13 changed files with 637 additions and 0 deletions
+2
View File
@@ -92,6 +92,7 @@ const (
AnalyzerTypeAirtablePat
AnalyzerTypeGroq
AnalyzerTypeLaunchDarkly
AnalyzerTypeFigma
// Add new items here with AnalyzerType prefix
)
@@ -129,6 +130,7 @@ var analyzerTypeStrings = map[AnalyzerType]string{
AnalyzerTypeAirtablePat: "AirtablePat",
AnalyzerTypeGroq: "Groq",
AnalyzerTypeLaunchDarkly: "LaunchDarkly",
AnalyzerTypeFigma: "Figma",
// Add new mappings here
}
@@ -0,0 +1,32 @@
{
"files:read": {
"url": "https://api.figma.com/v1/me",
"method": "GET",
"expected_status_code_with_scope": 200,
"expected_status_code_without_scope": 403
},
"library_analytics:read": {
"url": "https://api.figma.com/v1/analytics/libraries/0/component/actions",
"method": "GET",
"expected_status_code_with_scope": 400,
"expected_status_code_without_scope": 403
},
"file_dev_resources:write": {
"url": "https://api.figma.com/v1/dev_resources",
"method": "POST",
"expected_status_code_with_scope": 400,
"expected_status_code_without_scope": 403
},
"file_variables:read": {
"url": "https://api.figma.com/v1/files/0/variables/published",
"method": "GET",
"expected_status_code_with_scope": 404,
"expected_status_code_without_scope": 403
},
"webhooks:write": {
"url": "https://api.figma.com/v2/webhooks",
"method": "POST",
"expected_status_code_with_scope": 400,
"expected_status_code_without_scope": 403
}
}
@@ -0,0 +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}
+222
View File
@@ -0,0 +1,222 @@
//go:generate generate_permissions permissions.yaml permissions.go figma
package figma
import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strings"
"github.com/fatih/color"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config"
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
)
var _ analyzers.Analyzer = (*Analyzer)(nil)
type Analyzer struct {
Cfg *config.Config
}
func (Analyzer) Type() analyzers.AnalyzerType { return analyzers.AnalyzerTypeFigma }
type ScopeStatus string
const (
StatusError ScopeStatus = "Error"
StatusGranted ScopeStatus = "Granted"
StatusDenied ScopeStatus = "Denied"
StatusUnverified ScopeStatus = "Unverified"
)
func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) {
token, ok := credInfo["token"]
if !ok {
return nil, errors.New("token not found in credInfo")
}
info, err := AnalyzePermissions(a.Cfg, token)
if err != nil {
return nil, err
}
return MapToAnalyzerResult(info), nil
}
func AnalyzeAndPrintPermissions(cfg *config.Config, token string) {
info, err := AnalyzePermissions(cfg, token)
if err != nil {
color.Red("[x] Error : %s", err.Error())
return
}
color.Green("[!] Valid Figma Personal Access Token\n\n")
PrintUserAndPermissions(info)
}
func AnalyzePermissions(cfg *config.Config, token string) (*secretInfo, error) {
client := analyzers.NewAnalyzeClient(cfg)
allScopes := getAllScopes()
scopeToEndpoints, err := getScopeEndpointsMap()
if err != nil {
return nil, err
}
var info = &secretInfo{Scopes: map[Scope]ScopeStatus{}}
for _, scope := range allScopes {
info.Scopes[scope] = StatusUnverified
}
for _, scope := range orderedScopeList {
endpoint, err := getScopeEndpoint(scopeToEndpoints, scope)
if err != nil {
return nil, err
}
resp, err := callAPIEndpoint(client, token, endpoint)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
scopeStatus := determineScopeStatus(resp.StatusCode, endpoint)
if scopeStatus == StatusGranted {
if scope == ScopeFilesRead {
if err := json.Unmarshal(body, &info.UserInfo); err != nil {
return nil, fmt.Errorf("error decoding user info from response %v", err)
}
}
info.Scopes[scope] = StatusGranted
}
// If the token does NOT have the scope, response will include all the scopes it does have
if scopeStatus == StatusDenied {
scopes, ok := extractScopesFromError(body)
if !ok {
return nil, fmt.Errorf("could not extract scopes from error message")
}
for scope := range info.Scopes {
info.Scopes[scope] = StatusDenied
}
for _, scope := range scopes {
info.Scopes[scope] = StatusGranted
}
// We have enough info to finish analysis
break
}
}
return info, nil
}
// determineScopeStatus takes the API response status code and uses it along with the expected
// status codes to dermine whether the access token has the required scope to perform that action.
// It returns a ScopeStatus which can be Granted, Denied, or Unverified.
func determineScopeStatus(statusCode int, endpoint endpoint) ScopeStatus {
if statusCode == endpoint.ExpectedStatusCodeWithScope || statusCode == http.StatusOK {
return StatusGranted
}
if statusCode == endpoint.ExpectedStatusCodeWithoutScope {
return StatusDenied
}
// Can not determine scope as the expected error is unknown
return StatusUnverified
}
// Matches API response body with expected message pattern in case the token is missing a scope
// If the responses match, we can extract all available scopes from the response msg
func extractScopesFromError(body []byte) ([]Scope, bool) {
filteredBody := filterErrorResponseBody(string(body))
re := regexp.MustCompile(`Invalid scope(?:\(s\))?: ([a-zA-Z_:, ]+)\. This endpoint requires.*`)
matches := re.FindStringSubmatch(filteredBody)
if len(matches) > 1 {
scopes := strings.Split(matches[1], ", ")
return getScopesFromScopeStrings(scopes), true
}
return nil, false
}
// The filterErrorResponseBody function cleans the provided "invalid permission" API
// response message by removing the characters '"', '[', ']', '\', and '"'.
func filterErrorResponseBody(msg string) string {
result := strings.ReplaceAll(msg, "\\", "")
result = strings.ReplaceAll(result, "\"", "")
result = strings.ReplaceAll(result, "[", "")
return strings.ReplaceAll(result, "]", "")
}
func MapToAnalyzerResult(info *secretInfo) *analyzers.AnalyzerResult {
if info == nil {
return nil
}
result := analyzers.AnalyzerResult{
AnalyzerType: analyzers.AnalyzerTypeFigma,
}
var permissions []analyzers.Permission
for scope, status := range info.Scopes {
if status != StatusGranted {
continue
}
permissions = append(permissions, analyzers.Permission{Value: string(scope)})
}
userResource := analyzers.Resource{
Name: info.UserInfo.Handle,
FullyQualifiedName: info.UserInfo.ID,
Type: "user",
Metadata: map[string]any{
"email": info.UserInfo.Email,
"img_url": info.UserInfo.ImgURL,
},
}
result.Bindings = analyzers.BindAllPermissions(userResource, permissions...)
return &result
}
func PrintUserAndPermissions(info *secretInfo) {
color.Yellow("[i] User Info:")
t1 := table.NewWriter()
t1.SetOutputMirror(os.Stdout)
t1.AppendHeader(table.Row{"ID", "Handle", "Email", "Image URL"})
t1.AppendRow(table.Row{
color.GreenString(info.UserInfo.ID),
color.GreenString(info.UserInfo.Handle),
color.GreenString(info.UserInfo.Email),
color.GreenString(info.UserInfo.ImgURL),
})
t1.SetOutputMirror(os.Stdout)
t1.Render()
color.Yellow("\n[i] Scopes:")
t2 := table.NewWriter()
t2.AppendHeader(table.Row{"Scope", "Status", "Actions"})
for scope, status := range info.Scopes {
actions := getScopeActions(scope)
rows := []table.Row{}
for i, action := range actions {
var scopeCell string
var statusCell string
if i == 0 {
scopeCell = color.GreenString(string(scope))
statusCell = color.GreenString(string(status))
}
rows = append(rows, table.Row{scopeCell, statusCell, color.GreenString(action)})
}
t2.AppendRows(rows)
t2.AppendSeparator()
}
t2.SetOutputMirror(os.Stdout)
t2.Render()
fmt.Printf("%s: https://www.figma.com/developers/api\n\n", color.GreenString("Ref"))
}
+100
View File
@@ -0,0 +1,100 @@
package figma
import (
_ "embed"
"encoding/json"
"sort"
"testing"
"time"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config"
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
)
//go:embed expected_output.json
var expectedOutput []byte
func TestAnalyzer_Analyze(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors5")
if err != nil {
t.Fatalf("could not get test secrets from GCP: %s", err)
}
tests := []struct {
name string
token string
want string // JSON string
wantErr bool
}{
{
token: testSecrets.MustGetField("FIGMAPERSONALACCESSTOKEN_V2_TOKEN"),
name: "valid Figma Personal Access Token",
want: string(expectedOutput),
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
a := Analyzer{Cfg: &config.Config{}}
got, err := a.Analyze(ctx, map[string]string{"token": tt.token})
if (err != nil) != tt.wantErr {
t.Errorf("Analyzer.Analyze() error = %v, wantErr %v", err, tt.wantErr)
return
}
// bindings need to be in the same order to be comparable
sortBindings(got.Bindings)
// Marshal the actual result to JSON
gotJSON, err := json.Marshal(got)
if err != nil {
t.Fatalf("could not marshal got to JSON: %s", err)
}
// Parse the expected JSON string
var wantObj analyzers.AnalyzerResult
if err := json.Unmarshal([]byte(tt.want), &wantObj); err != nil {
t.Fatalf("could not unmarshal want JSON string: %s", err)
}
// bindings need to be in the same order to be comparable
sortBindings(wantObj.Bindings)
// Marshal the expected result to JSON (to normalize)
wantJSON, err := json.Marshal(wantObj)
if err != nil {
t.Fatalf("could not marshal want to JSON: %s", err)
}
// Compare the JSON strings
if string(gotJSON) != string(wantJSON) {
// Pretty-print both JSON strings for easier comparison
var gotIndented, wantIndented []byte
gotIndented, err = json.MarshalIndent(got, "", " ")
if err != nil {
t.Fatalf("could not marshal got to indented JSON: %s", err)
}
wantIndented, err = json.MarshalIndent(wantObj, "", " ")
if err != nil {
t.Fatalf("could not marshal want to indented JSON: %s", err)
}
t.Errorf("Analyzer.Analyze() = %s, want %s", gotIndented, wantIndented)
}
})
}
}
// Helper function to sort bindings
func sortBindings(bindings []analyzers.Binding) {
sort.SliceStable(bindings, func(i, j int) bool {
if bindings[i].Resource.Name == bindings[j].Resource.Name {
return bindings[i].Permission.Value < bindings[j].Permission.Value
}
return bindings[i].Resource.Name < bindings[j].Resource.Name
})
}
+20
View File
@@ -0,0 +1,20 @@
package figma
type userInfo struct {
ID string `json:"id"`
Handle string `json:"handle"`
ImgURL string `json:"img_url"`
Email string `json:"email"`
}
type secretInfo struct {
UserInfo userInfo
Scopes map[Scope]ScopeStatus
}
type endpoint struct {
URL string `json:"url"`
Method string `json:"method"`
ExpectedStatusCodeWithScope int `json:"expected_status_code_with_scope"`
ExpectedStatusCodeWithoutScope int `json:"expected_status_code_without_scope"`
}
@@ -0,0 +1,96 @@
// Code generated by go generate; DO NOT EDIT.
package figma
import "errors"
type Permission int
const (
Invalid Permission = iota
FilesRead Permission = iota
FileVariablesRead Permission = iota
FileVariablesWrite Permission = iota
FileCommentsWrite Permission = iota
FileDevResourcesRead Permission = iota
FileDevResourcesWrite Permission = iota
LibraryAnalyticsRead Permission = iota
WebhooksWrite Permission = iota
)
var (
PermissionStrings = map[Permission]string{
FilesRead: "files:read",
FileVariablesRead: "file_variables:read",
FileVariablesWrite: "file_variables:write",
FileCommentsWrite: "file_comments:write",
FileDevResourcesRead: "file_dev_resources:read",
FileDevResourcesWrite: "file_dev_resources:write",
LibraryAnalyticsRead: "library_analytics:read",
WebhooksWrite: "webhooks:write",
}
StringToPermission = map[string]Permission{
"files:read": FilesRead,
"file_variables:read": FileVariablesRead,
"file_variables:write": FileVariablesWrite,
"file_comments:write": FileCommentsWrite,
"file_dev_resources:read": FileDevResourcesRead,
"file_dev_resources:write": FileDevResourcesWrite,
"library_analytics:read": LibraryAnalyticsRead,
"webhooks:write": WebhooksWrite,
}
PermissionIDs = map[Permission]int{
FilesRead: 1,
FileVariablesRead: 2,
FileVariablesWrite: 3,
FileCommentsWrite: 4,
FileDevResourcesRead: 5,
FileDevResourcesWrite: 6,
LibraryAnalyticsRead: 7,
WebhooksWrite: 8,
}
IdToPermission = map[int]Permission{
1: FilesRead,
2: FileVariablesRead,
3: FileVariablesWrite,
4: FileCommentsWrite,
5: FileDevResourcesRead,
6: FileDevResourcesWrite,
7: LibraryAnalyticsRead,
8: WebhooksWrite,
}
)
// ToString converts a Permission enum to its string representation
func (p Permission) ToString() (string, error) {
if str, ok := PermissionStrings[p]; ok {
return str, nil
}
return "", errors.New("invalid permission")
}
// ToID converts a Permission enum to its ID
func (p Permission) ToID() (int, error) {
if id, ok := PermissionIDs[p]; ok {
return id, nil
}
return 0, errors.New("invalid permission")
}
// PermissionFromString converts a string representation to its Permission enum
func PermissionFromString(s string) (Permission, error) {
if p, ok := StringToPermission[s]; ok {
return p, nil
}
return 0, errors.New("invalid permission string")
}
// PermissionFromID converts an ID to its Permission enum
func PermissionFromID(id int) (Permission, error) {
if p, ok := IdToPermission[id]; ok {
return p, nil
}
return 0, errors.New("invalid permission ID")
}
@@ -0,0 +1,9 @@
permissions:
- files:read
- file_variables:read
- file_variables:write
- file_comments:write
- file_dev_resources:read
- file_dev_resources:write
- library_analytics:read
- webhooks:write
+19
View File
@@ -0,0 +1,19 @@
package figma
import (
"net/http"
)
func callAPIEndpoint(client *http.Client, token string, endpoint endpoint) (*http.Response, error) {
req, err := http.NewRequest(endpoint.Method, endpoint.URL, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-FIGMA-TOKEN", token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
return resp, nil
}
+127
View File
@@ -0,0 +1,127 @@
package figma
import (
_ "embed"
"encoding/json"
"errors"
)
type Scope string
const (
ScopeFilesRead Scope = "files:read"
ScopeFileVariablesRead Scope = "file_variables:read"
ScopeFileVariablesWrite Scope = "file_variables:write"
ScopeFileCommentsWrite Scope = "file_comments:write"
ScopeFileDevResourcesRead Scope = "file_dev_resources:read"
ScopeFileDevResourcesWrite Scope = "file_dev_resources:write"
ScopeLibraryAnalyticsRead Scope = "library_analytics:read"
ScopeWebhooksWrite Scope = "webhooks:write"
)
// This list orders the scope in which they must be tested
var orderedScopeList = []Scope{
ScopeFilesRead,
ScopeLibraryAnalyticsRead,
ScopeFileDevResourcesWrite,
ScopeFileVariablesRead,
ScopeWebhooksWrite,
}
var scopeToActions = map[Scope][]string{
ScopeFilesRead: {
"Get user info",
"Read files",
"Read projects",
"Read users",
"Read versions",
"Read comments",
"Read components & styles",
"Read webhooks",
},
ScopeFileVariablesRead: {
"Read file variables",
},
ScopeFileVariablesWrite: {
"Write file variables",
},
ScopeFileCommentsWrite: {
"Post comments",
"Delete comments",
"Post comment reactions",
"Delete comment reactions",
},
ScopeFileDevResourcesRead: {
"Read file dev resources",
},
ScopeFileDevResourcesWrite: {
"Write file dev resources",
},
ScopeLibraryAnalyticsRead: {
"Read design system analytics",
},
ScopeWebhooksWrite: {
"Create webhooks",
"Manage webhooks",
},
}
var scopeStringToScope map[string]Scope
//go:embed endpoints.json
var endpointsConfig []byte
func init() {
scopeStringToScope = map[string]Scope{
string(ScopeFilesRead): ScopeFilesRead,
string(ScopeFileVariablesRead): ScopeFileVariablesRead,
string(ScopeFileVariablesWrite): ScopeFileVariablesWrite,
string(ScopeFileCommentsWrite): ScopeFileCommentsWrite,
string(ScopeFileDevResourcesRead): ScopeFileDevResourcesRead,
string(ScopeFileDevResourcesWrite): ScopeFileDevResourcesWrite,
string(ScopeLibraryAnalyticsRead): ScopeLibraryAnalyticsRead,
string(ScopeWebhooksWrite): ScopeWebhooksWrite,
}
}
func getScopeActions(scope Scope) []string {
return scopeToActions[scope]
}
func getScopeEndpointsMap() (map[Scope]endpoint, error) {
var scopeToEndpoints map[Scope]endpoint
if err := json.Unmarshal(endpointsConfig, &scopeToEndpoints); err != nil {
return nil, errors.New("failed to unmarshal endpoints.json: " + err.Error())
}
return scopeToEndpoints, nil
}
func getScopeEndpoint(scopeToEndpoint map[Scope]endpoint, scope Scope) (endpoint, error) {
if endpoint, ok := scopeToEndpoint[scope]; ok {
return endpoint, nil
}
return endpoint{}, errors.New("invalid scope or endpoint doesn't exist")
}
func getScopesFromScopeStrings(scopeStrings []string) []Scope {
var scopes []Scope
for _, scopeString := range scopeStrings {
if scope, ok := scopeStringToScope[scopeString]; ok {
scopes = append(scopes, scope)
}
}
return scopes
}
func getAllScopes() []Scope {
return []Scope{
ScopeFilesRead,
ScopeFileVariablesRead,
ScopeFileVariablesWrite,
ScopeFileCommentsWrite,
ScopeFileDevResourcesRead,
ScopeFileDevResourcesWrite,
ScopeLibraryAnalyticsRead,
ScopeWebhooksWrite,
}
}
+3
View File
@@ -14,6 +14,7 @@ import (
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/digitalocean"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/dockerhub"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/elevenlabs"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/figma"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/github"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/gitlab"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/groq"
@@ -115,5 +116,7 @@ func Run(keyType string, secretInfo SecretInfo) {
groq.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"])
case "launchdarkly":
launchdarkly.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"])
case "figma":
figma.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"])
}
}
@@ -75,6 +75,9 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
} else {
s1.SetVerificationError(err, resMatch)
}
if s1.Verified {
s1.AnalysisInfo = map[string]string{"token": resMatch}
}
}
results = append(results, s1)
@@ -79,6 +79,9 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
} else {
s1.SetVerificationError(err, resMatch)
}
if s1.Verified {
s1.AnalysisInfo = map[string]string{"token": resMatch}
}
}
results = append(results, s1)