MDE Offboarding script

This commit is contained in:
2025-11-12 12:45:29 +01:00
commit 224f191e9c
4 changed files with 594 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.DS_Store
MDE_OffboardDevices.parameters.json
MDE/MDE_OffboardDevices.parameters.json

View File

@@ -0,0 +1,13 @@
{
"TenantId": "00000000-0000-0000-0000-000000000000",
"ClientId": "11111111-2222-3333-4444-555555555555",
"ClientSecret": "replace-with-client-secret",
"Tag": "offboard",
"CompletedTag": "offboarded",
"ApiBase": "https://eu.api.security.microsoft.com",
"StartsWith": false,
"Offboard": false,
"DelayBetweenCallsSec": 1,
"MaxRetries": 3,
"RetryDelaySec": 5
}

443
MDE/MDE_OffboardDevices.ps1 Normal file
View File

@@ -0,0 +1,443 @@
<#
.SYNOPSIS
Find machines by tag in Microsoft Defender for Endpoint and offboard them via API.
.NOTES
- Requires an Azure AD app (client id + secret) with Application permissions:
- Machine.Read.All (or Machine.ReadWrite.All) to list machines by tag
- Machine.Offboard to offboard machines
Grant admin consent for the permissions.
- Choose the correct API base for your geography (default below is eu). Example hosts:
- us.api.security.microsoft.com
- eu.api.security.microsoft.com
- uk.api.security.microsoft.com
- api.securitycenter.microsoft.com (other docs reference this variant)
See MS docs for recommended regional endpoint for best performance.
- The Offboard API requires a non-empty "Comment" JSON body.
- Script runs in dry-run mode by default; add -Offboard to actually submit offboard requests.
#>
param(
[Parameter()] [string] $TenantId,
[Parameter()] [string] $ClientId,
[Parameter()] [string] $ClientSecret,
[Parameter(HelpMessage="The machine tag to find (exact match unless -StartsWith used)")] [string] $Tag,
[Parameter()] [string] $CompletedTag = "offboarded",
[Parameter()] [switch] $StartsWith,
[Parameter()] [string] $ApiBase = "https://eu.api.security.microsoft.com", # change to your region
[Parameter()] [switch] $Offboard, # only offboard when explicitly requested
[Parameter()] [int] $DelayBetweenCallsSec = 1, # breathing room for rate limits
[Parameter()] [int] $MaxRetries = 3,
[Parameter()] [int] $RetryDelaySec = 5,
[Parameter(HelpMessage="Optional path to a JSON or PSD1 file that stores parameter values. CLI args override file entries.")] [string] $ParametersPath
)
function ConvertTo-Hashtable {
param(
[Parameter()] [psobject] $InputObject
)
if ($null -eq $InputObject) {
return @{}
}
if ($InputObject -is [hashtable]) {
return $InputObject
}
$hash = @{}
foreach ($prop in $InputObject.PSObject.Properties) {
$hash[$prop.Name] = $prop.Value
}
return $hash
}
function Import-ParameterFile {
param(
[Parameter(Mandatory=$true)] [string] $Path
)
if (-not (Test-Path -LiteralPath $Path)) {
throw "Parameter file '$Path' not found."
}
$resolvedPath = (Resolve-Path -LiteralPath $Path).ProviderPath
$extension = [System.IO.Path]::GetExtension($resolvedPath).ToLowerInvariant()
try {
switch ($extension) {
".psd1" {
$data = Import-PowerShellDataFile -Path $resolvedPath
}
default {
$raw = Get-Content -LiteralPath $resolvedPath -Raw
if ([string]::IsNullOrWhiteSpace($raw)) {
return @{}
}
$json = ConvertFrom-Json -InputObject $raw
$data = ConvertTo-Hashtable -InputObject $json
}
}
return ConvertTo-Hashtable -InputObject $data
} catch {
throw "Failed to parse parameter file '$resolvedPath': $($_.Exception.Message)"
}
}
function Get-ScriptRoot {
if ($null -ne $PSScriptRoot) {
if ($PSScriptRoot -is [string] -and -not [string]::IsNullOrWhiteSpace($PSScriptRoot)) {
return [string]$PSScriptRoot
}
if ($PSScriptRoot -is [System.Collections.IEnumerable]) {
foreach ($item in $PSScriptRoot) {
if (-not [string]::IsNullOrWhiteSpace([string]$item)) {
return [string]$item
}
}
}
}
if ($MyInvocation -and $MyInvocation.MyCommand -and $MyInvocation.MyCommand.Path) {
return Split-Path -Parent $MyInvocation.MyCommand.Path
}
return (Get-Location).ProviderPath
}
function Get-AccessToken {
param(
[string] $TenantId,
[string] $ClientId,
[string] $ClientSecret,
[string] $Scope = "https://api.securitycenter.microsoft.com/.default"
)
$tokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
$body = @{
client_id = $ClientId
client_secret = $ClientSecret
scope = $Scope
grant_type = "client_credentials"
}
try {
$resp = Invoke-RestMethod -Method Post -Uri $tokenEndpoint -Body $body -ErrorAction Stop
return $resp.access_token
} catch {
Write-Error "Failed to obtain access token: $($_.Exception.Message)"
throw
}
}
function Find-MachinesByTag {
param(
[string] $ApiBase,
[string] $Token,
[string] $Tag,
[bool] $StartsWith = $false
)
$uri = "$($ApiBase.TrimEnd('/'))/api/machines/findbytag?tag=$([uri]::EscapeDataString($Tag))&useStartsWithFilter=$($StartsWith.ToString().ToLower())"
$headers = @{
Authorization = "Bearer $Token"
}
try {
$resp = Invoke-RestMethod -Method Get -Uri $uri -Headers $headers -ErrorAction Stop
if ($null -eq $resp) {
return @()
}
# Some Defender APIs wrap results under a 'value' property; others return a bare array.
$machines = if ($resp.PSObject.Properties.Match('value').Count -gt 0) {
$resp.value
} else {
$resp
}
if ($null -eq $machines) {
return @()
}
if ($machines -is [string] -or -not ($machines -is [System.Collections.IEnumerable])) {
$machines = @($machines)
}
return @($machines)
} catch {
Write-Error "Find-by-tag call failed: $($_.Exception.Message)"
throw
}
}
function Invoke-MachineOffboard {
param(
[string] $ApiBase,
[string] $Token,
[string] $MachineId,
[string] $Comment = "Offboarded by automation",
[int] $MaxRetries = 3,
[int] $RetryDelaySec = 5
)
$uri = "$($ApiBase.TrimEnd('/'))/api/machines/$MachineId/offboard"
$headers = @{
Authorization = "Bearer $Token"
"Content-Type" = "application/json"
}
$body = @{ Comment = $Comment } | ConvertTo-Json
for ($attempt = 1; $attempt -le $MaxRetries; $attempt++) {
try {
$resp = Invoke-RestMethod -Method Post -Uri $uri -Headers $headers -Body $body -ErrorAction Stop
return @{ Success = $true; Response = $resp }
} catch {
$status = if ($_.Exception.Response) {
try { ($_.Exception.Response | Select-Object -ExpandProperty StatusCode).Value__ } catch { $_.Exception.Message }
} else { $_.Exception.Message }
Write-Warning "Offboard attempt $attempt failed for $MachineId (status: $status)."
if ($attempt -lt $MaxRetries) {
Start-Sleep -Seconds $RetryDelaySec
} else {
return @{ Success = $false; Error = $_.Exception.Message }
}
}
}
}
function Update-MachineTag {
param(
[string] $ApiBase,
[string] $Token,
[string] $MachineId,
[string] $TagValue,
[ValidateSet("Add","Remove")] [string] $Action
)
if ([string]::IsNullOrWhiteSpace($TagValue)) {
return
}
$uri = "$($ApiBase.TrimEnd('/'))/api/machines/$MachineId/tags"
$headers = @{
Authorization = "Bearer $Token"
"Content-Type" = "application/json"
}
$body = @{
Value = $TagValue
Action = $Action
} | ConvertTo-Json
Invoke-RestMethod -Method Post -Uri $uri -Headers $headers -Body $body -ErrorAction Stop | Out-Null
}
function Set-OffboardCompletionTag {
param(
[string] $ApiBase,
[string] $Token,
[string] $MachineId,
[string] $OriginalTag,
[string] $CompletedTag
)
if ([string]::IsNullOrWhiteSpace($CompletedTag)) {
return
}
if (-not [string]::IsNullOrWhiteSpace($OriginalTag) -and $OriginalTag -ne $CompletedTag) {
try {
Update-MachineTag -ApiBase $ApiBase -Token $Token -MachineId $MachineId -TagValue $OriginalTag -Action "Remove"
Write-Host "Removed tag '$OriginalTag' from $MachineId."
} catch {
Write-Warning "Failed to remove tag '$OriginalTag' from ${MachineId}: $($_.Exception.Message)"
}
}
try {
Update-MachineTag -ApiBase $ApiBase -Token $Token -MachineId $MachineId -TagValue $CompletedTag -Action "Add"
Write-Host "Added tag '$CompletedTag' to $MachineId."
} catch {
Write-Warning "Failed to add tag '$CompletedTag' to ${MachineId}: $($_.Exception.Message)"
}
}
function Get-ActiveOffboardRequest {
param(
[string] $ApiBase,
[string] $Token,
[string] $MachineId
)
$filter = "type eq 'Offboard' and machineId eq '$MachineId'"
$query = "`$filter=$([uri]::EscapeDataString($filter))&`$orderby=creationDateTime%20desc&`$top=5"
$uri = "$($ApiBase.TrimEnd('/'))/api/machineactions?$query"
$headers = @{
Authorization = "Bearer $Token"
}
try {
$resp = Invoke-RestMethod -Method Get -Uri $uri -Headers $headers -ErrorAction Stop
} catch {
Write-Warning "Failed to query machine actions for ${MachineId}: $($_.Exception.Message)"
return $null
}
if ($null -eq $resp) {
return $null
}
$actions = if ($resp.PSObject.Properties.Match('value').Count -gt 0) {
$resp.value
} else {
$resp
}
if ($null -eq $actions) {
return $null
}
if ($actions -isnot [System.Collections.IEnumerable]) {
$actions = @($actions)
}
$completedStatuses = @('Succeeded','Failed','Cancelled','TimedOut')
foreach ($action in $actions) {
$status = $action.status
if ([string]::IsNullOrWhiteSpace($status)) {
return $action
}
if ($completedStatuses -notcontains $status) {
return $action
}
}
return $null
}
# Allow parameters to be stored in a reusable file instead of always passing them via CLI.
$scriptRoot = Get-ScriptRoot
$parameterDefaults = @{}
if ([string]::IsNullOrWhiteSpace($ParametersPath)) {
$defaultCandidates = @(
(Join-Path -Path $scriptRoot -ChildPath "MDE_OffboardDevices.parameters.json")
(Join-Path -Path $scriptRoot -ChildPath "MDE_OffboardDevices.parameters.psd1")
)
foreach ($candidate in $defaultCandidates) {
if (Test-Path -LiteralPath $candidate) {
$ParametersPath = $candidate
break
}
}
}
if (-not [string]::IsNullOrWhiteSpace($ParametersPath)) {
Write-Host "Loading parameter defaults from '$ParametersPath'. CLI values take precedence."
$parameterDefaults = Import-ParameterFile -Path $ParametersPath
}
if ($parameterDefaults.Count -gt 0) {
if ($parameterDefaults.ContainsKey('WhatIf') -and -not $parameterDefaults.ContainsKey('Offboard')) {
Write-Verbose "Detected legacy 'WhatIf' setting in parameter file. Translating to new -Offboard logic."
$parameterDefaults['Offboard'] = -not [bool]$parameterDefaults['WhatIf']
}
$stringParams = @('TenantId','ClientId','ClientSecret','Tag','CompletedTag','ApiBase')
foreach ($name in $stringParams) {
if (-not $PSBoundParameters.ContainsKey($name) -and $parameterDefaults.ContainsKey($name)) {
Set-Variable -Name $name -Value ([string]$parameterDefaults[$name]) -Scope Script
}
}
$intParams = @('DelayBetweenCallsSec','MaxRetries','RetryDelaySec')
foreach ($name in $intParams) {
if (-not $PSBoundParameters.ContainsKey($name) -and $parameterDefaults.ContainsKey($name) -and $null -ne $parameterDefaults[$name]) {
Set-Variable -Name $name -Value ([int]$parameterDefaults[$name]) -Scope Script
}
}
$switchParams = @('StartsWith','Offboard')
foreach ($name in $switchParams) {
if (-not $PSBoundParameters.ContainsKey($name) -and $parameterDefaults.ContainsKey($name)) {
Set-Variable -Name $name -Value ([bool]$parameterDefaults[$name]) -Scope Script
}
}
}
$missingRequired = @()
foreach ($req in @('TenantId','ClientId','ClientSecret','Tag')) {
$value = Get-Variable -Name $req -ValueOnly
if ([string]::IsNullOrWhiteSpace([string]$value)) {
$missingRequired += $req
}
}
if ($missingRequired.Count -gt 0) {
throw "Missing required parameter(s): $($missingRequired -join ', '). Provide them via CLI arguments or a parameter file."
}
# main
Write-Host "Using API base: $ApiBase"
$token = Get-AccessToken -TenantId $TenantId -ClientId $ClientId -ClientSecret $ClientSecret
Write-Host "Searching for machines with tag '$Tag' (StartsWith = $StartsWith)..."
$machines = Find-MachinesByTag -ApiBase $ApiBase -Token $token -Tag $Tag -StartsWith $StartsWith
if (-not $machines -or $machines.Count -eq 0) {
Write-Host "No machines found for tag '$Tag'. Exiting."
return
}
Write-Host "Found $($machines.Count) machines. Listing IDs..."
$machines | ForEach-Object {
$id = $_.id
$dns = $_.computerDnsName
$deviceTags = if ($_.machineTags) { ($_.machineTags -join ', ') } else { 'none' }
Write-Host " - ID: $id DNS: $dns Tags: $deviceTags"
}
if (-not $Offboard) {
Write-Host "`nDry run: -Offboard was not supplied, so no offboard calls were sent."
Write-Host "Re-run with -Offboard to offboard the listed machines."
return
}
foreach ($m in $machines) {
$id = $m.id
if (-not $id) {
Write-Warning "Machine entry missing 'id' property, skipping: $($m | ConvertTo-Json -Depth 3)"
continue
}
$activeRequest = Get-ActiveOffboardRequest -ApiBase $ApiBase -Token $token -MachineId $id
if ($null -ne $activeRequest) {
$status = $activeRequest.status
$requestedOn = $activeRequest.creationDateTime
Write-Warning "Offboard already requested for $id (status: $status, requested: $requestedOn). Skipping."
continue
}
Write-Host "`nOffboarding machine $id ..."
$result = Invoke-MachineOffboard -ApiBase $ApiBase -Token $token -MachineId $id -Comment "Offboard by automation (tag: $Tag)" -MaxRetries $MaxRetries -RetryDelaySec $RetryDelaySec
if ($result.Success) {
Write-Host "Offboard requested for $id. Response: "
$result.Response | ConvertTo-Json -Depth 5 | Write-Host
if (-not [string]::IsNullOrWhiteSpace($CompletedTag)) {
Set-OffboardCompletionTag -ApiBase $ApiBase -Token $token -MachineId $id -OriginalTag $Tag -CompletedTag $CompletedTag
}
} else {
Write-Error "Failed to offboard $id. Error: $($result.Error)"
}
Start-Sleep -Seconds $DelayBetweenCallsSec
}
Write-Host "`nDone."

