A comprehensive solution for automatically demoting unauthorized admin users on macOS while maintaining compatibility with SAP Privileges.app, complete with tamper detection and automatic remediation.
- Automatic Admin Demotion: Removes admin rights from users not on an allow-list
- Privileges.app Integration: Respects temporary admin rights granted via SAP Privileges
- Self-Healing: Automatically detects and corrects permission tampering
- Tamper Detection: SHA-256 hash verification with automatic remediation
- Audit Trail: Comprehensive logging and tracking of security events
- Jamf Pro Integration: Full MDM support with Extension Attributes and Smart Groups
- Configuration Profiles: Managed via MDM configuration profiles
- Hidden Operation: Script and files are hidden from casual view
- Architecture
- Requirements
- Installation
- Security & Integrity
- Components
- Extension Attributes
- Smart Groups
- Monitoring & Maintenance
- Troubleshooting
- Security Considerations
- License
┌─────────────────────────────────────────────────────────────┐
│ Configuration Profile │
│ (AllowedAdmins, DemoterInterval) │
└──────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Interval-Based Check │
│ (LaunchDaemon - Every 15 minutes) │
└──────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Privileges.app Trigger │
│ (Instant response via WatchPaths trigger) │
└──────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ DemoteAdmin Script │
│ • Checks allow-list │
│ • Validates Privileges.app status │
│ • Demotes unauthorized admins │
│ • Self-heals permissions │
│ • Logs all actions │
└──────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ SHA-256 Hash Verification │
│ (Daily integrity check + auto-remediation) │
└─────────────────────────────────────────────────────────────┘
- macOS 11.0 (Big Sur) or later
- Jamf Pro (for full MDM integration)
- Root/admin access for installation
- (Optional) SAP Privileges.app for temporary admin elevation
- Clone or download this repository
git clone https://github.com/AndrewMBarnett/MacAdminRemediator.git
cd MacAdminRemediator- Run the installer as root
sudo bash Scripts/DemoteAdminInstall.sh- Deploy the configuration profile (see below)
Create a configuration profile in Jamf Pro with the following settings:
Profile Name: DemoteAdmin Configuration
Payload Type: Custom Settings
Preference Domain: com.demote.adminallow
Settings:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AllowedAdmins</key>
<array>
<string>admin</string>
<string>admin2</string>
</array>
<key>DemoterInterval</key>
<integer>900</integer>
</dict>
</plist>Key Descriptions:
AllowedAdmins(Array): Usernames that are permitted to have admin rightsDemoterInterval(Integer): Check interval in seconds (default: 900 = 15 minutes)
Upload these scripts to Jamf Pro Settings → Computer Management → Scripts:
| Script Name | File | Purpose |
|---|---|---|
| Install DemoteAdmin | DemoteAdminInstall.sh |
Installs the demotion engine, LaunchDaemons, trigger system, and stores SHA-256 hashes for tamper detection |
Policy 1: Initial Deployment
- Name: Install DemoteAdmin
- Trigger: Custom event
deployDemoterOR Self Service - Frequency: Ongoing
- Scope: Target computers
- Script:
DemoteAdminInstall.sh
Policy 2: Remediation (Called automatically on tamper detection)
- Name: DemoteAdmin - Remediation
- Trigger: Custom event
redeployDemoter - Frequency: Ongoing
- Scope: All computers
- Script:
DemoteAdminInstall.sh
DemoteAdmin uses SHA-256 cryptographic hashes to detect file tampering:
# Calculates and stores hashes during deployment
SCRIPT_SHA=$(openssl sha256 "$SCRIPT_PATH" | awk '{print $2}')
defaults write "$TRACKING_PLIST" "scriptHash" "$SCRIPT_SHA"Tracked Files:
- Main demotion script
- Wrapper trigger script
- LaunchDaemon plists (interval-based and trigger-based)
Storage Location: /var/db/.systemconfig/.tracking.plist
At every run, the demotion script checks its own permissions and the permissions of all managed files. If a critical violation is detected:
# Tampering detected - trigger remediation via Jamf
jamf policy -event redeployDemoterWhen tampering is detected:
- Log Event - Record details to tamper log
- Increment Counter - Track tampering frequency
- Redeploy - Trigger Jamf policy
redeployDemoterto reinstall clean files - Update Tracking - Record remediation action
Tamper Event Log: /Library/Management/.demoter/.tamper-events
Tracking Data (stored in /var/db/.systemconfig/.tracking.plist):
tamperCount- Total tampering events detectedlastTamperDetected- Timestamp of most recent tamperingwarningAcknowledged- User acknowledgments of security warningsautoFixCount- Automatic remediation attemptsdemotionCount- Total accounts demotedlastDemotedAccount- Username most recently demotedlastDemotionTime- Timestamp of most recent demotion
/Library/Management/.demoter/ # Hidden base directory (700)
├── .demote-unlisted-admins.sh # Main demotion script (500)
├── .trigger # Trigger file for Privileges (666)
├── .tamper-events # Persistent tamper log (400)
├── .version # Installed version number (400)
└── logs/
├── demoteadmins.log # Main log (400 when locked)
└── log-archive/ # Rotated logs (700)
└── demoteadmins_<timestamp>.zip
/var/db/.systemconfig/
└── .tracking.plist # SHA-256 hashes + counters (immutable)
/Library/LaunchDaemons/
├── com.demote.demoteadmins.plist # Interval-based daemon (644)
└── com.demote.privileges-trigger.plist # Trigger-based daemon (644)
/usr/local/bin/
└── .privileges-demote-trigger # Hidden wrapper script (755)
/Library/Managed Preferences/
└── com.demote.adminallow.plist # Configuration profile
Interval-Based Daemon:
- Label:
com.demote.demoteadmins - Function: Runs demotion check every 15 minutes (configurable); also fires immediately when a new user account is created
- Triggers:
StartInterval+WatchPathson/var/db/dslocal/nodes/Default/users
Trigger-Based Daemon:
- Label:
com.demote.privileges-trigger - Function: Instant response when Privileges.app grants/revokes admin
- Trigger:
WatchPathson trigger file
| File/Directory | Permissions | Owner | Purpose |
|---|---|---|---|
| Base directory | 700 (drwx------) |
root:wheel | Hidden, root-only access |
| Main script | 500 (-r-x------) |
root:wheel | Read+execute only (no write) |
| Trigger file | 666 (-rw-rw-rw-) |
root:wheel | World-writable for user triggers |
| Wrapper | 755 (-rwxr-xr-x) |
root:wheel | Executable by all |
| Logs | 400 (-r--------) |
root:wheel | Read-only (tamper-proof) |
| LaunchDaemons | 644 (-rw-r--r--) |
root:wheel | Standard daemon permissions |
Create these Extension Attributes in Jamf Pro for monitoring:
Name: DemoteAdmin - Version
#!/bin/bash
VERSION_FILE="/Library/Management/.demoter/.version"
if [[ -f "$VERSION_FILE" ]]; then
echo "<result>$(cat $VERSION_FILE 2>/dev/null || echo "Unknown")</result>"
else
echo "<result>Not Installed</result>"
fiName: DemoteAdmin - Tamper Count
#!/bin/bash
TRACKING_PLIST="/var/db/.systemconfig/.tracking.plist"
if [[ ! -f "$TRACKING_PLIST" ]]; then
echo "<result>Not Tracked</result>"
exit 0
fi
chflags nouchg "$TRACKING_PLIST" 2>/dev/null
tamper_count=$(defaults read "$TRACKING_PLIST" "tamperCount" 2>/dev/null || echo "0")
last_tamper=$(defaults read "$TRACKING_PLIST" "lastTamperDetected" 2>/dev/null || echo "Never")
chflags uchg "$TRACKING_PLIST" 2>/dev/null
if [[ "$tamper_count" -gt 0 ]]; then
echo "<result>$tamper_count (Last: $last_tamper)</result>"
else
echo "<result>0 - Clean</result>"
fiName: DemoteAdmin - Warnings Acknowledged
File: DemoterWarningsAcknowledged.sh
#!/bin/bash
TRACKING_PLIST="/var/db/.systemconfig/.tracking.plist"
if [[ ! -f "$TRACKING_PLIST" ]]; then
echo "<result>Not Tracked</result>"
exit 0
fi
chflags nouchg "$TRACKING_PLIST" 2>/dev/null
ack_count=$(defaults read "$TRACKING_PLIST" "warningAcknowledged" 2>/dev/null || echo "0")
chflags uchg "$TRACKING_PLIST" 2>/dev/null
echo "<result>$ack_count</result>"Name: DemoteAdmin - Risk Level
File: DemoterRiskLevel.sh
#!/bin/bash
TRACKING_PLIST="/var/db/.systemconfig/.tracking.plist"
if [[ ! -f "$TRACKING_PLIST" ]]; then
echo "<result>Not Tracked</result>"
exit 0
fi
chflags nouchg "$TRACKING_PLIST" 2>/dev/null
tamper_count=$(defaults read "$TRACKING_PLIST" "tamperCount" 2>/dev/null || echo "0")
chflags uchg "$TRACKING_PLIST" 2>/dev/null
if [[ "$tamper_count" -ge 5 ]]; then
echo "<result>High Risk ($tamper_count events)</result>"
elif [[ "$tamper_count" -ge 3 ]]; then
echo "<result>Elevated ($tamper_count events)</result>"
elif [[ "$tamper_count" -ge 1 ]]; then
echo "<result>Low Risk ($tamper_count event)</result>"
else
echo "<result>Clean Record</result>"
fiName: DemoteAdmin - Demoted Accounts
File: DemoterDemotedAccounts.sh
#!/bin/bash
TRACKING_PLIST="/var/db/.systemconfig/.tracking.plist"
if [[ ! -f "$TRACKING_PLIST" ]]; then
echo "<result>Not Tracked</result>"
exit 0
fi
chflags nouchg "$TRACKING_PLIST" 2>/dev/null
demotion_count=$(defaults read "$TRACKING_PLIST" "demotionCount" 2>/dev/null || echo "0")
last_account=$(defaults read "$TRACKING_PLIST" "lastDemotedAccount" 2>/dev/null)
last_time=$(defaults read "$TRACKING_PLIST" "lastDemotionTime" 2>/dev/null)
chflags uchg "$TRACKING_PLIST" 2>/dev/null
if [[ -z "$last_account" || "$demotion_count" -eq 0 ]]; then
echo "<result>No demotions recorded</result>"
else
echo "<result>$demotion_count demotion(s) — Last: $last_account ($last_time)</result>"
fiName: DemoteAdmin - Permissions Status
#!/bin/bash
SCRIPT_PATH="/Library/Management/.demoter/.demote-unlisted-admins.sh"
TRIGGER_FILE="/Library/Management/.demoter/.trigger"
if [[ ! -f "$SCRIPT_PATH" ]]; then
echo "<result>Not Installed</result>"
exit 0
fi
ISSUES=()
SCRIPT_PERMS=$(stat -f "%Sp" "$SCRIPT_PATH" 2>/dev/null)
[[ "$SCRIPT_PERMS" != "-r-x------" ]] && ISSUES+=("Script:$SCRIPT_PERMS")
TRIGGER_PERMS=$(stat -f "%Sp" "$TRIGGER_FILE" 2>/dev/null)
[[ "$TRIGGER_PERMS" != "-rw-rw-rw-" ]] && ISSUES+=("Trigger:$TRIGGER_PERMS")
if [[ ${#ISSUES[@]} -eq 0 ]]; then
echo "<result>OK</result>"
else
echo "<result>INCORRECT: ${ISSUES[*]}</result>"
fiCreate these Smart Groups for automated management:
Criteria:
DemoteAdmin - Tamper Count | greater than or equal | 3
Criteria:
DemoteAdmin - Risk Level | like | High Risk
Criteria:
DemoteAdmin - Permissions Status | is not | OK
AND
DemoteAdmin - Permissions Status | is not | Not Installed
Criteria:
DemoteAdmin - Demoted Accounts | is not | No demotions recorded
AND
DemoteAdmin - Demoted Accounts | is not | Not Tracked
Criteria:
DemoteAdmin - Version | is | Not Installed
AND
Operating System | like | macOS
Main Log:
/Library/Management/.demoter/logs/demoteadmins.logView recent entries:
sudo tail -f /Library/Management/.demoter/logs/demoteadmins.logTamper Events:
sudo cat /Library/Management/.demoter/.tamper-eventsLogs are automatically rotated when they exceed 500KB:
- Compressed to ZIP format
- Moved to archive directory
- Original log cleared
- Rotation logged in new log file
Check LaunchDaemon status:
sudo launchctl list | grep com.demoteManually trigger demotion:
sudo /Library/Management/.demoter/.demote-unlisted-admins.shTrigger via Privileges wrapper:
touch /Library/Management/.demoter/.triggerView tracking data:
sudo chflags nouchg /var/db/.systemconfig/.tracking.plist
sudo defaults read /var/db/.systemconfig/.tracking.plist
sudo chflags uchg /var/db/.systemconfig/.tracking.plistManual remediation:
sudo bash Scripts/DemoteAdminInstall.shCheck LaunchDaemon status:
sudo launchctl print system/com.demote.demoteadmins
sudo launchctl print system/com.demote.privileges-triggerReload daemons:
sudo launchctl bootout system/com.demote.demoteadmins
sudo launchctl bootstrap system /Library/LaunchDaemons/com.demote.demoteadmins.plistVerify profile installation:
sudo profiles list | grep com.demote
sudo defaults read /Library/Managed\ Preferences/com.demote.adminallow.plistCheck trigger file permissions:
ls -la /Library/Management/.demoter/.trigger
# Should show: -rw-rw-rw-Verify wrapper exists:
ls -la /usr/local/bin/.privileges-demote-triggerTest trigger manually:
touch /Library/Management/.demoter/.trigger
sleep 3
tail /Library/Management/.demoter/logs/demoteadmins.logCheck Jamf policy trigger:
sudo jamf policy -event redeployDemoter -verboseVerify tracking plist:
sudo chflags nouchg /var/db/.systemconfig/.tracking.plist
sudo defaults read /var/db/.systemconfig/.tracking.plist
sudo chflags uchg /var/db/.systemconfig/.tracking.plistHidden Files
All critical components use hidden file naming (prefix with .) to prevent casual discovery:
- Base directory:
.demoter - Main script:
.demote-unlisted-admins.sh - Trigger file:
.trigger - Wrapper:
.privileges-demote-trigger
- Script is read-execute only (
500) — cannot be modified withoutchmodfirst - Logs are locked read-only (
400) after writing — prevents tampering - Base directory is
700— only root can access - Tracking plist is immutable (
chflags uchg) — requires flag removal before any write
Script automatically detects and corrects at every run:
- Incorrect file permissions
- Missing trigger file
- Ownership changes
- Directory permission modifications
Minor issues (trigger file) are auto-corrected. Critical issues (script or directory permissions) trigger a full reinstall via redeployDemoter.
- SHA-256 cryptographic hashing of all managed files
- Automatic remediation on detection
- Persistent event logging
- Tamper and auto-fix counters preserved across reinstalls
Every action is logged with:
- Timestamp
- Script version
- User context
- Action taken
- Result status
No. Privileges.app is optional.
- If Privileges.app is installed, the script honors active temporary admin elevation for the logged-in user within their allowed time window.
- If Privileges.app is not installed, the script fully enforces the allow-list: only users in
AllowedAdminsretain admin rights.
MIT License — See LICENSE file for details