-
Notifications
You must be signed in to change notification settings - Fork 0
Add a tool to initialize new RDS databases #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| # 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 <postgres-dsn> | ||
| ``` | ||
|
|
||
| ### 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 | ||
| - **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 | ||
|
|
||
| - 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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,300 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "context" | ||
| "crypto/rand" | ||
| "encoding/hex" | ||
| "encoding/json" | ||
| "errors" | ||
| "fmt" | ||
| "log" | ||
| "net/url" | ||
| "os" | ||
| "strconv" | ||
| "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" | ||
| defaultPostgresPort = "5432" | ||
| ) | ||
|
|
||
| type dbConnInfo struct { | ||
| Host string | ||
| Port int | ||
| 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 randomHexPassword() (string, error) { | ||
| b := make([]byte, 16) // 16 bytes (32 hex chars) | ||
|
|
||
| if _, err := rand.Read(b); err != nil { | ||
| return "", fmt.Errorf("failed to read random bytes: %w", err) | ||
| } | ||
|
|
||
| s := hex.EncodeToString(b) // lowercase hex | ||
| return s[:32], nil // 32 hex chars (16 bytes) | ||
| } | ||
|
|
||
| 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() | ||
|
|
||
| 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 | ||
| 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 <postgres-dsn>", 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) | ||
|
|
||
| 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, connInfo.Port, 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 { | ||
| 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 = randomHexPassword() | ||
| 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: connInfo.Port, | ||
| 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) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.