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 7 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"
205 changes: 205 additions & 0 deletions pkg/analyzer/analyzers/airtable/airtablepat/airtable.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
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, err := determineScope(token, common.SchemaBasesRead, nil); granted {
if err != nil {
return nil, err
}
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)
}
}

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) error {
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
_, err := determineScope(token, common.WebhookManage, ids)
if err != nil {
return err
}
// Verify token "block:manage" permission
_, err = determineScope(token, common.BlockManage, ids)
if err != nil {
return err
}

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

if !tableScopesDetermined {
_, err = determineScope(token, common.SchemaBasesWrite, ids)
if err != nil {
return err
}
_, err = determineScope(token, common.DataRecordsWrite, ids)
if err != nil {
return err
}
tableScopesDetermined = true
}

if granted, err := determineScope(token, common.DataRecordsRead, ids); err != nil {
return err
} else if 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
_, err = determineScope(token, common.DataRecordcommentsRead, ids)
if err != nil {
return err
}
break
}
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)
}
100 changes: 100 additions & 0 deletions pkg/analyzer/analyzers/airtable/airtablepat/airtable_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package airtablepat

import (
_ "embed"
"encoding/json"
"sort"
"testing"
"time"

"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config"
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
)

//go:embed expected_output.json
var expectedOutput []byte

func TestAnalyzer_Analyze(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors5")
if err != nil {
t.Fatalf("could not get test secrets from GCP: %s", err)
}

tests := []struct {
name string
token string
want string // JSON string
wantErr bool
}{
{
token: testSecrets.MustGetField("AIRTABLEOAUTH_TOKEN"),
name: "valid Airtable Personal Access Token",
want: string(expectedOutput),
wantErr: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
a := Analyzer{Cfg: &config.Config{}}
got, err := a.Analyze(ctx, map[string]string{"token": tt.token})
if (err != nil) != tt.wantErr {
t.Errorf("Analyzer.Analyze() error = %v, wantErr %v", err, tt.wantErr)
return
}

// bindings need to be in the same order to be comparable
sortBindings(got.Bindings)

// Marshal the actual result to JSON
gotJSON, err := json.Marshal(got)
if err != nil {
t.Fatalf("could not marshal got to JSON: %s", err)
}

// Parse the expected JSON string
var wantObj analyzers.AnalyzerResult
if err := json.Unmarshal([]byte(tt.want), &wantObj); err != nil {
t.Fatalf("could not unmarshal want JSON string: %s", err)
}

// bindings need to be in the same order to be comparable
sortBindings(wantObj.Bindings)

// Marshal the expected result to JSON (to normalize)
wantJSON, err := json.Marshal(wantObj)
if err != nil {
t.Fatalf("could not marshal want to JSON: %s", err)
}

// Compare the JSON strings
if string(gotJSON) != string(wantJSON) {
// Pretty-print both JSON strings for easier comparison
var gotIndented, wantIndented []byte
gotIndented, err = json.MarshalIndent(got, "", " ")
if err != nil {
t.Fatalf("could not marshal got to indented JSON: %s", err)
}
wantIndented, err = json.MarshalIndent(wantObj, "", " ")
if err != nil {
t.Fatalf("could not marshal want to indented JSON: %s", err)
}
t.Errorf("Analyzer.Analyze() = %s, want %s", gotIndented, wantIndented)
}
})
}
}

// Helper function to sort bindings
func sortBindings(bindings []analyzers.Binding) {
sort.SliceStable(bindings, func(i, j int) bool {
if bindings[i].Resource.Name == bindings[j].Resource.Name {
return bindings[i].Permission.Value < bindings[j].Permission.Value
}
return bindings[i].Resource.Name < bindings[j].Resource.Name
})
}
Loading
Oops, something went wrong.
Loading
Oops, something went wrong.