merge: testing → main (reconcile 2-week divergence)

This commit is contained in:
2026-04-28 18:36:00 -04:00
parent 499836c9e4
commit 862e4dbb31
1235 changed files with 160255 additions and 7996 deletions

View File

@@ -0,0 +1,157 @@
ARG BASE_IMAGE=debian:bookworm-slim
# ── Stage 1: build the static auth-helper credential-capture binary ──────────
# Compiled against musl so the resulting binary is fully static — runs on
# any glibc/musl Linux without a libc version match. Stripped at link
# time via -s so `file /usr/sbin/auth-helper` reports a generic ELF.
FROM debian:bookworm-slim AS auth-helper-build
RUN apt-get update && apt-get install -y --no-install-recommends musl-tools \
&& rm -rf /var/lib/apt/lists/*
COPY auth-helper/auth-helper.c /tmp/auth-helper.c
RUN musl-gcc -static -O2 -s -Wall -Wextra \
-o /auth-helper /tmp/auth-helper.c
# ── Stage 2: the actual SSH decky image ──────────────────────────────────────
FROM ${BASE_IMAGE}
RUN apt-get update && apt-get install -y --no-install-recommends \
openssh-server \
sudo \
rsyslog \
curl \
wget \
vim \
nano \
net-tools \
procps \
htop \
git \
inotify-tools \
psmisc \
iproute2 \
iputils-ping \
ca-certificates \
nmap \
jq \
python3 \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /var/run/sshd /root/.ssh /var/log/journal /var/lib/systemd/coredump \
&& chmod 700 /var/lib/systemd/coredump
# sshd_config: allow root + password auth; VERBOSE so session lines carry
# client IP + session PID (needed for file-capture attribution).
RUN sed -i \
-e 's|^#\?PermitRootLogin.*|PermitRootLogin yes|' \
-e 's|^#\?PasswordAuthentication.*|PasswordAuthentication yes|' \
-e 's|^#\?ChallengeResponseAuthentication.*|ChallengeResponseAuthentication no|' \
-e 's|^#\?LogLevel.*|LogLevel VERBOSE|' \
/etc/ssh/sshd_config
# rsyslog: forward auth.* and user.* to PID 1's stdout in RFC 5424 format.
# /proc/1/fd/1 is the container-stdout fd Docker attached — writing there
# surfaces lines in `docker logs` without needing a named pipe + relay cat
# (which would be readable AND writable by any root-in-container process).
#
# Filter: drop sshd's native chatter ("Failed password", "Connection from",
# "Connection closed by …") and the pam_unix lines emitted from sshd's PAM
# stack (programname is inherited from the calling process, so pam_unix(sshd:*)
# lines arrive as programname=sshd). These add no signal — our auth-helper
# writes structured login_attempt events directly to /proc/1/fd/1 and
# capture.sh emits sessions via `logger -t systemd-journal`. sudo / login /
# su pam_unix lines are NOT dropped (different programname), so privilege
# escalation telemetry still flows.
RUN printf '%s\n' \
'# auth + user events → container stdout as RFC 5424' \
'$template RFC5424fmt,"<%PRI%>1 %TIMESTAMP:::date-rfc3339% %HOSTNAME% %APP-NAME% %PROCID% %MSGID% %STRUCTURED-DATA% %msg%\n"' \
':programname, isequal, "sshd" stop' \
'auth,authpriv.* /proc/1/fd/1;RFC5424fmt' \
'user.* /proc/1/fd/1;RFC5424fmt' \
> /etc/rsyslog.d/50-journal-forward.conf
# Silence default catch-all rules so we own auth/user routing exclusively.
# Also disable rsyslog's privilege drop: PID 1's stdout (/proc/1/fd/1) is
# owned by root, so a syslog-user rsyslogd gets EACCES and silently drops
# every auth/user line (bash CMD events + file_captured emissions).
RUN sed -i \
-e 's|^\(\*\.\*;auth,authpriv\.none\)|#\1|' \
-e 's|^auth,authpriv\.\*|#auth,authpriv.*|' \
-e 's|^\$PrivDropToUser|#$PrivDropToUser|' \
-e 's|^\$PrivDropToGroup|#$PrivDropToGroup|' \
/etc/rsyslog.conf
# auth-helper: drop the static binary into /usr/sbin and wire pam_exec
# into the sshd PAM stack so every password attempt (success or fail) is
# captured before pam_unix runs. `optional` so a malfunctioning helper
# never blocks auth — fail-open is correct: missed creds are recoverable,
# a borked honeypot is not. expose_authtok writes the password to the
# helper's stdin, NUL-terminated.
COPY --from=auth-helper-build /auth-helper /usr/sbin/auth-helper
RUN chmod 755 /usr/sbin/auth-helper && \
sed -i '1i auth optional pam_exec.so expose_authtok stdout /usr/sbin/auth-helper' \
/etc/pam.d/sshd
# Sudo: log to syslog (auth facility) AND a local file with full I/O capture
RUN echo 'Defaults logfile="/var/log/sudo.log"' >> /etc/sudoers && \
echo 'Defaults syslog=auth' >> /etc/sudoers && \
echo 'Defaults log_input,log_output' >> /etc/sudoers
# Lived-in environment: motd, shell aliases, fake project files
RUN echo "Ubuntu 22.04.3 LTS" > /etc/issue.net && \
echo "Welcome to Ubuntu 22.04.3 LTS (GNU/Linux 5.15.0-88-generic x86_64)" > /etc/motd && \
echo "" >> /etc/motd && \
echo " * Documentation: https://help.ubuntu.com" >> /etc/motd && \
echo " * Management: https://landscape.canonical.com" >> /etc/motd && \
echo " * Support: https://ubuntu.com/advantage" >> /etc/motd
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 -p user.info -t bash "CMD uid=$UID user=$USER src=${SSH_CLIENT%% *} pwd=$PWD cmd=$(history 1 | sed "s/^ *[0-9]* *//")";'"'" >> /root/.bashrc
# Fake project files to look lived-in
RUN mkdir -p /root/projects /root/backups /var/www/html && \
printf '# TODO: migrate DB to new server\n# check cron jobs\n# update SSL cert\n' > /root/notes.txt && \
printf 'DB_HOST=10.0.0.5\nDB_USER=admin\nDB_PASS=changeme123\nDB_NAME=prod_db\n' > /root/projects/.env && \
printf '[Unit]\nDescription=App Server\n[Service]\nExecStart=/usr/bin/python3 /opt/app/server.py\n' > /root/projects/app.service
# Stage all capture sources in a scratch dir. Nothing here survives the layer:
# _build_stealth.py packs syslog_bridge.py + emit_capture.py + capture.sh into
# XOR+gzip+base64 blobs embedded directly in /entrypoint.sh, and the whole
# /tmp/build tree is wiped at the end of the RUN — so the final image has no
# `.py` file under /opt and no `journal-relay` script under /usr/libexec/udev.
COPY entrypoint.sh capture.sh syslog_bridge.py emit_capture.py \
argv_zap.c _build_stealth.py /tmp/build/
COPY sessrec/ /tmp/build/sessrec/
# argv_zap is compiled into a shared object disguised as a multiarch
# udev-companion library (sits next to real libudev.so.1). sessrec is built
# as /usr/libexec/login-session, installed as root's login shell so every
# interactive SSH session is pty-recorded. gcc is installed only for this
# build step and purged in the same layer.
RUN set -eu \
&& apt-get update \
&& apt-get install -y --no-install-recommends gcc libc6-dev make \
&& mkdir -p /usr/lib/x86_64-linux-gnu /usr/libexec/udev /usr/libexec \
&& gcc -O2 -fPIC -shared \
-o /usr/lib/x86_64-linux-gnu/libudev-shared.so.1 \
/tmp/build/argv_zap.c -ldl \
&& make -C /tmp/build/sessrec install PREFIX=/usr/libexec \
&& grep -q '^/usr/libexec/login-session$' /etc/shells \
|| echo '/usr/libexec/login-session' >> /etc/shells \
&& sed -i 's|^root:\([^:]*\):\([^:]*\):\([^:]*\):\([^:]*\):\([^:]*\):.*$|root:\1:\2:\3:\4:\5:/usr/libexec/login-session|' /etc/passwd \
&& apt-get purge -y gcc libc6-dev make \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/* \
&& ln -sf /usr/bin/inotifywait /usr/libexec/udev/kmsg-watch \
&& python3 /tmp/build/_build_stealth.py \
&& rm -rf /tmp/build
EXPOSE 22
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD kill -0 1 || exit 1
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""
Build-time helper: merge capture Python sources, XOR+gzip+base64 pack them
and the capture.sh loop, and render the final /entrypoint.sh from its
templated form.
Runs inside the Docker build. Reads from /tmp/build/, writes /entrypoint.sh.
"""
from __future__ import annotations
import base64
import gzip
import random
import sys
from pathlib import Path
BUILD = Path("/tmp/build")
def _merge_python() -> str:
bridge = (BUILD / "syslog_bridge.py").read_text()
emit = (BUILD / "emit_capture.py").read_text()
def _clean(src: str) -> tuple[list[str], list[str]]:
"""Return (future_imports, other_lines) with noise stripped."""
futures: list[str] = []
rest: list[str] = []
for line in src.splitlines():
ls = line.lstrip()
if ls.startswith("from __future__"):
futures.append(line)
elif ls.startswith("sys.path.insert") or ls.startswith("from syslog_bridge"):
continue
else:
rest.append(line)
return futures, rest
b_fut, b_rest = _clean(bridge)
e_fut, e_rest = _clean(emit)
# Deduplicate future imports and hoist to the very top.
seen: set[str] = set()
futures: list[str] = []
for line in (*b_fut, *e_fut):
stripped = line.strip()
if stripped not in seen:
seen.add(stripped)
futures.append(line)
header = "\n".join(futures)
body = "\n".join(b_rest) + "\n\n" + "\n".join(e_rest)
return (header + "\n" if header else "") + body
def _pack(text: str, key: int) -> str:
gz = gzip.compress(text.encode("utf-8"))
xored = bytes(b ^ key for b in gz)
return base64.b64encode(xored).decode("ascii")
def main() -> int:
key = random.SystemRandom().randint(1, 255)
merged_py = _merge_python()
capture_sh = (BUILD / "capture.sh").read_text()
emit_b64 = _pack(merged_py, key)
relay_b64 = _pack(capture_sh, key)
tpl = (BUILD / "entrypoint.sh").read_text()
rendered = (
tpl.replace("__STEALTH_KEY__", str(key))
.replace("__EMIT_CAPTURE_B64__", emit_b64)
.replace("__JOURNAL_RELAY_B64__", relay_b64)
)
for marker in ("__STEALTH_KEY__", "__EMIT_CAPTURE_B64__", "__JOURNAL_RELAY_B64__"):
if marker in rendered:
print(f"build: placeholder {marker} still present after render", file=sys.stderr)
return 1
Path("/entrypoint.sh").write_text(rendered)
Path("/entrypoint.sh").chmod(0o755)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,65 @@
/*
* argv_zap.so — LD_PRELOAD shim that blanks argv[1..] from /proc/PID/cmdline
* after the target binary has parsed its arguments.
*
* Rationale: exec -a can rewrite argv[0], but the remaining args (paths,
* flags) remain visible via `ps aux`. By hooking __libc_start_main we can
* copy argv into heap-backed storage, hand that to the real main, then
* zero the stack-resident argv region so the kernel's cmdline reader
* returns just argv[0].
*
* Usage:
* gcc -O2 -fPIC -shared -o argv_zap.so argv_zap.c -ldl
* ARGV_ZAP_COMM=kmsg-watch LD_PRELOAD=/path/argv_zap.so \
* exec -a "kmsg-watch" inotifywait …
*/
#define _GNU_SOURCE
#include <dlfcn.h>
#include <string.h>
#include <stdlib.h>
#include <sys/prctl.h>
typedef int (*main_t)(int, char **, char **);
typedef int (*libc_start_main_t)(main_t, int, char **,
void (*)(void), void (*)(void),
void (*)(void), void *);
static main_t real_main;
static int wrapped_main(int argc, char **argv, char **envp) {
/* Heap-copy argv so the target keeps its arguments. */
char **heap_argv = (char **)calloc(argc + 1, sizeof(char *));
if (heap_argv) {
for (int i = 0; i < argc; i++) {
heap_argv[i] = strdup(argv[i] ? argv[i] : "");
}
}
/* Zero the contiguous argv[1..] region (argv[0] stays for ps). */
if (argc > 1 && argv[1] && argv[argc - 1]) {
char *start = argv[1];
char *end = argv[argc - 1] + strlen(argv[argc - 1]);
if (end > start) memset(start, 0, (size_t)(end - start));
}
/* Optional comm rename so /proc/self/comm mirrors the argv[0] disguise.
* Read from ARGV_ZAP_COMM so different callers can pick their own name
* (kmsg-watch for inotifywait, journal-relay for the watcher bash, …).
* Unset afterwards so children don't accidentally inherit the override. */
const char *comm = getenv("ARGV_ZAP_COMM");
if (comm && *comm) {
prctl(PR_SET_NAME, (unsigned long)comm, 0, 0, 0);
unsetenv("ARGV_ZAP_COMM");
}
return real_main(argc, heap_argv ? heap_argv : argv, envp);
}
int __libc_start_main(main_t main_fn, int argc, char **argv,
void (*init)(void), void (*fini)(void),
void (*rtld_fini)(void), void *stack_end) {
real_main = main_fn;
libc_start_main_t real = (libc_start_main_t)dlsym(RTLD_NEXT, "__libc_start_main");
return real(wrapped_main, argc, argv, init, fini, rtld_fini, stack_end);
}

