On this page

Storage is where malware waits. A blob uploaded to ingest/ by a pipeline step, a partner’s SFTP connector, or a misconfigured Logic App sits quietly until something downstream opens it — a Data Factory copy, a Function app, a Synapse notebook, a developer’s az storage blob download. The upload puts the round in the chamber; the retrieval is where it fires. For years the answer was “run AV on whatever reads it,” which is useless when the reader is a headless build runner with no EDR.

Defender for Storage Malware Scanning closes that gap. Every PutBlob triggers a scan inside the storage service itself. The scan runs asynchronously — the blob is readable during scanning, so this is not a hard upload-blocker — but the verdict lands on the blob as an index tag fast enough that downstream consumers can gate on it before opening the file, and a Defender for Cloud alert fires for the SOC. (For workflows that genuinely need the blob to be unreachable until clean, pair the scan with Microsoft’s soft-delete quarantine for malicious blobs or with data-plane ABAC rules that refuse access to blobs without a No threats found tag.) I wanted to measure two things:

  1. How fast is “scan on upload” in practice?
  2. What does the full Sentinel story look like — the malware alert alone, or can you layer correlation on top?

What I measured

Uploading EICAR to a freshly-deployed lab storage account on the MSFT tenant:

EventTime (UTC)Δ from upload
az storage blob upload returns17:40:350 s
Blob index tag Malware Scanning scan result: Malicious17:40:38+3 s
Defender alert Storage.Blob_AM.MalwareFound raised17:40:39+4 s
Alert visible in Sentinel SecurityAlert table18:14:18(see below)
Scheduled Rule 1 fires on the alertnext 5-min poll~5 min

Microsoft’s docs say “typically within 2 minutes.” For small blobs the hot path is two orders of magnitude faster. The 30-minute gap between the alert being raised and it showing up in Sentinel is the one you need to plan for — and it’s caused by a configuration step the docs elide. See Sentinel ingestion — the step the docs skip below.

Hands-on Lab: All Bicep, Sentinel rules, attack scripts, and the workbook are in the companion repo on GitHub.

Why storage is a blind spot

A fast inventory of real-world attack patterns I’ve seen against blob storage in the last year:

  • Phishing staging — attacker gets temporary SAS access, drops a malicious Excel or LNK into a public-ish container, mails the URL to employees. Recipients click, the browser downloads direct from the company’s own *.blob.core.windows.net domain, and neither Defender for Office nor any endpoint AV flags the storage-side artifact before open.
  • Supply chain payload stash — an attacker who’s already in CI drops a dropper into a container that a downstream build job fetches. The dropper is fetched by the build runner with a managed identity; the build runner has no EDR.
  • Anonymous backup theft — a container left allowBlobPublicAccess=true by mistake. Backups, training data, or cached credentials get crawled by the usual scanners.
  • Cross-tenant data drop — a compromised B2B guest has Contributor rights to a shared account. Used for exfil on the way out.

The common thread: the storage layer itself has no idea what it’s holding. Everything downstream inherits that problem.

What Defender for Storage Malware Scanning actually does

Two sub-capabilities are worth separating:

  • OnUpload Malware Scanning — on every PutBlob / PutBlockList, the storage service hashes the blob, runs it through a Microsoft-maintained scan engine (the same one backing Defender for Endpoint), and tags the result.
  • Activity monitoring — unusual access patterns (anonymous from the internet, access from TOR exit nodes, sudden data egress) raise Defender alerts independent of the malware scan.

Key constraints worth internalizing before committing budget:

  • Per-account — enablement is per storage account, not per container.
  • File size cap50 GB per blob at current Microsoft Learn limits. The documented tag values are No threats found, Malicious, Error, and Not scanned — plus Scan timed out for blobs that exceed Defender’s 30 min–3 hr scan window. Alert on the non-No threats found states too, not just Malicious.
  • Supported services — on-upload scanning covers Blob storage and ADLS Gen2; Queues and Tables are not in scope. On-demand scanning is a separate feature that covers blobs and (in recent previews) Azure Files as well.
  • Result delivery channels — four options ship with the feature: blob index tags (default, what this lab uses), Defender for Cloud alerts, Event Grid events, and an opt-in StorageMalwareScanningResults Log Analytics table for a durable audit trail. Pick whichever matches your use case: tags for downstream gating, alerts for SOC workflow, Event Grid for real-time automation, the LA table for compliance/forensics.
  • Cost model — $0.15 per GB scanned, charged from the first byte (there is no free tier despite older previews hinting at one — verify against the current pricing page before you commit to an uncapped deployment). Plus the base Defender for Storage Standard plan at roughly $10/account/month prorated.
  • Result tag naming — the tag keys are literally "Malware Scanning scan result" and "Malware Scanning scan time UTC" — with spaces. Plan for this when writing the blob-index-tag query that gates downstream consumers.

