Reconcile device inventory from MDM/EDR tools into Snipe-IT. Formerly GhostAssetSync.
Project description
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.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file cairn_sync-1.3.1.tar.gz.
File metadata
- Download URL: cairn_sync-1.3.1.tar.gz
- Upload date:
- Size: 114.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0fa526d5f31500ff998cf2558c0f89f5ef2dc2c5c148e5295b27028c00875178
|
|
| MD5 |
a34aecb96e6c762a3da1e605733f7147
|
|
| BLAKE2b-256 |
0e974a40ccd23ecdbffbfb1bddaedea4f3c07fd2624d0532043fb0435660cbb7
|
Provenance
The following attestation bundles were made for cairn_sync-1.3.1.tar.gz:
Publisher:
publish.yml on jsdosanj/cairn
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
cairn_sync-1.3.1.tar.gz -
Subject digest:
0fa526d5f31500ff998cf2558c0f89f5ef2dc2c5c148e5295b27028c00875178 - Sigstore transparency entry: 2088285534
- Sigstore integration time:
-
Permalink:
jsdosanj/cairn@a908700549b81c4853ae1806691744eb274f24b0 -
Branch / Tag:
refs/tags/v1.3.1 - Owner: https://github.com/jsdosanj
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@a908700549b81c4853ae1806691744eb274f24b0 -
Trigger Event:
release
-
Statement type:
File details
Details for the file cairn_sync-1.3.1-py3-none-any.whl.
File metadata
- Download URL: cairn_sync-1.3.1-py3-none-any.whl
- Upload date:
- Size: 118.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
884afb6850e41583ef9f30b63a1d09fe8d2b9285fcf96d35834caf852fa5a0e3
|
|
| MD5 |
c94bcfd617538e11a9bcdc59823d697f
|
|
| BLAKE2b-256 |
c94d64196fe0f946ba282425efbe1ba75fdef0af770cda730b317b3ee07d7ff3
|
Provenance
The following attestation bundles were made for cairn_sync-1.3.1-py3-none-any.whl:
Publisher:
publish.yml on jsdosanj/cairn
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
cairn_sync-1.3.1-py3-none-any.whl -
Subject digest:
884afb6850e41583ef9f30b63a1d09fe8d2b9285fcf96d35834caf852fa5a0e3 - Sigstore transparency entry: 2088285709
- Sigstore integration time:
-
Permalink:
jsdosanj/cairn@a908700549b81c4853ae1806691744eb274f24b0 -
Branch / Tag:
refs/tags/v1.3.1 - Owner: https://github.com/jsdosanj
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@a908700549b81c4853ae1806691744eb274f24b0 -
Trigger Event:
release
-
Statement type: