#!/usr/bin/env bash
#
##
# Linux Malware Detect v2.0.1
#             (C) 2002-2026, R-fx Networks <proj@rfxn.com>
#             (C) 2026, Ryan MacDonald <ryan@rfxn.com>
# This program may be freely redistributed under the terms of the GNU GPL v2
##
#
PATH=$PATH:/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin
lmd_version="2.0.1"

# Resolve install/source path (3-tier: LMD_BASEDIR env > source-tree auto-detect > default)
if [ -n "${LMD_BASEDIR:-}" ]; then
	inspath="$LMD_BASEDIR"
else
	_self=$(readlink -f "$0" 2>/dev/null || echo "$0")  # resolve symlinks; fallback to $0
	_selfdir="${_self%/*}"
	if [ -f "$_selfdir/internals/internals.conf" ]; then
		inspath="$_selfdir"
	else
		inspath='/usr/share/maldet'
	fi
	unset _self _selfdir
fi
# consumed; prevent leakage to child processes (e.g. maldet -b)
unset LMD_BASEDIR
intcnf="/etc/maldet/internals.conf"
if [ -f "/etc/sysconfig/maldet" ]; then
        syscnf=/etc/sysconfig/maldet
elif [ -f "/etc/default/maldet" ]; then
        syscnf=/etc/default/maldet
fi

header() {
	echo "Linux Malware Detect v$lmd_version"
	echo "            (C) 2002-2026, R-fx Networks <proj@rfxn.com>"
	echo "            (C) 2026, Ryan MacDonald <ryan@rfxn.com>"
	echo "This program may be freely redistributed under the terms of the GNU GPL v2"
	echo ""
}

# FHS package layout paths — fallback when legacy symlink farm is broken
_fhs_internals="/usr/lib/maldet/internals"

if [ -f "$intcnf" ]; then
	source "$intcnf"
elif [ -f "$_fhs_internals/internals.conf" ]; then
	source "$_fhs_internals/internals.conf"
else
	header
	echo "maldet($$): {glob} \$intcnf not found, aborting." >&2
	exit 1
fi

# Source decomposed library hub (sources all vendored libs, sub-libraries, and config)
if [ -f "$inspath/internals/lmd.lib.sh" ]; then
	source "$inspath/internals/lmd.lib.sh"
elif [ -f "$_fhs_internals/lmd.lib.sh" ]; then
	source "$_fhs_internals/lmd.lib.sh"
else
	header
	echo "maldet($$): {glob} lmd.lib.sh not found, aborting." >&2
	exit 1
fi

# Verify and repair package symlink farm (no-op for install.sh layout —
# pkg_fhs_verify_farm returns 0 immediately when manifest file is absent).
# pkg_lib.sh is not in the lmd.lib.sh source chain (it is a packaging
# library, not a runtime library), so source it directly here.
# shellcheck disable=SC1090
_pkg_lib="$inspath/internals/pkg_lib.sh"
if [ ! -f "$_pkg_lib" ] && [ -f "$_fhs_internals/pkg_lib.sh" ]; then
	_pkg_lib="$_fhs_internals/pkg_lib.sh"
fi
if [ -f "$_pkg_lib" ]; then
	source "$_pkg_lib"
	pkg_fhs_verify_farm "$_fhs_internals/.symlink-manifest" ||
		echo "maldet($$): warning: symlink farm has unresolvable entries — reinstall package" >&2
fi

# prerun operations
prerun

