Skip to content

Scanning

phil/upload-scan

The port

interface Scanner
{
    public function scanStream(mixed $stream): ScanResult;

    public function scanPath(string $path): ScanResult;
}

scanStream() reads an open stream whole and leaves it open. A seekable stream is rewound first: one read before the scan — to sniff its type, hash it or measure it — is scanned from its start, never from where that read stopped, which would send clamd nothing or only a tail and get a clean answer for an infected file. A stream that cannot be rewound, such as a pipe or a socket, is scanned as it comes when nothing was read from it, and is an error result once read, The stream was read before the scan and cannot be rewound: give it to the scanner before reading it. scanPath() opens a local file and closes it. A value that is not an open stream is refused with InvalidArgument: that is a mistake in the calling code. Everything else that keeps a file from being scanned — the engine unreachable or too slow, the file unreadable or larger than the limit — is an error result, never an exception, so that the policy decides.

The results

Resultstatussignaturereason
ScanResult::clean()cleannullnull
ScanResult::infected('Win.Test.EICAR_HDB-1')infectedthe signature that matchednull
ScanResult::error('clamd did not answer within 30 seconds.')errornullwhy the file was not scanned

isClean(), isInfected() and isError() ask the status. A signature and a reason are 1 to 255 bytes of printable ASCII without spaces around them, so they can be logged and shown to an operator as they are; the clamd adapter cuts what clamd sends to that shape.

The policy

ScanPolicy turns a result into a Decision:

ResultOnScanError::Reject (default)OnScanError::AcceptAndFlag
cleanAcceptAccept
infectedRejectReject
errorRejectFlag

Decision::keeps() says whether the file is kept, flagged or not. Rejecting what could not be scanned is the safe default: an attacker who can slow the antivirus down cannot slip a file past it. Accepting and flagging keeps uploads working while the antivirus is down; the application then keeps a flagged file out of sight — not published, not shared — until a later scan clears it.

use Phil\UploadScan\Decision;
use Phil\UploadScan\OnScanError;
use Phil\UploadScan\ScanPolicy;

$decision = new ScanPolicy(OnScanError::AcceptAndFlag)->decide($scanner->scanPath($path));

match ($decision) {
    Decision::Accept => $library->publish($path),
    Decision::Flag => $library->holdForReview($path),
    Decision::Reject => $library->refuse($path),
};