Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 24 additions & 7 deletions auditctl.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,11 @@ Pretty-printed by default, non-zero exit on parse failure. Useful options:
| `--tree` | Tree view of the bundle |
| `--warnings` | Findings only |
| `--warn-exit` | Non-zero exit when warnings are present — the one you want in CI |
| `--min-tier <tier>` | Floor on reported risk tier |
| `--verbose`, `--no-color`, `--exact` | As they sound |
| `--no-color` | Plain output. Setting `NO_COLOR` in the environment does the same |
| `--verbose`, `--exact` | As they sound |

There is no risk-tier filter here — `--min-tier` belongs to `preview`, and
`auditctl` exits 2 on any flag it doesn't recognise.

## Previewing updates

Expand All @@ -58,10 +61,15 @@ Pretty-printed by default, non-zero exit on parse failure. Useful options:

```bash
swift run -c release auditctl preview
swift run -c release auditctl preview --all-apps --only-noteworthy --min-tier warn
swift run -c release auditctl preview --all-apps --only-noteworthy --min-tier high
swift run -c release auditctl preview --json
```

`brew` is looked for at `$HOMEBREW_PREFIX/bin/brew` if that variable is set,
then `/opt/homebrew/bin/brew` and `/usr/local/bin/brew`. If none of them is
executable, `preview` exits 2 and says so — `--all-apps` skips Homebrew
entirely and reads `/Applications` and `~/Applications` directly.

With `--fetch`, it downloads the incoming cask build and diffs it against the
version you have installed — so you can see what an update *adds* before taking
it:
Expand All @@ -76,13 +84,22 @@ swift run -c release auditctl preview --fetch firefox
| `--apps-dir <path>` | Look somewhere other than `/Applications` |
| `--fetch` | Download the incoming build and diff it |
| `--only-noteworthy` | Suppress apps with nothing to say |
| `--min-tier <tier>` | Only report at or above a risk tier |
| `--min-tier <tier>` | Only report at or above a risk tier — `low`, `medium`, `high` or `critical`. Anything else exits 2 |
| `--json` | Machine-readable output |

<Note>
**`preview` is inform-only.** It never runs `brew`, never blocks an update,
and always exits 0. It is safe to wire into a shell prompt or a pre-upgrade
hook — it will not get between you and your package manager.
**`preview` will not upgrade anything.** It never runs `brew upgrade` and
never blocks or delays an update — it will not get between you and your
package manager.

It does run `brew`, though. In cask mode — the default — every invocation
shells out to `brew outdated --cask --json=v2`; only if that comes back with
something outdated does a second call to `brew info --cask --json=v2` follow,
so on an up-to-date machine you pay for `outdated` alone. Neither call is
timed out. `--fetch` adds `brew fetch --cask <token>`, which downloads the
incoming artifact into Homebrew's cache. And it exits 0 on success but 2 on
failure, including when Homebrew isn't installed at all. Account for both
before you wire it into a shell prompt or a pre-upgrade hook.
</Note>

### The `--fetch` caveat
Expand Down
28 changes: 23 additions & 5 deletions batch-scan.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ Click any row to open that app in the main window for the complete report.
taken responsibility for — this is the fastest way to find the handful of apps
worth a closer look.

**Finding the outliers.** Sorting by SDK count or risk tier surfaces the apps
that don't resemble their neighbours, which is usually more informative than
any absolute threshold.
**Finding the outliers.** Sorting by **Trackers** or risk surfaces the apps that
don't resemble their neighbours, which is usually more informative than any
absolute threshold. Note that the Trackers column counts only tracker-class SDK
hits, not every fingerprinted SDK — the full SDK count is in the CSV export and
in each app's own report.

**Periodic review.** Re-running a scan after a few months of updates shows which
apps have drifted. Pair it with [Compare runs](/compare-runs) to see exactly
Expand All @@ -41,8 +43,8 @@ Resist reading top to bottom. A more useful order:
most interesting rows in the table.
</Step>
<Step title="Compare like with like">
Two apps in the same category with very different SDK counts is a question
worth asking.
Two apps in the same category with very different tracker counts is a
question worth asking.
</Step>
<Step title="Open the interesting ones properly">
The table is triage. The judgement happens in the full report.
Expand All @@ -54,6 +56,22 @@ Resist reading top to bottom. A more useful order:
pass, including the binary work. Start it and go and do something else.
</Note>

## Getting the results out

The batch window has its own **Export** menu, separate from the single-app
[report exports](/reports):

