mirror of
https://github.com/trufflesecurity/trufflehog.git
synced 2026-06-15 13:24:37 +00:00
PR #3099 introduced the `trufflehog analyze` command but accidentally included the same dependency with different versions. Signed-off-by: Eng Zer Jun <engzerjun@gmail.com>
656 lines
17 KiB
Go
656 lines
17 KiB
Go
//go:generate generate_permissions permissions.yaml permissions.go postgres
|
|
|
|
package postgres
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/fatih/color"
|
|
"github.com/jedib0t/go-pretty/v6/table"
|
|
"github.com/lib/pq"
|
|
|
|
"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 (Analyzer) Type() analyzers.AnalyzerType { return analyzers.AnalyzerTypePostgres }
|
|
|
|
func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) {
|
|
uri, ok := credInfo["connection_string"]
|
|
if !ok {
|
|
return nil, errors.New("connection string not found in credInfo")
|
|
}
|
|
|
|
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: analyzers.AnalyzerTypePostgres,
|
|
Metadata: nil,
|
|
Bindings: []analyzers.Binding{},
|
|
}
|
|
|
|
// set user related bindings in result
|
|
userResource, userBindings := bakeUserBindings(info)
|
|
result.Bindings = append(result.Bindings, userBindings...)
|
|
|
|
// add user's database privileges to bindings
|
|
dbNameToResourceMap, dbBindings := bakeDatabaseBindings(userResource, info)
|
|
result.Bindings = append(result.Bindings, dbBindings...)
|
|
|
|
// add user's table privileges to bindings
|
|
tableBindings := bakeTableBindings(dbNameToResourceMap, info)
|
|
result.Bindings = append(result.Bindings, tableBindings...)
|
|
|
|
return &result
|
|
}
|
|
|
|
func bakeUserBindings(info *SecretInfo) (analyzers.Resource, []analyzers.Binding) {
|
|
userResource := analyzers.Resource{
|
|
Name: info.User,
|
|
FullyQualifiedName: info.Host + "/" + info.User,
|
|
Type: "user",
|
|
Metadata: map[string]any{
|
|
"role": info.Role,
|
|
},
|
|
}
|
|
|
|
var bindings []analyzers.Binding
|
|
|
|
for rolePriv, exists := range info.RolePrivs {
|
|
if exists {
|
|
bindings = append(bindings, analyzers.Binding{
|
|
Resource: userResource,
|
|
Permission: analyzers.Permission{
|
|
Value: rolePriv,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
return userResource, bindings
|
|
}
|
|
|
|
func bakeDatabaseBindings(userResource analyzers.Resource, info *SecretInfo) (map[string]*analyzers.Resource, []analyzers.Binding) {
|
|
dbNameToResourceMap := map[string]*analyzers.Resource{}
|
|
dbBindings := []analyzers.Binding{}
|
|
|
|
for _, db := range info.DBs {
|
|
dbResource := analyzers.Resource{
|
|
Name: db.DatabaseName,
|
|
FullyQualifiedName: info.Host + "/" + db.DatabaseName,
|
|
Type: "database",
|
|
Metadata: map[string]any{
|
|
"owner": db.Owner,
|
|
},
|
|
Parent: &userResource,
|
|
}
|
|
|
|
// populate map to reference later for tables
|
|
dbNameToResourceMap[db.DatabaseName] = &dbResource
|
|
|
|
dbPriviliges := map[string]bool{
|
|
"connect": db.Connect,
|
|
"create": db.Create,
|
|
"temp": db.CreateTemp,
|
|
}
|
|
|
|
for priv, exists := range dbPriviliges {
|
|
if exists {
|
|
dbBindings = append(dbBindings, analyzers.Binding{
|
|
Resource: dbResource,
|
|
Permission: analyzers.Permission{
|
|
Value: priv,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return dbNameToResourceMap, dbBindings
|
|
}
|
|
|
|
func bakeTableBindings(dbNameToResourceMap map[string]*analyzers.Resource, info *SecretInfo) []analyzers.Binding {
|
|
var tableBindings []analyzers.Binding
|
|
|
|
for dbName, tableMap := range info.TablePrivs {
|
|
dbResource, ok := dbNameToResourceMap[dbName]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
for tableName, tableData := range tableMap {
|
|
tableResource := analyzers.Resource{
|
|
Name: tableName,
|
|
FullyQualifiedName: info.Host + "/" + dbResource.Name + "/" + tableName,
|
|
Type: "table",
|
|
Metadata: map[string]any{
|
|
"size": tableData.Size,
|
|
"rows": tableData.Rows,
|
|
},
|
|
Parent: dbResource,
|
|
}
|
|
|
|
tablePrivsMap := map[string]bool{
|
|
"select": tableData.Privs.Select,
|
|
"insert": tableData.Privs.Insert,
|
|
"update": tableData.Privs.Update,
|
|
"delete": tableData.Privs.Delete,
|
|
"truncate": tableData.Privs.Truncate,
|
|
"references": tableData.Privs.References,
|
|
"trigger": tableData.Privs.Trigger,
|
|
}
|
|
|
|
for priv, exists := range tablePrivsMap {
|
|
if exists {
|
|
tableBindings = append(tableBindings, analyzers.Binding{
|
|
Resource: tableResource,
|
|
Permission: analyzers.Permission{
|
|
Value: priv,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return tableBindings
|
|
}
|
|
|
|
type DBPrivs struct {
|
|
Connect bool
|
|
Create bool
|
|
CreateTemp bool
|
|
}
|
|
|
|
type DB struct {
|
|
DatabaseName string
|
|
Owner string
|
|
DBPrivs
|
|
}
|
|
|
|
type TablePrivs struct {
|
|
Select bool
|
|
Insert bool
|
|
Update bool
|
|
Delete bool
|
|
Truncate bool
|
|
References bool
|
|
Trigger bool
|
|
}
|
|
|
|
type TableData struct {
|
|
Size string
|
|
Rows string
|
|
Privs TablePrivs
|
|
}
|
|
|
|
const (
|
|
pg_connect_timeout = "connect_timeout"
|
|
pg_dbname = "dbname"
|
|
pg_host = "host"
|
|
pg_password = "password"
|
|
pg_port = "port"
|
|
pg_requiressl = "requiressl"
|
|
pg_sslmode = "sslmode"
|
|
pg_sslmode_allow = "allow"
|
|
pg_sslmode_disable = "disable"
|
|
pg_sslmode_prefer = "prefer"
|
|
pg_sslmode_require = "require"
|
|
pg_user = "user"
|
|
)
|
|
|
|
var connStrPartPattern = regexp.MustCompile(`([[:alpha:]]+)='(.+?)' ?`)
|
|
|
|
type SecretInfo struct {
|
|
Host string
|
|
User string
|
|
Role string
|
|
RolePrivs map[string]bool
|
|
DBs []DB
|
|
TablePrivs map[string]map[string]*TableData
|
|
}
|
|
|
|
func AnalyzeAndPrintPermissions(cfg *config.Config, connectionStr string) {
|
|
|
|
// ToDo: Add in logging
|
|
if cfg.LoggingEnabled {
|
|
color.Red("[x] Logging is not supported for this analyzer.")
|
|
return
|
|
}
|
|
|
|
info, err := AnalyzePermissions(cfg, connectionStr)
|
|
if err != nil {
|
|
color.Red("[x] Error: %s", err.Error())
|
|
return
|
|
}
|
|
|
|
color.Yellow("[!] Successfully connected to Postgres database.")
|
|
printUserRoleAndPriv(info.Role, info.RolePrivs)
|
|
|
|
// Print db privs
|
|
if len(info.DBs) > 0 {
|
|
fmt.Print("\n\n")
|
|
color.Green("[i] User has the following database privileges:")
|
|
printDBPrivs(info.DBs, info.User)
|
|
}
|
|
|
|
// Print table privs
|
|
if len(info.TablePrivs) > 0 {
|
|
fmt.Print("\n\n")
|
|
color.Green("[i] User has the following table privileges:")
|
|
printTablePrivs(info.TablePrivs)
|
|
}
|
|
}
|
|
|
|
func AnalyzePermissions(cfg *config.Config, connectionStr string) (*SecretInfo, error) {
|
|
|
|
connStr, err := pq.ParseURL(string(connectionStr))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse Postgres connection string: %w", err)
|
|
}
|
|
parts := connStrPartPattern.FindAllStringSubmatch(connStr, -1)
|
|
params := make(map[string]string, len(parts))
|
|
for _, part := range parts {
|
|
params[part[1]] = part[2]
|
|
}
|
|
db, err := createConnection(params, "")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to connect to Postgres database: %w", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
role, privs, err := getUserPrivs(db)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to retrieve user privileges: %w", err)
|
|
}
|
|
currentUser, dbs, err := getDBPrivs(db)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to retrieve database privileges: %w", err)
|
|
}
|
|
tablePrivs, err := getTablePrivs(params, buildSliceDBNames(dbs))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to retrieve table privileges: %w", err)
|
|
}
|
|
|
|
return &SecretInfo{
|
|
Host: params[pg_host],
|
|
User: currentUser,
|
|
Role: role,
|
|
RolePrivs: privs,
|
|
DBs: dbs,
|
|
TablePrivs: tablePrivs,
|
|
}, nil
|
|
}
|
|
|
|
func isErrorDatabaseNotFound(err error, dbName string, user string) bool {
|
|
options := []string{dbName, user, "postgres"}
|
|
for _, option := range options {
|
|
if strings.Contains(err.Error(), fmt.Sprintf("database \"%s\" does not exist", option)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func createConnection(params map[string]string, database string) (*sql.DB, error) {
|
|
if sslmode := params[pg_sslmode]; sslmode == pg_sslmode_allow || sslmode == pg_sslmode_prefer {
|
|
// pq doesn't support 'allow' or 'prefer'. If we find either of them, we'll just ignore it. This will trigger
|
|
// the same logic that is run if no sslmode is set at all (which mimics 'prefer', which is the default).
|
|
delete(params, pg_sslmode)
|
|
}
|
|
|
|
var connStr string
|
|
for key, value := range params {
|
|
if database != "" && key == "dbname" {
|
|
connStr += fmt.Sprintf("%s='%s'", key, database)
|
|
} else {
|
|
connStr += fmt.Sprintf("%s='%s'", key, value)
|
|
}
|
|
}
|
|
|
|
db, err := sql.Open("postgres", connStr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = db.Ping()
|
|
switch {
|
|
case err == nil:
|
|
return db, nil
|
|
case strings.Contains(err.Error(), "password authentication failed"):
|
|
return nil, errors.New("password authentication failed")
|
|
case errors.Is(err, pq.ErrSSLNotSupported) && params[pg_sslmode] == "":
|
|
// If the sslmode is unset, then either it was unset in the candidate secret, or we've intentionally unset it
|
|
// because it was specified as 'allow' or 'prefer', neither of which pq supports. In all of these cases, non-SSL
|
|
// connections are acceptable, so now we try a connection without SSL.
|
|
params[pg_sslmode] = pg_sslmode_disable
|
|
defer delete(params, pg_sslmode) // We want to return with the original params map intact (for ExtraData)
|
|
return createConnection(params, database)
|
|
case isErrorDatabaseNotFound(err, params[pg_dbname], params[pg_user]):
|
|
color.Green("[!] Successfully connected to Postgres database.")
|
|
return nil, err
|
|
default:
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
func getUserPrivs(db *sql.DB) (string, map[string]bool, error) {
|
|
// Prepare the SQL statement
|
|
query := `SELECT rolname AS role_name,
|
|
rolsuper AS is_superuser,
|
|
rolinherit AS can_inherit,
|
|
rolcreaterole AS can_create_role,
|
|
rolcreatedb AS can_create_db,
|
|
rolcanlogin AS can_login,
|
|
rolreplication AS is_replication_role,
|
|
rolbypassrls AS bypasses_rls
|
|
FROM pg_roles WHERE rolname = current_user;`
|
|
|
|
// Execute the SQL query
|
|
rows, err := db.Query(query)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var roleName string
|
|
var isSuperuser, canInherit, canCreateRole, canCreateDB, canLogin, isReplicationRole, bypassesRLS bool
|
|
// Iterate over the rows
|
|
for rows.Next() {
|
|
if err := rows.Scan(&roleName, &isSuperuser, &canInherit, &canCreateRole, &canCreateDB, &canLogin, &isReplicationRole, &bypassesRLS); err != nil {
|
|
return "", nil, err
|
|
}
|
|
}
|
|
|
|
// Check for errors during iteration
|
|
if err := rows.Err(); err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
// Map roles to privileges
|
|
var mapRoles map[string]bool = map[string]bool{
|
|
"Superuser": isSuperuser,
|
|
"Inheritance of Privs": canInherit,
|
|
"Create Role": canCreateRole,
|
|
"Create DB": canCreateDB,
|
|
"Login": canLogin,
|
|
"Replication": isReplicationRole,
|
|
"Bypass RLS": bypassesRLS,
|
|
}
|
|
|
|
return roleName, mapRoles, nil
|
|
}
|
|
|
|
func getDBPrivs(db *sql.DB) (string, []DB, error) {
|
|
query := `
|
|
SELECT
|
|
d.datname AS database_name,
|
|
u.usename AS owner,
|
|
current_user AS current_user,
|
|
has_database_privilege(current_user, d.datname, 'CONNECT') AS can_connect,
|
|
has_database_privilege(current_user, d.datname, 'CREATE') AS can_create,
|
|
has_database_privilege(current_user, d.datname, 'TEMP') AS can_create_temporary_tables
|
|
FROM
|
|
pg_database d
|
|
JOIN
|
|
pg_user u ON d.datdba = u.usesysid
|
|
WHERE
|
|
NOT d.datistemplate
|
|
ORDER BY
|
|
d.datname;
|
|
`
|
|
// Originally had WHERE NOT d.datistemplate AND d.datallowconn
|
|
|
|
// Execute the query
|
|
rows, err := db.Query(query)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
dbs := make([]DB, 0)
|
|
|
|
var currentUser string
|
|
// Iterate through the result set
|
|
for rows.Next() {
|
|
var dbName, owner string
|
|
var canConnect, canCreate, canCreateTemp bool
|
|
err := rows.Scan(&dbName, &owner, ¤tUser, &canConnect, &canCreate, &canCreateTemp)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
db := DB{
|
|
DatabaseName: dbName,
|
|
Owner: owner,
|
|
DBPrivs: DBPrivs{
|
|
Connect: canConnect,
|
|
Create: canCreate,
|
|
CreateTemp: canCreateTemp,
|
|
},
|
|
}
|
|
dbs = append(dbs, db)
|
|
}
|
|
if err = rows.Err(); err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
return currentUser, dbs, nil
|
|
}
|
|
|
|
func printDBPrivs(dbs []DB, current_user string) {
|
|
t := table.NewWriter()
|
|
t.SetOutputMirror(os.Stdout)
|
|
t.AppendHeader(table.Row{"Database", "Owner", "Access Privileges"})
|
|
for _, db := range dbs {
|
|
privs := buildDBPrivsStr(db)
|
|
writer := getDBWriter(db, current_user)
|
|
t.AppendRow([]interface{}{writer(db.DatabaseName), writer(db.Owner), writer(privs)})
|
|
}
|
|
t.Render()
|
|
}
|
|
|
|
func buildDBPrivsStr(db DB) string {
|
|
privs := ""
|
|
if db.Connect {
|
|
privs += "CONNECT"
|
|
}
|
|
if db.Create {
|
|
privs += ", CREATE"
|
|
}
|
|
if db.CreateTemp {
|
|
privs += ", TEMP"
|
|
}
|
|
privs = strings.TrimPrefix(privs, ", ")
|
|
return privs
|
|
}
|
|
|
|
func getDBWriter(db DB, current_user string) func(a ...interface{}) string {
|
|
if db.Owner == current_user {
|
|
return analyzers.GreenWriter
|
|
} else if db.Connect && db.Create && db.CreateTemp {
|
|
return analyzers.GreenWriter
|
|
} else if db.Connect || db.Create || db.CreateTemp {
|
|
return analyzers.YellowWriter
|
|
} else {
|
|
return analyzers.DefaultWriter
|
|
}
|
|
}
|
|
|
|
func buildSliceDBNames(dbs []DB) []string {
|
|
var dbNames []string
|
|
for _, db := range dbs {
|
|
if db.DBPrivs.Connect {
|
|
dbNames = append(dbNames, db.DatabaseName)
|
|
}
|
|
}
|
|
return dbNames
|
|
}
|
|
|
|
func getTablePrivs(params map[string]string, databases []string) (map[string]map[string]*TableData, error) {
|
|
|
|
tablePrivileges := make(map[string]map[string]*TableData, 0)
|
|
|
|
for _, dbase := range databases {
|
|
// Connect to db
|
|
db, err := createConnection(params, dbase)
|
|
if err != nil {
|
|
// color.Red("[x] Failed to connect to Postgres database: %s", dbase)
|
|
continue
|
|
}
|
|
defer db.Close()
|
|
|
|
// Get table privs
|
|
query := `
|
|
SELECT
|
|
rtg.table_catalog,
|
|
rtg.table_name,
|
|
rtg.privilege_type,
|
|
pg_size_pretty(pg_total_relation_size(pc.oid)) AS table_size,
|
|
pc.reltuples AS estimate
|
|
FROM
|
|
information_schema.role_table_grants rtg
|
|
JOIN
|
|
pg_catalog.pg_class pc ON rtg.table_name = pc.relname
|
|
WHERE
|
|
rtg.grantee = current_user;
|
|
|
|
`
|
|
|
|
// Execute the query
|
|
rows, err := db.Query(query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
// Iterate through the result set
|
|
for rows.Next() {
|
|
var database, table, priv, size, row_count string
|
|
err := rows.Scan(&database, &table, &priv, &size, &row_count)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if _, ok := tablePrivileges[database]; !ok {
|
|
tablePrivileges[database] = map[string]*TableData{
|
|
table: {},
|
|
}
|
|
}
|
|
|
|
if _, ok := tablePrivileges[database][table]; !ok {
|
|
tablePrivileges[database][table] = &TableData{}
|
|
}
|
|
|
|
switch priv {
|
|
case "SELECT":
|
|
tablePrivileges[database][table].Privs.Select = true
|
|
case "INSERT":
|
|
tablePrivileges[database][table].Privs.Insert = true
|
|
case "UPDATE":
|
|
tablePrivileges[database][table].Privs.Update = true
|
|
case "DELETE":
|
|
tablePrivileges[database][table].Privs.Delete = true
|
|
case "TRUNCATE":
|
|
tablePrivileges[database][table].Privs.Truncate = true
|
|
case "REFERENCES":
|
|
tablePrivileges[database][table].Privs.References = true
|
|
case "TRIGGER":
|
|
tablePrivileges[database][table].Privs.Trigger = true
|
|
}
|
|
tablePrivileges[database][table].Size = size
|
|
if row_count != "-1" {
|
|
tablePrivileges[database][table].Rows = row_count
|
|
} else {
|
|
tablePrivileges[database][table].Rows = "Unknown"
|
|
}
|
|
}
|
|
if err = rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
db.Close()
|
|
}
|
|
|
|
return tablePrivileges, nil
|
|
}
|
|
|
|
func printTablePrivs(tables map[string]map[string]*TableData) {
|
|
t := table.NewWriter()
|
|
t.SetOutputMirror(os.Stdout)
|
|
t.AppendHeader(table.Row{"Database", "Table", "Access Privileges", "Est. Size", "Est. Rows"})
|
|
var writer func(a ...interface{}) string
|
|
for db, table := range tables {
|
|
for table_name, tableData := range table {
|
|
privs := tableData.Privs
|
|
privsStr := buildTablePrivsStr(privs)
|
|
if privsStr == "" {
|
|
writer = color.New().SprintFunc()
|
|
} else {
|
|
writer = color.New(color.FgGreen).SprintFunc()
|
|
}
|
|
t.AppendRow([]interface{}{writer(db), writer(table_name), writer(privsStr), writer("< " + tableData.Size), writer(tableData.Rows)})
|
|
}
|
|
}
|
|
t.Render()
|
|
}
|
|
|
|
func printUserRoleAndPriv(role string, privs map[string]bool) {
|
|
color.Yellow("[i] User: %s", role)
|
|
color.Yellow("[i] Privileges: ")
|
|
for role, priv := range privs {
|
|
if role == "Superuser" && priv {
|
|
color.Green(" - %s", role)
|
|
} else if priv {
|
|
color.Yellow(" - %s", role)
|
|
}
|
|
}
|
|
}
|
|
|
|
func buildTablePrivsStr(privs TablePrivs) string {
|
|
var privsStr string
|
|
if privs.Select {
|
|
privsStr += "SELECT"
|
|
}
|
|
if privs.Insert {
|
|
privsStr += ", INSERT"
|
|
}
|
|
if privs.Update {
|
|
privsStr += ", UPDATE"
|
|
}
|
|
if privs.Delete {
|
|
privsStr += ", DELETE"
|
|
}
|
|
if privs.Truncate {
|
|
privsStr += ", TRUNCATE"
|
|
}
|
|
if privs.References {
|
|
privsStr += ", REFERENCES"
|
|
}
|
|
if privs.Trigger {
|
|
privsStr += ", TRIGGER"
|
|
}
|
|
privsStr = strings.TrimPrefix(privsStr, ", ")
|
|
return privsStr
|
|
}
|