145 lines
6.0 KiB
PHP
145 lines
6.0 KiB
PHP
<?php
|
|
|
|
namespace App\Jobs;
|
|
|
|
use App\Services\Malware\MalwareScanner;
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
use Illuminate\Queue\InteractsWithQueue;
|
|
use Illuminate\Queue\SerializesModels;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use Throwable;
|
|
|
|
class ScanDocumentForMalware implements ShouldQueue, ShouldBeUnique
|
|
{
|
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
|
|
public int $tries = 3;
|
|
|
|
public int $timeout = 150;
|
|
|
|
public int $uniqueFor = 600;
|
|
|
|
public function __construct(public readonly string $documentId) {}
|
|
|
|
public function uniqueId(): string
|
|
{
|
|
return $this->documentId;
|
|
}
|
|
|
|
public function backoff(): array
|
|
{
|
|
return [15, 60];
|
|
}
|
|
|
|
public function handle(MalwareScanner $scanner): void
|
|
{
|
|
$document = DB::transaction(function (): ?object {
|
|
$row = DB::table('documents')->where('id', $this->documentId)->lockForUpdate()->first();
|
|
if (! $row || $row->status === 'deleted' || $row->deleted_at !== null) {
|
|
return null;
|
|
}
|
|
if ($row->malware_scan_status === 'clean' && hash_equals(trim($row->sha256), (string) $row->malware_scanned_sha256)) {
|
|
return null;
|
|
}
|
|
if ($row->malware_scan_status === 'infected') {
|
|
return null;
|
|
}
|
|
|
|
DB::table('documents')->where('id', $this->documentId)->update([
|
|
'status' => 'quarantined', 'malware_scan_status' => 'scanning', 'malware_failure_reason' => null,
|
|
'malware_scan_attempts' => DB::raw('malware_scan_attempts + 1'), 'updated_at' => now(),
|
|
]);
|
|
|
|
return $row;
|
|
});
|
|
|
|
if (! $document) {
|
|
return;
|
|
}
|
|
|
|
$startedAt = now();
|
|
$disk = Storage::disk('local');
|
|
$root = $disk->path('');
|
|
$path = $disk->path($document->storage_key);
|
|
$beforeHash = is_file($path) ? hash_file('sha256', $path) : false;
|
|
if (! is_string($beforeHash) || ! hash_equals(trim($document->sha256), $beforeHash)) {
|
|
$this->record($document, [
|
|
'result' => 'inconclusive', 'reason_code' => 'file_hash_mismatch', 'engine' => 'ClamAV',
|
|
'engine_version' => 'unknown', 'signatures_at' => null, 'duration_ms' => 0, 'detail' => null,
|
|
], $startedAt, 'inconclusive');
|
|
|
|
return;
|
|
}
|
|
|
|
$result = $scanner->scan($path, (int) ($document->size_bytes ?? filesize($path)), $root);
|
|
|
|
$afterHash = is_file($path) ? hash_file('sha256', $path) : false;
|
|
if (! is_string($afterHash) || ! hash_equals(trim($document->sha256), $afterHash)) {
|
|
$result['result'] = 'inconclusive';
|
|
$result['reason_code'] = 'file_changed_during_scan';
|
|
$result['detail'] = null;
|
|
}
|
|
$result['file_sha256'] = $afterHash;
|
|
|
|
$this->record($document, $result, $startedAt, $result['result']);
|
|
}
|
|
|
|
public function failed(Throwable $exception): void
|
|
{
|
|
$document = DB::table('documents')->where('id', $this->documentId)->first();
|
|
if (! $document || $document->status === 'deleted' || $document->deleted_at !== null) {
|
|
return;
|
|
}
|
|
|
|
$this->record($document, [
|
|
'result' => 'failed', 'reason_code' => 'scanner_unavailable_after_retries', 'engine' => 'ClamAV',
|
|
'engine_version' => 'unknown', 'signatures_at' => null, 'duration_ms' => null, 'detail' => null,
|
|
], now(), 'failed');
|
|
}
|
|
|
|
private function record(object $document, array $result, \DateTimeInterface $startedAt, string $state): void
|
|
{
|
|
DB::transaction(function () use ($document, $result, $startedAt, $state): void {
|
|
$locked = DB::table('documents')->where('id', $this->documentId)->lockForUpdate()->first();
|
|
if (! $locked || $locked->status === 'deleted' || $locked->deleted_at !== null) {
|
|
return;
|
|
}
|
|
|
|
$sameBytes = hash_equals(trim($document->sha256), trim((string) $locked->sha256));
|
|
$finalState = $sameBytes ? $state : 'inconclusive';
|
|
$reason = $sameBytes ? $result['reason_code'] : 'document_record_changed_during_scan';
|
|
$release = $finalState === 'clean' && hash_equals(trim($locked->sha256), trim((string) ($result['file_sha256'] ?? '')));
|
|
$finishedAt = now();
|
|
$scanId = (string) Str::uuid();
|
|
|
|
DB::table('documents')->where('id', $this->documentId)->update([
|
|
'status' => $release ? 'active' : 'quarantined',
|
|
'malware_scan_status' => $finalState,
|
|
'malware_scanned_sha256' => $sameBytes
|
|
&& hash_equals(trim($locked->sha256), trim((string) ($result['file_sha256'] ?? '')))
|
|
? trim($locked->sha256)
|
|
: null,
|
|
'malware_engine' => $result['engine'] ?? 'ClamAV',
|
|
'malware_engine_version' => $result['engine_version'] ?? 'unknown',
|
|
'malware_signatures_at' => $result['signatures_at'] ?? null,
|
|
'malware_scanned_at' => $finishedAt,
|
|
'malware_failure_reason' => $reason,
|
|
'updated_at' => $finishedAt,
|
|
]);
|
|
DB::table('document_malware_scans')->insert([
|
|
'id' => $scanId, 'document_id' => $this->documentId, 'file_sha256' => trim($locked->sha256),
|
|
'result' => $finalState, 'reason_code' => $reason,
|
|
'engine' => $result['engine'] ?? 'ClamAV', 'engine_version' => $result['engine_version'] ?? 'unknown',
|
|
'signatures_at' => $result['signatures_at'] ?? null, 'started_at' => $startedAt,
|
|
'finished_at' => $finishedAt, 'duration_ms' => $result['duration_ms'] ?? null,
|
|
'file_size_bytes' => $locked->size_bytes ?? null, 'created_at' => $finishedAt, 'updated_at' => $finishedAt,
|
|
]);
|
|
});
|
|
}
|
|
}
|