#!/bin/bash
#
# check-my-mac.sh — check the parts of a personal security setup that live on
# your Mac, and print a report you can paste back into the checklist at
# https://girishgupta.com/beyond-the-parrot
#
#   bash check-my-mac.sh           readable summary
#   bash check-my-mac.sh --json    JSON, for pasting into the checklist
#
# WHAT IT DOES AND DOESN'T DO
#   It reads settings and reports them. That's all. It changes nothing,
#   installs nothing, needs no sudo, and sends nothing anywhere — there is no
#   network call in this file, and you can check that yourself.
#
#   It can see whether FileVault is on. It cannot see whether you froze your
#   credit, enrolled a hardware key, or turned on a port-out PIN. Those are
#   the items that matter most, and they stay manual on the checklist.
#
#   Checks that would need root report "unknown" rather than asking for your
#   password. Nothing here should ever prompt for it. If something does, stop
#   and work out why.
#
# Exit status: 0 if nothing failed, 1 if at least one check failed.

set -uo pipefail

JSON_ONLY=0
[[ "${1:-}" == "--json" ]] && JSON_ONLY=1

PASS=0
FAIL=0
UNKNOWN=0
ROWS=()

record() {
  local key="$1" status="$2" detail="$3"
  case "$status" in
    pass) PASS=$((PASS + 1)) ;;
    fail) FAIL=$((FAIL + 1)) ;;
    *) UNKNOWN=$((UNKNOWN + 1)); status="unknown" ;;
  esac
  local esc=${detail//\\/\\\\}
  esc=${esc//\"/\\\"}
  esc=$(printf '%s' "$esc" | tr -d '\000-\037')
  ROWS+=("    \"$key\": { \"status\": \"$status\", \"detail\": \"$esc\" }")
  if [[ $JSON_ONLY -eq 0 ]]; then
    local mark
    case "$status" in
      pass) mark=" ok " ;;
      fail) mark="FAIL" ;;
      unknown) mark=" ?  " ;;
    esac
    printf '[%s] %-18s %s\n' "$mark" "$key" "$detail"
  fi
}

have() { command -v "$1" >/dev/null 2>&1; }

NOW=$(date +%s)

# Days between a unix timestamp and now. Prints nothing if given nothing.
days_since() {
  local then="${1:-}"
  [[ -n "$then" && "$then" =~ ^[0-9]+$ ]] || return 1
  echo $(( (NOW - then) / 86400 ))
}

if [[ "$(uname -s)" != "Darwin" ]]; then
  echo "This one only knows how to check macOS. Detected: $(uname -s)" >&2
  exit 2
fi

[[ $JSON_ONLY -eq 0 ]] && {
  echo
  echo "Checking $(sw_vers -productName) $(sw_vers -productVersion) on $(scutil --get ComputerName 2>/dev/null || hostname -s)"
  echo
}

# ── Disk encryption ──────────────────────────────────────────────────────
# Without this, your laptop is a filing cabinet with the key taped to it.
fv="$(fdesetup status 2>/dev/null)"
if grep -qi "FileVault is On" <<<"$fv"; then
  record "encryption" pass "FileVault is on"
elif [[ -n "$fv" ]]; then
  record "encryption" fail "$fv — turn it on in System Settings > Privacy & Security"
else
  record "encryption" unknown "could not read FileVault status"
fi

# ── Screen lock ──────────────────────────────────────────────────────────
# Two settings combine: how long until the screen sleeps, and the grace
# period before a password is demanded. A short screensaver with a ten
# minute grace period is not a lock.
idle=$(defaults -currentHost read com.apple.screensaver idleTime 2>/dev/null)
lock_raw=$(sysadminctl -screenLock status 2>&1 | tail -1)
grace=""
case "$lock_raw" in
  *immediate*) grace=0 ;;
  *"is off"*) grace=off ;;
  *[0-9]*seconds*) grace=$(grep -oE "[0-9]+" <<<"$lock_raw" | head -1) ;;
esac
if [[ "$grace" == "off" ]]; then
  record "screenlock" fail "no password is required after the screen sleeps"
elif [[ -n "$idle" && "$idle" =~ ^[0-9]+$ && "$idle" -eq 0 ]]; then
  record "screenlock" fail "the screen never sleeps on its own, so it never locks"
elif [[ -n "$grace" && -n "$idle" && "$idle" =~ ^[0-9]+$ ]]; then
  total=$((idle + grace))
  if [[ "$total" -le 300 ]]; then
    record "screenlock" pass "locks after ${total}s (${idle}s idle + ${grace}s grace)"
  else
    record "screenlock" fail "takes ${total}s to lock — aim for five minutes or less"
  fi
