-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
1 parent
590ba66
commit 59c6f2d
Showing
17 changed files
with
807 additions
and
124 deletions.
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.