PS C:\Blog\rksolutions> cd ..
The MSP License Ladder — Part 1

The MSP License Ladder #1: The Hunting Gap

· 7 min read · Roy Klooster
Defender MDE Security PowerShell Automation M365

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

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 the ThreatHunting.Read.All permission. 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 convention TENANT_<ShortName> with the tenant’s directory ID as the value. For example TENANT_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.Authentication module.

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:

Next up: #2, The Data Gap, on what Purview Suite and E5 Compliance unlock over base Business Premium and Microsoft 365 E3.

back to all posts next: InforcerCommunity: A PowerShell Module for the...
PS Select-String -Pattern
↑↓navigate open escclose