else
  record "screenlock" unknown "could not read the screen lock settings"
fi

# ── Automatic login ──────────────────────────────────────────────────────
# macOS can be set to log someone in at boot without asking for anything,
# which turns a stolen laptop into an open laptop. FileVault mostly
# suppresses it, so this matters most on machines without FileVault.
autologin=$(defaults read /Library/Preferences/com.apple.loginwindow autoLoginUser 2>/dev/null)
if [[ -n "$autologin" ]]; then
  record "auto-login" fail "this Mac logs in as '$autologin' at boot without a password"
else
  record "auto-login" pass "no automatic login at boot"
fi

# ── Automatic updates ────────────────────────────────────────────────────
# Read the whole domain: individual keys are often absent, which means
# "left at the default" (on), not "off". Then check the setting is actually
# working — "updates are enabled" and "updates are happening" are different
# claims, and only the second one keeps you patched.
SU=/Library/Preferences/com.apple.SoftwareUpdate
su_dump=$(defaults read "$SU" 2>/dev/null)
su_key() { grep -oE "^ *$1 = [0-9]+" <<<"$su_dump" | grep -oE "[0-9]+$"; }
if [[ -z "$su_dump" ]]; then
  record "updates" unknown "could not read the software update settings"
else
  auto_check=$(su_key AutomaticCheckEnabled)
  crit=$(su_key CriticalUpdateInstall)
  os_auto=$(su_key AutomaticallyInstallMacOSUpdates)

  # Last time a check actually succeeded, as recorded by macOS itself.
  last_su_raw=$(grep -oE 'LastSuccessfulDate = "[^"]+"' <<<"$su_dump" | head -1 |
                sed -E 's/.*"([0-9-]+ [0-9:]+).*/\1/')
  last_su_epoch=""
  [[ -n "$last_su_raw" ]] &&
    last_su_epoch=$(date -j -u -f "%Y-%m-%d %H:%M:%S" "$last_su_raw" +%s 2>/dev/null)
  su_age=$(days_since "$last_su_epoch") || su_age=""

  if [[ "${auto_check:-1}" == "0" ]]; then
    record "updates" fail "automatic update checks are turned off"
  elif [[ "${crit:-1}" == "0" ]]; then
    record "updates" fail "security responses are not installed automatically"
  elif [[ -n "$su_age" && "$su_age" -gt 30 ]]; then
    record "updates" fail "automatic updates are on, but the last successful check was ${su_age} days ago"
  elif [[ -n "$su_age" ]]; then
    record "updates" pass "automatic updates on and checking (last successful check ${su_age}d ago)"
  else
    record "updates" pass "automatic updates on (security=${crit:-default}, macOS=${os_auto:-default})"
  fi
fi

# ── Supported macOS ──────────────────────────────────────────────────────
# Apple ships security fixes for the current release and the two before it.
# That list changes every autumn, so it lives here with a date attached: if
# this script has gone stale the check says so instead of guessing at you.
OS_TABLE_DATE="2026-08-21"
SUPPORTED_MAJORS="26 15 14"
table_epoch=$(date -j -f "%Y-%m-%d" "$OS_TABLE_DATE" +%s 2>/dev/null)
table_age=$(days_since "$table_epoch") || table_age=""
major=$(sw_vers -productVersion | cut -d. -f1)
if [[ -n "$table_age" && "$table_age" -gt 400 ]]; then
  record "supported-os" unknown "this script's list of supported macOS versions is ${table_age} days old — too stale to judge macOS $(sw_vers -productVersion)"
elif [[ " $SUPPORTED_MAJORS " == *" $major "* ]]; then
  record "supported-os" pass "macOS $(sw_vers -productVersion) still gets security updates"
else
  record "supported-os" fail "macOS $(sw_vers -productVersion) is past its last security update"
fi

# ── System Integrity Protection ──────────────────────────────────────────
# SIP stops even root from modifying system files or injecting into Apple's
# own processes. It is on by default, and turning it off takes a deliberate
# trip to Recovery — which people make for some dev tool and then forget.
sip_raw=$(csrutil status 2>/dev/null)
if grep -qi "status: enabled" <<<"$sip_raw"; then
  record "sip" pass "System Integrity Protection is on"
elif grep -qi "status: disabled" <<<"$sip_raw"; then
  record "sip" fail "System Integrity Protection is off — re-enable it from Recovery unless you know why it's off"