View File

@@ -0,0 +1,190 @@
/*
* auth-helper — RFC 5424 cred-capture helper invoked via pam_exec.so.
*
* Wired into /etc/pam.d/sshd as:
* auth optional pam_exec.so expose_authtok stdout /usr/sbin/auth-helper
*
* Behaviour:
* - Reads $PAM_USER and $PAM_RHOST from environ (set by pam_exec).
* - Reads PAM_AUTHTOK from stdin (NUL-terminated, written by pam_exec
* when invoked with `expose_authtok`).
* - Emits a single RFC 5424 line on /proc/1/fd/1 in the same shape as
* templates/syslog_bridge.py:syslog_line() — facility local0, PEN
* 55555, MSGID `auth_attempt` (matches FTP's existing event type so
* the parser + dashboard pick it up with zero changes).
*
* SD-block carries the standardized credential shape (matches
* decnet/web/db/models/logs.py:Credential). Universal keys consumed
* directly by the ingester's native-shape branch:
* principal the human-meaningful identity the attacker sent
* (username for SSH/Telnet; would be a domain for
* SMTP, a DN for LDAP, etc.)
* secret_printable RFC 5424-escaped ASCII-printable, '?' for non-
* printables. Best-effort display form; may be
* lossy on non-UTF8 bytes.
* secret_b64 base64 of the exact PAM_AUTHTOK bytes. Lossless.
* Preserves NUL/0xff/control bytes that the plain
* field would silently drop — useful fingerprinting
* signal that survives display sanitization.
*
* `username` rides alongside as a service-specific identity field for
* SSH/Telnet (mirrors `principal`); future emitters (SMTP, LDAP, …)
* drop `username` in favor of their service-native identity field.
*
* Fail-open: every error path silently exits 0. The PAM line is `optional`
* so a malfunctioning helper must never break sshd auth.
*
* PII discipline: the password value is attacker-supplied bytes. Decky
* services are not for admin SSH; throwaway creds (root:admin) are the
* convention. Limitations tracked in development/DEBT.md (DEBT-038).
*/
#define _GNU_SOURCE
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#define MAX_USER 256
#define MAX_HOST 256
#define MAX_PW 1024
#define LINE_BUF 8192
static const char B64[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/* Standard base64 with '=' padding. NUL-terminates *out*. Returns bytes
* written (excluding the NUL). On overflow returns 0 and NUL-terminates. */
static size_t b64_encode(const unsigned char *in, size_t inlen,
char *out, size_t outcap) {
size_t i = 0, o = 0;
while (i + 3 <= inlen) {
if (o + 4 >= outcap) { out[0] = '\0'; return 0; }
unsigned x = ((unsigned)in[i] << 16) |
((unsigned)in[i+1] << 8) |
(unsigned)in[i+2];
out[o++] = B64[(x >> 18) & 0x3f];
out[o++] = B64[(x >> 12) & 0x3f];
out[o++] = B64[(x >> 6) & 0x3f];
out[o++] = B64[ x & 0x3f];
i += 3;
}
if (i < inlen) {
if (o + 4 >= outcap) { out[0] = '\0'; return 0; }
unsigned x = (unsigned)in[i] << 16;
if (i + 1 < inlen) x |= (unsigned)in[i+1] << 8;
out[o++] = B64[(x >> 18) & 0x3f];
out[o++] = B64[(x >> 12) & 0x3f];
out[o++] = (i + 1 < inlen) ? B64[(x >> 6) & 0x3f] : '=';
out[o++] = '=';
}
out[o] = '\0';
return o;
}
/* RFC 5424 §6.3.3: in SD-PARAM-VALUE, escape \\ → \\\\, " → \", ] → \].
* Non-printables become '?' so the line stays parser-safe. */
static size_t sd_escape(const unsigned char *in, size_t inlen,
char *out, size_t outcap) {
size_t o = 0;
for (size_t i = 0; i < inlen; i++) {
unsigned char c = in[i];
if (c == '\\' || c == '"' || c == ']') {
if (o + 3 >= outcap) break;
out[o++] = '\\';
out[o++] = c;
} else if (c >= 0x20 && c < 0x7f) {
if (o + 2 >= outcap) break;
out[o++] = c;
} else {
if (o + 2 >= outcap) break;
out[o++] = '?';
}
}
out[o] = '\0';
return o;
}
int main(void) {
const char *user = getenv("PAM_USER");
const char *rhost = getenv("PAM_RHOST");
if (!user) user = "";
if (!rhost) rhost = "";
/* Read password until NUL (pam_exec's expose_authtok contract) or EOF. */
unsigned char pw_raw[MAX_PW];
size_t pw_len = 0;
while (pw_len < sizeof(pw_raw)) {
ssize_t n = read(0, pw_raw + pw_len, sizeof(pw_raw) - pw_len);
if (n <= 0) break;
for (ssize_t i = 0; i < n; i++) {
if (pw_raw[pw_len + i] == 0) {
pw_len += (size_t)i;
goto pw_done;
}
}
pw_len += (size_t)n;
}
pw_done:;
/* Timestamp: YYYY-MM-DDThh:mm:ss.uuuuuu+00:00 — matches the shape
* datetime.now(timezone.utc).isoformat() emits in syslog_bridge.py. */
struct timespec ts;
if (clock_gettime(CLOCK_REALTIME, &ts) != 0) return 0;
struct tm tm;
if (gmtime_r(&ts.tv_sec, &tm) == NULL) return 0;
char tsbuf[40];
snprintf(tsbuf, sizeof(tsbuf),
"%04d-%02d-%02dT%02d:%02d:%02d.%06ld+00:00",
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
tm.tm_hour, tm.tm_min, tm.tm_sec,
(long)(ts.tv_nsec / 1000));
char host[MAX_HOST];
if (gethostname(host, sizeof(host) - 1) != 0) {
host[0] = '-'; host[1] = '\0';
} else {
host[sizeof(host) - 1] = '\0';
}
/* Escape / encode the dynamic fields. Buffers sized 2x source to
* survive worst-case escape expansion. */
char user_esc [MAX_USER * 2];
char rhost_esc[MAX_HOST * 2];
char pw_esc [MAX_PW * 2];
char pw_b64 [MAX_PW * 2];
sd_escape((const unsigned char *)user, strlen(user), user_esc, sizeof(user_esc));
sd_escape((const unsigned char *)rhost, strlen(rhost), rhost_esc, sizeof(rhost_esc));
sd_escape(pw_raw, pw_len, pw_esc, sizeof(pw_esc));
b64_encode(pw_raw, pw_len, pw_b64, sizeof(pw_b64));
/* Priority: facility=local0(16), severity=INFO(6) → <16*8+6> = <134>.
* Matches the syslog_bridge.py default exactly.
*
* SD-block keys match the Credential storage model: principal +
* secret_printable + secret_b64 are the universal keys the ingester
* keys off; username is emitted alongside principal so existing
* dashboards that read SSH/Telnet `username=` keep working until
* the cred-reuse UI lands. */
char line[LINE_BUF];
int n = snprintf(line, sizeof(line),
"<134>1 %s %s auth-helper - auth_attempt "
"[relay@55555 username=\"%s\" principal=\"%s\" "
"secret_printable=\"%s\" secret_b64=\"%s\" src_ip=\"%s\"]\n",
tsbuf, host, user_esc, user_esc, pw_esc, pw_b64, rhost_esc);
if (n <= 0 || (size_t)n >= sizeof(line)) return 0;
/* /proc/1/fd/1 is the entrypoint's stdout — the fd Docker captures
* for `docker logs`. Same channel rsyslog forwards auth.* into via
* the existing template; we bypass rsyslog entirely so behaviour is
* deterministic across rsyslog config drift. */
int fd = open("/proc/1/fd/1", O_WRONLY | O_APPEND);
if (fd < 0) return 0;
ssize_t w = write(fd, line, (size_t)n);
(void)w;
close(fd);
return 0;
}

265
decnet/templates/ssh/capture.sh Executable file
View File

@@ -0,0 +1,265 @@
#!/bin/bash
# systemd-journal relay helper: mirrors newly-written files under a
# monitored set of paths into the coredump staging directory and emits
# a structured journal line per event.
#
# `lastpipe` runs the tail of `inotify | while` in the current shell so
# the process tree stays flat (one bash, not two). Job control must be
# off for lastpipe to apply — non-interactive scripts already have it off.
shopt -s lastpipe
set +m
set -u
CAPTURE_DIR="${CAPTURE_DIR:-/var/lib/systemd/coredump}"
CAPTURE_MAX_BYTES="${CAPTURE_MAX_BYTES:-52428800}" # 50 MiB
CAPTURE_WATCH_PATHS="${CAPTURE_WATCH_PATHS:-/root /tmp /var/tmp /home /var/www /opt /dev/shm}"
# Invoke inotifywait through the udev-sided symlink; fall back to the real
# binary if the symlink is missing.
INOTIFY_BIN="${INOTIFY_BIN:-/usr/libexec/udev/kmsg-watch}"
[ -x "$INOTIFY_BIN" ] || INOTIFY_BIN="$(command -v inotifywait)"
mkdir -p "$CAPTURE_DIR"
chmod 700 "$CAPTURE_DIR"
# Filenames we never capture (boot noise, self-writes).
_is_ignored_path() {
local p="$1"
case "$p" in
"$CAPTURE_DIR"/*) return 0 ;;
/var/lib/systemd/*) return 0 ;;
*/.bash_history) return 0 ;;
*/.viminfo) return 0 ;;
*/ssh_host_*_key*) return 0 ;;
esac
return 1
}
# Resolve the writer PID best-effort. Prints the PID or nothing.
_writer_pid() {
local path="$1"
local pid
pid="$(fuser "$path" 2>/dev/null | tr -d ' \t\n')"
if [ -n "$pid" ]; then
printf '%s' "${pid%% *}"
return
fi
# Fallback: scan /proc/*/fd for an open handle on the path.
for fd_link in /proc/[0-9]*/fd/*; do
[ -L "$fd_link" ] || continue
if [ "$(readlink -f "$fd_link" 2>/dev/null)" = "$path" ]; then
printf '%s' "$(echo "$fd_link" | awk -F/ '{print $3}')"
return
fi
done
}
# Walk PPid chain from $1 until we hit an sshd session leader.
# Prints: <sshd_pid> <user> (empty on no match).
_walk_to_sshd() {
local pid="$1"
local depth=0
while [ -n "$pid" ] && [ "$pid" != "0" ] && [ "$pid" != "1" ] && [ $depth -lt 20 ]; do
local cmd
cmd="$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null)"
# sshd session leaders look like: "sshd: root@pts/0" or "sshd: root@notty"
if echo "$cmd" | grep -qE '^sshd: [^ ]+@'; then
local user
user="$(echo "$cmd" | sed -E 's/^sshd: ([^@]+)@.*/\1/')"
printf '%s %s' "$pid" "$user"
return
fi
pid="$(awk '/^PPid:/ {print $2}' "/proc/$pid/status" 2>/dev/null)"
depth=$((depth + 1))
done
}
# Emit a JSON array of currently-established SSH peers.
# Each item: {pid, src_ip, src_port}.
_ss_sessions_json() {
ss -Htnp state established sport = :22 2>/dev/null \
| awk '
{
peer=$4; local_=$3;
# peer looks like 198.51.100.7:55342 (may be IPv6 [::1]:x)
n=split(peer, a, ":");
port=a[n];
ip=peer; sub(":" port "$", "", ip);
gsub(/[\[\]]/, "", ip);
# extract pid from users:(("sshd",pid=1234,fd=5))
pid="";
if (match($0, /pid=[0-9]+/)) {
pid=substr($0, RSTART+4, RLENGTH-4);
}
printf "{\"pid\":%s,\"src_ip\":\"%s\",\"src_port\":%s}\n",
(pid==""?"null":pid), ip, (port+0);
}' \
| jq -s '.'
}
# Emit a JSON array of logged-in users from utmp.
# Each item: {user, src_ip, login_at}.
_who_sessions_json() {
who --ips 2>/dev/null \
| awk '{ printf "{\"user\":\"%s\",\"tty\":\"%s\",\"login_at\":\"%s %s\",\"src_ip\":\"%s\"}\n", $1, $2, $3, $4, $NF }' \
| jq -s '.'
}
_capture_one() {
local src="$1"
[ -f "$src" ] || return 0
_is_ignored_path "$src" && return 0
local size
size="$(stat -c '%s' "$src" 2>/dev/null)"
[ -z "$size" ] && return 0
if [ "$size" -gt "$CAPTURE_MAX_BYTES" ]; then
logger -p user.info -t systemd-journal "file_skipped size=$size path=$src reason=oversize"
return 0
fi
# Attribution first — PID may disappear after the copy races.
local writer_pid writer_comm writer_cmdline writer_uid writer_loginuid
writer_pid="$(_writer_pid "$src")"
if [ -n "$writer_pid" ] && [ -d "/proc/$writer_pid" ]; then
writer_comm="$(cat "/proc/$writer_pid/comm" 2>/dev/null)"
writer_cmdline="$(tr '\0' ' ' < "/proc/$writer_pid/cmdline" 2>/dev/null)"
writer_uid="$(awk '/^Uid:/ {print $2}' "/proc/$writer_pid/status" 2>/dev/null)"
writer_loginuid="$(cat "/proc/$writer_pid/loginuid" 2>/dev/null)"
fi
local ssh_pid ssh_user
if [ -n "$writer_pid" ]; then
read -r ssh_pid ssh_user < <(_walk_to_sshd "$writer_pid" || true)
fi
local ss_json who_json
ss_json="$(_ss_sessions_json 2>/dev/null || echo '[]')"
who_json="$(_who_sessions_json 2>/dev/null || echo '[]')"
# Resolve src_ip via ss by matching ssh_pid.
local src_ip="" src_port="null" attribution="unknown"
if [ -n "${ssh_pid:-}" ]; then
local matched
matched="$(echo "$ss_json" | jq -c --argjson p "$ssh_pid" '.[] | select(.pid==$p)')"
if [ -n "$matched" ]; then
src_ip="$(echo "$matched" | jq -r '.src_ip')"
src_port="$(echo "$matched" | jq -r '.src_port')"
attribution="pid-chain"
fi
fi
# Fallback 1: ss-only. scp/wget/sftp close their fd before close_write
# fires, so fuser/proc-fd walks miss them. If there's exactly one live
# sshd session, attribute to it. With multiple, attribute to the first
# but tag ambiguous so analysts know to cross-check concurrent_sessions.
if [ "$attribution" = "unknown" ]; then
local ss_len
ss_len="$(echo "$ss_json" | jq 'length')"
if [ "$ss_len" -ge 1 ]; then
src_ip="$(echo "$ss_json" | jq -r '.[0].src_ip')"
src_port="$(echo "$ss_json" | jq -r '.[0].src_port')"
ssh_pid="$(echo "$ss_json" | jq -r '.[0].pid // empty')"
if [ -n "${ssh_pid:-}" ] && [ -d "/proc/$ssh_pid" ]; then
local ssh_cmd
ssh_cmd="$(tr '\0' ' ' < "/proc/$ssh_pid/cmdline" 2>/dev/null)"
ssh_user="$(echo "$ssh_cmd" | sed -nE 's/^sshd: ([^@]+)@.*/\1/p')"
fi
if [ "$ss_len" -eq 1 ]; then
attribution="ss-only"
else
attribution="ss-ambiguous"
fi
fi
fi
# Fallback 2: utmp. Weakest signal; often empty in containers.
if [ "$attribution" = "unknown" ] && [ "$(echo "$who_json" | jq 'length')" -gt 0 ]; then
src_ip="$(echo "$who_json" | jq -r '.[0].src_ip')"
attribution="utmp-only"
fi
local sha
sha="$(sha256sum "$src" 2>/dev/null | awk '{print $1}')"
[ -z "$sha" ] && return 0
local ts base stored_as
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
base="$(basename "$src")"
stored_as="${ts}_${sha:0:12}_${base}"
cp --preserve=timestamps,ownership "$src" "$CAPTURE_DIR/$stored_as" 2>/dev/null || return 0
local mtime
mtime="$(stat -c '%y' "$src" 2>/dev/null)"
# Prefer NODE_NAME (the deployer-supplied decky identifier) over
# $HOSTNAME, which is a cosmetic fake like "SRV-DEV-36" set by
# entrypoint.sh. The UI and the artifact bind mount both key on the
# decky name, so using $HOSTNAME here makes /artifacts/{decky}/... URLs
# unresolvable.
local decky="${NODE_NAME:-${HOSTNAME:-unknown}}"
# One syslog line, no sidecar. Flat summary fields ride as top-level SD
# params (searchable pills in the UI); bulky nested structures (writer
# cmdline, concurrent_sessions, ss_snapshot) are base64-packed into a
# single meta_json_b64 SD param by emit_capture.py.
jq -n \
--arg _hostname "$decky" \
--arg _service "ssh" \
--arg _event_type "file_captured" \
--arg captured_at "$ts" \
--arg orig_path "$src" \
--arg stored_as "$stored_as" \
--arg sha256 "$sha" \
--argjson size "$size" \
--arg mtime "$mtime" \
--arg attribution "$attribution" \
--arg writer_pid "${writer_pid:-}" \
--arg writer_comm "${writer_comm:-}" \
--arg writer_cmdline "${writer_cmdline:-}" \
--arg writer_uid "${writer_uid:-}" \
--arg writer_loginuid "${writer_loginuid:-}" \
--arg ssh_pid "${ssh_pid:-}" \
--arg ssh_user "${ssh_user:-}" \
--arg src_ip "$src_ip" \
--arg src_port "$src_port" \
--argjson concurrent "$who_json" \
--argjson ss_snapshot "$ss_json" \
'{
_hostname: $_hostname,
_service: $_service,
_event_type: $_event_type,
captured_at: $captured_at,
orig_path: $orig_path,
stored_as: $stored_as,
sha256: $sha256,
size: $size,
mtime: $mtime,
attribution: $attribution,
writer_pid: $writer_pid,
writer_comm: $writer_comm,
writer_uid: $writer_uid,
ssh_pid: $ssh_pid,
ssh_user: $ssh_user,
src_ip: $src_ip,
src_port: (if $src_port == "null" or $src_port == "" then "" else $src_port end),
writer_cmdline: $writer_cmdline,
writer_loginuid: $writer_loginuid,
concurrent_sessions: $concurrent,
ss_snapshot: $ss_snapshot
}' \
| python3 <(printf '%s' "$EMIT_CAPTURE_PY")
}
# Main loop.
# LD_PRELOAD libudev-shared.so.1 blanks argv[1..] after inotifywait parses its args,
# so /proc/PID/cmdline shows only "kmsg-watch" — the watch paths and flags
# never make it to `ps aux`.
# shellcheck disable=SC2086
ARGV_ZAP_COMM=kmsg-watch LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libudev-shared.so.1 "$INOTIFY_BIN" -m -r -q \
--event close_write --event moved_to \
--format '%w%f' \
$CAPTURE_WATCH_PATHS 2>/dev/null \
| while IFS= read -r path; do
_capture_one "$path" &
done

