mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-18 09:35:07 +00:00
e9496dc357
An infected file now goes somewhere rather than nowhere. Staff holding the new release_quarantined_files permission get a Quarantine screen listing what was refused, who uploaded it, and what the scanner called it. They can delete it as they always could, or release it — which needs a written reason, a password confirmation on top of the permission, and lands in the activity log under their name. Only the administrator role holds that permission by default. Deciding a threat report is wrong is a different judgement from deciding a file is no longer needed, which is why it is not delete_files. Two notifications, two audiences: staff who can act on it, and the person who uploaded it — for whom this is how they learn their own machine has something on it. The people the file was shared with are deliberately not told about a file they never received. `projectsend:scan-files` runs hourly: it re-queues files still waiting, and re-scans the ones that went out unscanned while the scanner was unreachable, since it may be back. With --existing it also works through a library uploaded before scanning was switched on, paced by a setting so it does not starve today's uploads. A file that was downloadable before it was caught says so on the screen, with its download count, because that is the case where somebody may already have a copy.
177 lines
6.3 KiB
PHP
177 lines
6.3 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
|
|
{
|
|
$file->forceFill([
|
|
'scan_status' => $status,
|
|
'scan_note' => $note,
|
|
'scanned_at' => now(),
|
|
'scan_engine' => $engine,
|
|
])->save();
|
|
|
|
$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);
|
|
}
|
|
}
|
|
}
|