Architecture

Architecture diagram: blob upload triggers the OnUpload malware scanner in Defender for Storage, which writes a scan-result blob tag and raises a Storage.Blob_AM.MalwareFound alert. The alert flows into the Sentinel workspace via both the AzureSecurityCenter data connector and a required Continuous Export automation, landing in the SecurityAlert table alongside StorageBlobLogs diagnostics, where five analytics rules correlate detection with exfil patterns.

Two things are worth calling out on this diagram. First, the Ingest row has two nodes for a reason: the Sentinel data connector by itself doesn’t move alerts into SecurityAlert — you also need a Continuous Export automation, which is the subject of the next section. Second, the Correlate row shows why this is worth standing up at all: the same StorageBlobLogs diagnostic stream that powers access logging is what lets you correlate a malware detection against the reads that followed it. That’s the difference between “we detected malware” and “we detected malware and three IPs pulled it before quarantine ran.”

Sentinel ingestion — the step the docs skip

This one cost me 45 minutes of staring at an empty SecurityAlert table. Every Microsoft Sentinel tutorial for Defender for Cloud alerts says “enable the data connector.” I did. The UI tile flipped green. Zero alerts arrived. Meanwhile, Microsoft.Security/alerts (the Defender for Cloud API) had the alert sitting right there.

Root cause in the tenant I tested: the AzureSecurityCenter data connector flipped its state in Sentinel’s UI but did not actually move alerts into SecurityAlert on its own. A Defender for Cloud Continuous Export automation pointed at the workspace was what finally populated the table. Microsoft’s own docs are a bit inconsistent on this — the Sentinel connector guide implies the connector ingests alerts into SecurityAlert, while the Defender for Cloud export docs describe Continuous Export as the mechanism that populates SecurityAlert and SecurityRecommendation. The safe guidance for real deployments, based on the behaviour I saw, is to enable both and verify with a fresh detection that rows actually appear. This is especially worth checking in tenants that also have the unified Defender XDR connector enabled, which changes the routing again.

You need both:

# 1. Sentinel data connector (required; surfaces the connector in the UI)
az rest --method PUT \
  --url "https://management.azure.com/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.OperationalInsights/workspaces/<ws>/providers/Microsoft.SecurityInsights/dataConnectors/defender-for-cloud?api-version=2023-02-01" \
  --body '{"kind":"AzureSecurityCenter","properties":{"subscriptionId":"<sub>","dataTypes":{"alerts":{"state":"Enabled"}}}}'

# 2. Continuous Export automation (the piece that actually moves data)
az rest --method PUT \
  --url "https://management.azure.com/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.Security/automations/defender-alerts-to-sentinel?api-version=2023-12-01-preview" \
  --body '{
    "location": "<region>",
    "properties": {
      "isEnabled": true,
      "scopes":  [{"scopePath": "/subscriptions/<sub>"}],
      "sources": [{"eventSource": "Alerts"}],
      "actions": [{"actionType": "Workspace", "workspaceResourceId": "<workspace-id>"}]
    }
  }'

Deploy both and new Defender alerts start landing in SecurityAlert within ~30 seconds, not 30 minutes. The automation is forward-only — it doesn’t backfill — so alerts that existed before you created it stay in the Defender API but never reach Log Analytics. If you’re backfilling, re-trigger the detection (for this lab, just upload another EICAR).

The companion repo’s deploy-lab.sh does both in a single step. This is easily the single most useful thing I learned building this lab, and it applies to every Defender for Cloud → Sentinel pipeline, not just Malware Scanning.