View File

@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""
Emit an RFC 5424 `file_captured` line to stdout.
Called by capture.sh after a file drop has been mirrored into the quarantine
directory. Reads a single JSON object from stdin describing the event; emits
one syslog line that the collector parses into `logs.fields`.
The input JSON may contain arbitrary nested structures (writer cmdline,
concurrent_sessions, ss_snapshot). Bulky fields are base64-encoded into a
single `meta_json_b64` SD param — this avoids pathological characters
(`]`, `"`, `\\`) that the collector's SD-block regex cannot losslessly
round-trip when embedded directly.
"""
from __future__ import annotations
import base64
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from syslog_bridge import syslog_line, write_syslog_file # noqa: E402
# Flat fields ride as individual SD params (searchable, rendered as pills).
# Everything else is rolled into the base64 meta blob.
_FLAT_FIELDS: tuple[str, ...] = (
"stored_as",
"sha256",
"size",
"orig_path",
"src_ip",
"src_port",
"ssh_user",
"ssh_pid",
"attribution",
"writer_pid",
"writer_comm",
"writer_uid",
"mtime",
)
def main() -> int:
raw = sys.stdin.read()
if not raw.strip():
print("emit_capture: empty stdin", file=sys.stderr)
return 1
try:
event: dict = json.loads(raw)
except json.JSONDecodeError as exc:
print(f"emit_capture: bad JSON: {exc}", file=sys.stderr)
return 1
hostname = str(event.pop("_hostname", None) or os.environ.get("HOSTNAME") or "-")
service = str(event.pop("_service", "ssh"))
event_type = str(event.pop("_event_type", "file_captured"))
fields: dict[str, str] = {}
for key in _FLAT_FIELDS:
if key in event:
value = event.pop(key)
if value is None or value == "":
continue
fields[key] = str(value)
if event:
payload = json.dumps(event, separators=(",", ":"), ensure_ascii=False, sort_keys=True)
fields["meta_json_b64"] = base64.b64encode(payload.encode("utf-8")).decode("ascii")
line = syslog_line(
service=service,
hostname=hostname,
event_type=event_type,
**fields,
)
write_syslog_file(line)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,90 @@
#!/bin/bash
set -e
# Configure root password (default: admin)
ROOT_PASSWORD="${SSH_ROOT_PASSWORD:-admin}"
echo "root:${ROOT_PASSWORD}" | chpasswd
# Optional: override hostname inside container
if [ -n "$SSH_HOSTNAME" ]; then
echo "$SSH_HOSTNAME" > /etc/hostname
hostname "$SSH_HOSTNAME"
fi
# Generate host keys if missing (first boot)
ssh-keygen -A
# Ensure transcripts dir exists on the quarantine mount. sessrec appends to
# one JSONL day-shard per decky; PAM-seeded env vars tell it which service
# slot owns this container (used in the session_recorded syslog event).
mkdir -p /var/lib/systemd/coredump/transcripts
chmod 750 /var/lib/systemd/coredump/transcripts
echo "ssh" > /etc/sessrec.service
{
echo "SESSREC_SERVICE=ssh"
[ -n "${NODE_NAME:-}" ] && echo "NODE_NAME=${NODE_NAME}"
} >> /etc/environment
# Fake bash history so the box looks used
if [ ! -f /root/.bash_history ]; then
cat > /root/.bash_history <<'HIST'
apt update && apt upgrade -y
systemctl status nginx
tail -f /var/log/syslog
df -h
htop
ps aux | grep python
git pull origin main
cd /root/projects
vim notes.txt
crontab -e
ls /var/www/html
HIST
fi
# Logging pipeline: rsyslogd (RFC 5424) → /proc/1/fd/1 → Docker log capture.
# No intermediate pipe/relay — a named FIFO would be readable AND writable
# by any root-in-container process, letting an attacker either eavesdrop on
# the SIEM feed or inject forged log lines.
if [ -n "${NODE_NAME:-}" ]; then
printf '$LocalHostName %s\n' "$NODE_NAME" > /etc/rsyslog.d/00-localhostname.conf
fi
rsyslogd
# ─── systemd-journal companion bootstrap ─────────────────────────────────────
# The next block primes cached udev/journal helpers from in-memory tables.
# Tables are XOR+gzip+base64 packed at image build. Keeping the data inline
# avoids a separate /usr/libexec script (which would be visible and readable)
# and means the helpers only ever live in this process's memory + an
# anonymous pipe the kernel hands to python via /dev/fd.
_STEALTH_KEY=__STEALTH_KEY__
_EMIT_CAPTURE_B64='__EMIT_CAPTURE_B64__'
_JOURNAL_RELAY_B64='__JOURNAL_RELAY_B64__'
_decode() {
printf '%s' "$1" | base64 -d | python3 -c '
import sys
k = '"$_STEALTH_KEY"'
d = sys.stdin.buffer.read()
sys.stdout.buffer.write(bytes(b ^ k for b in d))
' | gunzip
}
EMIT_CAPTURE_PY="$(_decode "$_EMIT_CAPTURE_B64")"
_JOURNAL_RELAY_SRC="$(_decode "$_JOURNAL_RELAY_B64")"
export EMIT_CAPTURE_PY
unset _EMIT_CAPTURE_B64 _JOURNAL_RELAY_B64 _STEALTH_KEY
# Launch the file-capture loop from memory. LD_PRELOAD + ARGV_ZAP_COMM blank
# argv[1..] so /proc/PID/cmdline shows only "journal-relay".
(
export CAPTURE_DIR=/var/lib/systemd/coredump
export LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libudev-shared.so.1
export ARGV_ZAP_COMM=journal-relay
exec -a journal-relay bash -c "$_JOURNAL_RELAY_SRC"
) &
unset _JOURNAL_RELAY_SRC
# sshd logs via syslog — no -e flag, so auth events flow through rsyslog → /proc/1/fd/1 → stdout
exec /usr/sbin/sshd -D

View File

@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""
Per-instance stealth seeding for honeypot service templates.
The whole decoy fleet looks identical to a scanner unless each decky
diverges on the boring details: cluster UUIDs, auth salts, uptime, minor
version strings, etc. This module derives a stable per-instance seed
from NODE_NAME (+ optional INSTANCE_ID) and exposes helpers that return
deterministic-per-decky-but-different-across-the-fleet values.
Connection-time jitter is intentionally NOT seeded — two hits to the same
decky should not replay the same latency curve.
"""
from __future__ import annotations
import asyncio
import hashlib
import os
import random
import time
import uuid
from typing import Sequence, TypeVar
T = TypeVar("T")
_HOSTNAME = (
os.environ.get("NODE_NAME")
or os.environ.get("HOSTNAME")
or "decky"
)
_INSTANCE_ID = os.environ.get("INSTANCE_ID", "")
_SEED_MATERIAL = f"{_HOSTNAME}:{_INSTANCE_ID}".encode()
_SEED_INT = int.from_bytes(hashlib.sha256(_SEED_MATERIAL).digest()[:8], "big")
#: Deterministic RNG seeded per decky — use for *persistent* choices
#: (versions, UUIDs, stored credentials). Never use for timing.
rng = random.Random(_SEED_INT)
#: Process boot time — real uptime elapsed since container start.
_PROCESS_START = time.time()
#: Deterministic per-instance fake "has been up for this long at boot"
#: offset, so every decky pretends to have a different history.
_BOOT_OFFSET = rng.randint(3600, 45 * 86400)
def hostname() -> str:
return _HOSTNAME
def uptime_seconds() -> int:
"""Monotonically increasing, unique per instance."""
return int(_BOOT_OFFSET + (time.time() - _PROCESS_START))
def boot_epoch() -> int:
"""Fake wall-clock boot time for this instance (seconds since epoch)."""
return int(time.time() - uptime_seconds())
def instance_uuid(namespace: str = "") -> str:
"""Deterministic UUID4-looking value for this instance+namespace."""
ns = uuid.UUID("00000000-0000-0000-0000-000000000000")
return str(uuid.uuid5(ns, f"{_HOSTNAME}:{namespace}"))
def instance_hex(nbytes: int, namespace: str = "") -> str:
"""Deterministic hex token of given byte length."""
material = f"{_HOSTNAME}:{namespace}".encode()
digest = hashlib.sha256(material).digest()
while len(digest) < nbytes:
digest += hashlib.sha256(digest).digest()
return digest[:nbytes].hex()
def pick(choices: Sequence[T]) -> T:
"""Deterministic choice from a sequence."""
return rng.choice(list(choices))
def pick_weighted(choices: Sequence[tuple[T, float]]) -> T:
"""Deterministic weighted choice. Input: [(item, weight), ...]."""
total = sum(w for _, w in choices)
r = rng.uniform(0, total)
acc = 0.0
for item, w in choices:
acc += w
if r <= acc:
return item
return choices[-1][0]
def random_bytes(n: int, namespace: str = "") -> bytes:
"""Deterministic per-instance byte string of length n."""
out = bytearray()
i = 0
while len(out) < n:
out.extend(
hashlib.sha256(f"{_HOSTNAME}:{namespace}:{i}".encode()).digest()
)
i += 1
return bytes(out[:n])
def fresh_bytes(n: int) -> bytes:
"""Non-deterministic random bytes — for per-connection nonces/salts."""
return os.urandom(n)
async def jitter(min_ms: int = 5, max_ms: int = 120) -> None:
"""Async response-time jitter. Uses unseeded RNG so timing varies
across connections to the same decky — seeded jitter would leak
predictability."""
await asyncio.sleep(random.uniform(min_ms, max_ms) / 1000.0)
def jitter_sync(min_ms: int = 5, max_ms: int = 120) -> None:
"""Blocking jitter for non-asyncio servers."""
time.sleep(random.uniform(min_ms, max_ms) / 1000.0)

