diff --git a/README.md b/README.md index 4d6a5a08..b2bf53dd 100644 --- a/README.md +++ b/README.md @@ -504,7 +504,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 8ad26bf6..20b8be86 100644 --- a/SCAN_COVERAGE.md +++ b/SCAN_COVERAGE.md @@ -189,6 +189,8 @@ Per extension, the scan records identity (id, name, version, manifest version), 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`. @@ -226,6 +228,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 834fcba2..0e334217 100644 --- a/cmd/stepsecurity-dev-machine-guard/main.go +++ b/cmd/stepsecurity-dev-machine-guard/main.go @@ -39,14 +39,12 @@ import ( "github.com/step-security/dev-machine-guard/internal/wslguest" ) -// 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 @@ -119,6 +117,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..8eef52cf 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 @@ -190,13 +280,28 @@ granting **SystemPolicyAllFiles** (Full Disk Access) to the agent: 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 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 + 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 + 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 9669154d..490412b9 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 @@ -241,6 +249,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": @@ -564,6 +578,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 6a278f92..0be921a1 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. @@ -217,6 +227,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 } @@ -372,6 +385,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 == "" { @@ -563,6 +592,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" { @@ -673,6 +703,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 c47b99e1..e9211e6d 100644 --- a/internal/scan/scanner.go +++ b/internal/scan/scanner.go @@ -38,15 +38,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..610030a6 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,19 +88,57 @@ 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. 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 +// 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 } cleaned := filepath.Clean(path) - if filepath.Clean(walkRoot) == cleaned { + cleanRoot := filepath.Clean(walkRoot) + if cleanRoot == cleaned { return false } if _, ok := s.paths[cleaned]; ok { @@ -80,7 +151,10 @@ func (s *Skipper) ShouldSkip(path, walkRoot string) bool { return true } } - return false + if s.rootWithinVolume(cleanRoot) { + return false + } + return s.withinNetworkVolume(cleaned) } // WithinProtected reports whether path is a TCC-protected directory OR lies @@ -91,7 +165,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 +188,33 @@ 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 +} + +// 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 } @@ -196,9 +298,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..f265fbc6 --- /dev/null +++ b/internal/tcc/volumes_test.go @@ -0,0 +1,252 @@ +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}, + {"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) { + 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 +} + +// 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 + + // 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 713a6b22..50fd71ed 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -578,14 +578,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..4fbf33cb --- /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 + 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 + 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 + 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 + 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. + + + + + + +