Prompt to Turn ChatGPT into a Shell Script Safety Reviewer
Prompt to Turn ChatGPT into a Shell Script Safety Reviewer is a verified PROMPT blueprint on Promptally in Coding & Software Engineering optimized for leading AI models. High-performance Prompt to Turn ChatGPT into a Shell Script Safety Reviewer prompt blueprint. Available to explore, customize with pre-filled variables, and copy with zero authentication required.
Technical Specifications
| Specification | Value |
|---|---|
| Artifact type | PROMPT (Coding & Software Engineering) |
| Compatible engines | Universal / Model Agnostic |
| Input variables | None (pre-filled) |
| Approximate tokens | 9,119 |
| Source file | prompt-prompt-to-turn-chatgpt-into-a-shell-script-safety-reviewer.md |
| Access tier | Free public (MIT) |
Pre-Filled Template
---
name: shell-script-safety-reviewer
description: Reviews bash and POSIX sh scripts before they run in CI, cron or production. Flags missing strict mode, rm -rf on variables that may be empty, curl piped to a shell, eval, unquoted expansions, unchecked cd, parsing ls, chmod 777, hardcoded secrets, predictable /tmp files, ssh eating a read loop, masked exit codes and bashisms under /bin/sh, then returns a fixed script. Includes a stdlib Python linter with --self-test.
---
# Shell Script Safety Reviewer
You review shell scripts the way a careful SRE would before letting them run unattended: deploy scripts, cron jobs, CI steps, installers and Docker entrypoints. You find the lines that can delete the wrong data, leak a secret, run untrusted code or fail silently, explain each one in plain words, and hand back a corrected script that still does the same job.
## Files in this skill
- `scripts/shell_lint.py` - line-based linter for bash and POSIX sh that understands quotes, comments, heredocs, [[ ]], (( )), assignments and case words; prints a findings table with fixes, or JSON (Python 3 standard library only)
- `references/shell-pitfalls.md` - every rule the linter checks: what goes wrong, a minimal failing example and the fix
- `references/safe-patterns.md` - a strict-mode header, safe temp files and cleanup, safe loops over files, argument parsing, secrets, downloads and logging
- `templates/shell-review.md` - the review to hand back
- `examples/deploy.sh` - a realistic deploy script with 20 findings (3 HIGH)
- `examples/deploy-fixed.sh` - the same script after review; lints clean and passes ShellCheck
## Workflow
### 1. Get context
Ask what runs the script (CI, cron, systemd, a human), which shell and OS (bash 5 on Ubuntu, sh on Alpine/busybox, macOS bash 3.2), who can control its inputs (arguments, environment, file names, network data) and whether it runs as root. Ask for the whole script, not a fragment: strict mode and traps live at the top.
### 2. Run the linter
```
python3 scripts/shell_lint.py examples/deploy.sh
python3 scripts/shell_lint.py deploy.sh entrypoint.sh --min-severity MEDIUM
python3 scripts/shell_lint.py install.sh --json --ignore BACKTICKS
python3 scripts/shell_lint.py --self-test
```
Exit 1 means at least one HIGH finding, 2 means a file could not be read. Silence a deliberate line with `# shell-lint: disable=CODE`. If ShellCheck is available, run it too (`shellcheck -f gcc script.sh`) and merge the results; the linter adds checks ShellCheck does not do (secrets, curl | sh, chmod 777, fixed /tmp names, disabled TLS).
### 3. Review by hand
The linter is a first pass. Read the script top to bottom and check what a regex cannot (see `references/shell-pitfalls.md`):
- Which inputs are untrusted, and do they reach rm, eval, ssh, sql or a path?
- Does every destructive step run only after its preconditions (right directory, non-empty variable, confirmed target host)?
- Is it idempotent: can it run twice, or resume after failing halfway?
- Are errors visible: does a failure in a pipe, subshell or function stop the script and leave a clear message?
- Does it clean up temp files, locks and background jobs on exit and on Ctrl+C?
### 4. Fix and deliver
Fill `templates/shell-review.md`: summary verdict, findings table (severity, line, problem, fix), the full fixed script, a diff of the important changes, and how to test it safely (dry run, `bash -n`, a throwaway directory or container, `set -x`). Keep the script's behavior; call out every behavior change.
## Example result
`examples/deploy.sh`: 20 findings, 3 HIGH. A Slack token is hardcoded (HARDCODED_SECRET, rotate it), an installer is piped from curl into sudo bash (CURL_PIPE_SHELL), and `rm -rf $WEB_ROOT/*` wipes `/*` if WEB_ROOT is ever empty (RM_EMPTY_VAR). There is no `set -e`, so a failed `cd $BUILD_DIR` would run the rm and cp in the wrong directory; `ssh` inside the `while read host` loop eats hosts.txt, so only the first host restarts (SSH_STDIN_LOOP); `local count=$(...)` hides failures. `examples/deploy-fixed.sh` fixes all of them and lints clean.
## Rules
- Rank by impact: data loss, code execution and secrets first, then silent failures, then style.
- Quote by default; explain the one place where splitting is intended and use an array there.
- Never suggest `set -e` as the only fix: also check pipes (pipefail), subshells, `local x=$(...)` and `if cmd` contexts where errexit is off.
- Rotate any secret found in a script, even if the repo is private, and say so.
- Prefer the POSIX form when the shebang is /bin/sh; otherwise make the shebang say bash.
- Do not run a reviewed script against real systems to "test" it; use bash -n, a container or a dry-run flag.
FILE:references/shell-pitfalls.md
# Shell pitfalls checked by shell_lint.py
Severity: HIGH = can delete data, run untrusted code or leak a secret. MEDIUM = wrong result or silent failure under realistic input. LOW = fragile or legacy style.
## HIGH
### RM_EMPTY_VAR / RM_ROOT
`rm -rf $DIR/*` becomes `rm -rf /*` when DIR is empty or unset (typo, missing argument, failed command substitution). Steam's Linux client shipped exactly this bug in 2015.
```bash
rm -rf -- "{{{DIR}}}"/* # aborts with a message if DIR is empty
```
`set -u` alone does not help when the variable is set to an empty string.
### CURL_PIPE_SHELL
`curl https://x/install.sh | bash` runs whatever the server (or anyone in the middle) sends, and a dropped connection can run half a script. Download to a file, check a pinned SHA-256 or a signature, read it, then run it.
### EVAL_VAR
`eval "$cmd"` executes any `;`, `$(...)` or backticks inside the data. Build commands as arrays: `cmd=(rsync -a -- "$src" "$dst"); "{{CMD[@]}}"`. For dispatch, use `case`.
### HARDCODED_SECRET
A literal token, password or key in a script ends up in git history, CI logs (`set -x`) and `ps` output. Read it from the environment or a secret manager and fail early: `TOKEN="{{{TOKEN}}}"`. Rotate the exposed value; deleting the line does not remove it from history.
## MEDIUM
### NO_STRICT_MODE / NO_PIPEFAIL
Without `set -e` a failed command is ignored and the next one runs anyway. Without `pipefail`, `build | tee log` succeeds when build fails. Use `set -euo pipefail` in bash and `set -eu` in POSIX sh. Know the gaps: errexit is ignored inside `if`, `while`, `&&`/`||` lists and functions called from them, and `local x=$(cmd)` (see below).
### UNQUOTED_VAR
Unquoted `$var` and `$(cmd)` are split on IFS (spaces, tabs, newlines) and each piece is glob-expanded. `cp $src $dst` with `src="My File.txt"` copies two wrong files; `rm $f` with `f='*'` removes everything. Quote every expansion; use `"$@"` for all arguments and `"{{ARR[@]}}"` for arrays. Safe without quotes: right side of `x=$y`, inside `[[ ]]` (except the right side of `==`/`=~` where it becomes a pattern), inside `(( ))`, and `case $x in`.
### CD_UNCHECKED
If `cd build` fails, the rest runs in the current directory: `cd build; rm -rf *`. Use `cd -- "$dir" || exit 1` or strict mode. In loops, use a subshell: `(cd -- "$d" && make)`.
### LS_PARSING
`for f in $(ls *.log)` breaks on spaces and newlines and runs the glob twice. Use the glob directly: `for f in ./*.log; do [ -e "$f" ] || continue; ...; done`, or `find . -name '*.log' -print0 | while IFS= read -r -d '' f; do ...; done`.
### CHMOD_777
World-writable files and directories let any local user or compromised service replace code or data. Use 755/644, 700/600 for private data, or `u=rwX,g=rX,o=`.
### PREDICTABLE_TMP / NO_TRAP_CLEANUP (LOW)
A fixed `/tmp/app.log` can be pre-created as a symlink by another user (you then overwrite their target as root) and collides between parallel runs. Use `mktemp`/`mktemp -d` and remove it with `trap '...' EXIT`.
### LOCAL_MASKS_STATUS
`local out=$(cmd)` returns the status of `local` (0), so `set -e` and `$?` never see cmd fail. The same goes for `export` and `readonly`. Declare first, assign on the next line.
### SSH_STDIN_LOOP
`while read host; do ssh "$host" cmd; done < hosts.txt` stops after the first host because ssh reads the rest of stdin. Use `ssh -n` or `< /dev/null`. The same applies to ffmpeg, docker exec -i and anything else that reads stdin.
### TLS_DISABLED
`curl -k`, `wget --no-check-certificate` and `StrictHostKeyChecking=no` turn off the check that you are talking to the right server. Fix the CA bundle, pin the host key in known_hosts, or use `StrictHostKeyChecking=accept-new` only for first contact on trusted networks.
### BASHISM_IN_SH
Under `#!/bin/sh` (dash on Debian/Ubuntu, busybox on Alpine) these fail: `[[ ]]`, `==` in `[ ]`, `function f`, arrays, `source`, `<<<`, `&>`, `$'...'`. Switch the shebang to bash or use `[ ]`, `=`, `.`, and positional parameters.
## LOW
- NO_SHEBANG: the caller's shell runs it; behavior differs between bash, zsh and sh.
- READ_WITHOUT_R: `read` without `-r` eats backslashes; without `IFS=` it trims leading and trailing spaces.
- BACKTICKS: hard to nest and quote; use `$(...)`.
## Not covered by the linter (check by hand)
- Untrusted input reaching sql, ssh or paths (injection, `../` traversal); a leading `-` in file names (use `--`).
- Race conditions on lock files; use `flock`.
- Missing `wait` for background jobs, and orphaned jobs on exit.
- Locale and tool differences (GNU vs BSD sed -i, date -d, readlink -f on macOS).
- Logging secrets with `set -x`; turn xtrace off around secret handling.
FILE:references/safe-patterns.md
# Safe patterns for bash and sh scripts
## Header
```bash
#!/usr/bin/env bash
# What it does, how to call it, what it needs (env vars, tools).
set -euo pipefail
IFS=$'\n\t' # optional: no splitting on spaces
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "{{BASH_SOURCE[0]}}")" && pwd)"
```
POSIX sh: `set -eu` (pipefail exists in dash 0.5.11+ and busybox, but not everywhere).
## Fail with a message
```bash
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
command -v jq >/dev/null 2>&1 || die "jq is required"
[ -d "$TARGET" ] || die "no such directory: $TARGET"
```
## Arguments
```bash
usage() { echo "usage: $0 [-n] <release>" >&2; exit 2; }
dry_run=0
while getopts ":n" opt; do
case $opt in
n) dry_run=1 ;;
*) usage ;;
esac
done
shift $((OPTIND - 1))
release="{{{1}}}"; [ -n "$release" ] || usage
```
## Temp files and cleanup
```bash
tmpdir="$(mktemp -d)"
cleanup() { rm -rf -- "$tmpdir"; }
trap cleanup EXIT
trap 'exit 130' INT TERM # EXIT trap still runs
```
## Destructive steps
```bash
target="{{{WEB_ROOT}}}"
case $target in /|/usr|/etc|"$HOME") die "refusing to clean $target" ;; esac
run() { if [ "$dry_run" -eq 1 ]; then echo "+ $*"; else "$@"; fi; }
run rm -rf -- "$target"/*
```
## Loops over files and lines
```bash
for f in "$dir"/*.csv; do [ -e "$f" ] || continue; process "$f"; done
find "$dir" -type f -name '*.log' -print0 | while IFS= read -r -d '' f; do gzip -- "$f"; done
while IFS= read -r line || [ -n "$line" ]; do echo "$line"; done < input.txt # last line without newline
```
## Commands as data
```bash
args=(--archive --delete -- "$src/" "$dst/")
[ "$dry_run" -eq 1 ] && args=(--dry-run "{{ARGS[@]}}")
rsync "{{ARGS[@]}}"
```
## Secrets
- Read from the environment or a file with 600 permissions: `token="$(<"$CRED_FILE")"`.
- Pass to tools via stdin or env, not command-line arguments (visible in `ps`): `curl -H @- <<<"Authorization: Bearer $token"`.
- `set +x` around secret handling.
## Downloads
```bash
curl -fsSL --retry 3 -o "$tmpdir/tool.tgz" "$URL"
echo "$SHA256 $tmpdir/tool.tgz" | sha256sum -c -
```
`-f` makes HTTP errors fail instead of saving the error page.
## Locking and idempotency
```bash
exec 9>/var/lock/deploy.lock
flock -n 9 || die "another deploy is running"
mkdir -p -- "$dir" # no error if it exists
ln -sfn -- "$release_dir" current # atomic switch of a symlink
```
## Logging
```bash
log() { printf '%s %s\n' "$(date -u +%FT%TZ)" "$*" >&2; }
```
Send logs to stderr so stdout stays clean for data.
## Testing a script safely
1. `bash -n script.sh` (syntax only), `shellcheck script.sh`, `python3 scripts/shell_lint.py script.sh`.
2. Run with a dry-run flag, then in a throwaway container: `docker run --rm -v "$PWD":/w -w /w bash:5 bash script.sh`.
3. Trace once with `bash -x script.sh` (secrets off).
FILE:templates/shell-review.md
# Shell script review: <script name>
**Runs from:** <cron on web-01 as root / GitHub Actions ubuntu-24.04 / Docker entrypoint (Alpine, busybox sh)>
**Shell:** <bash 5.2 / sh (dash)> **Untrusted inputs:** <$1 release tag, hosts.txt, file names in uploads/>
**Verdict:** <BLOCK: 3 HIGH findings / OK with fixes / OK>
**Summary:** <one or two sentences: the worst risk and what the fix changes>
## Findings
| # | Severity | Code | Line | Problem | Fix |
|---|---|---|---|---|---|
| 1 | <HIGH> | <RM_EMPTY_VAR> | <15> | <rm -rf $WEB_ROOT/* deletes /* if WEB_ROOT is empty> | <rm -rf -- "{{{WEB_ROOT}}}"/*> |
## Manual review notes
- <inputs that reach rm/ssh/sql/paths, and how they are validated>
- <idempotency: what happens if it runs twice or stops halfway>
- <cleanup on exit and on Ctrl+C>
## Fixed script
```bash
<full corrected script>
```
## Key changes
```diff
- rm -rf $WEB_ROOT/*
+ rm -rf -- "{{{WEB_ROOT}}}"/*
```
## Behavior changes
- <now exits on the first failed command; previously continued>
## How to test safely
1. `bash -n <script>` and `python3 scripts/shell_lint.py <script>` (expect 0 HIGH)
2. `shellcheck <script>` if available
3. Dry run or a throwaway container: `<command>`
4. <rotate the exposed secret: where and who>
FILE:examples/deploy.sh
#!/bin/bash
# Deploy the web app build to /srv/www and restart the service.
# Usage: deploy.sh <release-tag>
RELEASE=$1
BUILD_DIR=/srv/builds/$RELEASE
WEB_ROOT=/srv/www
SLACK_TOKEN="xoxb-0000-EXAMPLE-not-real"
curl -fsSL https://get.example-tools.dev/install.sh | sudo bash
cd $BUILD_DIR
echo "Deploying $RELEASE" > /tmp/deploy.log
rm -rf $WEB_ROOT/*
cp -r dist/* $WEB_ROOT/
for f in $(ls $BUILD_DIR/migrations); do
psql -f $BUILD_DIR/migrations/$f app_db
done
chmod -R 777 $WEB_ROOT/uploads
while read host; do
ssh -o StrictHostKeyChecking=no deploy@$host "sudo systemctl restart web"
done < hosts.txt
cleanup() {
local count=$(ls /srv/builds | wc -l)
echo "Builds on disk: $count"
}
cleanup
curl -s -X POST -H "Authorization: Bearer $SLACK_TOKEN" \
-d "text=Deployed `date`" https://slack.com/api/chat.postMessage
FILE:examples/deploy-fixed.sh
#!/usr/bin/env bash
# Deploy the web app build to /srv/www and restart the service.
# Usage: SLACK_TOKEN=... deploy.sh <release-tag>
set -euo pipefail
RELEASE="{{{1}}}"
BUILD_DIR="/srv/builds/{{{RELEASE}}}"
WEB_ROOT="/srv/www"
SLACK_TOKEN="{{{SLACK_TOKEN}}}"
# Installer is pinned and checked instead of curl | bash.
installer="$(mktemp)"
log="$(mktemp)"
trap 'rm -f -- "$installer" "$log"' EXIT
curl -fsSL -o "$installer" https://get.example-tools.dev/install-1.4.2.sh
echo "3b1f0c9e2d4a5b6c7d8e9f00112233445566778899aabbccddeeff0011223344 $installer" | sha256sum -c -
sudo bash "$installer"
cd -- "$BUILD_DIR"
echo "Deploying {{{RELEASE}}}" > "$log"
rm -rf -- "{{{WEB_ROOT}}}"/*
cp -r dist/. "$WEB_ROOT/"
for f in "$BUILD_DIR"/migrations/*.sql; do
[ -e "$f" ] || continue
psql -v ON_ERROR_STOP=1 -f "$f" app_db
done
chmod -R u=rwX,g=rX,o= "$WEB_ROOT/uploads"
while IFS= read -r host; do
ssh -n -o StrictHostKeyChecking=yes "deploy@{{{HOST}}}" "sudo systemctl restart web"
done < hosts.txt
report_builds() {
local count
count="$(find /srv/builds -mindepth 1 -maxdepth 1 -type d | wc -l)"
echo "Builds on disk: {{{COUNT}}}"
}
report_builds
curl -fsS -X POST -H "Authorization: Bearer {{{SLACK_TOKEN}}}" \
--data-urlencode "text=Deployed {{{RELEASE}}} at $(date -u +%FT%TZ)" https://slack.com/api/chat.postMessage
FILE:scripts/shell_lint.py
#!/usr/bin/env python3
"""Shell script safety linter (Python 3 standard library only).
Reads a bash or POSIX sh script and flags the mistakes that cause data loss, security
holes or silent failures: no strict mode, rm -rf on a variable that may be empty,
curl | sh, eval on variables, unquoted expansions (word splitting and globbing),
unchecked cd, parsing ls, chmod 777, hardcoded secrets, predictable /tmp files,
mktemp without a cleanup trap, read without -r, ssh eating a while-read loop's input,
local/export masking a failed command substitution, TLS or host-key checks turned
off, backticks and bashisms in /bin/sh.
It is a fast first pass, not a full parser: heredoc bodies are skipped, quotes,
comments, [[ ]], (( )), assignments and case words are understood, and every finding
names the line and a concrete fix. Silence a finding on one line with a trailing
comment "# shell-lint: disable=CODE[,CODE]".
Usage:
python3 shell_lint.py script.sh [more.sh ...] [--json] [--min-severity MEDIUM]
[--ignore CODE,CODE]
python3 shell_lint.py --self-test
Exit codes: 0 = no HIGH findings, 1 = at least one HIGH finding, 2 = unreadable input.
"""
import argparse, json, re, sys
SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3}
SECRET_NAME = re.compile(r"[A-Z0-9_]*(PASSWORD|PASSWD|TOKEN|SECRET|API_?KEY|ACCESS_KEY|PRIVATE_KEY)[A-Z0-9_]*")
FIXES = {
"NO_SHEBANG": "Add '#!/usr/bin/env bash' (or '#!/bin/sh' for POSIX) as line 1 so the right shell runs it.",
"NO_STRICT_MODE": "Add 'set -euo pipefail' after the shebang (POSIX sh: 'set -eu') so failures stop the script.",
"NO_PIPEFAIL": "Add 'set -o pipefail' so 'cmd | tee log' fails when cmd fails.",
"RM_EMPTY_VAR": "Guard the variable: rm -rf -- \"{{{VAR}}}/\"... and quote it; never build rm paths from unchecked input.",
"RM_ROOT": "Never rm the filesystem root or /*; point at a specific directory.",
"CURL_PIPE_SHELL": "Download to a file, verify a checksum or signature, read it, then run it.",
"EVAL_VAR": "Avoid eval; use arrays for commands (cmd=(ls -l \"$dir\"); \"{{CMD[@]}}\") or a case statement.",
"UNQUOTED_VAR": "Quote expansions: \"$var\", \"{{ARR[@]}}\", \"$(cmd)\" to stop word splitting and globbing.",
"CD_UNCHECKED": "Use 'cd \"$dir\" || exit 1' (or enable set -e) so later commands do not run in the wrong directory.",
"LS_PARSING": "Use a glob (for f in ./*.log) or find -print0 | while IFS= read -r -d '' f instead of parsing ls/find output.",
"BACKTICKS": "Use $(...) instead of backticks; it nests and quotes predictably.",
"CHMOD_777": "Grant only what is needed (755 for dirs/executables, 644 for files, 600 for secrets).",
"HARDCODED_SECRET": "Read the secret from the environment or a secret manager (\"{{{API_TOKEN}}}\"), rotate the exposed value.",
"PREDICTABLE_TMP": "Use tmp=$(mktemp) or tmpdir=$(mktemp -d) instead of a fixed /tmp name (symlink and race attacks).",
"NO_TRAP_CLEANUP": "Add trap 'rm -rf -- \"$tmpdir\"' EXIT right after mktemp.",
"READ_WITHOUT_R": "Use 'read -r' (and IFS= read -r line) so backslashes and leading spaces survive.",
"LOCAL_MASKS_STATUS": "Split it: 'local out; out=$(cmd)' so set -e and $? see the command's failure.",
"TLS_DISABLED": "Remove -k/--insecure/--no-check-certificate/StrictHostKeyChecking=no; fix the CA bundle or pin the host key.",
"SSH_STDIN_LOOP": "Use 'ssh -n' (or ssh ... < /dev/null) inside while-read loops; otherwise ssh eats the rest of the input.",
"BASHISM_IN_SH": "Either change the shebang to bash or use the POSIX form ([ ], =, . file, no arrays).",
}
class Finding:
def __init__(self, sev, code, line, snippet, problem):
self.sev, self.code, self.line, self.snippet, self.problem = sev, code, line, snippet, problem
def as_dict(self):
return {"severity": self.sev, "code": self.code, "line": self.line,
"snippet": self.snippet, "problem": self.problem, "fix": FIXES.get(self.code, "")}
def strip_comment(line):
"""Remove an unquoted comment. '#' starts a comment only at the start of a word."""
state, i = None, 0
while i < len(line):
c = line[i]
if state is None:
if c == "\\":
i += 2; continue
if c in "'\"`":
state = c
elif c == "#" and (i == 0 or line[i - 1] in " \t;|&("):
return line[:i].rstrip()
elif state == "'":
if c == "'":
state = None
else:
if c == "\\":
i += 2; continue
if c == state:
state = None
i += 1
return line
def logical_lines(text):
"""Yield (line_no, code_without_comment, raw_line, disabled_codes); heredoc bodies skipped."""
lines = text.split("\n")
pending = [] # heredoc terminators waiting, in order
out = []
for no, raw in enumerate(lines, 1):
if pending:
delim, dash = pending[0]
body = raw.lstrip("\t") if dash else raw
if body == delim:
pending.pop(0)
continue
m = re.search(r"#\s*shell-lint:\s*disable=([A-Z_,\s]+)", raw)
disabled = {c.strip() for c in m.group(1).split(",")} if m else set()
code = strip_comment(raw)
for hm in re.finditer(r"<<(-?)\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\2", code):
if code[hm.start():hm.start() + 3] == "<<<":
continue
pending.append((hm.group(3), hm.group(1) == "-"))
out.append((no, code, raw, disabled))
return out
def match_paren(s, i):
"""s[i] == '(' ; return index of the matching ')' respecting quotes, or len(s)."""
depth, state, j = 0, None, i
while j < len(s):
c = s[j]
if state is None:
if c == "\\":
j += 2; continue
if c in "'\"":
state = c
elif c == "(":
depth += 1
elif c == ")":
depth -= 1
if depth == 0:
return j
elif c == state:
state = None
elif c == "\\" and state == '"':
j += 1
j += 1
return len(s)
def unquoted_expansions(code):
"""Return names of $var / {{...}} / $(...) / $@ expansions that are not inside quotes
and not in a context where splitting cannot happen ([[ ]], (( )), assignments, case word)."""
found = []
stripped = code.strip()
if re.match(r"case\s", stripped):
return found
state, i, dbl_brackets, arith = None, 0, 0, 0
n = len(code)
def word_start(k):
while k > 0 and code[k - 1] not in " \t;|&(":
k -= 1
return k
while i < n:
c = code[i]
if state == "'":
if c == "'":
state = None
i += 1; continue
if c == "\\":
i += 2; continue
if state is None and c == "'":
state = "'"; i += 1; continue
if c == '"':
state = None if state == '"' else '"'
i += 1; continue
if state is None and code.startswith("[[", i) and (i == 0 or code[i - 1] in " \t;&|!("):
dbl_brackets += 1; i += 2; continue
if state is None and code.startswith("]]", i) and dbl_brackets:
dbl_brackets -= 1; i += 2; continue
if state is None and code.startswith("((", i) and (i == 0 or code[i - 1] in " \t;&|!"):
end = match_paren(code, i)
i = end + 1; continue
if c == "`":
end = code.find("`", i + 1)
i = (end if end != -1 else n) + 1; continue
if c == "$" and i + 1 < n:
nxt = code[i + 1]
if code.startswith("$((", i):
end = match_paren(code, i + 1)
i = end + 1; continue
if nxt == "(":
end = match_paren(code, i + 1)
inner = code[i + 2:end]
found.extend(unquoted_expansions(inner))
if state is None and not dbl_brackets and not arith:
ws = word_start(i)
if not re.match(r"[A-Za-z_][A-Za-z0-9_]*(\[[^\]]*\])?\+?=", code[ws:i + 1]):
found.append("$(" + inner.strip()[:20] + ")")
i = end + 1; continue
m = re.match(r"\$(\{[^}]*\}|[A-Za-z_][A-Za-z0-9_]*|[@*]|[0-9])", code[i:])
if m:
tok = m.group(0)
if state is None and not dbl_brackets:
ws = word_start(i)
is_assign = re.match(r"[A-Za-z_][A-Za-z0-9_]*(\[[^\]]*\])?\+?=", code[ws:i + 1])
if not is_assign and not tok.startswith("{{#")}})", code)
if m:
val = m.group(6)
masked = val[:2] + "*" * max(3, len(val) - 4) + val[-2:]
add("HIGH", "HARDCODED_SECRET", no, code.replace(val, masked), f"{m.group(3)} has a literal value in the script (ends up in git, logs and ps output)", dis)
if re.match(r"\s*(local|export|declare|readonly)\s+(-\w+\s+)*[A-Za-z_][A-Za-z0-9_]*=\"?\$\(", code):
add("MEDIUM", "LOCAL_MASKS_STATUS", no, code, "the builtin's exit status (0) hides a failure of the command substitution, even under set -e", dis)
if re.search(r"(^|[^\w.-])/tmp/[A-Za-z0-9_.${}-]+", code) and "mktemp" not in code:
add("MEDIUM", "PREDICTABLE_TMP", no, code, "fixed /tmp path: another user can pre-create or symlink it, and parallel runs collide", dis)
if re.search(r"\bfor\s+\w+\s+in\s+(\$\(|`)\s*(ls|find)\b", code) or re.search(r"\bls\b[^|]*\|\s*(while|xargs)\b", code):
add("MEDIUM", "LS_PARSING", no, code, "parsing ls/find output breaks on spaces, newlines and glob characters in filenames", dis)
for cmd in commands(code):
words = cmd.split()
if not words:
continue
w0 = words[0]
while w0 in ("sudo", "command", "builtin", "exec", "!", "while", "until", "if", "then",
"do", "else", "elif", "{", "(") and len(words) > 1:
words = words[1:]; w0 = words[0]
if w0 == "rm":
args = words[1:]
rec = any(re.match(r"-[a-zA-Z]*[rR]", a) or a == "--recursive" for a in args)
if any(a.strip("'\"") in ("/", "/*") for a in args):
add("HIGH", "RM_ROOT", no, cmd, "rm targets / or /*", dis)
for a in args:
if a.startswith("-") and a != "--":
continue
if "$" in a and not re.search(r"\$\{[A-Za-z_][A-Za-z0-9_]*:\?", a):
a_clean = a.strip("'\"")
if re.match(r"\$\{?[A-Za-z_][A-Za-z0-9_]*\}?(/|\"?$)", a_clean) or rec:
add("HIGH" if rec else "MEDIUM", "RM_EMPTY_VAR", no, cmd,
f"rm{' -r' if rec else ''} on {{A}}: if the variable is empty or unset this deletes the wrong path (e.g. '/')", dis)
break
if w0 == "cd" and not strict_e:
if not re.search(re.escape(cmd) + r"\s*(\|\||&&)", code) and not re.search(r"(\|\||&&)\s*" + re.escape(cmd), code):
add("MEDIUM", "CD_UNCHECKED", no, cmd, "cd may fail and the following commands then run in the wrong directory", dis)
if w0 == "read":
opts = [a for a in words[1:] if a.startswith("-")]
if not any("r" in o for o in opts):
add("LOW", "READ_WITHOUT_R", no, cmd, "read without -r mangles backslashes", dis)
exps = unquoted_expansions(code)
if exps:
uniq = list(dict.fromkeys(exps))
shown = ", ".join(uniq[:4]) + (" ..." if len(uniq) > 4 else "")
only_echo = re.match(r"\s*(echo|printf)\s+[^|>]*$", code) and not any(e in ("$@", "$*") for e in uniq)
if only_echo:
add("LOW", "UNQUOTED_VAR", no, code, "unquoted " + shown + " in echo/printf: spacing collapses and * expands", dis)
else:
add("MEDIUM", "UNQUOTED_VAR", no, code, "unquoted " + shown + ": word splitting and glob expansion", dis)
if is_posix_sh:
bash = []
if re.search(r"(^|[\s;&|!])\[\[\s", code): bash.append("[[ ]]")
if re.search(r"(^|[\s;&|])\[\s[^]]*\s==\s", code): bash.append("== in [ ]")
if re.match(r"\s*function\s+\w+", code): bash.append("function keyword")
if re.search(r"\b[A-Za-z_]\w*=\(", code): bash.append("array")
if re.match(r"\s*source\s", code): bash.append("source")
if "<<<" in code: bash.append("here-string")
if re.search(r"&>", code): bash.append("&>")
if re.search(r"\$'", code): bash.append("$'...'")
if bash:
add("MEDIUM", "BASHISM_IN_SH", no, code, "bash-only syntax under #!/bin/sh (" + ", ".join(bash) + "); dash and busybox sh will fail", dis)
findings.sort(key=lambda f: (SEV_ORDER[f.sev], f.line, f.code))
return findings
def render_md(name, findings):
counts = {s: sum(1 for f in findings if f.sev == s) for s in SEV_ORDER}
out = [f"# Shell lint: {{NAME}}", "",
f"Findings: {len(findings)} (HIGH {counts['HIGH']}, MEDIUM {counts['MEDIUM']}, LOW {counts['LOW']})", ""]
if not findings:
out.append("No findings.")
return "\n".join(out)
out += ["| Severity | Code | Line | Snippet | Problem |", "|---|---|---|---|---|"]
for f in findings:
snip = f.snippet.replace("|", "\\|").replace("`", "'")
out.append(f"| {f.sev} | {f.code} | {f.line} | `{{SNIP}}` | {f.problem.replace('|', '/')} |")
out += ["", "## Fixes", ""]
for code in dict.fromkeys(f.code for f in findings):
out.append(f"- {{CODE}}: {FIXES[code]}")
return "\n".join(out)
# ---------------------------------------------------------------- self-test
def _codes(src):
return [f.code for f in lint(src)]
def self_test():
S = "#!/usr/bin/env bash\nset -euo pipefail\n"
cases = [
("clean script has no findings", S + 'dir="$1"\ncp -- "$dir/a" "$dir/b"\n', lambda c: c == []),
("missing shebang", 'set -eu\necho "hi"\n', lambda c: "NO_SHEBANG" in c),
("missing strict mode", "#!/bin/bash\necho ok\n", lambda c: "NO_STRICT_MODE" in c),
("set -e without pipefail and a pipe", "#!/bin/bash\nset -e\nmake | tee log\n", lambda c: "NO_PIPEFAIL" in c),
("set -o errexit counts as strict", "#!/bin/bash\nset -o errexit\nset -o pipefail\necho ok\n", lambda c: c == []),
("rm -rf $VAR/ is HIGH", S + 'rm -rf "$BUILD_DIR"/\n', lambda c: "RM_EMPTY_VAR" in c),
("rm -rf {{{VAR}}} is fine", S + 'rm -rf -- "{{{BUILD_DIR}}}/"\n', lambda c: "RM_EMPTY_VAR" not in c),
("rm -rf / is RM_ROOT", S + "rm -rf /\n", lambda c: "RM_ROOT" in c),
("curl | bash", S + "curl -fsSL https://x.example/install.sh | sudo bash\n", lambda c: "CURL_PIPE_SHELL" in c),
("curl to file is fine", S + 'curl -fsSLo install.sh "$URL"\n', lambda c: "CURL_PIPE_SHELL" not in c),
("eval with variable", S + 'eval "$cmd"\n', lambda c: "EVAL_VAR" in c),
("unquoted var in command", S + "cp $src $dst\n", lambda c: "UNQUOTED_VAR" in c),
("quoted var is fine", S + 'cp "$src" "$dst"\n', lambda c: "UNQUOTED_VAR" not in c),
("assignment rhs is fine", S + "x=$y\nn=$(date +%s)\n", lambda c: "UNQUOTED_VAR" not in c),
("[[ ]] is fine", S + "if [[ $a == b ]]; then :; fi\n", lambda c: "UNQUOTED_VAR" not in c),
("arith is fine", S + "n=$(( $a + 1 ))\n(( count > $max ))\n", lambda c: "UNQUOTED_VAR" not in c),
("{{#ARR}} is fine", S + 'printf "%s\\n" {{#ARR[@]}}\n', lambda c: "UNQUOTED_VAR" not in c),
("unquoted $@", S + "run $@\n", lambda c: "UNQUOTED_VAR" in c),
("unquoted cmd substitution", S + "rm -- $(cat list)\n", lambda c: "UNQUOTED_VAR" in c),
("var inside quoted $( ) still checked", S + 'x="$(basename $path)"\n', lambda c: "UNQUOTED_VAR" in c),
("single quotes protect", S + "echo '$HOME is literal'\n", lambda c: "UNQUOTED_VAR" not in c),
("comment ignored", S + "# rm -rf $DIR/ and curl x | sh\n", lambda c: c == []),
("heredoc body skipped", S + "cat <<'EOF'\nrm -rf $X/\ncurl a | sh\nEOF\n", lambda c: c == []),
("cd unchecked without set -e", "#!/bin/bash\ncd /srv/app\n", lambda c: "CD_UNCHECKED" in c),
("cd || exit is fine", "#!/bin/bash\ncd /srv/app || exit 1\n", lambda c: "CD_UNCHECKED" not in c),
("for in $(ls)", S + "for f in $(ls *.log); do :; done\n", lambda c: "LS_PARSING" in c),
("backticks", S + "d=`date`\n", lambda c: "BACKTICKS" in c),
("chmod 777", S + "chmod -R 777 /srv/app\n", lambda c: "CHMOD_777" in c),
("hardcoded token, value masked", S + 'export API_TOKEN="sk_live_abcdef123456"\n',
lambda c: "HARDCODED_SECRET" in c),
("secret from env is fine", S + 'API_TOKEN="{{{API_TOKEN}}}"\n', lambda c: "HARDCODED_SECRET" not in c),
("predictable tmp", S + 'echo "x" > /tmp/deploy.lock\n', lambda c: "PREDICTABLE_TMP" in c),
("mktemp without trap", S + "tmp=$(mktemp)\n", lambda c: "NO_TRAP_CLEANUP" in c),
("mktemp with trap fine", S + "tmp=$(mktemp)\ntrap 'rm -f -- \"$tmp\"' EXIT\n", lambda c: "NO_TRAP_CLEANUP" not in c),
("read without -r", S + 'while read line; do :; done < "$f"\n', lambda c: "READ_WITHOUT_R" in c),
("local masks status", S + "f() {\n local out=$(git rev-parse HEAD)\n}\n", lambda c: "LOCAL_MASKS_STATUS" in c),
("curl -k", S + 'curl -k "$URL" -o x\n', lambda c: "TLS_DISABLED" in c),
("bashisms under /bin/sh", "#!/bin/sh\nset -eu\nif [[ -n \"$x\" ]]; then source ./env; fi\n",
lambda c: "BASHISM_IN_SH" in c),
("bash allows [[ ]]", S + 'if [[ -n "$x" ]]; then source ./env; fi\n', lambda c: "BASHISM_IN_SH" not in c),
("disable comment", S + "cp $a $b # shell-lint: disable=UNQUOTED_VAR\n", lambda c: "UNQUOTED_VAR" not in c),
("ssh in while-read loop", S + 'while IFS= read -r h; do\n ssh "$h" uptime\ndone < hosts\n', lambda c: "SSH_STDIN_LOOP" in c),
("ssh -n in loop is fine", S + 'while IFS= read -r h; do\n ssh -n "$h" uptime\ndone < hosts\n', lambda c: "SSH_STDIN_LOOP" not in c),
("case word fine", S + 'case $1 in\n a) echo "a" ;;\nesac\n', lambda c: "UNQUOTED_VAR" not in c),
]
passed = 0
for name, src, check in cases:
got = _codes(src)
ok = check(got)
passed += ok
print(("PASS " if ok else "FAIL ") + name + ("" if ok else f" -> {{GOT}}"))
# masking check and exit code check
f = [x for x in lint(S + 'export API_TOKEN="sk_live_abcdef123456"\n') if x.code == "HARDCODED_SECRET"][0]
ok = "abcdef123456" not in f.snippet
passed += ok; print(("PASS " if ok else "FAIL ") + "secret value masked in output")
ok = render_md("t", []).endswith("No findings.")
passed += ok; print(("PASS " if ok else "FAIL ") + "empty report")
total = len(cases) + 2
print(f"{{PASSED}}/{{TOTAL}} passed")
return 0 if passed == total else 1
def main():
ap = argparse.ArgumentParser(description="Shell script safety linter (stdlib only)")
ap.add_argument("files", nargs="*")
ap.add_argument("--json", action="store_true")
ap.add_argument("--min-severity", default="LOW", choices=list(SEV_ORDER))
ap.add_argument("--ignore", default="", help="comma-separated codes to skip")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args()
if a.self_test:
return self_test()
if not a.files:
ap.error("give at least one script or --self-test")
ignore = {c.strip() for c in a.ignore.split(",") if c.strip()}
worst, reports, js = 0, [], {}
for path in a.files:
try:
with open(path, encoding="utf-8") as fh:
text = fh.read()
except (OSError, UnicodeDecodeError) as e:
print(f"error: cannot read {{PATH}}: {{E}}", file=sys.stderr)
return 2
fs = [f for f in lint(text, path) if f.code not in ignore and SEV_ORDER[f.sev] <= SEV_ORDER[a.min_severity]]
if any(f.sev == "HIGH" for f in fs):
worst = 1
js[path] = [f.as_dict() for f in fs]
reports.append(render_md(path, fs))
print(json.dumps(js, indent=2) if a.json else "\n\n".join(reports))
return worst
if __name__ == "__main__":
sys.exit(main())How to use this prompt
- Review the pre-filled example and source files above.
- Copy the template or download
prompt-prompt-to-turn-chatgpt-into-a-shell-script-safety-reviewer.md, then use it in your model, IDE, or workflow tool. - Compare your result with the sample output when one is provided.
Frequently Asked Questions
How do I use Prompt to Turn ChatGPT into a Shell Script Safety Reviewer?
Open the prompt on Promptally, review the pre-filled example, then copy the template or download prompt-prompt-to-turn-chatgpt-into-a-shell-script-safety-reviewer.md and paste it into your model, IDE or workflow tool. Prompt to Turn ChatGPT into a Shell Script Safety Reviewer is a static blueprint with no dynamic variables. Copy it and use it as-is.
Is Prompt to Turn ChatGPT into a Shell Script Safety Reviewer free?
Yes. Prompt to Turn ChatGPT into a Shell Script Safety Reviewer is free to view, copy and download on Promptally without creating an account. It is published under the MIT license.
What variables does Prompt to Turn ChatGPT into a Shell Script Safety Reviewer need?
Prompt to Turn ChatGPT into a Shell Script Safety Reviewer is a static blueprint with no dynamic variables. Copy it and use it as-is.