DEV Community

Edgaras
Edgaras

Posted on

Scanning an Azure Files share with ClamAV

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

UML sequence diagram of scan on upload with ClamAV on an Azure Files share

  1. The client sends the file to the upload function in one POST request.
  2. The function writes it to incoming/ on the share with upload_file and receives the service's response with an etag.
  3. The function sets status=queued in the file's metadata, puts one message on the scan-requests queue, and answers the client with 202.
  4. The message triggers the scan function.
  5. The scan function sends zSCAN /share/incoming/<file> to clamd over TCP.
  6. clamd reads the file from its own mount of the same share and replies with one line, OK or a signature name.
  7. The scan function moves the file to clean/ or quarantine/ and sets status=scanned or status=infected on it.
  8. A file can also be written into incoming/ directly, over SMB or any other way, with no message.
  9. The poller function runs every minute, lists incoming/, sets status=queued on every file that has no status, and puts one message per file on the queue.
  10. 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))
Enter fullscreen mode Exit fullscreen mode

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)"
  }
}
Enter fullscreen mode Exit fullscreen mode
// requirements.txt
azure-functions
azure-storage-file-share
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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 }]
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
{"queued": "incoming/report.txt", "bytes": 22}
{"queued": "incoming/eicar.txt", "bytes": 68}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
--- incoming/ ---
--- clean/ ---
report.txt
--- quarantine/ ---
eicar.txt
Enter fullscreen mode Exit fullscreen mode

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)