elif [[ -n "$sip_raw" ]]; then
  record "sip" fail "System Integrity Protection is partly disabled: $(tr -d '\n' <<<"$sip_raw" | cut -c1-90)"
else
  record "sip" unknown "could not read System Integrity Protection status"
fi

# ── Secure Boot ──────────────────────────────────────────────────────────
# Apple Silicon only. Full Security means the Mac will only boot a signed,
# current OS. Reduced Security is what you get after enabling third-party
# kernel extensions, and it is often deliberate — but it should be a choice
# you remember making rather than one you find out about here.
bridge=$(system_profiler SPiBridgeDataType 2>/dev/null)
sb=$(grep -oE "Secure Boot: .*" <<<"$bridge" | head -1 | sed 's/Secure Boot: //')
kext=$(grep -oE "Allow All Kernel Extensions: .*" <<<"$bridge" | head -1 | sed 's/.*: //')
if [[ -z "$bridge" ]]; then
  record "secure-boot" unknown "no boot policy reported — Intel Macs don't have this"
elif [[ "$sb" == "Full Security" ]]; then
  record "secure-boot" pass "Secure Boot is at Full Security"
elif [[ -n "$sb" ]]; then
  record "secure-boot" fail "Secure Boot is at ${sb}${kext:+, third-party kernel extensions allowed: $kext}"
else
  record "secure-boot" unknown "could not read the boot policy"
fi

# ── Firewall ─────────────────────────────────────────────────────────────
FW=/usr/libexec/ApplicationFirewall/socketfilterfw
if [[ -x "$FW" ]]; then
  if "$FW" --getglobalstate 2>/dev/null | grep -qi "enabled"; then
    stealth=""
    "$FW" --getstealthmode 2>/dev/null | grep -qi "on" && stealth=", stealth mode on"
    record "firewall" pass "the built-in firewall is on$stealth"
  else
    record "firewall" fail "the firewall is off — System Settings > Network > Firewall"
  fi
else
  record "firewall" unknown "could not find socketfilterfw"
fi

# ── Gatekeeper ───────────────────────────────────────────────────────────
if spctl --status 2>&1 | grep -qi "assessments enabled"; then
  xp=$(defaults read /Library/Apple/System/Library/CoreServices/XProtect.bundle/Contents/Info.plist \
        CFBundleShortVersionString 2>/dev/null)
  record "gatekeeper" pass "Gatekeeper is on${xp:+, XProtect $xp}"
else
  record "gatekeeper" fail "Gatekeeper is disabled — it is the thing that stops you double-clicking malware"
fi

# ── Services listening to the network ────────────────────────────────────
# Anything bound to 127.0.0.1 is talking to your own machine and is nobody
# else's business; development tools do it constantly. What matters is a
# service bound to every interface, because that is the one that answers
# strangers when you join café wifi.
#
# This uses netstat rather than lsof deliberately. lsof run as you only
# reports your own sockets, and sshd runs as root — so an lsof check would
# cheerfully report a clean bill of health on a Mac with Remote Login on.
listeners=$(netstat -an -p tcp 2>/dev/null | awk '/LISTEN/ {print $4}')
if [[ -z "$listeners" ]]; then
  record "sharing" unknown "could not read the list of listening sockets"
else
  # Strip loopback, then reduce each address to its port number.
  public_ports=$(grep -vE '^(127\.0\.0\.1|::1|\*\.0$)' <<<"$listeners" |
                 sed -E 's/.*[.:]([0-9]+)$/\1/' | sort -un)
  # AirPlay listens on two ports, so names are deduplicated before display.
  named_list=""
  for p in $public_ports; do
    svc=""
    case "$p" in
      22)   svc="Remote Login (SSH)" ;;
      445)  svc="File Sharing (SMB)" ;;
      548)  svc="File Sharing (AFP)" ;;
      631)  svc="Printer Sharing" ;;
      3283) svc="Remote Management" ;;
      5900) svc="Screen Sharing" ;;
      5000|7000) svc="AirPlay Receiver" ;;
    esac
    [[ -n "$svc" ]] && named_list="${named_list}${svc}
"
  done
  named=$(printf '%s' "$named_list" | sort -u | paste -sd ", " -)
  others=$(grep -cvE '^(22|445|548|631|3283|5900|5000|7000)$' <<<"$public_ports")
  if [[ -n "$named" ]]; then
    record "sharing" fail "reachable from the network: $named — turn off what you don't use in System Settings > General > Sharing"
  elif [[ "$others" -gt 0 ]]; then
    record "sharing" unknown "$others service(s) listening on all interfaces, none of them a standard sharing service: $(tr '\n' ' ' <<<"$public_ports")"
  else
    record "sharing" pass "nothing is listening to the network beyond your own machine"
  fi
