BLOG // [DEVOPS] Bash and Security: Secure your scripts in DevOps
Bash and Security: Secure your scripts in DevOps

Bash and Security: Secure your scripts in DevOps

Guide to securing Bash scripts in DevOps: secrets management, input validation, shellcheck, permission control, injection prevention, and security best practices.

DEVOPS BASH

Bash and Security in DevOps

In the previous post, we learned how to manage cloud with CLI tools. Now, we’ll discuss security — a critical topic often overlooked when writing Bash scripts.

Bash scripts run automatically everywhere — from CI/CD pipelines to cron jobs on servers. But without proper security, they can become major vulnerabilities: leaking API keys, enabling injection attacks, or being exploited to gain system access.

This post will guide you through managing secrets, validating input, setting permissions correctly, and scanning scripts with shellcheck to build secure Bash scripts in DevOps.


Why secure scripts?

Common risks

RiskDescriptionImpact
Hardcoded secretsAPI keys, passwords in codeLeaked when sharing/repo
Command injectionUnvalidated input executed directlyServer compromised
Insecure permissionsWorld-executable scriptsAnyone can run them
Unquoted variables$var instead of "$var"Unexpected behavior

Best practices overview

  • Never hardcode secrets — use env vars or vault
  • Always quote variables"$var" instead of $var
  • Validate input — check format before using
  • Set restrictive permissionschmod 700 for scripts
  • Scan with shellcheck — catch potential bugs

Secrets management

Never hardcode secrets

1
2
3
4
5
6
7
8
# BAD — never do this
API_KEY="sk-1234567890abcdef"
DB_PASSWORD="mysecretpassword"
./deploy.sh "$API_KEY"

# GOOD — use environment variables
export API_KEY="${API_KEY:?API_KEY is required}"
./deploy.sh "$API_KEY"

