Add egress drill + sid-verified harness and IR runbooks (roadmap v0.8)

Red-team harness: every detector now has a named drill with an expected
signature/sid, shown by `sentinel-redteam --list`. Add the missing
`egress` drill (the one detector without one) — explicit-only, since it
is the sole drill that emits real outbound traffic. After firing, the
harness runs `enodia-sentinel check` and reports PASS/MISS per poll
detection, turning it into a shared regression check.

Add docs/RUNBOOKS.md: confirm/preserve/contain/recover playbooks for
reverse shells & egress, persistence, trojaned binaries / package
tampering, hidden listeners & rootkits, and sensor tampering — each tied
to the real signatures, sids, and commands. Linked from README and the
OPERATIONS alert workflow.

Both close the remaining smaller v0.8 roadmap items.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-10 05:28:53 -07:00
parent f2d896a2d7
commit 4015ec872b
5 changed files with 335 additions and 14 deletions

View file

@ -8,11 +8,13 @@
# everything is cleaned up on exit.
#
# Usage:
# sentinel-redteam run all drills (default 20s each window)
# sentinel-redteam run the default drills (20s each window)
# sentinel-redteam <name>... run only the named drills
# sentinel-redteam --list list available drills
# sentinel-redteam --list list drills with their expected detector + sid
#
# Drills: reverse_shell ld_preload deleted_exe new_listener new_suid persistence
# Every current detector has a named drill with an expected signature/sid (see
# --list), so this doubles as a shared regression suite: after firing, it runs
# `enodia-sentinel check` and reports PASS/MISS for each poll detection.
#
# Watch it work, in another terminal:
# sudo tail -f /var/log/enodia-sentinel/events.log
@ -25,6 +27,24 @@ TMP="$(mktemp -d /tmp/enodia-drill.XXXXXX)"
PIDS=()
SANDBOX="$TMP/persistence-sandbox" # used by the persistence drill
LISTEN_PORT=14444
# egress drill target — the ONE drill that makes a real outbound connection.
EGRESS_HOST="${EGRESS_HOST:-1.1.1.1}"
EGRESS_PORT="${EGRESS_PORT:-53}"
# Drill metadata: name -> "signature sid kind", where kind is how it's verified:
# poll = the one-shot `check` should catch it
# event = only the eBPF monitor sees it (vanishes between polls)
# cond = conditional (needs extra setup; see the drill's note)
declare -A DRILL_SIG=(
[reverse_shell]="reverse_shell 100010 poll"
[ld_preload]="ld_preload 100011 poll"
[deleted_exe]="deleted_exe 100012 poll"
[new_listener]="new_listener 100013 poll"
[new_suid]="new_suid 100014 poll"
[persistence]="persistence 100015 cond"
[egress]="egress 100016 poll"
[ebpf_exec]="exec_anomaly 100001/100002 event"
)
cleanup() {
echo "[*] cleaning up drill artifacts..."
@ -110,6 +130,22 @@ drill_persistence() {
echo " -> wrote $SANDBOX/authorized_keys"
}
drill_egress() {
note egress "an interpreter (python) holding an outbound TCP connection to a public IP"
echo " WARNING: unlike the other drills, this makes ONE real outbound TCP"
echo " connection to a public host (${EGRESS_HOST}:${EGRESS_PORT}). No data is sent;"
echo " only the handshake. Set EGRESS_HOST/EGRESS_PORT to retarget, or skip it."
# python3 is an 'interpreter' to the egress detector; an established socket
# to a globally-routable IP is exactly the C2/exfil shape it flags (sid 100016).
python3 -c 'import socket,sys,time
s=socket.socket()
try:
s.connect((sys.argv[1], int(sys.argv[2]))); time.sleep(int(sys.argv[3]))
except Exception: pass' "$EGRESS_HOST" "$EGRESS_PORT" "$HOLD" >/dev/null 2>&1 &
PIDS+=("$!")
echo " -> python3 (pid $!) connected to ${EGRESS_HOST}:${EGRESS_PORT} for ${HOLD}s"
}
drill_ebpf_exec() {
note ebpf_exec "short-lived /tmp exec + reverse-shell-pattern argv (only the eBPF monitor catches these)"
cp /bin/true "$TMP/enodia-drill-exec" 2>/dev/null
@ -130,14 +166,29 @@ run() {
new_listener) drill_new_listener ;;
new_suid) drill_new_suid ;;
persistence) drill_persistence ;;
egress) drill_egress ;;
ebpf_exec) drill_ebpf_exec ;;
*) echo "unknown drill: $1" >&2; return 1 ;;
esac
}
# egress is excluded from the default run because it is the only drill that
# emits real network traffic; name it explicitly to include it.
ALL=(reverse_shell ld_preload deleted_exe new_listener new_suid persistence ebpf_exec)
KNOWN=(reverse_shell ld_preload deleted_exe new_listener new_suid persistence egress ebpf_exec)
if [ "${1:-}" = "--list" ]; then printf '%s\n' "${ALL[@]}"; exit 0; fi
if [ "${1:-}" = "--list" ]; then
printf '%-15s %-16s %-7s %s\n' DRILL SIGNATURE SID KIND
for d in "${KNOWN[@]}"; do
read -r sig sid kind <<<"${DRILL_SIG[$d]}"
printf '%-15s %-16s %-7s %s\n' "$d" "$sig" "$sid" "$kind"
done
echo
echo "kinds: poll=caught by 'check'; event=eBPF-only; cond=needs extra setup"
echo "note: rootcheck (100022-25) and posture (100040-47) are verified by"
echo " 'enodia-sentinel rootcheck' / 'posture check', not attack drills."
exit 0
fi
echo "######################################################################"
echo "# ENODIA SENTINEL — RED TEAM DRILL #"
@ -148,14 +199,31 @@ echo "######################################################################"
if [ "$#" -gt 0 ]; then targets=("$@"); else targets=("${ALL[@]}"); fi
for d in "${targets[@]}"; do run "$d"; done
echo -e "\n[*] drills live for ${HOLD}s. Run a one-shot poll check now:"
# --- verification: run one poll check and confirm each expected signature ----
echo -e "\n[*] drills live for ${HOLD}s. Verifying with a one-shot poll check..."
if command -v enodia-sentinel >/dev/null 2>&1; then
enodia-sentinel check 2>/dev/null | grep -i 'drill\|/tmp/enodia' \
&& echo " ^ Sentinel's poll detectors saw the drills." \
|| echo " (no poll hits yet — watch /var/log/enodia-sentinel/events.log)"
sleep 1
OUT="$(enodia-sentinel check 2>/dev/null)"
pass=0; miss=0
for d in "${targets[@]}"; do
read -r sig sid kind <<<"${DRILL_SIG[$d]}"
case "$kind" in
poll)
if grep -qw "$sig" <<<"$OUT"; then
printf ' PASS %-15s %s (sid %s)\n' "$d" "$sig" "$sid"; pass=$((pass+1))
else
printf ' MISS %-15s expected %s (sid %s)\n' "$d" "$sig" "$sid"; miss=$((miss+1))
fi ;;
event)
printf ' INFO %-15s %s — eBPF-only; see events.log (sid %s)\n' "$d" "$sig" "$sid" ;;
cond)
printf ' INFO %-15s %s — conditional; see the drill note (sid %s)\n' "$d" "$sig" "$sid" ;;
esac
done
echo " ---- ${pass} passed, ${miss} missed (poll detections) ----"
[ "$miss" -gt 0 ] && echo " MISS can be a real regression OR a timing/baseline gap — re-check events.log."
else
echo " (enodia-sentinel not on PATH — watch /var/log/enodia-sentinel/events.log)"
fi
echo " The 'ebpf_exec' drill only shows up with the eBPF monitor (service as root)."
echo "[*] holding for ${HOLD}s, then cleaning up..."
sleep "$HOLD"