Files
projectsend/app/Modules/Files/Console/ScanFilesCommand.php
T
ignacionelson c15c9c48f8 Close the gaps an end-to-end and security pass found in virus scanning
Run against the dev stack with real ClamAV and queue workers, and a code
review looking for ways around the scanner.

Quarantine now stays quarantined until somebody releases the file. A
rescan only touches files people can download, and changes nothing when
the scanner cannot answer or scanning is off. Before, an old infected file
rescanned while clamd restarted went through the "allow" policy and became
downloadable. The daily missing-files check leaves quarantined files alone,
so a storage outage no longer brings one back as a fresh upload.

A file longer than clamd's StreamMaxLength is "too large" again. clamd
answers and hangs up; the next write raised a warning that became an
exception before the answer was read, so the file was recorded as
"scanner down" and retried past the unscannable policy.

The production compose example gives clamd the settings it needs. On its
own defaults an encrypted zip comes back clean. The Test button now sends a
password-protected zip and fails when it is called clean, and says when an
address answers but is not ClamAV.

Saving the settings restarts the queue workers, which kept the old values
in memory. New scan runs --all, as its name says, and is refused while
scans are queued. A retry scheduled for later no longer counts as a scan
in progress.

Also: quarantine respects client scope for listing, release and
notifications; a zip built before a file was quarantined is refused;
public comments and version links skip unavailable files; a client no
longer sees their own quarantined or missing upload; a file whose bytes
return is scanned at once; clamd listens on IPv6 too, so its container
health check passes.
2026-09-17 02:48:03 -03:00

116 lines
4.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Files\Console;
use App\Modules\Files\Jobs\ScanFileJob;
use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\NotScannedReason;
use App\Modules\Files\Scanning\ScanningConfig;
use App\Modules\Files\Scanning\ScanStatus;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
/**
* Sends files back to the scanner: the ones still waiting, the ones that
* went through unscanned because it was down, and — when asked — the
* library that was already here before any of this existed.
*
* Hourly rather than daily. A file stuck pending is a file nobody can
* download, and an installation set to hold has no other way forward
* once its worker restarted and the job with it.
*/
class ScanFilesCommand extends Command
{
protected $signature = 'projectsend:scan-files
{--existing : also work through files that were never scanned because scanning was off}
{--all : check every file again, whatever it said last}';
protected $description = 'Scan files that are waiting, were missed, or were never checked (runs hourly)';
public function handle(ScanningConfig $config): int
{
if (! $config->enabled()) {
$this->info('Virus scanning is switched off.');
return self::SUCCESS;
}
$waiting = $this->dispatchFor(File::query()->where('scan_status', ScanStatus::Pending));
// Allowed through while the scanner was unreachable. Now that it
// may be back, they are asked again — a file found infected at
// this point is quarantined like any other, and its quarantine
// notice says it was available in the meantime.
$missed = $this->dispatchFor(
File::query()
->where('scan_status', ScanStatus::NotScanned)
->where('scan_note', NotScannedReason::ScannerUnavailable->value),
rescan: true,
);
$this->info("Re-queued {$waiting} waiting file(s) and {$missed} that were missed while the scanner was down.");
if ($this->option('all')) {
// Every file somebody can have today. Not a file waiting for
// its first verdict, not one with no bytes, and not one in or
// released from quarantine — a scan is not how a file leaves
// quarantine, and a release is not undone by one. Files keep
// their current state, and stay downloadable, until a new
// verdict arrives.
$limit = $config->existingScanRatePerMinute() * 60;
$checked = $this->dispatchFor(
File::query()->whereIn('scan_status', ScanFileJob::rescannableValues()),
$limit,
rescan: true,
);
$this->info("Queued {$checked} file(s) to be checked again.");
return self::SUCCESS;
}
if ($this->option('existing')) {
// Paced, because this can be a whole library at once and the
// scanner is also serving today's uploads. An hour's worth per
// run, since that is how often this command runs.
$limit = $config->existingScanRatePerMinute() * 60;
$old = $this->dispatchFor(File::query()->neverScanned(), $limit, rescan: true);
$this->info("Queued {$old} file(s) that had never been scanned.");
}
return self::SUCCESS;
}
/**
* Nothing here changes a file's state before the scanner has spoken.
*
* An earlier version marked each file pending first, which reads as
* tidy and is wrong twice over: pending means "withheld", so a
* backfill would have hidden an entire library from its clients for
* as long as it ran, and every file would then have been announced to
* its recipients a second time when it came back. The job knows which
* state it expects instead — see its $rescan.
*
* @param Builder<File> $query
*/
private function dispatchFor(Builder $query, ?int $limit = null, bool $rescan = false): int
{
if ($limit !== null) {
$query->limit($limit);
}
$ids = $query->orderBy('id')->pluck('id');
foreach ($ids as $id) {
ScanFileJob::dispatch((int) $id, $rescan);
}
return $ids->count();
}
}