Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Please provide a brief description of your changes and the context for this inte

- [ ] **New Integration Folder:** A new folder has been created for the integration.
- [ ] **Updated README:** The README has been updated based on the boilerplate to reflect the new integration details.
- [ ] **custom-integration.star File:** The `custom-integration-<name>.star` file has been created/updated as required.
- [ ] **Integration script:** The `<name>.star` file has been created/updated as required.
- [ ] **config.json File:** The `config.json` is updated with the `name` (product name) and `type` (inbound or outbound) of integration.

---
Expand Down
11 changes: 6 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ Each integration must be placed in its own directory at the root of the reposito
```
repo-root/
├── <integration-name>/
│ ├── custom-integration-<integration-name>.star # The main script
│ ├── config.json # Metadata
│ └── README.md # Documentation
│ ├── <integration-name>.star # The main script
│ ├── config.json # Metadata
│ └── README.md # Documentation
```

### 1. `config.json`
Expand All @@ -28,8 +28,9 @@ This file contains metadata about the integration.
```
* `type`: Use `"inbound"` for importing assets into runZero, `"outbound"` for exporting assets from runZero.

### 2. `custom-integration-<name>.star`
This is the main script written in Starlark.
### 2. `<integration-name>.star`
This is the main script written in Starlark. Name it after the
integration directory (e.g. `tailscale/tailscale.star`).

## Script Development

Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) [Year] [Your Name or Organization]
Copyright (c) 2025-2026 runZero, Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
88 changes: 87 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ If you need help setting up a custom integration, you can create an [issue](http
- [Ivanti Neurons](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ivanti_neurons/)
- [JAMF](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/jamf/)
- [Kandji](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/kandji/)
- [Kubernetes](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/kubernetes/)
- [Lima Charlie](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/lima-charlie/)
- [Manage Engine Endpoint Central](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/manage-engine-endpoint-central/)
- [Moysle](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/moysle/)
Expand All @@ -60,9 +61,94 @@ If you need help setting up a custom integration, you can create an [issue](http
## The boilerplate folder has examples to follow

1. Sample [README.md](./boilerplate/README.md) for contributing
2. Sample [script](./boilerplate/custom-integration-boilerplate.star) that shows how to use all of the supported libraries
2. Sample [script](./boilerplate/boilerplate.star) that shows how to use all of the supported libraries
3. Sample [config.json](./boilerplate/config.json) that gives context on the integration for automations to reference

## Asset IDs and match behavior

Every `ImportAsset` you return needs an `id` value. runZero uses that
foreign id as the primary key when correlating subsequent runs of the
same integration with the asset graph, so the value you choose has a
direct impact on whether records merge cleanly, fork into duplicates,
or collapse two unrelated devices into one.

### Pick an id that is stable AND unique

A good foreign id is:

- **Stable across runs.** The same physical asset should produce the
same id every time the script runs. Tomorrow's poll must agree with
today's poll, even across reboots, IP changes, agent reinstalls,
hostname renames, etc.
- **Unique across the dataset.** Two distinct assets must never share
the same id. If the upstream API recycles ids when devices are
decommissioned, namespace them (e.g. `vendor-{tenant}-{id}`).
- **Opaque.** Prefer vendor-issued UUIDs, serial numbers, or hardware
ids over derived values like hostnames or IPs (those drift).

If the upstream source does not expose anything that meets both
criteria, that is the signal to relax matching (see below) — do
**not** invent a random id with `new_uuid()` per run, because the
same device will appear as a new asset on every poll.

### Tuning matching with `matchBehavior`

`ImportAsset` accepts an optional `matchBehavior` string. The default
behavior matches and breaks on all four dimensions (id, MAC, IP, name)
which is correct when the integration owns a strong id. When the id
is weak or absent, use one of the knobs below to tell the cruncher
which dimensions are unreliable for **matching** (finding the right
existing asset to merge into) and which are unreliable for
**breaking** (refusing a merge that would otherwise happen because
one dimension conflicts).

Flags:

| Flag | Effect |
|------------------|------------------------------------------------------------------------|
| `no-id-match` | Do not use the foreign id to find candidate assets to merge with. |
| `no-id-break` | Allow a merge even when the foreign id differs from the existing asset.|
| `no-mac-match` | Do not use MAC addresses to find merge candidates. |
| `no-mac-break` | Allow a merge even when MAC addresses conflict. |
| `no-ip-match` | Do not use IP addresses to find merge candidates. |
| `no-ip-break` | Allow a merge even when IP addresses conflict. |
| `no-name-match` | Do not use hostnames to find merge candidates. |
| `no-name-break` | Allow a merge even when hostnames conflict. |

Combine flags with spaces. Recommended presets:

- **Strong, stable foreign id (most cloud / EDR / MDM APIs):** leave
`matchBehavior` unset. The default uses every signal.

- **Strong id, but the source also reports churny MAC/IP/hostnames
(e.g. ephemeral cloud workloads, VPN clients):**
```python
matchBehavior="no-mac-break no-ip-break no-name-break"
```
Keeps id-based merging authoritative, but stops drift in the other
dimensions from blocking a legitimate merge.

- **No stable id at all (the source only emits per-run / ephemeral
ids):**
```python
matchBehavior="no-id-match no-id-break"
```
Falls back to MAC / IP / name matching. Pair this with
`id=new_uuid()` or `id="vendor-" + hashlib.sha256(stable_attrs)` so
the row still has a unique key but the cruncher ignores it for
correlation.

- **Two-stage enrichment where one integration owns "identity" and
another only contributes attributes:** use `no-id-match no-id-break`
on the enrichment-only integration so it always merges into the
primary asset by MAC/IP/name rather than creating a parallel record.

A short rule of thumb: if the upstream id is **not both stable and
unique**, you must relax id matching. If MAC / IP / hostname are
known to be unreliable for this data source, relax the corresponding
`-break` flags so a conflict on those fields doesn't fragment one
real asset into many.

## Contributing

We welcome contributions to this repository! Whether you're fixing a bug, adding a new feature, or improving documentation, your efforts make a difference. To ensure a smooth process, please follow these guidelines:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
load('runzero.types', 'ImportAsset', 'NetworkInterface')
load('http', http_get='get', http_post='post', 'url_encode')
load('json', json_encode='encode', json_decode='decode')
load('net', 'ip_address')
load('runzero.types', 'ImportAsset', 'to_custom_attributes')
load('http', 'get_json', 'post_json', 'bearer')
load('net', 'network_interface')
load('time', 'parse_time')
load('uuid', 'new_uuid')

Expand All @@ -26,7 +25,7 @@ def build_assets(assets):
networks = agent_info.get('network', [])
for network in networks:
ips = [address.get('address', '') for address in network.get('ip_addresses', [])]
interface = build_network_interface(ips=ips, mac=network.get('hardware_address', None))
interface = network_interface(ips=ips, mac=network.get('hardware_address', None))
interfaces.append(interface)

# Retrieve and map custom attributes
Expand Down Expand Up @@ -68,7 +67,7 @@ def build_assets(assets):
'hardware.Uuid': hw_uuid,
'isOn': is_on,
'labels': labels,
'firstSeenTS': first_seen
'firstSeenTS': first_seen,
'lastSeenTS': last_seen,
'recentDomains': recent_domains,
'status': status,
Expand Down Expand Up @@ -105,27 +104,11 @@ def build_assets(assets):
hostnames=[hostname],
os=os,
networkInterfaces=interfaces,
customAttributes=custom_attributes
customAttributes=to_custom_attributes(custom_attributes),
)
)
return assets_import

def build_network_interface(ips, mac):
ip4s = []
ip6s = []
for ip in ips[:99]:
ip_addr = ip_address(ip)
if ip_addr.version == 4:
ip4s.append(ip_addr)
elif ip_addr.version == 6:
ip6s.append(ip_addr)
else:
continue
if not mac:
return NetworkInterface(ipv4Addresses=ip4s, ipv6Addresses=ip6s)
else:
return NetworkInterface(macAddress=mac, ipv4Addresses=ip4s, ipv6Addresses=ip6s)

def get_assets(token):
assets_all = []
results_per_page = 1000
Expand All @@ -136,64 +119,53 @@ def get_assets(token):
while True:
url = CENTRA_BASE_URL + '/api/v4.0/assets?'
headers = {'Accept': 'application/json',
'Authorization': 'Bearer ' + token}
'Authorization': bearer(token)}
params = {'max_results': results_per_page,
'start_at': start,
'status': 'on'}
response = http_get(url, headers=headers, params=params)
if response.status_code != 200:
print('failed to retrieve "on" assets ' + str(start) + ' to ' + str(start + results_per_page), 'status code: ' + str(response.status_code))
data, err = get_json(url, headers=headers, params=params)
if err:
print('failed to retrieve "on" assets ' + str(start) + ' to ' + str(start + results_per_page) + ': ' + err)
break
assets = (data or {}).get('objects', [])
assets_all.extend(assets)
last_return = len(assets)
start += last_return
if last_return < results_per_page:
start = 0
last_return = 1000
break
else:
data = json_decode(response.body)
assets = data['objects']
assets_all.extend(assets)
last_return = len(assets)
start += last_return
if last_return < results_per_page:
start = 0
last_return = 1000
break
# Return all 'status:off' assets. Remove this while loop to restrict import to only status 'on' assets.
while True:
url = CENTRA_BASE_URL + '/api/v3.0/assets?'
headers = {'Accept': 'application/json',
'Authorization': 'Bearer ' + token}
'Authorization': bearer(token)}
params = {'max_results': results_per_page,
'start_at': start,
'status': 'off'}
response = http_get(url, headers=headers, params=params)
if response.status_code != 200:
print('failed to retrieve "off" assets ' + str(start) + ' to ' + str(start + results_per_page), 'status code: ' + str(response.status_code))
data, err = get_json(url, headers=headers, params=params)
if err:
print('failed to retrieve "off" assets ' + str(start) + ' to ' + str(start + results_per_page) + ': ' + err)
break
assets = (data or {}).get('objects', [])
assets_all.extend(assets)
last_return = len(assets)
start += last_return
if last_return < results_per_page:
break
else:
data = json_decode(response.body)
assets = data['objects']
assets_all.extend(assets)
last_return = len(assets)
start += last_return
if last_return < results_per_page:
break

return assets_all

def get_token(username, password):
url = CENTRA_BASE_URL + '/api/v3.0/authenticate'
headers = {'Content-Type': 'application/json'}
payload = {'username': username,
'password': password}

response = http_post(url, headers=headers, body=bytes(json_encode(payload)))
if response.status_code != 200:
print('authentication failed: ' + str(response.status_code))
data, err = post_json(url, json={'username': username, 'password': password})
if err:
print('authentication failed:', err)
return None

auth_data = json_decode(response.body)
if not auth_data:
if not data:
print('invalid authentication data')
return None

return auth_data['access_token']
return data.get('access_token')

def main(*args, **kwargs):
username = kwargs['access_key']
Expand Down
Loading