Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions cmd/rds-init/README.md
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.
300 changes: 300 additions & 0 deletions cmd/rds-init/main.go
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)
}
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down