Secure Uploads - v1.0.0

Malware Scanning

Scanners satisfy MalwareScannerInterface (scan, isAvailable, getName). The package ships three implementations; you can also build your own — see Custom scanners.

Driver Use case
null Default. No-op. Dev, CI, kill-switch.
clamav Local scanning via Unix socket (preferred) or binary fallback. Private data stays on your host.
virustotal Cloud scanning via VirusTotal API. Hash-first to skip uploads for already-known files.

See Scanner setup for installing and configuring each.

When the scanner runs

The scanner runs as part of SecureFileStorageService::store() (which is what HasSecureFiles::attachSecureFile() calls):

validate → scan → write to disk → create DB row

If malwareScanning.async = true, the order changes:

validate → write to quarantine → create DB row marked pending_scan

…and the security:scan-quarantine command processes pending files later.

Sync mode (default)

$stored = $post->attachSecureFile($request->file('attachment'));

The store call blocks on the scan. If the file is infected, MalwareDetected fires and the call throws — no DB row, no file on disk.

Pros: simple, immediate user feedback. Cons: every upload waits for the scan (~tens of ms for ClamAV socket, seconds for VirusTotal cold path).

Async mode

Set in config:

'malwareScanning' => ['async' => true, /* ... */],

Or per-call:

$stored = $post->attachSecureFile($request->file('attachment'), ['async' => true]);

The file lands in the quarantine path and the SecureUploadedFile row gets scan_status = pending. Run the worker:

php artisan security:scan-quarantine

Schedule it via Laravel's scheduler so it processes the queue automatically. Files that pass move to their normal storage location; failures fire MalwareDetected and stay quarantined for review.

failOnScanError

When the scanner is unavailable (ClamAV daemon down, VirusTotal API key missing, etc.):

  • failOnScanError = true (default) — the upload is rejected. Fail closed.
  • failOnScanError = false — the upload proceeds, the error is logged, and the row gets scan_status = error.

Use true in production for anything sensitive. Use false only in environments where uploads must succeed even if scanning is intermittently broken (and you have downstream review).

ScanResult

Every scan call returns a ScanResult:

$result->infected;   // bool
$result->signature;  // ?string — the matched virus name, when known
$result->scanner;    // string — getName() of the scanner that ran
$result->scannedAt;  // CarbonInterface
$result->raw;        // array — raw scanner output, for debugging

The MalwareDetected event payload includes the full ScanResult so listeners can audit, alert, or escalate.

Building your own scanner

See Custom scanners.