Skip to main content

πŸ“šπŸͺŸπŸ’»πŸ“œCCScript - Windows - Detect RMM

What this script does πŸ”β€‹

This is a Custom Compliance Detection Script for Intune that checks if your RMM (Remote Monitoring & Management) agent is installed and actually running.

Because what's the point of remote management if the agent isn't... you know... managing anything?

The Challenge​

Microsoft's built-in compliance checks are solid:

  • BitLocker? βœ…
  • Secure Boot? βœ…
  • Firewall? βœ…

But they don't check for your business-critical tools. Like RMM agents.

That's where Custom Compliance saves the day.

This script checks if your RMM tool is:

  1. Present β€” Installed on the device
  2. Active β€” Actually running (not just sitting there doing nothing)

If either check fails, the device is marked non-compliant in Intune.


How it works πŸ› οΈβ€‹

Detection Strategy​

The script uses multiple detection methods to find RMM agents:

  1. Process Detection β€” Is the RMM process running? (e.g., NinjaRMMAgent.exe)
  2. Service Detection β€” Is the Windows service installed and started?
  3. File Path Detection β€” Do the installation files exist? (e.g., C:\Program Files\NinjaRMMAgent)
  4. Registry Detection β€” Is it registered in Windows' Uninstall keys?
  5. Version Detection β€” What version is installed?

If any of these methods find the RMM, it's considered present.

If the service is running OR the process is running, it's considered active.

Supported RMM Tools​

The script auto-detects these RMM platforms:

  • NinjaOne
  • Datto RMM (formerly Autotask/CentraStage)
  • TacticalRMM
  • ConnectWise Automate (LabTech)
  • N-able (SolarWinds RMM)
  • Atera
  • ManageEngine Endpoint Central (Desktop Central)
  • Pulseway
  • Splashtop
  • TeamViewer

It checks them in priority order and reports the first one found.

Auto vs Manual Mode​

Auto Mode (default):

# No configuration needed
# Script scans for all known RMM tools
# Reports the first one it finds

Manual Mode (optional):

# Set environment variable to force a specific RMM
$env:RMM_CHOICE = "NinjaOne"

This is useful if you have multiple RMM tools installed and want to check a specific one.


Output Format πŸ“€β€‹

The script outputs a single-line JSON object to stdout:

{
"tool": "NinjaOne",
"present": true,
"active": true,
"serviceInstalled": true,
"serviceRunning": true,
"processRunning": true,
"pathExists": true,
"regInstalled": true,
"version": "5.8.1234",
"alsoDetected": [],
"timestampUtc": "2025-01-17T12:34:56.789Z",
"detailsCompressed": "H4sIAAAAAAAA..."
}

Key Fields​

FieldTypeDescription
toolstringWhich RMM was detected (or "Auto" if none found)
presentbooleanIs the RMM installed? (Used by Intune compliance rule)
activebooleanIs the RMM running? (Used by Intune compliance rule)
serviceInstalledbooleanIs the Windows service registered?
serviceRunningbooleanIs the service currently running?
processRunningbooleanIs the process currently running?
pathExistsbooleanDo installation files exist?
regInstalledbooleanIs it in the Uninstall registry?
versionstringVersion number (if detectable)
alsoDetectedarrayOther RMM tools found (if any)
timestampUtcstringWhen the check ran (ISO 8601 UTC)
detailsCompressedstringFull detection details (gzip + base64)

The detailsCompressed field contains all the raw detection data (which processes, services, paths, registry keys were found) compressed for efficient storage.


Integration with Intune πŸ”—β€‹

Upload the Script​

In Intune:

  1. Go to Devices β†’ Compliance β†’ Scripts
  2. Click Add β†’ Windows 10 and later
  3. Upload this PowerShell script
  4. Set Run script in 64 bit PowerShell Host to Yes
  5. Set Run this script using the logged on credentials to No (runs as SYSTEM)

This script is used by the Custom Compliance Policy.

See πŸ“šπŸͺŸπŸ’»Compliance - Custom - Detect RMM for:

  • The compliance rules that evaluate this script's output
  • Device assignment configuration
  • Actions for non-compliance

Note: This script is not assigned separately β€” the compliance policy handles all assignments


Pro Tips πŸ§ β€‹

