Skip to content

Commit 72a515c

Browse files
authored
[Feature] Airtable Analyzer for OAuth Tokens (#3879)
* added airtable analyzer for oauth tokens * added airtable analyzer cli command
1 parent 6b1be99 commit 72a515c

File tree

9 files changed

+704
-0
lines changed

9 files changed

+704
-0
lines changed
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
//go:generate generate_permissions permissions.yaml permissions.go airtable
2+
package airtable
3+
4+
import (
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"net/http"
9+
"os"
10+
11+
"github.com/fatih/color"
12+
"github.com/jedib0t/go-pretty/table"
13+
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers"
14+
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config"
15+
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
16+
)
17+
18+
var _ analyzers.Analyzer = (*Analyzer)(nil)
19+
20+
type Analyzer struct {
21+
Cfg *config.Config
22+
}
23+
24+
func (Analyzer) Type() analyzers.AnalyzerType { return analyzers.AnalyzerTypeAirtable }
25+
26+
type AirtableUserInfo struct {
27+
ID string `json:"id"`
28+
Email *string `json:"email,omitempty"`
29+
Scopes []string `json:"scopes"`
30+
}
31+
32+
type AirtableBases struct {
33+
Bases []struct {
34+
ID string `json:"id"`
35+
Name string `json:"name"`
36+
} `json:"bases"`
37+
}
38+
39+
func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) {
40+
token, ok := credInfo["token"]
41+
if !ok {
42+
return nil, errors.New("token not found in credInfo")
43+
}
44+
45+
userInfo, err := fetchAirtableUserInfo(token)
46+
if err != nil {
47+
return nil, err
48+
}
49+
50+
var basesInfo *AirtableBases
51+
if hasScope(userInfo.Scopes, PermissionStrings[SchemaBasesRead]) {
52+
basesInfo, _ = fetchAirtableBases(token)
53+
}
54+
55+
return mapToAnalyzerResult(userInfo, basesInfo), nil
56+
}
57+
58+
func AnalyzeAndPrintPermissions(cfg *config.Config, token string) {
59+
userInfo, err := fetchAirtableUserInfo(token)
60+
if err != nil {
61+
color.Red("[x] Error : %s", err.Error())
62+
return
63+
}
64+
65+
color.Green("[!] Valid Airtable OAuth2 Access Token\n\n")
66+
printUserAndPermissions(userInfo)
67+
68+
if hasScope(userInfo.Scopes, PermissionStrings[SchemaBasesRead]) {
69+
var basesInfo *AirtableBases
70+
basesInfo, _ = fetchAirtableBases(token)
71+
printBases(basesInfo)
72+
}
73+
}
74+
75+
func fetchAirtableUserInfo(token string) (*AirtableUserInfo, error) {
76+
url := "https://api.airtable.com/v0/meta/whoami"
77+
req, err := http.NewRequest("GET", url, nil)
78+
if err != nil {
79+
return nil, err
80+
}
81+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
82+
83+
resp, err := http.DefaultClient.Do(req)
84+
if err != nil {
85+
return nil, err
86+
}
87+
defer resp.Body.Close()
88+
89+
if resp.StatusCode != http.StatusOK {
90+
return nil, fmt.Errorf("failed to fetch Airtable user info, status: %d", resp.StatusCode)
91+
}
92+
93+
var userInfo AirtableUserInfo
94+
if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
95+
return nil, err
96+
}
97+
98+
return &userInfo, nil
99+
}
100+
101+
func fetchAirtableBases(token string) (*AirtableBases, error) {
102+
url := "https://api.airtable.com/v0/meta/bases"
103+
req, err := http.NewRequest("GET", url, nil)
104+
if err != nil {
105+
return nil, err
106+
}
107+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
108+
109+
resp, err := http.DefaultClient.Do(req)
110+
if err != nil {
111+
return nil, err
112+
}
113+
defer resp.Body.Close()
114+
115+
if resp.StatusCode != http.StatusOK {
116+
return nil, fmt.Errorf("failed to fetch Airtable bases, status: %d", resp.StatusCode)
117+
}
118+
119+
var basesInfo AirtableBases
120+
if err := json.NewDecoder(resp.Body).Decode(&basesInfo); err != nil {
121+
return nil, err
122+
}
123+
124+
return &basesInfo, nil
125+
}
126+
127+
func hasScope(scopes []string, target string) bool {
128+
for _, scope := range scopes {
129+
if scope == target {
130+
return true
131+
}
132+
}
133+
return false
134+
}
135+
136+
func mapToAnalyzerResult(userInfo *AirtableUserInfo, basesInfo *AirtableBases) *analyzers.AnalyzerResult {
137+
if userInfo == nil {
138+
return nil
139+
}
140+
141+
result := analyzers.AnalyzerResult{
142+
AnalyzerType: analyzers.AnalyzerTypeAirtable,
143+
}
144+
var permissions []analyzers.Permission
145+
for _, scope := range userInfo.Scopes {
146+
permissions = append(permissions, analyzers.Permission{Value: scope})
147+
}
148+
userResource := analyzers.Resource{
149+
Name: userInfo.ID,
150+
FullyQualifiedName: userInfo.ID,
151+
Type: "user",
152+
Metadata: map[string]any{},
153+
}
154+
155+
if userInfo.Email != nil {
156+
userResource.Metadata["email"] = *userInfo.Email
157+
}
158+
159+
result.Bindings = analyzers.BindAllPermissions(userResource, permissions...)
160+
161+
if basesInfo != nil {
162+
for _, base := range basesInfo.Bases {
163+
resource := analyzers.Resource{
164+
Name: base.Name,
165+
FullyQualifiedName: base.ID,
166+
Type: "base",
167+
}
168+
result.UnboundedResources = append(result.UnboundedResources, resource)
169+
}
170+
}
171+
172+
return &result
173+
}
174+
175+
func printUserAndPermissions(info *AirtableUserInfo) {
176+
color.Yellow("[i] User:")
177+
t1 := table.NewWriter()
178+
email := "N/A"
179+
if info.Email != nil {
180+
email = *info.Email
181+
}
182+
t1.SetOutputMirror(os.Stdout)
183+
t1.AppendHeader(table.Row{"ID", "Email"})
184+
t1.AppendRow(table.Row{color.GreenString(info.ID), color.GreenString(email)})
185+
t1.SetOutputMirror(os.Stdout)
186+
t1.Render()
187+
188+
color.Yellow("\n[i] Scopes:")
189+
t2 := table.NewWriter()
190+
t2.SetOutputMirror(os.Stdout)
191+
t2.AppendHeader(table.Row{"Scope", "Permission"})
192+
for _, scope := range info.Scopes {
193+
for i, permission := range scope_mapping[scope] {
194+
scope_string := ""
195+
if i == 0 {
196+
scope_string = scope
197+
}
198+
t2.AppendRow(table.Row{color.GreenString(scope_string), color.GreenString(permission)})
199+
}
200+
}
201+
t2.Render()
202+
fmt.Printf("%s: https://airtable.com/developers/web/api/scopes\n", color.GreenString("Ref"))
203+
}
204+
205+
func printBases(bases *AirtableBases) {
206+
color.Yellow("\n[i] Bases:")
207+
t := table.NewWriter()
208+
t.SetOutputMirror(os.Stdout)
209+
if len(bases.Bases) > 0 {
210+
t.AppendHeader(table.Row{"ID", "Name"})
211+
for _, base := range bases.Bases {
212+
t.AppendRow(table.Row{color.GreenString(base.ID), color.GreenString(base.Name)})
213+
}
214+
} else {
215+
fmt.Printf("%s\n", color.GreenString("No bases associated with token"))
216+
}
217+
t.Render()
218+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package airtable
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("AIRTABLEOAUTH_TOKEN"),
35+
name: "valid Airtable OAuth 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: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"AnalyzerType": 22,
3+
"Bindings": [
4+
{
5+
"Resource": {
6+
"Name": "usraS0CjAASH3XMpU",
7+
"FullyQualifiedName": "usraS0CjAASH3XMpU",
8+
"Type": "user",
9+
"Metadata": {},
10+
"Parent": null
11+
},
12+
"Permission": {
13+
"Value": "data.records:read",
14+
"Parent": null
15+
}
16+
},
17+
{
18+
"Resource": {
19+
"Name": "usraS0CjAASH3XMpU",
20+
"FullyQualifiedName": "usraS0CjAASH3XMpU",
21+
"Type": "user",
22+
"Metadata": {},
23+
"Parent": null
24+
},
25+
"Permission": {
26+
"Value": "schema.bases:read",
27+
"Parent": null
28+
}
29+
}
30+
],
31+
"UnboundedResources": [
32+
{
33+
"Name": "Client Leads and Sales Management",
34+
"FullyQualifiedName": "appzRyj5Q9R9kK6cF",
35+
"Type": "base",
36+
"Parent": null
37+
}
38+
]
39+
}

0 commit comments

Comments
 (0)