# Pre-parse: extract position-independent modifier flags from anywhere in argv
_all_args=("$@")
_remaining_args=()
_report_format=""
_report_mailto=""
for (( _i=0; _i<${#_all_args[@]}; _i++ )); do
	case "${_all_args[$_i]}" in
		-b|--background)
			set_background=1
		;;
		--verbose)
			set_verbose=1
		;;
		-hscan|--hook-scan|--modsec)
			hscan=1
		;;
		-x|--exclude-regex)
			((_i++))
			if [ "$_i" -lt "${#_all_args[@]}" ]; then
				exclude_regex_args=(-not -regex "${_all_args[$_i]}")
			else
				echo "ERROR: -x/--exclude-regex requires a regex pattern argument" >&2
				exit 1
			fi
		;;
		-i|--include-regex)
			((_i++))
			if [ "$_i" -lt "${#_all_args[@]}" ]; then
				include_regex_args=(-regex "${_all_args[$_i]}")
			else
				echo "ERROR: -i/--include-regex requires a regex pattern argument" >&2
				exit 1
			fi
		;;
		-qd)
			((_i++))
			if [ "$_i" -ge "${#_all_args[@]}" ]; then
				echo "ERROR: -qd requires a directory argument" >&2
				exit 1
			fi
			if [ ! -d "${_all_args[$_i]}" ]; then
				echo "ERROR: -qd directory does not exist: ${_all_args[$_i]}" >&2
				exit 1
			fi
			eout "{scan} set quarantine path: ${_all_args[$_i]}" 1
			quardir="${_all_args[$_i]}"
			_qd_override=1
		;;
		--format)
			((_i++))
			if [ "$_i" -lt "${#_all_args[@]}" ]; then
				_report_format="${_all_args[$_i]}"
				case "$_report_format" in
					text|json|html|tsv) ;;
					*) echo "ERROR: --format requires text, json, html, or tsv" >&2; exit 1 ;;
				esac
			else
				echo "ERROR: --format requires a format argument (text, json, html, tsv)" >&2
				exit 1
			fi
		;;
		--mailto)
			((_i++))
			if [ "$_i" -lt "${#_all_args[@]}" ]; then
				_report_mailto="${_all_args[$_i]}"
				case "$_report_mailto" in
					*@*) ;;
					*) echo "ERROR: --mailto requires a valid email address" >&2; exit 1 ;;
				esac
			else
				echo "ERROR: --mailto requires an email address argument" >&2
				exit 1
			fi
		;;
		-co|--config-option)
			((_i++))
			if [ "$_i" -ge "${#_all_args[@]}" ]; then
				echo "ERROR: -co/--config-option requires a VAR=VAL argument" >&2
				exit 1
			fi
			_apply_cli_co "${_all_args[$_i]}" || exit 1
		;;
		--all)
			_list_all=1
		;;
		*)
			_remaining_args+=("${_all_args[$_i]}")
		;;
	esac
done
set -- "${_remaining_args[@]}"
unset _all_args _remaining_args _i

usage_short() {
cat <<EOF
signature set: $sig_version
usage: maldet [OPTION] [ARGUMENT]

  scanning:
    -a|--scan-all PATH          scan all files in path
    -r|--scan-recent PATH DAYS  scan recently created/modified files
    -f|--file-list FILE         scan files from a file list
    -b|--background             run scan in the background

  scan filters:
    -i|--include-regex REGEX    include only matching paths
    -x|--exclude-regex REGEX    exclude matching paths
    -co|--config-option VAR=VAL override config options at runtime
                                (enable YARA: -co scan_yara=1)
    -U|--user USER              run as specified user

  monitoring:
    -m|--monitor USERS|PATHS|FILE|RELOAD  start inotify monitoring
    -k|--kill-monitor           stop inotify monitoring

  quarantine & restore:
    -q|--quarantine SCANID      quarantine hits from scan
    -n|--clean SCANID           clean malware from scan hits
    -s|--restore FILE|SCANID    restore quarantined file(s)

  scan management:
    -L|--list-active              list active scans
    --kill SCANID                 abort a running scan
    --pause SCANID [DURATION]     pause a running scan
    --unpause SCANID              resume a paused scan
    --stop SCANID                 checkpoint and stop a running scan
    --continue SCANID             resume a stopped scan from checkpoint
    --maintenance                 rotate histories, compress/archive old sessions

  reporting:
    -e|--report [SCANID|list|latest|hooks|active]  view scan report
    --all                         show full history with -e list (default: recent)
    --format text|json|html|tsv   set report output format (default: text)
    --mailto ADDRESS              email report to address
    --json-report [SCANID|list]   shorthand: --report --format json
    --alert-daily                 generate inotify monitor digest alert
    --digest                      fire unified digest (monitor + hook sources)
    -l|--log                      view event log

  updates:
    -u|--update-sigs [--force]  update malware signatures
    -d|--update-ver [--force|--beta]  update maldet version

  other:
    -p|--purge                  clear logs, quarantine, temp data
    -c|--checkout FILE          submit suspected malware to rfxn.com
    --test-alert TYPE CHANNEL   send test alert (scan|digest, email|slack|telegram|discord)
    --mkpubpaths                create per-user pub/ data directories
    --web-proxy IP:PORT         set HTTP/HTTPS proxy
    -v|--version                show version information
    -h|--help                   show detailed help
EOF
}

