The MSP License Ladder #1: The Hunting Gap
A customer calls you on Monday morning. Defender flagged malware on a laptop over the weekend and cleaned it up. The alert is closed. Except the real questions are just starting. Was a new local admin account added to that device in the last week? Did a process quietly elevate to SYSTEM? Has that account signed in anywhere else in the fleet?
On Microsoft 365 Business Premium and Microsoft 365 E3, you cannot answer those questions across the fleet. That is the shift this post is about: reactive security (an alert fires, you respond) versus proactive security (you hunt for the setup steps before the detonation). Microsoft Defender for Endpoint Plan 2 is the rung that flips that switch.
A few terms up front:
- IoC (Indicator of Compromise): a file hash, IP, domain, or process pattern tied to known malicious activity. When a vendor or CERT publishes a breach, they usually publish IoCs so you can sweep your own estate.
- KQL (Kusto Query Language): the query language the Defender portal and Advanced Hunting use.
- EDR (Endpoint Detection and Response): tooling that records endpoint activity and lets you query it afterwards, not just block the bad stuff.
- NGAV (Next-Generation Antivirus): behaviour-based AV. The “block it” layer.
Table of Contents
- Reactive by default
- The proactive gap
- The upgrade paths
- What Defender for Endpoint Plan 2 gives you
- Demo: multi-tenant local admin hunt
- When not to bother
- How to pitch it
- Conclusion
Reactive by default
Business Premium ships with Defender for Business. NGAV, Attack Surface Reduction rules, basic EDR with alerts and automated investigation, device isolation, web content filtering, 30-day retention. Solid at the block-and-alert layer.
Microsoft 365 E3 ships with Defender for Endpoint Plan 1. NGAV and Attack Surface Reduction only. No hunting surface at all. Without an add-on, Microsoft 365 E3 is a weaker endpoint posture than Business Premium.
Both are reactive. An alert fires, you respond. Neither lets you ask your fleet a question the product did not think to ask on your behalf.
The proactive gap
Three scenarios that come up on real customer calls and leave you reaching for something you do not have.
Post-incident reconstruction
Defender caught the malware. Now you want to rebuild the story. Was a local admin added before detonation? Did a process elevate? On Defender for Business you can stare at the per-device timeline, but you cannot run a single KQL query that joins admin additions with logons with process events across every device.
Retrospective IoC sweep
A partner drops an IoC list. You want to know if any device in any customer tenant has ever seen those hashes, domains, or IPs. On Defender for Business you can search alerts. You cannot run a cross-fleet KQL query, and that is what an IoC sweep actually is.
The custom detection you want to keep running
You notice a subtle pattern unique to one of your customers’ stacks. You want a rule that alerts the moment it reappears, anywhere. Defender for Business has no Custom Detection Rules. You are stuck with Microsoft’s built-in detections plus Attack Surface Reduction.
Every one of those is a proactive question. Base SKUs answer the reactive ones and leave the proactive ones on the floor.
The upgrade paths
| Path | Best for | Includes | List price | Main constraint |
|---|---|---|---|---|
| Defender Suite for Business Premium | Business Premium customers under 300 users | Defender for Endpoint Plan 2, Defender for Office 365 Plan 2, Defender for Identity, Defender for Cloud Apps, Entra ID P2 | $10 | 300-user cap |
| Defender for Endpoint Plan 2 standalone | Customers who only need endpoint | Defender for Endpoint Plan 2 only | $5.20 | Leaves identity, email, SaaS gaps open |
| Microsoft 365 E5 Security add-on | Microsoft 365 E3 customers | Everything in Defender Suite plus Defender for IoT | $12 | Larger upfront spend |
Prices are Microsoft list per user per month, as of April 2026. CSP, NCE, and Enterprise Agreement pricing differs; check with your licensing partner for the real customer number. Defender Suite for Business Premium is the value pick at roughly 65% cheaper than buying its components individually. Combined with Purview Suite for Business Premium (also $10), the bundle drops to $15/user/month for both.
One caveat. Defender for Business and Defender for Endpoint Plan 2 cannot coexist in the same tenant. If both licence types are present, the tenant defaults to Defender for Business. To actually get Defender for Endpoint Plan 2, every user needs a Defender for Endpoint Plan 2 licence and you must open a Microsoft Support ticket to switch the experience. Plan this as a tenant-wide flip, not a phased migration. See the Defender for Business FAQ for the official word.
What Defender for Endpoint Plan 2 gives you
What it gives you:
- Advanced Hunting: 30 days of raw, KQL-queryable telemetry across every device. Tables you will use most:
DeviceEvents,DeviceLogonEvents,DeviceProcessEvents,DeviceFileEvents,DeviceNetworkEvents,DeviceRegistryEvents. - 180-day device timeline for deep-dive investigations on a specific device.
- Custom Detection Rules: your own KQL turned into automated detections and response actions. Runs in near real time for supported detections. This is where proactive lives.
- Live Response: an interactive shell into a device during an investigation. Grab files, run scripts, collect memory samples.
- Graph Threat Hunting API (
runHuntingQuery) with theThreatHunting.Read.Allpermission. This is what makes multi-tenant automation possible. - Full Threat and Vulnerability Management and Microsoft Threat Experts (opt-in).
Demo: multi-tenant local admin hunt
The scenario: after an incident at one customer, run the same hunting query across every customer tenant you manage. Post-incident reconstruction for the affected tenant, proactive sweep for the rest.
Shout-out to Damien Van Robaeys here. His post on hunting devices with local admin accounts via Defender for Endpoint already nailed the KQL side of this, so I am not reinventing the wheel on the query itself. What I am adding is the process around it: how to take a single-tenant, portal-driven hunt and wrap it so it runs across every customer tenant you manage, from a single script. Go read his original for the deep dive on the query logic, then come back here for the multi-tenant plumbing.
Requirements:
- A multi-tenant app registration in your MSP tenant with application permission
ThreatHunting.Read.All. - A certificate (self-signed or from your PKI) uploaded to the app registration.
- Admin consent granted in each customer tenant via the admin consent URL:
https://login.microsoftonline.com/{customer-tenant-id}/adminconsent?client_id={your-app-id} - An Azure DevOps project with two variable groups:
defender-hunt— auth only:CLIENT_ID,CERT_THUMBPRINT,CERT_PASSWORD(marked secret). A Secure File holds the.pfx.customer-tenants— the fleet: one variable per tenant, using the conventionTENANT_<ShortName>with the tenant’s directory ID as the value. For exampleTENANT_A = <TENANT_ID>,TENANT_B = <TENANT_ID>. Add a tenant by adding a row; remove one by deleting it. Splitting this group from the auth group means every future workflow — licensing sweeps, policy audits, backup checks — reuses the same tenant list without duplicating it.
- PowerShell 7 on the pipeline agent and the
Microsoft.Graph.Authenticationmodule.
Keeping the tenant list in a shared variable group means no customer data in the repo, and the script has no file dependencies. Everything it needs comes from $env.
The KQL
Local admin additions in the last 7 days, joined with interactive logons by the same account on the same device. One pass answers “was a new admin added, and has it been used”:
let timeWindow = 7d;
let adminAdds =
DeviceEvents
| where Timestamp > ago(timeWindow)
| where ActionType == "UserAccountAddedToLocalGroup"
| where AdditionalFields has "Administrators"
| extend AddedAccount = tostring(parse_json(AdditionalFields).AccountName)
| project AddTime = Timestamp, DeviceId, DeviceName, AddedAccount,
AddedBy = InitiatingProcessAccountName,
AddedByProcess = InitiatingProcessFileName;
let interactiveLogons =
DeviceLogonEvents
| where Timestamp > ago(timeWindow)
| where LogonType in ("Interactive", "RemoteInteractive")
| summarize LogonCount = count(), LastLogon = max(Timestamp)
by DeviceId, AccountName;
adminAdds
| join kind = leftouter interactiveLogons
on $left.DeviceId == $right.DeviceId
and $left.AddedAccount == $right.AccountName
| project AddTime, DeviceName, AddedAccount, AddedBy, AddedByProcess, LogonCount, LastLogon
| order by AddTime desc
The PowerShell script
Commit this as scripts/Invoke-LocalAdminHunt.ps1 in the Azure DevOps repo. It reads everything from $env, loops the tenants, runs the hunting query, and prints results. No parameters, no file dependencies, no secrets on disk.
# scripts/Invoke-LocalAdminHunt.ps1
# Inputs (all from pipeline env vars, set via variable groups):
# CLIENT_ID - App registration client ID
# CERT_THUMBPRINT - Thumbprint of the cert already imported into CurrentUser\My
# TENANT_* - One env var per customer tenant, from the customer-tenants
# variable group. Key is TENANT_<ShortName>, value is the
# tenant directory ID. Example: TENANT_Contoso = <guid>.
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force
Import-Module Microsoft.Graph.Authentication
$ClientId = $env:CLIENT_ID
$Thumbprint = $env:CERT_THUMBPRINT
$tenants = Get-ChildItem Env: |
Where-Object { $_.Name -like 'TENANT_*' } |
ForEach-Object {
[PSCustomObject]@{
Name = $_.Name -replace '^TENANT_',''
Id = $_.Value.Trim()
}
}
$query = @'
let timeWindow = 7d;
let adminAdds =
DeviceEvents
| where Timestamp > ago(timeWindow)
| where ActionType == "UserAccountAddedToLocalGroup"
| where AdditionalFields has "Administrators"
| extend AddedAccount = tostring(parse_json(AdditionalFields).AccountName)
| project AddTime = Timestamp, DeviceId, DeviceName, AddedAccount,
AddedBy = InitiatingProcessAccountName,
AddedByProcess = InitiatingProcessFileName;
let interactiveLogons =
DeviceLogonEvents
| where Timestamp > ago(timeWindow)
| where LogonType in ("Interactive", "RemoteInteractive")
| summarize LogonCount = count(), LastLogon = max(Timestamp)
by DeviceId, AccountName;
adminAdds
| join kind = leftouter interactiveLogons
on $left.DeviceId == $right.DeviceId
and $left.AddedAccount == $right.AccountName
| project AddTime, DeviceName, AddedAccount, AddedBy, AddedByProcess, LogonCount, LastLogon
| order by AddTime desc
'@
$results = New-Object System.Collections.Generic.List[object]
foreach ($tenant in $tenants) {
try {
Connect-MgGraph -ClientId $ClientId `
-CertificateThumbprint $Thumbprint `
-TenantId $tenant.Id `
-NoWelcome
$body = @{ Query = $query } | ConvertTo-Json -Depth 3
$response = Invoke-MgGraphRequest -Method POST `
-Uri 'https://graph.microsoft.com/v1.0/security/runHuntingQuery' `
-Body $body -ContentType 'application/json'
foreach ($row in $response.results) {
$results.Add([PSCustomObject]@{
Tenant = $tenant.Name
TenantId = $tenant.Id
AddTime = $row.AddTime
DeviceName = $row.DeviceName
AddedAccount = $row.AddedAccount
AddedBy = $row.AddedBy
Process = $row.AddedByProcess
LogonCount = $row.LogonCount
LastLogon = $row.LastLogon
})
}
}
catch {
Write-Warning "Tenant $($tenant.Name) [$($tenant.Id)]: $_"
}
finally {
Disconnect-MgGraph | Out-Null
}
}
Write-Host "$($results.Count) matches across $($tenants.Count) tenants"
$results | Format-Table -AutoSize
# From here, it is up to the admin how to follow this up. Pick whatever fits
# your stack: post $results to a Teams or Slack webhook, fire off a
# Send-MailMessage / Graph sendMail digest, open a ticket in your PSA
# (HaloPSA, Autotask, ConnectWise) via its REST API, push to Log Analytics,
# or hand off to a Logic App. The script gets the data out of Defender;
# how it lands in your triage workflow is your call.
The Azure DevOps pipeline
With the script in the repo, the pipeline stays small. It downloads the certificate, imports it into the agent’s store, and calls Invoke-LocalAdminHunt.ps1 with the variable groups attached so the script sees everything it needs on $env.
# azure-pipelines.yml
trigger: none
schedules:
- cron: "0 6 * * *"
displayName: Daily multi-tenant local admin hunt
branches:
include: [ main ]
always: true
pool:
vmImage: windows-latest
variables:
- group: defender-hunt
- group: customer-tenants
steps:
- task: DownloadSecureFile@1
name: huntCert
displayName: Download certificate
inputs:
secureFile: 'defender-hunt.pfx'
- task: PowerShell@2
displayName: Import certificate into CurrentUser\My
inputs:
targetType: inline
pwsh: true
script: |
$pwd = ConvertTo-SecureString '$(CERT_PASSWORD)' -AsPlainText -Force
Import-PfxCertificate -FilePath "$(huntCert.secureFilePath)" `
-CertStoreLocation Cert:\CurrentUser\My `
-Password $pwd | Out-Null
- task: PowerShell@2
displayName: Run multi-tenant hunt
inputs:
filePath: scripts/Invoke-LocalAdminHunt.ps1
pwsh: true
Run the pipeline ad-hoc after an incident. Let the schedule cover the daily proactive sweep. For near real time against a specific tenant, lift the KQL into a Custom Detection Rule in that tenant’s Defender portal. Swap the KQL in the .ps1 for any detection pattern you care about. The pipeline stays the same; the script is the thing you iterate on.
When not to bother
Defender for Endpoint Plan 2 earns its money when someone actually hunts with it. Skip the upgrade when:
- Nobody will triage the output. A daily report no one reads is a paper trail of signal you ignored. Regulators and insurers ask about that.
- The customer already pays for a third-party EDR or MDR. Stacking Defender for Endpoint Plan 2 on top duplicates coverage without displacing the other tool. That said, this can also be a selling point to save money: “You can drop your current EDR and save $X by switching to Defender for Endpoint Plan 2, which gives you the same EDR features plus proactive hunting and better integration with the Microsoft stack.”
How to pitch it
Lead with the customer’s specific risks, not the feature list. Two reliable openers, phrased the way you would actually say them:
-
Remember the questions we could not answer after the last incident? Was a new admin added beforehand, did anything elevate to SYSTEM, has that account been used anywhere else? Defender for Endpoint Plan 2 is the licence that lets us answer those — across every device, in a single query.
-
Your cyber insurance renewal will ask about EDR, custom detections, and mean time to triage. With Defender Suite for Business Premium you can tick EDR, Custom Detection Rules, and Live Response on a single line, which is easier than arguing about equivalents with the underwriter.
Price it as a managed hunting service, not a licence passthrough. The $10/user/month is the floor. The value is the KQL library you build over time and the triage capacity behind it.
Conclusion
Defender for Endpoint Plan 2 flips MSPs from reactive to proactive. Defender for Business and Plan 1 handle block, alert, respond. Plan 2 hunts the setup step, codifies patterns as Custom Detection Rules, and catches the next attempt before the Monday morning call. Advanced Hunting gives you 30 days of telemetry—tell your customer that ceiling up front.
Test the multi-tenant script in a lab first. Consent one customer tenant, confirm the query works or tweak a bit to your needs, then scale out.
Resources:
- Add Microsoft Defender Suite for Business Premium to your subscription
- Defender for Business FAQ on mixed subscriptions
- Advanced Hunting overview
- runHuntingQuery Graph API reference
- Damien Van Robaeys’s hunting devices with local admin accounts
Next up: #2, The Data Gap, on what Purview Suite and E5 Compliance unlock over base Business Premium and Microsoft 365 E3.