Skip to content

Commit 59c6f2d

Browse files
authored
Feature: Airtable Analyzer for Personal Access Tokens (#3941)
* added airtable personal access token analyzer * updated airtable oauth and pat analyzers * updated airtable entry in analyzer mapping * updated airtablepat error checking, table shape * added more error checks in analyzer code * updated airtable pat analyzer; incorporated feedback; fixed formating issues; improved code readability * updated analyzer type int of planetscale expected output json * fixed spelling in comment * updated expected output analyzer type numbers
1 parent 590ba66 commit 59c6f2d

File tree

17 files changed

+807
-124
lines changed

17 files changed

+807
-124
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package airtableoauth
2+
3+
import (
4+
"errors"
5+
6+
"github.com/fatih/color"
7+
8+
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers"
9+
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/airtable/common"
10+
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config"
11+
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
12+
)
13+
14+
var _ analyzers.Analyzer = (*Analyzer)(nil)
15+
16+
type Analyzer struct {
17+
Cfg *config.Config
18+
}
19+
20+
func (Analyzer) Type() analyzers.AnalyzerType { return analyzers.AnalyzerTypeAirtableOAuth }
21+
22+
func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) {
23+
token, ok := credInfo["token"]
24+
if !ok {
25+
return nil, errors.New("token not found in credInfo")
26+
}
27+
28+
userInfo, err := common.FetchAirtableUserInfo(token)
29+
if err != nil {
30+
return nil, err
31+
}
32+
33+
var basesInfo *common.AirtableBases
34+
baseScope := common.PermissionStrings[common.SchemaBasesRead]
35+
if hasScope(userInfo.Scopes, baseScope) {
36+
basesInfo, _ = common.FetchAirtableBases(token)
37+
}
38+
39+
return common.MapToAnalyzerResult(userInfo, basesInfo), nil
40+
}
41+
42+
func AnalyzeAndPrintPermissions(cfg *config.Config, token string) {
43+
userInfo, err := common.FetchAirtableUserInfo(token)
44+
if err != nil {
45+
color.Red("[x] Error : %s", err.Error())
46+
return
47+
}
48+
49+
color.Green("[!] Valid Airtable OAuth2 Access Token\n\n")
50+
printUserAndPermissions(userInfo)
51+
52+
baseScope := common.PermissionStrings[common.SchemaBasesRead]
53+
if hasScope(userInfo.Scopes, baseScope) {
54+
var basesInfo *common.AirtableBases
55+
basesInfo, _ = common.FetchAirtableBases(token)
56+
common.PrintBases(basesInfo)
57+
}
58+
}
59+
60+
func hasScope(scopes []string, target string) bool {
61+
for _, scope := range scopes {
62+
if scope == target {
63+
return true
64+
}
65+
}
66+
return false
67+
}
68+
69+
func printUserAndPermissions(info *common.AirtableUserInfo) {
70+
scopeStatusMap := make(map[string]bool)
71+
for _, scope := range common.PermissionStrings {
72+
scopeStatusMap[scope] = false
73+
}
74+
for _, scope := range info.Scopes {
75+
scopeStatusMap[scope] = true
76+
}
77+
78+
common.PrintUserAndPermissions(info, scopeStatusMap)
79+
}

pkg/analyzer/analyzers/airtable/airtable_test.go renamed to pkg/analyzer/analyzers/airtable/airtableoauth/airtable_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package airtable
1+
package airtableoauth
22

