mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 17:15:08 +00:00
0ae3f3d0f0
Sharing a file that is still being checked writes the assignment and says nothing. The announcement goes out when the file becomes available — a clean scan, a file let through while the scanner was down, or an administrator releasing it from quarantine — so nobody is ever sent to a page that refuses them, and a file about to be quarantined is not announced to everyone before anybody knows. Recipients are derived from the assignments as they stand at that moment, not remembered from the moment of sharing: a share taken back in the meantime produces no email, and one added does. New-version notices ride the same path, which they had to anyway — the audience rule re-checks visibility, and a file being scanned is not visible. Two bugs found while writing the tests, both in the hourly command: Re-queuing a file marked it pending first. Pending means withheld, so running --existing over a library that predates scanning would have hidden every file in it from every client for as long as the backfill ran, and then announced each one to its recipients a second time when it came back. The job now knows which state it expects instead, and a rescan leaves the file downloadable until a verdict actually arrives.
185 lines
6.7 KiB
PHP
185 lines
6.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Files\Scanning;
|
|
|
|
use App\Modules\Audit\Action;
|
|
use App\Modules\Audit\ActivityLogger;
|
|
use App\Modules\Files\Models\File;
|
|
use App\Modules\Files\Thumbnails\ThumbnailGenerator;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
/**
|
|
* What a verdict means for a file, on this installation.
|
|
*
|
|
* The scanner answers a question of fact — clean, infected, could not
|
|
* open it, did not answer. Three of those four are only half an answer:
|
|
* whether a file nobody could check may be handed to a client is a
|
|
* decision about somebody's business, not about the file, so it is a
|
|
* setting and it is applied here. Keeping that split is why ClamAvScanner
|
|
* knows nothing about settings and this class knows nothing about
|
|
* sockets.
|
|
*
|
|
* Every write to a file's scan columns goes through this class. They are
|
|
* not fillable and nothing else sets them.
|
|
*/
|
|
class ScanPolicy
|
|
{
|
|
public function __construct(
|
|
private readonly ScanningConfig $config,
|
|
private readonly FileAvailability $availability,
|
|
private readonly ActivityLogger $activity,
|
|
private readonly QuarantineNotifier $notifier,
|
|
) {}
|
|
|
|
/**
|
|
* Record a verdict, and return the state the file ended up in.
|
|
*
|
|
* Returns null when the verdict was "the scanner did not answer" and
|
|
* this installation waits: nothing is written, the file stays
|
|
* pending, and the caller retries.
|
|
*/
|
|
public function record(File $file, ScanVerdict $verdict): ?ScanStatus
|
|
{
|
|
return match ($verdict->outcome) {
|
|
ScanOutcome::Clean => $this->settle($file, ScanStatus::Clean, null, $verdict->engine),
|
|
ScanOutcome::Infected => $this->quarantine($file, $verdict->detail ?? 'unknown', $verdict->engine),
|
|
ScanOutcome::TooLarge => $this->unscannable($file, NotScannedReason::TooLarge, $verdict->engine),
|
|
ScanOutcome::Encrypted => $this->unscannable($file, NotScannedReason::Encrypted, $verdict->engine),
|
|
ScanOutcome::Unavailable => $this->unavailable($file, $verdict->detail),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The file existed before there was a scanner, or scanning is off.
|
|
* Not a verdict, so it is never logged: nothing happened to this
|
|
* file, it simply was never looked at.
|
|
*/
|
|
public function markNeverScanned(File $file): void
|
|
{
|
|
$file->forceFill([
|
|
'scan_status' => ScanStatus::NotScanned,
|
|
'scan_note' => NotScannedReason::BeforeScanning->value,
|
|
])->save();
|
|
}
|
|
|
|
/**
|
|
* A threat was found. The bytes stay — a scanner can be wrong, and an
|
|
* administrator may release it — but nothing may reach them, and the
|
|
* thumbnails already rendered from this file have to go: they are
|
|
* derived from the same bytes and are served by their own routes.
|
|
*/
|
|
private function quarantine(File $file, string $threat, ?string $engine): ScanStatus
|
|
{
|
|
$wasAvailable = $this->availability->isAvailable($file);
|
|
|
|
$file->forceFill(['scan_was_available' => $wasAvailable])->save();
|
|
|
|
$this->settle($file, ScanStatus::Infected, $threat, $engine);
|
|
$this->purgeRenditions($file);
|
|
|
|
$this->activity->logSystem(Action::FileQuarantined, [
|
|
'id' => $file->id,
|
|
'name' => $file->name,
|
|
'threat' => $threat,
|
|
// Said out loud because it changes what an administrator has
|
|
// to do: a file that was downloadable while it waited for a
|
|
// scanner may already be on somebody's machine, and its
|
|
// download history is the only way to know.
|
|
'was_available' => $wasAvailable,
|
|
]);
|
|
|
|
$this->notifier->quarantined($file, $threat);
|
|
|
|
return ScanStatus::Infected;
|
|
}
|
|
|
|
/** The scanner could not open the file: too large, or encrypted. */
|
|
private function unscannable(File $file, NotScannedReason $reason, ?string $engine): ScanStatus
|
|
{
|
|
if ($this->config->blocksUnscannable()) {
|
|
$this->settle($file, ScanStatus::UnscannableBlocked, $reason->value, $engine);
|
|
$this->purgeRenditions($file);
|
|
|
|
$this->activity->logSystem(Action::FileQuarantined, [
|
|
'id' => $file->id,
|
|
'name' => $file->name,
|
|
'threat' => $reason->label(),
|
|
'was_available' => false,
|
|
]);
|
|
|
|
$this->notifier->quarantined($file, $reason->label());
|
|
|
|
return ScanStatus::UnscannableBlocked;
|
|
}
|
|
|
|
return $this->letThrough($file, $reason, $engine);
|
|
}
|
|
|
|
/** The scanner never answered. Either wait for it, or let the file go. */
|
|
private function unavailable(File $file, ?string $reason): ?ScanStatus
|
|
{
|
|
if ($this->config->holdsWhileUnavailable()) {
|
|
return null;
|
|
}
|
|
|
|
return $this->letThrough($file, NotScannedReason::ScannerUnavailable, null);
|
|
}
|
|
|
|
/**
|
|
* Allowed through without being checked.
|
|
*
|
|
* Always logged, even though it is the configured behaviour: this is
|
|
* the state where the installation looks protected and is not, and
|
|
* the log is what makes "we were unprotected between these two dates"
|
|
* answerable afterwards.
|
|
*/
|
|
private function letThrough(File $file, NotScannedReason $reason, ?string $engine): ScanStatus
|
|
{
|
|
$this->settle($file, ScanStatus::NotScanned, $reason->value, $engine);
|
|
|
|
$this->activity->logSystem(Action::FileNotScanned, [
|
|
'id' => $file->id,
|
|
'name' => $file->name,
|
|
'reason' => $reason->value,
|
|
]);
|
|
|
|
return ScanStatus::NotScanned;
|
|
}
|
|
|
|
private function settle(File $file, ScanStatus $status, ?string $note, ?string $engine): ScanStatus
|
|
{
|
|
// Asked before the write, because what the announcement means is
|
|
// "this can now be had" and a file that could already be had has
|
|
// nothing to announce. Without this, re-scanning a file that went
|
|
// out unscanned would tell its recipients a second time.
|
|
$wasAvailable = $this->availability->isAvailable($file);
|
|
|
|
$file->forceFill([
|
|
'scan_status' => $status,
|
|
'scan_note' => $note,
|
|
'scanned_at' => now(),
|
|
'scan_engine' => $engine,
|
|
])->save();
|
|
|
|
if (! $wasAvailable) {
|
|
$this->availability->markAvailable($file);
|
|
}
|
|
|
|
return $status;
|
|
}
|
|
|
|
/**
|
|
* Thumbnails and previews are cached copies of the same bytes, served
|
|
* by routes of their own, so a quarantined file with a rendition
|
|
* already on disk would still be showing part of itself.
|
|
*/
|
|
private function purgeRenditions(File $file): void
|
|
{
|
|
foreach (ThumbnailGenerator::pathsFor($file->id, $file->mime_type) as $path) {
|
|
Storage::disk('files')->delete($path);
|
|
}
|
|
}
|
|
}
|