fix(ssh,telnet): move PROMPT_COMMAND out of /root/.bashrc + pin readonly
ANTI flagged two regressions in the existing command-event capture: 1. **Tell**: PROMPT_COMMAND lived in /root/.bashrc, the FIRST file an attacker greps after landing root. The logger invocation sitting there is plain-text honeypot signage. 2. **Bypass**: even when missed, `export PROMPT_COMMAND=""` silently disables capture. ANTI personally bypasses this on engagements. Reshape: * Move the assignment to **/etc/environment** — read by pam_env at session open (sshd via /etc/pam.d/sshd, telnet via /etc/pam.d/login), before any shell rc file fires. Far less obvious than .bashrc; a casual `cat .bashrc` no longer surfaces the capture. * Define the helper as a function `__bash_history_sync` in **/etc/bash.bashrc** (system-wide bashrc, sourced by every interactive bash). Function name reads as generic bash housekeeping; no DECNET branding in the symbol. * Pin both the function and PROMPT_COMMAND **readonly** so `export PROMPT_COMMAND=""` fails with "readonly variable" instead of silently winning. Mitigation, not airtight — `bash --norc` still bypasses — but the passive `export` bypass is closed. The actual `logger --rfc5424 --msgid command ... CMD ...` invocation is preserved exactly; only its location and the readonly guard change. R0001–R0030 (command-rule pack) consume the same syslog shape as before. Three new tests assert: the value lands in /etc/environment, the function body lives in /etc/bash.bashrc, no PROMPT_COMMAND line remains in /root/.bashrc, and `readonly PROMPT_COMMAND` / `readonly -f __bash_history_sync` are both present. Mirror assertions added on the Telnet Dockerfile via test_config_schema.py.
This commit is contained in:
@@ -173,6 +173,22 @@ def test_telnet_default_non_root_user():
|
||||
assert env["TELNET_USER_PASSWORD"] == "admin"
|
||||
|
||||
|
||||
def test_telnet_prompt_command_moved_out_of_root_bashrc():
|
||||
"""Mirror of test_ssh.test_prompt_command_lives_in_etc_environment.
|
||||
Telnet had the same /root/.bashrc tell — moved to
|
||||
/etc/environment + readonly guard."""
|
||||
df = (TelnetService().dockerfile_context() / "Dockerfile").read_text()
|
||||
assert "PROMPT_COMMAND=__bash_history_sync" in df
|
||||
assert "__bash_history_sync()" in df
|
||||
assert "readonly PROMPT_COMMAND" in df
|
||||
for line in df.splitlines():
|
||||
if "PROMPT_COMMAND" in line and "/root/.bashrc" in line:
|
||||
raise AssertionError(
|
||||
"PROMPT_COMMAND must not live in /root/.bashrc; "
|
||||
f"found tell-line: {line!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_rdp_schema_matches_and_bool_coerces():
|
||||
assert {f.key for f in RDPService.config_schema} == {"nla"}
|
||||
svc = RDPService()
|
||||
|
||||
@@ -203,6 +203,44 @@ def test_dockerfile_prompt_command_logger():
|
||||
assert "logger" in df
|
||||
|
||||
|
||||
def test_prompt_command_lives_in_etc_environment_not_root_bashrc():
|
||||
"""Operator-side stealth: PROMPT_COMMAND used to live in
|
||||
/root/.bashrc, which is the FIRST file an attacker greps after
|
||||
landing root. Move it to /etc/environment (read by pam_env at
|
||||
session open, much less obvious) and define the helper function
|
||||
in /etc/bash.bashrc so user-level shells can't unset it without
|
||||
tripping the readonly guard."""
|
||||
df = _dockerfile_text()
|
||||
# /etc/environment carries the assignment (just the function
|
||||
# name — pam_env doesn't run shell expansion, so the value is a
|
||||
# literal token bash later evaluates per-prompt).
|
||||
assert "PROMPT_COMMAND=__bash_history_sync" in df
|
||||
# System-wide bashrc carries the function body.
|
||||
assert "/etc/bash.bashrc" in df
|
||||
assert "__bash_history_sync()" in df
|
||||
# /root/.bashrc must NOT carry the PROMPT_COMMAND line anymore —
|
||||
# that's the original tell.
|
||||
assert ">> /root/.bashrc" in df # unrelated bashrc lines still ok
|
||||
# Specifically: no PROMPT_COMMAND line tail-piped into /root/.bashrc.
|
||||
for line in df.splitlines():
|
||||
if "PROMPT_COMMAND" in line and "/root/.bashrc" in line:
|
||||
raise AssertionError(
|
||||
"PROMPT_COMMAND must not live in /root/.bashrc; "
|
||||
f"found tell-line: {line!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_prompt_command_is_readonly_so_export_blank_fails():
|
||||
"""ANTI's bypass: `export PROMPT_COMMAND=""` silently disables
|
||||
capture. Counter: mark PROMPT_COMMAND readonly in /etc/bash.bashrc
|
||||
so the bypass fails with "readonly variable" instead. This is
|
||||
mitigation, not airtight — bash --norc still bypasses — but a
|
||||
passive `export` no longer works."""
|
||||
df = _dockerfile_text()
|
||||
assert "readonly PROMPT_COMMAND" in df
|
||||
assert "readonly -f __bash_history_sync" in df
|
||||
|
||||
|
||||
def test_entrypoint_has_no_named_pipe():
|
||||
# Named pipes in the container are a liability — readable and writable
|
||||
# by any root process. The log bridge must not rely on one.
|
||||
|
||||
Reference in New Issue
Block a user