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 1 commit
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
Prev Previous commit
Next Next commit
updated airtable oauth and pat analyzers
  • Loading branch information
nabeelalam committed Feb 28, 2025
commit 90434b2d755003933b486071de055d8b22b32aae
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)
}
174 changes: 174 additions & 0 deletions pkg/analyzer/analyzers/airtable/airtablepat/airtable.go
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
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
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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The 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 ids parameter? It took a bit of jumping around to understand its purpose.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: For consistency and better readability, can we rename scope to permission or perm? Since it's later used as an argument for getEndpointByPermission, this would make the code clearer.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. The terms got a bit shuffled since each AirTable scope maps to a Permission enum in the auto generated permissions.go file, but each AirTable scope also has its own granular "permissions".

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 {
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return false, err
}

if errorInfo, ok := result["error"].(map[string]interface{}); ok {
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 {
Copy link
Collaborator

Choose a reason for hiding this comment

The 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 if statement to return early, allowing us to de-indent the following for loop for better readability.

Ex:

if basesInfo == nil || len(basesInfo.Bases) == 0 {
    return nil
}

for _ base := range baseInfo.Bases {
    ...
}

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)

Check failure on line 135 in pkg/analyzer/analyzers/airtable/airtablepat/airtable.go

GitHub Actions / golangci-lint

Error return value is not checked (errcheck)
// Verify token "block:manage" permission
determineScope(token, common.BlockManage, ids)

Check failure on line 137 in pkg/analyzer/analyzers/airtable/airtablepat/airtable.go

GitHub Actions / golangci-lint

Error return value is not checked (errcheck)

// Verifying scopes that require an existing table
for _, table := range base.Schema.Tables {
ids["tableID"] = table.ID

if !tableScopesDetermined {
determineScope(token, common.SchemaBasesWrite, ids)

Check failure on line 144 in pkg/analyzer/analyzers/airtable/airtablepat/airtable.go

GitHub Actions / golangci-lint

Error return value is not checked (errcheck)
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)
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)
}
56 changes: 56 additions & 0 deletions pkg/analyzer/analyzers/airtable/airtablepat/requests.go
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) {

Check failure on line 16 in pkg/analyzer/analyzers/airtable/airtablepat/requests.go

GitHub Actions / golangci-lint

func `fetchBaseSchema` is unused (unused)
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
}
Loading
Oops, something went wrong.
Loading
Oops, something went wrong.