33
import (
44
_ "embed"

pkg/analyzer/analyzers/airtable/expected_output.json renamed to pkg/analyzer/analyzers/airtable/airtableoauth/expected_output.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"AnalyzerType": 22,
2+
"AnalyzerType": 28,
33
"Bindings": [
44
{
55
"Resource": {
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
package airtablepat
2+
3+
import (
4+
_ "embed"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"strings"
9+
10+
"github.com/fatih/color"
11+
12+
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers"
13+
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/airtable/common"
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.AnalyzerTypeAirtablePat }
25+
26+
var scopeStatusMap = make(map[string]bool)
27+
28+
func getEndpoint(endpointName common.EndpointName) (common.Endpoint, bool) {
29+
return common.GetEndpoint(endpointName)
30+
}
31+
32+
func getScopeEndpoint(scope string) (common.Endpoint, bool) {
33+
return common.GetScopeEndpoint(scope)
34+
}
35+
36+
func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) {
37+
token, ok := credInfo["token"]
38+
if !ok {
39+
return nil, errors.New("token not found in credInfo")
40+
}
41+
42+
userInfo, err := common.FetchAirtableUserInfo(token)
43+
if err != nil {
44+
return nil, err
45+
}
46+
47+
scopeStatusMap[common.PermissionStrings[common.UserEmailRead]] = userInfo.Email != nil
48+
49+
var basesInfo *common.AirtableBases
50+
granted, err := determineScope(token, common.SchemaBasesRead, nil)
51+
if err != nil {
52+
return nil, err
53+
}
54+
if granted {
55+
basesInfo, err = common.FetchAirtableBases(token)
56+
if err != nil {
57+
return nil, err
58+
}
59+
// If bases are fetched, determine the token scopes
60+
err := determineScopes(token, basesInfo)
61+
if err != nil {
62+
return nil, err
63+
}
64+
}
65+
66+
return mapToAnalyzerResult(userInfo, basesInfo), nil
67+
}
68+
69+
func AnalyzeAndPrintPermissions(cfg *config.Config, token string) {
70+
userInfo, err := common.FetchAirtableUserInfo(token)
71+
if err != nil {
72+
color.Red("[x] Error : %s", err.Error())
73+
return
74+
}
75+
76+
scopeStatusMap[common.PermissionStrings[common.UserEmailRead]] = userInfo.Email != nil
77+
78+
var basesInfo *common.AirtableBases
79+
basesReadPermission := common.SchemaBasesRead
80+
if granted, err := determineScope(token, basesReadPermission, nil); granted {
81+
if err != nil {
82+
color.Red("[x] Error : %s", err.Error())
83+
return
84+
}
85+
basesInfo, _ = common.FetchAirtableBases(token)
86+
err := determineScopes(token, basesInfo)
87+
if err != nil {
88+
color.Red("[x] Error : %s", err.Error())
89+
return
90+
}
91+
}
92+
93+
color.Green("[!] Valid Airtable Personal Access Token\n\n")
94+
95+
common.PrintUserAndPermissions(userInfo, scopeStatusMap)
96+
if scopeStatusMap[common.PermissionStrings[basesReadPermission]] {
97+
common.PrintBases(basesInfo)
98+
}
99+
}
100+
101+
// determineScope checks whether the given token has the specified permission by making an API call.
102+
//
103+
// The function performs the following actions:
104+
// - Determines the appropriate API Endpoint based on the input scope/permission.
105+
// - Constructs an HTTP request using the endpoint's URL, method, and required IDs.
106+
// If the URL contains path parameters (e.g., "{baseID}"), they must be replaced using `requiredIDs`.
107+
// - Sends the request and analyzes the response to determine if the token has the requested permission.
108+
//
109+
// Returns `true` if the token has the permission, `false` otherwise.
110+
// If an error occurs, it returns false along with the encountered error.
111+
func determineScope(token string, perm common.Permission, requiredIDs map[string]string) (bool, error) {
112+
scopeString := common.PermissionStrings[perm]
113+
endpoint, exists := getScopeEndpoint(scopeString)
114+
if !exists {
115+
return false, nil
116+
}
117+
118+
url := endpoint.URL
119+
if requiredIDs != nil {
120+
for _, key := range endpoint.RequiredIDs {
121+
if value, ok := requiredIDs[key]; ok {
122+
url = strings.Replace(url, fmt.Sprintf("{%s}", key), value, -1)
123+
}
124+
}
125+
}
126+
127+
resp, err := common.CallAirtableAPI(token, endpoint.Method, url)
128+
if err != nil {
129+
return false, err
130+
}
131+
defer resp.Body.Close()
132+
133+
if resp.StatusCode == endpoint.ExpectedSuccessStatus {
134+
scopeStatusMap[scopeString] = true
135+
return true, nil
136+
}
137+
138+
// If the response status is not 200 OK, we need to verify if the error is as expected
139+
if endpoint.ExpectedErrorResponse != nil {
140+
var result map[string]any
141+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
142+
return false, err
143+
}
144+
145+
errorInfo, ok := result["error"].(map[string]any)
146+
if !ok {
147+
// If no error is found in the response, the scope is unverified
148+
return false, nil
149+
}
150+
errorType, ok := errorInfo["type"].(string)
151+
if !ok || errorType != endpoint.ExpectedErrorResponse.Type {
152+
// If "type" is missing from the error body, or mismatches the expected type, the scope is unverified
153+
return false, nil
154+
}
155+
156+
// The token lacks the scope/permission to fulfill the request
157+
scopeStatusMap[scopeString] = false
158+
return false, nil
159+
}
160+
161+
// Can not determine scope as the expected error is unknown
162+
return false, nil
163+
}
164+
165+
func determineScopes(token string, basesInfo *common.AirtableBases) error {
166+
if basesInfo == nil || len(basesInfo.Bases) == 0 {
167+
return nil
168+
}
169+
170+
for _, base := range basesInfo.Bases {
171+
requiredIDs := map[string]string{"baseID": base.ID}
172+
tableScopesDetermined := false
173+
174+
// Verify token "webhooks:manage" permission
175+
_, err := determineScope(token, common.WebhookManage, requiredIDs)
176+
if err != nil {
177+
return err
178+
}
179+
// Verify token "block:manage" permission
180+
_, err = determineScope(token, common.BlockManage, requiredIDs)
181+
if err != nil {
182+
return err
183+
}
184+
185+
if base.Schema == nil || len(base.Schema.Tables) == 0 {
186+
return nil
187+
}
188+
189+
// Verifying scopes that require an existing table
190+
for _, table := range base.Schema.Tables {
191+
requiredIDs["tableID"] = table.ID
192+
193+
if !tableScopesDetermined {
194+
_, err = determineScope(token, common.SchemaBasesWrite, requiredIDs)
195+
if err != nil {
196+
return err
197+
}
198+
_, err = determineScope(token, common.DataRecordsWrite, requiredIDs)
199+
if err != nil {
200+
return err
201+
}
202+
tableScopesDetermined = true
203+
}
204+
205+
granted, err := determineScope(token, common.DataRecordsRead, requiredIDs)
206+
if err != nil {
207+
return err
208+
}
209+
if !granted {
210+
continue
211+
}
212+
// Verifying scopes that require an existing "record" and the "data records read" permission
213+
records, err := fetchAirtableRecords(token, base.ID, table.ID)
214+
if err != nil {
215+
return err
216+
}
217+
for _, record := range records {
218+
requiredIDs["recordID"] = record.ID
219+
_, err = determineScope(token, common.DataRecordcommentsRead, requiredIDs)
220+
if err != nil {
221+
return err
222+
}
223+
break
224+
}
225+
if len(records) != 0 {
226+
break
227+
}
228+
}
229+
}
230+
return nil
231+
}
232+
233+
func mapToAnalyzerResult(userInfo *common.AirtableUserInfo, basesInfo *common.AirtableBases) *analyzers.AnalyzerResult {
234+
for scope, status := range scopeStatusMap {
235+
if status {
236+
userInfo.Scopes = append(userInfo.Scopes, scope)
237+
}
238+
}
239+
return common.MapToAnalyzerResult(userInfo, basesInfo)
240+
}

0 commit comments

Comments
 (0)