[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:
Nabeel Alam
2025-04-11 19:36:05 +05:00
committed by GitHub
co-authored by Kashif Khan
parent e6f57f2659
commit 5b3d6155ca
13 changed files with 760 additions and 43 deletions
+2
View File
@@ -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}
+25
View File
@@ -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
+227
View File
@@ -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)
}
})
}
}
+256
View File
@@ -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
}
+3
View File
@@ -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"])
}
}
+43 -27
View File
@@ -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)
}
})
+11 -9
View File
@@ -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",