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 for eligible block blobs uploaded through supported Blob Storage and ADLS Gen2 paths. The scan is asynchronous, so an uploaded blob remains readable while its verdict is pending. On supported non-HNS Blob accounts, a data-plane ABAC condition can deny access to blobs without a No threats found tag. After the verdict, Defender can write an index tag and raise a Defender for Cloud alert. Microsoft’s optional soft-delete action for malicious blobs is post-verdict remediation; it does not hold every unscanned blob in quarantine. 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 can be over an order of magnitude faster. In this lab, the 30-minute gap between the alert being raised and it showing up in Sentinel was caused by the missing export path described below β€” worth checking early in your own setup. See Sentinel ingestion β€” the step worth verifying below.

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

Evidence boundary: The current hardened companion revision was reviewed with offline static and contract checks; it was not freshly deployed to Azure, no blob was uploaded, and no live Sentinel query was run for that revision. Measurements, portal captures, alerts, and incidents below are retained from the April 2026 validation of an earlier revision. They do not prove current deployment or promise the same timing, routing, or results in another tenant.

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, and mails the URL to employees. Email, link, browser, or endpoint controls may still inspect or block the URL or download, but they do not give the storage account an at-rest malware verdict while the blob waits for retrieval.
  • 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 β€” eligible block blobs written through supported PutBlob / PutBlockList paths are hashed, scanned by Microsoft Defender Antivirus technology, and tagged with the result. Append blobs, page blobs, client-side encrypted data, NFS 3.0 uploads, and other documented exclusions are not covered.
  • 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 cap β€” 50 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 eligible blobs in Blob Storage and ADLS Gen2; Queues, Tables, and Azure Files uploads are not in scope. On-demand scanning is a separate generally available feature for blobs and Azure Files.
  • 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 β€” malware scanning is usage-billed in addition to the Defender for Storage plan. Rates and included allowances change; verify the target region and subscription against the current pricing page before enabling an uncapped deployment.
  • 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 the AzureSecurityCenter data connector and, in this lab, a Continuous Export automation that populated 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: in the tenant I tested, the Sentinel data connector by itself didn’t move alerts into SecurityAlert β€” enabling Continuous Export as well was what actually populated the table. Microsoft’s own docs are inconsistent on whether the connector alone is enough (see the next section), so validate your own tenant path with a fresh detection, especially if you also have the unified Defender XDR connector in play. 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 remediation.”

Sentinel ingestion β€” the step worth verifying

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 behavior 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.

Lab configuration I validated:

# 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 (what populated SecurityAlert in this lab)
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>"}]
    }
  }'

In this lab, deploying both made new Defender alerts start landing in SecurityAlert within ~30 seconds rather than not at all. 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 was easily the single most useful thing I learned building this lab, and it is worth validating anywhere you rely on Defender for Cloud alerts in Sentinel.

Deploy the lab

The deployment combines Bicep with a guarded Bash/Python lifecycle and ARM REST calls. The wrapper captures shared state and ownership before it changes subscription pricing, creates the Sentinel connector and continuous export, deploys the templates and rules, and publishes the workbook.

git clone https://github.com/j-dahl7/defender-storage-malware-sentinel.git
cd defender-storage-malware-sentinel
git checkout fb6c876f2eafb0820df3909721dde8affac858a3

export SUBSCRIPTION="<sub-id>"
export WORKSPACE_RG="<sentinel-resource-group>"
export WORKSPACE_NAME="<sentinel-workspace-name>"
export LOCATION="eastus2"

az account show --query '{subscription:name,id:id}' -o table
az security pricing show --name StorageAccounts -o json

# Set only when the pricing check shows that a shared-plan change is required.
export CONFIRM_SUBSCRIPTION_SCOPE="ENABLE-DEFENDER-FOR-STORAGE"
./scripts/deploy-lab.sh

The script:

  1. Reuses matching subscription pricing or, after the exact confirmation above, changes it to Standard + DefenderForStorageV2 with OnUploadMalwareScanning enabled and records the prior state for cleanup.
  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 β€” most AV engines flag 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 stmalwareexample \
    --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 stmalwareexample \
    --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 blob detail page in the ingest container of a lab storage account. 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.