Load from .env file

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Create .env file (don't commit to git)
cat > .env << 'EOF'
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
DATABASE_URL=postgres://user:pass@localhost:5432/mydb
EOF

# Protect the file
chmod 600 .env

# Load in script
if [[ -f ".env" ]]; then
    set -a  # Auto-export
    source .env
    set +a
else
    echo "ERROR: .env file not found" >&2
    exit 1
fi

# Require variables
: "${AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID is required}"
: "${DATABASE_URL:?DATABASE_URL is required}"

CI/CD secrets

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# GitLab CI
deploy:
  script:
    - bash deploy.sh
  variables:
    AWS_ACCESS_KEY_ID: $AWS_ACCESS_KEY_ID  # From CI/CD settings
    AWS_SECRET_ACCESS_KEY: $AWS_SECRET_ACCESS_KEY

# GitHub Actions
deploy:
  runs-on: ubuntu-latest
  steps:
    - name: Deploy
      env:
        AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
        AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
      run: bash deploy.sh

HashiCorp Vault

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Get secret from Vault
get_vault_secret() {
    local path="$1"
    local key="$2"

    local secret
    secret=$(vault kv get -field="$key" "$path" 2>/dev/null)
    if [[ -z "$secret" ]]; then
        echo "ERROR: Failed to get secret from $path/$key" >&2
        return 1
    fi
    echo "$secret"
}

# Usage
DB_PASS=$(get_vault_secret "secret/data/database" "password")

Input validation

Validate parameters

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Check argument count
if [[ $# -ne 2 ]]; then
    echo "Usage: $0 <hostname> <port>" >&2
    exit 1
fi

HOSTNAME="$1"
PORT="$2"

# Validate hostname (alphanumeric, dot, hyphen only)
if [[ ! "$HOSTNAME" =~ ^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$ ]]; then
    echo "ERROR: Invalid hostname: $HOSTNAME" >&2
    exit 1
fi

# Validate port (number 1-65535)
if [[ ! "$PORT" =~ ^[0-9]+$ ]] || (( PORT < 1 || PORT > 65535 )); then
    echo "ERROR: Invalid port: $PORT" >&2
    exit 1
fi

Sanitize filenames

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Validate filename — allow only safe characters
sanitize_filename() {
    local filename="$1"
    # Remove path traversal and dangerous chars
    filename=$(basename "$filename")
    if [[ "$filename" =~ [^a-zA-Z0-9._-] ]]; then
        echo "ERROR: Invalid filename: $filename" >&2
        return 1
    fi
    echo "$filename"
}

# Usage
SAFE_FILE=$(sanitize_filename "$USER_INPUT") || exit 1
cp "$SAFE_FILE" /backup/

Prevent command injection

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# BAD — injection vulnerability
run_command() {
    local user_input="$1"
    eval "echo $user_input"  # DANGEROUS!
}

# SAFE — validate before using
run_command_safe() {
    local user_input="$1"
    if [[ "$user_input" =~ ^[a-zA-Z0-9]+$ ]]; then
        echo "$user_input"
    else
        echo "ERROR: Invalid input" >&2
        return 1
    fi
}

Variable quoting

Always quote variables

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# When to quote?
# Always quote variable expansion to prevent word splitting and globbing

# BAD — unquoted
file=$1              # If $1 has spaces → break
rm $file             # Dangerous!
echo $var            # Can be globbed *

# GOOD — quoted
file="$1"
rm "$file"
echo "$var"

# Real-world example
process_file() {
    local filepath="$1"  # Always quote parameter expansion

    if [[ ! -f "$filepath" ]]; then
        echo "File not found: $filepath" >&2
        return 1
    fi

    # Command substitution should also be quoted
    local line_count
    line_count=$(wc -l < "$filepath")
    echo "File has $line_count lines"
}

When NOT to quote?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# When intentionally wanting word splitting
# Example: iterate through list
for file in *.log; do
    echo "Processing: $file"
done

# When using [[ ]] (bash-specific, safer than [ ])
if [[ $var == "pattern" ]]; then
    echo "Matched"
fi

File permissions

Set correct permissions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Script permission — owner only execute
chmod 700 script.sh
# Or
chmod u+x script.sh

# Secret files — owner read only
chmod 600 .env
chmod 600 ~/.ssh/id_rsa
chmod 600 ~/.aws/credentials

# Directory permissions
chmod 700 ~/.ssh
chmod 700 ~/.aws

Check permissions in script

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
check_permissions() {
    local file="$1"
    local expected_perm="$2"

    local actual_perm
    actual_perm=$(stat -f "%Lp" "$file" 2>/dev/null || stat -c "%a" "$file" 2>/dev/null)

    if [[ "$actual_perm" != "$expected_perm" ]]; then
        echo "ERROR: $file has permissions $actual_perm, expected $expected_perm" >&2
        echo "Fix with: chmod $expected_perm $file" >&2
        return 1
    fi
}

# Usage
check_permissions ".env" "600"
check_permissions "script.sh" "700"

Shellcheck

Installation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Debian/Ubuntu
apt-get install -y shellcheck

# CentOS/RHEL
yum install -y shellcheck

# macOS
brew install shellcheck

# Or download binary
# https://github.com/koalaman/shellcheck/releases

Usage

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Check a single file
shellcheck script.sh

# Check all scripts in directory
find . -name "*.sh" -exec shellcheck {} \;

# Output with severity levels
shellcheck -S warning script.sh

# Format output
shellcheck -f gcc script.sh
shellcheck -f json script.sh

Common errors

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# SC2086: Double quote to prevent globbing and word splitting
# BAD
echo $1
# GOOD
echo "$1"

# SC2046: Quote this to prevent word splitting
# BAD
cd $(dirname $0)
# GOOD
cd "$(dirname "$0")"

# SC2034: Variable appears unused
# WARNING — may be typo or forgotten to use

# SC2155: Declare and assign separately
# BAD
local output=$(command)
# GOOD
local output
output=$(command)

CI/CD integration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# GitHub Actions
lint:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - name: Install shellcheck
      run: sudo apt-get install -y shellcheck
    - name: Run shellcheck
      run: shellcheck -s bash scripts/*.sh

# GitLab CI
shellcheck:
  image: koalaman/shellcheck:stable
  script:
    - shellcheck -s bash scripts/*.sh

Hands-on: Secure deploy script

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
#!/usr/bin/env bash
# ==============================================================================
# Script : secure-deploy.sh
# Purpose: Deploy application with security best practices
# ==============================================================================

set -euo pipefail

# ===== SECURITY CHECKS =====

# Check script permissions
SCRIPT_PERMS=$(stat -f "%Lp" "$0" 2>/dev/null || stat -c "%a" "$0" 2>/dev/null)
if [[ "$SCRIPT_PERMS" != "700" && "$SCRIPT_PERMS" != "755" ]]; then
    echo "WARNING: Script has loose permissions ($SCRIPT_PERMS). Recommended: 700" >&2
fi

# ===== ENVIRONMENT SETUP =====

# Load secrets from .env
if [[ -f ".env" ]]; then
    set -a
    source .env
    set +a
else
    echo "ERROR: .env file not found" >&2
    exit 1
fi

# Validate required variables
for var in APP_NAME DEPLOY_ENV SSH_HOST SSH_KEY; do
    : "${!var:?ERROR: $var is not set in environment or .env}"
done

# Validate SSH key file
if [[ ! -f "$SSH_KEY" ]]; then
    echo "ERROR: SSH key not found: $SSH_KEY" >&2
    exit 1
fi

SSH_KEY_PERMS=$(stat -f "%Lp" "$SSH_KEY" 2>/dev/null || stat -c "%a" "$SSH_KEY" 2>/dev/null)
if [[ "$SSH_KEY_PERMS" != "600" ]]; then
    echo "ERROR: SSH key has insecure permissions ($SSH_KEY_PERMS). Expected: 600" >&2
    echo "Fix with: chmod 600 $SSH_KEY" >&2
    exit 1
fi

# ===== INPUT VALIDATION =====

DEPLOY_VERSION="${1:-}"
if [[ -z "$DEPLOY_VERSION" ]]; then
    echo "Usage: $0 <version>" >&2
    echo "Example: $0 1.2.3" >&2
    exit 1
fi

# Validate version format (semver-ish)
if [[ ! "$DEPLOY_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
    echo "ERROR: Invalid version format: $DEPLOY_VERSION (expected X.Y.Z)" >&2
    exit 1
fi

# ===== DEPLOYMENT =====

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
}

log "Starting deployment of $APP_NAME v$DEPLOY_VERSION to $DEPLOY_ENV"

# Deploy via SSH
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no "deploy@${SSH_HOST}" \
    "cd /opt/$APP_NAME && git pull && git checkout v$DEPLOY_VERSION && systemctl restart $APP_NAME"

log "Deployment completed successfully"

Implementation notes

  • Secrets management: In production, don’t use .env files — use dedicated secrets managers (Vault, AWS Secrets Manager, GCP Secret Manager). .env is only suitable for development.
  • Audit logging: Log all configuration changes and secrets access. Meet compliance requirements (SOC 2, PCI DSS).
  • Secret rotation: Rotate secrets periodically. Scripts should support reloading secrets without restart.
  • Minimal privileges: Create dedicated service accounts with minimal permissions for each script. Don’t use root unless necessary.
  • Code review: Always do security-focused reviews when reviewing Bash scripts. Check for hardcoded secrets, missing validation, and insecure patterns.

Conclusion

Bash and Security is a critical topic in DevOps. From securely managing secrets, validating input, quoting variables, to scanning with shellcheck — each step helps reduce security risks.

A secure script not only protects sensitive data but also prevents injection attacks and ensures system stability.

In the next post, we’ll wrap up the series with Bash Best Practices: shebang, set -euo pipefail, modular functions, logging, and testing with bats.

RESPONSES & DISCUSSION