From 0acb9123eac427cad0455f71adc6e50ac93eb840 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Mon, 17 Aug 2026 07:26:39 +0530 Subject: [PATCH 1/4] feat(tcc): give MDM fleets a way to answer the network-volume prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Container runtimes expose the guest filesystem through mounts macOS classifies as network volumes — OrbStack's ~/OrbStack, Docker Desktop and Colima shares — so the first scan that walks one fires a SystemPolicyNetworkVolumes prompt naming a process the developer does not recognize. Admins could not pre-answer it: PPPC path identifiers cannot express $HOME and the loader installs per-user under ~/.stepsecurity/bin. Keep walking those mounts by default. That walk is what inventories npm and Python packages inside dev containers, which no other part of the scan reaches, so suppressing the prompt by skipping them would trade away the coverage that made the prompt worth firing. Instead give fleets both exits. include_network_volumes: false (or --no-include-network-volumes) skips every non-local mount, enumerated from the kernel mount table via getfsstat rather than a hard-coded path list, so a newly installed runtime needs no agent change; MNT_NOWAIT keeps a stale server from blocking the enumeration, and reading the mount table cannot itself fire the prompt. The run then warns which mounts it gave up, so the coverage loss shows up in fleet logs instead of going silent. The alternative is packaging/macos's PPPC profile, which pre-answers the prompt fleet-wide (allow or deny) alongside the existing Full Disk Access grant — that route needs a fixed system-wide install path, and the docs now carry the migration steps plus the two dead ends worth naming: a symlink at a stable path does not work because TCC matches the resolved executable, and bundleID identifiers do not apply to a bare CLI binary. The two toggles are independent and default opposite ways: protected dirs stay skip-by-default, network volumes stay walk-by-default. Both resolve through tcc.ForRun, so the ~15 walk sites inherit the new class through the existing Skipper choke point with no per-detector change. Closes #177 Signed-off-by: Swarit Pandey --- CHANGELOG.md | 6 + README.md | 3 +- SCAN_COVERAGE.md | 3 + cmd/stepsecurity-dev-machine-guard/main.go | 15 +- docs/launchd-troubleshooting.md | 3 +- docs/macos-tcc-permissions.md | 212 +++++++++++++++++- internal/cli/cli.go | 37 ++- internal/cli/cli_test.go | 26 +++ internal/config/config.go | 94 +++++--- internal/config/config_test.go | 38 ++++ internal/scan/scanner.go | 19 +- internal/tcc/tcc.go | 128 +++++++++-- internal/tcc/tcc_darwin.go | 69 +++++- internal/tcc/tcc_darwin_test.go | 27 ++- internal/tcc/tcc_other.go | 6 + internal/tcc/volumes_test.go | 208 +++++++++++++++++ internal/telemetry/telemetry.go | 16 +- packaging/macos/README.md | 59 +++++ ...ecurity-dev-machine-guard-tcc.mobileconfig | 110 +++++++++ 19 files changed, 997 insertions(+), 82 deletions(-) create mode 100644 internal/tcc/volumes_test.go create mode 100644 packaging/macos/README.md create mode 100644 packaging/macos/stepsecurity-dev-machine-guard-tcc.mobileconfig diff --git a/CHANGELOG.md b/CHANGELOG.md index 476155d7..b263e7d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 See [VERSIONING.md](VERSIONING.md) for why the version starts at 1.8.1. +## [Unreleased] + +### Added + +- **macOS network-volume scan toggle and PPPC pre-approval path** (#177): container runtimes expose the guest filesystem through mounts macOS classifies as *network volumes* (OrbStack's `~/OrbStack`, Docker Desktop and Colima shares), so the first scan that walks one fires a `SystemPolicyNetworkVolumes` prompt naming a process the developer doesn't recognize. That walk stays **on by default** — it is what inventories npm and Python packages inside dev containers, supply-chain surface nothing else covers — but MDM fleets now have both ways out. `include_network_volumes: false` (config) or `--no-include-network-volumes` (CLI) skips every non-local mount, enumerated from the kernel mount table via `getfsstat` rather than a hard-coded path list, so a newly installed runtime is covered without an agent change; the run then logs exactly which mounts it gave up. Alternatively `packaging/macos/stepsecurity-dev-machine-guard-tcc.mobileconfig` pre-answers the prompt for the whole fleet (allow *or* deny) alongside the existing Full Disk Access grant — that route needs a fixed system-wide install path, since PPPC identifiers can't express `$HOME`, and `docs/macos-tcc-permissions.md` now carries the migration steps for a fleet already deployed per-user. + ## [1.15.0] - 2026-08-03 ### Added diff --git a/README.md b/README.md index 2bcd9dd0..1943729c 100644 --- a/README.md +++ b/README.md @@ -518,7 +518,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. - [Scan Coverage](SCAN_COVERAGE.md) — full catalog of detections - [Release Process](docs/release-process.md) — how releases are signed and verified - [Deploying via SCCM](docs/deploying-via-sccm.md) — Windows fleet rollout via Microsoft Configuration Manager (signed MSI, no PowerShell) -- [macOS TCC Permissions](docs/macos-tcc-permissions.md) — how the agent handles Documents/Downloads/Mail TCC dirs, PPPC profile for MDM-pushed Full Disk Access, and the `include_tcc_protected` config field +- [macOS TCC Permissions](docs/macos-tcc-permissions.md) — how the agent handles Documents/Downloads/Mail TCC dirs and network volumes (container-runtime mounts), PPPC profile for MDM-pushed Full Disk Access and network-volume pre-approval, and the `include_tcc_protected` / `include_network_volumes` config fields +- [macOS MDM packaging](packaging/macos/README.md) — ready-made PPPC `.mobileconfig` for fleet deployment - [Versioning](VERSIONING.md) — why the version starts at 1.8.1 - [Security Policy](SECURITY.md) — reporting vulnerabilities - [Code of Conduct](CODE_OF_CONDUCT.md) diff --git a/SCAN_COVERAGE.md b/SCAN_COVERAGE.md index dc87c29c..fd8c02f8 100644 --- a/SCAN_COVERAGE.md +++ b/SCAN_COVERAGE.md @@ -158,6 +158,8 @@ Discovered via `pluginkit -mAD -p com.apple.dt.Xcode.extension.source-editor`. R Node.js scanning is **off by default** in community mode (it can be slow). Enable with `--enable-npm-scan`. +**Projects inside dev containers are covered on macOS.** Container runtimes (OrbStack, Docker Desktop, Colima) expose the guest filesystem through a mount under `$HOME` — `~/OrbStack`, for example — so a project living inside a running container is walked like any other. macOS classifies those mounts as *network volumes* and gates the first access behind a TCC prompt; the agent walks them anyway, because that inventory is not reachable any other way. Fleets that would rather not see the prompt turn the walk off with `include_network_volumes: false`, or pre-approve it via PPPC — see [macos-tcc-permissions.md](docs/macos-tcc-permissions.md). + ## Homebrew Package Scanning (Optional) Homebrew scanning detects installed formulae and casks with rich metadata. Enable with `--enable-brew-scan`. @@ -195,6 +197,7 @@ Discovered by scanning the **search directories** for virtual environments (`pyv - **TCC-protected user directories** — the project/venv walk skips `~/Documents`, `~/Desktop`, `~/Downloads`, and `~/Library` to avoid macOS permission prompts. (The macOS global user-site `~/Library/Python/*` is the exception: it is scanned as its own explicit global root, so global user-site packages are still covered.) A **project virtual environment** kept under one of these directories is missed unless `include_tcc_protected: true` is set **and** the agent has Full Disk Access (see [macos-tcc-permissions.md](docs/macos-tcc-permissions.md)). - **Locations outside `$HOME`** — e.g. `/opt`, `/srv`, `/data`, `/Users/Shared`, or a separate repos volume. Add them via `search_dirs`. +- **macOS network volumes when a fleet opts out** — venvs under a container-runtime mount (`~/OrbStack`, Docker Desktop / Colima shares) *are* covered by default; they're missed only where an admin set `include_network_volumes: false` to suppress the TCC prompt. The mounts given up are named in the run's warning log. - **Global interpreters at non-standard prefixes** not under any tree listed above. Add the prefix (or a parent) via `search_dirs`. The set of global install roots scanned is logged once per scan at info level (full paths at debug), so field logs show exactly where the agent looked. diff --git a/cmd/stepsecurity-dev-machine-guard/main.go b/cmd/stepsecurity-dev-machine-guard/main.go index acbcf18c..3d032143 100644 --- a/cmd/stepsecurity-dev-machine-guard/main.go +++ b/cmd/stepsecurity-dev-machine-guard/main.go @@ -38,14 +38,12 @@ import ( "github.com/step-security/dev-machine-guard/internal/winproc" ) -// auditSkipper builds a TCC skipper if scanning into TCC-protected dirs is -// not opted in. Mirrors scan.Run / telemetry.Run so the focused *Only audits -// don't accidentally prompt the user on macOS. +// auditSkipper builds the TCC skipper for the focused *Only audits from the +// same two toggles the full scan uses (protected dirs, network volumes), so +// they don't accidentally prompt the user on macOS. Mirrors scan.Run / +// telemetry.Run; nil when neither class has anything to skip. func auditSkipper(exec executor.Executor, cfg *cli.Config) *tcc.Skipper { - if !tcc.Enabled(cfg.IncludeTCCProtected) { - return nil - } - return tcc.New(executor.ResolveHome(exec)) + return tcc.ForRun(executor.ResolveHome(exec), cfg.IncludeTCCProtected, cfg.IncludeNetworkVolumes) } // hookReconcileTimeout caps the entire reconcile step (fetch + cache @@ -113,6 +111,9 @@ func main() { if cfg.IncludeTCCProtected == nil && config.IncludeTCCProtected != nil { cfg.IncludeTCCProtected = config.IncludeTCCProtected } + if cfg.IncludeNetworkVolumes == nil && config.IncludeNetworkVolumes != nil { + cfg.IncludeNetworkVolumes = config.IncludeNetworkVolumes + } if cfg.ColorMode == "auto" && config.ColorMode != "" { cfg.ColorMode = config.ColorMode } diff --git a/docs/launchd-troubleshooting.md b/docs/launchd-troubleshooting.md index 4ff6f201..50d975dd 100644 --- a/docs/launchd-troubleshooting.md +++ b/docs/launchd-troubleshooting.md @@ -168,9 +168,10 @@ launchctl kickstart -k "$DOMAIN/$LABEL" && tail -n 20 "$LOGDIR/agent.log" ## Gotchas -- **config.json is rewritten every tick.** The loader's `write_config()` keeps only a fixed set (customer_id, api_endpoint, api_key, scan_frequency_hours + optional install_dir / max_execution_duration / scan toggles); any other hand-edited or profile-pushed field (e.g. `include_tcc_protected`) is wiped within one interval. Make it stick by editing the loader heredoc before deploy. +- **config.json is rewritten every tick.** The loader's `write_config()` keeps only a fixed set (customer_id, api_endpoint, api_key, scan_frequency_hours + optional install_dir / max_execution_duration / scan toggles); any other hand-edited or profile-pushed field (e.g. `include_tcc_protected`, `include_network_volumes`) is wiped within one interval. Make it stick by editing the loader heredoc before deploy. - **Runs only in a live GUI session.** No console user (login window, headless, SSH) → not loaded, won't fire; the loader's initial run errors `no_user`, and `launchctl … gui/` over SSH can return `Bootstrap failed: 5`. - **TCC prompts are real.** It runs in the user's GUI session, so scanning Documents/Downloads/etc. pops permission dialogs; skipped by default. Grant Full Disk Access (PPPC profile), then set `include_tcc_protected`. +- **The network-volume prompt is the one users actually hit.** Container-runtime mounts (`~/OrbStack`, Docker Desktop / Colima shares) are network volumes to macOS, and unlike the dirs above the agent walks them by **default** — so a developer running containers sees one "would like to access files on a network volume" dialog per user. Pre-approve it with the `SystemPolicyNetworkVolumes` PPPC payload (needs a fixed system-wide install path), or set `include_network_volumes: false` to trade the container inventory for silence. See [macos-tcc-permissions.md](macos-tcc-permissions.md). - **A wedged run blocks every tick.** The binary's lock file makes overlapping runs exit; a hung run holds the lock until the loader SIGKILLs processes older than `MAX_PROCESS_AGE_HOURS` on a later tick. Self-heals, but loses up to that window. - **`StartInterval` quirks.** Missed fires during sleep coalesce into one run on wake; the timer also restarts on each load/login, so short sessions on a long interval can starve it. - **`Bootstrap failed: 5`** most often means already loaded — `bootout` first, then `bootstrap`. diff --git a/docs/macos-tcc-permissions.md b/docs/macos-tcc-permissions.md index ba6b4aaa..9a269905 100644 --- a/docs/macos-tcc-permissions.md +++ b/docs/macos-tcc-permissions.md @@ -61,6 +61,47 @@ If a search dir is explicitly named (`--search-dirs ~/Documents`) the walk root itself is honored — the skip only applies to TCC paths encountered as descendants of the walked root. +### What is NOT skipped by default — network volumes + +One TCC class is deliberately **not** in the list above: **Network +Volumes** (`kTCCServiceSystemPolicyNetworkVolumes`). macOS classifies +any non-local mount this way, which includes SMB/NFS/AFP shares *and* +the mounts container runtimes expose the guest filesystem through: + +| Runtime | Typical mount | Filesystem | +|---|---|---| +| **OrbStack** | `~/OrbStack` (containers, volumes, machines) | virtiofs | +| **Docker Desktop** | file-sharing mounts | virtiofs / gRPC-FUSE | +| **Colima / Lima** | share mounts | virtiofs / sshfs | + +The agent walks these by default, and that walk is the point: it is +what inventories the npm and Python packages living **inside dev +containers** — supply-chain surface no other part of the scan reaches. +The cost is one TCC prompt per user, the first time a scan walks a +container mount: + +> **"stepsecurity-dev-machine-guard" would like to access files on a +> network volume.** + +**What the user should click: Allow.** Denying doesn't break the scan — +the walk gets `EPERM` on that mount and the rest of the run completes +normally — it just drops the container inventory. The decision is +remembered per user; the prompt does not come back. + +Admins have two ways to keep it off developers' screens, and they are +mutually exclusive: + +1. **Pre-answer it with a PPPC profile** (allow *or* deny — both + suppress the prompt). This is the better option: `Allowed=true` + keeps full coverage with no user interaction. It requires a fixed + system-wide install path — see + [Network Volumes pre-approval](#network-volumes-pre-approval-recommended-for-fleets) + below. +2. **Turn the walk off** with `include_network_volumes: false`. No + prompt, no profile, no fixed install path needed — and no container + inventory. This is the escape hatch for fleets already deployed + per-user that can't migrate the install path. + ## Toggling the behavior Three places can set the toggle. CLI flag wins over persistent config @@ -77,6 +118,27 @@ stepsecurity-dev-machine-guard --pretty --enable-npm-scan --include-tcc-protecte # Explicit skip (even if config says otherwise) stepsecurity-dev-machine-guard --pretty --enable-npm-scan --no-include-tcc-protected + +# Skip network volumes (container mounts) for this run — no prompt, +# no container inventory +stepsecurity-dev-machine-guard --pretty --enable-npm-scan --no-include-network-volumes + +# Walk them (the default) even if config says otherwise +stepsecurity-dev-machine-guard --pretty --enable-npm-scan --include-network-volumes +``` + +The two toggles are independent, and their defaults are opposites: +`include_tcc_protected` defaults to **skip**, `include_network_volumes` +defaults to **walk**. + +When network volumes are skipped, the run logs exactly which mounts it +gave up so the coverage loss is visible in fleet logs rather than +silent: + +``` +WARN macOS TCC: skipping 1 network volume(s) — packages inside container + mounts will not be inventoried. Pass --include-network-volumes to + scan them: [/Users/alice/OrbStack] ``` ### Persistent config (`~/.stepsecurity/config.json`) @@ -87,10 +149,15 @@ stepsecurity-dev-machine-guard --pretty --enable-npm-scan --no-include-tcc-prote "api_endpoint": "https://api.stepsecurity.io", "api_key": "step_…", "scan_frequency_hours": "4", - "include_tcc_protected": true + "include_tcc_protected": true, + "include_network_volumes": false } ``` +Omit `include_network_volumes` entirely to keep the default (walk them). +Only `false` has an effect; `true` is the default and is written by +`configure` as an omitted field. + The agent reads this on every run. On an MDM-deployed fleet the StepSecurity loader script (the `.sh` file the dashboard generates for each customer) writes `config.json` on every periodic tick, so to roll @@ -107,6 +174,12 @@ self-censor**. macOS still enforces TCC: without a grant, reads in protected dirs will silently fail with `EACCES`. For the agent to actually see the contents, it needs Full Disk Access (FDA). +The same PPPC mechanism pre-answers the Network Volumes prompt +(`SystemPolicyNetworkVolumes`), which is a separate service from FDA — +granting Full Disk Access does **not** cover it. Both are in the ready-made +profile at +[`packaging/macos/stepsecurity-dev-machine-guard-tcc.mobileconfig`](../packaging/macos/stepsecurity-dev-machine-guard-tcc.mobileconfig). + There are two ways to grant FDA. ### Option A — MDM-pushed PPPC profile (recommended for fleets) @@ -130,6 +203,20 @@ This is the only way to grant FDA at scale without per-user clicks. `/usr/local/stepsecurity`, which installs the binary at `/usr/local/stepsecurity/bin/stepsecurity-dev-machine-guard`. + Two things that do **not** work as substitutes, so nobody spends an + afternoon on them: + + - **A symlink at a stable path.** TCC matches the resolved executable, + not the path used to launch it, so a symlink pointing into `$HOME` + is evaluated as the `$HOME` path and the profile never matches. + - **`IdentifierType=bundleID`.** That form is for app bundles. The + agent is a bare Mach-O CLI binary, so `path` is the only usable + identifier type. + + See [Migrating a per-user fleet to a fixed install + path](#migrating-a-per-user-fleet-to-a-fixed-install-path) if the + fleet is already deployed. + - **The code requirement string** derived from the binary's signature. PPPC pairs the install path with this requirement so an impostor binary at the same path can't claim the grant. Generate it with: @@ -149,8 +236,11 @@ This is the only way to grant FDA at scale without per-user clicks. Most MDMs (Jamf Pro, Kandji, Intune for macOS, JumpCloud, Mosyle, SimpleMDM, …) accept a `.mobileconfig` profile or a JSON equivalent they convert. The relevant payload type is -`com.apple.TCC.configuration-profile-policy`. A minimal profile -granting **SystemPolicyAllFiles** (Full Disk Access) to the agent: +`com.apple.TCC.configuration-profile-policy`. A profile granting +**SystemPolicyAllFiles** (Full Disk Access) and **SystemPolicyNetworkVolumes** +(container mounts, network shares) to the agent — the same content as +[`packaging/macos/stepsecurity-dev-machine-guard-tcc.mobileconfig`](../packaging/macos/stepsecurity-dev-machine-guard-tcc.mobileconfig), +reproduced here so the payload shape is visible in context: ```xml @@ -197,6 +287,21 @@ granting **SystemPolicyAllFiles** (Full Disk Access) to the agent: Allow Dev Machine Guard to scan all files for dev-tool inventory and supply-chain checks. + SystemPolicyNetworkVolumes + + + Identifier + REPLACE_INSTALL_DIR/bin/stepsecurity-dev-machine-guard + IdentifierType + path + CodeRequirement + anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "D63S9HLM4L" + Allowed + + Comment + Pre-approve the network-volume prompt so container-runtime mounts (OrbStack, Docker Desktop, Colima virtiofs) are inventoried without asking the developer. Set Allowed to false to pre-deny instead: still no prompt, but no container inventory. + + @@ -214,6 +319,66 @@ Replace: The `CodeRequirement` is already pinned to StepSecurity's Apple Developer Team ID (`D63S9HLM4L`) — leave it as-is. +### Network Volumes pre-approval (recommended for fleets) + +The `SystemPolicyNetworkVolumes` block above is what pre-answers the +"would like to access files on a network volume" prompt. Three +outcomes, chosen by the admin rather than by each developer: + +| Configuration | Prompt | Container inventory | +|---|---|---| +| `Allowed=true` in PPPC | none | **yes** — full coverage | +| `Allowed=false` in PPPC | none | no (walk gets `EPERM`) | +| No profile, `include_network_volumes: false` | none | no (walk never starts) | +| No profile, default config | **once per user** | yes, if the user clicks Allow | + +`Allowed=true` is the one to aim for. `Allowed=false` and +`include_network_volumes: false` reach the same end state by different +routes: the PPPC deny is enforced by macOS and needs the fixed install +path, while the config toggle is enforced by the agent and works on a +per-user install today. Prefer the config toggle if you're not +migrating the install path — it also skips the wasted walk. + +#### Migrating a per-user fleet to a fixed install path + +PPPC needs one absolute path that applies to every user on the device, +which `~/.stepsecurity/bin/` cannot provide. To move an existing +per-user deployment: + +1. **Set the install directory in the loader.** In the StepSecurity + dashboard's loader Advanced Configuration, set the install dir to a + system-wide path — `/usr/local/stepsecurity` is the convention. The + loader places the binary at + `/usr/local/stepsecurity/bin/stepsecurity-dev-machine-guard` and + writes `install_dir` into `config.json`; scheduler-fired runs pick it + up on the next tick with no re-install (resolution order: + `--install-dir` flag > `install_dir` config > `STEPSECURITY_HOME` env + > `~/.stepsecurity`). +2. **Re-deploy the loader via MDM.** The next periodic tick installs the + binary at the new path and rewrites the launchd job to point at it. +3. **Push the PPPC profile** with `REPLACE_INSTALL_DIR` set to the same + directory. +4. **Verify the path actually took**, since a stale env var or a + hand-edited config can leave the old location live: + + ```bash + ps -Ao args | grep -m1 stepsecurity-dev-machine-guard # what launchd runs + grep install_dir ~/.stepsecurity/config.json # what config says + ``` + + Both must show the fixed directory. `config.json` itself stays at + `~/.stepsecurity/config.json` by design — it is the bootstrap file + and does not move. +5. **Clean up the old per-user binaries** at + `~/.stepsecurity/bin/` once the fleet has checked in. Leaving them is + harmless (nothing launches them), but they'd hold a stale manual FDA + grant. + +Until a device completes this migration, the PPPC profile simply +doesn't match it — the profile is inert, not harmful, and the device +keeps prompting (or keeps honoring `include_network_volumes: false`). +Mixed fleets are fine. + #### Push the profile | MDM | Path | @@ -234,6 +399,20 @@ profiles list -all | grep -i stepsecurity # and confirm "stepsecurity-dev-machine-guard" is listed and toggled on. ``` +Network Volumes has no System Settings pane of its own, so verify that +grant behaviorally instead — on a machine with containers running, a +scan should complete without a prompt and report packages found under +the container mount: + +```bash +/usr/local/stepsecurity/bin/stepsecurity-dev-machine-guard \ + --pretty --enable-npm-scan --verbose 2>&1 | grep -i "network volume" +``` + +Silence means the walk proceeded. A "skipping N network volume(s)" line +means the agent self-censored (`include_network_volumes: false`), which +is the config toggle, not the profile. + ### Option B — Manual grant per machine For dev-only or single-machine testing, grant FDA manually: @@ -257,9 +436,10 @@ survive upgrades within that line. A fleet rollout that scans TCC paths typically looks like: 1. Customer's MDM deploys the loader script (downloaded from the - StepSecurity dashboard for that customer). + StepSecurity dashboard for that customer), configured with a fixed + system-wide install directory. 2. Customer's MDM **also** deploys the PPPC profile (Option A above) - granting the agent Full Disk Access. + granting the agent Full Disk Access **and** Network Volumes access. 3. The loader's generated `config.json` includes `"include_tcc_protected": true`. Either: - Customer edits the loader script's `write_config()` heredoc to @@ -271,6 +451,11 @@ A fleet rollout that scans TCC paths typically looks like: After the next periodic fire, the agent runs with full coverage and no popups. +A fleet that **can't** move off the per-user install path runs the same +sequence without steps 1–2 and adds `"include_network_volumes": false` +to the config in step 3. No popups either — the trade is the package +inventory inside dev containers. + ## What if I see a popup anyway? If a popup appears after deploying the PPPC profile and setting @@ -288,6 +473,7 @@ If a popup appears after deploying the PPPC profile and setting ```bash sudo tccutil reset SystemPolicyAllFiles + sudo tccutil reset SystemPolicyNetworkVolumes # the container-mount prompt ``` This forces re-evaluation against the latest profile on the next @@ -296,10 +482,24 @@ If a popup appears after deploying the PPPC profile and setting - **`include_tcc_protected` not actually set.** Verify with `cat ~/.stepsecurity/config.json` and re-run the loader's `write_config` step if the field is missing. +- **The popup names a network volume.** That's the separate + `SystemPolicyNetworkVolumes` service — a Full Disk Access grant does + not cover it. Add the second service block to the profile, or set + `include_network_volumes: false` if the fleet can't take the profile + route. +- **A new container runtime appeared.** The mount list is read from the + kernel at each run, not hard-coded, so a newly installed runtime is + picked up automatically — including its first prompt. The PPPC grant + is per-binary, not per-volume, so an existing profile already covers + it. ## Related -- `internal/tcc/tcc.go` — the skip-list source of truth in this repo. +- `internal/tcc/tcc.go` — the skip-list source of truth in this repo; + `internal/tcc/tcc_darwin.go` holds the protected-path table and the + `getfsstat`-based network-volume enumeration. +- [`packaging/macos/`](../packaging/macos/) — the ready-made PPPC + profile covering both services. - The StepSecurity macOS loader script (the `.sh` your dashboard generates for your customer ID) — writes `config.json` on each periodic tick, so the `include_tcc_protected` flag travels with the diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 37daf9da..c3d2ec66 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -39,12 +39,20 @@ type Config struct { // an MDM-pushed PPPC profile) flip this to true to opt back into // full scan coverage. See docs/macos-tcc-permissions.md. IncludeTCCProtected *bool - NPMRCOnly bool // --npmrc: run only the npmrc audit and render verbose pretty output - PipConfigOnly bool // --pipconfig: run only the pip config audit and render verbose pretty output - PnpmRCOnly bool // --pnpmrc: run only the pnpm config audit and render verbose pretty output - BunfigOnly bool // --bunfig: run only the bun config audit and render verbose pretty output - YarnRCOnly bool // --yarnrc: run only the yarn config audit (both flavors) and render verbose pretty output - SearchDirs []string // defaults to ["$HOME"] + // IncludeNetworkVolumes is tristate with the OPPOSITE default to + // IncludeTCCProtected: nil or true = walk macOS network volumes + // (container-runtime mounts — OrbStack, Docker Desktop, Colima — are + // Network Volumes to macOS), false = skip them. Walking them is what + // inventories packages inside dev containers, at the cost of a one-time + // TCC prompt on machines with no PPPC pre-approval. Fleets that can't + // pre-approve set this to false. See docs/macos-tcc-permissions.md. + IncludeNetworkVolumes *bool + NPMRCOnly bool // --npmrc: run only the npmrc audit and render verbose pretty output + PipConfigOnly bool // --pipconfig: run only the pip config audit and render verbose pretty output + PnpmRCOnly bool // --pnpmrc: run only the pnpm config audit and render verbose pretty output + BunfigOnly bool // --bunfig: run only the bun config audit and render verbose pretty output + YarnRCOnly bool // --yarnrc: run only the yarn config audit (both flavors) and render verbose pretty output + SearchDirs []string // defaults to ["$HOME"] // GateProceedReason is populated at runtime (not a CLI flag) by the run // gate when it lets a run proceed, so telemetry.Run can echo the gate @@ -219,6 +227,12 @@ func Parse(args []string) (*Config, error) { case arg == "--no-include-tcc-protected": v := false cfg.IncludeTCCProtected = &v + case arg == "--include-network-volumes": + v := true + cfg.IncludeNetworkVolumes = &v + case arg == "--no-include-network-volumes": + v := false + cfg.IncludeNetworkVolumes = &v case arg == "--npmrc": cfg.NPMRCOnly = true case arg == "--pipconfig": @@ -518,6 +532,17 @@ Options: Settings — see docs/macos-tcc-permissions.md. --no-include-tcc-protected Skip macOS TCC-protected dirs even if config has include_tcc_protected: true. + --no-include-network-volumes Skip macOS network volumes — which is how macOS + classifies container-runtime mounts (OrbStack, + Docker Desktop, Colima). Suppresses the one-time + "access files on a network volume" prompt at the + cost of the package inventory inside dev + containers. Default: walked. Fleets that can + pre-approve the prompt with a PPPC profile + should keep the default — see + docs/macos-tcc-permissions.md. + --include-network-volumes Walk macOS network volumes even if config has + include_network_volumes: false. --npmrc Run ONLY the npm config audit (verbose pretty view; --json supported) --pipconfig Run ONLY the pip config audit (verbose pretty view; --json supported) --pnpmrc Run ONLY the pnpm config audit (verbose pretty view; --json supported) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index f1609c93..3bee2c4f 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -122,6 +122,32 @@ func TestParse_NPMScan(t *testing.T) { } } +func TestParse_NetworkVolumes(t *testing.T) { + cfg, err := Parse(nil) + if err != nil { + t.Fatal(err) + } + if cfg.IncludeNetworkVolumes != nil { + t.Error("expected IncludeNetworkVolumes unset by default (walk them)") + } + + cfg, err = Parse([]string{"--no-include-network-volumes"}) + if err != nil { + t.Fatal(err) + } + if cfg.IncludeNetworkVolumes == nil || *cfg.IncludeNetworkVolumes { + t.Error("expected IncludeNetworkVolumes=false") + } + + cfg, err = Parse([]string{"--include-network-volumes"}) + if err != nil { + t.Fatal(err) + } + if cfg.IncludeNetworkVolumes == nil || !*cfg.IncludeNetworkVolumes { + t.Error("expected IncludeNetworkVolumes=true") + } +} + func TestParse_SearchDirs(t *testing.T) { cfg, err := Parse([]string{"--search-dirs", "/tmp", "/opt"}) if err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 9bdc0d01..f0eafa6d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,15 +16,24 @@ var ( APIKey = "{{API_KEY}}" //#nosec G101 -- build-time placeholder substituted by the backend installer; the literal is not a real credential. ScanFrequencyHours = "{{SCAN_FREQUENCY_HOURS}}" SearchDirs []string - EnableNPMScan *bool // nil=auto - EnableBrewScan *bool // nil=auto - EnablePythonScan *bool // nil=auto - IncludeTCCProtected *bool // nil or false = skip macOS TCC-protected dirs (default); true = walk them. The scan as a whole runs in both cases; only reads inside the TCC-protected subtrees themselves (Documents, Mail, etc.) need the agent to have Full Disk Access (PPPC or manual grant) — without that, those walks return EACCES per entry while the rest of the scan completes normally. See docs/macos-tcc-permissions.md. - ColorMode string // "" means auto - OutputFormat string // "" means default (pretty) - HTMLOutputFile string // "" means not set - LogLevel string // "" means default (info); one of error/warn/info/debug - InstallDir string // "" means default (~/.stepsecurity); non-empty makes the agent put all its files (logs, hook errors, future state) under this directory. Bootstrap config.json itself stays at the legacy location. Per-run opt-out is the CLI flag --install-dir=. Resolution: --install-dir flag > STEPSECURITY_HOME env > this field > default — see internal/paths. + EnableNPMScan *bool // nil=auto + EnableBrewScan *bool // nil=auto + EnablePythonScan *bool // nil=auto + IncludeTCCProtected *bool // nil or false = skip macOS TCC-protected dirs (default); true = walk them. The scan as a whole runs in both cases; only reads inside the TCC-protected subtrees themselves (Documents, Mail, etc.) need the agent to have Full Disk Access (PPPC or manual grant) — without that, those walks return EACCES per entry while the rest of the scan completes normally. See docs/macos-tcc-permissions.md. + // IncludeNetworkVolumes defaults the OTHER way from IncludeTCCProtected: + // nil or true = walk macOS network volumes (default); false = skip them. + // Container runtimes (OrbStack, Docker Desktop, Colima) expose the guest + // filesystem as a network volume, so walking them is what inventories + // packages inside dev containers — and what fires a one-time + // SystemPolicyNetworkVolumes prompt on machines with no PPPC + // pre-approval. Fleets that cannot pre-approve trade that coverage away + // by setting this to false. See docs/macos-tcc-permissions.md. + IncludeNetworkVolumes *bool + ColorMode string // "" means auto + OutputFormat string // "" means default (pretty) + HTMLOutputFile string // "" means not set + LogLevel string // "" means default (info); one of error/warn/info/debug + InstallDir string // "" means default (~/.stepsecurity); non-empty makes the agent put all its files (logs, hook errors, future state) under this directory. Bootstrap config.json itself stays at the legacy location. Per-run opt-out is the CLI flag --install-dir=. Resolution: --install-dir flag > STEPSECURITY_HOME env > this field > default — see internal/paths. // UseLegacyPackageScan, when true, disables the scan-state delta-upload // optimization for npm and Python project scans — every run re-uploads // the full snapshot as in pre-1.13 agents. @@ -67,24 +76,25 @@ var MaxExecutionDuration string // ConfigFile is the JSON structure persisted to ~/.stepsecurity/config.json. type ConfigFile struct { - CustomerID string `json:"customer_id,omitempty"` - APIEndpoint string `json:"api_endpoint,omitempty"` - APIKey string `json:"api_key,omitempty"` - ScanFrequencyHours string `json:"scan_frequency_hours,omitempty"` - SearchDirs []string `json:"search_dirs,omitempty"` - EnableNPMScan *bool `json:"enable_npm_scan,omitempty"` - EnableBrewScan *bool `json:"enable_brew_scan,omitempty"` - EnablePythonScan *bool `json:"enable_python_scan,omitempty"` - IncludeTCCProtected *bool `json:"include_tcc_protected,omitempty"` - ColorMode string `json:"color_mode,omitempty"` - OutputFormat string `json:"output_format,omitempty"` - HTMLOutputFile string `json:"html_output_file,omitempty"` - LogLevel string `json:"log_level,omitempty"` - InstallDir string `json:"install_dir,omitempty"` - MaxExecutionDuration string `json:"max_execution_duration,omitempty"` - UseLegacyPackageScan *bool `json:"use_legacy_package_scan,omitempty"` - UseLegacyNodeScan *bool `json:"use_legacy_node_scan,omitempty"` - UseLegacyPythonScan *bool `json:"use_legacy_python_scan,omitempty"` + CustomerID string `json:"customer_id,omitempty"` + APIEndpoint string `json:"api_endpoint,omitempty"` + APIKey string `json:"api_key,omitempty"` + ScanFrequencyHours string `json:"scan_frequency_hours,omitempty"` + SearchDirs []string `json:"search_dirs,omitempty"` + EnableNPMScan *bool `json:"enable_npm_scan,omitempty"` + EnableBrewScan *bool `json:"enable_brew_scan,omitempty"` + EnablePythonScan *bool `json:"enable_python_scan,omitempty"` + IncludeTCCProtected *bool `json:"include_tcc_protected,omitempty"` + IncludeNetworkVolumes *bool `json:"include_network_volumes,omitempty"` + ColorMode string `json:"color_mode,omitempty"` + OutputFormat string `json:"output_format,omitempty"` + HTMLOutputFile string `json:"html_output_file,omitempty"` + LogLevel string `json:"log_level,omitempty"` + InstallDir string `json:"install_dir,omitempty"` + MaxExecutionDuration string `json:"max_execution_duration,omitempty"` + UseLegacyPackageScan *bool `json:"use_legacy_package_scan,omitempty"` + UseLegacyNodeScan *bool `json:"use_legacy_node_scan,omitempty"` + UseLegacyPythonScan *bool `json:"use_legacy_python_scan,omitempty"` } // userConfigDir returns ~/.stepsecurity — the per-user config location. @@ -199,6 +209,9 @@ func Load() { if cfg.IncludeTCCProtected != nil && IncludeTCCProtected == nil { IncludeTCCProtected = cfg.IncludeTCCProtected } + if cfg.IncludeNetworkVolumes != nil && IncludeNetworkVolumes == nil { + IncludeNetworkVolumes = cfg.IncludeNetworkVolumes + } if cfg.ColorMode != "" && ColorMode == "" { ColorMode = cfg.ColorMode } @@ -354,6 +367,22 @@ func RunConfigure() error { existing.IncludeTCCProtected = nil } + // Walk macOS network volumes — container-runtime mounts (OrbStack, + // Docker Desktop, Colima) among them. Default is to walk them, so this + // one is stored only when DISABLED; anything but "false" clears it back + // to the default. See docs/macos-tcc-permissions.md. + currentVolumes := "true" + if existing.IncludeNetworkVolumes != nil && !*existing.IncludeNetworkVolumes { + currentVolumes = "false" + } + volumesInput := promptValue(reader, "Scan macOS network volumes — container mounts; false suppresses the TCC prompt (true/false)", currentVolumes) + if strings.EqualFold(strings.TrimSpace(volumesInput), "false") { + v := false + existing.IncludeNetworkVolumes = &v + } else { + existing.IncludeNetworkVolumes = nil + } + // Color mode currentColor := existing.ColorMode if currentColor == "" { @@ -545,6 +574,7 @@ func ShowConfigure() { fmt.Printf(" %-24s %s\n", "Enable Python Scan:", displayBoolScan(cfg.EnablePythonScan)) fmt.Printf(" %-24s %s\n", "Legacy Python Scan:", displayBoolScan(cfg.UseLegacyPythonScan)) fmt.Printf(" %-24s %s\n", "Scan TCC-Protected Dirs:", displayTCC(cfg.IncludeTCCProtected)) + fmt.Printf(" %-24s %s\n", "Scan Network Volumes:", displayNetworkVolumes(cfg.IncludeNetworkVolumes)) fmt.Printf(" %-24s %s\n", "Color Mode:", displayColorMode(cfg.ColorMode)) fmt.Printf(" %-24s %s\n", "Output Format:", displayOutputFormat(cfg.OutputFormat)) if cfg.OutputFormat == "html" { @@ -655,6 +685,16 @@ func displayTCC(v *bool) string { return "false (default — TCC-protected dirs skipped)" } +// displayNetworkVolumes renders the macOS network-volume toggle. nil/true +// both mean the default (walk them — container mounts stay in the +// inventory); false means skip them to suppress the TCC prompt. +func displayNetworkVolumes(v *bool) string { + if v != nil && !*v { + return "false (network volumes skipped — no TCC prompt, no container inventory)" + } + return "true (default — network volumes scanned)" +} + func isPlaceholder(v string) bool { return strings.Contains(v, "{{") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ef343517..074af57a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -224,6 +224,44 @@ func TestConfigFile_UseLegacyPackageScan_JSONRoundTrip(t *testing.T) { } } +func TestLoad_IncludeNetworkVolumes_AppliedFromFile(t *testing.T) { + prev := IncludeNetworkVolumes + t.Cleanup(func() { IncludeNetworkVolumes = prev }) + IncludeNetworkVolumes = nil + + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("STEPSECURITY_HOME", dir) + cfgPath := filepath.Join(dir, ".stepsecurity", "config.json") + if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil { + t.Fatal(err) + } + // false is the value that matters: it's how an MDM fleet opts out of the + // network-volume walk (and its TCC prompt). omitempty must not drop it. + if err := os.WriteFile(cfgPath, []byte(`{"include_network_volumes":false}`), 0o600); err != nil { + t.Fatal(err) + } + + Load() + + if IncludeNetworkVolumes == nil || *IncludeNetworkVolumes { + t.Errorf("Load did not propagate include_network_volumes=false, got %v", IncludeNetworkVolumes) + } + + data, err := json.Marshal(ConfigFile{IncludeNetworkVolumes: IncludeNetworkVolumes}) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(data, []byte(`"include_network_volumes":false`)) { + t.Errorf("explicit false should serialize, not be omitted: %s", data) + } + if data, err = json.Marshal(ConfigFile{}); err != nil { + t.Fatal(err) + } else if bytes.Contains(data, []byte("include_network_volumes")) { + t.Errorf("nil should be omitted: %s", data) + } +} + func TestLoad_UseLegacyPackageScan_AppliedFromFile(t *testing.T) { // Save and restore package var. prev := UseLegacyPackageScan diff --git a/internal/scan/scanner.go b/internal/scan/scanner.go index ed444603..24e3431e 100644 --- a/internal/scan/scanner.go +++ b/internal/scan/scanner.go @@ -37,15 +37,16 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { // Build the TCC skipper so directory walks avoid macOS-protected dirs // (Documents, Downloads, ~/Library/Mail, ...) and don't trigger system - // permission prompts. Nil when --include-tcc-protected is set; the - // skipper's ShouldSkip is nil-safe so downstream callers don't branch. - var tccSkipper *tcc.Skipper - if tcc.Enabled(cfg.IncludeTCCProtected) { - tccSkipper = tcc.New(executor.ResolveHome(exec)) - if cands := tccSkipper.Candidates(); len(cands) > 0 { - log.Warn("macOS TCC: skipping %d protected dirs (Documents, Downloads, ~/Library/Mail, ...) to avoid permission prompts. Pass --include-tcc-protected to scan them.", len(cands)) - log.Debug("tcc skip list: %v", cands) - } + // permission prompts. Nil when --include-tcc-protected is set and + // network volumes are walked (the default); every Skipper method is + // nil-safe so downstream callers don't branch. + tccSkipper := tcc.ForRun(executor.ResolveHome(exec), cfg.IncludeTCCProtected, cfg.IncludeNetworkVolumes) + if cands := tccSkipper.Candidates(); len(cands) > 0 { + log.Warn("macOS TCC: skipping %d protected dirs (Documents, Downloads, ~/Library/Mail, ...) to avoid permission prompts. Pass --include-tcc-protected to scan them.", len(cands)) + log.Debug("tcc skip list: %v", cands) + } + if vols := tccSkipper.NetworkVolumes(); len(vols) > 0 { + log.Warn("macOS TCC: skipping %d network volume(s) — packages inside container mounts will not be inventoried. Pass --include-network-volumes to scan them: %v", len(vols), vols) } // Gather device info diff --git a/internal/tcc/tcc.go b/internal/tcc/tcc.go index 43864796..3ce463ed 100644 --- a/internal/tcc/tcc.go +++ b/internal/tcc/tcc.go @@ -1,7 +1,19 @@ // Package tcc identifies macOS TCC (Transparency, Consent, and Control) -// protected directories so filesystem walks can skip them and avoid +// protected locations so filesystem walks can skip them and avoid // triggering system permission prompts on a user's machine. // +// Two independent skip classes, each with its own toggle and its own +// default, because the prompt/coverage trade-off differs between them: +// +// - Protected directories (~/Documents, ~/Library, …), gated by Enabled. +// Skipped by default — nothing developers care about lives there. +// - Network volumes (non-local mounts, which is how macOS classifies +// OrbStack/Docker/Colima container mounts), gated by +// SkipNetworkVolumes. Walked by default — that walk is the only thing +// that inventories packages inside dev containers. +// +// ForRun resolves both toggles and builds the one Skipper the scan uses. +// // On non-darwin builds the Skipper is a no-op: ShouldSkip always returns // false and Candidates returns nil, so callers can wire it unconditionally. package tcc @@ -34,20 +46,41 @@ func Enabled(override *bool) bool { return true } -// Skipper matches TCC-protected directories. Build one per scan via New; -// share across detectors. Hits are tracked so callers can prove from logs -// which protected paths were actually encountered during the walks. +// SkipNetworkVolumes reports whether the network-volume skip class should +// be active for this run. The override is the resolved tri-state +// cfg/config value: nil or true to apply the default (walk them), false to +// skip them. +// +// The polarity is the mirror image of Enabled, deliberately. Container +// runtimes expose their filesystems over virtiofs and friends, which macOS +// classifies as Network Volumes, so the first walk into an OrbStack / +// Docker / Colima mount fires a kTCCServiceSystemPolicyNetworkVolumes +// prompt. Skipping by default would suppress the prompt but also drop the +// package inventory inside dev containers — supply-chain surface nothing +// else covers — so the default keeps the coverage and admins who can't +// pre-approve the prompt via PPPC opt out per fleet with +// include_network_volumes: false. See docs/macos-tcc-permissions.md. +func SkipNetworkVolumes(override *bool) bool { + return override != nil && !*override +} + +// Skipper matches TCC-protected locations. Build one per scan via ForRun +// (or New for the protected-dirs class alone); share across detectors. +// Hits are tracked so callers can prove from logs which protected paths +// were actually encountered during the walks. type Skipper struct { paths map[string]struct{} prefixes []string + volumes []string mu sync.Mutex hits map[string]int } -// New builds a Skipper anchored at home. home == "" produces a degraded -// Skipper that only matches absolute-prefix entries (e.g. Time Machine -// snapshot mounts) — useful when the agent runs without a console user. +// New builds a Skipper for the protected-directories class alone, anchored +// at home. home == "" produces a degraded Skipper that only matches +// absolute-prefix entries (e.g. Time Machine snapshot mounts) — useful when +// the agent runs without a console user. func New(home string) *Skipper { return &Skipper{ paths: buildProtectedPaths(home), @@ -55,13 +88,48 @@ func New(home string) *Skipper { } } -// ShouldSkip reports whether path is a TCC-protected directory whose walk -// should be short-circuited. When path equals walkRoot the result is always -// false: passing --search-dirs ~/Documents is an explicit opt-in, and the -// walk root must be entered for anything to happen. +// ForRun builds the Skipper for one scan from both tri-state toggles. It +// returns nil when neither class is active — every Skipper method is +// nil-safe, so callers hand the result straight to detectors without +// branching. +// +// Enumerating the mount table is a metadata read, not a volume access, so +// it never fires the prompt it exists to avoid. +func ForRun(home string, includeTCCProtected, includeNetworkVolumes *bool) *Skipper { + var mounts []string + if SkipNetworkVolumes(includeNetworkVolumes) { + mounts = networkVolumeMounts() + } + return build(home, Enabled(includeTCCProtected), mounts) +} + +// build is ForRun without the mount-table syscall, so the matching rules +// are testable on every platform. +func build(home string, protected bool, mounts []string) *Skipper { + if !protected && len(mounts) == 0 { + return nil + } + s := &Skipper{volumes: mounts} + if protected { + s.paths = buildProtectedPaths(home) + s.prefixes = protectedPrefixes() + } + return s +} + +// ShouldSkip reports whether path is a TCC-protected directory — or a +// skipped network-volume mount point — whose walk should be +// short-circuited. When path equals walkRoot the result is always false: +// passing --search-dirs ~/Documents (or --search-dirs ~/OrbStack) is an +// explicit opt-in, and the walk root must be entered for anything to +// happen. +// +// Callers must consult this BEFORE reading the directory: it is the +// mountpoint's ReadDir, not the parent's listing of it, that fires the +// network-volume prompt. // // Safe to call on a nil receiver (returns false), which is what callers -// pass when --include-tcc-protected is set. +// pass when --include-tcc-protected is set and no volume class is skipped. func (s *Skipper) ShouldSkip(path, walkRoot string) bool { if s == nil { return false @@ -80,7 +148,7 @@ func (s *Skipper) ShouldSkip(path, walkRoot string) bool { return true } } - return false + return s.withinNetworkVolume(cleaned) } // WithinProtected reports whether path is a TCC-protected directory OR lies @@ -91,7 +159,8 @@ func (s *Skipper) ShouldSkip(path, walkRoot string) bool { // firing the very prompt we avoid — must use this BEFORE any filesystem access. // Safe on a nil receiver (returns false), matching the --include-tcc-protected // opt-in. Records a hit against the matched protected root so LogHits surfaces -// the skip. +// the skip. Also matches paths under a skipped network-volume mount, for the +// same reason: resolving into one is what triggers the prompt. func (s *Skipper) WithinProtected(path string) bool { if s == nil { return false @@ -113,6 +182,20 @@ func (s *Skipper) WithinProtected(path string) bool { return true } } + return s.withinNetworkVolume(cleaned) +} + +// withinNetworkVolume reports whether cleaned is a skipped network-volume +// mount point or lies beneath one, recording a hit against the mount. The +// slice is empty unless the run opted out of walking network volumes, so +// this is a no-op loop on the default path. +func (s *Skipper) withinNetworkVolume(cleaned string) bool { + for _, v := range s.volumes { + if hasDirPrefix(cleaned, v) { + s.recordHit(v) + return true + } + } return false } @@ -196,9 +279,24 @@ func (s *Skipper) LogHits(emit func(format string, args ...any)) { emit("macOS TCC: encountered and skipped %d protected path(s) during walks: %v", len(paths), paths) } +// NetworkVolumes returns the network-volume mount points the Skipper would +// skip, sorted lexicographically. Empty on the default path (network +// volumes are walked) and on non-darwin builds. Useful for surfacing in +// logs which mounts a fleet's include_network_volumes: false actually cost +// it in coverage. +func (s *Skipper) NetworkVolumes() []string { + if s == nil || len(s.volumes) == 0 { + return nil + } + out := make([]string, len(s.volumes)) + copy(out, s.volumes) + return out +} + // Candidates returns the exact-match protected paths the Skipper would // skip, sorted lexicographically. Useful for surfacing in logs. Returns nil -// on a nil receiver or on non-darwin builds. +// on a nil receiver or on non-darwin builds. Network-volume mounts are not +// included — see NetworkVolumes. func (s *Skipper) Candidates() []string { if s == nil || len(s.paths) == 0 { return nil diff --git a/internal/tcc/tcc_darwin.go b/internal/tcc/tcc_darwin.go index b14f76b4..82d19af6 100644 --- a/internal/tcc/tcc_darwin.go +++ b/internal/tcc/tcc_darwin.go @@ -2,7 +2,12 @@ package tcc -import "path/filepath" +import ( + "path/filepath" + "sort" + + "golang.org/x/sys/unix" +) // protectedSuffixes are paths relative to the user's home directory that // macOS gates behind TCC permission prompts. Categories: @@ -61,3 +66,65 @@ func buildProtectedPaths(home string) map[string]struct{} { func protectedPrefixes() []string { return protectedAbsolutePrefixes } + +// mountSlack is extra room in the getfsstat buffer so a volume mounted +// between the count call and the fill call still lands in the result +// rather than silently truncating the list — a missed mount here means a +// prompt for a fleet that asked not to get one. +const mountSlack = 8 + +// networkVolumeMounts returns the mount points macOS does NOT consider +// local, sorted lexicographically. That set is what TCC gates behind +// kTCCServiceSystemPolicyNetworkVolumes: SMB/NFS/AFP shares, but also the +// virtiofs and NFS mounts container runtimes expose the guest filesystem +// through (OrbStack's ~/OrbStack, Docker Desktop and Colima shares). The +// first walk into one of those fires the "would like to access files on a +// network volume" prompt. +// +// getfsstat reads the kernel's mount table — it does not touch the volumes +// themselves — so building this list is prompt-free even for the mounts it +// is about to exclude. MNT_NOWAIT keeps it that way: it returns cached +// statistics instead of asking each filesystem to refresh, which for a +// stale network mount is also the difference between returning promptly and +// blocking on an unreachable server. +// +// "/" is excluded defensively; the root volume is always local, and a +// misread flag there would skip the entire scan. +func networkVolumeMounts() []string { + n, err := unix.Getfsstat(nil, unix.MNT_NOWAIT) + if err != nil || n <= 0 { + return nil + } + buf := make([]unix.Statfs_t, n+mountSlack) + n, err = unix.Getfsstat(buf, unix.MNT_NOWAIT) + if err != nil { + return nil + } + if n > len(buf) { + n = len(buf) + } + + var mounts []string + for i := range buf[:n] { + if buf[i].Flags&unix.MNT_LOCAL != 0 { + continue + } + mount := filepath.Clean(cString(buf[i].Mntonname[:])) + if mount == "" || mount == "." || mount == "/" { + continue + } + mounts = append(mounts, mount) + } + sort.Strings(mounts) + return mounts +} + +// cString converts a NUL-terminated fixed-size C char array to a Go string. +func cString(b []byte) string { + for i, c := range b { + if c == 0 { + return string(b[:i]) + } + } + return string(b) +} diff --git a/internal/tcc/tcc_darwin_test.go b/internal/tcc/tcc_darwin_test.go index fbeee044..98097ce1 100644 --- a/internal/tcc/tcc_darwin_test.go +++ b/internal/tcc/tcc_darwin_test.go @@ -2,7 +2,10 @@ package tcc -import "testing" +import ( + "path/filepath" + "testing" +) func TestSkipper_ShouldSkip(t *testing.T) { home := "/Users/alice" @@ -147,6 +150,28 @@ func TestSkipper_RegressionAVFoundationMediaLibraryPrompt(t *testing.T) { } } +// TestNetworkVolumeMounts exercises the real getfsstat enumeration. The +// mount set depends on the machine, so the assertions are the invariants +// every caller relies on: absolute, sorted, and never "/" — skipping the +// root volume would silently gut the whole scan. +func TestNetworkVolumeMounts(t *testing.T) { + mounts := networkVolumeMounts() + for i, m := range mounts { + if m == "/" { + t.Error("root volume must never be reported as a network volume") + } + if !filepath.IsAbs(m) { + t.Errorf("mount %q is not absolute", m) + } + if m != filepath.Clean(m) { + t.Errorf("mount %q is not cleaned", m) + } + if i > 0 && mounts[i-1] > m { + t.Errorf("mounts not sorted: %q > %q", mounts[i-1], m) + } + } +} + func TestSkipper_CandidatesSorted(t *testing.T) { s := New("/Users/alice") cands := s.Candidates() diff --git a/internal/tcc/tcc_other.go b/internal/tcc/tcc_other.go index ca2754a9..4bf443e3 100644 --- a/internal/tcc/tcc_other.go +++ b/internal/tcc/tcc_other.go @@ -9,3 +9,9 @@ func buildProtectedPaths(_ string) map[string]struct{} { func protectedPrefixes() []string { return nil } + +// networkVolumeMounts is darwin-only: the Network Volumes TCC service does +// not exist elsewhere, so there is nothing to skip. +func networkVolumeMounts() []string { + return nil +} diff --git a/internal/tcc/volumes_test.go b/internal/tcc/volumes_test.go new file mode 100644 index 00000000..399705d6 --- /dev/null +++ b/internal/tcc/volumes_test.go @@ -0,0 +1,208 @@ +package tcc + +import ( + "io/fs" + "os" + "path/filepath" + "reflect" + "testing" +) + +// The network-volume class is driven entirely by the mount list handed to +// build(), so these tests run on every platform — only the enumeration +// itself (networkVolumeMounts) is darwin-specific. + +func TestSkipNetworkVolumes(t *testing.T) { + trueVal := true + falseVal := false + + tests := []struct { + name string + override *bool + want bool + }{ + {"nil override → walk (default keeps container coverage)", nil, false}, + {"explicit include (true) → walk", &trueVal, false}, + {"explicit exclude (false) → skip", &falseVal, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := SkipNetworkVolumes(tc.override); got != tc.want { + t.Errorf("SkipNetworkVolumes(%v) = %v, want %v", tc.override, got, tc.want) + } + }) + } +} + +func TestSkipper_NetworkVolumeMatching(t *testing.T) { + // The OrbStack mount is the case from issue #177; /Volumes/share stands + // in for a conventional SMB/NFS mount. + mounts := []string{"/Users/alice/OrbStack", "/Volumes/share"} + s := build("/Users/alice", false, mounts) + if s == nil { + t.Fatal("build with mounts must not return nil") + } + + tests := []struct { + name string + path string + walkRoot string + want bool + }{ + {"mount point itself skipped", "/Users/alice/OrbStack", "/Users/alice", true}, + {"trailing slash skipped", "/Users/alice/OrbStack/", "/Users/alice", true}, + {"nested under mount skipped", "/Users/alice/OrbStack/docker/containers", "/Users/alice", true}, + {"second mount skipped", "/Volumes/share/code", "/Volumes", true}, + {"sibling of mount not skipped", "/Users/alice/OrbStack-backup", "/Users/alice", false}, + {"dotted sibling of mount not skipped", "/Users/alice/OrbStack.old", "/Users/alice", false}, + {"unrelated path not skipped", "/Users/alice/code", "/Users/alice", false}, + {"explicit walk root opts in", "/Users/alice/OrbStack", "/Users/alice/OrbStack", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := s.ShouldSkip(tc.path, tc.walkRoot); got != tc.want { + t.Errorf("ShouldSkip(%q, %q) = %v, want %v", tc.path, tc.walkRoot, got, tc.want) + } + }) + } + + // WithinProtected must match the same set: detectors that resolve a deep + // path directly would otherwise stat inside the volume and fire the + // prompt the skip exists to avoid. + for _, p := range []string{"/Users/alice/OrbStack", "/Users/alice/OrbStack/docker/containers/x", "/Volumes/share"} { + if !s.WithinProtected(p) { + t.Errorf("WithinProtected(%q) = false, want true", p) + } + } + if s.WithinProtected("/Users/alice/code") { + t.Error("WithinProtected must not match a path outside every mount") + } +} + +// The protected-dirs class is off in this Skipper, so a network-volume-only +// run must not start skipping ~/Documents as a side effect. +func TestSkipper_VolumesOnlyLeavesProtectedDirsAlone(t *testing.T) { + s := build("/Users/alice", false, []string{"/Users/alice/OrbStack"}) + if s.ShouldSkip("/Users/alice/Documents", "/Users/alice") { + t.Error("volumes-only Skipper must not skip protected dirs") + } + if s.Candidates() != nil { + t.Error("volumes-only Skipper must report no protected-dir candidates") + } +} + +func TestSkipper_NetworkVolumesAccessor(t *testing.T) { + mounts := []string{"/Users/alice/OrbStack", "/Volumes/share"} + s := build("/Users/alice", true, mounts) + got := s.NetworkVolumes() + if !reflect.DeepEqual(got, mounts) { + t.Errorf("NetworkVolumes() = %v, want %v", got, mounts) + } + // Callers log this slice; mutating the copy must not corrupt the skipper. + got[0] = "/mutated" + if s.volumes[0] != mounts[0] { + t.Error("NetworkVolumes must return a copy") + } + + var nilS *Skipper + if nilS.NetworkVolumes() != nil { + t.Error("nil Skipper NetworkVolumes should return nil") + } + if build("/Users/alice", true, nil).NetworkVolumes() != nil { + t.Error("Skipper with no mounts should report no network volumes") + } +} + +func TestSkipper_HitsRecordMountNotLeaf(t *testing.T) { + s := build("", false, []string{"/Users/alice/OrbStack"}) + s.ShouldSkip("/Users/alice/OrbStack/docker/containers", "/Users/alice") + hits := s.Hits() + if hits["/Users/alice/OrbStack"] != 1 { + t.Errorf("hit should be recorded against the mount point, got %v", hits) + } +} + +func TestBuild_NilWhenNothingToSkip(t *testing.T) { + if s := build("/Users/alice", false, nil); s != nil { + t.Errorf("build with both classes off must return nil, got %+v", s) + } + // Nil is the documented "no skipping" value and every method tolerates it. + var nilS *Skipper + if nilS.ShouldSkip("/Users/alice/OrbStack", "/Users/alice") { + t.Error("nil Skipper must not skip") + } +} + +// TestSkipper_WalkNeverEntersSkippedVolume drives a real filepath.WalkDir the +// way every detector does. What fires the TCC prompt is reading the mount +// point, not seeing it listed in its parent, so the contract that matters is: +// the callback is invoked for the mount dir itself, SkipDir keeps the walk +// out of it, and nothing beneath it is ever visited. +func TestSkipper_WalkNeverEntersSkippedVolume(t *testing.T) { + home := t.TempDir() + mount := filepath.Join(home, "OrbStack") + if err := os.MkdirAll(filepath.Join(mount, "docker", "containers", "app"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(home, "code", "app"), 0o755); err != nil { + t.Fatal(err) + } + + s := build(home, false, []string{mount}) + var visited []string + err := filepath.WalkDir(home, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() && s.ShouldSkip(path, home) { + return filepath.SkipDir + } + visited = append(visited, path) + return nil + }) + if err != nil { + t.Fatal(err) + } + + for _, p := range visited { + if p != mount && hasDirPrefix(p, mount) { + t.Errorf("walk descended into a skipped network volume: %q", p) + } + } + if !hasVisited(visited, filepath.Join(home, "code", "app")) { + t.Error("walk must still cover the rest of the tree") + } + if s.Hits()[mount] != 1 { + t.Errorf("expected exactly one recorded hit on the mount, got %v", s.Hits()) + } +} + +func hasVisited(visited []string, want string) bool { + for _, p := range visited { + if p == want { + return true + } + } + return false +} + +func TestForRun_TogglePolarity(t *testing.T) { + trueVal := true + falseVal := false + + // Default run: protected dirs skipped (darwin) or nothing to skip + // (elsewhere), network volumes always walked. + if s := ForRun("/Users/alice", nil, nil); s.NetworkVolumes() != nil { + t.Error("default run must walk network volumes") + } + // Both classes opted in → nothing to skip at all. + if s := ForRun("/Users/alice", &trueVal, &trueVal); s != nil { + t.Error("include-everything run must produce a nil Skipper") + } + // Opting out of network volumes must not disturb the protected-dirs + // class, which stays on its own default. + optOut := ForRun("/Users/alice", nil, &falseVal) + if optOut == nil { + t.Fatal("network-volume opt-out must produce a Skipper") + } +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 44266da5..70fb213f 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -553,14 +553,14 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err // Build a TCC skipper so directory walks avoid macOS-protected dirs and // don't trigger system permission prompts when the agent runs without - // Full Disk Access. Nil when --include-tcc-protected is set; ShouldSkip - // is nil-safe. - var tccSkipper *tcc.Skipper - if tcc.Enabled(cfg.IncludeTCCProtected) { - tccSkipper = tcc.New(executor.ResolveHome(exec)) - if cands := tccSkipper.Candidates(); len(cands) > 0 { - log.Debug("tcc skip list (%d): %v", len(cands), cands) - } + // Full Disk Access. Nil when --include-tcc-protected is set and network + // volumes are walked (the default); every method is nil-safe. + tccSkipper := tcc.ForRun(executor.ResolveHome(exec), cfg.IncludeTCCProtected, cfg.IncludeNetworkVolumes) + if cands := tccSkipper.Candidates(); len(cands) > 0 { + log.Debug("tcc skip list (%d): %v", len(cands), cands) + } + if vols := tccSkipper.NetworkVolumes(); len(vols) > 0 { + log.Debug("tcc network volumes skipped (%d): %v", len(vols), vols) } // Detect IDEs diff --git a/packaging/macos/README.md b/packaging/macos/README.md new file mode 100644 index 00000000..ed9d82f8 --- /dev/null +++ b/packaging/macos/README.md @@ -0,0 +1,59 @@ +# macOS MDM packaging + +This directory holds the configuration-profile artifacts MDM admins push +alongside the agent. There is no installer package here — macOS +deployment goes through the StepSecurity loader script the dashboard +generates per customer. + +## `stepsecurity-dev-machine-guard-tcc.mobileconfig` + +A PPPC (Privacy Preferences Policy Control) profile that pre-approves +two TCC services for the agent, so developers never see a permission +prompt: + +| Service | Covers | Needed when | +|---|---|---| +| `SystemPolicyAllFiles` | Full Disk Access — `~/Documents`, `~/Downloads`, `~/Library`, … | `include_tcc_protected: true` | +| `SystemPolicyNetworkVolumes` | Non-local mounts, including container-runtime filesystems (OrbStack, Docker Desktop, Colima virtiofs) | **the default config** — the agent walks these to inventory packages inside dev containers | + +The second one surprises people: the agent walks container mounts out of +the box, and macOS gates that behind its own service which a Full Disk +Access grant does **not** cover. + +### Before deploying + +1. Replace `REPLACE-WITH-UUIDGEN-OUTPUT-1` and `-2` with fresh UUIDs + (`uuidgen`). +2. Replace `REPLACE_INSTALL_DIR` with the fixed system-wide install + directory configured in the loader — `/usr/local/stepsecurity` is the + convention. + +The install path **must** be system-wide. PPPC path identifiers have no +`$HOME` expansion, so the default per-user install at +`~/.stepsecurity/bin/` cannot be targeted by any profile, and a symlink +at a stable path doesn't help (TCC matches the resolved executable). + +Leave `CodeRequirement` as-is — it is pinned to StepSecurity's Apple +Developer Team ID (`D63S9HLM4L`). + +### Pre-deny instead of pre-approve + +Set `Allowed` to `false` on a service to answer the prompt with a denial +rather than a grant. Developers still see nothing; the agent's reads in +that class fail and the rest of the scan completes normally. + +For network volumes specifically there's a second way to get the same +end state without a profile — and therefore without the fixed install +path: `"include_network_volumes": false` in `config.json` stops the +agent from walking them at all. That's the route for fleets already +deployed per-user. + +### Push it + +Upload as a custom profile in your MDM (Jamf Pro, Kandji, Intune, +Mosyle, JumpCloud all accept `.mobileconfig`), scoped to the developer +machines. It takes effect on the next check-in. + +Full walkthrough, verification commands, and the migration steps for an +existing per-user fleet: +[`docs/macos-tcc-permissions.md`](../../docs/macos-tcc-permissions.md). diff --git a/packaging/macos/stepsecurity-dev-machine-guard-tcc.mobileconfig b/packaging/macos/stepsecurity-dev-machine-guard-tcc.mobileconfig new file mode 100644 index 00000000..d929e51c --- /dev/null +++ b/packaging/macos/stepsecurity-dev-machine-guard-tcc.mobileconfig @@ -0,0 +1,110 @@ + + + + + + PayloadType + Configuration + PayloadVersion + 1 + PayloadIdentifier + io.stepsecurity.dmg.tcc + PayloadUUID + REPLACE-WITH-UUIDGEN-OUTPUT-1 + PayloadDisplayName + StepSecurity Dev Machine Guard — TCC Pre-Approval + PayloadDescription + Grants Dev Machine Guard Full Disk Access and network-volume access so dev-tool inventory and supply-chain scans run without prompting developers. + PayloadOrganization + StepSecurity + PayloadScope + System + PayloadContent + + + PayloadType + com.apple.TCC.configuration-profile-policy + PayloadVersion + 1 + PayloadIdentifier + io.stepsecurity.dmg.tcc.pppc + PayloadUUID + REPLACE-WITH-UUIDGEN-OUTPUT-2 + PayloadDisplayName + Privacy Preferences Policy Control + PayloadOrganization + StepSecurity + Services + + SystemPolicyAllFiles + + + Identifier + REPLACE_INSTALL_DIR/bin/stepsecurity-dev-machine-guard + IdentifierType + path + CodeRequirement + anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "D63S9HLM4L" + Allowed + + Comment + Allow Dev Machine Guard to scan all files for dev-tool inventory and supply-chain checks. Only takes effect when include_tcc_protected is true. + + + SystemPolicyNetworkVolumes + + + Identifier + REPLACE_INSTALL_DIR/bin/stepsecurity-dev-machine-guard + IdentifierType + path + CodeRequirement + anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "D63S9HLM4L" + Allowed + + Comment + Pre-approve the network-volume prompt so container-runtime mounts (OrbStack, Docker Desktop, Colima virtiofs) are inventoried without asking the developer. Set Allowed to false to pre-deny instead: still no prompt, but no container inventory. + + + + + + + From fc7f73ec35f013447cafe3be90a0674cc13e41c3 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Mon, 17 Aug 2026 09:19:14 +0530 Subject: [PATCH 2/4] test(tcc): lock in that default runs behave exactly as before the toggle The whole point of defaulting include_network_volumes to walk is that an agent upgrade changes nothing for existing fleets. On macOS, quietly walking something new is how a customer gets a TCC prompt out of nowhere, so assert the property instead of trusting the code to keep it: build the pre-toggle New(home) skipper and the ForRun default side by side and require identical answers for every path, plus a nil skipper for --include-tcc-protected as before. Signed-off-by: Swarit Pandey --- internal/tcc/volumes_test.go | 43 ++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/internal/tcc/volumes_test.go b/internal/tcc/volumes_test.go index 399705d6..35d61627 100644 --- a/internal/tcc/volumes_test.go +++ b/internal/tcc/volumes_test.go @@ -186,6 +186,49 @@ func hasVisited(visited []string, want string) bool { return false } +// TestForRun_DefaultsMatchPreToggleBehavior is the no-surprise guarantee for +// already-deployed fleets: with neither toggle set, the Skipper must behave +// exactly as the pre-toggle New(home) did. A regression here means an agent +// upgrade silently changes which paths a scan walks — and on macOS, walking +// something new is how a customer gets a TCC prompt out of nowhere. +func TestForRun_DefaultsMatchPreToggleBehavior(t *testing.T) { + home := "/Users/alice" + before, after := New(home), ForRun(home, nil, nil) + + if after == nil { + t.Fatal("default run must still produce a Skipper") + } + if after.NetworkVolumes() != nil { + t.Error("default run must not skip any network volume") + } + if !reflect.DeepEqual(before.Candidates(), after.Candidates()) { + t.Errorf("protected-dir list drifted: %v vs %v", before.Candidates(), after.Candidates()) + } + for _, p := range []string{ + home, + home + "/Documents", + home + "/Library", + home + "/code/app", + home + "/OrbStack", + home + "/OrbStack/docker/containers", + "/Volumes/share", + } { + if before.ShouldSkip(p, home) != after.ShouldSkip(p, home) { + t.Errorf("ShouldSkip(%q) drifted from pre-toggle behavior", p) + } + if before.WithinProtected(p) != after.WithinProtected(p) { + t.Errorf("WithinProtected(%q) drifted from pre-toggle behavior", p) + } + } + + // The other pre-existing shape: --include-tcc-protected produced a nil + // skipper, and still must. + optedIn := true + if s := ForRun(home, &optedIn, nil); s != nil { + t.Error("--include-tcc-protected must still yield a nil Skipper by default") + } +} + func TestForRun_TogglePolarity(t *testing.T) { trueVal := true falseVal := false From 87002571d70559e6f02368c198c84f9ddeb74458 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Fri, 4 Sep 2026 10:58:38 +0530 Subject: [PATCH 3/4] fix(tcc): honor explicit network-volume walk roots for their descendants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShouldSkip only exempted path itself from the walk-root check; a child of an explicitly named mount (--search-dirs ~/OrbStack) still matched withinNetworkVolume and was skipped, so nested npm/Python projects were never scanned despite the documented opt-in. Bypass volume skipping for the whole walk when walkRoot is inside a skipped volume. Also bind the PPPC CodeRequirement to the Dev Machine Guard binary identifier (mobileconfig and the mirrored docs example), not just the signing team — as written, any binary signed by D63S9HLM4L at the configured path could claim the grant. Addresses review comments on #193. Signed-off-by: Swarit Pandey --- docs/macos-tcc-permissions.md | 4 ++-- internal/tcc/tcc.go | 23 +++++++++++++++++-- internal/tcc/volumes_test.go | 1 + ...ecurity-dev-machine-guard-tcc.mobileconfig | 4 ++-- 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/docs/macos-tcc-permissions.md b/docs/macos-tcc-permissions.md index 9a269905..8eef52cf 100644 --- a/docs/macos-tcc-permissions.md +++ b/docs/macos-tcc-permissions.md @@ -280,7 +280,7 @@ reproduced here so the payload shape is visible in context: IdentifierType path CodeRequirement - anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "D63S9HLM4L" + identifier "stepsecurity-dev-machine-guard" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "D63S9HLM4L" Allowed Comment @@ -295,7 +295,7 @@ reproduced here so the payload shape is visible in context: IdentifierType path CodeRequirement - anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "D63S9HLM4L" + identifier "stepsecurity-dev-machine-guard" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "D63S9HLM4L" Allowed Comment diff --git a/internal/tcc/tcc.go b/internal/tcc/tcc.go index 3ce463ed..610030a6 100644 --- a/internal/tcc/tcc.go +++ b/internal/tcc/tcc.go @@ -122,7 +122,9 @@ func build(home string, protected bool, mounts []string) *Skipper { // short-circuited. When path equals walkRoot the result is always false: // passing --search-dirs ~/Documents (or --search-dirs ~/OrbStack) is an // explicit opt-in, and the walk root must be entered for anything to -// happen. +// happen. That opt-in holds for the whole walk, not just the root itself: +// if walkRoot is inside a skipped volume, descendants of it are not +// re-skipped as the walk descends into them. // // Callers must consult this BEFORE reading the directory: it is the // mountpoint's ReadDir, not the parent's listing of it, that fires the @@ -135,7 +137,8 @@ func (s *Skipper) ShouldSkip(path, walkRoot string) bool { return false } cleaned := filepath.Clean(path) - if filepath.Clean(walkRoot) == cleaned { + cleanRoot := filepath.Clean(walkRoot) + if cleanRoot == cleaned { return false } if _, ok := s.paths[cleaned]; ok { @@ -148,6 +151,9 @@ func (s *Skipper) ShouldSkip(path, walkRoot string) bool { return true } } + if s.rootWithinVolume(cleanRoot) { + return false + } return s.withinNetworkVolume(cleaned) } @@ -199,6 +205,19 @@ func (s *Skipper) withinNetworkVolume(cleaned string) bool { return false } +// rootWithinVolume reports whether cleanRoot — the walk root, already +// filepath.Clean'd — is itself a skipped volume or nested inside one. +// Unlike withinNetworkVolume this does not record a hit: naming that root +// via --search-dirs is the opt-in, not a skip. +func (s *Skipper) rootWithinVolume(cleanRoot string) bool { + for _, v := range s.volumes { + if hasDirPrefix(cleanRoot, v) { + return true + } + } + return false +} + // hasPathPrefix returns true when s starts with prefix AND the character // immediately after is a path separator, a dot, or end-of-string. This // keeps a sentinel like "/Volumes/.timemachine" from matching unrelated diff --git a/internal/tcc/volumes_test.go b/internal/tcc/volumes_test.go index 35d61627..f265fbc6 100644 --- a/internal/tcc/volumes_test.go +++ b/internal/tcc/volumes_test.go @@ -57,6 +57,7 @@ func TestSkipper_NetworkVolumeMatching(t *testing.T) { {"dotted sibling of mount not skipped", "/Users/alice/OrbStack.old", "/Users/alice", false}, {"unrelated path not skipped", "/Users/alice/code", "/Users/alice", false}, {"explicit walk root opts in", "/Users/alice/OrbStack", "/Users/alice/OrbStack", false}, + {"child of explicit walk root opts in too", "/Users/alice/OrbStack/docker/containers", "/Users/alice/OrbStack", false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/packaging/macos/stepsecurity-dev-machine-guard-tcc.mobileconfig b/packaging/macos/stepsecurity-dev-machine-guard-tcc.mobileconfig index d929e51c..4fbf33cb 100644 --- a/packaging/macos/stepsecurity-dev-machine-guard-tcc.mobileconfig +++ b/packaging/macos/stepsecurity-dev-machine-guard-tcc.mobileconfig @@ -81,7 +81,7 @@ IdentifierType path CodeRequirement - anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "D63S9HLM4L" + identifier "stepsecurity-dev-machine-guard" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "D63S9HLM4L" Allowed Comment @@ -96,7 +96,7 @@ IdentifierType path CodeRequirement - anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "D63S9HLM4L" + identifier "stepsecurity-dev-machine-guard" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = "D63S9HLM4L" Allowed Comment From 0ef1dc34ee2e039bc57ced08d95da77497dcefb4 Mon Sep 17 00:00:00 2001 From: Swarit Pandey Date: Fri, 4 Sep 2026 11:10:50 +0530 Subject: [PATCH 4/4] chore: drop CHANGELOG.md edit to resolve conflict with main main closed out the [1.15.0]-adjacent slot as [1.16.0] with unrelated entries while this branch was open, so this PR's own [Unreleased] insertion no longer applies cleanly. Dropping it here; the network-volume entry can go in whichever changelog section actually lands next. Signed-off-by: Swarit Pandey --- CHANGELOG.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b263e7d2..db54010c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 See [VERSIONING.md](VERSIONING.md) for why the version starts at 1.8.1. -## [Unreleased] +## [1.16.0] - 2026-08-20 ### Added -- **macOS network-volume scan toggle and PPPC pre-approval path** (#177): container runtimes expose the guest filesystem through mounts macOS classifies as *network volumes* (OrbStack's `~/OrbStack`, Docker Desktop and Colima shares), so the first scan that walks one fires a `SystemPolicyNetworkVolumes` prompt naming a process the developer doesn't recognize. That walk stays **on by default** — it is what inventories npm and Python packages inside dev containers, supply-chain surface nothing else covers — but MDM fleets now have both ways out. `include_network_volumes: false` (config) or `--no-include-network-volumes` (CLI) skips every non-local mount, enumerated from the kernel mount table via `getfsstat` rather than a hard-coded path list, so a newly installed runtime is covered without an agent change; the run then logs exactly which mounts it gave up. Alternatively `packaging/macos/stepsecurity-dev-machine-guard-tcc.mobileconfig` pre-answers the prompt for the whole fleet (allow *or* deny) alongside the existing Full Disk Access grant — that route needs a fixed system-wide install path, since PPPC identifiers can't express `$HOME`, and `docs/macos-tcc-permissions.md` now carries the migration steps for a fleet already deployed per-user. +- **Credential-location inventory**: a new `credentials_scan` phase reports which developer tools on this machine hold credentials, where, and how well guarded each location is — locations and protection only, never the credential itself. Thirteen sources across cloud (AWS, GCP), source control (SSH keys, git credential store, `.netrc`, GitHub CLI hosts), package registries (`.npmrc`, `.pypirc`), containers (Docker, kubeconfig) and infrastructure (Terraform, Vault). Every location is an exact path rather than a root to walk, every read is byte-capped, and a capped or uninterpretable read is recorded as incomplete rather than clean — so a credential sitting past the cap can never render as "read, empty, complete". Paths come from the OS user record rather than the agent's own environment, which belongs to root or SYSTEM, and every read goes through a new resolver that refuses a symlink leaving the user's roots, checks TCC consent before each syscall, and cannot be hung by a FIFO planted in a credential path. A nil section means the phase did not run; zero findings is the positive assertion that no known location holds one. +- **OpenCode MCP server configs**: OpenCode keeps servers under a top-level `mcp` key rather than `mcpServers`, accepts the config as both `opencode.json` and `opencode.jsonc`, and documents examples carrying comments and trailing commas — so its servers were invisible to the MCP inventory. Global configs are now read from `~/.config/opencode/opencode.{json,jsonc}`, resolved against the developer's home rather than the service account's, and project configs are found by the existing bounded walk. The secret allowlist is unchanged and still deny-by-default, so OpenCode's `environment` and `headers` blocks are never collected. +- **Pi, Factory Droid and Amp in the AI agent inventory**: all three already appeared under `agent_skills` but were missing from `ai_agents_and_tools`, so a machine running them looked like a machine that wasn't. They cannot be added by binary name — `pi`, `amp` and `droid` each collide with a popular same-name tool, and because resolution takes the first candidate that exists, a collider winning the `$PATH` race would hide a genuine install elsewhere on the machine. Each agent is now proven from an on-disk artifact instead: an npm or Bun manifest naming the package, the installer's anchor directory, a Homebrew cask root as opposed to the collider's formula root, winget's publisher-qualified package directory, or a pacman file manifest claiming the path — searched across the prefixes a global install actually lands in, including the per-version trees of nvm, fnm, mise, volta and asdf. Amp and Pi skip the `--version` exec, which was measured to make Gatekeeper prompt; their versions come from disk or read `unknown`. +- **Two new agent-skills roots**: `~/.config/amp/skills` (`amp_user`) and `~/.agent/skills` (`factory_agent_user`) — singular `.agent`, distinct from the `~/.agents` convention. +- **Copilot CLI installs that never land on PATH**: `gh copilot` downloads the same `@github/copilot` CLI into gh's own data directory, which never reaches `$PATH`, so users who let gh install Copilot for them read as having no Copilot CLI at all. Seven home-relative anchors are now tried after a `LookPath` miss, covering the `gh copilot` download on both platform spellings, the non-root install-script path, the WinGet and npm global shims, and the `gh-copilot` extension. Bare names stay first, so machines that already resolve `copilot` through `$PATH` are unaffected. Two limits are documented in `SCAN_COVERAGE.md` rather than worked around: a non-default `$XDG_DATA_HOME` is not followed, because the scan runs as a root daemon and does not have the user's value, and WinGet's hashed payload directory needs globbing that binary-name resolution does not do, so only its `Links` shim is covered. + +### Changed + +- **The macOS TCC skipper is wired into AI CLI detection.** The new resolution ladders stat candidates directly instead of descending a walk, so the walk-level skip could not protect them; consent is now checked before every stat and again on every resolved symlink. The pnpm and fnm trees under `~/Library` are exempted, since the coarse `~/Library` skip that is correct for a walk would otherwise drop both macOS channels silently. +- **CI: release publishing is gated on verification.** A new `publish-release.yml` runs the verification suite as a reusable workflow and publishes the draft release, marking it latest, only if every check passes — signed checksums, Windows Authenticode, macOS notarization — replacing the manual `gh release edit --draft=false --latest` step. Verification now also requires a valid out-of-band Ed25519 `.sha256.sig` for the `x64` and `arm64` `.intunewin` packages, so every distributable artifact is covered. Because those checksums are created outside the repository, a compromised repository alone cannot ship a release that customers' loaders will accept. ## [1.15.0] - 2026-08-03 @@ -332,6 +341,7 @@ First open-source release. The scanning engine was previously an internal enterp - Execution log capture and base64 encoding - Instance locking to prevent concurrent runs +[1.16.0]: https://github.com/step-security/dev-machine-guard/compare/v1.15.0...v1.16.0 [1.15.0]: https://github.com/step-security/dev-machine-guard/compare/v1.14.0...v1.15.0 [1.14.0]: https://github.com/step-security/dev-machine-guard/compare/v1.13.0...v1.14.0 [1.13.0]: https://github.com/step-security/dev-machine-guard/compare/v1.12.0...v1.13.0