| Format | Contents |
|---|---|
| **CSV** | One row per app, twenty-two columns — identity, risk tier and score, signing, sandbox, hardened runtime, App Store origin, architecture, minimum macOS, update mechanism, download source, tracker and SDK counts, secrets, anti-analysis, launch items, hard-coded domains, concern flags, path, and any analysis error |
| **JSON** | The same rows as structured objects, wrapped with the scan's scope and a generated-at timestamp |

Both export the rows **currently shown** — filters and sort order are applied
first. Narrow the table to what you care about, then export.

This is the only way to get fleet-level results out of the GUI; the single-app
JSON, HTML and PDF exports cover one bundle at a time.

## From the command line

The same triage is available headless through
Expand Down
17 changes: 11 additions & 6 deletions develop/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ isolation.**
│ privacycommand.app │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ privacycommand │ │ privacycommandCore │ pure Swift, │
│ │ (SwiftUI + AppKit)│───▶│ (analyzer) │ no AppKit
│ │ (SwiftUI + AppKit)│───▶│ (analyzer) │ no views
│ └─────────┬──────────┘ └────────────────────┘ │
│ │ XPC ▲ analyze(bundleAt:) │
│ ▼ │ │
Expand All @@ -37,16 +37,21 @@ isolation.**

## Why each target is separate

**`privacycommandCore` is AppKit-free on purpose.** It runs from the CLI, from
tests, from the GUI, and from the helper. Keeping UI dependencies out means a
detector change is testable in seconds with `swift test`, and that the same
analysis code runs everywhere rather than being reimplemented per surface.
**`privacycommandCore` carries no UI on purpose.** It runs from the CLI, from
tests, from the GUI, and from the helper. Keeping the views out means a detector
change is testable in seconds with `swift test`, and that the same analysis code
runs everywhere rather than being reimplemented per surface. The `Analysis/`
half imports nothing from AppKit; three files under `Monitoring/` do, because
launching a bundle, reading the pasteboard, and driving a VM front-end have no
Foundation equivalent.