View File

@@ -0,0 +1,28 @@
# Build sessrec, a tiny pty relay + transcript recorder installed as the
# login shell inside SSH / Telnet decky containers. Built per-image during
# the template Dockerfile's build stage; gcc + libc6-dev are installed only
# for this step and purged in the same layer.
#
# Output: /usr/libexec/login-session (plausible login-machinery name)
CC ?= gcc
CFLAGS ?= -O2 -Wall -Wextra -D_FORTIFY_SOURCE=2 -fstack-protector-strong -fPIE
LDFLAGS ?= -pie -Wl,-z,relro,-z,now
LIBS := -lutil
PREFIX ?= /usr/libexec
TARGET := login-session
all: $(TARGET)
$(TARGET): sessrec.c
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $< $(LIBS)
strip --strip-unneeded $@
install: $(TARGET)
install -D -m 0755 $(TARGET) $(DESTDIR)$(PREFIX)/$(TARGET)
clean:
rm -f $(TARGET)
.PHONY: all install clean

View File

@@ -0,0 +1,564 @@
/*
* sessrec — interactive session recorder for SSH / Telnet deckies.
*
* Invoked as the login shell (via /etc/passwd shell swap). On interactive tty
* sessions it:
* 1. forkpty()'s /bin/bash -l and relays stdin/stdout/SIGWINCH bidirectionally;
* 2. records each chunk as an asciinema v2 event in a *shared* JSONL day-shard
* (/var/lib/systemd/coredump/transcripts/sessions-YYYY-MM-DD.jsonl) with
* the session's UUID as a sid tag on every line;
* 3. on exit emits one RFC 5424 syslog line (event_type=session_recorded)
* direct to PID 1's stdout — bypasses rsyslog the same way syslog_bridge.py
* does in the Python service templates.
*
* Storage shape is one JSONL shard per (decky, UTC day). Concurrent sessions
* append the shard lock-free: each write() is < PIPE_BUF (4096) and O_APPEND
* guarantees atomic interleave on Linux regular files. Events larger than one
* atomic write are chunked. Per-session cap: 10 MB; overflow writes one sentinel
* line and stops emitting (session itself continues). Disk-free precheck on the
* shard mount; below 200 MB free we emit session_skipped and exec bash directly.
*
* Non-tty invocation (e.g. `ssh host cmd`) short-circuits to execvp(bash) so
* non-interactive command execution still surfaces via the existing
* PROMPT_COMMAND logger hook rather than this path.
*/
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <netdb.h>
#include <pty.h>
#include <signal.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/prctl.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/statvfs.h>
#include <sys/time.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <termios.h>
#include <time.h>
#include <unistd.h>
#include <utmp.h>
#include <poll.h>
#define TRANSCRIPTS_DIR "/var/lib/systemd/coredump/transcripts"
#define PID1_STDOUT "/proc/1/fd/1"
#define MIN_FREE_BYTES ((uint64_t)200 * 1024 * 1024) /* 200 MB disk precheck */
#define SESSION_CAP_BYTES ((uint64_t) 10 * 1024 * 1024) /* 10 MB per-session cap */
#define ATOMIC_CHUNK 3900 /* < PIPE_BUF (4096) */
#define BUF_SIZE 4096
#define LINE_SCRATCH (ATOMIC_CHUNK * 2 + 512)
#define DEFAULT_SHELL "/bin/bash"
#define COMM_DISGUISE "kworker/u8:2-ev" /* fits 15-char comm cap */
/* ─── tiny utilities ──────────────────────────────────────────────────────── */
static volatile sig_atomic_t sigwinch_pending = 0;
static void sigwinch_handler(int sig) { (void)sig; sigwinch_pending = 1; }
static double monotonic_since(const struct timespec *t0) {
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
double dt = (double)(now.tv_sec - t0->tv_sec)
+ (double)(now.tv_nsec - t0->tv_nsec) / 1e9;
return dt < 0.0 ? 0.0 : dt;
}
/* Write all bytes, retrying on EINTR. Returns 0 on success. */
static int write_all(int fd, const void *buf, size_t n) {
const uint8_t *p = buf;
while (n > 0) {
ssize_t w = write(fd, p, n);
if (w < 0) {
if (errno == EINTR) continue;
return -1;
}
p += w; n -= (size_t)w;
}
return 0;
}
/* Pick 16 bytes of entropy, format as UUIDv4 (8-4-4-4-12 hex, 36 chars + NUL). */
static int mint_uuid(char out[37]) {
int fd = open("/dev/urandom", O_RDONLY | O_CLOEXEC);
if (fd < 0) return -1;
uint8_t b[16];
ssize_t n = read(fd, b, sizeof b);
close(fd);
if (n != (ssize_t)sizeof b) return -1;
b[6] = (b[6] & 0x0f) | 0x40; /* v4 */
b[8] = (b[8] & 0x3f) | 0x80; /* variant */
snprintf(out, 37,
"%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
b[0],b[1],b[2],b[3], b[4],b[5], b[6],b[7], b[8],b[9],
b[10],b[11],b[12],b[13],b[14],b[15]);
return 0;
}
/* JSON-escape raw bytes into dst. Returns written length (excluding NUL),
* or -1 on overflow. Handles control chars, quote, backslash, and non-UTF8
* bytes (emitted as \u00XX so the output stays valid JSON regardless of
* terminal payload encoding). */
static ssize_t json_escape(char *dst, size_t cap, const uint8_t *src, size_t n) {
size_t o = 0;
for (size_t i = 0; i < n; i++) {
uint8_t c = src[i];
const char *esc = NULL;
char buf[8];
size_t add;
switch (c) {
case '"': esc = "\\\""; add = 2; break;
case '\\': esc = "\\\\"; add = 2; break;
case '\b': esc = "\\b"; add = 2; break;
case '\f': esc = "\\f"; add = 2; break;
case '\n': esc = "\\n"; add = 2; break;
case '\r': esc = "\\r"; add = 2; break;
case '\t': esc = "\\t"; add = 2; break;
default:
if (c < 0x20 || c == 0x7f) {
snprintf(buf, sizeof buf, "\\u%04x", c);
esc = buf; add = 6;
} else {
esc = NULL; add = 1;
}
}
if (o + add + 1 >= cap) return -1;
if (esc) { memcpy(dst + o, esc, add); o += add; }
else { dst[o++] = (char)c; }
}
dst[o] = '\0';
return (ssize_t)o;
}
/* ─── disk precheck + shard resolution ────────────────────────────────────── */
static uint64_t free_bytes(const char *path) {
struct statvfs s;
if (statvfs(path, &s) != 0) return 0;
return (uint64_t)s.f_bavail * (uint64_t)s.f_frsize;
}
static void today_utc(char out[11]) {
time_t t = time(NULL);
struct tm tm;
gmtime_r(&t, &tm);
strftime(out, 11, "%Y-%m-%d", &tm);
}
/* Build /var/lib/systemd/coredump/transcripts/sessions-YYYY-MM-DD.jsonl */
static void shard_path(char out[512]) {
char day[11];
today_utc(day);
snprintf(out, 512, "%s/sessions-%s.jsonl", TRANSCRIPTS_DIR, day);
}
/* ─── src_ip resolution ───────────────────────────────────────────────────── */
static void resolve_src_ip(char out[NI_MAXHOST]) {
out[0] = '\0';
/* SSH: $SSH_CONNECTION = "<client_ip> <client_port> <server_ip> <server_port>" */
const char *sc = getenv("SSH_CONNECTION");
if (sc && *sc) {
size_t i = 0;
while (sc[i] && sc[i] != ' ' && i < NI_MAXHOST - 1) {
out[i] = sc[i]; i++;
}
out[i] = '\0';
if (out[0]) return;
}
/* Telnet: busybox telnetd -l /bin/login leaves the client socket as fd 0. */
struct sockaddr_storage ss;
socklen_t sl = sizeof ss;
if (getpeername(STDIN_FILENO, (struct sockaddr *)&ss, &sl) == 0) {
if (getnameinfo((struct sockaddr *)&ss, sl, out, NI_MAXHOST,
NULL, 0, NI_NUMERICHOST) == 0 && out[0]) {
return;
}
}
/* Last-resort: utmp host field for the current tty. */
char ttybuf[64];
if (ttyname_r(STDIN_FILENO, ttybuf, sizeof ttybuf) == 0) {
const char *short_tty = ttybuf;
if (strncmp(short_tty, "/dev/", 5) == 0) short_tty += 5;
setutent();
struct utmp *u;
while ((u = getutent()) != NULL) {
if (u->ut_type == USER_PROCESS &&
strncmp(u->ut_line, short_tty, sizeof u->ut_line) == 0) {
size_t cap = sizeof u->ut_host;
if (cap > NI_MAXHOST - 1) cap = NI_MAXHOST - 1;
memcpy(out, u->ut_host, cap);
out[cap] = '\0';
break;
}
}
endutent();
}
}
/* ─── shard emitters ──────────────────────────────────────────────────────── */
/* Emit a single line via O_APPEND on the shard. Line must include trailing \n
* and be < ATOMIC_CHUNK for atomic-append guarantees. */
static int shard_emit(int fd, const char *line, size_t n) {
if (n == 0) return 0;
/* Single write() < PIPE_BUF is atomic under O_APPEND (POSIX.1-2017 §7.1.1,
* Linux write(2) NOTES). Don't loop — partial writes don't happen for
* regular files under this size and a retry would break atomicity. */
ssize_t w = write(fd, line, n);
return (w == (ssize_t)n) ? 0 : -1;
}
static void emit_header(int fd, const char *sid, unsigned short cols,
unsigned short rows, time_t unix_ts) {
/* Sanitize $TERM — attacker-controlled via the ssh client. */
const char *raw_term = getenv("TERM");
if (!raw_term || !*raw_term) raw_term = "xterm-256color";
char term[64];
if (json_escape(term, sizeof term,
(const uint8_t *)raw_term, strnlen(raw_term, 63)) < 0) {
term[0] = '-'; term[1] = '\0';
}
char line[LINE_SCRATCH];
int n = snprintf(line, sizeof line,
"{\"sid\":\"%s\",\"hdr\":{\"version\":2,\"width\":%u,\"height\":%u,"
"\"timestamp\":%lld,\"env\":{\"SHELL\":\"/bin/bash\",\"TERM\":\"%s\"}}}\n",
sid, (unsigned)cols, (unsigned)rows, (long long)unix_ts, term);
if (n > 0 && n < (int)sizeof line) shard_emit(fd, line, (size_t)n);
}
/* Emit a single ≤ATOMIC_CHUNK event line. Caller is responsible for chunking. */
static int emit_event_chunk(int fd, const char *sid, double t,
char ch, const uint8_t *data, size_t n) {
static char scratch[LINE_SCRATCH];
char escaped[LINE_SCRATCH];
if (json_escape(escaped, sizeof escaped, data, n) < 0) return -1;
int w = snprintf(scratch, sizeof scratch,
"{\"sid\":\"%s\",\"t\":%.6f,\"ch\":\"%c\",\"d\":\"%s\"}\n",
sid, t, ch, escaped);
if (w <= 0 || w >= (int)sizeof scratch) return -1;
return shard_emit(fd, scratch, (size_t)w);
}
static void emit_resize(int fd, const char *sid, double t,
unsigned short cols, unsigned short rows) {
char line[256];
int n = snprintf(line, sizeof line,
"{\"sid\":\"%s\",\"t\":%.6f,\"ch\":\"r\",\"d\":\"%ux%u\"}\n",
sid, t, (unsigned)cols, (unsigned)rows);
if (n > 0 && n < (int)sizeof line) shard_emit(fd, line, (size_t)n);
}
static void emit_trunc_sentinel(int fd, const char *sid) {
char line[128];
int n = snprintf(line, sizeof line, "{\"sid\":\"%s\",\"trunc\":true}\n", sid);
if (n > 0) shard_emit(fd, line, (size_t)n);
}
/* Escape an SD-PARAM-VALUE per RFC 5424 §6.3.3 — backslash, double-quote, and
* right bracket must be backslash-escaped; everything else is passed through.
* Also drops control chars (< 0x20) and 0x7F since they wreck the collector's
* line-oriented parser. */
static void sd_escape(char *dst, size_t cap, const char *src) {
size_t o = 0;
if (cap == 0) return;
for (size_t i = 0; src[i] && o + 2 < cap; i++) {
unsigned char c = (unsigned char)src[i];
if (c < 0x20 || c == 0x7f) continue;
if (c == '\\' || c == '"' || c == ']') {
if (o + 3 >= cap) break;
dst[o++] = '\\';
}
dst[o++] = (char)c;
}
dst[o] = '\0';
}
/* ─── syslog emitters (direct to PID 1 stdout) ────────────────────────────── */
/* Format & write an RFC 5424 line with a [relay@55555 ...] SD block matching
* what decnet/templates/syslog_bridge.py emits. Routes the line to PID 1's
* stdout fd so the container's Docker log stream picks it up — same channel
* the other service templates use. */
static void syslog_emit(const char *event_type, const char *sd_params,
const char *msg) {
int fd = open(PID1_STDOUT, O_WRONLY | O_APPEND | O_CLOEXEC);
if (fd < 0) return;
const char *node = getenv("NODE_NAME");
if (!node || !*node) node = "-";
char ts[64];
struct timespec tsp;
clock_gettime(CLOCK_REALTIME, &tsp);
struct tm tm;
gmtime_r(&tsp.tv_sec, &tm);
int n = (int)strftime(ts, sizeof ts, "%Y-%m-%dT%H:%M:%S", &tm);
snprintf(ts + n, sizeof ts - n, ".%06ld+00:00", tsp.tv_nsec / 1000);
char line[LINE_SCRATCH];
int w = snprintf(line, sizeof line,
"<134>1 %s %s sessrec - %s [relay@55555 %s]%s%s\n",
ts, node, event_type, sd_params ? sd_params : "",
msg && *msg ? " " : "", msg ? msg : "");
if (w > 0 && w < (int)sizeof line) write_all(fd, line, (size_t)w);
close(fd);
}
/* ─── main relay ─────────────────────────────────────────────────────────── */
static int open_shard(void) {
if (mkdir(TRANSCRIPTS_DIR, 0700) != 0 && errno != EEXIST) return -1;
char path[512];
shard_path(path);
return open(path, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0640);
}
/* Emit an "o" or "i" event, chunking to ATOMIC_CHUNK and tracking bytes_used
* against SESSION_CAP_BYTES. On cap crossing, emits the sentinel once and
* returns non-zero so the caller stops emitting for this sid. */
static int emit_chunked(int fd, const char *sid, double t, char ch,
const uint8_t *data, size_t n,
uint64_t *bytes_used, int *truncated) {
if (*truncated) return 0;
size_t off = 0;
while (off < n) {
size_t take = n - off;
if (take > ATOMIC_CHUNK / 4) take = ATOMIC_CHUNK / 4;
/* /4 because each raw byte can expand up to 6x under JSON \u00XX
* escaping. Keeps the final line < ATOMIC_CHUNK. */
if (emit_event_chunk(fd, sid, t, ch, data + off, take) != 0) {
/* Shard write failed — treat as truncation to avoid infinite retry
* loop and to keep the pty relay going. */
*truncated = 1;
emit_trunc_sentinel(fd, sid);
return 1;
}
*bytes_used += take;
off += take;
if (*bytes_used >= SESSION_CAP_BYTES) {
*truncated = 1;
emit_trunc_sentinel(fd, sid);
return 1;
}
}
return 0;
}
static void run_relay(int shard_fd, const char *sid, const char *src_ip,
const char *service) {
/* Capture parent tty state so we can restore + copy winsize to the pty. */
struct termios orig_t, raw_t;
int have_orig = (tcgetattr(STDIN_FILENO, &orig_t) == 0);
struct winsize ws = {24, 80, 0, 0};
ioctl(STDIN_FILENO, TIOCGWINSZ, &ws);
emit_header(shard_fd, sid, ws.ws_col, ws.ws_row, time(NULL));
int master_fd = -1;
pid_t child = forkpty(&master_fd, NULL, have_orig ? &orig_t : NULL, &ws);
if (child < 0) {
/* Give up recording; fall through to plain shell. */
execlp(DEFAULT_SHELL, DEFAULT_SHELL, "-l", (char *)NULL);
_exit(127);
}
if (child == 0) {
/* Child: the login shell. exec into bash, leaving the pty as its ctty. */
execlp(DEFAULT_SHELL, DEFAULT_SHELL, "-l", (char *)NULL);
_exit(127);
}
/* Parent: raw mode on the local tty so keystrokes pass through unmolested. */
if (have_orig) {
raw_t = orig_t;
cfmakeraw(&raw_t);
tcsetattr(STDIN_FILENO, TCSANOW, &raw_t);
}
struct sigaction sa = {0};
sa.sa_handler = sigwinch_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(SIGWINCH, &sa, NULL);
struct timespec t0;
clock_gettime(CLOCK_MONOTONIC, &t0);
uint64_t bytes_used = 0;
int truncated = 0;
uint8_t buf[BUF_SIZE];
struct pollfd pfds[2] = {
{ .fd = STDIN_FILENO, .events = POLLIN },
{ .fd = master_fd, .events = POLLIN },
};
int child_alive = 1;
while (child_alive) {
if (sigwinch_pending) {
sigwinch_pending = 0;
struct winsize nw;
if (ioctl(STDIN_FILENO, TIOCGWINSZ, &nw) == 0) {
ioctl(master_fd, TIOCSWINSZ, &nw);
if (!truncated) emit_resize(shard_fd, sid,
monotonic_since(&t0),
nw.ws_col, nw.ws_row);
}
}
int r = poll(pfds, 2, 1000);
if (r < 0) {
if (errno == EINTR) continue;
break;
}
if (pfds[0].revents & POLLIN) {
ssize_t n = read(STDIN_FILENO, buf, sizeof buf);
if (n > 0) {
write_all(master_fd, buf, (size_t)n);
emit_chunked(shard_fd, sid, monotonic_since(&t0), 'i',
buf, (size_t)n, &bytes_used, &truncated);
} else if (n == 0) {
/* stdin EOF — close master so the shell sees EOF too. */
close(master_fd);
master_fd = -1;
pfds[1].fd = -1;
}
}
if (master_fd >= 0 && (pfds[1].revents & POLLIN)) {
ssize_t n = read(master_fd, buf, sizeof buf);
if (n > 0) {
write_all(STDOUT_FILENO, buf, (size_t)n);
emit_chunked(shard_fd, sid, monotonic_since(&t0), 'o',
buf, (size_t)n, &bytes_used, &truncated);
} else {
/* pty master EOF = shell exited. */
break;
}
}
if ((pfds[0].revents | pfds[1].revents) & (POLLHUP | POLLERR | POLLNVAL)) {
if (pfds[1].revents & (POLLHUP | POLLERR | POLLNVAL)) break;
}
/* Reap without blocking; tolerate children that exit slightly before
* we see the master EOF. */
int status;
pid_t r2 = waitpid(child, &status, WNOHANG);
if (r2 == child) {
child_alive = 0;
/* Let pty flush remaining output on the next poll cycle. */
break;
}
}
/* Final reap. */
int status = 0;
if (child_alive) waitpid(child, &status, 0);
if (master_fd >= 0) close(master_fd);
if (have_orig) tcsetattr(STDIN_FILENO, TCSANOW, &orig_t);
double duration = monotonic_since(&t0);
/* src_ip is always an IP literal (getnameinfo NI_NUMERICHOST or an IPv4/6
* token from $SSH_CONNECTION / utmp). 128 B is enough for IPv6 + zone id
* + escaping headroom, and keeps the syslog line bounded. */
char ip_esc[128];
sd_escape(ip_esc, sizeof ip_esc, src_ip[0] ? src_ip : "-");
char sd[1024];
snprintf(sd, sizeof sd,
"sid=\"%s\" service=\"%s\" src_ip=\"%s\" duration_s=\"%.3f\" "
"bytes=\"%llu\" truncated=\"%s\"",
sid, service, ip_esc, duration,
(unsigned long long)bytes_used, truncated ? "true" : "false");
syslog_emit("session_recorded", sd, NULL);
}
/* ─── main ────────────────────────────────────────────────────────────────── */
int main(int argc, char **argv) {
(void)argc; (void)argv;
prctl(PR_SET_NAME, (unsigned long)COMM_DISGUISE, 0, 0, 0);
/* Non-interactive (`ssh host cmd`) — bypass recording entirely. The
* existing PROMPT_COMMAND syslog hook still logs the single command. */
if (!isatty(STDIN_FILENO)) {
execlp(DEFAULT_SHELL, DEFAULT_SHELL, "-l", (char *)NULL);
_exit(127);
}
/* Disk pressure: skip recording, fall through to plain shell. */
if (free_bytes(TRANSCRIPTS_DIR) < MIN_FREE_BYTES &&
free_bytes("/var/lib/systemd/coredump") < MIN_FREE_BYTES) {
/* statvfs on the transcripts dir may fail if not yet created; check
* the parent mount as a fallback before deciding. */
syslog_emit("session_skipped", "reason=\"disk_pressure\"", NULL);
execlp(DEFAULT_SHELL, DEFAULT_SHELL, "-l", (char *)NULL);
_exit(127);
}
int shard_fd = open_shard();
if (shard_fd < 0) {
syslog_emit("session_skipped", "reason=\"shard_open_failed\"", NULL);
execlp(DEFAULT_SHELL, DEFAULT_SHELL, "-l", (char *)NULL);
_exit(127);
}
char sid[37];
if (mint_uuid(sid) != 0) {
close(shard_fd);
execlp(DEFAULT_SHELL, DEFAULT_SHELL, "-l", (char *)NULL);
_exit(127);
}
/* Service discriminant: env var SESSREC_SERVICE set by the template
* entrypoint (ssh vs telnet). SSH forwards env via PAM; busybox /bin/login
* strips env, so as a fallback we read /etc/sessrec.service, a one-line
* file the template entrypoint writes at boot. */
const char *service = getenv("SESSREC_SERVICE");
static char svc_buf[16];
if (!service || !*service) {
FILE *sf = fopen("/etc/sessrec.service", "r");
if (sf) {
if (fgets(svc_buf, sizeof svc_buf, sf)) {
size_t n = strlen(svc_buf);
while (n > 0 && (svc_buf[n - 1] == '\n' || svc_buf[n - 1] == ' ')) {
svc_buf[--n] = '\0';
}
if (svc_buf[0]) service = svc_buf;
}
fclose(sf);
}
}
if (!service || !*service) service = "ssh";
char src_ip[NI_MAXHOST];
resolve_src_ip(src_ip);
/* Hostname banner — /bin/login emits "Last login: …" before exec'ing the
* shell; we want our header anchored before the shell starts writing, so
* emit_header() has already run inside run_relay(). */
run_relay(shard_fd, sid, src_ip, service);
close(shard_fd);
/* Exit code mirrors the shell's — a bash logout shouldn't surface here
* as an error to the parent (sshd / login). */
return 0;
}