Test Before Deploying​

Before creating the compliance policy, test the script locally:

# Run locally on a test device
.\CCScript-Detect-RMM.ps1

# Check the JSON output
# Verify it detects your RMM correctly

Monitor False Positives​

If devices are failing compliance but the RMM is installed:

  • Check the script's detection logic for your specific RMM
  • Verify process names, service names, and paths match your installation
  • Update the catalog if needed (e.g., custom install paths)

Add Custom RMM Tools​

If you use an RMM not in the catalog, extend the script:

# Add to the Get-RmmCatalog function
'CustomRMM' = [ordered]@{
ProcessNames = @('CustomAgent')
ServiceNamePatterns = @('CustomRMM.*')
Paths = @('C:\Program Files\CustomRMM\*')
RegDisplayNamePatterns = @('CustomRMM')
}

Then add it to the $autoOrder array.

Decompress Details for Troubleshooting​

The detailsCompressed field contains full detection data:

# Decompress the details
$compressed = "H4sIAAAAAAAA..." # from JSON output
$bytes = [Convert]::FromBase64String($compressed)
$msIn = New-Object System.IO.MemoryStream($bytes, 0, $bytes.Length)
$gzip = New-Object System.IO.Compression.GZipStream($msIn, [IO.Compression.CompressionMode]::Decompress)
$sr = New-Object System.IO.StreamReader($gzip)
$json = $sr.ReadToEnd()
$sr.Close()
$gzip.Close()
$msIn.Close()

# View the details
$json | ConvertFrom-Json | ConvertTo-Json -Depth 10

This shows exactly which processes, services, and paths were found (or not found).


Troubleshooting πŸ”§β€‹

Script returns present: false but RMM is installed​

Possible causes:

  • Custom installation path not in the catalog
  • Service renamed during installation
  • Process running under a different name

Solution:

  • Run the script manually to see what's detected
  • Check which detection methods are failing
  • Update the catalog with your specific paths/names

Script returns active: false but service is running​

Possible causes:

  • Service is installed but not started
  • Process crashed but service is still registered
  • Service startup type is "Disabled"

Solution:

  • Check service status: Get-Service -Name "YourRMMService"
  • Check process list: Get-Process -Name "YourRMMProcess"
  • Restart the service or reboot the device

Script outputs nothing​

Possible causes:

  • Script execution policy blocked it
  • Error in the script (syntax/logic)
  • Output redirected or suppressed

Solution:

  • Check script execution policy: Get-ExecutionPolicy
  • Run manually to see error messages
  • Verify $ErrorActionPreference isn't hiding errors

Final Thoughts πŸ§˜β€‹

Microsoft's compliance checks cover the basics.

But they're generic. They don't know about your tools.

That's why Custom Compliance exists.

This script lets you enforce compliance based on business-critical tools like RMM agents.

Because "compliant" isn't just what Microsoft says.

It's what you say.

And if you say a device needs an RMM agent?

Then it needs an RMM agent.


πŸ“šπŸͺŸπŸ’»πŸ“œCCScript - Windows - Detect RMM.ps1

<#
Intune Custom Compliance – RMM Auto Detection (Generic)
- Output: single-line JSON with top-level booleans: present, active
- Works with the provided Intune Rules that check $.present == true AND $.active == true
- Supports Auto discovery across common RMM/remote tools.

Configure (optional):
- Env var RMM_CHOICE can override selection. Use "Auto" to scan all.

#>

# Ensure no extraneous output
$InformationPreference = 'SilentlyContinue'
$ErrorActionPreference = 'SilentlyContinue'

# -------- Utilities --------
function Invoke-Safe {
param([scriptblock]$ScriptBlock)
try { & $ScriptBlock } catch { $null }
}

function Compress-StringGzipBase64 {
param([Parameter(Mandatory)][string]$InputString)
$bytes = [System.Text.Encoding]::UTF8.GetBytes($InputString)
$msOut = New-Object System.IO.MemoryStream
$gzip = New-Object System.IO.Compression.GZipStream($msOut, [IO.Compression.CompressionLevel]::Optimal)
$gzip.Write($bytes, 0, $bytes.Length)
$gzip.Close()
$b64 = [Convert]::ToBase64String($msOut.ToArray())
$msOut.Dispose()
return $b64
}

