From 8537b6fe1d05efd2b777a1993ba458c292607069 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 10 Aug 2026 02:29:20 +1000 Subject: [PATCH] docs: correct 18 verified inaccuracies in the initial docs site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This site was written yesterday against the README rather than the source, and it shows. Everything below was verified in the Swift sources. Security claims that were wrong - The helper's client validation was documented as a guarantee — "requires an Apple anchor and a Team ID", "this is not optional" — on four pages. CodeSignValidator.validateConnection FAILS OPEN: when the helper's own signature carries no Team ID, which is exactly what Xcode's default "Sign to Run Locally" produces, it logs dev mode and returns true for any caller with no anchor check. So on a from-source build — the audience those pages address — any local binary can drive the root helper. The guarantee holds for the notarized release and personal-team builds. Now says so, with the condition. build-from-source asserted the inverse of real behaviour. - "Exactly three outbound calls" — at least five. The GUI's preview fetches the inspected app's Sparkle feed and downloads its enclosure DMG from the vendor; auditctl preview --fetch shells out to brew fetch. Both were advertised on other pages of the same site. Commands that did not work - --min-tier was listed under `auditctl audit`; it is preview-only, so the documented invocation exits 2. - The example used --min-tier warn. Not a tier. Dies with "needs one of: low, medium, high, critical". The same bad example is in the source README. - "preview never runs brew and always exits 0, safe to wire into a shell prompt" — it runs brew outdated on every invocation and exits 2 when Homebrew is missing. Only "never blocks an update" survived. Behaviour that does not match - First launch describes an empty inspector. A five-step onboarding wizard runs first, and its fourth step is where the helper install is offered. - VM mode said privacycommand "discovers the guest session". There is no discovery — no Bonjour, no mDNS. You type the guest IP and port 49374 by hand. - The commands claimed to sit behind every finding included plutil and mdls, neither of which the product runs; the popover covers 14 of ~40 detectors and has no copy button. - Exports were said to carry Knowledge Base explanations in summary-first order. HTMLExporter never references KnowledgeBase, and the section order is the reverse. Also documented four shipped features the site omitted: watch mode, the permission matrix's granted and used axes with the Full Disk Access prerequisite, the batch-scan Export menu, and VM mode's guided installer. --- auditctl.mdx | 31 +++++++++++++---- batch-scan.mdx | 28 ++++++++++++--- develop/architecture.mdx | 17 +++++---- develop/build-from-source.mdx | 15 +++++--- develop/contributing.mdx | 6 ++-- develop/guest-agent.mdx | 7 ++-- develop/overview.mdx | 13 +++++-- faq.mdx | 23 ++++++++++--- first-audit.mdx | 5 ++- installation.mdx | 22 ++++++++---- introduction.mdx | 7 ++-- monitored-runs.mdx | 24 +++++++++++++ privileged-helper.mdx | 26 +++++++++----- reports.mdx | 29 +++++++++++----- security.mdx | 25 ++++++++++---- static-analysis.mdx | 65 ++++++++++++++++++++++++++++++++--- telemetry.mdx | 19 +++++++--- troubleshooting.mdx | 49 ++++++++++++++++++++++++++ vm-mode.mdx | 40 ++++++++++++++++++--- 19 files changed, 371 insertions(+), 80 deletions(-) diff --git a/auditctl.mdx b/auditctl.mdx index d838fa9..9228107 100644 --- a/auditctl.mdx +++ b/auditctl.mdx @@ -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 ` | 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 @@ -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: @@ -76,13 +84,22 @@ swift run -c release auditctl preview --fetch firefox | `--apps-dir ` | Look somewhere other than `/Applications` | | `--fetch` | Download the incoming build and diff it | | `--only-noteworthy` | Suppress apps with nothing to say | -| `--min-tier ` | Only report at or above a risk tier | +| `--min-tier ` | Only report at or above a risk tier — `low`, `medium`, `high` or `critical`. Anything else exits 2 | | `--json` | Machine-readable output | - **`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 `, 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. ### The `--fetch` caveat diff --git a/batch-scan.mdx b/batch-scan.mdx index b774edc..0b87f2e 100644 --- a/batch-scan.mdx +++ b/batch-scan.mdx @@ -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 @@ -41,8 +43,8 @@ Resist reading top to bottom. A more useful order: most interesting rows in the table. - 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. The table is triage. The judgement happens in the full report. @@ -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. +## 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 diff --git a/develop/architecture.mdx b/develop/architecture.mdx index c4b3677..2251aec 100644 --- a/develop/architecture.mdx +++ b/develop/architecture.mdx @@ -15,7 +15,7 @@ isolation.** │ privacycommand.app │ │ ┌────────────────────┐ ┌────────────────────┐ │ │ │ privacycommand │ │ privacycommandCore │ pure Swift, │ -│ │ (SwiftUI + AppKit)│───▶│ (analyzer) │ no AppKit │ +│ │ (SwiftUI + AppKit)│───▶│ (analyzer) │ no views │ │ └─────────┬──────────┘ └────────────────────┘ │ │ │ XPC ▲ analyze(bundleAt:) │ │ ▼ │ │ @@ -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 diff --git a/develop/build-from-source.mdx b/develop/build-from-source.mdx index b8fe1d4..8a45874 100644 --- a/develop/build-from-source.mdx +++ b/develop/build-from-source.mdx @@ -20,7 +20,8 @@ Then, before the first build: **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. **privacycommand → Signing & Capabilities → Team.** A personal team is fine @@ -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). ⌘B to build, ⌘R to run, ⌘U for the app test bundle. diff --git a/develop/contributing.mdx b/develop/contributing.mdx index 5f6d01a..85dc4a6 100644 --- a/develop/contributing.mdx +++ b/develop/contributing.mdx @@ -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: diff --git a/develop/guest-agent.mdx b/develop/guest-agent.mdx index 73117b9..290c4b4 100644 --- a/develop/guest-agent.mdx +++ b/develop/guest-agent.mdx @@ -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. @@ -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. diff --git a/develop/overview.mdx b/develop/overview.mdx index 79643ed..e7b5484 100644 --- a/develop/overview.mdx +++ b/develop/overview.mdx @@ -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. There is a second, stale copy of the helper sources under diff --git a/faq.mdx b/faq.mdx index 6063d4f..19c6d51 100644 --- a/faq.mdx +++ b/faq.mdx @@ -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). Not unless you ask it to. The first pass is entirely static. Executing the @@ -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. - 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). Yes. Drop a `.dmg` on it, or use diff --git a/first-audit.mdx b/first-audit.mdx index 5b0bf22..956f5b0 100644 --- a/first-audit.mdx +++ b/first-audit.mdx @@ -34,7 +34,10 @@ are worth reading first. 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 diff --git a/installation.mdx b/installation.mdx index 3fb03cc..c65c9c9 100644 --- a/installation.mdx +++ b/installation.mdx @@ -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 diff --git a/introduction.mdx b/introduction.mdx index 9957b89..e138710 100644 --- a/introduction.mdx +++ b/introduction.mdx @@ -15,8 +15,11 @@ at login — and, if you let it, what it reaches for while it runs. **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). ## The two halves diff --git a/monitored-runs.mdx b/monitored-runs.mdx index afb0095..16d22b3 100644 --- a/monitored-runs.mdx +++ b/monitored-runs.mdx @@ -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 diff --git a/privileged-helper.mdx b/privileged-helper.mdx index ec41369..8858bd4 100644 --- a/privileged-helper.mdx +++ b/privileged-helper.mdx @@ -26,15 +26,25 @@ The helper is embedded in the app bundle and registered as a daemon through ## How it protects itself -The helper **validates its clients by code signature on connect**. It requires -an Apple anchor and a Team ID matching its own, so a binary not signed by the -same team cannot talk to it — even running as your user. - - - This matters if you build from source. The helper's signing team must match - the app's, or the XPC connection is refused. See +The helper **validates its clients by code signature on connect**. It reads its +own Team ID at startup and then requires every connecting process to satisfy an +Apple anchor plus that same Team ID, so a binary signed by anyone else cannot +talk to it — even running as your user. + + + **That check only runs when the helper has a Team ID to compare against.** + If the helper binary is unsigned, ad-hoc signed, or signed with Xcode's + "Sign to Run Locally" (no team), the validator has nothing to match on. It + logs `No Team ID — accepting connection (dev mode)` and returns true for + every caller — no anchor check either. In that state any unprivileged process + running as you can drive the whole root API: start the file monitor, run the + BTM dump, install and remove the kill switch. + + Release builds are Developer ID-signed and do carry a Team ID, so this is a + build-from-source condition rather than a shipped one. If you build locally + and care about the boundary, set a signing team on the helper target — see [Build from source](/develop/build-from-source). - + ## What it will not do diff --git a/reports.mdx b/reports.mdx index 6ff632b..0197adc 100644 --- a/reports.mdx +++ b/reports.mdx @@ -17,23 +17,36 @@ The complete output — nothing is summarised away. This is the format to keep i you're building a record over time, because a future version of privacycommand can read an old export even if the UI has moved on. -It's also what you want for scripting. [`auditctl --json`](/auditctl) produces -the same shape headlessly, so a scheduled job and a manual audit are directly -comparable. +It's also what you want for scripting — but [`auditctl --json`](/auditctl) is +**not** this format. The CLI emits its own flat, summarised, static-only object: +identity and signing, risk score and tier, privacy keys, capabilities, signals +and findings, with components reduced to four counts. No events, no bundle +model, no fidelity notes. Its keys are emitted sorted alphabetically rather than +grouped, so read them by name and don't rely on position. The two share no +top-level keys, so a scheduled `auditctl --json` job and a saved GUI export +can't be diffed against each other. Pick one and stay on it. ## HTML and PDF -Both are laid out as a vendor-review one-pager: the summary and risk tier first, -then the findings with their plain-English explanations, then the detail. +Both are laid out as a vendor-review one-pager, in this order: fidelity notes, +then the static analysis — code signing, declared privacy keys, inferred +capabilities, findings, hard-coded domains and paths — then the run summary +where the risk score and tier appear, then the event log. The tier comes *after* +the findings, not before them. PDF is a render of the same HTML. The audience is someone deciding whether to approve an app, not someone debugging it. If your reader is technical and wants everything, send the JSON. ## What's in an export -Exports carry the findings, the signals behind them, and the Knowledge Base -explanations — so the document stands on its own without privacycommand -installed. +Exports carry the findings — severity and message — and, in the JSON, the +evidence strings behind each one. + +They do **not** carry the Knowledge Base explanations. HTML and PDF omit the +reference entirely; JSON carries a `kbArticleID` per finding, which is a +pointer, not the article. If you're sending a report to somebody who won't +install privacycommand and the reasoning matters, paste the explanation in +yourself. A report describes **one bundle at one version at one moment**. Include the diff --git a/security.mdx b/security.mdx index 1969270..b1ad0f5 100644 --- a/security.mdx +++ b/security.mdx @@ -17,23 +17,34 @@ empty. ## Every outbound call -Three, all narrow: +Five, all narrow. The first three happen on privacycommand's own account; the +last two only when you ask for a feature that fetches something: | Call | When | What is sent | |---|---|---| | **DNS reverse lookups** | During a [monitored run](/monitored-runs) | The IP addresses the *inspected* app contacted, so the Network tab can label them | -| **App Store privacy labels** | Analysing a Mac App Store app | The inspected app's bundle ID. Nothing about you | +| **App Store privacy labels** | Analysing a Mac App Store app | The inspected app's bundle ID, to Apple's lookup endpoint and then that app's App Store product page. Nothing about you | | **Sparkle appcast** | Checking for updates — **off by default** | A request to `privacykey.github.io` | +| **The inspected app's own appcast** | You click **Preview next version** on a Sparkle-distributed app — see [Compare runs](/compare-runs) | A request to the *vendor's* feed URL, then a download of the release it points at. HTTPS only, capped at 600 MB, analysed but never executed | +| **`brew fetch`** | [`auditctl preview --fetch`](/auditctl) | Homebrew downloads the incoming cask artifact into its own cache, from wherever the cask points | **All analysis runs locally.** The inspected bundle's contents never leave your -machine, and privacycommand never uploads a report anywhere. +machine, and privacycommand never uploads a report anywhere. The last two rows +are downloads *into* your machine, not uploads out of it. ## The privileged helper -Opt-in, narrow, and it validates callers by code signature — an Apple anchor -plus a Team ID matching its own. There is no general run-as-root path. See +Opt-in, narrow, and there is no general run-as-root path. See [The privileged helper](/privileged-helper). +It validates callers by code signature on connect — an Apple anchor plus a Team +ID matching its own — **but only when the helper binary itself carries a Team +Identifier.** That is the case for any Developer ID build, including every +release. A helper that is unsigned, ad-hoc signed, or built with Xcode's +"Sign to Run Locally" has no Team ID to compare against, and in that state it +accepts every connecting process. Treat a locally built helper as reachable by +anything running as you. + ## Things worth knowing **Reports can contain sensitive strings.** Hard-coded hostnames, internal @@ -69,5 +80,5 @@ We aim to respond within 72 hours. Especially interested in: anything that lets an unauthorised process reach the helper, any path from analysing a malicious bundle to code execution on the -host, and anything that causes privacycommand to transmit data beyond the three -calls listed above. +host, and anything that causes privacycommand to transmit data beyond the calls +listed above. diff --git a/static-analysis.mdx b/static-analysis.mdx index bffab8a..82079db 100644 --- a/static-analysis.mdx +++ b/static-analysis.mdx @@ -42,6 +42,44 @@ a bundle; this page groups them by the question they answer. - **App extensions and helpers** — every embedded extension, XPC service, and helper binary, each analysed rather than counted. +### Requested, granted, used + +Declaring an interest is only the first of three questions, and the Static tab +crosses all three in one table — *Permissions: requested vs granted vs used*. + +| Axis | Where it comes from | +|---|---| +| **Requested** | Info.plist usage keys and the entitlements that declare them — the bullet above | +| **Granted** | What macOS has actually recorded, read from the system's TCC databases | +| **Used** | What a [monitored run](/monitored-runs) observed. Camera, microphone and screen recording only; everything else reads `—` | + +The gaps are the point. A capability requested and granted but never touched +across a run you drove properly is worth a question; one macOS has granted that +the app never declared a reason for is worth a larger one. + + + **The granted column needs Full Disk Access — privacycommand's own, not the + inspected app's.** macOS protects the TCC databases, so a copy of + privacycommand without FDA can't read them. It doesn't guess: the column + reads `unknown` and the table shows a card with a button into the right + System Settings pane. Grant it and reopen the app. + + This is a TCC grant, not root — the [helper](/privileged-helper) is a separate + thing and won't help here. Four things go missing without FDA, all of them in + this table: the granted column; any row that exists *only* because macOS + recorded a grant — including the granted-but-never-declared case above, which + has no row at all when the grant can't be read; the *System access granted* + list below the table, which is where the inspected app's own Full Disk Access, + Accessibility, Screen Recording or Input Monitoring grants would appear; and + the **Verdict** column, which collapses to *Grant unknown* on every row. + + That last one costs more than it looks. *Used in binary, not declared* — a + warning that needs no TCC data at all, since it comes from the binary — is + computed after the unknown-grant check and never reached, so the warning and + the row ordering that surfaces it both disappear. The rest of the static + report is unaffected. + + ## Content — what does it ship? - **SDK fingerprints** — analytics, advertising, and attribution SDKs matched @@ -87,11 +125,30 @@ This is a deliberate contract: **a new detector is not complete without its Knowledge Base entry.** If you're adding one, see [Contributing](/develop/contributing). -## Underlying commands +## Showing your work + +Fourteen of the risk contributors carry a **show source** button — the +magnifying glass next to the row on the risk-score breakdown. It opens a +popover naming where that evidence actually came from. + +For the signing-related ones that's a command you can re-run yourself: + +```bash +codesign -dvv /Applications/SomeApp.app +codesign -d --entitlements :- /Applications/SomeApp.app +spctl --assess -vvv /Applications/SomeApp.app +``` + +For the rest it's a location rather than a command — `Contents/Info.plist`, +`Contents/MacOS/`, or the Files or Network tab for anything a +monitored run produced. The popover text is selectable, and where there's a +file behind it, **Reveal in Finder** takes you there. Contributors outside those +fourteen have no button — for those, the Knowledge Base entry above is the +explanation. -Every finding shows the command behind it — `codesign`, `otool`, `plutil`, -`sfltool`, `lsof`, `mdls` — copyable in one click, so you can reproduce any -result outside privacycommand or paste it into a ticket. +The Executive Summary rows have a one-click copy, but what it copies is a +Markdown summary of the finding — severity, message, evidence, Knowledge Base +id — not a command. ## Next diff --git a/telemetry.mdx b/telemetry.mdx index cd7d8e3..fc392d1 100644 --- a/telemetry.mdx +++ b/telemetry.mdx @@ -16,9 +16,14 @@ Every detection is **traceable**: hover any SDK and you see the matched strings that produced it. There is no opaque score — if privacycommand claims Firebase is present, it will show you what it found. -Detected families include LaunchDarkly, Firebase, Mixpanel, AdMob, AppsFlyer, -Amplitude, Segment, Sentry, PostHog, Statsig, Optimizely and Unleash, among -others. +The families that reach this card are the analytics, advertising and attribution +ones — Firebase Analytics, Mixpanel, Amplitude, Segment, PostHog, AdMob and +AppsFlyer among them. + +The database is wider than the card. Crash reporters (Sentry, Crashlytics) and +A/B platforms (LaunchDarkly, Optimizely, Firebase Remote Config) are +fingerprinted too and appear in the SDK list on the Static tab, but they aren't +counted here — the heat grade is telemetry only. ## Reading the count @@ -44,7 +49,13 @@ matches the business model. Alongside the SDK count, privacycommand extracts the flag names the binary checks at runtime — `isPro`, `isTrial`, `subscription_status`, `experiment_id`, -and the vendor-specific equivalents. +and the vendor-specific equivalents for LaunchDarkly, Optimizely, Firebase +Remote Config, PostHog, Statsig and Unleash. + +This is a separate scanner from the SDK fingerprints, and it lands in a separate +section. Statsig and Unleash in particular are *only* recognised here — they +have no fingerprint entry, so they will never appear in the telemetry count +above. This is useful for a reason that isn't really about privacy: it shows you which behaviours the vendor can change remotely, without shipping an update. diff --git a/troubleshooting.mdx b/troubleshooting.mdx index 7f4c8c5..7764a2c 100644 --- a/troubleshooting.mdx +++ b/troubleshooting.mdx @@ -29,6 +29,23 @@ description: "The failures people actually hit, and what to do about each." Expected unless the app came from the Mac App Store. Direct downloads and Homebrew casks have no labels to fetch. See [Privacy labels](/privacy-labels). + + privacycommand couldn't read the TCC databases, which is where the + *granted* axis of the permission matrix comes from. macOS protects them, so + privacycommand itself needs **Full Disk Access** to open them. + + The matrix shows a card with a button straight into the right System + Settings pane. Grant it and reopen privacycommand. + + Four things go missing, all of them in the permission matrix: the granted + column, any 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*, which also suppresses + the *Used in binary, not declared* warning even though that one needs no TCC + data. The rest of the static report is unaffected. See + [Requested, granted, used](/static-analysis#requested-granted-used). + @@ -44,6 +61,16 @@ description: "The failures people actually hit, and what to do about each." For a release build, this usually means a damaged or partially quarantined install. Reinstall from a fresh download. + + Expected, and worth knowing about. A helper with no Team Identifier — + unsigned, ad-hoc signed, or Xcode's "Sign to Run Locally" — accepts every + connecting process rather than refusing them. It logs + `No Team ID — accepting connection (dev mode)`; check with + `log show --predicate 'process == "privacycommandHelper"' --last 5m`. + + Set a signing team on the helper target to get the real check back. See + [How it protects itself](/privileged-helper#how-it-protects-itself). + `fs_usage` produces nothing for a process it can't observe. Confirm the run actually launched the app and that the target isn't a launcher stub that @@ -79,6 +106,28 @@ description: "The failures people actually hit, and what to do about each." +## auditctl + + + + `auditctl preview` looks for `brew` at `$HOMEBREW_PREFIX/bin/brew` first, if + that variable is set, then `/opt/homebrew/bin/brew` and + `/usr/local/bin/brew`. A Homebrew installed somewhere else is invisible to + it unless you point the variable at the prefix: + + ```bash + HOMEBREW_PREFIX="$(brew --prefix)" swift run -c release auditctl preview + ``` + + Or skip brew entirely with `preview --all-apps`, which reads + `/Applications` and `~/Applications` directly. + + + Pass `--no-color`, or set `NO_COLOR` in the environment — `auditctl` honours + both. See [auditctl](/auditctl). + + + ## Filing a useful issue Include the privacycommand version, your macOS version and architecture, diff --git a/vm-mode.mdx b/vm-mode.mdx index a6a9dc7..8a8c6f1 100644 --- a/vm-mode.mdx +++ b/vm-mode.mdx @@ -33,18 +33,46 @@ Tested against [VirtualBuddy](https://github.com/insidegui/VirtualBuddy), ## Setting it up -You need a macOS VM you can install into. The broad shape: +You need a macOS VM you can install into. privacycommand packages the agent for +you, but not all of this is automated — it's worth knowing which parts aren't +before you start. Install macOS in your VM tool of choice and take a snapshot before anything else touches it. That snapshot is what makes runs disposable. - - Copy the agent into the guest and start it. It waits for a host session. + + **Settings → VM agent → Build installer disk image.** It packages + `privacycommand-guest` together with its LaunchAgent plist and an + `Install.command` into a small `.dmg` under + `~/Library/Application Support/privacycommand/`. About thirty seconds the + first time. No toolchain needed — a release build ships the agent binary + inside itself. + + The same panel detects VirtualBuddy, UTM, Parallels and VMware Fusion. For + the first three it lists the VMs each one knows about and can start one for + you. VMware Fusion exposes no automation surface privacycommand can rely on, + so it is detected and then left alone — start that VM yourself. + + + Drag the `.dmg` onto the running VM's window. Every supported front-end + accepts the drop and mounts it inside the guest. + + This is the step privacycommand can't do for you: none of the VM tools + exposes a public way for an outside app to attach a disk image to a running + guest. + + + In the guest, open the mounted volume and double-click `Install.command`. + It asks for the guest's admin password, installs the LaunchAgent, and + confirms the agent is listening on **TCP 49374** (override with `--port`). - privacycommand discovers the guest session and drives the run from your Mac. + There is no discovery — nothing on your Mac goes looking for the guest. + Read the guest's address from inside it with `ifconfig en0 | grep inet`, + then type that IP and the port into **Settings → VM agent** and click + **Test connection**. **Run in VM** stays disabled until the agent answers. Roll the guest back to the clean snapshot. Anything the app installed goes @@ -52,6 +80,10 @@ You need a macOS VM you can install into. The broad shape: +Getting the app you want to inspect into the guest is also yours to do — drag, +AirDrop or `scp` it across — and you give privacycommand its path *inside* the +VM, because the host can't enumerate the guest's filesystem. + The protocol and the agent's internals are documented under [Guest agent](/develop/guest-agent).