usage_long() {
cat<<EOF
signature set: $sig_version
usage: maldet [OPTION] [ARGUMENT]

SCANNING:
    -a, --scan-all PATH
       Scan all files in path (default: /home, wildcard: ?)
       e.g: maldet -a /home/?/public_html

    -r, --scan-recent PATH DAYS
       Scan files changed in the last X days (default: 7d, wildcard: ?)
       e.g: maldet -r /home/?/public_html 2

    -f, --file-list FILE
       Scan files or paths defined in line-spaced file
       e.g: maldet -f /root/scan_file_list

    -b, --background
       Execute scan operations in the background, ideal for large scans.
       May appear anywhere in the command line.
       e.g: maldet -b -r /home/?/public_html 7
       e.g: maldet -a /home -b

SCAN FILTERS:
    -i, --include-regex REGEX
       Include paths/files based on POSIX extended regular expression (grep -E).
       May appear anywhere in the command line.
       e.g: maldet --include-regex ".*/wp-content/.*|.*.php\$"
       e.g: maldet -a /home -i ".*.php\$"

    -x, --exclude-regex REGEX
       Exclude paths/files based on POSIX extended regular expression (grep -E).
       May appear anywhere in the command line.
       e.g: maldet --exclude-regex ".*wp-content/w3tc/.*|.*core.[0-9]+\$"
       e.g: maldet -a /home -x ".*\.log"

    -co, --config-option VAR=VALUE[,VAR=VALUE,...]
       Override conf.maldet config options at runtime
       e.g: maldet --config-option email_addr=you@domain.com,quarantine_hits=1

    -U, --user USER
       Run as specified user, for user quarantine or viewing user reports
       e.g: maldet --user nobody --report
       e.g: maldet --user nobody --restore 050910-1534.21135

MONITORING:
    -m, --monitor USERS|PATHS|FILE|RELOAD
       Start inotify kernel-level file create/modify monitoring
       USERS:  monitor homedirs for UIDs >= inotify_minuid (default: 500)
       FILE:   extract paths from line-spaced file
       PATHS:  comma-separated path list (no wildcards)
       RELOAD: trigger configuration reload on running monitor
       e.g: maldet --monitor users
       e.g: maldet --monitor /root/monitor_paths
       e.g: maldet --monitor /home/mike,/home/ashton
       e.g: maldet --monitor RELOAD

    -k, --kill-monitor (also: -kill)
       Terminate inotify monitoring service

QUARANTINE & RESTORE:
    -q, --quarantine SCANID
       Quarantine all malware from scan report
       e.g: maldet --quarantine 050910-1534.21135

    -n, --clean SCANID
       Try to clean & restore malware hits from scan report
       e.g: maldet --clean 050910-1534.21135

    -s, --restore FILE|SCANID
       Restore file from quarantine to original path, or restore all
       items from a specific scan
       e.g: maldet --restore /usr/local/maldetect/quarantine/config.php.23754
       e.g: maldet --restore 050910-1534.21135

    -qd PATH
       Override the quarantine directory for this run.
       PATH must exist or the command exits with an error.
       May appear anywhere in the command line.
       e.g: maldet -qd /data/quarantine -q 050910-1534.21135
       e.g: maldet -q 050910-1534.21135 -qd /data/quarantine

SCAN MANAGEMENT:
    -L, --list-active
       List active (running, paused, stale) scans with status info.
       Combine with --format for JSON or TSV output. Use --verbose
       for additional detail (workers, sig version, progress).
       e.g: maldet -L
       e.g: maldet --format json -L
       e.g: maldet --verbose -L

    --kill SCANID
       Abort a running or paused scan. Writes an abort sentinel for
       cooperative shutdown, then sends SIGTERM (with SIGKILL fallback
       after 30s). Cleans scan-scoped temp files and updates meta to
       state=killed. Also handles stale scans (process already dead).
       e.g: maldet --kill 260327-1509.25279

    --pause SCANID [DURATION]
       Pause a running scan. Workers enter a sleep loop and external
       processes (clamscan, yara) receive SIGSTOP. Optional DURATION
       auto-resumes after the specified time: Ns (seconds), Nm (minutes),
       Nh (hours), or bare number (seconds). Without DURATION, the pause
       is indefinite until --unpause. Not supported for daemon ClamAV
       (clamdscan) scans.
       e.g: maldet --pause 260327-1509.25279
       e.g: maldet --pause 260327-1509.25279 30m
       e.g: maldet --pause 260327-1509.25279 2h

    --unpause SCANID
       Resume a paused scan. Removes the pause sentinel and sends
       SIGCONT to any stopped external processes.
       e.g: maldet --unpause 260327-1509.25279

    --stop SCANID
       Checkpoint and stop a running or paused scan at the current
       stage boundary. Writes a checkpoint file preserving the scan
       state (stage, hits, options, sig version) for later resumption
       via --continue. Not supported for daemon ClamAV (clamdscan)
       scans.
       e.g: maldet --stop 260327-1509.25279

    --continue SCANID
       Resume a previously stopped scan from its checkpoint. Compares
       the current signature version with the checkpoint and warns if
       signatures have changed. Restores -co options from the
       checkpoint. Use --unpause for paused scans instead.
       e.g: maldet --continue 260327-1509.25279

    --maintenance
       Run maintenance tasks: rotate oversized history files (hits.hist,
       quarantine.hist, monitor.scanned.hist, inotify_log), clean up
       stale scan meta files, compress completed session files older
       than 1 hour, and archive sessions older than 30 days into monthly
       bundles (session.archive.YYMM.tsv.gz). Intended for cron or
       manual housekeeping.
       e.g: maldet --maintenance

REPORTING:
    -e, --report [SCANID|list|latest|hooks|active]
       View scan report of most recent scan or of a specific SCANID.
       Use "latest" for most recent, "list" for all, "hooks" for hook
       scan activity, "active" for running scans. Requesting a SCANID
       that is still running shows active scan status instead.
       Combine with --format and --mailto.
       e.g: maldet --report
       e.g: maldet --report list
       e.g: maldet --report 050910-1534.21135
       e.g: maldet --format json -e 050910-1534.21135
       e.g: maldet --mailto admin@example.com -e 050910-1534.21135
       e.g: maldet --report hooks
       e.g: maldet --report hooks --last 7d
       e.g: maldet --report hooks --mode modsec
       e.g: maldet --report active

    --report hooks [--last DURATION] [--mode MODE]
       View hook scan activity log. DURATION: Nh (hours), Nd (days),
       Nm (minutes), default 24h. MODE: filter by hook mode (modsec,
       ftp, proftpd, exim, generic).

    --all
       Show full scan history when used with -e list. Without --all,
       -e list shows only recent sessions. May appear anywhere in the
       command line.
       e.g: maldet -e list --all
       e.g: maldet --all --report list

    --format text|json|html|tsv
       Set report output format for -e/--report and -L (default: text).
       May appear anywhere in the command line.
       e.g: maldet --format json -e list
       e.g: maldet -e 050910-1534.21135 --format html

    --mailto ADDRESS
       Email the report to the specified address instead of stdout.
       May appear anywhere in the command line.
       e.g: maldet --mailto admin@example.com -e 050910-1534.21135
       e.g: maldet --format json --mailto admin@example.com -e latest

    --json-report [SCANID|list|newest]
       Shorthand for --report --format json. Output scan report as
       structured JSON (v1.2 schema: schema_version, scanner, host,
       reports[]). Supports both TSV and legacy plaintext sessions
       (legacy sessions include "source": "legacy").
       Use "list" to output all session reports as a JSON array.
       e.g: maldet --json-report
       e.g: maldet --json-report 050910-1534.21135
       e.g: maldet --json-report list

    --alert-daily (also: --monitor-report)
       Generate a digest alert summarizing inotify monitor activity.
       Requires monitor mode to be running.

    --digest
       Fire a unified digest alert reading all available sources: monitor
       session hits, hook scan hits, cleaned/suspended history. Does not
       require monitor mode — fires if any source has new data since the
       last digest. Cursors advance on read, preventing duplicate alerts.

    -l, --log
       View maldet event log

YARA SCANNING:
    Native YARA scanning invokes the yara (or yr from YARA-X) binary
    independently of ClamAV. Enable via conf.maldet or runtime -co flag.

    Config options (set in conf.maldet or via -co):
      scan_yara              enable YARA scan stage [auto|0|1] (default: auto)
      scan_yara_timeout      YARA timeout in seconds (default: 300, 0=none)
      scan_yara_scope        rule scope when ClamAV also active [all|custom]
                             (default: custom)

    Custom rules:
      sigs/custom.yara       single-file YARA rules
      sigs/custom.yara.d/    drop-in directory for .yar/.yara rule files

    e.g: maldet -co scan_yara=1 -a /home/?/public_html

UPDATES:
    -u, --update-sigs (also: --update) [--force]
       Update malware detection signatures from rfxn.com

    -d, --update-ver (also: --update-version) [--force|--beta]
       Update the installed version from rfxn.com

    --cron-sigup
       Perform a signature update for the independent cron job.
       Internal use only — invoked by /etc/cron.d/maldet-sigup.

OTHER:
    -p, --purge
       Clear logs, quarantine queue, session and temporary data

    --test-alert TYPE CHANNEL
       Send a test alert with synthetic data to verify alerting configuration.
       TYPE: scan (scan report with 3 synthetic hits) or digest (digest summary).
       CHANNEL: email, slack, telegram, or discord.
       Uses the real rendering pipeline with a [TEST] subject prefix.
       Channel isolation ensures only the specified channel receives the alert.
       Requires root privileges.
       e.g: maldet --test-alert scan email
       e.g: maldet --test-alert scan slack
       e.g: maldet --test-alert digest email

    -c, --checkout FILE
       Submit suspected malware to rfxn.com for review & signature hashing

    --mkpubpaths
       Create per-user pub/ data directories for non-root scan operations.
       Requires scan_user_access=1 in conf.maldet.

    --web-proxy (also: --wget-proxy, --curl-proxy) IP:PORT
       Set HTTP/HTTPS proxy for all remote URL calls

    -hscan, --hook-scan (also: --modsec)
       Scan a single file supplied by a ModSecurity inspectFile hook.
       Used internally by hookscan.sh; not intended for direct invocation.
       May appear anywhere in the command line.

    -v, --version
       Show version information

    -h, --help
       Show this help message
EOF
}

