Skip to content

Commit 4b78d67

Browse files
committed
[Fix] Checkpoint resume: allowlist gate + stopped scan visibility in -e list
[Fix] _lifecycle_continue: eval replaced with printf -v; checkpoint options now gated through _build_co_allowed_pattern (rejects PATH, IFS, LD_PRELOAD, inspath, etc. from tampered checkpoint files) [New] -e list: stopped/checkpointed scans now visible between active scans and scan history — text table shows SCANID, STAGE, FILES, HITS, WKRS, STOPPED, PATH with resume hint; JSON list emits "stopped" array [New] 10 tests: 5 adversarial allowlist rejection (PATH, IFS, LD_PRELOAD, inspath, BASH_ENV), 5 _lifecycle_list_stopped (empty, no-checkpoint, populated, column headers, running-only)
1 parent 1a829ff commit 4b78d67

7 files changed

Lines changed: 219 additions & 5 deletions

File tree

CHANGELOG

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ v2.0.1 | Mar 25 2026:
135135
[Fix] -L/--list-active: elapsed time showed 0h 00m for running scans; now
136136
computed live from scan start epoch instead of reading the completed-only
137137
meta field
138+
[Fix] Checkpoint resume: eval replaced with printf -v and options gated through
139+
-co allowlist; rejects PATH, IFS, LD_PRELOAD from tampered checkpoint files
140+
[New] -e list: stopped/checkpointed scans now visible between active and history
141+
sections (text and JSON); shows stage, hits, workers, stopped time, resume hint
138142

139143
[Fix] JSON report list hang: index-first hybrid eliminates O(N) per-file glob on servers
140144
without session.index; legacy plaintext sessions preserved

CHANGELOG.RELEASE

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ v2.0.1 | Mar 25 2026:
135135
[Fix] -L/--list-active: elapsed time showed 0h 00m for running scans; now
136136
computed live from scan start epoch instead of reading the completed-only
137137
meta field
138+
[Fix] Checkpoint resume: eval replaced with printf -v and options gated through
139+
-co allowlist; rejects PATH, IFS, LD_PRELOAD from tampered checkpoint files
140+
[New] -e list: stopped/checkpointed scans now visible between active and history
141+
sections (text and JSON); shows stage, hits, workers, stopped time, resume hint
138142

139143
[Fix] JSON report list hang: index-first hybrid eliminates O(N) per-file glob on servers
140144
without session.index; legacy plaintext sessions preserved

files/internals/lmd_alert.sh

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -765,6 +765,29 @@ _lmd_render_json_list() {
765765
esac
766766
done
767767

768+
printf '\n ],\n "stopped": ['
769+
770+
# Enumerate stopped scans with valid checkpoints (resumable)
771+
local _first_stopped=1 _jl_stopped_meta _jl_stopped_sid _jl_stopped_state
772+
for _jl_stopped_meta in "$sessdir"/scan.meta.*; do
773+
[ -f "$_jl_stopped_meta" ] || continue
774+
_jl_stopped_sid="${_jl_stopped_meta##*scan.meta.}"
775+
case "$_jl_stopped_sid" in *.tmp) continue ;; esac
776+
_jl_stopped_state=$(_lifecycle_detect_state "$_jl_stopped_sid" 2>/dev/null) || continue # safe: skip unreadable
777+
if [ "$_jl_stopped_state" = "stopped" ] && [ -f "$sessdir/scan.checkpoint.$_jl_stopped_sid" ]; then
778+
_lifecycle_read_meta "$_jl_stopped_sid" || continue
779+
[ "$_first_stopped" != "1" ] && printf ","
780+
_first_stopped=0
781+
local _jl_sp="${_meta_path//\\/\\\\}"
782+
_jl_sp="${_jl_sp//\"/\\\"}"
783+
local _jl_sfiles="${_meta_total_files:-0}"; [ "$_jl_sfiles" = "-" ] && _jl_sfiles=0
784+
local _jl_shits="${_meta_hits:-0}"; [ "$_jl_shits" = "-" ] && _jl_shits=0
785+
printf '\n {"scan_id": "%s", "stage": "%s", "total_files": %s, "hits": %s, "workers": "%s", "stopped_hr": "%s", "path": "%s"}' \
786+
"$_jl_stopped_sid" "${_meta_stage:--}" "$_jl_sfiles" "$_jl_shits" \
787+
"${_meta_workers:--}" "${_meta_stopped_hr:-unknown}" "$_jl_sp"
788+
fi
789+
done
790+
768791
printf '\n ],\n "reports": ['
769792
local _first=1
770793
local _index_file="$sessdir/session.index"