# -------- Catalog --------
function Get-RmmCatalog {
$catalog = [ordered]@{
'NinjaOne' = [ordered]@{
ProcessNames = @('NinjaRMMAgent','NinjaRMMAgentUpdater','NinjaRMMAgentSVC')
ServiceNamePatterns = @('^Ninja.*RMM.*','^NinjaRMMAgent(Updater)?$')
Paths = @('C:\Program Files\Ninja*\*','C:\Program Files\NinjaRMMAgent\*')
RegDisplayNamePatterns = @('Ninja.*RMM')
}
'DattoRMM' = [ordered]@{
ProcessNames = @('CagService','AgentService','AEMAgent','CentraStage')
ServiceNamePatterns = @('CentraStage','AEM.*Agent','CagService','Datto.*RMM')
Paths = @('C:\Program Files\CentraStage\*','C:\Program Files (x86)\CentraStage\*','C:\Program Files\Datto\RMM\*','C:\Program Files (x86)\Datto\RMM\*')
RegDisplayNamePatterns = @('Datto.*RMM','CentraStage','AEM')
}
'TacticalRMM' = [ordered]@{
ProcessNames = @('tacticalrmm','meshagent')
ServiceNamePatterns = @('tactical.*rmm','mesh(agent)?','Mesh Agent')
Paths = @('C:\Program Files\TacticalRMM\*','C:\Program Files\Mesh Agent\*','C:\Program Files (x86)\Mesh Agent\*')
RegDisplayNamePatterns = @('Tactical.*RMM','MeshCentral|Mesh Agent')
}
'ConnectWiseAutomate' = [ordered]@{
ProcessNames = @('LTService','LTSvcMon','LTClient','LTTray')
ServiceNamePatterns = @('^LTService$','^LTSvcMon$','LabTech|ConnectWise Automate')
Paths = @('C:\Program Files (x86)\LabTech\*','C:\Program Files\LabTech\*','C:\Program Files (x86)\ConnectWise Automate\*')
RegDisplayNamePatterns = @('LabTech|ConnectWise Automate')
}
'Nable' = [ordered]@{
ProcessNames = @('Agent','WinAgent','ncagent','NableTray','AMPAgent','RMMServiceHost')
ServiceNamePatterns = @('N-?able.*Agent','Ncentral.*Agent','Windows Agent Maintenance Service','Advanced Monitoring Agent','SolarWinds.*RMM')
Paths = @('C:\Program Files\N-able\*','C:\Program Files (x86)\N-able\*','C:\Program Files (x86)\SolarWinds MSP\Remote Monitoring*\*','C:\Program Files (x86)\Advanced Monitoring Agent\*')
RegDisplayNamePatterns = @('N-?able','N-central','SolarWinds.*RMM','Advanced Monitoring Agent')
}
'Atera' = [ordered]@{
ProcessNames = @('AteraAgent')
ServiceNamePatterns = @('^AteraAgent$')
Paths = @('C:\Program Files\Atera Networks\AteraAgent\*','C:\Program Files (x86)\Atera Networks\AteraAgent\*')
RegDisplayNamePatterns = @('Atera')
}
'ManageEngineEndpointCentral' = [ordered]@{
ProcessNames = @('dcagentservice','UEMSAgentService')
ServiceNamePatterns = @('ManageEngine.*Desktop Central.*Agent','Desktop Central - Agent','UEMS.*Agent')
Paths = @('C:\Program Files\DesktopCentral_Agent\*','C:\Program Files (x86)\DesktopCentral_Agent\*','C:\Program Files\ManageEngine\UEMS_Agent\*')
RegDisplayNamePatterns = @('ManageEngine.*(Desktop|Endpoint).*Agent','UEMS.*Agent')
}
'Pulseway' = [ordered]@{
ProcessNames = @('Pulseway','PCMonitorSrv')
ServiceNamePatterns = @('Pulseway','PC Monitor')
Paths = @('C:\Program Files\Pulseway\*','C:\Program Files (x86)\Pulseway\*')
RegDisplayNamePatterns = @('Pulseway','PC Monitor')
}
'Splashtop' = [ordered]@{
ProcessNames = @('SRService','Streamer','SplashtopService','SplashtopStreamer')
ServiceNamePatterns = @('Splashtop.*(Remote|Connect|Streamer).*Service','^SplashtopRemoteService$')
Paths = @('C:\Program Files (x86)\Splashtop\*','C:\Program Files\Splashtop\*')
RegDisplayNamePatterns = @('Splashtop')
}
'TeamViewer' = [ordered]@{
ProcessNames = @('TeamViewer','TeamViewer_Service')
ServiceNamePatterns = @('^TeamViewer(\d+)?$','TeamViewer.*Service')
Paths = @('C:\Program Files\TeamViewer\*')
RegDisplayNamePatterns = @('TeamViewer')
}
}
return $catalog
}

