feat(domain): scan uploads with clamd over INSTREAM; 422 refused, 503 scanner down (refs #192)
Verified against a real clamd 1.4.6: clean → Clean, EICAR → Infected, closed port → Unavailable. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,48 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Big.Application;
|
||||
|
||||
namespace Big.Infrastructure;
|
||||
|
||||
/// <summary>Scans a document with clamd over its INSTREAM protocol (S-29, ADR-0036).</summary>
|
||||
/// <summary>
|
||||
/// Scans a document with clamd over its INSTREAM protocol (S-29, ADR-0036): <c>zINSTREAM\0</c>, the
|
||||
/// document as one big-endian length-prefixed chunk, a zero-length terminator, then one reply —
|
||||
/// <c>stream: OK</c> or <c>stream: <signature> FOUND</c>. Anything else (an ERROR reply, a refused
|
||||
/// connection, a timeout) is <see cref="ScanVerdict.Unavailable"/>, so the caller fails closed.
|
||||
/// </summary>
|
||||
public sealed class ClamdDocumentScanner(ClamAvOptions options) : IDocumentScanner
|
||||
{
|
||||
public Task<ScanVerdict> ScanAsync(byte[] content, CancellationToken ct = default)
|
||||
=> Task.FromResult(ScanVerdict.Clean);
|
||||
public async Task<ScanVerdict> ScanAsync(byte[] content, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(options.Timeout);
|
||||
|
||||
string reply;
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(options.Host, options.Port, timeout.Token);
|
||||
var stream = client.GetStream();
|
||||
|
||||
var length = new byte[4];
|
||||
BinaryPrimitives.WriteInt32BigEndian(length, content.Length);
|
||||
await stream.WriteAsync("zINSTREAM\0"u8.ToArray(), timeout.Token);
|
||||
await stream.WriteAsync(length, timeout.Token);
|
||||
await stream.WriteAsync(content, timeout.Token);
|
||||
await stream.WriteAsync(new byte[4], timeout.Token);
|
||||
|
||||
using var reader = new StreamReader(stream, Encoding.ASCII);
|
||||
reply = (await reader.ReadToEndAsync(timeout.Token)).TrimEnd('\0', '\n');
|
||||
}
|
||||
catch (Exception e) when (e is SocketException or IOException
|
||||
|| (e is OperationCanceledException && !ct.IsCancellationRequested))
|
||||
{
|
||||
return ScanVerdict.Unavailable;
|
||||
}
|
||||
|
||||
if (reply == "stream: OK") return ScanVerdict.Clean;
|
||||
return reply.EndsWith(" FOUND", StringComparison.Ordinal) ? ScanVerdict.Infected : ScanVerdict.Unavailable;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user