MDE Offboarding script
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.DS_Store
|
||||
MDE_OffboardDevices.parameters.json
|
||||
MDE/MDE_OffboardDevices.parameters.json
|
||||
13
MDE/MDE_OffboardDevices.parameters.sample.json
Normal file
13
MDE/MDE_OffboardDevices.parameters.sample.json
Normal 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
443
MDE/MDE_OffboardDevices.ps1
Normal 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
135
README.md
Normal 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.
|
||||
Reference in New Issue
Block a user