Event-driven vulnerability scanning for Azure Container Instances (ACI) and Azure Container Apps (ACA) using the Qualys Container Security Registry API.
ACI/ACA Deployment → Activity Log → Event Hub → Azure Function → Qualys Registry API
↓
Check Cache
↓
Get/Create ACR Registry
↓
Submit On-Demand Scan
↓
Service Bus Notification
↓
Qualys Cloud Platform
- Azure CLI 2.50+
- Azure subscription with Contributor role
- Qualys subscription with Container Security module
- Qualys API Token (Container Security permissions)
- Azure Functions Core Tools 4.x
# Set your Qualys API token
export QUALYS_ACCESS_TOKEN='your-qualys-token'
# Deploy (auto-creates Service Principal for ACR access)
make deploy QUALYS_POD=CA1The Makefile automatically:
- Creates a Service Principal with AcrPull role
- Deploys all Azure infrastructure via Bicep
- Publishes the Azure Function code
# 1. Create Service Principal for ACR Access
az ad sp create-for-rbac \
--name qualys-acr-scanner \
--role AcrPull \
--scopes /subscriptions/$(az account show --query id -o tsv)
# Save: appId → ACR_APPLICATION_ID, password → ACR_CLIENT_SECRET
# 2. Deploy
export QUALYS_API_TOKEN="your-qualys-token"
export ACR_APPLICATION_ID="your-service-principal-app-id"
export ACR_CLIENT_SECRET="your-service-principal-secret"
./deploy.sh| Target | Description |
|---|---|
make deploy QUALYS_POD=CA1 |
Deploy scanner (auto-creates SP) |
make status |
Show deployment status |
make logs |
Stream function app logs |
make test-scan TEST_IMAGE=... |
Deploy test container |
make cleanup |
Remove all resources |
make create-sp |
Manually create Service Principal |
| Variable | Description | Default |
|---|---|---|
QUALYS_ACCESS_TOKEN |
Qualys API Bearer token | Required |
QUALYS_POD |
Qualys POD (US1, US2, CA1, etc.) | Required |
RESOURCE_GROUP |
Azure resource group name | qualys-scanner-rg |
LOCATION |
Azure region | eastus |
SCAN_CACHE_HOURS |
Hours before rescanning same image | 24 |
| POD | Gateway URL |
|---|---|
| US1 | https://gateway.qg1.apps.qualys.com |
| US2 | https://gateway.qg2.apps.qualys.com |
| US3 | https://gateway.qg3.apps.qualys.com |
| US4 | https://gateway.qg4.apps.qualys.com |
| GOV1 | https://gateway.gov1.qualys.us |
| EU1 | https://gateway.qg1.apps.qualys.eu |
| EU2 | https://gateway.qg2.apps.qualys.eu |
| EU3 | https://gateway.qg3.apps.qualys.it |
| IN1 | https://gateway.qg1.apps.qualys.in |
| CA1 | https://gateway.qg1.apps.qualys.ca |
| AE1 | https://gateway.qg1.apps.qualys.ae |
| UK1 | https://gateway.qg1.apps.qualys.co.uk |
| AU1 | https://gateway.qg1.apps.qualys.com.au |
| KSA1 | https://gateway.qg1.apps.qualysksa.com |
Identify the platform that hosts your subscription at Qualys Platform Identification.
The ACR connector in Qualys is automatically named: acr-{subscription_short}
Example: acr-d8ee7e92
One connector covers all ACRs in the subscription (regardless of region).
The Bicep templates and function code are cloud-agnostic. Azure endpoints for
storage, Event Hub, Service Bus, Key Vault, Azure Container Registry, and Azure
Resource Manager are resolved at deploy time from the cloud the Azure CLI
targets, using the Bicep environment() function. The function app reads these
endpoints from its app settings. ACR images are recognized in each cloud:
.azurecr.io (Public), .azurecr.us (US Government), and .azurecr.cn (China).
The Qualys platform is independent of the Azure cloud. Set QUALYS_POD (or
QUALYS_GATEWAY_URL) to the platform that hosts your Qualys subscription. This
can be a commercial platform (US1 through US4, EU, CA1, and others) or the
Qualys Government platform (GOV1). Identify your platform at
Qualys Platform Identification.
To deploy into Azure US Government:
# 1. Target the Azure Government cloud and sign in
az cloud set --name AzureUSGovernment
az login
# 2. Deploy to a Government region, using your Qualys platform
export QUALYS_ACCESS_TOKEN='your-qualys-token'
make deploy QUALYS_POD=GOV1 LOCATION=usgovvirginiaNotes:
LOCATIONdefaults toeastus. Set a Government region such asusgovvirginiaorusgovtexas.QUALYS_PODaccepts any platform in the gateway table, including GOV1. For a platform not listed, setQUALYS_GATEWAY_URLdirectly. WhenQUALYS_GATEWAY_URLis set,QUALYS_PODis not required.- Run
az cloud set --name AzureUSGovernmentbefore any deploy or cleanup command so the service principal and ARM deployments are created in the Government tenant.
By default the Function's supporting resources (Storage, Key Vault, Event Hub,
Service Bus) are deployed with public network access, and the Function runs on
the Consumption plan. This is not required. Set enablePrivateNetworking=true
to VNet-integrate the Function, add private endpoints, and disable public access
on those resources.
When enabled, the deployment:
- Runs the Function on an Elastic Premium plan (Consumption cannot VNet-integrate;
a
Y1selection is upgraded toEP1). - Provisions Event Hub and Service Bus at the Standard tier (Basic does not support private endpoints).
- Regional VNet-integrates the Function into
functionSubnetIdand routes all outbound traffic through the VNet. - Creates private endpoints for Storage (blob, file, queue, table), Key Vault,
Event Hub, and Service Bus in
privateEndpointSubnetId. - Sets public network access to Disabled on Storage, Key Vault, Event Hub, and Service Bus.
Bring your own subnets and use your existing private DNS:
export ENABLE_PRIVATE_NETWORKING=true
export FUNCTION_SUBNET_ID='/subscriptions/.../subnets/functions' # delegated to Microsoft.Web/serverFarms
export PRIVATE_ENDPOINT_SUBNET_ID='/subscriptions/.../subnets/endpoints'
make deploy QUALYS_POD=US2DNS resolution for the private endpoints is left to your central resolver or
Azure Policy by default. To have the template register records in existing
zones, pass privateDnsZoneIds (via main.bicepparam or a direct
az deployment call), keyed by sub-resource:
param privateDnsZoneIds = {
blob: '/subscriptions/.../privateDnsZones/privatelink.blob.core.windows.net'
file: '/subscriptions/.../privateDnsZones/privatelink.file.core.windows.net'
queue: '/subscriptions/.../privateDnsZones/privatelink.queue.core.windows.net'
table: '/subscriptions/.../privateDnsZones/privatelink.table.core.windows.net'
vault: '/subscriptions/.../privateDnsZones/privatelink.vaultcore.azure.net'
servicebus: '/subscriptions/.../privateDnsZones/privatelink.servicebus.windows.net'
}Notes:
- Zone names are cloud-specific. In Azure Government use the
privatelink.*.usgovcloudapi.netequivalents. - The Function reads ACI/ACA container definitions through Azure Resource Manager (control plane), so scanning private ACA/ACR deployments does not require the app to reach their data plane.
- Scanning by Qualys Cloud Platform still requires the registry to be reachable from Qualys. A registry with no path from Qualys needs the Qualys Registry Sensor deployed inside your network; that sensor is not part of this template.
- Application Insights ingestion remains public unless you front it with Azure Monitor Private Link Scope (AMPLS), which is out of scope here.
Deploy to a central subscription and add spoke subscriptions:
export QUALYS_ACCESS_TOKEN="your-qualys-token"
export CENTRAL_SUBSCRIPTION_ID="your-central-subscription-id"
make deploy-multi QUALYS_POD=CA1
# Add spoke subscriptions
make add-spoke SPOKE_SUBSCRIPTION_ID="spoke-subscription-id"make test-scan TEST_IMAGE=myacr.azurecr.io/myapp:v1.0Or manually:
az container create \
--resource-group qualys-scanner-rg \
--name test-scan-$(date +%s) \
--image <your-acr>.azurecr.io/<repo>:<tag> \
--os-type Linux \
--cpu 1 --memory 1 \
--restart-policy NeverNote: Only images from Azure Container Registry (.azurecr.io) are scanned. Public images are skipped.
Scan events are sent to Azure Service Bus queue scan-notifications. Subscribe to receive:
scan_submitted- New scan initiated
qualys-aci/
├── Makefile # Simplified deployment commands
├── function_app/
│ ├── function_app.py # Azure Function (Event Hub trigger)
│ ├── qualys_api.py # Qualys Registry API client
│ ├── image_parser.py # Container image name parser
│ ├── storage_handler.py # Azure Storage operations
│ └── requirements.txt # Python 3.12 dependencies
├── infrastructure/
│ ├── main.bicep # Single subscription deployment
│ ├── central.bicep # Multi-subscription central hub
│ ├── spoke.bicep # Multi-subscription spoke
│ └── resources.bicep # Core resources
├── deploy.sh # Single subscription deployment
├── deploy-multi.sh # Multi-subscription deployment
├── add-spoke.sh # Add spoke subscription
└── cleanup.sh # Resource cleanup
This solution follows Azure security best practices with least-privilege access:
All Azure services use Managed Identity with RBAC instead of connection strings:
| Resource | Role | Scope |
|---|---|---|
| Storage Account | Storage Blob Data Contributor | Storage Account |
| Storage Account | Storage Table Data Contributor | Storage Account |
| Event Hub | Azure Event Hubs Data Receiver | Event Hub Namespace |
| Service Bus | Azure Service Bus Data Sender | Service Bus Namespace |
| Key Vault | Key Vault Secrets User | Key Vault |
Instead of the broad Reader role, a custom role grants only:
Microsoft.ContainerInstance/containerGroups/readMicrosoft.App/containerApps/readMicrosoft.Resources/subscriptions/resourceGroups/read
- Event Hub:
disableLocalAuth: true- No SAS token authentication - Service Bus:
disableLocalAuth: true- No SAS token authentication
Only two secrets stored in Key Vault (accessed via Key Vault references):
- Qualys API Token
- Service Principal Secret (for ACR connector)
- Runtime: Python 3.12
- Infrastructure: Azure Bicep (2024/2025 API versions)
- Authentication: Managed Identity + Service Principal
Monthly (~100 scans):
| Service | Cost |
|---|---|
| Azure Functions (Consumption) | $3-8 |
| Storage Account | $1-2 |
| Event Hub (Basic) | $11 |
| Service Bus (Basic) | $0.05 |
| Application Insights | $0-2 |
| Total | ~$15-25/month |
- Activity Log latency: 10-15 minutes
- ACR images only (public images are skipped)
- Service Principal must have access to all ACRs
MIT License