Skip to content

Commit 90d4cb8

Browse files
committed
[Fix] PR #478 latest Copilot review: _json_escape_string FreeBSD + multi-newline; post_scan_hook_timeout doc; deferred-item rationale comments
[Fix] _json_escape_string: replace sed pipeline with bash parameter expansion. Two defects fixed in one rewrite. (1) The quoted "$sed" invocation tried to execve a binary literally named "sed -E" on FreeBSD, where internals.conf sets sed="$sed -E", silently breaking post_scan_hook JSON payload construction. (2) The N;s/\n/\\n/;P;D sed cycle only escaped the first newline in multi-line input, leaving subsequent LF bytes raw in JSON output for paths with two or more embedded newlines. The bash parameter expansion form is portable, correct for any number of newlines, and removes an external process from the hook hot path. Backslash is escaped first so subsequent escape sequences do not get re-escaped. [New] tests: J-01 through J-15 unit coverage for _json_escape_string including regression tests for the multi-newline bug and both a runtime (J-14) and structural (J-15) guard against $sed reintroduction. Expected values are pre-constructed using a BS local to avoid the Rocky 9 bash 5.1 BATS preprocessor gotcha for multi-backslash assertion strings. [Change] conf.maldet / maldet.1: correct post_scan_hook_timeout docs. The prior text claimed a 5-second grace period before SIGKILL, but _scan_hook_exec_sync/_async invoke timeout(1) without --kill-after, so a hook that traps SIGTERM continues to run until it completes. Updated both conf.maldet and the man page to match actual behavior. [Change] lmd_clamav, lmd_config, lmd_init: inline rationale comments for the three deferred-or-rejected items from the PR #478 Copilot review. lmd_clamav.sh mktemp -d failure is deferred to 2.0.2 with blast-radius documented. lmd_config.sh ps-u-iworx grep is a false positive because the user-filtered ps excludes the root-owned grep child. lmd_init.sh chown user:root is deferred to 2.0.2 with FreeBSD wheel-vs-root group divergence documented alongside identical class-usage in lmd_quarantine.sh; pr#478
1 parent db9c4ab commit 90d4cb8

File tree

9 files changed

+256
-15
lines changed

9 files changed

+256
-15
lines changed

CHANGELOG

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,22 @@ v2.0.1 | Mar 25 2026:
124124

125125
-- Bug Fixes --
126126

127+
[Fix] _json_escape_string: replace sed pipeline with bash parameter expansion.
128+
Two defects fixed in one rewrite: (1) the quoted "$sed" invocation tried
129+
to execve a binary literally named "sed -E" on FreeBSD (where
130+
internals.conf sets sed="$sed -E"), silently breaking post_scan_hook
131+
JSON payload construction; (2) the N;s/\n/\\n/;P;D sed cycle only
132+
escaped the first newline in multi-line input, emitting raw LF bytes
133+
in JSON output for paths with two or more embedded newlines. The bash
134+
parameter expansion form is portable, correct for any number of
135+
newlines, and removes an external process from the hook hot path.
136+
BATS coverage added (J-01 through J-15) including a structural guard
137+
against sed reintroduction; pr#478
138+
[Change] conf.maldet / maldet.1: correct post_scan_hook_timeout documentation.
139+
The prior text claimed a 5-second grace period before SIGKILL, but the
140+
code invokes timeout(1) without --kill-after, so a hook that traps
141+
SIGTERM will run indefinitely. Updated to match actual behavior;
142+
pr#478
127143
[Fix] DEB: override_dh_fixperms target restores 640/750 modes after debhelper's
128144
dh_fixperms normalizes executables to 755 and non-executables to 644.
129145
Without this, 72 files shipped in the data.tar at the wrong mode including

CHANGELOG.RELEASE

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,22 @@ v2.0.1 | Mar 25 2026:
124124

125125
-- Bug Fixes --
126126

