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.
174 lines
6.9 KiB
PHP
174 lines
6.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Audit\Action;
|
|
use App\Modules\Audit\ActivityLog;
|
|
use App\Modules\Files\Models\File;
|
|
use App\Modules\Files\Scanning\ScanStatus;
|
|
use App\Modules\Files\Scanning\ScanVerdict;
|
|
use App\Modules\Identity\Models\Role;
|
|
use App\Modules\Identity\Models\RolePermission;
|
|
use App\Modules\Identity\Permissions\Permission;
|
|
use App\Modules\Notifications\InAppNotification;
|
|
use App\Modules\Platform\Settings\Setting;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use Inertia\Testing\AssertableInertia;
|
|
|
|
beforeEach(function () {
|
|
Storage::fake('files');
|
|
$this->admin = User::factory()->create();
|
|
|
|
app(Settings::class)->set(Setting::VirusScanningEnabled, true);
|
|
app(Settings::class)->set(Setting::VirusScannerAddress, 'tcp://scanner.test:3310');
|
|
app(Settings::class)->set(Setting::VirusUnscannablePolicy, 'allow');
|
|
app(Settings::class)->set(Setting::VirusScannerDownPolicy, 'allow');
|
|
app(Settings::class)->set(Setting::EmailNotificationsEnabled, false);
|
|
});
|
|
|
|
function quarantined(array $overrides = []): File
|
|
{
|
|
$path = 'uploads/'.Str::uuid()->toString().'.pdf';
|
|
Storage::disk('files')->put($path, 'some bytes');
|
|
|
|
return File::factory()->create(array_merge([
|
|
'path' => $path,
|
|
'disk' => 'files',
|
|
'size' => 10,
|
|
'scan_status' => ScanStatus::Infected,
|
|
'scan_note' => 'Eicar-Test-Signature',
|
|
'scanned_at' => now(),
|
|
], $overrides));
|
|
}
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Who may open it
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
test('the quarantine screen needs its own permission', function () {
|
|
$role = Role::query()->create(['name' => 'No release '.Str::random(4)]);
|
|
RolePermission::query()->create(['role_id' => $role->id, 'permission' => Permission::DeleteOthersFiles->value]);
|
|
$staff = User::factory()->create(['role_id' => $role->id]);
|
|
|
|
$this->actingAs($staff)->get('/files/quarantine')->assertForbidden();
|
|
|
|
// Deleting a file is not the same judgement as deciding the scanner
|
|
// was wrong, which is why this permission exists separately.
|
|
$file = quarantined();
|
|
$this->actingAs($staff)->post("/files/{$file->id}/release", ['reason' => 'looks fine'])->assertForbidden();
|
|
|
|
expect($file->refresh()->scan_status)->toBe(ScanStatus::Infected);
|
|
});
|
|
|
|
test('a client cannot reach it at all', function () {
|
|
$client = User::factory()->client()->create();
|
|
|
|
// EnsureStaff sends a client to their own dashboard rather than
|
|
// answering 403 — what matters here is that the screen is not served.
|
|
$this->actingAs($client)->get('/files/quarantine')->assertRedirect(route('dashboard'));
|
|
});
|
|
|
|
test('an administrator sees what is quarantined, and what got out first', function () {
|
|
$uploader = User::factory()->client()->create(['name' => 'Cliente Uno']);
|
|
$file = quarantined(['name' => 'Factura', 'uploaded_by' => $uploader->id, 'scan_was_available' => true]);
|
|
|
|
$this->actingAs($this->admin)->get('/files/quarantine')->assertInertia(
|
|
fn (AssertableInertia $page) => $page
|
|
->component('files/quarantine')
|
|
->where('files.0.name', 'Factura')
|
|
->where('files.0.uploader', 'Cliente Uno')
|
|
->where('files.0.threat', 'Eicar-Test-Signature')
|
|
->where('files.0.was_available', true),
|
|
);
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Releasing
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
test('releasing needs a reason, and records who gave it', function () {
|
|
$file = quarantined();
|
|
confirmPassword($this->admin);
|
|
|
|
$this->actingAs($this->admin)->post("/files/{$file->id}/release", ['reason' => ''])
|
|
->assertSessionHasErrors('reason');
|
|
|
|
expect($file->refresh()->scan_status)->toBe(ScanStatus::Infected);
|
|
|
|
$this->actingAs($this->admin)->post("/files/{$file->id}/release", ['reason' => 'False positive, reported upstream'])
|
|
->assertSessionHasNoErrors();
|
|
|
|
$file->refresh();
|
|
expect($file->scan_status)->toBe(ScanStatus::Released)
|
|
->and($file->released_by)->toBe($this->admin->id)
|
|
->and($file->released_at)->not->toBeNull();
|
|
|
|
$entry = ActivityLog::query()->where('action', Action::FileReleased)->sole();
|
|
expect($entry->actor_id)->toBe($this->admin->id)
|
|
->and($entry->context['reason'])->toBe('False positive, reported upstream');
|
|
});
|
|
|
|
test('a released file downloads again', function () {
|
|
$file = quarantined(['uploaded_by' => $this->admin->id]);
|
|
|
|
$this->actingAs($this->admin)->get("/files/{$file->id}/download")->assertStatus(423);
|
|
|
|
confirmPassword($this->admin);
|
|
$this->actingAs($this->admin)->post("/files/{$file->id}/release", ['reason' => 'Known false positive']);
|
|
|
|
$this->actingAs($this->admin)->get("/files/{$file->id}/download")->assertOk();
|
|
});
|
|
|
|
test('releasing asks for the password first', function () {
|
|
$file = quarantined();
|
|
|
|
// No confirmPassword() here: the middleware should send the request
|
|
// to the confirmation screen rather than release the file.
|
|
$this->actingAs($this->admin)->post("/files/{$file->id}/release", ['reason' => 'sure'])
|
|
->assertRedirect(route('password.confirm'));
|
|
|
|
expect($file->refresh()->scan_status)->toBe(ScanStatus::Infected);
|
|
});
|
|
|
|
test('a file that is not quarantined cannot be released', function () {
|
|
$file = quarantined(['scan_status' => ScanStatus::Clean, 'scan_note' => null]);
|
|
confirmPassword($this->admin);
|
|
|
|
$this->actingAs($this->admin)->post("/files/{$file->id}/release", ['reason' => 'why not'])->assertNotFound();
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Who is told
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
test('a quarantined file tells the administrators and the uploader, and nobody else', function () {
|
|
$uploader = User::factory()->client()->create();
|
|
$bystander = User::factory()->client()->create();
|
|
$file = quarantined(['scan_status' => ScanStatus::Pending, 'scan_note' => null, 'uploaded_by' => $uploader->id]);
|
|
|
|
app(App\Modules\Files\Scanning\ScanPolicy::class)->record($file, ScanVerdict::infected('Eicar-Test-Signature'));
|
|
|
|
$told = InAppNotification::query()->pluck('type', 'user_id');
|
|
|
|
expect($told[$this->admin->id] ?? null)->toBe('file_quarantined')
|
|
->and($told[$uploader->id] ?? null)->toBe('upload_blocked')
|
|
->and($told->has($bystander->id))->toBeFalse();
|
|
});
|
|
|
|
test('a staff member who uploaded it is told once, as staff', function () {
|
|
$file = quarantined(['scan_status' => ScanStatus::Pending, 'scan_note' => null, 'uploaded_by' => $this->admin->id]);
|
|
|
|
app(App\Modules\Files\Scanning\ScanPolicy::class)->record($file, ScanVerdict::infected('Eicar-Test-Signature'));
|
|
|
|
expect(InAppNotification::query()->where('user_id', $this->admin->id)->count())->toBe(1);
|
|
});
|