0Pricing
DevOps Bootcamp · Lesson

Static Analysis and Auditing with ShellCheck

Integrate ShellCheck into a security gate and interpret its findings to harden every script.

Static Analysis and Auditing with ShellCheck is a free DevOps Bootcamp lesson on CoddyKit — lesson 4 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the DevOps Bootcamp learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

What Is ShellCheck and Why It Matters

ShellCheck is an open-source static analysis tool for shell scripts. It parses your Bash (and POSIX sh, dash, ksh) source without executing it and reports bugs, unsafe constructs, portability issues, and style problems — each tagged with a unique rule code like SC2086.

In a security-hardened pipeline, ShellCheck acts as a mandatory gate: no script ships until it passes. This matters because:

  • Many shell vulnerabilities (word splitting, injection, unquoted expansion) are invisible during happy-path testing but trigger under attacker-controlled input.
  • ShellCheck catches these classes of bugs before runtime, at zero cost.
  • It documents why each pattern is dangerous, making your team more aware over time.

Install it on any system:

# Debian / Ubuntu
sudo apt-get install shellcheck

# macOS (Homebrew)
brew install shellcheck

# From source via Cabal (any platform)
cabal update && cabal install ShellCheck

# Verify
shellcheck --version

Running ShellCheck for the First Time

The simplest invocation is shellcheck <script>. ShellCheck reads the shebang line to determine the shell dialect and then emits findings to stdout.

Each finding includes:

  • File and line number — exact location
  • Severityerror, warning, info, or style
  • SC code — stable rule identifier you can look up or suppress
  • Human explanation — tells you what is wrong and often how to fix it

Run the script below and observe the output ShellCheck would produce:

#!/usr/bin/env bash
# demo_bad.sh — intentionally flawed for ShellCheck demonstration

FILE=$1

if [ $FILE == '' ]; then
  echo "No file given"
fi

cat $FILE | grep 'error' | wc -l

Interpreting ShellCheck Output and SC Codes

For the script in the previous scene, ShellCheck would emit findings like:

  • SC2086 (warning) — Double quote to prevent globbing and word splitting — on $FILE in [ $FILE == '' ] and in cat $FILE.
  • SC2039 / SC3010 (info) — == in [ ] is a bashism; use = for POSIX.
  • SC2002 (style) — Useless cat. Consider cmd < file instead of cat file | cmd.

Each SC code maps to a wiki page at https://www.shellcheck.net/wiki/SCxxxx with rationale and a corrected example.

The corrected version of that script:

#!/usr/bin/env bash
# demo_fixed.sh — ShellCheck-clean

FILE="$1"

if [ -z "$FILE" ]; then
  echo "No file given" >&2
  exit 1
fi

grep -c 'error' "$FILE"

Severity Levels and What to Act On

ShellCheck classifies every finding by severity. In a security gate you should treat them as follows:

  • error — Almost certainly a bug or security hole. Block the build. Fix immediately. Example: SC2148 missing shebang, SC2070 unquoted $?.
  • warning — High-risk pattern that is often exploitable. Block the build. Fix or explicitly justify suppression. Example: SC2086 unquoted variable.
  • info — Likely correct today but fragile or non-portable. Fix in the same PR unless ruled out of scope.
  • style — Cosmetic / POSIX preference. Recommended but optional in a pure Bash codebase.

Use --severity=warning to exit non-zero only on warnings and above — the standard security gate threshold:

