Skip to content

Commit e545120

Browse files
Merge pull request #264 from bcgov/feature/BCHEP-343-clamav-container
Feature/bchep 343 clamav container
2 parents 8120c5e + ace396b commit e545120

35 files changed

+1906
-240
lines changed

.github/workflows/dev.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ on:
1313
- public/**
1414
- spec/**
1515
- swagger/**
16+
- docker/clamav/**
1617
- config.ru
1718
- Gemfile
1819
- Gemfile.lock
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: Build and Publish ClamAV Image
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
paths:
8+
- .github/workflows/publish-clamav.yml
9+
- clamav/**
10+
11+
env:
12+
GITHUB_REGISTRY: ghcr.io
13+
14+
jobs:
15+
build-push-clamav:
16+
runs-on: ubuntu-22.04
17+
timeout-minutes: 5
18+
permissions:
19+
contents: read
20+
packages: write
21+
22+
steps:
23+
- uses: hmarr/debug-action@f7318c783045ac39ed9bb497e22ce835fdafbfe6
24+
- uses: actions/checkout@8edcb1bdb4e267140fa742c62e395cd74f332709
25+
26+
- name: Build and Push
27+
uses: egose/actions/docker-build-push@46d589997e49cae4a8a3d06644ae8b04351a30f2
28+
with:
29+
registry-url: ${{ env.GITHUB_REGISTRY }}
30+
registry-username: ${{ github.actor }}
31+
registry-password: ${{ secrets.GITHUB_TOKEN }}
32+
image-name: bcgov/hesp-clamav
33+
docker-context: clamav
34+
docker-file: clamav/Dockerfile
35+
metadata-tags: |
36+
type=ref,event=branch
37+
type=sha,format=long,prefix=,suffix=

app/controllers/api/storage_controller.rb

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,93 @@ def upload
77
set_rack_response FileUploader.presign_response(:cache, request.env)
88
end
99

10+
def virus_scan
11+
# Pre-upload virus scanning endpoint
12+
unless ClamAvService.enabled?
13+
render json: {
14+
clean: true,
15+
message: "Virus scanning disabled"
16+
},
17+
status: :ok
18+
return
19+
end
20+
21+
unless params[:file].present?
22+
render json: { error: "File required for scanning" }, status: :bad_request
23+
return
24+
end
25+
26+
uploaded_file = params[:file]
27+
temp_file = nil
28+
29+
begin
30+
# Create temporary file from uploaded content
31+
temp_file =
32+
Tempfile.new(
33+
[
34+
"virus_scan",
35+
File.extname(uploaded_file.original_filename || ".tmp")
36+
]
37+
)
38+
temp_file.binmode
39+
40+
# Copy file content to temp file
41+
uploaded_file.rewind
42+
IO.copy_stream(uploaded_file, temp_file)
43+
temp_file.close
44+
45+
# Perform virus scan
46+
scan_result = ClamAvService.scan_file(temp_file.path)
47+
48+
if scan_result[:status] == :infected
49+
virus_name = scan_result[:virus_name] || "Unknown virus"
50+
Rails.logger.warn "Pre-upload virus detected: #{virus_name} in file #{uploaded_file.original_filename}"
51+
52+
render json: {
53+
clean: false,
54+
virus_detected: true,
55+
virus_name: virus_name,
56+
message: "Virus detected: #{virus_name}. File upload blocked."
57+
},
58+
status: :unprocessable_entity
59+
elsif scan_result[:status] == :error
60+
Rails.logger.error "Virus scan error: #{scan_result[:message]}"
61+
62+
if Rails.env.production?
63+
render json: {
64+
clean: false,
65+
scan_error: true,
66+
message:
67+
"File security scan failed. Please try again or contact support."
68+
},
69+
status: :unprocessable_entity
70+
else
71+
Rails.logger.warn "Allowing upload in development despite scan error"
72+
render json: {
73+
clean: true,
74+
message:
75+
"File passed virus scan (development mode with scan error)"
76+
},
77+
status: :ok
78+
end
79+
else
80+
Rails.logger.info "File passed pre-upload virus scan: #{uploaded_file.original_filename}"
81+
render json: { clean: true, message: "File is clean" }, status: :ok
82+
end
83+
rescue => e
84+
Rails.logger.error "Virus scan endpoint error: #{e.message}"
85+
render json: {
86+
clean: false,
87+
scan_error: true,
88+
message: "Scan failed: #{e.message}"
89+
},
90+
status: :internal_server_error
91+
ensure
92+
temp_file&.close
93+
temp_file&.unlink
94+
end
95+
end
96+
1097
AUTHORIZED_S3_MODELS = {
1198
"SupportingDocument" => SupportingDocument,
1299
"StepCode" => StepCode

app/frontend/utils/uploads.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ import { getCsrfToken } from './utility-functions';
77
const sleep = (delay: number) => new Promise((resolve) => setTimeout(resolve, delay));
88

99
export const uploadFile = async (file: File, fileName: string, progressCallback?: (event: ProgressEvent) => void) => {
10-
// Use direct upload with presigned URLs
11-
console.log('Using direct upload with presigned URLs');
10+
// Step 1: Pre-upload virus scanning
11+
console.log('Performing virus scan before upload...');
12+
await virusScanFile(file, fileName);
13+
14+
// Step 2: If virus scan passes, proceed with S3 upload
15+
console.log('Virus scan passed, proceeding with S3 upload...');
1216
return await directUpload(file, fileName, progressCallback);
1317
};
1418

@@ -268,3 +272,40 @@ export const persistFileUpload = async (
268272
throw error;
269273
}
270274
};
275+
276+
// Virus scanning function - scans file before S3 upload
277+
export const virusScanFile = async (file: File, fileName: string): Promise<void> => {
278+
try {
279+
console.log(`Scanning file for viruses: ${fileName}`);
280+
281+
const formData = new FormData();
282+
formData.append('file', file);
283+
284+
const response = await fetch('/api/storage/s3/virus_scan', {
285+
method: 'POST',
286+
body: formData,
287+
headers: {
288+
'X-CSRF-Token': getCsrfToken(),
289+
},
290+
});
291+
292+
if (!response.ok) {
293+
const errorData = await response.json().catch(() => null);
294+
const errorMessage = errorData?.message || errorData?.error || 'Virus scan failed';
295+
throw new Error(`Virus scan failed: ${errorMessage}`);
296+
}
297+
298+
const scanResult = await response.json();
299+
300+
if (!scanResult.clean) {
301+
const virusName = scanResult.virus_name || 'Unknown virus';
302+
const message = scanResult.message || `Virus detected: ${virusName}`;
303+
throw new Error(`Upload blocked: ${message}`);
304+
}
305+
306+
console.log(`File passed virus scan: ${fileName}`);
307+
} catch (error) {
308+
console.error('Virus scan error:', error);
309+
throw error; // Re-throw to stop the upload process
310+
}
311+
};

app/services/aws_credential_refresh_service.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,9 @@ def test_credentials(credentials = nil)
196196
access_key_id: test_creds[:access_key_id],
197197
secret_access_key: test_creds[:secret_access_key],
198198
session_token: test_creds[:session_token],
199-
region: ENV["BCGOV_OBJECT_STORAGE_REGION"] || "ca-central-1"
199+
region: ENV["BCGOV_OBJECT_STORAGE_REGION"] || "ca-central-1",
200+
endpoint: ENV["BCGOV_OBJECT_STORAGE_ENDPOINT"],
201+
force_path_style: true
200202
)
201203

202204
# Test by listing bucket (minimal operation)

app/uploaders/file_uploader.rb

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ class FileUploader < Shrine
88
validate_max_size Constants::Sizes::FILE_UPLOAD_MAX_SIZE * 1024 * 1024 # 100 MB to start
99
# Could be images, excel files, bims, we do not have an exhaustive list right now.
1010

11-
# Immediate virus scanning validation with database storage
11+
# Immediate virus scanning validation
1212
next unless file # Skip if no file attached
1313
next unless ClamAvService.enabled? # Skip if virus scanning disabled
1414

15-
Rails.logger.info "Performing immediate virus scan with database storage for: #{file.original_filename}"
15+
Rails.logger.info "Performing immediate virus scan for: #{file.original_filename}"
1616

1717
# Check if ClamAV service is reachable before proceeding
1818
unless ClamAvService.ping
@@ -66,6 +66,18 @@ class FileUploader < Shrine
6666
)
6767

6868
Rails.logger.warn "Upload blocked - virus detected: #{virus_name} in file #{file.original_filename}"
69+
70+
# Immediately delete infected file from S3 cache
71+
if file.respond_to?(:storage) && file.storage.is_a?(Shrine::Storage::S3)
72+
begin
73+
Rails.logger.warn "Deleting infected file from S3 cache: #{file.id}"
74+
file.delete
75+
Rails.logger.info "Successfully deleted infected file from S3 cache: #{file.id}"
76+
rescue => delete_error
77+
Rails.logger.error "Failed to delete infected file from S3 cache: #{delete_error.message}"
78+
end
79+
end
80+
6981
errors << error_message
7082
elsif scan_result[:status] == :error
7183
Rails.logger.error "Virus scan error: #{scan_result[:message]}"
@@ -124,8 +136,6 @@ class FileUploader < Shrine
124136
temp_file&.unlink
125137
end
126138
end
127-
128-
# Store immediate virus scan results in database after promotion
129139
Attacher.promote_block do |attacher|
130140
# Store virus scan results immediately in database
131141
if attacher.record.respond_to?(:virus_scan_status) &&

clamav/Dockerfile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# See https://hub.docker.com/r/clamav/clamav/tags
2+
FROM clamav/clamav:1.1
3+
4+
# See https://github.com/Cisco-Talos/clamav-docker/blob/main/clamav/1.1/alpine/scripts/docker-entrypoint-unprivileged.sh
5+
COPY "./scripts/docker-entrypoint-unprivileged.sh" "/init"
6+
RUN chmod +x /init
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#!/sbin/tini /bin/sh
2+
# shellcheck shell=sh
3+
# SPDX-License-Identifier: GPL-2.0-or-later
4+
#
5+
# Copyright (C) 2021 Olliver Schinagl <oliver@schinagl.nl>
6+
# Copyright (C) 2021-2022 Cisco Systems, Inc. and/or its affiliates. All rights reserved.
7+
#
8+
# A beginning user should be able to docker run image bash (or sh) without
9+
# needing to learn about --entrypoint
10+
# https://github.com/docker-library/official-images#consistency
11+
12+
set -eu
13+
14+
# run command if it is not starting with a "-" and is an executable in PATH
15+
if [ "${#}" -gt 0 ] &&
16+
[ "${1#-}" = "${1}" ] &&
17+
command -v "${1}" >"/dev/null" 2>&1; then
18+
# Ensure healthcheck always passes
19+
CLAMAV_NO_CLAMD="true" exec "${@}"
20+
else
21+
if [ "${#}" -ge 1 ] &&
22+
[ "${1#-}" != "${1}" ]; then
23+
# If an argument starts with "-" pass it to clamd specifically
24+
exec clamd "${@}"
25+
fi
26+
# else default to running clamav's servers
27+
28+
# Ensure we have some virus data, otherwise clamd refuses to start
29+
if [ ! -f "/var/lib/clamav/main.cvd" ]; then
30+
echo "Updating initial database"
31+
freshclam --foreground --stdout
32+
fi
33+
34+
if [ "${CLAMAV_NO_FRESHCLAMD:-false}" != "true" ]; then
35+
echo "Starting Freshclamd"
36+
freshclam \
37+
--checks="${FRESHCLAM_CHECKS:-1}" \
38+
--daemon \
39+
--foreground \
40+
--stdout \
41+
--user="clamav" \
42+
&
43+
fi
44+
45+
if [ "${CLAMAV_NO_CLAMD:-false}" != "true" ]; then
46+
echo "Starting ClamAV"
47+
if [ -S "/tmp/clamd.sock" ]; then
48+
unlink "/tmp/clamd.sock"
49+
fi
50+
clamd --foreground &
51+
while [ ! -S "/tmp/clamd.sock" ]; do
52+
if [ "${_timeout:=0}" -gt "${CLAMD_STARTUP_TIMEOUT:=1800}" ]; then
53+
echo
54+
echo "Failed to start clamd"
55+
exit 1
56+
fi
57+
printf "\r%s" "Socket for clamd not found yet, retrying (${_timeout}/${CLAMD_STARTUP_TIMEOUT}) ..."
58+
sleep 1
59+
_timeout="$((_timeout + 1))"
60+
done
61+
echo "socket found, clamd started."
62+
fi
63+
64+
if [ "${CLAMAV_NO_MILTERD:-true}" != "true" ]; then
65+
echo "Starting clamav milterd"
66+
clamav-milter &
67+
fi
68+
69+
# Wait forever (or until canceled)
70+
exec tail -f "/dev/null"
71+
fi
72+
73+
exit 0

config/locales/en.yml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -751,10 +751,13 @@ en:
751751
application_metrics_csv_headers: 'Jurisdiction,Permit type,Work type,First Nations,In draft,In inbox,Average days before first submit,Average days before latest submit'
752752
file_upload:
753753
virus_scan:
754-
virus_detected: 'File upload blocked: Virus detected (%{virus_name}). Please scan your file with antivirus software before uploading.'
754+
virus_detected: 'File upload failed: The file appears to be corrupted or contains suspicious content. Please try a different file or contact support if you believe this is an error.'
755755
scan_error_production: 'File upload temporarily unavailable due to security scanning issues. Please try again later.'
756-
scan_error_development: 'File upload allowed despite scan error in development mode.'
757-
unknown_virus: 'Unknown virus'
756+
scan_error_development: 'File upload allowed despite scan error in development mode.'
757+
service_unavailable: 'Virus scanning service unavailable'
758+
scan_timeout_production: 'File upload failed due to timeout. Please try again or contact support.'
759+
scan_timeout_development: 'File upload timeout in development mode.'
760+
unknown_virus: 'Unknown issue'
758761
audit_log:
759762
created_record: 'Record created'
760763
created_with_field: 'Created with %{field}: %{value}'

config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@
256256
post "tags/search", to: "tags#index", as: :tags_search
257257

258258
get "storage/s3" => "storage#upload" # use a storage controller instead of shrine mount since we want api authentication before being able to access
259+
post "storage/s3/virus_scan" => "storage#virus_scan" # Pre-upload virus scanning
259260
get "storage/s3/download" => "storage#download"
260261
delete "storage/s3/delete" => "storage#delete"
261262

0 commit comments

Comments
 (0)