-
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
Changes from 1 commit
ca91b0b
746f75c
90434b2
4c82e64
1921386
25c36ac
838a1ef
5c0da7d
e587433
1a0df40
d0b8c24
1c07637
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
- Loading branch information
There are no files selected for viewing
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) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,174 @@ | ||
package airtablepat | ||
|
||
import ( | ||
_ "embed" | ||
"encoding/json" | ||
"errors" | ||
"fmt" | ||
"net/http" | ||
"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(endpoint common.EndpointName) common.Endpoint { | ||
return common.Endpoints[endpoint] | ||
} | ||
|
||
func getEndpointByPermission(scope string) common.Endpoint { | ||
return common.ScopeEndpointMap[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 | ||
if granted, _ := determineScope(token, common.SchemaBasesRead, nil); granted { | ||
basesInfo, _ = common.FetchAirtableBases(token) | ||
// If bases are fetched, determine the token scopes | ||
determineScopes(token, basesInfo) | ||
} | ||
|
||
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) | ||
determineScopes(token, basesInfo) | ||
} | ||
|
||
color.Green("[!] Valid Airtable Personal Access Token\n\n") | ||
|
||
common.PrintUserAndPermissions(userInfo, scopeStatusMap) | ||
if scopeStatusMap[common.PermissionStrings[basesReadPermission]] { | ||
common.PrintBases(basesInfo) | ||
} | ||
} | ||
|
||
func determineScope(token string, scope common.Permission, ids map[string]string) (bool, error) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: Could we add a short comment for this function, specifically explaining the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: For consistency and better readability, can we rename There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're right. The terms got a bit shuffled since each AirTable I agree that the usage of the terms should be more consistent. I'll update this and any other instances I see with inconsistent use of the terms scope/permission. |
||
scopeString := common.PermissionStrings[scope] | ||
endpoint := getEndpointByPermission(scopeString) | ||
url := endpoint.URL | ||
if ids != nil { | ||
for _, key := range endpoint.RequiredIDs { | ||
if value, ok := ids[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 == http.StatusOK { | ||
scopeStatusMap[scopeString] = true | ||
return true, nil | ||
} else if endpoint.ExpectedErrorResponse != nil { | ||
nabeelalam marked this conversation as resolved.
Show resolved
Hide resolved
|
||
var result map[string]interface{} | ||
nabeelalam marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { | ||
return false, err | ||
} | ||
|
||
if errorInfo, ok := result["error"].(map[string]interface{}); ok { | ||
nabeelalam marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if errorType, ok := errorInfo["type"].(string); ok && errorType == endpoint.ExpectedErrorResponse.Type { | ||
scopeStatusMap[scopeString] = false | ||
return false, nil | ||
} | ||
} | ||
} | ||
|
||
scopeStatusMap[scopeString] = true | ||
return true, nil | ||
} | ||
|
||
func determineScopes(token string, basesInfo *common.AirtableBases) { | ||
if basesInfo != nil && len(basesInfo.Bases) > 0 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: Similar to my earlier "line of sight" comment, we can invert the logic of this Ex:
|
||
for _, base := range basesInfo.Bases { | ||
if base.Schema != nil && len(base.Schema.Tables) > 0 { | ||
ids := map[string]string{"baseID": base.ID} | ||
tableScopesDetermined := false | ||
|
||
// Verify token "webhooks:manage" permission | ||
determineScope(token, common.WebhookManage, ids) | ||
// Verify token "block:manage" permission | ||
determineScope(token, common.BlockManage, ids) | ||
|
||
// Verifying scopes that require an existing table | ||
for _, table := range base.Schema.Tables { | ||
ids["tableID"] = table.ID | ||
|
||
if !tableScopesDetermined { | ||
determineScope(token, common.SchemaBasesWrite, ids) | ||
determineScope(token, common.DataRecordsWrite, ids) | ||
tableScopesDetermined = true | ||
} | ||
|
||
if granted, _ := determineScope(token, common.DataRecordsRead, ids); granted { | ||
// Verifying scopes that require an existing record and record read permission | ||
records, err := fetchAirtableRecords(token, base.ID, table.ID) | ||
nabeelalam marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if err != nil || len(records) > 0 { | ||
for _, record := range records { | ||
ids["recordID"] = record.ID | ||
determineScope(token, common.DataRecordcommentsRead, ids) | ||
break | ||
} | ||
break | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} | ||
|
||
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) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
package airtablepat | ||
|
||
import ( | ||
"encoding/json" | ||
"fmt" | ||
"net/http" | ||
"strings" | ||
|
||
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/airtable/common" | ||
) | ||
|
||
type AirtableRecordsResponse struct { | ||
Records []common.AirtableEntity `json:"records"` | ||
} | ||
|
||
func fetchBaseSchema(baseID, token string) (*common.Schema, error) { | ||
endpoint := getEndpoint(common.GetBaseSchemaEndpoint) | ||
url := strings.Replace(endpoint.URL, "{baseID}", baseID, -1) | ||
resp, err := common.CallAirtableAPI(token, endpoint.Method, url) | ||
if err != nil { | ||
return nil, err | ||
} | ||
defer resp.Body.Close() | ||
|
||
if resp.StatusCode != http.StatusOK { | ||
return nil, fmt.Errorf("failed to fetch schema for base %s, status: %d", baseID, resp.StatusCode) | ||
} | ||
|
||
var schema common.Schema | ||
if err := json.NewDecoder(resp.Body).Decode(&schema); err != nil { | ||
return nil, err | ||
} | ||
|
||
return &schema, nil | ||
} | ||
|
||
func fetchAirtableRecords(token string, baseID string, tableID string) ([]common.AirtableEntity, error) { | ||
endpoint := getEndpoint(common.ListRecordsEndpoint) | ||
url := strings.Replace(strings.Replace(endpoint.URL, "{baseID}", baseID, -1), "{tableID}", tableID, -1) | ||
resp, err := common.CallAirtableAPI(token, "GET", url) | ||
if err != nil { | ||
return nil, err | ||
} | ||
defer resp.Body.Close() | ||
|
||
if resp.StatusCode != http.StatusOK { | ||
return nil, fmt.Errorf("failed to fetch Airtable records, status: %d", resp.StatusCode) | ||
} | ||
|
||
var recordsResponse AirtableRecordsResponse | ||
if err := json.NewDecoder(resp.Body).Decode(&recordsResponse); err != nil { | ||
return nil, err | ||
} | ||
|
||
return recordsResponse.Records, nil | ||
} |
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