This post builds a scanned upload path for an Azure Files share. A file enters through an HTTP endpoint, ClamAV scans it, and it reaches the directory applications read only when the scan finds nothing. A detected file is moved to a quarantine directory on the same share instead.
Nothing in Azure scans a file share automatically when a file is written. Azure Files does not send write events, and the Event Grid storage event types fire on the blob service only, so no trigger can react to a write on a share. Microsoft Defender for Storage scans blobs on upload, but for file shares it runs on-demand scans only, billed per gigabyte scanned. A share that must never expose an unscanned file to its readers needs the scan built into the upload path.
How it works
- The client sends the file to the
uploadfunction in one POST request. - The function writes it to
incoming/on the share withupload_fileand receives the service's response with an etag. - The function sets
status=queuedin the file's metadata, puts one message on thescan-requestsqueue, and answers the client with 202. - The message triggers the
scanfunction. - The
scanfunction sendszSCAN /share/incoming/<file>to clamd over TCP. - clamd reads the file from its own mount of the same share and replies with one line,
OKor a signature name. - The
scanfunction moves the file toclean/orquarantine/and setsstatus=scannedorstatus=infectedon it. - A file can also be written into
incoming/directly, over SMB or any other way, with no message. - The
pollerfunction runs every minute, listsincoming/, setsstatus=queuedon every file that has no status, and puts one message per file on the queue. - The scan then runs the same way from step 4.
The 202 does not wait for the scan, so a slow scan never holds a request open. Applications mount the share and read clean/. The scan function moves a file there only after clamd has answered OK for it.
The scanner
The container app runs clamav/clamav:1.5, which starts two processes. clamd loads the signature databases and answers scan requests on port 3310. freshclam checks for new signature databases once a day by default and downloads what is new. clamd detects the changed files on its next self-check, at most ten minutes later, logs SelfCheck: Database modification detected. Forcing reload., and reloads without a restart. A new engine version is a new image tag in the template.
clamd holds the signatures in memory, at about 1 GB resident and 2 GB while a database loads, so the app gets 4 GiB. The scale is fixed at one replica.
clamd has a default file size limit of 100 MiB. A larger file is not scanned, and the reply for it is still OK.
The clamd protocol has no authentication. Anyone who reaches port 3310 can use it, so the port must not be public. The Container Apps environment is therefore internal.
The functions
One Function App contains all three functions. upload has an HTTP trigger and a queue output binding. scan has a queue trigger, which the runtime invokes for each message. poller has a timer trigger that fires every minute.
# function_app.py
import json
import logging
import os
import socket
from typing import List
import azure.functions as func
from azure.core.exceptions import ResourceExistsError
from azure.storage.fileshare import ShareClient
app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION)
def get_share() -> ShareClient:
return ShareClient.from_connection_string(
os.environ["STORAGE_CONNECTION_STRING"], os.environ["SHARE_NAME"]
)
def ensure_dirs(share: ShareClient) -> None:
for name in ("incoming", "clean", "quarantine"):
try:
share.create_directory(name)
except ResourceExistsError:
pass
def clamd_scan(path: str) -> str:
address = (os.environ["CLAMD_HOST"], int(os.environ.get("CLAMD_PORT", "3310")))
with socket.create_connection(address, timeout=240) as conn:
conn.sendall(b"zSCAN " + path.encode() + b"\x00")
reply = b""
while not reply.endswith(b"\x00"):
chunk = conn.recv(4096)
if not chunk:
break
reply += chunk
return reply.rstrip(b"\x00").decode()
@app.function_name("upload")
@app.route(route="upload", methods=["POST"])
@app.queue_output(
arg_name="msg", queue_name="%QUEUE_NAME%", connection="STORAGE_CONNECTION_STRING"
)
def upload(req: func.HttpRequest, msg: func.Out[str]) -> func.HttpResponse:
name = os.path.basename(req.params.get("name", ""))
if not name:
return func.HttpResponse(
json.dumps({"error": "pass the file name as ?name="}),
status_code=400,
mimetype="application/json",
)
body = req.get_body()
if not body:
return func.HttpResponse(
json.dumps({"error": "empty body"}), status_code=400, mimetype="application/json"
)
share = get_share()
ensure_dirs(share)
file_client = share.get_file_client(f"incoming/{name}")
uploaded = file_client.upload_file(body)
if uploaded.get("etag"):
file_client.set_file_metadata({"status": "queued"})
msg.set(json.dumps({"name": name}))
return func.HttpResponse(
json.dumps({"queued": f"incoming/{name}", "bytes": len(body)}),
status_code=202,
mimetype="application/json",
)
@app.function_name("scan")
@app.queue_trigger(
arg_name="msg", queue_name="%QUEUE_NAME%", connection="STORAGE_CONNECTION_STRING"
)
def scan(msg: func.QueueMessage) -> None:
name = json.loads(msg.get_body())["name"]
reply = clamd_scan(f"/share/incoming/{name}")
logging.info("clamd reply: %s", reply)
if reply.endswith(" OK"):
dest = "clean"
elif reply.endswith(" FOUND"):
dest = "quarantine"
else:
raise RuntimeError(f"scan not conclusive, will retry: {reply}")
share = get_share()
ensure_dirs(share)
share.get_file_client(f"incoming/{name}").rename_file(f"{dest}/{name}")
status = "scanned" if dest == "clean" else "infected"
share.get_file_client(f"{dest}/{name}").set_file_metadata({"status": status})
logging.info("moved incoming/%s to %s/", name, dest)
@app.function_name("poller")
@app.timer_trigger(arg_name="timer", schedule="0 * * * * *")
@app.queue_output(
arg_name="msgs", queue_name="%QUEUE_NAME%", connection="STORAGE_CONNECTION_STRING"
)
def poller(timer: func.TimerRequest, msgs: func.Out[List[str]]) -> None:
share = get_share()
ensure_dirs(share)
found = []
for item in share.get_directory_client("incoming").list_directories_and_files():
if item["is_directory"]:
continue
file_client = share.get_file_client(f"incoming/{item['name']}")
if file_client.get_file_properties().metadata.get("status"):
continue
file_client.set_file_metadata({"status": "queued"})
found.append(json.dumps({"name": item["name"]}))
if found:
msgs.set(found)
logging.info("poller queued %d files", len(found))
The exchange with clamd is one command and one reply over TCP. The function sends zSCAN /share/incoming/<name>, and clamd reads that file from its own mount and answers with a single line. Only the path is sent over the connection, never the file. The z prefix and the \x00 bytes in the code are the protocol's framing for the command and the reply.
The reply determines where the file is moved. /share/incoming/report.txt: OK means clean/. /share/incoming/eicar.txt: Eicar-Test-Signature FOUND means quarantine/. Any other reply raises an exception. The message then stays on the queue and the runtime retries it. After repeated failures the runtime moves the message to the scan-requests-poison queue.
Two more files complete the Function App.
// host.json
{
"version": "2.0",
"functionTimeout": "00:10:00",
"extensionBundle": {
"id": "Microsoft.Azure.Functions.ExtensionBundle",
"version": "[4.*, 5.0.0)"
}
}
// requirements.txt
azure-functions
azure-storage-file-share
Bicep template
// main.bicep
param location string = resourceGroup().location
param baseName string = 'clamscan'
var suffix = uniqueString(resourceGroup().id)
var storageName = toLower(take('st${baseName}${suffix}', 24))
var shareName = 'files'
var queueName = 'scan-requests'
resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = {
name: storageName
location: location
sku: { name: 'Standard_LRS' }
kind: 'StorageV2'
properties: {
allowBlobPublicAccess: false
minimumTlsVersion: 'TLS1_2'
}
}
resource fileService 'Microsoft.Storage/storageAccounts/fileServices@2023-05-01' = {
parent: storage
name: 'default'
}
resource share 'Microsoft.Storage/storageAccounts/fileServices/shares@2023-05-01' = {
parent: fileService
name: shareName
}
resource queueService 'Microsoft.Storage/storageAccounts/queueServices@2023-05-01' = {
parent: storage
name: 'default'
}
resource queue 'Microsoft.Storage/storageAccounts/queueServices/queues@2023-05-01' = {
parent: queueService
name: queueName
}
var storageConn = 'DefaultEndpointsProtocol=https;AccountName=${storage.name};AccountKey=${storage.listKeys().keys[0].value};EndpointSuffix=${environment().suffixes.storage}'
resource logs 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
name: 'log-${baseName}'
location: location
properties: {
sku: { name: 'PerGB2018' }
retentionInDays: 30
}
}
resource vnet 'Microsoft.Network/virtualNetworks@2023-11-01' = {
name: 'vnet-${baseName}'
location: location
properties: {
addressSpace: { addressPrefixes: ['10.60.0.0/16'] }
subnets: [
{
name: 'containerapps'
properties: {
addressPrefix: '10.60.0.0/23'
delegations: [
{
name: 'aca'
properties: { serviceName: 'Microsoft.App/environments' }
}
]
}
}
{
name: 'functions'
properties: {
addressPrefix: '10.60.2.0/24'
delegations: [
{
name: 'flex'
properties: { serviceName: 'Microsoft.App/environments' }
}
]
}
}
]
}
}
resource env 'Microsoft.App/managedEnvironments@2024-03-01' = {
name: 'cae-${baseName}'
location: location
properties: {
vnetConfiguration: {
infrastructureSubnetId: vnet.properties.subnets[0].id
internal: true
}
appLogsConfiguration: {
destination: 'log-analytics'
logAnalyticsConfiguration: {
customerId: logs.properties.customerId
sharedKey: logs.listKeys().primarySharedKey
}
}
}
}
resource envShare 'Microsoft.App/managedEnvironments/storages@2024-03-01' = {
parent: env
name: 'sharemount'
properties: {
azureFile: {
accountName: storage.name
accountKey: storage.listKeys().keys[0].value
shareName: shareName
accessMode: 'ReadWrite'
}
}
dependsOn: [share]
}
resource clamd 'Microsoft.App/containerApps@2024-03-01' = {
name: 'ca-clamd'
location: location
properties: {
managedEnvironmentId: env.id
configuration: {
ingress: {
external: true
transport: 'Tcp'
targetPort: 3310
exposedPort: 3310
}
}
template: {
containers: [
{
name: 'clamd'
image: 'clamav/clamav:1.5'
resources: {
cpu: json('2')
memory: '4Gi'
}
volumeMounts: [
{
volumeName: 'share'
mountPath: '/share'
}
]
}
]
volumes: [
{
name: 'share'
storageType: 'AzureFile'
storageName: envShare.name
}
]
scale: {
minReplicas: 1
maxReplicas: 1
}
}
}
}
module privateDns 'dns.bicep' = {
name: 'private-dns'
params: {
domain: env.properties.defaultDomain
staticIp: env.properties.staticIp
vnetId: vnet.id
}
}
resource deployContainerService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' = {
parent: storage
name: 'default'
}
resource deployContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = {
parent: deployContainerService
name: 'function-deployments'
}
resource plan 'Microsoft.Web/serverfarms@2024-04-01' = {
name: 'plan-${baseName}'
location: location
kind: 'functionapp'
sku: {
name: 'FC1'
tier: 'FlexConsumption'
}
properties: {
reserved: true
}
}
resource functionApp 'Microsoft.Web/sites@2024-04-01' = {
name: 'func-${baseName}-${suffix}'
location: location
kind: 'functionapp,linux'
properties: {
serverFarmId: plan.id
httpsOnly: true
virtualNetworkSubnetId: vnet.properties.subnets[1].id
functionAppConfig: {
deployment: {
storage: {
type: 'blobContainer'
value: '${storage.properties.primaryEndpoints.blob}${deployContainer.name}'
authentication: {
type: 'StorageAccountConnectionString'
storageAccountConnectionStringName: 'DEPLOYMENT_STORAGE_CONNECTION_STRING'
}
}
}
scaleAndConcurrency: {
maximumInstanceCount: 40
instanceMemoryMB: 2048
}
runtime: {
name: 'python'
version: '3.11'
}
}
siteConfig: {
vnetRouteAllEnabled: true
appSettings: [
{ name: 'AzureWebJobsStorage', value: storageConn }
{ name: 'DEPLOYMENT_STORAGE_CONNECTION_STRING', value: storageConn }
{ name: 'STORAGE_CONNECTION_STRING', value: storageConn }
{ name: 'SHARE_NAME', value: shareName }
{ name: 'QUEUE_NAME', value: queueName }
{ name: 'CLAMD_HOST', value: clamd.properties.configuration.ingress.fqdn }
{ name: 'CLAMD_PORT', value: '3310' }
]
}
}
dependsOn: [privateDns]
}
output functionAppName string = functionApp.name
output clamdFqdn string = clamd.properties.configuration.ingress.fqdn
output storageAccountName string = storage.name
// dns.bicep
param domain string
param staticIp string
param vnetId string
resource zone 'Microsoft.Network/privateDnsZones@2020-06-01' = {
name: domain
location: 'global'
}
resource link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = {
parent: zone
name: 'vnetlink'
location: 'global'
properties: {
virtualNetwork: { id: vnetId }
registrationEnabled: false
}
}
resource wildcard 'Microsoft.Network/privateDnsZones/A@2020-06-01' = {
parent: zone
name: '*'
properties: {
ttl: 300
aRecords: [{ ipv4Address: staticIp }]
}
}
An app with external TCP ingress deploys only to an environment on a custom virtual network, so the template creates one with two delegated subnets: one for the environment, one for the Function App. The environment is internal, so port 3310 gets a private address.
Names in an internal environment do not resolve by default. The private DNS zone in dns.bicep adds one wildcard record with the environment's IP, which makes CLAMD_HOST resolvable for the function. The zone is a separate file because its name must be the environment's generated domain, and ARM requires in-file resource names before that domain exists.
Deploying
RG=rg-clamscan
LOC=swedencentral
az group create --name $RG --location $LOC
az deployment group create \
--resource-group $RG \
--template-file main.bicep
FUNC=$(az deployment group show --resource-group $RG --name main \
--query properties.outputs.functionAppName.value -o tsv)
The function code deploys as a zip of the three files. --build-remote makes Azure install the requirements during deployment.
zip func.zip function_app.py host.json requirements.txt
az functionapp deployment source config-zip \
--resource-group $RG \
--name $FUNC \
--src func.zip \
--build-remote true
Verifying the deployment
The EICAR test file is a file that every antivirus engine reports as infected.
KEY=$(az functionapp function keys list --resource-group $RG --name $FUNC \
--function-name upload --query default -o tsv)
echo "quarterly report text" > report.txt
curl -s https://secure.eicar.org/eicar.com.txt -o eicar.txt
curl -s -X POST "https://$FUNC.azurewebsites.net/api/upload?name=report.txt&code=$KEY" \
--data-binary @report.txt
curl -s -X POST "https://$FUNC.azurewebsites.net/api/upload?name=eicar.txt&code=$KEY" \
--data-binary @eicar.txt
{"queued": "incoming/report.txt", "bytes": 22}
{"queued": "incoming/eicar.txt", "bytes": 68}
Both uploads are accepted, because the response confirms storage and queuing, not the scan result. The result is the file's final directory:
ACC=$(az deployment group show --resource-group $RG --name main \
--query properties.outputs.storageAccountName.value -o tsv)
KEY_STORAGE=$(az storage account keys list --resource-group $RG --account-name $ACC \
--query '[0].value' -o tsv)
for d in incoming clean quarantine; do
echo "--- $d/ ---"
az storage file list --share-name files --path $d \
--account-name $ACC --account-key "$KEY_STORAGE" --query '[].name' -o tsv
done
--- incoming/ ---
--- clean/ ---
report.txt
--- quarantine/ ---
eicar.txt
Conclusion
Two paths put files on the share. An upload through the endpoint is queued immediately; a file written into incoming/ any other way is queued by the poller within a minute. Clean files are moved to clean/, detections to quarantine/, and each file's status metadata records the outcome.
Signature updates require no deployments. freshclam downloads new databases daily and clamd reloads them within ten minutes.
Only incoming/ is scanned, and a file over clamd's default 100 MiB limit is moved to clean/ as OK without a scan.

Top comments (0)