#!/bin/zsh # ============================================================================= # Odd$ Guard for Mac - "Who can get into my Mac?" access audit for macOS. # # Read-only scan. Looks for remote-control software, hidden or extra admin # accounts, background items that start with the Mac, weakened security # settings, device-management profiles and network redirects. Writes a # plain-English HTML report and a JSON file to ./Reports next to this script. # Changes nothing on the Mac. # # Run it from Terminal: zsh OddsGuard.zsh # --demo Build a sample report with example findings, without scanning. # --no-open Do not open the report when the scan finishes. # --output DIR Folder for the report (default: Reports next to this script). # --limited Skip the offer to run with administrator rights. # # Favorable Odds Tech Solutions - https://favorableodds.io # Built for macOS 13 Ventura and later (zsh 5.8+). Older versions may skip checks. # ============================================================================= emulate -R zsh setopt NULL_GLOB EXTENDED_GLOB NO_NOMATCH PIPE_FAIL zmodload zsh/datetime BRAND='Odd$ Guard' COMPANY='Favorable Odds Tech Solutions' WEBSITE='https://favorableodds.io' VERSION='0.1.0' # Every file this script reads is prefixed with $R. It is empty on a real Mac; the automated # tests point it at a folder of sample files so the scan can be checked without a Mac. R=${ODDS_GUARD_ROOT:-} SCRIPT_PATH=${0:A} SCRIPT_DIR=${SCRIPT_PATH:h} DEMO=0; NO_OPEN=0; LIMITED=0; OUTPUT_DIR='' ORIG_ARGS=("$@") while (( $# )); do case $1 in --demo) DEMO=1 ;; --no-open) NO_OPEN=1 ;; --limited) LIMITED=1 ;; --output) shift; OUTPUT_DIR=${1:-} ;; --help|-h) print -r -- "Usage: zsh OddsGuard.zsh [--demo] [--no-open] [--limited] [--output DIR]"; exit 0 ;; *) print -r -- "Unknown option: $1 (try --help)"; exit 2 ;; esac shift done [[ -z $OUTPUT_DIR ]] && OUTPUT_DIR="$SCRIPT_DIR/Reports" US=$'\x1f' # separates a key from its value RS=$'\x1e' # separates one key/value pair (or table cell) from the next # ----------------------------------------------------------------------------- # Small helpers # ----------------------------------------------------------------------------- step() { print -r -- " - $1" } lc() { print -rn -- ${(L)1} } plural() { (( $1 == 1 )) && print -rn -- "$1 $2" || print -rn -- "$1 $3" } trim() { local s=$1; s=${s##[[:space:]]#}; s=${s%%[[:space:]]#}; print -rn -- $s } # HTML-encode everything that came from the Mac before it goes into the report. h() { local s=$1 s=${s//&/&}; s=${s///>}; s=${s//\"/"}; s=${s//\'/'} print -rn -- $s } # JSON string, quotes included. j() { local s=$1 s=${s//\\/\\\\}; s=${s//\"/\\\"}; s=${s//$'\n'/\\n}; s=${s//$'\r'/\\r}; s=${s//$'\t'/\\t} s=${s//[[:cntrl:]]/} print -rn -- "\"$s\"" } # Read one value from a property list (or JSON) file. Empty when missing. pl() { plutil -extract "$2" raw -o - "$1" 2>/dev/null } # True when the key exists and is not false/0 (works for KeepAlive dictionaries too). pl_on() { local v v=$(plutil -extract "$2" json -o - "$1" 2>/dev/null) || return 1 [[ -n $v && $v != false && $v != 0 ]] } # Print each item of an array value, one per line. pl_list() { local i=0 v while v=$(plutil -extract "$2.$i" raw -o - "$1" 2>/dev/null); do print -r -- $v; (( i++ )); (( i > 500 )) && break done } # "Signed by ", "Signed by Apple", "Not signed" or "File not found". Cached per path. typeset -A SIGNER_CACHE signer() { local p=$1 out auth [[ -z $p ]] && { print -rn -- 'Unknown'; return } if [[ -n ${SIGNER_CACHE[$p]-} ]]; then print -rn -- ${SIGNER_CACHE[$p]}; return; fi if [[ $p != /* ]]; then p=${commands[$p]:-$p}; fi if [[ ! -e $R$p ]]; then SIGNER_CACHE[$1]='File not found' else out=$(codesign -dv --verbose=2 "$R$p" 2>&1) auth=${${(M)${(f)out}:#Authority=*}[1]#Authority=} if [[ -n $auth ]]; then case $auth in 'Software Signing'|'Apple Mac OS Application Signing') auth='Apple' ;; 'Developer ID Application: '*) auth=${${auth#Developer ID Application: }% \(*\)} ;; 'Apple Development: '*|'Apple Distribution: '*) auth=${${auth#*: }% \(*\)} ;; esac SIGNER_CACHE[$1]="Signed by $auth" elif [[ $out == *'Signature=adhoc'* ]]; then SIGNER_CACHE[$1]='Not signed' elif [[ $out == *'not signed at all'* ]]; then SIGNER_CACHE[$1]='Not signed' else SIGNER_CACHE[$1]='Unknown' fi fi print -rn -- ${SIGNER_CACHE[$1]} } # ----------------------------------------------------------------------------- # Results # ----------------------------------------------------------------------------- typeset -a F_LEVEL F_AREA F_TITLE F_WHY F_ACTION F_DETAILS typeset -a C_AREA C_NAME C_RESULT C_STATUS typeset -a INV_ACCOUNTS INV_STARTUP INV_CRON INV_EXT INV_PROFILES INV_ACCOUNTS_COLS=(Account Enabled Administrator 'Hidden from sign-in' 'User ID') INV_STARTUP_COLS=(Name 'Starts' Command Publisher) INV_CRON_COLS=(User Schedule Command) INV_EXT_COLS=(Browser 'Mac user' Profile Extension ID) INV_PROFILES_COLS=(Profile Scope) # add_finding LEVEL AREA TITLE WHY ACTION [key value]... add_finding() { local d='' k v F_LEVEL+=($1); F_AREA+=($2); F_TITLE+=($3); F_WHY+=($4); F_ACTION+=($5); shift 5 while (( $# >= 2 )); do k=$1; v=$2; shift 2; d+="$k$US$v$RS"; done F_DETAILS+=("$d") } # add_check AREA NAME RESULT [Clear|Flagged|Skipped] add_check() { C_AREA+=($1); C_NAME+=($2); C_RESULT+=($3); C_STATUS+=(${4:-Clear}) } row() { local IFS=$RS; print -rn -- "$*" } # ----------------------------------------------------------------------------- # Detection data # ----------------------------------------------------------------------------- # Tier Consumer = remote-control apps common in tech-support scams. # Tier IT = remote management agents normally installed by IT firms. # Patterns are extended regular expressions matched against lower-case app names, paths and labels. REMOTE_TOOLS=( 'AnyDesk|Consumer|anydesk|philandro' 'TeamViewer|Consumer|teamviewer' 'ScreenConnect (ConnectWise)|Consumer|screenconnect|connectwise ?control|connectwisecontrol' 'UltraViewer|Consumer|ultraviewer' 'RustDesk|Consumer|rustdesk' 'AnyViewer|Consumer|anyviewer' 'HopToDesk|Consumer|hoptodesk' 'Supremo|Consumer|(^|[^a-z])supremo' 'AeroAdmin|Consumer|aeroadmin' 'Ammyy Admin|Consumer|ammyy' 'SimpleHelp|Consumer|simplehelp|simple-help' 'NetSupport|Consumer|netsupport' 'Remote Utilities|Consumer|remote utilities|rutserv|rfusclient' 'Splashtop|Consumer|splashtop' 'LogMeIn / GoTo|Consumer|logmein|gotoassist|goto ?resolve|gotoopener' 'Zoho Assist|Consumer|zoho ?assist' 'RemotePC|Consumer|remotepc' 'Chrome Remote Desktop|Consumer|chrome ?remote ?desktop|chromoting|remoting_me2me|remoting_host' 'DWService|Consumer|dwservice|dwagent' 'ISL Online|Consumer|isl ?light|isl ?alwayson' 'MeshCentral agent|Consumer|mesh ?agent' 'VNC|Consumer|tightvnc|ultravnc|realvnc|tigervnc|vnc ?server|x11vnc' 'Atera|IT|(^|[^a-z])atera' 'NinjaOne|IT|ninjarmm|ninjaone' 'Kaseya|IT|kaseya' 'N-able|IT|n-able|n-central|mspanywhere|advanced monitoring agent' 'Datto RMM|IT|datto ?rmm|centrastage|aemagent' 'Action1|IT|action1' 'Tactical RMM|IT|tacticalrmm|tactical ?rmm|tacticalagent' 'Syncro|IT|(^|[^a-z])syncro([^a-z]|$)|kabuto' ) # Ports third-party remote-control tools listen on. macOS's own sharing ports (22, 3031, 3283, 5900) are # reported by the Mac sharing checks instead, so they are not repeated here. typeset -A REMOTE_PORTS REMOTE_PORTS=(5901 VNC 5902 VNC 5938 TeamViewer 7070 AnyDesk 8040 ScreenConnect 8041 ScreenConnect 21116 RustDesk 21118 RustDesk) SUSPICIOUS_PATH_RX='^/volumes/|^(/private)?/(tmp|var/tmp)/|^/private/var/folders/|/users/shared/|/downloads/|/desktop/|/\.[^/]+/' ANY_HOST_RX='(^|/)(sh|bash|zsh|dash|ksh|osascript|python[0-9.]*|perl[0-9.]*|ruby|node|php|curl|wget|nc|ncat)$' SCRIPT_HOST_RX='(^|/)(sh|bash|zsh|dash|ksh|osascript|python[0-9.]*|perl[0-9.]*|ruby|node|php)$|\.(sh|command|py|pl|rb|scpt|applescript|js)$' DANGER_ARGS_RX='base64|(^|[ /])curl |(^|[ /])wget |https?://|\| *(ba|z)?sh|(^|[ ;])eval |osascript -e|/dev/tcp/|python[0-9.]* -c|perl -e|ruby -e|(^|[ /])nc(at)? |mkfifo' REMOTE_EXT_RX='remote desktop|remote control|remote access|screen ?shar' # ----------------------------------------------------------------------------- # Who is running this, and on what # ----------------------------------------------------------------------------- IS_MAC=0; [[ $(uname -s 2>/dev/null) == Darwin ]] && IS_MAC=1 IS_ADMIN=0; [[ $(id -u 2>/dev/null) == 0 ]] && IS_ADMIN=1 if (( ! IS_MAC && ! DEMO )); then print -r -- "$BRAND for Mac scans Macs only. On Windows, use the Windows version. Use --demo to preview a sample report." exit 1 fi # The person whose account is being checked: the one who typed sudo, not root. if (( IS_ADMIN )) && [[ -n ${SUDO_USER:-} && $SUDO_USER != root ]]; then ME=$SUDO_USER; else ME=$(id -un 2>/dev/null); fi home_of() { local d d=$(dscl . -read "/Users/$1" NFSHomeDirectory 2>/dev/null) d=${d#NFSHomeDirectory: } [[ -n $d && $d == /* ]] && print -rn -- $d || print -rn -- "/Users/$1" } MY_HOME=$(home_of $ME) # ----------------------------------------------------------------------------- # Offer administrator rights (like the Windows launcher's permission prompt) # ----------------------------------------------------------------------------- if (( IS_MAC && ! IS_ADMIN && ! DEMO && ! LIMITED )) && [[ -t 0 && -t 1 ]]; then print print -r -- "$BRAND v$VERSION $COMPANY" print -r -- 'A few checks (other accounts, background items, sudo rules) need administrator rights.' print -r -- 'The scan stays read-only either way.' print -rn -- 'Run the full scan? macOS will ask for your Mac password (nothing shows as you type). [Y/n] ' read -r answer if [[ ${(L)answer} != n* ]]; then if sudo -v 2>/dev/null; then exec sudo /bin/zsh "$SCRIPT_PATH" "${ORIG_ARGS[@]}" fi print -r -- 'Continuing without administrator rights.' fi fi # ----------------------------------------------------------------------------- # Inputs gathered once and shared by several checks # ----------------------------------------------------------------------------- typeset -a USER_HOMES # home folders of local people (only readable ones are scanned) typeset -a LISTEN_PORTS # "portaddress" typeset -a LAUNCH_ITEMS # "plistwherelabelexecommandatload" collect_users() { local line name uid for line in ${(f)"$(dscl . -list /Users UniqueID 2>/dev/null)"}; do name=${line%% *}; uid=${line##* } [[ $name == _* || $name == root || $name == daemon || $name == nobody ]] && continue [[ $uid == <-> ]] || continue USER_HOMES+=("$name$US$(home_of $name)") done } collect_ports() { local line local_addr port addr local -a cols for line in ${(f)"$(netstat -an -p tcp 2>/dev/null)"}; do [[ $line == *LISTEN* ]] || continue cols=(${=line}); local_addr=${cols[4]:-} port=${local_addr##*.}; addr=${local_addr%.*} [[ $port == <-> ]] || continue [[ $addr == 127.* || $addr == ::1 || $addr == fe80::1%lo0 ]] && continue LISTEN_PORTS+=("$port$US$addr") done } port_open() { local p; for p in $LISTEN_PORTS; do [[ ${p%%$US*} == $1 ]] && return 0; done; return 1 } port_users() { local out out=$(lsof -nP -iTCP:$1 -sTCP:LISTEN -Fc 2>/dev/null) out=${(j:, :)${(u)${(M)${(f)out}:#c*}#c}} print -rn -- ${out:-Unknown} } collect_launch_items() { local dir where f label exe cmd args atload pair uname uhome local -a dirs dirs=("$R/Library/LaunchDaemons${US}All users, at startup" "$R/Library/LaunchAgents${US}All users, at login") for pair in $USER_HOMES; do uname=${pair%%$US*}; uhome=${pair#*$US} if [[ $uname == $ME ]]; then dirs+=("$R$uhome/Library/LaunchAgents${US}This user, at login") else dirs+=("$R$uhome/Library/LaunchAgents${US}$uname, at login"); fi done for pair in $dirs; do dir=${pair%%$US*}; where=${pair#*$US} [[ -d $dir && -r $dir ]] || continue for f in $dir/*.plist(N); do [[ -r $f ]] || continue label=$(pl $f Label); [[ -z $label ]] && label=${f:t:r} args=(${(f)"$(pl_list $f ProgramArguments)"}) exe=$(pl $f Program); [[ -z $exe ]] && exe=${args[1]:-} cmd=${(j: :)args}; [[ -z $cmd ]] && cmd=$exe atload=no if pl_on $f RunAtLoad || pl_on $f KeepAlive; then atload=yes; fi pl_on $f Disabled && atload=disabled LAUNCH_ITEMS+=("${f#$R}$US$where$US$label$US$exe$US$cmd$US$atload") done done } # ----------------------------------------------------------------------------- # Finding templates shared by the scan and the demo report # ----------------------------------------------------------------------------- remote_tool_finding() { # NAME MODE [key value]... local tool=$1 mode=$2; shift 2 case $mode in IT) add_finding Review 'Remote access' "$tool is installed" \ 'This is a tool IT companies use to manage and control computers remotely. It is normal when a business you trust looks after this Mac.' \ 'Confirm who installed it. If no IT company manages this computer, uninstall it and change your important passwords.' "$@" ;; Portable) add_finding Urgent 'Remote access' "$tool is running from a download" \ 'Scammers posing as tech support usually have people download and open this directly, without installing it. Anyone with its ID and code can see and control this screen.' \ 'If you did not start this yourself, disconnect from the internet now and quit it. Do not sign in to banking or email on this Mac until it has been checked.' "$@" ;; Unattended) add_finding Urgent 'Remote access' "$tool can accept connections at any time" \ 'It is set to start on its own in the background, so anyone with its password can take control, even while nobody is at the computer.' \ "If you do not use it on purpose, uninstall it (use its own uninstaller, or drag it from Applications to the Trash), then change your email and bank passwords from a different device." "$@" ;; *) add_finding Review 'Remote access' "$tool is on this Mac" \ 'This program lets someone see and control this Mac once it is opened and a code is shared. It is safe when you use it on purpose, and it is also a favorite tool in tech-support scams.' \ 'Keep it only if you use it. Otherwise drag it from Applications (or your Downloads folder) to the Trash.' "$@" ;; esac } # ----------------------------------------------------------------------------- # Scan: remote-control software # ----------------------------------------------------------------------------- scan_remote_tools() { step 'Looking for remote-control programs' local -a apps procs loose inst svc run lcopy pids online li det shown local f pair uhome line pid ppath rx entry name tier ver mode odd auto hits=0 # Installed apps and support folders for f in $R/Applications/*.app(N) $R/Applications/*/*.app(N) $R/Library/Application\ Support/*(N/) \ $R/Library/PrivilegedHelperTools/*(N) $R/opt/*(N/); do apps+=(${f#$R}); done for pair in $USER_HOMES; do uhome=${pair#*$US} for f in $R$uhome/Applications/*.app(N); do apps+=(${f#$R}); done for f in $R$uhome/Downloads/*(N) $R$uhome/Downloads/*/*(N) $R$uhome/Desktop/*(N) $R$uhome/Desktop/*/*(N); do [[ ${(L)f} == *.(app|dmg|pkg|zip) ]] && loose+=(${f#$R}) done done # Running programs: "pid path" procs=(${(f)"$(ps -axo pid=,comm= 2>/dev/null)"}) for entry in $REMOTE_TOOLS; do name=${entry%%|*}; tier=${${entry#*|}%%|*}; rx=${entry#*|*|} inst=(); svc=(); run=(); lcopy=(); pids=(); online=() for f in $apps; do [[ ${(L)f:t} =~ $rx ]] && inst+=($f); done for f in $LAUNCH_ITEMS; do li=("${(@ps:$US:)f}") [[ ${(L)li[3]} =~ $rx || ${(L)li[4]} =~ $rx ]] && svc+=($f) done for line in $procs; do line=$(trim "$line"); pid=${line%% *}; ppath=${line#* } [[ ${(L)ppath} =~ $rx ]] && { run+=("$ppath"); pids+=($pid) } done for f in $loose; do [[ ${(L)f:t} =~ $rx ]] && lcopy+=($f); done (( ${#inst} + ${#svc} + ${#run} + ${#lcopy} == 0 )) && continue (( hits++ )) if (( ${#pids} )); then for line in ${(f)"$(lsof -nP -a -iTCP -sTCP:ESTABLISHED -p ${(j:,:)pids} -Fn 2>/dev/null)"}; do [[ $line == n*'->'* ]] || continue line=${line#*->} [[ $line == 127.* || $line == '[::1]'* || $line == localhost* ]] || online+=($line) done fi det=('Type' "$([[ $tier == IT ]] && print 'IT management tool' || print 'Remote-control app')") if (( ${#inst} )); then shown=() for f in $inst; do ver=''; [[ $f == *.app ]] && ver=$(pl "$R$f/Contents/Info.plist" CFBundleShortVersionString) shown+=("$f${ver:+ (version $ver)}") done det+=('Installed at' "${(j:; :)shown}") fi if (( ${#svc} )); then shown=() for f in $svc; do li=("${(@ps:$US:)f}") shown+=("${li[3]} (${li[2]}$([[ ${li[6]} == yes ]] && print ', starts by itself'))") done det+=('Background item' "${(j:; :)shown}") fi (( ${#run} )) && det+=('Running from' "${(j:; :)${(u)run}}") if (( ${#online} )); then det+=('Online right now' 'Yes, connected to the internet') elif (( ${#run} )); then det+=('Online right now' 'Running, no outside connection seen') else det+=('Online right now' 'Not running'); fi (( ${#lcopy} )) && det+=('Downloaded copies' "${(j:; :)lcopy}") mode=Present; odd=0; auto=0 for f in $run; do [[ ${(L)f} =~ $SUSPICIOUS_PATH_RX ]] && odd=1; done for f in $svc; do [[ ${f##*$US} == yes ]] && auto=1; done if [[ $tier == IT ]]; then mode=IT elif (( odd )); then mode=Portable elif (( auto )); then mode=Unattended; fi remote_tool_finding "$name" $mode "${det[@]}" done if (( hits )); then add_check 'Remote access' 'Remote-control programs' "$hits found (searched for ${#REMOTE_TOOLS} known tools)" Flagged else add_check 'Remote access' 'Remote-control programs' "None of ${#REMOTE_TOOLS} known tools found" fi } # ----------------------------------------------------------------------------- # Scan: the Mac's own sharing settings # ----------------------------------------------------------------------------- scan_builtin_remote() { step 'Checking Screen Sharing, Remote Management and Remote Login' if port_open 5900 || port_open 3283; then local what='Screen Sharing' port_open 3283 && what='Remote Management (Apple Remote Desktop)' add_finding Review 'Remote access' 'Screen Sharing or Remote Management is turned on' \ 'Another computer can see and control this screen if it has a user name and password for this Mac (or a VNC password, if one is set). Home Macs rarely need this.' \ 'If nobody connects to this Mac on purpose, turn off Screen Sharing and Remote Management in System Settings > General > Sharing.' \ 'Turned on' "$what" 'Listening by' "$(port_users 5900)" add_check 'Remote access' 'Screen Sharing / Remote Management' 'Turned on' Flagged else add_check 'Remote access' 'Screen Sharing / Remote Management' 'Off' fi if port_open 22; then add_finding Review 'Remote access' 'Remote Login (SSH) is turned on' \ 'Anyone who knows a password for an account on this Mac can sign in to it from another computer and run commands. Home Macs rarely need this.' \ 'If nobody connects to this Mac remotely on purpose, turn off Remote Login in System Settings > General > Sharing.' add_check 'Remote access' 'Remote Login (SSH)' 'Turned on' Flagged else add_check 'Remote access' 'Remote Login (SSH)' 'Off' fi if port_open 3031; then add_finding Review 'Remote access' 'Remote Apple Events is turned on' \ 'Other Macs can send commands to apps on this Mac if they have an account password. It is rarely needed at home.' \ 'Unless you use it on purpose, turn off Remote Application Scripting in System Settings > General > Sharing.' add_check 'Remote access' 'Remote Apple Events' 'Turned on' Flagged else add_check 'Remote access' 'Remote Apple Events' 'Off' fi } # ----------------------------------------------------------------------------- # Scan: accounts # ----------------------------------------------------------------------------- scan_accounts() { step 'Reviewing user accounts' local -a admins hidden_list known local out pair line name uid enabled is_admin is_hidden auth ishid flagged=0 admin_count=0 hidden_count=0 console if ! out=$(dscl . -list /Users UniqueID 2>/dev/null) || [[ -z $out ]]; then add_check 'Accounts' 'Local accounts' 'Could not be read' Skipped return fi admins=(${=${"$(dscl . -read /Groups/admin GroupMembership 2>/dev/null)"#GroupMembership:}}) hidden_list=(${(f)"$(pl_list $R/Library/Preferences/com.apple.loginwindow.plist HiddenUsersList)"}) console=$(who 2>/dev/null | awk '$2 == "console" { print $1; exit }') known=($ME $console) for line in ${(f)out}; do name=${line%% *}; uid=${line##* } [[ $name == _* || $name == root || $name == daemon || $name == nobody ]] && continue [[ $uid == <-> ]] || continue auth=$(dscl . -read "/Users/$name" AuthenticationAuthority 2>/dev/null) enabled=Yes; [[ $auth == *DisabledUser* ]] && enabled=No is_admin=No; (( ${admins[(Ie)$name]} )) && is_admin=Yes ishid=$(dscl . -read "/Users/$name" IsHidden 2>/dev/null) is_hidden=No [[ ${(L)ishid} == *(1|yes|true)* || ${hidden_list[(Ie)$name]} -gt 0 ]] && is_hidden=Yes (( uid < 500 )) && is_hidden=Yes # below 500 is hidden from the login window by design [[ $is_admin == Yes && $enabled == Yes ]] && (( admin_count++ )) [[ $is_hidden == Yes ]] && (( hidden_count++ )) INV_ACCOUNTS+=("$(row "$name" $enabled $is_admin $is_hidden $uid)") [[ $enabled == Yes ]] || continue if [[ $is_hidden == Yes ]]; then (( flagged++ )) add_finding $([[ $is_admin == Yes ]] && print Urgent || print Review) 'Accounts' \ "The account \"$name\" is hidden from the login screen" \ 'This Mac is set to hide this account, so you would never see it when signing in. Attackers use this trick to keep a secret way back in.' \ 'Unless an IT provider created it on purpose, have the account removed and change your important passwords.' \ 'Account' "$name" 'Administrator' $is_admin 'User ID' $uid continue fi if [[ $is_admin == Yes ]] && (( ! ${known[(Ie)$name]} )); then (( flagged++ )) add_finding Review 'Accounts' "\"$name\" is another administrator on this Mac" \ 'Administrators can install software and change any setting, so every admin account is a full key to this computer.' \ 'Make sure you know who uses this account. If you do not recognize it, remove it in System Settings > Users & Groups.' \ 'Account' "$name" 'Administrator' Yes fi done # The root user is normally disabled on a Mac local rootauth rootpw rootauth=$(dscl . -read /Users/root AuthenticationAuthority 2>/dev/null) rootpw=$(dscl . -read /Users/root Password 2>/dev/null) if [[ $rootauth == *ShadowHash* && $rootpw != *'Password: *' ]]; then (( flagged++ )) add_finding Review 'Accounts' 'The root user is turned on' \ 'The all-powerful root account is switched off on a new Mac, and once it has a password it is a target for password guessing.' \ 'Unless an IT provider needs it, turn it off in Directory Utility (Edit > Disable Root User).' fi local lw=$R/Library/Preferences/com.apple.loginwindow.plist if [[ $(pl $lw GuestEnabled) == (true|1) ]]; then (( flagged++ )) add_finding Review 'Accounts' 'The Guest account is turned on' \ 'Anyone can use this Mac without a password. Guest sessions are limited, but they are still a way in.' \ 'Turn it off in System Settings > Users & Groups > Guest User.' fi local auto; auto=$(pl $lw autoLoginUser) if [[ -n $auto ]]; then (( flagged++ )) add_finding Review 'Accounts' 'This Mac signs in automatically' \ "It opens the \"$auto\" account without asking for a password when it starts, so anyone who can switch it on can use it." \ 'Turn off automatic login in System Settings > Users & Groups.' 'Account' "$auto" fi add_check 'Accounts' 'Administrator accounts' "$admin_count active" $( (( flagged )) && print Flagged || print Clear) if (( hidden_count )); then add_check 'Accounts' 'Hidden accounts' "$(plural $hidden_count 'hidden account' 'hidden accounts')" Flagged else add_check 'Accounts' 'Hidden accounts' 'None'; fi } # ----------------------------------------------------------------------------- # Scan: what starts with the Mac (launch agents and daemons) and cron jobs # ----------------------------------------------------------------------------- autorun_verdict() { # EXE COMMAND SIGNER local exe=${(L)1} cmd=${(L)2} if [[ $exe =~ $ANY_HOST_RX && $cmd =~ $DANGER_ARGS_RX ]]; then print -rn -- Hidden; return; fi if [[ $exe =~ $SUSPICIOUS_PATH_RX && $3 == 'Not signed' ]]; then print -rn -- OddFolder; return; fi if [[ $exe =~ $SCRIPT_HOST_RX ]]; then print -rn -- Script; return; fi print -rn -- OK } scan_startup() { step 'Listing programs that start with this Mac' local f flagged=0 count=0 sig v local -a li det for f in $LAUNCH_ITEMS; do li=("${(@ps:$US:)f}") # plist where label exe command atload (( count++ )) sig=$(signer "${li[4]}") INV_STARTUP+=("$(row "${li[3]}" "${li[2]}$([[ ${li[6]} == disabled ]] && print ' (disabled)')" "${li[5]}" "$sig")") [[ ${li[6]} == disabled ]] && continue det=('Entry' "${li[3]}" 'Starts' "${li[2]}" 'Command' "${li[5]}" 'Publisher' "$sig" 'File' "${li[1]}") v=$(autorun_verdict "${li[4]}" "${li[5]}" "$sig") case $v in Hidden) (( flagged++ )) add_finding Urgent 'Startup' 'A hidden command runs every time this Mac starts' \ 'A background item launches a script with options typical of malware, such as downloading and running code from the internet or hiding what it runs.' \ 'Do not delete it blindly. Have a technician look at it, since it may be how someone keeps access.' "${det[@]}" ;; OddFolder) (( flagged++ )) add_finding Review 'Startup' "\"${li[3]}\" starts from an unusual folder" \ 'It runs from a temporary, download or hidden folder and is not signed by a known developer. Real software rarely does this.' \ 'If you do not recognize it, switch it off under Allow in the Background in System Settings > General > Login Items, and have it checked.' "${det[@]}" ;; Script) (( flagged++ )) add_finding Review 'Startup' "\"${li[3]}\" runs a script at startup" \ 'Scripts that run in the background can be legitimate, but they are also a common way to keep malware running.' \ 'If you do not recognize it, switch it off under Allow in the Background in System Settings > General > Login Items, and have it checked.' "${det[@]}" ;; esac done if (( count )); then add_check 'Startup' 'Programs that start with this Mac' "$count found, $(( count - flagged )) look normal" $( (( flagged )) && print Flagged || print Clear) else add_check 'Startup' 'Programs that start with this Mac' 'None'; fi } scan_cron() { step 'Reviewing scheduled jobs' local -a sources w det local src owner line sched cmd exe sig v flagged=0 count=0 if (( IS_ADMIN )); then for src in $R/usr/lib/cron/tabs/*(N.); do sources+=("${src:t}$US$(<$src)"); done else sources+=("$ME$US$(crontab -l 2>/dev/null)") fi for src in $sources; do owner=${src%%$US*} for line in ${(f)${src#*$US}}; do line=$(trim "$line") [[ -z $line || $line == \#* || $line == [A-Za-z_]##=* ]] && continue w=(${=line}) if [[ $line == @* ]]; then sched=${w[1]}; cmd=${(j: :)w[2,-1]} else sched=${(j: :)w[1,5]}; cmd=${(j: :)w[6,-1]}; fi [[ -z $cmd ]] && continue (( count++ )) exe=${cmd%% *} sig=$(signer "$exe") INV_CRON+=("$(row "$owner" "$sched" "$cmd")") v=$(autorun_verdict "$exe" "$cmd" "$sig") det=('User' "$owner" 'Schedule' "$sched" 'Runs' "$cmd" 'Publisher' "$sig") case $v in Hidden) (( flagged++ )) add_finding Urgent 'Scheduled jobs' 'A scheduled job runs a hidden command' \ 'It launches a script with options typical of malware, such as downloading and running code from the internet.' \ 'Have a technician check it. It is listed by the "crontab -l" command in Terminal.' "${det[@]}" ;; OddFolder|Script) (( flagged++ )) add_finding Review 'Scheduled jobs' 'A scheduled job runs a script or an unsigned program' \ 'Scheduled scripts can be legitimate, but they are also a common way to keep malware or remote access running.' \ 'If you do not recognize it, have it checked. It is listed by the "crontab -l" command in Terminal.' "${det[@]}" ;; esac done done if (( count )); then add_check 'Startup' 'Scheduled jobs (cron)' "$count found, $(( count - flagged )) look normal" $( (( flagged )) && print Flagged || print Clear) else add_check 'Startup' 'Scheduled jobs (cron)' 'None'; fi } # ----------------------------------------------------------------------------- # Scan: security settings # ----------------------------------------------------------------------------- scan_security() { step 'Checking Gatekeeper, System Integrity Protection, updates, firewall and FileVault' local out out=$(spctl --status 2>/dev/null) if [[ $out == *disabled* ]]; then add_finding Urgent 'Security' 'Gatekeeper is turned off' \ 'This Mac no longer checks that apps come from identified developers before opening them, so anything you download can run. Scammers often walk people through turning this off.' \ 'Turn it back on: open Terminal, run "sudo spctl --master-enable", then check System Settings > Privacy & Security.' add_check 'Security' 'App protection (Gatekeeper)' 'Off' Flagged elif [[ $out == *enabled* ]]; then add_check 'Security' 'App protection (Gatekeeper)' 'On' else add_check 'Security' 'App protection (Gatekeeper)' 'Status could not be read' Skipped; fi out=$(csrutil status 2>/dev/null) if [[ $out == *'status: disabled'* ]]; then add_finding Urgent 'Security' 'System Integrity Protection is turned off' \ 'System Integrity Protection stops any program, even one with your password, from changing macOS itself. It can only be turned off on purpose, by restarting into Recovery.' \ 'Unless a developer or IT provider turned it off on purpose, turn it back on: restart into Recovery, open Terminal there and run "csrutil enable".' add_check 'Security' 'System Integrity Protection' 'Off' Flagged elif [[ $out == *'status: enabled'* ]]; then add_check 'Security' 'System Integrity Protection' 'On' elif [[ -n $out ]]; then add_check 'Security' 'System Integrity Protection' "Partly on: ${${(f)out}[1]##*: }" Flagged else add_check 'Security' 'System Integrity Protection' 'Status could not be read' Skipped; fi local xp su crit xp=$(pl "$R/Library/Apple/System/Library/CoreServices/XProtect.bundle/Contents/Info.plist" CFBundleShortVersionString) su=$R/Library/Preferences/com.apple.SoftwareUpdate.plist crit=$(pl $su CriticalUpdateInstall) if [[ $crit == (false|0) || $(pl $su ConfigDataInstall) == (false|0) ]]; then add_finding Review 'Security' 'Automatic security updates are turned off' \ "macOS's built-in malware protection (XProtect) and security fixes update themselves only when this is on. With it off, new threats are not recognized." \ 'Turn on "Install Security Responses and system files" in System Settings > General > Software Update > Automatic Updates.' \ 'XProtect version' ${xp:-Unknown} add_check 'Security' 'Built-in malware protection (XProtect)' "Updates off${xp:+ (version $xp)}" Flagged elif [[ -n $xp ]]; then add_check 'Security' 'Built-in malware protection (XProtect)' "On, version $xp" else add_check 'Security' 'Built-in malware protection (XProtect)' 'Version could not be read' Skipped; fi out=$("$R/usr/libexec/ApplicationFirewall/socketfilterfw" --getglobalstate 2>/dev/null) if [[ $out == *disabled* || $out == *'State = 0'* ]]; then add_finding Review 'Security' 'The Mac firewall is off' \ 'It is off on a new Mac. The firewall stops other computers on the network from reaching apps on this Mac that you did not mean to share.' \ 'Turn it on in System Settings > Network > Firewall. Apps you use keep working; macOS asks if one needs incoming connections.' add_check 'Security' 'Firewall' 'Off' Flagged elif [[ $out == *enabled* || $out == *'State = '[12]* ]]; then add_check 'Security' 'Firewall' 'On' else add_check 'Security' 'Firewall' 'Status could not be read' Skipped; fi out=$(fdesetup status 2>/dev/null) if [[ $out == *'FileVault is Off'* ]]; then add_finding Review 'Security' 'FileVault disk encryption is off' \ 'If this Mac is lost or stolen, anyone can read the files on it without your password.' \ 'Turn it on in System Settings > Privacy & Security > FileVault. Keep the recovery key somewhere safe.' add_check 'Security' 'Disk encryption (FileVault)' 'Off' Flagged elif [[ $out == *'FileVault is On'* ]]; then add_check 'Security' 'Disk encryption (FileVault)' 'On' else add_check 'Security' 'Disk encryption (FileVault)' 'Status could not be read' Skipped; fi # sudo rules that skip the password prompt (the Mac's version of switching off admin prompts) if (( IS_ADMIN )); then local -a rules; local f line for f in $R/etc/sudoers $R/etc/sudoers.d/*(N.); do [[ -r $f ]] || continue for line in ${(f)"$(<$f)"}; do line=$(trim "$line") [[ $line == \#* ]] && continue [[ $line == *NOPASSWD* ]] && rules+=("${f#$R}: $line") done done if (( ${#rules} )); then add_finding Review 'Security' 'Administrator commands can run without a password' \ 'A sudo rule lets some accounts or programs take full control of this Mac without typing a password. Developers sometimes set this up; so do attackers who want to stay in.' \ 'Have a technician review these rules and remove any you did not add on purpose.' 'Rules' "${(j:; :)rules}" add_check 'Security' 'Admin password prompts (sudo)' "$(plural ${#rules} 'rule skips the password' 'rules skip the password')" Flagged else add_check 'Security' 'Admin password prompts (sudo)' 'On' fi else add_check 'Security' 'Admin password prompts (sudo)' 'Needs administrator rights to read' Skipped fi } # ----------------------------------------------------------------------------- # Scan: device management (MDM) and configuration profiles # ----------------------------------------------------------------------------- scan_profiles() { step 'Checking device management and configuration profiles' local out line id scope local -a ids out=$(profiles status -type enrollment 2>/dev/null) if [[ $out == *'MDM enrollment: Yes'* ]]; then add_finding Review 'Device management' 'This Mac is managed by an organization' \ 'Device management lets an organization install apps, change settings, and lock or erase this Mac remotely. It is normal on a work or school Mac.' \ 'If no employer or school manages this Mac, have a technician remove the enrollment.' \ 'Enrollment' "${(j:; :)${(f)out}}" add_check 'Device management' 'Device management (MDM)' 'Enrolled' Flagged elif [[ $out == *'MDM enrollment: No'* ]]; then add_check 'Device management' 'Device management (MDM)' 'Not enrolled' else add_check 'Device management' 'Device management (MDM)' 'Status could not be read' Skipped; fi if ! out=$(profiles list 2>/dev/null); then add_check 'Device management' 'Configuration profiles' 'Could not be read' Skipped return fi for line in ${(f)out}; do [[ $line == *profileIdentifier:* ]] || continue id=$(trim "${line##*profileIdentifier:}") scope=$([[ $line == _computerlevel* ]] && print 'Whole Mac' || print 'This user') ids+=($id) INV_PROFILES+=("$(row "$id" "$scope")") done if (( ${#ids} )); then add_finding Review 'Device management' "$(plural ${#ids} 'configuration profile is' 'configuration profiles are') installed" \ 'Profiles can set a proxy, add trusted certificates, force browser settings or install apps. Workplaces use them on purpose, and so do scams and adware.' \ 'Open System Settings > General > Device Management (Profiles on older macOS) and remove any profile you do not recognize.' \ 'Profiles' "${(j:; :)ids}" add_check 'Device management' 'Configuration profiles' "$(plural ${#ids} found found)" Flagged else add_check 'Device management' 'Configuration profiles' 'None' fi } # ----------------------------------------------------------------------------- # Scan: browsers # ----------------------------------------------------------------------------- ext_name() { # VERSION_DIR local mf=$1/manifest.json n key loc v n=$(pl $mf name) [[ -z $n ]] && n=$(sed -n 's/^[[:space:]]*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' $mf 2>/dev/null | head -n 1) if [[ $n == __MSG_*__ ]]; then key=${${n#__MSG_}%__} for loc in $(pl $mf default_locale) en en_US; do [[ -r $1/_locales/$loc/messages.json ]] || continue v=$(pl $1/_locales/$loc/messages.json "$key.message") [[ -z $v ]] && v=$(pl $1/_locales/$loc/messages.json "${(L)key}.message") [[ -n $v ]] && { print -rn -- $v; return } done fi print -rn -- $n } scan_browsers() { step 'Checking browsers for forced or screen-sharing extensions' local -a forced roots local pair b dom f v for pair in 'Chrome|com.google.Chrome' 'Edge|com.microsoft.Edge' 'Brave|com.brave.Browser'; do b=${pair%%|*}; dom=${pair#*|} for f in "$R/Library/Managed Preferences/$dom.plist" "$R/Library/Managed Preferences/$ME/$dom.plist" "$R/Library/Preferences/$dom.plist"; do [[ -r $f ]] || continue for v in ${(f)"$(pl_list $f ExtensionInstallForcelist)"}; do forced+=("$b: $v"); done done done forced=(${(u)forced}) if (( ${#forced} )); then add_finding Review 'Browsers' 'Browser extensions are being forced onto this Mac' \ 'A policy setting installs these extensions and stops you from removing them. Workplaces do this on purpose, and so do browser hijackers.' \ 'If this is not a work computer, check System Settings > General > Device Management for a profile you do not recognize, and have the extensions checked.' \ 'Forced extensions' "${(j:; :)forced}" add_check 'Browsers' 'Forced extensions' "$(plural ${#forced} found found)" Flagged else add_check 'Browsers' 'Forced extensions' 'None' fi roots=('Chrome|Library/Application Support/Google/Chrome' 'Edge|Library/Application Support/Microsoft Edge' 'Brave|Library/Application Support/BraveSoftware/Brave-Browser') local uname uhome base prof ext ver name flagged=0 count=0 local -a vers for pair in $USER_HOMES; do uname=${pair%%$US*}; uhome=${pair#*$US} for f in $roots; do b=${f%%|*}; base="$R$uhome/${f#*|}" [[ -d $base && -r $base ]] || continue for prof in $base/(Default|Profile\ *)(N/); do for ext in $prof/Extensions/*(N/); do vers=($ext/*(N/nOn)); ver=${vers[1]:-} [[ -n $ver && -r $ver/manifest.json ]] || continue name=$(ext_name "$ver") (( count++ )) INV_EXT+=("$(row "$b" "$uname" "${prof:t}" "$name" "${ext:t}")") if [[ ${(L)name} =~ $REMOTE_EXT_RX ]]; then (( flagged++ )) add_finding Review 'Browsers' "The $b extension \"$name\" can share or control this screen" \ 'Screen-sharing and remote-control extensions give another person a view into this computer when active.' \ 'Keep it only if you use it on purpose. Otherwise remove it from the browser extensions page.' \ 'Mac user' "$uname" 'Profile' "${prof:t}" 'Extension ID' "${ext:t}" fi done done done done add_check 'Browsers' 'Browser extensions (Chrome, Edge, Brave)' "$count listed, $flagged flagged" $( (( flagged )) && print Flagged || print Clear) } # ----------------------------------------------------------------------------- # Scan: network redirects, proxies and open doors # ----------------------------------------------------------------------------- scan_network() { step 'Checking for web redirects, proxies and open doors' local line out local -a entries redirects if [[ -r $R/etc/hosts ]]; then for line in ${(f)"$(<$R/etc/hosts)"}; do line=$(trim "${line%%\#*}") [[ -z $line ]] && continue line=${(j: :)${=line}} [[ $line == ('127.0.0.1 localhost'|'::1 localhost'|'255.255.255.255 broadcasthost'|'fe80::1%lo0 localhost') ]] && continue entries+=($line) [[ $line == (0.0.0.0|127.0.0.1|::1|::|0:0:0:0:0:0:0:0)\ * ]] || redirects+=($line) done if (( ${#redirects} )); then add_finding Review 'Network' 'Some websites are redirected on this Mac' \ 'The hosts file overrides where website names point. It can send you to a fake bank or email page that looks real.' \ 'Have the entries checked. Anything pointing a real website to an unfamiliar address should be removed from /etc/hosts.' \ 'Redirect entries' "${(j:; :)redirects}" add_check 'Network' 'Website redirects (hosts file)' "$(plural ${#redirects} redirect redirects)" Flagged elif (( ${#entries} )); then add_check 'Network' 'Website redirects (hosts file)' "None. $(plural ${#entries} 'entry is' 'entries are') blocking the sites listed." else add_check 'Network' 'Website redirects (hosts file)' 'None' fi else add_check 'Network' 'Website redirects (hosts file)' 'Could not be read' Skipped fi # Proxy settings in use right now (System Settings, or set by a profile) typeset -A px out=$(scutil --proxy 2>/dev/null) for line in ${(f)out}; do [[ $line == *' : '* ]] || continue px[$(trim "${line%% : *}")]=$(trim "${line#* : }") done local found=0 kind local -a servers for kind in HTTP HTTPS SOCKS; do [[ ${px[${kind}Enable]-} == 1 && -n ${px[${kind}Proxy]-} ]] && servers+=("$kind ${px[${kind}Proxy]}:${px[${kind}Port]-}") done if (( ${#servers} )); then found=1 add_finding Review 'Network' 'Web traffic is routed through a proxy' \ 'All browsing passes through another server first. Workplaces use this, and attackers use it to watch or change what you see.' \ 'If this is not a work Mac, turn it off in System Settings > Network > (your network) > Details > Proxies. If it comes back, look for a configuration profile.' \ 'Proxy server' "${(j:; :)servers}" fi if [[ ${px[ProxyAutoConfigEnable]-} == 1 ]]; then found=1 add_finding Review 'Network' 'A proxy script controls web traffic' \ 'A setup script decides where your browsing goes. Workplaces use this, and attackers use it to watch or change what you see.' \ 'If this is not a work Mac, turn off "Automatic proxy configuration" in System Settings > Network > (your network) > Details > Proxies.' \ 'Script address' "${px[ProxyAutoConfigURLString]-}" fi if [[ -z $out ]]; then add_check 'Network' 'Proxy settings' 'Could not be read' Skipped elif (( found )); then add_check 'Network' 'Proxy settings' 'In use' Flagged else add_check 'Network' 'Proxy settings' 'None'; fi # Third-party remote-access ports open to the network local -a ports; local p port for p in $LISTEN_PORTS; do port=${p%%$US*}; [[ -n ${REMOTE_PORTS[$port]-} ]] && ports+=($port); done ports=(${(un)ports}) for port in $ports; do add_finding Review 'Network' "Port $port is open for ${REMOTE_PORTS[$port]}" \ "This is a door that other computers on the network, or the internet, can knock on to reach ${REMOTE_PORTS[$port]}." \ 'Close it by quitting or uninstalling the program that opened it, unless you use it on purpose.' \ 'Port' $port 'Used by' "$(port_users $port)" done if (( ${#ports} )); then add_check 'Network' 'Open remote-access ports' "$(plural ${#ports} open open)" Flagged else add_check 'Network' 'Open remote-access ports' 'None (Mac sharing is listed above)'; fi } # ----------------------------------------------------------------------------- # Demo data (sample report, no scanning). Inventory and check counts agree. # ----------------------------------------------------------------------------- add_demo_data() { remote_tool_finding 'AnyDesk' Unattended 'Type' 'Remote-control app' 'Installed at' '/Applications/AnyDesk.app (version 9.0.6)' \ 'Background item' 'com.philandro.anydesk.service (All users, at startup, starts by itself)' 'Online right now' 'Yes, connected to the internet' add_finding Urgent 'Security' 'Gatekeeper is turned off' \ 'This Mac no longer checks that apps come from identified developers before opening them, so anything you download can run. Scammers often walk people through turning this off.' \ 'Turn it back on: open Terminal, run "sudo spctl --master-enable", then check System Settings > Privacy & Security.' add_finding Review 'Accounts' '"support01" is another administrator on this Mac' \ 'Administrators can install software and change any setting, so every admin account is a full key to this computer.' \ 'Make sure you know who uses this account. If you do not recognize it, remove it in System Settings > Users & Groups.' \ 'Account' 'support01' 'Administrator' 'Yes' add_finding Review 'Device management' '1 configuration profile is installed' \ 'Profiles can set a proxy, add trusted certificates, force browser settings or install apps. Workplaces use them on purpose, and so do scams and adware.' \ 'Open System Settings > General > Device Management (Profiles on older macOS) and remove any profile you do not recognize.' \ 'Profiles' 'com.secure-browsing.helper' add_finding Review 'Network' 'Web traffic is routed through a proxy' \ 'All browsing passes through another server first. Workplaces use this, and attackers use it to watch or change what you see.' \ 'If this is not a work Mac, turn it off in System Settings > Network > (your network) > Details > Proxies. If it comes back, look for a configuration profile.' \ 'Proxy server' 'HTTPS 203.0.113.24:8080' add_finding Review 'Security' 'The Mac firewall is off' \ 'It is off on a new Mac. The firewall stops other computers on the network from reaching apps on this Mac that you did not mean to share.' \ 'Turn it on in System Settings > Network > Firewall. Apps you use keep working; macOS asks if one needs incoming connections.' INV_ACCOUNTS+=("$(row martha Yes Yes No 501)" "$(row support01 Yes Yes No 502)") INV_STARTUP+=("$(row com.philandro.anydesk.service 'All users, at startup' '/Applications/AnyDesk.app/Contents/MacOS/AnyDesk --service' 'Signed by philandro Software GmbH')" "$(row com.google.keystone.agent 'This user, at login' '/Users/martha/Library/Application Support/Google/GoogleUpdater/Current/GoogleUpdater.app/Contents/MacOS/GoogleUpdater --wake' 'Signed by Google LLC')" "$(row us.zoom.ZoomDaemon 'All users, at startup' '/Library/PrivilegedHelperTools/us.zoom.ZoomDaemon' 'Signed by Zoom Video Communications, Inc.')") INV_EXT+=("$(row Chrome martha Default 'Google Docs Offline' ghbmnnjooekpmoecnnnilnnbdlolhkhi)" "$(row Chrome martha Default 'Secure Browsing Helper' aamfmnhcipnbjjnbfmaoooiohikifefk)") INV_PROFILES+=("$(row com.secure-browsing.helper 'This user')") add_check 'Remote access' 'Remote-control programs' "1 found (searched for ${#REMOTE_TOOLS} known tools)" Flagged add_check 'Remote access' 'Screen Sharing / Remote Management' 'Off' add_check 'Remote access' 'Remote Login (SSH)' 'Off' add_check 'Remote access' 'Remote Apple Events' 'Off' add_check 'Accounts' 'Administrator accounts' '2 active' Flagged add_check 'Accounts' 'Hidden accounts' 'None' add_check 'Startup' 'Programs that start with this Mac' "${#INV_STARTUP} found, ${#INV_STARTUP} look normal" add_check 'Startup' 'Scheduled jobs (cron)' 'None' add_check 'Security' 'App protection (Gatekeeper)' 'Off' Flagged add_check 'Security' 'System Integrity Protection' 'On' add_check 'Security' 'Built-in malware protection (XProtect)' 'On, version 5297' add_check 'Security' 'Firewall' 'Off' Flagged add_check 'Security' 'Disk encryption (FileVault)' 'On' add_check 'Security' 'Admin password prompts (sudo)' 'On' add_check 'Device management' 'Device management (MDM)' 'Not enrolled' add_check 'Device management' 'Configuration profiles' "${#INV_PROFILES} found" Flagged add_check 'Browsers' 'Forced extensions' 'None' add_check 'Browsers' 'Browser extensions (Chrome, Edge, Brave)' "${#INV_EXT} listed, 0 flagged" add_check 'Network' 'Website redirects (hosts file)' 'None' add_check 'Network' 'Proxy settings' 'In use' Flagged add_check 'Network' 'Open remote-access ports' 'None (Mac sharing is listed above)' } # ----------------------------------------------------------------------------- # Report # ----------------------------------------------------------------------------- finding_html() { # INDEX local i=$1 cls=${(L)F_LEVEL[$1]} pair print -r -- "
" print -r -- "