127+
[Fix] _json_escape_string: replace sed pipeline with bash parameter expansion.
128+
Two defects fixed in one rewrite: (1) the quoted "$sed" invocation tried
129+
to execve a binary literally named "sed -E" on FreeBSD (where
130+
internals.conf sets sed="$sed -E"), silently breaking post_scan_hook
131+
JSON payload construction; (2) the N;s/\n/\\n/;P;D sed cycle only
132+
escaped the first newline in multi-line input, emitting raw LF bytes
133+
in JSON output for paths with two or more embedded newlines. The bash
134+
parameter expansion form is portable, correct for any number of
135+
newlines, and removes an external process from the hook hot path.
136+
BATS coverage added (J-01 through J-15) including a structural guard
137+
against sed reintroduction; pr#478
138+
[Change] conf.maldet / maldet.1: correct post_scan_hook_timeout documentation.
139+
The prior text claimed a 5-second grace period before SIGKILL, but the
140+
code invokes timeout(1) without --kill-after, so a hook that traps
141+
SIGTERM will run indefinitely. Updated to match actual behavior;
142+
pr#478
127143
[Fix] DEB: override_dh_fixperms target restores 640/750 modes after debhelper's
128144
dh_fixperms normalizes executables to 755 and non-executables to 644.
129145
Without this, 72 files shipped in the data.tar at the wrong mode including

files/conf.maldet

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -576,8 +576,9 @@ post_scan_hook_format="args"
576576
# [ async | sync ]
577577
post_scan_hook_exec="async"
578578

579-
# Timeout in seconds before the hook is sent SIGTERM.
580-
# After SIGTERM, a 5-second grace period is given before SIGKILL.
579+
# Timeout in seconds before the hook is sent SIGTERM via timeout(1).
580+
# A well-behaved hook will exit on SIGTERM; a hook that traps and ignores
581+
# SIGTERM will continue to run until it completes (no SIGKILL escalation).
581582
# Set to 0 to disable timeout (hook runs indefinitely).
582583
# Minimum effective value is 5 (values 1-4 are clamped to 5).
583584
# [ 0 = disabled, default = 60 ]

