Skip to main content

mactail

m a c t a i l
Local-first macOS launchd persistence baselining & threat hunting.

Python 3.12+ macOS 12+ MIT PyPI CI


mactail snapshots every LaunchAgent and LaunchDaemon plist on your Mac, diffs against a saved baseline, and flags suspicious persistence with evidence and rule IDs. It inspects code signatures via codesign to classify Apple-signed vs unsigned binaries — all locally, with zero network calls.

mactail baseline    # snapshot your system
mactail diff        # see what changed since then

📑 Contents


🚀 Install

Pick one. All put a mactail command on your $PATH.

Option A — uv (recommended)

uv tool install mactail
mactail --help

Update later:

uv tool upgrade mactail

Option B — pipx

pipx install mactail
mactail --help

Option C — pip (user site)

python3 -m pip install --user mactail
mactail --help

Option D — From a local clone (developer)

git clone https://github.com/htooayelwinict/mactail.git
cd mactail
make install   # creates .venv and installs in editable mode
.venv/bin/mactail --help

Requirements: Python 3.12+, macOS 12+ (Monterey or later). Apple Silicon and Intel both work.


⚡ Quickstart (60 seconds)

# 1. Snapshot the system you trust (right after a clean install or known-good state)
mactail baseline

# 2. Time passes. You install stuff, malware shows up, who knows. Check what changed.
mactail diff

# 3. Read the output. Anything in [CRITICAL] or [HIGH] deserves a look.

That's the whole loop. Everything else in this README is detail.


🔍 How it works

                  ┌──────────────────────┐
   your Mac  ──▶  │  mactail scan/parse  │  reads every plist under
                  │  (read-only)         │  /Library/LaunchDaemons
                  └──────────┬───────────┘  /Library/LaunchAgents
                             │              ~/Library/LaunchAgents
                             ▼
                  ┌──────────────────────┐
                  │  SQLite state file   │  default: ~/.local/share/mactail/state.db
                  │  (your snapshots)    │  one row per plist, full history
                  └──────────┬───────────┘
                             │
                             ▼  on `mactail diff`
                  ┌──────────────────────┐
                  │  diff + rule engine  │  10 built-in detectors
                  │  (codesign + plist)  │  → Markdown or JSON findings
                  └──────────────────────┘
  • No network. Nothing leaves the machine.
  • No root needed for user agents. Daemons under /Library/LaunchDaemons may require sudo to read.
  • Idempotent. Running mactail baseline again is a no-op for unchanged plists and a no-op for an empty state file.
  • Reversible. Delete the state file to wipe history.

🧭 Walkthroughs

First-time setup on a clean Mac

Right after a fresh install — before you add apps, browsers, or run any third-party installer — capture a known-good baseline.

mactail baseline
# baseline: wrote 87 plists, 0 parse errors -> ~/.local/share/mactail/state.db

You should see a number in the dozens (system daemons + a handful of user agents). 0 plists means something is wrong; 500+ means you already have a lot of third-party persistence.

Save the database somewhere safe if you want to compare against it later from a different machine:

cp ~/.local/share/mactail/state.db ~/Dropbox/mactail-baseline-$(hostname).db

Daily check routine

After installing software, applying updates, or just at the end of the work day:

mactail diff

Sample output:

# mactail diff

**new=1 changed=0 removed=0 critical=0 high=1 medium=0 low=0**

## New
- `~/Library/LaunchAgents/com.shady.updater.plist` (label: `com.shady.updater`)
  - [HIGH] **R-RUN-KEEPALIVE-UNSIGNED**: RunAtLoad=true KeepAlive=true; binary is unsigned

A clean run prints new=0 changed=0 removed=0 and no findings. If you see anything in ## New or ## Changed that you didn't intentionally install, treat it as suspicious.

Investigating a finding

Three steps: read the rule, inspect the plist, verify the binary.

# 1. What is this rule checking? See the table below, or run with --format json for full evidence.
mactail diff --format json | jq '.findings[] | select(.rule_id=="R-RUN-KEEPALIVE-UNSIGNED")'

# 2. Look at the plist itself.
mactail show ~/Library/LaunchAgents/com.shady.updater.plist

# 3. Check the binary it points at.
codesign -dv --verbose=4 /path/to/suspicious/binary 2>&1 | head -20
file /path/to/suspicious/binary

If it's malware: launchctl unload ~/Library/LaunchAgents/com.shady.updater.plist, then delete the plist and the binary. Then re-run mactail diff to confirm removed=1.

Multiple machines / shared baseline

Use a separate state file per machine, or sync a known-good baseline to many machines.

# On machine A (the "known clean" one):
mactail baseline --db ./team-baseline.db
scp ./team-baseline.db mac2:/tmp/

# On machine B:
mactail diff --db /tmp/team-baseline.db   # shows everything that differs from A

For fleet-scale, point the env var at a shared path:

export MACTAIL_HOME=/Volumes/IR/mactail
mactail baseline

🧂 Recipes

Only see high/critical findings

mactail diff --min-severity high

Pipe to a SIEM / grep

mactail diff --format json | curl -X POST -H 'Content-Type: application/json' -d @- https://siem.example.com/ingest

Watch one user agent over time

mactail baseline --db ./watch.db
# later...
mactail diff --db ./watch.db --include-system

Include Apple's shipped system plists for context

mactail diff --include-system

These are read-only and never written to your state file.

Reset state and start over

rm ~/.local/share/mactail/state.db
mactail baseline

Run inside CI

# .github/workflows/persistence-audit.yml
- run: pipx run mactail diff --format json > diff.json

🔎 Built-in rules reference

Rule ID Severity What it detects
R-WORLD-WRITABLE 🔴 critical Plist file has world-writable permissions
R-ENV-DYLD 🔴 critical DYLD_INSERT_LIBRARIES or LD_PRELOAD in EnvironmentVariables
R-PROG-TMP 🟠 high Program path points at /tmp or similar
R-RUN-KEEPALIVE-UNSIGNED 🟠 high / critical RunAtLoad=true + KeepAlive=true, binary is non-Apple signed or unsigned
R-USERAGENT-ROOT 🟠 high Per-user agent declares UserName=root
R-PROG-INTERPRETER 🟡 medium Program invokes sh, bash, curl, nc, or osascript
R-RUN-KEEPALIVE 🟡 medium RunAtLoad=true + KeepAlive=true, binary not found for signature check
R-NO-LABEL 🔵 low Plist has no Label key
R-DISABLED-KEEPALIVE 🔵 low Disabled=true AND KeepAlive=true (contradictory config)
R-NEW-USER-AGENT 🔵 low New user LaunchAgent not present in baseline

Severity cutoffs: --min-severity low|medium|high|critical.


📤 Output formats

Markdown (default, human-readable)

