For years, macOS compliance in Intune meant working within a fixed list. FileVault, firewall, SIP, password rules, OS version. Useful, but if your security baseline asked for anything outside that list, you were stuck explaining to your auditor why “we can’t check that from Intune.”
Custom compliance for macOS changes that. If you can read it from the device with a shell command, you can make it a compliance signal — and Conditional Access will act on it.
This post walks through what it adds over the built-in policy, how to deploy it end to end, how the rules file actually works, and — the part that will cost you the most time if you skip it — how to troubleshoot when the device reports something you didn’t expect.
What the built-in policy already covers
Before reaching for a script, it’s worth being honest about what you don’t need to build. The macOS compliance policy already handles:
| Built-in setting | Where it lives in the policy |
|---|---|
| System integrity protection | Device Health |
| Firewall | System Security |
| Stealth mode | System Security |
| Gatekeeper (allowed app sources) | System Security |
| Encryption of data storage (FileVault) | System Security |
| Require a password to unlock devices | Device Security |
| Minimum / maximum OS version and build | Device Properties |
These are maintained by Microsoft, evaluated natively, and cost you nothing to configure. Duplicating them in a custom script adds maintenance burden with no benefit — and worse, gives you two sources of truth that can disagree.
My rule: if there’s a toggle for it, use the toggle. Custom compliance is for the gaps.
What custom compliance adds
Everything else. It works in two parts:
- A discovery script — bash, running on the device as root, that outputs a single line of JSON with your settings as key/value pairs.
- A JSON rules file — uploaded to the compliance policy, telling Intune what value each key must have to be compliant, and what message the end user sees when it isn’t.
The script runs locally. The rules are evaluated in the cloud. Anything you can query with defaults, systemsetup, launchctl, pmset, sysadminctl, or any other CLI becomes a compliance signal.
That opens up a lot: sharing services, software update behaviour, screen saver and lock screen enforcement, guest account and login window configuration, Apple Intelligence restrictions, and the state of third-party agents.
Part 1: Deploying it end to end
The flow trips people up because the script and the policy are configured in two different places, and the policy only offers you scripts that already exist.
Step 1 — Upload the discovery script first
- Sign into Microsoft Intune admin center and go to Endpoint security > Device compliance > Scripts > Add > (choose macOS platform).


- On Basics, provide a Name.

- On Settings, add your script to Detection script. Review your script carefully. Intune doesn’t validate the script for syntax or programmatic errors.


