diff --git a/examples/deploy.sh b/examples/deploy.sh index be3959e..420e711 100755 --- a/examples/deploy.sh +++ b/examples/deploy.sh @@ -1,7 +1,8 @@ #!/bin/bash -# >>> fetch-runner-guard:BEGIN user=deploy-user -if [ "$(whoami)" != "deploy-user" ] || [ "$(id -u)" -eq 0 ]; then - printf 'fetch-runner-guard: refusing to run as %s (uid %s); required: deploy-user, non-root\n' "$(whoami)" "$(id -u)" >&2 +# >>> fetch-runner-guard:BEGIN +DEPLOY_USER=deploy-user +if [ "$(whoami)" != "$DEPLOY_USER" ] || [ "$(id -u)" -eq 0 ]; then + printf 'fetch-runner-guard: refusing to run as %s (uid %s); required: %s, non-root\n' "$(whoami)" "$(id -u)" "$DEPLOY_USER" >&2 exit 1 fi # <<< fetch-runner-guard:END diff --git a/pyproject.toml b/pyproject.toml index 65a8c23..50bd4e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "uv_build" [project] name = "fetch-runner" -version = "0.1.0" +version = "0.1.1" description = "Poll git branches and run scripts when new commits arrive" readme = "README.md" requires-python = ">=3.11" diff --git a/src/fetch_runner/guard.py b/src/fetch_runner/guard.py index ece2406..dd173d3 100644 --- a/src/fetch_runner/guard.py +++ b/src/fetch_runner/guard.py @@ -22,8 +22,9 @@ from dataclasses import dataclass from pathlib import Path -GUARD_BEGIN_MARKER_PREFIX = "# >>> fetch-runner-guard:BEGIN" +GUARD_BEGIN_MARKER = "# >>> fetch-runner-guard:BEGIN" GUARD_END_MARKER = "# <<< fetch-runner-guard:END" +GUARD_USER_ASSIGNMENT_PREFIX = "DEPLOY_USER=" # Env vars fetch-runner sets per script. Sudo's --preserve-env= and sudoers # env_keep both render from this tuple to prevent drift. @@ -38,10 +39,11 @@ # bytes against the rendered output exactly, so "helpful" rewrites do not # silently weaken the check. _GUARD_TEMPLATE = ( - "# >>> fetch-runner-guard:BEGIN user={user}\n" - 'if [ "$(whoami)" != "{user}" ] || [ "$(id -u)" -eq 0 ]; then\n' + f"{GUARD_BEGIN_MARKER}\n" + f"{GUARD_USER_ASSIGNMENT_PREFIX}{{user}}\n" + 'if [ "$(whoami)" != "$DEPLOY_USER" ] || [ "$(id -u)" -eq 0 ]; then\n' " printf 'fetch-runner-guard: refusing to run as %s (uid %s);" - ' required: {user}, non-root\\n\' "$(whoami)" "$(id -u)" >&2\n' + ' required: %s, non-root\\n\' "$(whoami)" "$(id -u)" "$DEPLOY_USER" >&2\n' " exit 1\n" "fi\n" f"{GUARD_END_MARKER}\n" @@ -131,17 +133,10 @@ def validate_canonical_script_guard( if script_lines and script_lines[0].startswith("#!"): current_line_index = 1 - expected_guard_begin_marker = f"# >>> fetch-runner-guard:BEGIN user={expected_user_name}" while current_line_index < len(script_lines): stripped_line = script_lines[current_line_index].strip() - if stripped_line == expected_guard_begin_marker: + if stripped_line == GUARD_BEGIN_MARKER: break - if stripped_line.startswith(GUARD_BEGIN_MARKER_PREFIX): - return ScriptGuardValidation( - False, - f"{script_path}:{current_line_index + 1}: guard BEGIN marker targets a different " - f"user (expected {expected_user_name!r}); found {stripped_line!r}", - ) # Allow only comments before the guard. Even benign shell code like # `set -e` can change behavior before the identity check runs. if stripped_line == "" or stripped_line.startswith("#"): @@ -158,6 +153,20 @@ def validate_canonical_script_guard( f"{script_path}: canonical guard block for user {expected_user_name!r} not found", ) + user_assignment_line_index = current_line_index + 1 + if user_assignment_line_index < len(script_lines): + user_assignment_line = script_lines[user_assignment_line_index] + expected_user_assignment = f"{GUARD_USER_ASSIGNMENT_PREFIX}{expected_user_name}" + if ( + user_assignment_line.startswith(GUARD_USER_ASSIGNMENT_PREFIX) + and user_assignment_line != expected_user_assignment + ): + return ScriptGuardValidation( + False, + f"{script_path}:{user_assignment_line_index + 1}: guard targets a different " + f"user (expected {expected_user_name!r}); found {user_assignment_line!r}", + ) + expected_guard_lines = render_canonical_script_guard(expected_user_name).splitlines() for guard_line_offset, expected_line in enumerate(expected_guard_lines): line_number = current_line_index + guard_line_offset + 1 diff --git a/tests/test_guard.py b/tests/test_guard.py index c1b737d..1125c2d 100644 --- a/tests/test_guard.py +++ b/tests/test_guard.py @@ -17,8 +17,8 @@ def test_render_guard_embeds_user_everywhere(): rendered_guard = render_canonical_script_guard("deploy") - assert "user=deploy" in rendered_guard - assert '"$(whoami)" != "deploy"' in rendered_guard + assert "DEPLOY_USER=deploy\n" in rendered_guard + assert '"$(whoami)" != "$DEPLOY_USER"' in rendered_guard assert '"$(id -u)" -eq 0' in rendered_guard assert rendered_guard.endswith("# <<< fetch-runner-guard:END\n") @@ -90,7 +90,7 @@ def test_validate_script_guard_rejects_code_before_guard(tmp_path: Path): def test_validate_script_guard_detects_flipped_comparator(tmp_path: Path): # The most dangerous form of tampering: a check that always passes. canonical_guard = render_canonical_script_guard("deploy") - tampered_guard = canonical_guard.replace('!= "deploy"', '== "deploy"') + tampered_guard = canonical_guard.replace('!= "$DEPLOY_USER"', '= "$DEPLOY_USER"') assert tampered_guard != canonical_guard script_path = _write_script_file( tmp_path / "s.sh", diff --git a/uv.lock b/uv.lock index 345bace..4b18ccf 100644 --- a/uv.lock +++ b/uv.lock @@ -31,7 +31,7 @@ wheels = [ [[package]] name = "fetch-runner" -version = "0.1.0" +version = "0.1.1" source = { editable = "." } [package.dev-dependencies]