Skip to content
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
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions pkg/analyzer/analyzers/airtable/airtableoauth/airtable.go
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
@@ -1,4 +1,4 @@
package airtable
package airtableoauth

import (
_ "embed"
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 pkg/analyzer/analyzers/airtable/airtablepat/airtable.go
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
Copy link
Collaborator

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 the user.email:read scope?

Copy link
Contributor Author

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


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)
}
Loading
Oops, something went wrong.
Loading
Oops, something went wrong.