Historical April validation capture of the index tags Defender wrote after the EICAR verdict. It illustrates the storage-side field a downstream consumer can query; it is not current-revision deployment evidence.

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 stmalwareexample/ingest
  [409] https://stmalwareexample.blob.core.windows.net/ingest?restype=container&comp=list
  [409] https://stmalwareexample.blob.core.windows.net/ingest/readme.txt
  [409] https://stmalwareexample.blob.core.windows.net/ingest/config.json
  [409] https://stmalwareexample.blob.core.windows.net/ingest/.env
  [409] https://stmalwareexample.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. Each one unions its primary table read with an empty datatable of the same shape under union isfuzzy=true, the pattern that lets a rule deploy into a workspace before SecurityAlert or StorageBlobLogs has been created. The blocks below are the detection logic: Rules 1 and 2 keep the wrapper because it shapes the query, Rules 3, 4 and 5 are printed without it. Take the text you deploy from infra/sentinel-rules.bicep rather than from this post.

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 behavior, 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 a post-detection retrieval to investigate.

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 baselineWindow = 7d;
let NormalizeCallerIp = (caller:string) {
  case(
    caller matches regex @"^\[[0-9A-Fa-f:.]+\](:[0-9]+)?$", extract(@"^\[([0-9A-Fa-f:.]+)\](:[0-9]+)?$", 1, caller),
    caller matches regex @"^([0-9]{1,3}[.]){3}[0-9]{1,3}:[0-9]+$", extract(@"^([^:]+):[0-9]+$", 1, caller),
    caller
  )
};
let baseline =
  StorageBlobLogs
  | where AccountName =~ storageAccount
  | where TimeGenerated between (ago(baselineWindow) .. ago(1h))
  | extend IP = NormalizeCallerIp(tostring(CallerIpAddress))
  | where isnotempty(IP)
  | summarize by IP;
union isfuzzy=true
  (datatable(TimeGenerated:datetime, AccountName:string, CallerIpAddress:string, OperationName:string, Uri:string, StatusText:string)[]),
  (StorageBlobLogs
    | where AccountName =~ storageAccount
    | where TimeGenerated > ago(1h)
    | extend IP = NormalizeCallerIp(tostring(CallerIpAddress))
    | where isnotempty(IP)
    | where IP !in (baseline)
    | project TimeGenerated, AccountName, CallerIpAddress=IP, OperationName, Uri, StatusText
  )
| summarize Ops=count(), Operations=make_set(OperationName, 10), Blobs=make_set(Uri, 25), First=min(TimeGenerated), Last=max(TimeGenerated) by CallerIpAddress
| where Ops > 0

Two implementation notes:

  1. CallerIpAddress can include a source port β€” but splitting on every colon corrupts raw IPv6. The shared normalizer preserves unbracketed IPv6, unwraps bracketed IPv6 with or without a port, and removes a port only from unambiguous dotted IPv4.
  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 failWindow = 15m;
let readWindow = 10m;
let NormalizeCallerIp = (caller:string) {
  case(
    caller matches regex @"^\[[0-9A-Fa-f:.]+\](:[0-9]+)?$", extract(@"^\[([0-9A-Fa-f:.]+)\](:[0-9]+)?$", 1, caller),
    caller matches regex @"^([0-9]{1,3}[.]){3}[0-9]{1,3}:[0-9]+$", extract(@"^([^:]+):[0-9]+$", 1, caller),
    caller
  )
};
let fails =
  StorageBlobLogs
  | where AccountName =~ storageAccount
  | where TimeGenerated > ago(failWindow)
  | where StatusText startswith "Authorization" or StatusText in ("AuthenticationFailed", "Forbidden")
  | extend IP = NormalizeCallerIp(tostring(CallerIpAddress))
  | where isnotempty(IP)
  | summarize FailCount=count(), FailFirst=min(TimeGenerated), FailLast=max(TimeGenerated) by IP
  | where FailCount >= 5;
