diff --git a/decnet/templates/ssh/Dockerfile b/decnet/templates/ssh/Dockerfile index 33515a87..03843b9a 100644 --- a/decnet/templates/ssh/Dockerfile +++ b/decnet/templates/ssh/Dockerfile @@ -108,8 +108,24 @@ RUN echo 'alias ll="ls -alF"' >> /root/.bashrc && \ echo 'alias la="ls -A"' >> /root/.bashrc && \ echo 'alias l="ls -CF"' >> /root/.bashrc && \ echo 'export HISTSIZE=1000' >> /root/.bashrc && \ - echo 'export HISTFILESIZE=2000' >> /root/.bashrc && \ - echo 'PROMPT_COMMAND='"'"'logger --rfc5424 --msgid command -p user.info -t bash "CMD uid=$UID user=$USER src=${SSH_CLIENT%% *} pwd=$PWD cmd=$(history 1 | sed "s/^ *[0-9]* *//")";'"'" >> /root/.bashrc + echo 'export HISTFILESIZE=2000' >> /root/.bashrc + +# Command-event capture. PROMPT_COMMAND is set in /etc/environment +# (read by pam_env at session open, before any shell rc fires) and +# pinned readonly via /etc/bash.bashrc — `export PROMPT_COMMAND=""` +# fails with "readonly variable" instead of silently disabling the +# capture. Function name is generic ("__bash_history_sync") so a +# casual `set | grep PROMPT` doesn't surface DECNET branding; the +# logger invocation looks like a stock bash housekeeping helper. +RUN printf 'PROMPT_COMMAND=__bash_history_sync\n' >> /etc/environment \ + && printf '%s\n' \ + '# bash history → syslog, system-wide.' \ + '__bash_history_sync() {' \ + ' logger --rfc5424 --msgid command -p user.info -t bash "CMD uid=$UID user=$USER src=${SSH_CLIENT%% *} pwd=$PWD cmd=$(history 1 | sed '"'"'s/^ *[0-9]* *//'"'"')"' \ + '}' \ + 'readonly -f __bash_history_sync 2>/dev/null || true' \ + 'readonly PROMPT_COMMAND 2>/dev/null || true' \ + >> /etc/bash.bashrc # Fake project files to look lived-in RUN mkdir -p /root/projects /root/backups /var/www/html && \ diff --git a/decnet/templates/telnet/Dockerfile b/decnet/templates/telnet/Dockerfile index 70650aca..94a75989 100644 --- a/decnet/templates/telnet/Dockerfile +++ b/decnet/templates/telnet/Dockerfile @@ -82,8 +82,21 @@ RUN mkdir -p /root/scripts /root/backups && \ printf 'DB_HOST=10.0.0.5\nDB_USER=admin\nDB_PASS=changeme123\n' > /root/.env && \ printf 'alias ll="ls -alF"\nalias la="ls -A"\nexport HISTSIZE=1000\n' >> /root/.bashrc -# Log bash commands via syslog -RUN echo 'PROMPT_COMMAND='"'"'logger --rfc5424 --msgid command -p user.info -t bash "CMD uid=$UID pwd=$PWD cmd=$(history 1 | sed "s/^ *[0-9]* *//")";'"'" >> /root/.bashrc +# Command-event capture. PROMPT_COMMAND is set in /etc/environment +# (read by pam_env at /bin/login session open, before any shell rc +# fires) and pinned readonly via /etc/bash.bashrc — `export +# PROMPT_COMMAND=""` fails with "readonly variable" instead of +# silently disabling capture. Function name is generic so a casual +# `set | grep PROMPT` doesn't surface DECNET branding. +RUN printf 'PROMPT_COMMAND=__bash_history_sync\n' >> /etc/environment \ + && printf '%s\n' \ + '# bash history → syslog, system-wide.' \ + '__bash_history_sync() {' \ + ' logger --rfc5424 --msgid command -p user.info -t bash "CMD uid=$UID pwd=$PWD cmd=$(history 1 | sed '"'"'s/^ *[0-9]* *//'"'"')"' \ + '}' \ + 'readonly -f __bash_history_sync 2>/dev/null || true' \ + 'readonly PROMPT_COMMAND 2>/dev/null || true' \ + >> /etc/bash.bashrc COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/tests/services/test_config_schema.py b/tests/services/test_config_schema.py index 80bfaa04..656e93c8 100644 --- a/tests/services/test_config_schema.py +++ b/tests/services/test_config_schema.py @@ -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() diff --git a/tests/services/test_ssh.py b/tests/services/test_ssh.py index 0d2c389a..a878ec5b 100644 --- a/tests/services/test_ssh.py +++ b/tests/services/test_ssh.py @@ -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.