[Feat] Plaid API Analyzer (#4004)
* added plaid api analyzer * updated plaid analyzer and detector * updated plaidkey detector verify loop * added requested changes * added build tags into detector integration test * updated plaid analyzer and implemented suggestions --------- Co-authored-by: Kashif Khan <[email protected]>
This commit is contained in:
co-authored by
Kashif Khan
parent
e6f57f2659
commit
5b3d6155ca
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
permissions:
|
||||
- read
|
||||
- write
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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{},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user