# -------- Detection Helpers --------
function Test-AnyPathExists {
param([string[]]$Paths)
$hits = @()
foreach ($p in $Paths) {
if (Invoke-Safe { Test-Path -LiteralPath $p -PathType Any }) { $hits += $p; continue }
# allow wildcards too
$items = Invoke-Safe { Get-ChildItem -Path $p -ErrorAction SilentlyContinue }
if ($items) { $hits += $p }
}
[PSCustomObject]@{ any = [bool]$hits; hits = $hits }
}

function Find-ServicesByPatterns {
param([string[]]$Patterns)
$matches = @()
$svcs = Invoke-Safe { Get-Service } | Where-Object { $_ }
if ($svcs) {
foreach ($pat in $Patterns) {
try { $regex = [System.Text.RegularExpressions.Regex]::new($pat, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) } catch { continue }
foreach ($s in $svcs) {
if ($regex.IsMatch([string]$s.Name) -or $regex.IsMatch([string]$s.DisplayName)) {
$matches += [PSCustomObject]@{
Name = $s.Name
DisplayName = $s.DisplayName
State = $s.Status.ToString()
Started = [bool]($s.Status -eq 'Running')
}
}
}
}
}
$running = $matches | Where-Object { $_.Started }
[PSCustomObject]@{
anyInstalled = [bool]$matches
anyRunning = [bool]$running
installed = $matches | Sort-Object Name -Unique
running = $running | Sort-Object Name -Unique
}
}

function Find-ProcessesByNames {
param([string[]]$Names)
$norm = @()
foreach ($n in $Names) {
if ([string]::IsNullOrWhiteSpace($n)) { continue }
$norm += ($n -replace '\.exe$','')
}
$procs = Invoke-Safe { Get-Process } | Where-Object { $_ }
$hits = @()
if ($procs) {
foreach ($p in $procs) {
if ($norm -contains $p.Name) { $hits += $p }
}
}
[PSCustomObject]@{
any = [bool]$hits
running = $hits | Select-Object -Property Name,Id,StartTime -ErrorAction SilentlyContinue
}
}

function Find-UninstallMatches {
param([string[]]$Patterns)
$roots = @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'
)
$matches = @()
foreach ($r in $roots) {
$items = Invoke-Safe { Get-ChildItem $r -ErrorAction Stop }
foreach ($i in ($items | Where-Object { $_ })) {
$p = $i.PSPath
$dn = Invoke-Safe { (Get-ItemProperty $p -ErrorAction Stop).DisplayName }
$dv = Invoke-Safe { (Get-ItemProperty $p -ErrorAction Stop).DisplayVersion }
if (-not $dn) { continue }
foreach ($pat in $Patterns) {
try { $regex = [System.Text.RegularExpressions.Regex]::new($pat, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) } catch { continue }
if ($regex.IsMatch([string]$dn)) {
$matches += [PSCustomObject]@{ Key=$i.PSChildName; DisplayName=$dn; DisplayVersion=$dv }
}
}
}
}
[PSCustomObject]@{ any = [bool]$matches; items = $matches | Sort-Object DisplayName -Unique }
}

function Get-FileVersionFromPaths {
param([string[]]$Paths)
$versions = @()
foreach ($p in $Paths) {
$files = Invoke-Safe { Get-ChildItem -Path $p -Include *.exe -Recurse -ErrorAction SilentlyContinue }
foreach ($f in ($files | Where-Object { $_ })) {
$fv = Invoke-Safe { (Get-Item $f.FullName).VersionInfo.FileVersion }
if ($fv) { $versions += [PSCustomObject]@{ File=$f.FullName; Version=$fv } }
}
}
$versions | Sort-Object Version -Descending | Select-Object -First 1
}