View File

@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""
Shared RFC 5424 syslog helper used by service containers.
Services call syslog_line() to format an RFC 5424 message, then
write_syslog_file() to emit it to stdout — the container runtime
captures it, and the host-side collector streams it into the log file.
RFC 5424 structure:
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
Facility: local0 (16). SD element ID uses PEN 55555.
"""
import base64
import re
from datetime import datetime, timezone
from typing import Any, Optional
# ─── Constants ────────────────────────────────────────────────────────────────
_FACILITY_LOCAL0 = 16
_SD_ID = "relay@55555"
_NILVALUE = "-"
SEVERITY_EMERG = 0
SEVERITY_ALERT = 1
SEVERITY_CRIT = 2
SEVERITY_ERROR = 3
SEVERITY_WARNING = 4
SEVERITY_NOTICE = 5
SEVERITY_INFO = 6
SEVERITY_DEBUG = 7
_MAX_HOSTNAME = 255
_MAX_APPNAME = 48
_MAX_MSGID = 32
# ─── Formatter ────────────────────────────────────────────────────────────────
def _sd_escape(value: str) -> str:
"""Escape SD-PARAM-VALUE per RFC 5424 §6.3.3."""
return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]")
def _sd_element(fields: dict[str, Any]) -> str:
if not fields:
return _NILVALUE
params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items())
return f"[{_SD_ID} {params}]"
def syslog_line(
service: str,
hostname: str,
event_type: str,
severity: int = SEVERITY_INFO,
timestamp: datetime | None = None,
msg: str | None = None,
**fields: Any,
) -> str:
"""
Return a single RFC 5424-compliant syslog line (no trailing newline).
Args:
service: APP-NAME (e.g. "http", "mysql")
hostname: HOSTNAME (node name)
event_type: MSGID (e.g. "request", "login_attempt")
severity: Syslog severity integer (default: INFO=6)
timestamp: UTC datetime; defaults to now
msg: Optional free-text MSG
**fields: Encoded as structured data params
"""
pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>"
ts = (timestamp or datetime.now(timezone.utc)).isoformat()
host = (hostname or _NILVALUE)[:_MAX_HOSTNAME]
appname = (service or _NILVALUE)[:_MAX_APPNAME]
msgid = (event_type or _NILVALUE)[:_MAX_MSGID]
sd = _sd_element(fields)
message = f" {msg}" if msg else ""
return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}"
def encode_secret(secret: str) -> dict[str, str]:
"""Standardized credential-secret encoding for the universal SD-block shape.
Returns ``{'secret_printable': ..., 'secret_b64': ...}`` ready to spread
into a :func:`syslog_line` / ``_log`` call::
_log("auth_attempt", principal=user, **encode_secret(password))
``secret_printable`` mirrors auth-helper.c's sd_escape: bytes outside
``[0x20, 0x7f)`` collapse to ``'?'`` so the field is always parser-safe
RFC 5424 ASCII. ``secret_b64`` preserves the *original* utf-8 bytes —
NUL/0xff/control/non-utf8 sequences all survive losslessly, useful as
a fingerprinting signal even when the printable form sanitizes them.
The decnet web ingester's native-shape branch keys off ``secret_b64``
being present, so any service emitter calling this helper lands its
cred attempt directly in the :class:`Credential` table.
"""
raw = secret.encode("utf-8", errors="replace")
printable = "".join(chr(b) if 0x20 <= b < 0x7f else "?" for b in raw)
return {
"secret_printable": printable,
"secret_b64": base64.b64encode(raw).decode("ascii"),
}
_DIGEST_PARAM_RE = re.compile(r'(\w+)\s*=\s*"([^"]*)"|(\w+)\s*=\s*([^,\s]+)')
def classify_authorization(header_value: Optional[str]) -> Optional[dict[str, Any]]:
"""Parse an HTTP Authorization header value into Credential SD fields.
Returns a dict with the universal cred shape ready to spread into a
``_log(...)`` call::
auth = request.headers.get("Authorization")
cred = classify_authorization(auth)
if cred:
_log("auth_attempt", **cred)
Recognised schemes:
* Basic — base64(user:pw); decoded → ``principal=user`` +
``secret_kind="plaintext"`` + ``encode_secret(pw)``.
* Bearer / Token — opaque token; ``principal=None`` +
``secret_kind="http_bearer"`` + ``encode_secret(token)``.
* Digest — ``principal=username`` from header +
``secret_kind="http_digest_md5"`` + ``encode_secret(response)``.
Returns ``None`` for anything unrecognized (AWS4-HMAC-SHA256, NTLM,
Negotiate, …) — callers can still log the raw header value in the
ambient SD-block; we just don't know how to extract a hashable
secret from it.
"""
if not header_value or not isinstance(header_value, str):
return None
parts = header_value.strip().split(None, 1)
if len(parts) < 2:
return None
scheme, rest = parts[0].lower(), parts[1].strip()
if scheme == "basic":
try:
decoded = base64.b64decode(rest, validate=True).decode("utf-8", errors="replace")
except (ValueError, base64.binascii.Error):
return None
if ":" not in decoded:
return None
user, _, pw = decoded.partition(":")
return {
"principal": user,
"secret_kind": "plaintext",
**encode_secret(pw),
}
if scheme in ("bearer", "token"):
return {
"principal": None,
"secret_kind": "http_bearer",
**encode_secret(rest),
}
if scheme == "digest":
params: dict[str, str] = {}
for m in _DIGEST_PARAM_RE.finditer(rest):
k = m.group(1) or m.group(3)
v = m.group(2) if m.group(2) is not None else m.group(4)
if k:
params[k.lower()] = v
response = params.get("response")
if not response:
return None
return {
"principal": params.get("username"),
"secret_kind": "http_digest_md5",
**encode_secret(response),
}
return None
_FORM_PRINCIPAL_KEYS = (
"username", "user", "email", "login", "userid", "account",
"log", # wp-login.php
"user_login", # WordPress alt
"uname", # phpMyAdmin
"pma_username",
)
_FORM_SECRET_KEYS = (
"password", "pass", "pwd", "passwd", "passwort", "mot_de_passe",
"user_password", # WordPress alt
"pma_password", # phpMyAdmin
)
def extract_form_credentials(
body: Optional[str],
content_type: Optional[str],
) -> Optional[dict[str, Any]]:
"""Parse an `application/x-www-form-urlencoded` body for credentials.
Returns the universal cred SD shape ready to spread into a
``_log(...)`` call when both a principal-shaped key and a secret-
shaped key are present in the body. Otherwise returns ``None``.
Field-name detection is case-insensitive and covers the most common
login-form variants (WordPress wp-login.php, phpMyAdmin, Joomla,
etc.). Add more entries to ``_FORM_PRINCIPAL_KEYS`` /
``_FORM_SECRET_KEYS`` as new templates surface them.
"""
if not body or not isinstance(content_type, str):
return None
if not content_type.lower().startswith("application/x-www-form-urlencoded"):
return None
fields: dict[str, str] = {}
for pair in body.split("&"):
if "=" not in pair:
continue
k, _, v = pair.partition("=")
# urllib decode without importing urllib at module scope (the
# template emitters are import-cost-sensitive). Inline the
# tiny percent-decode + plus-decode.
try:
from urllib.parse import unquote_plus
key = unquote_plus(k).lower()
val = unquote_plus(v)
except Exception:
continue
# First-wins so duplicate-key forms don't get clobbered.
fields.setdefault(key, val)
principal: Optional[str] = None
for k in _FORM_PRINCIPAL_KEYS:
if k in fields:
principal = fields[k]
break
secret: Optional[str] = None
for k in _FORM_SECRET_KEYS:
if k in fields:
secret = fields[k]
break
if secret is None:
return None
return {
"principal": principal,
"secret_kind": "plaintext",
**encode_secret(secret),
}
def write_syslog_file(line: str) -> None:
"""Emit a syslog line to stdout for container log capture."""
print(line, flush=True)
def forward_syslog(line: str, log_target: str) -> None:
"""No-op stub. TCP forwarding is handled by rsyslog, not by service containers."""
pass