Skip to content

Commit 87aef08

Browse files
authored
Merge pull request #45 from 10up/feature/TENUPSYS-3848-clamdscan
Improve virus scan performance with clamdscan
2 parents d392efb + 0bea115 commit 87aef08

4 files changed

Lines changed: 265 additions & 22 deletions

File tree

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ RUN apt-get update && \
88
build-essential \
99
ca-certificates \
1010
clamav \
11+
clamav-daemon \
1112
clamav-freshclam \
1213
curl \
1314
fonts-liberation \
@@ -204,4 +205,3 @@ COPY ./entrypoint.sh /entrypoint.sh
204205
RUN chmod +x /entrypoint.sh
205206

206207
ENTRYPOINT ["/entrypoint.sh"]
207-

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
- php
1717
- rsync
1818
- shellcheck
19-
- clamscan
19+
- clamdscan
2020
- kubectl
2121
- aws-cli
2222
- azure-cli
@@ -90,7 +90,7 @@ The `scripts` directory contains useful tools that can help test applications an
9090

9191
- `all-scripts`: Runs all the included and additional custom scripts inside the `/custom-scripts` directory.
9292
- `php-syntax`: Checks the syntax of all PHP files inside the `workdir`
93-
- `virus-scan`: Runs `clamscan` against the `workdir`.
93+
- `virus-scan`: Starts a temporary ClamAV daemon and runs a parallel `clamdscan` against the `workdir`.
9494
- `slack-message`: Sends Slack notifications via webhook.
9595

9696
### Using slack-message

scripts/virus-scan

Lines changed: 99 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,122 @@
11
#!/bin/bash
22

3-
# Use ClamAV to do a virus scan of the repo. Only display files where a virus is found
3+
set -uo pipefail
4+
5+
# Use a short-lived ClamAV daemon to scan the repository in parallel. Only
6+
# display files where a virus is found.
47

58
# Colors
69
# shellcheck disable=SC1117
710
end="\033[0m"
811
red="\033[0;31m"
912
green="\033[0;32m"
1013