**The helper is tiny on purpose.** It exposes only what genuinely needs root:
version, start/stop file monitor, the BTM dump, install/remove kill switch,
uninstall. No general run-as-root path. It validates callers by code signature —
Apple anchor plus matching Team ID — so a binary signed by anyone else can't
talk to it.
talk to it. That check depends on the helper itself being team-signed; see
[How it protects itself](/privileged-helper#how-it-protects-itself) for what a
no-team build does instead.

**The guest protocol has zero dependencies on purpose.** The agent has to build
and run inside a VM without compiling Core, so the wire format lives in its own
Expand Down
15 changes: 11 additions & 4 deletions develop/build-from-source.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ Then, before the first build:
<Step title="Add the Sparkle dependency">
**File → Add Package Dependencies…** →
`https://github.com/sparkle-project/Sparkle`, *Up to Next Major* from
`2.9.0`. Tick the `Sparkle` product on the **privacycommand** target.
`2.9.1` — that's what the committed project pins. Tick the `Sparkle`
product on the **privacycommand** target.
</Step>
<Step title="Set the app's signing team">
**privacycommand → Signing & Capabilities → Team.** A personal team is fine
Expand All @@ -30,9 +31,15 @@ Then, before the first build:
**privacycommandHelper → Signing & Capabilities → Team**, set to the *same*
team.

This is not optional. The helper's `CodeSignValidator` requires an Apple
anchor plus a Team ID matching its own, so a mismatch means the XPC
connection is refused at runtime and every privileged feature fails.
Two different failures hang off getting this wrong, and only one of them is
loud. If both targets are team-signed but with *different* teams, the
helper's `CodeSignValidator` refuses the XPC connection at runtime and every
privileged feature stops working — obvious, and easy to diagnose. If the
helper ends up with **no** team at all — unsigned, ad-hoc, or
"Sign to Run Locally" — the validator has nothing to compare against and
accepts *every* caller instead, silently. Your build works, and the root
helper is reachable by any process running as you. See
[How it protects itself](/privileged-helper#how-it-protects-itself).
</Step>
<Step title="Build">
⌘B to build, ⌘R to run, ⌘U for the app test bundle.
Expand Down
6 changes: 3 additions & 3 deletions develop/contributing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ only says "this is bad" makes the report worse, not better.

## Writing a detector

Detectors live in `Sources/privacycommandCore/Analysis/`. Keep them in Core —
AppKit-free — so they stay testable from `swift test` and usable from
[`auditctl`](/auditctl).
Detectors live in `Sources/privacycommandCore/Analysis/`. Keep them there, and
keep them free of AppKit — nothing under `Analysis/` imports it today — so they
stay testable from `swift test` and usable from [`auditctl`](/auditctl).

Useful habits:

Expand Down
7 changes: 4 additions & 3 deletions develop/guest-agent.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ There is no second copy of the GUI inside the VM.

## The protocol

The host connects to the agent over TCP and exchanges two message types defined
in `privacycommandGuestProtocol`:
The host connects to the agent over TCP — port 49374 unless the agent was
started with `--port` — and exchanges two message types defined in
`privacycommandGuestProtocol`:

- **`GuestCommand`** — host to guest: start a run, decompile, stop.
- **`GuestObservation`** — guest to host: the events a monitored run produces.
Expand All @@ -34,7 +35,7 @@ the thing to look at first if host and guest disagree.

Beyond monitored runs, the agent can decompile an entire app inside the guest
and stream the reconstructed classes back
(**Settings → VM → Decompile in VM**).
(**Settings → VM agent → Decompile in VM**).

This offloads Ghidra's CPU-heavy analysis onto the VM, so it never runs on your
real Mac. It needs Ghidra installed **in the guest**, not on the host.
Expand Down
13 changes: 10 additions & 3 deletions develop/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,22 @@ a privileged helper, a CLI, and an agent that runs inside a VM.

| Target | Path | Role |
|---|---|---|
| `privacycommandCore` | `Sources/privacycommandCore/` | The analyzer. AppKit-free, so it runs from the CLI, the tests, the GUI and the helper without dragging UI dependencies along |
| `privacycommandCore` | `Sources/privacycommandCore/` | The analyzer. No SwiftUI and no window, so it runs from the CLI, the tests, the GUI and the helper alike |
| `privacycommand` | `Sources/privacycommand/` | The SwiftUI app — views and view-models only |
| `privacycommandHelper` | the `privacycommandHelper` directory | Privileged XPC service, installed via `SMAppService.daemon` |
| `privacycommandGuestProtocol` | `Sources/privacycommandGuestProtocol/` | Wire format shared by host and guest. Zero dependencies, so the agent builds without compiling Core |
| `privacycommandGuestAgent` | `Sources/privacycommandGuestAgent/` | The `privacycommand-guest` daemon that runs inside a VM |
| `auditctl` / `auditctlKit` | `Sources/auditctl*/` | The [CLI](/auditctl), and the smallest end-to-end exercise of the analyzer |

The split is deliberate. Core stays AppKit-free so a change to the analyzer can
be tested with `swift test` in seconds rather than by launching a GUI.
The split is deliberate: a change to the analyzer can be tested with
`swift test` in seconds rather than by launching a GUI.

The whole *analysis* half of Core has no UI dependency at all. Three files under
`Monitoring/` do import AppKit behind `#if canImport(AppKit)` — `DynamicMonitor`
launches the inspected bundle via `NSWorkspace`, `LiveProbeMonitor` polls
`NSPasteboard.general.changeCount`, and `VMHostDetection` drives the VM
front-ends. Those are on the dynamic path and can't do their jobs without it.
If you're adding a detector under `Analysis/`, keep it free of both.

<Note>
There is a second, stale copy of the helper sources under
Expand Down
23 changes: 19 additions & 4 deletions faq.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ description: "Short answers to the questions that come up first."
The optional [helper](/privileged-helper) unlocks live file-event
monitoring, the Background Task Management audit without a mid-audit
prompt, and the network kill switch. Installing it asks for admin once.

One separate thing, not admin and not the helper: reading *granted*
permissions out of the macOS TCC databases needs privacycommand itself to
hold Full Disk Access. Without it you lose the granted column, any matrix
row that exists only because macOS recorded a grant, the *System access
granted* list that flags the inspected app's own Full Disk Access,
Accessibility, Screen Recording or Input Monitoring, and the Verdict column
— every row reads *Grant unknown* — see
[Requested, granted, used](/static-analysis#requested-granted-used).
</Accordion>
<Accordion title="Does it run the app I'm inspecting?" icon="play">
Not unless you ask it to. The first pass is entirely static. Executing the
Expand All @@ -24,10 +33,16 @@ description: "Short answers to the questions that come up first."
from static analysis. Use it as evidence, not as a verdict.
</Accordion>
<Accordion title="Is anything sent anywhere?" icon="wifi">
Three narrow calls: DNS reverse lookups for addresses the inspected app
contacted, Mac App Store privacy-label lookups keyed on the inspected app's
bundle ID, and the Sparkle appcast when you check for updates — which is off
by default. Full detail in [Security](/security).
Nothing about you, and no part of the inspected bundle. Three calls happen
on privacycommand's own account: DNS reverse lookups for addresses the
inspected app contacted, Mac App Store privacy-label lookups keyed on the
inspected app's bundle ID, and the Sparkle appcast when you check for
updates — which is off by default.

Two more only happen when you ask for a feature that fetches something:
**Preview next version** reads the *inspected* app's own update feed and
downloads the release it points at, and `auditctl preview --fetch` has
Homebrew download the incoming cask. Full detail in [Security](/security).
</Accordion>
<Accordion title="Can I audit an app I haven't installed?" icon="download">
Yes. Drop a `.dmg` on it, or use
Expand Down
5 changes: 4 additions & 1 deletion first-audit.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ are worth reading first.
</Steps>

The **Static** tab walks every signal in detail, and every finding carries a
plain-English explanation of what it means and the command that produced it.
plain-English explanation of what it means. Expand the risk tier and there's a
layer under that again: fourteen of the contributor rows behind the score carry
a **show source** button pointing at the evidence itself — see
[Showing your work](/static-analysis#showing-your-work).

## A worked example

Expand Down
22 changes: 15 additions & 7 deletions installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,21 @@ the first build — the full walkthrough is in

## First launch

privacycommand opens to an empty inspector. Drop a `.app` bundle or a `.dmg`
onto the window and the [first audit](/first-audit) begins immediately — no
account, no sign-in, no configuration.

The [privileged helper](/privileged-helper) is **not** installed at this point.
You'll be asked for it the first time you try something that needs it, and the
static report never does.
The first launch opens a five-step wizard rather than the inspector: welcome,
static analysis, network monitoring, file monitoring, and a closing step. It's
explanatory — no account, no sign-in — and **Skip onboarding** at the bottom
left jumps straight past it. You only see it once; after that privacycommand
opens to an empty inspector. **Help → Show Onboarding…** replays it, as does
**Settings → General → Onboarding → Replay**.

The fourth step, *File monitoring (optional)*, is where the
[privileged helper](/privileged-helper) comes up. It shows the helper's current
status and offers to install it — so you are asked about it during first launch,
not only the first time you reach for a feature that needs it. Skipping the step
installs nothing, and the static report never needs it.

Past the wizard, drop a `.app` bundle or a `.dmg` onto the window and the
[first audit](/first-audit) begins immediately.

## Verifying what you installed

Expand Down
7 changes: 5 additions & 2 deletions introduction.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ at login — and, if you let it, what it reaches for while it runs.

<Note>
**Everything runs locally.** The inspected app's contents never leave your
machine. privacycommand makes exactly three kinds of outbound call, all
narrow and all described in [Security](/security).
machine, and no report is ever uploaded. privacycommand does make a handful of
outbound calls — reverse DNS, App Store privacy labels, its own update feed,
and, only if you ask it to fetch a pending update, that vendor's feed and
download or a `brew fetch` of the incoming cask. All five are enumerated in
[Security](/security).
</Note>

## The two halves
Expand Down
24 changes: 24 additions & 0 deletions monitored-runs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,30 @@ launch screen will barely touch anything.
until minutes in.
- **Then check what changed at rest.** Some apps only phone home on quit.

## Watch mode

Some behaviour won't show up in the ten minutes you're prepared to sit there:
licence-server pings, telemetry that batches on a timer, a scheduled update
check, a phone-home the day a trial expires. Watch mode is a monitored run you
leave running.

Start it from **Run → Start Watching…** (⇧⌘W) or the eye button in the header —
either one starts a run first if there isn't one already. Then:

- **A menu-bar icon appears** with an unread badge. A change detector diffs each
tick of the run against the previous one and posts a single entry per
genuinely new thing: a destination not contacted before, a new behavioural
anomaly, an event the risk classifier called surprising, a live-probe event, a
CPU spike. Clicking the icon opens the list and marks it read.
- **Closing the main window does not quit privacycommand.** This is the part
worth knowing in advance — while watching, the app deliberately stays alive
with the menu-bar icon as your only handle on it. **Stop Watching** — ⇧⌘W
again, or the header button — ends the watch and the run together.
- The icon style is yours to pick under **Settings → General**.

Everything else is an ordinary monitored run, with the same requirements — file
events still need the helper.

## Pausing the target

You can freeze the inspected app and its child processes mid-run, then resume
Expand Down
Loading
Loading