> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pipefort.com/llms.txt
> Use this file to discover all available pages before exploring further.

# BEST-PRAC-5 — Multi-command shell step runs without strict mode

> GitHub sets -e and nothing else, so a failure on the left of a pipe passes green.

| Field      | Value                                          |
| ---------- | ---------------------------------------------- |
| Rule ID    | `best-prac-5-shell-hardening`                  |
| Category   | `BEST-PRAC-5`                                  |
| Severity   | LOW                                            |
| Confidence | HIGH                                           |
| Persona    | **pedantic** — silent at the default tier      |
| Platforms  | GitHub Actions **and** GitLab CI (one rule ID) |
| Frameworks | none (resilience hygiene)                      |
| Auto-fix   | ✓                                              |

## What the check does

Flags a **multi-command bash** `run:` block (GitHub Actions) or a
multi-command `script:` (GitLab CI) that does not enable strict mode.

## Why it matters

GitHub's default shell for a `run:` step on Linux and macOS is `bash -e` — one
of the three flags that matter, and not the interesting one:

| Flag          | Effect                             | Set by default? |
| ------------- | ---------------------------------- | --------------- |
| `-e`          | stop on a failing command          | ✅               |
| `-u`          | stop on an unset variable          | ❌               |
| `-o pipefail` | a pipeline fails if any stage does | ❌               |

Without `pipefail`, this step **passes green** when `generate` crashes, because
the step's exit status is `tee`'s:

```yaml theme={null}
- run: |
    generate-report | tee report.txt
    upload report.txt          # ← uploads an empty file, reports success
```

Without `-u`, a misspelled or unset variable expands to the empty string:

```yaml theme={null}
- run: |
    rm -rf "$BILD_DIR/"        # ← typo. this is now `rm -rf /`
```

Both fail silently, and both produce a build that looks like it worked. GitLab
has the same gap — the runner sets `-e` per script, not `pipefail`.

## Safe alternative

```yaml theme={null}
- run: |
    set -euo pipefail          # ← first line, or after a shebang
    generate-report | tee report.txt
    upload report.txt
```

Or set it on the step's shell, which is equivalent:

```yaml theme={null}
- shell: bash -euo pipefail {0}
  run: |
    ...
```

On GitLab, once in a top-level `default:` covers every job:

```yaml theme={null}
default:
  before_script:
    - set -euo pipefail
```

## What does not fire

Scoped tightly on purpose — this pattern is everywhere, and a rule that fires on
all of it is a rule people turn off:

| Stays quiet                           | Why                                                                                                                                                           |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A single-command step                 | No pipeline to swallow, no sequencing to lose. Blank lines and comments don't count toward the command count, so a documented one-liner is still one command. |
| `shell: sh`                           | `pipefail` is not POSIX — recommending it would produce a script that fails to start.                                                                         |
| `shell: pwsh`, `shell: python`        | Their own error models.                                                                                                                                       |
| `runs-on: windows-*` with no `shell:` | Windows defaults to pwsh. An **explicit** `shell: bash` on Windows *does* fire.                                                                               |
| Any of the hardened spellings         | `set -o pipefail`, `set -euo pipefail`, `set -euxo pipefail`, a `shell:` string carrying the flags, or a GitLab `default: before_script:` the job inherits.   |

## Persona and rulesets

This rule is tiered to the **`pedantic`** persona. It is a real gap, not a
stylistic preference, but it fires on ordinary CI and must not crowd out
security findings at the default tier:

```bash theme={null}
pipefort -p .                      # silent
pipefort -p . --persona pedantic   # fires
```

It carries **no framework tags** — it is resilience hygiene, like
[missing timeout](/rules/best-prac-2) and
[missing concurrency](/rules/best-prac-4-missing-concurrency) — so it appears
under the `all` ruleset and not under `owasp` or `slsa`.

## Auto-fix

Prepends `set -euo pipefail` — **after a shebang** when the script has one,
because a shebang only works on line 1 and inserting above it would silently
change which interpreter runs the script. The inserted line matches the
script's own indentation. On GitLab the line becomes the first `script:` entry.

## Related rules

* [BEST-PRAC-1 — Command piped directly to shell](/rules/best-prac-1)
* [BEST-PRAC-2 — Job timeout not configured](/rules/best-prac-2)
* [CICD-SEC-10 — Job-level `continue-on-error`](/rules/cicd-sec-10)
