malware-guard.sh
Rama main · réplica de lectura del repositorio original.
#!/bin/bash
# Watches the downloads tree and IMMEDIATELY DELETES Windows executables dressed up as video
# files (hash and metadata logged first). Runs as a systemd service.
#
# Detection is by EXTENSION *and* CONTENT (magic bytes): the same payload has arrived with its
# .exe dropped. Deletion is IRREVERSIBLE, so the content rule is deliberately narrow
# (STRONG_MAGIC / WEAK_MAGIC) and a file is held OPEN and re-verified immediately before unlink.
set -u
_here="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=lib/paths.sh
. "$_here/lib/paths.sh"
WATCH_DIR="$MEDIA_STACK_ROOT/data/torrents"
# ReadWritePaths in ops/systemd/malware-guard.service must cover this path.
LOG="$VAR_LOG/malware-guard.log"
# Full rescan interval: inotify can drop events on queue overflow, and a populated directory
# moved in whole generates no event for its children.
RESCAN_SECS=900
# Never legitimate in a media pipeline. Keep lowercased.
BAD_EXTS='exe|scr|bat|vbs|pif|lnk|cmd|com|msi|ps1|hta|wsf|jar'
# file(1) descriptions that justify irreversible deletion, anchored at the start (for a regular
# file it never contains the pathname). DO NOT WIDEN WITHOUT REPRODUCING THE FALSE POSITIVES.
# Rejected:
# * a bare "for MS Windows" substring: matches metadata (a gzip named "Subtitles for MS
# Windows.srt", Windows in EXIF); ^PE32 already catches the real payload.
# * "COM executable": weak one-byte heuristic; text beginning 0xb8 (EUC-KR note, CP437 NFO) matches.
# * "DOS/MBR boot sector": a zero-filled file with 55 aa at offset 510 matches.
#
# "MS-DOS executable" is DEMOTED to WEAK: "MZ" at offset 0 can occur by accident (a QuickTime
# `free` atom sized 0x4d5a0000). A weak hit never deletes alone: ffprobe must ALSO find no stream.
STRONG_MAGIC='^(PE32|ELF [0-9]|Mach-O)'
WEAK_MAGIC='^MS-DOS executable'
# Safe for a line-oriented log (a newline in a filename could forge a record).
qpath() {
printf '%s' "$1" | tr -c '[:print:]' '?'
}
# True when ffprobe finds no stream in the OPEN descriptor; only corroborates a WEAK_MAGIC hit.
not_media() {
local out
out=$(ffprobe -v error -show_entries stream=index -of csv=p=0 "/proc/self/fd/$1" 2>/dev/null)
[[ -z "${out//[[:space:]]/}" ]]
}
log() {
printf '[%s] %s\n' "$(date -u +%FT%TZ)" "$*" | tee -a "$LOG"
}
# Classify and, if warranted, destroy ONE path: the single decision point for the sweep and the
# live watch. The descriptor is held from classification through unlink; before rm the file is
# re-verified both ways: the inode must still be the one classified (replace-by-rename) and
# re-classifying the descriptor must say the same (in-place rewrite). Bash cannot unlink by
# descriptor, so a window of one syscall survives.
check_and_destroy() {
local f="$1" base ext lc_ext desc why="" fd sha size ino_open ino_now recheck
[[ -f "$f" ]] || return 0
base="${f##*/}"
ext="${base##*.}"
lc_ext=$(printf '%s' "$ext" | tr '[:upper:]' '[:lower:]')
exec {fd}<"$f" 2>/dev/null || return 0
ino_open=$(stat -L -c%i "/proc/self/fd/$fd" 2>/dev/null) || { exec {fd}<&-; return 0; }
# Extension only if the BASENAME has a dot (a dotted parent dir must not supply one).
if [[ "$base" == *.* && "|$BAD_EXTS|" == *"|$lc_ext|"* ]]; then
why="extension:$lc_ext"
else
# -L dereferences /proc/self/fd/N; classifying the descriptor, not the path, judges exactly
# the bytes we destroy.
desc=$(file -bL -- "/proc/self/fd/$fd" 2>/dev/null)
if [[ -n "$desc" && "$desc" =~ $STRONG_MAGIC ]]; then
why="content:$desc"
elif [[ -n "$desc" && "$desc" =~ $WEAK_MAGIC ]] && not_media "$fd"; then
why="content-weak+nomedia:$desc"
fi
fi
if [[ -z "$why" ]]; then
exec {fd}<&-
return 0
fi
sha=$(sha256sum < "/proc/self/fd/$fd" | cut -d' ' -f1)
size=$(stat -L -c%s "/proc/self/fd/$fd" 2>/dev/null)
# Forensic record BEFORE the final check and the unlink.
log "DESTROYING reason=$why sha256=$sha size=$size src='$(qpath "$f")'"
# Content re-check FIRST: anything slow between the pathname check and rm lets a replacement
# arriving in that gap be what gets deleted.
recheck=$(file -bL -- "/proc/self/fd/$fd" 2>/dev/null)
if [[ "$why" == content* && "$recheck" != "$desc" ]]; then
exec {fd}<&-
log "ABORTED not deleted: content changed during inspection (now '${recheck:-unreadable}') src='$(qpath "$f")'"
return 0
fi
# A weak hit rests on BOTH conditions, so both must still hold (an incomplete download that
# gains decodable streams still reports "MS-DOS executable").
if [[ "$why" == content-weak* ]] && ! not_media "$fd"; then
exec {fd}<&-
log "ABORTED not deleted: became decodable media during inspection src='$(qpath "$f")'"
return 0
fi
ino_now=$(stat -c%i -- "$f" 2>/dev/null)
exec {fd}<&-
if [[ "$ino_open" != "${ino_now:-}" ]]; then
log "ABORTED not deleted: path re-pointed during inspection (inode $ino_open -> ${ino_now:-gone}) src='$(qpath "$f")'"
return 0
fi
rm -f -- "$f" || {
log "FAILED to delete: $(qpath "$f")"
return 1
}
}
sweep() {
while IFS= read -r -d '' f; do
check_and_destroy "$f"
done < <(find "$WATCH_DIR" -type f -print0 2>/dev/null)
}
ROOT_INO=$(stat -c%i -- "$WATCH_DIR" 2>/dev/null) \
|| { log "FATAL: watch root $WATCH_DIR does not exist"; exit 1; }
log "malware-guard starting, watching $WATCH_DIR (root inode $ROOT_INO)"
# The watcher must be RUNNING before the sweep, or a file landing in an already-enumerated
# directory is missed. (1) bash blocks opening a FIFO's write end until a reader exists, so the
# read end is opened on a descriptor FIRST. (2) -q is omitted so we wait for "Watches
# established." rather than sleeping.
EVENTS=$(mktemp -u -t malware-guard.XXXXXX.fifo)
WSTATUS=$(mktemp -t malware-guard.XXXXXX.status)
mkfifo -m 600 "$EVENTS" || { log "FATAL: cannot create event FIFO"; exit 1; }
exec {EV}<>"$EVENTS"
# NUL-delimited (%w%f%0 + --no-newline): a newline in a filename would split one path in two.
inotifywait -m -r --format '%w%f%0' --no-newline \
-e close_write -e moved_to \
"$WATCH_DIR" >&$EV 2>"$WSTATUS" &
WATCHER=$!
cleanup() { kill "$WATCHER" 2>/dev/null; rm -f "$EVENTS" "$WSTATUS"; }
trap cleanup EXIT INT TERM
for _ in $(seq 1 600); do
grep -q "Watches established" "$WSTATUS" 2>/dev/null && break
kill -0 "$WATCHER" 2>/dev/null || { log "FATAL: watcher died: $(head -2 "$WSTATUS")"; exit 1; }
sleep 0.5
done
grep -q "Watches established" "$WSTATUS" 2>/dev/null \
|| log "WARNING: proceeding without watcher readiness confirmation"
sweep
last_sweep=$SECONDS
while :; do
if IFS= read -r -d '' -t 60 -u "$EV" f; then
if [[ -d "$f" ]]; then
# A directory moved in whole generates no events for existing children; walk it.
while IFS= read -r -d '' g; do
check_and_destroy "$g"
done < <(find "$f" -type f -print0 2>/dev/null)
else
check_and_destroy "$f"
fi
else
# >128 is the read timeout; anything else means the stream ended.
(( $? > 128 )) || break
# EOF is NOT a death signal (we hold the FIFO's write end open via <>); without this poll the
# service would stay "active" but degraded to periodic sweeps only.
if ! kill -0 "$WATCHER" 2>/dev/null; then
log "watcher pid $WATCHER is gone; exiting so systemd restarts a live watch"
exit 1
fi
# A live process is NOT proof of a live watch: deleting the watched root drops inotifywait's
# watch count to zero while it keeps running. Restart if the root's inode changed or vanished.
root_now=$(stat -c%i -- "$WATCH_DIR" 2>/dev/null)
if [[ "$root_now" != "$ROOT_INO" ]]; then
log "watch root changed (inode $ROOT_INO -> ${root_now:-gone}); exiting to rebuild watches"
exit 1
fi
fi
if (( SECONDS - last_sweep >= RESCAN_SECS )); then
sweep
last_sweep=$SECONDS
fi
done
log "event stream ended (watcher pid $WATCHER); exiting for restart"
exit 1