fix(cron): lifecycle guard — never crash on binary referenced paths, stop matching lifecycle words inside SQL/text

Two live failures on the same guard (cron/lifecycle_guard.py), both of
which blocked legitimate diagnostics from inside the gateway:

1. Crash class: the referenced-script walk read compiled binaries as if
   they were shell scripts. Reading/inspecting a referenced file is now
   best-effort by construction: executable magic numbers (ELF, PE,
   Mach-O fat/thin) short-circuit before any full read via a 4KB sniff,
   NUL-bearing heads are skipped as non-scripts, and unreadable paths of
   every kind (NUL bytes in the token, ENAMETOOLONG, missing files)
   degrade to "nothing to scan" instead of raising. A second fail-safe
   layer wraps the pure-string fallback so the boundary function stays
   total even if the tokenizer itself fails.

2. False-positive class: the lifecycle regex matched its command shapes
   inside DATA arguments — SQL string literals passed to sqlite3/psql
   and grep/rg/journalctl patterns hunting for the lifecycle string in
   logs. Added a fail-closed second-pass exemption: on a raw regex hit,
   re-scan with data-sink executables' arguments masked; only a match
   that survives (i.e. sits in command position) blocks. Masking is
   skipped for pipes into shells/xargs, command/process substitution,
   sqlite3 dot-commands and psql backslash escapes, so it can only ever
   allow, never miss.

