-
Notifications
You must be signed in to change notification settings - Fork 1.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feature: Airtable Analyzer for Personal Access Tokens #3941
Merged
nabeelalam
merged 12 commits into
trufflesecurity:main
from
nabeelalam:feature/analyzer/airtable-personal-access-token
Mar 3, 2025
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
ca91b0b
added airtable personal access token analyzer
nabeelalam 746f75c
Merge branch 'main' into feature/analyzer/airtable-personal-access-token
nabeelalam 90434b2
updated airtable oauth and pat analyzers
nabeelalam 4c82e64
Merge branch 'main' into feature/analyzer/airtable-personal-access-token
nabeelalam 1921386
updated airtable entry in analyzer mapping
nabeelalam 25c36ac
updated airtablepat error checking, table shape
nabeelalam 838a1ef
added more error checks in analyzer code
nabeelalam 5c0da7d
updated airtable pat analyzer; incorporated feedback; fixed formating…
nabeelalam e587433
Merge branch 'main' into feature/analyzer/airtable-personal-access-token
nabeelalam 1a0df40
updated analyzer type int of planetscale expected output json
nabeelalam d0b8c24
fixed spelling in comment
nabeelalam 1c07637
updated expected output analyzer type numbers
nabeelalam File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
package airtableoauth | ||
|
||
import ( | ||
"errors" | ||
|
||
"github.com/fatih/color" | ||
|
||
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" | ||
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/airtable/common" | ||
"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.AnalyzerTypeAirtableOAuth } | ||
|
||
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") | ||
} | ||
|
||
userInfo, err := common.FetchAirtableUserInfo(token) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
var basesInfo *common.AirtableBases | ||
baseScope := common.PermissionStrings[common.SchemaBasesRead] | ||
if hasScope(userInfo.Scopes, baseScope) { | ||
basesInfo, _ = common.FetchAirtableBases(token) | ||
} | ||
|
||
return common.MapToAnalyzerResult(userInfo, basesInfo), nil | ||
} | ||
|
||
func AnalyzeAndPrintPermissions(cfg *config.Config, token string) { | ||
userInfo, err := common.FetchAirtableUserInfo(token) | ||
if err != nil { | ||
color.Red("[x] Error : %s", err.Error()) | ||
return | ||
} | ||
|
||
color.Green("[!] Valid Airtable OAuth2 Access Token\n\n") | ||
printUserAndPermissions(userInfo) | ||
|
||
baseScope := common.PermissionStrings[common.SchemaBasesRead] | ||
if hasScope(userInfo.Scopes, baseScope) { | ||
var basesInfo *common.AirtableBases | ||
basesInfo, _ = common.FetchAirtableBases(token) | ||
common.PrintBases(basesInfo) | ||
} | ||
} | ||
|
||
func hasScope(scopes []string, target string) bool { | ||
for _, scope := range scopes { | ||
if scope == target { | ||
return true | ||
} | ||
} | ||
return false | ||
} | ||
|
||
func printUserAndPermissions(info *common.AirtableUserInfo) { | ||
scopeStatusMap := make(map[string]bool) | ||
for _, scope := range common.PermissionStrings { | ||
scopeStatusMap[scope] = false | ||
} | ||
for _, scope := range info.Scopes { | ||
scopeStatusMap[scope] = true | ||
} | ||
|
||
common.PrintUserAndPermissions(info, scopeStatusMap) | ||
} |
2 changes: 1 addition & 1 deletion
2
...lyzer/analyzers/airtable/airtable_test.go → ...s/airtable/airtableoauth/airtable_test.go
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,4 @@ | ||
package airtable | ||
package airtableoauth | ||
|
||
import ( | ||
_ "embed" | ||
|
2 changes: 1 addition & 1 deletion
2
...r/analyzers/airtable/expected_output.json → ...rtable/airtableoauth/expected_output.json
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,5 @@ | ||
{ | ||
"AnalyzerType": 22, | ||
"AnalyzerType": 28, | ||
"Bindings": [ | ||
{ | ||
"Resource": { | ||
|
240 changes: 240 additions & 0 deletions
240
pkg/analyzer/analyzers/airtable/airtablepat/airtable.go
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,240 @@ | ||
package airtablepat | ||
|
||
import ( | ||
_ "embed" | ||
"encoding/json" | ||
"errors" | ||
"fmt" | ||
"strings" | ||
|
||
"github.com/fatih/color" | ||
|
||
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" | ||
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/airtable/common" | ||
"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.AnalyzerTypeAirtablePat } | ||
|
||
var scopeStatusMap = make(map[string]bool) | ||
|
||
func getEndpoint(endpointName common.EndpointName) (common.Endpoint, bool) { | ||
return common.GetEndpoint(endpointName) | ||
} | ||
|
||
func getScopeEndpoint(scope string) (common.Endpoint, bool) { | ||
return common.GetScopeEndpoint(scope) | ||
} | ||
|
||
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") | ||
} | ||
|
||
userInfo, err := common.FetchAirtableUserInfo(token) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
scopeStatusMap[common.PermissionStrings[common.UserEmailRead]] = userInfo.Email != nil | ||
|
||
var basesInfo *common.AirtableBases | ||
granted, err := determineScope(token, common.SchemaBasesRead, nil) | ||
if err != nil { | ||
return nil, err | ||
} | ||
if granted { | ||
basesInfo, err = common.FetchAirtableBases(token) | ||
if err != nil { | ||
return nil, err | ||
} | ||
// If bases are fetched, determine the token scopes | ||
err := determineScopes(token, basesInfo) | ||
if err != nil { | ||
return nil, err | ||
} | ||
} | ||
|
||
return mapToAnalyzerResult(userInfo, basesInfo), nil | ||
} | ||
|
||
func AnalyzeAndPrintPermissions(cfg *config.Config, token string) { | ||
userInfo, err := common.FetchAirtableUserInfo(token) | ||
if err != nil { | ||
color.Red("[x] Error : %s", err.Error()) | ||
return | ||
} | ||
|
||
scopeStatusMap[common.PermissionStrings[common.UserEmailRead]] = userInfo.Email != nil | ||
|
||
var basesInfo *common.AirtableBases | ||
basesReadPermission := common.SchemaBasesRead | ||
if granted, err := determineScope(token, basesReadPermission, nil); granted { | ||
if err != nil { | ||
color.Red("[x] Error : %s", err.Error()) | ||
return | ||
} | ||
basesInfo, _ = common.FetchAirtableBases(token) | ||
err := determineScopes(token, basesInfo) | ||
if err != nil { | ||
color.Red("[x] Error : %s", err.Error()) | ||
return | ||
} | ||
} | ||
|
||
color.Green("[!] Valid Airtable Personal Access Token\n\n") | ||
|
||
common.PrintUserAndPermissions(userInfo, scopeStatusMap) | ||
if scopeStatusMap[common.PermissionStrings[basesReadPermission]] { | ||
common.PrintBases(basesInfo) | ||
} | ||
} | ||
|
||
// determineScope checks whether the given token has the specified permission by making an API call. | ||
// | ||
// The function performs the following actions: | ||
// - Determines the appropriate API Endpoint based on the input scope/permission. | ||
// - Constructs an HTTP request using the endpoint's URL, method, and required IDs. | ||
// If the URL contains path parameters (e.g., "{baseID}"), they must be replaced using `requiredIDs`. | ||
// - Sends the request and analyzes the response to determine if the token has the requested permission. | ||
// | ||
// Returns `true` if the token has the permission, `false` otherwise. | ||
// If an error occurs, it returns false along with the encountered error. | ||
func determineScope(token string, perm common.Permission, requiredIDs map[string]string) (bool, error) { | ||
scopeString := common.PermissionStrings[perm] | ||
endpoint, exists := getScopeEndpoint(scopeString) | ||
if !exists { | ||
return false, nil | ||
} | ||
|
||
url := endpoint.URL | ||
if requiredIDs != nil { | ||
for _, key := range endpoint.RequiredIDs { | ||
if value, ok := requiredIDs[key]; ok { | ||
url = strings.Replace(url, fmt.Sprintf("{%s}", key), value, -1) | ||
} | ||
} | ||
} | ||
|
||
resp, err := common.CallAirtableAPI(token, endpoint.Method, url) | ||
if err != nil { | ||
return false, err | ||
} | ||
defer resp.Body.Close() | ||
|
||
if resp.StatusCode == endpoint.ExpectedSuccessStatus { | ||
scopeStatusMap[scopeString] = true | ||
return true, nil | ||
} | ||
|
||
// If the response status is not 200 OK, we need to verify if the error is as expected | ||
if endpoint.ExpectedErrorResponse != nil { | ||
var result map[string]any | ||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { | ||
return false, err | ||
} | ||
|
||
errorInfo, ok := result["error"].(map[string]any) | ||
if !ok { | ||
// If no error is found in the response, the scope is unverified | ||
return false, nil | ||
} | ||
errorType, ok := errorInfo["type"].(string) | ||
if !ok || errorType != endpoint.ExpectedErrorResponse.Type { | ||
// If "type" is missing from the error body, or mismatches the expected type, the scope is unverified | ||
return false, nil | ||
} | ||
|
||
// The token lacks the scope/permission to fulfill the request | ||
scopeStatusMap[scopeString] = false | ||
return false, nil | ||
} | ||
|
||
// Can not determine scope as the expected error is unknown | ||
return false, nil | ||
} | ||
|
||
func determineScopes(token string, basesInfo *common.AirtableBases) error { | ||
if basesInfo == nil || len(basesInfo.Bases) == 0 { | ||
return nil | ||
} | ||
|
||
for _, base := range basesInfo.Bases { | ||
requiredIDs := map[string]string{"baseID": base.ID} | ||
tableScopesDetermined := false | ||
|
||
// Verify token "webhooks:manage" permission | ||
_, err := determineScope(token, common.WebhookManage, requiredIDs) | ||
if err != nil { | ||
return err | ||
} | ||
// Verify token "block:manage" permission | ||
_, err = determineScope(token, common.BlockManage, requiredIDs) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
if base.Schema == nil || len(base.Schema.Tables) == 0 { | ||
return nil | ||
} | ||
|
||
// Verifying scopes that require an existing table | ||
for _, table := range base.Schema.Tables { | ||
requiredIDs["tableID"] = table.ID | ||
|
||
if !tableScopesDetermined { | ||
_, err = determineScope(token, common.SchemaBasesWrite, requiredIDs) | ||
if err != nil { | ||
return err | ||
} | ||
_, err = determineScope(token, common.DataRecordsWrite, requiredIDs) | ||
if err != nil { | ||
return err | ||
} | ||
tableScopesDetermined = true | ||
} | ||
|
||
granted, err := determineScope(token, common.DataRecordsRead, requiredIDs) | ||
if err != nil { | ||
return err | ||
} | ||
if !granted { | ||
continue | ||
} | ||
// Verifying scopes that require an existing "record" and the "data records read" permission | ||
records, err := fetchAirtableRecords(token, base.ID, table.ID) | ||
if err != nil { | ||
return err | ||
} | ||
for _, record := range records { | ||
requiredIDs["recordID"] = record.ID | ||
_, err = determineScope(token, common.DataRecordcommentsRead, requiredIDs) | ||
if err != nil { | ||
return err | ||
} | ||
break | ||
} | ||
if len(records) != 0 { | ||
break | ||
} | ||
} | ||
} | ||
return nil | ||
} | ||
|
||
func mapToAnalyzerResult(userInfo *common.AirtableUserInfo, basesInfo *common.AirtableBases) *analyzers.AnalyzerResult { | ||
for scope, status := range scopeStatusMap { | ||
if status { | ||
userInfo.Scopes = append(userInfo.Scopes, scope) | ||
} | ||
} | ||
return common.MapToAnalyzerResult(userInfo, basesInfo) | ||
} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
question: Is the idea here that if the response from the
FetchAirtableUserInfo
API contains an email, they have theuser.email:read
scope?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes. Here's the API reference for that: https://airtable.com/developers/web/api/get-user-id-scopes