diff --git a/README.md b/README.md index 77131cf..3f1f62e 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ If you need help setting up a custom integration, you can create an [issue](http - [Snipe-IT](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/snipe-it/) - [Snow License Manager](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/snow-license-manager/) - [SolarWinds Information Service](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/solarwinds-information-service/) +- [Sophos EDR](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/sophos-edr/) - [Stairwell](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/stairwell/) - [Tailscale](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/tailscale/) - [Tanium](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/tanium/) diff --git a/docs/integrations.json b/docs/integrations.json index a5ad1eb..c150dfb 100644 --- a/docs/integrations.json +++ b/docs/integrations.json @@ -1,6 +1,6 @@ { - "lastUpdated": "2026-07-25T05:46:25.476142Z", - "totalIntegrations": 45, + "lastUpdated": "2026-08-14T12:06:30.063985Z", + "totalIntegrations": 46, "integrationDetails": [ { "name": "Akamai Guardicore Centra", @@ -218,6 +218,12 @@ "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/solarwinds-information-service/README.md", "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/solarwinds-information-service/swis.star" }, + { + "name": "Sophos EDR", + "type": "inbound", + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/sophos-edr/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/sophos-edr/sophos-edr.star" + }, { "name": "Stairwell", "type": "inbound", diff --git a/sophos-edr/README.md b/sophos-edr/README.md new file mode 100644 index 0000000..f5b8964 --- /dev/null +++ b/sophos-edr/README.md @@ -0,0 +1,136 @@ +# Custom Integration: Sophos EDR + +Imports computer and server endpoints from Sophos Central (Intercept X / EDR) +using the [Endpoint API](https://developer.sophos.com/docs/endpoint-v1/1/overview), +including health, protection, isolation, encryption, cloud, and ownership +metadata. + +## runZero requirements + +- Superuser access to the [Custom Integrations configuration](https://console.runzero.com/custom-integrations) in runZero. +- A [Custom Integration Script Secret](https://console.runzero.com/credentials) credential configured with your Sophos API credential client ID and client secret. + +## Sophos requirements + +- A Sophos Central **API credential** (service principal). A Super Admin can + create one under **Global Settings → API Credentials** in + [Sophos Central Admin](https://central.sophos.com/manage). See + [Getting Started as a Tenant](https://developer.sophos.com/getting-started-tenant). +- The read-only **Service Principal ReadOnly** role is sufficient; this + integration only calls `GET` endpoints. +- Tenant credentials work with no extra configuration. For **partner** or + **organization** service principals, also set the `tenant_id` and + `data_region_url` parameters for the tenant you want to import. + +## Parameters + +| Key | Required | Description | +| --- | --- | --- | +| `client_id` | yes | Sophos Central API credential client ID | +| `client_secret` | yes | Sophos Central API credential client secret | +| `tenant_id` | no | Target tenant ID. Only needed for partner/organization credentials; discovered via `/whoami/v1` for tenant credentials | +| `data_region_url` | no | Regional API host, e.g. `https://api-us03.central.sophos.com`. Only needed with an explicit `tenant_id` | +| `endpoint_types` | no | Filter to `computer` and/or `server`; blank imports all | +| `last_seen_days` | no | Only import endpoints seen within the last N days (`lastSeenAfter=-PD`); 0 imports all | + +## How it works + +1. Exchanges the client ID/secret for a JWT at + `https://id.sophos.com/api/v2/oauth2/token` (client credentials, + `scope=token`, 1 hour lifetime). The token is refreshed automatically if a + page request returns 401 mid-run. +2. Calls `https://api.central.sophos.com/whoami/v1` to discover the tenant ID + and regional data host, unless both are provided as parameters. +3. Pages through `GET {dataRegion}/endpoint/v1/endpoints?view=full` with + `pageSize=100` (documented maximum) and the `X-Tenant-ID` header, following + key-based pagination: fetch continues with `pageFromKey=` + until `nextKey` is absent. +4. Streams each page to runZero with `report_assets`, so large inventories are + never held in memory. + +Rate limiting (HTTP 429) and transient server errors are retried automatically +with exponential backoff, honoring `Retry-After`. + +## Asset identity + +- Target entity: Sophos Central managed endpoints (computers and servers + running the Sophos agent). +- Source ID field: `id` +- Documentation evidence: + [GET /endpoints/{endpointId}](https://developer.sophos.com/docs/endpoint-v1/1/routes/endpoints/%7BendpointId%7D/get) + — `id (required) string (uuid) "Unique ID for the endpoint."` +- Uniqueness scope: Sophos Central tenant (UUIDs are globally unique in + practice; the tenant ID is included in the namespace regardless). +- Cardinality: one `items[]` row per endpoint in `GET /endpoints`; child data + (products, volumes, agent services) is nested in the same row and imported + as software and custom attributes, never as separate assets. +- Stability: the ID persists across polls, renames, IP/MAC changes, OS and + agent updates. A full agent reinstall registers a new endpoint ID; Sophos + flags suspected duplicates via the `cloned` field, which is imported. +- Reuse behavior: UUIDs; not documented as recycled. +- Presence: required field in the documented response schema. +- Final runZero ID: `sophos::` +- Missing-ID behavior: skip the record with a log line (no fallback ID). +- Match behavior: `no-mac-break no-ip-break no-name-break` — the vendor ID is + authoritative, so normal network churn must not fragment the asset. +- Verdict: scoped authoritative foreign ID. + +## Imported data + +| Sophos field | runZero field | +| --- | --- | +| `id` (+ tenant ID) | `ImportAsset.id` (`sophos::`) | +| `hostname` | `hostnames` | +| `os.name` | `os` | +| `os.majorVersion`/`minorVersion`/`build` | `osVersion` (dotted) | +| `type` / `os.isServer` | `deviceType` (`Server` when applicable) | +| `ipv4Addresses`, `ipv6Addresses`, `macAddresses` | `networkInterfaces` (all IPs on the first MAC; additional MACs as extra interfaces — the API does not correlate IPs to MACs) | +| `assignedProducts[]` (code, version, status) | `software` (vendor `Sophos`) | +| `health.*`, `tamperProtection*`, `lockdown.*`, `isolation.*`, `encryption.volumes`, `cloud.*`, `group`/`groupHierarchy`, `associatedPerson.*`, `serialNumber`, `online`, `cloned`, `lastSeenAt`, `lastOsUpdateAt`, `lastAgentUpdateAt`, `tags` | `customAttributes` | + +Not imported: `packages` / `deviceSoftware` (available agent package choices, +not asset state). + +Note: per the Sophos docs, only Mac endpoints currently report `serialNumber`. + +## Steps + +### Sophos configuration + +1. Sign in to [Sophos Central Admin](https://central.sophos.com/manage) as a Super Admin. +2. Go to **Global Settings → API Credentials** and add a new credential + (ReadOnly role recommended). +3. Record the client ID and client secret. + +### runZero configuration + +1. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials). + - Select **Custom Integration Script Secrets**. + - Enter the Sophos client ID and client secret. +2. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new). + - Add a descriptive name (e.g. `sophos-edr`), paste the script, click + **Validate**, then **Save**. +3. [Create the Custom Integration task](https://console.runzero.com/ingest/custom/). + - Select the Credential and Custom Integration created above, pick an + Explorer, set the schedule, and **Save**. + +## Validation + +Local validation with the `runzero` CLI: + +```bash +runzero script --filename sophos-edr/sophos-edr.star --validate +runzero script --filename sophos-edr/sophos-edr.star \ + --kwargs client_id= --kwargs client_secret= +``` + +Verified with a local fixture: stable IDs across repeated runs, distinct IDs +for distinct endpoints, records without `id` skipped, pagination followed to +the final page, and 401 mid-run token refresh. + +## Documentation references + +- Authentication and whoami: +- Endpoint API overview: +- List endpoints (pagination, filters, views): +- Endpoint object schema: diff --git a/sophos-edr/sophos-edr.star b/sophos-edr/sophos-edr.star new file mode 100644 index 0000000..ee5a418 --- /dev/null +++ b/sophos-edr/sophos-edr.star @@ -0,0 +1,319 @@ +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-sophos-edr", + "name": "Sophos EDR", + "type": "inbound", + "description": "Imports computer and server endpoints from Sophos Central with health, protection, isolation, encryption, and cloud metadata.", + "version": "26081400", + "minVersion": "5.0.260723.0", + "params": [ + { + "key": "client_id", + "label": "Client ID", + "type": "string", + "required": True, + "description": "Sophos Central API credential client ID", + }, + { + "key": "client_secret", + "label": "Client secret", + "type": "secret", + "required": True, + "description": "Sophos Central API credential client secret", + }, + { + "key": "tenant_id", + "label": "Tenant ID", + "type": "string", + "required": False, + "description": "Only needed for partner or organization service principals; discovered automatically for tenant credentials", + }, + { + "key": "data_region_url", + "label": "Data region URL", + "type": "url", + "required": False, + "placeholder": "https://api-us03.central.sophos.com", + "description": "Only needed with an explicit tenant ID; discovered automatically for tenant credentials", + }, + { + "key": "endpoint_types", + "label": "Endpoint types", + "type": "enum", + "required": False, + "multi": True, + "options": ["computer", "server"], + "description": "Endpoint types to import; leave blank to import all", + }, + { + "key": "last_seen_days", + "label": "Last seen within days", + "type": "int", + "required": False, + "default": 0, + "min": 0, + "description": "Only import endpoints seen within the last N days; 0 imports all", + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +# Sophos Central (EDR) -> runZero ImportAsset integration +# +# Flow: +# 1. OAuth2 client-credentials exchange at id.sophos.com for a JWT (1h TTL). +# 2. GET /whoami/v1 to discover the tenant ID and regional API host, unless +# both are supplied explicitly (partner / organization credentials). +# 3. Page through GET {dataRegion}/endpoint/v1/endpoints?view=full using +# key-based pagination (pages.nextKey), streaming each page to runZero. + +load("runzero.types", "ImportAsset", "Software", "to_custom_attributes") +load("net", "network_interface") +load("http", "get_json", "bearer", "oauth2_token") +load("kwargs", "require", "get_string", "get_int", "get_list", "get_http_options") + +TOKEN_URL = "https://id.sophos.com/api/v2/oauth2/token" +WHOAMI_URL = "https://api.central.sophos.com/whoami/v1" +PAGE_SIZE = 100 # documented maximum for GET /endpoint/v1/endpoints +MAX_PAGES = 10000 + + +def _log(msg): + print("[SOPHOS-EDR] " + msg) + + +def get_token(config_kwargs, client_id, client_secret): + return oauth2_token( + token_url=TOKEN_URL, + client_id=client_id, + client_secret=client_secret, + scope="token", + **get_http_options(config_kwargs) + ) + + +def fetch_page(config_kwargs, region_url, token, tenant_id, params): + headers = { + "Authorization": bearer(token), + "X-Tenant-ID": tenant_id, + "Accept": "application/json", + } + return get_json(region_url + "/endpoint/v1/endpoints", params=params, **get_http_options(config_kwargs, headers=headers)) + + +def join_pairs(items, key_field, value_field): + """Render a list of objects as 'key=value; ...' for custom attributes.""" + pairs = [] + for item in items or []: + key = item.get(key_field) + if not key: + continue + pairs.append("{}={}".format(key, item.get(value_field, ""))) + return "; ".join(pairs) + + +def join_group_hierarchy(groups): + names = [] + for group in groups or []: + name = group.get("name") + if name: + names.append(name) + return " > ".join(names) + + +def format_tags(tags): + out = [] + for tag in tags or []: + if type(tag) == "string": + out.append(tag) + elif type(tag) == "dict": + parts = [] + for key in sorted(tag.keys()): + val = tag[key] + if type(val) in ("string", "int", "bool"): + parts.append("{}={}".format(key, val)) + if parts: + out.append(" ".join(parts)) + return "; ".join(out) + + +def build_os_version(os_info): + parts = [] + for key in ("majorVersion", "minorVersion", "build"): + val = os_info.get(key) + if val == None: + break + parts.append(str(val)) + return ".".join(parts) + + +def build_nics(endpoint): + # The API returns uncorrelated IP and MAC lists: attach all IPs to the + # first MAC and emit the remaining MACs as their own interfaces. + macs = endpoint.get("macAddresses") or [] + ips = (endpoint.get("ipv4Addresses") or []) + (endpoint.get("ipv6Addresses") or []) + nics = [] + primary = network_interface(mac=macs[0] if macs else None, ips=ips) + if primary: + nics.append(primary) + for mac in macs[1:]: + nic = network_interface(mac=mac) + if nic: + nics.append(nic) + return nics + + +def build_software(endpoint): + software = [] + for product in endpoint.get("assignedProducts") or []: + code = product.get("code") + if not code: + continue + software.append(Software( + id=code, + vendor="Sophos", + product=code, + version=product.get("version") or "", + customAttributes=to_custom_attributes({"status": product.get("status")}), + )) + return software + + +def build_asset(endpoint, tenant_id): + endpoint_id = endpoint.get("id") + if not endpoint_id: + _log("WARN: skipping endpoint record with no id") + return None + + os_info = endpoint.get("os") or {} + health = endpoint.get("health") or {} + threats = health.get("threats") or {} + services = health.get("services") or {} + group = endpoint.get("group") or {} + person = endpoint.get("associatedPerson") or {} + lockdown = endpoint.get("lockdown") or {} + isolation = endpoint.get("isolation") or {} + cloud = endpoint.get("cloud") or {} + encryption = endpoint.get("encryption") or {} + + attrs = { + "type": endpoint.get("type"), + "tenant.id": (endpoint.get("tenant") or {}).get("id") or tenant_id, + "online": endpoint.get("online"), + "cloned": endpoint.get("cloned"), + "serialNumber": endpoint.get("serialNumber"), + "lastSeenAt": endpoint.get("lastSeenAt"), + "lastOsUpdateAt": endpoint.get("lastOsUpdateAt"), + "lastAgentUpdateAt": endpoint.get("lastAgentUpdateAt"), + "health.overall": health.get("overall"), + "health.threats.status": threats.get("status"), + "health.services.status": services.get("status"), + "health.services.details": join_pairs(services.get("serviceDetails"), "name", "status"), + "os.platform": os_info.get("platform"), + "os.isServer": os_info.get("isServer"), + "group.id": group.get("id"), + "group.name": group.get("name"), + "groupHierarchy": join_group_hierarchy(endpoint.get("groupHierarchy")), + "associatedPerson.id": person.get("id"), + "associatedPerson.name": person.get("name"), + "associatedPerson.viaLogin": person.get("viaLogin"), + "tamperProtectionEnabled": endpoint.get("tamperProtectionEnabled"), + "tamperProtectionSupported": endpoint.get("tamperProtectionSupported"), + "lockdown.status": lockdown.get("status"), + "lockdown.updateStatus": lockdown.get("updateStatus"), + "isolation.status": isolation.get("status"), + "isolation.adminIsolated": isolation.get("adminIsolated"), + "isolation.selfIsolated": isolation.get("selfIsolated"), + "cloud.provider": cloud.get("provider"), + "cloud.instanceId": cloud.get("instanceId"), + "encryption.volumes": join_pairs(encryption.get("volumes"), "volumeId", "status"), + "tags": format_tags(endpoint.get("tags")), + } + + device_type = "" + if endpoint.get("type") == "server" or os_info.get("isServer"): + device_type = "Server" + + return ImportAsset( + id="sophos:{}:{}".format(tenant_id, endpoint_id), + hostnames=[endpoint.get("hostname")], + os=os_info.get("name") or os_info.get("platform") or "", + osVersion=build_os_version(os_info), + deviceType=device_type, + networkInterfaces=build_nics(endpoint), + software=build_software(endpoint)[:99], + customAttributes=to_custom_attributes(attrs), + # The Sophos endpoint ID is authoritative; do not let IP/MAC/name + # churn between polls fragment the asset. + matchBehavior="no-mac-break no-ip-break no-name-break", + ) + + +def main(*args, **kwargs): + require(kwargs, "client_id", "client_secret") + client_id = get_string(kwargs, "client_id") + client_secret = get_string(kwargs, "client_secret") + tenant_id = get_string(kwargs, "tenant_id", default="") + region_url = get_string(kwargs, "data_region_url", default="").rstrip("/") + endpoint_types = get_list(kwargs, "endpoint_types", default=[]) + last_seen_days = get_int(kwargs, "last_seen_days", default=0) + + token = get_token(kwargs, client_id, client_secret) + + if not tenant_id or not region_url: + info, err = get_json(WHOAMI_URL, **get_http_options(kwargs, headers={"Authorization": bearer(token), "Accept": "application/json"})) + if err: + _log("ERROR: whoami lookup failed: " + err) + return None + id_type = info.get("idType", "") + if not tenant_id: + if id_type != "tenant": + _log("ERROR: credential is a '{}' service principal; set the tenant_id and data_region_url parameters to target a specific tenant".format(id_type)) + return None + tenant_id = info.get("id", "") + if not region_url: + region_url = ((info.get("apiHosts") or {}).get("dataRegion") or "").rstrip("/") + + if not tenant_id or not region_url: + _log("ERROR: could not determine the tenant ID and data region URL") + return None + + params = {"pageSize": PAGE_SIZE, "view": "full"} + if endpoint_types: + params["type"] = ",".join(endpoint_types) + if last_seen_days > 0: + params["lastSeenAfter"] = "-P{}D".format(last_seen_days) + + total = 0 + pages = 0 + for _ in range(MAX_PAGES): + data, err = fetch_page(kwargs, region_url, token, tenant_id, params) + if err and err.startswith("status 401"): + _log("access token expired; requesting a new one") + token = get_token(kwargs, client_id, client_secret) + data, err = fetch_page(kwargs, region_url, token, tenant_id, params) + if err: + _log("ERROR: endpoint fetch failed: " + err) + break + + page_assets = [] + for endpoint in (data or {}).get("items") or []: + asset = build_asset(endpoint, tenant_id) + if asset != None: + page_assets.append(asset) + + # Stream each page to runZero so the full inventory is never held in memory. + total += report_assets(page_assets) + pages += 1 + _log("page {}: reported {} endpoints ({} total)".format(pages, len(page_assets), total)) + + next_key = ((data or {}).get("pages") or {}).get("nextKey") + if not next_key: + break + params["pageFromKey"] = next_key + + _log("SUCCESS: reported {} assets from {} pages".format(total, pages)) + return None