11-
function red {
12-
echo -e "${red}${1}${end}"
14+
red() {
15+
echo -e "${red}${1}${end}"
16+
}
17+
18+
green() {
19+
echo -e "${green}${1}${end}"
1320
}
1421

15-
function green {
16-
echo -e "${green}${1}${end}"
22+
scanner_error() {
23+
red "Virus scanner internal error."
1724
}
1825

26+
for binary in clamd clamdscan; do
27+
if ! command -v "${binary}" > /dev/null 2>&1; then
28+
scanner_error
29+
exit 0
30+
fi
31+
done
32+
33+
database_dir="${CLAMAV_DB_DIR:-/var/lib/clamav}"
34+
if [[ ! -d "${database_dir}" ]]; then
35+
scanner_error
36+
exit 0
37+
fi
38+
39+
runtime_dir="$(mktemp -d "${TMPDIR:-/tmp}/virus-scan.XXXXXX")"
40+
if [[ -z "${runtime_dir}" || ! -d "${runtime_dir}" ]]; then
41+
scanner_error
42+
exit 0
43+
fi
44+
45+
clamd_config="${runtime_dir}/clamd.conf"
46+
clamd_log="${runtime_dir}/clamd.log"
47+
clamd_pid=""
48+
49+
cleanup() {
50+
local exit_status=$?
51+
trap - EXIT
52+
53+
if [[ -n "${clamd_pid}" ]] && kill -0 "${clamd_pid}" 2>/dev/null; then
54+
kill "${clamd_pid}" 2>/dev/null || true
55+
wait "${clamd_pid}" 2>/dev/null || true
56+
fi
57+
58+
if [[ -d "${runtime_dir}" ]]; then
59+
rm -r -- "${runtime_dir}"
60+
fi
61+
exit "${exit_status}"
62+
}
63+
trap cleanup EXIT
64+
65+
cat > "${clamd_config}" <<EOF
66+
LocalSocket ${runtime_dir}/clamd.sock
67+
FixStaleSocket yes
68+
DatabaseDirectory ${database_dir}
69+
Foreground yes
70+
PidFile ${runtime_dir}/clamd.pid
71+
ExcludePath (^|/)\.composer-cache(/|$)
72+
ExcludePath (^|/)node_modules_cache(/|$)
73+
EOF
74+
1975
green "#### Starting Virus Scan ####"
2076

21-
clamscan --exclude-dir ./.composer-cache --exclude-dir ./node_modules_cache -riz .
77+
clamd -c "${clamd_config}" > "${clamd_log}" 2>&1 &
78+
clamd_pid=$!
79+
80+
clamd_ready=false
81+
for _ in {1..30}; do
82+
if clamdscan --config-file="${clamd_config}" --ping=1 > /dev/null 2>&1; then
83+
clamd_ready=true
84+
break
85+
fi
86+
87+
if ! kill -0 "${clamd_pid}" 2>/dev/null; then
88+
break
89+
fi
90+
91+
sleep 1
92+
done
93+
94+
if [[ "${clamd_ready}" != "true" ]]; then
95+
[[ -f "${clamd_log}" ]] && cat "${clamd_log}" >&2
96+
scanner_error
97+
exit 0
98+
fi
2299

100+
clamdscan \
101+
--config-file="${clamd_config}" \
102+
--multiscan \
103+
--infected \
104+
"${PWD}"
23105
virus_status=$?
24106

25107
echo "-------"
26108
echo ""
27109

28-
if [ $virus_status -eq 0 ]
29-
then
30-
green "Clean - no viruses found"
31-
echo ""
32-
exit 0
33-
elif [ $virus_status -eq 1 ]
34-
then
35-
red "**** INFECTED FILE FOUND!!! **** PLEASE SEE REPORT ABOVE ****"
36-
echo ""
37-
exit 1
110+
if [[ "${virus_status}" -eq 0 ]]; then
111+
green "Clean - no viruses found"
112+
echo ""
113+
exit 0
114+
elif [[ "${virus_status}" -eq 1 ]]; then
115+
red "**** INFECTED FILE FOUND!!! **** PLEASE SEE REPORT ABOVE ****"
116+
echo ""
117+
exit 1
38118
else
39-
red "Virus scanner internal error."
40-
echo ""
41-
exit 0 # don't block a deploy because the virus scan program is broken
119+
scanner_error
120+
echo ""
121+
exit 0 # don't block a deploy because the virus scan program is broken
42122
fi

tests/test-virus-scan.sh

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
#!/usr/bin/env bash
2+
3+
set -uo pipefail
4+
5+
TEST_DIR="$(mktemp -d "/private/tmp/virus-scan-tests.XXXXXX")"
6+
readonly TEST_DIR
7+
REPOSITORY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
8+
readonly REPOSITORY_DIR
9+
readonly SCRIPT_PATH="${REPOSITORY_DIR}/scripts/virus-scan"
10+
11+
failures=0
12+
13+
cleanup() {
14+
if [[ -d "${TEST_DIR}" ]]; then
15+
rm -r -- "${TEST_DIR}"
16+
fi
17+
}
18+
trap cleanup EXIT
19+
20+
fail() {
21+
printf 'not ok - %s\n' "$1" >&2
22+
failures=$((failures + 1))
23+
}
24+
25+
assert_status() {
26+
local expected="$1"
27+
local actual="$2"
28+
local description="$3"
29+
30+
if [[ "${actual}" -ne "${expected}" ]]; then
31+
fail "${description}: expected exit ${expected}, got ${actual}"
32+
fi
33+
}
34+
35+
assert_file_contains() {
36+
local file="$1"
37+
local pattern="$2"
38+
local description="$3"
39+
40+
if [[ ! -f "${file}" ]] || ! grep -Eq -- "${pattern}" "${file}"; then
41+
fail "${description}"
42+
fi
43+
}
44+
45+
create_fake_binaries() {
46+
local bin_dir="$1"
47+
48+
mkdir -p "${bin_dir}"
49+
50+
cat > "${bin_dir}/clamd" <<'EOF'
51+
#!/usr/bin/env bash
52+
set -u
53+
54+
if [[ "${FAKE_START_MODE:-success}" == "failure" ]]; then
55+
exit 2
56+
fi
57+
58+
config_file=""
59+
while [[ $# -gt 0 ]]; do
60+
case "$1" in
61+
-c|--config-file)
62+
config_file="$2"
63+
shift 2
64+
;;
65+
*)
66+
shift
67+
;;
68+
esac
69+
done
70+
71+
cp "${config_file}" "${TEST_STATE}/clamd.conf"
72+
printf '%s\n' "$$" > "${TEST_STATE}/clamd.pid"
73+
trap 'touch "${TEST_STATE}/clamd.stopped"; exit 0' TERM INT
74+
while :; do
75+
sleep 0.05
76+
done
77+
EOF
78+
79+
cat > "${bin_dir}/clamdscan" <<'EOF'
80+
#!/usr/bin/env bash
81+
set -u
82+
83+
printf '%q ' "$@" >> "${TEST_STATE}/clamdscan.calls"
84+
printf '\n' >> "${TEST_STATE}/clamdscan.calls"
85+
86+
for argument in "$@"; do
87+
if [[ "${argument}" == --ping* ]]; then
88+
for _ in {1..50}; do
89+
[[ -f "${TEST_STATE}/clamd.pid" ]] && exit 0
90+
sleep 0.01
91+
done
92+
exit 2
93+
fi
94+
done
95+
96+
exit "${FAKE_SCAN_EXIT:-0}"
97+
EOF
98+
99+
chmod +x "${bin_dir}/clamd" "${bin_dir}/clamdscan"
100+
}
101+
102+
run_scan_case() {
103+
local name="$1"
104+
local scan_exit="$2"
105+
local start_mode="$3"
106+
local expected_exit="$4"
107+
local state_dir="${TEST_DIR}/${name}"
108+
local bin_dir="${state_dir}/bin"
109+
local work_dir="${state_dir}/work"
110+
local output_file="${state_dir}/output"
111+
local actual_exit
112+
113+
mkdir -p \
114+
"${state_dir}/database" \
115+
"${state_dir}/tmp" \
116+
"${work_dir}/.composer-cache" \
117+
"${work_dir}/node_modules_cache"
118+
create_fake_binaries "${bin_dir}"
119+
120+
set +e
121+
(
122+
cd "${work_dir}" || exit 99
123+
PATH="${bin_dir}:${PATH}" \
124+
TEST_STATE="${state_dir}" \
125+
TMPDIR="${state_dir}/tmp" \
126+
CLAMAV_DB_DIR="${state_dir}/database" \
127+
FAKE_SCAN_EXIT="${scan_exit}" \
128+
FAKE_START_MODE="${start_mode}" \
129+
bash "${SCRIPT_PATH}"
130+
) > "${output_file}" 2>&1
131+
actual_exit=$?
132+
set -e
133+
134+
assert_status "${expected_exit}" "${actual_exit}" "${name}"
135+
}
136+
137+
run_scan_case clean 0 success 0
138+
assert_file_contains "${TEST_DIR}/clean/clamdscan.calls" '--multiscan' 'clean scan requests multiscan'
139+
assert_file_contains "${TEST_DIR}/clean/clamdscan.calls" '--infected' 'clean scan only reports infected files'
140+
assert_file_contains "${TEST_DIR}/clean/clamd.conf" 'ExcludePath .*\\.composer-cache' 'composer cache exclusion is configured for clamd'
141+
assert_file_contains "${TEST_DIR}/clean/clamd.conf" 'ExcludePath .*node_modules_cache' 'node modules cache exclusion is configured for clamd'
142+
assert_file_contains "${TEST_DIR}/clean/output" 'Clean - no viruses found' 'clean scan reports success'
143+
[[ -f "${TEST_DIR}/clean/clamd.stopped" ]] || fail 'clean scan stops the temporary daemon'
144+
145+
run_scan_case infected 1 success 1
146+
assert_file_contains "${TEST_DIR}/infected/output" 'INFECTED FILE FOUND' 'infected scan reports malware'
147+
[[ -f "${TEST_DIR}/infected/clamd.stopped" ]] || fail 'infected scan stops the temporary daemon'
148+
149+
run_scan_case scan_error 2 success 0
150+
assert_file_contains "${TEST_DIR}/scan_error/output" 'Virus scanner internal error' 'scan errors remain fail-open'
151+
[[ -f "${TEST_DIR}/scan_error/clamd.stopped" ]] || fail 'scan errors stop the temporary daemon'
152+
153+
run_scan_case startup_error 0 failure 0
154+
assert_file_contains "${TEST_DIR}/startup_error/output" 'Virus scanner internal error' 'daemon startup errors remain fail-open'
155+
156+
assert_file_contains "${REPOSITORY_DIR}/Dockerfile" 'clamav-daemon' 'container installs the ClamAV daemon package'
157+
158+
if [[ "${failures}" -gt 0 ]]; then
159+
printf '%s test assertion(s) failed\n' "${failures}" >&2
160+
exit 1
161+
fi
162+
163+
printf 'ok - virus-scan behavior\n'

0 commit comments

Comments
 (0)