From dd39db4584b38889c67c1f416097dd2397dcf5de Mon Sep 17 00:00:00 2001 From: Mike Reeves Date: Fri, 17 Apr 2026 18:59:39 -0400 Subject: [PATCH 1/4] Drop so_telegraf_trim cron.absent tombstone feature/postgres never shipped the original cron.present, so this cleanup state is a no-op on every fresh install. The script itself stays on disk for emergency use. --- salt/postgres/enabled.sls | 9 --------- 1 file changed, 9 deletions(-) diff --git a/salt/postgres/enabled.sls b/salt/postgres/enabled.sls index b6a51580f..52b6440e8 100644 --- a/salt/postgres/enabled.sls +++ b/salt/postgres/enabled.sls @@ -80,15 +80,6 @@ delete_so-postgres_so-status.disabled: - name: /opt/so/conf/so-status/so-status.conf - regex: ^so-postgres$ -# Retention is now handled by pg_partman (hourly maintenance via pg_cron -# scheduled from postgres/telegraf_users.sls). The so-telegraf-trim script -# stays on disk for manual/emergency use but is no longer scheduled. -so_telegraf_trim: - cron.absent: - - name: /usr/sbin/so-telegraf-trim >> /opt/so/log/postgres/telegraf-trim.log 2>&1 - - identifier: so_telegraf_trim - - user: root - {% else %} {{sls}}_state_not_allowed: From f3181b204a1c02a42ebc76e74d0db9fb6ed75f8c Mon Sep 17 00:00:00 2001 From: Mike Reeves Date: Fri, 17 Apr 2026 19:06:16 -0400 Subject: [PATCH 2/4] Remove so-telegraf-trim and update retention description pg_partman drops old partitions hourly; row-DELETE retention is obsolete and a confusing emergency fallback on partitioned tables. --- salt/postgres/soc_postgres.yaml | 2 +- salt/postgres/tools/sbin/so-telegraf-trim | 103 ---------------------- 2 files changed, 1 insertion(+), 104 deletions(-) delete mode 100644 salt/postgres/tools/sbin/so-telegraf-trim diff --git a/salt/postgres/soc_postgres.yaml b/salt/postgres/soc_postgres.yaml index 167772e3f..8b4e22921 100644 --- a/salt/postgres/soc_postgres.yaml +++ b/salt/postgres/soc_postgres.yaml @@ -1,7 +1,7 @@ postgres: telegraf: retention_days: - description: Number of days of Telegraf metrics to keep in the so_telegraf database. Older rows are deleted nightly by so-telegraf-trim. + description: Number of days of Telegraf metrics to keep in the so_telegraf database. Older partitions are dropped hourly by pg_partman. forcedType: int advanced: True helpLink: influxdb diff --git a/salt/postgres/tools/sbin/so-telegraf-trim b/salt/postgres/tools/sbin/so-telegraf-trim deleted file mode 100644 index 664469d0c..000000000 --- a/salt/postgres/tools/sbin/so-telegraf-trim +++ /dev/null @@ -1,103 +0,0 @@ -#!/bin/bash - -# 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. - -# Deletes Telegraf metric rows older than the configured retention window from -# every minion schema in the so_telegraf database. Intended to run daily from -# cron. Retention comes from pillar (postgres.telegraf.retention_days), -# defaulting to 14 days. An explicit --days argument overrides the pillar. - -. /usr/sbin/so-common - -usage() { - cat </dev/null) -fi -if ! [[ "$DAYS" =~ ^[0-9]+$ ]] || [ "$DAYS" -lt 1 ]; then - DAYS=14 -fi - -log() { - echo "$(date '+%Y-%m-%d %H:%M:%S') so-telegraf-trim: $*" -} - -so_psql() { - docker exec so-postgres psql -U postgres -d so_telegraf -At -F $'\t' "$@" -} - -if ! docker exec so-postgres psql -U postgres -lqt 2>/dev/null | cut -d\| -f1 | grep -qw so_telegraf; then - log "Database so_telegraf not present; nothing to trim." - exit 0 -fi - -log "Trimming rows older than ${DAYS} days (dry_run=${DRY_RUN})." - -TOTAL_DELETED=0 - -# Every metric table in the shared telegraf schema has a 'time' column. -# Tag tables (_tag) don't, so filtering on the column presence is -# enough to scope the trim to metric tables only. -ROWS=$(so_psql -c " - SELECT table_schema || '.' || table_name - FROM information_schema.columns - WHERE column_name = 'time' - AND data_type IN ('timestamp with time zone', 'timestamp without time zone') - AND table_schema = 'telegraf' - ORDER BY 1;") - -if [ -z "$ROWS" ]; then - log "No telegraf metric tables found." - exit 0 -fi - -for qualified in $ROWS; do - if [ "$DRY_RUN" -eq 1 ]; then - count=$(so_psql -c "SELECT count(*) FROM \"${qualified%.*}\".\"${qualified#*.}\" WHERE time < now() - interval '${DAYS} days';") - log "would delete ${count:-0} rows from ${qualified}" - else - # RETURNING count via a CTE so we can log how much was trimmed per table - deleted=$(so_psql -c " - WITH d AS ( - DELETE FROM \"${qualified%.*}\".\"${qualified#*.}\" - WHERE time < now() - interval '${DAYS} days' - RETURNING 1 - ) - SELECT count(*) FROM d;") - deleted=${deleted:-0} - TOTAL_DELETED=$((TOTAL_DELETED + deleted)) - [ "$deleted" -gt 0 ] && log "deleted ${deleted} rows from ${qualified}" - fi -done - -if [ "$DRY_RUN" -eq 0 ]; then - log "Trim complete. Total rows deleted: ${TOTAL_DELETED}." -fi From 3f46caaf0285625f3ddd1f66964cb67da45b412b Mon Sep 17 00:00:00 2001 From: Mike Reeves Date: Fri, 17 Apr 2026 19:10:07 -0400 Subject: [PATCH 3/4] Revoke PUBLIC CONNECT on securityonion database Per-minion telegraf roles inherit CONNECT via PUBLIC by default and could open sessions to the SOC database (though they have no readable grants inside). Close the soft edge by revoking PUBLIC's CONNECT and re-granting it to so_postgres only. --- salt/postgres/files/init-users.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/salt/postgres/files/init-users.sh b/salt/postgres/files/init-users.sh index e1be5df19..79387adaa 100644 --- a/salt/postgres/files/init-users.sh +++ b/salt/postgres/files/init-users.sh @@ -15,6 +15,12 @@ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-E END \$\$; GRANT ALL PRIVILEGES ON DATABASE "$POSTGRES_DB" TO "$SO_POSTGRES_USER"; + -- Lock the SOC database down at the connect layer; PUBLIC gets CONNECT + -- by default, which would let per-minion telegraf roles open sessions + -- here. They have no schema/table grants inside so reads fail, but + -- revoking CONNECT closes the soft edge entirely. + REVOKE CONNECT ON DATABASE "$POSTGRES_DB" FROM PUBLIC; + GRANT CONNECT ON DATABASE "$POSTGRES_DB" TO "$SO_POSTGRES_USER"; EOSQL # Bootstrap the Telegraf metrics database. Per-minion roles + schemas are From 8225d41661b00f878b5a77c73f34b5a921879214 Mon Sep 17 00:00:00 2001 From: Mike Reeves Date: Mon, 20 Apr 2026 12:10:05 -0400 Subject: [PATCH 4/4] 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'}) %}