Behavioral tests: exact live false-positive shapes as negatives, the
smuggling shapes as positives, the kill-primitive positive catalog
unchanged, and an adversarial never-raises suite (NUL bytes, non-UTF-8,
/dev/*, directories, missing files, magic-prefix binaries).
This commit is contained in:
Teknium 2026-08-06 07:04:48 -07:00
parent 863e313185
commit 70de958921
2 changed files with 301 additions and 11 deletions

View File

@ -109,6 +109,42 @@ _MAX_REFERENCED_SCRIPT_BYTES = 1024 * 1024
_MAX_REFERENCED_SCRIPT_DEPTH = 8
_CONTROL_CHARS = frozenset(";&|()")
# Executables whose arguments are DATA, not commands: search patterns, SQL
# statements, log filters. None of these can execute their argument text, so
# a lifecycle-shaped string inside their arguments (a grep pattern hunting
# for `systemctl restart hermes-gateway` in syslog, a SQL LIKE literal over a
# restart-events table) is diagnostics, not a lifecycle command. Deliberately
# conservative: no `awk` (system()), no `sed` (`s///e`), no `echo`/`printf`
# (routinely piped into a shell), no `mysql` (`\\!` and `system` escapes).
_DATA_SINK_EXECUTABLES = frozenset(
{"grep", "egrep", "fgrep", "rg", "ag", "ack", "journalctl", "sqlite3", "psql"}
)
# Argument shapes that can smuggle execution back INTO a data sink: command
# and process substitution anywhere, sqlite3 dot-commands (`.shell ...`),
# psql backslash escapes (`\! ...`). Any hit disables masking for the whole
# segment — fail closed to the plain regex verdict.
_UNSAFE_DATA_ARG_MARKERS = ("`", "$(", "<(", ">(", "\\!")
# A data sink piped into a shell/interpreter can feed matched lines straight
# to execution (`grep 'systemctl restart hermes-gateway' f | sh`); never mask
# such a line.
_PIPE_TO_INTERPRETER = re.compile(
r"\|\s*&?\s*(?:sudo\s+)?(?:sh|bash|dash|ksh|zsh|xargs|eval|source)\b"
)
# Executable-image magic numbers: ELF, PE/COFF, Mach-O (universal + thin,
# both endiannesses). A referenced file starting with one of these is a
# compiled binary, never a shell script — don't read or scan it at all.
_BINARY_MAGIC_PREFIXES = (
b"\x7fELF",
b"MZ",
b"\xca\xfe\xba\xbe",
b"\xcf\xfa\xed\xfe",
b"\xce\xfa\xed\xfe",
b"\xfe\xed\xfa\xce",
b"\xfe\xed\xfa\xcf",
)
_BINARY_SNIFF_BYTES = 4096
@ -173,6 +209,102 @@ def contains_launchctl_submit_command(command: str) -> bool:
return False
def _mask_data_sink_arguments(text: str) -> str:
"""Replace data-sink executables' arguments with a neutral placeholder.
The lifecycle regex is command-shaped, but it cannot tell an EXECUTED
``systemctl restart hermes-gateway`` from the same characters appearing
as *data* a grep/rg pattern, a journalctl filter, a SQL string literal
passed to sqlite3/psql. Those diagnostics commands were being rejected
(false positives blocking legitimate cron prompts), e.g.::
grep -c 'systemctl restart hermes-gateway' /var/log/syslog
sqlite3 db "SELECT msg FROM log WHERE msg LIKE '%systemctl restart hermes-gateway%'"
This masker shell-tokenizes each line and, for command segments whose
executable is a known data sink (``_DATA_SINK_EXECUTABLES``), replaces
every argument with ``arg``. The caller then re-runs the lifecycle regex
on the masked text: a match that survives masking sits OUTSIDE any data
argument and is a real command.
Strictly fail-closed: masking is skipped (leaving the original,
regex-matching text in place) whenever the line pipes into a shell or
interpreter, any argument carries an execution-capable marker
(substitution, sqlite3 ``.``-commands, psql ``\\!``), or the line cannot
be tokenized at all. Masking can therefore only ever ALLOW a command the
plain regex would have blocked never block one it would have allowed
so it runs solely as a second-pass exemption check.
"""
lines_out: list[str] = []
changed = False
for line in text.splitlines() or [text]:
if _PIPE_TO_INTERPRETER.search(line):
lines_out.append(line)
continue
try:
lexer = shlex.shlex(line, posix=True, punctuation_chars=";&|()")
lexer.whitespace_split = True
lexer.commenters = "#"
tokens = list(lexer)
except ValueError:
lines_out.append(line)
continue
segments: list[list[str]] = []
current: list[str] = []
for token in tokens:
if token and set(token) <= _CONTROL_CHARS:
segments.append(current)
segments.append([token])
current = []
continue
current.append(token)
segments.append(current)
rebuilt: list[str] = []
for segment in segments:
if not segment:
continue
index = _command_token_index(segment)
if index is not None and Path(segment[index]).name in _DATA_SINK_EXECUTABLES:
arguments = segment[index + 1 :]
if not any(
argument.startswith(".")
or any(marker in argument for marker in _UNSAFE_DATA_ARG_MARKERS)
for argument in arguments
):
changed = True
rebuilt.extend(segment[: index + 1])
rebuilt.extend("arg" for _ in arguments)
continue
rebuilt.extend(segment)
lines_out.append(" ".join(rebuilt))
if not changed:
return text
return "\n".join(lines_out)
def _lifecycle_command_scan_with_data_exemption(text: str) -> bool:
"""Lifecycle-regex scan that exempts matches living inside data arguments.
Two-pass: the cheap regex first (the overwhelmingly common no-match case
pays nothing extra); on a raw match, re-scan with data-sink arguments
masked out. Only a match that survives masking i.e. one in actual
command position blocks.
"""
if not contains_gateway_lifecycle_command(text):
return False
normalized = _SHELL_LINE_CONTINUATION.sub(" ", text)
return contains_gateway_lifecycle_command(_mask_data_sink_arguments(normalized))
def _direct_lifecycle_scan(command: str) -> bool:
"""Pure-string direct scans: lifecycle regex (data-exempted) + submit."""
return _lifecycle_command_scan_with_data_exemption(
command
) or contains_launchctl_submit_command(command)
def _expand_candidate_path(candidate: str) -> Optional[Path]:
"""Sanitize a tokenized path candidate at the ingestion boundary.
@ -309,10 +441,23 @@ def _read_referenced_script(path: Path) -> tuple[Optional[str], bool]:
metadata = os.fstat(descriptor)
if not stat.S_ISREG(metadata.st_mode):
return None, True
# Read a bounded chunk first — even for oversized files, the first
# chunk tells us if this is a binary (NUL bytes) that should be
# skipped as "nothing to scan" rather than failing closed (#76762).
data = os.read(descriptor, _MAX_REFERENCED_SCRIPT_BYTES + 1)
# Sniff a small prefix first: files that are clearly compiled
# binaries (executable magic, or NUL bytes in the head) are never
# shell scripts, so skip them WITHOUT reading the rest — reading a
# megabyte of machine code just to discard it wastes the guard's
# budget and (pre-#77703) fed decoded garbage into the recursion.
data = os.read(descriptor, _BINARY_SNIFF_BYTES)
if data.startswith(_BINARY_MAGIC_PREFIXES) or b"\x00" in data:
return None, False
# Read the remainder (bounded). Loop because os.read may return
# short for non-regular-file-backed descriptors.
while len(data) <= _MAX_REFERENCED_SCRIPT_BYTES:
chunk = os.read(
descriptor, _MAX_REFERENCED_SCRIPT_BYTES + 1 - len(data)
)
if not chunk:
break
data += chunk
except OSError:
return None, False
finally:
@ -364,9 +509,7 @@ def _contains_unsafe_gateway_action(
visited: set[Path],
read_remote_script: Optional[_ReadRemoteScriptFn] = None,
) -> bool:
if contains_gateway_lifecycle_command(command) or contains_launchctl_submit_command(
command
):
if _direct_lifecycle_scan(command):
return True
if depth >= _MAX_REFERENCED_SCRIPT_DEPTH:
return True
@ -458,9 +601,15 @@ def contains_gateway_lifecycle_command_or_referenced_script(
exc_info=True,
)
# Pure string scans of the top-level command — cannot raise.
return contains_gateway_lifecycle_command(
command
) or contains_launchctl_submit_command(command)
try:
return _direct_lifecycle_scan(command)
except Exception:
# The data-argument masker tokenizes arbitrary text; if even
# that fails, fall to the raw regex + submit scan so the guard
# stays total.
return contains_gateway_lifecycle_command(
command
) or contains_launchctl_submit_command(command)
@ -548,7 +697,7 @@ def check_gateway_lifecycle(
# `hermes gateway restart` embedded in a .py script is still
# blocked. Non-regular/oversized script files still fail closed
# via the lifecycle-shaped sentinel in _read_script_for_scanning.
unsafe = contains_gateway_lifecycle_command(combined)
unsafe = _lifecycle_command_scan_with_data_exemption(combined)
else:
script_dir = _resolve_script_directory(script) if script else None
unsafe = contains_gateway_lifecycle_command_or_referenced_script(

View File

@ -1043,3 +1043,144 @@ class TestCronCreateLifecycleBlockExtra:
assert rc == 1
out = capsys.readouterr().out
assert "Blocked" in out
class TestLifecycleGuardDataArgumentExemption:
"""Lifecycle words inside DATA arguments (SQL text, grep patterns) must
not block; the same words in command position must. Reproduces the two
live false positives (Aug 2026): a sqlite3 SELECT over restart-history
text and a grep for the lifecycle string in syslog."""
def _scan(self, command, **kwargs):
from cron.lifecycle_guard import (
contains_gateway_lifecycle_command_or_referenced_script,
)
return contains_gateway_lifecycle_command_or_referenced_script(
command, **kwargs
)
@pytest.mark.parametrize("command", [
# Exact live false-positive shapes: SQL string literals carrying the
# full lifecycle command as text.
'sqlite3 db "SELECT msg FROM log WHERE msg LIKE '
"'%systemctl restart hermes-gateway%'\"",
'psql -c "SELECT * FROM events WHERE cmd = '
"'systemctl stop hermes-gateway'\"",
# grep/rg pattern arguments hunting for the lifecycle string.
"grep -c 'systemctl restart hermes-gateway' /var/log/syslog",
"rg 'hermes gateway restart' /home/user/.hermes/logs/",
"journalctl -u hermes-gateway --grep 'systemctl restart hermes-gateway'",
# SQL with stop/restart column/value words but no command shape.
'sqlite3 stats.db "SELECT stop_time, restart_reason FROM '
'hermes_gateway_restarts"',
"psql -c \"SELECT count(*) FROM events WHERE action IN "
"('stop','restart') AND service LIKE '%gateway%'\"",
])
def test_data_argument_lifecycle_text_not_blocked(self, command):
assert self._scan(command) is False
@pytest.mark.parametrize("command", [
# Execution smuggled through or around a data sink must still block.
'sqlite3 db ".shell hermes gateway restart"',
'psql -c "\\! systemctl restart hermes-gateway"',
"grep 'systemctl restart hermes-gateway' cmds.txt | sh",
"grep gateway f | xargs systemctl restart hermes-gateway",
'grep "$(systemctl restart hermes-gateway)" f',
"grep 'restart' log; systemctl restart hermes-gateway",
'sqlite3 db "SELECT 1"; hermes gateway stop',
# Plain lifecycle commands are unaffected by the exemption.
"hermes gateway restart",
"sudo systemctl stop hermes-gateway",
])
def test_command_position_lifecycle_still_blocked(self, command):
assert self._scan(command) is True
def test_python_script_branch_gets_the_same_exemption(self, tmp_path):
"""check_gateway_lifecycle's .py branch scans the combined
prompt+script text with the direct regex; a shell-shaped diagnostic
command in the PROMPT (the live false-positive shape) must not block
a job that runs a clean .py script. Note the exemption is
fail-closed: the same SQL buried in non-shell-shaped Python source
(e.g. inside a subprocess.run list literal) stays blocked because
the masker cannot prove it is data."""
from cron.lifecycle_guard import check_gateway_lifecycle
script = tmp_path / "report.py"
script.write_text("print('nightly report')\n", encoding="utf-8")
prompt = (
'sqlite3 db "SELECT msg FROM log '
"WHERE msg LIKE '%systemctl restart hermes-gateway%'\""
)
check_gateway_lifecycle(prompt, str(script))
class TestLifecycleGuardNeverRaises:
"""The guard must return a verdict for every input — binary referenced
paths, NUL bytes, non-UTF-8, /dev/* nodes, directories, missing files
never crash (the live 'ValueError: embedded null byte' class)."""
def _scan(self, command, **kwargs):
from cron.lifecycle_guard import (
contains_gateway_lifecycle_command_or_referenced_script,
)
return contains_gateway_lifecycle_command_or_referenced_script(
command, **kwargs
)
def test_command_referencing_elf_binary_returns_false(self, tmp_path):
"""The exact live crash shape: a command referencing a compiled
executable path (e.g. a venv python) must scan as 'nothing', not
crash on the binary's decoded bytes."""
binary = tmp_path / "python3.11"
binary.write_bytes(b"\x7fELF\x02\x01\x01" + bytes(64) + b"\x90" * 256)
assert self._scan(f"{binary} -m json.tool /tmp/x.json") is False
@pytest.mark.parametrize("command", [
"run /tmp/foo\x00bar/baz.sh",
"bash ./run\x00me.sh",
"bash /nonexistent/deeply/missing.sh",
"bash /" + "a" * 4096 + ".sh", # ENAMETOOLONG
])
def test_adversarial_paths_never_raise(self, command):
assert self._scan(command, cwd="/tmp") is False
def test_non_utf8_referenced_file_never_raises(self, tmp_path):
weird = tmp_path / "weird.sh"
weird.write_bytes(b"\xff\xfe\x00\x01 not really a script")
assert self._scan(f"bash {weird}") is False
def test_directory_and_dev_null_fail_closed_not_crash(self, tmp_path):
# Non-regular files are suspicious (fail closed = blocked), but the
# important contract is: verdict, not exception.
assert self._scan(f"bash {tmp_path}") is True
assert self._scan("bash /dev/null") is True
def test_magic_prefix_binaries_skipped_without_full_read(self, tmp_path):
"""Executable magic (ELF/PE/Mach-O) short-circuits the read: the
guard must not treat compiled binaries as scripts at all."""
from cron.lifecycle_guard import _read_referenced_script
for name, magic in [
("elf", b"\x7fELF"),
("pe", b"MZ"),
("macho", b"\xcf\xfa\xed\xfe"),
("fat", b"\xca\xfe\xba\xbe"),
]:
path = tmp_path / name
# No NUL after the magic — proves the magic check itself fires.
path.write_bytes(magic + b"ABCDEF" * 10)
text, unsafe = _read_referenced_script(path)
assert text is None, name
assert unsafe is False, name
def test_check_gateway_lifecycle_adversarial_script_values(self, tmp_path):
"""check_gateway_lifecycle must never raise anything but the
documented GatewayLifecycleBlocked for junk script values."""
from cron.lifecycle_guard import (
GatewayLifecycleBlocked,
check_gateway_lifecycle,
)
binary = tmp_path / "prog"
binary.write_bytes(b"\x7fELF" + bytes(128))
for value in ("nul\x00byte.sh", str(binary), "/nonexistent/x.sh"):
check_gateway_lifecycle("clean prompt", value) # must not raise
for value in ("/dev/null", str(tmp_path)):
with pytest.raises(GatewayLifecycleBlocked):
check_gateway_lifecycle("clean prompt", value)