From 224f191e9cd5e6456102fb51c6d6289a0f81a3db Mon Sep 17 00:00:00 2001 From: Tomas Kracmar Date: Wed, 12 Nov 2025 12:45:29 +0100 Subject: [PATCH] MDE Offboarding script --- .gitignore | 3 + ...MDE_OffboardDevices.parameters.sample.json | 13 + MDE/MDE_OffboardDevices.ps1 | 443 ++++++++++++++++++ README.md | 135 ++++++ 4 files changed, 594 insertions(+) create mode 100644 .gitignore create mode 100644 MDE/MDE_OffboardDevices.parameters.sample.json create mode 100644 MDE/MDE_OffboardDevices.ps1 create mode 100644 README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1003755 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +MDE_OffboardDevices.parameters.json +MDE/MDE_OffboardDevices.parameters.json \ No newline at end of file diff --git a/MDE/MDE_OffboardDevices.parameters.sample.json b/MDE/MDE_OffboardDevices.parameters.sample.json new file mode 100644 index 0000000..c67b97d --- /dev/null +++ b/MDE/MDE_OffboardDevices.parameters.sample.json @@ -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 +} diff --git a/MDE/MDE_OffboardDevices.ps1 b/MDE/MDE_OffboardDevices.ps1 new file mode 100644 index 0000000..7bd7aee --- /dev/null +++ b/MDE/MDE_OffboardDevices.ps1 @@ -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." diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f4e843 --- /dev/null +++ b/README.md @@ -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 "" ` + -ClientId "" ` + -ClientSecret "" ` + -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 "" -ClientId "" -ClientSecret "" -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.