Deploy the lab

Everything is Bicep. The plan-level enablement is a subscription resource so the deploy script does it via az rest before the Bicep runs.

git clone https://github.com/j-dahl7/defender-storage-malware-sentinel.git
cd defender-storage-malware-sentinel/scripts
SUBSCRIPTION=<sub-id> LOCATION=eastus2 ./deploy-lab.sh

The script:

  1. Switches the subscription’s Defender for Storage plan from Free to Standard + DefenderForStorageV2, with the OnUploadMalwareScanning extension enabled.
  2. Creates storage-malware-lab-rg and deploys infra/main.bicep — a storage account, three containers (ingest / processed / quarantine), StorageBlobLogs diagnostics wired to the Sentinel workspace, and a per-account DefenderForStorageSettings resource that overrides the subscription plan for this specific account.
  3. Deploys infra/sentinel-rules.bicep — five scheduled analytics rules.
  4. Publishes the workbook.

The per-account override in Bicep looks like this:

resource malwareScanning 'Microsoft.Security/DefenderForStorageSettings@2022-12-01-preview' = {
  name: 'current'
  scope: storage
  properties: {
    isEnabled: true
    malwareScanning: {
      onUpload: { isEnabled: true, capGBPerMonth: 5 }
      scanResultsEventGridTopicResourceId: null
    }
    overrideSubscriptionLevelSettings: true
  }
}

capGBPerMonth is the cost cap. In a production account you want this bounded — a misconfigured pipeline that dumps a petabyte into ingest/ will otherwise hand you a five-figure scan bill.

Attack 1: EICAR baseline

EICAR is the industry-standard “safe malware” test string — every AV engine on earth flags it, and it doesn’t do anything. Perfect for a dev tenant.

export STORAGE_ACCOUNT=stmalwr<suffix>
./attacks/upload-eicar.sh

The script uploads two blobs: eicar-<timestamp>.com (the AV test pattern, base64-encoded to survive shell escaping) and readme-<timestamp>.txt (a harmless negative control).

Query the blob to confirm the scan result:

$ az storage blob tag list --account-name stmalwr53unacwptv5r \
    --container-name ingest --name "eicar-20260417T174035Z.com" \
    --auth-mode login
{
  "Malware Scanning scan result": "Malicious",
  "Malware Scanning scan time UTC": "2026-04-17 17:40:38Z"
}

$ az storage blob tag list --account-name stmalwr53unacwptv5r \
    --container-name ingest --name "readme-20260417T174035Z.txt" \
    --auth-mode login
{
  "Malware Scanning scan result": "No threats found",
  "Malware Scanning scan time UTC": "2026-04-17 17:40:38Z"
}

Both blobs scanned at the same second. The clean one got a benign verdict; EICAR got the malicious verdict.

Azure portal screenshot of the eicar-20260417T181415Z.com blob detail page in the ingest container of storage account stmalwr53unacwptv5r. The Blob index tags section at the bottom clearly shows two entries written by Defender for Storage: 'Malware Scanning scan result' = 'Malicious', and 'Malware Scanning scan time UTC' = '2026-04-17 18:14:17Z'. The blob is 68 bytes, content-type application/x-msdos-program, unlocked lease.
The blob index tags Defender writes when the verdict is ready — this is what a downstream consumer queries to decide whether to open the file.

The Defender alert payload

The alert itself carries more than just “something’s wrong”:

AlertType:    Storage.Blob_AM.MalwareFound
Display:      Malicious blob uploaded to storage account
Severity:     High
Entities:     azure-resource (the storage account)
              filehash
              file (blob name)
              malware (Virus:DOS/EICAR_Test_File)
              blob-container (ingest)
              blob (blob name again)

Entity-rich. The malware entity carries the threat-intel family name (Virus:DOS/EICAR_Test_File in this case); for real malware it’ll be the Microsoft Defender family name and so is usable for correlation against Defender for Endpoint detections elsewhere in your estate.

Attack 2: anonymous access probe

The account is deployed with allowBlobPublicAccess=false, which ought to make anonymous reads impossible. Worth verifying — attackers poke storage accounts all day looking for the one that was misconfigured.

