diff --git a/pkg/analyzer/analyzers/analyzers.go b/pkg/analyzer/analyzers/analyzers.go index 5dfec310d..972220435 100644 --- a/pkg/analyzer/analyzers/analyzers.go +++ b/pkg/analyzer/analyzers/analyzers.go @@ -93,6 +93,7 @@ const ( AnalyzerTypeGroq AnalyzerTypeLaunchDarkly AnalyzerTypeFigma + AnalyzerTypePlaid // Add new items here with AnalyzerType prefix ) @@ -131,6 +132,7 @@ var analyzerTypeStrings = map[AnalyzerType]string{ AnalyzerTypeGroq: "Groq", AnalyzerTypeLaunchDarkly: "LaunchDarkly", AnalyzerTypeFigma: "Figma", + AnalyzerTypePlaid: "Plaid", // Add new mappings here } diff --git a/pkg/analyzer/analyzers/plaid/expected_output.json b/pkg/analyzer/analyzers/plaid/expected_output.json new file mode 100644 index 000000000..14602a6f9 --- /dev/null +++ b/pkg/analyzer/analyzers/plaid/expected_output.json @@ -0,0 +1 @@ +{"AnalyzerType":33,"Bindings":[{"Resource":{"Name":"Assets","FullyQualifiedName":"zkjAkPqvmyikkkyJMbBjCnEPEAR5kjio3KAM4/product/assets","Type":"product","Metadata":{"productDesc":"Request, retrieve and share detailed reports of financial assets and account history"},"Parent":null},"Permission":{"Value":"write","Parent":null}},{"Resource":{"Name":"Auth","FullyQualifiedName":"zkjAkPqvmyikkkyJMbBjCnEPEAR5kjio3KAM4/product/auth","Type":"product","Metadata":{"productDesc":"Retrieve account and routing numbers"},"Parent":null},"Permission":{"Value":"read","Parent":null}},{"Resource":{"Name":"Identity","FullyQualifiedName":"zkjAkPqvmyikkkyJMbBjCnEPEAR5kjio3KAM4/product/identity","Type":"product","Metadata":{"productDesc":"Access personal identity information like name, phone, address, and email"},"Parent":null},"Permission":{"Value":"read","Parent":null}},{"Resource":{"Name":"Investments","FullyQualifiedName":"zkjAkPqvmyikkkyJMbBjCnEPEAR5kjio3KAM4/product/investments","Type":"product","Metadata":{"productDesc":"Retrieve holdings, balances, and historical investment transactions"},"Parent":null},"Permission":{"Value":"read","Parent":null}},{"Resource":{"Name":"Liabilities","FullyQualifiedName":"zkjAkPqvmyikkkyJMbBjCnEPEAR5kjio3KAM4/product/liabilities","Type":"product","Metadata":{"productDesc":"Access detailed information about loans, credit cards, and other liabilities"},"Parent":null},"Permission":{"Value":"write","Parent":null}},{"Resource":{"Name":"Transactions","FullyQualifiedName":"zkjAkPqvmyikkkyJMbBjCnEPEAR5kjio3KAM4/product/transactions","Type":"product","Metadata":{"productDesc":"Retrieve, filter, and analyze categorized transaction history"},"Parent":null},"Permission":{"Value":"read","Parent":null}},{"Resource":{"Name":"Transfer","FullyQualifiedName":"zkjAkPqvmyikkkyJMbBjCnEPEAR5kjio3KAM4/product/transfer","Type":"product","Metadata":{"productDesc":"Initiate, manage, and track bank transfers"},"Parent":null},"Permission":{"Value":"write","Parent":null}}],"UnboundedResources":[{"Name":"Plaid Checking","FullyQualifiedName":"K1xy1qQJn8u555qNpjrbFxba95ydRxCRpw1nM","Type":"account","Metadata":{"officialName":"Plaid Gold Standard 0% Interest Checking"},"Parent":null},{"Name":"Plaid Saving","FullyQualifiedName":"rpBKpzVvJ8S333ZPGE8bcg8PvJbG4gC7JK1on","Type":"account","Metadata":{"officialName":"Plaid Silver Standard 0.1% Interest Saving"},"Parent":null},{"Name":"Plaid CD","FullyQualifiedName":"zkjAkPqvmyikkkyJMbBjCnEP1lepvnClNPQq1","Type":"account","Metadata":{"officialName":"Plaid Bronze Standard 0.2% Interest CD"},"Parent":null},{"Name":"Plaid Credit Card","FullyQualifiedName":"B4Vv4GxJReIEEEj6Lz9viM6XpLBRzMT4MegZn","Type":"account","Metadata":{"officialName":"Plaid Diamond 12.5% APR Interest Credit Card"},"Parent":null},{"Name":"Plaid Money Market","FullyQualifiedName":"3y8LyjgMKltRRR1aNd5zHEGl8Q3LWEHZloEPn","Type":"account","Metadata":{"officialName":"Plaid Platinum Standard 1.85% Interest Money Market"},"Parent":null},{"Name":"Plaid IRA","FullyQualifiedName":"ed9ydAVLvNUjjjPMG5N6CXN6yWry9jtr96mEk","Type":"account","Metadata":{"officialName":""},"Parent":null},{"Name":"Plaid 401k","FullyQualifiedName":"QEj7ExolJbi555xEeqBbFjE4qJ3qQbtwmyDzD","Type":"account","Metadata":{"officialName":""},"Parent":null},{"Name":"Plaid Student Loan","FullyQualifiedName":"ZvopvQVaqlSKKKQak4lrCgl14Wd4yXfeqQGz1","Type":"account","Metadata":{"officialName":""},"Parent":null},{"Name":"Plaid Mortgage","FullyQualifiedName":"MpaRprMJ7WS555r49WVdFabjPmyPeDUL6n1zX","Type":"account","Metadata":{"officialName":""},"Parent":null},{"Name":"Plaid HSA","FullyQualifiedName":"1boLbNpQlXSqqqoM7e53trlEbaAb91FpjWr3X","Type":"account","Metadata":{"officialName":"Plaid Cares Health Savings Account"},"Parent":null},{"Name":"Plaid Cash Management","FullyQualifiedName":"LLdQLKZJVEH555KNbnxaFd3RwEawW9ukjDEoj","Type":"account","Metadata":{"officialName":"Plaid Growth Cash Management"},"Parent":null},{"Name":"Plaid Business Credit Card","FullyQualifiedName":"p1rl1KVv5ouzzzkMEVo1s5vBPXyPo9UpoRzkM","Type":"account","Metadata":{"officialName":"Plaid Platinum Small Business Credit Card"},"Parent":null}],"Metadata":null} \ No newline at end of file diff --git a/pkg/analyzer/analyzers/plaid/models.go b/pkg/analyzer/analyzers/plaid/models.go new file mode 100644 index 000000000..98439854f --- /dev/null +++ b/pkg/analyzer/analyzers/plaid/models.go @@ -0,0 +1,25 @@ +package plaid + +type account struct { + AccountID string `json:"account_id"` + Name string `json:"name"` + OfficialName string `json:"official_name"` + Subtype string `json:"subtype"` + Type string `json:"type"` +} + +type item struct { + Products []string `json:"products"` + ItemID string `json:"item_id"` +} + +type accountsResponse struct { + Accounts []account `json:"accounts"` + Item item `json:"item"` +} + +type secretInfo struct { + Item item + Accounts []account + Environment string +} diff --git a/pkg/analyzer/analyzers/plaid/permissions.go b/pkg/analyzer/analyzers/plaid/permissions.go new file mode 100644 index 000000000..42add62a1 --- /dev/null +++ b/pkg/analyzer/analyzers/plaid/permissions.go @@ -0,0 +1,66 @@ +// Code generated by go generate; DO NOT EDIT. +package plaid + +import "errors" + +type Permission int + +const ( + Invalid Permission = iota + Read Permission = iota + Write Permission = iota +) + +var ( + PermissionStrings = map[Permission]string{ + Read: "read", + Write: "write", + } + + StringToPermission = map[string]Permission{ + "read": Read, + "write": Write, + } + + PermissionIDs = map[Permission]int{ + Read: 1, + Write: 2, + } + + IdToPermission = map[int]Permission{ + 1: Read, + 2: Write, + } +) + +// ToString converts a Permission enum to its string representation +func (p Permission) ToString() (string, error) { + if str, ok := PermissionStrings[p]; ok { + return str, nil + } + return "", errors.New("invalid permission") +} + +// ToID converts a Permission enum to its ID +func (p Permission) ToID() (int, error) { + if id, ok := PermissionIDs[p]; ok { + return id, nil + } + return 0, errors.New("invalid permission") +} + +// PermissionFromString converts a string representation to its Permission enum +func PermissionFromString(s string) (Permission, error) { + if p, ok := StringToPermission[s]; ok { + return p, nil + } + return 0, errors.New("invalid permission string") +} + +// PermissionFromID converts an ID to its Permission enum +func PermissionFromID(id int) (Permission, error) { + if p, ok := IdToPermission[id]; ok { + return p, nil + } + return 0, errors.New("invalid permission ID") +} diff --git a/pkg/analyzer/analyzers/plaid/permissions.yaml b/pkg/analyzer/analyzers/plaid/permissions.yaml new file mode 100644 index 000000000..9bc58ca5b --- /dev/null +++ b/pkg/analyzer/analyzers/plaid/permissions.yaml @@ -0,0 +1,3 @@ +permissions: + - read + - write diff --git a/pkg/analyzer/analyzers/plaid/plaid.go b/pkg/analyzer/analyzers/plaid/plaid.go new file mode 100644 index 000000000..d35d2580a --- /dev/null +++ b/pkg/analyzer/analyzers/plaid/plaid.go @@ -0,0 +1,227 @@ +//go:generate generate_permissions permissions.yaml permissions.go plaid +package plaid + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "strings" + + "github.com/fatih/color" + "github.com/jedib0t/go-pretty/v6/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 (a Analyzer) Type() analyzers.AnalyzerType { + return analyzers.AnalyzerTypePlaid +} + +func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) { + secret, exist := credInfo["secret"] + if !exist { + return nil, errors.New("secret not found in credentials info") + } + clientID, exist := credInfo["id"] + if !exist { + return nil, errors.New("id not found in credentials info") + } + accessToken, exist := credInfo["token"] + if !exist { + return nil, errors.New("token not found in credentials info") + } + + info, err := AnalyzePermissions(a.Cfg, secret, clientID, accessToken) + if err != nil { + return nil, err + } + + return secretInfoToAnalyzerResult(info), nil +} + +func AnalyzeAndPrintPermissions(cfg *config.Config, secret string, clientID string, accessToken string) { + info, err := AnalyzePermissions(cfg, secret, clientID, accessToken) + if err != nil { + color.Red("[x] Invalid Plaid API key\n") + color.Red("[x] Error : %s", err.Error()) + return + } + + if info == nil { + color.Red("[x] Error : %s", "No information found") + return + } + + color.Green("[i] Valid Plaid API Credentials\n") + color.Yellow("\n[i] Environment: %s", info.Environment) + if info.Environment == "sandbox" { + color.Cyan("Credentials are for Sandbox environment. All resources found are simulated and not real data.\n") + } + printAccountsAndProducts(info) +} + +func AnalyzePermissions(cfg *config.Config, secret string, clientId string, accessToken string) (*secretInfo, error) { + environment := "sandbox" + if strings.Contains(accessToken, "production") { + environment = "production" + } + + // Plaid API uses POST requests for all requests, so we need to use an unrestricted client + client := analyzers.NewAnalyzeClientUnrestricted(cfg) + var secretInfo = &secretInfo{} + secretInfo.Environment = environment + resp, err := getPlaidAccounts(client, clientId, secret, accessToken, environment) + if err != nil { + return nil, err + } + secretInfo.Item = resp.Item + secretInfo.Accounts = resp.Accounts + return secretInfo, nil +} + +func getPlaidAccounts(client *http.Client, clientID string, secret string, accessToken string, environment string) (*accountsResponse, error) { + body := map[string]interface{}{ + "client_id": clientID, + "secret": secret, + "access_token": accessToken, + } + url := "https://" + environment + ".plaid.com/accounts/get" + jsonBody, _ := json.Marshal(body) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonBody)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("received non-OK HTTP status: %d", resp.StatusCode) + } + + var accounts accountsResponse + if err := json.NewDecoder(resp.Body).Decode(&accounts); err != nil { + return nil, err + } + + return &accounts, nil +} + +func secretInfoToAnalyzerResult(info *secretInfo) *analyzers.AnalyzerResult { + if info == nil { + return nil + } + + itemID := info.Item.ItemID + userProducts := info.Item.Products + userAccounts := info.Accounts + + result := analyzers.AnalyzerResult{ + AnalyzerType: analyzers.AnalyzerTypePlaid, + Metadata: nil, + Bindings: make([]analyzers.Binding, len(userProducts)), + UnboundedResources: make([]analyzers.Resource, len(userAccounts)), + } + + for idx, productName := range userProducts { + product, ok := GetProductByName(productName) + if !ok { + continue + } + result.Bindings[idx] = analyzers.Binding{ + Resource: analyzers.Resource{ + Name: product.DisplayName, + FullyQualifiedName: itemID + "/product/" + product.Name, + Type: "product", + Metadata: map[string]any{ + "productDesc": product.Description, + }, + }, + Permission: analyzers.Permission{ + Value: PermissionStrings[product.PermissionLevel], + }, + } + } + + for idx, account := range info.Accounts { + result.UnboundedResources[idx] = analyzers.Resource{ + Name: account.Name, + FullyQualifiedName: account.AccountID, + Type: "account", + Metadata: map[string]any{ + "officialName": account.OfficialName, + }, + } + } + + return &result +} + +func printAccountsAndProducts(info *secretInfo) { + userProducts := info.Item.Products + userAccounts := info.Accounts + + color.Yellow("\n[i] Item ID: %s", info.Item.ItemID) + + color.Yellow("\n[i] Accounts Info:") + t1 := table.NewWriter() + t1.SetOutputMirror(os.Stdout) + t1.AppendHeader(table.Row{"ID", "Name", "Official Name", "Type", "Subtype"}) + for _, account := range userAccounts { + t1.AppendRow(table.Row{ + color.GreenString(account.AccountID), + color.GreenString(account.Name), + color.GreenString(account.OfficialName), + color.GreenString(account.Type), + color.GreenString(account.Subtype), + }) + t1.AppendSeparator() + } + t1.SetOutputMirror(os.Stdout) + t1.Render() + + color.Yellow("\n[i] Products:") + t2 := table.NewWriter() + t2.AppendHeader(table.Row{"Product Name", "Access Level", "Capabilities"}) + + for _, product := range plaidProducts { + productCell := color.GreenString(product.DisplayName) + productDescCell := color.GreenString(product.Description) + productPermissionCell := color.GreenString("Denied") + + for _, productName := range userProducts { + if productName == product.Name { + permissionLevel := PermissionStrings[product.PermissionLevel] + productPermissionCell = "Granted" // If permission level is not defined, default to "Granted" + if len(permissionLevel) > 0 { + // Capitalize the perssion level string + capitalizedLevel := strings.ToUpper(string(permissionLevel[0])) + strings.ToLower(permissionLevel[1:]) + productPermissionCell = color.GreenString(capitalizedLevel) + } + break + } + } + + t2.AppendRow(table.Row{productCell, productPermissionCell, productDescCell}) + t2.AppendSeparator() + } + + t2.SetOutputMirror(os.Stdout) + t2.Render() + fmt.Printf("%s: https://plaid.com/docs/api/\n\n", color.GreenString("Ref")) +} diff --git a/pkg/analyzer/analyzers/plaid/plaid_test.go b/pkg/analyzer/analyzers/plaid/plaid_test.go new file mode 100644 index 000000000..a6de49d29 --- /dev/null +++ b/pkg/analyzer/analyzers/plaid/plaid_test.go @@ -0,0 +1,85 @@ +package plaid + +import ( + _ "embed" + "encoding/json" + "fmt" + "testing" + "time" + + "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.Minute*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) + } + + secret := testSecrets.MustGetField("PLAIDKEY_SECRET") + clientID := testSecrets.MustGetField("PLAIDKEY_CLIENTID") + accessToken := testSecrets.MustGetField("PLAIDKEY_ACCESS_TOKEN") + + tests := []struct { + name string + clientID string + secret string + accessToken string + want string + wantErr bool + }{ + { + name: "valid plaid credentials", + clientID: clientID, + secret: secret, + accessToken: accessToken, + 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{ + "secret": tt.secret, + "id": tt.clientID, + "token": tt.accessToken, + }) + if (err != nil) != tt.wantErr { + t.Errorf("Analyzer.Analyze() error = %v, wantErr %v", err, tt.wantErr) + return + } + + // marshal the actual result to JSON + gotJSON, err := json.Marshal(got) + if err != nil { + t.Fatalf("could not marshal got to JSON: %s", err) + } + + fmt.Println(string(gotJSON)) + + // compare the JSON strings + if string(gotJSON) != string(tt.want) { + // 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(tt.want, "", " ") + if err != nil { + t.Fatalf("could not marshal want to indented JSON: %s", err) + } + t.Errorf("Analyzer.Analyze() = %s, want %s", gotIndented, wantIndented) + } + }) + } +} diff --git a/pkg/analyzer/analyzers/plaid/products.go b/pkg/analyzer/analyzers/plaid/products.go new file mode 100644 index 000000000..b55758f56 --- /dev/null +++ b/pkg/analyzer/analyzers/plaid/products.go @@ -0,0 +1,256 @@ +package plaid + +type plaidProduct struct { + Name string + DisplayName string + Description string + PermissionLevel Permission +} + +type Product int + +const ( + Assets Product = iota + Auth + Balance + BalancePlus + Beacon + CraBaseReport + CraIncomeInsights + CraPartnerInsights + CraNetworkInsights + CraCashflowInsights + CreditDetails + Employment + Identity + IdentityMatch + IdentityVerification + Income + IncomeVerification + Investments + InvestmentsAuth + Layer + Liabilities + PayByBank + PaymentInitiation + ProcessorPayments + ProcessorIdentity + Profile + RecurringTransactions + Signal + StandingOrders + Statements + Transactions + TransactionsRefresh + Transfer +) + +var plaidProducts = map[Product]plaidProduct{ + Assets: { + Name: "assets", + DisplayName: "Assets", + Description: "Request, retrieve and share detailed reports of financial assets and account history", + PermissionLevel: Write, + }, + Auth: { + Name: "auth", + DisplayName: "Auth", + Description: "Retrieve account and routing numbers", + PermissionLevel: Read, + }, + Balance: { + Name: "balance", + DisplayName: "Balance", + Description: "Check current and available account balance in real time", + PermissionLevel: Read, + }, + BalancePlus: { + Name: "balance_plus", + DisplayName: "Balance Plus", + Description: "Estimate projected balances and financial runway", + PermissionLevel: Read, + }, + Beacon: { + Name: "beacon", + DisplayName: "Beacon", + Description: "Generate risk insights and fraud signals based on user account behavior", + PermissionLevel: Write, + }, + CraBaseReport: { + Name: "cra_base_report", + DisplayName: "CRA Base Report", + Description: "Generate a standardized financial report", + PermissionLevel: Write, + }, + CraIncomeInsights: { + Name: "cra_income_insights", + DisplayName: "CRA Income Insights", + Description: "Analyze income trends and consistency", + PermissionLevel: Write, + }, + CraPartnerInsights: { + Name: "cra_partner_insights", + DisplayName: "CRA Partner Insights", + Description: "Access custom insights", + PermissionLevel: Write, + }, + CraNetworkInsights: { + Name: "cra_network_insights", + DisplayName: "CRA Network Insights", + Description: "View analytics and performance benchmarks", + PermissionLevel: Write, + }, + CraCashflowInsights: { + Name: "cra_cashflow_insights", + DisplayName: "CRA Cashflow Insights", + Description: "Evaluate cash flow behavior including recurring income and expenses", + PermissionLevel: Write, + }, + CreditDetails: { + Name: "credit_details", + DisplayName: "Credit Details", + Description: "Access credit account usage, limits, and repayment history", + PermissionLevel: Read, + }, + Employment: { + Name: "employment", + DisplayName: "Employment", + Description: "Retrieve current employment status and employer details", + PermissionLevel: Read, + }, + Identity: { + Name: "identity", + DisplayName: "Identity", + Description: "Access personal identity information like name, phone, address, and email", + PermissionLevel: Read, + }, + IdentityMatch: { + Name: "identity_match", + DisplayName: "Identity Match", + Description: "Match user-provided identity details against institution records", + PermissionLevel: Read, + }, + IdentityVerification: { + Name: "identity_verification", + DisplayName: "Identity Verification", + Description: "Verify user identity through government documents and identity data sources", + PermissionLevel: Write, + }, + Income: { + Name: "income", + DisplayName: "Income", + Description: "Analyze income patterns based on transaction history", + PermissionLevel: Write, + }, + IncomeVerification: { + Name: "income_verification", + DisplayName: "Income Verification", + Description: "Verify income through paystubs, payroll data, or bank information", + PermissionLevel: Write, + }, + Investments: { + Name: "investments", + DisplayName: "Investments", + Description: "Retrieve holdings, balances, and historical investment transactions", + PermissionLevel: Read, + }, + InvestmentsAuth: { + Name: "investments_auth", + DisplayName: "Investments Auth", + Description: "Retrieve account and routing numbers for investment accounts", + PermissionLevel: Read, + }, + Layer: { + Name: "layer", + DisplayName: "Layer", + Description: "Use a simplified onboarding experience for linking financial accounts", + PermissionLevel: Read, + }, + Liabilities: { + Name: "liabilities", + DisplayName: "Liabilities", + Description: "Access detailed information about loans, credit cards, and other liabilities", + PermissionLevel: Write, + }, + PayByBank: { + Name: "pay_by_bank", + DisplayName: "Pay By Bank", + Description: "Initiate payments directly from the user's bank account", + PermissionLevel: Write, + }, + PaymentInitiation: { + Name: "payment_initiation", + DisplayName: "Payment Initiation", + Description: "Create and manage payment requests and track their status", + PermissionLevel: Write, + }, + ProcessorPayments: { + Name: "processor_payments", + DisplayName: "Processor Payments", + Description: "Send payment details securely to third-party processors", + PermissionLevel: Write, + }, + ProcessorIdentity: { + Name: "processor_identity", + DisplayName: "Processor Identity", + Description: "Share identity data with payment processors for verification", + PermissionLevel: Read, + }, + Profile: { + Name: "profile", + DisplayName: "Profile", + Description: "Access user profile data", + PermissionLevel: Read, + }, + RecurringTransactions: { + Name: "recurring_transactions", + DisplayName: "Recurring Transactions", + Description: "Identify and analyze recurring payments and subscriptions", + PermissionLevel: Write, + }, + Signal: { + Name: "signal", + DisplayName: "Signal", + Description: "Assess the likelihood of ACH returns", + PermissionLevel: Read, + }, + StandingOrders: { + Name: "standing_orders", + DisplayName: "Standing Orders", + Description: "View and manage recurring scheduled bank transfers", + PermissionLevel: Write, + }, + Statements: { + Name: "statements", + DisplayName: "Statements", + Description: "List and download historical bank statements in PDF format", + PermissionLevel: Read, + }, + Transactions: { + Name: "transactions", + DisplayName: "Transactions", + Description: "Retrieve, filter, and analyze categorized transaction history", + PermissionLevel: Read, + }, + TransactionsRefresh: { + Name: "transactions_refresh", + DisplayName: "Transactions Refresh", + Description: "Trigger a manual refresh to retrieve the latest transactions", + PermissionLevel: Read, + }, + Transfer: { + Name: "transfer", + DisplayName: "Transfer", + Description: "Initiate, manage, and track bank transfers", + PermissionLevel: Write, + }, +} + +func GetProductByName(name string) (plaidProduct, bool) { + for _, product := range plaidProducts { + if product.Name == name { + return product, true + } + } + return plaidProduct{}, false +} diff --git a/pkg/analyzer/cli.go b/pkg/analyzer/cli.go index 01dbbb48f..3be98829f 100644 --- a/pkg/analyzer/cli.go +++ b/pkg/analyzer/cli.go @@ -26,6 +26,7 @@ import ( "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/notion" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/openai" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/opsgenie" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/plaid" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/planetscale" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/postgres" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/postman" @@ -118,5 +119,7 @@ func Run(keyType string, secretInfo SecretInfo) { launchdarkly.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) case "figma": figma.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) + case "plaid": + plaid.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["secret"], secretInfo.Parts["id"], secretInfo.Parts["token"]) } } diff --git a/pkg/detectors/plaidkey/plaidkey.go b/pkg/detectors/plaidkey/plaidkey.go index 6900a1de4..ce54115cc 100644 --- a/pkg/detectors/plaidkey/plaidkey.go +++ b/pkg/detectors/plaidkey/plaidkey.go @@ -24,8 +24,9 @@ var ( client = common.SaneHttpClient() // Make sure that your group is surrounded in boundary characters such as below to reduce false positives. - keyPat = regexp.MustCompile(detectors.PrefixRegex([]string{"plaid"}) + `\b([a-z0-9]{30})\b`) - idPat = regexp.MustCompile(detectors.PrefixRegex([]string{"plaid"}) + `\b([a-z0-9]{24})\b`) + secretPat = regexp.MustCompile(detectors.PrefixRegex([]string{"plaid"}) + `\b([a-f0-9]{30})\b`) + idPat = regexp.MustCompile(detectors.PrefixRegex([]string{"plaid"}) + `\b([a-f0-9]{24})\b`) + tokenPat = regexp.MustCompile(detectors.PrefixRegex([]string{"plaid"}) + `\b(access-(sandbox|production)-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})\b`) ) // Keywords are used for efficiently pre-filtering chunks. @@ -39,15 +40,15 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result dataStr := string(data) // find all the matching keys and ids in the data and make a unique maps for both. - uniqueKeys, uniqueIds := make(map[string]struct{}), make(map[string]struct{}) + uniqueSecrets, uniqueIds, uniqueTokens := make(map[string]struct{}), make(map[string]struct{}), make(map[string]struct{}) - for _, foundKey := range keyPat.FindAllStringSubmatch(dataStr, -1) { + for _, foundKey := range secretPat.FindAllStringSubmatch(dataStr, -1) { key := foundKey[1] if detectors.StringShannonEntropy(key) < 3 { continue } - uniqueKeys[key] = struct{}{} + uniqueSecrets[key] = struct{}{} } for _, foundId := range idPat.FindAllStringSubmatch(dataStr, -1) { @@ -59,38 +60,53 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result uniqueIds[id] = struct{}{} } - for key := range uniqueKeys { - for id := range uniqueIds { + for _, foundToken := range tokenPat.FindAllStringSubmatch(dataStr, -1) { + token := foundToken[1] + if detectors.StringShannonEntropy(token) < 3 { + continue + } - s1 := detectors.Result{ - DetectorType: detectorspb.DetectorType_PlaidKey, - Raw: []byte(key), - } - environments := []string{"sandbox", "production"} - if verify { - for _, env := range environments { - isVerified, _, verificationErr := verifyMatch(ctx, client, id, key, env) + uniqueTokens[token] = struct{}{} + } + + for secret := range uniqueSecrets { + for id := range uniqueIds { + for token := range uniqueTokens { + s1 := detectors.Result{ + DetectorType: detectorspb.DetectorType_PlaidKey, + Raw: []byte(secret), + RawV2: []byte(fmt.Sprintf(`%s:%s:%s`, secret, id, token)), + } + + if verify { + environment := "sandbox" + if strings.Contains(token, "production") { + environment = "production" + } + isVerified, _, verificationErr := verifyMatch(ctx, client, id, secret, token, environment) s1.Verified = isVerified - s1.ExtraData = map[string]string{"environment": fmt.Sprintf("https://%s.plaid.com", env)} - s1.SetVerificationError(verificationErr, id, key) + s1.ExtraData = map[string]string{"environment": fmt.Sprintf("https://%s.plaid.com", environment)} + s1.SetVerificationError(verificationErr, id, secret) + if s1.Verified { + s1.AnalysisInfo = map[string]string{ + "secret": secret, + "id": id, + "token": token, + } + } } results = append(results, s1) - // if the environment is sandbox, we don't need to check production - if s1.Verified { - break - } - } else { - results = append(results, s1) } } } - return results, nil + return } -func verifyMatch(ctx context.Context, client *http.Client, id string, secret string, env string) (bool, map[string]string, error) { - payload := strings.NewReader(`{"client_id":"` + id + `","secret":"` + secret + `","user":{"client_user_id":"60e3ee4019a2660010f8bc54","phone_number_verified_time":"0001-01-01T00:00:00Z","email_address_verified_time":"0001-01-01T00:00:00Z"},"client_name":"Plaid Test App","products":["auth","transactions"],"country_codes":["US"],"webhook":"https://webhook-uri.com","account_filters":{"depository":{"account_subtypes":["checking","savings"]}},"language":"en","link_customization_name":"default"}`) - req, err := http.NewRequestWithContext(ctx, "POST", "https://"+env+".plaid.com/link/token/create", payload) +func verifyMatch(ctx context.Context, client *http.Client, id string, secret string, token string, env string) (bool, map[string]string, error) { + payload := strings.NewReader(`{"client_id":"` + id + `","secret":"` + secret + `","access_token":"` + token + `"}`) + url := "https://" + env + ".plaid.com/item/get" + req, err := http.NewRequestWithContext(ctx, "POST", url, payload) if err != nil { return false, nil, nil } diff --git a/pkg/detectors/plaidkey/plaidkey_integration_test.go b/pkg/detectors/plaidkey/plaidkey_integration_test.go index 6eb483c2c..824cb2113 100644 --- a/pkg/detectors/plaidkey/plaidkey_integration_test.go +++ b/pkg/detectors/plaidkey/plaidkey_integration_test.go @@ -9,23 +9,25 @@ import ( "testing" "time" - "github.com/kylelemons/godebug/pretty" - "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/trufflesecurity/trufflehog/v3/pkg/common" + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" "github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb" ) func TestPlaidKey_FromChunk(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) defer cancel() - testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors3") + testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors5") if err != nil { t.Fatalf("could not get test secrets from GCP: %s", err) } secret := testSecrets.MustGetField("PLAIDKEY_SECRET") inactiveSecret := testSecrets.MustGetField("PLAIDKEY_SECRET_INACTIVE") id := testSecrets.MustGetField("PLAIDKEY_CLIENTID") + token := testSecrets.MustGetField("PLAIDKEY_ACCESS_TOKEN") env := "sandbox" type args struct { @@ -45,13 +47,19 @@ func TestPlaidKey_FromChunk(t *testing.T) { s: Scanner{}, args: args{ ctx: context.Background(), - data: []byte(fmt.Sprintf("You can find a plaidkey secret %s within plaidkey %s", secret, id)), + data: []byte(fmt.Sprintf("You can find a plaid secret %s within plaid %s and plaid token %s", secret, id, token)), verify: true, }, want: []detectors.Result{ { DetectorType: detectorspb.DetectorType_PlaidKey, Verified: true, + RawV2: []byte(fmt.Sprintf("%s:%s:%s", secret, id, token)), + AnalysisInfo: map[string]string{ + "secret": secret, + "id": id, + "token": token, + }, ExtraData: map[string]string{ "environment": fmt.Sprintf("https://%s.plaid.com", env), }, @@ -64,13 +72,17 @@ func TestPlaidKey_FromChunk(t *testing.T) { s: Scanner{}, args: args{ ctx: context.Background(), - data: []byte(fmt.Sprintf("You can find a plaidkey secret %s within but plaidkey %s not valid", inactiveSecret, id)), // the secret would satisfy the regex but not pass validation + data: []byte(fmt.Sprintf("You can find a plaid secret %s within plaid %s and plaid token %s", inactiveSecret, id, token)), // the secret would satisfy the regex but not pass validation verify: true, }, want: []detectors.Result{ { DetectorType: detectorspb.DetectorType_PlaidKey, Verified: false, + RawV2: []byte(fmt.Sprintf("%s:%s:%s", inactiveSecret, id, token)), + ExtraData: map[string]string{ + "environment": fmt.Sprintf("https://%s.plaid.com", env), + }, }, }, wantErr: false, @@ -99,9 +111,12 @@ func TestPlaidKey_FromChunk(t *testing.T) { if len(got[i].Raw) == 0 { t.Fatalf("no raw secret present: \n %+v", got[i]) } - got[i].Raw = nil + if len(got[i].RawV2) == 0 { + t.Fatalf("no raw v2 secret present: \n %+v", got[i]) + } } - if diff := pretty.Compare(got, tt.want); diff != "" { + ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "verificationError", "AnalysisInfo", "ExtraData") + if diff := cmp.Diff(got, tt.want, ignoreOpts); diff != "" { t.Errorf("PlaidKey.FromData() %s diff: (-got +want)\n%s", tt.name, diff) } }) diff --git a/pkg/detectors/plaidkey/plaidkey_test.go b/pkg/detectors/plaidkey/plaidkey_test.go index 73241d75a..fedd9a062 100644 --- a/pkg/detectors/plaidkey/plaidkey_test.go +++ b/pkg/detectors/plaidkey/plaidkey_test.go @@ -12,11 +12,13 @@ import ( ) var ( - validKey = "3vl81ihtozf9im7kqz7ldp6kxbsd8y" - invalidKey = "3vl81ihtozf9im7?qz7ldp6kxbsd8y" - validId = "ic1mh5b49ycvmz2vgvlgxtb0" - invalidId = "ic1?h5b49ycvmz2vgvlgxtb0" - keyword = "plaid" + validSecret = "6e611cb89c263457b5e028d66c16c4" + invalidSecret = "3vl81ihtozf9im7dqz7ldp6kxbsd8y" + validId = "60e3ee4019a2660010f8bc54" + invalidId = "ic1Ah5b49ycvmz2vgvlgxtb0" + validToken = "access-sandbox-833d862e-ffa8-43a7-ae28-72f56f1acb32" + invalidToken = "access-sandbox-g33z362e-fha8-43a7-au28-7kf56z1acl32" + keyword = "plaid" ) func TestPlaidKey_Pattern(t *testing.T) { @@ -29,12 +31,12 @@ func TestPlaidKey_Pattern(t *testing.T) { }{ { name: "valid pattern - with keyword plaid", - input: fmt.Sprintf("%s token - '%s'\n%s token - '%s'\n", keyword, validKey, keyword, validId), - want: []string{validKey}, + input: fmt.Sprintf("%s secret - '%s'\n%s client id - '%s'\n%s token - '%s'", keyword, validSecret, keyword, validId, keyword, validToken), + want: []string{fmt.Sprintf("%s:%s:%s", validSecret, validId, validToken)}, }, { - name: "invalid pattern", - input: fmt.Sprintf("%s token - '%s'\n%s token - '%s'\n", keyword, invalidKey, keyword, invalidId), + name: "invalid pattern - with keyword plaid", + input: fmt.Sprintf("%s secret - '%s'\n%s client id - '%s'\n%s token - '%s'", keyword, invalidSecret, keyword, invalidId, keyword, invalidToken), want: []string{}, }, } diff --git a/pkg/tui/pages/analyze_form/analyze_form.go b/pkg/tui/pages/analyze_form/analyze_form.go index 7e82b4f27..ec0b42e52 100644 --- a/pkg/tui/pages/analyze_form/analyze_form.go +++ b/pkg/tui/pages/analyze_form/analyze_form.go @@ -79,6 +79,22 @@ func New(c common.Common, keyType string) *AnalyzeForm { Required: true, RedactInput: true, }} + case "plaid": + inputs = []textinputs.InputConfig{{ + Label: "Secret", + Key: "secret", + Required: true, + RedactInput: true, + }, { + Label: "Client ID", + Key: "id", + Required: true, + }, { + Label: "Access Token", + Key: "token", + Required: true, + RedactInput: true, + }} default: inputs = []textinputs.InputConfig{{ Label: "Secret",