$(h ${F_TITLE[$i]})

" print -r -- "

$(h ${F_WHY[$i]})

" print -r -- "

What to do: $(h ${F_ACTION[$i]})

" print -r -- '
Technical details
' print -r -- "" for pair in "${(@ps:$RS:)F_DETAILS[$i]}"; do [[ -z $pair ]] && continue print -r -- "" done print -r -- '
Area$(h ${F_AREA[$i]})
$(h ${pair%%$US*})$(h ${pair#*$US})
' } table_html() { # COLS_ARRAY_NAME ROWS_ARRAY_NAME local -a cols rows; cols=("${(@P)1}"); rows=("${(@P)2}") local c r if (( ! ${#rows} )); then print -r -- '

Nothing found.

'; return; fi print -rn -- '
' for c in $cols; do print -rn -- ""; done print -r -- '' for r in $rows; do print -rn -- '' for c in "${(@ps:$RS:)r}"; do print -rn -- ""; done print -r -- '' done print -r -- '
$(h $c)
$(h $c)
' } report_html() { local -a urgent review local i n_clear=0 cls headline when summary word for (( i = 1; i <= ${#F_LEVEL}; i++ )); do [[ ${F_LEVEL[$i]} == Urgent ]] && urgent+=($i) || review+=($i); done for (( i = 1; i <= ${#C_STATUS}; i++ )); do [[ ${C_STATUS[$i]} == Clear ]] && (( n_clear++ )); done if (( ${#urgent} )); then cls=urgent; headline="$(plural ${#urgent} thing things) could let someone into this computer right now." elif (( ${#review} )); then cls=review; headline="Nothing urgent. $(plural ${#review} 'item is' 'items are') worth a quick look." else cls=clear; headline='No signs of outside access on this computer.'; fi when="$(strftime '%A, %B %f, %Y' $SCAN_EPOCH) at $(strftime '%L:%M %p' $SCAN_EPOCH)" local -a parts (( ${#urgent} )) && parts+=("$(plural ${#urgent} 'needs attention now' 'need attention now')") (( ${#review} )) && parts+=("$(plural ${#review} 'is worth a look' 'are worth a look')") parts+=("$(plural $n_clear 'check came back clear' 'checks came back clear')") summary="${(j:, :)parts}." print -r -- '' print -r -- '' print -r -- "$(h $BRAND) report for $(h $COMPUTER)" print -r -- '
' print -r -- "

$(h $BRAND)Who can get into this Mac? A check by $(h $COMPANY)

" print -r -- "

$(h $headline)

" print -r -- "

$(h $COMPUTER)$([[ -n $MACOS ]] && print -rn -- ", macOS $(h $MACOS)"), scanned $(h $when).

" print -r -- "

$(h $summary)

" if (( DEMO )); then print -r -- '' elif (( ! IS_ADMIN )); then print -r -- '' fi if (( ${#urgent} )); then print -r -- "

Needs attention now $(plural ${#urgent} item items)

" for i in $urgent; do finding_html $i; done print -r -- '
' fi if (( ${#review} )); then print -r -- "

Worth a look $(plural ${#review} item items)

" for i in $review; do finding_html $i; done print -r -- '
' fi print -r -- "

What was checked $(plural ${#C_NAME} check checks)

    " for (( i = 1; i <= ${#C_NAME}; i++ )); do case ${C_STATUS[$i]} in Flagged) word=Flagged ;; Skipped) word='Not checked' ;; *) word=Clear ;; esac print -r -- "
  • $word$(h ${C_NAME[$i]})$(h ${C_RESULT[$i]})
  • " done print -r -- '
' print -r -- '

Everything we found For a technician

' local spec label n for spec in 'User accounts|INV_ACCOUNTS' 'Programs that start with this Mac|INV_STARTUP' 'Scheduled jobs (cron)|INV_CRON' \ 'Browser extensions|INV_EXT' 'Configuration profiles|INV_PROFILES'; do label=${spec%%|*}; n=${spec#*|} print -r -- "
$(h $label) (${#${(P)n}})" table_html ${n}_COLS $n print -r -- '
' done print -r -- '
' print -r -- '
' } report_json() { local i first pair print -r -- '{' print -r -- " \"tool\": $(j $BRAND), \"platform\": \"macOS\", \"version\": $(j $VERSION), \"macOSVersion\": $(j $MACOS)," print -r -- " \"computer\": $(j $COMPUTER), \"scannedAt\": $(j $(strftime '%Y-%m-%dT%H:%M:%S%z' $SCAN_EPOCH))," print -r -- " \"ranAsAdmin\": $( (( IS_ADMIN )) && print true || print false), \"demo\": $( (( DEMO )) && print true || print false)," print -r -- ' "findings": [' for (( i = 1; i <= ${#F_LEVEL}; i++ )); do print -rn -- " {\"Level\": $(j ${F_LEVEL[$i]}), \"Area\": $(j ${F_AREA[$i]}), \"Title\": $(j ${F_TITLE[$i]}), \"Why\": $(j ${F_WHY[$i]}), \"Action\": $(j ${F_ACTION[$i]}), \"Details\": {" first=1 for pair in "${(@ps:$RS:)F_DETAILS[$i]}"; do [[ -z $pair ]] && continue (( first )) || print -rn -- ', ' first=0 print -rn -- "$(j ${pair%%$US*}): $(j ${pair#*$US})" done print -r -- "}}$( (( i < ${#F_LEVEL} )) && print ',')" done print -r -- ' ],' print -r -- ' "checks": [' for (( i = 1; i <= ${#C_NAME}; i++ )); do print -r -- " {\"Area\": $(j ${C_AREA[$i]}), \"Name\": $(j ${C_NAME[$i]}), \"Result\": $(j ${C_RESULT[$i]}), \"Status\": $(j ${C_STATUS[$i]})}$( (( i < ${#C_NAME} )) && print ',')" done print -r -- ' ]' print -r -- '}' } # ----------------------------------------------------------------------------- # Main # ----------------------------------------------------------------------------- SCAN_EPOCH=$EPOCHSECONDS if (( DEMO )); then COMPUTER='Sample MacBook Air'; MACOS='15.1' else COMPUTER=$(scutil --get ComputerName 2>/dev/null); [[ -z $COMPUTER ]] && COMPUTER=$(hostname 2>/dev/null) MACOS=$(sw_vers -productVersion 2>/dev/null) fi print print -r -- "$BRAND for Mac v$VERSION $COMPANY" print -r -- 'Read-only scan. Nothing on this computer will be changed.' (( IS_MAC && ! IS_ADMIN && ! DEMO )) && print -r -- 'Note: running without administrator rights, so some checks will be skipped.' print if (( DEMO )); then step 'Building a sample report (no scan)' add_demo_data else collect_users; collect_ports; collect_launch_items for s in scan_remote_tools scan_builtin_remote scan_accounts scan_startup scan_cron scan_security scan_profiles scan_browsers scan_network; do $s || add_check 'Scanner' $s 'Stopped early' Skipped done fi # Pick a writable output folder (the script may be on a read-only disk image) probe_dir() { mkdir -p -- "$1" 2>/dev/null && print -n ok > "$1/.write-test" 2>/dev/null && rm -f -- "$1/.write-test" } if ! probe_dir $OUTPUT_DIR; then OUTPUT_DIR="$MY_HOME/Desktop/OddsGuard Reports" probe_dir $OUTPUT_DIR || OUTPUT_DIR=${TMPDIR:-/tmp} fi stamp=$(strftime '%Y%m%d-%H%M' $SCAN_EPOCH) if (( DEMO )); then base="OddsGuard-Mac-SAMPLE-$stamp"; else base="OddsGuard-${COMPUTER//[^A-Za-z0-9._-]/-}-$stamp"; fi html_path="$OUTPUT_DIR/$base.html"; json_path="$OUTPUT_DIR/$base.json" report_html > $html_path report_json > $json_path # Files written while running with sudo belong to the person who ran it, not to root. if (( IS_ADMIN )) && [[ -n ${SUDO_USER:-} ]]; then chown "$SUDO_USER" -- $OUTPUT_DIR $html_path $json_path 2>/dev/null; fi n_urgent=${#${(M)F_LEVEL:#Urgent}}; n_review=${#${(M)F_LEVEL:#Review}} print print -r -- "Done. $n_urgent urgent, $n_review to review." print -r -- "Report: $html_path" if (( ! NO_OPEN && IS_MAC )); then if (( IS_ADMIN )) && [[ -n ${SUDO_USER:-} ]]; then sudo -u "$SUDO_USER" open -- $html_path 2>/dev/null else open -- $html_path 2>/dev/null; fi fi exit 0