Skip to content

09 · Security Hardening for Production Scripts

Production scripts run unattended, often with elevated access to real data. This module turns Level 3's security considerations into a concrete production checklist: where secrets belong, how to run with the least privilege a script actually needs, and a hardening audit you can run against any script before it ships.

Where secrets do NOT belong

# BAD — visible in shell history and any `ps aux` listing while it runs
mysql -u root -pSuperSecret123 mydb

# BAD — committed to version control forever, even if later removed
API_KEY="sk_live_abc123"

Environment variables: better, but still leak further than you'd expect

export API_KEY="sk_live_abc123"
some_command      # API_KEY is visible to some_command and anything IT spawns,
                   # and readable via /proc/<pid>/environ by the same user or root

Secret files: the standard production pattern

install -m 600 -o svcacct -g svcacct api_key.secret /run/secrets/api_key   # owner-only, no group/world access

read_secret() {
    local path="$1"
    [[ -f "$path" ]] || { echo "secret file '$path' not found" >&2; return 1; }
    local perms
    perms=$(stat -c '%a' "$path" 2>/dev/null || stat -f '%Lp' "$path")
    if [[ "$perms" != "600" && "$perms" != "400" ]]; then
        echo "refusing to read '$path' — permissions are $perms, expected 600/400" >&2
        return 1
    fi
    cat "$path"
}

api_key=$(read_secret /run/secrets/api_key)

Secrets managers (Vault-style, tool-agnostic)

# most secrets managers boil down to: fetch at runtime, never persist longer than needed
api_key=$(vault kv get -field=api_key secret/myapp)      # HashiCorp Vault CLI

# or a cloud secrets manager:
api_key=$(aws secretsmanager get-secret-value --secret-id myapp/api_key --query SecretString --output text)

curl -H "Authorization: Bearer ${api_key}" https://api.example.com
unset api_key    # scrub it from this shell's variable table once you're done

Keeping secrets out of logs and traces

set +x    # NEVER trace around secret-handling code — `-x` prints argument values verbatim

redact() {
    # strip anything that looks like a bearer token or "token=..." before logging a line
    echo "$1" | sed -E 's/(Bearer|token=)[A-Za-z0-9._-]+/\1[REDACTED]/g'
}

Least-privilege execution: dedicated service accounts

sudo useradd -r -s /usr/sbin/nologin deploy_svc
sudo install -o deploy_svc -g deploy_svc -m 700 -d /opt/myapp

# run the script as that account instead of root, even during manual testing
sudo -u deploy_svc /opt/myapp/bin/deploy.sh

Dropping privileges inside a script that must start as root

#!/usr/bin/env bash
set -euo pipefail

if [[ "$(id -u)" -eq 0 ]]; then
    # exec replaces this process — everything after this line runs as deploy_svc, not root
    exec setpriv --reuid=deploy_svc --regid=deploy_svc --init-groups "$0" "$@"
fi

echo "running as: $(whoami)"

systemd sandboxing directives (defense in depth for services)

[Service]
User=deploy_svc
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/opt/myapp/data
CapabilityBoundingSet=

Each directive removes something the service doesn't need: NoNewPrivileges blocks escalation via setuid binaries, ProtectSystem=strict makes the whole filesystem read-only except explicit ReadWritePaths, and an empty CapabilityBoundingSet strips every Linux capability — so even a fully compromised process is boxed in.

A hardening checklist script

#!/usr/bin/env bash
# harden_check.sh — audit a script for common production hardening gaps
set -euo pipefail

target="$1"
issues=0

check() {
    local description="$1"; shift
    if "$@"; then
        echo "  [ok]   $description"
    else
        echo "  [FAIL] $description"
        issues=$((issues + 1))
    fi
}

has_strict_mode()      { grep -q 'set -euo pipefail' "$target"; }
not_world_writable() {
    local perms
    perms=$(stat -c '%a' "$target" 2>/dev/null || stat -f '%Lp' "$target")
    [[ "$perms" != *2 ]]
}
no_hardcoded_secrets()  { ! grep -qiE '(password|api_key)=[^$]' "$target"; }
no_eval_calls()             { ! grep -q 'eval ' "$target"; }

echo "hardening check: $target"
check "has 'set -euo pipefail'"                has_strict_mode
check "not world-writable"                        not_world_writable
check "no hardcoded password=/api_key="  no_hardcoded_secrets
check "no bare eval calls"                        no_eval_calls

echo "$issues issue(s) found"
exit "$issues"

Notice the checklist script itself follows its own rules — no eval, no hardcoded secrets, set -euo pipefail at the top — a hardening tool that fails its own audit isn't trustworthy.

How It Actually Works

A hardening checklist script (checking file permissions, SUID bits, open ports) works by querying kernel-maintained metadata directly rather than inferring anything: file mode bits come from stat(2), open listening ports come from walking /proc/net/tcp (or calling into the same data via ss/netstat, which read that same kernel table), and the SUID bit specifically tells the kernel to run a binary with the file owner's effective UID rather than the invoking user's — which is exactly the mechanism sudo and passwd themselves rely on, and exactly why an unexpected SUID bit on the wrong binary is a genuine privilege-escalation path rather than a cosmetic issue.

Running a script with least privilege (dropping from root as early as possible, e.g. via su/setpriv/dedicated service users) matters because a process's effective UID is checked by the kernel on essentially every security-relevant syscall (open, chmod, kill targeting another user's process, binding to privileged ports below 1024) — a script that never needed root for 95% of its work but stays root the whole time means every line of that script, including any injected or buggy command, executes with full kernel-level permission to bypass file ownership checks.

Sanitizing input before it reaches eval, xargs, or a subshell isn't a style preference — it's addressing the exact mechanism covered in the security-considerations module: the bash parser cannot tell trusted script text from untrusted data once they're concatenated into the same string that later gets re-parsed, so hardening here means never letting untrusted bytes reach a second parsing pass.

Cheat sheet

Practice Why
never hardcode secrets in source leaks via version control, ps, logs
secret files, mode 600, owned by the service account standard least-exposure pattern
secrets managers (Vault, cloud KMS) short-lived, centrally audited credentials
set +x around secret handling prevents -x tracing from leaking values
dedicated non-root service account limits blast radius of a compromised script
setpriv/sudo -u to drop privileges run as root only as long as strictly necessary
systemd sandboxing directives defense in depth even if the script is compromised
a hardening-check script in CI catches regressions automatically, not just at review time

Exercise

Run (or adapt) a hardening-check script like the one above against Level 1's backup.sh and Level 3's taskctl. Fix any findings — in particular, confirm neither script would leak a secret if invoked with bash -x, and add a dedicated backup_svc system user that the backup script could run as instead of your personal account.