Skip to content

Commit

Permalink
[analyze] Add Analyzer for MySQL (#3193)
Browse files Browse the repository at this point in the history
* implement analyzer interface for mysql

* add integration test for mysql analyzer

* linked detectors with analyzers for jdbc and mysql
validation for connection string in analyzer

* refactored secretInfoToAnalyzerResult func

* generated permissions for mysql analyzer

* [chore]
- optimization in execution flow
- use test-container library for analyze test.

* added host in secret info struct
simplified the mysql test due to huge structure

---------

Co-authored-by: Abdul Basit <abasit@folio3.com>
  • Loading branch information
abmussani and abasit-folio3 authored Sep 12, 2024
1 parent e89190f commit b0318a9
Show file tree
Hide file tree
Showing 7 changed files with 835 additions and 2 deletions.
1 change: 1 addition & 0 deletions pkg/analyzer/analyzers/mysql/expected_output.json

Large diffs are not rendered by default.

197 changes: 195 additions & 2 deletions pkg/analyzer/analyzers/mysql/mysql.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//go:generate generate_permissions permissions.yaml permissions.go mysql

package mysql

import (
Expand All @@ -17,8 +19,189 @@ import (

"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/pb/analyzerpb"
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
)

var _ analyzers.Analyzer = (*Analyzer)(nil)

type Analyzer struct {
Cfg *config.Config
}

func (Analyzer) Type() analyzerpb.AnalyzerType { return analyzerpb.AnalyzerType_MySQL }

func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) {
uri, ok := credInfo["connection_string"]
if !ok {
return nil, fmt.Errorf("missing connection string")
}
info, err := AnalyzePermissions(a.Cfg, uri)
if err != nil {
return nil, err
}
return secretInfoToAnalyzerResult(info), nil
}

func secretInfoToAnalyzerResult(info *SecretInfo) *analyzers.AnalyzerResult {
if info == nil {
return nil
}
result := analyzers.AnalyzerResult{
AnalyzerType: analyzerpb.AnalyzerType_MySQL,
Metadata: nil,
Bindings: []analyzers.Binding{},
}

// add user priviliges to bindings
userBindings, userResource := bakeUserBindings(info)
result.Bindings = append(result.Bindings, userBindings...)

// add user's database priviliges to bindings
databaseBindings := bakeDatabaseBindings(userResource, info)
result.Bindings = append(result.Bindings, databaseBindings...)

return &result
}

func bakeUserBindings(info *SecretInfo) ([]analyzers.Binding, *analyzers.Resource) {

var userBindings []analyzers.Binding

// add user and their priviliges to bindings
userResource := analyzers.Resource{
Name: info.User,
FullyQualifiedName: info.Host + "/" + info.User,
Type: "user",
}

for _, priv := range info.GlobalPrivs.Privs {
userBindings = append(userBindings, analyzers.Binding{
Resource: userResource,
Permission: analyzers.Permission{
Value: priv,
},
})
}

return userBindings, &userResource
}

func bakeDatabaseBindings(userResource *analyzers.Resource, info *SecretInfo) []analyzers.Binding {
var databaseBindings []analyzers.Binding

for _, database := range info.Databases {
dbResource := analyzers.Resource{
Name: database.Name,
FullyQualifiedName: info.Host + "/" + database.Name,
Type: "database",
Metadata: map[string]any{
"default": database.Default,
"non_existent": database.Nonexistent,
},
Parent: userResource,
}

for _, priv := range database.Privs {
databaseBindings = append(databaseBindings, analyzers.Binding{
Resource: dbResource,
Permission: analyzers.Permission{
Value: priv,
},
})
}

// add this database's table privileges to bindings
tableBindings := bakeTableBindings(&dbResource, database)
databaseBindings = append(databaseBindings, tableBindings...)

// add this database's routines privileges to bindings
routineBindings := bakeRoutineBindings(&dbResource, database)
databaseBindings = append(databaseBindings, routineBindings...)
}

return databaseBindings
}

func bakeTableBindings(dbResource *analyzers.Resource, database *Database) []analyzers.Binding {
if database.Tables == nil {
return nil
}
var tableBindings []analyzers.Binding
for _, table := range *database.Tables {
tableResource := analyzers.Resource{
Name: table.Name,
FullyQualifiedName: dbResource.FullyQualifiedName + "/" + table.Name,
Type: "table",
Metadata: map[string]any{
"bytes": table.Bytes,
"non_existent": table.Nonexistent,
},
Parent: dbResource,
}

for _, priv := range table.Privs {
tableBindings = append(tableBindings, analyzers.Binding{
Resource: tableResource,
Permission: analyzers.Permission{
Value: priv,
},
})
}

// Add this table's column privileges to bindings
for _, column := range table.Columns {
columnResource := analyzers.Resource{
Name: column.Name,
FullyQualifiedName: tableResource.FullyQualifiedName + "/" + column.Name,
Type: "column",
Parent: &tableResource,
}

for _, priv := range column.Privs {
tableBindings = append(tableBindings, analyzers.Binding{
Resource: columnResource,
Permission: analyzers.Permission{
Value: priv,
},
})
}
}
}

return tableBindings
}

func bakeRoutineBindings(dbResource *analyzers.Resource, database *Database) []analyzers.Binding {
if database.Routines == nil {
return nil
}

var routineBindings []analyzers.Binding
for _, routine := range *database.Routines {
routineResource := analyzers.Resource{
Name: routine.Name,
FullyQualifiedName: dbResource.FullyQualifiedName + "/" + routine.Name,
Type: "routine",
Metadata: map[string]any{
"non_existent": routine.Nonexistent,
},
Parent: dbResource,
}

for _, priv := range routine.Privs {
routineBindings = append(routineBindings, analyzers.Binding{
Resource: routineResource,
Permission: analyzers.Permission{
Value: priv,
},
})
}
}

return routineBindings
}

const (
// MySQL SSL Modes
mysql_sslmode = "ssl-mode"
Expand Down Expand Up @@ -74,6 +257,7 @@ type Routine struct {
// USER() returns `doadmin@localhost`

type SecretInfo struct {
Host string
User string
Databases map[string]*Database
GlobalPrivs GlobalPrivs
Expand All @@ -99,8 +283,13 @@ func AnalyzeAndPrintPermissions(cfg *config.Config, key string) {
}

func AnalyzePermissions(cfg *config.Config, connectionStr string) (*SecretInfo, error) {
// Parse the connection string
u, err := parseConnectionStr(connectionStr)
if err != nil {
return nil, fmt.Errorf("parsing the connection string: %w", err)
}

db, err := createConnection(connectionStr)
db, err := createConnection(u)
if err != nil {
return nil, fmt.Errorf("connecting to the MySQL database: %w", err)
}
Expand Down Expand Up @@ -139,13 +328,14 @@ func AnalyzePermissions(cfg *config.Config, connectionStr string) (*SecretInfo,
processGrants(grants, databases, &globalPrivs)

return &SecretInfo{
Host: u.Hostname(),
User: user,
Databases: databases,
GlobalPrivs: globalPrivs,
}, nil
}

func createConnection(connection string) (*sql.DB, error) {
func parseConnectionStr(connection string) (*dburl.URL, error) {
// Check if the connection string starts with 'mysql://'
if !strings.HasPrefix(connection, "mysql://") {
color.Yellow("[i] The connection string should start with 'mysql://'. Adding it for you.")
Expand All @@ -163,7 +353,10 @@ func createConnection(connection string) (*sql.DB, error) {
if err != nil {
return nil, err
}
return u, nil
}

func createConnection(u *dburl.URL) (*sql.DB, error) {
// Connect to the MySQL database
db, err := sql.Open("mysql", u.DSN)
if err != nil {
Expand Down
104 changes: 104 additions & 0 deletions pkg/analyzer/analyzers/mysql/mysql_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package mysql

import (
_ "embed"
"encoding/json"
"fmt"
"testing"

"github.com/brianvoe/gofakeit/v7"
"github.com/google/go-cmp/cmp"
"github.com/testcontainers/testcontainers-go/modules/mysql"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config"
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
)

//go:embed expected_output.json
var expectedOutput []byte

func TestAnalyzer_Analyze(t *testing.T) {
mysqlUser := "root"
mysqlPass := gofakeit.Password(true, true, true, false, false, 10)
mysqlDatabase := "mysql"

ctx := context.Background()

mysqlC, err := mysql.Run(ctx, "mysql",
mysql.WithDatabase(mysqlDatabase),
mysql.WithUsername(mysqlUser),
mysql.WithPassword(mysqlPass),
)
if err != nil {
t.Fatal(err)
}

defer func() { _ = mysqlC.Terminate(ctx) }()

host, err := mysqlC.Host(ctx)
if err != nil {
t.Fatal(err)
}
port, err := mysqlC.MappedPort(ctx, "3306")
if err != nil {
t.Fatal(err)
}

tests := []struct {
name string
connectionString string
want []byte // JSON string
wantErr bool
}{
{
name: "valid Mysql connection",
connectionString: fmt.Sprintf(`root:%s@%s:%s/%s`, mysqlPass, host, port.Port(), mysqlDatabase),
want: expectedOutput,
wantErr: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
a := Analyzer{Cfg: &config.Config{}}
got, err := a.Analyze(context.Background(), map[string]string{"connection_string": tt.connectionString})
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)
}

// Parse the expected JSON string
var wantObj analyzers.AnalyzerResult
if err := json.Unmarshal(tt.want, &wantObj); err != nil {
t.Fatalf("could not unmarshal want JSON string: %s", err)
}

// Marshal the expected result to JSON (to normalize)
wantJSON, err := json.Marshal(wantObj)
if err != nil {
t.Fatalf("could not marshal want to JSON: %s", err)
}

// Compare bindings separately because they are not guaranteed to be in the same order
if len(got.Bindings) != len(wantObj.Bindings) {
t.Errorf("Analyzer.Analyze() = %s, want %s", gotJSON, wantJSON)
return
}

got.Bindings = nil
wantObj.Bindings = nil

// Compare the rest of the Object
if diff := cmp.Diff(&wantObj, got); diff != "" {
t.Errorf("%s: (-want +got)\n%s", tt.name, diff)
return
}
})
}
}
Loading

0 comments on commit b0318a9

Please sign in to comment.