Files
projectsend/app/Modules/Files/Scanning/ClamAvScanner.php
T
ignacionelson bab90c0ad8 Scan uploaded files for viruses, and withhold them until they are checked
Every upload now starts as "being checked" and is not served to anyone
until a scanner has looked at it. Infected files are quarantined: kept
on disk, unreachable, waiting for an administrator.

The scanner is ClamAV, reached over a socket, streaming the file
wherever it is stored — no temporary copy for an S3 or GCS disk. What
the scanner answers is a fact; what it means for the file is this
installation's setting, so ClamAvScanner knows nothing about settings
and ScanPolicy knows nothing about sockets. Three of clamd's own alert
options are what make a file it could not open come back as an answer
rather than as "OK"; the client maps those to "too large" and
"encrypted" instead of to a threat.

Both policies default to letting files through, marked "not scanned",
which is the product owner's decision: a scanner that cannot answer must
not stop people working. Every such file is logged, and the screens that
say so come with the rest of this work.

Withholding is two rules. A file that is not available drops out of the
scopes that answer "what may this person see" — recipients and the
public listings, never the uploader's own copy. And every route that
puts bytes on the wire asks FileAvailability first: download, thumbnail,
preview, share link, the four public routes and both ends of a zip
build. A share link minted before the scan finishes says the file is
still being checked rather than 404ing.

Not yet here, and coming next: the quarantine screen and its permission,
the notifications, the settings screen, the hourly retry, the backfill
for existing libraries, and the Docker service.
2026-09-16 14:23:56 -03:00

234 lines
7.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Files\Scanning;
use Illuminate\Support\Carbon;
use Throwable;
/**
* Talks to ClamAV's daemon, `clamd`, over a Unix socket or TCP.
*
* The protocol is small enough to own: a command is `z<COMMAND>\0`, and
* INSTREAM is that followed by length-prefixed chunks and a zero-length
* chunk to finish. Taking a library for this would be more dependency
* than code.
*
* **Three of clamd's own settings decide whether this class can tell the
* truth**, and without them a file it could not open comes back as `OK`:
* `AlertExceedsMax`, `AlertEncrypted` and its two companions turn those
* cases into answers, which arrive here as `Heuristics.Limits.Exceeded.*`
* and `Heuristics.Encrypted.*` and are mapped below to tooLarge and
* encrypted rather than to a threat. An encrypted archive full of malware
* reported as clean is the failure this exists to prevent, so the
* shipped Docker configuration sets all of them and the documentation
* says so for manual installs.
*
* Nothing here throws for a scanner that is down, slow or misconfigured:
* the caller has a policy for that, and an exception would read as a bug
* in the job rather than as the state of somebody's server.
*/
class ClamAvScanner implements VirusScanner
{
/** 64 KiB — clamd's own read buffer size, and small enough to stream 5 GB without holding it. */
private const CHUNK = 65536;
public function __construct(
private readonly ScanningConfig $config,
) {}
public function scan(mixed $stream, int $size): ScanVerdict
{
$max = $this->config->maxScanBytes();
// Asked before opening a socket: a file this installation has
// decided not to scan should not spend a connection, and clamd
// would refuse it anyway once it passed StreamMaxLength.
if ($max > 0 && $size > $max) {
return ScanVerdict::tooLarge();
}
$socket = $this->connect();
if ($socket === null) {
return ScanVerdict::unavailable(__('The scanner could not be reached at :address.', [
'address' => $this->config->address(),
]));
}
try {
fwrite($socket, "zINSTREAM\0");
while (! feof($stream)) {
$chunk = fread($stream, self::CHUNK);
if ($chunk === false) {
return ScanVerdict::unavailable(__('The file could not be read for scanning.'));
}
if ($chunk === '') {
continue;
}
// Big-endian length, then the bytes. A short write here
// means clamd hung up mid-stream — usually its own size
// limit — and the reply below says which.
if (fwrite($socket, pack('N', strlen($chunk)).$chunk) === false) {
break;
}
}
fwrite($socket, pack('N', 0));
$reply = $this->readReply($socket);
} catch (Throwable $e) {
return ScanVerdict::unavailable($e->getMessage());
} finally {
fclose($socket);
}
if ($reply === null) {
return ScanVerdict::unavailable(__('The scanner did not answer in time.'));
}
return $this->verdictFor($reply);
}
public function status(): ScannerStatus
{
$socket = $this->connect();
if ($socket === null) {
return ScannerStatus::unreachable(__('No answer from :address.', ['address' => $this->config->address()]));
}
try {
fwrite($socket, "zVERSION\0");
$reply = $this->readReply($socket);
} catch (Throwable $e) {
return ScannerStatus::unreachable($e->getMessage());
} finally {
fclose($socket);
}
if ($reply === null || $reply === '') {
return ScannerStatus::unreachable(__('The scanner did not answer in time.'));
}
// "ClamAV 1.4.1/27412/Mon Sep 15 09:12:03 2026" — engine,
// signature database number, and when that database was built.
// Older builds answer with the engine alone, so every part after
// the first is optional rather than assumed.
$parts = explode('/', $reply);
$definitions = isset($parts[1]) && is_numeric(trim($parts[1])) ? (int) trim($parts[1]) : null;
$built = null;
if (isset($parts[2])) {
try {
$built = Carbon::parse(trim($parts[2]));
} catch (Throwable) {
$built = null;
}
}
return new ScannerStatus(true, trim($parts[0]), $definitions, $built);
}
private function verdictFor(string $reply): ScanVerdict
{
$engine = null;
if (str_ends_with($reply, 'OK')) {
return ScanVerdict::clean($engine);
}
// "stream: Win.Test.EICAR_HDB-1 FOUND"
if (str_ends_with($reply, 'FOUND')) {
$threat = trim(str_replace(['stream:', 'FOUND'], '', $reply));
// Not threats: clamd's way of saying "I could not look
// inside". Which one it is decides the file's fate, and both
// are the installation's policy rather than a detection.
if (str_contains($threat, 'Heuristics.Encrypted')) {
return ScanVerdict::encrypted($engine);
}
if (str_contains($threat, 'Heuristics.Limits.Exceeded')) {
return ScanVerdict::tooLarge($engine);
}
return ScanVerdict::infected($threat === '' ? 'unknown' : $threat, $engine);
}
// "INSTREAM size limit exceeded. ERROR" — the stream was longer
// than clamd's StreamMaxLength. Same meaning as the heuristic
// above, reached when this installation's own maximum is the
// larger of the two.
if (str_contains($reply, 'size limit exceeded')) {
return ScanVerdict::tooLarge($engine);
}
return ScanVerdict::unavailable($reply);
}
/** @return resource|null */
private function connect(): mixed
{
$address = $this->config->address();
if ($address === '') {
return null;
}
$socket = @stream_socket_client(
$address,
$code,
$message,
$this->config->connectTimeoutSeconds(),
STREAM_CLIENT_CONNECT,
);
if ($socket === false) {
return null;
}
// Without this a scanner that accepts the connection and then
// stops answering holds the worker open indefinitely.
stream_set_timeout($socket, $this->config->replyTimeoutSeconds());
return $socket;
}
/**
* clamd's replies end with a NUL in `z` mode. Returns null when the
* socket timed out rather than answered.
*
* @param resource $socket
*/
private function readReply(mixed $socket): ?string
{
$reply = '';
while (! feof($socket)) {
$byte = fread($socket, 1);
if ($byte === false || $byte === '') {
break;
}
if ($byte === "\0") {
break;
}
$reply .= $byte;
}
if (stream_get_meta_data($socket)['timed_out']) {
return null;
}
return trim($reply);
}
}