Requirements for the script itself:
- Valid shebang (
#!/bin/bash) - UTF-8 encoded, no BOM
- Exit code 0 for success, non-zero for failure
- Output exactly one line of JSON to stdout
That last point is stricter than it sounds. Any stray echo for debugging, any command that writes to stdout unexpectedly, and your JSON is malformed. This is one of the reasons I log to a file rather than to stdout — more on that below.
Step 2 — Reference it from the compliance policy
Now go to Devices > Compliance > Policies, create or edit a macOS policy, and expand Custom Compliance.


- Set Custom compliance to Require

- Select your discovery script — click to select, and your uploaded script appears in the list

- Upload and validate the JSON file with your custom compliance settings


If the script picker is empty, you skipped step 1. The policy will not let you upload a script from here.
Step 3 — Configure the built-in settings alongside
In the same policy, set the built-in toggles for the things you’re not covering in script: system integrity protection, firewall, Gatekeeper, encryption, password to unlock. These evaluate independently and appear in the same results view.

Step 4 — Assign and wait
Assign to a pilot group. The macOS agent evaluates on its sync cycle; you can force it from Company Portal, but give it a few minutes either way.
Step 5 — Read the results
Devices > Compliance > Policies > [your policy] > Device compliance, then click a device. You’ll see every setting listed by name with Compliant / Not compliant beside it — your custom keys and the built-in settings together in one list.

This is where naming your keys well pays off. CIS_2_3_3_4_RemoteLoginDisabled tells an auditor exactly which benchmark line failed. check12 tells them nothing.



Part 2: The JSON rules file in depth
The rules file is a single JSON object with a Rules array. Each rule maps one key from your script’s output to a condition.
json
{ "Rules": [ { "SettingName": "CIS_2_3_3_4_RemoteLoginDisabled", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://www.cisecurity.org/benchmark/apple_os", "RemediationStrings": [ { "Language": "en_US", "Title": "CIS 2.3.3.4 - Remote Login (SSH) Is Disabled", "Description": "Disable Remote Login: System Settings > General > Sharing > Remote Login (off)." } ] } ]}
The fields
SettingName must match the key in your JSON output character for character. Case-sensitive. A typo here doesn’t throw an error — the setting simply never evaluates, and you’ll spend twenty minutes wondering why one check is missing from the portal.
DataType — Boolean, String, Int64, Double, Version, or DateTime. It must match what your script actually outputs. A common mistake: outputting "true" (quoted, a string) and declaring Boolean. Emit true unquoted for Boolean.
Operator — the ones you’ll use most:
| Operator | Typical use |
|---|---|
IsEquals / NotEquals | Boolean and String checks |
GreaterEquals / LessEquals | Numeric thresholds — timeouts, counts, days |
GreaterThan / LessThan | Strict numeric comparison |
Between | Numeric range (Operand takes two values) |
AllOf / OneOf / NoneOf | Membership against a list of allowed values |
Operand is the value compared against. For Boolean, true or false unquoted.
MoreInfoUrl becomes a clickable link for the end user. Point it somewhere genuinely useful — your internal remediation guidance is better than a vendor page.
RemediationStrings is what the user sees in Company Portal. You can supply multiple language blocks; the device picks the match for its locale.
Design decisions worth making early
Booleans over raw values. You could emit "ScreenSaverIdleTime": 1200 and use LessEquals in the rules. I prefer evaluating in the script and emitting true/false. The logic lives in one place, and if the CIS threshold changes you edit the script rather than re-uploading a rules file. The tradeoff: the portal shows you pass/fail, not the actual value — which is exactly why the script needs its own logging.
Write remediation strings for the person reading them. “Non-compliant” helps nobody. “System Settings > General > Sharing > Remote Login (off)” lets a user fix it without a ticket. If the fix requires admin rights, say so, and say who to contact.
Validate before you upload. Intune validates on upload, but a local check catches problems faster:
bash
python3 -m json.tool CIS-L1-Tahoe-Compliance.json > /dev/null && echo "valid"
{ "Rules": [ { "SettingName": "CIS_1_2_AutoDownloadUpdates", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://www.cisecurity.org/benchmark/apple_os", "RemediationStrings": [ { "Language": "en_US", "Title": "CIS 1.2 - Download New Updates When Available Is Enabled \u2014 device is non-compliant.", "Description": "Enable automatic download of new updates: System Settings > General > Software Update > Automatic Updates, or deploy a Software Update policy from Intune." } ] }, { "SettingName": "CIS_1_3_InstallMacOSUpdates", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://www.cisecurity.org/benchmark/apple_os", "RemediationStrings": [ { "Language": "en_US", "Title": "CIS 1.3 - Install of macOS Updates Is Enabled \u2014 device is non-compliant.", "Description": "Enable automatic installation of macOS updates via System Settings > General > Software Update, or an Intune Software Update policy." } ] }, { "SettingName": "CIS_1_4_InstallAppStoreUpdates", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://www.cisecurity.org/benchmark/apple_os", "RemediationStrings": [ { "Language": "en_US", "Title": "CIS 1.4 - Install Application Updates from the App Store Is Enabled \u2014 device is non-compliant.", "Description": "Enable automatic App Store app updates via System Settings > General > Software Update." } ] }, { "SettingName": "CIS_1_5_InstallSecurityResponses", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://www.cisecurity.org/benchmark/apple_os", "RemediationStrings": [ { "Language": "en_US", "Title": "CIS 1.5 - Install Security Responses and System Files Is Enabled \u2014 device is non-compliant.", "Description": "Enable automatic installation of Security Responses and system files via System Settings > General > Software Update." } ] }, { "SettingName": "CIS_1_6_UpdateDefermentMax30Days", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://www.cisecurity.org/benchmark/apple_os", "RemediationStrings": [ { "Language": "en_US", "Title": "CIS 1.6 - Software Update Deferment Is 30 Days or Less \u2014 device is non-compliant.", "Description": "Reduce the enforced software update deferral (enforcedSoftwareUpdateDelay) to 30 days or less in your device restrictions profile." } ] }, { "SettingName": "CIS_2_3_1_2_AirPlayReceiverDisabled", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://www.cisecurity.org/benchmark/apple_os", "RemediationStrings": [ { "Language": "en_US", "Title": "CIS 2.3.1.2 - AirPlay Receiver Is Disabled \u2014 device is non-compliant.", "Description": "Disable AirPlay Receiver: System Settings > General > AirDrop & Handoff, or enforce via configuration profile." } ] }, { "SettingName": "CIS_2_3_2_1_NetworkTimeEnabled", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://www.cisecurity.org/benchmark/apple_os", "RemediationStrings": [ { "Language": "en_US", "Title": "CIS 2.3.2.1 - Set Time and Date Automatically Is Enabled \u2014 device is non-compliant.", "Description": "Enable automatic date and time: System Settings > General > Date & Time > Set time and date automatically." } ] }, { "SettingName": "CIS_2_3_3_1_ScreenSharingDisabled", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://www.cisecurity.org/benchmark/apple_os", "RemediationStrings": [ { "Language": "en_US", "Title": "CIS 2.3.3.1 - Screen Sharing Is Disabled \u2014 device is non-compliant.", "Description": "Disable Screen Sharing: System Settings > General > Sharing > Screen Sharing (off)." } ] }, { "SettingName": "CIS_2_3_3_2_FileSharingDisabled", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://www.cisecurity.org/benchmark/apple_os", "RemediationStrings": [ { "Language": "en_US", "Title": "CIS 2.3.3.2 - File Sharing Is Disabled \u2014 device is non-compliant.", "Description": "Disable File Sharing: System Settings > General > Sharing > File Sharing (off)." } ] }, { "SettingName": "CIS_2_3_3_3_PrinterSharingDisabled", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://www.cisecurity.org/benchmark/apple_os", "RemediationStrings": [ { "Language": "en_US", "Title": "CIS 2.3.3.3 - Printer Sharing Is Disabled \u2014 device is non-compliant.", "Description": "Disable Printer Sharing: System Settings > General > Sharing > Printer Sharing (off)." } ] }, { "SettingName": "CIS_2_3_3_4_RemoteLoginDisabled", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://www.cisecurity.org/benchmark/apple_os", "RemediationStrings": [ { "Language": "en_US", "Title": "CIS 2.3.3.4 - Remote Login (SSH) Is Disabled \u2014 device is non-compliant.", "Description": "Disable Remote Login: System Settings > General > Sharing > Remote Login (off)." } ] }, { "SettingName": "CIS_2_3_3_5_RemoteManagementDisabled", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://www.cisecurity.org/benchmark/apple_os", "RemediationStrings": [ { "Language": "en_US", "Title": "CIS 2.3.3.5 - Remote Management Is Disabled \u2014 device is non-compliant.", "Description": "Disable Remote Management: System Settings > General > Sharing > Remote Management (off)." } ] }, { "SettingName": "CIS_2_3_3_6_RemoteAppleEventsDisabled", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://www.cisecurity.org/benchmark/apple_os", "RemediationStrings": [ { "Language": "en_US", "Title": "CIS 2.3.3.6 - Remote Apple Events Is Disabled \u2014 device is non-compliant.", "Description": "Disable Remote Apple Events: System Settings > General > Sharing > Remote Apple Events (off)." } ] }, { "SettingName": "CIS_2_3_3_7_InternetSharingDisabled", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://www.cisecurity.org/benchmark/apple_os", "RemediationStrings": [ { "Language": "en_US", "Title": "CIS 2.3.3.7 - Internet Sharing Is Disabled \u2014 device is non-compliant.", "Description": "Disable Internet Sharing: System Settings > General > Sharing > Internet Sharing (off)." } ] }, { "SettingName": "CIS_2_3_3_10_BluetoothSharingDisabled", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://www.cisecurity.org/benchmark/apple_os", "RemediationStrings": [ { "Language": "en_US", "Title": "CIS 2.3.3.10 - Bluetooth Sharing Is Disabled \u2014 device is non-compliant.", "Description": "Disable Bluetooth Sharing: System Settings > General > Sharing > Bluetooth Sharing (off)." } ] }, { "SettingName": "CIS_2_5_1_1_ExternalIntelligenceDisabled", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://www.cisecurity.org/benchmark/apple_os", "RemediationStrings": [ { "Language": "en_US", "Title": "CIS 2.5.1.1 - External Intelligence Extensions Are Disabled \u2014 device is non-compliant.", "Description": "Deploy a device restrictions profile setting allowExternalIntelligenceIntegrations to false (com.apple.applicationaccess)." } ] }, { "SettingName": "CIS_2_5_1_2_WritingToolsDisabled", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://www.cisecurity.org/benchmark/apple_os", "RemediationStrings": [ { "Language": "en_US", "Title": "CIS 2.5.1.2 - Writing Tools Is Disabled \u2014 device is non-compliant.", "Description": "Deploy a device restrictions profile setting allowWritingTools to false (com.apple.applicationaccess)." } ] }, { "SettingName": "CIS_2_5_1_3_MailSummarizationDisabled", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://www.cisecurity.org/benchmark/apple_os", "RemediationStrings": [ { "Language": "en_US", "Title": "CIS 2.5.1.3 - Mail Summarization Is Disabled \u2014 device is non-compliant.", "Description": "Deploy a device restrictions profile setting allowMailSummary to false (com.apple.applicationaccess)." } ] }, { "SettingName": "CIS_2_5_1_4_NotesSummarizationDisabled", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://www.cisecurity.org/benchmark/apple_os", "RemediationStrings": [ { "Language": "en_US", "Title": "CIS 2.5.1.4 - Notes Summarization Is Disabled \u2014 device is non-compliant.", "Description": "Deploy a device restrictions profile setting allowNotesTranscriptionSummary to false (com.apple.applicationaccess)." } ] }, { "SettingName": "CIS_2_10_3_WakeForNetworkAccessDisabled", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://www.cisecurity.org/benchmark/apple_os", "RemediationStrings": [ { "Language": "en_US", "Title": "CIS 2.10.3 - Wake for Network Access Is Disabled \u2014 device is non-compliant.", "Description": "Disable Wake for network access: System Settings > Battery/Energy > Options, or run: sudo pmset -a womp 0." } ] }, { "SettingName": "CIS_2_11_1_ScreenSaverMax15Min", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://www.cisecurity.org/benchmark/apple_os", "RemediationStrings": [ { "Language": "en_US", "Title": "CIS 2.11.1 - Screen Saver Inactivity Interval Is 15 Minutes or Less \u2014 device is non-compliant.", "Description": "Set screen saver to start after 15 minutes or less: System Settings > Lock Screen, or enforce via configuration profile." } ] }, { "SettingName": "CIS_2_11_2_PasswordAfterScreenSaver", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://www.cisecurity.org/benchmark/apple_os", "RemediationStrings": [ { "Language": "en_US", "Title": "CIS 2.11.2 - Require Password After Screen Saver Within 5 Seconds \u2014 device is non-compliant.", "Description": "Require password immediately (or within 5 seconds) after screen saver begins: System Settings > Lock Screen, or enforce via configuration profile." } ] }, { "SettingName": "CIS_2_11_5_PasswordHintsDisabled", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://www.cisecurity.org/benchmark/apple_os", "RemediationStrings": [ { "Language": "en_US", "Title": "CIS 2.11.5 - Show Password Hints Is Disabled \u2014 device is non-compliant.", "Description": "Disable password hints at the login window: set RetriesUntilHint to 0 via configuration profile (com.apple.loginwindow)." } ] }, { "SettingName": "CIS_2_13_1_GuestAccountDisabled", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://www.cisecurity.org/benchmark/apple_os", "RemediationStrings": [ { "Language": "en_US", "Title": "CIS 2.13.1 - Guest Account Is Disabled \u2014 device is non-compliant.", "Description": "Disable the Guest account: System Settings > Users & Groups, or enforce DisableGuestAccount via configuration profile." } ] }, { "SettingName": "CIS_2_13_2_GuestSharedFoldersDisabled", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://www.cisecurity.org/benchmark/apple_os", "RemediationStrings": [ { "Language": "en_US", "Title": "CIS 2.13.2 - Guest Access to Shared Folders Is Disabled \u2014 device is non-compliant.", "Description": "Disable guest access to shared folders: run sudo sysadminctl -smbGuestAccess off, or enforce via configuration profile." } ] }, { "SettingName": "MDE_Onboarded", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://learn.microsoft.com/en-us/defender-endpoint/mac-health-status", "RemediationStrings": [ { "Language": "en_US", "Title": "Microsoft Defender for Endpoint - Device Is Onboarded \u2014 device is non-compliant.", "Description": "Defender for Endpoint is not installed or not licensed on this device. Install/onboard Defender via Intune and verify with: mdatp health --field licensed." } ] }, { "SettingName": "MDE_Healthy", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://learn.microsoft.com/en-us/defender-endpoint/mac-health-status", "RemediationStrings": [ { "Language": "en_US", "Title": "Microsoft Defender for Endpoint - Agent Is Healthy \u2014 device is non-compliant.", "Description": "The Defender agent reports an unhealthy state. Run: mdatp health, and resolve reported issues (e.g., missing Full Disk Access or network extension approval)." } ] }, { "SettingName": "MDE_RealTimeProtectionOn", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://learn.microsoft.com/en-us/defender-endpoint/mac-health-status", "RemediationStrings": [ { "Language": "en_US", "Title": "Microsoft Defender for Endpoint - Real-Time Protection Is Enabled \u2014 device is non-compliant.", "Description": "Real-time protection is disabled. Enable it via your Defender security settings policy, or locally: mdatp config real-time-protection --value enabled." } ] }, { "SettingName": "MDE_NoActiveThreats", "Operator": "IsEquals", "DataType": "Boolean", "Operand": true, "MoreInfoUrl": "https://learn.microsoft.com/en-us/defender-endpoint/mac-health-status", "RemediationStrings": [ { "Language": "en_US", "Title": "Microsoft Defender for Endpoint - No Active Threats Detected \u2014 device is non-compliant.", "Description": "Defender has detected threats on this device. Review and remediate them (mdatp threat list) before the device can return to compliance." } ] } ]}
Part 3: A worked example — CIS Level 1
To make this concrete, I built a discovery script covering CIS Apple macOS 26 Tahoe Benchmark Level 1 — specifically the recommendations the built-in policy doesn’t reach.
The check inventory
| CIS ID | Check | How it’s read |
|---|---|---|
| 1.2 | Download new updates when available | defaults read com.apple.SoftwareUpdate AutomaticDownload |
| 1.3 | Install macOS updates | AutomaticallyInstallMacOSUpdates + managed domain |
| 1.4 | Install App Store app updates | defaults read com.apple.commerce AutoUpdate |
| 1.5 | Install Security Responses and system files | ConfigDataInstall + CriticalUpdateInstall |
| 1.6 | Software update deferment ≤ 30 days | managed enforcedSoftwareUpdateDelay |
| 2.3.1.2 | AirPlay Receiver disabled | user com.apple.controlcenter AirplayRecieverEnabled |
| 2.3.2.1 | Set time and date automatically | systemsetup -getusingnetworktime |
| 2.3.3.1 | Screen Sharing disabled | launchctl print-disabled system |
| 2.3.3.2 | File Sharing disabled | print-disabled + launchctl list fallback |
| 2.3.3.3 | Printer Sharing disabled | cupsctl |
| 2.3.3.4 | Remote Login disabled | systemsetup -getremotelogin |
| 2.3.3.5 | Remote Management disabled | ARDAgent process check |
| 2.3.3.6 | Remote Apple Events disabled | systemsetup -getremoteappleevents |
| 2.3.3.7 | Internet Sharing disabled | com.apple.nat plist |
| 2.3.3.10 | Bluetooth Sharing disabled | user com.apple.Bluetooth PrefKeyServicesEnabled |
| 2.5.1.1 | External Intelligence disabled | managed allowExternalIntelligenceIntegrations |
| 2.5.1.2 | Writing Tools disabled | managed allowWritingTools |
| 2.5.1.3 | Mail Summarization disabled | managed allowMailSummary |
| 2.5.1.4 | Notes Summarization disabled | managed allowNotesTranscriptionSummary |
| 2.10.3 | Wake for network access disabled | pmset -g custom (AC power) |
| 2.11.1 | Screen saver ≤ 15 minutes | com.apple.screensaver idleTime |
| 2.11.2 | Password after screen saver ≤ 5 sec | askForPassword + askForPasswordDelay |
| 2.11.5 | Password hints disabled | com.apple.loginwindow RetriesUntilHint |
| 2.13.1 | Guest account disabled | com.apple.loginwindow GuestEnabled |
| 2.13.2 | Guest access to shared folders disabled | sysadminctl -smbGuestAccess status |
Note the variety in that right-hand column. Some settings live in a system plist, some in a user plist, some only exist as a managed preference, and some aren’t in a plist at all — you have to ask a running service. That inconsistency is the real work in writing one of these.
The script pattern
Read, evaluate, store:
raw=$(systemsetup -getremotelogin 2>/dev/null)echo "$raw" | grep -q "Off" && c_2_3_3_4="true" || c_2_3_3_4="false"
Then emit everything as one line:
echo "{\"CIS_2_3_3_4_RemoteLoginDisabled\":$c_2_3_3_4, ... }"exit 0
Naming keys after the CIS recommendation ID means the portal results read like a benchmark report, and anyone auditing can trace a red row straight to a line item.
Part 4: Bringing Defender for Endpoint into compliance
Custom compliance also lets you surface signals from other agents on the device. The Defender CLI exposes health state locally:
mdatp health --field licensedmdatp health --field org_idmdatp health --field healthymdatp health --field real_time_protection_enabledmdatp threat list
Four checks fall out of that naturally:
| Key | What it proves |
|---|---|
MDE_Onboarded | Licensed and has an org ID — genuinely onboarded, not just installed |
MDE_Healthy | Agent reports healthy (catches missing Full Disk Access, unapproved network extension) |
MDE_RealTimeProtectionOn | RTP hasn’t been disabled |
MDE_NoActiveThreats | Nothing currently detected on the device |
MDATP="/usr/local/bin/mdatp"if [[ -x "$MDATP" ]]; then lic=$("$MDATP" health --field licensed 2>/dev/null | tr -d '"') org=$("$MDATP" health --field org_id 2>/dev/null | tr -d '"') [[ "$lic" == "true" && -n "$org" ]] && mde_onboarded="true" || mde_onboarded="false"else mde_onboarded="false"fi
If the binary doesn’t exist, everything reports false — which is correct. A device without Defender installed is not a compliant device.
Local detections vs portal risk score — know which you’re using
This distinction matters, and it’s easy to blur.
What this script reads: on-device state from the Defender agent. mdatp threat list returns threats the local agent has found. It’s immediate, requires no connector, and works regardless of your Defender-Intune integration status.
What it does not read: the machine risk score from the Defender portal. That score incorporates cloud analysis, incident correlation, and signals from across your estate — things the device itself has no idea about. For that, you want the Defender for Endpoint connector under Endpoint Security, which feeds risk level into a built-in compliance setting.
They’re complementary, not alternatives:
- Connector / risk score — better signal quality, cloud-correlated, but depends on the integration being healthy and on the device reporting to the Defender service
- Local CLI checks — narrower, but direct: agent health, RTP state, and current on-device detections, read straight from the endpoint
Running both means a device gets flagged whether the threat was caught by cloud correlation or is simply sitting on disk right now. And the agent-health checks catch something the risk score never will: an agent that is installed but broken and therefore reporting nothing at all.
The Conditional Access consequence
Once these are in your compliance policy, a local Defender detection flows through to access control: threat detected → MDE_NoActiveThreats: false → device non-compliant → CA blocks access to resources.
Test this deliberately before you rely on it. The chain has several links — script runs on sync, results upload, compliance re-evaluates, CA picks up the state change — and each adds latency. This is not real-time blocking. Know what your actual detection-to-block window looks like before you tell anyone it’s a control.
Part 5: Troubleshooting — the part that will actually cost you time
Here’s what I ran into, and it’s the reason I’d build logging into any discovery script from day one.
I tested in Terminal. Everything looked right. I deployed, the device synced, and several settings came back non-compliant that had reported clean locally.
What the agent log tells you
The macOS agent logs to /Library/Logs/Microsoft/Intune/. Note the filename contains a space, so quote your paths:
sudo grep -i "compliance" "/Library/Logs/Microsoft/Intune/IntuneMDMDaemon 2026-08-03--09-25-56-615.log"
You’ll find the execution record:
ScriptPolicyRunner | Running custom compliance script policy PolicyID: ccbc6c59-..., ExecutionContext: root, ExecutionFrequency: 0ScriptPolicyRunner | Custom compliance script policy ran PolicyID: ccbc6c59-..., TotalRetries: 0, Status: Success, ExitCode: 0ExecutionClock | Policy measurement. Context: macComplianceScript, Duration: 1.4154269695281982, Status: success
Between those lines, ScriptOrchestrationLogger narrates each stage: starting the runtime, writing the script, reading the output stream, reading the error stream, waiting for exit, cleaning up handles. If a script hangs or times out, this sequence shows you exactly where it stopped.
Useful. But note what’s missing: stdout never appears in this log. You learn that the script ran and exited 0. You do not learn what it said, or why any individual check failed.
One more line worth knowing:
ScriptPolicyRunner | Script advisory on custom compliance script policy Description: [sudo usage]
That’s the agent noticing your script calls sudo. It doesn’t block execution, but it’s a hint that you’re doing something context-dependent — see the user domain section below.
Make the script log itself
Since the daemon won’t tell you values, the script has to. A few lines:
LOGFILE="/Library/Logs/IntuneCISCompliance.log"log() { echo "$(date '+%Y-%m-%d %H:%M:%S') | $1" >> "$LOGFILE"; }logcheck() { log " $1 | raw='$2' | result=$3"; }
Log to a file, never to stdout — stdout is reserved for your JSON, and anything else corrupts it.
Add a run header with the execution context:
RUN START | user=root | consoleUser=... | tty=no | PATH=/usr/bin:/bin:/usr/sbin:/sbin
tty=no and that minimal PATH explain an entire category of “works in Terminal, fails via Intune” problems. The agent runs your script through launchd: no login session, no interactive terminal, stripped environment. Any command you call by bare name that isn’t on that PATH will silently fail.
Log the submitted JSON at the end too, so you can compare exactly what the device sent against what the portal shows.
And rotate it, or you’ll be the person who filled a fleet’s boot volumes with a compliance log:
if [[ -f "$LOGFILE" ]]; then size=$(stat -f%z "$LOGFILE" 2>/dev/null || echo 0) [[ "$size" -gt 1048576 ]] && mv "$LOGFILE" "${LOGFILE}.1"fi
What the raw values immediately revealed
First run with logging in place:
CIS_2_3_3_1_ScreenSharingDisabled | raw='"com.apple.screensharing" => enabled' | result=falseCIS_2_3_3_2_FileSharingDisabled | raw='' | result=false
Two failures, two completely different causes.
The first is a genuine finding — screen sharing is on, the check worked, go remediate.
The second is a broken check. launchctl print-disabled didn’t list smbd at all, and my logic treated absence as “enabled.” An empty raw value means the check didn’t read anything, which is not the same as reading a bad value. Without the raw string in the log, both look identical in the portal, and you’d waste an afternoon remediating a setting that was already fine.
The fix — don’t treat absence as a result:
if echo "$raw" | grep -q "true"; then c_2_3_3_2="true"elif echo "$raw" | grep -q "enabled\|false"; then c_2_3_3_2="false"else # Not in print-disabled output — check whether smbd is actually loaded if launchctl list 2>/dev/null | grep -q "com.apple.smbd"; then c_2_3_3_2="false"; raw="not listed; smbd loaded" else c_2_3_3_2="true"; raw="not listed; smbd not loaded" fifi
Note that the fallback also writes what it did into raw, so the log tells you which path the logic took.
The same pattern caught a second bug. My pmset check was matching across all power sources:
CIS_2_10_3_WakeForNetworkAccessDisabled | raw='0,1,' | result=false
Battery 0, AC 1 — but grepping for “1” across the whole string can’t tell you which is which. Parsing AC power specifically gives a value you can actually act on:
CIS_2_10_3_WakeForNetworkAccessDisabled (pmset womp, AC power) | raw='1' | result=false
Same verdict, but now it’s a verdict you can trust.
Reproducing the agent’s environment
If you want to test the way the daemon runs it:
sudo env -i /bin/bash /path/to/yourscript.sh
env -i strips the environment. If the output differs from a normal Terminal run, you’ve found an environment dependency — usually a command not on the minimal PATH, or something that needs a user session.
The user domain trap
Several CIS recommendations live in the user preference domain — screen saver timeout, AirPlay Receiver, Bluetooth sharing. Your script runs as root, so a plain defaults read won’t find them.
You can read them as the console user:
consoleUser=$(stat -f%Su /dev/console)sudo -u "$consoleUser" defaults -currentHost read com.apple.screensaver idleTime
Two caveats. That’s what triggers the [sudo usage] advisory. And if nobody is logged in, there is no console user and the read returns empty — which, if your logic treats empty as non-compliant, means every unattended device fails.
For settings CIS expects to be enforced by profile anyway, read the managed domain instead:
osascript -l JavaScript \ -e "$.NSUserDefaults.alloc.initWithSuiteName('com.apple.SoftwareUpdate').objectForKey('AutomaticallyInstallMacOSUpdates').js"
This reflects what you actually enforce rather than what the user happened to click, and it doesn’t depend on anyone being logged in.
Whichever you use, make the log distinguish “not enforced” from “enforced to the wrong value”:
CIS_2_11_5_PasswordHintsDisabled | raw='unset(default=3)' | result=false
Both are non-compliant. But one needs a profile deployed and the other needs a profile corrected, and you want to know which before you start.
Confirming a finding is real
When the managed reads for software update came back empty, the question was whether the check was broken or the setting genuinely absent. Two commands settle it:
sudo defaults read /Library/Managed\ Preferences/com.apple.SoftwareUpdatesudo profiles show -type configuration | grep -A3 -i "softwareupdate"
Both empty meant no Software Update payload was deployed at all. Script correct, finding real, fix is a profile.
That’s the loop worth internalising: portal says red → check your own log for the raw value → if raw is empty, confirm on-device whether the setting exists → then decide whether you’re fixing code or deploying a profile.
Detection is not remediation
Worth stating plainly, because it’s easy to get excited about custom compliance and forget: a discovery script tells you a setting is wrong. It doesn’t fix it.
When my script reported eleven failures, every one was accurate. The remedy wasn’t better code — it was deploying settings catalog profiles for software update behaviour, screen saver enforcement, login window configuration, wake-for-network, and Apple Intelligence restrictions.
Keep the two jobs separate:
- Settings catalog enforces the configuration
- Custom compliance verifies it and gates access through Conditional Access
If you find yourself writing remediation logic into a discovery script, you’re solving the problem in the wrong place. Discovery scripts should be read-only. A script that changes device state during a compliance evaluation is a script that will eventually change it at the worst possible moment.
Getting started
- Decide what the built-in policy already covers and don’t duplicate it.
- Write your discovery script. One line of JSON to stdout, exit 0, logging to a file. Build the logging in before you deploy, not after you’re confused.
- Test it the way the agent runs it:
sudo env -i /bin/bash yourscript.sh. Differences from a Terminal run are environment dependencies you need to fix now. - Validate your rules JSON locally before uploading, and check every
SettingNameagainst your script output character for character. - Upload the script to Endpoint security > Device compliance > Scripts, then reference it from the policy with the rules file.
- Assign to a pilot group, sync, and read your own log after the first run.
- Build settings catalog profiles for whatever comes back red.
- Re-run and confirm the log now shows the enforced values, not empty reads.
The built-in policy still does the heavy lifting for the fundamentals. Custom compliance is what you reach for when your baseline asks a question Intune doesn’t have a checkbox for — and now, on macOS, that answer is finally “yes, we can check that.”
Discovery Script:
https://github.com/pathaksomesh06/scripts/blob/main/CIS-L1-Tahoe-Discovery.sh
Compliance json:
https://github.com/pathaksomesh06/scripts/blob/main/CIS-L1-Tahoe-Compliance.json