if [ -z "$1" ]; then
	header
	usage_short
else
	while [ -n "$1" ]; do
		case "$1" in
			--mkpubpaths)
				if [ "$scan_user_access" == "1" ]; then
					chmod 711 "$userbasedir"
					while IFS=':' read -r user _rest; do
						uid=$(id -u "$user")
						if [ -z "$uid" ]; then
							uid=9
						fi
						if [ -z "$scan_user_access_minuid" ]; then
							scan_user_access_minuid=100
						fi
						if [ "$uid" -ge "$scan_user_access_minuid" ] && [ ! -d "$userbasedir/$user" ]; then
							mkdir -p "$userbasedir/$user/quar" "$userbasedir/$user/sess" "$userbasedir/$user/tmp"
							touch "$userbasedir/$user/event_log"
							chown -R "$user" "$userbasedir/$user"
							chmod 750 "$userbasedir/$user" "$userbasedir/$user/quar" "$userbasedir/$user/sess" "$userbasedir/$user/tmp"
							chmod 640 "$userbasedir/$user/event_log"
							eout "{glob} created public scanning paths for user $user"
						fi
						unset uid user
					done < /etc/passwd
					exit 0
				else
					header
					echo "public scanning support not enabled in $cnf, aborting."
					exit 1
				fi
			;;
			-U|--user)
				shift
				user="$1"
				if [ -z "$user" ]; then
					echo "ERROR: --user requires a username argument" >&2
					exit 1
				fi
				case "$user" in
					*..* | */* )
						echo "ERROR: --user value contains invalid characters" >&2
						exit 1
					;;
				esac
				if ! id "$user" > /dev/null 2>&1; then
					echo "ERROR: user '$user' does not exist" >&2
					exit 1
				fi
				if [ -z "$_qd_override" ]; then
					quardir=$userbasedir/$user/quar
				fi
				sessdir=$userbasedir/$user/sess
				tmpdir=$userbasedir/$user/tmp
				maldet_log=$userbasedir/$user/event_log
			;;
			-c|--checkout)
				shift
				if [ -z "$1" ]; then
					echo "ERROR: -c/--checkout requires a file or path argument" >&2
					exit 1
				fi
				header
				checkout "$1"
			;;
			--wget-proxy|--curl-proxy|--web-proxy)
				shift
				if [ -z "$1" ]; then
					echo "ERROR: --web-proxy requires a proxy address argument" >&2
					exit 1
				fi
				web_proxy="$1"
			;;
			--alert-daily|--monitor-report)
				if ! pgrep -f 'inotify.paths.[0-9]+' >/dev/null 2>&1; then
					echo "ERROR: no active monitor process found; start with: maldet --monitor users" >&2
					exit 1
				fi
				genalert digest
			;;
			--digest)
				_lmd_alert_init
				genalert digest
			;;
			--test-alert)
				if [ "$(id -u)" -ne 0 ]; then
					echo "ERROR: --test-alert requires root privileges" >&2
					exit 1
				fi
				shift
				_test_type="${1:-}"
				shift 2>/dev/null || true  # safe: handles missing arg without error
				_test_channel="${1:-}"
				if [ -z "$_test_type" ] || [ -z "$_test_channel" ]; then
					echo "usage: maldet --test-alert {scan|digest} {email|slack|telegram|discord}" >&2
					exit 1
				fi
				case "$_test_type" in
					scan|digest) ;;
					*)
						echo "ERROR: invalid alert type '$_test_type' (use: scan or digest)" >&2
						exit 1
						;;
				esac
				case "$_test_channel" in
					email|slack|telegram|discord) ;;
					*)
						echo "ERROR: invalid channel '$_test_channel' (use: email, slack, telegram, or discord)" >&2
						exit 1
						;;
				esac
				_lmd_alert_init
				case "$_test_type" in
					scan) _test_alert_scan "$_test_channel" ;;
					digest) _test_alert_digest "$_test_channel" ;;
				esac
			;;
			-m|--monitor)
				header
				shift
				# Use CLI argument if provided, fall back to default_monitor_mode
				# from conf.maldet. If neither is set, bail out cleanly (exit 0
				# so systemd Restart=on-failure does not respawn).
				_mon_mode="${1:-${default_monitor_mode:-}}"
				if [ -z "$_mon_mode" ]; then
					eout "{mon} no monitor mode specified and default_monitor_mode is not configured" 1
					eout "{mon} set default_monitor_mode in $cnf or MONITOR_MODE in /etc/sysconfig/maldet" 1
					exit 0
				fi
				if [ "$os_freebsd" == "1" ]; then
					eout "{mon} not currently supported under FreeBSD" 1
				elif [ "$_mon_mode" == "reload" ] || [ "$_mon_mode" == "RELOAD" ]; then
					eout "{mon} queued monitor for configuration reload" 1
					command touch "$inspath/reload_monitor"
				else
					svc=m
					if [ "$set_background" = "1" ]; then
						eout "{mon} daemonizing monitor supervisor to background" 1
						( exec "$0" --monitor "$_mon_mode" ) >> /dev/null 2>&1 &
						_bg_pid=$!
						echo "$_bg_pid" > "$tmpdir/monitor.pid"
						eout "{mon} background monitor started (pid: $_bg_pid)" 1
						exit 0
					fi
					monitor_init "$_mon_mode"
				fi
			;;
			-k|--kill-monitor|-kill)
				header
				if [ "$os_freebsd" == "1" ]; then
					eout "{mon} not currently supported under FreeBSD" 1
				elif command -v systemctl >/dev/null 2>&1 && systemctl is-active maldet.service >/dev/null 2>&1; then
					# Use systemctl stop so Restart=on-failure does not respawn
					eout "{mon} stopping monitor via systemctl" 1
					systemctl stop maldet.service
				else
					if [ -f "$tmpdir/monitor.pid" ]; then
						_mpid=$(cat "$tmpdir/monitor.pid")
						if [ -n "$_mpid" ] && kill -0 "$_mpid" 2>/dev/null; then
							eout "{mon} sending stop to monitor supervisor (pid: $_mpid)" 1
							monitor_kill
						else
							eout "{mon} stale monitor.pid found, cleaning up" 1
							command rm -f "$tmpdir/monitor.pid"
						fi
					else
						monitorpid=$(pgrep -f 'inotify.paths.[0-9]+' 2>/dev/null)
						if [ -z "$monitorpid" ]; then
							eout "{mon} could not find running monitor process, are we already dead?" 1
						else
							eout "{mon} sent kill to legacy monitor process (pid: $monitorpid)" 1
							monitor_kill
						fi
					fi
				fi
			;;
			--kill)
				shift
				if [ -z "${1:-}" ]; then
					echo "maldet($$): --kill requires a SCANID argument" >&2
					exit 1
				fi
				# Validate scanid format: YYMMDD-HHMM.PID
				_valid_scanid_pat='^[0-9]{6}-[0-9]{4}\.[0-9]+$'
				if [[ ! "$1" =~ $_valid_scanid_pat ]]; then
					echo "maldet($$): invalid SCANID format: $1" >&2
					exit 1
				fi
				header
				_lifecycle_kill "$1"
				exit $?
			;;
			--pause)
				shift
				if [ -z "${1:-}" ]; then
					echo "maldet($$): --pause requires a SCANID argument" >&2
					exit 1
				fi
				# Validate scanid format: YYMMDD-HHMM.PID
				_valid_scanid_pat='^[0-9]{6}-[0-9]{4}\.[0-9]+$'
				if [[ ! "$1" =~ $_valid_scanid_pat ]]; then
					echo "maldet($$): invalid SCANID format: $1" >&2
					exit 1
				fi
				_pause_scanid="$1"
				shift
				header
				_lifecycle_pause "$_pause_scanid" "${1:-}"
				exit $?
			;;
			--unpause)
				shift
				if [ -z "${1:-}" ]; then
					echo "maldet($$): --unpause requires a SCANID argument" >&2
					exit 1
				fi
				# Validate scanid format: YYMMDD-HHMM.PID
				_valid_scanid_pat='^[0-9]{6}-[0-9]{4}\.[0-9]+$'
				if [[ ! "$1" =~ $_valid_scanid_pat ]]; then
					echo "maldet($$): invalid SCANID format: $1" >&2
					exit 1
				fi
				header
				_lifecycle_unpause "$1"
				exit $?
			;;
			--stop)
				shift
				if [ -z "${1:-}" ]; then
					echo "maldet($$): --stop requires a SCANID argument" >&2
					exit 1
				fi
				# Validate scanid format: YYMMDD-HHMM.PID
				_valid_scanid_pat='^[0-9]{6}-[0-9]{4}\.[0-9]+$'
				if [[ ! "$1" =~ $_valid_scanid_pat ]]; then
					echo "maldet($$): invalid SCANID format: $1" >&2
					exit 1
				fi
				header
				_lifecycle_stop "$1"
				exit $?
			;;
			--continue)
				shift
				if [ -z "${1:-}" ]; then
					echo "maldet($$): --continue requires a SCANID argument" >&2
					exit 1
				fi
				# Validate scanid format: YYMMDD-HHMM.PID
				_valid_scanid_pat='^[0-9]{6}-[0-9]{4}\.[0-9]+$'
				if [[ ! "$1" =~ $_valid_scanid_pat ]]; then
					echo "maldet($$): invalid SCANID format: $1" >&2
					exit 1
				fi
				header
				_lifecycle_continue "$1" || exit $?
				# Read meta for original scan path
				_lifecycle_read_meta "$_continue_scanid" || {
					echo "maldet($$): {lifecycle} meta not found for $_continue_scanid" >&2
					exit 1
				}
				svc=a
				trap trap_exit INT TERM
				spath="$_meta_path"
				hrspath="$_meta_path"
				scan "$spath" all
			;;
			-L|--list-active)
				# List active scans — structured output suppresses header
				if [ -z "${_report_format:-}" ] || [ "$_report_format" = "text" ]; then
					header
				fi
				_lifecycle_list_active "${_report_format:-text}" "${set_verbose:-0}"
				exit $?
			;;
			--maintenance)
				header
				eout "{lifecycle} running maintenance tasks" 1
				# Rotate oversized history files
				_rotate_histories
				# Clean up old meta files
				_lifecycle_cleanup_stale_metas
				# Compress completed sessions older than maint_compress_age days
				_maint_compress_age_days="${maint_compress_age:-30}"
				if [ "$_maint_compress_age_days" != "0" ]; then
					_maint_compress_age_min=$((_maint_compress_age_days * 24 * 60))
					for _maint_tsv in "$sessdir"/session.tsv.[0-9]*; do
						[ -f "$_maint_tsv" ] || continue
						# Skip already-compressed (.gz) files — glob won't match them
						# but guard against session.tsv.SCANID.gz naming confusion
						case "$_maint_tsv" in *.gz) continue ;; esac
						if [ -n "$($find "$_maint_tsv" -maxdepth 0 -mmin +"$_maint_compress_age_min" 2>/dev/null)" ]; then  # safe: find may warn on race-deleted files
							_maint_sid="${_maint_tsv##*session.tsv.}"
							_session_compress "$_maint_sid"
							eout "{lifecycle} compressed session: $_maint_sid" 1
						fi
					done
				fi
				# Archive sessions older than maint_archive_age days into monthly bundles
				_maint_archive_age_days="${maint_archive_age:-90}"
				if [ "$_maint_archive_age_days" != "0" ]; then
					_maint_archive_age_min=$((_maint_archive_age_days * 24 * 60))
					_maint_seen_months=""
					for _maint_sess in "$sessdir"/session.tsv.[0-9]*; do
						[ -f "$_maint_sess" ] || continue
						# Skip monthly archives
						case "$_maint_sess" in *session.archive.*) continue ;; esac
						if [ -n "$($find "$_maint_sess" -maxdepth 0 -mmin +"$_maint_archive_age_min" 2>/dev/null)" ]; then  # safe: find may warn on race-deleted files
							# Extract YYMM from filename: session.tsv.YYMMDD-HHMM.PID[.gz]
							_maint_basename="${_maint_sess##*/session.tsv.}"
							_maint_month="${_maint_basename:0:4}"
							# Deduplicate: only archive each month once
							case "$_maint_seen_months" in
								*" $_maint_month "*) continue ;;
							esac
							_maint_seen_months="$_maint_seen_months $_maint_month "
							_session_archive_month "$_maint_month"
						fi
					done
				fi
				eout "{lifecycle} maintenance complete" 1
				exit 0
			;;
			-f|--file-list)
				shift
				if [ -z "$hscan" ]; then
					header
				fi
				svc=f
				trap trap_exit INT TERM
				file_list="$1"
				if [ ! -f "$file_list" ]; then
					eout "{scan} file does not exist ($1)" 1
					exit 1
					elif [ ! -s "$file_list" ]; then
					eout "{scan} file list is empty ($1)" 1
					exit 1
				fi
				spath="$file_list"
				hrspath="$file_list"
				if [ "$set_background" == "1" ]; then
					eout "{scan} launching scan of $spath to background, see $maldet_log for progress" 1
					scan "$spath" "$file_list" >> /dev/null 2>&1 &
				else
					scan "$spath" "$file_list"
				fi
			;;
			-a|--scan-all)
				shift
				if [ -z "$hscan" ]; then
					header
				fi
				svc=a
				trap trap_exit INT TERM
				spath="$1"
				hrspath="$1"
				if [ "$spath" == "" ]; then
					spath=/home
					hrspath="$spath"
				fi
				# Only validate non-glob, non-comma-separated literal paths —
				# glob expansions and comma-separated multi-path lists are handled by find/scan
				if [[ "$spath" != *[\*\?\[,]* ]] && [ ! -e "$spath" ]; then
					eout "{scan} error: scan path does not exist: $spath" 1
					exit 1
				fi
				if [ "$set_background" == "1" ]; then
					eout "{scan} launching scan of $spath to background, see $maldet_log for progress" 1
					scan "$spath" all >> /dev/null 2>&1 &
				else
					scan "$spath" all
				fi
			;;
			-r|--scan-recent)
				header
				svc=r
				trap trap_exit INT TERM
				shift
				spath="$1"
				hrspath="$1"
				shift
				days="$1"
				if [ -z "$spath" ]; then
					eout "{scan} no path defined" 1
					exit 1
				fi
				if [ -z "$days" ]; then
					days=7
				fi
				if [ "$set_background" == "1" ]; then
					eout "{scan} launching scan of $spath changes in last ${days}d to background, see $maldet_log for progress" 1
					scan "$spath" "$days" >> /dev/null 2>&1 &
				else
					scan "$spath" "$days"
				fi
			;;
			-l|--log)
				header
				view
			;;
			-e|--report)
				shift
				if [ "${1:-}" == "hooks" ]; then
					header
					# Parse optional --last and --mode flags
					_hooks_last="24h"
					_hooks_mode=""
					shift  # consume "hooks"
					while [ -n "${1:-}" ]; do
						case "$1" in
							--last) shift; _hooks_last="${1:-24h}" ;;
							--mode) shift; _hooks_mode="${1:-}" ;;
							*) break ;;
						esac
						shift
					done
					view_report "hooks" "$_hooks_last" "$_hooks_mode"
				elif [ "${1:-}" = "active" ]; then
					# List active scans — structured output suppresses header
					if [ -z "${_report_format:-}" ] || [ "$_report_format" = "text" ]; then
						header
					fi
					_lifecycle_list_active "${_report_format:-text}" "${set_verbose:-0}"
					exit $?
				else
					if [ -z "${_report_format:-}" ] || [ "$_report_format" = "text" ]; then
						header
					fi
					view_report "$1" "$2"
					# consume optional email argument
					if [ -n "$2" ]; then
						shift
					fi
				fi
			;;
			-E|--dump-report)
				header
				shift
				dump_report "$1"
			;;
			--json-report)
				shift
				view_report_json "$1"
			;;
			-p|--purge)
				header
				purge
			;;
                        -d|--update-ver|--update-version)
				shift
                                if [ "$1" != "1" ]; then
                                        header
				fi
				if [ "$1" == "--force" ]; then
					lmdup_force=1
                                elif [ "$1" == "--beta" ]; then
					lmdup_beta=1
				fi
                                lmdup
                        ;;
                        -u|--update|--update-sigs)
                                shift
                                if [ "$1" != "1" ]; then
                                        header
				fi
				if [ "$1" == "--force" ]; then
					sigup_force=1
                                fi
                                sigup
                        ;;
			--cron-sigup)
				# Independent cron sig update — silent, serialized via sigup() flock
				[ -z "${LMD_TEST_MODE:-}" ] && sleep $(( RANDOM % 121 ))
				sigup
				exit 0
			;;
			-s|--restore)
				header
				shift
				if [ -z "$1" ]; then
					echo "ERROR: -s/--restore requires a SCANID or file argument" >&2
					exit 1
				fi
				if [ -f "$sessdir/session.tsv.$1" ] || [ -f "$sessdir/session.hits.$1" ]; then
					restore_hitlist "$1" || exit 1
				else
					restore "$1" || exit 1
				fi
			;;
			-q|--quarantine)
				header
				shift
				if [ -z "$1" ]; then
					echo "ERROR: -q/--quarantine requires a SCANID argument" >&2
					exit 1
				fi
				quar_hitlist "$1"
			;;
			-n|--clean)
				header
				shift
				if [ -z "$1" ]; then
					echo "ERROR: -n/--clean requires a SCANID argument" >&2
					exit 1
				fi
				clean_hitlist "$1"
			;;
			-v|--version)
				header
			;;
			-h|--help)
				header
				usage_long | more
			;;
			*)
				header
				echo "maldet: unrecognized option '$1'" >&2
				usage_short
				exit 1
			;;
		esac
		shift
	done
fi

# import any remote configuration data
import_conf

# postrun operations
postrun
