feat(ssh): capture attacker-dropped files with session attribution
inotifywait watches writable paths in the SSH decky and mirrors any file close_write/moved_to into a per-decky host-mounted quarantine dir. Each artifact carries a .meta.json with attacker attribution resolved by walking the writer PID's PPid chain to the sshd session leader, then cross-referencing ss and utmp for source IP/user/login time. Also emits an RFC 5424 syslog line per capture for SIEM correlation.
This commit is contained in:
@@ -36,12 +36,17 @@ class SSHService(BaseService):
|
|||||||
if "hostname" in cfg:
|
if "hostname" in cfg:
|
||||||
env["SSH_HOSTNAME"] = cfg["hostname"]
|
env["SSH_HOSTNAME"] = cfg["hostname"]
|
||||||
|
|
||||||
|
# File-catcher quarantine: bind-mount a per-decky host dir so attacker
|
||||||
|
# drops (scp/sftp/wget) are mirrored out-of-band for forensic analysis.
|
||||||
|
# The container path is internal-only; attackers never see this mount.
|
||||||
|
quarantine_host = f"/var/lib/decnet/artifacts/{decky_name}/ssh"
|
||||||
return {
|
return {
|
||||||
"build": {"context": str(TEMPLATES_DIR)},
|
"build": {"context": str(TEMPLATES_DIR)},
|
||||||
"container_name": f"{decky_name}-ssh",
|
"container_name": f"{decky_name}-ssh",
|
||||||
"restart": "unless-stopped",
|
"restart": "unless-stopped",
|
||||||
"cap_add": ["NET_BIND_SERVICE"],
|
"cap_add": ["NET_BIND_SERVICE"],
|
||||||
"environment": env,
|
"environment": env,
|
||||||
|
"volumes": [f"{quarantine_host}:/var/decnet/captured:rw"],
|
||||||
}
|
}
|
||||||
|
|
||||||
def dockerfile_context(self) -> Path:
|
def dockerfile_context(self) -> Path:
|
||||||
|
|||||||
@@ -13,15 +13,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
procps \
|
procps \
|
||||||
htop \
|
htop \
|
||||||
git \
|
git \
|
||||||
|
inotify-tools \
|
||||||
|
psmisc \
|
||||||
|
iproute2 \
|
||||||
|
jq \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN mkdir -p /var/run/sshd /root/.ssh /var/log/decnet
|
RUN mkdir -p /var/run/sshd /root/.ssh /var/log/decnet /var/decnet/captured \
|
||||||
|
&& chmod 700 /var/decnet /var/decnet/captured
|
||||||
|
|
||||||
# sshd_config: allow root + password auth
|
# sshd_config: allow root + password auth; VERBOSE so session lines carry
|
||||||
|
# client IP + session PID (needed for file-capture attribution).
|
||||||
RUN sed -i \
|
RUN sed -i \
|
||||||
-e 's|^#\?PermitRootLogin.*|PermitRootLogin yes|' \
|
-e 's|^#\?PermitRootLogin.*|PermitRootLogin yes|' \
|
||||||
-e 's|^#\?PasswordAuthentication.*|PasswordAuthentication yes|' \
|
-e 's|^#\?PasswordAuthentication.*|PasswordAuthentication yes|' \
|
||||||
-e 's|^#\?ChallengeResponseAuthentication.*|ChallengeResponseAuthentication no|' \
|
-e 's|^#\?ChallengeResponseAuthentication.*|ChallengeResponseAuthentication no|' \
|
||||||
|
-e 's|^#\?LogLevel.*|LogLevel VERBOSE|' \
|
||||||
/etc/ssh/sshd_config
|
/etc/ssh/sshd_config
|
||||||
|
|
||||||
# rsyslog: forward auth.* and user.* to named pipe in RFC 5424 format.
|
# rsyslog: forward auth.* and user.* to named pipe in RFC 5424 format.
|
||||||
@@ -57,7 +64,7 @@ RUN echo 'alias ll="ls -alF"' >> /root/.bashrc && \
|
|||||||
echo 'alias l="ls -CF"' >> /root/.bashrc && \
|
echo 'alias l="ls -CF"' >> /root/.bashrc && \
|
||||||
echo 'export HISTSIZE=1000' >> /root/.bashrc && \
|
echo 'export HISTSIZE=1000' >> /root/.bashrc && \
|
||||||
echo 'export HISTFILESIZE=2000' >> /root/.bashrc && \
|
echo 'export HISTFILESIZE=2000' >> /root/.bashrc && \
|
||||||
echo 'PROMPT_COMMAND='"'"'logger -p user.info -t bash "CMD uid=$UID pwd=$PWD cmd=$(history 1 | sed "s/^ *[0-9]* *//")";'"'" >> /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
|
# Fake project files to look lived-in
|
||||||
RUN mkdir -p /root/projects /root/backups /var/www/html && \
|
RUN mkdir -p /root/projects /root/backups /var/www/html && \
|
||||||
@@ -66,7 +73,8 @@ RUN mkdir -p /root/projects /root/backups /var/www/html && \
|
|||||||
printf '[Unit]\nDescription=App Server\n[Service]\nExecStart=/usr/bin/python3 /opt/app/server.py\n' > /root/projects/app.service
|
printf '[Unit]\nDescription=App Server\n[Service]\nExecStart=/usr/bin/python3 /opt/app/server.py\n' > /root/projects/app.service
|
||||||
|
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
COPY capture.sh /usr/local/sbin/decnet-capture
|
||||||
|
RUN chmod +x /entrypoint.sh /usr/local/sbin/decnet-capture
|
||||||
|
|
||||||
EXPOSE 22
|
EXPOSE 22
|
||||||
|
|
||||||
|
|||||||
228
templates/ssh/capture.sh
Executable file
228
templates/ssh/capture.sh
Executable file
@@ -0,0 +1,228 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# DECNET SSH honeypot file-catcher.
|
||||||
|
#
|
||||||
|
# Watches attacker-writable paths with inotifywait. On close_write/moved_to,
|
||||||
|
# copies the file to the host-mounted quarantine dir, writes a .meta.json
|
||||||
|
# with attacker attribution, and emits an RFC 5424 syslog line.
|
||||||
|
#
|
||||||
|
# Attribution chain (strongest → weakest):
|
||||||
|
# pid-chain : fuser/lsof finds writer PID → walk PPid to sshd session
|
||||||
|
# → cross-ref with `ss` to get src_ip/src_port
|
||||||
|
# utmp-only : writer PID gone (scp exited); fall back to `who --ips`
|
||||||
|
# unknown : no live session at all (unlikely under real attack)
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
CAPTURE_DIR="${CAPTURE_DIR:-/var/decnet/captured}"
|
||||||
|
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}"
|
||||||
|
|
||||||
|
mkdir -p "$CAPTURE_DIR"
|
||||||
|
chmod 700 "$CAPTURE_DIR"
|
||||||
|
|
||||||
|
# Filenames we never capture (noise from container boot / attacker-irrelevant).
|
||||||
|
_is_ignored_path() {
|
||||||
|
local p="$1"
|
||||||
|
case "$p" in
|
||||||
|
"$CAPTURE_DIR"/*) return 0 ;;
|
||||||
|
/var/decnet/*) 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 decnet-capture "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
|
||||||
|
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)"
|
||||||
|
|
||||||
|
local decky="${HOSTNAME:-unknown}"
|
||||||
|
|
||||||
|
jq -n \
|
||||||
|
--arg captured_at "$ts" \
|
||||||
|
--arg orig_path "$src" \
|
||||||
|
--arg stored_as "$stored_as" \
|
||||||
|
--arg sha "$sha" \
|
||||||
|
--argjson size "$size" \
|
||||||
|
--arg mtime "$mtime" \
|
||||||
|
--arg decky "$decky" \
|
||||||
|
--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" \
|
||||||
|
'{
|
||||||
|
captured_at: $captured_at,
|
||||||
|
orig_path: $orig_path,
|
||||||
|
stored_as: $stored_as,
|
||||||
|
sha256: $sha,
|
||||||
|
size: $size,
|
||||||
|
mtime: $mtime,
|
||||||
|
decky: $decky,
|
||||||
|
attribution: $attribution,
|
||||||
|
writer: {
|
||||||
|
pid: ($writer_pid | if . == "" then null else tonumber? end),
|
||||||
|
comm: $writer_comm,
|
||||||
|
cmdline: $writer_cmdline,
|
||||||
|
uid: ($writer_uid | if . == "" then null else tonumber? end),
|
||||||
|
loginuid: ($writer_loginuid | if . == "" then null else tonumber? end)
|
||||||
|
},
|
||||||
|
ssh_session: {
|
||||||
|
pid: ($ssh_pid | if . == "" then null else tonumber? end),
|
||||||
|
user: (if $ssh_user == "" then null else $ssh_user end),
|
||||||
|
src_ip: (if $src_ip == "" then null else $src_ip end),
|
||||||
|
src_port: ($src_port | if . == "null" or . == "" then null else tonumber? end)
|
||||||
|
},
|
||||||
|
concurrent_sessions: $concurrent,
|
||||||
|
ss_snapshot: $ss_snapshot
|
||||||
|
}' > "$CAPTURE_DIR/$stored_as.meta.json"
|
||||||
|
|
||||||
|
logger -p user.info -t decnet-capture \
|
||||||
|
"file_captured orig_path=$src sha256=$sha size=$size stored_as=$stored_as src_ip=${src_ip:-unknown} ssh_user=${ssh_user:-unknown} attribution=$attribution"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main loop.
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
inotifywait -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
|
||||||
@@ -40,5 +40,10 @@ cat /var/run/decnet-logs &
|
|||||||
# Start rsyslog (reads /etc/rsyslog.d/99-decnet.conf, writes to the pipe above)
|
# Start rsyslog (reads /etc/rsyslog.d/99-decnet.conf, writes to the pipe above)
|
||||||
rsyslogd
|
rsyslogd
|
||||||
|
|
||||||
|
# File-catcher: mirror attacker drops into host-mounted quarantine with attribution.
|
||||||
|
# exec -a masks the process name so casual `ps` inspection doesn't reveal the honeypot.
|
||||||
|
CAPTURE_DIR=/var/decnet/captured \
|
||||||
|
bash -c 'exec -a "[kworker/u8:0]" /usr/local/sbin/decnet-capture' &
|
||||||
|
|
||||||
# sshd logs via syslog — no -e flag, so auth events flow through rsyslog → pipe → stdout
|
# sshd logs via syslog — no -e flag, so auth events flow through rsyslog → pipe → stdout
|
||||||
exec /usr/sbin/sshd -D
|
exec /usr/sbin/sshd -D
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ def _entrypoint_text() -> str:
|
|||||||
return (get_service("ssh").dockerfile_context() / "entrypoint.sh").read_text()
|
return (get_service("ssh").dockerfile_context() / "entrypoint.sh").read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_script_path():
|
||||||
|
return get_service("ssh").dockerfile_context() / "capture.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_text() -> str:
|
||||||
|
return _capture_script_path().read_text()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Registration
|
# Registration
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -166,3 +174,135 @@ def test_deaddeck_nmap_os():
|
|||||||
|
|
||||||
def test_deaddeck_preferred_distros_not_empty():
|
def test_deaddeck_preferred_distros_not_empty():
|
||||||
assert len(get_archetype("deaddeck").preferred_distros) >= 1
|
assert len(get_archetype("deaddeck").preferred_distros) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# File-catcher: Dockerfile wiring
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_dockerfile_installs_inotify_tools():
|
||||||
|
assert "inotify-tools" in _dockerfile_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dockerfile_installs_attribution_tools():
|
||||||
|
df = _dockerfile_text()
|
||||||
|
for pkg in ("psmisc", "iproute2", "jq"):
|
||||||
|
assert pkg in df, f"missing {pkg} in Dockerfile"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dockerfile_copies_capture_script():
|
||||||
|
df = _dockerfile_text()
|
||||||
|
assert "COPY capture.sh /usr/local/sbin/decnet-capture" in df
|
||||||
|
assert "chmod +x" in df and "decnet-capture" in df
|
||||||
|
|
||||||
|
|
||||||
|
def test_dockerfile_creates_quarantine_dir():
|
||||||
|
df = _dockerfile_text()
|
||||||
|
assert "/var/decnet/captured" in df
|
||||||
|
assert "chmod 700" in df
|
||||||
|
|
||||||
|
|
||||||
|
def test_dockerfile_ssh_loglevel_verbose():
|
||||||
|
assert "LogLevel VERBOSE" in _dockerfile_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dockerfile_prompt_command_logs_ssh_client():
|
||||||
|
df = _dockerfile_text()
|
||||||
|
assert "PROMPT_COMMAND" in df
|
||||||
|
assert "SSH_CLIENT" in df
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# File-catcher: capture.sh semantics
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_capture_script_exists_and_executable():
|
||||||
|
import os
|
||||||
|
p = _capture_script_path()
|
||||||
|
assert p.exists(), f"capture.sh missing: {p}"
|
||||||
|
assert os.access(p, os.X_OK), "capture.sh must be executable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_script_uses_close_write_and_moved_to():
|
||||||
|
body = _capture_text()
|
||||||
|
assert "close_write" in body
|
||||||
|
assert "moved_to" in body
|
||||||
|
assert "inotifywait" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_script_skips_quarantine_path():
|
||||||
|
body = _capture_text()
|
||||||
|
# Must not loop on its own writes.
|
||||||
|
assert "/var/decnet/" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_script_resolves_writer_pid():
|
||||||
|
body = _capture_text()
|
||||||
|
assert "fuser" in body
|
||||||
|
# walks PPid to find sshd session leader
|
||||||
|
assert "PPid" in body
|
||||||
|
assert "/proc/" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_script_snapshots_ss_and_utmp():
|
||||||
|
body = _capture_text()
|
||||||
|
assert "ss " in body or "ss -" in body
|
||||||
|
assert "who " in body or "who --" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_script_writes_meta_json():
|
||||||
|
body = _capture_text()
|
||||||
|
assert ".meta.json" in body
|
||||||
|
for key in ("attribution", "ssh_session", "writer", "sha256"):
|
||||||
|
assert key in body, f"meta key {key} missing from capture.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_script_emits_syslog_with_attribution():
|
||||||
|
body = _capture_text()
|
||||||
|
assert "logger" in body
|
||||||
|
assert "file_captured" in body
|
||||||
|
assert "src_ip" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_script_enforces_size_cap():
|
||||||
|
body = _capture_text()
|
||||||
|
assert "CAPTURE_MAX_BYTES" in body
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# File-catcher: entrypoint wiring
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_entrypoint_starts_capture_watcher():
|
||||||
|
ep = _entrypoint_text()
|
||||||
|
assert "decnet-capture" in ep
|
||||||
|
# masked process name for casual stealth
|
||||||
|
assert "kworker" in ep
|
||||||
|
# started before sshd so drops during first login are caught
|
||||||
|
assert ep.index("decnet-capture") < ep.index("exec /usr/sbin/sshd")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# File-catcher: compose_fragment volume
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_fragment_mounts_quarantine_volume():
|
||||||
|
frag = _fragment()
|
||||||
|
vols = frag.get("volumes", [])
|
||||||
|
assert any(
|
||||||
|
v.endswith(":/var/decnet/captured:rw") for v in vols
|
||||||
|
), f"quarantine volume missing: {vols}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fragment_quarantine_host_path_layout():
|
||||||
|
vols = _fragment()["volumes"]
|
||||||
|
host = vols[0].split(":", 1)[0]
|
||||||
|
assert host == "/var/lib/decnet/artifacts/test-decky/ssh"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fragment_quarantine_path_per_decky():
|
||||||
|
frag_a = get_service("ssh").compose_fragment("decky-01")
|
||||||
|
frag_b = get_service("ssh").compose_fragment("decky-02")
|
||||||
|
assert frag_a["volumes"] != frag_b["volumes"]
|
||||||
|
assert "decky-01" in frag_a["volumes"][0]
|
||||||
|
assert "decky-02" in frag_b["volumes"][0]
|
||||||
|
|||||||
Reference in New Issue
Block a user