./attacks/simulate-anon.sh
Issuing anonymous requests against stmalwr53unacwptv5r/ingest
  [409] https://stmalwr53unacwptv5r.blob.core.windows.net/ingest?restype=container&comp=list
  [409] https://stmalwr53unacwptv5r.blob.core.windows.net/ingest/readme.txt
  [409] https://stmalwr53unacwptv5r.blob.core.windows.net/ingest/config.json
  [409] https://stmalwr53unacwptv5r.blob.core.windows.net/ingest/.env
  [409] https://stmalwr53unacwptv5r.blob.core.windows.net/ingest/backup.sql

Worth noting: the responses are 409 PublicAccessNotPermitted, not 403 AuthenticationFailed. That matters for KQL filtering — if you’re writing a rule on “403 storms”, you’ll miss the cleanest anonymous-probe signal. Rule 3 below looks for AuthenticationType == "Anonymous" rows in StorageBlobLogs instead of keying off HTTP status.

Sentinel analytics rules

Five rules, all using the union isfuzzy=true fallback pattern so they validate against an empty table before real data arrives. Full Bicep is in infra/sentinel-rules.bicep.

Rule 1 — Malicious file uploaded to blob storage

union isfuzzy=true
  (datatable(TimeGenerated:datetime, AlertName:string, AlertSeverity:string, ProductName:string, Entities:string, AlertLink:string, CompromisedEntity:string)[]),
  (SecurityAlert
    | where ProductName =~ "Microsoft Defender for Cloud"
    | where AlertType startswith "Storage.Blob_AM"
  )
| project TimeGenerated, AlertName, AlertSeverity, AlertType, CompromisedEntity, Entities, AlertLink

Note the filter is startswith "Storage.Blob_AM" — the actual alert type emitted by the service is Storage.Blob_AM.MalwareFound, not Storage.Blob_MalwareUploaded or any of the other plausible guesses. I initially wrote the rule against the latter, deployed it, uploaded EICAR, and got crickets. Always check the raw alert before finalizing your rule filters.

Severity: High. Creates an incident. Grouping (matchingMethod: Selected, groupByAlertDetails: [DisplayName]) collapses every Rule 1 alert into a single open incident — I verified this live against SecurityIncident: 12 Rule 1 firings across two distinct EICAR uploads all rolled into a single open incident. That’s the intended behaviour, but worth knowing: if you need per-file incidents, switch the grouping method to AllEntities and include the blob entity, like Rule 2 does below.

Rule 2 — Post-detection blob read on infected file

This is the rule that turns a single malware alert into an incident with actual blast-radius information:

let storageAccount = "__STORAGE__";
let alerts =
  union isfuzzy=true
    (datatable(TimeGenerated:datetime, BlobUrl:string, AlertName:string)[]),
    (SecurityAlert
      | where ProductName =~ "Microsoft Defender for Cloud"
      | where AlertType startswith "Storage.Blob_AM"
      | extend ent = parse_json(Entities)
      | mv-expand ent
      | where tostring(ent.Type) == "blob"       // not "file" — the file entity has an empty Url
      | extend BlobUrl = tostring(ent.Url)
      | project TimeGenerated, BlobUrl, AlertName
    );
alerts
| join kind=inner (
    StorageBlobLogs
    | where AccountName =~ storageAccount
    | where OperationName == "GetBlob"
    | where StatusText in ("Success", "SuccessWithThrottling")
    | extend BlobUrl = replace_string(tostring(Uri), ":443", "")  // logs include :443; the alert Url does not
    | project ReadTime=TimeGenerated, BlobUrl, CallerIpAddress, UserAgentHeader, StatusText
) on BlobUrl
| where ReadTime > TimeGenerated
| summarize Reads=count(), Callers=make_set(CallerIpAddress, 25), UserAgents=make_set(UserAgentHeader, 10)
    by BlobUrl, AlertName, bin(TimeGenerated, 5m)

