Skip to content

Scan architecture

Profiles are not interchangeable

Bumblebee ships three profiles. Take the upstream semantics literally rather than treating them as speed settings:

ProfileCoversUse for
baselineStandard per-user package roots, dependency caches, system package managersThe default fleet-wide scan
projectRepository working trees you point it atDeveloper machines with a known repo root
deepEverything reachableInvestigation, on demand

deep is deliberately not scheduled here. It is the right tool when you are chasing something and the wrong tool to run on a laptop four times a day.

--all-users cannot be combined with --root. That single constraint shapes the whole design: the baseline daemons use --all-users and find their own roots, while the project daemons pass explicit roots and therefore cannot use it.

Why one schedule does not work

The obvious deployment is one scan on a timer. It fails in a specific way.

Detection wants to be frequent: a malicious package installed at 09:00 should not wait until tomorrow. Inventory wants to be complete: the point is to know everything that is present, including the large read-only dependency caches.

Those two goals fight over the same time budget, and on real developer machines the caches win.

A single schedule silently under-reports, it does not visibly fail

On a combined schedule, the frequent scan spends ~440s of a 600s budget walking ~/go/pkg/mod and times out roughly 29% of the time. Because a truncated scan still reports status: "complete", that is a quarter of scans quietly emitting fewer findings, with nothing to indicate it.

Raising --max-duration does not fix this. It moves the cliff, and the caches grow.

The split

Four daemons, two shapes:

findings/baseline    every 4h*  --findings-only, RunAtLoad
                                --exclude pkg/mod --exclude .cargo/registry
                                max-duration 10m

findings/project     every 4h*  --findings-only, RunAtLoad
                                explicit repo roots
                                max-duration 15m

inventory/baseline   daily      full package inventory, NO exclusions
                                max-duration 45m

inventory/project    daily      full package inventory, explicit repo roots
                                max-duration 30m

The findings pass is fast and bounded. It excludes the big read-only dependency caches, so it finishes in seconds and detection stays frequent on the roots that carry executable, actually installed code.

The inventory pass covers the same roots with no exclusions, on a generous budget, once a day. So cache exposure is still detected - at a daily cadence rather than four-hourly, without starving the fast pass.

The trade is explicit and worth stating in your own runbook: a malicious package that exists only inside a read-only dependency cache is detected within 24 hours, not within 4. That is the right call, because a package sitting in a Go module cache is not installed, is not executed, and cannot be removed persistently anyway - see the finding tiers.

After the split, the expected steady-state truncation rate is ~0, which turns the timeout alert from noise into a regression detector with real headroom.

* The findings interval is per-device

4h is the default, not a constant. Build machines and CI runners compete with the scanner for CPU and disk, so they can be dropped to daily by shipping a macOSCustomAppConfiguration carrying BumblebeeFindingsIntervalSeconds and scoping it with an assignment filter. The installer reads it from /Library/Managed Preferences/<your-domain> and falls back to 14400 when it is absent or non-numeric.

It has to arrive as a managed preference because you cannot ask a Mac what Intune thinks it is: profiles show -type enrollment returns (null) on an ADE-enrolled device, so the enrolment profile name - the obvious thing to branch on - is not readable from a script at all. Intune evaluates the filter server-side and the device just reads the answer.

LaunchDaemons, not agents

Run as root LaunchDaemons in the system domain:

<key>UserName</key><string>root</string>
<key>StartInterval</key><integer>14400</integer>
<key>RunAtLoad</key><true/>
<key>ProcessType</key><string>Background</string>
<key>LowPriorityIO</key><true/>
<key>Nice</key><integer>10</integer>

RunAtLoad is load-bearing, and more so once you raise the interval. The installer boots out and re-bootstraps all four daemons on every run, and Intune runs it daily - so each reload resets the StartInterval countdown. With RunAtLoad=no, a daemon whose interval is at or above the installer cadence can be starved indefinitely and simply never scan. RunAtLoad is what guarantees roughly one scan per installer cycle whatever the interval is.

  • root + --all-users is what makes this a fleet control rather than a per-user tool. A LaunchAgent only runs when someone is logged in and only sees that user.
  • RunAtLoad on the findings daemons means a laptop that has been shut for a week scans on wake rather than waiting up to four hours.
  • ProcessType Background, LowPriorityIO, Nice 10 matter more than they look. This is somebody's laptop, and a scanner that makes the machine feel slow gets uninstalled by whatever means the user can find.
  • StartInterval, not StartCalendarInterval. Calendar scheduling on laptops fires a thundering herd at 09:00 and misses machines that were asleep. Interval scheduling spreads naturally.

Root is necessary but not sufficient for the TCC-protected paths. See Full Disk Access.

The wrapper

The daemons do not invoke the scanner directly. They call bumblebee-run, which:

  1. Selects a schema-coherent catalog set and proves it loads (catalogs)
  2. Probes Full Disk Access with a real read (FDA)
  3. Runs the scan
  4. Writes a per-(mode, profile) summary for the Intune custom attribute
  5. Emits a catalog_health record carrying everything the scanner cannot report about itself
  6. Ships the NDJSON to Loki and prunes it

Step 5 is where most of the value is. The scanner reports what it found; the wrapper reports whether it was in a position to find anything.

Give every daemon its own summary file, keyed ${MODE}_${PROFILE}.json, and have the Intune attribute read all of them and report the worst case.

One shared summary file reports whichever daemon finished last

A single last_summary.json written by all four daemons makes the device attribute a race. On a host with a live finding in one profile, it reads clean whenever a different profile happens to finish after it - and the attribute is only re-evaluated daily, so the wrong answer sticks.

Source: src/endpoint/bumblebee-run.sh.

Concurrency

No locking between daemons, deliberately. Each stages its catalog set into its own catalog/<mode>_<profile>/ directory via an atomic rename, so concurrent runs cannot interfere. The shared upstream catalog cache is refreshed by atomic rename too, so the worst case is last-writer-wins with a valid file.

The daemons can and do overlap - a daily inventory pass runs long enough that a four-hourly findings scan will start underneath it. That is fine, and the I/O settings above are what keep it from being noticeable.