Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions examples/deploy.sh
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
33 changes: 21 additions & 12 deletions src/fetch_runner/guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"
Expand Down Expand Up @@ -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("#"):
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions tests/test_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.