Skip to content

Commit

Permalink
[Feature] Airtable Analyzer for OAuth Tokens (#3879)
Browse files Browse the repository at this point in the history
* added airtable analyzer for oauth tokens

* added airtable analyzer cli command
  • Loading branch information
nabeelalam authored Feb 19, 2025
1 parent 6b1be99 commit 72a515c
Show file tree
Hide file tree
Showing 9 changed files with 704 additions and 0 deletions.
218 changes: 218 additions & 0 deletions pkg/analyzer/analyzers/airtable/airtable.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
//go:generate generate_permissions permissions.yaml permissions.go airtable
package airtable

import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"

"github.com/fatih/color"
"github.com/jedib0t/go-pretty/table"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers"
"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.AnalyzerTypeAirtable }

type AirtableUserInfo struct {
ID string `json:"id"`
Email *string `json:"email,omitempty"`
Scopes []string `json:"scopes"`
}

type AirtableBases struct {
Bases []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"bases"`
}

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 := fetchAirtableUserInfo(token)
if err != nil {
return nil, err
}

var basesInfo *AirtableBases
if hasScope(userInfo.Scopes, PermissionStrings[SchemaBasesRead]) {
basesInfo, _ = fetchAirtableBases(token)
}

return mapToAnalyzerResult(userInfo, basesInfo), nil
}

func AnalyzeAndPrintPermissions(cfg *config.Config, token string) {
userInfo, err := fetchAirtableUserInfo(token)
if err != nil {
color.Red("[x] Error : %s", err.Error())
return
}

color.Green("[!] Valid Airtable OAuth2 Access Token\n\n")
printUserAndPermissions(userInfo)

if hasScope(userInfo.Scopes, PermissionStrings[SchemaBasesRead]) {
var basesInfo *AirtableBases
basesInfo, _ = fetchAirtableBases(token)
printBases(basesInfo)
}
}

func fetchAirtableUserInfo(token string) (*AirtableUserInfo, error) {
url := "https://api.airtable.com/v0/meta/whoami"
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))

resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch Airtable user info, status: %d", resp.StatusCode)
}

var userInfo AirtableUserInfo
if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
return nil, err
}

return &userInfo, nil
}

func fetchAirtableBases(token string) (*AirtableBases, error) {
url := "https://api.airtable.com/v0/meta/bases"
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))

resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch Airtable bases, status: %d", resp.StatusCode)
}

var basesInfo AirtableBases
if err := json.NewDecoder(resp.Body).Decode(&basesInfo); err != nil {
return nil, err
}

return &basesInfo, nil
}

func hasScope(scopes []string, target string) bool {
for _, scope := range scopes {
if scope == target {
return true
}
}
return false
}

func mapToAnalyzerResult(userInfo *AirtableUserInfo, basesInfo *AirtableBases) *analyzers.AnalyzerResult {
if userInfo == nil {
return nil
}

result := analyzers.AnalyzerResult{
AnalyzerType: analyzers.AnalyzerTypeAirtable,
}
var permissions []analyzers.Permission
for _, scope := range userInfo.Scopes {
permissions = append(permissions, analyzers.Permission{Value: scope})
}
userResource := analyzers.Resource{
Name: userInfo.ID,
FullyQualifiedName: userInfo.ID,
Type: "user",
Metadata: map[string]any{},
}

if userInfo.Email != nil {
userResource.Metadata["email"] = *userInfo.Email
}

result.Bindings = analyzers.BindAllPermissions(userResource, permissions...)

if basesInfo != nil {
for _, base := range basesInfo.Bases {
resource := analyzers.Resource{
Name: base.Name,
FullyQualifiedName: base.ID,
Type: "base",
}
result.UnboundedResources = append(result.UnboundedResources, resource)
}
}

return &result
}

func printUserAndPermissions(info *AirtableUserInfo) {
color.Yellow("[i] User:")
t1 := table.NewWriter()
email := "N/A"
if info.Email != nil {
email = *info.Email
}
t1.SetOutputMirror(os.Stdout)
t1.AppendHeader(table.Row{"ID", "Email"})
t1.AppendRow(table.Row{color.GreenString(info.ID), color.GreenString(email)})
t1.SetOutputMirror(os.Stdout)
t1.Render()

color.Yellow("\n[i] Scopes:")
t2 := table.NewWriter()
t2.SetOutputMirror(os.Stdout)
t2.AppendHeader(table.Row{"Scope", "Permission"})
for _, scope := range info.Scopes {
for i, permission := range scope_mapping[scope] {
scope_string := ""
if i == 0 {
scope_string = scope
}
t2.AppendRow(table.Row{color.GreenString(scope_string), color.GreenString(permission)})
}
}
t2.Render()
fmt.Printf("%s: https://airtable.com/developers/web/api/scopes\n", color.GreenString("Ref"))
}

func printBases(bases *AirtableBases) {
color.Yellow("\n[i] Bases:")
t := table.NewWriter()
t.SetOutputMirror(os.Stdout)
if len(bases.Bases) > 0 {
t.AppendHeader(table.Row{"ID", "Name"})
for _, base := range bases.Bases {
t.AppendRow(table.Row{color.GreenString(base.ID), color.GreenString(base.Name)})
}
} else {
fmt.Printf("%s\n", color.GreenString("No bases associated with token"))
}
t.Render()
}
100 changes: 100 additions & 0 deletions pkg/analyzer/analyzers/airtable/airtable_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package airtable

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 OAuth 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
})
}
39 changes: 39 additions & 0 deletions pkg/analyzer/analyzers/airtable/expected_output.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"AnalyzerType": 22,
"Bindings": [
{
"Resource": {
"Name": "usraS0CjAASH3XMpU",
"FullyQualifiedName": "usraS0CjAASH3XMpU",
"Type": "user",
"Metadata": {},
"Parent": null
},
"Permission": {
"Value": "data.records:read",
"Parent": null
}
},
{
"Resource": {
"Name": "usraS0CjAASH3XMpU",
"FullyQualifiedName": "usraS0CjAASH3XMpU",
"Type": "user",
"Metadata": {},
"Parent": null
},
"Permission": {
"Value": "schema.bases:read",
"Parent": null
}
}
],
"UnboundedResources": [
{
"Name": "Client Leads and Sales Management",
"FullyQualifiedName": "appzRyj5Q9R9kK6cF",
"Type": "base",
"Parent": null
}
]
}
Loading

0 comments on commit 72a515c

Please # to comment.