macos-malware-triage · git:20261024.d153cce · 2026-10-24 · sha256 a6483431a794a75c
macos-malware-triage git:20261024.d153cceA
Immutable. This exact content never changes and is served at /api/v1/blob/a6483431a794a75c.
---
name: macos-malware-triage
description: >
Use when asked to check a running macOS machine for compromise, malware, or
unwanted persistence — "is my Mac infected", "did I get hacked", "check this
laptop", "something is watching me", "scan this machine", "what's in my login
items", "why is that process running at boot". The target is the live host,
not a source repository: a repo-scoped security review reads code you are
about to ship, this reads the state of a machine already running. An
improvised host sweep has no manifest, so its coverage is whatever the agent
happened to recall — walk a fixed set of coverage axes, hand back every check
your privileges cannot reach instead of dropping it, and separate malicious
findings from stale ones and from legitimate-but-privacy-relevant ones. Also
use when auditing a skill library for blind spots: audit by target class
(running host, source repo, network, account), not by topic keyword.
---
# macOS Malware Triage
**Type:** Open-source — client-agnostic methodology, no project-specific detail.
**Created by akbarsha — https://github.com/iamakbarsha1**
Distilled from a host-level compromise check that had to be improvised because
every security skill in the installed library was scoped to source
repositories — the two root-only checks it skipped surfaced only at the end,
and nothing in the report said they had been skipped.
**Licence:** Released under CC BY 4.0 — share and adapt for any purpose with
credit. Full text: `LICENSE` at the repository root.
**Feedback & Support:** If a rule here proves wrong or needs sharpening,
open an issue on the repository or contact the author at the profile link
above. If the problem is the agent not following a rule below rather than
the rule itself, that's an execution failure — acknowledge and correct it.
## The core rule
An improvised host check produces a report that looks identical whether it was
thorough or not. Coverage collapses to whatever you happened to recall, and
nothing in the output records what you never ran. So fix the axes before the
first command — platform protections, persistence at every scope, code
signatures of installed applications, network posture, browser extensions,
quarantine history — then walk them in order and state the result of each one,
including the axes that found nothing.
Two rules decide whether that report is honest. A check you cannot run because
you lack the privilege is still part of the deliverable: hand it to the user as
an explicit block to run, and never let a failed sandboxed call quietly shrink
the scope. And the result has three classes, not two — malicious,
orphaned/stale, and legitimate-but-privacy-relevant. A genuinely clean machine
turns up mostly the second and third; collapsing them produces either a false
alarm or false comfort. Triage reports. Removing anything is a separate
decision the user makes.
A clean result here is an absence claim, and the only thing supporting it is
the axis list you actually walked — which is why the list comes first.
`verify-absence-claims` governs trusting a negative someone else handed you;
this governs whether your own sweep covered enough ground to produce one.
## When this fires
**Where:** A check on a machine someone is actually using — a laptop that feels
wrong, a post-incident sweep, a handover audit — as opposed to a security
review of code sitting in a repository.
**The case this came from.** A user asked for a host-level compromise check on
a macOS machine. The installed skill library held three security skills and all
three were scoped to source repositories, so the entire sequence was improvised
in-session. It produced a clean-looking report, but the two root-only checks —
the configuration-profile list and the background-task manager dump — were
noticed as gaps only at the end, after a sandboxed attempt failed and the
failure narrowed the scope instead of being handed back. Nothing in the output
recorded that those axes had gone unchecked.
**Another shape it takes.** *(Illustrative: An agent is asked whether a
production container is compromised. It reads the Dockerfile and the image
manifest, finds nothing unusual, and reports clean — having never looked at the
running container's processes, mounts, or outbound connections. The build
inputs were audited; the running thing never was.)*
## Checks
- **Fix the axis list before the first command.** Name the axes up front —
platform protections (SIP, Gatekeeper, FileVault, XProtect version),
persistence at every scope, code signatures across installed applications,
network posture (hosts file, DNS, proxy settings, listening ports,
established connections), browser extensions, quarantine-event history — then
run them in that order and report every one, empty results included. To a
reader, "not mentioned" and "checked, found nothing" are the same sentence.
*(A host check assembled from recall read as complete; its omissions surfaced
only at the end, and the report itself named no axes, so there was no way to
tell which had been run.)*
- **A check you lack privileges for is a deliverable, not a gap.** When a
command needs root and the agent is sandboxed, the scope does not shrink —
the check moves into a copy-paste block for the user to run, with what each
command proves and what a bad result looks like. Collect these as you hit
them and put the block in the report, never in a dropped thought. *(A
sandboxed attempt at the two root-only checks failed mid-run; the failure
silently removed them from scope rather than becoming a block handed to the
user, and they were recovered only by accident at the end.)*
- **Sweep persistence at every scope, including jobs with no file behind
them.** User and system `LaunchAgents`, `LaunchDaemons`, legacy
`StartupItems`, cron, and login items via the background-task dump
(`sfltool dumpbtm`, root). Then diff against `launchctl list`: jobs submitted
through the modern service-registration API run from an app bundle and appear
in the job list with no plist in any of those directories, so a
directory-only sweep returns a confident short answer. *(Illustrative: A
persistence audit enumerates the three plist directories, finds them clean,
and misses a helper the user had never approved — it was registered by its
parent app and existed only as a live launchd job.)*
- **Resolve opaque identifiers to names before reporting them.** A column of
browser-extension IDs, bundle identifiers, or numeric PIDs is not a finding;
it is a list the user cannot act on, and it hides the one entry that matters.
Resolve each to its real name and publisher — extensions through their
`_locales` message catalogues, binaries through their signing identity.
*(A scan's extension list was first reported as raw IDs; resolving them
through `_locales` turned an unreadable column into named publishers, which
is the only reason the privacy-relevant one was visible at all.)*
- **Report three outcome classes, not two.** Split findings into malicious,
orphaned/stale (leftovers from uninstalled software, dead paths, expired
profiles), and legitimate-but-privacy-relevant (real software holding broad
access the user may not have realised it had). State which class each item is
in and why. *(A clean run's real output was almost entirely the second and
third classes; filing them under "suspicious" would have alarmed the user,
and filing them under "clean" would have buried the access grants.)*
- **Audit skill coverage by target class, not by topic keyword.** A gap in a
skill library is invisible from the inside: when nothing matches, the agent
improvises and still returns something that looks like a completed task, so
the gap never surfaces as a failure. Enumerate targets — source repository,
running host, network, account/identity — and ask which has no skill at all.
*(Three skills in the library all said "security" and all three read source
repositories; the missing target class surfaced only when a host check was
requested, and even then not until it had already been improvised.)*
## Pre-flight check — before reporting a host as clean
- [ ] You named the coverage axes before running anything, and the report
states a result for every axis, including the empty ones.
- [ ] Every check your privileges could not reach is in the report as a
copy-paste block for the user, with what it proves — none were dropped
because a call failed.
- [ ] Persistence was checked at user, system, and background-item scope, and
`launchctl list` was diffed against the plist directories to catch jobs
with no file.
- [ ] Every identifier in the report resolves to a human-readable name and
publisher; no raw extension IDs or bare PIDs are presented as findings.
- [ ] Findings are split into malicious, orphaned/stale, and
legitimate-but-privacy-relevant, each labelled with its class.
- [ ] If this check had no skill behind it, you said so, and named the target
class — host, repo, network, account — the library is missing.
If any box is unchecked, you do not have a clean machine — you have a sweep
whose coverage you cannot state, which is a guess with a tidy format.