Cairn
Every device. One source of truth.
Cairn reconciles device inventory from the MDM and EDR tools that already manage your fleet into your asset system of record (Snipe-IT), automatically. Point it at Jamf, Intune, JumpCloud, CrowdStrike, Sophos, and Microsoft Defender; it pulls each one, merges records that describe the same physical machine by serial number, and keeps your CMDB honest.
Cairn is the evolution of GhostAssetSync. The
ghostsynccommand and the legacysettings.confstill work, so existing deployments keep running.
📚 Full documentation: see
docs/— getting started, a complete CLI reference and config schema, every source connector and sink/CMDB reader, the drift report, scheduling, security, and an exhaustive troubleshooting + FAQ.
Why Cairn
The original tool synced one source (Jamf) to one sink (Snipe-IT) for the machine it ran on. Real fleets are messier: a laptop shows up in Jamf and CrowdStrike, a PC in Intune and Defender, and your asset database drifts out of date the moment someone forgets to update it. Cairn treats every tool as a pluggable source, every asset system as a pluggable sink, and reconciliation as a first-class step.
- Pluggable providers — add an MDM/EDR by dropping in one module. The core never changes.
- Serial-number reconciliation — one device seen by three tools becomes one asset, merged field-by-field in your trust order.
- Two run modes —
agent(run on each endpoint, sync that machine) orfleet(run centrally, pull and reconcile the whole fleet). - Security-first — HTTPS enforced, secrets via env vars, config permission checks, retry/backoff on every API call, serial masking in logs.
- Dry-run — see exactly what would change before anything is written.
- Cross-platform single binary — macOS, Windows, Linux. No Python required on the endpoint.
Supported integrations
| Sources (MDM / EDR) | System of record | Notifications |
|---|---|---|
| Jamf Pro | Snipe-IT | Microsoft Teams |
| Microsoft Intune (Graph) | Slack | |
| Kandji | Generic webhook | |
| JumpCloud | ||
| Google Workspace (ChromeOS) | ||
| CrowdStrike Falcon | ||
| Sophos Central | ||
| Microsoft Defender for Endpoint | ||
| Apple Business Manager | ||
| UniFi (network gear) | ||
| CDW (procurement CSV import) | ||
| Rudder |
Cairn also does writeback — push your Snipe-IT asset tags back into Jamf or Intune so the MDM matches your system of record:
cairn writeback # preview (dry-run): what would change in the MDM
cairn writeback --apply # write the asset tags to Jamf / Intune
Writeback is dry-run by default, honors a per-target conflict policy
(snipe_wins or only_if_empty), and never creates devices. See the
roadmap for what's next.
Install
Download a release binary (recommended) from Releases:
- macOS —
.pkginstaller orcairn-macos.tar.gz - Windows —
.zipor the Inno Setup.exeinstaller - Linux —
.deborcairn-linux.tar.gz
Or install from source:
git clone https://github.com/jsdosanj/cairn.git
cd cairn
python3 -m venv .venv && source .venv/bin/activate
pip install -e .
cairn --help
Easiest start (no YAML, no terminal know-how)
cairn setup # guided wizard: pick your tools, paste credentials, test live
cairn web # opens a local dashboard: test connections, dry-run, schedule
cairn setup walks you through Snipe-IT and each integration, tests every
connection as you go, can store secrets in your OS keychain, and writes the config
for you. cairn web then gives you a clickable dashboard. Prefer the terminal?
The manual path is below.
Quick start
-
Copy the example config and lock it down:
cp config.example.yaml config.yaml chmod 600 config.yaml # Cairn refuses a world-readable config
-
Enable the sources you use and fill in credentials (or supply secrets via env vars — see below). Minimal example:
mode: fleet source_priority: [intune, jamf, crowdstrike] sources: jamf: enabled: true url: https://your.jamf.instance.com client_id: ... client_secret: ... crowdstrike: enabled: true client_id: ... client_secret: ... base_url: https://api.us-2.crowdstrike.com sinks: snipeit: enabled: true url: https://your-snipe-it/api/v1 token: ...
-
Validate, then dry-run, then sync:
cairn validate # check config + credentials wiring cairn sync --dry-run # show what would change, write nothing cairn sync # do it
Commands
cairn setup interactive first-run wizard (recommended)
cairn web launch the local point-and-click dashboard
cairn doctor test every configured connection
cairn drift reconcile sources vs Snipe-IT: missing / stale / duplicate / conflicting
cairn drift --stale-days 60 flag CMDB assets no source has seen in 60 days
cairn drift --json emit the drift report as JSON
cairn validate load config, initialize every provider, report readiness
cairn sync run a sync (agent or fleet per config)
cairn sync --dry-run report changes, write nothing
cairn sync --full re-sync every device (ignore incremental state)
cairn sync --mode agent override the configured mode for one run
cairn writeback preview pushing Snipe-IT asset tags back to your MDM
cairn writeback --apply actually write the asset tags to Jamf/Intune
cairn schedule install install a native scheduled auto-sync (--interval SECONDS)
cairn schedule status show the scheduled job
cairn schedule uninstall remove the scheduled job
cairn list-providers list available sources / sinks / notifiers
cairn -c path/to.yaml ... use a specific config file
cairn -v ... debug logging
Run modes
fleet (run centrally, e.g. on a schedule): each enabled source is fully
enumerated, records sharing a serial are merged using source_priority, and the
reconciled devices are written to every enabled sink. This is how you keep an
entire estate current.
agent (run on the endpoint, e.g. via Jamf/Intune/GPO): Cairn collects the
local machine's facts, asks each source for that one serial, merges, and writes a
single asset. This mirrors the original GhostAssetSync behavior.
Reconciliation
When two tools report the same serial, fields are filled in source_priority
order: the highest-priority source wins each field it has a value for, and empty
fields are backfilled from lower-priority sources. MAC addresses are unioned and
normalized; every source's raw payload is preserved under raw[<source>]. EDR-only
records that lack a serial (some Defender/Sophos endpoints) are still synced, just
not merged.
Drift report (is your CMDB lying?)
cairn drift is read-only and writes nothing. It pulls every enabled source,
reconciles by serial, pulls your whole Snipe-IT inventory, and diffs them — so
in one command you see exactly where the system of record disagrees with the
tools that actually manage your fleet:
- missing — a device your MDM/EDR sees that isn't in Snipe-IT at all.
- stale — a Snipe-IT asset no source has seen in
--stale-days(default 30); a retirement / lost-device candidate. - duplicate — more than one Snipe-IT row sharing a serial.
- conflicting — present in both, but a field (hostname, model, …) disagrees.
Every finding carries a confidence score — how sure Cairn is the finding is
real, weighted by how many independent sources corroborate it. Serials are masked
to the last four characters by default (--show-serials to override).
cairn drift # grouped, color report, worst first
cairn drift --stale-days 60 # only flag assets unseen for 60+ days
cairn drift --json -o drift.json # machine-readable for BI / alerting
Exit code is non-zero when drift is found, so a scheduled cairn drift cleanly
gates CI or fires an alert. Configured notifiers (Teams/Slack) get a digest.
Scheduling (auto-sync)
Install Cairn as a native scheduled job so it keeps Snipe-IT current on its own:
cairn schedule install --interval 3600 # sync every hour
cairn schedule status
cairn schedule uninstall
Per platform, this uses the OS-native scheduler (no daemon to babysit):
- macOS — a launchd LaunchAgent (
~/Library/LaunchAgents/com.cairn.sync.plist), run at low I/O priority, logging to~/Library/Logs/cairn.log. - Linux — a
systemd --userservice + timer, falling back to cron if systemd isn't available. (Runloginctl enable-linger $USERto keep it running while logged out.) - Windows — a Task Scheduler task (
schtasks).
The interval defaults to schedule.interval in your config (or 3600s).
Efficiency
Cairn is built to run cheaply on a tight schedule:
- Incremental sync — a per-device content hash means a scheduled run only
writes the devices that actually changed. Unchanged devices are skipped before
any write.
last_seenis excluded from the hash by default so a routine check-in doesn't cause churn. Force a full re-sync withcairn sync --full. - Streaming — sources yield devices as they page, so memory stays flat regardless of fleet size.
- Connection reuse — pooled keep-alive sessions with retry/backoff per source.
- Lazy loading — only the providers you enable are imported.
- Low priority — scheduled jobs run niced / idle-I/O so they stay out of the way of interactive work.
State lives in ~/.cairn/state.json (override with state_path or CAIRN_STATE).
Configuration & secrets
Configuration is YAML (config.yaml / config.yml, auto-discovered in the working
directory) or the legacy settings.conf. Any value can be overridden by an
environment variable — keep secrets out of files in production:
# Legacy vars (still honored):
export GHOST_JAMF_URL=... GHOST_SNIPE_TOKEN=... TEAMS_WEBHOOK_URL=...
# Generic nested form — CAIRN_<section>__<provider>__<key>:
export CAIRN_sources__crowdstrike__client_secret=...
export CAIRN_sinks__snipeit__token=...
Precedence: environment variables > config.yaml > legacy settings.conf.
Snipe-IT field mapping
Snipe-IT custom fields are mapped by label to any device attribute (or a nested
extra.<source>.<key> path) in config, so you decide what lands where without
touching code:
sinks:
snipeit:
field_map:
"Operating System": os_name
"OS Version": os_version
"MAC Address": mac_addresses
"Falcon Risk": extra.crowdstrike.reduced_functionality_mode
See config.example.yaml for every option.
Security
- HTTPS enforced on every API and webhook URL (localhost exempt for dev).
- Config permissions — Cairn refuses to start on a world-readable config and
warns on group-readable;
chmod 600it. - Secrets via env — every credential can be supplied by environment variable.
- TLS verification on, retry/backoff with
Retry-Afterrespected. - Serial masking — only the last 4 chars of a serial appear in logs and notifications.
- Report vulnerabilities via a GitHub issue tagged
[SECURITY].
Architecture
src/cairn/
├── cli.py argparse entrypoint (sync / validate / list-providers)
├── orchestrator.py pull -> reconcile-by-serial -> upsert -> notify
├── config.py YAML + env + legacy .ini loader
├── models.py NormalizedDevice + merge_devices (the lingua franca)
├── http.py retrying session, HTTPS enforcement, OAuth2 client creds
├── registry.py maps config keys -> provider classes (lazy import)
├── state.py incremental-sync hash store (skip unchanged devices)
├── scheduler.py install native scheduled jobs (launchd / systemd / schtasks)
├── system_info.py local machine facts (macOS / Windows / Linux) for agent mode
├── sources/ DeviceSource plugins: jamf, intune, jumpcloud,
│ crowdstrike, sophos, defender
├── sinks/ AssetSink plugins: snipeit
└── notifiers/ teams, slack, webhook
Adding a source is one file implementing DeviceSource.fetch_all() (plus an
optional server-side find_by_serial) and one line in registry.py.
Development
pip install -e ".[dev]"
pytest # 26 tests: models, config, sink, orchestrator, providers
Provider tests mock the upstream APIs with responses, so the suite is fast and
offline.
Packaging & releases
Tag a release and CI builds all three platforms and attaches them to a GitHub Release:
git tag v1.0.0 && git push origin v1.0.0
Build locally with the scripts in installers/ (PyInstaller
one-file binary, then .pkg / .deb / .zip). See
installers/README.md.
License
GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later) — the same license as Snipe-IT, which Cairn integrates with. See LICENSE.
AGPL is a strong copyleft: if you modify Cairn and run it as a network service, you must make your modified source available to its users. For most teams running it internally to sync their own fleet, that changes nothing.
Formerly GhostAssetSync.
Release files for cairn-sync 1.3.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| cairn_sync-1.3.1.tar.gz | 114.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| cairn_sync-1.3.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 233.7 kB
Release files / cairn_sync-1.3.1.tar.gz
| Download URL | cairn_sync-1.3.1.tar.gz |
|---|---|
| Size | 114.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
0fa526d5f31500ff998cf2558c0f89f5ef2dc2c5c148e5295b27028c00875178
|
|
BLAKE2b-256 checksum How to use checksums |
0e974a40ccd23ecdbffbfb1bddaedea4f3c07fd2624d0532043fb0435660cbb7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 6, 2026.
Transparency logRelease files / cairn_sync-1.3.1-py3-none-any.whl
| Download URL | cairn_sync-1.3.1-py3-none-any.whl |
|---|---|
| Size | 118.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
884afb6850e41583ef9f30b63a1d09fe8d2b9285fcf96d35834caf852fa5a0e3
|
|
BLAKE2b-256 checksum How to use checksums |
c94d64196fe0f946ba282425efbe1ba75fdef0af770cda730b317b3ee07d7ff3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 6, 2026.
Transparency log