Two traps in this rule that the preview version of this post walked straight into:

  • The file entity has an empty Url on Storage.Blob_AM.MalwareFound. The blob name lives there, but the actual URL is on the separate blob entity — so you have to mv-expand and filter on Type == "blob", not "file", or the join evaluates to zero rows.
  • StorageBlobLogs.Uri includes :443 (e.g. https://foo.blob.core.windows.net:443/ingest/file.ext), but the blob entity’s Url does not. Without replace_string(Uri, ':443', ''), the string equality in the join never matches.

The logic: parse the Entities JSON out of each malware alert, pull the blob URL from the blob entity, join against StorageBlobLogs GetBlob events on the normalized URL, and keep only reads that happened after the alert fired. Any row that comes out is an adversary retrieval after detection.

Severity: High. Creates an incident. This rule uses matchingMethod: AllEntities so each distinct combination of blob-url + caller-set produces its own incident, rather than collapsing under the display name the way Rule 1 does.

Rule 3 — Anonymous access attempt

let storageAccount = "__STORAGE__";
StorageBlobLogs
| where AccountName =~ storageAccount
| where AuthenticationType == "Anonymous"
| summarize Attempts=count(), Operations=make_set(OperationName, 10),
            Blobs=make_set(Uri, 25)
    by CallerIpAddress, bin(TimeGenerated, 5m)
| where Attempts > 0

AuthenticationType == "Anonymous" catches probe traffic whether the response was 409 (public access disabled) or 200 (somebody accidentally flipped allowBlobPublicAccess). Either way, Sentinel gets to see the caller IP and the paths being guessed — useful for threat intel on what the scanners are probing for.

Rule 4 — Geo-anomalous caller

Baseline the last 7 days of caller IPs against the storage account. Anything new shows up as a hit:

let storageAccount = "__STORAGE__";
let baseline =
  StorageBlobLogs
  | where AccountName =~ storageAccount
  | where TimeGenerated between (ago(7d) .. ago(1h))
  | extend IP = tostring(split(CallerIpAddress, ":")[0])
  | summarize by IP;
StorageBlobLogs
| where AccountName =~ storageAccount
| where TimeGenerated > ago(1h)
| extend IP = tostring(split(CallerIpAddress, ":")[0])
| where isnotempty(IP) and IP !in (baseline)
| summarize Ops=count(), Operations=make_set(OperationName, 10), Blobs=make_set(Uri, 25)
    by CallerIpAddress=IP

Two implementation notes:

  1. CallerIpAddress includes source port — e.g., 10.0.0.1:54321. You must split on : to aggregate by IP.
  2. Baselines cost query time — I run this one at queryFrequency: PT1H, queryPeriod: P7D. Running it faster than hourly is throwing money at the Log Analytics query engine for no added signal.

This rule is deliberately deployed as “notification only” (no incident) — the false-positive rate on net-new IPs is high, but it pairs well with Rule 1 as incident-enrichment context in Sentinel’s investigation view.

Rule 5 — Bulk download after auth failures

The credential-spray-to-exfil pattern:

let storageAccount = "__STORAGE__";
let fails =
  StorageBlobLogs
  | where AccountName =~ storageAccount
  | where TimeGenerated > ago(15m)
  // Authorization* catches AuthorizationError, AuthorizationFailure,
  // AuthorizationPermissionMismatch — the three real shapes Azure Storage
  // produces for an authenticated-but-unauthorized call.
  | where StatusText startswith "Authorization" or StatusText in ("AuthenticationFailed", "Forbidden")
  | extend IP = tostring(split(CallerIpAddress, ":")[0])
  | summarize FailCount=count(), FailLast=max(TimeGenerated) by IP
  | where FailCount >= 5;
fails
| join kind=inner (
    StorageBlobLogs
    | where AccountName =~ storageAccount
    | where OperationName == "GetBlob"
    | where StatusText in ("Success", "SuccessWithThrottling")
    | extend IP = tostring(split(CallerIpAddress, ":")[0])
    | summarize Reads=count(), ReadFirst=min(TimeGenerated) by IP
    | where Reads >= 10
) on IP
| where ReadFirst between (FailLast .. FailLast + 10m)
| project IP, Reads, FailCount, FailLast, ReadFirst

Five or more authentication failures from the same IP, followed by ten or more successful GetBlobs from that same IP within 10 minutes, is a credential-guessing-then-exfil pattern. Two things you need to get right, because the preview version of this post got both wrong:

  • The real 403 status text is AuthorizationPermissionMismatch (or sometimes AuthorizationError) — not AuthorizationFailure or Forbidden, which is what the REST error-code docs suggest. startswith "Authorization" catches all three shapes in one predicate.
  • Thresholds are calibrated for a lab. A busy infrastructure scanner will trip FailCount >= 5 alone, so tune both thresholds up for production and seriously consider requiring the successful reads to be on distinct blob paths before declaring exfil.

Workbook

The workbook (infra/workbook.json) surfaces seven panels:

  • KPI tiles — detections today, high-severity count, unique accounts flagged, unique files detected.
  • TimelineStorage.Blob_AM.* alerts over time, split by severity.
  • Recent malware detections — table with timestamp, alert name, blob URI (from the blob entity, so the URL actually resolves), compromised entity, alert link.
  • Top caller IPs (last 24h) — per-IP totals for ops, reads, writes, and failures (using the Authorization* filter pattern so it actually catches real 403s).
  • Anonymous access attempts chart — hourly column chart of anonymous-auth rows.
  • Caller IP map — public caller IPs plotted geographically, heat-mapped by failure count, so private heartbeats don’t dominate the view.
  • Unremediated malicious blobs — a join that surfaces every Storage.Blob_AM.MalwareFound detection whose blob is still in a non-quarantine container. This is the actionable view: each row is a file an analyst or playbook still needs to move or delete.

For an SOC analyst triaging a malware alert, panels 3, 4, and 7 together give you “which file, who touched it, and is it still where the adversary can reach it?” on one screen.

Gotchas worth writing down

Things the docs don’t spell out that bit me during the build:

(The single most useful finding — that the Sentinel data connector alone doesn’t ingest alerts, and a Continuous Export automation is required — is called out separately above in Sentinel ingestion — the step the docs skip.)

  1. Plan enablement extensions array is strict. The valid extension names are exactly OnUploadMalwareScanning and SensitiveDataDiscovery. My first PUT included {"name": "Blobs"} (a guess based on old plan names) and got back Error converting value "Blobs" to type ...PricingExtensionNames.
  2. additionalExtensionProperties: {"CapGBPerMonth": "..."} on the subscription-level extension returns Additional property 'CapGBPerMonth' is not supported. The cap lives on the per-account DefenderForStorageSettings resource (capGBPerMonth property), not on the plan extension. Moving it fixed the deploy.
  3. Alert type namingStorage.Blob_AM.MalwareFound, not Storage.Blob_MalwareUploaded / Storage.Blob_MalwareDetected / any other plausible guess. Pin your filter to the actual string the service emits, or use AlertType contains "Malware".
  4. queryPeriod ISO format — Sentinel rejects PT7D. The correct ISO 8601 for “7 days” is P7D (the T is for time components only). The 7-day baseline rule would not deploy until I fixed this.
  5. queryPeriod >= 2d requires queryFrequency >= PT1H. Sentinel enforces this as a hard validation rule. You cannot have a 7-day baseline running every 30 minutes.
  6. Blob index tag reads require Storage Blob Data Owner (or the Microsoft.Storage/storageAccounts/blobServices/containers/blobs/tags/read action). Neither Contributor nor Storage Blob Data Contributor is enough — the tag data-plane permissions are separate.
  7. StorageBlobLogs.CallerIpAddress includes source port (e.g., 10.0.0.1:54321). Always split(CallerIpAddress, ":")[0] before aggregating by IP.
  8. Anonymous probe responses are 409 PublicAccessNotPermitted, not 403. Write rule filters against AuthenticationType == "Anonymous" rather than HTTP status codes.
  9. A bad SAS token logs as StatusText: AuthorizationError, not the AuthenticationFailed / AuthorizationFailure / Forbidden you might expect from reading the REST error-codes page. Rule 5’s has_any() filter must include AuthorizationError or the credential-spray-to-exfil correlation never fires. Also: SAS requests with completely garbage signatures don’t appear to land in StorageBlobLogs at all — the front door drops them before the operation is recognized. To exercise Rule 5 reliably, use a principal with authenticated access that lacks the specific data-plane action you’re testing.
  10. Storage.Blob_AM.MalwareFound alerts include two entities for the blob: file (with Name, empty Url) and blob (with both Name and full Url). Rule 2’s join key must use the blob entity. If you grab the file entity you get an empty URL and the join produces zero rows.
  11. StorageBlobLogs.Uri includes the :443 port (e.g., https://foo.blob.core.windows.net:443/ingest/file.ext), but the blob entity’s Url in SecurityAlert does not. replace_string(Uri, ':443', '') normalises the two for joins.

What this closes

  • Phishing staging — the malicious artifact is tagged Malicious before the phish arrives, and the tag is queryable by downstream consumers.
  • Supply chain drop — a dropper placed in a CI-readable container is flagged before the build runner pulls it. Pair with a data-plane ABAC role assignment that only grants read when the blob’s Malware Scanning scan result index tag equals No threats found. (Azure Policy is the wrong tool for this — it enforces management-plane control, but blob reads are a data-plane operation and need data-plane enforcement.) For workflows you can’t gate at read time, Defender’s built-in soft-delete quarantine for malicious blobs is a reasonable compensating control.
  • Anonymous misconfig — Rule 3 catches the probe traffic even when the account is correctly locked down, so you learn who’s looking.
  • Exfil after detection — Rule 2 upgrades the raw alert into an incident only when the blob was actually retrieved post-detection.

What it doesn’t close

  • Files > 50 GB — exceed the scanner’s size cap. Write a blob-lifecycle policy that either chunks large uploads into scan-able sizes or gates downstream consumers on "Malware Scanning scan result" == "No threats found" (rather than != "Malicious") so Not scanned, Error, and Scan timed out verdicts don’t silently pass through.
  • Queues, Tables, Files — out of scope for Defender for Storage Malware Scanning. Queue payloads in particular are a common overlooked surface.
  • Time-of-check / time-of-use — the scan runs on write. A blob that was clean yesterday but whose contents were overwritten with malware today is re-scanned on the new write, but a blob that was never rescanned after a scan-engine-definition update has a stale verdict. Microsoft rescans periodically but the cadence isn’t documented per-blob.
  • Encrypted payloads — a zip wrapped in an attacker-held key. Defender still tags the archive (often as Malicious based on YARA rules over the archive container), but individual encrypted members are opaque.
  • Model artifacts with embedded code — pickles, TorchScript, etc. Those need Defender for AI Services, which I covered separately.

Validation — what I saw end-to-end

Microsoft Defender XDR Incidents page filtered to 'Storage Malware'. The Incidents counter reads 8 Incidents. The list shows eight rows, each prefixed 'Storage Malware -': two 'Anonymous access attempt on lab account' incidents (IDs 116 and 106, Medium, Discovery category, each with 6/6 active alerts), one 'Malicious file uploaded to blob storage' incident (ID 109, High, Execution category, 12/12 active alerts), four 'Post-detection blob read on infected file' incidents (IDs 112, 113, 114, 115, High, Execution category, each 1/1 active alerts), and one 'Bulk download after auth failures from same caller' incident (ID 117, High, Collection category, 1/1 active alerts). Service source for every row is Microsoft Sentinel.
Defender XDR incidents page filtered to Storage Malware. Eight rows, one per incident: 1 from Rule 1 (12/12 alerts, DisplayName-grouped into one incident), 4 from Rule 2 (AllEntities-grouped, one per blob-caller combo), 2 from Rule 3 (DisplayName-grouped, two buckets), 0 from Rule 4 (notification-only), 1 from Rule 5. Alert totals add to 31 across 8 incidents — the exact grouping ratios the Validation table below claims.

Fire counts queried live from the Sentinel workspace on 2026-04-17 after roughly twelve hours of attack traffic (two EICAR uploads with the negative-control readme, two runs of the anonymous probe, a fifteen-blob download burst against the flagged file, and an AuthorizationPermissionMismatch storm from an under-privileged service principal followed by another successful-read burst from the same source IP):

RuleFiresDepends on
Rule 1 — Malicious file uploaded12SecurityAlert + Continuous Export
Rule 2 — Post-detection blob read4Rule 1’s alert + StorageBlobLogs.GetBlob
Rule 3 — Anonymous access attempt12StorageBlobLogs.AuthenticationType=Anonymous
Rule 4 — Geo-anomalous caller3StorageBlobLogs + 7-day IP baseline
Rule 5 — Bulk download after auth failures1StorageBlobLogs auth-fail → read pattern

All five fired end-to-end. Rule 5 was the hardest to exercise: a handful of garbage SAS tokens or stray curls won’t match it, because Azure Storage doesn’t log completely-unauthenticated requests (those die at the front door, before StorageBlobLogs). Authentication must succeed and authorization must fail — which meant standing up a dedicated service principal with Reader on the subscription and no blob data-plane role, authenticating it via OAuth2 client credentials, and having it try GetBlob. Each request logs as AuthorizationPermissionMismatch, and then fifteen successful reads from the same caller IP within ten minutes makes the correlation match.

A few things jumped out running this:

  • Two EICAR uploads, twelve distinct Rule 1 firings, one open incident. The rule polls every 5 minutes with a 1-hour lookback, so the same detection re-fires as long as it’s inside the lookback window. Grouping (matchingMethod: Selected, groupByAlertDetails: [DisplayName]) rolls every one of those firings into the same incident — not one per file, but one per rule — because the display name is constant across firings. I verified this against live SecurityIncident: all 12 Rule 1 alerts rolled into a single incident. That’s the right behaviour for reducing analyst fatigue; if you want per-file incidents, switch to AllEntities grouping with the blob entity.
  • Rule 2’s first firing lagged the malware alert by ~6 minutes, driven entirely by StorageBlobLogs ingestion (the reads have to land before the join resolves). That’s roughly the minimum time-to-incident on an active exfil pattern using the scheduled-rule model. For a faster gate, lean on blob-index-tag checks at the consumer side instead of Sentinel alone.
  • Rule 4 baseline quirk: on day zero the baseline (StorageBlobLogs | where TimeGenerated between (ago(7d)..ago(1h))) is mostly empty, so the rule fires on every new IP. Expect noise in the first week and tighten as the baseline fills out.
  • Rule 5 took a full authenticated-but-unauthorized principal to exercise — a real mis-configured pipeline looks like this. Bad SAS tokens with garbage signatures don’t cut it, because those are rejected before they reach the logging layer.
  1. Enable the plan subscription-wide (DefenderForStorageV2 with OnUploadMalwareScanning). Per-account opt-out is cleaner than per-account opt-in for compliance reporting.
  2. Set a realistic capGBPerMonth per account. A low-throughput service account is fine at 50 GB/mo; a data-lake account might need 10 TB/mo. Either way, don’t run uncapped.
  3. Publish both the Defender for Cloud → Sentinel pieces at the time you enable the plan: the AzureSecurityCenter data connector and a Microsoft.Security/automations Continuous Export targeting the workspace. The connector without the automation produces a green UI tile and an empty SecurityAlert table — see Sentinel ingestion — the step the docs skip. New alerts start landing within ~30 seconds of both being in place; the automation is forward-only, so re-trigger any detections you want backfilled.
  4. Deploy the five rules and the workbook against your Sentinel workspace. Start with Rule 1 creating incidents and Rules 2–5 in “enabled, no incident” mode for a soak period so you can understand what your noise floor looks like.
  5. Write a blob-lifecycle or consumer-side gate that refuses to process blobs where "Malware Scanning scan result" == "Malicious". The detection is worthless if downstream still opens the file.
  6. Route Defender alerts to your SOC incident queue, with the tag-read query baked into the playbook so analysts can confirm the scan result independently from the portal view.

Further reading


Scan-on-upload is the control that finally puts storage accounts on the same footing as email and endpoints. The part that makes it useful for a real SOC isn’t the detection — it’s the telemetry that lands in Log Analytics at the same time, so “malware was uploaded” can become “malware was uploaded and exfiltrated to this caller in the same Sentinel incident.”

Jerrad Dahlager

Jerrad Dahlager, CISSP, CCSP

Cloud Security Architect · Adjunct Instructor

Marine Corps veteran and firm believer that the best security survives contact with reality.

Have thoughts on this post? I'd love to hear from you.