135
README.md Normal file
View File

@@ -0,0 +1,135 @@
Collection of M365 scripts
# M365-Scripts
This repository contains administrative and automation scripts for managing Microsoft 365 services, with a focus on Microsoft Defender for Endpoint (MDE) device lifecycle management.
---
## 🔧 Script: MDE Offboard Devices by Tag
**Script name:** `MDE\MDE_OffboardDevices.ps1`
**Purpose:** Identify and offboard Microsoft Defender for Endpoint devices based on a specific device tag.
The script connects to the Defender for Endpoint API, finds devices tagged with a defined value (e.g. `offboard`), and issues an offboarding request for each one. This is typically used during device decommissioning or cleanup processes.
---
## 🚀 How to Use
### 1. Requirements
- PowerShell 7.0 or newer
- An Entra ID App Registration with the following **API permissions** under “APIs my organization uses → WindowsDefenderATP”:
- `Machine.Read.All` (minimum to list devices)
- `Machine.ReadWrite.All` (required if you want the script to remove the source tag and apply `CompletedTag`)
- `Machine.Offboard`
- These permissions appear under the WindowsDefenderATP API in the Azure portal and require admin consent to be granted.
- Offboarding via the Defender API is only supported for Windows 10/11 and Windows Server 2019 (or later) endpoints. Earlier OS versions must be offboarded through other supported methods.
- Correct Defender API base URL for your tenant:
- Global: `https://api.securitycenter.microsoft.com`
- EU: `https://eu.api.security.microsoft.com`
- US: `https://us.api.security.microsoft.com`
---
### 2. Example usage
```powershell
.\MDE_OffboardDevices.ps1 `
-TenantId "<tenant-id>" `
-ClientId "<client-id>" `
-ClientSecret "<client-secret>" `
-Tag "offboard" ` # devices currently tagged with this value will be targeted
-CompletedTag "offboarded" ` # optional: tag applied after successful offboard (defaults to offboarded)
-ApiBase "https://eu.api.security.microsoft.com"
```
The script runs in dry-run mode unless you add `-Offboard`. Use this switch only when you're ready to send the requests:
```powershell
.\Offboard-ByTag.ps1 -TenantId "<tenant>" -ClientId "<id>" -ClientSecret "<secret>" -Tag "offboard" -Offboard
```
### 3. Use a parameter file
Store frequently reused values in a JSON or `.psd1` file so you don't have to pass them on every run. By default, the script looks for `MDE_OffboardDevices.parameters.json` or `MDE_OffboardDevices.parameters.psd1` in the same folder, but you can also pass a custom path through `-ParametersPath`.
Example JSON file:
```json
{
"TenantId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"ClientId": "ffffffff-1111-2222-3333-444444444444",
"ClientSecret": "super-secret-value",
"Tag": "offboard",
"CompletedTag": "offboarded",
"ApiBase": "https://eu.api.security.microsoft.com",
"StartsWith": true,
"Offboard": false,
"DelayBetweenCallsSec": 2,
"MaxRetries": 5,
"RetryDelaySec": 10
}
```
Run the script and let the file supply the values:
```powershell
.\Offboard-ByTag.ps1 -ParametersPath .\MDE_OffboardDevices.parameters.json
```
Any CLI arguments you pass will override the values loaded from the file, so you can mix and match (e.g., keep secrets in the file but override `-Tag` for specific runs). Set `"Offboard": true` (or pass `-Offboard`) only when you want to send the offboard requests. Once an offboard succeeds, the script removes the original `Tag` value and applies `CompletedTag` (default `offboarded`) so you can track completed machines (requires the app to have `Machine.ReadWrite.All` permission). The script also queries Defender's Machine Actions API before each run so it can report and skip devices that already have an offboarding action in progress.
---
## 🏷️ Tagging Devices
Before running the script, ensure target devices are tagged appropriately.
### Add the tag manually
1. Go to [https://security.microsoft.com](https://security.microsoft.com)
2. Navigate to **Assets → Devices**
3. Select devices to manage
4. Choose **Manage tags → Add tag**
5. Add the tag `offboard`
### Add the tag via API
```http
POST /api/machines/{machineId}/tags
{
"Value": "offboard",
"Action": "Add"
}
```
Devices with this tag will be detected automatically when you run the script.
---
## ⚙️ What Happens When You Offboard
- The Defender for Endpoint sensor on the machine stops sending telemetry.
- The device will appear as **Inactive** or **Offboarded** in the MDE portal.
- Devices stay in an **Active** state for up to ~7 days after the offboarding request before flipping to **Inactive**, so rewriting the tag to `CompletedTag` provides immediate tracking you can rely on during that transition window.
- The device timeline should eventually show an event titled **`Event of type [OffboardDevice] observed on device`**; this is the final confirmation that the service observed the offboarding command. Verify this manually in the Defender portal (Device timeline) after running the script, especially for high-value machines.
- The agent is not uninstalled; re-onboarding will be required to bring the device back.
- The action is irreversible once executed.
---
## 🧭 Tips
- Always run a dry run (omit `-Offboard`) before production to verify the device list.
- Use a unique tag (like `offboard`) to avoid affecting unintended devices.
- Logs and API responses are printed to the console for transparency.
---
## 🧾 License
MIT License
Copyright © 2025
---
## 📬 Feedback & Contributions
Feel free to open issues or submit pull requests to expand the collection of M365 automation scripts.