From 17f717abf3ed5be72250091169194d25e16d2443 Mon Sep 17 00:00:00 2001 From: Andrew Cremins Date: Fri, 12 Dec 2025 04:22:10 -0800 Subject: [PATCH 1/4] init --- cmd/rds-init/README.md | 65 +++++++++ cmd/rds-init/main.go | 299 +++++++++++++++++++++++++++++++++++++++++ go.mod | 2 + go.sum | 4 + 4 files changed, 370 insertions(+) create mode 100644 cmd/rds-init/README.md create mode 100644 cmd/rds-init/main.go diff --git a/cmd/rds-init/README.md b/cmd/rds-init/README.md new file mode 100644 index 0000000..6f6aed5 --- /dev/null +++ b/cmd/rds-init/README.md @@ -0,0 +1,65 @@ +# RDS Database Initialization Tool + +A utility for initializing and configuring RDS PostgreSQL databases. This tool manages the `nessus_scan_user` account for security scanning and enables RDS IAM authentication for the admin user. + +## Overview + +This tool performs two main tasks: + +1. **Nessus scan user management** — Creates and maintains a dedicated database user (`nessus_scan_user`) for Nessus security scans, with credentials stored in AWS Secrets Manager. + +2. **RDS IAM authentication setup** — Grants the `rds_iam` role to the admin user, enabling IAM-based database authentication for future connections. + +## How It Works + +### Nessus User Setup + +The tool follows this workflow for the `nessus_scan_user`: + +1. Parses the provided PostgreSQL DSN to extract connection details +2. Looks up the RDS instance identifier by matching the endpoint host and port +3. Checks for an existing secret named `{db-identifier}_nessus` in Secrets Manager +4. If no secret exists, generates a random 22-character password and creates the secret +5. If a secret exists, reuses the stored password +6. Creates the `nessus_scan_user` role in PostgreSQL if it doesn't exist +7. Sets (or updates) the user's password to match the Secrets Manager value +8. Grants `pg_read_all_settings` to the user for Nessus compliance scanning + +### RDS IAM Setup + +After configuring the Nessus user, the tool grants `rds_iam` to the currently authenticated user (the admin user specified in the DSN). + +## Usage + +```bash +./rds-init +``` + +### Example + +```bash +./rds-init "postgres://admin_user:password@mydb.abc123.us-east-1.rds.amazonaws.com:5432/myappdb" +``` + +## When to Run + +- **Initial provisioning** — Run after creating a new RDS instance +- **Password rotation** — Run after manually updating the password in the `{db-identifier}_nessus` Secrets Manager secret to sync it to the database +- **Idempotent operation** — Safe to run multiple times; subsequent runs have no adverse effects + +## Prerequisites + +- AWS credentials configured with permissions for: + - `secretsmanager:GetSecretValue` + - `secretsmanager:CreateSecret` + - `rds:DescribeDBInstances` +- Network access to the target RDS instance +- A PostgreSQL admin user with privileges to create roles and grant permissions + +## Secrets Manager Secret + +Secret names follow the pattern: `{rds-instance-identifier}_nessus` + +## Notes + +This tool is safe to run multiple times (e.g., if the admin user's IAM auth mode was reset). However, after the initial run, password authentication is typically disabled for the admin user. Subsequent executions using the same password-based DSN will fail. \ No newline at end of file diff --git a/cmd/rds-init/main.go b/cmd/rds-init/main.go new file mode 100644 index 0000000..9763d2c --- /dev/null +++ b/cmd/rds-init/main.go @@ -0,0 +1,299 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "log" + "net/url" + "os" + "strings" + "time" + + "github.com/jmoiron/sqlx" + "github.com/lib/pq" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/rds" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + smtypes "github.com/aws/aws-sdk-go-v2/service/secretsmanager/types" +) + +const ( + nessusUserName = "nessus_scan_user" + passwordLength = 22 + passwordCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + defaultPostgresPort = "5432" +) + +type dbConnInfo struct { + Host string + Port string + DBName string + User string + RawDSN string +} + +type nessusSecret struct { + Engine string `json:"engine"` + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + DBName string `json:"dbname"` +} + +func randomPassword(n int) (string, error) { + if n <= 0 { + return "", errors.New("password length must be > 0") + } + buf := make([]byte, n) + charsetLen := byte(len(passwordCharset)) + + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("failed to read random bytes: %w", err) + } + for i, b := range buf { + buf[i] = passwordCharset[b%charsetLen] + } + return string(buf), nil +} + +func parseDSN(dsn string) (*dbConnInfo, error) { + u, err := url.Parse(dsn) + if err != nil { + return nil, fmt.Errorf("failed to parse DSN as URL: %w", err) + } + if u.Scheme != "postgres" && u.Scheme != "postgresql" { + return nil, fmt.Errorf("unexpected scheme %q in DSN, expected postgres or postgresql", u.Scheme) + } + + user := "" + if u.User != nil { + user = u.User.Username() + } + + host := u.Hostname() + port := u.Port() + if port == "" { + port = defaultPostgresPort + } + dbname := strings.TrimPrefix(u.Path, "/") + + // If path is empty, optionally look for ?dbname=foo + if dbname == "" { + if qName := u.Query().Get("dbname"); qName != "" { + dbname = qName + } + } + + // If still empty, fall back to username (lib/pq's default behavior) + if dbname == "" && user != "" { + dbname = user + } + + return &dbConnInfo{ + Host: host, + Port: port, + DBName: dbname, + User: user, + RawDSN: dsn, + }, nil +} + +func loadAWSConfig(ctx context.Context) (aws.Config, error) { + return awsconfig.LoadDefaultConfig(ctx) +} + +func getSecretIfExists(ctx context.Context, sm *secretsmanager.Client, name string) (*nessusSecret, error) { + out, err := sm.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{ + SecretId: aws.String(name), + }) + if err != nil { + var rnfe *smtypes.ResourceNotFoundException + if errors.As(err, &rnfe) { + return nil, nil + } + return nil, fmt.Errorf("GetSecretValue failed: %w", err) + } + + if out.SecretString == nil { + return nil, fmt.Errorf("existing secret %q does not have SecretString", name) + } + + var sec nessusSecret + if err := json.Unmarshal([]byte(*out.SecretString), &sec); err != nil { + return nil, fmt.Errorf("failed to unmarshal existing secret JSON: %w", err) + } + return &sec, nil +} + +func createSecret(ctx context.Context, sm *secretsmanager.Client, name string, sec *nessusSecret) error { + payload, err := json.Marshal(sec) + if err != nil { + return fmt.Errorf("failed to marshal secret JSON: %w", err) + } + _, err = sm.CreateSecret(ctx, &secretsmanager.CreateSecretInput{ + Name: aws.String(name), + SecretString: aws.String(string(payload)), + }) + if err != nil { + return fmt.Errorf("CreateSecret failed: %w", err) + } + return nil +} + +func findDBInstanceByEndpoint(ctx context.Context, client *rds.Client, host string, port int32) (string, error) { + var marker *string + + for { + out, err := client.DescribeDBInstances(ctx, &rds.DescribeDBInstancesInput{ + Marker: marker, + }) + if err != nil { + return "", fmt.Errorf("DescribeDBInstances failed: %w", err) + } + + for _, inst := range out.DBInstances { + if inst.Endpoint == nil { + continue + } + if aws.ToString(inst.Endpoint.Address) == host && aws.ToInt32(inst.Endpoint.Port) == port { + return aws.ToString(inst.DBInstanceIdentifier), nil + } + } + + if out.Marker == nil || len(out.DBInstances) == 0 { + break + } + marker = out.Marker + } + + return "", fmt.Errorf("no RDS DB instance found with endpoint %s:%d", host, port) +} + +func main() { + log.SetFlags(0) + + if len(os.Args) != 2 { + log.Fatalf("Usage: %s ", os.Args[0]) + } + dsn := os.Args[1] + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + connInfo, err := parseDSN(dsn) + if err != nil { + log.Fatalf("Error parsing DSN: %v", err) + } + + db, err := sqlx.Connect("postgres", dsn) + if err != nil { + log.Fatalf("Error connecting to database: %s", err) + } + defer db.Close() + + awsCfg, err := loadAWSConfig(ctx) + if err != nil { + log.Fatalf("Failed to load AWS config: %v", err) + } + + smClient := secretsmanager.NewFromConfig(awsCfg) + rdsClient := rds.NewFromConfig(awsCfg) + + portInt := 5432 + fmt.Sscanf(connInfo.Port, "%d", &portInt) + + dbIdentifier, err := findDBInstanceByEndpoint(ctx, rdsClient, connInfo.Host, int32(portInt)) + if err != nil { + log.Fatalf("Could not look up RDS DB instance for endpoint %s:%d: %v", connInfo.Host, portInt, err) + } + log.Printf("Discovered RDS DB instance identifier: %s", dbIdentifier) + + secretName := dbIdentifier + "_nessus" + log.Printf("Using secret name: %s", secretName) + + // Secrets Manager is canonical for password: + // - If it exists, reuse its password + // - If it doesn't, generate & create it + existingSecret, err := getSecretIfExists(ctx, smClient, secretName) + if err != nil { + log.Fatalf("Error checking existing secret: %v", err) + } + + var password string + if existingSecret != nil && existingSecret.Password != "" { + password = existingSecret.Password + log.Printf("Reusing existing password from secret %q.", secretName) + } else { + password, err = randomPassword(passwordLength) + if err != nil { + log.Fatalf("Error generating random password: %v", err) + } + log.Printf("Generated new password for %s.", nessusUserName) + } + + secretBody := &nessusSecret{ + Engine: "postgres", + Host: connInfo.Host, + Port: portInt, + Username: nessusUserName, + Password: password, + DBName: connInfo.DBName, + } + + if existingSecret == nil { + if err := createSecret(ctx, smClient, secretName, secretBody); err != nil { + log.Fatalf("Error creating secret %q: %v", secretName, err) + } + log.Printf("Created secret %q in Secrets Manager.", secretName) + } + + // 1) Ensure user exists (no password embedded) + ensureUserSQL := fmt.Sprintf(` +DO $do$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = %s) THEN + CREATE USER %s; + END IF; +END +$do$; +`, + pq.QuoteLiteral(nessusUserName), + pq.QuoteIdentifier(nessusUserName), + ) + + if _, err := db.ExecContext(ctx, ensureUserSQL); err != nil { + log.Fatalf("Failed to ensure %s exists: %v", nessusUserName, err) + } + + // 2) Set password (ALTER ROLE does not support parameter placeholders) + alterRoleSQL := fmt.Sprintf( + "ALTER ROLE %s WITH PASSWORD %s", + pq.QuoteIdentifier(nessusUserName), + pq.QuoteLiteral(password), + ) + + if _, err := db.ExecContext(ctx, alterRoleSQL); err != nil { + log.Fatalf("Failed to set password for %s: %v", nessusUserName, err) + } + + // 3) Grants + grantSQL := fmt.Sprintf( + "GRANT pg_read_all_settings TO %s", + pq.QuoteIdentifier(nessusUserName), + ) + + if _, err := db.ExecContext(ctx, grantSQL); err != nil { + log.Fatalf("Failed to grant pg_read_all_settings to %s: %v", nessusUserName, err) + } + + // CURRENT_USER is the user this script authenticated as on this connection. + if _, err := db.ExecContext(ctx, "GRANT rds_iam TO CURRENT_USER"); err != nil { + log.Fatalf("Failed to grant rds_iam to CURRENT_USER: %v", err) + } +} diff --git a/go.mod b/go.mod index 5018873..e78c405 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,8 @@ require ( github.com/aws/aws-sdk-go-v2/config v1.31.0 github.com/aws/aws-sdk-go-v2/credentials v1.19.5 github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.16 + github.com/aws/aws-sdk-go-v2/service/rds v1.113.1 + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.0 github.com/aws/aws-sdk-go-v2/service/ssm v1.63.0 github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 github.com/coreos/go-oidc/v3 v3.6.0 diff --git a/go.sum b/go.sum index fd22c0a..a332804 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,10 @@ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEd github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= +github.com/aws/aws-sdk-go-v2/service/rds v1.113.1 h1:/vV0g/Su8rCTqT57UUYiFU/aRrPXz//fGDn1dkXblG4= +github.com/aws/aws-sdk-go-v2/service/rds v1.113.1/go.mod h1:q02df+DL73LN+jDXzj86tMsI6kKf1kfv61nB684H+o8= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.0 h1:vL6rQXcGtFv9q/9eRPdI+lL+dvTm7xKGZYSHEvmrpDk= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.0/go.mod h1:QwEDLD+7EukuEUnbWtiNE8LhgvvmhjZoi4XAppYPtyc= github.com/aws/aws-sdk-go-v2/service/ssm v1.63.0 h1:1T8wFNEtOP4lgLC7v8Fzgbb4kFrMmnscG7kOqkbA26c= github.com/aws/aws-sdk-go-v2/service/ssm v1.63.0/go.mod h1:CDVmu8K5JKdgdJakdZ9gC3K6OJ/+izv/kUncFeGRIj4= github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 h1:eYnlt6QxnFINKzwxP5/Ucs1vkG7VT3Iezmvfgc2waUw= From 0a88a3565ff3e3174ac77d7517029d9a1d4002cf Mon Sep 17 00:00:00 2001 From: Andrew Cremins Date: Tue, 16 Dec 2025 16:07:28 -0800 Subject: [PATCH 2/4] respnd to PR comments --- cmd/rds-init/README.md | 2 +- cmd/rds-init/main.go | 44 +++++++++++++++++++++++++----------------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/cmd/rds-init/README.md b/cmd/rds-init/README.md index 6f6aed5..0c86b21 100644 --- a/cmd/rds-init/README.md +++ b/cmd/rds-init/README.md @@ -45,7 +45,7 @@ After configuring the Nessus user, the tool grants `rds_iam` to the currently au - **Initial provisioning** — Run after creating a new RDS instance - **Password rotation** — Run after manually updating the password in the `{db-identifier}_nessus` Secrets Manager secret to sync it to the database -- **Idempotent operation** — Safe to run multiple times; subsequent runs have no adverse effects +- **Safe to rerun on failure** — On success password auth will be disabled for the admin user. As this tool requires password auth, subsequent runs will not be possible. If the script exits unsuccessfully before disabling password auth, it is safe to execute multiple times. ## Prerequisites diff --git a/cmd/rds-init/main.go b/cmd/rds-init/main.go index 9763d2c..5ed50e8 100644 --- a/cmd/rds-init/main.go +++ b/cmd/rds-init/main.go @@ -7,8 +7,10 @@ import ( "errors" "fmt" "log" + "math/big" "net/url" "os" + "strconv" "strings" "time" @@ -31,7 +33,7 @@ const ( type dbConnInfo struct { Host string - Port string + Port int DBName string User string RawDSN string @@ -50,16 +52,19 @@ func randomPassword(n int) (string, error) { if n <= 0 { return "", errors.New("password length must be > 0") } - buf := make([]byte, n) - charsetLen := byte(len(passwordCharset)) - if _, err := rand.Read(buf); err != nil { - return "", fmt.Errorf("failed to read random bytes: %w", err) - } - for i, b := range buf { - buf[i] = passwordCharset[b%charsetLen] + max := big.NewInt(int64(len(passwordCharset))) + out := make([]byte, n) + + for i := 0; i < n; i++ { + x, err := rand.Int(rand.Reader, max) + if err != nil { + return "", fmt.Errorf("rand.Int: %w", err) + } + out[i] = passwordCharset[x.Int64()] } - return string(buf), nil + + return string(out), nil } func parseDSN(dsn string) (*dbConnInfo, error) { @@ -77,10 +82,16 @@ func parseDSN(dsn string) (*dbConnInfo, error) { } host := u.Hostname() - port := u.Port() - if port == "" { - port = defaultPostgresPort + + portStr := u.Port() + if portStr == "" { + portStr = defaultPostgresPort + } + port, err := strconv.Atoi(portStr) + if err != nil { + return nil, fmt.Errorf("invalid port %q: %w", portStr, err) } + dbname := strings.TrimPrefix(u.Path, "/") // If path is empty, optionally look for ?dbname=foo @@ -205,12 +216,9 @@ func main() { smClient := secretsmanager.NewFromConfig(awsCfg) rdsClient := rds.NewFromConfig(awsCfg) - portInt := 5432 - fmt.Sscanf(connInfo.Port, "%d", &portInt) - - dbIdentifier, err := findDBInstanceByEndpoint(ctx, rdsClient, connInfo.Host, int32(portInt)) + dbIdentifier, err := findDBInstanceByEndpoint(ctx, rdsClient, connInfo.Host, int32(connInfo.Port)) if err != nil { - log.Fatalf("Could not look up RDS DB instance for endpoint %s:%d: %v", connInfo.Host, portInt, err) + log.Fatalf("Could not look up RDS DB instance for endpoint %s:%d: %v", connInfo.Host, connInfo.Port, err) } log.Printf("Discovered RDS DB instance identifier: %s", dbIdentifier) @@ -240,7 +248,7 @@ func main() { secretBody := &nessusSecret{ Engine: "postgres", Host: connInfo.Host, - Port: portInt, + Port: connInfo.Port, Username: nessusUserName, Password: password, DBName: connInfo.DBName, From f00a6a8730fdea4a2ad333e7dec6af3b6a21ddcc Mon Sep 17 00:00:00 2001 From: Andrew Cremins Date: Thu, 18 Dec 2025 21:54:08 -0800 Subject: [PATCH 3/4] Respond to PR comments --- cmd/rds-init/main.go | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/cmd/rds-init/main.go b/cmd/rds-init/main.go index 5ed50e8..f815b6f 100644 --- a/cmd/rds-init/main.go +++ b/cmd/rds-init/main.go @@ -3,11 +3,11 @@ package main import ( "context" "crypto/rand" + "encoding/hex" "encoding/json" "errors" "fmt" "log" - "math/big" "net/url" "os" "strconv" @@ -26,8 +26,6 @@ import ( const ( nessusUserName = "nessus_scan_user" - passwordLength = 22 - passwordCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" defaultPostgresPort = "5432" ) @@ -48,23 +46,15 @@ type nessusSecret struct { DBName string `json:"dbname"` } -func randomPassword(n int) (string, error) { - if n <= 0 { - return "", errors.New("password length must be > 0") - } - - max := big.NewInt(int64(len(passwordCharset))) - out := make([]byte, n) +func randomHexPassword() (string, error) { + b := make([]byte, 16) // 16 bytes (32 hex chars) - for i := 0; i < n; i++ { - x, err := rand.Int(rand.Reader, max) - if err != nil { - return "", fmt.Errorf("rand.Int: %w", err) - } - out[i] = passwordCharset[x.Int64()] + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("failed to read random bytes: %w", err) } - return string(out), nil + s := hex.EncodeToString(b) // lowercase hex + return s[:32], nil // 32 hex chars (16 bytes) } func parseDSN(dsn string) (*dbConnInfo, error) { @@ -234,11 +224,14 @@ func main() { } var password string - if existingSecret != nil && existingSecret.Password != "" { + if existingSecret != nil { + if existingSecret.Password == "" { + log.Fatal("Malformed secret with an empty password. Please manually verify the secret and re-run.") + } password = existingSecret.Password log.Printf("Reusing existing password from secret %q.", secretName) } else { - password, err = randomPassword(passwordLength) + password, err = randomHexPassword() if err != nil { log.Fatalf("Error generating random password: %v", err) } From bddb5a7f629a97c4bf103f4f537a4904fbaee88c Mon Sep 17 00:00:00 2001 From: Andrew Cremins Date: Mon, 22 Dec 2025 12:15:29 -0800 Subject: [PATCH 4/4] Update readme --- cmd/rds-init/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/cmd/rds-init/README.md b/cmd/rds-init/README.md index 0c86b21..a12627e 100644 --- a/cmd/rds-init/README.md +++ b/cmd/rds-init/README.md @@ -44,7 +44,6 @@ After configuring the Nessus user, the tool grants `rds_iam` to the currently au ## When to Run - **Initial provisioning** — Run after creating a new RDS instance -- **Password rotation** — Run after manually updating the password in the `{db-identifier}_nessus` Secrets Manager secret to sync it to the database - **Safe to rerun on failure** — On success password auth will be disabled for the admin user. As this tool requires password auth, subsequent runs will not be possible. If the script exits unsuccessfully before disabling password auth, it is safe to execute multiple times. ## Prerequisites