union isfuzzy=true
  (datatable(IP:string, Reads:int, FailCount:int, FailLast:datetime, ReadFirst:datetime)[]),
  (fails
    | join kind=inner (
        StorageBlobLogs
        | where AccountName =~ storageAccount
        | where OperationName == "GetBlob"
        | where StatusText in ("Success", "SuccessWithThrottling")
        | extend IP = NormalizeCallerIp(tostring(CallerIpAddress))
        | where isnotempty(IP)
        | project IP, ReadTime = TimeGenerated
    ) on IP
    // Count only reads that follow the failure burst. Taking min() across every
    // read by this IP let a single earlier benign read move ReadFirst outside
    // the window and suppress the alert entirely.
    | where ReadTime between (FailLast .. FailLast + readWindow)
    | summarize Reads = count(), ReadFirst = min(ReadTime) by IP, FailCount, FailLast
    | where Reads >= 10
    | 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.

Manual validation boundary: the public attack scripts do not automate Rule 5 or create the under-privileged identity it needs. Completely unauthenticated garbage can die before StorageBlobLogs, so a few malformed SAS strings are not a reliable test. Use a disposable authenticated principal that lacks the tested blob data-plane action, generate the authorization failures, then perform successful reads from the same controlled egress IP. The historical live incident below used that method; it is evidence of the rule, not a claim that one public helper command deterministically reproduces it.

Workbook

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

  • KPI tiles β€” detections today, high-severity count, unique accounts flagged, unique files detected.
  • Timeline β€” Storage.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 lab finding β€” that in this tenant the Sentinel data connector alone did not populate SecurityAlert, while adding Continuous Export did β€” is called out separately above in Sentinel ingestion β€” the step worth verifying. Treat that as something to validate in your own Defender for Cloud β†’ Sentinel path, especially if Defender XDR integration is enabled.)

  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 naming β€” Storage.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 can include a source port. Strip it only from unambiguous dotted IPv4 or bracketed IPv6. Preserve raw IPv6 as a complete address; splitting on its first colon collapses unrelated callers into the same fragment.
  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', '') normalizes the two for joins.

What this closes

  • Phishing staging β€” eligible artifacts receive a verdict that downstream consumers can query before distribution; use ABAC when access must fail closed while a verdict is absent or unsafe.
  • Supply chain drop β€” on supported non-HNS Blob accounts, pair scanning with a data-plane ABAC role assignment that grants reads only 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 HNS/ADLS Gen2 or workflows you cannot gate at read time, use an event-driven consumer/quarantine control; Defender’s built-in soft-delete action for malicious blobs is post-verdict remediation.
  • 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 and Tables β€” out of scope for Defender for Storage Malware Scanning. Queue payloads in particular are a common overlooked surface.
  • Azure Files (on-upload) β€” on-upload scanning is not supported for Azure Files. On-demand scanning for Files became generally available in May 2026, so existing shares can be scanned manually or on a schedule, but uploads are not protected inline.
  • 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. A blob that was never re-uploaded after a scan-engine-definition update keeps its original verdict; if you need coverage against engine updates, trigger an on-demand scan over the container rather than assuming Defender rescans automatically.
  • Encrypted/protected payloads β€” client-side encrypted blobs can’t be scanned, and password-protected archives return a Scan failed - blob is protected by password verdict. Both cases leave the content opaque to the scanner, so treat the non-No threats found tag as the exception rather than trusting the verdict.
  • Model artifacts with embedded code β€” pickles, TorchScript, etc. Those need AI-specific controls and alerting; see the Agent 365 Defender playbook for related Defender for AI Services coverage.

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.
Historical April validation capture of the Defender XDR incidents page filtered to Storage Malware. That earlier run produced eight rows: 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), and 1 from Rule 5. The 31-alert total and grouping ratios describe that run, not the current revision or another tenant.

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 behavior 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. Wire Defender alerts into Sentinel and validate with a fresh detection. Enable the AzureSecurityCenter data connector on the workspace and a Microsoft.Security/automations Continuous Export targeting the same workspace. In my lab, the connector alone showed green but produced zero rows in SecurityAlert until Continuous Export was in place; Microsoft’s own docs are inconsistent about which component is load-bearing (see Sentinel ingestion β€” the step worth verifying). Trigger a new detection, confirm that it lands in SecurityAlert, and record the end-to-end latency in your environment; timing and table availability vary by tenant, region, connector, and ingestion path. Continuous Export is forward-only, so re-trigger anything you want backfilled. This is doubly worth testing if your tenant also has the unified Defender XDR connector enabled, which changes the routing.
  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 only allows "Malware Scanning scan result" == "No threats found" β€” don’t just block Malicious. Defender also produces Not scanned, Error, Scan timed out, and Scan failed - blob is protected by password verdicts, plus cases where a blob might not carry a scan-result tag at all. A deny-Malicious gate lets all of those fall through silently. Prefer data-plane ABAC conditions over management-plane Azure Policy for supported non-HNS Blob accounts. Blob-index-tag conditions are not supported on hierarchical-namespace / ADLS Gen2 accounts; use an Event Grid or Log Analytics verdict workflow and a separate consumer/quarantine gate there.
  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.