-
Notifications
You must be signed in to change notification settings - Fork 1.8k
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
Changes from 7 commits
ca91b0b
746f75c
90434b2
4c82e64
1921386
25c36ac
838a1ef
5c0da7d
e587433
1a0df40
d0b8c24
1c07637
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
---|---|---|
@@ -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 | ||
|
||
var basesInfo *common.AirtableBases | ||
if granted, err := determineScope(token, common.SchemaBasesRead, nil); granted { | ||
nabeelalam marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: For consistency and better readability, can we rename There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're right. The terms got a bit shuffled since each AirTable 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 { | ||
nabeelalam marked this conversation as resolved.
Show resolved
Hide resolved
|
||
var result map[string]interface{} | ||
nabeelalam marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { | ||
return false, err | ||
} | ||
|
||
if errorInfo, ok := result["error"].(map[string]interface{}); ok { | ||
nabeelalam marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Ex:
|
||
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 { | ||
nabeelalam marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return err | ||
} else if granted { | ||
// Verifying scopes that require an existing record and record read permission | ||
records, err := fetchAirtableRecords(token, base.ID, table.ID) | ||
nabeelalam marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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) | ||
} |
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 | ||
}) | ||
} |
There was a problem hiding this comment.
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 theuser.email:read
scope?There was a problem hiding this comment.
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