From 8225d41661b00f878b5a77c73f34b5a921879214 Mon Sep 17 00:00:00 2001 From: Mike Reeves Date: Mon, 20 Apr 2026 12:10:05 -0400 Subject: [PATCH] Harden postgres secrets, TLS enforcement, and admin tooling - Deliver postgres super and app passwords via mounted 0600 secret files (POSTGRES_PASSWORD_FILE, SO_POSTGRES_PASS_FILE) instead of plaintext env vars visible in docker inspect output - Mount a managed pg_hba.conf that only allows local trust and hostssl scram-sha-256 so TCP clients cannot negotiate cleartext sessions - Restrict postgres.key to 0400 and ensure owner/group 939 - Set umask 0077 on so-postgres-backup output - Validate host values in so-stats-show against [A-Za-z0-9._-] before SQL interpolation so a compromised minion cannot inject SQL via a tag value - Coerce postgres:telegraf:retention_days to int before rendering into SQL - Escape single quotes when rendering pillar values into postgresql.conf - Own postgres tooling in /usr/sbin as root:root so a container escape cannot rewrite admin scripts - Gate ES migration TLS verification on esVerifyCert (default false, matching the elastic module's existing pattern) --- salt/backup/tools/sbin/so-postgres-backup | 3 ++ salt/postgres/config.sls | 43 +++++++++++++++++++++-- salt/postgres/defaults.yaml | 1 + salt/postgres/enabled.sls | 17 ++++++--- salt/postgres/files/init-users.sh | 3 ++ salt/postgres/files/pg_hba.conf.jinja | 15 ++++++++ salt/postgres/files/postgresql.conf.jinja | 2 +- salt/postgres/ssl.sls | 3 +- salt/postgres/telegraf_users.sls | 2 +- salt/postgres/tools/sbin/so-stats-show | 13 +++++++ salt/soc/defaults.map.jinja | 2 +- 11 files changed, 94 insertions(+), 10 deletions(-) create mode 100644 salt/postgres/files/pg_hba.conf.jinja diff --git a/salt/backup/tools/sbin/so-postgres-backup b/salt/backup/tools/sbin/so-postgres-backup index c577f7b59..9db522336 100644 --- a/salt/backup/tools/sbin/so-postgres-backup +++ b/salt/backup/tools/sbin/so-postgres-backup @@ -7,6 +7,9 @@ . /usr/sbin/so-common +# Backups contain role password hashes and full chat data; keep them 0600. +umask 0077 + TODAY=$(date '+%Y_%m_%d') BACKUPDIR=/nsm/backup BACKUPFILE="$BACKUPDIR/so-postgres-backup-$TODAY.sql.gz" diff --git a/salt/postgres/config.sls b/salt/postgres/config.sls index 25bcf6ad3..76a926d59 100644 --- a/salt/postgres/config.sls +++ b/salt/postgres/config.sls @@ -15,6 +15,14 @@ postgresconfdir: - group: 939 - makedirs: True +postgressecretsdir: + file.directory: + - name: /opt/so/conf/postgres/secrets + - user: 939 + - group: 939 + - mode: 700 + - makedirs: True + postgresdatadir: file.directory: - name: /nsm/postgres @@ -54,12 +62,43 @@ postgresconf: - defaults: PGMERGED: {{ PGMERGED }} +postgreshba: + file.managed: + - name: /opt/so/conf/postgres/pg_hba.conf + - source: salt://postgres/files/pg_hba.conf.jinja + - user: 939 + - group: 939 + - mode: 640 + - template: jinja + +postgres_super_secret: + file.managed: + - name: /opt/so/conf/postgres/secrets/postgres_password + - user: 939 + - group: 939 + - mode: 600 + - contents_pillar: 'secrets:postgres_pass' + - show_changes: False + - require: + - file: postgressecretsdir + +postgres_app_secret: + file.managed: + - name: /opt/so/conf/postgres/secrets/so_postgres_pass + - user: 939 + - group: 939 + - mode: 600 + - contents_pillar: 'postgres:auth:users:so_postgres_user:pass' + - show_changes: False + - require: + - file: postgressecretsdir + postgres_sbin: file.recurse: - name: /usr/sbin - source: salt://postgres/tools/sbin - - user: 939 - - group: 939 + - user: root + - group: root - file_mode: 755 {% else %} diff --git a/salt/postgres/defaults.yaml b/salt/postgres/defaults.yaml index 30523cda9..7ad82f453 100644 --- a/salt/postgres/defaults.yaml +++ b/salt/postgres/defaults.yaml @@ -11,6 +11,7 @@ postgres: ssl_cert_file: '/conf/postgres.crt' ssl_key_file: '/conf/postgres.key' ssl_ca_file: '/conf/ca.crt' + hba_file: '/conf/pg_hba.conf' log_destination: 'stderr' logging_collector: 'off' log_min_messages: 'warning' diff --git a/salt/postgres/enabled.sls b/salt/postgres/enabled.sls index 52b6440e8..4c5838466 100644 --- a/salt/postgres/enabled.sls +++ b/salt/postgres/enabled.sls @@ -7,9 +7,7 @@ {% if sls.split('.')[0] in allowed_states %} {% from 'vars/globals.map.jinja' import GLOBALS %} {% from 'docker/docker.map.jinja' import DOCKERMERGED %} -{% set PASSWORD = salt['pillar.get']('secrets:postgres_pass') %} {% set SO_POSTGRES_USER = salt['pillar.get']('postgres:auth:users:so_postgres_user:user', 'so_postgres') %} -{% set SO_POSTGRES_PASS = salt['pillar.get']('postgres:auth:users:so_postgres_user:pass', '') %} include: - postgres.auth @@ -31,9 +29,12 @@ so-postgres: {% endfor %} - environment: - POSTGRES_DB=securityonion - - POSTGRES_PASSWORD={{ PASSWORD }} + # Passwords are delivered via mounted 0600 secret files, not plaintext env vars. + # The upstream postgres image resolves POSTGRES_PASSWORD_FILE; entrypoint.sh and + # init-users.sh resolve SO_POSTGRES_PASS_FILE the same way. + - POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password - SO_POSTGRES_USER={{ SO_POSTGRES_USER }} - - SO_POSTGRES_PASS={{ SO_POSTGRES_PASS }} + - SO_POSTGRES_PASS_FILE=/run/secrets/so_postgres_pass {% if DOCKERMERGED.containers['so-postgres'].extra_env %} {% for XTRAENV in DOCKERMERGED.containers['so-postgres'].extra_env %} - {{ XTRAENV }} @@ -43,6 +44,8 @@ so-postgres: - /opt/so/log/postgres/:/log:rw - /nsm/postgres:/var/lib/postgresql/data:rw - /opt/so/conf/postgres/postgresql.conf:/conf/postgresql.conf:ro + - /opt/so/conf/postgres/pg_hba.conf:/conf/pg_hba.conf:ro + - /opt/so/conf/postgres/secrets:/run/secrets:ro - /opt/so/conf/postgres/init/init-users.sh:/docker-entrypoint-initdb.d/init-users.sh:ro - /etc/pki/postgres.crt:/conf/postgres.crt:ro - /etc/pki/postgres.key:/conf/postgres.key:ro @@ -66,12 +69,18 @@ so-postgres: {% endif %} - watch: - file: postgresconf + - file: postgreshba - file: postgresinitusers + - file: postgres_super_secret + - file: postgres_app_secret - x509: postgres_crt - x509: postgres_key - require: - file: postgresconf + - file: postgreshba - file: postgresinitusers + - file: postgres_super_secret + - file: postgres_app_secret - x509: postgres_crt - x509: postgres_key diff --git a/salt/postgres/files/init-users.sh b/salt/postgres/files/init-users.sh index 79387adaa..e28b11f0f 100644 --- a/salt/postgres/files/init-users.sh +++ b/salt/postgres/files/init-users.sh @@ -4,6 +4,9 @@ set -e # Create or update application user for SOC platform access # This script runs on first database initialization via docker-entrypoint-initdb.d # The password is properly escaped to handle special characters +if [ -z "${SO_POSTGRES_PASS:-}" ] && [ -n "${SO_POSTGRES_PASS_FILE:-}" ] && [ -r "$SO_POSTGRES_PASS_FILE" ]; then + SO_POSTGRES_PASS="$(< "$SO_POSTGRES_PASS_FILE")" +fi psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL DO \$\$ BEGIN diff --git a/salt/postgres/files/pg_hba.conf.jinja b/salt/postgres/files/pg_hba.conf.jinja new file mode 100644 index 000000000..1d6a22a04 --- /dev/null +++ b/salt/postgres/files/pg_hba.conf.jinja @@ -0,0 +1,15 @@ +{# Copyright Security Onion Solutions LLC and/or licensed to Security Onion Solutions LLC under one + or more contributor license agreements. Licensed under the Elastic License 2.0 as shown at + https://securityonion.net/license; you may not use this file except in compliance with the + Elastic License 2.0. #} +# Managed by Salt — do not edit by hand. +# Client authentication config: only local (Unix socket) connections and TLS-wrapped TCP +# connections are accepted. Plain-text `host ...` lines are intentionally omitted so a +# misconfigured client with sslmode=disable cannot negotiate a cleartext session. + +# Local connections (Unix socket, container-internal) use peer/trust. +local all all trust + +# TCP connections MUST use TLS (hostssl) and authenticate with SCRAM. +hostssl all all 0.0.0.0/0 scram-sha-256 +hostssl all all ::/0 scram-sha-256 diff --git a/salt/postgres/files/postgresql.conf.jinja b/salt/postgres/files/postgresql.conf.jinja index 6833b3dbc..2ddc52a51 100644 --- a/salt/postgres/files/postgresql.conf.jinja +++ b/salt/postgres/files/postgresql.conf.jinja @@ -4,5 +4,5 @@ Elastic License 2.0. #} {% for key, value in PGMERGED.config.items() %} -{{ key }} = '{{ value }}' +{{ key }} = '{{ value | string | replace("'", "''") }}' {% endfor %} diff --git a/salt/postgres/ssl.sls b/salt/postgres/ssl.sls index ebd3ccbc9..4223ead34 100644 --- a/salt/postgres/ssl.sls +++ b/salt/postgres/ssl.sls @@ -42,7 +42,8 @@ postgresKeyperms: file.managed: - replace: False - name: /etc/pki/postgres.key - - mode: 640 + - mode: 400 + - user: 939 - group: 939 {% else %} diff --git a/salt/postgres/telegraf_users.sls b/salt/postgres/telegraf_users.sls index 4719d363a..cab65d8a8 100644 --- a/salt/postgres/telegraf_users.sls +++ b/salt/postgres/telegraf_users.sls @@ -119,7 +119,7 @@ postgres_telegraf_role_{{ u }}: # Reconcile partman retention from pillar. Runs after role/schema setup so # any partitioned parents Telegraf has already created get their retention # refreshed whenever postgres.telegraf.retention_days changes. -{% set retention = salt['pillar.get']('postgres:telegraf:retention_days', 14) %} +{% set retention = salt['pillar.get']('postgres:telegraf:retention_days', 14) | int %} postgres_telegraf_retention_reconcile: cmd.run: - name: | diff --git a/salt/postgres/tools/sbin/so-stats-show b/salt/postgres/tools/sbin/so-stats-show index bfc81887a..102b51ccd 100644 --- a/salt/postgres/tools/sbin/so-stats-show +++ b/salt/postgres/tools/sbin/so-stats-show @@ -42,6 +42,15 @@ esac FILTER_HOST="${1:-}" SCHEMA="telegraf" +# Host values are interpolated into SQL below. Hostnames are [A-Za-z0-9._-]; +# any other character in a tag value or CLI arg is rejected to prevent a +# stored-tag (or CLI) → SQL injection via a compromised Telegraf writer. +HOST_RE='^[A-Za-z0-9._-]+$' +if [ -n "$FILTER_HOST" ] && ! [[ "$FILTER_HOST" =~ $HOST_RE ]]; then + echo "Invalid host filter: $FILTER_HOST" >&2 + exit 1 +fi + so_psql() { docker exec so-postgres psql -U postgres -d so_telegraf -At -F $'\t' "$@" } @@ -78,6 +87,10 @@ print_metric() { } for host in $HOSTS; do + if ! [[ "$host" =~ $HOST_RE ]]; then + echo "Skipping host with invalid characters in tag value: $host" >&2 + continue + fi if [ -n "$FILTER_HOST" ] && [ "$host" != "$FILTER_HOST" ]; then continue fi diff --git a/salt/soc/defaults.map.jinja b/salt/soc/defaults.map.jinja index 46ae7e8fd..00a9604f6 100644 --- a/salt/soc/defaults.map.jinja +++ b/salt/soc/defaults.map.jinja @@ -26,7 +26,7 @@ {% if GLOBALS.postgres is defined and GLOBALS.postgres.auth is defined %} {% set PG_ADMIN_PASS = salt['pillar.get']('secrets:postgres_pass', '') %} -{% do SOCDEFAULTS.soc.config.server.modules.update({'postgres': {'hostUrl': GLOBALS.manager_ip, 'port': 5432, 'username': GLOBALS.postgres.auth.users.so_postgres_user.user, 'password': GLOBALS.postgres.auth.users.so_postgres_user.pass, 'adminUser': 'postgres', 'adminPassword': PG_ADMIN_PASS, 'dbname': 'securityonion', 'sslMode': 'require', 'assistantEnabled': true, 'esHostUrl': 'https://' ~ GLOBALS.manager_ip ~ ':9200', 'esUsername': GLOBALS.elasticsearch.auth.users.so_elastic_user.user, 'esPassword': GLOBALS.elasticsearch.auth.users.so_elastic_user.pass}}) %} +{% do SOCDEFAULTS.soc.config.server.modules.update({'postgres': {'hostUrl': GLOBALS.manager_ip, 'port': 5432, 'username': GLOBALS.postgres.auth.users.so_postgres_user.user, 'password': GLOBALS.postgres.auth.users.so_postgres_user.pass, 'adminUser': 'postgres', 'adminPassword': PG_ADMIN_PASS, 'dbname': 'securityonion', 'sslMode': 'require', 'assistantEnabled': true, 'esHostUrl': 'https://' ~ GLOBALS.manager_ip ~ ':9200', 'esUsername': GLOBALS.elasticsearch.auth.users.so_elastic_user.user, 'esPassword': GLOBALS.elasticsearch.auth.users.so_elastic_user.pass, 'esVerifyCert': false}}) %} {% endif %} {% do SOCDEFAULTS.soc.config.server.modules.influxdb.update({'hostUrl': 'https://' ~ GLOBALS.influxdb_host ~ ':8086'}) %}