diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0a463ac --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + shellcheck: + name: shellcheck + # Pinned runner image: shellcheck comes from its apt repo, so the image + # decides the version (ubuntu-24.04 ships 0.9.x, stricter on SC2015 than + # 0.11). Moving to a newer image is a deliberate change. + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Show shellcheck version + run: shellcheck --version + - name: Run shellcheck on all shell scripts + run: | + find . -type f -name '*.sh' -not -path './.git/*' -print0 \ + | xargs -0 --no-run-if-empty shellcheck --severity=style + + actionlint: + name: actionlint + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Lint workflows + # `go run module@version` fetches a fixed release, verified against the + # Go checksum database (sum.golang.org). + run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 -color + + gitleaks: + name: gitleaks + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + - name: Scan for committed secrets + uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + smoke: + name: script smoke test + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Syntax check every script (bash -n) + run: | + find . -type f -name '*.sh' -not -path './.git/*' -print0 \ + | xargs -0 --no-run-if-empty -n1 bash -n + - name: Smoke test self-test (goes red on each F-0024 failure mode) + run: bash scripts/test-smoke-selftest.sh + - name: Smoke each entrypoint (--help, no sudo) and README symlink targets + run: bash scripts/test-smoke.sh diff --git a/README.md b/README.md index 0469887..e9dc616 100644 --- a/README.md +++ b/README.md @@ -27,3 +27,40 @@ sudo ln -s /home/mregra/dev/fedora-workstation-scripts/system-health/system-heal ``` Then follow the per-project READMEs for configuration and automation details. + +## Continuous integration + +Every push to `main` and every pull request runs `.github/workflows/ci.yml`: + +- **shellcheck** — static analysis of every `*.sh` (the runner's shellcheck is + older than Fedora's and a little stricter; the version is printed in the log). +- **actionlint** — lints the workflow files. +- **gitleaks** — scans for committed secrets. +- **script smoke test** — `bash -n` on every script plus `scripts/test-smoke.sh`, + which guards against the ways `fedora-maintenance.service` failed (F-0024). It + reads the `ln -s` lines and the `ExecStart=` lines from the READMEs and fails if: + - a script behind a symlink or a direct repo path (`%h/.../fedora-workstation-scripts/`) + is missing, has no shebang or is not committed as executable (`100755`), + so the unit would fail with `status=203/EXEC`; + - an `ExecStart` runs a `/usr/local/bin` or `~/.local/bin` name that no README + symlink creates, or runs nothing from this repo; + - a script that declares `# smoke-modes: ...` is started with no mode, an + unknown mode, or `%i` in a unit that is not a template (`name@.service`), + so it would exit 1 with its usage text; + - a user unit (`~/.config/systemd/user`) runs a script that calls `need_root` + without `sudo`. + + It also runs every `*.sh` outside `scripts/` with `--help` (no `sudo`, no side + effects). A script whose source never mentions `--help` is reported and not + run. `scripts/test-smoke-selftest.sh` proves the smoke test goes red on each + of those failures. + +Every runnable script therefore supports `--help`. When you move a script, +update its README symlink line in the same change. When you add a mode to +`fedora-maintenance.sh`, add it to its `# smoke-modes:` line. Run the checks +locally with: + +```bash +bash scripts/test-smoke.sh +bash scripts/test-smoke-selftest.sh +``` diff --git a/bug-bounty-agent/install-tools.sh b/bug-bounty-agent/install-tools.sh index 47972c5..a5a4e83 100755 --- a/bug-bounty-agent/install-tools.sh +++ b/bug-bounty-agent/install-tools.sh @@ -3,6 +3,19 @@ # Run as your normal user (not root) — tools install to ~/go/bin and ~/.local/bin. set -Eeuo pipefail +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + cat <<'EOF' +install-tools.sh — install security tooling for the bug-bounty orchestrator. + +Usage: + bash install-tools.sh Install Python deps, Go tools, nmap, sqlmap, etc. + bash install-tools.sh --help Show this help and exit. + +Run as your normal user (not root). Tools install to ~/go/bin and ~/.local/bin. +EOF + exit 0 +fi + say() { printf '\n=== %s ===\n' "$1"; } ok() { printf ' OK: %s\n' "$1"; } skip() { printf ' SKIP: %s (already installed)\n' "$1"; } diff --git a/bug-bounty-agent/nightly-launch.sh b/bug-bounty-agent/nightly-launch.sh index 6d4fd5a..4595f2c 100644 --- a/bug-bounty-agent/nightly-launch.sh +++ b/bug-bounty-agent/nightly-launch.sh @@ -9,9 +9,23 @@ # Output and logs go to ./output/ and ./nightly.log set -Eeuo pipefail +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + cat <<'EOF' +nightly-launch.sh — queue tonight's orchestrator run and detach it. + +Usage: + bash nightly-launch.sh scope.yaml + bash nightly-launch.sh scope.yaml --phases recon scan + bash nightly-launch.sh --help + +Output and logs go to ./output/ and ./nightly.log; PID is written to nightly.pid. +EOF + exit 0 +fi + SCOPE="${1:-scope.yaml}" shift || true -EXTRA_ARGS="${*}" +EXTRA_ARGS=("$@") LOG="nightly.log" PID_FILE="nightly.pid" @@ -32,11 +46,11 @@ if [[ -f "$PID_FILE" ]]; then rm -f "$PID_FILE" fi -echo "Starting overnight run: scope=$SCOPE extra=${EXTRA_ARGS:-none}" +echo "Starting overnight run: scope=$SCOPE extra=${EXTRA_ARGS[*]:-none}" echo "Log: $LOG" echo "Kill with: kill \$(cat $PID_FILE)" -nohup python3 orchestrator.py --scope "$SCOPE" $EXTRA_ARGS \ +nohup python3 orchestrator.py --scope "$SCOPE" "${EXTRA_ARGS[@]}" \ > "$LOG" 2>&1 & echo $! > "$PID_FILE" diff --git a/fedora-maintenance/README.md b/fedora-maintenance/README.md index 7650e6a..d818441 100644 --- a/fedora-maintenance/README.md +++ b/fedora-maintenance/README.md @@ -74,11 +74,11 @@ Create these files under `~/.config/systemd/user`. ```ini [Unit] -Description=Fedora maintenance (%i) +Description=Fedora maintenance (daily) [Service] Type=oneshot -ExecStart=/usr/bin/sudo -n /usr/local/bin/fedora-maintenance %i +ExecStart=/usr/bin/sudo -n /usr/local/bin/fedora-maintenance daily Environment=NOTIFY_USER=mregra ``` @@ -105,6 +105,9 @@ systemctl --user enable --now fedora-maintenance.timer If you use the user timers, make sure `sudo` is configured for passwordless execution of the maintenance script (or it will prompt and fail in the background). +Always pass a mode in `ExecStart`: without one the script prints usage and exits 1. +`%i` only works in a template unit (`fedora-maintenance@.service`, Option B); in +`fedora-maintenance.service` it expands to an empty string. ### Option B: systemd system timers diff --git a/fedora-maintenance/fedora-maintenance.sh b/fedora-maintenance/fedora-maintenance.sh index 76d2326..e690bd2 100755 --- a/fedora-maintenance/fedora-maintenance.sh +++ b/fedora-maintenance/fedora-maintenance.sh @@ -1,4 +1,6 @@ #!/usr/bin/env bash +# Modes accepted by main(); scripts/test-smoke.sh checks README ExecStart lines against it. +# smoke-modes: daily monthly major set -Eeuo pipefail LOG_FILE="/var/log/fedora-maintenance.log" @@ -198,6 +200,8 @@ cleanup_system() { say " OK (nothing to remove)" log "No old kernels to remove" else + # shellcheck disable=SC2086 # intentional word splitting: a space/newline + # separated list of kernel package names must expand to multiple args. if dnf -y remove $old_kernels >>"$LOG_FILE" 2>&1; then ok else @@ -316,6 +320,11 @@ main() { local mode="${1:-}" [[ -n "$mode" ]] || { usage; exit 1; } + if [[ "$mode" == "-h" || "$mode" == "--help" || "$mode" == "help" ]]; then + usage + exit 0 + fi + MODE="$mode" need_root "$@" mkdir -p "$(dirname "$LOG_FILE")" diff --git a/scripts/test-smoke-selftest.sh b/scripts/test-smoke-selftest.sh new file mode 100644 index 0000000..cda016e --- /dev/null +++ b/scripts/test-smoke-selftest.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# Self-test for scripts/test-smoke.sh: proves it goes red on each F-0024 +# failure mode and stays green on a healthy tree. Each case builds a throwaway +# fixture tree in a temp dir and runs the smoke test against it with SMOKE_ROOT. +# +# F-0024 failure modes (see the header of test-smoke.sh): +# 1. 203/EXEC - ExecStart points at a moved/missing/non-executable script +# (through a README symlink or directly at a repo path) +# 2. exit 1 - ExecStart passes no mode (or %i in a non-template unit) +# 3. exit 1 - a user unit runs a root-only script without sudo +# +# Usage: bash scripts/test-smoke-selftest.sh +# +# shellcheck disable=SC2016 # fixtures deliberately write literal $ and backticks +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SMOKE="$HERE/test-smoke.sh" +WORK="$(mktemp -d)" +trap 'rm -rf -- "$WORK"' EXIT + +REPO="/home/user/dev/fedora-workstation-scripts" +status=0 + +# write_tool : a minimal script that honours --help. +write_tool() { + mkdir -p "$1/$(dirname "$2")" + cat >"$1/$2" <<'EOF' +#!/usr/bin/env bash +if [[ "${1:-}" == "--help" ]]; then echo usage; exit 0; fi +exit 1 +EOF + chmod "$3" "$1/$2" +} + +# write_modal_tool : like fedora-maintenance.sh, it needs a mode +# (daily|monthly) and root. +write_modal_tool() { + mkdir -p "$1/$(dirname "$2")" + cat >"$1/$2" <<'EOF' +#!/usr/bin/env bash +# smoke-modes: daily monthly +need_root() { [[ "$EUID" -eq 0 ]] || exit 1; } +if [[ "${1:-}" == "--help" ]]; then echo usage; exit 0; fi +need_root +case "${1:-}" in + daily) exit 0 ;; + monthly) exit 0 ;; +esac +exit 1 +EOF + chmod 755 "$1/$2" +} + +# link : README `ln -s` line. +link() { printf 'sudo ln -s %s/%s /usr/local/bin/%s\n\n' "$REPO" "$2" "$3" >>"$1/README.md"; } + +# unit : README unit block. +unit() { + printf '`%s`\n\n```ini\n[Service]\nType=oneshot\nExecStart=%s\n```\n\n' "$2" "$3" >>"$1/README.md" +} + +# expect +expect() { + local name="$1" want="$2" pattern="$3" out rc + out="$(SMOKE_ROOT="$WORK/$name" bash "$SMOKE" 2>&1)"; rc=$? + if [[ "$want" == pass && "$rc" -eq 0 ]] \ + || [[ "$want" == fail && "$rc" -ne 0 && ( -z "$pattern" || "$out" == *"$pattern"* ) ]]; then + printf 'ok %s (exit %d)\n' "$name" "$rc" + else + printf 'NOT OK %s: wanted %s (output matching "%s"), got exit %d\n%s\n' \ + "$name" "$want" "$pattern" "$rc" "$out" + status=1 + fi +} + +SYS=/etc/systemd/system +USR=/home/user/.config/systemd/user + +# healthy: every documented shape that works, including `ln -sf`, a direct repo +# path, a template unit with valid instances and a user unit going through sudo. +write_tool "$WORK/healthy" tool/tool.sh 755 +write_modal_tool "$WORK/healthy" maint/maint.sh +link "$WORK/healthy" tool/tool.sh tool +printf 'ln -sf %s/maint/maint.sh ~/.local/bin/maint\n\n' "$REPO" >>"$WORK/healthy/README.md" +unit "$WORK/healthy" "$SYS/tool.service" /usr/local/bin/tool +unit "$WORK/healthy" "$SYS/tool2.service" "%h/dev/fedora-workstation-scripts/tool/tool.sh" +unit "$WORK/healthy" "$SYS/maint@.service" "%h/.local/bin/maint %i" +printf '`%s/maint@daily.timer`\n\n`%s/maint@monthly.timer`\n\n' "$SYS" "$SYS" >>"$WORK/healthy/README.md" +unit "$WORK/healthy" "$USR/maint.service" "/usr/bin/sudo -n $REPO/maint/maint.sh daily" +expect healthy pass '' + +# moved: the README symlink still points at the old path (203/EXEC). +write_tool "$WORK/moved" tool/tool.sh 755 +link "$WORK/moved" tool.sh tool +unit "$WORK/moved" "$SYS/tool.service" /usr/local/bin/tool +expect moved fail 'tool.sh: missing' + +# direct-moved: the literal F-0024 unit, ExecStart straight at an old repo path. +write_tool "$WORK/direct-moved" tool/tool.sh 755 +link "$WORK/direct-moved" tool/tool.sh tool +unit "$WORK/direct-moved" "$USR/tool.service" "%h/dev/fedora-workstation-scripts/tool.sh" +expect direct-moved fail 'tool.sh: missing, but README.md:' + +# noexec: the symlinked script lost its executable bit. +write_tool "$WORK/noexec" tool/tool.sh 644 +link "$WORK/noexec" tool/tool.sh tool +unit "$WORK/noexec" "$SYS/tool.service" /usr/local/bin/tool +expect noexec fail 'not committed as executable' + +# unlinked: ExecStart runs a binary no README ln -s line creates. +write_tool "$WORK/unlinked" tool/tool.sh 755 +link "$WORK/unlinked" tool/tool.sh tool +unit "$WORK/unlinked" "$SYS/tool.service" /usr/local/bin/other-tool +expect unlinked fail 'no README' + +# foreign: ExecStart runs nothing from this repo (would otherwise be skipped). +write_tool "$WORK/foreign" tool/tool.sh 755 +link "$WORK/foreign" tool/tool.sh tool +unit "$WORK/foreign" "$SYS/tool.service" /opt/somewhere/tool.sh +expect foreign fail 'does not run a script from this repo' + +# no-mode: ExecStart gives a mode-taking script no mode (F-0024, 2026-09-27). +write_modal_tool "$WORK/no-mode" maint/maint.sh +link "$WORK/no-mode" maint/maint.sh maint +unit "$WORK/no-mode" "$SYS/maint.service" /usr/local/bin/maint +expect no-mode fail 'with no mode' + +# pct-i-plain: %i in a non-template unit expands to nothing, so no mode either. +write_modal_tool "$WORK/pct-i-plain" maint/maint.sh +link "$WORK/pct-i-plain" maint/maint.sh maint +unit "$WORK/pct-i-plain" "$USR/maint.service" "/usr/bin/sudo -n /usr/local/bin/maint %i" +expect pct-i-plain fail 'is not a template' + +# bad-mode: an unknown mode. +write_modal_tool "$WORK/bad-mode" maint/maint.sh +link "$WORK/bad-mode" maint/maint.sh maint +unit "$WORK/bad-mode" "$SYS/maint.service" "/usr/local/bin/maint weekly" +expect bad-mode fail "unknown mode 'weekly'" + +# bad-instance: a template timer instance that is not a mode. +write_modal_tool "$WORK/bad-instance" maint/maint.sh +link "$WORK/bad-instance" maint/maint.sh maint +unit "$WORK/bad-instance" "$SYS/maint@.service" "/usr/local/bin/maint %i" +printf '`%s/maint@weekly.timer`\n' "$SYS" >>"$WORK/bad-instance/README.md" +expect bad-instance fail "maint@weekly.timer" + +# user-no-sudo: a user unit runs the root-only script as the user. +write_modal_tool "$WORK/user-no-sudo" maint/maint.sh +link "$WORK/user-no-sudo" maint/maint.sh maint +unit "$WORK/user-no-sudo" "$USR/maint.service" "%h/dev/fedora-workstation-scripts/maint/maint.sh daily" +expect user-no-sudo fail 'needs root, without sudo' + +# mode-drift: '# smoke-modes' lists a mode the script does not handle. +write_modal_tool "$WORK/mode-drift" maint/maint.sh +sed -i 's/^# smoke-modes: daily monthly$/# smoke-modes: daily monthly weekly/' "$WORK/mode-drift/maint/maint.sh" +link "$WORK/mode-drift" maint/maint.sh maint +unit "$WORK/mode-drift" "$SYS/maint.service" "/usr/local/bin/maint daily" +expect mode-drift fail "no 'weekly)' case branch" + +# nohelp: an entrypoint with no --help branch is reported and never run. +write_tool "$WORK/nohelp" tool/tool.sh 755 +link "$WORK/nohelp" tool/tool.sh tool +unit "$WORK/nohelp" "$SYS/tool.service" /usr/local/bin/tool +mkdir -p "$WORK/nohelp/other" +printf '#!/usr/bin/env bash\ntouch "$(dirname "$0")/RAN"\n' >"$WORK/nohelp/other/other.sh" +expect nohelp fail 'other/other.sh: does not handle --help (not run' +if [[ -e "$WORK/nohelp/other/RAN" ]]; then + printf 'NOT OK nohelp: the smoke test executed a script without a --help branch\n' + status=1 +fi + +# help-fails: an entrypoint whose --help branch exits non-zero. +write_tool "$WORK/help-fails" tool/tool.sh 755 +link "$WORK/help-fails" tool/tool.sh tool +unit "$WORK/help-fails" "$SYS/tool.service" /usr/local/bin/tool +mkdir -p "$WORK/help-fails/other" +printf '#!/usr/bin/env bash\n[[ "${1:-}" == "--help" ]] && exit 3\n' >"$WORK/help-fails/other/other.sh" +expect help-fails fail 'other/other.sh: --help did not exit 0' + +echo "----" +if [[ "$status" -ne 0 ]]; then + echo "SMOKE SELF-TEST FAILED" + exit 1 +fi +echo "SMOKE SELF-TEST PASSED" diff --git a/scripts/test-smoke.sh b/scripts/test-smoke.sh new file mode 100644 index 0000000..87e2ee0 --- /dev/null +++ b/scripts/test-smoke.sh @@ -0,0 +1,256 @@ +#!/usr/bin/env bash +# Smoke test for every shell entrypoint in this repository. +# +# Regression guard for F-0024 +# --------------------------- +# `fedora-maintenance.service` failed on the workstation in three ways, one +# after the other: +# 1. `status=203/EXEC`: the unit's `ExecStart=%h/dev/fedora-workstation-scripts/fedora-maintenance.sh` +# pointed at a path that no longer existed after the scripts moved into +# per-tool subdirectories (`fedora-maintenance/fedora-maintenance.sh`). +# 2. `status=1/FAILURE` after 4ms: with the path fixed, ExecStart passed no +# mode argument, and main() prints usage and exits 1 without one. +# 3. Even with a mode, a `systemctl --user` unit runs as the user, and +# need_root() exits 1 unless the script is started through sudo. +# +# The units are documented in the READMEs, so this test reads the README +# `ln -s` lines and `ExecStart=` lines (not a list kept here) and fails when: +# * a README `ln -s` source path does not exist in the repo (script moved/renamed), +# or that script has no shebang or is not committed as executable (100755) [1] +# * an `ExecStart=` runs a repo path directly (`%h/.../fedora-workstation-scripts/` +# or an absolute path) that is missing, has no shebang or is not 100755 [1] +# * an `ExecStart=` runs a `/usr/local/bin/` or `~/.local/bin/` that no +# README `ln -s` line creates, or runs nothing from this repo at all [1] +# * the script declares `# smoke-modes: ...` and the ExecStart passes no +# mode, an unknown mode, or `%i` in a unit that is not a template +# (`name@.service`), or a `name@.timer` uses an unknown mode [2] +# * a user unit (`~/.config/systemd/user/...`) runs a script that calls +# need_root without going through sudo [3] +# * any entrypoint (every `*.sh` outside `scripts/`) fails `bash -n`, does not +# handle `--help` (checked statically, so it is never run for real), or does +# not exit 0 on `--help` without root. +# +# Usage: bash scripts/test-smoke.sh +# SMOKE_ROOT= bash scripts/test-smoke.sh # check another tree (self-test) +set -uo pipefail + +ROOT="${SMOKE_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +cd "$ROOT" || exit 2 + +REPO_MARKER="/fedora-workstation-scripts/" + +fail=0 +pass() { printf 'PASS %s\n' "$1"; } +bad() { printf 'FAIL %s\n' "$1"; fail=1; } + +# Committed mode wins over the working-tree mode: the file mode is what a fresh +# clone (and therefore the owner's /usr/local/bin symlink) gets. +is_executable() { + local f="$1" mode="" + if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + mode="$(git ls-files -s -- "$f" | awk '{print $1}')" + fi + if [[ -n "$mode" ]]; then + [[ "$mode" == "100755" ]] + else + [[ -x "$f" ]] + fi +} + +has_shebang() { head -n 1 "$1" | grep -q '^#!'; } + +# check_target : can systemd execute it? +check_target() { + local script="$1" what="$2" + if [[ ! -f "$script" ]]; then + bad "$script: missing, but $what (F-0024: would fail with 203/EXEC)" + elif ! has_shebang "$script"; then + bad "$script: no shebang, but $what (would fail with 203/EXEC)" + elif ! is_executable "$script"; then + bad "$script: not committed as executable (100755), but $what (would fail with 203/EXEC; fix: git update-index --chmod=+x $script)" + else + return 0 + fi + return 1 +} + +# Modes a script accepts, from its `# smoke-modes: a b c` line (empty if none). +script_modes() { sed -n 's/^#[[:space:]]*smoke-modes:[[:space:]]*//p' "$1" | head -n 1; } + +# Does the script call need_root (not just define it)? +needs_root() { grep -qE '^[[:space:]]*need_root([[:space:]]|$)' "$1"; } + +# --------------------------------------------------------------------------- +# 1. Symlink targets documented in the READMEs (what systemd actually executes) +# --------------------------------------------------------------------------- +declare -A LINK_SRC=() # link name -> repo-relative script path +while read -r src dst; do + [[ "$src" == *"$REPO_MARKER"* ]] || continue + rel="${src#*"$REPO_MARKER"}" + name="$(basename "$dst")" + if [[ -n "${LINK_SRC[$name]:-}" && "${LINK_SRC[$name]}" != "$rel" ]]; then + bad "READMEs link '$name' to two different scripts: ${LINK_SRC[$name]} and $rel" + continue + fi + LINK_SRC["$name"]="$rel" +done < <(grep -rhoE --include='*.md' --exclude-dir=.git 'ln +-[A-Za-z]*s[A-Za-z]* +[^ ]+ +[^ ]+' . \ + | awk '{print $(NF-1), $NF}') + +if [[ "${#LINK_SRC[@]}" -eq 0 ]]; then + bad "no README 'ln -s /