Skip to content

Commit

Permalink
Feature: Airtable Analyzer for Personal Access Tokens (#3941)
Browse files Browse the repository at this point in the history
* 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
nabeelalam authored Mar 3, 2025
1 parent 590ba66 commit 59c6f2d
Showing 17 changed files with 807 additions and 124 deletions.
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

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

0 comments on commit 59c6f2d

Please sign in to comment.