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 pat analyzer; incorporated feedback; fixed formating…
… issues; improved code readability
  • Loading branch information
nabeelalam committed Mar 3, 2025
commit 5c0da7dd2bf6a509f54e95150c55f2a06671d017
169 changes: 102 additions & 67 deletions pkg/analyzer/analyzers/airtable/airtablepat/airtable.go
Original file line number Diff line number Diff line change
@@ -5,7 +5,6 @@ import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"

"github.com/fatih/color"
@@ -26,12 +25,12 @@ func (Analyzer) Type() analyzers.AnalyzerType { return analyzers.AnalyzerTypeAir

var scopeStatusMap = make(map[string]bool)

func getEndpoint(endpoint common.EndpointName) common.Endpoint {
return common.Endpoints[endpoint]
func getEndpoint(endpointName common.EndpointName) (common.Endpoint, bool) {
return common.GetEndpoint(endpointName)
}

func getEndpointByPermission(scope string) common.Endpoint {
return common.ScopeEndpointMap[scope]
func getScopeEndpoint(scope string) (common.Endpoint, bool) {
return common.GetScopeEndpoint(scope)
}

func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) {
@@ -48,10 +47,11 @@ func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analy
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
}
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
@@ -98,13 +98,27 @@ func AnalyzeAndPrintPermissions(cfg *config.Config, token string) {
}
}

func determineScope(token string, scope common.Permission, ids map[string]string) (bool, error) {
scopeString := common.PermissionStrings[scope]
endpoint := getEndpointByPermission(scopeString)
// determineScope checks whether the given token has the specified permission by making an API call.
//
// The function performs the following actions:
// - Determines the approprate 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 ids != nil {
if requiredIDs != nil {
for _, key := range endpoint.RequiredIDs {
if value, ok := ids[key]; ok {
if value, ok := requiredIDs[key]; ok {
url = strings.Replace(url, fmt.Sprintf("{%s}", key), value, -1)
}
}
@@ -116,79 +130,100 @@ func determineScope(token string, scope common.Permission, ids map[string]string
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusOK {
if resp.StatusCode == endpoint.ExpectedSuccessStatus {
scopeStatusMap[scopeString] = true
return true, nil
} else if endpoint.ExpectedErrorResponse != nil {
var result map[string]interface{}
}

// 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
}

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
}
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
}

scopeStatusMap[scopeString] = true
return true, 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 {
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 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
}
// Verify token "block:manage" permission
_, err = determineScope(token, common.BlockManage, ids)
_, err = determineScope(token, common.DataRecordsWrite, requiredIDs)
if err != nil {
return err
}
tableScopesDetermined = true
}

// 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
}
}
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
}
}
}
5 changes: 4 additions & 1 deletion pkg/analyzer/analyzers/airtable/airtablepat/requests.go
Original file line number Diff line number Diff line change
@@ -14,7 +14,10 @@ type AirtableRecordsResponse struct {
}

func fetchAirtableRecords(token string, baseID string, tableID string) ([]common.AirtableEntity, error) {
endpoint := getEndpoint(common.ListRecordsEndpoint)
endpoint, exists := getEndpoint(common.ListRecordsEndpoint)
if !exists {
return nil, fmt.Errorf("endpoint for ListRecordsEndpoint does not exist")
}
url := strings.Replace(strings.Replace(endpoint.URL, "{baseID}", baseID, -1), "{tableID}", tableID, -1)
resp, err := common.CallAirtableAPI(token, "GET", url)
if err != nil {
21 changes: 17 additions & 4 deletions pkg/analyzer/analyzers/airtable/common/common.go
Original file line number Diff line number Diff line change
@@ -30,7 +30,10 @@ func CallAirtableAPI(token string, method string, url string) (*http.Response, e
}

func FetchAirtableUserInfo(token string) (*AirtableUserInfo, error) {
endpoint := Endpoints[GetUserInfoEndpoint]
endpoint, exists := GetEndpoint(GetUserInfoEndpoint)
if !exists {
return nil, fmt.Errorf("endpoint for GetUserInfoEndpoint does not exist")
}
resp, err := CallAirtableAPI(token, endpoint.Method, endpoint.URL)
if err != nil {
return nil, err
@@ -50,7 +53,10 @@ func FetchAirtableUserInfo(token string) (*AirtableUserInfo, error) {
}

func FetchAirtableBases(token string) (*AirtableBases, error) {
endpoint := Endpoints[ListBasesEndpoint]
endpoint, exists := GetEndpoint(ListBasesEndpoint)
if !exists {
return nil, fmt.Errorf("endpoint for ListBasesEndpoint does not exist")
}
resp, err := CallAirtableAPI(token, endpoint.Method, endpoint.URL)
if err != nil {
return nil, err
@@ -80,7 +86,10 @@ func FetchAirtableBases(token string) (*AirtableBases, error) {
}

func fetchBaseSchema(token string, baseID string) (*Schema, error) {
endpoint := Endpoints[GetBaseSchemaEndpoint]
endpoint, exists := GetEndpoint(GetBaseSchemaEndpoint)
if !exists {
return nil, fmt.Errorf("endpoint for GetBaseSchemaEndpoint does not exist")
}
url := strings.Replace(endpoint.URL, "{baseID}", baseID, -1)
resp, err := CallAirtableAPI(token, endpoint.Method, url)
if err != nil {
@@ -165,7 +174,11 @@ func PrintUserAndPermissions(info *AirtableUserInfo, scopeStatusMap map[string]b
scopeStatus = "Denied"
}
}
for i, permission := range ScopePermissions[scope] {
permissions, ok := GetScopePermissions(scope)
if !ok {
continue
}
for i, permission := range permissions {
scopeString := ""
if i == 0 {
scopeString = scope
Loading
Oops, something went wrong.