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
Install ClamAV using its platform instructions,
configure its database directory, and update the signatures with freshclam. In clamd.conf,
bind TCPAddr to 127.0.0.1, use TCPSocket 3310, and set StreamMaxLength to at least 25 MiB.
Configure AlertExceedsMax yes so scan limits produce an alert instead of silently skipping content.
Review MaxFileSize, MaxScanSize, archive limits, and encrypted-file policy for your workload.
Start clamd with that configuration through your platform’s service manager.
Install the .NET 10 SDK, then create the application:
dotnet new console --framework net10.0 --name FileScan
cd FileScan
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; }
}
}
Create a synthetic text file and scan it from the project directory:
printf 'ordinary test text\n' > sample.txt
dotnet run -- sample.txt
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
Use an ordinary synthetic file for the clean path and the official EICAR test file for the detection path. Also exercise an unavailable daemon and an over-limit file. Verify that all inputs remain intact after every outcome. 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
ClamAV’s streaming interface provides a small, non-destructive integration with explicit failure handling. Transloadit also offers a Virus Scan Robot for files in a processing pipeline.