files/internals/lmd_clamav.sh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ clamav_linksigs() {
7272
local _log_ctx="${2:-sigup}" # caller passes "scan" or "sigup"
7373
if [ -d "$cpath" ]; then
7474
local _staging
75+
# NOTE (PR#478 copilot review, deferred to 2.0.2): if mktemp fails
76+
# here, _staging is empty and the subsequent "cp -f ... $_staging/"
77+
# resolves to "cp -f ... /", writing signature files into the
78+
# filesystem root. The risk is bounded because $tmpdir is created
79+
# and validated in _init_user_paths() during prerun, so a failure
80+
# here only occurs if the tmpfs fills up between init and sigup.
81+
# The correct fix is to gate the cp loop on mktemp success.
7582
_staging=$(mktemp -d "$tmpdir/.clamval.XXXXXX")
7683
# Stage sig files into temp dir (same conditional logic as before)
7784
# HEX (.ndb) and YARA always seeded regardless of hash mode

files/internals/lmd_config.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ LMD_CONFIG_VERSION="1.0.0"
1818

1919
detect_control_panel() {
2020
if [[ -d /usr/local/interworx ]]; then
21+
# NOTE (PR#478 copilot review): these "ps | grep" patterns do not
22+
# self-match. ps is filtered by user (-u iworx, -u iworx-web) and
23+
# the grep child of maldet runs as root, so the grep process is
24+
# not in the user-scoped ps output. No false positives possible
25+
# unless maldet itself is invoked as the iworx user, which is not
26+
# a supported invocation path.
2127
iworx_db_ps=$(ps -u iworx | grep iworx-db)
2228
iworx_web_ps=$(ps -u iworx-web | grep iworx-web)
2329
pex_script=$(readlink -e /usr/local/interworx/bin/listaccounts.pex)

files/internals/lmd_hook.sh

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,23 @@ LMD_HOOK_VERSION="1.0.0"
2020
# _json_escape_string(string)
2121
# Escape a string for safe embedding inside a JSON double-quoted value.
2222
# Handles: backslash, double-quote, tab, carriage return, and embedded
23-
# newlines. Uses sed for portability. Outputs escaped string to stdout.
23+
# newlines. Uses bash parameter expansion (no external process) so the
24+
# implementation is portable across Linux and FreeBSD without depending
25+
# on $sed, which is set to "sed -E" on FreeBSD and cannot be quoted as a
26+
# single executable path. Also escapes every embedded newline (the prior
27+
# sed N;P;D cycle only caught the first newline in multi-line input).
28+
# Backslash must be escaped FIRST so the subsequent escape sequences
29+
# (\t, \r, \n, \") do not get their own backslashes re-escaped.
30+
# Outputs escaped string to stdout.
2431
# ---------------------------------------------------------------------------
2532
_json_escape_string() {
2633
local _in="$1"
27-
# shellcheck disable=SC2154
28-
# $sed is discovered via command -v in internals.conf (globals from sourced config)
29-
printf '%s' "$_in" \
30-
| "$sed" \
31-
-e 's/\\/\\\\/g' \
32-
-e 's/"/\\"/g' \
33-
-e 's/ /\\t/g' \
34-
-e 's/\r/\\r/g' \
35-
-e 'N;s/\n/\\n/;P;D' \
36-
2>/dev/null # sed pipeline on trusted internal data; error means sed absent (FreeBSD alt path)
34+
_in="${_in//\\/\\\\}"
35+
_in="${_in//\"/\\\"}"
36+
_in="${_in//$'\t'/\\t}"
37+
_in="${_in//$'\r'/\\r}"
38+
_in="${_in//$'\n'/\\n}"
39+
printf '%s' "$_in"
3740
}
3841

3942
# ---------------------------------------------------------------------------

files/internals/lmd_init.sh

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,14 @@ prerun() {
165165
mkdir -p "$quardir" "$sessdir" "$tmpdir" 2> /dev/null
166166
chmod 711 "$userbasedir" 2> /dev/null
167167
touch "$maldet_log" 2> /dev/null
168+
# NOTE (PR#478 copilot review, deferred to 2.0.2): the literal
169+
# "root" group name is correct on Linux but not on FreeBSD,
170+
# where the root user's primary group is "wheel". On FreeBSD
171+
# this chown fails silently (stderr is discarded) and the
172+
# public-scan paths retain their default touch/mkdir group.
173+
# lmd_quarantine.sh has the same class of "chown root:root"
174+
# usage. Proper fix: derive the group once in internals.conf
175+
# via os_freebsd and use a variable in both files.
168176
chown -R "${user}:root" "$userbasedir/$user" 2> /dev/null
169177
chmod 750 "$userbasedir/$user" "$quardir" "$sessdir" "$tmpdir" 2> /dev/null
170178
chmod 640 "$maldet_log" 2> /dev/null

files/maldet.1

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -726,8 +726,11 @@ Monitor mode always forces async regardless of this setting.
726726
Default: async.
727727
.PP
728728
.B post_scan_hook_timeout
729-
sets the timeout in seconds before SIGTERM, followed by SIGKILL after
730-
a 5\-second grace period.
729+
sets the timeout in seconds before the hook is sent SIGTERM via
730+
.BR timeout (1).
731+
A well\-behaved hook will exit on SIGTERM; a hook that traps and
732+
ignores SIGTERM will continue to run until it completes
733+
(no SIGKILL escalation).
731734
Set to 0 to disable.
732735
Values 1\-4 are clamped to 5.
733736
Default: 60.

tests/46-post-scan-hook.bats

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -639,3 +639,184 @@ WWEOF
639639
[ "$status" -ne 1 ]
640640
[ -f "$HOOK_MARKER" ]
641641
}
642+
643+
# ---------------------------------------------------------------------------
644+
# J-01 to J-14: _json_escape_string unit tests
645+
# Regression coverage for the bash-param-expansion rewrite (replaces the
646+
# sed pipeline that broke on FreeBSD ($sed set to "sed -E") and only
647+
# escaped the first newline in multi-line input).
648+
# ---------------------------------------------------------------------------
649+
650+
# J-* expected values are pre-constructed outside the assertion lines to
651+
# avoid the Rocky 9 bash 5.1 BATS preprocessor corruption described in
652+
# CLAUDE.md (multi-backslash sequences in [ ... ] lines can be rewritten).
653+
# BS and DQ are the single-character literals that appear in the outputs.
654+
655+
@test "J-01: _json_escape_string: plain ASCII pass-through" {
656+
_source_lmd_stack
657+
run _json_escape_string "hello world"
658+
[ "$status" -eq 0 ]
659+
[ "$output" = "hello world" ]
660+
}
661+
662+
@test "J-02: _json_escape_string: empty input produces empty output" {
663+
_source_lmd_stack
664+
run _json_escape_string ""
665+
[ "$status" -eq 0 ]
666+
[ -z "$output" ]
667+
}
668+
669+
@test "J-03: _json_escape_string: double-quote is escaped" {
670+
_source_lmd_stack
671+
local BS=$'\x5c' expected
672+
# Expected: foo + BS + " + bar
673+
expected="foo${BS}\"bar"
674+
run _json_escape_string 'foo"bar'
675+
[ "$status" -eq 0 ]
676+
[ "$output" = "$expected" ]
677+
}
678+
679+
@test "J-04: _json_escape_string: single backslash is doubled" {
680+
_source_lmd_stack
681+
local BS=$'\x5c' expected
682+
# Expected: a + BS + BS + b
683+
expected="a${BS}${BS}b"
684+
run _json_escape_string "a${BS}b"
685+
[ "$status" -eq 0 ]
686+
[ "$output" = "$expected" ]
687+
}
688+
689+
@test "J-05: _json_escape_string: backslash-first ordering preserves quote escape" {
690+
_source_lmd_stack
691+
local BS=$'\x5c' input expected
692+
# Input: a + BS + " + b (4 chars)
693+
input="a${BS}\"b"
694+
# Expected: a + BS + BS + BS + " + b (6 chars)
695+
# Backslash must be escaped first, otherwise the newly-inserted BS + "
696+
# from step 2 would get re-escaped by step 1 into BS + BS + ".
697+
expected="a${BS}${BS}${BS}\"b"
698+
run _json_escape_string "$input"
699+
[ "$status" -eq 0 ]
700+
[ "$output" = "$expected" ]
701+
}
702+
703+
@test "J-06: _json_escape_string: tab becomes literal backslash-t" {
704+
_source_lmd_stack
705+
local BS=$'\x5c' expected
706+
expected="a${BS}tb"
707+
run _json_escape_string "$(printf 'a\tb')"
708+
[ "$status" -eq 0 ]
709+
[ "$output" = "$expected" ]
710+
}
711+
712+
@test "J-07: _json_escape_string: CR becomes literal backslash-r" {
713+
_source_lmd_stack
714+
local BS=$'\x5c' expected
715+
expected="a${BS}rb"
716+
run _json_escape_string "$(printf 'a\rb')"
717+
[ "$status" -eq 0 ]
718+
[ "$output" = "$expected" ]
719+
}
720+
721+
@test "J-08: _json_escape_string: single LF becomes literal backslash-n" {
722+
_source_lmd_stack
723+
local BS=$'\x5c' expected
724+
expected="a${BS}nb"
725+
run _json_escape_string "$(printf 'a\nb')"
726+
[ "$status" -eq 0 ]
727+
[ "$output" = "$expected" ]
728+
}
729+
730+
@test "J-09: _json_escape_string: multiple newlines all escaped (regression)" {
731+
_source_lmd_stack
732+
local BS=$'\x5c' expected
733+
# Regression: old sed pipeline (N;s/\n/\\n/;P;D) only escaped the
734+
# first newline, leaving subsequent LF bytes raw in JSON output.
735+
expected="a${BS}nb${BS}nc${BS}nd"
736+
run _json_escape_string "$(printf 'a\nb\nc\nd')"
737+
[ "$status" -eq 0 ]
738+
[ "$output" = "$expected" ]
739+
}
740+
741+
@test "J-10: _json_escape_string: five newlines all escaped" {
742+
_source_lmd_stack
743+
local BS=$'\x5c' expected
744+
expected="1${BS}n2${BS}n3${BS}n4${BS}n5${BS}n6"
745+
run _json_escape_string "$(printf '1\n2\n3\n4\n5\n6')"
746+
[ "$status" -eq 0 ]
747+
[ "$output" = "$expected" ]
748+
}
749+
750+
@test "J-11: _json_escape_string: CRLF escapes both CR and LF" {
751+
_source_lmd_stack
752+
local BS=$'\x5c' expected
753+
expected="a${BS}r${BS}nb"
754+
run _json_escape_string "$(printf 'a\r\nb')"
755+
[ "$status" -eq 0 ]
756+
[ "$output" = "$expected" ]
757+
}
758+
759+
@test "J-12: _json_escape_string: realistic path with spaces and quotes" {
760+
_source_lmd_stack
761+
local BS=$'\x5c' expected
762+
# /tmp/my \"odd\" folder/file.txt
763+
expected="/tmp/my ${BS}\"odd${BS}\" folder/file.txt"
764+
run _json_escape_string '/tmp/my "odd" folder/file.txt'
765+
[ "$status" -eq 0 ]
766+
[ "$output" = "$expected" ]
767+
}
768+
769+
@test "J-13: _json_escape_string: all handled specials in one input" {
770+
_source_lmd_stack
771+
local BS=$'\x5c' TAB=$'\t' CR=$'\r' LF=$'\n' input expected
772+
# NOTE: must use $'\n' for LF (command substitution strips trailing
773+
# newlines, so $(printf '\n') gives an empty string).
774+
# Input raw chars: a, BS, b, ", c, TAB, d, CR, e, LF, f (11 chars)
775+
input="a${BS}b\"c${TAB}d${CR}e${LF}f"
776+
# Expected: a + BS + BS + b + BS + " + c + BS + t + d + BS + r + e + BS + n + f
777+
expected="a${BS}${BS}b${BS}\"c${BS}td${BS}re${BS}nf"
778+
run _json_escape_string "$input"
779+
[ "$status" -eq 0 ]
780+
[ "$output" = "$expected" ]
781+
}
782+
783+
@test "J-14: _json_escape_string: does not require \$sed (FreeBSD regression)" {
784+
_source_lmd_stack
785+
local BS=$'\x5c' input expected result
786+
input="hello/world with \"quote\" and ${BS}backslash"
787+
expected="hello/world with ${BS}\"quote${BS}\" and ${BS}${BS}backslash"
788+
# On FreeBSD, internals.conf sets sed to include "-E", so any future
789+
# reintroduction of a quoted "\$sed" invocation would try to execve
790+
# a binary literally named "sed -E" and fail. Scope the override in
791+
# a subshell so the fake value does not leak into later tests, and
792+
# pick a deliberately nonexistent path so any external-sed attempt
793+
# would fail loudly rather than accidentally picking up /bin/sed.
794+
result=$(
795+
sed="/nonexistent/regression-sed-xyz"
796+
_json_escape_string "$input"
797+
)
798+
[ "$result" = "$expected" ]
799+
}
800+
801+
@test "J-15: _json_escape_string: function body does not reference sed" {
802+
# Structural guard: the bash-parameter-expansion rewrite contains zero
803+
# legitimate uses of the word "sed" in the function body, so any future
804+
# reintroduction (quoted "$sed", unquoted $sed, "| sed", $($sed ...),
805+
# eval "... sed ...", etc.) will contain the literal word "sed" and
806+
# trip this guard. Simple word-boundary match is intentionally strict.
807+
local hook_src="$LMD_INSTALL/internals/lmd_hook.sh"
808+
[ -f "$hook_src" ]
809+
# Extract body of _json_escape_string (from opening { to closing })
810+
local body
811+
body=$(awk '
812+
/^_json_escape_string\(\) *\{/ { in_fn=1; next }
813+
in_fn && /^\}/ { in_fn=0; exit }
814+
in_fn { print }
815+
' "$hook_src")
816+
[ -n "$body" ]
817+
if printf '%s' "$body" | grep -qE '\bsed\b'; then
818+
echo "FAIL: _json_escape_string body references sed:"
819+
echo "$body"
820+
return 1
821+
fi
822+
}

0 commit comments

Comments
 (0)