#!/usr/bin/env bash
# gate.sh — fail CI on errors and warnings only
shellcheck --severity=warning scripts/*.sh
echo "ShellCheck exit code: $?"

Integrating ShellCheck as a CI Security Gate

A security gate is only useful when it is mandatory and automated. The pattern below wraps ShellCheck in a CI step that:

  • Finds every .sh file in the repository.
  • Runs ShellCheck with --severity=warning and machine-readable JSON output.
  • Fails the pipeline (exit 1) if any finding exists.
  • Prints a summary so engineers can act on findings without leaving the CI log.

Drop this file into your repo and call it from your CI pipeline (GitHub Actions, Jenkins, GitLab CI, etc.):

#!/usr/bin/env bash
# ci/shellcheck_gate.sh
set -euo pipefail

SCRIPTS=$(find . -name '*.sh' -not -path './.git/*')
FAILED=0

for script in $SCRIPTS; do
  echo "==> Checking: $script"
  if ! shellcheck --severity=warning --format=tty "$script"; then
    FAILED=1
  fi
done

if [ "$FAILED" -eq 1 ]; then
  echo "[GATE] ShellCheck found warnings or errors. Build blocked." >&2
  exit 1
fi

echo "[GATE] All scripts passed ShellCheck."

The SC2086 Family: Unquoted Variable Expansions

SC2086 is the most common ShellCheck finding and one of the most exploited shell vulnerabilities: unquoted variable expansions.

When a variable is not double-quoted, the shell performs word splitting (splits on IFS) and glob expansion on its value. An attacker who controls the variable can inject extra arguments, trigger filesystem traversal, or cause commands to receive unexpected operands.

Classic dangerous pattern:

#!/usr/bin/env bash
# Attacker sets: FILENAME="important.txt /etc/passwd"
FILENAME="$1"

# UNSAFE — word splitting turns this into two args
rm $FILENAME

# SAFE — double quotes prevent splitting
rm "$FILENAME"

# Arrays are the right tool for lists
FILES=("$@")
rm -- "${FILES[@]}"

Detecting Command Injection Risk with SC2046 and SC2035

Two less-known but critical rules address command injection via subshell output:

  • SC2046Quote this to prevent word splitting / glob inside $(…). If the output of a subshell is used unquoted, any whitespace or glob character in the output becomes a shell token.
  • SC2035Use ./*.sh instead of *.sh to avoid filenames starting with - being interpreted as options (a classic argument injection vector).

Concrete exploitation scenario and fix:

#!/usr/bin/env bash
# SC2046 example — output of find fed unquoted to chmod
# If a filename contains spaces, extra arguments appear

# UNSAFE
chmod 600 $(find /secrets -name '*.key')

# SAFE — use a while-read loop or xargs with -0
find /secrets -name '*.key' -print0 \
  | xargs -0 chmod 600

# SC2035 example
# UNSAFE — a file named '-rf' would be passed as an option
rm *.sh

# SAFE
rm -- ./*.sh

Using the JSON Output Format for Automation

ShellCheck supports multiple output formats via --format:

  • tty (default) — human-readable terminal output
  • json — machine-readable; ideal for dashboards, custom blockers, or uploading to SAST platforms
  • gcc — compatible with tools that parse GCC error format (IDEs, Vim/Emacs)
  • checkstyle — XML format consumed by Jenkins Checkstyle plugin

The JSON format lets you write automated policies, for example blocking on specific SC codes only or aggregating findings across a large codebase into a security report.

#!/usr/bin/env bash
# Emit JSON and filter for only error-severity findings using jq
shellcheck --format=json scripts/deploy.sh \
  | jq '[.[] | select(.level == "error")]'

# Count distinct SC codes across all scripts
find . -name '*.sh' -print0 \
  | xargs -0 shellcheck --format=json 2>/dev/null \
  | jq '[.[] | .code] | group_by(.) | map({code: .[0], count: length}) | sort_by(-.count)'

Suppressing False Positives Correctly

Blanket disabling of ShellCheck defeats its purpose. The correct approach is targeted, documented suppression that affects only the exact line or block where the finding is genuinely inapplicable.

Three suppression mechanisms:

  • Inline disable# shellcheck disable=SC2086 on the line above the offending code. Affects only that line.
  • Block disable/enable — wrap a section with # shellcheck disable=… and # shellcheck enable=….
  • File-level directive — place # shellcheck disable=… at the top of the file (rarely justified; document why).

Every suppression must include a comment explaining why the finding is a false positive:

#!/usr/bin/env bash
# deploy.sh

# Legitimate suppression: $DEPLOY_ARGS is intentionally word-split
# because it is a pre-validated list of flags from a trusted config file.
# shellcheck disable=SC2086
exec deploy-tool $DEPLOY_ARGS

# Block suppression for a section that generates dynamic code
# shellcheck disable=SC2016
VARS='$HOME $PATH $USER'
echo "Unexpanded vars: $VARS"
# shellcheck enable=SC2016

Configuring ShellCheck via .shellcheckrc

For project-wide settings, ShellCheck reads .shellcheckrc from the script's directory upward to /. This lets you avoid repeating flags on every invocation and keeps gate scripts simple.

Useful directives in .shellcheckrc:

  • shell=bash — override dialect detection (useful for files without shebangs)
  • enable=all — activate optional checks (e.g., avoid-nullary-conditions, require-variable-braces)
  • disable=SC2059 — project-wide suppression for a justified exception
  • external-sources=true — follow and check source / . directives
# .shellcheckrc — project root
shell=bash
enable=all
external-sources=true

# SC2312: consider invoking this command separately to avoid masking its
# return value — suppressed project-wide because we use set -e.
# Rationale: errexit already aborts on failure; masking risk is mitigated.
disable=SC2312

End-to-End Hardened Script: Before and After

The most effective way to internalize ShellCheck findings is to refactor a realistic script from a failing state to a clean, hardened one. The script below backs up a directory and was written without security in mind. It fails ShellCheck on at least five distinct rules.

Study both versions. The after version passes shellcheck --severity=warning with no suppression directives and is significantly safer under attacker-controlled input:

#!/usr/bin/env bash
# BEFORE — multiple ShellCheck violations
DEST=$1
SRC=$2
DATE=`date +%Y%m%d`

if [ ! -d $DEST ]; then
  mkdir $DEST
fi

cp -r $SRC $DEST/$DATE
echo Done


#!/usr/bin/env bash
# AFTER — ShellCheck-clean and hardened
set -euo pipefail

DEST="${1:?Usage: backup.sh <dest> <src>}"
SRC="${2:?Usage: backup.sh <dest> <src>}"
DATE=$(date +%Y%m%d)

if [ ! -d "$DEST" ]; then
  mkdir -p -- "$DEST"
fi

cp -r -- "$SRC" "$DEST/$DATE"
echo 'Done' >&2

Knowledge Check: ShellCheck in a Security Gate

Test your understanding of ShellCheck's role as a security gate.

Recap: Static Analysis as a Security Gate

In this lesson you learned how to make ShellCheck a mandatory security gate in your Bash workflow:

  • ShellCheck performs static analysis without executing your scripts, catching quoting errors, injection risks, and unsafe patterns before runtime.
  • Every finding carries a SC code (e.g., SC2086) linked to detailed documentation and fix guidance.
  • The severity laddererror, warning, info, style — lets you calibrate the gate: --severity=warning is the recommended security threshold.
  • Use machine-readable output (--format=json) to automate reporting, trend tracking, and SAST integration.
  • Suppress sparingly: always target a single line, always document why in a comment, never suppress globally unless justified by .shellcheckrc.
  • Pair ShellCheck with set -euo pipefail, explicit quoting, -- argument terminators, and input validation for defence in depth.

A script that passes ShellCheck is not automatically secure — but a script that fails ShellCheck should never reach production.

Frequently asked questions

Is the “Static Analysis and Auditing with ShellCheck” lesson free?

Yes — the full text of “Static Analysis and Auditing with ShellCheck” is free to read here on the web, and the DevOps Bootcamp course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the DevOps Bootcamp course, upgrade to CoddyKit PRO.

What will I learn in “Static Analysis and Auditing with ShellCheck”?

Integrate ShellCheck into a security gate and interpret its findings to harden every script. You practise DevOps Bootcamp with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start DevOps Bootcamp?

No prior experience is required. DevOps Bootcamp on CoddyKit is structured for beginners through advanced learners; this is — lesson 4 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Static Analysis and Auditing with ShellCheck” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this DevOps Bootcamp lesson?

Yes. Every DevOps Bootcamp lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. Preventing Command and Argument Injection
  2. Secure Secret Handling and Environment Hygiene
  3. Least-Privilege Execution and sudo Discipline
  4. Static Analysis and Auditing with ShellCheck
← Back to DevOps Bootcamp