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