Count files going out unscanned, not log entries about them

The dashboard warned that 62 files had been let through unscanned in the
last day. They were 62 activity log entries for 16 files, and none of
those files could be downloaded: 50 entries were for files now missing
from storage, and 12 for files since deleted. The count read the log, so
it counted a file once per attempt and kept counting it after it was
deleted, went missing or was scanned clean.

It now counts files in their current state: not scanned, let through
while the scanner was down or because it could not open them, with that
verdict in the last day. The settings screen already counted this way
without the time limit; the rule is one File scope used by the
dashboard, the settings screen and projectsend:status. The status key
keeps its name and meaning, and is now accurate.
This commit is contained in:
ignacionelson
2026-09-17 11:08:57 -03:00
parent 783536be40
commit ba99674cc7
5 changed files with 75 additions and 22 deletions
@@ -568,12 +568,13 @@ class DashboardController extends Controller
'reachable' => $scanner->reachable,
'engine' => $scanner->engine,
'definitions_age_hours' => $scanner->definitionsAgeHours(),
// Files that went out unchecked in the last day. Zero is the
// only number that means "protected"; anything else is a
// scanner that was down, or files nobody could open.
'let_through_24h' => ActivityLog::query()
->where('action', Action::FileNotScanned)
->where('created_at', '>=', now()->subDay())
// Files let through unchecked in the last day that can still
// be downloaded. Zero is the only number that means
// "protected"; anything else is a scanner that was down, or
// files nobody could open. See File::scopeLetThrough().
'let_through_24h' => File::query()
->letThrough()
->where('scanned_at', '>=', now()->subDay())
->count(),
// Waiting more than an hour: on an installation set to hold,
// this is what an outage looks like.
@@ -383,14 +383,7 @@ class VirusScanningSettingsController extends Controller
ScanStatus::UnscannableBlocked->value,
])->count(),
'never_scanned' => File::query()->neverScanned()->count(),
'let_through' => File::query()
->where('scan_status', ScanStatus::NotScanned)
->whereIn('scan_note', [
NotScannedReason::ScannerUnavailable->value,
NotScannedReason::TooLarge->value,
NotScannedReason::Encrypted->value,
])
->count(),
'let_through' => File::query()->letThrough()->count(),
// What a New scan would actually check — see
// ScanFileJob::rescannableValues().
'scannable' => File::query()
+22
View File
@@ -319,6 +319,28 @@ class File extends Model
});
}
/**
* Files people can download that nothing checked: let through while
* the scanner was down, or because it could not open them.
*
* A state, not a history. The dashboard used to count "let through"
* entries in the activity log, which counted a file once per attempt,
* and went on counting files that had since been deleted, gone
* missing or been scanned clean none of which is going out
* unscanned.
*
* @param Builder<File> $query
*/
public function scopeLetThrough(Builder $query): void
{
$query->where('scan_status', ScanStatus::NotScanned)
->whereIn('scan_note', [
NotScannedReason::ScannerUnavailable->value,
NotScannedReason::TooLarge->value,
NotScannedReason::Encrypted->value,
]);
}
/**
* Files nothing has ever looked at.
*
@@ -445,12 +445,13 @@ class StatusCommand extends Command
ScanStatus::Infected->value,
ScanStatus::UnscannableBlocked->value,
])->count(),
// Files that went out unchecked in the last day. Zero is the
// only number that means "protected"; anything else is a
// scanner that was down, or files nobody could open.
'let_through_24h' => ActivityLog::query()
->where('action', Action::FileNotScanned)
->where('created_at', '>=', now()->subDay())
// Files let through unchecked in the last day that can still
// be downloaded. Zero is the only number that means
// "protected"; anything else is a scanner that was down, or
// files nobody could open. See File::scopeLetThrough().
'let_through_24h' => File::query()
->letThrough()
->where('scanned_at', '>=', now()->subDay())
->count(),
];
}
@@ -292,8 +292,10 @@ test('the status command reports an unreachable scanner and what got through', f
app(Settings::class)->set(Setting::VirusScannerAddress, 'tcp://nowhere.test:3310');
app()->instance(VirusScanner::class, (new FakeVirusScanner)->reports(ScannerStatus::unreachable('no answer')));
app(App\Modules\Audit\ActivityLogger::class)->logSystem(App\Modules\Audit\Action::FileNotScanned, [
'id' => 1, 'name' => 'x', 'reason' => 'scanner_unavailable',
File::factory()->create([
'scan_status' => ScanStatus::NotScanned,
'scan_note' => NotScannedReason::ScannerUnavailable->value,
'scanned_at' => now(),
]);
// statusJson() lives in StatusCommandTest — Pest loads every test
@@ -626,3 +628,37 @@ test('a retry scheduled for later is not a scan in progress', function () {
->assertJsonPath('running', true);
});
test('"let through" counts files that can still be downloaded unchecked, once each', function () {
// What the dashboard reported after a day of real use: 62, for 16
// files, none of them downloadable. It counted activity log entries —
// one per attempt, and still counting files since deleted, gone
// missing or scanned clean.
app(Settings::class)->set(Setting::VirusScanningEnabled, true);
app(Settings::class)->set(Setting::VirusScannerAddress, 'tcp://scanner.test:3310');
app()->instance(VirusScanner::class, new FakeVirusScanner);
$letThrough = fn (array $overrides = []): File => File::factory()->create(array_merge([
'scan_status' => ScanStatus::NotScanned,
'scan_note' => NotScannedReason::ScannerUnavailable->value,
'scanned_at' => now(),
], $overrides));
$out = $letThrough();
$letThrough()->delete();
$letThrough(['scan_status' => ScanStatus::Missing, 'scan_note' => null]);
$letThrough(['scan_status' => ScanStatus::Clean, 'scan_note' => null]);
$letThrough(['scanned_at' => now()->subDays(2)]);
foreach (range(1, 3) as $attempt) {
app(App\Modules\Audit\ActivityLogger::class)->logSystem(App\Modules\Audit\Action::FileNotScanned, [
'id' => $out->id, 'name' => $out->name, 'reason' => 'scanner_unavailable',
]);
}
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page->where('system.scanning.let_through_24h', 1),
);
expect(scanningStatus()['let_through_24h'])->toBe(1);
});