Section per category (## New, ## Changed, ## Removed), one bullet per plist, indented finding lines with [SEVERITY] tags.

JSON (machine-readable)

mactail diff --format json

Shape:

{
  "summary": { "new": 1, "changed": 0, "removed": 0, "by_severity": { "critical": 0, "high": 1, "medium": 0, "low": 0 } },
  "findings": [
    {
      "rule_id": "R-RUN-KEEPALIVE-UNSIGNED",
      "severity": "high",
      "evidence": "RunAtLoad=true KeepAlive=true; binary is unsigned",
      "path": "~/Library/LaunchAgents/com.shady.updater.plist"
    }
  ],
  "parse_errors": []
}

📁 Where things live

Thing Location
State database ~/.local/share/mactail/state.db (override with --db or MACTAIL_HOME)
User agents scanned ~/Library/LaunchAgents/
System agents /Library/LaunchAgents/
System daemons /Library/LaunchDaemons/ (read-only with sudo)
System reference (opt-in) /System/Library/LaunchAgents/, /System/Library/LaunchDaemons/

❓ FAQ

Does it modify my system? No. baseline and diff only read. show only reads. The only file written is the state DB you point it at.

Does it phone home? No. Zero network calls. codesign is the only subprocess, and it only reads signature metadata.

Do I need root? No for user agents and /Library/LaunchAgents. Yes for /Library/LaunchDaemons if you want full coverage: sudo mactail baseline.

How big does the DB get? Roughly 1 KB per plist per run. 100 plists across 1000 runs ≈ 100 MB. Prune with rm state.db and re-baseline.

What if a plist won't parse? You see ! path: reason after baseline and a ## Parse errors section after diff. The plist is skipped, not crashed on.

Is this a replacement for a real EDR? No. It's a focused, auditable, local-first baselining tool. Use it alongside, not instead of, commercial tooling.


🧪 Development

git clone https://github.com/htooayelwinict/mactail.git
cd mactail
make install   # .venv with Python 3.12+, editable install
make test      # 76 tests
make lint      # ruff
make run       # mactail --help via the venv

CI

GitHub Actions runs ruff check . and pytest -v on Python 3.12 and 3.13 for every push and PR.

Release

Bump version in pyproject.toml, push a tag. The publish.yml workflow builds and uploads to PyPI via trusted publishing.

git tag v0.2.0
git push --tags

🏗️ Project structure

mactail/
├── pyproject.toml                  # Metadata, deps, tool config
├── Makefile                        # install / test / lint / run / clean
├── .github/workflows/
│   ├── ci.yml                      # Lint + tests on 3.12 & 3.13
│   └── publish.yml                 # Trusted publishing to PyPI
│
├── src/mactail/
│   ├── __init__.py
│   ├── __main__.py                 # python -m mactail
│   ├── cli/
│   │   └── app.py                  # Typer CLI (baseline, diff, show)
│   ├── persistence/
│   │   ├── scanner.py              # Enumerate LaunchAgents/Daemons
│   │   ├── parser.py               # Parse plist → dict
│   │   ├── store.py                # SQLite state storage
│   │   ├── diff.py                 # Diff engine (added/removed/changed)
│   │   └── sigs.py                 # Code signature inspection (codesign)
│   └── rules/
│       ├── __init__.py             # Rule protocol + severity enum
│       └── builtin.py              # 10 built-in detection rules
│
└── tests/                          # 76 tests

🔒 Security & license

  • Local-only — no network calls, no telemetry, no cloud
  • Read-only inspectionmactail diff and mactail show never modify system files
  • Stdlib persistence — SQLite via sqlite3, plists via plistlib; no third-party database drivers
  • codesign subprocess — only reads signature data; never modifies signatures

MIT


macOS persistence hunting, one plist at a time.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

mactail-0.2.0.tar.gz (29.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

mactail-0.2.0-py3-none-any.whl (24.6 kB view details)

Uploaded Python 3

File details

Details for the file mactail-0.2.0.tar.gz.

File metadata

  • Download URL: mactail-0.2.0.tar.gz
  • Upload date:
  • Size: 29.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for mactail-0.2.0.tar.gz
Algorithm Hash digest
SHA256 3730da1169e0aa9a4ec493b3b7a618f2f186c587ccbe5515c73f3d933217fc29
MD5 0694c35cfa75f3c5a9d4e72d7a86a0bd
BLAKE2b-256 f12efd7fb93f7e0f7672211343e40c0b889c4fc3bd31418bfd07b07779bdc893

See more details on using hashes here.

File details

Details for the file mactail-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: mactail-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 24.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for mactail-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 2ed5bb79a9592542a8c47120080b77f731e51e1f1be70ea30ed1248b91edd753
MD5 01f69a5b5bc4776d7f30e6dc1fe21a13
BLAKE2b-256 12276b4a68d8034405ad318c14a6b4e25bf65bfbca644f6b9170599a107a778b

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page