Files
Investidor-V2/app/Jobs/ScanDocumentForMalware.php

270 lines
11 KiB
PHP

<?php
namespace App\Jobs;
use App\Services\Malware\DocumentScanAttempts;
use App\Services\Malware\MalwareScanner;
use App\Services\OpportunityTechnicalIncidents;
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\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Throwable;
class ScanDocumentForMalware implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 150;
public int $uniqueFor = 600;
public ?string $executionId = null;
/** @var array<string,mixed>|null */
public ?array $opportunityContext = null;
/** @param array<string,mixed>|null $opportunityContext */
public function __construct(public readonly string $documentId, ?array $opportunityContext = null)
{
$this->executionId = (string) Str::uuid();
$this->opportunityContext = $opportunityContext;
}
/** Queue UUID also supports payloads serialized before executionId existed. */
public function runIdentifier(): string
{
if ($this->executionId !== null) {
return $this->executionId;
}
$queued = $this->job?->uuid();
return is_string($queued) && Str::isUuid($queued) ? $queued : ($this->executionId = (string) Str::uuid());
}
public function uniqueId(): string
{
return $this->documentId;
}
/** @return list<int> */
public function backoff(): array
{
return [15, 60];
}
public function handle(MalwareScanner $scanner): void
{
$document = DB::transaction(function (): ?\stdClass {
$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;
}
$attempts = app(DocumentScanAttempts::class);
if ($attempts->completed($row->id, $this->runIdentifier())) {
return null;
}
if ($row->malware_scan_status === 'scanning' && $attempts->owns($row, $this->runIdentifier(), (int) $this->attempts())) {
return null;
}
$attempt = $attempts->start($row, $this->runIdentifier(), (int) $this->attempts());
DB::table('documents')->where('id', $this->documentId)->update([
'status' => 'quarantined', 'malware_scan_status' => 'scanning', 'malware_failure_reason' => null,
'malware_scan_attempts' => $attempt, 'updated_at' => now(),
]);
$row->malware_scan_attempts = $attempt;
return $row;
});
if (! $document) {
return;
}
$startedAt = now();
$disk = Storage::disk('local');
$root = $disk->path('');
$path = $disk->path($document->storage_key);
$temporary = null;
$operationFailed = false;
try {
if (($document->storage_encryption ?? null) === 'laravel') {
$temporary = tempnam($root, 'scan-private-');
if ($temporary === false) {
throw new \RuntimeException('Private scan workspace unavailable');
}
if (! @chmod($temporary, 0600)) {
throw new \RuntimeException('Private scan workspace permissions unavailable');
}
$plain = Crypt::decryptString($disk->get($document->storage_key));
if (file_put_contents($temporary, $plain) !== strlen($plain)) {
throw new \RuntimeException('Private scan workspace write incomplete');
}
unset($plain);
$path = $temporary;
}
$beforeHash = $this->fileHash($path);
if ($beforeHash === null || ! hash_equals(trim($document->sha256), $beforeHash)) {
$applied = $this->record($document, [
'result' => 'inconclusive', 'reason_code' => 'file_hash_mismatch', 'engine' => 'not_run',
'engine_version' => 'unknown', 'signatures_at' => null, 'duration_ms' => 0, 'detail' => null,
], $startedAt, 'inconclusive');
if ($applied) {
$this->notify('scan_inconclusive');
}
return;
}
$result = $scanner->scan($path, (int) ($document->size_bytes ?? filesize($path)), $root);
$afterHash = $this->fileHash($path);
if ($afterHash === null || ! hash_equals(trim($document->sha256), $afterHash)) {
$result['result'] = 'inconclusive';
$result['reason_code'] = 'file_changed_during_scan';
$result['detail'] = null;
}
$result['file_sha256'] = $afterHash;
$applied = $this->record($document, $result, $startedAt, $result['result']);
if (in_array($applied, ['inconclusive', 'failed'], true)) {
$this->notify($applied === 'failed' ? 'scan_worker_failed' : 'scan_inconclusive');
}
} catch (Throwable $error) {
$operationFailed = true;
throw $error;
} finally {
if (is_string($temporary) && is_file($temporary)) {
try {
$removed = @unlink($temporary);
} catch (Throwable) {
$removed = false;
}
if (! $removed) {
try {
Log::warning('Private scan workspace cleanup requires verification.', ['reference' => $this->runIdentifier(), 'error_code' => 'scan_cleanup_unconfirmed']);
} catch (Throwable) {
}
if (! $operationFailed) {
$this->notify('scan_cleanup_unconfirmed');
}
}
}
}
}
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;
}
if ($document->malware_scan_status === 'pending') {
// A failure before claiming the row cannot overwrite another scan result.
$this->notify('scan_worker_failed');
return;
}
if ($document->malware_scan_status !== 'scanning' || ! app(DocumentScanAttempts::class)->owns($document, $this->runIdentifier(), (int) $this->attempts())) {
return;
}
$descriptor = app(DocumentScanAttempts::class)->descriptor($document, $this->runIdentifier(), (int) $this->attempts());
if ($descriptor === null) {
$this->notify('scan_worker_failed');
return;
}
$document->sha256 = $descriptor['sha256'];
$document->size_bytes = $descriptor['size_bytes'];
$applied = $this->record($document, [
'result' => 'failed', 'reason_code' => 'scan_failed_after_retries', 'engine' => 'unknown',
'engine_version' => 'unknown', 'signatures_at' => null, 'duration_ms' => null, 'detail' => null,
], now(), 'failed');
if ($applied) {
$this->notify('scan_worker_failed');
}
}
private function notify(string $errorCode): void
{
if ($this->opportunityContext !== null) {
app(OpportunityTechnicalIncidents::class)->recordScan($this->opportunityContext, $this->runIdentifier(), $errorCode);
}
}
private function fileHash(string $path): ?string
{
// Re-read the filesystem after the scanner; files may change meanwhile.
clearstatcache(true, $path);
$hash = is_file($path) ? hash_file('sha256', $path) : false;
return is_string($hash) ? $hash : null;
}
/** @param array<string,mixed> $result */
private function record(\stdClass $document, array $result, \DateTimeInterface $startedAt, string $state): ?string
{
return DB::transaction(function () use ($document, $result, $startedAt, $state): ?string {
$locked = DB::table('documents')->where('id', $this->documentId)->lockForUpdate()->first();
if (! $locked || $locked->status === 'deleted' || $locked->deleted_at !== null) {
return null;
}
if ($locked->malware_scan_status !== 'scanning' || ! app(DocumentScanAttempts::class)->owns($locked, $this->runIdentifier(), (int) $this->attempts(), (int) $document->malware_scan_attempts)) {
app(DocumentScanAttempts::class)->discarded($locked, $this->runIdentifier(), (int) $this->attempts(), (int) $document->malware_scan_attempts);
return null;
}
$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($document->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' => $document->size_bytes ?? null, 'created_at' => $finishedAt, 'updated_at' => $finishedAt,
]);
app(DocumentScanAttempts::class)->finish($document, $this->runIdentifier(), (int) $this->attempts(), $scanId, $state === 'failed');
return $finalState;
});
}
}