fi

# ── Guest account ────────────────────────────────────────────────────────
guest=$(defaults read /Library/Preferences/com.apple.loginwindow GuestEnabled 2>/dev/null)
if [[ "${guest:-0}" == "1" ]]; then
  record "guest-account" fail "the guest account is enabled"
else
  record "guest-account" pass "the guest account is off"
fi

# ── Find My ──────────────────────────────────────────────────────────────
# The thing that lets you wipe a laptop you are never getting back. Note
# that the findmydeviced process runs whether or not the feature is on, so
# it proves nothing; these two signals do.
fmm=$(defaults read /Library/Preferences/com.apple.FindMyMac FMMEnabled 2>/dev/null)
fmm_token=$(nvram -p 2>/dev/null | grep -c 'fmm-mobileme-token-FMM')
if [[ "${fmm:-0}" == "1" || "${fmm_token:-0}" -gt 0 ]]; then
  record "find-my" pass "Find My Mac is on — a lost machine can be wiped remotely"
elif [[ -n "$fmm" || "${fmm_token:-0}" == "0" ]]; then
  record "find-my" fail "Find My Mac is off — System Settings > Apple Account > iCloud > Find My Mac"
else
  record "find-my" unknown "could not read Find My status"
fi

# ── SSH keys ─────────────────────────────────────────────────────────────
# Only relevant if you have them, and if you do, an unencrypted private key
# is a password sitting in a file. ssh-keygen with an empty passphrase
# succeeds only on a key that has none, and never prompts.
if [[ -d "$HOME/.ssh" ]]; then
  dir_mode=$(stat -f "%A" "$HOME/.ssh" 2>/dev/null)
  bare=() loose=()
  for k in "$HOME"/.ssh/id_* "$HOME"/.ssh/*.pem; do
    [[ -f "$k" ]] || continue
    [[ "$k" == *.pub ]] && continue
    ssh-keygen -y -P "" -f "$k" >/dev/null 2>&1 </dev/null && bare+=("$(basename "$k")")
    mode=$(stat -f "%A" "$k" 2>/dev/null)
    [[ -n "$mode" && "$mode" != "600" && "$mode" != "400" ]] && loose+=("$(basename "$k") is $mode")
  done
  problems=""
  [[ ${#bare[@]} -gt 0 ]] && problems="no passphrase: ${bare[*]}"
  [[ ${#loose[@]} -gt 0 ]] && problems="${problems:+$problems; }readable by others: ${loose[*]}"
  [[ -n "$dir_mode" && "$dir_mode" != "700" ]] && problems="${problems:+$problems; }~/.ssh is $dir_mode, should be 700"
  if [[ -n "$problems" ]]; then
    record "ssh-keys" fail "$problems"
  else
    record "ssh-keys" pass "SSH keys and permissions look right"
  fi
else
  record "ssh-keys" pass "no ~/.ssh directory, so nothing to get wrong"
fi

# ── Password manager ─────────────────────────────────────────────────────
pm=""
for app in "1Password" "Bitwarden" "Proton Pass" "Dashlane"; do
  [[ -d "/Applications/$app.app" ]] && { pm="$app"; break; }
done
if [[ -n "$pm" ]]; then
  record "password-manager" pass "$pm is installed"
elif [[ -d "/System/Applications/Passwords.app" ]]; then
  record "password-manager" unknown "no third-party manager found; Apple Passwords is available but this script can't tell whether you use it"
else
  record "password-manager" fail "no password manager found in /Applications"
fi

# ── Backups ──────────────────────────────────────────────────────────────
# A backup you have never restored from is a hypothesis, not a backup — and
# an app in /Applications is not even that. What matters is whether
# something ran recently, so where a tool records its own history in a file
# you can read, this reads it. Where it doesn't, it says so rather than
# awarding a pass for owning the software.
FRESH_DAYS=7
best_age=""      # smallest number of days since a verified backup
verified=""      # tools with a real, dated last run
present=""       # tools installed but unverifiable from here

note_verified() {
  local name="$1" age="$2"
  verified="${verified:+$verified; }$name (${age}d ago)"
  [[ -z "$best_age" || "$age" -lt "$best_age" ]] && best_age="$age"
}

# Time Machine. A configured destination is not the point; a recent snapshot
# is. Snapshot paths end in YYYY-MM-DD-HHMMSS.
if have tmutil && tmutil destinationinfo >/dev/null 2>&1; then
  latest=$(tmutil latestbackup 2>/dev/null | tail -1)
  stamp=$(grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6}' <<<"$latest" | tail -1)
  if [[ -n "$stamp" ]]; then
    tm_epoch=$(date -j -f "%Y-%m-%d-%H%M%S" "$stamp" +%s 2>/dev/null)
    tm_age=$(days_since "$tm_epoch") && note_verified "Time Machine" "$tm_age"
  else
    present="${present:+$present, }Time Machine (destination set, but no snapshot this script can date)"
  fi
fi

# Arq. The agent keeps a world-readable log and writes a line on every
# completed backup record, which is a far better signal than the app existing.
if [[ -d /Applications/Arq.app ]]; then
  arq_log=$(ls -t /Library/Logs/ArqAgent/*.log 2>/dev/null | head -1)
  arq_done=""
  if [[ -n "$arq_log" && -r "$arq_log" ]]; then
    arq_done=$(tail -c 2000000 "$arq_log" 2>/dev/null |
               grep -oE '[0-9]{2}-[A-Za-z]{3}-[0-9]{4} [0-9]{2}:[0-9]{2}:[0-9]{2}' | tail -1)
  fi
  if [[ -n "$arq_done" ]]; then
    arq_epoch=$(date -j -f "%d-%b-%Y %H:%M:%S" "$arq_done" +%s 2>/dev/null)
    arq_age=$(days_since "$arq_epoch") && note_verified "Arq" "$arq_age"
  else
    present="${present:+$present, }Arq (installed, no readable backup record)"
  fi
fi

# Everything else: detectable, but without a log this script can honestly
# read, so they count as "something is here" rather than "you are covered".
for app in "Backblaze" "Carbon Copy Cloner" "SuperDuper!" "ChronoSync"; do
  [[ -d "/Applications/$app.app" ]] && present="${present:+$present, }$app (installed, last run not checkable from here)"
done
for tool in restic borg kopia duplicacy rclone; do
  have "$tool" && present="${present:+$present, }$tool (command-line tool present, schedule unknown)"
done

if [[ -n "$best_age" && "$best_age" -le "$FRESH_DAYS" ]]; then
  record "backups" pass "backed up within the last ${FRESH_DAYS} days — $verified"
elif [[ -n "$best_age" ]]; then
  record "backups" fail "the most recent backup was ${best_age} days ago — $verified"
elif [[ -n "$present" ]]; then
  record "backups" unknown "backup software found but no run this script can date: $present"
else
  record "backups" fail "no backup software found and no Time Machine destination — one dead disk from losing everything"
fi

# ── Hardware security key ────────────────────────────────────────────────
# Presence proves you own one. It says nothing about whether you enrolled it
# anywhere, which is the part that matters, so this never reports a pass.
if system_profiler SPUSBDataType 2>/dev/null | grep -qiE "yubikey|security key"; then
  record "hardware-key" unknown "a security key is plugged in — whether it's enrolled on your accounts can't be checked from here"
else
  record "hardware-key" unknown "no security key plugged in right now; enrolment can't be checked from here either way"
fi

# ── Report ───────────────────────────────────────────────────────────────
json() {
  echo "{"
  echo "  \"tool\": \"check-my-mac.sh\","
  echo "  \"version\": 2,"
  echo "  \"generated\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\","
  echo "  \"platform\": \"macOS $(sw_vers -productVersion)\","
  echo "  \"summary\": { \"pass\": $PASS, \"fail\": $FAIL, \"unknown\": $UNKNOWN },"
  echo "  \"checks\": {"
  local n=${#ROWS[@]} idx=0
  for row in "${ROWS[@]}"; do
    idx=$((idx + 1))
    if [[ $idx -lt $n ]]; then echo "$row,"; else echo "$row"; fi
  done
  echo "  }"
  echo "}"
}

if [[ $JSON_ONLY -eq 1 ]]; then
  json
else
  echo
  echo "$PASS passed, $FAIL failed, $UNKNOWN unknown."
  echo
  echo "The things this cannot see — your credit freeze, your hardware keys,"
  echo "your phone carrier's port-out PIN — are the ones worth doing first."
  echo
  echo "To tick these off in the checklist:"
  echo "  bash check-my-mac.sh --json | pbcopy"
  echo "then paste into the box in the post."
  echo
fi

[[ $FAIL -eq 0 ]]