function Test-RmmDefinition {
param([hashtable]$Def,[string]$Key)
$pathRes = Test-AnyPathExists -Paths $Def.Paths
$svcRes = Find-ServicesByPatterns -Patterns $Def.ServiceNamePatterns
$procRes = Find-ProcessesByNames -Names $Def.ProcessNames
$regRes = Find-UninstallMatches -Patterns $Def.RegDisplayNamePatterns
$verFile = Get-FileVersionFromPaths -Paths $Def.Paths

$present = ($pathRes.any -or $svcRes.anyInstalled -or $regRes.any)
$active = ($svcRes.anyRunning -or $procRes.any)

$version = $null
if ($regRes.items) { $version = ($regRes.items | Select-Object -ExpandProperty DisplayVersion -First 1) }
if (-not $version -and $verFile) { $version = $verFile.Version }

$details = [ordered]@{
key = $Key
pathsChecked = $Def.Paths
servicesMatched = $svcRes.installed
servicesRunning = $svcRes.running
processesRunning = $procRes.running
regMatches = $regRes.items
topExeVersion = $verFile
}

[PSCustomObject]@{
key = $Key
present = [bool]$present
active = [bool]$active
pathExists= [bool]$pathRes.any
serviceInstalled = [bool]$svcRes.anyInstalled
serviceRunning = [bool]$svcRes.anyRunning
processRunning = [bool]$procRes.any
regInstalled = [bool]$regRes.any
version = $version
details = $details
}
}

# -------- MAIN --------
$RmmChoice = $env:RMM_CHOICE
if ([string]::IsNullOrWhiteSpace($RmmChoice)) { $RmmChoice = 'Auto' }

$catalog = Get-RmmCatalog

# Preferred auto order: true RMMs first, then remote-only tools
$autoOrder = @('NinjaOne','DattoRMM','TacticalRMM','ConnectWiseAutomate','Nable','Atera','ManageEngineEndpointCentral','Pulseway','Splashtop','TeamViewer')

$final = $null
$selectedKey = $RmmChoice

if ($RmmChoice -ieq 'Auto') {
foreach ($k in $autoOrder) {
if (-not $catalog.ContainsKey($k)) { continue }
$res = Test-RmmDefinition -Def $catalog[$k] -Key $k
if ($res.present) { $final = $res; $selectedKey = $k; break }
}
if (-not $final) {
# none detected
$final = [PSCustomObject]@{
key=$selectedKey; present=$false; active=$false;
pathExists=$false; serviceInstalled=$false; serviceRunning=$false; processRunning=$false; regInstalled=$false; version=$null; details=@{}
}
}
} else {
if ($catalog.ContainsKey($selectedKey)) {
$final = Test-RmmDefinition -Def $catalog[$selectedKey] -Key $selectedKey
} else {
$final = [PSCustomObject]@{
key=$selectedKey; present=$false; active=$false;
pathExists=$false; serviceInstalled=$false; serviceRunning=$false; processRunning=$false; regInstalled=$false; version=$null; details=@{error="Unknown RMM choice"}
}
}
}

# Also-detected summary (optional)
$also = @()
foreach ($k in $catalog.Keys) {
if ($k -eq $final.key) { continue }
$r = Test-RmmDefinition -Def $catalog[$k] -Key $k
if ($r.present) { $also += [PSCustomObject]@{ key=$k; active=$r.active } }
}

# Build output JSON
$now = [DateTime]::UtcNow.ToString('o')
$detailsJson = ($final.details | ConvertTo-Json -Depth 6)
$compressed = Compress-StringGzipBase64 -InputString $detailsJson

$out = [ordered]@{
tool = $final.key
present = [bool]$final.present
active = [bool]$final.active
serviceInstalled = [bool]$final.serviceInstalled
serviceRunning = [bool]$final.serviceRunning
processRunning = [bool]$final.processRunning
pathExists= [bool]$final.pathExists
regInstalled = [bool]$final.regInstalled
version = $final.version
alsoDetected = $also
timestampUtc = $now
detailsCompressed = $compressed
}

$outJson = $out | ConvertTo-Json -Depth 8 -Compress
$outJson