files/internals/lmd_lifecycle.sh

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,59 @@ _lifecycle_render_text_active() {
362362
return 0
363363
}
364364

365+
##
366+
# _lifecycle_list_stopped()
367+
# Enumerates stopped scans that have a valid checkpoint (resumable).
368+
# Returns 0 if any found (and prints text table), 1 if none.
369+
# Stopped scans are those with scan.meta.* state=stopped AND a matching
370+
# scan.checkpoint.* file — these can be resumed with --continue.
371+
##
372+
_lifecycle_list_stopped() {
373+
local _scanid _state _found=0
374+
local _stopped_ids=""
375+
376+
local _meta_file
377+
for _meta_file in "$sessdir"/scan.meta.*; do
378+
[ -f "$_meta_file" ] || continue
379+
_scanid="${_meta_file##*scan.meta.}"
380+
case "$_scanid" in *.tmp) continue ;; esac
381+
_state=$(_lifecycle_detect_state "$_scanid" 2>/dev/null) || continue # safe: skip unreadable meta
382+
if [ "$_state" = "stopped" ] && [ -f "$sessdir/scan.checkpoint.$_scanid" ]; then
383+
if [ -z "$_stopped_ids" ]; then
384+
_stopped_ids="$_scanid"
385+
else
386+
_stopped_ids="$_stopped_ids"$'\n'"$_scanid"
387+
fi
388+
_found=1
389+
fi
390+
done
391+
392+
if [ "$_found" -eq 0 ]; then
393+
return 1
394+
fi
395+
396+
local _count
397+
_count=$(printf '%s\n' "$_stopped_ids" | command grep -c '^.' || echo 0)
398+
printf 'Stopped scans (%s):\n' "$_count"
399+
printf ' %-22s %-10s %-10s %-6s %-6s %-20s %s\n' \
400+
"SCANID" "STAGE" "FILES" "HITS" "WKRS" "STOPPED" "PATH"
401+
402+
while IFS= read -r _scanid; do
403+
[ -z "$_scanid" ] && continue
404+
_lifecycle_read_meta "$_scanid" || continue
405+
406+
local _stopped_display
407+
_stopped_display=$(echo "${_meta_stopped_hr:-unknown}" | awk '{print $1,$2,$3,$4}')
408+
printf ' %-22s %-10s %-10s %-6s %-6s %-20s %s\n' \
409+
"$_scanid" "${_meta_stage:--}" "${_meta_total_files:--}" \
410+
"${_meta_hits:-0}" "${_meta_workers:--}" \
411+
"$_stopped_display" "${_meta_path:--}"
412+
done <<< "$_stopped_ids"
413+
414+
printf ' (resume: maldet --continue SCANID)\n'
415+
return 0
416+
}
417+
365418
##
366419
# _lifecycle_render_json_active(scanids_newline_separated)
367420
# JSON array output. Build manually with printf (no jq dependency).
@@ -1410,19 +1463,22 @@ _lifecycle_continue() {
14101463
eout "{lifecycle} warning: signature version changed since checkpoint (was: $_ckpt_sig_version, now: $_cur_sig_ver)" 1
14111464
fi
14121465

1413-
# Apply checkpoint options as config overrides
1466+
# Apply checkpoint options as config overrides (gated by -co allowlist)
14141467
if [ -n "$_ckpt_options" ]; then
1415-
local _opt
1468+
local _opt _co_allowed_pat
1469+
_build_co_allowed_pattern _co_allowed_pat
14161470
local _saved_ifs="$IFS"
14171471
IFS=','
14181472
for _opt in $_ckpt_options; do
14191473
IFS="$_saved_ifs"
14201474
# Each _opt is "key=value" — apply to shell environment
14211475
local _opt_key="${_opt%%=*}"
14221476
local _opt_val="${_opt#*=}"
1423-
# Validate key is a safe identifier (word chars only) to prevent eval injection
1424-
if [ -n "$_opt_key" ] && [[ "$_opt_key" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
1425-
eval "$_opt_key=\"\$_opt_val\""
1477+
# Validate key against -co allowlist (rejects PATH, IFS, LD_PRELOAD, etc.)
1478+
if [ -n "$_opt_key" ] && [[ "$_opt_key" =~ $_co_allowed_pat ]]; then
1479+
printf -v "$_opt_key" '%s' "$_opt_val"
1480+
else
1481+
eout "{lifecycle} warning: rejected unknown checkpoint option: $_opt_key" 1
14261482
fi
14271483
done
14281484
IFS="$_saved_ifs"

files/internals/lmd_session.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,10 @@ view_report() {
354354
if _lifecycle_list_active "text" "0" 2>/dev/null; then
355355
echo
356356
fi
357+
# Show stopped/checkpointed scans that can be resumed
358+
if _lifecycle_list_stopped 2>/dev/null; then # safe: stderr "No stopped scans." suppressed on empty result
359+
echo
360+
fi
357361
tmpf=$(mktemp "$tmpdir/.areps.XXXXXX")
358362
local _index_file="$sessdir/session.index"
359363
local _seen_ids=""

tests/39-lifecycle-list.bats

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,3 +449,71 @@ EOF
449449
first_line=$(echo "$output" | head -1)
450450
[[ "$first_line" == Active* ]]
451451
}
452+
453+
# ========================================================================
454+
# _lifecycle_list_stopped — no stopped scans
455+
# ========================================================================
456+
457+
@test "lifecycle list_stopped: returns 1 when no meta files exist" {
458+
_source_lmd_stack
459+
rm -f "$sessdir"/scan.meta.* "$sessdir"/scan.checkpoint.*
460+
run _lifecycle_list_stopped
461+
[ "$status" -eq 1 ]
462+
[ -z "$output" ]
463+
}
464+
465+
@test "lifecycle list_stopped: returns 1 when only running scans exist" {
466+
_source_lmd_stack
467+
rm -f "$sessdir"/scan.meta.* "$sessdir"/scan.checkpoint.*
468+
_create_meta_file "260328-1900.$$" "$$" "running" "/home"
469+
run _lifecycle_list_stopped
470+
[ "$status" -eq 1 ]
471+
}
472+
473+
@test "lifecycle list_stopped: returns 1 when stopped scan has no checkpoint file" {
474+
_source_lmd_stack
475+
rm -f "$sessdir"/scan.meta.* "$sessdir"/scan.checkpoint.*
476+
_create_meta_file "260328-1901.99999" "99999" "stopped" "/home"
477+
# No checkpoint file created — should not appear
478+
run _lifecycle_list_stopped
479+
[ "$status" -eq 1 ]
480+
}
481+
482+
# ========================================================================
483+
# _lifecycle_list_stopped — with stopped scans
484+
# ========================================================================
485+
486+
@test "lifecycle list_stopped: shows stopped scan with checkpoint" {
487+
_source_lmd_stack
488+
rm -f "$sessdir"/scan.meta.* "$sessdir"/scan.checkpoint.*
489+
local scanid="260328-1910.99999"
490+
_create_meta_file "$scanid" "99999" "stopped" "/home/user" "5000" "4"
491+
# Add stopped metadata
492+
echo "stopped=$(date +%s)" >> "$sessdir/scan.meta.$scanid"
493+
echo "stopped_hr=$(date '+%b %d %Y %H:%M:%S %z')" >> "$sessdir/scan.meta.$scanid"
494+
echo "stage=hex" >> "$sessdir/scan.meta.$scanid"
495+
# Create checkpoint file
496+
printf '#LMD_CHECKPOINT:v1\nscanid=%s\nstage=hex\n' "$scanid" > "$sessdir/scan.checkpoint.$scanid"
497+
run _lifecycle_list_stopped
498+
[ "$status" -eq 0 ]
499+
assert_output --partial "Stopped scans (1):"
500+
assert_output --partial "$scanid"
501+
assert_output --partial "resume: maldet --continue"
502+
}
503+
504+
@test "lifecycle list_stopped: shows correct column headers" {
505+
_source_lmd_stack
506+
rm -f "$sessdir"/scan.meta.* "$sessdir"/scan.checkpoint.*
507+
local scanid="260328-1911.99999"
508+
_create_meta_file "$scanid" "99999" "stopped" "/var/www"
509+
echo "stage=md5" >> "$sessdir/scan.meta.$scanid"
510+
printf '#LMD_CHECKPOINT:v1\nscanid=%s\nstage=md5\n' "$scanid" > "$sessdir/scan.checkpoint.$scanid"
511+
run _lifecycle_list_stopped
512+
[ "$status" -eq 0 ]
513+
assert_output --partial "SCANID"
514+
assert_output --partial "STAGE"
515+
assert_output --partial "FILES"
516+
assert_output --partial "HITS"
517+
assert_output --partial "STOPPED"
518+
assert_output --partial "PATH"
519+
}

tests/42-lifecycle-stop.bats

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,3 +692,58 @@ _source_lmd_stack() {
692692
found=$(grep -A5 '_engine_type="clamav"' "$scan_src" | grep -c 'clamdscan' || echo 0)
693693
[ "$found" -gt 0 ]
694694
}
695+
696+
# ========================================================================
697+
# _lifecycle_continue — allowlist rejection (security)
698+
# ========================================================================
699+
700+
@test "lifecycle_continue: rejects PATH in checkpoint options" {
701+
_source_lmd_stack
702+
local scanid="260328-4050.$$"
703+
printf '#LMD_CHECKPOINT:v1\nscanid=%s\nstage=hex\nsig_version=2026032601\nworkers=4\ntotal_files=100\nhits_so_far=0\noptions=PATH=/evil/bin,scan_yara=0\nstopped=1774588200\nstopped_hr=Mar 27 2026 20:30:00 +0000\n' "$scanid" > "$sessdir/scan.checkpoint.$scanid"
704+
echo "2026032601" > "$sigdir/maldet.sigs.ver"
705+
run _lifecycle_continue "$scanid"
706+
[ "$status" -eq 0 ]
707+
assert_output --partial "rejected unknown checkpoint option: PATH"
708+
}
709+
710+
@test "lifecycle_continue: rejects IFS in checkpoint options" {
711+
_source_lmd_stack
712+
local scanid="260328-4051.$$"
713+
printf '#LMD_CHECKPOINT:v1\nscanid=%s\nstage=hex\nsig_version=2026032601\nworkers=4\ntotal_files=100\nhits_so_far=0\noptions=IFS=x,scan_clamscan=0\nstopped=1774588200\nstopped_hr=Mar 27 2026 20:30:00 +0000\n' "$scanid" > "$sessdir/scan.checkpoint.$scanid"
714+
echo "2026032601" > "$sigdir/maldet.sigs.ver"
715+
run _lifecycle_continue "$scanid"
716+
[ "$status" -eq 0 ]
717+
assert_output --partial "rejected unknown checkpoint option: IFS"
718+
}
719+
720+
@test "lifecycle_continue: rejects LD_PRELOAD in checkpoint options" {
721+
_source_lmd_stack
722+
local scanid="260328-4052.$$"
723+
printf '#LMD_CHECKPOINT:v1\nscanid=%s\nstage=hex\nsig_version=2026032601\nworkers=4\ntotal_files=100\nhits_so_far=0\noptions=LD_PRELOAD=/evil.so,quarantine_hits=1\nstopped=1774588200\nstopped_hr=Mar 27 2026 20:30:00 +0000\n' "$scanid" > "$sessdir/scan.checkpoint.$scanid"
724+
echo "2026032601" > "$sigdir/maldet.sigs.ver"
725+
run _lifecycle_continue "$scanid"
726+
[ "$status" -eq 0 ]
727+
assert_output --partial "rejected unknown checkpoint option: LD_PRELOAD"
728+
}
729+
730+
@test "lifecycle_continue: rejects inspath in checkpoint options" {
731+
_source_lmd_stack
732+
local scanid="260328-4053.$$"
733+
printf '#LMD_CHECKPOINT:v1\nscanid=%s\nstage=hex\nsig_version=2026032601\nworkers=4\ntotal_files=100\nhits_so_far=0\noptions=inspath=/tmp/evil\nstopped=1774588200\nstopped_hr=Mar 27 2026 20:30:00 +0000\n' "$scanid" > "$sessdir/scan.checkpoint.$scanid"
734+
echo "2026032601" > "$sigdir/maldet.sigs.ver"
735+
run _lifecycle_continue "$scanid"
736+
[ "$status" -eq 0 ]
737+
assert_output --partial "rejected unknown checkpoint option: inspath"
738+
}
739+
740+
@test "lifecycle_continue: accepts allowlisted option alongside rejected one" {
741+
_source_lmd_stack
742+
local scanid="260328-4054.$$"
743+
printf '#LMD_CHECKPOINT:v1\nscanid=%s\nstage=hex\nsig_version=2026032601\nworkers=4\ntotal_files=100\nhits_so_far=0\noptions=scan_clamscan=1,BASH_ENV=/evil,quarantine_hits=0\nstopped=1774588200\nstopped_hr=Mar 27 2026 20:30:00 +0000\n' "$scanid" > "$sessdir/scan.checkpoint.$scanid"
744+
echo "2026032601" > "$sigdir/maldet.sigs.ver"
745+
run _lifecycle_continue "$scanid"
746+
[ "$status" -eq 0 ]
747+
assert_output --partial "rejected unknown checkpoint option: BASH_ENV"
748+
assert_output --partial "resuming scan"
749+
}

0 commit comments

Comments
 (0)