mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 17:15:08 +00:00
c15c9c48f8
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.
436 lines
19 KiB
PHP
436 lines
19 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Files\Http\Controllers;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Modules\Audit\Action;
|
|
use App\Modules\Audit\ActivityLogger;
|
|
use App\Modules\Files\Jobs\ScanFileJob;
|
|
use App\Modules\Files\Models\File;
|
|
use App\Modules\Files\Scanning\NotScannedReason;
|
|
use App\Modules\Files\Scanning\ScannerAddress;
|
|
use App\Modules\Files\Scanning\ScanningConfig;
|
|
use App\Modules\Files\Scanning\ScanOutcome;
|
|
use App\Modules\Files\Scanning\ScanStatus;
|
|
use App\Modules\Files\Scanning\VirusScanner;
|
|
use App\Modules\Platform\Capabilities\Capability;
|
|
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
|
use App\Modules\Platform\Settings\Setting;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Validation\Rule;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
|
|
/**
|
|
* The virus scanning screen.
|
|
*
|
|
* Two of these settings decide what happens when the scanner cannot
|
|
* answer, and both default to letting files through. That is a
|
|
* deliberate choice (see docs/feature-virus-scanning.md) and it is the
|
|
* reason this screen states the count of files currently allowed through
|
|
* unscanned rather than leaving it to be discovered: a scanner that has
|
|
* quietly stopped protecting anything looks exactly like one that is
|
|
* working.
|
|
*
|
|
* Where a managed configuration names a scanner, the connection is not
|
|
* this screen's to change and scanning cannot be switched off — the
|
|
* policies still are. Same shape as the CAPTCHA screen under managed
|
|
* keys.
|
|
*/
|
|
class VirusScanningSettingsController extends Controller
|
|
{
|
|
/**
|
|
* A zip holding check.txt ("ProjectSend checks that the scanner
|
|
* reports encrypted archives."), encrypted with the password
|
|
* "projectsend". Made with `zip -P`.
|
|
*/
|
|
private const ENCRYPTED_ARCHIVE = 'UEsDBBQACQAIAACon1toMefdSgAAAEAAAAAJAAAAY2hlY2sudHh0prvUiniNGfEwEalXOcDbsYylfm2yAcyjplSfHJqk2sSxcVWFx0omz5AvASvSRdDbfeSQ+CC2qu6JEP/NYbBKy+g5t2nJr4swpv9QSwcIaDHn3UoAAABAAAAAUEsBAh4DFAAJAAgAAKifW2gx591KAAAAQAAAAAkAAAAAAAAAAQAAALSBAAAAAGNoZWNrLnR4dFBLBQYAAAAAAQABADcAAACBAAAAAAA=';
|
|
|
|
public function __construct(
|
|
private readonly Settings $settings,
|
|
private readonly ScanningConfig $config,
|
|
private readonly ActivityLogger $activity,
|
|
private readonly CapabilityRegistry $capabilities,
|
|
) {}
|
|
|
|
public function edit(Request $request): Response
|
|
{
|
|
return Inertia::render('system/settings/virus-scanning', [
|
|
// Which half of the screen is open. The connection and the
|
|
// policies are two different jobs — one is done once when the
|
|
// scanner is set up, the other is revisited — and a single
|
|
// column of fields with two Save buttons reads as one form
|
|
// that saves half of itself.
|
|
'tab' => in_array($request->query('tab'), ['options', 'activity'], true)
|
|
? (string) $request->query('tab')
|
|
: 'scanner',
|
|
// Read from the session here rather than shared as a flash
|
|
// prop: HandleInertiaRequests shares `success` and `error` and
|
|
// nothing else, which is why the Test button appeared to do
|
|
// nothing at all. Same shape the CAPTCHA screen uses.
|
|
'test_result' => $request->session()->get('scanner_test_result'),
|
|
'enabled' => $this->config->enabled(),
|
|
// Two different reasons the connection is not this screen's to
|
|
// change: a managed configuration names the scanner, or this
|
|
// edition does not connect scanners at all. The screen says
|
|
// the same thing for both, since to the person reading it
|
|
// they are the same fact.
|
|
'managed' => $this->config->isManaged() || ! $this->canConnect(),
|
|
// Distinct from `managed`, which covers two different reasons
|
|
// the address is not editable. A managed installation still
|
|
// has a scanner worth testing; one that does not connect
|
|
// scanners at all has nothing to test, and the endpoint says
|
|
// so with a 403.
|
|
'can_test' => $this->canConnect(),
|
|
'address' => $this->config->isManaged() ? '' : $this->settings->get(Setting::VirusScannerAddress),
|
|
'max_size_mb' => $this->settings->get(Setting::VirusScanMaxSizeMb),
|
|
'unscannable_policy' => $this->settings->get(Setting::VirusUnscannablePolicy),
|
|
'scanner_down_policy' => $this->settings->get(Setting::VirusScannerDownPolicy),
|
|
'wait_minutes' => $this->settings->get(Setting::VirusScannerWaitMinutes),
|
|
'existing_rate_per_minute' => $this->settings->get(Setting::VirusScanExistingRatePerMinute),
|
|
'counts' => $this->counts(),
|
|
]);
|
|
}
|
|
|
|
public function update(Request $request): RedirectResponse
|
|
{
|
|
$validated = $request->validate([
|
|
'enabled' => ['required', 'boolean'],
|
|
'address' => ['nullable', 'string', 'max:255'],
|
|
'max_size_mb' => ['required', 'integer', 'min:0', 'max:4096'],
|
|
'unscannable_policy' => ['required', Rule::in(['allow', 'block'])],
|
|
'scanner_down_policy' => ['required', Rule::in(['allow', 'hold'])],
|
|
'wait_minutes' => ['required', 'integer', 'min:1', 'max:1440'],
|
|
'existing_rate_per_minute' => ['required', 'integer', 'min:1', 'max:6000'],
|
|
]);
|
|
|
|
// A managed installation may still choose its policies. The
|
|
// connection and the switch are not on the screen there, and a
|
|
// request that sends them anyway changes nothing.
|
|
if (! $this->config->isManaged() && $this->canConnect()) {
|
|
$address = trim((string) ($validated['address'] ?? ''));
|
|
|
|
// Refused rather than saved and quietly inert: switching this
|
|
// on with nowhere to send files would leave every upload
|
|
// waiting for a scanner that does not exist.
|
|
if ($request->boolean('enabled') && $address === '') {
|
|
return back()->withErrors(['address' => __('Enter the address of your scanner first.')]);
|
|
}
|
|
|
|
// Checked here rather than left to the socket, which accepts
|
|
// more than it should — see ScannerAddress.
|
|
if ($address !== '' && ! ScannerAddress::isValid($address)) {
|
|
return back()->withErrors(['address' => __(ScannerAddress::message())]);
|
|
}
|
|
|
|
$this->settings->set(Setting::VirusScannerAddress, $address);
|
|
$this->settings->set(Setting::VirusScanningEnabled, $request->boolean('enabled'));
|
|
}
|
|
|
|
$this->settings->set(Setting::VirusScanMaxSizeMb, (int) $validated['max_size_mb']);
|
|
$this->settings->set(Setting::VirusUnscannablePolicy, $validated['unscannable_policy']);
|
|
$this->settings->set(Setting::VirusScannerDownPolicy, $validated['scanner_down_policy']);
|
|
$this->settings->set(Setting::VirusScannerWaitMinutes, (int) $validated['wait_minutes']);
|
|
$this->settings->set(Setting::VirusScanExistingRatePerMinute, (int) $validated['existing_rate_per_minute']);
|
|
|
|
// The scans worker is the one process that acts on every setting
|
|
// above, and it holds them in memory from the job it started on.
|
|
// Without this, switching scanning off or pointing it at another
|
|
// scanner changed the screen and nothing else until somebody
|
|
// restarted the worker.
|
|
Artisan::call('queue:restart');
|
|
|
|
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'virus_scanning']);
|
|
|
|
return back();
|
|
}
|
|
|
|
/**
|
|
* Prove the scanner is there, and that it is actually detecting.
|
|
*
|
|
* Three steps, reported separately, because "cannot connect" and
|
|
* "connects and finds nothing" are different problems and the second
|
|
* is the one that looks fine from the outside. The third sends the
|
|
* EICAR test string — a harmless sequence every engine recognises by
|
|
* agreement — so the answer is "it detected something" rather than
|
|
* "it did not complain".
|
|
*/
|
|
public function test(Request $request, VirusScanner $scanner): RedirectResponse
|
|
{
|
|
// Nothing to test where the connection is not this installation's
|
|
// to make.
|
|
abort_unless($this->canConnect(), 403);
|
|
|
|
$typed = trim((string) $request->input('address', ''));
|
|
|
|
// What the button is for: the address on screen, which on a first
|
|
// attempt has never been saved. Falls back to the stored one when
|
|
// the field is empty, so the button still answers on a screen
|
|
// somebody has not touched.
|
|
if ($typed !== '') {
|
|
if (! ScannerAddress::isValid($typed)) {
|
|
// Answered as a test result rather than as a field error:
|
|
// the person pressed Test, and this is what the test
|
|
// found. Nothing is dialled.
|
|
return back()->with('scanner_test_result', [
|
|
'ok' => false,
|
|
'message' => __(ScannerAddress::message()),
|
|
]);
|
|
}
|
|
|
|
$this->config->preview($typed);
|
|
}
|
|
|
|
$status = $scanner->status();
|
|
|
|
if (! $status->reachable) {
|
|
return back()->with('scanner_test_result', [
|
|
'ok' => false,
|
|
'message' => $status->error ?? __('The scanner could not be reached.'),
|
|
]);
|
|
}
|
|
|
|
$stream = fopen('php://temp', 'r+');
|
|
assert($stream !== false);
|
|
fwrite($stream, $this->eicar());
|
|
rewind($stream);
|
|
|
|
$verdict = $scanner->scan($stream, strlen($this->eicar()));
|
|
fclose($stream);
|
|
|
|
if ($verdict->outcome === ScanOutcome::Infected) {
|
|
// Detecting is half of it. A clamd left on its own defaults
|
|
// answers "OK" for an archive it could not open, and every
|
|
// encrypted zip would be recorded as clean — while this test
|
|
// passed. So ask it about one.
|
|
if ($this->passesEncryptedArchives($scanner)) {
|
|
return back()->with('scanner_test_result', [
|
|
'ok' => false,
|
|
'message' => __(':engine detects viruses, but reports encrypted archives as clean, so a password-protected zip would get through unchecked. Add AlertEncrypted, AlertEncryptedArchive, AlertEncryptedDoc and AlertExceedsMax, each set to yes, to its clamd.conf and restart it.', [
|
|
'engine' => $status->engine ?? __('The scanner'),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
return back()->with('scanner_test_result', [
|
|
'ok' => true,
|
|
'message' => __('Working. :engine detected the test file as ":threat".', [
|
|
'engine' => $status->engine ?? __('The scanner'),
|
|
'threat' => $verdict->detail ?? '',
|
|
]),
|
|
]);
|
|
}
|
|
|
|
// Reachable, and did not recognise a file every engine is supposed
|
|
// to. Almost always empty or broken virus definitions, which is
|
|
// exactly the failure nothing else would show.
|
|
return back()->with('scanner_test_result', [
|
|
'ok' => false,
|
|
'message' => __(':engine answered but did not detect the standard test file. Check that its virus definitions are installed and up to date.', [
|
|
'engine' => $status->engine ?? __('The scanner'),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Check every file people can download again.
|
|
*
|
|
* The work itself is the command's, so this button does not hold a
|
|
* request open for a library of any size, and the pace is the setting
|
|
* above rather than "as fast as the queue will go".
|
|
*/
|
|
public function scanExisting(): RedirectResponse
|
|
{
|
|
abort_unless($this->config->enabled(), 422);
|
|
|
|
// The screen disables the button while a scan is working through
|
|
// the queue. Refused here too, because each press queues the whole
|
|
// library again, and the throttle alone allows six a minute.
|
|
if ($this->scansInQueue() > 0) {
|
|
return redirect()
|
|
->route('system-settings.virus-scanning.edit', ['tab' => 'activity'])
|
|
->with('error', __('A scan is already running. Wait for it to finish.'));
|
|
}
|
|
|
|
// --all rather than --existing: this is "New scan", and on a
|
|
// library already scanned once --existing finds nothing to do.
|
|
Artisan::queue('projectsend:scan-files', ['--all' => true]);
|
|
|
|
// Onto the tab that shows it happening rather than back where they
|
|
// were: somebody who just started a scan wants to watch it, and a
|
|
// screen that looks unchanged reads as a button that did nothing.
|
|
return redirect()
|
|
->route('system-settings.virus-scanning.edit', ['tab' => 'activity'])
|
|
->with('success', __('The scan has started.'));
|
|
}
|
|
|
|
/**
|
|
* What the scanner is doing right now, and what it last decided.
|
|
*
|
|
* Polled by the Activity tab rather than rendered with the page: a
|
|
* backfill takes minutes to hours, and a screen that only tells you
|
|
* where things stood when you opened it is the screen somebody
|
|
* reloads repeatedly instead of watching.
|
|
*
|
|
* JSON rather than an Inertia partial, the way the notification bell
|
|
* and the zip builder already poll — see use-notification-poll.ts.
|
|
*/
|
|
public function activity(): JsonResponse
|
|
{
|
|
$recent = File::query()
|
|
->whereNotNull('scanned_at')
|
|
->orderByDesc('scanned_at')
|
|
->limit(20)
|
|
->get(['id', 'name', 'scan_status', 'scan_note', 'scanned_at', 'scan_engine']);
|
|
|
|
$waiting = File::query()->where('scan_status', ScanStatus::Pending)->count();
|
|
|
|
// Counted as well as the files above, and this is the half that
|
|
// makes a backfill visible: re-scanning a file that already went
|
|
// out unchecked deliberately leaves it available, so it is not
|
|
// "pending" and a screen watching only that count says nothing is
|
|
// happening while the queue works through a whole library.
|
|
$queued = $this->scansInQueue();
|
|
|
|
return response()->json([
|
|
// "Something is happening" is the one thing a person watching
|
|
// this screen wants to know, and it is worth being explicit
|
|
// about rather than left to be inferred from a count.
|
|
'running' => $waiting > 0 || $queued > 0,
|
|
'waiting' => $waiting,
|
|
'queued' => $queued,
|
|
'checked_last_hour' => File::query()->where('scanned_at', '>=', now()->subHour())->count(),
|
|
'last_scanned_at' => $recent->first()?->scanned_at?->toIso8601String(),
|
|
'never_scanned' => File::query()->neverScanned()->count(),
|
|
'quarantined' => File::query()->whereIn('scan_status', [
|
|
ScanStatus::Infected->value,
|
|
ScanStatus::UnscannableBlocked->value,
|
|
])->count(),
|
|
'recent' => $recent->map(fn (File $file): array => [
|
|
'id' => $file->id,
|
|
'name' => $file->name,
|
|
'status' => $file->scan_status->value,
|
|
// A reason is a key and is translated; a threat name is
|
|
// the scanner's own words and is passed through.
|
|
'note' => $this->noteFor($file),
|
|
'scanned_at' => $file->scanned_at?->toIso8601String(),
|
|
'engine' => $file->scan_engine,
|
|
])->all(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Scan jobs waiting to run or running now.
|
|
*
|
|
* Not size(), which counts delayed jobs too. A file held while the
|
|
* scanner was down leaves a retry scheduled for up to five minutes
|
|
* after the scanner is back and the file already checked, and for
|
|
* that long the screen said "Scanning now" and refused a new scan.
|
|
*/
|
|
private function scansInQueue(): int
|
|
{
|
|
$queue = Queue::connection();
|
|
|
|
if (method_exists($queue, 'pendingSize') && method_exists($queue, 'reservedSize')) {
|
|
return (int) $queue->pendingSize('scans') + (int) $queue->reservedSize('scans');
|
|
}
|
|
|
|
return $queue->size('scans');
|
|
}
|
|
|
|
/**
|
|
* Whether this installation connects its own scanner.
|
|
*
|
|
* Community only, through the registry rather than an edition check —
|
|
* see Capability::VirusScanningConnect for the division.
|
|
*/
|
|
private function canConnect(): bool
|
|
{
|
|
return $this->capabilities->has(Capability::VirusScanningConnect);
|
|
}
|
|
|
|
private function noteFor(File $file): ?string
|
|
{
|
|
$note = $file->scan_note;
|
|
|
|
if ($note === null) {
|
|
return $file->scan_status === ScanStatus::NotScanned
|
|
? (string) __(NotScannedReason::BeforeScanning->label())
|
|
: null;
|
|
}
|
|
|
|
$reason = NotScannedReason::tryFrom($note);
|
|
|
|
return $reason === null ? $note : (string) __($reason->label());
|
|
}
|
|
|
|
/**
|
|
* @return array<string, int>
|
|
*/
|
|
private function counts(): array
|
|
{
|
|
return [
|
|
'pending' => File::query()->where('scan_status', ScanStatus::Pending)->count(),
|
|
'quarantined' => File::query()->whereIn('scan_status', [
|
|
ScanStatus::Infected->value,
|
|
ScanStatus::UnscannableBlocked->value,
|
|
])->count(),
|
|
'never_scanned' => File::query()->neverScanned()->count(),
|
|
'let_through' => File::query()
|
|
->where('scan_status', ScanStatus::NotScanned)
|
|
->whereIn('scan_note', [
|
|
NotScannedReason::ScannerUnavailable->value,
|
|
NotScannedReason::TooLarge->value,
|
|
NotScannedReason::Encrypted->value,
|
|
])
|
|
->count(),
|
|
// What a New scan would actually check — see
|
|
// ScanFileJob::rescannableValues().
|
|
'scannable' => File::query()
|
|
->whereIn('scan_status', ScanFileJob::rescannableValues())
|
|
->count(),
|
|
// So the New scan button can refuse a second scan while one is
|
|
// still working through the queue.
|
|
'queued' => $this->scansInQueue(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Whether the scanner calls a password-protected zip clean.
|
|
*
|
|
* The archive holds one line of text and nothing else; what matters
|
|
* is only that it cannot be opened without the password. A scanner
|
|
* set up as documented answers "encrypted".
|
|
*/
|
|
private function passesEncryptedArchives(VirusScanner $scanner): bool
|
|
{
|
|
$archive = (string) base64_decode(self::ENCRYPTED_ARCHIVE, true);
|
|
|
|
$stream = fopen('php://temp', 'r+');
|
|
assert($stream !== false);
|
|
fwrite($stream, $archive);
|
|
rewind($stream);
|
|
|
|
$verdict = $scanner->scan($stream, strlen($archive));
|
|
fclose($stream);
|
|
|
|
return $verdict->outcome === ScanOutcome::Clean;
|
|
}
|
|
|
|
private function eicar(): string
|
|
{
|
|
// Assembled rather than written out, so the repository itself
|
|
// never contains the literal string: antivirus software on a
|
|
// developer's machine quarantines files that do, and a checkout
|
|
// that deletes its own test fixtures is a bad afternoon.
|
|
return 'X5O!P%@AP[4\\PZX54(P^)7CC)7}$'.'EICAR-STANDARD-'.'ANTIVIRUS-TEST-FILE!'.'$H+H*';
|
|
}
|
|
}
|