Scan files for viruses in .NET using open source
Scan an application-owned staging file with ClamAV before accepting it. This .NET 10 console program sends the file to a local daemon and returns separate clean, infected, and scanner-error verdicts. It never cleans, modifies, or deletes the input.
Introduction
Only a completed clean verdict permits the next processing step. A missing file, size limit, unavailable daemon, malformed reply, timeout, or cancellation must leave the file unaccepted. A clean verdict describes the engine’s scan with its current configuration and signatures; it does not guarantee that a file is harmless.
Open-source antivirus tools for .NET
ClamAV supplies the scanner. .NET’s built-in TCP client can speak its documented INSTREAM protocol, so this example needs no NuGet scanner wrapper. Keep the daemon on the same host: its TCP interface is unauthenticated.
Set up ClamAV
The commands below use Bash on Linux. Install the current .NET 10 SDK, version 10.0.111 or newer within the .NET 10 series, and ClamAV 1.5.4 using its platform instructions. The program was tested with SDK 10.0.111 and ClamAV 1.5.4; check the ClamAV support policy before deploying that release.
Configure clamd and freshclam to use the same database directory, and allow the initial signature
download to finish. If your distribution runs a FreshClam service, let it manage updates rather than
starting a second updater. Keep the package’s database, user, and logging settings in clamd.conf.
Replace existing values for these directives with this fragment; do not append conflicting copies:
TCPAddr 127.0.0.1
TCPSocket 3310
StreamMaxLength 25M
MaxFileSize 26M
MaxScanSize 26M
AlertExceedsMax yes
The stream limit matches the client’s 25 MiB cap. The engine limits also bound unpacked content;
AlertExceedsMax makes the corresponding limits produce alerts that this program treats as scanner
errors. Review other archive limits and encrypted-file policy for your workload. See the
versioned configuration reference.
Start or restart clamd through your platform’s service manager with the edited configuration.
Create the application in a writable directory outside an existing .NET build tree. Paste this Bash
block as a whole. It refuses ancestor SDK/build configuration and an existing FileScan directory,
and enters the project only after creation and restore succeed:
(
cd -P . || exit 1
scanner_parent=$PWD
while :; do
for scanner_config in global.json Directory.Build.props Directory.Build.targets; do
if [ -e "$scanner_parent/$scanner_config" ] || [ -L "$scanner_parent/$scanner_config" ]; then
printf 'Choose a directory outside an existing .NET build tree.\n' >&2
exit 1
fi
done
[ "$scanner_parent" = / ] && break
scanner_parent=${scanner_parent%/*}
scanner_parent=${scanner_parent:-/}
done
command -v dotnet >/dev/null &&
mkdir FileScan &&
dotnet new console --framework net10.0 --name FileScan --output FileScan --no-restore &&
dotnet restore FileScan/FileScan.csproj
) && cd FileScan
A failed creation or restore can leave a partial FileScan directory while your shell stays in its
original directory. Inspect the error and preserve existing files. If this block created the
project successfully but restore failed, fix the SDK or package-source problem, then retry from that
original directory:
dotnet restore FileScan/FileScan.csproj && cd FileScan
If project creation itself failed, choose a new empty location before repeating setup. Do not use
--force to overwrite an existing project.
Implement file scanning in C#
Replace Program.cs with this complete program. It streams at most 25 MiB in 64 KiB chunks, bounds
the daemon reply, and cancels all network and file operations after 30 seconds or Ctrl+C.
using System.Buffers.Binary;
using System.Net;
using System.Net.Sockets;
using System.Text;
public enum Verdict { Clean, Infected, ScannerError }
public static class Program
{
public static async Task<int> Main(string[] args)
{
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(30));
Console.CancelKeyPress += (_, e) => { e.Cancel = true; deadline.Cancel(); };
if (args.Length != 1) { Console.Error.WriteLine("Pass one local file."); return 2; }
var verdict = await FileScanner.ScanAsync(args[0], 3310, deadline.Token);
Console.WriteLine(verdict);
return verdict switch { Verdict.Clean => 0, Verdict.Infected => 1, _ => 2 };
}
}
public static class FileScanner
{
private const int MaxBytes = 25 * 1024 * 1024;
public static async Task<Verdict> ScanAsync(string path, int port, CancellationToken ct)
{
try
{
await using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
if (file.Length > MaxBytes) return Verdict.ScannerError;
using var client = new TcpClient();
await client.ConnectAsync(IPAddress.Loopback, port, ct);
using var stream = client.GetStream();
await stream.WriteAsync(Encoding.ASCII.GetBytes("zINSTREAM\0"), ct);
var buffer = new byte[64 * 1024];
var header = new byte[4];
long sent = 0;
int count;
while ((count = await file.ReadAsync(buffer, ct)) != 0)
{
sent += count;
if (sent > MaxBytes) return Verdict.ScannerError;
BinaryPrimitives.WriteUInt32BigEndian(header, (uint)count);
await stream.WriteAsync(header, ct);
await stream.WriteAsync(buffer.AsMemory(0, count), ct);
}
BinaryPrimitives.WriteUInt32BigEndian(header, 0);
await stream.WriteAsync(header, ct);
var reply = new List<byte>();
var next = new byte[1];
while (reply.Count < 4096)
{
if (await stream.ReadAsync(next, ct) != 1) return Verdict.ScannerError;
if (next[0] == 0)
{
if (await stream.ReadAsync(next, ct) != 0) return Verdict.ScannerError;
string result = Encoding.UTF8.GetString(reply.ToArray());
if (result == "stream: OK") return Verdict.Clean;
if (result.StartsWith("stream: ", StringComparison.Ordinal) &&
result.EndsWith(" FOUND", StringComparison.Ordinal) && result.Length > 14 &&
!result.Contains("Heuristics.Limits.Exceeded", StringComparison.Ordinal))
return Verdict.Infected;
return Verdict.ScannerError;
}
reply.Add(next[0]);
}
return Verdict.ScannerError;
}
catch (Exception) { return Verdict.ScannerError; }
}
}
From the project directory, create a synthetic text file and scan it. This replaces sample.txt;
use that name only for disposable test data. A failed write prevents the scan from running:
printf 'ordinary test text\n' > sample.txt &&
dotnet run -- sample.txt
With a working daemon, this prints Clean.
Exit codes are 0 for clean, 1 for infected, and 2 for scanner errors. Check the exit code
before accepting a file. A refusal to connect must produce ScannerError, never Clean.
Keep scanning non-destructive
The program opens the input for reading only. Keep staged files in an application-owned directory and prevent concurrent writers; process the same bytes that were scanned. An antivirus API that combines scanning with remediation is unsuitable when the application must preserve original files.
Test clean, infected, and failed scans
Download the official EICAR test file as
eicar.com.txt into the project directory, then run:
dotnet run -- eicar.com.txt
Expect Infected and exit code 1. EICAR is a harmless antivirus
test file; other antivirus software on your machine may quarantine it independently of this program.
Stop your local daemon and repeat dotnet run -- sample.txt: expect
ScannerError and exit code 2.
Also exercise a missing file, an input larger than 25 MiB, and an archive whose unpacked content
exceeds the configured engine limit. Those cases must return ScannerError, not Clean. An empty
file and an ordinary file exactly 25 MiB are within the client limit. Compare input hashes before
and after each scan. A private daemon with a test-only signature database verifies the integration,
but does not establish production malware-database coverage.
Best practices
Keep production signatures current, review engine limits, and retain staged files under your application’s retention policy. Scan errors require a retry or rejection decision; they do not authorize publication. Keep credentials and scanner diagnostics out of user-visible responses.
Conclusion
For scanning inside a file-processing pipeline, see Transloadit’s Virus Scan Robot.
