From ceaabe294fd7714aba5d31b3d1962de7b59b4435 Mon Sep 17 00:00:00 2001 From: Hellpuff Date: Thu, 22 Oct 2026 21:51:26 +0545 Subject: [PATCH 1/3] Fix dotfile data loss, symlink/mode clobbering, --force and --dry-run state bugs, fresh-apt installs, slow-network preflight - managed blocks: rewrite in place (keeps symlinks and mode); refuse damaged markers instead of deleting the rest of the file - shell-profile: an up-to-date block is not an apply failure (--force shell-profile) - --dry-run --force no longer edits the state file - apt: apt-get update once per run before installing (noninteractive) - preflight network probe is a HEAD request with a connect timeout - argument errors exit 3 before any write; profile names validated; --force must name a profile step - bats regression tests for each --- bootstrap.sh | 56 +++++++++++++++++++++++++------- lib/editfile.sh | 34 +++++++++++++++++-- lib/pkg.sh | 9 ++++- lib/preflight.sh | 5 ++- lib/step.sh | 3 +- steps/shell-profile.sh | 6 +++- tests/bootstrap_integration.bats | 39 ++++++++++++++++++++++ tests/editfile.bats | 38 ++++++++++++++++++++++ tests/pkg.bats | 15 +++++++++ tests/preflight.bats | 15 +++++++++ tests/step.bats | 9 +++++ 11 files changed, 211 insertions(+), 18 deletions(-) diff --git a/bootstrap.sh b/bootstrap.sh index 8b8e0a5..0fd04fc 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -45,10 +45,21 @@ Options: EOF } +# Every option that takes a value goes through this, so a missing value is a +# bad argument (exit 3) rather than a bare ${2:?} shell error (exit 1). +need_value() { + if [ "$2" -lt 2 ] || [ -z "$3" ]; then + printf '%s requires a value\n' "$1" >&2 + usage >&2 + exit 3 + fi +} + while [ "$#" -gt 0 ]; do case "$1" in --profile) - PROFILE="${2:?--profile requires a value}" + need_value --profile "$#" "${2:-}" + PROFILE="$2" shift 2 ;; --dry-run) @@ -60,15 +71,18 @@ while [ "$#" -gt 0 ]; do shift ;; --force) - FORCE_STEP="${2:?--force requires a step name}" + need_value --force "$#" "${2:-}" + FORCE_STEP="$2" shift 2 ;; --state-file) - STATE_FILE="${2:?--state-file requires a value}" + need_value --state-file "$#" "${2:-}" + STATE_FILE="$2" shift 2 ;; --log-file) - LOG_FILE_PATH="${2:?--log-file requires a value}" + need_value --log-file "$#" "${2:-}" + LOG_FILE_PATH="$2" shift 2 ;; --skip-preflight) @@ -119,12 +133,38 @@ if [ "$LIST_STEPS" -eq 1 ]; then exit 0 fi +case "$PROFILE" in +"" | *[!A-Za-z0-9_-]*) + printf 'invalid profile name: %s (letters, digits, - and _ only)\n' "$PROFILE" >&2 + exit 3 + ;; +esac + PROFILE_FILE="$BOOTSTRAP_ROOT/profiles/${PROFILE}.conf" if [ ! -f "$PROFILE_FILE" ]; then printf 'unknown profile: %s (looked for %s)\n' "$PROFILE" "$PROFILE_FILE" >&2 exit 3 fi +# Read with a loop rather than `mapfile`: mapfile is a bash 4 builtin and +# macOS still ships bash 3.2, where it does not exist. A bootstrap tool that +# cannot run on macOS is not much of a bootstrap tool. +STEP_IDS=() +while IFS= read -r _line; do + STEP_IDS+=("$_line") +done < <(grep -Ev '^[[:space:]]*(#|$)' "$PROFILE_FILE") + +if [ -n "$FORCE_STEP" ]; then + _force_in_profile=0 + for id in "${STEP_IDS[@]}"; do + [ "$id" = "$FORCE_STEP" ] && _force_in_profile=1 + done + if [ "$_force_in_profile" -ne 1 ]; then + printf -- '--force %s: no such step in profile %s (steps: %s)\n' "$FORCE_STEP" "$PROFILE" "${STEP_IDS[*]}" >&2 + exit 3 + fi +fi + log_init "$LOG_FILE_PATH" log_info "bootstrap-dev starting: profile=$PROFILE dry_run=$DRY_RUN resume=$RESUME_MODE" @@ -139,14 +179,6 @@ if [ "$DRY_RUN" -ne 1 ]; then state_init fi -# Read with a loop rather than `mapfile`: mapfile is a bash 4 builtin and -# macOS still ships bash 3.2, where it does not exist. A bootstrap tool that -# cannot run on macOS is not much of a bootstrap tool. -STEP_IDS=() -while IFS= read -r _line; do - STEP_IDS+=("$_line") -done < <(grep -Ev '^[[:space:]]*(#|$)' "$PROFILE_FILE") - exit_code=0 for id in "${STEP_IDS[@]}"; do if ! run_step "$id"; then diff --git a/lib/editfile.sh b/lib/editfile.sh index b287298..2ec8ccc 100644 --- a/lib/editfile.sh +++ b/lib/editfile.sh @@ -14,8 +14,12 @@ # Public API: # managed_block_render -> block text on stdout # managed_block_current -> current block body on stdout (empty if absent) -# managed_block_apply -> writes file, returns 0 if changed, 1 if unchanged +# managed_block_apply -> writes file, returns 0 if changed, 1 if unchanged, +# 2 if the file's markers are damaged (nothing written) # Honors DRY_RUN=1: prints the change and returns without writing. +# +# The file is rewritten in place (its contents replaced, not the file itself), so a +# symlinked dotfile stays a symlink and the file keeps its owner and permissions. if [ -n "${BOOTSTRAP_EDITFILE_SH_LOADED:-}" ]; then return @@ -67,6 +71,25 @@ managed_block_apply() { source="/dev/null" fi + # Refuse to touch a file whose markers are not exactly one start line followed by + # one end line: with the end marker missing, the replacement below would treat + # everything after the start marker as part of the block and delete it. + if [ "$existing" -eq 1 ]; then + local markers + markers="$(awk -v start="$start" -v end="$end" ' + $0 == start { s++; if (e > 0) order = "bad" } + $0 == end { e++; if (s == 0) order = "bad" } + END { print s + 0, e + 0, (order == "" ? "ok" : order) } + ' "$file")" + case "$markers" in + "0 0 ok" | "1 1 ok") ;; + *) + log_error "managed block '$id' in $file has damaged markers (start count, end count, order: $markers); fix or remove them by hand, nothing was changed" + return 2 + ;; + esac + fi + local tmp tmp="$(mktemp)" @@ -120,6 +143,13 @@ managed_block_apply() { fi mkdir -p "$(dirname -- "$file")" - mv -- "$tmp" "$file" + # Write through the existing path instead of renaming over it: keeps symlinks + # (e.g. a dotfile manager's link) intact and keeps the file's permissions. + if ! cat -- "$tmp" >"$file"; then + rm -f "$tmp" + log_error "could not write managed block '$id' to $file" + return 2 + fi + rm -f "$tmp" return 0 } diff --git a/lib/pkg.sh b/lib/pkg.sh index fa3d0ca..24bcd4e 100644 --- a/lib/pkg.sh +++ b/lib/pkg.sh @@ -56,7 +56,14 @@ pkg_install() { pkg_detect || return 1 case "$PKG_MANAGER" in apt) - pkg_run_privileged apt-get install -y "$@" + # A fresh Debian/Ubuntu install (or container) has no package lists, so + # install fails with "Unable to locate package" until they are fetched. + # Refresh once per run; noninteractive so tzdata & co. cannot block. + if [ "${BOOTSTRAP_APT_UPDATED:-0}" != "1" ]; then + pkg_run_privileged env DEBIAN_FRONTEND=noninteractive apt-get update || return 1 + BOOTSTRAP_APT_UPDATED=1 + fi + pkg_run_privileged env DEBIAN_FRONTEND=noninteractive apt-get install -y "$@" ;; dnf) pkg_run_privileged dnf install -y "$@" diff --git a/lib/preflight.sh b/lib/preflight.sh index 86eae8e..3954799 100644 --- a/lib/preflight.sh +++ b/lib/preflight.sh @@ -45,7 +45,10 @@ preflight_check_network() { fi local host="${1:-github.com}" if command -v curl >/dev/null 2>&1; then - if curl -fsS --max-time 5 -o /dev/null "https://${host}"; then + # HEAD only: reachability needs a response, not the page body. Fetching + # the full github.com page under a 5s cap reported "unreachable" on any + # link slow enough to need more than 5s for ~300 KB. + if curl -fsS --head --connect-timeout 5 --max-time 15 -o /dev/null "https://${host}"; then log_info "preflight: network reachable ($host)" return 0 fi diff --git a/lib/step.sh b/lib/step.sh index 4d69361..83c3ef1 100644 --- a/lib/step.sh +++ b/lib/step.sh @@ -59,7 +59,8 @@ run_step() { local force=0 if [ "${FORCE_STEP:-}" = "$id" ]; then force=1 - state_clear "$id" + # --dry-run must not touch the state file. + [ "${DRY_RUN:-0}" = "1" ] || state_clear "$id" fi if [ "$force" -ne 1 ] && [ "${RESUME_MODE:-0}" = "1" ] && state_is_done "$id"; then diff --git a/steps/shell-profile.sh b/steps/shell-profile.sh index 8f6a67d..f27fafa 100644 --- a/steps/shell-profile.sh +++ b/steps/shell-profile.sh @@ -24,7 +24,11 @@ step_shell-profile_check() { } step_shell-profile_apply() { - managed_block_apply "$BOOTSTRAP_SHELL_PROFILE_FILE" "$BOOTSTRAP_SHELL_PROFILE_ID" "$(_shell_profile_content)" + # managed_block_apply returns 1 when the block is already up to date; that is + # success for a step (e.g. under --force). Only 2 means it could not apply. + local rc=0 + managed_block_apply "$BOOTSTRAP_SHELL_PROFILE_FILE" "$BOOTSTRAP_SHELL_PROFILE_ID" "$(_shell_profile_content)" || rc=$? + [ "$rc" -le 1 ] } step_shell-profile_verify() { diff --git a/tests/bootstrap_integration.bats b/tests/bootstrap_integration.bats index 7a0eb66..dda665a 100644 --- a/tests/bootstrap_integration.bats +++ b/tests/bootstrap_integration.bats @@ -92,3 +92,42 @@ run_bootstrap() { run run_bootstrap --profile minimal [[ "$output" == *"failed: 0"* ]] } + +# Regression: the README's "--force shell-profile" example failed because the +# managed-block editor reports "already up to date" with exit 1. +@test "--force shell-profile on an up-to-date block succeeds" { + export BOOTSTRAP_SHELL_PROFILE_FILE="$HOME/.bashrc" + run_bootstrap --profile minimal + run run_bootstrap --profile minimal --force shell-profile + [ "$status" -eq 0 ] + [[ "$output" == *"done: 'shell-profile'"* ]] + [ "$(grep -c 'bootstrap-dev:shell-profile >>>' "$BOOTSTRAP_SHELL_PROFILE_FILE")" -eq 1 ] +} + +@test "--dry-run --force leaves the state file unchanged" { + export BOOTSTRAP_SHELL_PROFILE_FILE="$HOME/.bashrc" + run_bootstrap --profile minimal + cp "$STATE_FILE" "$TEST_TMP/state.before" + run run_bootstrap --profile minimal --dry-run --force git-defaults + [ "$status" -eq 0 ] + cmp -s "$STATE_FILE" "$TEST_TMP/state.before" +} + +@test "a damaged shell-profile block fails the run without losing user lines" { + export BOOTSTRAP_SHELL_PROFILE_FILE="$HOME/.bashrc" + printf '# >>> bootstrap-dev:shell-profile >>>\nmine 1\nmine 2\n' >"$BOOTSTRAP_SHELL_PROFILE_FILE" + run run_bootstrap --profile minimal + [ "$status" -eq 1 ] + [ "$(grep -c '^mine' "$BOOTSTRAP_SHELL_PROFILE_FILE")" -eq 2 ] +} + +@test "bad arguments exit 3 before anything is written" { + run bash "$ROOT/bootstrap.sh" --profile + [ "$status" -eq 3 ] + run run_bootstrap --profile ../tests/x + [ "$status" -eq 3 ] + run run_bootstrap --profile minimal --force no-such-step + [ "$status" -eq 3 ] + [ ! -e "$STATE_FILE" ] + [ ! -e "$LOG_FILE" ] +} diff --git a/tests/editfile.bats b/tests/editfile.bats index 9bab594..423d290 100644 --- a/tests/editfile.bats +++ b/tests/editfile.bats @@ -101,3 +101,41 @@ line two" DRY_RUN=1 run managed_block_apply "$TARGET" "demo" "export FOO=bar" [ "$status" -eq 1 ] } + +# Regression: the file used to be replaced via `mv` of a mktemp file, which +# turned a symlinked dotfile into a regular file and reset its mode to 0600. +@test "managed_block_apply keeps a symlinked dotfile a symlink" { + printf 'existing line\n' >"$TEST_TMP/real-dotfile" + ln -s "$TEST_TMP/real-dotfile" "$TARGET" + managed_block_apply "$TARGET" "demo" "export FOO=bar" + [ -L "$TARGET" ] + grep -qF 'export FOO=bar' "$TEST_TMP/real-dotfile" + grep -qF 'existing line' "$TEST_TMP/real-dotfile" +} + +@test "managed_block_apply keeps the file's permissions" { + printf 'existing line\n' >"$TARGET" + chmod 644 "$TARGET" + managed_block_apply "$TARGET" "demo" "export FOO=bar" + [ "$(find "$TARGET" -perm 644 | wc -l | tr -d ' ')" = "1" ] +} + +# Regression: with the end marker missing, everything after the start marker +# was treated as block content and deleted. +@test "a missing end marker is refused (exit 2) and the file is left untouched" { + printf 'before\n# >>> bootstrap-dev:demo >>>\nold\nuser line 1\nuser line 2\n' >"$TARGET" + cp "$TARGET" "$TEST_TMP/expected" + run managed_block_apply "$TARGET" "demo" "export FOO=bar" + [ "$status" -eq 2 ] + [[ "$output" == *"damaged markers"* ]] + cmp -s "$TARGET" "$TEST_TMP/expected" +} + +@test "duplicate or out-of-order markers are refused (exit 2)" { + printf '# <<< bootstrap-dev:demo <<<\n# >>> bootstrap-dev:demo >>>\nx\n' >"$TARGET" + run managed_block_apply "$TARGET" "demo" "export FOO=bar" + [ "$status" -eq 2 ] + printf '# >>> bootstrap-dev:demo >>>\na\n# <<< bootstrap-dev:demo <<<\n# >>> bootstrap-dev:demo >>>\nb\n# <<< bootstrap-dev:demo <<<\n' >"$TARGET" + run managed_block_apply "$TARGET" "demo" "export FOO=bar" + [ "$status" -eq 2 ] +} diff --git a/tests/pkg.bats b/tests/pkg.bats index fb3ab37..fe48320 100644 --- a/tests/pkg.bats +++ b/tests/pkg.bats @@ -86,3 +86,18 @@ EOF run pkg_install [ "$status" -eq 0 ] } + +# Regression: a fresh Debian/Ubuntu system has no package lists, so +# `apt-get install` failed with "Unable to locate package" until updated. +@test "pkg_install on apt refreshes package lists once, before the first install" { + stub_pkg_manager apt-get + PKG_MANAGER="" + BOOTSTRAP_APT_UPDATED=0 + pkg_install git + pkg_install curl + run cat "$CALL_LOG" + [ "${lines[0]}" = "update" ] + [[ "${lines[1]}" == *"install -y git"* ]] + [[ "${lines[2]}" == *"install -y curl"* ]] + [ "${#lines[@]}" -eq 3 ] +} diff --git a/tests/preflight.bats b/tests/preflight.bats index 0950c35..ce53b72 100644 --- a/tests/preflight.bats +++ b/tests/preflight.bats @@ -58,3 +58,18 @@ teardown() { BOOTSTRAP_MIN_DISK_MB=1 BOOTSTRAP_SKIP_NETWORK_CHECK=1 BOOTSTRAP_SKIP_SUDO_CHECK=1 run preflight_run [ "$status" -eq 0 ] } + +# Regression: the probe downloaded the whole page with --max-time 5, so a slow +# but working connection failed preflight. It must be a HEAD request. +@test "network check sends a HEAD request with a connect timeout" { + cat >"$FAKE_BIN/curl" <"$TEST_TMP/curl.args" +exit 0 +EOF + chmod +x "$FAKE_BIN/curl" + run preflight_check_network github.com + [ "$status" -eq 0 ] + grep -q -- '--head' "$TEST_TMP/curl.args" + grep -q -- '--connect-timeout' "$TEST_TMP/curl.args" +} diff --git a/tests/step.bats b/tests/step.bats index cf5baa4..b9014af 100644 --- a/tests/step.bats +++ b/tests/step.bats @@ -127,3 +127,12 @@ teardown() { run step_summary_print [[ "$output" == *"changed: 1"* ]] } + +# Regression: --force cleared the step from the state file even under --dry-run. +@test "DRY_RUN=1 with FORCE_STEP does not modify the state file" { + printf 'good\n' >"$STATE_FILE" + cp "$STATE_FILE" "$TEST_TMP/state.before" + DRY_RUN=1 FORCE_STEP=good run run_step good + [ "$status" -eq 0 ] + cmp -s "$STATE_FILE" "$TEST_TMP/state.before" +} From 4295456c86420038cdba5a902f8e14c7ef032df5 Mon Sep 17 00:00:00 2001 From: Hellpuff Date: Thu, 22 Oct 2026 21:51:27 +0545 Subject: [PATCH 2/3] Fix clone URL, document exit codes, env vars and limitations; add SECURITY.md, .editorconfig, CI read-only permissions --- .editorconfig | 17 +++++++++++++++++ .github/workflows/ci.yml | 3 +++ CHANGELOG.md | 30 +++++++++++++++++++++++++++++ README.md | 41 ++++++++++++++++++++++++++++++++++++---- SECURITY.md | 20 ++++++++++++++++++++ 5 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 .editorconfig create mode 100644 SECURITY.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7a485d0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{sh,bash,bats}] +indent_style = tab + +[*.{yml,yaml,md}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fcc070..58241e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: bats: name: "bats: ${{ matrix.os }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index c3c593d..1c20964 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,36 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [Unreleased] + +### Fixed + +- The managed-block editor replaced the dotfile with a new file via `mv`, + which turned a symlinked `~/.bashrc` (e.g. from a dotfiles manager) into a + regular file and reset its mode to 0600. It now rewrites the file in place. +- With the block's end marker missing, every line after the start marker was + treated as block content and deleted. Damaged markers (missing, duplicated + or out-of-order) now fail the step and leave the file untouched + (`managed_block_apply` returns 2). +- `--force shell-profile` (a README example) failed whenever the block was + already up to date, because the editor's "unchanged" status was treated as + an apply failure. +- `--dry-run --force ` removed the step from the state file. +- On a fresh Debian/Ubuntu system every package install failed with "Unable + to locate package": the `apt` backend now runs `apt-get update` once per + run (non-interactively) before installing. +- A missing option value exited 1 instead of the documented 3; profile names + are validated (letters, digits, `-`, `_`); `--force` with a step that is not + in the profile is an error (3) instead of a silent no-op. All are rejected + before the log or state file is written. +- README: the clone URL pointed at `example.com`. + +### Added + +- Regression tests for each fix; `SECURITY.md`, `.editorconfig`, + `docs/PRODUCTION_READINESS.md`; CI `permissions: contents: read`; README + environment-variable table and limitations. + ## [0.1.0] - 2026-08-30 ### Added diff --git a/README.md b/README.md index 915d187..9cce5b0 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,8 @@ summary. no log directory changes beyond the log itself, and no dotfile edits. - Package manager abstraction (`lib/pkg.sh`): detects `apt`, `dnf`, `pacman`, or `brew` and dispatches to it; fails with a clear error on an unsupported - platform rather than guessing. + platform rather than guessing. On `apt` it refreshes the package lists once + per run before the first install (a fresh system has none). - Profiles (`profiles/*.conf`): a profile is a plain list of step ids, selected with `--profile`. - Preflight checks (`lib/preflight.sh`) that run before anything changes: @@ -100,7 +101,7 @@ List them at any time with `--list-profiles`; list every known step with ## Installation ```sh -git clone https://example.com/bootstrap-dev.git +git clone https://github.com/hellpuffyt/bootstrap-dev.git cd bootstrap-dev chmod +x bootstrap.sh ``` @@ -145,7 +146,19 @@ Options: ``` Exit codes: `0` success, `1` a step failed, `2` preflight failed, `3` a bad -argument or unknown profile. +argument (including a missing option value), an unknown or invalid profile +name, or `--force` naming a step that is not in the profile. + +Environment variables (all optional): + +| Variable | Effect | +|---|---| +| `BOOTSTRAP_SKIP_NETWORK_CHECK=1` | Skip the preflight HTTPS probe of github.com. | +| `BOOTSTRAP_SKIP_SUDO_CHECK=1` | Skip the preflight sudo check. | +| `BOOTSTRAP_MIN_DISK_MB` | Free space required by preflight (default 500). | +| `BOOTSTRAP_CONFLICT_BINS` | Space-separated binaries whose presence fails preflight. | +| `BOOTSTRAP_SHELL_PROFILE_FILE` | File the `shell-profile` step edits (default `~/.bashrc`). | +| `STATE_FILE` / `LOG_FILE_PATH` | Same as `--state-file` / `--log-file`. | ## Writing a step @@ -204,7 +217,10 @@ Rules for a good step: no-op). - Dotfile edits go through managed blocks, never raw appends: the marker comments make the boundary of what `bootstrap-dev` owns explicit and - removable. + removable. The file is rewritten in place, so a symlinked dotfile stays a + symlink and keeps its permissions. If the markers are damaged (an end + marker missing, duplicated or out of order), the step fails and the file + is left untouched rather than guessing where the block ends. - A failing `verify` fails the whole run immediately rather than continuing past a step that silently didn't take effect. - `--dry-run` never calls a step's real `apply` path — steps receive @@ -249,6 +265,8 @@ and preflight checks including their failure paths. ## Security +To report a vulnerability, see [SECURITY.md](SECURITY.md). + - `bootstrap-dev` never pipes a fetched script straight into a shell (`curl ... | bash`) from an unpinned URL. If a step needs to fetch something, the rule is: pin the URL to a specific version and verify a @@ -262,6 +280,21 @@ and preflight checks including their failure paths. - `set -euo pipefail` throughout, all variable expansions quoted, no `eval` of untrusted input anywhere in the codebase. +## Limitations + +- Verified end to end only in throwaway containers (Ubuntu 24.04 with `apt`, + Fedora with `dnf`, see `docs/PRODUCTION_READINESS.md`); `pacman` and + `brew` dispatch is covered by stubbed tests only. It has not been run + against a real macOS or desktop Linux install. +- `shell-profile` edits `~/.bashrc` by default. On macOS the default login + shell is zsh, which does not read it; point `BOOTSTRAP_SHELL_PROFILE_FILE` + at `~/.zshrc` there. +- Steps are run inside `if`, where bash disables `set -e`; a multi-command + `apply` can therefore continue past a failing command. The separate + `verify` call is what catches a step that did not take effect. +- No locking: two concurrent runs against the same state file are not + supported. + ## License MIT, see [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..aa9442a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,20 @@ +# Security policy + +## Supported versions + +Only the latest commit on `main` is supported. + +## Reporting a vulnerability + +Please report security issues privately through GitHub: +https://github.com/hellpuffyt/bootstrap-dev/security/advisories/new + +Do not open a public issue for a vulnerability. + +## Scope + +bootstrap-dev installs packages and edits dotfiles on the machine it runs on, +using `sudo` only for the package-manager call. In scope: anything that makes +it change more than its steps declare, run during `--dry-run`, lose or corrupt +user data in edited files, escalate privileges beyond the package-manager +call, or fetch and execute unverified content. From c034fb5c5a7f1ca467400872387d44a12567ea34 Mon Sep 17 00:00:00 2001 From: Hellpuff Date: Thu, 22 Oct 2026 21:52:14 +0545 Subject: [PATCH 3/3] Add production-readiness report --- docs/PRODUCTION_READINESS.md | 137 +++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 docs/PRODUCTION_READINESS.md diff --git a/docs/PRODUCTION_READINESS.md b/docs/PRODUCTION_READINESS.md new file mode 100644 index 0000000..bfd6f51 --- /dev/null +++ b/docs/PRODUCTION_READINESS.md @@ -0,0 +1,137 @@ +# Production readiness — bootstrap-dev + +Audit of the `audit/production-readiness` branch. Evidence only; the +classification at the end is a proposal for the maintainer to confirm. + +- Tested commit: `4295456c86420038cdba5a902f8e14c7ef032df5` (this file was + added in the next commit and changes no code) +- Date: 2026-10-22 +- Host: macOS 27 (Apple Silicon). Tools: ShellCheck 0.11.0, bats 1.14.0 + (inside the `bats/bats:latest` container), shfmt v3.9.0 (the version CI + pins, inside `mvdan/shfmt:v3.9.0`), Docker 29.5.2. + +## Safety rule followed + +The tool installs packages and edits dotfiles, so it was **never run in apply +mode on the audit host**. On the host, only `--dry-run`, `--list-*` and +`--help` were run, with the HOME listing and checksums of `~/.bashrc`, +`~/.zshrc` and `~/.gitconfig` compared before and after (unchanged). The bats +suite and every real run happened in throwaway `docker run --rm` containers +with the repository mounted read-only. + +## Scope + +A bash bootstrapper: profiles list steps; each step has +`check`/`apply`/`verify` (+ optional describe/rollback hint); `run_step` +drives resume-skip → check → apply → verify → record. Libraries: package +manager abstraction (apt/dnf/pacman/brew), managed-block dotfile editor, +state file, preflight checks, logging. Six steps, four profiles. + +## Clean-clone verification + +Fresh clone of the branch into an empty scratch directory, README followed +literally (apply-mode commands are replaced by `--dry-run` on the host and run +for real only in containers, below): + +| Command | Exit | Result | +|---|---|---| +| `chmod +x bootstrap.sh` | 0 | | +| `./bootstrap.sh --profile web --dry-run` (scratch state/log paths) | 0 | plan printed; no state file; HOME unchanged | +| `./bootstrap.sh --list-steps`, `--list-profiles`, `--help` | 0 | 6 steps, 4 profiles | +| `docker run --rm -v "$PWD:/w:ro" -w /w bats/bats:latest tests/` | 0 | 69 passed, 0 failed, 0 skipped | + +## Real runs in throwaway containers (after the fixes) + +`docker run --rm` as root, repository mounted read-only and copied inside, +`BOOTSTRAP_SKIP_NETWORK_CHECK=1` (see bug 7): + +| Scenario | `ubuntu:24.04`, profile `ops` | `fedora:41`, profile `minimal` | +|---|---|---| +| `--dry-run` on a fresh system | exit 0, no state file, no `~/.bashrc` | same | +| run 1 (fresh system, no manual `apt-get update`) | exit 0, changed 4 | exit 0, changed 3 | +| run 2 (idempotency) | exit 0, changed 0, skipped 4, one managed block | exit 0, changed 0, skipped 3, one block | +| `--force shell-profile` (README example) | exit 0 | exit 0 | +| symlinked `~/.bashrc`, mode 644, block edited | stays a symlink, mode 644 | same | +| block with missing end marker + user lines | exit 1, both user lines kept | same | +| run interrupted at `shell-profile`, then `--resume` | state had the earlier steps; resume exit 0, changed 1, skipped the rest | same | + +On the macOS host, `--dry-run` for all four profiles: exit 0, HOME unchanged. + +## Commands executed (working clone) + +| Command | Exit | Outcome | +|---|---|---| +| `shellcheck bootstrap.sh lib/*.sh steps/*.sh` (as CI) | 0 | clean, before and after | +| `shfmt -d .` (v3.9.0 container) | 0 | clean, before and after | +| bats in container, baseline `35f1d05` | 0 | 58 passed, 0 failed | +| baseline container probes (`ubuntu:24.04`) | — | bugs 1-6 reproduced (see below) | +| new tests against baseline sources (`git stash` of `bootstrap.sh lib steps`) | 1 | 10 of the new tests fail | +| bats in container, after | 0 | 69 passed, 0 failed, 0 skipped | +| history secret scan (`git grep` over every commit) | — | no hits | + +No dependency audit applies: the tool has no package dependencies (it calls +the system package manager and coreutils). CI installs `shfmt` with a pinned +`go install …@v3.9.0`. + +## Bugs found and fixed + +| # | Bug (reproduced how) | Fix | Regression test | +|---|---|---|---| +| 1 | **Data loss**: with the end marker missing, every line after the start marker was deleted (container: 2 user lines → 0). | Exactly one start then one end marker required; otherwise the file is left untouched and the step fails (`managed_block_apply` returns 2). | `editfile.bats`: missing end marker, duplicate/out-of-order markers; integration: damaged block keeps user lines | +| 2 | The dotfile was replaced via `mv` of a `mktemp` file: a symlinked `~/.bashrc` became a regular file, and mode 644 became 600 (container). | Rewrite in place. | `editfile.bats`: symlink kept, permissions kept | +| 3 | `--force shell-profile` (README example) failed whenever the block was already current: "unchanged" (1) was treated as an apply failure (container, exit 1). | The step accepts 0/1, fails on 2. | integration: `--force shell-profile on an up-to-date block succeeds` | +| 4 | `--dry-run --force ` removed the step from the state file (container: state diff). | No state writes in dry-run. | `step.bats`, integration | +| 5 | Fresh Debian/Ubuntu: `apt-get install` failed with "Unable to locate package"; no `apt-get update` (container, exit 1). | `apt-get update` once per run, noninteractive. | `pkg.bats`: update once, before first install | +| 6 | A missing option value exited 1, not the documented 3; `--profile ../x` reached outside `profiles/`; `--force no-such-step` silently did nothing (exit 0). | Validated before the log/state are written; exit 3. | integration: `bad arguments exit 3 before anything is written` | +| 7 | Preflight network check downloaded the whole github.com page with `--max-time 5`; on a slow link it reported "unreachable" (exit 2): reproduced on the host and in the container. | HEAD request, `--connect-timeout 5 --max-time 15`. | `preflight.bats`: HEAD with connect timeout | +| 8 | README clone URL was `example.com`. | Real URL. | clean clone | + +## Security review + +- No `eval`, no `curl | sh`; variables quoted; `set -euo pipefail`. +- `sudo` is used only for the package-manager command (`env + DEBIAN_FRONTEND=noninteractive apt-get …` under sudo for apt). +- Profile names are restricted to `[A-Za-z0-9_-]`; step ids from profiles are + only used as quoted parts of function names. +- Dotfile edits can no longer destroy user content (bug 1) or replace links + (bug 2). +- CI now declares `permissions: contents: read`; action references are tag + pins (`@v4`), left as they were. `SECURITY.md` added. + +## Limitations and unresolved risks + +- `pacman` and `brew` dispatch are covered only by stubbed tests; no real + Arch or macOS apply run was performed (macOS: dry-run only, by rule). +- `shell-profile` targets `~/.bashrc` by default, which zsh (macOS default) + does not read; documented with the override. +- Steps run inside `if`, so `set -e` does not apply inside `apply`; `verify` + is the safety net (documented). +- No locking against concurrent runs. + +## CI status + +- Default branch (`35f1d05`): success. +- Audit branch: pending until pushed. + +## Acceptance checklist + +| Item | Expected behavior | Command / test | Result | Actual | Reference | +|---|---|---|---|---|---| +| Lint | shellcheck + shfmt clean | CI commands locally | pass | 0 findings | ci.yml | +| Unit/integration tests | all pass | bats (container) | pass | 69/69 | tests/ | +| Idempotent | second run changes nothing; one managed block | container run 2 | pass | changed 0 (Ubuntu, Fedora) | lib/step.sh | +| Verified | a step that does not take effect fails the run | `step.bats` failing-verify test | pass | exit 1 + rollback hint | lib/step.sh | +| Resumable | interrupted run resumes, skipping finished steps | container interrupt + `--resume` | pass | skipped done steps | lib/state.sh | +| Dry-run touches nothing | no state, no dotfile edits, HOME unchanged | host (4 profiles) + containers + CI smoke | pass | unchanged | bootstrap.sh | +| Fresh distro install | works without manual prep | `ubuntu:24.04`, `fedora:41` | pass | exit 0 | bug 5 | +| User data safety | damaged markers, symlinks, modes preserved | containers + `editfile.bats` | pass | preserved | bugs 1, 2 | +| `--force` | re-runs one step, including an up-to-date one | containers + integration | pass | exit 0 | bug 3 | +| Exit codes | 0/1/2/3 as documented | integration tests | pass | — | bug 6 | + +## Classification proposal + +**Partially Verified**: lint, the full test suite and real apply runs on fresh +Ubuntu and Fedora containers pass, including the idempotency, resume and +dry-run guarantees. Arch (`pacman`) and macOS (`brew`) apply paths are only +covered by stubbed tests (macOS was dry-run only by design), and the CI legs +have not run on this branch.