diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a8621d3..22b3417 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -31,8 +31,8 @@ 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-.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. +- [ ] **Integration script:** The `.star` file has been created/updated as required. +- [ ] **Embedded CONFIG:** The script's `CONFIG` block declares the `name` (product name), `type` (inbound, outbound, or internal), parameters, and any shared option-set includes. --- diff --git a/AGENTS.md b/AGENTS.md index e9535a1..4059805 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,25 +11,74 @@ Each integration must be placed in its own directory at the root of the reposito ``` repo-root/ ├── / -│ ├── custom-integration-.star # The main script -│ ├── config.json # Metadata -│ └── README.md # Documentation +│ ├── .star # The main script (also carries the integration metadata) +│ └── README.md # Documentation ``` -### 1. `config.json` -This file contains metadata about the integration. +### 1. Integration metadata (embedded `CONFIG` block) + +Every script must declare a top-level `CONFIG = {...}` literal at the top +of the file. The platform extracts this block (via a strict literal-only +Starlark walk) to render the credential form, validate user input, apply +defaults, and route secret fields through encrypted storage. Only literal +expressions are permitted on the right-hand side — no function calls, no +variable references, no arithmetic. The only exception is +`CONFIG["includes"]`, which may reference allowlisted shared option-set +identifiers such as `OPTIONS_TLS` and `OPTIONS_HTTP`. **Format:** -```json -{ - "name": "Integration Name", - "type": "inbound" +```python +CONFIG = { + "id": "runzero-example", + "name": "Integration Name", + "type": "inbound", + "description": "Short summary shown in the catalog.", + "version": "26052700", + "minVersion": "4.9", + "params": [ + { + "key": "client_id", + "label": "Client ID", + "type": "string", + "required": True, + }, + { + "key": "client_secret", + "label": "Client secret", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, } + +load("runzero.types", "ImportAsset") +# ...rest of script ``` -* `type`: Use `"inbound"` for importing assets into runZero, `"outbound"` for exporting assets from runZero. -### 2. `custom-integration-.star` -This is the main script written in Starlark. +**Required rules:** +- `CONFIG` must be a top-level assignment. +- All values must be literals (`True`/`False`/`None`, strings, ints, floats, lists, tuples, dicts with string keys, negated numbers via unary `-`). +- `id` must be a stable lower-case integration identifier, e.g. `runzero-tailscale`. +- `version` must use the integration version string for the target release, e.g. `26052700`. +- `minVersion` is optional. When set, it declares the minimum runZero version required to run the script as a dotted numeric version (an optional leading `v` is allowed, e.g. `4.9` or `v4.9.260604`). Before the script runs, the running runZero version is compared against it; if the Explorer is older, the task fails with a clear upgrade message. Omit it (or leave it blank) to impose no requirement. Development builds (version `0.0.0`) skip the check. +- `type` must be `inbound`, `outbound`, or `internal`. +- Each `params[].key` must match `^[a-zA-Z_][a-zA-Z0-9_]*$` and must match the kwarg name the script reads. +- `type: "secret"` (or `secret: True`) marks the field as a credential — never log or print these values, and never set a `default` on them. +- `includes` expands shared option sets with the dict key as a prefix, for example `{"src_tls_": OPTIONS_TLS, "dst_http_": OPTIONS_HTTP}`. + +**Supported top-level CONFIG fields:** `id`, `name`, `type`, `description`, `version`, `minVersion`, `params`, `includes`, `rejectUnknown`, `atLeastOneOf`, `exactlyOneOf`. + +**Supported param types:** `string`, `secret`, `int`, `float`, `bool`, `enum` (requires `options`), `url`, `textarea`, `json`. + +**Supported param fields:** `key`, `label`, `description`, `type`, `required`, `secret`, `default`, `placeholder`, `options`, `multi`, `min`, `max`, `pattern`, `dependsOn`, `group`. + +### 2. `.star` +This is the main script written in Starlark. Name it after the +integration directory (e.g. `tailscale/tailscale.star`). ## Script Development @@ -48,32 +97,103 @@ def main(*args, **kwargs): return assets # List of ImportAsset objects (for inbound) or None ``` -* **Arguments**: - * `kwargs['access_key']`: Typically the username, client ID, or organization ID. - * `kwargs['access_secret']`: Typically the password, API token, or secret key. +* **Arguments** — keys delivered through `**kwargs` are those declared in `CONFIG["params"]`. Reserved keys (those starting with `_`, e.g. `_integration_id`) are stored on the credential but **not** forwarded to the script. The platform applies declared `default` values, coerces `int`/`float`/`bool`/`enum` types, and rejects requests that fail validation (`required`, `min`, `max`, `pattern`, `options`) before `main` runs. + +#### Reading kwargs safely + +A helper module exposes typed accessors and validators: + +```python +load("kwargs", "require", "has", "get_string", "get_bool", "get_int", "get_float", "get_list") + +def main(*args, **kwargs): + require(kwargs, "client_id", "client_secret") + client_id = get_string(kwargs, "client_id") + client_secret = get_string(kwargs, "client_secret") + page_size = get_int(kwargs, "page_size", default=100) + include_offline = get_bool(kwargs, "include_offline", default=False) + regions = get_list(kwargs, "regions", default=[]) + if has(kwargs, "region"): + ... +``` + +Use descriptive parameter keys such as `username`, `password`, `api_token`, `client_id`, or `client_secret`; each `params[].key` must match the kwarg name the script reads. ### Return Type -* **Inbound**: Must return a `list` of `ImportAsset` objects. +* **Inbound**: Either return a `list` of `ImportAsset` objects from `main`, + **or** stream assets incrementally with `report_assets(...)` and return + `None`. The two approaches can be combined (anything returned from `main` + is imported in addition to whatever was already reported). * **Outbound**: Typically returns `None` after performing the export operation. +### Streaming assets with `report_assets` (large datasets) + +Returning one giant `list` from `main` forces the whole result set — every +`ImportAsset`, plus the raw API responses used to build them — to live in +memory at once. For integrations that page through large inventories this can +exhaust the Explorer's memory. Instead, report assets to runZero as you build +them and let each page be garbage-collected before the next is fetched: + +```python +def main(**kwargs): + total = 0 + cursor = None + while True: + page, cursor = fetch_page(kwargs, cursor) # one page of raw records + if not page: + break + assets = build_assets(page) # build just this page + report_assets(assets) # stream it to runZero + total += len(assets) + if not cursor: + break + print("reported {} assets".format(total)) + return None # nothing buffered in main +``` + +`report_assets` is a predeclared builtin (no `load` required) and accepts any +combination of: + +```python +report_assets(asset) # a single ImportAsset +report_assets(asset1, asset2) # several as positional args +report_assets(page_assets) # a list/tuple of ImportAsset +report_assets(*page_assets) # the same, spread +n = report_assets(batch) # returns the count reported, for logging +``` + +Notes: +* Reported assets are merged with any `list` returned from `main`, so a + partial migration (report some pages, return the rest) is safe. + + ### Available Libraries -Load libraries at the top of your script. +Load libraries at the top of your script. The list below covers the most +common modules; see [docs/starlark-helpers.md](docs/starlark-helpers.md) +for the complete reference (including `kwargs`, `requests`, `re`, `csv`, +`xml`, `jsonstream`, `jwt`, and the low-level `socket`/`runzero.ssh`/ +`runzero.smb`/`runzero.winrm`/`runzero.wmi`/`runzero.sql` modules). ```python -load('runzero.types', 'ImportAsset', 'NetworkInterface', 'Software', 'Vulnerability') +load('runzero.types', 'ImportAsset', 'NetworkInterface', 'Service', + 'ServiceProtocolData', 'Software', 'Vulnerability', + 'to_custom_attributes') +load('kwargs', 'require', 'has', 'get_string', 'get_bool', 'get_int', + 'get_list', 'get_http_options') load('json', json_encode='encode', json_decode='decode') -load('net', 'ip_address') -load('http', http_post='post', http_get='get', 'url_encode') +load('net', 'ip_address', 'network_interface', 'normalize_mac', 'resolve') +load('http', http_post='post', http_get='get', 'get_json', 'post_json', + 'url_encode', 'bearer', 'basic', 'oauth2_token') load('uuid', 'new_uuid') -load('time', 'parse_time') +load('time', 'parse_time', 'parse_duration', 'now') load('gzip', gzip_decompress='decompress', gzip_compress='compress') load('base64', base64_encode='encode', base64_decode='decode') -load('crypto', 'sha256', 'sha512', 'sha1', 'md5') +load('crypto', 'sha256', 'sha512', 'sha1', 'md5', 'hmac_sha256') load('flatten_json', 'flatten') ``` ## runZero SDK Types -The Starlark `runzero.types` library exposes `ImportAsset`, `NetworkInterface`, `Software`, and `Vulnerability`. The Python SDK wraps the same REST models and also provides `Hostname`, `Tag`, `Service`, `ServiceProtocolData`, `ScanOptions`, `ScanTemplate`, and `ScanTemplateOptions`. These wrappers enforce validation and normalization, so build your payloads to fit the expected shape: +The Starlark `runzero.types` library exposes `ImportAsset`, `NetworkInterface`, `Service`, `ServiceProtocolData`, `Software`, `Vulnerability`, and the `to_custom_attributes` helper. The Python SDK wraps the same REST models and also provides `Hostname`, `Tag`, `ScanOptions`, `ScanTemplate`, and `ScanTemplateOptions`. These wrappers enforce validation and normalization, so build your payloads to fit the expected shape: - `ImportAsset`: unique `id`; `hostnames`/`tags` accept plain strings or wrapped types; optional `os`, `osVersion`, `services`, `software`, `vulnerabilities`; `customAttributes` should stay under 1024 entries with keys <=256 chars and values <=1024 chars. - `NetworkInterface`: `macAddress`, `ipv4Addresses`, `ipv6Addresses`; IP strings are parsed/validated. @@ -289,13 +409,97 @@ def flatten_example(): # Result: {"a.b": 1, "a.c": 2, "d": 3} ``` +### kwargs +Typed, validating accessors over the `**kwargs` passed to `main()`. + +```python +load('kwargs', 'require', 'has', 'get_string', 'get_int', 'get_bool', 'get_list') + +def kwargs_example(**kwargs): + require(kwargs, 'client_id', 'client_secret') # error if missing/blank + page = get_int(kwargs, 'page_size', default=100) + regions = get_list(kwargs, 'regions', default=[]) # CSV or list -> list +``` + +### get_json / post_json +Drop-in replacements for `GET` + status-check + `json_decode`, with retry +and backoff. Return `(data, err)`. + +```python +load('http', 'get_json', 'post_json', 'bearer') + +def get_json_example(token): + data, err = get_json("https://api.example.com/devices", + headers={"Authorization": bearer(token)}, + params={"limit": 100}) + if err: + print("fetch failed:", err) + return [] + return data +``` + +### re +Regular expressions (Go RE2 syntax). + +```python +load('re', re_find_all='find_all', re_sub='sub') + +def re_example(s): + ids = re_find_all(r"id=(\d+)", s) + clean = re_sub(r"\s+", " ", s) + return ids, clean +``` + +### csv / xml / jsonstream +Parse non-JSON payloads and stream large responses. + +```python +load('csv', csv_read='read_all') +load('xml', xml_parse='parse') +load('jsonstream', 'iter_array') + +def parse_examples(csv_text, xml_text, big_json): + rows = csv_read(csv_text) # list[dict] keyed by header + doc = xml_parse(xml_text) # element tree + name = doc.find("device/name").text if doc else None + for item in iter_array(big_json, path="data.items"): # streamed + print(item.get("id")) + return rows, name +``` + +### runzero.progress +Report progress and log lines into the runZero UI. + +```python +load('runzero.progress', progress_report='report', progress_info='info') + +def progress_example(): + progress_report(50, "halfway done") # pct clamped to [0, 100] + progress_info("processing next page") +``` + +### Low-level protocols (socket / ssh / smb / winrm / wmi / sql) +For sources without a REST API, open a raw connection and **always +`close()`** it. See [docs/starlark-helpers.md](docs/starlark-helpers.md) +for full signatures. + +```python +load('runzero.ssh', ssh_dial='dial') + +def ssh_example(host, user, password): + sess = ssh_dial(host, username=user, password=password) + stdout, stderr, code = sess.run("uname -a") + sess.close() + return stdout +``` + ## Testing Use the `runzero` CLI to test your script locally. 1. **Run with arguments**: ```bash - runzero script --filename --kwargs access_key=MY_KEY --kwargs access_secret=MY_SECRET + runzero script --filename --kwargs client_id=MY_ID --kwargs client_secret=MY_SECRET ``` 2. **REPL**: @@ -326,7 +530,7 @@ def build_network_interface(ips, mac): return NetworkInterface(macAddress=mac, ipv4Addresses=ip4s, ipv6Addresses=ip6s) def main(**kwargs): - api_key = kwargs.get('access_secret') + api_key = kwargs.get('api_token') headers = {"Authorization": "Bearer {}".format(api_key)} assets = [] diff --git a/LICENSE b/LICENSE index 749f792..974f290 100644 --- a/LICENSE +++ b/LICENSE @@ -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 diff --git a/README.md b/README.md index d4c0394..cb32be0 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ If you need help setting up a custom integration, you can create an [issue](http - [Automox](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/automox/) - [Bitsight](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/bitsight/) - [Carbon Black](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/carbon-black/) -- [Cisco-ISE](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/cisco-ise/) +- [Cisco ISE](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/cisco-ise/) - [Cortex XDR](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/cortex-xdr/) - [Cyberint](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/cyberint/) - [Device42](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/device42/) @@ -31,39 +31,129 @@ If you need help setting up a custom integration, you can create an [issue](http - [Drata](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/drata/) - [Extreme Networks CloudIQ](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/extreme-cloud-iq/) - [Ghost Security](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ghost/) -- [Halycon](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/halycon/) +- [Halcyon](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/halycon/) - [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/) -- [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/) +- [Kubernetes](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/kubernetes/) +- [LimaCharlie](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/lima-charlie/) +- [Linux via SSH](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/linux-ssh/) +- [ManageEngine Endpoint Central](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/manage-engine-endpoint-central/) +- [Microsoft SQL Server databases](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/mssql-databases/) +- [Mosyle](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/moysle/) - [Netskope](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/netskope/) - [Nexthink](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/nexthink/) - [NinjaOne](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ninjaone/) - [pfSense](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/pfsense/) - [Proxmox](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/proxmox/) -- [runZero Task Sync](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/task-sync/) +- [runZero Task Sync](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/runzero-task-sync/) - [Scale Computing](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/scale-computing/) - [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/) +- [SolarWinds Information Service](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/solarwinds-information-service/) - [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/) -- [Ubiquiti Unifi Network](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ubiquiti-unifi-network/) +- [Ubiquiti UniFi Network](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ubiquiti-unifi-network/) - [Wazuh](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/wazuh/) +- [Windows SMB shares](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/windows-smb-shares/) +- [Windows WMI](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/windows-wmi/) ## Export from runZero - [Audit Log to Webhook](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/audit-events-to-webhook/) - [Sumo Logic](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/sumo-logic/) ## Internal Integrations -- [Scan Passive Assets](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/scan-passive-assets/) -- [Vunerability Workflow](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/vulnerability-workflow/) +- [Scan Passive Assets](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/runzero-scan-passive-assets/) +- [Vulnerability Workflow](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/runzero-vulnerability-workflow/) ## 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 -3. Sample [config.json](./boilerplate/config.json) that gives context on the integration for automations to reference +2. Sample [script](./boilerplate/boilerplate.star) that shows how to use all of the supported libraries +3. Embedded `CONFIG` metadata in each script 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 diff --git a/akamai-guardicore-centra/README.md b/akamai-guardicore-centra/README.md index 3935558..eded858 100644 --- a/akamai-guardicore-centra/README.md +++ b/akamai-guardicore-centra/README.md @@ -35,8 +35,8 @@ git clone https://github.com/runZeroInc/runzero-custom-integrations.git 2. Determine the proper Guardicore Centra URL: - Assign the URL to `CENTRA_BASE_URL` within the starlark script 3. Create login credentials with necessary, read-only access to retrieve JWT token for API access: - - Copy the username to the value for `access_key` when creating the Custom Integration credentials in the runZero console (see below) - - Copy the password to the the value for `access_secret` when creating the Custom Integration credentials in the runZero console (see below) + - Copy the username to the value for `username` when creating the Custom Integration credentials in the runZero console (see below) + - Copy the password to the value for `password` when creating the Custom Integration credentials in the runZero console (see below) ### runZero configuration @@ -51,9 +51,9 @@ git clone https://github.com/runZeroInc/runzero-custom-integrations.git - Modify datapoints uploaded to runZero as needed 2. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials) - Select the type `Custom Integration Script Secrets` - - Both `access_key` and `access_secret` are required - - `access_key` corresponds to the Client ID provided when creating the Guardicore Centra Application Registration - - `access_secret` corresponds to the Client secret provided when creating the Guardicore Centra Application Registration + - Both `username` and `password` are required + - `username` corresponds to the username provided for Guardicore Centra + - `password` corresponds to the password provided for Guardicore Centra 3. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new) - Add a Name and Icon - Toggle `Enable custom integration script` to input your finalized script diff --git a/akamai-guardicore-centra/custom-integration-centra-v3-api.star b/akamai-guardicore-centra/centra-v3-api.star similarity index 55% rename from akamai-guardicore-centra/custom-integration-centra-v3-api.star rename to akamai-guardicore-centra/centra-v3-api.star index c8c41a6..880cf9f 100644 --- a/akamai-guardicore-centra/custom-integration-centra-v3-api.star +++ b/akamai-guardicore-centra/centra-v3-api.star @@ -1,13 +1,48 @@ -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') +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-akamai-guardicore-centra-v3-api", + "name": "Akamai Guardicore Centra (v3)", + "type": "inbound", + "description": "Imports assets from Akamai Guardicore Centra using the v3 API.", + "version": "26061000", + "params": [ + { + "key": "url", + "label": "Centra URL", + "type": "url", + "required": True, + "placeholder": "https://centra.example.com", + "description": "Centra API base URL.", + }, + { + "key": "username", + "label": "Username", + "type": "string", + "required": True, + "description": "Centra API username.", + }, + { + "key": "password", + "label": "Password", + "type": "secret", + "required": True, + "description": "Centra API password.", + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} + +load('runzero.types', 'ImportAsset', 'to_custom_attributes') +load('http', 'get_json', 'post_json', 'bearer') +load('kwargs', 'get_url_base', 'get_http_options') +load('net', 'network_interface') load('time', 'parse_time') load('uuid', 'new_uuid') -#Change the URL to match your Guardicore Centra server -CENTRA_BASE_URL = 'https://' -RUNZERO_REDIRECT = 'https://console.runzero.com/' def build_assets(assets): assets_import = [] @@ -26,7 +61,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 @@ -105,106 +140,66 @@ 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 = [] +def stream_assets(base_url, token, config_kwargs): + """Paginate Centra assets ('on' then 'off'), building and streaming each page + via report_assets so the full asset set is never held in memory. Returns the + number of assets reported.""" + reported = 0 results_per_page = 1000 - start = 0 - last_return = 1000 - - # Return all 'status:on' assets - while True: - url = CENTRA_BASE_URL + '/api/v4.0/assets?' - headers = {'Accept': 'application/json', - '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)) - 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 + + # The 'on' and 'off' status queries historically target different API + # versions; preserve those endpoints exactly. + status_urls = [('on', base_url + '/api/v4.0/assets?'), ('off', base_url + '/api/v3.0/assets?')] + + for status, url in status_urls: + # Remove the 'off' entry above to restrict import to only status 'on' + # assets. + start = 0 + while True: + headers = {'Accept': 'application/json', + 'Authorization': bearer(token)} + http_options = get_http_options(config_kwargs, headers=headers) + params = {'max_results': results_per_page, + 'start_at': start, + 'status': status} + data, err = get_json(url, params=params, **http_options) + if err: + print('failed to retrieve "' + status + '" assets ' + str(start) + ' to ' + str(start + results_per_page) + ': ' + err) 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} - 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)) - break - else: - data = json_decode(response.body) - assets = data['objects'] - assets_all.extend(assets) + assets = (data or {}).get('objects', []) + reported += report_assets(build_assets(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)) - return None + break + + return reported - auth_data = json_decode(response.body) - if not auth_data: +def get_token(base_url, username, password, config_kwargs): + url = base_url + '/api/v3.0/authenticate' + data, err = post_json(url, json={'username': username, 'password': password}, **get_http_options(config_kwargs)) + if err: + print('authentication failed:', err) + return None + 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'] - password = kwargs['access_secret'] - token = get_token(username, password) - assets = get_assets(token) - - # Format asset list for import into runZero - import_assets = build_assets(assets) - if not import_assets: + base_url = get_url_base(kwargs) + username = kwargs['username'] + password = kwargs['password'] + token = get_token(base_url, username, password, kwargs) + + # Assets are streamed page-by-page via report_assets in stream_assets. + reported = stream_assets(base_url, token, kwargs) + if not reported: print('no assets') - return None - return import_assets \ No newline at end of file + return None \ No newline at end of file diff --git a/akamai-guardicore-centra/custom-integration-centra-v4-api.star b/akamai-guardicore-centra/centra-v4-api.star similarity index 54% rename from akamai-guardicore-centra/custom-integration-centra-v4-api.star rename to akamai-guardicore-centra/centra-v4-api.star index d6c3b02..da530d4 100644 --- a/akamai-guardicore-centra/custom-integration-centra-v4-api.star +++ b/akamai-guardicore-centra/centra-v4-api.star @@ -1,17 +1,50 @@ -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') +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-akamai-guardicore-centra-v4-api", + "name": "Akamai Guardicore Centra", + "type": "inbound", + "description": "Imports Centra agents and assets via the Centra v3 or v4 API.", + "version": "26061000", + "params": [ + { + "key": "url", + "label": "Centra URL", + "type": "url", + "required": True, + "placeholder": "https://centra.example.com", + "group": "Connection", + }, + { + "key": "username", + "label": "Centra username", + "type": "string", + "required": True, + "group": "Authentication", + }, + { + "key": "password", + "label": "Centra password", + "type": "secret", + "required": True, + "group": "Authentication", + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +load('runzero.types', 'ImportAsset', 'to_custom_attributes') +load('http', 'get_json', 'post_json', 'bearer') +load('kwargs', 'get_url_base', 'get_http_options') +load('net', 'network_interface') load('time', 'parse_time') load('uuid', 'new_uuid') -#Change the URL to match your Guardicore Centra server -CENTRA_BASE_URL = 'https://' -RUNZERO_REDIRECT = 'https://console.runzero.com/' -def build_assets(assets, token): +def build_assets(base_url, assets, token, config_kwargs, label_mapping): assets_import = [] - label_mapping = {} for asset in assets: agent_info = asset.get('agent', {}) os_info = asset.get('os_info', {}) @@ -29,7 +62,7 @@ def build_assets(assets, token): nics = asset.get('nics', []) for nic in nics: addresses = nic.get('ip_addresses', []) - interface = build_network_interface(ips=addresses, mac=nic.get('mac_address', None)) + interface = network_interface(ips=addresses, mac=nic.get('mac_address', None)) interfaces.append(interface) # Retrieve and map custom attributes @@ -68,7 +101,7 @@ def build_assets(assets, token): for guid in label_guids: name = label_mapping.get(guid, None) if not name: - new_mapping = get_labels(guid, token) + new_mapping = get_labels(base_url, guid, token, config_kwargs) for k, v in new_mapping.items(): label_mapping[k] = v name = label_mapping.get(guid, '') @@ -119,124 +152,83 @@ def build_assets(assets, token): os=os, first_seen_ts=first_seen, networkInterfaces=interfaces, - customAttributes=custom_attributes, - tags=tags + customAttributes=to_custom_attributes(custom_attributes), + tags=tags, ) ) 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_labels(guid, token): - url = CENTRA_BASE_URL + '/api/v4.0/labels/' + guid + '?' +def get_labels(base_url, guid, token, config_kwargs): + url = base_url + '/api/v4.0/labels/' + guid + '?' headers = {'Accept': 'application/json', - 'Authorization': 'Bearer ' + token} + 'Authorization': bearer(token)} params = {'asset_limit': 1} - response = http_get(url, headers=headers, params=params) - if response.status_code != 200: - print('failed to retrieve label info for ' + guid, 'status code: ' + str(response.status_code)) - data = json_decode(response.body) - label_info = data['objects'] + data, err = get_json(url, params=params, **get_http_options(config_kwargs, headers=headers)) + if err: + print('failed to retrieve label info for ' + guid + ': ' + err) + return {} + label_info = (data or {}).get('objects', []) + if not label_info: + return {} label_key = label_info[0].get('key', '') label_value = label_info[0].get('value', '') - label_mapping = { guid: label_key + ': ' + label_value } - return label_mapping + return { guid: label_key + ': ' + label_value } -def get_assets(token): - assets_all = [] +def stream_assets(base_url, token, config_kwargs): + """Paginate Centra assets ('on' then 'off'), building and streaming each page + via report_assets so the full asset set is never held in memory. The label + cache is shared across pages to avoid redundant label lookups. Returns the + number of assets reported.""" + label_mapping = {} + reported = 0 results_per_page = 1000 - start = 0 - last_return = 1000 - - # Return all 'status:on' assets - while True: - url = CENTRA_BASE_URL + '/api/v4.0/assets?' - headers = {'Accept': 'application/json', - 'Authorization': 'Bearer ' + token} - params = {'max_results': results_per_page, - 'start_at': start, - 'status': 'on', - 'expand': 'agent'} - 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)) - 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 + + for status in ('on', 'off'): + # Return all assets for this status. Remove 'off' from the tuple above to + # restrict import to only status 'on' assets. + start = 0 + while True: + url = base_url + '/api/v4.0/assets?' + headers = {'Accept': 'application/json', + 'Authorization': bearer(token)} + http_options = get_http_options(config_kwargs, headers=headers) + params = {'max_results': results_per_page, + 'start_at': start, + 'status': status, + 'expand': 'agent'} + data, err = get_json(url, params=params, **http_options) + if err: + print('failed to retrieve "' + status + '" assets ' + str(start) + ' to ' + str(start + results_per_page) + ': ' + err) 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/v4.0/assets?' - headers = {'Accept': 'application/json', - 'Authorization': 'Bearer ' + token} - params = {'max_results': results_per_page, - 'start_at': start, - 'status': 'off', - 'expand': 'agent'} - 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)) - break - else: - data = json_decode(response.body) - assets = data['objects'] - assets_all.extend(assets) + assets = (data or {}).get('objects', []) + reported += report_assets(build_assets(base_url, assets, token, config_kwargs, label_mapping)) 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)) - return None + break + + return reported - auth_data = json_decode(response.body) - if not auth_data: +def get_token(base_url, username, password, config_kwargs): + url = base_url + '/api/v3.0/authenticate' + data, err = post_json(url, json={'username': username, 'password': password}, **get_http_options(config_kwargs)) + if err: + print('authentication failed:', err) + return None + 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'] - password = kwargs['access_secret'] - token = get_token(username, password) - assets = get_assets(token) - - # Format asset list for import into runZero - import_assets = build_assets(assets, token) - if not import_assets: + base_url = get_url_base(kwargs) + username = kwargs['username'] + password = kwargs['password'] + token = get_token(base_url, username, password, kwargs) + + # Assets are streamed page-by-page via report_assets in stream_assets. + reported = stream_assets(base_url, token, kwargs) + if not reported: print('no assets') - return None - return import_assets \ No newline at end of file + return None \ No newline at end of file diff --git a/akamai-guardicore-centra/config.json b/akamai-guardicore-centra/config.json deleted file mode 100644 index a3efea2..0000000 --- a/akamai-guardicore-centra/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Akamai Guardicore Centra", "type": "inbound" } \ No newline at end of file diff --git a/audit-events-to-webhook/README.md b/audit-events-to-webhook/README.md index ba7631d..4dbadc2 100644 --- a/audit-events-to-webhook/README.md +++ b/audit-events-to-webhook/README.md @@ -15,8 +15,8 @@ The integration is a Starlark script that performs the following actions: To use this integration, you will need to configure a new custom integration in your runZero account. 1. **Create a new Custom Integration:** In your runZero console, navigate to `Account > Custom Integrations` and create a new custom integration. -2. **Copy the Script:** Copy the contents of the `custom-integration-audit-events.star` file and paste it into the script editor for your new custom integration. -3. **Set up Credentials:** The script requires the following credentials to be configured in the custom integration's `access_secret` field as a JSON object: +2. **Copy the Script:** Copy the contents of the `audit-events.star` file and paste it into the script editor for your new custom integration. +3. **Set up Credentials:** The script requires the following credentials to be configured in the custom integration's `legacy_credentials` field as a JSON object: * `webhook_url`: The URL of the webhook to which the audit events will be sent. * `rz_account_token`: A runZero account token. @@ -36,11 +36,11 @@ To use this integration, you will need to configure a new custom integration in ## Script Details -The `custom-integration-audit-events.star` script is written in Starlark and uses the built-in `http` and `json` modules to interact with the runZero API and the destination webhook. +The `audit-events.star` script is written in Starlark and uses the built-in `http` and `json` modules to interact with the runZero API and the destination webhook. ### `main` function -The `main` function is the entry point for the script. It retrieves the necessary credentials from the `access_secret`, fetches the latest audit events from the runZero API, and then calls the `send_events_to_webhook` function to send the events to the configured webhook. +The `main` function is the entry point for the script. It retrieves the necessary credentials from `legacy_credentials`, fetches the latest audit events from the runZero API, and then calls the `send_events_to_webhook` function to send the events to the configured webhook. ### `send_events_to_webhook` function diff --git a/audit-events-to-webhook/audit-events.star b/audit-events-to-webhook/audit-events.star new file mode 100644 index 0000000..d3b74ce --- /dev/null +++ b/audit-events-to-webhook/audit-events.star @@ -0,0 +1,137 @@ +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-audit-log-to-webhook", + "name": "Audit Log to Webhook", + "type": "outbound", + "description": "Forwards runZero audit events to an external webhook.", + "version": "26052700", + "params": [ + { + "key": "src_url", + "label": "runZero source URL", + "type": "url", + "required": False, + "default": "https://console.runzero.com", + "group": "Source", + }, + { + "key": "dst_url", + "label": "Webhook URL", + "type": "url", + "required": True, + "group": "Destination", + "description": "Where to POST audit events", + }, + { + "key": "external_api_key", + "label": "Webhook API key", + "type": "secret", + "required": False, + "group": "Destination", + "description": "Optional bearer token sent to the webhook", + }, + { + "key": "rz_account_token", + "label": "runZero account token", + "type": "secret", + "required": True, + "group": "Source", + "description": "Account-scoped token used to read the audit log", + }, + { + "key": "legacy_credentials", + "label": "Legacy JSON credential", + "type": "secret", + "required": False, + "group": "Legacy", + "description": "Back-compat JSON with webhook_url, external_api_key, rz_account_token", + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +load('http', http_post='post', 'get_json', 'bearer') +load('json', json_encode='encode', json_decode='decode') +load('kwargs', 'get_http_options', 'get_http_tls') + +def send_events_to_webhook(events, webhook, http_options): + print("Sending {} events to Webhook".format(len(events))) + batchsize = 500 + if len(events) > 0: + for i in range(0, len(events), batchsize): + batch = events[i:i+batchsize] + tmp = "" + for a in batch: + tmp = tmp + "{}\n".format(json_encode(a)) + post_to_webhook = http_post(url=webhook, body=bytes(tmp), **http_options) + print("Response code from Webhook: {}".format(post_to_webhook.status_code)) + else: + print("No events found") + +def main(*args, **kwargs): + """ + Export runZero events from the last hour and send to a webhook. + Credentials dict passed as: + {"webhook_url":"URL","external_api_key":"bearer-auth-token","rz_export_token":"runzero-export-token"} + """ + + creds = kwargs.get('legacy_credentials') # Legacy JSON path + if type(creds) == 'string': + creds = json_decode(creds) + if type(creds) != 'dict': + creds = {} + + src_url = kwargs.get('src_url') or creds.get('src_url') or 'https://console.runzero.com' + webhook_url = kwargs.get('dst_url') or creds.get('dst_url') or creds.get('webhook_url') + external_api_key = kwargs.get('external_api_key') or creds.get('external_api_key') + rz_token = kwargs.get('rz_account_token') or creds.get('rz_account_token') + + if not webhook_url: + print("Missing destination webhook URL.") + return [] + if not rz_token: + print("Missing runZero account token.") + return [] + + # We'll assume search query supports time filters (e.g. "timestamp > now-1h") + search_query = "created:<1h" + + # Request headers for runZero export + headers = { + "Accept": "application/json", + "Authorization": bearer(rz_token), + } + + events_url = src_url.rstrip('/') + "/api/v1.0/account/events.json" + tls = get_http_tls(kwargs) + if creds.get('tls_disable_validation', False): + tls["insecure"] = True + src_options = get_http_options(kwargs, headers=headers) + src_options["tls"] = tls + + # Fetch events + events, err = get_json( + events_url, + params={"search": search_query}, + **src_options + ) + + if err: + print("Failed to fetch events from runZero:", err) + return [] + + # Send to Webhook + headers = { + "Content-Type": "application/json" + } + if external_api_key: + headers["Authorization"] = "Bearer {}".format(external_api_key) + dst_options = get_http_options(kwargs, headers=headers) + dst_options["tls"] = tls + + send_events_to_webhook(events, webhook_url, dst_options) + + return [] diff --git a/audit-events-to-webhook/config.json b/audit-events-to-webhook/config.json deleted file mode 100644 index 4c62296..0000000 --- a/audit-events-to-webhook/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Audit Log to Webhook", "type": "outbound" } diff --git a/audit-events-to-webhook/custom-integration-audit-events.star b/audit-events-to-webhook/custom-integration-audit-events.star deleted file mode 100644 index fcdbae7..0000000 --- a/audit-events-to-webhook/custom-integration-audit-events.star +++ /dev/null @@ -1,67 +0,0 @@ -load('http', http_post='post', http_get='get', 'url_encode') -load('json', json_encode='encode', json_decode='decode') -load('time', 'parse_time') - -def send_events_to_webhook(events, webhook, headers): - print("Sending {} events to Webhook".format(len(events))) - batchsize = 500 - if len(events) > 0: - for i in range(0, len(events), batchsize): - batch = events[i:i+batchsize] - tmp = "" - for a in batch: - tmp = tmp + "{}\n".format(json_encode(a)) - post_to_webhook = http_post(url=webhook, headers=headers, body=bytes(tmp)) - print("Response code from Webhook: {}".format(post_to_webhook.status_code)) - else: - print("No events found") - -def main(*args, **kwargs): - """ - Export runZero events from the last hour and send to a webhook. - Credentials dict passed as: - {"webhook_url":"URL","external_api_key":"bearer-auth-token","rz_export_token":"runzero-export-token"} - """ - - creds = kwargs.get('access_secret') # runZero passes this as JSON string or dict - if type(creds) == 'string': - creds = json_decode(creds) - - webhook_url = creds.get('webhook_url') - external_api_key = creds.get('external_api_key') - rz_token = creds.get('rz_account_token') - - # We'll assume search query supports time filters (e.g. "timestamp > now-1h") - search_query = "created:<1h" - - # Request headers for runZero export - headers = { - "Accept": "application/json" - } - - if external_api_key: - headers["Authorization"] = "Bearer {}".format(external_api_key) - - # Build runZero API URL - base_url = "https://console.runzero.com/api/v1.0" - events_url = "https://console.runzero.com/api/v1.0/account/events.json" - - # Fetch events - response = http_get(events_url, headers=headers, params={"search": search_query}) - - if not response or response.status_code != 200: - print("Failed to fetch events from runZero. Status: {}".format(response.body)) - return [] - - events = json_decode(response.body) - - # Send to Webhook - headers = { - "Content-Type": "application/json" - } - if external_api_key: - headers["Authorization"] = "Bearer {}".format(external_api_key) - - send_events_to_webhook(events, webhook_url, headers) - - return [] diff --git a/automox/README.md b/automox/README.md index 0bd8024..efb3d5e 100644 --- a/automox/README.md +++ b/automox/README.md @@ -27,8 +27,8 @@ 2. **Create a Credential for the Custom Integration**: - Go to [runZero Credentials](https://console.runzero.com/credentials). - Select `Custom Integration Script Secrets`. - - Enter your **Automox API Key** as `access_secret`. - - Use a placeholder value like `foo` for `access_key` (unused in this integration). + - Enter your **Automox API Key** as `api_token`. + - Optionally set `organization_hint` to an Automox organization ID or name. 3. **Create the Custom Integration**: - Go to [runZero Custom Integrations](https://console.runzero.com/custom-integrations/new). - Add a **Name and Icon** for the integration (e.g., "automox"). diff --git a/automox/automox.star b/automox/automox.star new file mode 100644 index 0000000..332ffaa --- /dev/null +++ b/automox/automox.star @@ -0,0 +1,276 @@ +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-automox", + "name": "Automox", + "type": "inbound", + "description": "Imports endpoints from the Automox platform.", + "version": "26061000", + "params": [ + { + "key": "organization_hint", + "label": "Organization hint", + "type": "string", + "required": False, + "description": "Optional Automox organization ID or name", + }, + { + "key": "api_token", + "label": "Automox API token", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +## Automox! + +load('runzero.types', 'ImportAsset', 'Software', 'to_custom_attributes') +load('net', 'network_interface') +load('http', 'get_json', 'bearer') +load('kwargs', 'get_http_options') +load('uuid', 'new_uuid') + +AUTOMOX_BASE_URL = "https://console.automox.com/api" +AUTOMOX_SERVERS_URL = AUTOMOX_BASE_URL + "/servers" +AUTOMOX_ORGS_URL = AUTOMOX_BASE_URL + "/orgs" + +def looks_numeric(v): + if v == None: + return False + s = str(v) + if s == "": + return False + return s.isdigit() + +def normalize_list(decoded): + if decoded == None: + return [] + t = type(decoded) + if t == "list": + return decoded + if t == "dict": + if "data" in decoded and type(decoded["data"]) == "list": + return decoded["data"] + if "results" in decoded and type(decoded["results"]) == "list": + return decoded["results"] + if "items" in decoded and type(decoded["items"]) == "list": + return decoded["items"] + if "records" in decoded and type(decoded["records"]) == "list": + return decoded["records"] + fail("Unexpected dict response (no data/results/items/records list field).") + fail("Unexpected response type: " + t) + +def get_orgs(http_options): + orgs = [] + page = 0 + limit = 500 + + while True: + params = {"limit": str(limit), "page": str(page)} + data, err = get_json(AUTOMOX_ORGS_URL, params=params, **http_options) + + if err: + fail("Failed to fetch orgs from Automox: " + err) + + batch = normalize_list(data) + if not batch: + break + + for o in batch: + orgs.append(o) + + page = page + 1 + + return orgs + +def choose_org_id(http_options, org_hint): + if looks_numeric(org_hint): + return str(org_hint) + + orgs = get_orgs(http_options) + if not orgs: + fail("No organizations returned from Automox; cannot determine org_id.") + + oid = orgs[0].get("id", None) + if oid == None: + fail("Automox /orgs response missing 'id'.") + return str(oid) + +def fetch_org_packages(http_options, org_id): + url = AUTOMOX_BASE_URL + "/orgs/" + str(org_id) + "/packages" + packages = [] + page = 0 + limit = 500 + + while True: + params = {"limit": str(limit), "page": str(page), "o": str(org_id)} + data, err = get_json(url, params=params, **http_options) + + if err: + fail("Failed to fetch org packages from Automox: " + err) + + batch = normalize_list(data) + if not batch: + break + + for p in batch: + packages.append(p) + + page = page + 1 + + return packages + +def index_software_by_server(packages): + by_server = {} + + for soft in packages: + sid = soft.get("server_id", None) + if sid == None: + continue + + sw = Software( + id=str(soft.get("id", "")), + installedFrom=str(soft.get("repo", "")), + product=str(soft.get("display_name", "")), + version=str(soft.get("version", "")), + customAttributes=to_custom_attributes({ + "server_id": soft.get("server_id"), + "package_id": soft.get("package_id"), + "software_id": soft.get("software_id"), + "installed": soft.get("installed"), + "ignored": soft.get("ignored"), + "group_ignored": soft.get("group_ignored"), + "deferred_until": soft.get("deferred_until"), + "group_deferred_until": soft.get("group_deferred_until"), + "name": soft.get("name"), + "cves": soft.get("cves"), + "cve_score": soft.get("cve_score"), + "agent_severity": soft.get("agent_severity"), + "severity": soft.get("severity"), + "package_version_id": soft.get("package_version_id"), + "os_name": soft.get("os_name"), + "os_version": soft.get("os_version"), + "os_version_id": soft.get("os_version_id"), + "create_time": soft.get("create_time"), + "requires_reboot": soft.get("requires_reboot"), + "patch_classification_category_id": soft.get("patch_classification_category_id"), + "patch_scope": soft.get("patch_scope"), + "is_uninstallable": soft.get("is_uninstallable"), + "secondary_id": soft.get("secondary_id"), + "is_managed": soft.get("is_managed"), + "impact": soft.get("impact"), + "organization_id": soft.get("organization_id"), + }), + ) + + key = str(sid) + if key not in by_server: + by_server[key] = [] + by_server[key].append(sw) + + return by_server + +def build_network_interfaces_from_device(device): + details = device.get("details", device.get("detail", {})) + if type(details) == "dict": + nics = details.get("NICS", None) + if type(nics) == "list" and nics: + out = [] + for nic in nics[:99]: + mac = nic.get("MAC", "") + ips = nic.get("IPS", []) + out.append(network_interface(ips=ips, mac=mac)) + if out: + return out + + ips = device.get("ip_addrs", []) + device.get("ip_addrs_private", []) + return [network_interface(ips=ips, mac="")] + +def build_device_asset(device, sw_by_server): + device_id = device.get("id", new_uuid()) + + custom_attrs = { + "os_version": device.get("os_version", ""), + "os_name": device.get("os_name", ""), + "os_family": device.get("os_family", ""), + "agent_version": device.get("agent_version", ""), + "compliant": str(device.get("compliant", "")), + "last_logged_in_user": device.get("last_logged_in_user", ""), + "serial_number": device.get("serial_number", ""), + "agent_status": device.get("status", {}).get("agent_status", ""), + } + + return ImportAsset( + id=str(device_id), + networkInterfaces=build_network_interfaces_from_device(device), + hostnames=[device.get("name", "")], + os_version=device.get("os_version", ""), + os=device.get("os_family", "") + " " + device.get("os_name", ""), + software=sw_by_server.get(str(device_id), []), + customAttributes=to_custom_attributes(custom_attrs), + trust_device_type=True, + trust_os=True, + trust_os_version=True, + ) + +def stream_device_assets(http_options, org_hint, sw_by_server): + """Paginate Automox devices, building and streaming each page of assets via + report_assets so the full device set is never held in memory. Returns the + number of assets reported.""" + reported = 0 + page = 0 + limit = 500 + use_o = looks_numeric(org_hint) + + while True: + params = {"limit": str(limit), "page": str(page), "include_details": "1"} + if use_o: + params["o"] = str(org_hint) + + data, err = get_json(AUTOMOX_SERVERS_URL, params=params, **http_options) + + if err and err.startswith("status 404") and use_o: + use_o = False + reported = 0 + page = 0 + continue + + if err: + fail("Failed to fetch devices from Automox: " + err) + + batch = normalize_list(data) + if not batch: + break + + page_assets = [build_device_asset(d, sw_by_server) for d in batch] + reported += report_assets(page_assets) + + page = page + 1 + + return reported + +def build_assets(api_token, org_hint, config_kwargs): + headers = {"Authorization": "Bearer " + api_token, "Content-Type": "application/json"} + http_options = get_http_options(config_kwargs, headers=headers) + + org_id = choose_org_id(http_options, org_hint) + + packages = fetch_org_packages(http_options, org_id) + sw_by_server = index_software_by_server(packages) + + return stream_device_assets(http_options, org_hint, sw_by_server) + +def main(**kwargs): + org_hint = kwargs.get("organization_hint", None) + api_token = kwargs.get("api_token", None) + + if not api_token: + fail("Missing api_token (Automox API token).") + + # Assets are streamed page-by-page via report_assets in build_assets. + build_assets(api_token, org_hint, kwargs) + return None diff --git a/automox/config.json b/automox/config.json deleted file mode 100644 index 25a50b3..0000000 --- a/automox/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Automox", "type": "inbound" } diff --git a/automox/custom-integration-automox.star b/automox/custom-integration-automox.star deleted file mode 100644 index e4d646a..0000000 --- a/automox/custom-integration-automox.star +++ /dev/null @@ -1,264 +0,0 @@ -## Automox! - -load('runzero.types', 'ImportAsset', 'NetworkInterface', 'Software') -load('json', json_decode='decode') -load('net', 'ip_address') -load('http', http_get='get') -load('uuid', 'new_uuid') - -AUTOMOX_BASE_URL = "https://console.automox.com/api" -AUTOMOX_SERVERS_URL = AUTOMOX_BASE_URL + "/servers" -AUTOMOX_ORGS_URL = AUTOMOX_BASE_URL + "/orgs" - -def looks_numeric(v): - if v == None: - return False - s = str(v) - if s == "": - return False - return s.isdigit() - -def normalize_list(decoded): - if decoded == None: - return [] - t = type(decoded) - if t == "list": - return decoded - if t == "dict": - if "data" in decoded and type(decoded["data"]) == "list": - return decoded["data"] - if "results" in decoded and type(decoded["results"]) == "list": - return decoded["results"] - if "items" in decoded and type(decoded["items"]) == "list": - return decoded["items"] - if "records" in decoded and type(decoded["records"]) == "list": - return decoded["records"] - fail("Unexpected dict response (no data/results/items/records list field).") - fail("Unexpected response type: " + t) - -def get_automox_devices(headers, org_hint): - devices = [] - page = 0 - limit = 500 - use_o = looks_numeric(org_hint) - - while True: - params = {"limit": str(limit), "page": str(page), "include_details": "1"} - if use_o: - params["o"] = str(org_hint) - - resp = http_get(AUTOMOX_SERVERS_URL, headers=headers, params=params) - - if resp.status_code == 404 and use_o: - use_o = False - devices = [] - page = 0 - continue - - if resp.status_code != 200: - fail("Failed to fetch devices from Automox: " + str(resp.status_code)) - - batch = normalize_list(json_decode(resp.body)) - if not batch: - break - - for d in batch: - devices.append(d) - - page = page + 1 - - return devices - -def get_orgs(headers): - orgs = [] - page = 0 - limit = 500 - - while True: - params = {"limit": str(limit), "page": str(page)} - resp = http_get(AUTOMOX_ORGS_URL, headers=headers, params=params) - - if resp.status_code != 200: - fail("Failed to fetch orgs from Automox: " + str(resp.status_code)) - - batch = normalize_list(json_decode(resp.body)) - if not batch: - break - - for o in batch: - orgs.append(o) - - page = page + 1 - - return orgs - -def choose_org_id(headers, org_hint): - if looks_numeric(org_hint): - return str(org_hint) - - orgs = get_orgs(headers) - if not orgs: - fail("No organizations returned from Automox; cannot determine org_id.") - - oid = orgs[0].get("id", None) - if oid == None: - fail("Automox /orgs response missing 'id'.") - return str(oid) - -def fetch_org_packages(headers, org_id): - url = AUTOMOX_BASE_URL + "/orgs/" + str(org_id) + "/packages" - packages = [] - page = 0 - limit = 500 - - while True: - params = {"limit": str(limit), "page": str(page), "o": str(org_id)} - resp = http_get(url, headers=headers, params=params) - - if resp.status_code != 200: - fail("Failed to fetch org packages from Automox: " + str(resp.status_code)) - - batch = normalize_list(json_decode(resp.body)) - if not batch: - break - - for p in batch: - packages.append(p) - - page = page + 1 - - return packages - -def index_software_by_server(packages): - by_server = {} - - for soft in packages: - sid = soft.get("server_id", None) - if sid == None: - continue - - sw = Software( - id=str(soft.get("id", "")), - installedFrom=str(soft.get("repo", "")), - product=str(soft.get("display_name", "")), - version=str(soft.get("version", "")), - customAttributes={ - "server_id": str(soft.get("server_id", "")), - "package_id": str(soft.get("package_id", "")), - "software_id": str(soft.get("software_id", "")), - "installed": str(soft.get("installed", "")), - "ignored": str(soft.get("ignored", "")), - "group_ignored": str(soft.get("group_ignored", "")), - "deferred_until": str(soft.get("deferred_until", "")), - "group_deferred_until": str(soft.get("group_deferred_until", "")), - "name": str(soft.get("name", "")), - "cves": str(soft.get("cves", "")), - "cve_score": str(soft.get("cve_score", "")), - "agent_severity": str(soft.get("agent_severity", "")), - "severity": str(soft.get("severity", "")), - "package_version_id": str(soft.get("package_version_id", "")), - "os_name": str(soft.get("os_name", "")), - "os_version": str(soft.get("os_version", "")), - "os_version_id": str(soft.get("os_version_id", "")), - "create_time": str(soft.get("create_time", "")), - "requires_reboot": str(soft.get("requires_reboot", "")), - "patch_classification_category_id": str(soft.get("patch_classification_category_id", "")), - "patch_scope": str(soft.get("patch_scope", "")), - "is_uninstallable": str(soft.get("is_uninstallable", "")), - "secondary_id": str(soft.get("secondary_id", "")), - "is_managed": str(soft.get("is_managed", "")), - "impact": str(soft.get("impact", "")), - "organization_id": str(soft.get("organization_id", "")), - }, - ) - - key = str(sid) - if key not in by_server: - by_server[key] = [] - by_server[key].append(sw) - - return by_server - -def build_network_interface(ips, mac=None): - ip4s = [] - ip6s = [] - - for ip in ips[:99]: - if not ip: - continue - addr = ip_address(ip) - if addr.version == 4: - ip4s.append(addr) - elif addr.version == 6: - ip6s.append(addr) - - return NetworkInterface(macAddress=mac, ipv4Addresses=ip4s, ipv6Addresses=ip6s) - -def build_network_interfaces_from_device(device): - details = device.get("details", device.get("detail", {})) - if type(details) == "dict": - nics = details.get("NICS", None) - if type(nics) == "list" and nics: - out = [] - for nic in nics[:99]: - mac = nic.get("MAC", "") - ips = nic.get("IPS", []) - out.append(build_network_interface(ips, mac)) - if out: - return out - - ips = device.get("ip_addrs", []) + device.get("ip_addrs_private", []) - return [build_network_interface(ips, "")] - -def build_assets(api_token, org_hint): - headers = {"Authorization": "Bearer " + api_token, "Content-Type": "application/json"} - - devices = get_automox_devices(headers, org_hint) - org_id = choose_org_id(headers, org_hint) - - packages = fetch_org_packages(headers, org_id) - sw_by_server = index_software_by_server(packages) - - assets = [] - for device in devices: - device_id = device.get("id", new_uuid()) - - custom_attrs = { - "os_version": device.get("os_version", ""), - "os_name": device.get("os_name", ""), - "os_family": device.get("os_family", ""), - "agent_version": device.get("agent_version", ""), - "compliant": str(device.get("compliant", "")), - "last_logged_in_user": device.get("last_logged_in_user", ""), - "serial_number": device.get("serial_number", ""), - "agent_status": device.get("status", {}).get("agent_status", ""), - } - - assets.append( - ImportAsset( - id=str(device_id), - networkInterfaces=build_network_interfaces_from_device(device), - hostnames=[device.get("name", "")], - os_version=device.get("os_version", ""), - os=device.get("os_family", "") + " " + device.get("os_name", ""), - software=sw_by_server.get(str(device_id), []), - customAttributes=custom_attrs, - trust_device_type=True, - trust_os=True, - trust_os_version=True - ) - ) - - return assets - -def main(**kwargs): - org_hint = kwargs.get("access_key", None) - api_token = kwargs.get("access_secret", None) - - if not api_token: - fail("Missing access_secret (Automox API token).") - - assets = build_assets(api_token, org_hint) - if not assets: - return None - return assets diff --git a/bitsight/README.md b/bitsight/README.md index a82b506..1fcb0cb 100644 --- a/bitsight/README.md +++ b/bitsight/README.md @@ -33,7 +33,7 @@ git clone https://github.com/runZeroInc/runzero-custom-integrations.git 1. Determine the proper Bitsight URL: - Assign the URL to `BITSIGHT_BASE_URL` within the starlark script. This field is already populated with the commonly used URL. 2. Create an API token for API access (Company or User token): - - Copy the API token to the the value for `access_secret` when creating the Custom Integration credentials in the runZero console (see below) + - Copy the API token to the value for `api_token` when creating the Custom Integration credentials in the runZero console (see below) ### runZero configuration @@ -43,9 +43,9 @@ git clone https://github.com/runZeroInc/runzero-custom-integrations.git - Modify datapoints uploaded to runZero as needed 2. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials) - Select the type `Custom Integration Script Secrets` - - Both `access_key` and `access_secret` are required, though `access_key` is not used in the starlark integration script - - `access_key` can be any string value (e.g. foo) as it is not required in the starlark script but the field does need to be populated in the runZero console - - `access_secret` corresponds to the Company or User API token created in Bitsight + - Both `company_id` and `api_token` are required + - `company_id` corresponds to the Bitsight company ID + - `api_token` corresponds to the Company or User API token created in Bitsight 3. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new) - Add a Name and Icon - Toggle `Enable custom integration script` to input your finalized script diff --git a/bitsight/custom-integration-bitsight.star b/bitsight/bitsight.star similarity index 79% rename from bitsight/custom-integration-bitsight.star rename to bitsight/bitsight.star index f335c68..f8cdf09 100644 --- a/bitsight/custom-integration-bitsight.star +++ b/bitsight/bitsight.star @@ -1,8 +1,35 @@ +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-bitsight", + "name": "Bitsight", + "type": "inbound", + "description": "Imports company assets from Bitsight.", + "version": "26052700", + "params": [ + { + "key": "company_id", + "label": "Company ID", + "type": "string", + "required": True, + }, + { + "key": "api_token", + "label": "API token", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} load('base64', base64_encode='encode', base64_decode='decode') -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', 'NetworkInterface', 'Vulnerability') +load('http', 'get_json', 'basic') +load('kwargs', 'get_http_options') +load('net', 'network_interface') +load('runzero.types', 'ImportAsset', 'Vulnerability', 'to_custom_attributes') load('time', 'parse_time') load('uuid', 'new_uuid') @@ -10,7 +37,7 @@ load('uuid', 'new_uuid') BITSIGHT_BASE_URL = 'https://api.bitsighttech.com' RUNZERO_REDIRECT = 'https://console.runzero.com/' -def build_assets(assets, company_id, creds): +def build_assets(assets, company_id, http_options): assets_import = [] for asset in assets: asset_id = str(asset.get('temporary_id', new_uuid)) @@ -93,18 +120,18 @@ def build_assets(assets, company_id, creds): vulns = [] if ip_addresses: for address in ip_addresses: - findings = get_findings(address, company_id, creds) + findings = get_findings(address, company_id, http_options) for finding in findings: vuln = build_vuln(finding) vulns.append(vuln) elif not ip_addresses and asset_name: - findings = get_findings(asset_name, company_id, creds) + findings = get_findings(asset_name, company_id, http_options) for finding in findings: vuln = build_vuln(finding) vulns.append(vuln) # create the network interfaces - interface = build_network_interface(ips=ip_addresses, mac=None) + interface = network_interface(ips=ip_addresses, mac=None) # Build assets for import assets_import.append( @@ -113,28 +140,13 @@ def build_assets(assets, company_id, creds): hostnames=[asset_name], tags=tags, networkInterfaces=[interface], - customAttributes=custom_attributes, - vulnerabilities=vulns + customAttributes=to_custom_attributes(custom_attributes), + vulnerabilities=vulns, + matchBehavior="no-id-match no-id-break", ) ) 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 build_vuln(vuln): details = vuln.get('details') or {} observed_ips = details.get('observed_ips') or [] @@ -211,66 +223,59 @@ def build_vuln(vuln): riskRank=risk_rank, severityScore=severity_score, solution=solution, - customAttributes=custom_attributes + customAttributes=to_custom_attributes(custom_attributes) ) -def get_assets(company_id, creds): +def get_assets(company_id, http_options): assets_all = [] total_count = 10000 url = BITSIGHT_BASE_URL + '/ratings/v1/companies/' + company_id + '/assets?' - headers = {'Accept': 'application/json', - 'Authorization': 'Basic ' + creds} params = {'is_ip': 'true'} # The default operation is to return only IP-based assets (i.e. filter out domains and CIDRs) comment the above params variable and uncomment the following params variable if you wish to import all asset types. # params = {} while len(assets_all) < total_count - 1: - response = http_get(url, headers=headers, params=params) - if response.status_code != 200: - print('failed to retrieve assets', 'status code: ' + str(response.status_code)) + data, err = get_json(url, params=params, **http_options) + if err: + print('failed to retrieve assets:', err) break - else: - data = json_decode(response.body) - url = data.get('links', {}).get('next', '') - total_count = data.get('count', 1) - assets = data.get('results', []) - assets_all.extend(assets) + if not data: + break + url = data.get('links', {}).get('next', '') + total_count = data.get('count', 1) + assets_all.extend(data.get('results', [])) return assets_all -def get_findings(asset, company_id, creds): +def get_findings(asset, company_id, http_options): vulns_all = [] vulns_count = 10000 url = BITSIGHT_BASE_URL + '/ratings/v1/companies/' + company_id + '/findings?' - headers = {'Accept': 'application/json', - 'Authorization': 'Basic ' + creds} params = {'assets.asset': asset} while len(vulns_all) < vulns_count - 1: - response = http_get(url, headers=headers, params=params) - if response.status_code != 200: - print('failed to retrieve findings', 'status code: ' + str(response.status_code)) + data, err = get_json(url, params=params, **http_options) + if err: + print('failed to retrieve findings:', err) + break + if not data: break - else: - data = json_decode(response.body) - url = data.get('links', {}).get('next', '') - vulns_count = data.get('count', 1) - findings = data.get('results', []) - vulns_all.extend(findings) + url = data.get('links', {}).get('next', '') + vulns_count = data.get('count', 1) + vulns_all.extend(data.get('results', [])) return vulns_all def main(*args, **kwargs): - company_id = kwargs['access_key'] - token = kwargs['access_secret'] + company_id = kwargs['company_id'] + token = kwargs['api_token'] b64_creds = base64_encode(token + ':') - assets = get_assets(company_id, b64_creds) + http_options = get_http_options(kwargs, headers={'Accept': 'application/json', 'Authorization': 'Basic ' + b64_creds}) + assets = get_assets(company_id, http_options) - # Format asset list for import into runZero - import_assets = build_assets(assets, company_id, b64_creds) - if not import_assets: + # Build and stream asset import via report_assets instead of returning a list + if not report_assets(build_assets(assets, company_id, http_options)): print('no assets') - return None - return import_assets \ No newline at end of file + return None \ No newline at end of file diff --git a/bitsight/config.json b/bitsight/config.json deleted file mode 100644 index 3679513..0000000 --- a/bitsight/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Bitsight", "type": "inbound" } \ No newline at end of file diff --git a/boilerplate/README.md b/boilerplate/README.md index 68ba4af..d880bac 100644 --- a/boilerplate/README.md +++ b/boilerplate/README.md @@ -24,8 +24,7 @@ - Modify datapoints uploaded to runZero as needed 2. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials) - Select the type `Custom Integration Script Secrets` - - Both `access_key` and `access_secret` are required, but not all scripts will use both - - Input a placeholde value like `foo` if the value is unused + - Both `client_id` and `client_secret` are required in this template; real integrations should use credential field names that match their API. 3. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new) - Add a Name and Icon - Toggle `Enable custom integration script` to input your finalized script diff --git a/boilerplate/boilerplate.star b/boilerplate/boilerplate.star new file mode 100644 index 0000000..0312bce --- /dev/null +++ b/boilerplate/boilerplate.star @@ -0,0 +1,334 @@ +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-boilerplate", + "name": "Product Name", + "type": "inbound", + "description": "Replace with your integration description.", + "version": "26052700", + "params": [ + { + "key": "client_id", + "label": "Client ID", + "type": "string", + "required": True, + "description": "Client ID, username, or organization ID", + }, + { + "key": "client_secret", + "label": "Client secret", + "type": "secret", + "required": True, + "description": "Client secret, password, or API token", + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +# This script demonstrates how to import and use the runZero custom Starlark +# libraries. The most commonly used libraries are: +# +# 1. runzero.types (ImportAsset, NetworkInterface, Service, +# ServiceProtocolData, Software, Vulnerability, +# to_custom_attributes) +# 2. kwargs (require, has, get_string, get_bool, get_int, get_list, +# get_http_options) +# 3. json (json_encode="encode", json_decode="decode") +# 4. net (ip_address, network_interface, normalize_mac, resolve) +# 5. http (http_post="post", http_get="get", get_json, post_json, +# url_encode, bearer, basic, oauth2_token) +# 6. uuid (new_uuid) +# 7. time (now, parse_time, parse_duration) +# 8. re (find_all, sub) +# 9. csv (read_all) +# 10. runzero.progress (report, info) +# +# Additional modules not shown here — requests, xml, jsonstream, jwt, +# crypto, base64/hex/base32, gzip, and the low-level socket / runzero.ssh / +# runzero.smb / runzero.winrm / runzero.wmi / runzero.sql modules — are +# documented in docs/starlark-helpers.md. +# +# The kwargs passed to main() are declared in the CONFIG block at the top of +# this file. The runZero UI builds its credential form from those param +# definitions, and the platform routes any param marked "type":"secret" +# through encrypted storage. + +load("runzero.types", "ImportAsset", "NetworkInterface", "Service", "ServiceProtocolData", "Software", "Vulnerability", "to_custom_attributes") +load("json", json_encode="encode", json_decode="decode") +load("net", "ip_address", "network_interface", "normalize_mac", "resolve") +load("http", http_post="post", http_get="get", "get_json", "post_json", "url_encode", "bearer", "basic", "oauth2_token") +load("uuid", "new_uuid") +load("time", "now", "parse_time", "parse_duration") +load("re", re_find_all="find_all", re_sub="sub") +load("csv", csv_read="read_all") +load("runzero.progress", progress_report="report", progress_info="info") +load("kwargs", "require", "get_string", "get_bool", "get_int", "get_list", "has", "get_http_options") + + +# ------------------------- +# runzero.types (3 examples) +# ------------------------- + +def create_asset_example(): + """ + Demonstrates how to create an ImportAsset object, which is used + to represent a device or endpoint for ingestion into runZero. + + The `network_interface()` helper accepts a MAC in any common form + (colon, dash, Cisco-dotted, bare-hex) and a mixed list of IPv4 + and IPv6 strings. It classifies them automatically, strips + "addr:port" and "%zone" suffixes, dedupes, and caps at 99 per + family. It returns None when nothing usable is present. + + Returns: + ImportAsset: a populated ImportAsset object + """ + netif = network_interface( + mac="AA:BB:CC:DD:EE:FF", + ips=["192.168.1.10", "fe80::1%eth0", "[2001:db8::1]:443"], + ) + return ImportAsset( + id="asset-12345", + networkInterfaces=[netif], + hostnames=["sample-device"], + os="ExampleOS", + osVersion="1.0", + # match_behavior tells the runZero cruncher how aggressively + # to merge this asset with existing records. The default is + # full matching on id+mac+ip+name. When your source provides + # a stable foreign id (vendor-assigned permanent device id, + # serial number, etc.) the recommended setting is: + # "no-mac-break no-ip-break no-name-break" + # which keeps id-based merging but stops other dimensions + # from disqualifying a merge. When your source only emits + # per-run / ephemeral ids, prefer: + # "no-id-match no-id-break" + # so the platform falls back to MAC/IP/name matching. + ) + +def create_software_example(): + """ + Demonstrates how to create a Software object, which can be attached + to an ImportAsset for software inventory tracking. + + Returns: + Software: a populated Software object + """ + return Software( + id="software-456", + vendor="ExampleVendor", + product="ExampleProduct", + version="v2.1.3", + serviceAddress="127.0.0.1" + ) + +def create_vulnerability_example(): + """ + Demonstrates how to create a Vulnerability object, which can be attached + to an ImportAsset for vulnerability information tracking. + + Returns: + Vulnerability: a populated Vulnerability object + """ + return Vulnerability( + id="vuln-789", + name="CVE-1234-5678", + description="Example vulnerability", + cve="CVE-1234-5678", + solution="Update to the latest patch", + severityRank=4, # 0=Info, 1=Low, 2=Med, 3=High, 4=Critical + severityScore=10.0, + riskRank=4, + riskScore=10.0 + ) + +def create_custom_attrs_example(): + """ + Demonstrates `to_custom_attributes(...)`, which flattens an + arbitrary value into the string->string shape required by + `ImportAsset.customAttributes`. The helper: + + - Flattens nested dicts using a configurable separator (default ".") + - Joins lists with a configurable separator (default ",") + - Stringifies bool/int/float values + - Drops empty strings / None by default + - Truncates keys/values and caps the total entry count + """ + raw = { + "name": "host1", + "active": True, + "sys": {"os": "linux", "ver": {"major": 5, "minor": 15}}, + "tags": ["a", "b", "c"], + "empty": "", # dropped by default + } + return to_custom_attributes(raw, exclude=["empty"]) + + +# --------------- +# json library +# --------------- +def example_json_usage(): + """ + Demonstrates how to use the json library for encoding and decoding. + """ + sample_data = {"key": "value", "numbers": [1, 2, 3]} + encoded = json_encode(sample_data) + print("JSON-encoded data:", encoded) + decoded = json_decode(encoded) + print("JSON-decoded data:", decoded) + return decoded + + +# -------------- +# net library +# -------------- +def example_ip_usage(): + """ + Parse and classify IP addresses. + """ + print("IPv4 version:", ip_address("192.168.10.55").version) + # normalize_mac accepts any common form; returns lowercase + # colon-separated form, or None for unparseable input. + print("MAC:", normalize_mac("AABB.CCDD.EEFF")) + # resolve returns a list of IPAddress values (empty list, never an + # error, on failure) so it is safe to iterate directly. + for addr in resolve("localhost"): + print("resolved:", addr, "v", addr.version) + + +# -------------- +# time library +# -------------- +def example_time_usage(): + """ + Parse timestamps and durations; do arithmetic with them. + """ + t = parse_time("2023-10-27T10:00:00Z") + print("unix:", t.unix, "year:", t.year) + window = parse_duration("24h") + print("cutoff:", (now() - window).unix) + + +# -------------- +# re library +# -------------- +def example_re_usage(text): + """ + Extract and rewrite text with Go RE2 regular expressions. + """ + ids = re_find_all(r"id=(\d+)", text) + collapsed = re_sub(r"\s+", " ", text) + return ids, collapsed + + +# -------------- +# csv library +# -------------- +def example_csv_usage(csv_text): + """ + Parse a CSV payload into a list of dicts keyed by the header row. + """ + return csv_read(csv_text) + + +# --------------- +# http library +# --------------- +def example_http_usage(config_kwargs): + """ + Build common request shapes: + - GET / POST with auto-decoded JSON (recommended for typical APIs) + - GET with params (raw response when you need headers/cookies) + - POST with `json=` (auto-encodes + sets Content-Type) + - OAuth2 client_credentials token exchange in one call + - Bearer / Basic auth header builders + """ + headers = {"Authorization": bearer("my-token")} + http_options = get_http_options(config_kwargs, headers=headers) + + # get_json / post_json: decode the JSON body for you, retry on + # transient failures (408, 425, 429, 5xx) with exponential backoff + # + Retry-After honoring, and return (data, err) instead of an + # http response. `err` is None on success or a short string on + # failure ("status 401: ", "transport: ..."). + # + # data, err = get_json("https://example.com/api/devices", + # params={"limit": 100}, + # **http_options) + # if err: + # print("device fetch failed:", err) + # return [] + # + # data, err = post_json("https://example.com/api/search", + # json={"q": "alive:t"}, + # **http_options) + + # GET with query params (raw response when you need headers/cookies) + # response = http_get("https://example.com/api", + # params={"search": "alive:t", "limit": 10}, + # **http_options) + + # POST with auto-JSON body + # response = http_post("https://example.com/api", + # json={"name": "runZero"}, + # **http_options) + + # OAuth2 client_credentials (returns access_token string; raises + # on non-2xx or missing access_token). + # token = oauth2_token( + # token_url="https://idp.example.com/oauth/token", + # client_id="cid", + # client_secret="csec", + # scope="read", + # **get_http_options(config_kwargs), + # ) + + # Basic auth (handles base64 encoding internally) + # headers = {"Authorization": basic("user", "pass")} + return http_options + + +# --------------- +# uuid library +# --------------- +def example_uuid_usage(): + """Generate a unique ID.""" + unique_id = new_uuid() + print("Generated UUID:", unique_id) + return unique_id + + +# ------------- +# main function +# ------------- +def main(*args, **kwargs): + """ + User-configured fields declared in the embedded ``CONFIG['params']`` + block are delivered through ``**kwargs``. Use the ``kwargs`` helper + module to validate and coerce them safely: + + require(kwargs, "client_id", "client_secret") + client_id = get_string(kwargs, "client_id") + client_secret = get_string(kwargs, "client_secret") + + Optional fields use the typed accessors with defaults: + + page_size = get_int(kwargs, "page_size", default=100) + include_offline = get_bool(kwargs, "include_offline", default=False) + regions = get_list(kwargs, "regions", default=[]) + + Credential fields declared in CONFIG are available in ``kwargs``. + set on the credential continue to work without any CONFIG block. + """ + require(kwargs, "client_id", "client_secret") + client_id = get_string(kwargs, "client_id") + client_secret = get_string(kwargs, "client_secret") + + if has(kwargs, "region"): + print("region override:", get_string(kwargs, "region")) + + progress_report(0, "starting custom integration") + print("welcome to runZero custom integrations:", client_id) + progress_info("custom integration setup complete") diff --git a/boilerplate/config.json b/boilerplate/config.json deleted file mode 100644 index fb43558..0000000 --- a/boilerplate/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Product Name", "type": "inbound or outbound" } diff --git a/boilerplate/custom-integration-boilerplate.star b/boilerplate/custom-integration-boilerplate.star deleted file mode 100644 index 27dc6b2..0000000 --- a/boilerplate/custom-integration-boilerplate.star +++ /dev/null @@ -1,178 +0,0 @@ -# This script demonstrates how to import and use all of the runZero custom Starlark libraries. -# -# The libraries are: -# -# 1. runzero.types (ImportAsset, NetworkInterface, Software, Vulnerability) -# 2. json (json_encode="encode", json_decode="decode") -# 3. net (ip_address) -# 4. http (http_post="post", http_get="get", url_encode) -# 5. uuid (new_uuid) -# -# The main() function also shows how to use the Credentials stored in runZero with the **kwargs input. - -load("runzero.types", "ImportAsset", "NetworkInterface", "Software", "Vulnerability") -load("json", json_encode="encode", json_decode="decode") -load("net", "ip_address") -load("http", http_post="post", http_get="get", "url_encode") -load("uuid", "new_uuid") - - -# ------------------------- -# runzero.types (3 examples) -# ------------------------- - -def create_asset_example(): - """ - Demonstrates how to create an ImportAsset object, which is used - to represent a device or endpoint for ingestion into runZero. - - Returns: - ImportAsset: a populated ImportAsset object - """ - # Minimal example: single network interface, hostnames, OS, etc. - # Normally, you'll populate these from real data. - netif = NetworkInterface( - ipv4Addresses=["192.168.1.10"], - macAddress="AA:BB:CC:DD:EE:FF" - ) - return ImportAsset( - id="asset-12345", - networkInterfaces=[netif], - hostnames=["sample-device"], - os="ExampleOS", - osVersion="1.0" - ) - -def create_software_example(): - """ - Demonstrates how to create a Software object, which can be attached - to an ImportAsset for software inventory tracking. - - Returns: - Software: a populated Software object - """ - return Software( - id="software-456", - vendor="ExampleVendor", - product="ExampleProduct", - version="v2.1.3", - serviceAddress="127.0.0.1" - ) - -def create_vulnerability_example(): - """ - Demonstrates how to create a Vulnerability object, which can be attached - to an ImportAsset for vulnerability information tracking. - - Returns: - Vulnerability: a populated Vulnerability object - """ - return Vulnerability( - id="vuln-789", - name="CVE-1234-5678", - description="Example vulnerability", - cve="CVE-1234-5678", - solution="Update to the latest patch", - severityRank=4, # 0=Info, 1=Low, 2=Med, 3=High, 4=Critical - severityScore=10.0, - riskRank=4, - riskScore=10.0 - ) - - -# --------------- -# json library -# --------------- -def example_json_usage(): - """ - Demonstrates how to use the json library for encoding and decoding. - """ - sample_data = {"key": "value", "numbers": [1, 2, 3]} - # Encode Python/Starlark dict to JSON string - encoded = json_encode(sample_data) - print("JSON-encoded data:", encoded) - - # Decode back to a Starlark/Python object - decoded = json_decode(encoded) - print("JSON-decoded data:", decoded) - return decoded - - -# -------------- -# net library -# -------------- -def example_ip_usage(): - """ - Demonstrates how to parse an IP address using the net library. - Returns a net.ip_address object (either IPv4 or IPv6). - """ - addr_string = "192.168.10.55" - ip_obj = ip_address(addr_string) - print("IP version:", ip_obj.version) - print("Compressed representation:", ip_obj.compressed) - return ip_obj - - -# --------------- -# http library -# --------------- -def example_http_usage(): - """ - Demonstrates usage of the http library to construct a URL-encoded - parameter set, then perform a GET request. - """ - params = {"search": "alive:t", "limit": 10} - encoded_params = url_encode(params) - print("Encoded GET parameters:", encoded_params) - - # This is just a sample. If you hit a real URL, you'd typically do: - # response = get(url="https://example.com/api", params=params) - # or - # response = post(url="https://example.com/api", body=...) - # For this demo, we'll just return the encoded_params - return encoded_params - - -# --------------- -# uuid library -# --------------- -def example_uuid_usage(): - """ - Demonstrates how to use the uuid library to generate a unique ID. - """ - unique_id = new_uuid() - print("Generated UUID:", unique_id) - return unique_id - - -# ------------- -# main function -# ------------- -def main(*args, **kwargs): - """ - Main function that demonstrates capturing parameters from kwargs - and printing a simple welcome message. - - Example usage in runZero: - def main(*args, **kwargs): - client_id = kwargs['access_key'] - client_secret = kwargs['access_secret'] - # Do something with client_id, client_secret - """ - # For demonstration: - if "access_key" in kwargs: - client_id = kwargs["access_key"] - if "access_secret" in kwargs: - client_secret = kwargs["access_secret"] - - print("welcome to runZero custom integrations") - - # If needed, you can call any of the example functions here: - # decoded = example_json_usage() - # ip_obj = example_ip_usage() - # new_id = example_uuid_usage() - # params = example_http_usage() - # asset = create_asset_example() - # software = create_software_example() - # vuln = create_vulnerability_example() - # ... diff --git a/carbon-black/README.md b/carbon-black/README.md index 0b89463..c1471de 100644 --- a/carbon-black/README.md +++ b/carbon-black/README.md @@ -29,8 +29,8 @@ - Adjust which attributes are included in runZero. 2. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials). - Select the type `Custom Integration Script Secrets`. - - Use the `access_key` field for your **Carbon Black Org Key**. - - Use the `access_secret` field for your **Carbon Black API Key**. + - Use the `organization_key` field for your **Carbon Black Org Key**. + - Use the `api_key` field for your **Carbon Black API Key**. 3. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new). - Add a Name and Icon for the integration (e.g., "carbonblack"). - Toggle `Enable custom integration script` to input the finalized script. diff --git a/carbon-black/custom-integration-carbon-black.star b/carbon-black/carbon-black.star similarity index 59% rename from carbon-black/custom-integration-carbon-black.star rename to carbon-black/carbon-black.star index 7c22aa6..ff171a3 100644 --- a/carbon-black/custom-integration-carbon-black.star +++ b/carbon-black/carbon-black.star @@ -1,75 +1,101 @@ -load('runzero.types', 'ImportAsset', 'NetworkInterface', 'Vulnerability') -load('json', json_encode='encode', json_decode='decode') -load('net', 'ip_address') -load('http', http_post='post', http_get='get', 'url_encode') +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-carbon-black", + "name": "Carbon Black", + "type": "inbound", + "description": "Imports endpoints from VMware Carbon Black Cloud.", + "version": "26061000", + "params": [ + { + "key": "url", + "label": "Carbon Black base URL", + "type": "url", + "required": True, + "placeholder": "https://defense.conferdeploy.net", + }, + { + "key": "organization_key", + "label": "Organization key", + "type": "string", + "required": True, + }, + { + "key": "api_key", + "label": "API key", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +load('runzero.types', 'ImportAsset', 'Vulnerability', 'to_custom_attributes') +load('net', 'network_interface') +load('http', 'post_json') +load('kwargs', 'get_url_base', 'get_http_options') -CARBON_BLACK_HOST = "" # Example: https://defense.conferdeploy.net SCROLL_API_URL = "{}/appservices/v6/orgs/{}/devices/_scroll" VULNERABILITY_API_URL = "{}/vulnerability/assessment/api/v1/orgs/{}/devices/{}/vulnerabilities/_search?dataForExport=true" PAGE_SIZE = 1000 # Max devices per request VULN_PAGE_SIZE = 100 # Max vulnerabilities per API call MAX_VULNS = None # Set to None for all, or an integer for a limit (e.g., 50) -def get_devices(org_key, api_key): - """Retrieve all devices from Carbon Black Cloud API using _scroll for large datasets""" +def fetch_and_report_devices(base_url, org_key, api_key, config_kwargs): + """Scroll through all devices, building and reporting assets one page at a + time so the full device + vulnerability dataset is never held in memory.""" headers = { "X-Auth-Token": "{}/{}".format(api_key, org_key), "Content-Type": "application/json", } + http_options = get_http_options(config_kwargs, headers=headers) + + url = SCROLL_API_URL.format(base_url, org_key) + total = 0 - devices = [] - # Step 1: Start the scroll session payload = { "criteria": {}, "rows": PAGE_SIZE } - url = SCROLL_API_URL.format(CARBON_BLACK_HOST, org_key) - response = http_post(url, headers=headers, body=bytes(json_encode(payload))) - - if response.status_code != 200: - print("Failed to start scroll session. Status: {}".format(response.status_code)) - return devices - - response_json = json_decode(response.body) - batch = response_json.get("results", []) - scroll_id = response_json.get("scroll_id", None) - - if not batch: - print("No devices returned or missing scroll_id.") - return devices - - devices.extend(batch) - if not scroll_id: - return devices - - # Step 2: Continue fetching batches using scroll_id - while scroll_id: - payload = {"scroll_id": scroll_id} - response = http_post(url, headers=headers, body=bytes(json_encode(payload))) - - if response.status_code != 200: - print("Failed to retrieve next batch. Status: {}".format(response.status_code)) + while True: + response_json, err = post_json(url, json=payload, **http_options) + if err: + print("Failed to retrieve devices:", err) break - response_json = json_decode(response.body) + response_json = response_json or {} batch = response_json.get("results", []) scroll_id = response_json.get("scroll_id", None) if not batch: break # No more data to retrieve - devices.extend(batch) + # Build and report this page's assets immediately, then drop the + # references so memory usage stays bounded by a single page. + assets = build_assets(base_url, org_key, api_key, batch, config_kwargs) + if assets: + report_assets(assets) + total += len(assets) - return devices + if not scroll_id: + break -def get_device_vulnerabilities(org_key, api_key, device_id, MAX_VULNS): + # Step 2: Continue fetching batches using scroll_id + payload = {"scroll_id": scroll_id} + + return total + +def get_device_vulnerabilities(base_url, org_key, api_key, device_id, MAX_VULNS, config_kwargs): """Retrieve vulnerabilities for a specific device, with optional max limit""" headers = { "X-Auth-Token": "{}/{}".format(api_key, org_key), "Content-Type": "application/json", } + http_options = get_http_options(config_kwargs, headers=headers) vulnerabilities = [] start = 0 @@ -87,14 +113,14 @@ def get_device_vulnerabilities(org_key, api_key, device_id, MAX_VULNS): "sort": [{"field": "risk_meter_score", "order": "DESC"}] } - url = VULNERABILITY_API_URL.format(CARBON_BLACK_HOST, org_key, device_id) - response = http_post(url, headers=headers, body=bytes(json_encode(payload))) + url = VULNERABILITY_API_URL.format(base_url, org_key, device_id) + response_json, err = post_json(url, json=payload, **http_options) - if response.status_code != 200: - print("Failed to retrieve vulnerabilities for device:", device_id) + if err: + print("Failed to retrieve vulnerabilities for device:", device_id, err) return vulnerabilities - response_json = json_decode(response.body) + response_json = response_json or {} batch = response_json.get("results", []) if not batch: @@ -133,19 +159,19 @@ def build_vulnerabilities(vuln_data): severityScore=float(risk_meter_score), severityRank=risk_rank, solution=vuln_info.get("solution", ""), - customAttributes={ - "fixed_by": vuln_info.get("fixed_by", ""), - "created_at": vuln_info.get("created_at", ""), - "nvd_link": vuln_info.get("nvd_link", ""), - "cvss_score": vuln_info.get("cvss_score", ""), - "cvss_v3_score": vuln_info.get("cvss_v3_score", ""), - } + customAttributes=to_custom_attributes({ + "fixed_by": vuln_info.get("fixed_by"), + "created_at": vuln_info.get("created_at"), + "nvd_link": vuln_info.get("nvd_link"), + "cvss_score": vuln_info.get("cvss_score"), + "cvss_v3_score": vuln_info.get("cvss_v3_score"), + }) ) ) return vulnerabilities -def build_assets(org_key, api_key, devices): +def build_assets(base_url, org_key, api_key, devices, config_kwargs): """Convert Carbon Black devices into runZero assets with vulnerability data""" assets = [] @@ -158,11 +184,11 @@ def build_assets(org_key, api_key, devices): mac = device.get("mac_address", "") # Fetch vulnerabilities for the device - vuln_data = get_device_vulnerabilities(org_key, api_key, device_id, MAX_VULNS) + vuln_data = get_device_vulnerabilities(base_url, org_key, api_key, device_id, MAX_VULNS, config_kwargs) vulnerabilities = build_vulnerabilities(vuln_data) # Build network interfaces - network = build_network_interface(ips=[ip], mac=mac if mac else None) + network = network_interface(ips=[ip], mac=mac if mac else None) # Manually build customAttributes for compatibility custom_attrs = { @@ -190,41 +216,23 @@ def build_assets(org_key, api_key, devices): osVersion=os_version, networkInterfaces=[network], vulnerabilities=vulnerabilities, - customAttributes=custom_attrs + customAttributes=to_custom_attributes(custom_attrs), ) ) return assets -def build_network_interface(ips, mac): - """Build runZero network interfaces""" - ip4s = [] - ip6s = [] - - for ip in ips[:99]: - if ip: - ip_addr = ip_address(ip) - if ip_addr.version == 4: - ip4s.append(ip_addr) - elif ip_addr.version == 6: - ip6s.append(ip_addr) - - return NetworkInterface(macAddress=mac, ipv4Addresses=ip4s, ipv6Addresses=ip6s) - def main(**kwargs): """Main function for Carbon Black integration""" - org_key = kwargs['access_key'] - api_key = kwargs['access_secret'] + base_url = get_url_base(kwargs) + org_key = kwargs['organization_key'] + api_key = kwargs['api_key'] - devices = get_devices(org_key, api_key) - - if not devices: - print("No devices found.") - return None + # Devices are streamed to runZero page-by-page via report_assets() inside + # fetch_and_report_devices, so nothing is returned from main(). + total = fetch_and_report_devices(base_url, org_key, api_key, kwargs) - assets = build_assets(org_key, api_key, devices) - - if not assets: + if total == 0: print("No assets created.") - - return assets + + return None diff --git a/carbon-black/config.json b/carbon-black/config.json deleted file mode 100644 index e8f965a..0000000 --- a/carbon-black/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Carbon Black", "type": "inbound" } diff --git a/cisco-ise/README.md b/cisco-ise/README.md index eccabe7..30b6219 100644 --- a/cisco-ise/README.md +++ b/cisco-ise/README.md @@ -36,8 +36,7 @@ 2. **Create a Credential for the Custom Integration**: - Go to [runZero Credentials](https://console.runzero.com/credentials). - Select `Custom Integration Script Secrets`. - - Input the Base64-encoded string in the `access_secret` field. - - Use a placeholder like `foo` for `access_key` (unused). + - Input the Base64-encoded string in the `basic_auth_credential` field. 3. **Create the Custom Integration**: - Go to [runZero Custom Integrations](https://console.runzero.com/custom-integrations/new). diff --git a/cisco-ise/custom_integration_cisco-ise.star b/cisco-ise/cisco-ise.star similarity index 56% rename from cisco-ise/custom_integration_cisco-ise.star rename to cisco-ise/cisco-ise.star index e470d44..75137f6 100644 --- a/cisco-ise/custom_integration_cisco-ise.star +++ b/cisco-ise/cisco-ise.star @@ -1,11 +1,36 @@ -load('runzero.types', 'ImportAsset', 'NetworkInterface') -load('json', json_encode='encode', json_decode='decode') -load('net', 'ip_address') +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-cisco-ise", + "name": "Cisco ISE", + "type": "inbound", + "description": "Imports endpoints from Cisco Identity Services Engine.", + "version": "26052700", + "params": [ + { + "key": "url", + "label": "Cisco ISE URL", + "type": "url", + "required": True, + "placeholder": "https://ise.example.com", + }, + { + "key": "basic_auth_credential", + "label": "Base64 basic-auth credential", + "type": "secret", + "required": True, + "description": "Base64-encoded user:password for the Cisco ISE API", + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +load('runzero.types', 'ImportAsset', 'to_custom_attributes') +load('net', 'network_interface') load('http', http_get='get') - -# Constants -CISCO_ISE_HOST = "" -ENDPOINTS_API_URL = "{}/admin/API/mnt/Session/ActiveList".format(CISCO_ISE_HOST) +load('kwargs', 'get_url_base', 'get_http_options') def extract_sessions(xml): sessions = [] @@ -23,14 +48,14 @@ def extract_sessions(xml): sessions.append(session) return sessions -def get_endpoints(auth_b64): +def get_endpoints(endpoints_api_url, auth_b64, config_kwargs): """Retrieve all endpoints from Cisco ISE.""" headers = { "Accept": "application/xml", "Authorization": "Basic {}".format(auth_b64) } - response = http_get(ENDPOINTS_API_URL, headers=headers) + response = http_get(endpoints_api_url, **get_http_options(config_kwargs, headers=headers)) if response.status_code != 200: print("Failed to retrieve endpoints. Status: {}".format(response.status_code)) @@ -44,22 +69,6 @@ def get_endpoints(auth_b64): return sessions -def build_network_interface(ip, mac=None): - """Build a runZero NetworkInterface object.""" - ip4s, ip6s = [], [] - - if ip: - ip_addr = ip_address(ip) - if ip_addr.version == 4: - ip4s.append(ip_addr) - elif ip_addr.version == 6: - ip6s.append(ip_addr) - - if not ip4s and not ip6s and not mac: - return None - - return NetworkInterface(macAddress=mac, ipv4Addresses=ip4s, ipv6Addresses=ip6s) - def build_assets(sessions): """Convert Cisco ISE session data into runZero assets.""" assets = [] @@ -72,7 +81,7 @@ def build_assets(sessions): if not mac and not ip: continue - network = build_network_interface(ip=ip, mac=mac) + network = network_interface(ip=ip, mac=mac) custom_attrs = { "acct_session_id": session.get("acct_session_id"), @@ -84,9 +93,10 @@ def build_assets(sessions): assets.append( ImportAsset( id=session.get("audit_session_id"), - hostnames=[hostname] if hostname else [], - networkInterfaces=[network] if network else [], - customAttributes=custom_attrs + hostnames=[hostname], + networkInterfaces=[network], + customAttributes=to_custom_attributes(custom_attrs), + matchBehavior="no-id-match no-id-break", ) ) @@ -94,21 +104,22 @@ def build_assets(sessions): def main(*args, **kwargs): """Main function for Cisco ISE integration.""" - auth_b64 = kwargs.get('access_secret') + base_url = get_url_base(kwargs) + endpoints_api_url = base_url + "/admin/API/mnt/Session/ActiveList" + auth_b64 = kwargs.get('basic_auth_credential') if not auth_b64: print("Missing authentication credentials.") return [] - sessions = get_endpoints(auth_b64) + sessions = get_endpoints(endpoints_api_url, auth_b64, kwargs) if not sessions: print("No sessions found.") - return [] - - assets = build_assets(sessions) + return None - if not assets: + # Stream assets to runZero via report_assets instead of returning a list. + if not report_assets(build_assets(sessions)): print("No assets created.") - return assets + return None diff --git a/cisco-ise/config.json b/cisco-ise/config.json deleted file mode 100644 index 156443c..0000000 --- a/cisco-ise/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Cisco-ISE", "type": "inbound" } diff --git a/cortex-xdr/README.md b/cortex-xdr/README.md index d8ce3a4..d5c8895 100644 --- a/cortex-xdr/README.md +++ b/cortex-xdr/README.md @@ -30,8 +30,8 @@ 2. **Create a Credential for the Custom Integration**: - Go to [runZero Credentials](https://console.runzero.com/credentials). - Select `Custom Integration Script Secrets`. - - Enter your **Cortex XDR API Key** as `access_secret`. - - Enter your **Cortex XDR API Key ID** as `access_key`. + - Enter your **Cortex XDR API Key** as `api_key`. + - Enter your **Cortex XDR API Key ID** as `api_key_id`. 3. **Create the Custom Integration**: - Go to [runZero Custom Integrations](https://console.runzero.com/custom-integrations/new). - Add a **Name and Icon** for the integration (e.g., "cortex-xdr"). diff --git a/cortex-xdr/config.json b/cortex-xdr/config.json deleted file mode 100644 index 936c759..0000000 --- a/cortex-xdr/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Cortex XDR", "type": "inbound" } diff --git a/cortex-xdr/custom-integration-cortex-xdr.star b/cortex-xdr/cortex-xdr.star similarity index 55% rename from cortex-xdr/custom-integration-cortex-xdr.star rename to cortex-xdr/cortex-xdr.star index 17ee125..4157a88 100644 --- a/cortex-xdr/custom-integration-cortex-xdr.star +++ b/cortex-xdr/cortex-xdr.star @@ -1,14 +1,46 @@ +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-cortex-xdr", + "name": "Cortex XDR", + "type": "inbound", + "description": "Imports endpoints from Palo Alto Cortex XDR.", + "version": "26061000", + "params": [ + { + "key": "url", + "label": "Cortex XDR base URL", + "type": "url", + "required": True, + "placeholder": "https://api-.xdr.us.paloaltonetworks.com", + }, + { + "key": "api_key_id", + "label": "API key ID", + "type": "string", + "required": True, + }, + { + "key": "api_key", + "label": "API key", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} ## Cortex XDR integration -load('runzero.types', 'ImportAsset', 'NetworkInterface') -load('json', json_encode='encode', json_decode='decode') -load('net', 'ip_address') -load('http', http_post='post', http_get='get', 'url_encode') +load('runzero.types', 'ImportAsset', 'to_custom_attributes') +load('net', 'network_interface') +load('http', 'post_json') +load('kwargs', 'get_url_base', 'get_http_options') load('uuid', 'new_uuid') -CORTEX_API_URL = "/public_api/v1/" - -def do_cortex_api_call(api_key, api_key_id, api_call, post_data={}): +def do_cortex_api_call(base_url, api_key, api_key_id, api_call, post_data={}, config_kwargs={}): """Perform API request to Cortex XDR, handling authentication""" headers = { @@ -17,22 +49,24 @@ def do_cortex_api_call(api_key, api_key_id, api_call, post_data={}): "Content-Type": "application/json" } - response = http_post(CORTEX_API_URL + api_call, headers=headers, body=bytes(json_encode(post_data))) + data, err = post_json(base_url + "/public_api/v1/" + api_call, json=post_data, **get_http_options(config_kwargs, headers=headers)) - if response.status_code != 200: - print("API call failed:", response.status_code) + if err: + print("API call failed:", err) return None - return json_decode(response.body) + return data -def get_all_cortex_endpoints(api_key, api_key_id): - """Retrieve all Cortex XDR endpoints using pagination""" +def stream_endpoints(base_url, api_key, api_key_id, config_kwargs): + """Retrieve Cortex XDR endpoints using pagination, building and streaming + each page of assets via report_assets so the full endpoint set is never held + in memory. Returns the number of assets reported.""" cortex_filter = {"request_data": {"search_from": 0, "search_to": 100}} - all_endpoints = [] + reported = 0 page_size = 100 while True: - result = do_cortex_api_call(api_key, api_key_id, "endpoints/get_endpoint", cortex_filter) + result = do_cortex_api_call(base_url, api_key, api_key_id, "endpoints/get_endpoint", cortex_filter, config_kwargs) if not result or "reply" not in result: print("Error retrieving endpoints") @@ -43,7 +77,7 @@ def get_all_cortex_endpoints(api_key, api_key_id): fetched_endpoints = reply else: fetched_endpoints = reply.get("endpoints", []) - all_endpoints.extend(fetched_endpoints) + reported += report_assets(build_assets(fetched_endpoints)) if len(fetched_endpoints) < page_size: break # Stop when fewer than page_size results are returned @@ -51,12 +85,11 @@ def get_all_cortex_endpoints(api_key, api_key_id): cortex_filter["request_data"]["search_from"] += page_size cortex_filter["request_data"]["search_to"] += page_size - print("Loaded", len(all_endpoints), "endpoints") - return all_endpoints + print("Loaded", reported, "endpoints") + return reported -def build_assets(api_key, api_key_id): - """Convert Cortex XDR endpoint data into runZero asset format""" - all_endpoints = get_all_cortex_endpoints(api_key, api_key_id) +def build_assets(all_endpoints): + """Convert a page of Cortex XDR endpoint data into runZero asset format""" assets = [] for endpoint in all_endpoints: @@ -101,41 +134,25 @@ def build_assets(api_key, api_key_id): assets.append( ImportAsset( id=str(endpoint.get("agent_id", endpoint.get("endpoint_id", new_uuid()))), - networkInterfaces=[build_network_interface(endpoint.get("ip", []) + endpoint.get("ipv6", []), mac_address)], + networkInterfaces=[network_interface(ips=endpoint.get("ip", []) + endpoint.get("ipv6", []), mac=mac_address)], hostnames=[endpoint.get("host_name", endpoint.get("endpoint_name", ""))], os_version=endpoint.get("os_version", ""), os=endpoint.get("operating_system", ""), - customAttributes=custom_attrs + customAttributes=to_custom_attributes(custom_attrs), ) ) return assets -def build_network_interface(ips, mac=None): - """Convert IPs and MAC addresses into a NetworkInterface object""" - ip4s = [] - ip6s = [] - - for ip in ips[:99]: - if ip: - 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 - - return NetworkInterface(macAddress=mac, ipv4Addresses=ip4s, ipv6Addresses=ip6s) - def main(**kwargs): - """Main function to retrieve and return Cortex XDR asset data""" - api_key = kwargs['access_secret'] # Use API token from runZero credentials - api_key_id = kwargs['access_key'] # Use API key ID + """Main function to retrieve and stream Cortex XDR asset data""" + base_url = get_url_base(kwargs) + api_key = kwargs['api_key'] + api_key_id = kwargs['api_key_id'] - assets = build_assets(api_key, api_key_id) - - if not assets: + # Endpoints are streamed page-by-page via report_assets in stream_endpoints. + reported = stream_endpoints(base_url, api_key, api_key_id, kwargs) + + if not reported: print("No assets retrieved from Cortex XDR") - return None - return assets + return None diff --git a/cyberint/config.json b/cyberint/config.json deleted file mode 100644 index a5cc473..0000000 --- a/cyberint/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Cyberint", "type": "inbound" } diff --git a/cyberint/custom-integration-cyberint.star b/cyberint/cyberint.star similarity index 57% rename from cyberint/custom-integration-cyberint.star rename to cyberint/cyberint.star index 279e274..063c955 100644 --- a/cyberint/custom-integration-cyberint.star +++ b/cyberint/cyberint.star @@ -1,10 +1,38 @@ +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-cyberint", + "name": "Cyberint", + "type": "inbound", + "description": "Imports assets from Cyberint.", + "version": "26052700", + "params": [ + { + "key": "url", + "label": "Cyberint base URL", + "type": "url", + "required": True, + "placeholder": "https://.cyberint.io", + }, + { + "key": "access_token", + "label": "Access token (cookie)", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} load('requests', 'Session', 'Cookie') load('json', json_encode='encode', json_decode='decode') load('runzero.types', 'ImportAsset', 'Vulnerability') +load('kwargs', 'get_url_base', 'get_bool') load('uuid', 'new_uuid') -DOMAIN = "UPDATE_ME" -INSECURE_ALLOWED = True +INSECURE_ALLOWED = False def main(*args, **kwargs): """ @@ -13,20 +41,24 @@ def main(*args, **kwargs): """ # Cyberint credentials - access_token = kwargs.get('access_secret') # used as cookie auth + access_token = kwargs.get('access_token') # used as cookie auth + base_url = get_url_base(kwargs) # Cyberint API endpoint (tenant-specific, includes asset-configuration) - url = "https://{}.cyberint.io/alert/api/v1/alerts".format(DOMAIN) + url = base_url + "/alert/api/v1/alerts" # Setup session with cookie authentication - session = Session(insecure_skip_verify=INSECURE_ALLOWED) + insecure_allowed = get_bool(kwargs, 'tls_disable_validation', INSECURE_ALLOWED) + session = Session(insecure_skip_verify=insecure_allowed) session.headers.set('Accept', 'application/json') + if kwargs.get('http_user_agent'): + session.headers.set('User-Agent', kwargs.get('http_user_agent')) session.cookies.set(url, {"access_token": access_token}) related_assets = {} assets = [] - response = session.post(url, body=bytes(json_encode({})), timeout=300) + response = session.post(url, json={}, timeout=300) if response and response.status_code == 200: data = json_decode(response.body) @@ -57,7 +89,12 @@ def main(*args, **kwargs): assets.append(ImportAsset( id=domain.replace(".", "-"), hostnames=[domain], - vulnerabilities=vulns + vulnerabilities=vulns, )) - return assets + # Stream assets to runZero via report_assets instead of returning a list. + reported = report_assets(assets) + if not reported: + print("no assets") + + return None diff --git a/device42/README.md b/device42/README.md index 4d059fa..4313f2b 100644 --- a/device42/README.md +++ b/device42/README.md @@ -17,8 +17,8 @@ Device42 supports **Basic** or **Bearer** authentication. You must configure both fields in your runZero credential: -- `access_key`: must be either `basic` or `bearer`. -- `access_secret`: +- `auth_scheme`: must be either `basic` or `bearer`. +- `credential`: - For `basic`: a base64-encoded string of `username:password`. - For `bearer`: your raw API token. @@ -28,11 +28,11 @@ You must configure both fields in your runZero credential: echo -n 'myuser:mypassword' | base64 ``` -Use the output as your `access_secret`. Set `access_key` to `basic`. +Use the output as your `credential`. Set `auth_scheme` to `basic`. ### Example for Bearer Authentication -Set `access_key` to `bearer`, and `access_secret` to your API token string. +Set `auth_scheme` to `bearer`, and `credential` to your API token string. ## Steps @@ -41,8 +41,8 @@ Set `access_key` to `bearer`, and `access_secret` to your API token string. - Go to [runZero Credentials](https://console.runzero.com/credentials). - Select the type: **Custom Integration Script Secrets**. - Set: - - `access_key`: `basic` or `bearer` - - `access_secret`: base64-encoded `username:password` or API token + - `auth_scheme`: `basic` or `bearer` + - `credential`: base64-encoded `username:password` or API token ### 2. Create the Custom Integration diff --git a/device42/config.json b/device42/config.json deleted file mode 100644 index d6af8fd..0000000 --- a/device42/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Device42", "type": "inbound" } diff --git a/device42/custom-integration-device42.star b/device42/device42.star similarity index 66% rename from device42/custom-integration-device42.star rename to device42/device42.star index 0e864ac..7333c8e 100644 --- a/device42/custom-integration-device42.star +++ b/device42/device42.star @@ -1,27 +1,43 @@ -load('runzero.types', 'ImportAsset', 'NetworkInterface') -load('json', json_encode='encode', json_decode='decode') -load('net', 'ip_address') -load('http', http_get='get') +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-device42", + "name": "Device42", + "type": "inbound", + "description": "Imports configuration items from Device42.", + "version": "26061000", + "params": [ + { + "key": "auth_scheme", + "label": "Auth scheme", + "type": "enum", + "required": True, + "options": ["basic", "bearer"], + "default": "basic", + }, + { + "key": "credential", + "label": "Credential", + "type": "secret", + "required": True, + "description": "Base64 user:pass for basic, or bearer token", + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +load('runzero.types', 'ImportAsset', 'to_custom_attributes') +load('net', 'network_interface') +load('http', 'get_json') +load('kwargs', 'get_http_options') load('uuid', 'new_uuid') DEVICE42_HOST = 'swaggerdemo.device42.com' DEVICE42_ENDPOINT = '/api/1.0/devices/all/' PAGE_SIZE = 1000 -def build_network_interface(ips, mac): - ip4s, ip6s = [], [] - for ip in ips: - addr = ip_address(ip) - if addr.version == 4: - ip4s.append(addr) - elif addr.version == 6: - ip6s.append(addr) - return NetworkInterface( - macAddress=mac, - ipv4Addresses=ip4s, - ipv6Addresses=ip6s, - ) - def build_network_interfaces(mac_entries, ip_entries): interfaces = [] seen_macs = {} @@ -32,12 +48,12 @@ def build_network_interfaces(mac_entries, ip_entries): macaddr = ip_obj.get('macaddress') or ip_obj.get('mac_address') seen_macs[macaddr] = seen_macs.get(macaddr, []) seen_macs[macaddr].append(ip_str) - interfaces.append(build_network_interface([ip_str], macaddr)) + interfaces.append(network_interface(ips=[ip_str], mac=macaddr)) for m in mac_entries: mac_addr = m.get('mac') or m.get('mac_address') if mac_addr not in seen_macs: - interfaces.append(build_network_interface([], mac_addr)) + interfaces.append(network_interface(ips=[], mac=mac_addr)) return interfaces @@ -93,14 +109,14 @@ def build_assets(devices): deviceType=asset_device_type, tags=asset_tags, networkInterfaces=network_ifaces, - customAttributes=custom, + customAttributes=to_custom_attributes(custom), ) ) return assets def main(**kwargs): - auth_type = kwargs['access_key'].lower() - secret = kwargs['access_secret'] + auth_type = kwargs['auth_scheme'].lower() + secret = kwargs['credential'] if auth_type == 'basic': headers = { @@ -113,22 +129,21 @@ def main(**kwargs): 'Accept': 'application/json', } else: - print('Unsupported access_key (must be "basic" or "bearer")') + print('Unsupported auth_scheme (must be "basic" or "bearer")') return None + http_options = get_http_options(kwargs, headers=headers) offset = 0 - all_devices = [] + total = 0 while True: url = 'https://{}{}?format=json&limit={}&offset={}'.format( DEVICE42_HOST, DEVICE42_ENDPOINT, PAGE_SIZE, offset ) - resp = http_get(url, headers=headers) + body, err = get_json(url, **http_options) - if resp.status_code != 200: - print('Device42 API error:', resp.status_code, resp.body) + if err: + print('Device42 API error:', err) return None - - body = json_decode(resp.body) if body.get('code', 0) != 0: print('Device42 API logical error:', body.get('msg')) return None @@ -137,14 +152,17 @@ def main(**kwargs): if not page: break - all_devices.extend(page) + # Build and stream this page's assets, then let it be reclaimed before + # fetching the next page so memory stays bounded by a single page. + report_assets(build_assets(page)) + total += len(page) + if len(page) < PAGE_SIZE: break offset += PAGE_SIZE - if not all_devices: + if total == 0: print('No devices returned') - return None - return build_assets(all_devices) + return None diff --git a/digital-ocean/README.md b/digital-ocean/README.md index ccc7948..3f98abd 100644 --- a/digital-ocean/README.md +++ b/digital-ocean/README.md @@ -25,8 +25,7 @@ - Modify datapoints uploaded to runZero as needed. 2. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials). - Select the type `Custom Integration Script Secrets`. - - Use the `access_secret` field for your Digital Ocean API token. - - For `access_key`, input a placeholder value like `foo` (unused in this integration). + - Use the `api_token` field for your Digital Ocean API token. 3. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new). - Add a Name and Icon for the integration (e.g., "digital-ocean"). - Toggle `Enable custom integration script` to input the finalized script. diff --git a/digital-ocean/config.json b/digital-ocean/config.json deleted file mode 100644 index ec0ca83..0000000 --- a/digital-ocean/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Digital Ocean", "type": "inbound" } diff --git a/digital-ocean/custom-integration-digital-ocean.star b/digital-ocean/custom-integration-digital-ocean.star deleted file mode 100644 index 10e36a4..0000000 --- a/digital-ocean/custom-integration-digital-ocean.star +++ /dev/null @@ -1,141 +0,0 @@ -load('runzero.types', 'ImportAsset', 'NetworkInterface') -load('json', json_encode='encode', json_decode='decode') -load('net', 'ip_address') -load('http', http_post='post', http_get='get', 'url_encode') -load('uuid', 'new_uuid') - -DIGITAL_OCEAN_OAUTH_URL = 'https://cloud.digitalocean.com/v1/' -DIGITAL_OCEAN_API_URL = 'https://api.digitalocean.com/v2/' - -def build_assets(assets_json): - assets_import = [] - for item in assets_json: - id = item.get('id', new_uuid) - hostname = item.get('name', '') - memory = item.get('memory', '') - vcpus = item.get('vcpus', '') - disk = item.get('disk','') - locked = item.get('locked', '') - status = item.get('status', '') - created_at = item.get('created_at', '') - vpc_uuid = item.get('vpc_uuid', '') - size_slug = item.get('size_slug', '') - - # parse IP addresses - ipv4s = [] - ipv6s = [] - ips = [] - networks = item.get('networks', {}) - if networks: - ipv4s = networks.get('v4', []) - ipv6s = networks.get('v6', []) - - if ipv4s: - for v4 in ipv4s: - addr = v4.get('ip_address', '') - ips.append(addr) - - if ipv6s: - for v6 in ipv6s: - addr = v6.get('ip_address', '') - ips.append(addr) - - network = build_network_interface(ips=ips, mac=None) - - # parse image information - image = item.get('image', {}) - if image: - image_id = image.get('id', '') - image_name = image.get('name','') - image_distribution = image.get('distribution', '') - image_type = image.get('type', '') - image_public = image.get('public', '') - image_status = image.get('status', '') - - # parse region information - region = item.get('region', {}) - if region: - region_name = region.get('name', '') - region_features = region.get('features', '') - region_available = region.get('available', '') - - # parse tags - tags_rz = [] - tags_do = item.get('tags', []) - if tags_do: - for t in tags_do: - if ':' in t: - key, value = t.split(':', 1) - tags_rz.append(key + '=' + value) - else: - key = t - tags_rz.append(key) - - assets_import.append( - ImportAsset( - id=str(id), - hostnames=[hostname], - networkInterfaces=[network], - os=image_distribution, - customAttributes={ - "id":id, - "size_slug":size_slug, - "memory":memory, - "vcpus":vcpus, - "disk":disk, - "locked":locked, - "status":status, - "created_at":created_at, - "vpcUUID":vpc_uuid, - "image.id":image_id, - "image.name":image_name, - "image.distribution":image_distribution, - "image.type":image_type, - "image.public":image_public, - "image.status":image_status, - "region.name":region_name, - "region.features":region_features, - "region.available":region_available, - "tags":tags_rz - } - ) - ) - return assets_import - -# build runZero network interfaces; shouldn't need to touch this -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) - - return NetworkInterface(macAddress=mac, ipv4Addresses=ip4s, ipv6Addresses=ip6s) - -def main(**kwargs): - # kwargs!! - token = kwargs['access_secret'] - - # get assets - assets = [] - url = '{}/{}'.format(DIGITAL_OCEAN_API_URL, 'droplets') - assets = http_get(url, headers={"Content-Type": "application/json", "Authorization": "Bearer " + token}) - if assets.status_code != 200: - print('failed to retrieve assets' + assets) - return None - - assets_json = json_decode(assets.body)['droplets'] - - # build asset import - assets_import = build_assets(assets_json) - if not assets_import: - print('no assets') - - return assets_import diff --git a/digital-ocean/digital-ocean.star b/digital-ocean/digital-ocean.star new file mode 100644 index 0000000..d41ee3e --- /dev/null +++ b/digital-ocean/digital-ocean.star @@ -0,0 +1,107 @@ +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-digital-ocean", + "name": "Digital Ocean", + "type": "inbound", + "description": "Imports droplets from DigitalOcean.", + "version": "26052700", + "params": [ + { + "key": "api_token", + "label": "API token", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +load('runzero.types', 'ImportAsset', 'to_custom_attributes') +load('net', 'network_interface') +load('http', 'get_json', 'bearer') +load('kwargs', 'get_http_options') +load('uuid', 'new_uuid') + +DIGITAL_OCEAN_API_URL = 'https://api.digitalocean.com/v2/' + +def collect_ips(networks): + """Pull the v4 and v6 addresses out of a DigitalOcean `networks` block.""" + ips = [] + if not networks: + return ips + for v in networks.get('v4', []) or []: + ip = v.get('ip_address', '') + if ip: + ips.append(ip) + for v in networks.get('v6', []) or []: + ip = v.get('ip_address', '') + if ip: + ips.append(ip) + return ips + +def format_tags(tags): + """Convert DigitalOcean `key:value` tags to runZero `key=value` tags.""" + out = [] + for t in tags or []: + if ':' in t: + k, v = t.split(':', 1) + out.append(k + '=' + v) + else: + out.append(t) + return out + +def build_assets(assets_json): + assets = [] + for item in assets_json: + nic = network_interface(ips=collect_ips(item.get('networks', {}))) + nics = [nic] if nic else [] + + image = item.get('image', {}) or {} + region = item.get('region', {}) or {} + + assets.append(ImportAsset( + id=str(item.get('id') or new_uuid()), + hostnames=[item.get('name', '')], + networkInterfaces=nics, + os=image.get('distribution', ''), + tags=format_tags(item.get('tags')), + customAttributes=to_custom_attributes({ + "id": item.get('id'), + "size_slug": item.get('size_slug'), + "memory": item.get('memory'), + "vcpus": item.get('vcpus'), + "disk": item.get('disk'), + "locked": item.get('locked'), + "status": item.get('status'), + "created_at": item.get('created_at'), + "vpcUUID": item.get('vpc_uuid'), + "image.id": image.get('id'), + "image.name": image.get('name'), + "image.distribution": image.get('distribution'), + "image.type": image.get('type'), + "image.public": image.get('public'), + "image.status": image.get('status'), + "region.name": region.get('name'), + "region.available": region.get('available'), + }), + )) + return assets + +def main(**kwargs): + token = kwargs['api_token'] + headers = {"Authorization": bearer(token)} + http_options = get_http_options(kwargs, headers=headers) + + data, err = get_json(DIGITAL_OCEAN_API_URL + 'droplets', **http_options) + if err: + print('failed to retrieve droplets:', err) + return None + + droplets = (data or {}).get('droplets', []) + # Stream assets to runZero via report_assets instead of returning a list. + if not report_assets(build_assets(droplets)): + print('no assets') + return None diff --git a/docs/integrations.json b/docs/integrations.json index c7d3ec6..b65dba3 100644 --- a/docs/integrations.json +++ b/docs/integrations.json @@ -1,234 +1,264 @@ { - "lastUpdated": "2026-05-27T20:41:38.578027Z", - "totalIntegrations": 38, + "lastUpdated": "2026-06-17T19:10:20.774174Z", + "totalIntegrations": 43, "integrationDetails": [ { - "name": "Moysle", + "name": "Stairwell", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/moysle/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/moysle/custom-integration-moysle.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/stairwell/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/stairwell/stairwell.star" }, { - "name": "Automox", + "name": "pfSense", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/automox/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/automox/custom-integration-automox.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/pfsense/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/pfsense/pfsense.star" }, { - "name": "Netskope", + "name": "Snipe-IT", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/netskope/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/netskope/custom-integration-netskope.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/snipe-it/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/snipe-it/snipe-it.star" }, { - "name": "Device42", + "name": "Cisco ISE", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/device42/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/device42/custom-integration-device42.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/cisco-ise/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/cisco-ise/cisco-ise.star" }, { - "name": "Ghost Security", + "name": "Automox", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ghost/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ghost/custom-integration-ghost.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/automox/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/automox/automox.star" }, { - "name": "Cortex XDR", + "name": "Kubernetes", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/cortex-xdr/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/cortex-xdr/custom-integration-cortex-xdr.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/kubernetes/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/kubernetes/kubernetes.star" }, { - "name": "Bitsight", + "name": "runZero Task Sync", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/bitsight/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/bitsight/custom-integration-bitsight.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/runzero-task-sync/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/runzero-task-sync/task-sync.star" }, { - "name": "Carbon Black", + "name": "Tanium", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/carbon-black/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/carbon-black/custom-integration-carbon-black.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/tanium/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/tanium/tanium.star" }, { - "name": "Snipe-IT", + "name": "Audit Log to Webhook", + "type": "outbound", + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/audit-events-to-webhook/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/audit-events-to-webhook/audit-events.star" + }, + { + "name": "Cyberint", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/snipe-it/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/snipe-it/snipeit.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/cyberint/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/cyberint/cyberint.star" }, { - "name": "Vunerability Workflow", + "name": "Ghost Security", + "type": "inbound", + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ghost/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ghost/ghost.star" + }, + { + "name": "Scan Passive Assets", "type": "internal", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/vulnerability-workflow/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/vulnerability-workflow/custom-integration-vulnerability-workflow.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/runzero-scan-passive-assets/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/runzero-scan-passive-assets/scan-passive-assets.star" }, { - "name": "Tanium", + "name": "Akamai Guardicore Centra", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/tanium/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/tanium/custom-integration-tanium.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/akamai-guardicore-centra/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/akamai-guardicore-centra/centra-v4-api.star" }, { - "name": "Digital Ocean", + "name": "Sumo Logic", + "type": "outbound", + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/sumo-logic/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/sumo-logic/sumo.star" + }, + { + "name": "Nexthink", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/digital-ocean/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/digital-ocean/custom-integration-digital-ocean.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/nexthink/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/nexthink/nexthink.star" }, { - "name": "Ubiquiti Unifi Network", + "name": "Halcyon", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ubiquiti-unifi-network/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ubiquiti-unifi-network/custom-integration-ubiquiti-unifi-network.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/halycon/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/halycon/halycon.star" }, { - "name": "JAMF", + "name": "Ivanti Neurons", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/jamf/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/jamf/custom-integration-jamf.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ivanti_neurons/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ivanti_neurons/neurons.star" }, { "name": "Proxmox", "type": "inbound", "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/proxmox/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/proxmox/custom-integration-proxmox.star" + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/proxmox/proxmox.star" }, { - "name": "Cyberint", + "name": "NinjaOne", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/cyberint/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/cyberint/custom-integration-cyberint.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ninjaone/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ninjaone/ninjaone.star" }, { - "name": "Lima Charlie", + "name": "Wazuh", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/lima-charlie/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/lima-charlie/custom-integration-lima-charlie.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/wazuh/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/wazuh/wazuh.star" }, { - "name": "Nexthink", + "name": "Snow License Manager", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/nexthink/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/nexthink/custom-integration-nexthink.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/snow-license-manager/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/snow-license-manager/snow.star" }, { - "name": "Kandji", + "name": "Windows SMB shares", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/kandji/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/kandji/custom-integration-kandji.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/windows-smb-shares/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/windows-smb-shares/windows-smb-shares.star" }, { - "name": "Ivanti Neurons", + "name": "Carbon Black", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ivanti_neurons/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ivanti_neurons/custom-integration-neurons.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/carbon-black/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/carbon-black/carbon-black.star" }, { - "name": "Scale Computing", + "name": "Digital Ocean", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/scale-computing/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/scale-computing/custom-integration-scale-computing.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/digital-ocean/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/digital-ocean/digital-ocean.star" }, { - "name": "Extreme Networks CloudIQ", + "name": "Device42", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/extreme-cloud-iq/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/extreme-cloud-iq/custom-integrations-extreme-cloud-iq.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/device42/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/device42/device42.star" + }, + { + "name": "Kandji", + "type": "inbound", + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/kandji/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/kandji/kandji.star" + }, + { + "name": "SolarWinds Information Service", + "type": "inbound", + "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": "Drata", "type": "inbound", "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/drata/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/drata/custom-integration-drata.star" + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/drata/drata.star" }, { - "name": "Snow License Manager", + "name": "Bitsight", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/snow-license-manager/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/snow-license-manager/custom-integration-snow.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/bitsight/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/bitsight/bitsight.star" }, { - "name": "Stairwell", + "name": "Windows WMI", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/stairwell/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/stairwell/custom-integration-stairwell.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/windows-wmi/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/windows-wmi/windows-wmi.star" }, { - "name": "Akamai Guardicore Centra", + "name": "JAMF", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/akamai-guardicore-centra/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/akamai-guardicore-centra/custom-integration-centra-v4-api.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/jamf/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/jamf/jamf.star" }, { - "name": "Solarwinds Information Service", + "name": "Tailscale", "type": "inbound", - "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/custom-integration-swis.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/tailscale/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/tailscale/tailscale.star" }, { - "name": "Scan Passive Assets", - "type": "internal", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/scan-passive-assets/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/scan-passive-assets/custom-integration-scan-passive-assets.star" + "name": "Extreme Networks CloudIQ", + "type": "inbound", + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/extreme-cloud-iq/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/extreme-cloud-iq/extreme-cloud-iq.star" }, { - "name": "pfSense", + "name": "Mosyle", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/pfsense/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/pfsense/custom-integration-pfsense.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/moysle/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/moysle/moysle.star" }, { - "name": "Cisco-ISE", + "name": "Netskope", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/cisco-ise/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/cisco-ise/custom_integration_cisco-ise.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/netskope/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/netskope/netskope.star" }, { - "name": "Sumo Logic", - "type": "outbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/sumo-logic/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/sumo-logic/custom-integration-sumo.star" + "name": "Ubiquiti UniFi Network", + "type": "inbound", + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ubiquiti-unifi-network/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ubiquiti-unifi-network/ubiquiti-unifi-network.star" }, { - "name": "Wazuh", + "name": "LimaCharlie", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/wazuh/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/wazuh/custom-integration-wazuh.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/lima-charlie/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/lima-charlie/lima-charlie.star" }, { - "name": "Manage Engine Endpoint Central", + "name": "Microsoft SQL Server databases", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/manage-engine-endpoint-central/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/manage-engine-endpoint-central/custom-integration-endpoint-central.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/mssql-databases/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/mssql-databases/mssql-databases.star" }, { - "name": "Tailscale", + "name": "Cortex XDR", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/tailscale/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/tailscale/custom-integration-tailscale.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/cortex-xdr/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/cortex-xdr/cortex-xdr.star" }, { - "name": "Audit Log to Webhook", - "type": "outbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/audit-events-to-webhook/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/audit-events-to-webhook/custom-integration-audit-events.star" + "name": "Scale Computing", + "type": "inbound", + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/scale-computing/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/scale-computing/scale-computing.star" }, { - "name": "Halycon", + "name": "Linux via SSH", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/halycon/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/halycon/custom-integration-halycon.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/linux-ssh/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/linux-ssh/linux-ssh.star" }, { - "name": "runZero Task Sync", - "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/task-sync/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/task-sync/custom-integration-task-sync.star" + "name": "Vulnerability Workflow", + "type": "internal", + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/runzero-vulnerability-workflow/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/runzero-vulnerability-workflow/vulnerability-workflow.star" }, { - "name": "NinjaOne", + "name": "ManageEngine Endpoint Central", "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ninjaone/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ninjaone/custom-integration-ninjaone.star" + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/manage-engine-endpoint-central/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/manage-engine-endpoint-central/endpoint-central.star" } ] } \ No newline at end of file diff --git a/docs/starlark-helpers.md b/docs/starlark-helpers.md new file mode 100644 index 0000000..57efac8 --- /dev/null +++ b/docs/starlark-helpers.md @@ -0,0 +1,546 @@ +# Starlark helpers for runZero custom integrations + +This page is a quick reference for the runZero-provided Starlark +builtins available to integration scripts. Use them in place of +hand-rolled helpers whenever possible — they handle the awkward +edge cases (mixed v4/v6 IPs, zone IDs, port suffixes, base64 +encoding, retries, JSON decoding, length limits, ...) so your +scripts stay short and consistent. + +The full list of registered modules is loaded automatically when +your script runs; you only need a `load(...)` line for the names +you actually use. + +## Loading + +The full list of registered modules is loaded automatically; you only need a +`load(...)` line for the names you actually use. + +```python +load("runzero.types", "ImportAsset", "NetworkInterface", "Service", + "ServiceProtocolData", "Software", "Vulnerability", + "to_custom_attributes") +load("net", "ip_address", "network_interface", "normalize_mac", + "ip_network", "ip_in_network", "resolve") +load("http", http_get="get", http_post="post", + "head", "put", "patch", "delete", + "get_json", "post_json", + "url_encode", "url_parse", "url_join", "multipart", + "bearer", "basic", "oauth2_token") +load("kwargs", "require", "has", "get", "get_string", "get_bool", "get_int", + "get_float", "get_list", "get_url_base", "get_http_tls", + "get_http_options") +load("json", json_encode="encode", json_decode="decode") +load("jsonstream", "iter_array", "iter_lines") +load("csv", csv_read="read_all", csv_write="write_dicts") +load("xml", xml_parse="parse") +load("re", re_match="match", re_find_all="find_all", re_sub="sub") +load("uuid", "new_uuid") +load("time", "now", "parse_time", "parse_duration", "sleep") +load("requests", "Session", "Cookie") +load("base64", base64_encode="encode", base64_decode="decode") +load("hex", hex_encode="encode", hex_decode="decode") +load("crypto", "sha256", "hmac_sha256", "sign_v4", "random_hex") +load("jwt", jwt_encode="encode", jwt_decode="decode") +load("gzip", gzip_decompress="decompress", gzip_compress="compress") +load("flatten_json", "flatten") +load("runzero.progress", progress_report="report", progress_info="info") +``` + +Lower-level network modules — `socket`, `runzero.ssh`, `runzero.smb`, +`runzero.winrm`, `runzero.wmi`, and `runzero.sql` — are documented under +[Low-level network & database](#low-level-network--database) below. + +## Shared HTTP/TLS Options + +Use `CONFIG["includes"]` for common connection controls instead of copying +the same parameter blocks into every script. The include prefix becomes part +of each generated kwarg name. + +```python +CONFIG = { + "id": "runzero-example", + "name": "Example", + "type": "inbound", + "version": "26052700", + "params": [ + {"key": "url", "type": "url", "required": True}, + {"key": "api_token", "type": "secret", "required": True}, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +``` + +For one endpoint, pass the collected dict directly to the HTTP helper: + +```python +options = get_http_options(kwargs, "http_", "tls_", { + "Authorization": bearer(kwargs["api_token"]), +}) +data, err = get_json(url, **options) +``` + +For multiple endpoints, give each include a prefix and collect by that prefix: + +```python +src_options = get_http_options(kwargs, "src_http_", "src_tls_", src_headers) +dst_options = get_http_options(kwargs, "dst_http_", "dst_tls_", dst_headers) +``` + +Use `get_http_tls(kwargs, "src_tls_")` when a script only needs the `tls=` +dict and manages headers separately. + +## `http.get_json` / `http.post_json` + +Drop-in replacements for the common `GET then status-check then +json_decode` pattern. + +```python +data, err = get_json(url, headers=headers, params={"limit": 100}) +if err: + print("fetch failed:", err) + return [] +``` + +Behaviour: + +- Returns a `(data, err)` tuple. `err` is `None` on success or a + short string on failure (`"status 401: "`, + `"transport: ..."`). +- 2xx responses with empty bodies decode to `None`. +- Retries transient failures with exponential backoff (default + statuses: `408, 425, 429, 500, 502, 503, 504`). Pass + `retry_on=[418]` (or `None` to disable) to override. +- Honors `Retry-After` headers in seconds or HTTP-date form. +- All `http.get`/`http.post` kwargs are accepted + (`headers`, `params`, `timeout`, `insecure_skip_verify`, ...). +- `post_json` accepts either `json=` (auto-encodes and sets + `Content-Type: application/json`) or `body=`. + +Use the raw `http.get`/`http.post` builtins when you need the +response headers, cookies, or status code directly. + +## `http.bearer(token)` / `http.basic(user, pass)` + +Format auth headers without manual base64: + +```python +headers = { + "Authorization": bearer(api_key), + # or + "Authorization": basic(username, password), +} +``` + +## `http.oauth2_token(...)` + +Run a `client_credentials` token exchange and return the +`access_token` string. Raises on non-2xx or a missing +`access_token`. + +```python +token = oauth2_token( + token_url="https://idp.example.com/oauth/token", + client_id=client_id, + client_secret=client_secret, + scope="read", # optional + audience="my-api", # optional + extra={"resource": "..."}, # optional extra form fields +) +``` + +Authentication flows that aren't `client_credentials` +(username/password login endpoints, refresh tokens, custom flows) +should keep using `http.post` directly. + +## Other `http` helpers + +- `http.head(url, headers=None, params=None, timeout=30, tls=None)` and + `http.put(url, ..., body=b"", json=None)` round out the verb set + alongside `get`/`post`/`patch`/`delete`. Every verb returns the same + response struct (`.status_code`, `.status`, `.headers`, `.body`). +- `http.url_parse(url)` returns a struct with `scheme`, `host`, + `hostname`, `port`, `path`, `query` (dict of `str -> list[str]`), + `raw_query`, `fragment`, `username`, `password`, `raw`; returns + `None` on parse failure. +- `http.url_join(base, ref)` resolves a relative reference against a base + URL (handy for pagination `next` links). +- `http.multipart(fields)` builds a `multipart/form-data` body and + returns `(body_bytes, content_type)`. Each field value is a `str`/ + `bytes` (plain field) or a dict `{"filename":..., "content_type":..., + "content":...}` (file part). Pass the returned content type as the + request `Content-Type` header. + +```python +body, content_type = multipart({ + "name": "report", + "file": {"filename": "a.csv", "content_type": "text/csv", "content": data}, +}) +http_post(url, headers={"Content-Type": content_type}, body=body) +``` + +## `net.network_interface(...)` + +Build a `NetworkInterface` from a MAC and a mixed list of IP +strings. + +```python +nic = network_interface( + mac="AA:BB:CC:DD:EE:FF", # any common form + ips=["192.0.2.5", "fe80::1%eth0", "[2001:db8::1]:443"], +) +``` + +- MAC is normalized (colon/dash/Cisco-dotted/bare-hex all accepted). + Invalid MACs are silently dropped. +- `ips` is auto-classified into v4 / v6; `ipv4=` and `ipv6=` may + also be passed explicitly. +- Strips `[bracket]:port`, `addr:port`, `%zone` suffixes. +- Dedupes; caps at 99 addresses per family. +- Returns `None` when no usable address is present AND no MAC was + supplied, so callers can do `if nic: nics.append(nic)`. + +## `net.normalize_mac(s)` + +Returns the canonical lowercase colon form, or `None` for +unparseable input. + +## `net.ip_address(s)` + +Validates and classifies a single address. The result exposes +`.version` (4 or 6) and stringifies to the canonical form. + +## `net.ip_network(cidr)` / `net.ip_in_network(ip, cidr)` + +`ip_network(cidr)` parses a CIDR and returns a struct with `cidr`, +`version` (4/6), `prefix`, `network`, `broadcast`, `netmask`, and a +`contains(ip) -> bool` method (returns `None` on parse failure). +`ip_in_network(ip, cidr)` is the one-shot form and returns `False` for +malformed or mixed-family input. + +```python +net10 = ip_network("10.0.0.0/8") +if net10 and net10.contains(addr): + ... +if ip_in_network("10.1.2.3", "10.0.0.0/8"): + ... +``` + +## `net.resolve(host, timeout=10)` + +Looks up A/AAAA records and returns a list of `IPAddress` values (mixed +v4/v6, de-duplicated). Returns an empty list (never an error) for empty, +unresolvable, or timed-out lookups, so you can iterate directly. A bare +IP literal resolves to itself; trailing `:port`, brackets, and `%zone` +suffixes are tolerated. + +```python +for ip in resolve("api.example.com"): + print(ip, ip.version) +``` + +## `runzero.types.to_custom_attributes(value, ...)` + +Coerces an arbitrary value into the `string -> string` shape +required by `ImportAsset.customAttributes`. Replaces hand-rolled +`force_string`/flatten helpers. + +```python +attrs = to_custom_attributes({ + "name": host.get("name"), + "tags": host.get("tags", []), # joined with "," + "sys": {"os": "linux", "ver": 5}, # flattened with "." + "active": True, # stringified + "blank": "", # dropped +}) +``` + +Supported kwargs: + +- `separator` — key separator for nested dicts (default `"."`) +- `list_join` — `","` (default), `"json"`, or `""` to recurse +- `prefix` — prepended to every key +- `exclude` — list of dotted keys to skip +- `drop_empty` — drop empty strings / None / empty lists (default + `True`) +- `max_key` / `max_value` / `max_entries` — limits applied to keep + the platform happy (the platform itself caps keys at 256 and + values at 1024 chars, and supports up to 1024 entries). + +## ImportAsset notes + +- `hostnames`, `tags` and `networkInterfaces` automatically drop + empty / `None` entries, so you can write + `hostnames=[device.get("hostname")]` without the + `[x] if x else []` wrapper. Pass `[]` to keep them empty. +- `matchBehavior` accepts a space-separated flag string. The two + presets to remember: + - `"no-mac-break no-ip-break no-name-break"` — recommended when + your source supplies a **stable foreign id** (vendor-assigned + UUID, serial number). The id still drives merges, but + differing MACs/IPs/names won't disqualify a merge against an + existing asset. + - `"no-id-match no-id-break"` — recommended when your source + only emits **ephemeral / per-run ids**. The id is ignored, + and merging falls back to MAC/IP/hostname. + +## `runzero.types.Service` / `ServiceProtocolData` + +Attach richer service detail to an `ImportAsset` via `services=[...]`. + +```python +load("runzero.types", "Service", "ServiceProtocolData") + +svc = Service( + address="10.0.0.5", + port=443, + transport="tcp", # lower-cased + vendor="nginx", + product="nginx", + version="1.25.3", + protocolData=[ + ServiceProtocolData(name="http", attributes={"server": "nginx"}), + ServiceProtocolData(name="tls", attributes={"subject": "CN=acme"}), + ], + customAttributes={"tier": "edge"}, +) +``` + +- `Service` requires `address`, `port`, `transport`; `vendor`, `product`, + `version`, `protocolData`/`protocol_data`, and `customAttributes`/ + `custom_attributes` are optional. +- `ServiceProtocolData` requires `name`; `attributes` is an optional + mapping of protocol-specific fields. Both camelCase and snake_case + keyword spellings are accepted everywhere in `runzero.types`. + +## `kwargs` accessors + +Typed, validating accessors over the `**kwargs` dict passed to `main()`. +Each `params[].key` in `CONFIG` arrives as a kwarg of the same name. + +```python +load("kwargs", "require", "has", "get_string", "get_bool", "get_int", + "get_float", "get_list") + +def main(*args, **kwargs): + require(kwargs, "client_id", "client_secret") # error if missing/blank + client_id = get_string(kwargs, "client_id") + page_size = get_int(kwargs, "page_size", default=100) + ratio = get_float(kwargs, "ratio", default=1.0) + include_down = get_bool(kwargs, "include_offline", default=False) + regions = get_list(kwargs, "regions", default=[]) # CSV or list -> list[str] + if has(kwargs, "region"): + ... +``` + +- `get`/`get_string` are aliases. `get_bool` accepts `true/false/1/0/ + yes/no/on/off`. `get_list` splits a comma-separated string or passes a + list through. Missing optional values fall back to `default`. +- `get_url_base(kwargs, key)` extracts the scheme+host (drops path/query) + from a URL kwarg. +- `get_http_tls(kwargs, "tls_")` collects the `OPTIONS_TLS` include into a + `tls=` dict. +- `get_http_options(kwargs, "http_", "tls_", headers)` collects the + `OPTIONS_HTTP` + `OPTIONS_TLS` includes (timeout, proxy, TLS, etc.) into + a kwargs dict you can splat into any `http` helper (see + [Shared HTTP/TLS Options](#shared-httptls-options)). + +## `time` + +Re-exports the standard Starlark `time` module plus `time.sleep`. + +```python +load("time", "now", "parse_time", "parse_duration", "from_timestamp", "sleep") + +t = parse_time("2023-10-27T10:00:00Z") # time.time +print(t.unix, t.year, t.hour) +print(t.format("2006-01-02")) +d = parse_duration("90m") # time.duration +print(d.minutes) # 90.0 +later = now() + d # time + duration -> time +sleep("250ms") # honors the sandbox deadline +``` + +- `time.time` fields: `year`, `month`, `day`, `hour`, `minute`, `second`, + `nanosecond`, `unix`, `unix_nano`; methods `format(layout)`, + `in_location(name)`. +- `time.duration` fields: `hours`, `minutes`, `seconds`, `milliseconds`, + `microseconds`, `nanoseconds`. Arithmetic with `time`/`duration` is + supported (`time - time -> duration`, `duration / duration -> float`). +- Constants: `time.second`, `time.minute`, `time.hour`, ... + +## `requests` (stateful HTTP sessions) + +Use a `Session` when you need cookie persistence or sticky headers across +requests. Method names are case-insensitive (`session.get` == +`session.GET`). + +```python +load("requests", "Session", "Cookie") +load("json", json_decode="decode") + +session = Session() # Session(insecure_skip_verify=False) +session.headers.set("Accept", "application/json") +session.cookies.set("https://api.example.com", {"sid": "abc"}) + +resp = session.get("https://api.example.com/data", params={"limit": 100}) +if resp.status_code == 200: + data = json_decode(resp.body) +``` + +- Verbs: `get`, `post`, `put`, `patch`, `delete`, `head`. Each accepts + `headers`, `cookies`, `params`, `body`, `json`, `timeout` and returns + the same response struct as the `http` module. +- `session.headers` exposes `get(key)` / `set(key, value)` (`value=None` + deletes). `session.cookies` exposes `get(url)` / `set(url, cookies)` / + `clear()`. +- `Cookie(name, value, path="", domain="", secure=False, http_only=False, + max_age=0, ...)` constructs a cookie for `cookies.set`. + +## Encoding & hashing + +```python +load("base64", b64_encode="encode", b64_decode="decode") +load("hex", hex_encode="encode", hex_decode="decode") +load("base32", b32_encode="encode", b32_decode="decode") +load("crypto", "sha256", "sha512", "md5", + "hmac_sha256", "hmac", "sign_v4", "random_hex", "random_bytes") +load("jwt", jwt_encode="encode", jwt_decode="decode") +``` + +- `base64` also provides `raw_encode`/`raw_decode` (unpadded) and + `url_encode`/`url_decode` (URL-safe alphabet). `hex` and `base32` + (`raw_*` for unpadded) cover the other common encodings. +- `crypto` hashes (`sha1`, `sha256`, `sha512`, `md5`) take a `str`/`bytes` + and return hex. HMAC: `hmac_sha1/256/512(key, data, output="hex")` and + the generic `hmac(algorithm, key, data, output="hex")`; `output` may be + `"hex"`, `"base64"`, `"base64_raw"`, or `"bytes"`. +- `crypto.sign_v4(method, url, headers, body, access_key, secret_key, + region, service, session_token=None, timestamp=None)` returns the AWS + SigV4 headers dict (`Authorization`, `X-Amz-Date`, + `X-Amz-Content-Sha256`, optional `X-Amz-Security-Token`). +- `crypto.random_bytes(n)` / `crypto.random_hex(n)` produce CSPRNG output. +- `jwt.encode(claims, key, algorithm="HS256", headers=None)`, + `jwt.decode(token, key, algorithms=None)` (verifies), and + `jwt.decode_unverified(token)` (returns `{"header", "claims"}`). The + `none` algorithm is rejected. + +## Data parsing: `json` / `jsonstream` / `csv` / `xml` / `re` + +```python +load("jsonstream", "iter_array", "iter_lines") +load("csv", csv_read="read_all", csv_write="write_dicts") +load("xml", xml_parse="parse") +load("re", re_match="match", re_find_all="find_all", re_sub="sub") +``` + +- `jsonstream.iter_array(body, path=None)` streams elements of a (possibly + nested, dot-pathed) JSON array without materializing the whole document; + `jsonstream.iter_lines(body)` streams NDJSON / JSON-lines. Prefer these + for large responses. +- `csv.read_all(text, delimiter=",", comment="", header=True)` returns a + list of dicts (or list-of-lists with `header=False`); + `csv.read_rows(...)` is the header-less form; `csv.write_all(rows)` and + `csv.write_dicts(rows, fields=None)` serialize back to a CSV string. +- `xml.parse(text)` returns an element tree (XXE-safe). Elements expose + `tag`, `text`, `tail`, `attrib`, `children`, and methods `find(path)`, + `find_all(path)`, `get(name, default="")`, `text_all()`. +- `re` uses Go RE2 syntax. `match`/`search` return a struct + (`.match`, `.start`, `.end`, `.groups`, `.named`) or `None`; `find_all`, + `find_all_groups`, `sub(pattern, repl, string, count=-1)`, `split`, + `escape`, and `compile(pattern)` (reusable object with + `.match`/`.find_all`/`.sub`) are also available. + +## Low-level network & database + +These modules open raw connections for protocols that don't have a REST +API. They return connection/session objects with a `close()` method — +always close them when done. + +```python +load("socket", "tcp", "udp", "tls") +load("runzero.ssh", ssh_dial="dial") +load("runzero.smb", smb_dial="dial") +load("runzero.winrm", winrm_dial="dial") +load("runzero.wmi", wmi_dial="dial") +load("runzero.sql", sql_connect="connect") +``` + +- `socket.tcp(host, port, timeout=30, tls=False, ...)` / `socket.udp(...)` + / `socket.tls(...)` return a socket with `send`, `recv`, `recv_exact`, + `recv_line`, `recv_until`, `starttls`, `set_timeout`, `close` and + attributes `local_addr`, `remote_addr`, `is_tls`, `closed`. +- `runzero.ssh.dial(host, username, password=None, private_key=None, + port=22, timeout=30)` → session with `run(command) -> (stdout, stderr, + exit_code)`. +- `runzero.smb.dial(host, username, password="", nt_hash=None, port=445)` + → session with `mount(share)`, `list_shares()`; a mounted share offers + `read`, `list`, `stat`, `exists`. +- `runzero.winrm.dial(host, username, password, https=False, auth="ntlm")` + → session with `run(command)`, `run_powershell(script)`, + `wql(query, namespace="root/cimv2")`. +- `runzero.wmi.dial(host, username, password, transport="tcp", + namespace="//./root/cimv2")` → session with `query(wql, limit=0, + page=100) -> list[dict]`. +- `runzero.sql.connect(driver, dsn, ...)` (`driver` ∈ `postgres`, + `mysql`, `mssql`) → session with `query(sql, params=None) -> list[dict]` + and `exec(sql, params=None) -> {rows_affected, last_insert_id}`. DSNs are + restricted to network-only access. + +## `runzero.progress` + +Surface task progress and log lines in the runZero UI. + +```python +load("runzero.progress", progress_report="report", progress_info="info") + +progress_report(40, "fetched 4/10 pages") # pct clamped to [0, 100] +progress_info("retrying after 429") +``` + +- `report(pct, msg="")` — calls within 250ms are coalesced; messages are + truncated to 256 bytes. +- `info(msg)` / `warn(msg)` — emit log lines through the per-task logger. + +## `report_assets` (streaming inbound assets) + +`report_assets` is a predeclared builtin (no `load` required) that streams +`ImportAsset` values to runZero as your script runs, instead of accumulating +them all and returning a single `list` from `main`. Use it for inbound +integrations that page through large inventories so memory stays bounded by a +single page rather than the entire dataset. + +```python +def main(**kwargs): + cursor = None + while True: + page, cursor = fetch_page(kwargs, cursor) + if not page: + break + report_assets(build_assets(page)) # stream this page + if not cursor: + break + return None # nothing buffered in main +``` + +Accepted argument shapes: + +```python +report_assets(asset) # a single ImportAsset +report_assets(a, b) # several positional ImportAssets +report_assets(page_assets) # a list/tuple of ImportAsset +report_assets(*page_assets) # the same, spread +n = report_assets(batch) # returns the int count reported +``` + +- Reported assets are merged with any `list` returned from `main`, so partial + adoption is safe. + +## See also + +- `boilerplate/boilerplate.star` — a runnable + example that exercises the common helpers above. +- `AGENTS.md` — guidance for authoring new integrations. diff --git a/drata/README.md b/drata/README.md index c43da83..cad6065 100644 --- a/drata/README.md +++ b/drata/README.md @@ -26,8 +26,7 @@ - Modify datapoints uploaded to runZero as needed. 2. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials). - Select the type `Custom Integration Script Secrets`. - - Use the `access_secret` field for your Drata API Client Token. - - For `access_key`, input a placeholder value like `foo` (unused in this integration). + - Use the `api_token` field for your Drata API Client Token. 3. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new). - Add a Name and Icon for the integration (e.g., "drata"). - Toggle `Enable custom integration script` to input the finalized script. diff --git a/drata/config.json b/drata/config.json deleted file mode 100644 index 35f10a0..0000000 --- a/drata/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Drata", "type": "inbound" } diff --git a/drata/custom-integration-drata.star b/drata/drata.star similarity index 90% rename from drata/custom-integration-drata.star rename to drata/drata.star index fe78d7d..aff9d27 100644 --- a/drata/custom-integration-drata.star +++ b/drata/drata.star @@ -1,7 +1,28 @@ -load('runzero.types', 'ImportAsset', 'NetworkInterface') -load('json', json_encode='encode', json_decode='decode') -load('net', 'ip_address') -load('http', http_post='post', http_get='get', 'url_encode') +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-drata", + "name": "Drata", + "type": "inbound", + "description": "Imports assets from Drata.", + "version": "26061000", + "params": [ + { + "key": "api_token", + "label": "API token", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +load('runzero.types', 'ImportAsset', 'to_custom_attributes') +load('net', 'network_interface') +load('http', 'get_json', 'bearer') +load('kwargs', 'get_http_options') load('uuid', 'new_uuid') load('flatten_json', 'flatten') @@ -24,9 +45,9 @@ def build_assets(assets_json): macs = [] if macs: #for m in macs: - network = build_network_interface(ips=ips, mac=macs) + network = network_interface(ips=ips, mac=macs) else: - network = build_network_interface(ips=ips, mac=None) + network = network_interface(ips=ips, mac=None) device = [] device = item.get('device', {}) @@ -122,7 +143,7 @@ def build_assets(assets_json): hostnames=[hostname], networkInterfaces=[network], os=os_version, - customAttributes={ + customAttributes=to_custom_attributes({ "description":description, "assetType":asset_type, "asset_provider":asset_provider, @@ -195,51 +216,38 @@ def build_assets(assets_json): "owner.createdAt":owner_created_at, "owner.updatedAt":owner_updated_at, "owner.roles":[owner_roles] - } + }), ) ) return assets_import # Build runZero network interfaces; shouldn't need to touch this -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) - - return NetworkInterface(macAddress=mac, ipv4Addresses=ip4s, ipv6Addresses=ip6s) - def main(**kwargs): - token = kwargs['access_secret'] + token = kwargs['api_token'] + http_options = get_http_options(kwargs, headers={"Authorization": bearer(token)}) # Get assets - assets = [] filter = 'assetClassType=HARDWARE&employmentStatus=CURRENT_EMPLOYEE' - + page = 1 page_size = 50 hasNextPage = True - + reported = 0 + while hasNextPage: url = '{}/{}?{}&page={}&limit={}'.format(DRATA_URL, 'public/assets', filter, page, page_size) - results = http_get(url, headers={"Content-Type": "application/json", "Authorization": "Bearer " + token}) - if results.status_code != 200: - print('failed to retrieve assets') + data, err = get_json(url, **http_options) + if err: + print('failed to retrieve assets:', err) return None - - total = json_decode(results.body)['total'] + + total = (data or {}).get('total', 0) if total == 9999999: - results_json = json_decode(results.body)['data'] - assets.extend(results_json) + results_json = (data or {}).get('data', []) + # Build and stream each page via report_assets so the full asset set + # is never held in memory. + reported += report_assets(build_assets(results_json)) page += 1 elif total == 0: hasNextPage = False @@ -247,8 +255,7 @@ def main(**kwargs): print('unexpected value returned for total') hasNextPage = False - assets_import = build_assets(assets) - if not assets_import: + if not reported: print('no assets') - return assets_import \ No newline at end of file + return None \ No newline at end of file diff --git a/extreme-cloud-iq/README.md b/extreme-cloud-iq/README.md index 783236f..2a9f423 100644 --- a/extreme-cloud-iq/README.md +++ b/extreme-cloud-iq/README.md @@ -25,8 +25,8 @@ - All discovered assets will be enriched with additional metadata using `customAttributes`. 2. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials). - Select the type `Custom Integration Script Secrets`. - - Use the `access_key` field for your ExtremeCloud IQ username. - - Use the `access_secret` field for your ExtremeCloud IQ password. + - Use the `username` field for your ExtremeCloud IQ username. + - Use the `password` field for your ExtremeCloud IQ password. 3. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new). - Add a Name and Icon for the integration (e.g., "ExtremeCloudIQ"). - Toggle `Enable custom integration script` and paste in the finalized script. diff --git a/extreme-cloud-iq/config.json b/extreme-cloud-iq/config.json deleted file mode 100644 index d2dc080..0000000 --- a/extreme-cloud-iq/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Extreme Networks CloudIQ", "type": "inbound" } \ No newline at end of file diff --git a/extreme-cloud-iq/custom-integrations-extreme-cloud-iq.star b/extreme-cloud-iq/extreme-cloud-iq.star similarity index 69% rename from extreme-cloud-iq/custom-integrations-extreme-cloud-iq.star rename to extreme-cloud-iq/extreme-cloud-iq.star index 9622935..2a08a75 100644 --- a/extreme-cloud-iq/custom-integrations-extreme-cloud-iq.star +++ b/extreme-cloud-iq/extreme-cloud-iq.star @@ -1,9 +1,37 @@ +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-extreme-networks-cloudiq", + "name": "Extreme Networks CloudIQ", + "type": "inbound", + "description": "Imports access points and switches from Extreme CloudIQ.", + "version": "26061000", + "params": [ + { + "key": "username", + "label": "Username", + "type": "string", + "required": True, + }, + { + "key": "password", + "label": "Password", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} load('runzero.types', 'ImportAsset', 'NetworkInterface') load('requests', 'Session') load('json', json_encode='encode', json_decode='decode') load('uuid', 'new_uuid') load('flatten_json', 'flatten') load('net', 'ip_address') +load('kwargs', 'get_bool') SKIP_UNMANAGED = False @@ -21,18 +49,20 @@ def asset_networks(ips, mac): return NetworkInterface(macAddress=mac, ipv4Addresses=ip4s, ipv6Addresses=ip6s) def main(*args, **kwargs): - username = kwargs.get('access_key') - password = kwargs.get('access_secret') + username = kwargs.get('username') + password = kwargs.get('password') - session = Session() + session = Session(insecure_skip_verify=get_bool(kwargs, 'tls_disable_validation', False)) session.headers.set('Content-Type', 'application/json') session.headers.set('Accept', 'application/json') + if kwargs.get('http_user_agent'): + session.headers.set('User-Agent', kwargs.get('http_user_agent')) login_payload = { "username": username, "password": password } - login_resp = session.post("https://api.extremecloudiq.com/login", body=bytes(json_encode(login_payload))) + login_resp = session.post("https://api.extremecloudiq.com/login", json=login_payload) print("Login response code:", login_resp.status_code) login_body = json_decode(login_resp.body) print("Login response body:", login_body.keys()) @@ -47,7 +77,7 @@ def main(*args, **kwargs): session.headers.set("Authorization", "Bearer {}".format(token)) - assets = [] + reported = 0 page = 1 limit = 100 @@ -66,6 +96,7 @@ def main(*args, **kwargs): print("No devices on page", page) break + page_assets = [] for device in devices: if device.get("device_admin_state", "") != "MANAGED" and SKIP_UNMANAGED: @@ -85,7 +116,7 @@ def main(*args, **kwargs): hostnames=[device.get("hostname", "")], networkInterfaces=[asset_networks(ips, mac)], device_type=device.get("device_function", ""), - customAttributes={} + customAttributes={}, ) for key in device.keys(): @@ -96,11 +127,15 @@ def main(*args, **kwargs): elif type(val) in ["string", "int", "bool"]: asset.customAttributes[key] = str(val) - assets.append(asset) + page_assets.append(asset) + + # Build and stream each page via report_assets so the full device set + # is never held in memory. + reported += report_assets(page_assets) if len(devices) < limit: break page += 1 - print("Total assets imported:", len(assets)) - return assets + print("Total assets imported:", reported) + return None diff --git a/ghost/README.md b/ghost/README.md index acc2207..dc5b194 100644 --- a/ghost/README.md +++ b/ghost/README.md @@ -40,7 +40,7 @@ No manual mapping of repositories to IPs or hostnames is required. 1. Log in to your **Ghost Security console**. 2. Navigate to your account or organization **API Keys** section. 3. Generate a new key with **read permissions** for repositories and findings. -4. Copy the key — you’ll use it as your `access_secret` in runZero. +4. Copy the key — you’ll use it as your `api_token` in runZero. --- @@ -50,8 +50,7 @@ No manual mapping of repositories to IPs or hostnames is required. 2. Choose **Custom Integration Script Secrets**. 3. Enter: - * `access_secret`: your Ghost API key - * `access_key`: any placeholder value (unused) + * `api_token`: your Ghost API key 4. Save the credential. --- @@ -61,7 +60,7 @@ No manual mapping of repositories to IPs or hostnames is required. 1. Go to [Custom Integrations](https://console.runzero.com/custom-integrations/new). 2. Add a **name** (e.g., `ghost-security`) and optional icon. 3. Enable **Custom integration script**. -4. Paste in the `custom-integration-ghost.star` script (latest version). +4. Paste in the `ghost.star` script (latest version). 5. Click **Validate** to confirm syntax. 6. Save the integration. @@ -120,7 +119,7 @@ No manual mapping of repositories to IPs or hostnames is required. * Run locally for testing: ```bash - runzero script -f custom-integration-ghost.star --kwargs access_secret= + runzero script -f ghost.star --kwargs api_token= ``` * Logs will show: diff --git a/ghost/config.json b/ghost/config.json deleted file mode 100644 index 5c12114..0000000 --- a/ghost/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Ghost Security", "type": "inbound" } diff --git a/ghost/custom-integration-ghost.star b/ghost/ghost.star similarity index 75% rename from ghost/custom-integration-ghost.star rename to ghost/ghost.star index b12976a..f376e1d 100644 --- a/ghost/custom-integration-ghost.star +++ b/ghost/ghost.star @@ -1,3 +1,24 @@ +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-ghost-security", + "name": "Ghost Security", + "type": "inbound", + "description": "Imports assets from Ghost Security.", + "version": "26052700", + "params": [ + { + "key": "api_token", + "label": "API token", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} # Ghost Findings → runZero Integration # # Fetches repositories and findings from Ghost Security API. @@ -6,17 +27,17 @@ # # Updated: 2025-10-24 -load('http', http_get='get') -load('json', json_decode='decode') -load('net', 'ip_address') -load('runzero.types', 'ImportAsset', 'NetworkInterface', 'Vulnerability') - -def get_all_repositories(api_token): +load('http', 'get_json', 'bearer') +load('kwargs', 'get_http_options') +load('net', 'network_interface') +load('runzero.types', 'ImportAsset', 'Vulnerability') +def get_all_repositories(api_token, config_kwargs): """ Fetch all repositories from Ghost API with pagination. Extracts deployment hostnames from each repo's projects.deployments field. """ - headers = {"Authorization": "Bearer {}".format(api_token), "Accept": "application/json"} + headers = {"Authorization": bearer(api_token), "Accept": "application/json"} + http_options = get_http_options(config_kwargs, headers=headers) base_url = "https://api.ghostsecurity.ai/v1/repos" repos = [] page = 1 @@ -27,14 +48,13 @@ def get_all_repositories(api_token): while has_more: url = "{}?page={}".format(base_url, page) print("Requesting repos page {}".format(page)) - response = http_get(url, headers=headers) - if not response or response.status_code != 200: - print("Failed to get repo list at page {}: {}".format(page, response.status_code)) + data, err = get_json(url, **http_options) + if err: + print("Failed to get repo list at page {}: {}".format(page, err)) return repos - data = json_decode(response.body) - items = data.get("items", []) - has_more = data.get("has_more", False) + items = data.get("items", []) if data else [] + has_more = data.get("has_more", False) if data else False print("Page {} contains {} repos (has_more={})".format(page, len(items), has_more)) for repo in items: @@ -68,23 +88,10 @@ def get_all_repositories(api_token): return repos -def build_network_interface(ips): - """Convert IPs into a NetworkInterface object.""" - ip4s = [] - ip6s = [] - for ip in ips: - addr = ip_address(ip) - if addr: - if addr.version == 4: - ip4s.append(addr) - else: - ip6s.append(addr) - return NetworkInterface(ipv4Addresses=ip4s, ipv6Addresses=ip6s) - def main(*args, **kwargs): print("Starting main()") - api_token = kwargs.get("access_secret") + api_token = kwargs.get("api_token") if not api_token: print("Ghost API token missing.") return [] @@ -92,7 +99,7 @@ def main(*args, **kwargs): severity_map = {"critical": 4, "high": 3, "medium": 2, "low": 1} # 1️⃣ Fetch repositories and build lookup by repo_id - repos = get_all_repositories(api_token) + repos = get_all_repositories(api_token, kwargs) print("Fetched {} repos".format(len(repos))) repo_map = {} @@ -102,16 +109,16 @@ def main(*args, **kwargs): print("Built repo_map with repo_ids: {}".format(list(repo_map.keys()))) # 2️⃣ Fetch findings - headers = {"Authorization": "Bearer {}".format(api_token), "Accept": "application/json"} + headers = {"Authorization": bearer(api_token), "Accept": "application/json"} + http_options = get_http_options(kwargs, headers=headers) findings_url = "https://api.ghostsecurity.ai/v1/findings" print("Fetching findings from {}".format(findings_url)) - resp = http_get(findings_url, headers=headers) - if not resp or resp.status_code != 200: - print("Failed to fetch findings, status={}".format(resp.status_code if resp else 'N/A')) + data, err = get_json(findings_url, **http_options) + if err: + print("Failed to fetch findings:", err) return [] - data = json_decode(resp.body) - findings = data.get("items", []) + findings = data.get("items", []) if data else [] print("Total findings returned: {}".format(len(findings))) asset_map = {} @@ -150,7 +157,7 @@ def main(*args, **kwargs): asset = ImportAsset( id=asset_key, hostnames=mapping["hostnames"], - networkInterfaces=[build_network_interface(mapping["ips"])] + networkInterfaces=[network_interface(ips=mapping["ips"])], ) asset.vulnerabilities = [] asset_map[asset_key] = asset @@ -178,4 +185,6 @@ def main(*args, **kwargs): asset_map[asset_key].vulnerabilities.append(vuln) print("Completed. Assets created: {}".format(len(asset_map))) - return list(asset_map.values()) + # Stream assets to runZero via report_assets instead of returning a list. + report_assets(list(asset_map.values())) + return None diff --git a/halycon/README.md b/halycon/README.md index e5cc537..4e4d27e 100644 --- a/halycon/README.md +++ b/halycon/README.md @@ -7,16 +7,12 @@ ## Halcyon requirements - Access to the Halcyon API at `https://api.halcyon.ai` from the runZero Explorer. -- One of the following authentication methods: - - Recommended: Halcyon username in `access_key` and password in `access_secret`. - - Supported: pre-issued bearer token in `access_secret` with `access_key` set to a placeholder value. +- Halcyon username in `username` and password in `password`. - Permissions to query the Halcyon asset search and asset detail endpoints. ## Authentication behavior -- When `access_key` is set, the script authenticates to Halcyon, retrieves a JWT access token, and automatically refreshes that token if the API returns `401 Unauthorized` during asset collection. -- When `access_key` is omitted, the script treats `access_secret` as an already-issued bearer token. -- Bearer token mode does not support automatic token refresh because the script does not have credentials to request a new token. +- The script authenticates to Halcyon, retrieves a JWT access token, and automatically refreshes that token if the API returns `401 Unauthorized` during asset collection. ## Data imported into runZero @@ -39,21 +35,15 @@ ### Halcyon configuration 1. Confirm that the runZero Explorer can reach `https://api.halcyon.ai` over HTTPS. -2. Choose your authentication method: - - Preferred: use a Halcyon username and password so the script can automatically refresh expired JWTs. - - Alternative: use a pre-issued bearer token if your environment requires token-based authentication. +2. Confirm your Halcyon username and password can authenticate to the API. 3. Verify that the credentials can access the Halcyon asset APIs. ### runZero configuration 1. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials). - Select the type `Custom Integration Script Secrets`. - - If using username/password authentication: - - Set `access_key` to your Halcyon username. - - Set `access_secret` to your Halcyon password. - - If using bearer token authentication: - - Leave `access_key` blank if allowed, or use a placeholder value like `foo`. - - Set `access_secret` to the Halcyon bearer token. + - Set `username` to your Halcyon username. + - Set `password` to your Halcyon password. 2. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new). - Add a Name and Icon for the integration, such as `halycon`. - Toggle `Enable custom integration script` to input the finalized script. diff --git a/halycon/config.json b/halycon/config.json deleted file mode 100644 index 407d285..0000000 --- a/halycon/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Halycon", "type": "inbound" } \ No newline at end of file diff --git a/halycon/custom-integration-halycon.star b/halycon/halycon.star similarity index 50% rename from halycon/custom-integration-halycon.star rename to halycon/halycon.star index 283c936..5dc8540 100644 --- a/halycon/custom-integration-halycon.star +++ b/halycon/halycon.star @@ -1,123 +1,126 @@ -load('runzero.types', 'ImportAsset', 'NetworkInterface') -load('json', json_decode='decode', json_encode='encode') -load('net', 'ip_address') -load('http', http_post='post', http_get='get') +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-halcyon", + "name": "Halcyon", + "type": "inbound", + "description": "Imports endpoints from Halcyon.", + "version": "26061000", + "params": [ + { + "key": "username", + "label": "Username", + "type": "string", + "required": True, + }, + { + "key": "password", + "label": "Password", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +load('runzero.types', 'ImportAsset', 'to_custom_attributes') +load('net', 'network_interface') +load('http', 'get_json', 'post_json', 'bearer') +load('kwargs', 'get_http_options') BASE_URL = "https://api.halcyon.ai" -def _get_access_token(username, password): - login_url = "{}/identity/auth/login".format(BASE_URL) - headers = { - "Content-Type": "application/json", - "Accept": "application/json", - } - payload = { - "username": username, - "password": password, - } - - response = http_post(login_url, headers=headers, body=bytes(json_encode(payload))) - - if response.status_code != 200: - print("Halcyon login failed: {} {}".format(response.status_code, response.body)) +def _get_access_token(username, password, config_kwargs): + data, err = post_json( + "{}/identity/auth/login".format(BASE_URL), + json={"username": username, "password": password}, + **get_http_options(config_kwargs, headers={"Accept": "application/json"}) + ) + if err: + print("Halcyon login failed:", err) return None - - token = json_decode(response.body).get("accessToken") - - return token + return data.get("accessToken") if data else None def _build_headers(api_token): return { - "Authorization": "Bearer {}".format(api_token), - "Content-Type": "application/json", - "Accept": "application/json" + "Authorization": bearer(api_token), + "Accept": "application/json", } def _refresh_access_token(auth_state): username = auth_state.get("username") password = auth_state.get("password") - if not username or not password: return False - - api_token = _get_access_token(username, password) + api_token = _get_access_token(username, password, auth_state.get("config_kwargs")) if not api_token: return False - auth_state["api_token"] = api_token return True -def _authorized_post(url, auth_state, payload): - response = http_post(url, headers=_build_headers(auth_state.get("api_token")), body=bytes(json_encode(payload))) - - if response.status_code == 401 and _refresh_access_token(auth_state): - response = http_post(url, headers=_build_headers(auth_state.get("api_token")), body=bytes(json_encode(payload))) - - return response - -def _authorized_get(url, auth_state): - response = http_get(url, headers=_build_headers(auth_state.get("api_token"))) - - if response.status_code == 401 and _refresh_access_token(auth_state): - response = http_get(url, headers=_build_headers(auth_state.get("api_token"))) - - return response +def _authorized_post_json(url, auth_state, payload): + data, err = post_json( + url, + json=payload, + **get_http_options(auth_state.get("config_kwargs"), headers=_build_headers(auth_state.get("api_token"))) + ) + if err and err.startswith("status 401") and _refresh_access_token(auth_state): + data, err = post_json( + url, + json=payload, + **get_http_options(auth_state.get("config_kwargs"), headers=_build_headers(auth_state.get("api_token"))) + ) + return data, err + +def _authorized_get_json(url, auth_state): + data, err = get_json( + url, + **get_http_options(auth_state.get("config_kwargs"), headers=_build_headers(auth_state.get("api_token"))) + ) + if err and err.startswith("status 401") and _refresh_access_token(auth_state): + data, err = get_json( + url, + **get_http_options(auth_state.get("config_kwargs"), headers=_build_headers(auth_state.get("api_token"))) + ) + return data, err def _build_network_interfaces(ip_objects, mac_list): - ip4s = [] - ip6s = [] - - # Halcyon IP entries are objects like: {"ipAddressType": "IPv4", "value": "1.2.3.4"} - for ip_obj in ip_objects: - if type(ip_obj) != "dict": - continue - ip_text = ip_obj.get("value") - if not ip_text: - continue - - addr = ip_address(ip_text) - if not addr: - continue - if addr.version == 4: - ip4s.append(addr) - elif addr.version == 6: - ip6s.append(addr) - - mac_address = "" + # Halcyon IP entries are objects like {"ipAddressType": "IPv4", "value": "1.2.3.4"}. + ips = [obj.get("value") for obj in ip_objects if type(obj) == "dict"] + mac = "" if type(mac_list) == "list" and len(mac_list) > 0 and mac_list[0]: - mac_address = str(mac_list[0]) - - if not mac_address and not ip4s and not ip6s: - return [] - - return [NetworkInterface(macAddress=mac_address, ipv4Addresses=ip4s, ipv6Addresses=ip6s)] + mac = str(mac_list[0]) + nic = network_interface(mac=mac, ips=ips) + return [nic] if nic else [] def main(*args, **kwargs): - # Preferred credential mode: access_key=username, access_secret=password. - username = kwargs.get('access_key') - password_or_token = kwargs.get('access_secret') + username = kwargs.get('username') + password_or_token = kwargs.get('password') if not password_or_token: - print("Error: Missing access_secret.") + print("Error: Missing password.") return [] api_token = None if username: - api_token = _get_access_token(username, password_or_token) + api_token = _get_access_token(username, password_or_token, kwargs) if not api_token: return [] else: - # Backward compatible mode: access_secret is already a bearer token. + # Backward compatible mode: password is already a bearer token. api_token = password_or_token auth_state = { "username": username, "password": password_or_token if username else "", "api_token": api_token, + "config_kwargs": kwargs, } - assets = [] + reported = 0 page = 1 page_size = 10 @@ -135,36 +138,34 @@ def main(*args, **kwargs): } } - response = _authorized_post(search_url, auth_state, payload) - - if response.status_code != 200: - print("API Error during asset search: {} {}".format(response.status_code, response.body)) + data, err = _authorized_post_json(search_url, auth_state, payload) + if err: + print("API Error during asset search:", err) break - - data = json_decode(response.body) - items = data.get("items", []) + items = data.get("items", []) if data else [] if not items: break + page_assets = [] for item in items: asset_id = item.get("id") if not asset_id: continue detail_url = "{}/v2/assets/{}".format(BASE_URL, asset_id) - detail_res = _authorized_get(detail_url, auth_state) + detail_data, detail_err = _authorized_get_json(detail_url, auth_state) ips = [] macs = [] - detail_data = {} - - if detail_res.status_code == 200: - detail_data = json_decode(detail_res.body) + if detail_err: + print("Failed to fetch detailed info for asset {}: {}".format(asset_id, detail_err)) + detail_data = {} + elif detail_data: ips = detail_data.get("ipAddresses", []) macs = detail_data.get("macAddresses", []) else: - print("Failed to fetch detailed info for asset {}: {}".format(asset_id, detail_res.status_code)) + detail_data = {} net_interfaces = _build_network_interfaces(ips, macs) @@ -192,14 +193,18 @@ def main(*args, **kwargs): hostname = detail_data.get("name") os_name = item.get("operatingSystem") - assets.append(ImportAsset( + page_assets.append(ImportAsset( id=str(asset_id), hostnames=[str(hostname)] if hostname else [], os=str(os_name) if os_name else "", networkInterfaces=net_interfaces, - customAttributes=custom_attrs + customAttributes=to_custom_attributes(custom_attrs), )) + # Build and stream each page via report_assets so the full asset set is + # never held in memory. + reported += report_assets(page_assets) + # Handle pagination using the API's pagination block pagination = data.get("pagination", {}) current_page = pagination.get("currentPage", page) @@ -210,4 +215,7 @@ def main(*args, **kwargs): page += 1 - return assets \ No newline at end of file + if not reported: + print("no assets") + + return None \ No newline at end of file diff --git a/ivanti_neurons/README.md b/ivanti_neurons/README.md index a44b7e5..820961e 100644 --- a/ivanti_neurons/README.md +++ b/ivanti_neurons/README.md @@ -44,8 +44,8 @@ The URL scheme is `https:////` 6. Click Register to generate the authentication settings. 7. In the Complete this registration panel, the authentication settings, required to complete the registration, are provided: - *Neurons Auth URL* - Copy this to the value for the `NEURONS_AUTH_URL` variable in the custom integration script. - - *Client ID* - Copy the Client ID to the value for `access_key` when creating the Custom Integration credentials in the runZero console (see below). - - *Client secret* - Copy the Client secret to the the value for `access_secret` when creating the Custom Integration credentials in the runZero console (see below) + - *Client ID* - Copy the Client ID to the value for `client_id` when creating the Custom Integration credentials in the runZero console (see below). + - *Client secret* - Copy the Client secret to the value for `client_secret` when creating the Custom Integration credentials in the runZero console (see below) ***Warning***: For security reasons, the Client Secret will not be visible again once you close this panel, so make sure you copy it before clicking @@ -61,9 +61,9 @@ The URL scheme is `https:////` - Modify datapoints uploaded to runZero as needed 2. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials) - Select the type `Custom Integration Script Secrets` - - Both `access_key` and `access_secret` are required - - `access_key` corresponds to the Client ID provided when creating the app registration in the Neurons Console. - - `access_secret` corresponds to the Client secret provided when creating the app registration in the Neurons Console. + - Both `client_id` and `client_secret` are required + - `client_id` corresponds to the Client ID provided when creating the app registration in the Neurons Console. + - `client_secret` corresponds to the Client secret provided when creating the app registration in the Neurons Console. Paste the parameters to pass on the field below as described in documentation. diff --git a/ivanti_neurons/config.json b/ivanti_neurons/config.json deleted file mode 100644 index da9efea..0000000 --- a/ivanti_neurons/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Ivanti Neurons", "type": "inbound" } \ No newline at end of file diff --git a/ivanti_neurons/custom-integration-neurons.star b/ivanti_neurons/custom-integration-neurons.star deleted file mode 100644 index 4fd7759..0000000 --- a/ivanti_neurons/custom-integration-neurons.star +++ /dev/null @@ -1,119 +0,0 @@ -load('runzero.types', 'ImportAsset', 'NetworkInterface') -load('json', json_encode='encode', json_decode='decode') -load('net', 'ip_address') -load('http', http_get='get', http_post='post', 'url_encode') -load('uuid', 'new_uuid') - -#Change the URL to match your Ivanti Neurons app registration -NEURONS_AUTH_URL = 'https://' -NEURONS_TENANT_ID = '' -RUNZERO_REDIRECT = 'https://console.runzero.com/' - -def build_assets(assets): - assets_import = [] - for asset in assets: - asset_id = str(asset.get('DiscoveryId', str(new_uuid))) - hostname = asset.get('DeviceName', '') - os = asset.get('OS', {}).get('Name', '') - os_version = asset.get('OS', {}).get('Version', '') - os = os + ' ' + os_version if os_version else os - model = asset.get('System', {}).get('Model', '') - - # create the network interfaces - tcpip = asset.get('Network', {}).get('TCPIP', {}) - address_list = list(tcpip.values()) - interfaces = build_network_interface(ips=address_list, mac=None) - - #map additional custom attributes - displayname = asset.get('DisplayName', '') - device_id = asset.get('DeviceID', '') - - custom_attributes = { - 'discoveryId': asset_id, - 'deviceName': hostname, - 'deviceId': device_id, - 'displayName': displayname, - 'os.name': os, - 'os.version': os_version, - 'system.model': model - } - - # Build assets for import - assets_import.append( - ImportAsset( - id=asset_id, - hostnames=[hostname], - os=os, - model=model, - networkInterfaces=[interfaces], - customAttributes=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 = [] - url = NEURONS_AUTH_URL + '/api/apigatewaydataservices/v1/devices' - headers = {'Accept': 'application/json', - 'Authorization': 'Bearer ' + token} - total_assets = 1000 - while len(assets_all) < (total_assets - 1): - response = http_get(url, headers=headers) - if response.status_code != 200: - print('failed to retrieve devices from ' + url, 'status code: ' + str(response.status_code)) - break - else: - data = json_decode(response.body) - assets = data['value'] - assets_all.extend(assets) - total_assets = data.get('@odata.count') - url = data.get('@odata.nextLink') - - return assets_all - -def get_token(client_id, client_secret): - url = NEURONS_AUTH_URL + '/api/apigatewaydataservices/v1/token' - headers = {'Content-Type': 'application/json', - 'X-ClientSecret': client_secret, - 'X-TenantId': NEURONS_TENANT_ID, - 'X-ClientId': client_id} - response = http_get(url, headers=headers) - if response.status_code != 200: - print('authentication failed: ' + str(response.status_code)) - return None - auth_data = response.body - if not auth_data: - print('invalid authentication data') - return None - - return auth_data - -def main(*args, **kwargs): - client_id = kwargs['access_key'] - client_secret = kwargs['access_secret'] - token = get_token(client_id, client_secret) - assets = get_assets(token) - - # Format asset list for import into runZero - import_assets = build_assets(assets) - if not import_assets: - print('no assets') - return None - - return import_assets \ No newline at end of file diff --git a/ivanti_neurons/neurons.star b/ivanti_neurons/neurons.star new file mode 100644 index 0000000..da9a8d1 --- /dev/null +++ b/ivanti_neurons/neurons.star @@ -0,0 +1,139 @@ +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-ivanti-neurons", + "name": "Ivanti Neurons", + "type": "inbound", + "description": "Imports devices from Ivanti Neurons.", + "version": "26052700", + "params": [ + { + "key": "url", + "label": "Ivanti Neurons URL", + "type": "url", + "required": True, + "placeholder": "https://neurons.example.com", + }, + { + "key": "tenant_id", + "label": "Tenant ID", + "type": "string", + "required": True, + }, + { + "key": "client_id", + "label": "OAuth client ID", + "type": "string", + "required": True, + }, + { + "key": "client_secret", + "label": "OAuth client secret", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +load('runzero.types', 'ImportAsset', 'to_custom_attributes') +load('net', 'network_interface') +load('http', http_get='get', http_post='post', 'get_json', 'url_encode') +load('kwargs', 'get_url_base', 'get_http_options', 'get_string') +load('uuid', 'new_uuid') + +def build_assets(assets): + assets_import = [] + for asset in assets: + asset_id = str(asset.get('DiscoveryId', str(new_uuid))) + hostname = asset.get('DeviceName', '') + os = asset.get('OS', {}).get('Name', '') + os_version = asset.get('OS', {}).get('Version', '') + os = os + ' ' + os_version if os_version else os + model = asset.get('System', {}).get('Model', '') + + # create the network interfaces + tcpip = asset.get('Network', {}).get('TCPIP', {}) + address_list = list(tcpip.values()) + interfaces = network_interface(ips=address_list, mac=None) + + #map additional custom attributes + displayname = asset.get('DisplayName', '') + device_id = asset.get('DeviceID', '') + + custom_attributes = { + 'discoveryId': asset_id, + 'deviceName': hostname, + 'deviceId': device_id, + 'displayName': displayname, + 'os.name': os, + 'os.version': os_version, + 'system.model': model + } + + # Build assets for import + assets_import.append( + ImportAsset( + id=asset_id, + hostnames=[hostname], + os=os, + model=model, + networkInterfaces=[interfaces], + customAttributes=to_custom_attributes(custom_attributes), + ) + ) + return assets_import + +def get_assets(base_url, token, config_kwargs): + assets_all = [] + url = base_url + '/api/apigatewaydataservices/v1/devices' + headers = {'Accept': 'application/json', + 'Authorization': 'Bearer ' + token} + http_options = get_http_options(config_kwargs, headers=headers) + total_assets = 1000 + while len(assets_all) < (total_assets - 1): + data, err = get_json(url, **http_options) + if err: + print('failed to retrieve devices from ' + url + ': ' + err) + break + if not data: + break + assets = data['value'] + assets_all.extend(assets) + total_assets = data.get('@odata.count') + url = data.get('@odata.nextLink') + + return assets_all + +def get_token(base_url, tenant_id, client_id, client_secret, config_kwargs): + url = base_url + '/api/apigatewaydataservices/v1/token' + headers = {'Content-Type': 'application/json', + 'X-ClientSecret': client_secret, + 'X-TenantId': tenant_id, + 'X-ClientId': client_id} + response = http_get(url, **get_http_options(config_kwargs, headers=headers)) + if response.status_code != 200: + print('authentication failed: ' + str(response.status_code)) + return None + auth_data = response.body + if not auth_data: + print('invalid authentication data') + return None + + return auth_data + +def main(*args, **kwargs): + base_url = get_url_base(kwargs) + tenant_id = get_string(kwargs, 'tenant_id') + client_id = kwargs['client_id'] + client_secret = kwargs['client_secret'] + token = get_token(base_url, tenant_id, client_id, client_secret, kwargs) + assets = get_assets(base_url, token, kwargs) + + # Build and stream asset import via report_assets instead of returning a list + if not report_assets(build_assets(assets)): + print('no assets') + + return None \ No newline at end of file diff --git a/jamf/README.md b/jamf/README.md index d17cc32..faebf5d 100644 --- a/jamf/README.md +++ b/jamf/README.md @@ -28,8 +28,8 @@ - Modify datapoints uploaded to runZero as needed. 2. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials). - Select the type `Custom Integration Script Secrets`. - - Use the `access_key` field for your JAMF API Client ID. - - Use the `access_secret` field for your JAMF API Client Secret. + - Use the `client_id` field for your JAMF API Client ID. + - Use the `client_secret` field for your JAMF API Client Secret. 3. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new). - Add a Name and Icon for the integration (e.g., "JAMF"). - Toggle `Enable custom integration script` to input the finalized script. diff --git a/jamf/config.json b/jamf/config.json deleted file mode 100644 index cc938bb..0000000 --- a/jamf/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "JAMF", "type": "inbound" } diff --git a/jamf/custom-integration-jamf.star b/jamf/jamf.star similarity index 61% rename from jamf/custom-integration-jamf.star rename to jamf/jamf.star index 3b5c91b..977fa85 100644 --- a/jamf/custom-integration-jamf.star +++ b/jamf/jamf.star @@ -1,11 +1,43 @@ -load('runzero.types', 'ImportAsset', 'NetworkInterface') -load('json', json_encode='encode', json_decode='decode') +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-jamf", + "name": "JAMF", + "type": "inbound", + "description": "Imports computers and mobile devices from Jamf Pro.", + "version": "26061000", + "params": [ + { + "key": "url", + "label": "Jamf base URL", + "type": "url", + "required": True, + "placeholder": "https://.jamfcloud.com", + }, + { + "key": "client_id", + "label": "API client ID", + "type": "string", + "required": True, + }, + { + "key": "client_secret", + "label": "API client secret", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +load('runzero.types', 'ImportAsset', 'NetworkInterface', 'to_custom_attributes') load('net', 'ip_address') -load('http', http_post='post', http_get='get', 'url_encode') +load('http', 'get_json', 'post_json', 'bearer', 'oauth2_token') +load('kwargs', 'get_url_base', 'get_http_options') load('time', 'now', 'parse_duration') load('flatten_json', 'flatten') - -JAMF_URL = 'https://.jamfcloud.com' DAYS_AGO = 60 # Adjust as needed duration_str = "-{}h".format(DAYS_AGO * 24) # Go duration format, e.g. "-720h" for 30 days ago_duration = parse_duration(duration_str) @@ -22,82 +54,73 @@ def sanitize_string(s): else: return None -def get_bearer_token(client_id, client_secret): - headers = {'Content-Type': 'application/x-www-form-urlencoded', 'accept': 'application/json'} - params = {'client_id': client_id, 'client_secret': client_secret, 'grant_type': 'client_credentials'} - url = "{}/api/oauth/token".format(JAMF_URL) - resp = http_post(url, headers=headers, body=bytes(url_encode(params))) - - if resp.status_code != 200: - print("Failed to retrieve bearer token. Status code:", resp.status_code) - return None, 0 - - body_json = json_decode(resp.body) - if not body_json: - print("Invalid JSON response for bearer token") - return None, 0 - - token = body_json['access_token'] +def get_bearer_token(base_url, client_id, client_secret, config_kwargs): + token = oauth2_token( + "{}/api/oauth/token".format(base_url), + client_id=client_id, + client_secret=client_secret, + **get_http_options(config_kwargs) + ) return token, 0 -def get_valid_token(token, request_count, client_id, client_secret): +def get_valid_token(token, request_count, base_url, client_id, client_secret, config_kwargs): if token and request_count < MAX_REQUESTS: return token, request_count + 1 else: print("Fetching new token after", request_count, "requests") - return get_bearer_token(client_id, client_secret) + return get_bearer_token(base_url, client_id, client_secret, config_kwargs) -def http_request(method, url, headers=None, params=None, body=None, token=None, request_count=None, client_id=None, client_secret=None): - token, request_count = get_valid_token(token, request_count, client_id, client_secret) +def http_request(method, url, config_kwargs=None, headers=None, params=None, body=None, token=None, request_count=None, base_url=None, client_id=None, client_secret=None): + token, request_count = get_valid_token(token, request_count, base_url, client_id, client_secret, config_kwargs) if not token: - return None, token, request_count + return None, "no token", token, request_count - headers = headers or {} - params = params or {} - headers["Authorization"] = "Bearer {}".format(token) + headers = dict(headers or {}) + headers["Authorization"] = bearer(token) + http_options = get_http_options(config_kwargs, headers=headers) if method == "GET": - response = http_get(url=url, headers=headers, params=params) + data, err = get_json(url=url, params=params or {}, **http_options) elif method == "POST": - response = http_post(url=url, headers=headers, body=body) + data, err = post_json(url=url, body=body, **http_options) else: - print("Unsupported HTTP method:", method) - return None, token, request_count + return None, "unsupported method: " + method, token, request_count - print("API Response Status:", response.status_code) - - if response.status_code == 403: + if err and err.startswith("status 403"): print("403 Forbidden. Fetching new token and retrying...") - token, request_count = get_bearer_token(client_id, client_secret) + token, request_count = get_bearer_token(base_url, client_id, client_secret, config_kwargs) if not token: - return None, token, request_count - headers["Authorization"] = "Bearer {}".format(token) + return None, "refresh failed", token, request_count + headers["Authorization"] = bearer(token) + http_options = get_http_options(config_kwargs, headers=headers) if method == "GET": - response = http_get(url=url, headers=headers, params=params, timeout=300) + data, err = get_json(url=url, params=params or {}, timeout=300, **http_options) elif method == "POST": - response = http_post(url=url, headers=headers, body=body) + data, err = post_json(url=url, body=body, **http_options) - return response, token, request_count + return data, err, token, request_count -def get_jamf_inventory(token, request_count, client_id, client_secret): +def stream_computer_assets(base_url, config_kwargs, token, request_count, client_id, client_secret): + """Paginate computer inventory, fetch per-device details, then build and + stream each page of assets via report_assets so the full inventory is never + held in memory. Returns (reported_count, token, request_count).""" hasNextPage = True page = 0 page_size = 100 - endpoints = [] + reported = 0 # hardcoded filter for the time being until we support datetime - url = JAMF_URL + '/api/v1/computers-inventory' + url = base_url + '/api/v1/computers-inventory' while hasNextPage: params = {"page": page, "page-size": page_size, "filter": 'general.lastContactTime=ge="{}T00:00:00Z"'.format(START_DATE)} - resp, token, request_count = http_request("GET", url, params=params, token=token, request_count=request_count, client_id=client_id, client_secret=client_secret) - if not resp or resp.status_code != 200: - print("Failed to retrieve inventory. Status code:", getattr(resp, 'status_code', 'None')) - return endpoints, token, request_count + inventory, err, token, request_count = http_request("GET", url, config_kwargs=config_kwargs, params=params, token=token, request_count=request_count, base_url=base_url, client_id=client_id, client_secret=client_secret) + if err: + print("Failed to retrieve inventory:", err) + return reported, token, request_count - inventory = json_decode(resp.body) if not inventory: - print("Invalid or empty JSON response for inventory:", resp.body) - return endpoints, token, request_count + print("Empty inventory response") + return reported, token, request_count results = inventory.get('results', []) @@ -105,12 +128,13 @@ def get_jamf_inventory(token, request_count, client_id, client_secret): hasNextPage = False continue - endpoints.extend(results) + details, token, request_count = get_jamf_details(base_url, config_kwargs, token, request_count, client_id, client_secret, results) + reported += report_assets(build_assets(details)) page += 1 - return endpoints, token, request_count + return reported, token, request_count -def get_jamf_details(token, request_count, client_id, client_secret, inventory): +def get_jamf_details(base_url, config_kwargs, token, request_count, client_id, client_secret, inventory): endpoints_final = [] for item in inventory: uid = item.get('id') @@ -118,17 +142,16 @@ def get_jamf_details(token, request_count, client_id, client_secret, inventory): print("ID not found in inventory item:", item) continue - url = "{}/api/v1/computers-inventory-detail/{}".format(JAMF_URL, uid) - resp, token, request_count = http_request("GET", url, token=token, request_count=request_count, client_id=client_id, client_secret=client_secret) - if not resp or resp.status_code != 200: - print("Failed to retrieve details for ID:", uid, "Status code:", getattr(resp, 'status_code', 'None')) + url = "{}/api/v1/computers-inventory-detail/{}".format(base_url, uid) + extra, err, token, request_count = http_request("GET", url, config_kwargs=config_kwargs, token=token, request_count=request_count, base_url=base_url, client_id=client_id, client_secret=client_secret) + if err: + print("Failed to retrieve details for ID:", uid, err) continue - extra = json_decode(resp.body) if DEV_MODE: build_asset(extra) if not extra: - print("Invalid JSON for detail:", resp.body) + print("Empty detail for ID:", uid) continue item.update(extra) @@ -136,25 +159,27 @@ def get_jamf_details(token, request_count, client_id, client_secret, inventory): return endpoints_final, token, request_count -def get_mobile_device_inventory(token, request_count, client_id, client_secret): +def stream_mobile_assets(base_url, config_kwargs, token, request_count, client_id, client_secret): + """Paginate mobile device inventory, fetch per-device details, then build and + stream each page of assets via report_assets so the full inventory is never + held in memory. Returns (reported_count, token, request_count).""" hasNextPage = True page = 0 page_size = 100 - mobile_devices = [] + reported = 0 # hardcoded filter for the time being until we support datetime - url = JAMF_URL + "/api/v2/mobile-devices/detail" + url = base_url + "/api/v2/mobile-devices/detail" while hasNextPage: params = {"page": page, "page-size": page_size, "section": "GENERAL", "filter": 'lastInventoryUpdateDate=ge="{}T00:00:00Z"'.format(START_DATE)} - resp, token, request_count = http_request("GET", url, params=params, token=token, request_count=request_count, client_id=client_id, client_secret=client_secret) - if not resp or resp.status_code != 200: - print("Failed to retrieve mobile device inventory. Status code:", getattr(resp, 'status_code', 'None')) - return mobile_devices, token, request_count + inventory, err, token, request_count = http_request("GET", url, config_kwargs=config_kwargs, params=params, token=token, request_count=request_count, base_url=base_url, client_id=client_id, client_secret=client_secret) + if err: + print("Failed to retrieve mobile device inventory:", err) + return reported, token, request_count - inventory = json_decode(resp.body) if not inventory: - print("Invalid or empty JSON response for mobile inventory:", resp.body) - return mobile_devices, token, request_count + print("Empty mobile inventory response") + return reported, token, request_count results = inventory.get('results', []) @@ -162,12 +187,13 @@ def get_mobile_device_inventory(token, request_count, client_id, client_secret): hasNextPage = False continue - mobile_devices.extend(results) + details, token, request_count = get_mobile_device_details(base_url, config_kwargs, token, request_count, client_id, client_secret, results) + reported += report_assets(build_mobile_assets(details)) page += 1 - return mobile_devices, token, request_count + return reported, token, request_count -def get_mobile_device_details(token, request_count, client_id, client_secret, inventory): +def get_mobile_device_details(base_url, config_kwargs, token, request_count, client_id, client_secret, inventory): mobile_devices_final = [] for item in inventory: uid = item.get('mobileDeviceId') @@ -175,17 +201,16 @@ def get_mobile_device_details(token, request_count, client_id, client_secret, in print("ID not found in mobile device item:", item) continue - url = "{}/api/v2/mobile-devices/{}/detail".format(JAMF_URL, uid) - resp, token, request_count = http_request("GET", url, token=token, request_count=request_count, client_id=client_id, client_secret=client_secret) - if not resp or resp.status_code != 200: - print("Failed to retrieve details for mobile device ID:", uid, "Status code:", getattr(resp, 'status_code', 'None')) + url = "{}/api/v2/mobile-devices/{}/detail".format(base_url, uid) + extra, err, token, request_count = http_request("GET", url, config_kwargs=config_kwargs, token=token, request_count=request_count, base_url=base_url, client_id=client_id, client_secret=client_secret) + if err: + print("Failed to retrieve details for mobile device ID:", uid, err) continue - extra = json_decode(resp.body) if DEV_MODE: build_mobile_asset(extra) if not extra: - print("Invalid JSON for mobile detail:", resp.body) + print("Empty detail for mobile device ID:", uid) continue item.update(extra) @@ -321,7 +346,7 @@ def build_asset(item): manufacturer=os_hardware.get('manufacturer', ''), model=os_hardware.get('model', ''), hostnames=[name], - customAttributes=custom_attributes, + customAttributes=to_custom_attributes(custom_attributes), ) def build_assets(inventory): @@ -376,7 +401,7 @@ def build_mobile_asset(item): osVersion=os_hardware.get('os_version', ''), manufacturer=os_hardware.get('manufacturer', ''), model=os_hardware.get('model', ''), - customAttributes=custom_attributes + customAttributes=to_custom_attributes(custom_attributes), ) def build_mobile_assets(inventory): @@ -389,36 +414,20 @@ def build_mobile_assets(inventory): return assets def main(*args, **kwargs): - client_id = kwargs['access_key'] - client_secret = kwargs['access_secret'] + base_url = get_url_base(kwargs) + client_id = kwargs['client_id'] + client_secret = kwargs['client_secret'] - token, request_count = get_bearer_token(client_id, client_secret) + token, request_count = get_bearer_token(base_url, client_id, client_secret, kwargs) if not token: print("Failed to get bearer token") return None - # Build assets - assets = [] + # Assets are streamed page-by-page via report_assets. if COMPUTER_ASSETS: # Fetch and process computer inventory - inventory, token, request_count = get_jamf_inventory(token, request_count, client_id, client_secret) - if not inventory: - print("No inventory data found for computers") - - details, token, request_count = get_jamf_details(token, request_count, client_id, client_secret, inventory) - if not details: - print("No details retrieved for computers") - computer_assets = build_assets(details) - assets.extend(computer_assets) + _, token, request_count = stream_computer_assets(base_url, kwargs, token, request_count, client_id, client_secret) if MOBILE_ASSETS: # Fetch and process mobile device inventory - mobile_inventory, token, request_count = get_mobile_device_inventory(token, request_count, client_id, client_secret) - if not mobile_inventory: - print("No inventory data found for mobile devices") - - mobile_details, token, request_count = get_mobile_device_details(token, request_count, client_id, client_secret, mobile_inventory) - if not mobile_details: - print("No details retrieved for mobile devices") - mobile_assets = build_mobile_assets(mobile_details) - assets.extend(mobile_assets) - return assets + _, token, request_count = stream_mobile_assets(base_url, kwargs, token, request_count, client_id, client_secret) + return None diff --git a/kandji/README.md b/kandji/README.md index 181af27..d440d87 100644 --- a/kandji/README.md +++ b/kandji/README.md @@ -6,8 +6,8 @@ ## Kandji requirements +- Kandji API URL. - Kandji API Bearer Token. -- Kandji subdomain. ## Steps @@ -17,19 +17,18 @@ - Navigate to **Settings** > **Access** > **Add API Token** to create a new API key in the Kandji console. - Note down the **API Token** and tenant-specific **API URL**. 2. Find your Kandji API URL: - - This depends on your region (e.g., `https://SubDomain.api.eu.kandji.io`). - - Refer to the [Kandji API Documentation](https://support.kandji.io/kb/kandji-api) for the steps to get your tenet-specific **API URL** when creating an API token. + - This depends on your region (e.g., `https://SubDomain.api.eu.kandji.io/api/v1`). + - Refer to the [Kandji API Documentation](https://support.kandji.io/kb/kandji-api) for the steps to get your tenant-specific **API URL** when creating an API token. ### runZero configuration 1. Make any necessary changes to the script to align with your environment. - - Update the **Kandji_API_URL** variable in the script and set to your tenant-specific **API URL** from Kandji - (OPTIONAL) Modify API queries as needed to filter asset data. - (OPTIONAL) Adjust which attributes are included in runZero. 3. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials). - Select the type `Custom Integration Script Secrets`. - - Use the `access_secret` field for your **Kandji API Key**. - - Use a placeholder value like `foo` for `access_key` (unused in this integration). + - Use the `url` field for your tenant-specific Kandji API URL. + - Use the `api_token` field for your **Kandji API Key**. 4. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new). - Add a Name and Icon for the integration (e.g., "kandji"). - Toggle `Enable custom integration script` to input the finalized script. diff --git a/kandji/config.json b/kandji/config.json deleted file mode 100644 index b2f3d9e..0000000 --- a/kandji/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Kandji", "type": "inbound" } diff --git a/kandji/custom-integration-kandji.star b/kandji/custom-integration-kandji.star deleted file mode 100644 index f6d226d..0000000 --- a/kandji/custom-integration-kandji.star +++ /dev/null @@ -1,134 +0,0 @@ -## Kandji - -load('runzero.types', 'ImportAsset', 'NetworkInterface') -load('json', json_encode='encode', json_decode='decode') -load('http', http_get='get') -load('net', 'ip_address') - -KANDJI_API_URL = "https://{sub_domain}.kandji.io/api/v1" -PAGE_LIMIT = 300 # Number of devices to fetch per request - -def get_device_list(api_token): - """Fetch all devices from Kandji with pagination.""" - headers = { - "Authorization": "Bearer " + api_token, - "Accept": "application/json", - "Content-Type": "application/json" - } - - devices = [] - offset = 0 - - while True: - params = { - "limit": str(PAGE_LIMIT), - "offset": str(offset) - } - response = http_get(KANDJI_API_URL + "/devices", headers=headers, params=params) - - if response.status_code != 200: - print("Error fetching device list from Kandji", response.status_code) - break - - data = json_decode(response.body) - - if not data: - break - - devices.extend(data) - offset += PAGE_LIMIT - - return devices - -def get_device_details(api_token, device_id): - """Fetch detailed information for a single device.""" - headers = { - "Authorization": "Bearer " + api_token, - "Accept": "application/json", - "Content-Type": "application/json" - } - - url = "{}/devices/{}/details".format(KANDJI_API_URL, device_id) - response = http_get(url, headers=headers) - - if response.status_code != 200: - print("Error fetching details for device:", device_id) - return None - - return json_decode(response.body) - -def build_assets(api_token): - """Retrieve Kandji devices and transform them into runZero assets.""" - devices = get_device_list(api_token) - assets = [] - - for device in devices: - device_id = device.get("device_id", "") - details = get_device_details(api_token, device_id) - if not details: - continue - - general = details.get("general", "") - agent_details = details.get("kandji_agent", "") - network = details.get("network", "") - hardware_overview = details.get("hardware_overview", "") - - hostname = network.get("local_hostname", "") - mac_address = network.get("mac_address", "") - - ips = [network.get("ip_address", []) + network.get("public_ip", [])] - - serial_number = hardware_overview.get("serial_number", "") - os_version = general.get("os_version", "") - model = general.get("model", "") - - custom_attrs = { - "model": model, - "serial_number": serial_number - } - - assets.append( - ImportAsset( - id=device_id, - hostnames=[hostname], - networkInterfaces=[build_network_interface(ips,mac_address)], - os=model, - os_version=os_version, - customAttributes=custom_attrs - ) - ) - - return assets - -def build_network_interface(ips, mac=None): - """Convert IPs and MAC addresses into a NetworkInterface object""" - ip4s = [] - ip6s = [] - - for ip in ips[:99]: - if ip: - ip_addr = ip_address(ip) - if ip_addr == None: - continue - elif 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) - - return NetworkInterface(macAddress=mac, ipv4Addresses=ip4s, ipv6Addresses=ip6s) - -def main(**kwargs): - api_token = kwargs['access_secret'] - - assets = build_assets(api_token) - - if not assets: - print("No assets found in Kandji") - return None - - return assets diff --git a/kandji/kandji.star b/kandji/kandji.star new file mode 100644 index 0000000..dd3ea2b --- /dev/null +++ b/kandji/kandji.star @@ -0,0 +1,123 @@ +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-kandji", + "name": "Kandji", + "type": "inbound", + "description": "Imports devices from Kandji.", + "version": "26061000", + "params": [ + { + "key": "url", + "label": "Kandji API URL", + "type": "url", + "required": True, + "placeholder": "https://.api.kandji.io/api/v1", + }, + { + "key": "api_token", + "label": "API token", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +## Kandji + +load('runzero.types', 'ImportAsset', 'to_custom_attributes') +load('http', 'get_json', 'bearer') +load('kwargs', 'get_http_options') +load('net', 'network_interface') + +PAGE_LIMIT = 300 # Number of devices to fetch per request + +def _auth_headers(api_token): + return { + "Authorization": bearer(api_token), + "Accept": "application/json", + "Content-Type": "application/json", + } + +def stream_devices(api_url, api_token, config_kwargs): + """Fetch devices from Kandji with pagination, building and streaming each + page of assets (including per-device details) via report_assets so the full + device set is never held in memory. Returns the number of assets reported.""" + headers = _auth_headers(api_token) + http_options = get_http_options(config_kwargs, headers=headers) + reported = 0 + offset = 0 + while True: + params = {"limit": str(PAGE_LIMIT), "offset": str(offset)} + data, err = get_json(api_url + "/devices", params=params, **http_options) + if err: + print("Error fetching device list from Kandji:", err) + break + if not data: + break + reported += report_assets(build_assets(api_url, api_token, data, config_kwargs)) + offset += PAGE_LIMIT + return reported + +def get_device_details(api_url, api_token, device_id, config_kwargs): + """Fetch detailed information for a single device.""" + url = "{}/devices/{}/details".format(api_url, device_id) + data, err = get_json(url, **get_http_options(config_kwargs, headers=_auth_headers(api_token))) + if err: + print("Error fetching details for device:", device_id, err) + return None + return data + +def build_assets(api_url, api_token, devices, config_kwargs): + """Transform a page of Kandji devices into runZero assets.""" + assets = [] + + for device in devices: + device_id = device.get("device_id", "") + details = get_device_details(api_url, api_token, device_id, config_kwargs) + if not details: + continue + + general = details.get("general", {}) or {} + network = details.get("network", {}) or {} + hardware = details.get("hardware_overview", {}) or {} + + # network_interface() classifies v4/v6 automatically, strips + # ports/zones, dedupes, and returns None when nothing usable + # is present. + ips = [] + for key in ("ip_address", "public_ip"): + v = network.get(key) + if v: + if type(v) == "list": + ips.extend(v) + else: + ips.append(v) + nic = network_interface(mac=network.get("mac_address"), ips=ips) + nics = [nic] if nic else [] + + assets.append(ImportAsset( + id=device_id, + hostnames=[network.get("local_hostname", "")], + networkInterfaces=nics, + os=general.get("model", ""), + os_version=general.get("os_version", ""), + customAttributes=to_custom_attributes({ + "model": general.get("model"), + "serial_number": hardware.get("serial_number"), + }), + )) + + return assets + +def main(**kwargs): + api_url = kwargs['url'].rstrip('/') + api_token = kwargs['api_token'] + # Devices (and their details) are streamed page-by-page via report_assets. + reported = stream_devices(api_url, api_token, kwargs) + if not reported: + print("No assets found in Kandji") + return None diff --git a/kubernetes/README.md b/kubernetes/README.md new file mode 100644 index 0000000..7044e2b --- /dev/null +++ b/kubernetes/README.md @@ -0,0 +1,68 @@ +# Custom Integration: Kubernetes + +Pulls cluster Nodes (and, optionally, LoadBalancer / NodePort Services) from +the Kubernetes API server and imports them as runZero assets. + +## runZero requirements + +- Superuser access to the [Custom Integrations configuration](https://console.runzero.com/custom-integrations) +- An Explorer that has network reach to the Kubernetes API server + +## Kubernetes requirements + +- API server URL (e.g. `https://kubernetes.example.com:6443`) +- A ServiceAccount bearer token with read access to `nodes` (and + `services`, if you want LoadBalancer ingress IPs). + +### Create a read-only ServiceAccount and token + +```sh +kubectl create serviceaccount runzero -n kube-system +kubectl create clusterrolebinding runzero-readonly \ + --clusterrole=view \ + --serviceaccount=kube-system:runzero +kubectl -n kube-system create token runzero --duration=8760h +``` + +The `view` ClusterRole already grants `get`/`list`/`watch` on `nodes` and +`services` and does **not** grant access to Secrets or pod logs. + +## Steps + +### Kubernetes configuration + +1. Get the API server URL: `kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}'` +2. Create the ServiceAccount, ClusterRoleBinding, and token (see above). +3. If your apiserver presents a self-signed certificate, set + `INSECURE_SKIP_VERIFY = True` at the top of + `kubernetes.star`. Prefer leaving it `False` and + trusting the apiserver CA whenever possible. +4. To skip Services and only import Nodes, set + `INCLUDE_LOADBALANCER_SERVICES = False`. + +### runZero configuration + +1. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials) + - Type: `Custom Integration Script Secrets` + - `url`: the Kubernetes API server URL (e.g. `https://kubernetes.example.com:6443`) + - `bearer_token`: the ServiceAccount bearer token +2. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new) + - Add a Name and Icon + - Toggle `Enable custom integration script` and paste in the contents of + `kubernetes.star` + - 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 that can reach the apiserver + - Set the schedule and `Save` + +### What's next? + +- The task runs like any other ingestion task. Existing assets are merged + on hostname / MAC / IP; new ones are created when nothing matches. +- Search for imported assets with `custom_integration:`. +- Node assets carry a rich `k8s.node.*` attribute set + (kubelet/containerd versions, kernel, OS image, capacity, allocatable, + pod CIDRs, labels, taints). +- Service assets are tagged `k8s-service` plus a type-specific tag + (`k8s-svc-loadbalancer`, `k8s-svc-nodeport`). diff --git a/kubernetes/config.json b/kubernetes/config.json deleted file mode 100644 index 74c02d3..0000000 --- a/kubernetes/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Kubernetes", "type": "inbound" } diff --git a/kubernetes/kubernetes.star b/kubernetes/kubernetes.star new file mode 100644 index 0000000..780b6e5 --- /dev/null +++ b/kubernetes/kubernetes.star @@ -0,0 +1,274 @@ +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-kubernetes", + "name": "Kubernetes", + "type": "inbound", + "description": "Imports nodes, pods, and services from a Kubernetes cluster.", + "version": "26052700", + "params": [ + { + "key": "url", + "label": "API server URL", + "type": "url", + "required": True, + "placeholder": "https://k8s.example.com:6443", + }, + { + "key": "bearer_token", + "label": "Bearer token", + "type": "secret", + "required": True, + }, + { + "key": "include_loadbalancer_services", + "label": "Import LoadBalancer/NodePort services", + "type": "bool", + "required": False, + "default": True, + }, + { + "key": "request_timeout", + "label": "Request timeout (seconds)", + "type": "int", + "required": False, + "default": 300, + "min": 10, + "max": 3600, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +# Kubernetes -> runZero ImportAsset Integration +# +# Pulls cluster Nodes from the Kubernetes API server and converts each +# Node into a runZero ImportAsset. Optionally also pulls Services of +# type LoadBalancer / NodePort and emits them as additional assets so +# that exposed ingress IPs show up in inventory. +# +# Credentials (runZero "Custom Integration Script Secrets"): +# url : Kubernetes API server URL, e.g. +# https://kubernetes.example.com:6443 +# bearer_token : ServiceAccount bearer token with at least +# `get`/`list` on nodes (and services, if enabled). +# +# To create a token: +# kubectl create serviceaccount runzero -n kube-system +# kubectl create clusterrolebinding runzero-readonly \ +# --clusterrole=view \ +# --serviceaccount=kube-system:runzero +# kubectl -n kube-system create token runzero --duration=8760h +# +# Many on-prem clusters present a self-signed apiserver certificate. +# Use the tls_disable_validation parameter only when you trust the network path. + +load("runzero.types", "ImportAsset", "to_custom_attributes") +load("net", "network_interface") +load("http", "get_json", "bearer") +load("kwargs", "get_bool", "get_int", "get_http_options") + +# --- Defaults --- +DEFAULT_INCLUDE_LOADBALANCER_SERVICES = True +DEFAULT_REQUEST_TIMEOUT = 300 + + +def _log(msg): + print("[KUBERNETES] " + msg) + + +def _api_get(base_url, path, token, timeout_seconds, config_kwargs): + url = base_url.rstrip("/") + path + headers = { + "Authorization": bearer(token), + "Accept": "application/json", + } + return get_json( + url, + timeout=timeout_seconds, + **get_http_options(config_kwargs, headers=headers) + ) + + +def _node_to_asset(node): + meta = node.get("metadata") or {} + status = node.get("status") or {} + info = status.get("nodeInfo") or {} + spec = node.get("spec") or {} + labels = meta.get("labels") or {} + + node_id = meta.get("uid") + if not node_id: + return None + + addresses = status.get("addresses") or [] + ips = [] + hostnames = [] + for a in addresses: + a_type = a.get("type", "") + a_addr = a.get("address", "") + if not a_addr: + continue + if a_type in ("InternalIP", "ExternalIP"): + ips.append(a_addr) + elif a_type in ("Hostname", "InternalDNS", "ExternalDNS"): + hostnames.append(a_addr) + + name = meta.get("name", "") + if name and name not in hostnames: + hostnames.insert(0, name) + + nic = network_interface(ips=ips) + nics = [nic] if nic else [] + + attrs = to_custom_attributes({ + "k8s.node.name": name, + "k8s.node.uid": node_id, + "k8s.node.creation_timestamp": meta.get("creationTimestamp"), + "k8s.node.provider_id": spec.get("providerID"), + "k8s.node.pod_cidr": spec.get("podCIDR"), + "k8s.node.pod_cidrs": spec.get("podCIDRs"), + "k8s.node.unschedulable": spec.get("unschedulable", False), + "k8s.node.taints": spec.get("taints"), + "k8s.node.architecture": info.get("architecture"), + "k8s.node.boot_id": info.get("bootID"), + "k8s.node.container_runtime": info.get("containerRuntimeVersion"), + "k8s.node.kernel_version": info.get("kernelVersion"), + "k8s.node.kube_proxy_version": info.get("kubeProxyVersion"), + "k8s.node.kubelet_version": info.get("kubeletVersion"), + "k8s.node.machine_id": info.get("machineID"), + "k8s.node.system_uuid": info.get("systemUUID"), + "k8s.node.os_image": info.get("osImage"), + "k8s.node.capacity": status.get("capacity"), + "k8s.node.allocatable": status.get("allocatable"), + "k8s.node.labels": labels, + }, list_join="json") + + return ImportAsset( + id="k8s-node-" + node_id, + hostnames=hostnames, + networkInterfaces=nics, + os=info.get("operatingSystem", "linux"), + osVersion=info.get("osImage", ""), + manufacturer=labels.get("node.kubernetes.io/instance-type", ""), + deviceType="Kubernetes Node", + tags=["kubernetes", "k8s-node"], + customAttributes=attrs, + ) + + +def _service_to_asset(svc): + meta = svc.get("metadata") or {} + spec = svc.get("spec") or {} + status = svc.get("status") or {} + + svc_uid = meta.get("uid") + if not svc_uid: + return None + + svc_type = spec.get("type", "") + if svc_type not in ("LoadBalancer", "NodePort"): + return None + + ips = [] + hostnames = [] + + # ClusterIP(s) + cluster_ips = spec.get("clusterIPs") or [] + for ip in cluster_ips: + if ip and ip != "None": + ips.append(ip) + cluster_ip = spec.get("clusterIP") + if cluster_ip and cluster_ip != "None" and cluster_ip not in ips: + ips.append(cluster_ip) + + # LoadBalancer ingress + lb = status.get("loadBalancer") or {} + for ing in lb.get("ingress") or []: + if ing.get("ip"): + ips.append(ing.get("ip")) + if ing.get("hostname"): + hostnames.append(ing.get("hostname")) + + # External IPs + for ip in spec.get("externalIPs") or []: + ips.append(ip) + + name = meta.get("name", "") + namespace = meta.get("namespace", "") + fqdn = "{}.{}.svc".format(name, namespace) if name and namespace else name + if fqdn: + hostnames.insert(0, fqdn) + + nic = network_interface(ips=ips) + if not nic: + return None + + attrs = to_custom_attributes({ + "k8s.service.name": name, + "k8s.service.namespace": namespace, + "k8s.service.uid": svc_uid, + "k8s.service.type": svc_type, + "k8s.service.creation_timestamp": meta.get("creationTimestamp"), + "k8s.service.cluster_ip": cluster_ip, + "k8s.service.external_name": spec.get("externalName"), + "k8s.service.session_affinity": spec.get("sessionAffinity"), + "k8s.service.ports": spec.get("ports"), + "k8s.service.selector": spec.get("selector"), + "k8s.service.labels": meta.get("labels"), + }, list_join="json") + + return ImportAsset( + id="k8s-svc-" + svc_uid, + hostnames=hostnames, + networkInterfaces=[nic], + deviceType="Kubernetes Service", + tags=["kubernetes", "k8s-service", "k8s-svc-" + svc_type.lower()], + customAttributes=attrs, + ) + + +def main(*args, **kwargs): + base_url = kwargs.get("url", "") + token = kwargs.get("bearer_token", "") + include_loadbalancer_services = get_bool(kwargs, "include_loadbalancer_services", DEFAULT_INCLUDE_LOADBALANCER_SERVICES) + timeout_seconds = get_int(kwargs, "request_timeout", DEFAULT_REQUEST_TIMEOUT) + + if not base_url: + _log("ERROR: url (Kubernetes API server URL) is required.") + return [] + if not token: + _log("ERROR: bearer_token (ServiceAccount bearer token) is required.") + return [] + + assets = [] + + nodes_resp, err = _api_get(base_url, "/api/v1/nodes", token, timeout_seconds, kwargs) + if err: + _log("ERROR: failed to list nodes: " + err) + return [] + for node in (nodes_resp or {}).get("items", []): + a = _node_to_asset(node) + if a: + assets.append(a) + _log("nodes: imported {} asset(s)".format(len(assets))) + + if include_loadbalancer_services: + svc_count = 0 + svcs_resp, err = _api_get(base_url, "/api/v1/services", token, timeout_seconds, kwargs) + if err: + _log("WARN: failed to list services: " + err) + else: + for svc in (svcs_resp or {}).get("items", []): + a = _service_to_asset(svc) + if a: + assets.append(a) + svc_count += 1 + _log("services: imported {} asset(s)".format(svc_count)) + + # Stream assets to runZero via report_assets instead of returning a list. + reported = report_assets(assets) + _log("SUCCESS: reported {} ImportAsset(s)".format(reported)) + return None diff --git a/lima-charlie/README.md b/lima-charlie/README.md index d9880a5..f643a29 100644 --- a/lima-charlie/README.md +++ b/lima-charlie/README.md @@ -28,8 +28,8 @@ - Set boolean values in ARCHITECTURE to control what sensor architectures are imported. By default, chromium and usp_adapter sensors are not imported because they do not represent traditional cyber assets. 2. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials). - Select the type `Custom Integration Script Secrets`. - - Use the `access_key` field for your Lima Charlie Organization ID (`oid`). - - Use the `access_secret` field for your API Access Token. + - Use the `organization_id` field for your Lima Charlie Organization ID (`oid`). + - Use the `api_token` field for your API Access Token. 3. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new). - Add a Name and Icon for the integration (e.g., "lima-charlie"). - Toggle `Enable custom integration script` to input the finalized script. diff --git a/lima-charlie/config.json b/lima-charlie/config.json deleted file mode 100644 index 2fb30c7..0000000 --- a/lima-charlie/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Lima Charlie", "type": "inbound" } diff --git a/lima-charlie/custom-integration-lima-charlie.star b/lima-charlie/lima-charlie.star similarity index 61% rename from lima-charlie/custom-integration-lima-charlie.star rename to lima-charlie/lima-charlie.star index 1005bad..ffe8d7d 100644 --- a/lima-charlie/custom-integration-lima-charlie.star +++ b/lima-charlie/lima-charlie.star @@ -1,7 +1,34 @@ -load('runzero.types', 'ImportAsset', 'NetworkInterface') -load('json', json_encode='encode', json_decode='decode') -load('net', 'ip_address') -load('http', http_post='post', http_get='get', 'url_encode') +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-limacharlie", + "name": "LimaCharlie", + "type": "inbound", + "description": "Imports endpoints from LimaCharlie.", + "version": "26052700", + "params": [ + { + "key": "organization_id", + "label": "Organization ID (OID)", + "type": "string", + "required": True, + }, + { + "key": "api_token", + "label": "API access token", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +load('runzero.types', 'ImportAsset', 'to_custom_attributes') +load('net', 'network_interface') +load('http', 'get_json', 'post_json', 'bearer') +load('kwargs', 'get_http_options') load('uuid', 'new_uuid') LIMACHARLIE_JWT_URL = 'https://jwt.limacharlie.io' @@ -39,20 +66,18 @@ ARCHITECTURE = { 9: False, # usp_adapter } -def get_token(oid, token): +def get_token(oid, token, config_kwargs): url = '{}/?oid={}'.format(LIMACHARLIE_JWT_URL, oid) headers = { 'Content-Type': 'application/json', 'X-LC-Secret': token } - response = http_post(url, headers=headers) - if response.status_code != 200: - print('Failed to fetch token. ', response) + response_json, err = post_json(url, **get_http_options(config_kwargs, headers=headers)) + if err: + print('Failed to fetch token:', err) return None - else: - response_json = json_decode(response.body) - return response_json['jwt'] + return (response_json or {}).get('jwt') def build_assets(sensors): assets = [] @@ -78,9 +103,9 @@ def build_assets(sensors): mac = item.get('mac_addr', '') if mac: mac = mac.replace("-", ":") - network = build_network_interface(ips=ips, mac=mac) + network = network_interface(ips=ips, mac=mac) else: - network = build_network_interface(ips=ips, mac=None) + network = network_interface(ips=ips, mac=None) # Parse additional attributes collected from sensors, ignore attributes defined in ATTRIBS_TO_IGNORE custom_attrs = {} @@ -94,44 +119,30 @@ def build_assets(sensors): id=sid, hostnames=[hostname], networkInterfaces=[network], - customAttributes=custom_attrs + customAttributes=to_custom_attributes(custom_attrs), ) ) return assets -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) - - return NetworkInterface(macAddress=mac, ipv4Addresses=ip4s, ipv6Addresses=ip6s) - def main(**kwargs): - oid = kwargs['access_key'] - access_token = kwargs['access_secret'] - token = get_token(oid, access_token) + oid = kwargs['organization_id'] + access_token = kwargs['api_token'] + token = get_token(oid, access_token, kwargs) # Get sensors url = '{}/{}/{}'.format(LIMACHARLIE_BASE_URL, 'sensors', oid) - sensors = http_get(url, headers={"Content-Type": "application/json", "Authorization": "Bearer " + token}) - if sensors.status_code != 200: - print('Failed to fetch sensors. ', sensors) + data, err = get_json(url, **get_http_options(kwargs, headers={"Authorization": bearer(token)})) + if err: + print('Failed to fetch sensors:', err) return None - sensors_json = json_decode(sensors.body)['sensors'] + sensors_json = (data or {}).get('sensors', []) - assets = build_assets(sensors_json) - if not assets: + # Stream assets to runZero via report_assets so the full sensor set is + # never held in memory. + reported = report_assets(build_assets(sensors_json)) + if not reported: print('No sensors were retrieved.') - - return assets \ No newline at end of file + + return None \ No newline at end of file diff --git a/linux-ssh/README.md b/linux-ssh/README.md new file mode 100644 index 0000000..684b68a --- /dev/null +++ b/linux-ssh/README.md @@ -0,0 +1,33 @@ +# Linux via SSH + +Inbound integration that authenticates to a Linux/Unix host over SSH, +collects basic identification facts, and reports the system as a single +runZero asset. + +## What it collects + +- Fully qualified hostname (`hostname -f`) +- OS distribution and version (`/etc/os-release`) +- Kernel release (`uname -r`) +- `/etc/machine-id` for stable asset identity +- IPv4 / IPv6 addresses and MAC addresses for every interface + reported by `ip link` / `ip addr` + +## Authentication + +Provide **either** a password **or** an OpenSSH private key (PEM format). +You may also supply a passphrase for an encrypted key. + +The `host_key` field accepts the server's public key in +`authorized_keys` format (e.g. `ssh-ed25519 AAAA…`). When supplied, the +script pins the host key and refuses to connect to any other key. If +left blank, the host key is not verified — only use that mode in +trusted networks where the SSH endpoint identity is otherwise enforced. + +## Required tools on the target + +- `bash`, `cat`, `hostname`, `uname` +- `ip` (from `iproute2`) for interface enumeration + +If the target image does not provide `iproute2`, the network interfaces +list will be empty but the asset will still be reported. diff --git a/linux-ssh/linux-ssh.star b/linux-ssh/linux-ssh.star new file mode 100644 index 0000000..43b3a57 --- /dev/null +++ b/linux-ssh/linux-ssh.star @@ -0,0 +1,150 @@ +CONFIG = { + "id": "runzero-linux-ssh", + "name": "Linux via SSH", + "type": "inbound", + "description": "Collects host facts from Linux/Unix targets over SSH and reports them as assets.", + "version": "26052700", + "params": [ + {"key": "host", "label": "Target host", "type": "string", "required": True}, + {"key": "port", "label": "SSH port", "type": "int", "required": False, "default": 22, "min": 1, "max": 65535}, + {"key": "username", "label": "Username", "type": "string", "required": True}, + {"key": "password", "label": "Password", "type": "secret", "required": False}, + {"key": "private_key", "label": "Private key (PEM)", "type": "textarea", "required": False}, + {"key": "private_key_passphrase", "label": "Private key passphrase", "type": "secret", "required": False}, + {"key": "host_key", "label": "Expected host public key (authorized_keys format)", "type": "textarea", "required": False}, + {"key": "timeout", "label": "Connection timeout (seconds)", "type": "int", "required": False, "default": 30, "min": 1, "max": 600}, + ], +} + +load("runzero.types", "ImportAsset", "NetworkInterface") +load("runzero.ssh", ssh_dial="dial") +load("net", "ip_address") +load("kwargs", "require", "get_string", "get_int") + + +def _run(session, cmd): + stdout, stderr, code = session.run(cmd) + if code != 0: + return "" + return stdout.strip() + + +def _hostname(session): + return _run(session, "hostname -f 2>/dev/null || hostname") + + +def _os_release(session): + raw = _run(session, "cat /etc/os-release 2>/dev/null") + name = "" + version = "" + for line in raw.split("\n"): + if line.startswith("PRETTY_NAME="): + name = line.split("=", 1)[1].strip('"') + if line.startswith("VERSION_ID="): + version = line.split("=", 1)[1].strip('"') + return name, version + + +def _macs_and_ips(session): + out = _run(session, "ip -o link show 2>/dev/null; echo ---; ip -o -4 addr show 2>/dev/null; echo ---; ip -o -6 addr show 2>/dev/null") + parts = out.split("---") + mac_by_iface = {} + v4 = {} + v6 = {} + if len(parts) >= 1: + for line in parts[0].split("\n"): + line = line.strip() + if not line: + continue + # "1: lo: mtu 65536 ... link/loopback 00:00:00:00:00:00 brd ..." + chunks = line.split() + if len(chunks) < 2: + continue + iface = chunks[1].rstrip(":") + if "link/ether" in line: + idx = chunks.index("link/ether") + if idx + 1 < len(chunks): + mac_by_iface[iface] = chunks[idx + 1] + if len(parts) >= 2: + for line in parts[1].split("\n"): + chunks = line.split() + if len(chunks) < 4: + continue + iface = chunks[1] + cidr = chunks[3] + ip = cidr.split("/")[0] + v4.setdefault(iface, []).append(ip) + if len(parts) >= 3: + for line in parts[2].split("\n"): + chunks = line.split() + if len(chunks) < 4: + continue + iface = chunks[1] + cidr = chunks[3] + ip = cidr.split("/")[0] + v6.setdefault(iface, []).append(ip) + + interfaces = [] + for iface, mac in mac_by_iface.items(): + v4s = [] + v6s = [] + for ip in v4.get(iface, []): + addr = ip_address(ip) + if addr: + v4s.append(addr) + for ip in v6.get(iface, []): + addr = ip_address(ip) + if addr: + v6s.append(addr) + interfaces.append(NetworkInterface(macAddress=mac, ipv4Addresses=v4s, ipv6Addresses=v6s)) + return interfaces + + +def main(*args, **kwargs): + require(kwargs, "host", "username") + host = get_string(kwargs, "host") + port = get_int(kwargs, "port", default=22) + username = get_string(kwargs, "username") + password = get_string(kwargs, "password", default="") + private_key = get_string(kwargs, "private_key", default="") + private_key_passphrase = get_string(kwargs, "private_key_passphrase", default="") + host_key = get_string(kwargs, "host_key", default="") + timeout = get_int(kwargs, "timeout", default=30) + + if not password and not private_key: + print("either password or private_key is required") + return [] + + session = ssh_dial( + host=host, + port=port, + username=username, + password=password, + private_key=private_key, + private_key_passphrase=private_key_passphrase, + host_key=host_key, + timeout=timeout, + ) + hostname = _hostname(session) + os_name, os_version = _os_release(session) + kernel = _run(session, "uname -r") + machine_id = _run(session, "cat /etc/machine-id 2>/dev/null || cat /var/lib/dbus/machine-id 2>/dev/null") + interfaces = _macs_and_ips(session) + session.close() + + asset_id = machine_id if machine_id else "{}@{}".format(username, host) + asset = ImportAsset( + id=asset_id, + hostnames=[hostname] if hostname else [], + os=os_name, + osVersion=os_version, + networkInterfaces=interfaces, + customAttributes={ + "ssh.target": "{}:{}".format(host, port), + "kernel": kernel, + "machine_id": machine_id, + }, + ) + # Stream the asset to runZero via report_assets instead of returning a list. + report_assets(asset) + return None diff --git a/manage-engine-endpoint-central/README.md b/manage-engine-endpoint-central/README.md index b14717d..af440ac 100644 --- a/manage-engine-endpoint-central/README.md +++ b/manage-engine-endpoint-central/README.md @@ -8,7 +8,7 @@ - Valid Endpoint Central URL (`EC_HOST`) for your account. - API Version (e.g., `1.4`, default is 1.4). -- Endpoint Central API token (`access_secret`) with permissions to access inventory data. +- Endpoint Central API token (`oauth_token`) with permissions to access inventory data. ## Steps @@ -37,8 +37,8 @@ 2. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials). - Select the type `Custom Integration Script Secrets`. - - Use the `access_key` field for your Endpoint Central URL. - - Use the `access_secret` field for your API token. + - Use the `url` field for your Endpoint Central URL. + - Use the `oauth_token` field for your API token. 3. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new). - Add a Name and Icon for the integration (e.g., "endpoint-central"). diff --git a/manage-engine-endpoint-central/config.json b/manage-engine-endpoint-central/config.json deleted file mode 100644 index ec0f1c2..0000000 --- a/manage-engine-endpoint-central/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Manage Engine Endpoint Central", "type": "inbound" } diff --git a/manage-engine-endpoint-central/custom-integration-endpoint-central.star b/manage-engine-endpoint-central/endpoint-central.star similarity index 55% rename from manage-engine-endpoint-central/custom-integration-endpoint-central.star rename to manage-engine-endpoint-central/endpoint-central.star index 7f9c0cd..f079f4b 100644 --- a/manage-engine-endpoint-central/custom-integration-endpoint-central.star +++ b/manage-engine-endpoint-central/endpoint-central.star @@ -1,10 +1,37 @@ -load('runzero.types', 'ImportAsset', 'NetworkInterface') -load('json', json_encode='encode', json_decode='decode') +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-manageengine-endpoint-central", + "name": "ManageEngine Endpoint Central", + "type": "inbound", + "description": "Imports endpoints from ManageEngine Endpoint Central.", + "version": "26061000", + "params": [ + { + "key": "url", + "label": "Endpoint Central URL", + "type": "url", + "required": True, + "placeholder": "https://ec.example.com", + }, + { + "key": "oauth_token", + "label": "OAuth token", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +load('runzero.types', 'ImportAsset', 'NetworkInterface', 'to_custom_attributes') load('net', 'ip_address') -load('http', http_get='get') +load('http', 'get_json') +load('kwargs', 'get_url_base', 'get_http_options') load('uuid', 'new_uuid') -EC_HOST = '' API_VERSION = '1.4' SCAN_ENDPOINT = '/api/' + API_VERSION + '/inventory/scancomputers' PAGE_LIMIT = 1000 @@ -45,44 +72,47 @@ def build_assets(devices): assets.append( ImportAsset( id=asset_id, - hostnames=[hostname] if hostname else [], + hostnames=[hostname], networkInterfaces=net_ifaces, - customAttributes=custom, + customAttributes=to_custom_attributes(custom), ) ) return assets def main(**kwargs): - # access_secret is your auth_token - token = kwargs['access_secret'] + # oauth_token is your auth token. + base_url = get_url_base(kwargs) + token = kwargs['oauth_token'] headers = { 'Authorization': token, 'Accept': 'application/json', } + http_options = get_http_options(kwargs, headers=headers) page = 1 - all_devices = [] + reported = 0 while True: - url = 'https://' + EC_HOST + SCAN_ENDPOINT + url = base_url + SCAN_ENDPOINT params = {"pagelimit": PAGE_LIMIT, "page": page} - resp = http_get(url, headers=headers, params=params, timeout=3600) - if resp.status_code != 200: - print('Scan API error:', resp.status_code, resp.body) + body, err = get_json(url, params=params, timeout=3600, **http_options) + if err: + print('Scan API error:', err) return None - body = json_decode(resp.body) + body = body or {} msg = body.get('message_response', {}) devices = msg.get('scancomputers', []) if not devices: break - all_devices.extend(devices) + # Build and stream each page via report_assets so the full device set + # is never held in memory. + reported += report_assets(build_assets(devices)) if len(devices) < PAGE_LIMIT: break page += 1 - if not all_devices: + if not reported: print('No devices returned') - return None - return build_assets(all_devices) \ No newline at end of file + return None \ No newline at end of file diff --git a/moysle/README.md b/moysle/README.md index 34cc20e..f6979df 100644 --- a/moysle/README.md +++ b/moysle/README.md @@ -6,8 +6,8 @@ ## Moysle Requirements -- Moysle API token (`access_key`). -- Moysle admin account email and password, provided in `access_secret` as a JSON/dict (`{"email": "", "password": ""}` or `{"username": "", "password": ""}`). +- Moysle API token (`api_token`). +- Moysle admin account email and password, provided in `email` and `password` fields. The `legacy_credentials` JSON/dict field remains supported for existing credentials. - Account must have permission to access device inventory. ## Steps @@ -52,8 +52,8 @@ 2. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials). - Select the type `Custom Integration Script Secrets`. - - Use the `access_key` field for your API token. - - Use the `access_secret` field as JSON/dict with keys `{"email": "", "password": ""}` (or `username`). Pre-issued bearer tokens are not used by the script. + - Use the `api_token` field for your API token. + - Use the `email` and `password` fields for the admin login. Existing credentials can continue to use `legacy_credentials` as JSON/dict with keys `{"email": "", "password": ""}` (or `username`). Pre-issued bearer tokens are not used by the script. 3. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new). - Add a Name and Icon for the integration (e.g., `moysle`). diff --git a/moysle/config.json b/moysle/config.json deleted file mode 100644 index 07ab234..0000000 --- a/moysle/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Moysle", "type": "inbound" } diff --git a/moysle/custom-integration-moysle.star b/moysle/moysle.star similarity index 70% rename from moysle/custom-integration-moysle.star rename to moysle/moysle.star index 3567077..5aeb4c4 100644 --- a/moysle/custom-integration-moysle.star +++ b/moysle/moysle.star @@ -1,15 +1,60 @@ +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-mosyle", + "name": "Mosyle", + "type": "inbound", + "description": "Imports devices from Mosyle.", + "version": "26061000", + "params": [ + { + "key": "api_token", + "label": "API token", + "type": "secret", + "required": True, + "description": "Mosyle API token (sent in headers)", + }, + { + "key": "email", + "label": "Account email", + "type": "string", + "required": False, + "group": "Account login", + }, + { + "key": "password", + "label": "Account password", + "type": "secret", + "required": False, + "group": "Account login", + }, + { + "key": "legacy_credentials", + "label": "Legacy JSON credential", + "type": "secret", + "required": False, + "group": "Legacy", + "description": "Back-compat: JSON object with email and password", + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} load('requests', 'Session') load('json', json_encode='encode', json_decode='decode') -load('runzero.types', 'ImportAsset', 'NetworkInterface') -load('net', 'ip_address') +load('runzero.types', 'ImportAsset') +load('net', 'network_interface') load('flatten_json', 'flatten') +load('kwargs', 'get_bool') BASE_URL = "https://managerapi.mosyle.com/v2" def parse_credentials(secret): """ - Parse access_secret provided as a dict or JSON string containing email/username and password. + Parse legacy_credentials provided as a dict or JSON string containing email/username and password. """ if not secret: return None, None @@ -19,7 +64,7 @@ def parse_credentials(secret): if secret.find("{") != -1: creds = json_decode(secret) else: - print("access_secret must be a JSON string with email/password") + print("legacy_credentials must be a JSON string with email/password") return None, None if type(creds) == "dict": @@ -28,7 +73,7 @@ def parse_credentials(secret): if email and password: return email, password else: - print("Missing email or password in access_secret") + print("Missing email or password in legacy_credentials") return None, None return None, None @@ -44,7 +89,7 @@ def get_bearer_token(session, access_token, email, password): "email": email, "password": password, } - resp = session.post(login_url, body=bytes(json_encode(payload))) + resp = session.post(login_url, json=payload) if not resp or resp.status_code != 200: print("Login failed: {}".format(resp.status_code if resp else "no response")) return None @@ -63,28 +108,6 @@ def get_bearer_token(session, access_token, email, password): return None -def build_network_interface(mac, ips): - """ - Build a NetworkInterface from a MAC and list of IP strings. - """ - ip4s = [] - ip6s = [] - for ip in ips: - if not ip: - continue - # IPv6 has a %interface appended - ip = ip.split("%")[0] - addr = ip_address(ip) - if addr.version == 4: - ip4s.append(addr) - elif addr.version == 6: - ip6s.append(addr) - - if not mac and not ip4s and not ip6s: - return None - - return NetworkInterface(macAddress=mac or None, ipv4Addresses=ip4s, ipv6Addresses=ip6s) - def collect_hostnames(device): names = [] @@ -125,18 +148,21 @@ def build_custom_attributes(device, used_keys): def main(*args, **kwargs): """ Custom integration for importing Mosyle device inventory into runZero. - Requires access_key (API token) and access_secret (JSON or dict with email/username and password). + Requires api_token and legacy_credentials (JSON or dict with email/username and password). """ - api_token = kwargs.get("access_key") - email, password = parse_credentials(kwargs.get("access_secret")) + api_token = kwargs.get("api_token") + email = kwargs.get("email") + password = kwargs.get("password") + if not email or not password: + email, password = parse_credentials(kwargs.get("legacy_credentials")) if not api_token or not email or not password: print("Missing required credentials") return [] - session = Session() + session = Session(insecure_skip_verify=get_bool(kwargs, 'tls_disable_validation', False)) session.headers.set("Content-Type", "application/json") session.headers.set("Accept", "application/json") - session.headers.set("User-Agent", "runZeroCustomScript/1.0") + session.headers.set("User-Agent", kwargs.get("http_user_agent") or "runZeroCustomScript/1.0") bearer = get_bearer_token(session, api_token, email, password) if not bearer: @@ -144,7 +170,7 @@ def main(*args, **kwargs): session.headers.set("Authorization", "Bearer {}".format(bearer)) - assets = [] + reported = 0 for os_type in ["ios", "mac", "tvos", "visionos"]: print("Fetching {} devices".format(os_type)) @@ -160,7 +186,7 @@ def main(*args, **kwargs): }, } - device_resp = session.post(list_url, body=bytes(json_encode(list_payload))) + device_resp = session.post(list_url, json=list_payload) if not device_resp or device_resp.status_code != 200: print("Device list request failed on page {}: {}".format(page, device_resp.status_code if device_resp else "no response")) break @@ -171,6 +197,7 @@ def main(*args, **kwargs): if not devices: break + page_assets = [] for d in devices: device_id = d.get("deviceudid") or d.get("serial_number") or "" if not device_id: @@ -190,11 +217,11 @@ def main(*args, **kwargs): network_interfaces = [] if len(wifi_ips) > 0 and wifi_mac: - wifi_iface = build_network_interface(wifi_mac, wifi_ips) + wifi_iface = network_interface(ips=wifi_mac, mac=wifi_ips) network_interfaces.append(wifi_iface) if len(eth_ips) > 0 and eth_mac: - eth_iface = build_network_interface(eth_mac, eth_ips) + eth_iface = network_interface(ips=eth_mac, mac=eth_ips) network_interfaces.append(eth_iface) model = d.get("device_model_name") or d.get("model_name") or d.get("device_model") or d.get("model") or "" @@ -235,8 +262,14 @@ def main(*args, **kwargs): tags=tags, customAttributes=custom_attrs, ) - assets.append(asset) + page_assets.append(asset) + # Build and stream each page via report_assets so the full device + # set is never held in memory. + reported += report_assets(page_assets) page += 1 - return assets + if not reported: + print("no assets") + + return None diff --git a/mssql-databases/README.md b/mssql-databases/README.md new file mode 100644 index 0000000..cff637b --- /dev/null +++ b/mssql-databases/README.md @@ -0,0 +1,17 @@ +# Microsoft SQL Server databases + +Inbound integration that connects to a SQL Server instance using the +`runzero.sql` module and emits one runZero asset per database, with +recovery model, state, collation, creation date, and size metadata. + +## Required permissions + +The account used must be able to read `sys.databases` and +`sys.master_files`. The built-in `public` role is sufficient on most +deployments. + +## DSN format + +Internally the script builds a `sqlserver://` URL DSN consumed by the +`github.com/microsoft/go-mssqldb` driver. TLS encryption is on by +default; disable only on isolated test instances. diff --git a/mssql-databases/mssql-databases.star b/mssql-databases/mssql-databases.star new file mode 100644 index 0000000..bc0e7cc --- /dev/null +++ b/mssql-databases/mssql-databases.star @@ -0,0 +1,98 @@ +CONFIG = { + "id": "runzero-mssql-databases", + "name": "Microsoft SQL Server databases", + "type": "inbound", + "description": "Connects to a SQL Server instance and emits one asset per database with size and recovery model metadata.", + "version": "26052700", + "params": [ + {"key": "host", "label": "SQL Server host", "type": "string", "required": True}, + {"key": "port", "label": "Port", "type": "int", "required": False, "default": 1433, "min": 1, "max": 65535}, + {"key": "username", "label": "Username", "type": "string", "required": True}, + {"key": "password", "label": "Password", "type": "secret", "required": True}, + {"key": "database", "label": "Initial catalog", "type": "string", "required": False, "default": "master"}, + {"key": "encrypt", "label": "Require TLS encryption", "type": "bool", "required": False, "default": True}, + {"key": "timeout", "label": "Query timeout (seconds)", "type": "int", "required": False, "default": 30, "min": 1, "max": 600}, + ], +} + +load("runzero.types", "ImportAsset") +load("runzero.sql", sql_connect="connect") +load("kwargs", "require", "get_string", "get_int", "get_bool") + + +_QUERY = """ +SELECT + d.database_id, + d.name, + d.recovery_model_desc, + d.state_desc, + d.collation_name, + d.create_date, + SUM(CAST(mf.size AS BIGINT)) * 8 AS size_kb +FROM sys.databases d +LEFT JOIN sys.master_files mf ON mf.database_id = d.database_id +GROUP BY d.database_id, d.name, d.recovery_model_desc, d.state_desc, d.collation_name, d.create_date +ORDER BY d.name +""" + + +def _dsn(host, port, username, password, database, encrypt): + enc = "true" if encrypt else "disable" + return "sqlserver://{}:{}@{}:{}?database={}&encrypt={}".format( + username, password, host, port, database, enc, + ) + + +def main(*args, **kwargs): + require(kwargs, "host", "username", "password") + host = get_string(kwargs, "host") + port = get_int(kwargs, "port", default=1433) + username = get_string(kwargs, "username") + password = get_string(kwargs, "password") + database = get_string(kwargs, "database", default="master") + encrypt = get_bool(kwargs, "encrypt", default=True) + timeout = get_int(kwargs, "timeout", default=30) + + dsn = _dsn(host, port, username, password, database, encrypt) + db = sql_connect(driver="mssql", dsn=dsn, timeout=timeout) + + server_info = db.query("SELECT @@SERVERNAME AS server, @@VERSION AS version") + server_name = host + server_version = "" + if len(server_info) > 0: + row = server_info[0] + if row.get("server"): + server_name = row["server"] + if row.get("version"): + server_version = row["version"] + + rows = db.query(_QUERY) + db.close() + + assets = [] + for row in rows: + db_id = row.get("database_id") + name = row.get("name") or "" + asset = ImportAsset( + id="mssql://{}:{}/{}".format(host, port, name), + hostnames=[server_name], + os="Microsoft SQL Server", + osVersion=server_version, + customAttributes={ + "database.id": "{}".format(db_id) if db_id != None else "", + "database.name": name, + "database.recovery_model": row.get("recovery_model_desc") or "", + "database.state": row.get("state_desc") or "", + "database.collation": row.get("collation_name") or "", + "database.created": row.get("create_date") or "", + "database.size_kb": "{}".format(row.get("size_kb") or 0), + "server.host": host, + "server.port": "{}".format(port), + }, + ) + assets.append(asset) + + # Stream assets to runZero via report_assets instead of returning a list. + if not report_assets(assets): + print("no assets") + return None diff --git a/netskope/README.md b/netskope/README.md index 9aac603..f84c399 100644 --- a/netskope/README.md +++ b/netskope/README.md @@ -27,7 +27,7 @@ 2. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials). - Select the type `Custom Integration Script Secrets`. - Leave the `access_client` blank. - - Use the `access_secret` field for your Netskope API token. + - Use the `api_token` field for your Netskope API token. 3. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new). - Add a Name and Icon for the integration (e.g., "netskope"). - Toggle `Enable custom integration script` to input the finalized script. diff --git a/netskope/config.json b/netskope/config.json deleted file mode 100644 index 044a9a1..0000000 --- a/netskope/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Netskope", "type": "inbound" } \ No newline at end of file diff --git a/netskope/custom-integration-netskope.star b/netskope/custom-integration-netskope.star deleted file mode 100644 index 45f2642..0000000 --- a/netskope/custom-integration-netskope.star +++ /dev/null @@ -1,159 +0,0 @@ -load('runzero.types', 'ImportAsset', 'NetworkInterface') -load('json', json_encode='encode', json_decode='decode') -load('net', 'ip_address') -load('http', http_post='post', http_get='get', 'url_encode') -load('uuid', 'new_uuid') - -NETSKOPE_API_URL = 'https://.goskope.com/api' -NETSKOPE_API_GROUPBYS = 'nsdeviceuid' -NETSKOPE_API_ATTRIBUTES = [ - 'deleted', - 'device_classification_status', - 'device_id', - 'device_make', - 'device_model', - 'groups', - 'hostname', - 'mac_addresses', - 'nsdeviceuid', - 'ns_tenant_id', - 'organization_unit', - 'os', - 'os_version', - 'serial_number', - 'steering_config', - 'timestamp', - 'ur_normalized', - 'user', - 'userkey', - 'usergroup', - 'user_added_time', - 'user_status' -] - -def get_assets(token): - hasNextPage = True - page_offset = 0 - page_limit = 20000 - assets = [] - assets_all = [] - - fields = ','.join(NETSKOPE_API_ATTRIBUTES) - headers = {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token} - - while hasNextPage: - query = '?groupbys={}&fields={}&offset={}&limit={}'.format(NETSKOPE_API_GROUPBYS, fields, page_offset, page_limit) - url = NETSKOPE_API_URL + '/v2/events/datasearch/clientstatus' + query - - response = http_get(url, headers=headers, timeout=300) - - if response.status_code != 200: - print('failed to retrieve assets', response.status_code) - return None - - assets = json_decode(response.body)['result'] - print(assets) - - if len(assets) == page_limit: - assets_all.extend(assets) - page_offset = page_offset + page_limit - elif len(assets) > 0 and len(assets) < page_limit: - assets_all.extend(assets) - hasNextPage = False - else: - print('something weird happened') - hasNextPage = False - - return assets_all - -def build_assets(assets_json): - imported_assets = [] - for item in assets_json: - - # parse operating system - os_name = item.get('os', '') - os_version = item.get('os_version', '') - - if 'Mac' in os_name: - os = 'macOS' - else: - os = os_name - - # parse network interfaces - ips = ["127.0.0.1"] - macs = [] - networks = [] - - macs = item.get('mac_addresses', []) - if macs: - for m in macs: - network = build_network_interface(ips=ips, mac=m) - networks.append(network) - else: - network = build_network_interface(ips=ips, mac=None) - networks.append(network) - - imported_assets.append( - ImportAsset( - id=item.get('_id', {}).get('nsdeviceuid', new_uuid), - hostnames=[item.get('hostname', '')], - networkInterfaces=networks, - os=os, - #os_version=os_version, - manufacturer=item.get('device_make', ''), - model=item.get('device_model', ''), - customAttributes={ - 'clientVersion':item.get('client_version', ''), - 'deviceId':item.get('device_id', ''), - 'deleted':item.get('deleted', ''), - 'groups':item.get('groups', []), - 'nsdeviceuid':item.get('_id', {}).get('nsdeviceuid', ''), - 'ns_tenant_id':item.get('ns_tenant_id', ''), - 'osName':item.get('os', ''), - 'osVersion':item.get('os_version', ''), - 'serialNumber':item.get('serial_number', ''), - 'steeringConfig':item.get('steering_config', ''), - 'netskopeTS':item.get('timestamp', ''), - 'userInfoDeviceClassificationStatus':item.get('device_classification_status', ''), - 'userInfoUserKey':item.get('userkey', ''), - 'userName':item.get('username', ''), - 'userNormalized':item.get('ur_normalized', ''), - 'userSource':item.get('user_source', ''), - 'userStatus':item.get('user_status', ''), - 'userGroup':item.get('usergroup', []) - } - ) - ) - return imported_assets - -# build runZero network interfaces; shouldn't need to touch this -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) - - return NetworkInterface(macAddress=mac, ipv4Addresses=ip4s, ipv6Addresses=ip6s) - -def main(**kwargs): - # kwargs!! - token = kwargs['access_secret'] - - # get assets - assets = get_assets(token) - if not assets: - print('failed to retrieve assets') - return None - - # build asset import - imported_assets = build_assets(assets) - - return imported_assets \ No newline at end of file diff --git a/netskope/netskope.star b/netskope/netskope.star new file mode 100644 index 0000000..5669df2 --- /dev/null +++ b/netskope/netskope.star @@ -0,0 +1,168 @@ +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-netskope", + "name": "Netskope", + "type": "inbound", + "description": "Imports devices from Netskope.", + "version": "26061000", + "params": [ + { + "key": "url", + "label": "Netskope tenant URL", + "type": "url", + "required": True, + "placeholder": "https://.goskope.com", + }, + { + "key": "api_token", + "label": "API token", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +load('runzero.types', 'ImportAsset', 'to_custom_attributes') +load('net', 'network_interface') +load('http', 'get_json') +load('kwargs', 'get_url_base', 'get_http_options') +load('uuid', 'new_uuid') + +NETSKOPE_API_GROUPBYS = 'nsdeviceuid' +NETSKOPE_API_ATTRIBUTES = [ + 'deleted', + 'device_classification_status', + 'device_id', + 'device_make', + 'device_model', + 'groups', + 'hostname', + 'mac_addresses', + 'nsdeviceuid', + 'ns_tenant_id', + 'organization_unit', + 'os', + 'os_version', + 'serial_number', + 'steering_config', + 'timestamp', + 'ur_normalized', + 'user', + 'userkey', + 'usergroup', + 'user_added_time', + 'user_status' +] + +def get_assets(base_url, token, config_kwargs): + hasNextPage = True + page_offset = 0 + page_limit = 20000 + assets = [] + reported = 0 + + fields = ','.join(NETSKOPE_API_ATTRIBUTES) + headers = {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token} + http_options = get_http_options(config_kwargs, headers=headers) + + while hasNextPage: + query = '?groupbys={}&fields={}&offset={}&limit={}'.format(NETSKOPE_API_GROUPBYS, fields, page_offset, page_limit) + url = base_url + '/api/v2/events/datasearch/clientstatus' + query + + response, err = get_json(url, timeout=300, **http_options) + + if err: + print('failed to retrieve assets:', err) + return reported + + assets = (response or {}).get('result', []) + + if len(assets) == page_limit: + # Build and stream this page before fetching the next so the full + # device set is never held in memory at once. + reported += report_assets(build_assets(assets)) + page_offset = page_offset + page_limit + elif len(assets) > 0 and len(assets) < page_limit: + reported += report_assets(build_assets(assets)) + hasNextPage = False + else: + hasNextPage = False + + return reported + +def build_assets(assets_json): + imported_assets = [] + for item in assets_json: + + # parse operating system + os_name = item.get('os', '') + os_version = item.get('os_version', '') + + if 'Mac' in os_name: + os = 'macOS' + else: + os = os_name + + # parse network interfaces + ips = ["127.0.0.1"] + macs = [] + networks = [] + + macs = item.get('mac_addresses', []) + if macs: + for m in macs: + network = network_interface(ips=ips, mac=m) + networks.append(network) + else: + network = network_interface(ips=ips, mac=None) + networks.append(network) + + imported_assets.append( + ImportAsset( + id=item.get('_id', {}).get('nsdeviceuid', new_uuid), + hostnames=[item.get('hostname', '')], + networkInterfaces=networks, + os=os, + #os_version=os_version, + manufacturer=item.get('device_make', ''), + model=item.get('device_model', ''), + customAttributes=to_custom_attributes({ + 'clientVersion':item.get('client_version'), + 'deviceId':item.get('device_id'), + 'deleted':item.get('deleted'), + 'groups':item.get('groups', []), + 'nsdeviceuid':item.get('_id', {}).get('nsdeviceuid'), + 'ns_tenant_id':item.get('ns_tenant_id'), + 'osName':item.get('os'), + 'osVersion':item.get('os_version'), + 'serialNumber':item.get('serial_number'), + 'steeringConfig':item.get('steering_config'), + 'netskopeTS':item.get('timestamp'), + 'userInfoDeviceClassificationStatus':item.get('device_classification_status'), + 'userInfoUserKey':item.get('userkey'), + 'userName':item.get('username'), + 'userNormalized':item.get('ur_normalized'), + 'userSource':item.get('user_source'), + 'userStatus':item.get('user_status'), + 'userGroup':item.get('usergroup', []) + }), + ) + ) + return imported_assets + +# build runZero network interfaces; shouldn't need to touch this +def main(**kwargs): + # kwargs!! + base_url = get_url_base(kwargs) + token = kwargs['api_token'] + + # get and stream assets page-by-page via report_assets + reported = get_assets(base_url, token, kwargs) + if not reported: + print('no assets retrieved') + + return None \ No newline at end of file diff --git a/nexthink/README.md b/nexthink/README.md index abd1f3f..b1f5444 100644 --- a/nexthink/README.md +++ b/nexthink/README.md @@ -48,29 +48,24 @@ The script imports assets from Nexthink using NQL export workflow and maps: - `device.first_seen` - `device.last_seen` -### 2. Configure the script - -Edit [nexthink/custom-integration-nexthink.star](nexthink/custom-integration-nexthink.star) constants: - -- `AUTH_URL` format: `https://-login..nexthink.cloud` -- `API_URL` format: `https://.api..nexthink.cloud` -- `QUERY_ID` format: `#` -- `SCOPE` usually: `service:integration` - -### 3. Configure runZero credential +### 2. Configure runZero credential 1. In runZero, create a credential of type Custom Integration Script Secrets. -2. Set: - - `access_key` = Nexthink Client ID - - `access_secret` = Nexthink Client Secret +2. Set `client_secret` to your Nexthink Client Secret. -### 4. Configure runZero custom integration +### 3. Configure runZero custom integration 1. Create a new custom integration in runZero. -2. Paste the script from [nexthink/custom-integration-nexthink.star](nexthink/custom-integration-nexthink.star). -3. Validate and save. +2. Paste the script from [nexthink/nexthink.star](nexthink/nexthink.star). +3. Configure the integration parameters: + - `auth_url`: `https://-login..nexthink.cloud` + - `api_url`: `https://.api..nexthink.cloud` + - `client_id`: your Nexthink Client ID + - `query_id`: your saved NQL query ID (defaults to `#runzero_integration`) + - `scope`: OAuth scope (defaults to `service:integration`) +4. Validate and save. -### 5. Create an ingest task +### 4. Create an ingest task 1. Create a custom ingest task. 2. Select the credential and custom integration. @@ -88,7 +83,7 @@ Edit [nexthink/custom-integration-nexthink.star](nexthink/custom-integration-nex - `No rows returned from Nexthink export workflow`: - Verify query fields and data availability in Nexthink. - - Verify `QUERY_ID` matches a saved Nexthink query. + - Verify `query_id` matches a saved Nexthink query. - `Failed to download export results ... Only one auth mechanism allowed`: - Ensure no `Authorization` header is sent to `resultsFileUrl`. - Exactly 1,000 rows imported: @@ -97,8 +92,7 @@ Edit [nexthink/custom-integration-nexthink.star](nexthink/custom-integration-nex ## Preparing a pull request 1. Ensure these files exist: - - [nexthink/custom-integration-nexthink.star](nexthink/custom-integration-nexthink.star) - - [nexthink/config.json](nexthink/config.json) + - [nexthink/nexthink.star](nexthink/nexthink.star) - [nexthink/README.md](nexthink/README.md) 2. Verify your branch is `nexthink` and only intended files changed. 3. Run a quick manual validation in runZero with test credentials. diff --git a/nexthink/config.json b/nexthink/config.json deleted file mode 100644 index 91ff07e..0000000 --- a/nexthink/config.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "Nexthink", - "type": "inbound" -} diff --git a/nexthink/custom-integration-nexthink.star b/nexthink/nexthink.star similarity index 72% rename from nexthink/custom-integration-nexthink.star rename to nexthink/nexthink.star index b12011b..44c295d 100644 --- a/nexthink/custom-integration-nexthink.star +++ b/nexthink/nexthink.star @@ -1,13 +1,65 @@ +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-nexthink", + "name": "Nexthink", + "type": "inbound", + "description": "Imports devices from Nexthink using the NQL export workflow.", + "version": "26052700", + "params": [ + { + "key": "auth_url", + "label": "Nexthink authentication URL", + "type": "url", + "required": True, + "placeholder": "https://-login..nexthink.cloud", + }, + { + "key": "api_url", + "label": "Nexthink API URL", + "type": "url", + "required": True, + "placeholder": "https://.api..nexthink.cloud", + }, + { + "key": "client_id", + "label": "Client ID", + "type": "string", + "required": True, + }, + { + "key": "client_secret", + "label": "Client secret", + "type": "secret", + "required": True, + }, + { + "key": "query_id", + "label": "NQL query ID", + "type": "string", + "required": False, + "default": "#runzero_integration", + }, + { + "key": "scope", + "label": "OAuth scope", + "type": "string", + "required": False, + "default": "service:integration", + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} + load('runzero.types', 'ImportAsset', 'NetworkInterface') load('http', http_post='post', http_get='get') load('json', json_encode='encode', json_decode='decode') load('base64', base64_encode='encode') load('net', 'ip_address') - -AUTH_URL = 'https://-login..nexthink.cloud' -API_URL = 'https://.api..nexthink.cloud' -QUERY_ID = '#runzero_integration' -SCOPE = 'service:integration' +load('kwargs', 'require', 'get_string', 'get_http_options') def _safe_str(value): if value == None: @@ -113,7 +165,7 @@ def parse_csv_rows(csv_text): return rows -def get_token(client_id, client_secret, base_url, scope=None): +def get_token(client_id, client_secret, base_url, scope, config): token_url = "{}/oauth2/default/v1/token".format(base_url) auth_str = "{}:{}".format(client_id, client_secret) encoded_auth = base64_encode(auth_str) @@ -128,7 +180,7 @@ def get_token(client_id, client_secret, base_url, scope=None): if scope: params["scope"] = scope - response = http_post(token_url, headers=headers, params=params) + response = http_post(token_url, params=params, **get_http_options(config, headers=headers)) if response.status_code != 200: print("Failed to get Nexthink token. Status Code: {}".format(response.status_code)) print("Failed to get Nexthink token. Response Body: {}".format(response.body)) @@ -137,14 +189,14 @@ def get_token(client_id, client_secret, base_url, scope=None): data = json_decode(response.body) return data.get("access_token") -def start_nql_export(token, api_url, query_id): +def start_nql_export(token, api_url, query_id, config): url = "{}/api/v1/nql/export?queryId={}".format(api_url, _encode_query_id(query_id)) headers = { "Authorization": "Bearer {}".format(token), "Accept": "application/json", } - response = http_get(url, headers=headers) + response = http_get(url, **get_http_options(config, headers=headers)) if response.status_code == 200: data = json_decode(response.body) return data.get("exportId") @@ -155,14 +207,14 @@ def start_nql_export(token, api_url, query_id): print("Failed to start NQL export. Response Body: {}".format(response.body)) return None -def get_export_status(token, api_url, export_id): +def get_export_status(token, api_url, export_id, config): url = "{}/api/v1/nql/status/{}".format(api_url, export_id) headers = { "Authorization": "Bearer {}".format(token), "Accept": "application/json", } - response = http_get(url, headers=headers) + response = http_get(url, **get_http_options(config, headers=headers)) if response.status_code == 200: return json_decode(response.body) if response.status_code == 401: @@ -172,27 +224,27 @@ def get_export_status(token, api_url, export_id): print("Failed to get export status. Response Body: {}".format(response.body)) return None -def fetch_all_export_rows(token, api_url, query_id, client_id, client_secret, auth_url, scope): - export_id = start_nql_export(token, api_url, query_id) +def fetch_all_export_rows(token, api_url, query_id, client_id, client_secret, auth_url, scope, config): + export_id = start_nql_export(token, api_url, query_id, config) if export_id == "__UNAUTHORIZED__": - token = get_token(client_id, client_secret, auth_url, scope=scope) + token = get_token(client_id, client_secret, auth_url, scope, config) if not token: return [] - export_id = start_nql_export(token, api_url, query_id) + export_id = start_nql_export(token, api_url, query_id, config) if not export_id: return [] status_data = None for _ in range(0, 120): - status_data = get_export_status(token, api_url, export_id) + status_data = get_export_status(token, api_url, export_id, config) if not status_data: return [] if status_data.get("__unauthorized__"): - token = get_token(client_id, client_secret, auth_url, scope=scope) + token = get_token(client_id, client_secret, auth_url, scope, config) if not token: return [] - status_data = get_export_status(token, api_url, export_id) + status_data = get_export_status(token, api_url, export_id, config) if not status_data or status_data.get("__unauthorized__"): return [] @@ -218,10 +270,10 @@ def fetch_all_export_rows(token, api_url, query_id, client_id, client_secret, au return [] # resultsFileUrl is pre-signed. Do not send Authorization headers. - download_response = http_get(results_file_url) + download_response = http_get(results_file_url, **get_http_options(config)) if download_response.status_code == 406: - download_response = http_get(results_file_url, headers={"Accept": "text/csv"}) + download_response = http_get(results_file_url, **get_http_options(config, headers={"Accept": "text/csv"})) if download_response.status_code != 200: print("Failed to download export results. Status Code: {}".format(download_response.status_code)) @@ -232,18 +284,19 @@ def fetch_all_export_rows(token, api_url, query_id, client_id, client_secret, au return parse_csv_rows(csv_text) def main(**kwargs): - client_id = kwargs.get("access_key") - client_secret = kwargs.get("access_secret") - - if not client_id or not client_secret: - print("Missing credentials! Please configure access_key and access_secret in runZero.") - return [] - - token = get_token(client_id, client_secret, AUTH_URL, scope=SCOPE) + require(kwargs, "client_id", "client_secret", "auth_url", "api_url") + client_id = get_string(kwargs, "client_id") + client_secret = get_string(kwargs, "client_secret") + auth_url = get_string(kwargs, "auth_url") + api_url = get_string(kwargs, "api_url") + query_id = get_string(kwargs, "query_id", default="#runzero_integration") + scope = get_string(kwargs, "scope", default="service:integration") + + token = get_token(client_id, client_secret, auth_url, scope, kwargs) if not token: return [] - rows = fetch_all_export_rows(token, API_URL, QUERY_ID, client_id, client_secret, AUTH_URL, SCOPE) + rows = fetch_all_export_rows(token, api_url, query_id, client_id, client_secret, auth_url, scope, kwargs) if type(rows) != "list" or len(rows) == 0: print("No rows returned from Nexthink export workflow") return [] @@ -291,4 +344,6 @@ def main(**kwargs): customAttributes=custom_attrs, )) - return assets + # Stream assets to runZero via report_assets instead of returning a list. + report_assets(assets) + return None diff --git a/ninjaone/README.md b/ninjaone/README.md index 5b1d672..eb6dd02 100644 --- a/ninjaone/README.md +++ b/ninjaone/README.md @@ -24,8 +24,8 @@ - Modify datapoints uploaded to runZero as needed. 2. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials). - Select the type `Custom Integration Script Secrets`. - - For the `access_key`, input your NinjaOne client ID. - - For the `access_secret`, input your NinjaOne client secret. + - For `client_id`, input your NinjaOne client ID. + - For `client_secret`, input your NinjaOne client secret. 3. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new). - Add a Name and Icon for the integration (e.g., "ninjaone"). - Upload an image file for the NinjaOne icon. diff --git a/ninjaone/config.json b/ninjaone/config.json deleted file mode 100644 index 64b206e..0000000 --- a/ninjaone/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "NinjaOne", "type": "inbound" } \ No newline at end of file diff --git a/ninjaone/custom-integration-ninjaone.star b/ninjaone/custom-integration-ninjaone.star deleted file mode 100644 index cf7c794..0000000 --- a/ninjaone/custom-integration-ninjaone.star +++ /dev/null @@ -1,191 +0,0 @@ -load('runzero.types', 'ImportAsset', 'NetworkInterface') -load('json', json_encode='encode', json_decode='decode') -load('net', 'ip_address') -load('http', http_post='post', http_get='get', 'url_encode') -load('uuid', 'new_uuid') - -NINJAONE_API_URL = 'https://us2.ninjarmm.com' - -def get_token(client_id, client_secret): - url = NINJAONE_API_URL + '/ws/oauth/token' - headers = {'Content-Type': 'application/x-www-form-urlencoded'} - payload = {"grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, "scope": "monitoring"} - - resp = http_post(url, headers=headers, body=bytes(url_encode(payload))) - if resp.status_code != 200: - print('authentication failed: ', resp.status_code) - return None - - auth_data = json_decode(resp.body) - if not auth_data: - print('invalid authentication data') - return None - - return auth_data.get('access_token') - -def get_assets(token): - hasNextPage = True - after = '' - page_size = 500 - assets = [] - assets_all = [] - - url = NINJAONE_API_URL + "/v2/devices-detailed" - headers = {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token} - - while hasNextPage: - query = {'pageSize': page_size, 'after': after} - response = http_get(url, headers=headers, params=query) - - if response.status_code != 200: - print('failed to retrieve assets', response.status_code) - return None - - assets = json_decode(response.body) - - if len(assets) == page_size: - assets_all.extend(assets) - last_node = page_size - 1 - after = assets[last_node].get('id', '') - if not after: - print('failed to retrieve last node id') - return None - elif len(assets) > 0 and len(assets) < page_size: - assets_all.extend(assets) - hasNextPage = False - else: - hasNextPage = False - - return assets_all - -def build_assets(assets_json): - imported_assets = [] - for item in assets_json: - id = item.get('id', new_uuid) - - display_name = item.get('displayName', '') - system_name = item.get('systemName', '') - dns_name = item.get('') - - # parse network interfaces - ips = [] - macs = [] - networks = [] - - ips = item.get('ipAddresses', []) - - # check for assets with weird address blocks and rebuilt ips - rebuilt_ips = [] - for ip in ips: - if '|' in ip: - rebuilt_ips.extend(ip.split('|')) - elif ip == '': - continue - else: - rebuilt_ips.append(ip) - ips = rebuilt_ips - - # check for assets with no ip address - if len(ips) == 0: - ips.append('127.0.0.1') - - macs = item.get('macAddresses', []) - if macs: - for m in macs: - network = build_network_interface(ips=ips, mac=m) - networks.append(network) - else: - network = build_network_interface(ips=ips, mac=None) - networks.append(network) - - imported_assets.append( - ImportAsset( - id=str(id), - hostnames=[ - item.get('displayName', ''), - item.get('systemName', ''), - item.get('dnsName', ''), - item.get('netbiosName', '') - ], - networkInterfaces=networks, - os=item.get('os', {}).get('name', ''), - manufacturer=item.get('system', {}).get('manufacturer', ''), - customAttributes={ - 'id':id, - 'displayName':item.get('displayName', ''), - 'systemName':item.get('systemName', ''), - 'dnsName':item.get('dnsName', ''), - 'netbiosName':item.get('netbiosName', ''), - 'nodeClass':item.get('nodeClass', ''), - 'nodeRoleId':item.get('nodeRoleId', ''), - 'rolePolicyId':item.get('rolePolicyId', ''), - 'policyId':item.get('policyId', ''), - 'approvalStatus':item.get('approvalStatus', ''), - 'offline':item.get('offline', ''), - 'ipAddresses':item.get('ipAddresses', ''), - 'macAddresses':item.get('macAddresses', ''), - 'publicIP':item.get('publicIP', ''), - 'osManufacturer':item.get('os', {}).get('manufacturer', ''), - 'osName':item.get('os', {}).get('name', ''), - 'osArchitecture':item.get('os', {}).get('architecture', ''), - 'osBuildNumber':item.get('os', {}).get('buildNumber', ''), - 'osReleaseId':item.get('os', {}).get('manufacturer', ''), - 'osServicePackMajorVersion':item.get('os', {}).get('servicePackMajorVersion', ''), - 'osServicePackMinorVersion':item.get('os', {}).get('servicePackMinorVersion', ''), - 'osLanguage':item.get('os', {}).get('language', ''), - 'osNeedsReboot':item.get('os', {}).get('needsReboot', ''), - 'systemManufacturer':item.get('system', {}).get('manufacturer', ''), - 'systemModel':item.get('system', {}).get('model', ''), - 'systemBiosSerialNumber':item.get('system', {}).get('biosSerialNumber', ''), - 'systemSerialNumber':item.get('system', {}).get('serialNumberr', ''), - 'systemDomain':item.get('system', {}).get('domain', ''), - 'systemDomainRole':item.get('system', {}).get('domainRole', ''), - 'systemProcessors':item.get('system', {}).get('numberOfProcessors', ''), - 'systemTotalPhysicalMemory':item.get('system', {}).get('totalPhysicalMemory', ''), - 'systemVirtualMachine':item.get('system', {}).get('virtualMachine', ''), - 'systemChassisType':item.get('system', {}).get('chassisType', ''), - 'lastLoggedInUser':item.get('lastLoggedInUser', ''), - 'deviceType':item.get('deviceType', '') - } - ) - ) - return imported_assets - -# build runZero network interfaces; shouldn't need to touch this -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) - - return NetworkInterface(macAddress=mac, ipv4Addresses=ip4s, ipv6Addresses=ip6s) - -def main(**kwargs): - # kwargs!! - client_id = kwargs['access_key'] - client_secret = kwargs['access_secret'] - - # get bearer token - token = get_token(client_id, client_secret) - if not token: - print('failed to retrieve bearer token') - return None - - # get assets - assets = get_assets(token) - if not assets: - print('failed to retrieve assets') - return None - - # build asset import - imported_assets = build_assets(assets) - - return imported_assets \ No newline at end of file diff --git a/ninjaone/ninjaone.star b/ninjaone/ninjaone.star new file mode 100644 index 0000000..702fbd2 --- /dev/null +++ b/ninjaone/ninjaone.star @@ -0,0 +1,197 @@ +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-ninjaone", + "name": "NinjaOne", + "type": "inbound", + "description": "Imports devices from NinjaOne.", + "version": "26061000", + "params": [ + { + "key": "api_url", + "label": "NinjaOne API URL", + "type": "url", + "required": True, + "placeholder": "https://us2.ninjarmm.com", + }, + { + "key": "client_id", + "label": "OAuth client ID", + "type": "string", + "required": True, + }, + { + "key": "client_secret", + "label": "OAuth client secret", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +load('runzero.types', 'ImportAsset', 'to_custom_attributes') +load('net', 'network_interface') +load('http', 'get_json', 'oauth2_token', 'bearer') +load('kwargs', 'get_http_options', 'get_string') +load('uuid', 'new_uuid') + +def get_token(api_url, client_id, client_secret, config_kwargs): + return oauth2_token( + api_url + '/ws/oauth/token', + client_id=client_id, + client_secret=client_secret, + scope="monitoring", + **get_http_options(config_kwargs) + ) + +def get_assets(api_url, token, config_kwargs): + hasNextPage = True + after = '' + page_size = 500 + assets = [] + reported = 0 + + url = api_url + "/v2/devices-detailed" + headers = {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token} + http_options = get_http_options(config_kwargs, headers=headers) + + while hasNextPage: + query = {'pageSize': page_size, 'after': after} + assets, err = get_json(url, params=query, **http_options) + + if err: + print('failed to retrieve assets:', err) + return reported + + if len(assets) == page_size: + # Build and stream this page before fetching the next so the full + # device set is never held in memory at once. + reported += report_assets(build_assets(assets)) + last_node = page_size - 1 + after = assets[last_node].get('id', '') + if not after: + print('failed to retrieve last node id') + return reported + elif len(assets) > 0 and len(assets) < page_size: + reported += report_assets(build_assets(assets)) + hasNextPage = False + else: + hasNextPage = False + + return reported + +def build_assets(assets_json): + imported_assets = [] + for item in assets_json: + id = item.get('id', new_uuid) + + display_name = item.get('displayName', '') + system_name = item.get('systemName', '') + dns_name = item.get('') + + # parse network interfaces + ips = [] + macs = [] + networks = [] + + ips = item.get('ipAddresses', []) + + # check for assets with weird address blocks and rebuilt ips + rebuilt_ips = [] + for ip in ips: + if '|' in ip: + rebuilt_ips.extend(ip.split('|')) + elif ip == '': + continue + else: + rebuilt_ips.append(ip) + ips = rebuilt_ips + + # check for assets with no ip address + if len(ips) == 0: + ips.append('127.0.0.1') + + macs = item.get('macAddresses', []) + if macs: + for m in macs: + network = network_interface(ips=ips, mac=m) + networks.append(network) + else: + network = network_interface(ips=ips, mac=None) + networks.append(network) + + imported_assets.append( + ImportAsset( + id=str(id), + hostnames=[ + item.get('displayName', ''), + item.get('systemName', ''), + item.get('dnsName', ''), + item.get('netbiosName', '') + ], + networkInterfaces=networks, + os=item.get('os', {}).get('name', ''), + manufacturer=item.get('system', {}).get('manufacturer', ''), + customAttributes=to_custom_attributes({ + 'id':id, + 'displayName':item.get('displayName'), + 'systemName':item.get('systemName'), + 'dnsName':item.get('dnsName'), + 'netbiosName':item.get('netbiosName'), + 'nodeClass':item.get('nodeClass'), + 'nodeRoleId':item.get('nodeRoleId'), + 'rolePolicyId':item.get('rolePolicyId'), + 'policyId':item.get('policyId'), + 'approvalStatus':item.get('approvalStatus'), + 'offline':item.get('offline'), + 'ipAddresses':item.get('ipAddresses'), + 'macAddresses':item.get('macAddresses'), + 'publicIP':item.get('publicIP'), + 'osManufacturer':item.get('os', {}).get('manufacturer'), + 'osName':item.get('os', {}).get('name'), + 'osArchitecture':item.get('os', {}).get('architecture'), + 'osBuildNumber':item.get('os', {}).get('buildNumber'), + 'osReleaseId':item.get('os', {}).get('manufacturer'), + 'osServicePackMajorVersion':item.get('os', {}).get('servicePackMajorVersion'), + 'osServicePackMinorVersion':item.get('os', {}).get('servicePackMinorVersion'), + 'osLanguage':item.get('os', {}).get('language'), + 'osNeedsReboot':item.get('os', {}).get('needsReboot'), + 'systemManufacturer':item.get('system', {}).get('manufacturer'), + 'systemModel':item.get('system', {}).get('model'), + 'systemBiosSerialNumber':item.get('system', {}).get('biosSerialNumber'), + 'systemSerialNumber':item.get('system', {}).get('serialNumberr'), + 'systemDomain':item.get('system', {}).get('domain'), + 'systemDomainRole':item.get('system', {}).get('domainRole'), + 'systemProcessors':item.get('system', {}).get('numberOfProcessors'), + 'systemTotalPhysicalMemory':item.get('system', {}).get('totalPhysicalMemory'), + 'systemVirtualMachine':item.get('system', {}).get('virtualMachine'), + 'systemChassisType':item.get('system', {}).get('chassisType'), + 'lastLoggedInUser':item.get('lastLoggedInUser'), + 'deviceType':item.get('deviceType') + }), + ) + ) + return imported_assets + +# build runZero network interfaces; shouldn't need to touch this +def main(**kwargs): + # kwargs!! + api_url = get_string(kwargs, 'api_url').rstrip('/') + client_id = kwargs['client_id'] + client_secret = kwargs['client_secret'] + + # get bearer token + token = get_token(api_url, client_id, client_secret, kwargs) + if not token: + print('failed to retrieve bearer token') + return None + + # get and stream assets page-by-page via report_assets + reported = get_assets(api_url, token, kwargs) + if not reported: + print('no assets retrieved') + + return None \ No newline at end of file diff --git a/pfsense/README.md b/pfsense/README.md index 92eb26b..2696c58 100644 --- a/pfsense/README.md +++ b/pfsense/README.md @@ -6,17 +6,17 @@ This integration imports your pfSense firewall as a runZero `ImportAsset` and ca - Superuser access to [Custom Integrations](https://console.runzero.com/custom-integrations). - A **Custom Integration Script Secret** credential: - - `access_key`: your pfSense base URL (example: `https://pfsense.example.local`) - - `access_secret`: your pfSense REST API token + - `base_url`: your pfSense base URL (example: `https://pfsense.example.local`) + - `api_token`: your pfSense REST API token ## Optional JSON credential mode -If you prefer to keep all settings in `access_secret`, you can store JSON instead: +If you prefer to keep all settings in `legacy_credentials`, you can store JSON instead: ```json { "base_url": "https://pfsense.example.local", - "access_secret": "YOUR_API_TOKEN", + "api_token": "YOUR_API_TOKEN", "auth_header": "authorization", "insecure_skip_verify": false } @@ -36,14 +36,14 @@ The script tries these endpoints in order and uses the first successful response 1. In pfSense, generate an API token with read access to status/system data. 2. In runZero, create the credential values above. -3. Create a new Custom Integration and paste `custom-integration-pfsense.star`. +3. Create a new Custom Integration and paste `pfsense.star`. 4. Validate, save, and attach it to a task. ## Local test with runZero CLI ```bash -runzero script --filename pfsense/custom-integration-pfsense.star --kwargs access_key=https://pfsense.example.local --kwargs access_secret=YOUR_API_TOKEN +runzero script --filename pfsense/pfsense.star --kwargs base_url=https://pfsense.example.local --kwargs api_token=YOUR_API_TOKEN ``` -If your API token/header model is different, use the JSON `access_secret` mode so you can switch header behavior. +If your API token/header model is different, use the JSON `legacy_credentials` mode so you can switch header behavior. diff --git a/pfsense/config.json b/pfsense/config.json deleted file mode 100644 index a1e6e8a..0000000 --- a/pfsense/config.json +++ /dev/null @@ -1,2 +0,0 @@ -{ "name": "pfSense", "type": "inbound" } - diff --git a/pfsense/custom-integration-pfsense.star b/pfsense/pfsense.star similarity index 52% rename from pfsense/custom-integration-pfsense.star rename to pfsense/pfsense.star index d4b4761..321a8f7 100644 --- a/pfsense/custom-integration-pfsense.star +++ b/pfsense/pfsense.star @@ -1,8 +1,52 @@ -load("runzero.types", "ImportAsset", "NetworkInterface") +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-pfsense", + "name": "pfSense", + "type": "inbound", + "description": "Imports firewall objects and DHCP leases from pfSense.", + "version": "26052700", + "params": [ + { + "key": "base_url", + "label": "pfSense base URL", + "type": "url", + "required": True, + "placeholder": "https://pfsense.example.com", + }, + { + "key": "api_token", + "label": "API token", + "type": "secret", + "required": True, + }, + { + "key": "auth_header", + "label": "Auth header name", + "type": "string", + "required": False, + "default": "X-API-Key", + "description": "Header used to send the API token", + }, + { + "key": "legacy_credentials", + "label": "Legacy JSON credential", + "type": "secret", + "required": False, + "group": "Legacy", + "description": "Back-compat: JSON object containing base_url, api_token, auth_header, insecure_skip_verify", + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +load("runzero.types", "ImportAsset", "NetworkInterface", "to_custom_attributes") load("json", json_decode="decode") -load("http", http_get="get") -load("net", "ip_address") - +load("http", "get_json", "bearer") +load("kwargs", "get_http_options", "get_http_tls") +load("net", "network_interface") def _parse_bool(value, default_value): if value == None: return default_value @@ -32,40 +76,32 @@ def _safe_decode_config(secret_value): return {} return decoded -def build_network_interface(ips, mac): - ip4s = [] - ip6s = [] - # Limit to 99 IPs to prevent excessive payload sizes - 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) - - return NetworkInterface(macAddress=mac, ipv4Addresses=ip4s, ipv6Addresses=ip6s) - def _build_settings(kwargs): - raw_access_key = kwargs.get("access_key", "") - raw_access_secret = kwargs.get("access_secret", "") + legacy_credentials = kwargs.get("legacy_credentials", "") - parsed_secret = _safe_decode_config(raw_access_secret) + parsed_secret = _safe_decode_config(legacy_credentials) - base_url = _normalize_base_url(parsed_secret.get("base_url", raw_access_key)) + # Prefer structured top-level kwargs and fall back to legacy JSON credentials. + base_url = _normalize_base_url( + kwargs.get("base_url", "") + or parsed_secret.get("base_url", ""), + ) - api_token = raw_access_secret - if len(parsed_secret) > 0: - api_token = parsed_secret.get("access_secret", parsed_secret.get("api_token", "")) + api_token = kwargs.get("api_token", "") + if not api_token: + if len(parsed_secret) > 0: + api_token = parsed_secret.get("api_token", "") + else: + api_token = legacy_credentials - auth_header = parsed_secret.get("auth_header", "x-api-key") + auth_header = kwargs.get("auth_header", "") or parsed_secret.get("auth_header", "x-api-key") if type(auth_header) != "string": auth_header = "authorization" - insecure_skip_verify = _parse_bool(parsed_secret.get("insecure_skip_verify", False), False) + + insecure_skip_verify = _parse_bool( + kwargs.get("insecure_skip_verify", parsed_secret.get("insecure_skip_verify", False)), + False, + ) return { "base_url": base_url, @@ -84,28 +120,24 @@ def _build_headers(api_token, auth_header): elif auth_header == "api-key": headers["api_key"] = api_token else: - headers["Authorization"] = "Bearer {}".format(api_token) + headers["Authorization"] = bearer(api_token) return headers -def _request_json(url, headers, insecure_skip_verify): - response = http_get(url=url, headers=headers, insecure_skip_verify=insecure_skip_verify) - if response == None: - return None, "no response", 0 - if response.status_code != 200: - return None, "HTTP {}".format(response.status_code), response.status_code - if not response.body: - return None, "empty body", response.status_code - - decoded = json_decode(response.body) +def _request_json(url, http_options): + decoded, err = get_json(url=url, **http_options) + if err: + return None, err, 0 + if decoded == None: + return None, "empty body", 0 if type(decoded) != "dict": - return None, "unexpected JSON shape", response.status_code + return None, "unexpected JSON shape", 0 # Some pfSense APIs wrap payloads under a top-level data object. if type(decoded.get("data")) == "dict": decoded = decoded.get("data") - return decoded, "ok", response.status_code + return decoded, "ok", 200 def _pick(payload, keys, default_value): if payload == None: @@ -149,31 +181,31 @@ def _sanitize_identifier(value): result = result.replace("--", "-") return result.strip("-") -def get(settings, headers, path): +def get(settings, http_options, path): url = settings.get("base_url") + path print("INFO: ", url) - decoded, status_text, _ = _request_json(url, headers, settings.get("insecure_skip_verify")) + decoded, status_text, _ = _request_json(url, http_options) if decoded != None: system_data = decoded endpoint_used = path return system_data, endpoint_used -def getVersion(settings, headers): - system_data, _ = get(settings, headers, "/api/v2/system/version") +def getVersion(settings, http_options): + system_data, _ = get(settings, http_options, "/api/v2/system/version") platform = _pick(system_data, ["product_name", "name", "platform"], "pfSense") return platform, _pick(system_data, ["version", "product_version", "pf_version", "release"], "unknown") -def getHostInfo(settings, headers): - system_data, _ = get(settings, headers, "/api/v2/system/hostname") +def getHostInfo(settings, http_options): + system_data, _ = get(settings, http_options, "/api/v2/system/hostname") return _pick(system_data, ["hostname", "host", "system_hostname"], ""), _pick(system_data, ["domain"], "") -def getInterfaces(settings, headers): - system_data, _ = get(settings, headers, "/api/v2/status/interfaces") +def getInterfaces(settings, http_options): + system_data, _ = get(settings, http_options, "/api/v2/status/interfaces") return _extract_interfaces(system_data) -def getSystemInfo(settings, headers): - system_data, _ = get(settings, headers, "/api/v2/status/system") +def getSystemInfo(settings, http_options): + system_data, _ = get(settings, http_options, "/api/v2/status/system") base_id = _pick(system_data, ["netgate_id"], settings.get("base_url")) model = _pick(system_data, ["product_name", "name", "platform"], "pfSense") serial = _pick(system_data, ["serial", "serial_number"], "") @@ -183,20 +215,25 @@ def getSystemInfo(settings, headers): def main(*args, **kwargs): settings = _build_settings(kwargs) if not settings.get("base_url"): - print("ERROR: access_key must be the pfSense base URL, or include base_url in access_secret JSON.") + print("ERROR: base_url is required, or include base_url in legacy_credentials JSON.") return [] if not settings.get("api_token"): - print("ERROR: access_secret must be the API token, or include access_secret/api_token in access_secret JSON.") + print("ERROR: api_token is required, or include api_token in legacy_credentials JSON.") return [] headers = _build_headers(settings.get("api_token"), settings.get("auth_header")) - - hostname, domain = getHostInfo(settings, headers) - platform, version = getVersion(settings, headers) - network_interfaces = getInterfaces(settings, headers) + tls = get_http_tls(kwargs) + if settings.get("insecure_skip_verify"): + tls["insecure"] = True + http_options = get_http_options(kwargs, headers=headers) + http_options["tls"] = tls + + hostname, domain = getHostInfo(settings, http_options) + platform, version = getVersion(settings, http_options) + network_interfaces = getInterfaces(settings, http_options) last_error = "unknown error" - asset_id, model, serial, build_id = getSystemInfo(settings, headers) + asset_id, model, serial, build_id = getSystemInfo(settings, http_options) attrs = { "source": "pfSense REST API", } @@ -208,10 +245,11 @@ def main(*args, **kwargs): attrs["build"] = build_id - return [ImportAsset( + # Stream the asset to runZero via report_assets instead of returning a list. + report_assets(ImportAsset( id=asset_id, domain=str(domain) if domain else "", - hostnames=[hostname] if hostname else [], + hostnames=[hostname], os=platform, trust_os=True, osVersion=str(version), @@ -219,6 +257,7 @@ def main(*args, **kwargs): device_type="Firewall", trust_device_type=True, networkInterfaces=network_interfaces, - customAttributes=attrs, - )] + customAttributes=to_custom_attributes(attrs), + )) + return None diff --git a/proxmox/README.md b/proxmox/README.md index b09f442..ed5975b 100644 --- a/proxmox/README.md +++ b/proxmox/README.md @@ -69,17 +69,17 @@ To ensure the script can read node, VM, container, storage, and other resource d ## Configuration -**Access Secret** (paste as a single-line JSON string): +**API token secret** may also be provided as a single-line JSON string: ```json -{"base_url":"https://your.proxmox.server:8006","access_key":"root@pam!monitoring","access_secret":"123e4567-e89b-12d3-a456-426614174000"} +{"base_url":"https://your.proxmox.server:8006","api_token_id":"root@pam!monitoring","api_token_secret":"123e4567-e89b-12d3-a456-426614174000"} ``` | Field | Description | | --------------- | --------------------------------------------------------------- | | `base_url` | Proxmox API URL (including port), e.g. `https://pve.local:8006` | -| `access_key` | Your API token ID, e.g. `root@pam!monitoring` | -| `access_secret` | UUID secret of your API token | +| `api_token_id` | Your API token ID, e.g. `root@pam!monitoring` | +| `api_token_secret` | UUID secret of your API token | --- @@ -92,10 +92,10 @@ DEBUG = True def main(*args, **kwargs): """ Entrypoint for Proxmox VE integration. - Expects kwargs['access_secret'] to be a JSON string containing: + Expects kwargs['api_token_secret'] to be a JSON string containing: - base_url: Proxmox URL - - access_key: API token ID - - access_secret: API token secret (UUID) + - api_token_id: API token ID + - api_token_secret: API token secret (UUID) Returns: list of ImportAsset objects for nodes, VMs, containers. """ # (…script code with DEBUG guards…) @@ -106,7 +106,7 @@ def main(*args, **kwargs): ## Running the Integration 1. **Associate** the custom script with a discovery job. -2. **Select** the credential (Access Key=`foo`, your JSON `access_secret`). +2. **Select** the credential with your Proxmox API token values. 3. **Run** the scan. 4. **Review** discovered assets—nodes, VMs, and containers with IPs and MACs—in runZero. diff --git a/proxmox/config.json b/proxmox/config.json deleted file mode 100644 index a5014c3..0000000 --- a/proxmox/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Proxmox", "type": "inbound" } diff --git a/proxmox/custom-integration-proxmox.star b/proxmox/proxmox.star similarity index 88% rename from proxmox/custom-integration-proxmox.star rename to proxmox/proxmox.star index 86e4975..64deada 100644 --- a/proxmox/custom-integration-proxmox.star +++ b/proxmox/proxmox.star @@ -1,11 +1,47 @@ +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-proxmox", + "name": "Proxmox", + "type": "inbound", + "description": "Imports VMs, containers, and nodes from Proxmox VE.", + "version": "26052700", + "params": [ + { + "key": "base_url", + "label": "Proxmox base URL", + "type": "url", + "required": True, + "placeholder": "https://pve.example.com:8006", + }, + { + "key": "api_token_id", + "label": "API token ID", + "type": "string", + "required": True, + "description": "user@realm!tokenid", + }, + { + "key": "api_token_secret", + "label": "API token secret", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} load('requests', 'Session') load('json', json_decode='decode', json_encode='encode') load('runzero.types', 'ImportAsset', 'NetworkInterface') load('net', 'ip_address') +load('kwargs', 'get_bool') # Toggle debug prints on or off DEBUG = False -INSECURE_ALLOWED = True +INSECURE_ALLOWED = False def is_external_ip(ip_str): """Check if IP is external (not loopback, link-local, or internal k8s)""" @@ -58,19 +94,29 @@ def main(*args, **kwargs): Focuses on external IPs, MACs, hostnames, and running status. """ # --- 1) Parse config & auth --- - secret = json_decode(kwargs.get('access_secret', '{}')) - base_url = secret.get('base_url', '').rstrip('/') - token_id = secret.get('access_key') - token_secret = secret.get('access_secret') + # Prefer structured top-level kwargs (params[] schema). Fall back to + # the legacy JSON-stuffed API token secret for back-compat. + base_url = kwargs.get('base_url', '').rstrip('/') + token_id = kwargs.get('api_token_id', '') + token_secret = kwargs.get('api_token_secret', '') + if not base_url or token_secret.startswith('{'): + legacy = json_decode(token_secret) if token_secret.startswith('{') else {} + if type(legacy) == 'dict' and len(legacy) > 0: + base_url = base_url or legacy.get('base_url', '').rstrip('/') + token_id = token_id or legacy.get('api_token_id', '') + token_secret = legacy.get('api_token_secret', token_secret) if not (base_url and token_id and token_secret): print("ERROR: Missing base_url or credentials") return [] # --- 2) Setup session & fetch Proxmox version --- token_header = "PVEAPIToken={}={}".format(token_id, token_secret) - session = Session(insecure_skip_verify=INSECURE_ALLOWED) + insecure_allowed = get_bool(kwargs, 'tls_disable_validation', INSECURE_ALLOWED) + session = Session(insecure_skip_verify=insecure_allowed) session.headers.set('Accept', 'application/json') session.headers.set('Authorization', token_header) + if kwargs.get('http_user_agent'): + session.headers.set('User-Agent', kwargs.get('http_user_agent')) api_url = base_url + "/api2/json" ver_resp = session.get(api_url + "/version") @@ -373,7 +419,7 @@ def main(*args, **kwargs): assets.append(ImportAsset( id = "{}-{}-ct-{}".format(cluster_name, node_name, ct_id), - hostnames = [hostname] if hostname else [], + hostnames=[hostname], networkInterfaces = ct_ifaces, os = config.get('ostype', 'LXC Container'), osVersion = "", @@ -387,5 +433,7 @@ def main(*args, **kwargs): if DEBUG: print("=" * 60) print("Total assets discovered: {}".format(len(assets))) - - return assets + + # Stream assets to runZero via report_assets instead of returning a list. + report_assets(assets) + return None diff --git a/scan-passive-assets/README.md b/runzero-scan-passive-assets/README.md similarity index 95% rename from scan-passive-assets/README.md rename to runzero-scan-passive-assets/README.md index cff6169..0090ca9 100644 --- a/scan-passive-assets/README.md +++ b/runzero-scan-passive-assets/README.md @@ -28,8 +28,7 @@ This custom integration finds assets discovered only by passive sources, creates 1. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials). - Select the type `Custom Integration Script Secrets`. - - Set `access_secret` to your runZero API token. - - Set `access_key` to a placeholder value like `foo` (unused). + - Set `org_api_token` to your runZero API token. 2. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new). - Add a Name and Icon (e.g., `scan-passive-assets`). - Toggle `Enable custom integration script` to input the finalized script. diff --git a/scan-passive-assets/custom-integration-scan-passive-assets.star b/runzero-scan-passive-assets/scan-passive-assets.star similarity index 68% rename from scan-passive-assets/custom-integration-scan-passive-assets.star rename to runzero-scan-passive-assets/scan-passive-assets.star index d2fec19..94d0a2e 100644 --- a/scan-passive-assets/custom-integration-scan-passive-assets.star +++ b/runzero-scan-passive-assets/scan-passive-assets.star @@ -1,12 +1,46 @@ +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-scan-passive-assets", + "name": "Scan Passive Assets", + "type": "internal", + "description": "Schedules scans against passively-observed assets.", + "version": "26052700", + "params": [ + { + "key": "url", + "label": "runZero URL", + "type": "url", + "required": True, + "default": "https://console.runzero.com", + }, + { + "key": "site_id", + "label": "Target site ID", + "type": "string", + "required": True, + }, + { + "key": "org_api_token", + "label": "runZero org API token", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} load('requests', 'Session') load('json', json_encode='encode', json_decode='decode') load('net', 'ip_address') load('http', 'url_encode') +load('kwargs', 'get_url_base', 'get_bool') # ------------------------- # Global Configuration # ------------------------- -SITE_ID = "UPDATE_ME" DELETE_ASSETS = True ALLOW_LIST = ["10.0.0.0/8", "192.168.0.0/16"] @@ -40,15 +74,20 @@ def is_ip_allowed(ip_str, allow_list): # Entrypoint # ------------------------- def main(*args, **kwargs): - org_token = kwargs["access_secret"] + base_url = get_url_base(kwargs) + site_id = kwargs["site_id"] + org_token = kwargs["org_api_token"] + insecure_allowed = get_bool(kwargs, "tls_disable_validation", False) - session = Session() + session = Session(insecure_skip_verify=insecure_allowed) session.headers.set("Authorization", "Bearer {}".format(org_token)) session.headers.set("Content-Type", "application/json") + if kwargs.get("http_user_agent"): + session.headers.set("User-Agent", kwargs.get("http_user_agent")) # Step 1: Export assets params = {"search": "source:sample source_count:1", "fields": "id,addresses,last_agent_id"} - asset_url = "https://console.runzero.com/api/v1.0/export/org/assets.json?{}".format(url_encode(params)) + asset_url = base_url + "/api/v1.0/export/org/assets.json?{}".format(url_encode(params)) response = session.get(asset_url, timeout=3600) if not response or response.status_code != 200: @@ -77,7 +116,7 @@ def main(*args, **kwargs): # Step 3: Create scan task per explorer/agent for agent_id, ips in agent_ip_map.items(): - scan_url = "https://console.runzero.com/api/v1.0/org/sites/{}/scan".format(SITE_ID) + scan_url = base_url + "/api/v1.0/org/sites/{}/scan".format(site_id) scan_payload = { "targets": "\n".join(ips), "scan-name": "Auto Scan Sample Only Assets", @@ -97,7 +136,7 @@ def main(*args, **kwargs): "screenshots": "true", } print(scan_payload) - post = session.put(scan_url, body=bytes(json_encode(scan_payload))) + post = session.put(scan_url, json=scan_payload) if post and post.status_code == 200: print("Scan created for agent {}".format(agent_id)) else: @@ -105,9 +144,9 @@ def main(*args, **kwargs): # Step 4: Optional asset deletion if DELETE_ASSETS and len(asset_ids) > 0: - delete_url = "https://console.runzero.com/api/v1.0/org/assets/bulk/delete" + delete_url = base_url + "/api/v1.0/org/assets/bulk/delete" delete_payload = {"asset_ids": asset_ids} - del_resp = session.post(delete_url, body=bytes(json_encode(delete_payload))) + del_resp = session.post(delete_url, json=delete_payload) if del_resp and del_resp.status_code == 204: print("Deleted {} assets".format(len(asset_ids))) else: diff --git a/task-sync/README.md b/runzero-task-sync/README.md similarity index 95% rename from task-sync/README.md rename to runzero-task-sync/README.md index cd280e5..f987bd8 100644 --- a/task-sync/README.md +++ b/runzero-task-sync/README.md @@ -30,8 +30,8 @@ This integration script is designed to sync tasks between a runZero SaaS instanc 2. **Create the Credential for the Custom Integration** - Go to [runZero Credentials](https://console.runzero.com/credentials). - Select `Custom Integration Script Secrets`. - - Use the `access_key` field for the SaaS token. - - Use the `access_secret` field for the self-hosted token. + - Use the `src_api_token` field for the SaaS token. + - Use the `dst_api_token` field for the self-hosted token. 3. **Create the Custom Integration** - Go to [runZero Custom Integrations](https://console.runzero.com/custom-integrations/new). diff --git a/runzero-task-sync/task-sync.star b/runzero-task-sync/task-sync.star new file mode 100644 index 0000000..90a1747 --- /dev/null +++ b/runzero-task-sync/task-sync.star @@ -0,0 +1,172 @@ +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-task-sync", + "name": "runZero Task Sync", + "type": "inbound", + "description": "Mirrors tasks between two runZero instances (SaaS to self-hosted, etc.).", + "version": "26052700", + "params": [ + { + "key": "src_url", + "label": "Source runZero URL", + "type": "url", + "required": False, + "default": "https://console.runzero.com", + }, + { + "key": "src_org_id", + "label": "Source org ID", + "type": "string", + "required": True, + }, + { + "key": "src_task_search_filter", + "label": "Source task search filter", + "type": "string", + "required": False, + "default": "name:=\"test\"", + }, + { + "key": "dst_url", + "label": "Destination runZero URL", + "type": "url", + "required": False, + "default": "https://console.runzero.com", + }, + { + "key": "dst_org_id", + "label": "Destination org ID", + "type": "string", + "required": True, + }, + { + "key": "dst_site_id", + "label": "Destination site ID", + "type": "string", + "required": True, + }, + { + "key": "hide_tasks_on_sync", + "label": "Hide source tasks after sync", + "type": "bool", + "required": False, + "default": False, + }, + { + "key": "src_api_token", + "label": "Source API token", + "type": "secret", + "required": True, + "description": "Account or org token for the source instance", + }, + { + "key": "dst_api_token", + "label": "Destination API token", + "type": "secret", + "required": True, + "description": "Account or org token for the destination instance", + }, + ], + "includes": { + "src_tls_": OPTIONS_TLS, + "dst_tls_": OPTIONS_TLS, + "src_http_": OPTIONS_HTTP, + "dst_http_": OPTIONS_HTTP, + }, +} +load('http', http_get='get', http_post='post', http_put='put', 'get_json', 'bearer', 'url_encode') +load('gzip', gzip_decompress='decompress', gzip_compress='compress') +load('kwargs', 'get_http_options') + +def get_tasks(src_url, src_org_id, src_task_search_filter, src_token, config_kwargs): + params = {"_oid": src_org_id, "search": src_task_search_filter} + url = "{}{}{}".format(src_url, "/api/v1.0/org/tasks?", url_encode(params)) + data, err = get_json( + url, + **get_http_options(config_kwargs, "src_http_", "src_tls_", {"Authorization": bearer(src_token)}) + ) + if err: + print("Failed to get tasks:", err) + return [] + return data or [] + +def sync_task(task_id, src_token, dst_token, src_url, dst_url, src_org_id, dst_org_id, dst_site_id, hide_tasks_on_sync, config_kwargs): + # Download data from SaaS + print("Pulling task with ID {}".format(task_id)) + download_url = "{}/api/v1.0/org/tasks/{}/data".format(src_url, task_id) + download = http_get( + download_url, + timeout=3600, + **get_http_options(config_kwargs, "src_http_", "src_tls_", {"Authorization": bearer(src_token), "Accept": "application/octet-stream", "Content-Encoding": "gzip"}), + ) + if download.status_code != 200: + print("Failed to download task:", task_id) + return False + + # Upload data to self-hosted + print("Uploading task with ID {}".format(task_id)) + unzipped = gzip_decompress(download.body) + upload_url = "{}/api/v1.0/org/sites/{}/import?_oid={}".format(dst_url, dst_site_id, dst_org_id) + upload = http_put( + upload_url, + body=gzip_compress(unzipped), + timeout=3600, + **get_http_options(config_kwargs, "dst_http_", "dst_tls_", {"Authorization": bearer(dst_token), "Content-Type": "application/octet-stream", "Content-Encoding": "gzip"}), + ) + + if upload.status_code != 200: + print("Failed to upload task:", task_id) + return False + + print("Successfully synced task:", task_id) + + if hide_tasks_on_sync: + hide_url = "{}/api/v1.0/org/tasks/{}/hide?_oid={}".format(src_url, task_id, src_org_id) + hide = http_post( + hide_url, + **get_http_options(config_kwargs, "src_http_", "src_tls_", {"Authorization": bearer(src_token), "Content-Type": "application/json"}), + ) + if hide.status_code == 200: + print("Task hidden:", task_id) + + return True + +def main(**kwargs): + src_url = kwargs.get("src_url", "https://console.runzero.com").rstrip("/") + src_org_id = kwargs["src_org_id"] + src_task_search_filter = kwargs.get("src_task_search_filter", 'name:="test"') + dst_url = kwargs.get("dst_url", "https://console.runzero.com").rstrip("/") + dst_org_id = kwargs["dst_org_id"] + dst_site_id = kwargs["dst_site_id"] + hide_tasks_on_sync = kwargs.get("hide_tasks_on_sync", False) + + src_token = kwargs["src_api_token"] + dst_token = kwargs["dst_api_token"] + + tasks = get_tasks(src_url, src_org_id, src_task_search_filter, src_token, kwargs) + print("Got {} task(s) to sync".format(len(tasks))) + if not tasks: + print("No tasks found.") + return + + for task in tasks: + task_id = task.get("id", "") + if not task_id: + continue + success = sync_task( + task_id, + src_token, + dst_token, + src_url, + dst_url, + src_org_id, + dst_org_id, + dst_site_id, + hide_tasks_on_sync, + kwargs, + ) + if not success: + print("Sync failed for task:", task_id) + + return None diff --git a/vulnerability-workflow/README.md b/runzero-vulnerability-workflow/README.md similarity index 95% rename from vulnerability-workflow/README.md rename to runzero-vulnerability-workflow/README.md index 0a9bd22..b2b1394 100644 --- a/vulnerability-workflow/README.md +++ b/runzero-vulnerability-workflow/README.md @@ -10,7 +10,7 @@ The workflow ensures each CVE on an asset is only ticketed once, using a tag lik - **runZero**: - Superuser access to [Custom Integrations](https://console.runzero.com/custom-integrations). - - API token (`access_secret`) to access vulnerability exports and update asset tags. + - API token (`runzero_api_token`) to access vulnerability exports and update asset tags. - **External Ticketing System**: - An HTTP endpoint to receive ticket payloads (e.g., a Sumo Logic HTTP collector or custom webhook server). @@ -67,7 +67,7 @@ When a new CVE-asset match is detected, the integration sends a POST with the fo 1. **Create a Credential in runZero**: - Go to [Credentials](https://console.runzero.com/credentials). - Create a **Custom Integration Script Secrets** credential. - - Store your runZero API token in the `access_secret` field. + - Store your runZero API token in the `runzero_api_token` field. 2. **Create the Custom Integration**: - Go to [Custom Integrations](https://console.runzero.com/custom-integrations/new). diff --git a/runzero-vulnerability-workflow/vulnerability-workflow.star b/runzero-vulnerability-workflow/vulnerability-workflow.star new file mode 100644 index 0000000..aa2e757 --- /dev/null +++ b/runzero-vulnerability-workflow/vulnerability-workflow.star @@ -0,0 +1,117 @@ +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-vulnerability-workflow", + "name": "Vulnerability Workflow", + "type": "internal", + "description": "Runs a vulnerability triage workflow against runZero data.", + "version": "26052700", + "params": [ + { + "key": "src_url", + "label": "runZero source URL", + "type": "url", + "required": True, + "default": "https://console.runzero.com", + }, + { + "key": "dst_url", + "label": "Workflow endpoint URL", + "type": "url", + "required": True, + }, + { + "key": "runzero_api_token", + "label": "runZero API token", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +load("http", http_patch="patch", "get_json", "post_json", "bearer") +load('kwargs', 'get_url_base', 'get_http_options') +VULNERABILITY_SEARCH = "risk:critical" + +def main(**kwargs): + src_url = get_url_base(kwargs, "src_url") + dst_url = get_url_base(kwargs, "dst_url") + runzero_token = kwargs["runzero_api_token"] + src_options = get_http_options(kwargs, headers={"Authorization": bearer(runzero_token)}) + dst_options = get_http_options(kwargs) + tag_options = get_http_options(kwargs, headers={"Content-Type": "application/json", "Authorization": bearer(runzero_token)}) + + # Step 1: Fetch vulnerabilities + vulns, err = get_json( + src_url + "/api/v1.0/export/org/vulnerabilities.json?search={}".format(VULNERABILITY_SEARCH), + timeout=3600, + **src_options + ) + + if err: + print("Failed to fetch vulnerabilities:", err) + return + + vulns = vulns or [] + + # Step 2: Aggregate vulnerabilities + seen = {} + for v in vulns: + cve = v.get("vulnerability_cve", None) + asset_id = v.get("vulnerability_asset_id", None) + if cve and asset_id: + key = "{}:{}".format(asset_id, cve) + if key not in seen: + seen[key] = v + + # Step 3 and 4: POST and tag + for key, vuln in seen.items(): + + # Get attributes for the payload + asset_id = vuln.get("vulnerability_asset_id", None) + cve = vuln.get("vulnerability_cve", None) + tags = vuln.get("tags", {}) + vuln_name = vuln.get("vulnerability_name", "") + vulnerability_exploitable = vuln.get("vulnerability_exploitable", "") + os_vendor = vuln.get("os_vendor", "") + os_product = vuln.get("os_product", "") + addresses = vuln.get("addresses", []) + names = vuln.get("names", []) + macs = vuln.get("macs", []) + + # Verify the asset doesn't already have an open case for the CVE + if cve and asset_id and cve not in tags.keys(): + payload = { + "asset_id": asset_id, + "cve": cve, + "tags": tags, + "vulnerability_name": vuln_name, + "vulnerability_exploitable": vulnerability_exploitable, + "os_vendor": os_vendor, + "os_product": os_product, + "addresses": addresses, + "names": names, + "macs": macs + } + + post_json( + dst_url, + json=payload, + **dst_options + ) + + tag_url = src_url + "/api/v1.0/org/assets/{}/tags".format(asset_id) + http_patch( + tag_url, + json={"tags": "{}=OPENED".format(cve)}, + **tag_options + ) + + else: + print("Already has an open case for {}".format(cve)) + continue + + diff --git a/scale-computing/README.md b/scale-computing/README.md index f994c25..0c597ae 100644 --- a/scale-computing/README.md +++ b/scale-computing/README.md @@ -52,15 +52,15 @@ Make note of: | Field | Description | | --------------- | ---------------------------------------------- | | `base_url` | Scale API endpoint, e.g. `https://scale.local` | -| `access_key` | Your Scale username or token ID | -| `access_secret` | Your Scale password or token secret | +| `username` | Your Scale username or token ID | +| `password` | Your Scale password or token secret | ### 2. runZero Console 1. **Credentials** → **Add Credential** → **Custom Script Secret** - * **Access Key**: any placeholder (e.g. `foo`) - * **Access Secret**: your JSON config (see below) + * **Username**: your Scale username or token ID + * **Password**: your Scale password or token secret 2. **Integrations** → **Custom Integrations** → **Add Script** @@ -71,17 +71,17 @@ Make note of: ## Configuration -**Access Secret** (paste as a single-line JSON string): +**Password** may also be provided as a single-line JSON string: ```json -{"base_url":"https://scale.api.server","access_key":"scale_user","access_secret":"s3cr3tP@ssw0rd"} +{"base_url":"https://scale.api.server","username":"scale_user","password":"s3cr3tP@ssw0rd"} ``` | Field | Description | | --------------- | ------------------------------------------------------------- | | `base_url` | Scale API URL (no trailing slash), e.g. `https://scale.local` | -| `access_key` | Scale username or token ID | -| `access_secret` | Scale password or token secret | +| `username` | Scale username or token ID | +| `password` | Scale password or token secret | --- @@ -97,10 +97,10 @@ DEBUG = True def main(*args, **kwargs): """ Entrypoint for Scale API v1 integration. - Expects kwargs['access_secret'] to be a JSON string containing: + Expects kwargs['password'] to be a JSON string containing: - base_url : Scale API URL - - access_key : Username or token ID - - access_secret : Password or token secret + - username : Username or token ID + - password : Password or token secret Returns: list of ImportAsset objects for clusters and VMs. """ # …script code with debug_print(), json_decode(), base64_encode(), Session, etc.… diff --git a/scale-computing/config.json b/scale-computing/config.json deleted file mode 100644 index 7485afb..0000000 --- a/scale-computing/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Scale Computing", "type": "inbound" } \ No newline at end of file diff --git a/scale-computing/custom-integration-scale-computing.star b/scale-computing/scale-computing.star similarity index 71% rename from scale-computing/custom-integration-scale-computing.star rename to scale-computing/scale-computing.star index 7fca526..a27bff6 100644 --- a/scale-computing/custom-integration-scale-computing.star +++ b/scale-computing/scale-computing.star @@ -1,9 +1,44 @@ +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-scale-computing", + "name": "Scale Computing", + "type": "inbound", + "description": "Imports VMs and nodes from Scale Computing HC3 clusters.", + "version": "26052700", + "params": [ + { + "key": "base_url", + "label": "Cluster base URL", + "type": "url", + "required": True, + "placeholder": "https://hc3.example.com", + }, + { + "key": "username", + "label": "Username", + "type": "string", + "required": True, + }, + { + "key": "password", + "label": "Password", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} load('requests', 'Session') load('json', json_decode='decode') load('runzero.types', 'ImportAsset', 'NetworkInterface') load('base64', base64_encode='encode') +load('kwargs', 'get_bool') -INSECURE_ALLOWED = True +INSECURE_ALLOWED = False DEBUG = True # set to False to disable debug prints def debug_print(msg): @@ -13,11 +48,17 @@ def debug_print(msg): def main(*args, **kwargs): debug_print(">>> main() start") - # Decode the secret - secret = json_decode(kwargs.get('access_secret', '{}')) - username = secret.get('access_key') - password = secret.get('access_secret') - base_url = secret.get('base_url', '').rstrip('/') + # Prefer structured top-level kwargs (params[] schema). Fall back + # to the legacy JSON-stuffed password for back-compat. + base_url = kwargs.get('base_url', '').rstrip('/') + username = kwargs.get('username', '') + password = kwargs.get('password', '') + if password.startswith('{'): + secret = json_decode(password) + if type(secret) == 'dict' and len(secret) > 0: + base_url = base_url or secret.get('base_url', '').rstrip('/') + username = username or secret.get('username', '') + password = secret.get('password', password) debug_print("Base URL: {}".format(base_url)) if not base_url or not username or not password: debug_print("Failed to parse configuration") @@ -26,9 +67,12 @@ def main(*args, **kwargs): # Session & Auth header auth_str = "{}:{}".format(username, password) auth_hdr = "Basic {}".format(base64_encode(auth_str)) - session = Session(insecure_skip_verify=INSECURE_ALLOWED) + insecure_allowed = get_bool(kwargs, 'tls_disable_validation', INSECURE_ALLOWED) + session = Session(insecure_skip_verify=insecure_allowed) session.headers.set("Accept", "application/json") session.headers.set("Authorization", auth_hdr) + if kwargs.get('http_user_agent'): + session.headers.set('User-Agent', kwargs.get('http_user_agent')) debug_print("Session headers: {}".format(session.headers)) # 1) Fetch clusters @@ -132,10 +176,12 @@ def main(*args, **kwargs): "tags": tags, "createdAt": vm.get("created"), "modifiedAt": vm.get("modified"), - } + }, ) debug_print("Built asset: {}".format(asset)) assets.append(asset) - debug_print(">>> main() complete: returning {} assets".format(len(assets))) - return assets + # Stream assets to runZero via report_assets instead of returning a list. + reported = report_assets(assets) + debug_print(">>> main() complete: reported {} assets".format(reported)) + return None diff --git a/scan-passive-assets/config.json b/scan-passive-assets/config.json deleted file mode 100644 index 3261451..0000000 --- a/scan-passive-assets/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Scan Passive Assets", "type": "internal" } \ No newline at end of file diff --git a/scripts/generate_integration_json.py b/scripts/generate_integration_json.py index 0095e82..f68d448 100644 --- a/scripts/generate_integration_json.py +++ b/scripts/generate_integration_json.py @@ -1,6 +1,7 @@ import os import json -from datetime import datetime +import ast +from datetime import datetime, timezone # --- Config --- BLOCK_LIST = {".github", "boilerplate", "LICENSE", "README.md"} @@ -8,13 +9,61 @@ integration_details = [] +OPTION_SET_IDENTIFIERS = {"OPTIONS_TLS", "OPTIONS_HTTP"} + + +def find_matching_brace(text, open_idx): + depth = 0 + quote = None + escape = False + for idx in range(open_idx, len(text)): + ch = text[idx] + if quote: + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == quote: + quote = None + continue + if ch in {"'", '"'}: + quote = ch + elif ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return idx + return -1 + + +def load_embedded_config(script_path): + with open(script_path) as sf: + text = sf.read() + + marker = "CONFIG" + marker_idx = text.find(marker) + if marker_idx == -1: + return {} + equals_idx = text.find("=", marker_idx + len(marker)) + open_idx = text.find("{", equals_idx) + if equals_idx == -1 or open_idx == -1: + return {} + close_idx = find_matching_brace(text, open_idx) + if close_idx == -1: + return {} + + literal = text[open_idx : close_idx + 1] + for identifier in OPTION_SET_IDENTIFIERS: + literal = literal.replace(identifier, repr(identifier)) + return ast.literal_eval(literal) + for entry in os.listdir("."): if entry in BLOCK_LIST or not os.path.isdir(entry): continue folder_path = os.path.join(".", entry) readme_path = os.path.join(folder_path, "README.md") - config_path = os.path.join(folder_path, "config.json") integration_file = None # Look for the first .star file @@ -31,15 +80,12 @@ friendly_name = entry integration_type = "inbound" - # Override with config.json if available - if os.path.isfile(config_path): - try: - with open(config_path) as cf: - config = json.load(cf) - friendly_name = config.get("name", entry) - integration_type = config.get("type", "inbound") - except Exception as e: - print(f"⚠️ Failed to read config.json in {entry}: {e}") + try: + config = load_embedded_config(os.path.join(folder_path, integration_file)) + friendly_name = config.get("name", entry) + integration_type = config.get("type", "inbound") + except Exception as e: + print(f"⚠️ Failed to read embedded CONFIG in {entry}: {e}") if not integration_type: integration_type = "inbound" @@ -62,7 +108,7 @@ # --- Save JSON --- output = { - "lastUpdated": datetime.utcnow().isoformat() + "Z", + "lastUpdated": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), "totalIntegrations": len(integration_details), "integrationDetails": integration_details, } diff --git a/snipe-it/README.md b/snipe-it/README.md index 89471ea..dd40ead 100644 --- a/snipe-it/README.md +++ b/snipe-it/README.md @@ -41,9 +41,7 @@ - Any additional custom fields tracked in **Snipe-IT** that do not correspond to runZero's [ImportAsset](https://runzeroinc.github.io/runzero-sdk-py/autoapi/runzero/types/_data_models_gen/index.html#runzero.types._data_models_gen.ImportAsset) data model can be mapped to custom attributes following the pattern in the script 2. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials) - Select the type `Custom Integration Script Secrets` - - Both `access_key` and `access_secret` are required, but **Snipe-IT** only requires the bearer token - - Input a placeholder value like `foo` for the `access_key` value - - Input the **Snipe-IT** bearer token in the `access_secret` field + - Input the **Snipe-IT** bearer token in the `api_token` field. 3. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new) - Add a Name and Icon (e.g. snipeit) - The name given to the custom integration will correspond to the custom_integration value when creating queries diff --git a/snipe-it/config.json b/snipe-it/config.json deleted file mode 100644 index 93cde06..0000000 --- a/snipe-it/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Snipe-IT", "type": "inbound" } \ No newline at end of file diff --git a/snipe-it/snipeit.star b/snipe-it/snipe-it.star similarity index 77% rename from snipe-it/snipeit.star rename to snipe-it/snipe-it.star index f5423b7..aff3f61 100644 --- a/snipe-it/snipeit.star +++ b/snipe-it/snipe-it.star @@ -1,12 +1,38 @@ -load('runzero.types', 'ImportAsset', 'NetworkInterface') -load('json', json_encode='encode', json_decode='decode') -load('net', 'ip_address') -load('http', http_post='post', http_get='get', 'url_encode') -load('uuid', 'new_uuid') +# Copyright 2026 runZero, Inc. Available under the MIT License -#Change the URL to match your Snipe-IT server -SNIPE_BASE_URL = 'https://:' -RUNZERO_REDIRECT = 'https://console.runzero.com/' +CONFIG = { + "id": "runzero-snipe-it", + "name": "Snipe-IT", + "type": "inbound", + "description": "Imports hardware assets from Snipe-IT.", + "version": "26052700", + "params": [ + { + "key": "url", + "label": "Snipe-IT URL", + "type": "url", + "required": True, + "description": "Base URL for the Snipe-IT instance.", + "placeholder": "https://snipeit.example.com", + }, + { + "key": "api_token", + "label": "API token", + "type": "secret", + "required": True, + "description": "API token used to authenticate to Snipe-IT.", + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +load('runzero.types', 'ImportAsset', 'to_custom_attributes') +load('net', 'network_interface') +load('http', 'get_json', 'bearer') +load('kwargs', 'get_url_base', 'get_http_options') +load('uuid', 'new_uuid') def build_assets(assets_json): assets_import = [] @@ -110,7 +136,7 @@ def build_assets(assets_json): addr = v6.get('ip_address', '') ips.append(addr) - network = build_network_interface(ips=[], mac=mac) + network = network_interface(ips=[], mac=mac) assets_import.append( ImportAsset( @@ -119,7 +145,7 @@ def build_assets(assets_json): deviceType=device_type, manufacturer=manufacturer, networkInterfaces=[network], - customAttributes={ + customAttributes=to_custom_attributes({ "age": age, "asset.tag": asset_tag, "book.value": book_value, @@ -149,46 +175,29 @@ def build_assets(assets_json): "user.checkout": user_checkout, "warranty.months": warranty_months, "warranty.expiration": warranty_exp - } + }), ) ) return assets_import # build runZero network interfaces; shouldn't need to touch this -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) - - return NetworkInterface(macAddress=mac, ipv4Addresses=ip4s, ipv6Addresses=ip6s) - def main(**kwargs): - # assign API key from kwargs - token = kwargs['access_secret'] + base_url = get_url_base(kwargs) + token = kwargs['api_token'] + http_options = get_http_options(kwargs, headers={'Accept': 'application/json', 'Authorization': bearer(token)}) # get assets assets = [] - url = '{}/{}'.format(SNIPE_BASE_URL, 'api/v1/hardware') - assets = http_get(url, headers={'Accept': 'application/json', 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token}) - if assets.status_code != 200: - print('failed to retrieve assets' + str(assets)) + url = '{}/{}'.format(base_url, 'api/v1/hardware') + data, err = get_json(url, **http_options) + if err: + print('failed to retrieve assets:', err) return None - assets_json = json_decode(assets.body)['rows'] + assets_json = (data or {}).get('rows', []) - # build asset import - assets_import = build_assets(assets_json) - if not assets_import: + # build and stream asset import via report_assets instead of returning a list + if not report_assets(build_assets(assets_json)): print('no assets') - return None - return assets_import \ No newline at end of file + return None \ No newline at end of file diff --git a/snow-license-manager/README.md b/snow-license-manager/README.md index 6095d48..6d42ea5 100644 --- a/snow-license-manager/README.md +++ b/snow-license-manager/README.md @@ -36,8 +36,8 @@ NA - Identify the Customer ID to use for asset retrieval - Assign the Customer ID to `SNOW_CUSTOMER_ID` within the starlark script (multiple scripts can be created to import from different Customer IDs) 2. Create a valid username:password login to be used to authenticate to the API endpoints - - Copy the username; this will be used as the value for `access_key` when creating the Custom Integration credentials in the runZero console (see below) - - Copy the password; this will be used as the value for `access_secret` when creating the Custom Integration credentials in the runZero console (see below) + - Copy the username; this will be used as the value for `username` when creating the Custom Integration credentials in the runZero console (see below) + - Copy the password; this will be used as the value for `password` when creating the Custom Integration credentials in the runZero console (see below) ### runZero configuration @@ -46,9 +46,9 @@ NA - Modify datapoints uploaded to runZero as needed 2. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials) - Select the type `Custom Integration Script Secrets` - - Both `access_key` and `access_secret` are required - - `access_key` corresponds to the username for the Snow License Manager credentials - - `access_secret` corresponds to the password for the Snow License Manager credentials + - Both `username` and `password` are required + - `username` corresponds to the username for the Snow License Manager credentials + - `password` corresponds to the password for the Snow License Manager credentials 3. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new) - Add a Name and Icon - Toggle `Enable custom integration script` to input your finalized script diff --git a/snow-license-manager/config.json b/snow-license-manager/config.json deleted file mode 100644 index 5488d78..0000000 --- a/snow-license-manager/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Snow License Manager", "type": "inbound" } \ No newline at end of file diff --git a/snow-license-manager/custom-integration-snow.star b/snow-license-manager/snow.star similarity index 77% rename from snow-license-manager/custom-integration-snow.star rename to snow-license-manager/snow.star index c1d4ae5..335a877 100644 --- a/snow-license-manager/custom-integration-snow.star +++ b/snow-license-manager/snow.star @@ -1,17 +1,52 @@ -load('runzero.types', 'ImportAsset', 'NetworkInterface', 'Software') +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-snow-license-manager", + "name": "Snow License Manager", + "type": "inbound", + "description": "Imports devices from Snow License Manager.", + "version": "26061000", + "params": [ + { + "key": "url", + "label": "Snow base URL", + "type": "url", + "required": True, + "placeholder": "https://snow.example.com", + }, + { + "key": "customer_id", + "label": "Customer ID", + "type": "string", + "required": True, + }, + { + "key": "username", + "label": "Username", + "type": "string", + "required": True, + }, + { + "key": "password", + "label": "Password", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +load('runzero.types', 'ImportAsset', 'Software', 'to_custom_attributes') load('base64', base64_encode='encode', base64_decode='decode') -load('http', http_get='get', http_post='post', 'url_encode') -load('json', json_encode='encode', json_decode='decode') -load('net', 'ip_address') +load('http', 'get_json', 'url_encode') +load('kwargs', 'get_url_base', 'get_http_options', 'get_string') +load('net', 'network_interface') load('time', 'parse_time') load('uuid', 'new_uuid') -#Change the URL to match your Snow Software License Manager server -SNOW_BASE_URL = 'https://' -SNOW_CUSTOMER_ID = '' -RUNZERO_REDIRECT = 'https://console.runzero.com/' - -def build_assets(assets, creds): +def build_assets(base_url, customer_id, assets, creds, config_kwargs): assets_import = [] for entry in assets: item = entry.get('Body', {}) @@ -29,7 +64,7 @@ def build_assets(assets, creds): addresses = adapter.get('IpAddress', '').split(';') if type(addresses) != 'list': addresses = [addresses] - interface = build_network_interface(ips=addresses, mac=adapter.get('MacAddress', None)) + interface = network_interface(ips=addresses, mac=adapter.get('MacAddress', None)) interfaces.append(interface) # Retrieve and map custom attributes @@ -148,7 +183,7 @@ def build_assets(assets, creds): # Retrieve software information for asset # create software entries software = [] - applications = get_apps(asset_id, creds) + applications = get_apps(base_url, customer_id, asset_id, creds, config_kwargs) for app in applications: software_entry = build_app(app) software.append(software_entry) @@ -163,28 +198,12 @@ def build_assets(assets, creds): os=os, os_version=os_version, networkInterfaces=interfaces, - customAttributes=custom_attributes, - software=software + customAttributes=to_custom_attributes(custom_attributes), + software=software, ) ) 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 build_app(software_entry): app = software_entry.get('Body', {}) app_id = app.get('Id', None) @@ -268,25 +287,29 @@ def build_app(software_entry): id=app_id, product=product, vendor=vendor, - customAttributes=custom_attributes + customAttributes=to_custom_attributes(custom_attributes) ) -def get_computers(creds): +def get_computers(base_url, customer_id, creds, config_kwargs): + """Paginate computers, building and streaming each page of assets (including + their per-computer applications) via report_assets so the full computer set + is never held in memory. Returns the number of assets reported.""" items_returned = 0 total_items = 10000 - assets_all = [] + reported = 0 while True: - url = SNOW_BASE_URL + '/api/customers/' + SNOW_CUSTOMER_ID + '/computers?' + url = base_url + '/api/customers/' + customer_id + '/computers?' headers = {'Accept': 'application/json', 'Authorization': 'Basic ' + creds} + http_options = get_http_options(config_kwargs, headers=headers) params = {'$inlinecount': 'allpages', '$skip': str(items_returned)} - response = http_get(url, headers=headers, params=params) - if response.status_code != 200: - print('failed to retrieve assets at $skip=' + str(items_returned), 'status code: ' + str(response.status_code)) - else: - data = json_decode(response.body) + data, err = get_json(url, params=params, **http_options) + if err: + print('failed to retrieve assets at $skip=' + str(items_returned) + ': ' + err) + break + elif data: meta = data['Meta'] has_page_size = False for item in meta: @@ -296,29 +319,31 @@ def get_computers(creds): has_page_size = True items_returned += item.get('Value') computers = data['Body'] - assets_all.extend(computers) + reported += report_assets(build_assets(base_url, customer_id, computers, creds, config_kwargs)) if not has_page_size: # The last page lacks the page size meta value break - print(str(items_returned) + ' computers of ' + str(total_items) + ' returned from API') + print(str(items_returned) + ' computers of ' + str(total_items) + ' returned from API') + else: + break - return assets_all + return reported -def get_apps(asset_id, creds): +def get_apps(base_url, customer_id, asset_id, creds, config_kwargs): items_returned = 0 total_items = 10000 applications_all = [] while True: - url = SNOW_BASE_URL + '/api/customers/' + SNOW_CUSTOMER_ID + '/computers/' + str(asset_id) + '/applications?' + url = base_url + '/api/customers/' + customer_id + '/computers/' + str(asset_id) + '/applications?' headers = {'Accept': 'application/json', 'Authorization': 'Basic ' + creds} + http_options = get_http_options(config_kwargs, headers=headers) params = {'$inlinecount': 'allpages', '$skip': str(items_returned)} - response = http_get(url, headers=headers, params=params) - if response.status_code != 200: - print('failed to retrieve application for ' + str(asset_id) + ' at $skip=' + str(items_returned), 'status code: ' + str(response.status_code)) - else: - data = json_decode(response.body) + data, err = get_json(url, params=params, **http_options) + if err: + print('failed to retrieve application for ' + str(asset_id) + ' at $skip=' + str(items_returned) + ': ' + err) + elif data: meta = data['Meta'] has_page_size = False for item in meta: @@ -335,29 +360,30 @@ def get_apps(asset_id, creds): return applications_all -def get_app_details(app_id, creds): - url = SNOW_BASE_URL + '/api/customers/' + SNOW_CUSTOMER_ID + '/applications/' + str(app_id) +def get_app_details(base_url, customer_id, app_id, creds, config_kwargs): + url = base_url + '/api/customers/' + customer_id + '/applications/' + str(app_id) headers = {'Accept': 'application/json', 'Authorization': 'Basic ' + creds} - response = http_get(url, headers=headers) - if response.status_code != 200: - print('failed to retrieve application details for ' + str(app_id), 'status code: ' + str(response.status_code)) + data, err = get_json(url, **get_http_options(config_kwargs, headers=headers)) + if err: + print('failed to retrieve application details for ' + str(app_id) + ': ' + err) + details = None else: - data = json_decode(response.body) - details = data['Body'] + details = (data or {}).get('Body') return details def main(*args, **kwargs): - username = kwargs['access_key'] - password = kwargs['access_secret'] + base_url = get_url_base(kwargs) + customer_id = get_string(kwargs, 'customer_id') + username = kwargs['username'] + password = kwargs['password'] b64_creds = base64_encode(username + ":" + password) - assets = get_computers(b64_creds) - - # Format asset list for import into runZero - import_assets = build_assets(assets, b64_creds) - if not import_assets: + + # Computers (and their applications) are streamed page-by-page via + # report_assets in get_computers. + reported = get_computers(base_url, customer_id, b64_creds, kwargs) + if not reported: print('no assets') - return None - return import_assets \ No newline at end of file + return None \ No newline at end of file diff --git a/solarwinds-information-service/README.md b/solarwinds-information-service/README.md index 1c28797..69e6409 100644 --- a/solarwinds-information-service/README.md +++ b/solarwinds-information-service/README.md @@ -47,9 +47,9 @@ git clone https://github.com/runZeroInc/runzero-custom-integrations.git >- For a list of "core" attributes that runZero maps, reference the Custom SDK documentation [here](https://runzeroinc.github.io/runzero-sdk-py/autoapi/runzero/types/_data_models_gen/index.html#runzero.types._data_models_gen.ImportAsset). All other attributes provided by Solarwinds should be mapped within 'Custom Attributes' 2. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials) - Select the type **Custom Integration Script Secrets** - - Both **access_key** and **access_secret** are required - - **access_key** corresponds to the username to access Solarwinds - - **access_secret** corresponds to the password to access Solarwinds + - Both **username** and **password** are required + - **username** corresponds to the username to access Solarwinds + - **password** corresponds to the password to access Solarwinds 3. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new) - Add a Name (e.g. solarwinds) and Icon - Toggle **Enable custom integration script** to input your finalized script diff --git a/solarwinds-information-service/config.json b/solarwinds-information-service/config.json deleted file mode 100644 index 301ec59..0000000 --- a/solarwinds-information-service/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Solarwinds Information Service", "type": "inbound" } \ No newline at end of file diff --git a/solarwinds-information-service/custom-integration-swis.star b/solarwinds-information-service/custom-integration-swis.star deleted file mode 100644 index ce99e8b..0000000 --- a/solarwinds-information-service/custom-integration-swis.star +++ /dev/null @@ -1,111 +0,0 @@ -load('runzero.types', 'ImportAsset', 'NetworkInterface') -load('base64', base64_encode='encode', base64_decode='decode') -load('http', http_get='get', http_post='post', 'url_encode') -load('json', json_encode='encode', json_decode='decode') -load('net', 'ip_address') -load('uuid', 'new_uuid') - -SWIS_BASE_URL = 'https://localhost:17774' -RUNZERO_REDIRECT = 'https://console.runzero.com/' - -def build_assets(assets): - assets_import = [] - for asset in assets: - asset_id = str(asset.get('NodeId', str(new_uuid))) - hostname = asset.get('Fqdn', '') - os = asset.get('OsVersion', '') - vendor = asset.get('Vendor', '') - - # create the network interfaces - interfaces = [] - addresses = asset.get('IpAddress', []) - interface = build_network_interface(ips=[addresses], mac=None) - interfaces.append(interface) - - # Retrieve and map custom attributes - cpu_util = str(asset.get('CpuPercentUtilization', '')) - discovery_profile_id = str(asset.get('DiscoveryProfileId', '')) - mem_util_perc = str(asset.get('PercentMemoryUsed', '')) - mem_util = str(asset.get('MemoryUsed', '')) - pollers = asset.get('Pollers', '') - response_time = str(asset.get('ResponseTime', '')) - snmp_port = str(asset.get('SnmpPort', '')) - snmp_version = str(asset.get('SnmpVersion', '')) - status = asset.get('Status', '') - sys_object_id = asset.get('SysObjectId', '') - uptime = str(asset.get('Uptime', '')) - - custom_attributes = { - 'percentCpuUtilization': cpu_util, - 'discoveryProfileId': discovery_profile_id, - 'percentMemoryUtilization': mem_util_perc, - 'memoryUtilized': mem_util, - 'pollers': pollers, - 'responseTime': response_time, - 'snmp.port': snmp_port, - 'snmp.version': snmp_version, - 'status': status, - 'sysObjectId': sys_object_id, - 'uptime': uptime - } - - # Build assets for import - assets_import.append( - ImportAsset( - id=asset_id, - hostnames=[hostname], - os=os, - manufacturer=vendor, - networkInterfaces=interfaces, - customAttributes=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(creds): - - url = SWIS_BASE_URL + 'SolarWinds/InformationService/v3/Json/Query?' - headers = {'Accept': 'application/json', - 'Authorization': 'Basic ' + creds} - # Populate the SWQL query to return desired assets and attributes in the params query value e.g. - # params = {'query': 'SELECT N.NodeID, N.OsVersion, N.Fqdn, N.Vendor, N.IPAddress, N.CpuPercentUtilization, N.DiscoveryProfileId, N.PercentMemoryUsed, N.MemoryUsed, N.Pollers, N.responseTime, N.snmp.port, N.snmp.version, N.status, N.sysObjectId, N.Uptime FROM Orion.Nodes'} - params = {'query': ''} - response = http_get(url, headers=headers, params=params) - # To skip secure verification, comment the above response line and uncomment the response line below - # response = http_get(url, headers=headers, params=params, insecure_skip_verify=True) - if response.status_code != 200: - print('failed to retrieve assets', 'status code: ' + str(response.status_code)) - data = json_decode(response.body) - assets = data['results'] - - return assets - -def main(*args, **kwargs): - username = kwargs['access_key'] - password = kwargs['access_secret'] - b64_creds = base64_encode(username + ":" + password) - assets = get_assets(b64_creds) - - # Format asset list for import into runZero - import_assets = build_assets(assets) - if not import_assets: - print('no assets') - return None - - return import_assets diff --git a/solarwinds-information-service/swis.star b/solarwinds-information-service/swis.star new file mode 100644 index 0000000..65b68f2 --- /dev/null +++ b/solarwinds-information-service/swis.star @@ -0,0 +1,114 @@ +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-solarwinds-information-service", + "name": "SolarWinds Information Service", + "type": "inbound", + "description": "Imports devices via the SolarWinds Information Service (SWIS) API.", + "version": "26052700", + "params": [ + { + "key": "url", + "label": "SWIS URL", + "type": "url", + "required": False, + "default": "https://localhost:17774", + }, + { + "key": "username", + "label": "Username", + "type": "string", + "required": True, + }, + { + "key": "password", + "label": "Password", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +load('runzero.types', 'ImportAsset', 'to_custom_attributes') +load('base64', base64_encode='encode', base64_decode='decode') +load('http', 'get_json', 'url_encode') +load('kwargs', 'get_url_base', 'get_http_options') +load('net', 'network_interface') +load('uuid', 'new_uuid') + +RUNZERO_REDIRECT = 'https://console.runzero.com/' + +def build_assets(assets): + assets_import = [] + for asset in assets: + asset_id = str(asset.get('NodeId', str(new_uuid))) + hostname = asset.get('Fqdn', '') + os = asset.get('OsVersion', '') + vendor = asset.get('Vendor', '') + + # create the network interfaces + interfaces = [] + addresses = asset.get('IpAddress', []) + interface = network_interface(ips=[addresses], mac=None) + interfaces.append(interface) + + # Retrieve and map custom attributes + custom_attributes = to_custom_attributes({ + 'percentCpuUtilization': asset.get('CpuPercentUtilization'), + 'discoveryProfileId': asset.get('DiscoveryProfileId'), + 'percentMemoryUtilization': asset.get('PercentMemoryUsed'), + 'memoryUtilized': asset.get('MemoryUsed'), + 'pollers': asset.get('Pollers'), + 'responseTime': asset.get('ResponseTime'), + 'snmp.port': asset.get('SnmpPort'), + 'snmp.version': asset.get('SnmpVersion'), + 'status': asset.get('Status'), + 'sysObjectId': asset.get('SysObjectId'), + 'uptime': asset.get('Uptime'), + }) + + # Build assets for import + assets_import.append( + ImportAsset( + id=asset_id, + hostnames=[hostname], + os=os, + manufacturer=vendor, + networkInterfaces=interfaces, + customAttributes=custom_attributes, + ) + ) + return assets_import + +def get_assets(base_url, creds, config_kwargs): + + url = base_url + '/SolarWinds/InformationService/v3/Json/Query?' + headers = {'Accept': 'application/json', + 'Authorization': 'Basic ' + creds} + http_options = get_http_options(config_kwargs, headers=headers) + # Populate the SWQL query to return desired assets and attributes in the params query value e.g. + # params = {'query': 'SELECT N.NodeID, N.OsVersion, N.Fqdn, N.Vendor, N.IPAddress, N.CpuPercentUtilization, N.DiscoveryProfileId, N.PercentMemoryUsed, N.MemoryUsed, N.Pollers, N.responseTime, N.snmp.port, N.snmp.version, N.status, N.sysObjectId, N.Uptime FROM Orion.Nodes'} + params = {'query': ''} + data, err = get_json(url, params=params, **http_options) + if err: + print('failed to retrieve assets:', err) + return [] + assets = (data or {}).get('results', []) + + return assets + +def main(*args, **kwargs): + base_url = get_url_base(kwargs, default='https://localhost:17774') + username = kwargs['username'] + password = kwargs['password'] + b64_creds = base64_encode(username + ":" + password) + assets = get_assets(base_url, b64_creds, kwargs) + + # Build and stream asset import via report_assets instead of returning a list + if not report_assets(build_assets(assets)): + print('no assets') + + return None diff --git a/stairwell/README.md b/stairwell/README.md index 7a68ccd..6f1ba55 100644 --- a/stairwell/README.md +++ b/stairwell/README.md @@ -24,8 +24,8 @@ - Modify datapoints uploaded to runZero as needed. 2. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials). - Select the type `Custom Integration Script Secrets`. - - For the `access_key`, input your Stairwell client ID. - - For the `access_secret`, input your Stairwell client secret. + - For `environment_id`, input your Stairwell environment ID. + - For `api_token`, input your Stairwell API token. 3. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new). - Add a Name and Icon for the integration (e.g., "Stairwell"). - Upload an image file for the Stairwell icon. diff --git a/stairwell/config.json b/stairwell/config.json deleted file mode 100644 index 491edda..0000000 --- a/stairwell/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Stairwell", "type": "inbound" } \ No newline at end of file diff --git a/stairwell/custom-integration-stairwell.star b/stairwell/stairwell.star similarity index 53% rename from stairwell/custom-integration-stairwell.star rename to stairwell/stairwell.star index b8a4c8e..0df2818 100644 --- a/stairwell/custom-integration-stairwell.star +++ b/stairwell/stairwell.star @@ -1,30 +1,58 @@ -load('runzero.types', 'ImportAsset', 'NetworkInterface') -load('json', json_encode='encode', json_decode='decode') -load('net', 'ip_address') -load('http', http_post='post', http_get='get', 'url_encode') +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-stairwell", + "name": "Stairwell", + "type": "inbound", + "description": "Imports environment data from Stairwell.", + "version": "26061000", + "params": [ + { + "key": "environment_id", + "label": "Environment ID", + "type": "string", + "required": True, + }, + { + "key": "api_token", + "label": "API token", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +load('runzero.types', 'ImportAsset', 'to_custom_attributes') +load('net', 'network_interface') +load('http', 'get_json') +load('kwargs', 'get_http_options') load('uuid', 'new_uuid') STAIRWELL_API_URL = 'https://app.stairwell.com' -def get_assets(env, token): +def stream_assets(env, token, config_kwargs): + """Paginate Stairwell assets, building and streaming each page via + report_assets so the full asset set is never held in memory. Returns the + number of assets reported.""" hasNextPage = True page_size = 5 - assets_all = [] + reported = 0 url = STAIRWELL_API_URL + "/v1/environments/" + env + "/assets" headers = {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token} + http_options = get_http_options(config_kwargs, headers=headers) params = {'limit': page_size} while hasNextPage: - response = http_get(url, headers=headers, params=params) - if response.status_code != 200: - print('failed to retrieve assets', response.status_code) - return None + assets, err = get_json(url, params=params, **http_options) + if err: + print('failed to retrieve assets:', err) + return reported - assets = json_decode(response.body) - - for a in assets.get('assets', ''): - assets_all.append(a) + reported += report_assets(build_assets(assets.get('assets', []))) next_token = assets.get('nextPageToken', '') if next_token: @@ -32,7 +60,7 @@ def get_assets(env, token): else: hasNextPage = False - return assets_all + return reported def build_assets(assets_json): imported_assets = [] @@ -65,10 +93,10 @@ def build_assets(assets_json): networks = [] if macs: for m in macs: - network = build_network_interface(ips=ips, mac=m) + network = network_interface(ips=ips, mac=m) networks.append(network) else: - network = build_network_interface(ips=ips, mac=None) + network = network_interface(ips=ips, mac=None) networks.append(network) # parse operating system @@ -93,49 +121,29 @@ def build_assets(assets_json): networkInterfaces=networks, os=os, osVersion = item.get('osVersion', ''), - customAttributes={ - 'createTime':item.get('createTime', ''), - 'lastCheckinTime':item.get('lastCheckinTime', ''), - 'environment':item.get('environment', ''), - 'forwarderVersion':item.get('forwarderVersion', ''), - 'uploadToken':item.get('uploadToken', ''), - 'backscanState':item.get('backscanState', ''), + customAttributes=to_custom_attributes({ + 'createTime':item.get('createTime'), + 'lastCheckinTime':item.get('lastCheckinTime'), + 'environment':item.get('environment'), + 'forwarderVersion':item.get('forwarderVersion'), + 'uploadToken':item.get('uploadToken'), + 'backscanState':item.get('backscanState'), 'os.raw':os_raw, - 'state':item.get('state', '') - } + 'state':item.get('state') + }), ) ) return imported_assets # build runZero network interfaces; shouldn't need to touch this -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) - - return NetworkInterface(macAddress=mac, ipv4Addresses=ip4s, ipv6Addresses=ip6s) - def main(**kwargs): # kwargs!! - env = kwargs['access_key'] - token = kwargs['access_secret'] - - # get assets - assets = get_assets(env, token) - if not assets: + env = kwargs['environment_id'] + token = kwargs['api_token'] + + # Assets are streamed page-by-page via report_assets in stream_assets. + reported = stream_assets(env, token, kwargs) + if not reported: print('failed to retrieve assets') - return None - # build asset import - imported_assets = build_assets(assets) - - return imported_assets \ No newline at end of file + return None \ No newline at end of file diff --git a/sumo-logic/README.md b/sumo-logic/README.md index 2e2850a..53d946a 100644 --- a/sumo-logic/README.md +++ b/sumo-logic/README.md @@ -26,8 +26,7 @@ - Modify the `SEARCH` variable to adjust the query used to filter assets in runZero. 2. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials). - Select the type `Custom Integration Script Secrets`. - - Use the `access_secret` field for your runZero API Export Token. - - For `access_key`, input a placeholder value like `foo` (unused in this integration). + - Use the `runzero_export_token` field for your runZero API Export Token. 3. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new). - Add a Name and Icon for the integration (e.g., "sumo-logic-export"). - Toggle `Enable custom integration script` to input the finalized script. diff --git a/sumo-logic/config.json b/sumo-logic/config.json deleted file mode 100644 index ee9cb97..0000000 --- a/sumo-logic/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Sumo Logic", "type": "outbound" } diff --git a/sumo-logic/custom-integration-sumo.star b/sumo-logic/custom-integration-sumo.star deleted file mode 100644 index ae1f870..0000000 --- a/sumo-logic/custom-integration-sumo.star +++ /dev/null @@ -1,42 +0,0 @@ -load('runzero.types', 'ImportAsset', 'NetworkInterface') -load('json', json_encode='encode', json_decode='decode') -load('net', 'ip_address') -load('http', http_post='post', http_get='get', 'url_encode') - -SUMO_HTTP_ENDPOINT = "" -BASE_URL = "https://console.runZero.com/api/v1.0" -SEARCH = "alive:t" - -def get_assets(headers): - # get assets to upload to sumo - assets = [] - url = BASE_URL + "/export/org/assets.json?{}".format(url_encode({"search": SEARCH})) - get_assets = http_get(url=url, headers=headers, timeout=600) - assets_json = json_decode(get_assets.body) - if get_assets.status_code == 200 and len(assets_json) > 0: - print("Got {} assets".format(len(assets_json))) - return assets_json - else: - print("runZero did not return any assets - status code {}".format(get_assets.status_code)) - return None - -def sync_to_sumo(assets): - print("Sending {} assets to Sumo Logic".format(len(assets))) - batchsize = 500 - if len(assets) > 0: - for i in range(0, len(assets), batchsize): - batch = assets[i:i+batchsize] - tmp = "" - for a in batch: - tmp = tmp + "{}\n".format(json_encode(a)) - post_to_sumo = http_post(url=SUMO_HTTP_ENDPOINT, body=bytes(tmp)) - else: - print("No assets found") - - -def main(*args, **kwargs): - rz_export_token = kwargs['access_secret'] - headers = {"Authorization": "Bearer {}".format(rz_export_token)} - assets = get_assets(headers=headers) - if assets: - sync_to_sumo(assets=assets) \ No newline at end of file diff --git a/sumo-logic/sumo.star b/sumo-logic/sumo.star new file mode 100644 index 0000000..312f5d0 --- /dev/null +++ b/sumo-logic/sumo.star @@ -0,0 +1,79 @@ +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-sumo-logic", + "name": "Sumo Logic", + "type": "outbound", + "description": "Exports runZero assets into Sumo Logic.", + "version": "26052700", + "params": [ + { + "key": "src_url", + "label": "runZero source URL", + "type": "url", + "required": True, + "default": "https://console.runzero.com", + }, + { + "key": "dst_url", + "label": "Sumo HTTP endpoint", + "type": "url", + "required": True, + }, + { + "key": "runzero_export_token", + "label": "runZero export token", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +load('runzero.types', 'ImportAsset', 'NetworkInterface') +load('json', json_encode='encode') +load('net', 'ip_address') +load('http', http_post='post', 'get_json', 'bearer', 'url_encode') +load('kwargs', 'get_url_base', 'get_http_options') + +SEARCH = "alive:t" + +def get_assets(base_url, http_options): + # get assets to upload to sumo + assets = [] + url = base_url + "/api/v1.0/export/org/assets.json?{}".format(url_encode({"search": SEARCH})) + assets_json, err = get_json(url=url, timeout=600, **http_options) + if err: + print("runZero export failed:", err) + return None + if assets_json and len(assets_json) > 0: + print("Got {} assets".format(len(assets_json))) + return assets_json + else: + print("runZero did not return any assets") + return None + +def sync_to_sumo(dst_url, assets, http_options): + print("Sending {} assets to Sumo Logic".format(len(assets))) + batchsize = 500 + if len(assets) > 0: + for i in range(0, len(assets), batchsize): + batch = assets[i:i+batchsize] + tmp = "" + for a in batch: + tmp = tmp + "{}\n".format(json_encode(a)) + post_to_sumo = http_post(url=dst_url, body=bytes(tmp), **http_options) + else: + print("No assets found") + + +def main(*args, **kwargs): + src_url = get_url_base(kwargs, "src_url") + dst_url = get_url_base(kwargs, "dst_url") + rz_export_token = kwargs['runzero_export_token'] + headers = {"Authorization": "Bearer {}".format(rz_export_token)} + assets = get_assets(base_url=src_url, http_options=get_http_options(kwargs, headers=headers)) + if assets: + sync_to_sumo(dst_url=dst_url, assets=assets, http_options=get_http_options(kwargs)) \ No newline at end of file diff --git a/tailscale/README.md b/tailscale/README.md index 5452a91..4551348 100644 --- a/tailscale/README.md +++ b/tailscale/README.md @@ -4,8 +4,8 @@ - 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: - - `access_key`: your **Tailscale OAuth Client ID** (leave blank if using a standard API key) - - `access_secret`: your **Tailscale API key** or **OAuth Client Secret** + - `client_id`: your **Tailscale OAuth Client ID** (leave blank if using a standard API key) + - `api_key_or_client_secret`: your **Tailscale API key** or **OAuth Client Secret** ## Tailscale requirements @@ -45,8 +45,8 @@ Update this value inside the script if your environment uses a specific tailnet 1. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials). * Select **Custom Integration Script Secrets**. - * For `access_secret`, enter your **API key** or **OAuth client secret**. - * For `access_key`, enter your **OAuth client ID**, or a placeholder value (e.g., `foo`) if using an API key. + * For `api_key_or_client_secret`, enter your **API key** or **OAuth client secret**. + * For `client_id`, enter your **OAuth client ID**, or leave it blank if using an API key. 2. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new). * Add a descriptive name (e.g., `tailscale-sync`). diff --git a/tailscale/config.json b/tailscale/config.json deleted file mode 100644 index 33d1288..0000000 --- a/tailscale/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Tailscale", "type": "inbound" } diff --git a/tailscale/custom-integration-tailscale.star b/tailscale/custom-integration-tailscale.star deleted file mode 100644 index c9ed496..0000000 --- a/tailscale/custom-integration-tailscale.star +++ /dev/null @@ -1,337 +0,0 @@ -# Tailscale API + OAuth2 Client -> runZero ImportAsset Integration -# -# Supports both: -# - Direct API key (tskey-api-xxxxx) -# - OAuth client credentials (access_key = client_id, access_secret = client_secret) -# -# Only two credential inputs are required: -# access_key : client_id (if OAuth) or unused for API key mode -# access_secret : client_secret (if OAuth) or API key (tskey-api-xxxxx) -# -# Tailnet ID is defined below as a global variable. - -load("runzero.types", "ImportAsset", "NetworkInterface") -load("json", json_decode="decode") -load("net", "ip_address") -load("http", http_get="get", http_post="post", "url_encode") -load("time", "parse_time") - -# --- Configuration --- -TAILSCALE_API_BASE = "https://api.tailscale.com/api/v2" -TAILSCALE_TOKEN_URL = "https://api.tailscale.com/api/v2/oauth/token" -TAILNET_DEFAULT = "YOUR_TAILNET_ID" # change to your tailnet ID, e.g. "T1234CNTRL" -DEFAULT_SCOPE = "devices:core:read" -INSECURE_SKIP_VERIFY_DEFAULT = False - - -def _log(msg): - print("[TAILSCALE] " + msg) - - -def obtain_oauth_token(client_id, client_secret, scope, insecure_skip_verify): - """ - Request an OAuth2 access token from Tailscale. - """ - _log("Requesting OAuth2 token from Tailscale...") - headers = { - "Content-Type": "application/x-www-form-urlencoded", - "Accept": "application/json", - } - form = { - "grant_type": "client_credentials", - "client_id": client_id, - "client_secret": client_secret, - "scope": scope, - } - - resp = http_post( - url=TAILSCALE_TOKEN_URL, - headers=headers, - body=bytes(url_encode(form)), - insecure_skip_verify=insecure_skip_verify, - ) - - if resp == None: - _log("ERROR: No response from OAuth token endpoint.") - return None - - _log("DEBUG: OAuth token response status: " + str(resp.status_code)) - if resp.status_code != 200: - _log("ERROR: OAuth token request failed: " + str(resp.status_code)) - if resp.body != None: - _log("ERROR: Response: " + str(resp.body)) - return None - - body = json_decode(resp.body) - token = body.get("access_token") - expires = body.get("expires_in") - if token == None: - _log("ERROR: Missing access_token in OAuth response.") - return None - - _log("SUCCESS: Obtained access token (expires_in=" + str(expires) + "s)") - return token - - -def tailscale_get_devices(access_token, tailnet, insecure_skip_verify): - """ - Fetch device inventory for a tailnet using an access token or API key. - Uses fields=all to get complete device information including clientConnectivity. - """ - url = TAILSCALE_API_BASE + "/tailnet/" + tailnet + "/devices?fields=all" - headers = {"Authorization": "Bearer " + access_token, "Accept": "application/json"} - _log("DEBUG: Fetching devices from " + url) - - resp = http_get(url=url, headers=headers, insecure_skip_verify=insecure_skip_verify) - if resp == None: - _log("ERROR: No response from Tailscale devices endpoint.") - return None - - _log("DEBUG: Devices response status: " + str(resp.status_code)) - - if resp.status_code == 401: - _log("ERROR: Unauthorized (401) - invalid or expired token.") - return None - if resp.status_code == 403: - _log("ERROR: Forbidden (403) - insufficient permissions or missing scope.") - if resp.body != None: - _log("ERROR: Body: " + str(resp.body)) - return None - if resp.status_code == 404: - _log("ERROR: Not Found (404) - invalid tailnet ID.") - return None - if resp.status_code != 200: - _log("ERROR: Unexpected status: " + str(resp.status_code)) - if resp.body != None: - _log("ERROR: Body: " + str(resp.body)) - return None - - body = json_decode(resp.body) - devices = body.get("devices", []) - _log("SUCCESS: Retrieved " + str(len(devices)) + " devices.") - return devices - - -def _clean_address(addr): - """ - Remove CIDR notation from addresses (e.g., 10.0.0.1/32 -> 10.0.0.1) - """ - if addr == None: - return None - parts = addr.split("/") - return parts[0] - - -def _extract_ip_from_endpoint(endpoint): - """ - Extract IP address from endpoint string (e.g., "129.222.196.154:63425" -> "129.222.196.154") - Handles both IPv4 and IPv6 formats: - - IPv4: 129.222.196.154:63425 - - IPv6: [2605:59c0:2959:8910:d1aa:3b0:5142:f680]:41641 - """ - if endpoint == None or endpoint == "": - return None - - # IPv6 format: [address]:port - if endpoint.startswith("["): - end_bracket = endpoint.find("]") - if end_bracket > 0: - return endpoint[1:end_bracket] - - # IPv4 format: address:port - colon_pos = endpoint.rfind(":") - if colon_pos > 0: - return endpoint[:colon_pos] - - return endpoint - - -def build_network_interface_from_addresses(addresses, mac): - """ - Build NetworkInterface from Tailscale addresses (Tailscale IPs) - """ - if addresses == None: - return None - ipv4s = [] - ipv6s = [] - for a in addresses: - ipstr = _clean_address(a) - if ipstr == None: - continue - ipobj = ip_address(ipstr) - if ipobj == None: - continue - if ipobj.version == 4: - ipv4s.append(ipobj) - else: - ipv6s.append(ipobj) - if len(ipv4s) + len(ipv6s) >= 99: - break - if len(ipv4s) == 0 and len(ipv6s) == 0 and mac == None: - return None - return NetworkInterface(macAddress=mac, ipv4Addresses=ipv4s, ipv6Addresses=ipv6s) - - -def build_network_interfaces_from_endpoints(endpoints): - """ - Build additional NetworkInterfaces from clientConnectivity endpoints. - These are the actual physical IPs (public and private) that runZero can correlate with. - """ - if endpoints == None or len(endpoints) == 0: - return [] - - ipv4s = [] - ipv6s = [] - - for endpoint in endpoints: - ipstr = _extract_ip_from_endpoint(endpoint) - if ipstr == None: - continue - ipobj = ip_address(ipstr) - if ipobj == None: - continue - if ipobj.version == 4: - ipv4s.append(ipobj) - else: - ipv6s.append(ipobj) - if len(ipv4s) + len(ipv6s) >= 99: - break - - if len(ipv4s) == 0 and len(ipv6s) == 0: - return [] - - return [NetworkInterface(ipv4Addresses=ipv4s, ipv6Addresses=ipv6s)] - - -def transform_device_to_importasset(device, tailnet): - device_id = device.get("id", "") - hostname = device.get("hostname", device.get("name", "")) - addresses = device.get("addresses", []) - os_name = device.get("os", "Unknown") - - if device_id == "": - return None - - # Build primary interface from Tailscale VPN addresses - network_interfaces = [] - tailscale_netif = build_network_interface_from_addresses(addresses, None) - if tailscale_netif != None: - network_interfaces.append(tailscale_netif) - - attrs = { - "source": "Tailscale Integration", - "tailscale_device_id": device_id, - "tailscale_tailnet": tailnet, - "tailscale_user": device.get("user", ""), - "tailscale_os": os_name, - "tailscale_client_version": device.get("clientVersion", ""), - "tailscale_authorized": str(device.get("authorized", False)), - "tailscale_update_available": str(device.get("updateAvailable", False)), - "tailscale_key_expiry_disabled": str(device.get("keyExpiryDisabled", False)), - "tailscale_is_external": str(device.get("isExternal", False)), - "tailscale_blocks_incoming_connections": str(device.get("blocksIncomingConnections", False)), - "tailscale_created": device.get("created", ""), - } - - # Extract clientConnectivity information (available with fields=all) - client_conn = device.get("clientConnectivity") - if client_conn != None: - endpoints = client_conn.get("endpoints", []) - if endpoints != None and len(endpoints) > 0: - # Store raw endpoints for reference - attrs["tailscale_client_endpoints"] = ", ".join(endpoints) - - # Build additional network interfaces from physical IPs for runZero correlation - endpoint_interfaces = build_network_interfaces_from_endpoints(endpoints) - network_interfaces.extend(endpoint_interfaces) - _log("DEBUG: Added " + str(len(endpoint_interfaces)) + " endpoint interfaces for device " + device_id) - - derp = client_conn.get("derp", "") - if derp != None and derp != "": - attrs["tailscale_client_derp"] = derp - - mapping_varies = client_conn.get("mappingVariesByDestIP") - if mapping_varies != None: - attrs["tailscale_mapping_varies_by_dest_ip"] = str(mapping_varies) - - latency = client_conn.get("latency") - if latency != None: - for region, ms in latency.items(): - attrs["tailscale_latency_" + region] = str(ms) - - # Require at least one network interface for correlation - if len(network_interfaces) == 0: - _log("WARN: Skipping device " + device_id + " - no network interfaces available") - return None - - parsed_time = device.get("created") - if parsed_time != None and parsed_time != "": - parsed = parse_time(parsed_time) - if parsed != None: - attrs["tailscale_created_ts"] = parsed.unix - - tags = device.get("tags", []) - if tags != None and len(tags) > 0: - attrs["tailscale_tags"] = ", ".join(tags) - - adv_routes = device.get("advertisedRoutes", []) - if adv_routes != None and len(adv_routes) > 0: - attrs["tailscale_advertised_routes"] = ", ".join(adv_routes) - - en_routes = device.get("enabledRoutes", []) - if en_routes != None and len(en_routes) > 0: - attrs["tailscale_enabled_routes"] = ", ".join(en_routes) - - asset_id = "tailscale-" + device_id - hostnames = [hostname] if hostname != "" else [] - asset_tags = ["tailscale", "api"] + tags - - return ImportAsset( - id=asset_id, - hostnames=hostnames, - networkInterfaces=network_interfaces, - os=os_name, - tags=asset_tags, - customAttributes=attrs, - ) - - -def main(*args, **kwargs): - _log("=== TAILSCALE API / OAUTH INTEGRATION ===") - - client_id = kwargs.get("access_key") # used only for OAuth - secret = kwargs.get("access_secret") # API key or OAuth client_secret - insecure_skip_verify = INSECURE_SKIP_VERIFY_DEFAULT - - if secret == None or secret == "": - _log("ERROR: Missing required access_secret (API key or client secret).") - return [] - - # Detect auth type - if client_id != None and client_id != "": - _log("Detected OAuth client credentials mode.") - token = obtain_oauth_token(client_id, secret, DEFAULT_SCOPE, insecure_skip_verify) - if token == None: - _log("ERROR: Failed to obtain OAuth access token.") - return [] - else: - _log("Detected API key mode.") - token = secret - - tailnet = TAILNET_DEFAULT - _log("Fetching devices for tailnet: " + tailnet) - - devices = tailscale_get_devices(token, tailnet, insecure_skip_verify) - if devices == None or len(devices) == 0: - _log("WARN: No devices found or API call failed.") - return [] - - assets = [] - for d in devices: - ia = transform_device_to_importasset(d, tailnet) - if ia != None: - assets.append(ia) - - _log("SUCCESS: Prepared " + str(len(assets)) + " ImportAsset objects.") - _log("=== INTEGRATION COMPLETE ===") - return assets diff --git a/tailscale/tailscale.star b/tailscale/tailscale.star new file mode 100644 index 0000000..36bb713 --- /dev/null +++ b/tailscale/tailscale.star @@ -0,0 +1,212 @@ +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-tailscale", + "name": "Tailscale", + "type": "inbound", + "description": "Imports devices from a Tailscale tailnet.", + "version": "26052700", + "params": [ + { + "key": "url", + "label": "Tailscale API URL", + "type": "url", + "required": False, + "default": "https://api.tailscale.com", + }, + { + "key": "tailnet", + "label": "Tailnet", + "type": "string", + "required": True, + "pattern": "[a-zA-Z0-9][a-zA-Z0-9_.@-]*", + "description": "Tailnet ID or name, for example T1234CNTRL or example.com", + }, + { + "key": "client_id", + "label": "OAuth client ID", + "type": "string", + "required": False, + "description": "Leave blank to use a plain API key", + }, + { + "key": "api_key_or_client_secret", + "label": "API key / OAuth client secret", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +# Tailscale API + OAuth2 Client -> runZero ImportAsset Integration +# +# Supports both: +# - Direct API key (tskey-api-xxxxx) +# - OAuth client credentials (client_id + api_key_or_client_secret) +# +# Credential inputs: +# url : API host, defaulting to https://api.tailscale.com +# tailnet : tailnet ID or name +# client_id : OAuth client ID, or blank for API key mode +# api_key_or_client_secret : OAuth client secret, or API key (tskey-api-xxxxx) + +load("runzero.types", "ImportAsset", "to_custom_attributes") +load("net", "network_interface") +load("http", "get_json", "bearer", "oauth2_token") +load("kwargs", "get_url_base", "get_http_options") +load("time", "parse_time") + +# --- Configuration --- +TAILSCALE_API_PATH = "/api/v2" +TAILSCALE_TOKEN_PATH = "/api/v2/oauth/token" +DEFAULT_SCOPE = "devices:core:read" + + +def _log(msg): + print("[TAILSCALE] " + msg) + + +def tailscale_get_devices(api_base_url, access_token, tailnet, config_kwargs): + """Fetch device inventory for a tailnet using an access token or API key.""" + url = api_base_url + "/tailnet/" + tailnet + "/devices?fields=all" + data, err = get_json(url, **get_http_options(config_kwargs, headers={"Authorization": bearer(access_token), "Accept": "application/json"})) + if err: + _log("ERROR: " + err) + return None + return data.get("devices", []) + + +def transform_device_to_importasset(device, tailnet): + device_id = device.get("id", "") + if device_id == "": + return None + + hostname = device.get("hostname", device.get("name", "")) + os_name = device.get("os", "Unknown") + + # Build primary interface from the device's Tailscale (VPN) addresses. + # network_interface() handles v4/v6 classification, port/CIDR stripping, + # dedupe, and capping at 99 addresses per family. + nics = [] + tailscale_nic = network_interface(ips=device.get("addresses", [])) + if tailscale_nic: + nics.append(tailscale_nic) + + attrs = { + "source": "Tailscale Integration", + "tailscale_device_id": device_id, + "tailscale_tailnet": tailnet, + "tailscale_user": device.get("user", ""), + "tailscale_os": os_name, + "tailscale_client_version": device.get("clientVersion", ""), + "tailscale_authorized": str(device.get("authorized", False)), + "tailscale_update_available": str(device.get("updateAvailable", False)), + "tailscale_key_expiry_disabled": str(device.get("keyExpiryDisabled", False)), + "tailscale_is_external": str(device.get("isExternal", False)), + "tailscale_blocks_incoming_connections": str(device.get("blocksIncomingConnections", False)), + "tailscale_created": device.get("created", ""), + } + + client_conn = device.get("clientConnectivity") + if client_conn: + endpoints = client_conn.get("endpoints") or [] + if endpoints: + attrs["tailscale_client_endpoints"] = ", ".join(endpoints) + # Physical IPs (public + private). network_interface() also + # strips "addr:port" and "[ipv6]:port" forms automatically. + phys_nic = network_interface(ips=endpoints) + if phys_nic: + nics.append(phys_nic) + + derp = client_conn.get("derp", "") + if derp: + attrs["tailscale_client_derp"] = derp + + mapping_varies = client_conn.get("mappingVariesByDestIP") + if mapping_varies != None: + attrs["tailscale_mapping_varies_by_dest_ip"] = str(mapping_varies) + + latency = client_conn.get("latency") + if latency: + for region, ms in latency.items(): + attrs["tailscale_latency_" + region] = str(ms) + + if len(nics) == 0: + _log("WARN: Skipping device " + device_id + " - no network interfaces available") + return None + + created = device.get("created") + if created: + parsed = parse_time(created) + if parsed != None: + attrs["tailscale_created_ts"] = parsed.unix + + tags = device.get("tags") or [] + if tags: + attrs["tailscale_tags"] = ", ".join(tags) + + for field in ("advertisedRoutes", "enabledRoutes"): + vals = device.get(field) or [] + if vals: + attrs["tailscale_" + field] = ", ".join(vals) + + return ImportAsset( + id="tailscale-" + device_id, + hostnames=[hostname], + networkInterfaces=nics, + os=os_name, + tags=["tailscale", "api"] + tags, + customAttributes=to_custom_attributes(attrs), + ) + + +def main(*args, **kwargs): + _log("=== TAILSCALE API / OAUTH INTEGRATION ===") + + client_id = kwargs.get("client_id") + secret = kwargs.get("api_key_or_client_secret") + base_url = get_url_base(kwargs, default="https://api.tailscale.com") + api_url = base_url + TAILSCALE_API_PATH + token_url = base_url + TAILSCALE_TOKEN_PATH + tailnet = (kwargs.get("tailnet") or "").strip() + + if not secret: + _log("ERROR: Missing required api_key_or_client_secret (API key or client secret).") + return [] + if not tailnet: + _log("ERROR: Missing required tailnet ID or name.") + return [] + + # If a client_id was supplied, exchange it for an OAuth access token; + # otherwise treat `secret` as a long-lived API key. + if client_id: + _log("Detected OAuth client credentials mode.") + token = oauth2_token( + token_url=token_url, + client_id=client_id, + client_secret=secret, + scope=DEFAULT_SCOPE, + **get_http_options(kwargs) + ) + else: + _log("Detected API key mode.") + token = secret + + devices = tailscale_get_devices(api_url, token, tailnet, kwargs) or [] + if not devices: + _log("WARN: No devices found or API call failed.") + return [] + + assets = [] + for d in devices: + ia = transform_device_to_importasset(d, tailnet) + if ia != None: + assets.append(ia) + + # Stream assets to runZero via report_assets instead of returning a list. + reported = report_assets(assets) + _log("SUCCESS: Reported " + str(reported) + " ImportAsset objects.") + return None diff --git a/tanium/README.md b/tanium/README.md index 66e2955..01055b0 100644 --- a/tanium/README.md +++ b/tanium/README.md @@ -25,8 +25,7 @@ - Adjust data parsing or mappings to suit your organizational needs. 2. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials). - Select the type `Custom Integration Script Secrets`. - - Use the `access_secret` field for your Tanium API Token. - - For `access_key`, input a placeholder value like `foo` (unused in this integration). + - Use the `api_token` field for your Tanium API Token. 3. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new). - Add a Name and Icon for the integration (e.g., "tanium"). - Toggle `Enable custom integration script` to input the finalized script. diff --git a/tanium/config.json b/tanium/config.json deleted file mode 100644 index ef34424..0000000 --- a/tanium/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Tanium", "type": "inbound" } diff --git a/tanium/custom-integration-tanium.star b/tanium/tanium.star similarity index 77% rename from tanium/custom-integration-tanium.star rename to tanium/tanium.star index 8304a15..ffa5079 100644 --- a/tanium/custom-integration-tanium.star +++ b/tanium/tanium.star @@ -1,17 +1,37 @@ -load('runzero.types', 'ImportAsset', 'NetworkInterface', 'Software', 'Vulnerability') -load('json', json_encode='encode', json_decode='decode') +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-tanium", + "name": "Tanium", + "type": "inbound", + "description": "Imports endpoints from Tanium.", + "version": "26061000", + "params": [ + { + "key": "url", + "label": "Tanium URL", + "type": "url", + "required": True, + "placeholder": "https://tenant.titankube.com", + }, + { + "key": "api_token", + "label": "API token", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} +load('runzero.types', 'ImportAsset', 'NetworkInterface', 'Software', 'Vulnerability', 'to_custom_attributes') load('net', 'ip_address') -load('http', http_post='post', http_get='get', 'url_encode') +load('http', 'post_json') +load('kwargs', 'get_url_base', 'get_http_options') -def force_string(value): - if type(value) == "list": - output = ",".join([str(v) for v in value]) - elif type(value) == "dict": - output = json_encode(value) - else: - output = str(value) - return output[:1023] def build_vulnerabilities(vulnerabilities): output_vulnerabilities = [] @@ -81,27 +101,27 @@ def build_vulnerabilities(vulnerabilities): severityScore=float(score), severityRank=risk_rank, serviceAddress="127.0.0.1", - customAttributes={ - "affectedProducts": force_string(affectedProducts), - "cisaDueDate": force_string(cisaDueDate), - "cisaNotes": force_string(cisaNotes), - "cisaProduct": force_string(cisaProduct), - "cisaRequiredAction": force_string(cisaRequiredAction), - "cisaVulnerabilityName": force_string(cisaVulnerabilityName), - "cisaVendor": force_string(cisaVendor), - "cveYear": force_string(cveYear), - "excepted": force_string(excepted), - "firstFound": force_string(firstFound), - "lastScanDate": force_string(lastScanDate), - "scanType": force_string(scanType), - "summary": force_string(summary), - "cpes": force_string(cpes), - "absoluteFirstFoundDate": force_string(absoluteFirstFoundDate), - "cisaDateAdded": force_string(cisaDateAdded), - "isCisaKev": force_string(isCisaKev), - "lastFound": force_string(lastFound), - "cisaShortDescription": force_string(cisaShortDescription), - }, + customAttributes=to_custom_attributes({ + "affectedProducts": affectedProducts, + "cisaDueDate": cisaDueDate, + "cisaNotes": cisaNotes, + "cisaProduct": cisaProduct, + "cisaRequiredAction": cisaRequiredAction, + "cisaVulnerabilityName": cisaVulnerabilityName, + "cisaVendor": cisaVendor, + "cveYear": cveYear, + "excepted": excepted, + "firstFound": firstFound, + "lastScanDate": lastScanDate, + "scanType": scanType, + "summary": summary, + "cpes": cpes, + "absoluteFirstFoundDate": absoluteFirstFoundDate, + "cisaDateAdded": cisaDateAdded, + "isCisaKev": isCisaKev, + "lastFound": lastFound, + "cisaShortDescription": cisaShortDescription, + }), ) ) @@ -203,7 +223,7 @@ def build_asset(item): manufacturer=manufacturer, model=model, hostnames=[name], - customAttributes={ + customAttributes=to_custom_attributes({ "eid_first_seen": eid_first_seen, "eid_last_seen": eid_last_seen, "namespace": namespace, @@ -216,12 +236,12 @@ def build_asset(item): "is_encrypted": is_encrypted, "risk": risk, "computer_id": computer_id, - }, + }), domain=domain_name, # firstSeenTS=eid_first_seen, # TODO: add parsing deviceType=chassis_type, software=software[:99], - vulnerabilities=vulnerabilities[:99] + vulnerabilities=vulnerabilities[:99], ) @@ -235,7 +255,7 @@ def build_assets(inventory): return assets -def get_endpoints(tanium_url, tanium_token): +def get_endpoints(tanium_url, tanium_token, config_kwargs): query = """query getEndpoints($first: Int, $after: Cursor) { endpoints(first: $first, after: $after) { edges { @@ -324,7 +344,7 @@ def get_endpoints(tanium_url, tanium_token): }""" cursor = None hasNextPage = True - endpoints = [] + reported = 0 while hasNextPage: # set cursor if it exists (all but the first query) if cursor: @@ -335,36 +355,37 @@ def get_endpoints(tanium_url, tanium_token): body = {"query": query, "variables": variables} # get endpoints - data = http_post( + data, err = post_json( tanium_url + "/plugin/products/gateway/graphql", - headers={"Content-Type": "application/json", "session": tanium_token}, - body=bytes(json_encode(body)), + json=body, + **get_http_options(config_kwargs, headers={"session": tanium_token}) ) - - # unnpack results and add to the endpoints - json_data = json_decode(data.body) + if err: + print("Failed to fetch endpoints:", err) + return reported + + # unpack results and add to the endpoints + json_data = data or {} new_endpoints = json_data.get("data", {}).get("endpoints", {}).get("edges", []) - endpoints.extend(new_endpoints) - + + # Build and stream this page before fetching the next so the full + # endpoint set is never held in memory at once. + reported += report_assets(build_assets(new_endpoints)) + # check if there is a next page hasNextPage = json_data.get("data", {}).get("endpoints", {}).get("pageInfo", {}).get("hasNextPage", False) cursor = json_data.get("data", {}).get("endpoints", {}).get("pageInfo", {}).get("endCursor", None) - return endpoints + return reported def main(*args, **kwargs): - tanium_url = "https://.titankube.com" - tanium_token = kwargs['access_secret'] + tanium_url = get_url_base(kwargs) + tanium_token = kwargs['api_token'] - tanium_endpoints = get_endpoints(tanium_url, tanium_token) + # Endpoints are streamed page-by-page via report_assets in get_endpoints. + reported = get_endpoints(tanium_url, tanium_token, kwargs) - if not tanium_endpoints: + if not reported: print("got nothing from Tanium") - return None - - assets = build_assets(tanium_endpoints) - if not assets: - print("no assets") - - return assets + return None diff --git a/task-sync/config.json b/task-sync/config.json deleted file mode 100644 index a6b47ab..0000000 --- a/task-sync/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "runZero Task Sync", "type": "inbound" } diff --git a/task-sync/custom-integration-task-sync.star b/task-sync/custom-integration-task-sync.star deleted file mode 100644 index 40b0e9d..0000000 --- a/task-sync/custom-integration-task-sync.star +++ /dev/null @@ -1,74 +0,0 @@ -load('http', http_get='get', http_post='post', http_put='put', 'url_encode') -load('json', json_decode='decode') -load('gzip', gzip_decompress='decompress', gzip_compress='compress') - -# Parameters from kwargs -SAAS_ORG_ID = "ORG-UUID-REPLACE" -SAAS_SITE_ID = "SITE-UUID-REPLACE" -SAAS_BASE_URL = "https://console.runzero.com" -SELF_ORG_ID = "ORG-UUID-REPLACE" -SELF_SITE_ID = "SITE-UUID-REPLACE" -SELF_BASE_URL = "https://console.runzero.com" -SAAS_TASK_SEARCH_FILTER = 'name:="test"' - -# Flags -HIDE_TASKS_ON_SYNC = False - -def get_tasks(saas_token): - params = {"_oid": SAAS_ORG_ID, "search": SAAS_TASK_SEARCH_FILTER} - url = "{}{}{}".format(SAAS_BASE_URL, "/api/v1.0/org/tasks?", url_encode(params)) - headers = {"Content-Type": "application/json", "Authorization": "Bearer {}".format(saas_token)} - response = http_get(url, headers=headers) - if response.status_code != 200: - print("Failed to get tasks", response.status_code) - return [] - return json_decode(response.body) - -def sync_task(task_id, saas_token, self_token): - # Download data from SaaS - print("Pulling task with ID {}".format(task_id)) - download_url = "{}/api/v1.0/org/tasks/{}/data".format(SAAS_BASE_URL, task_id) - download = http_get(download_url, headers={"Authorization": "Bearer {}".format(saas_token), "Accept": "application/octet-stream", "Content-Encoding": "gzip"}, timeout=3600) - if download.status_code != 200: - print("Failed to download task:", task_id) - return False - - # Upload data to self-hosted - print("Uploading task with ID {}".format(task_id)) - unzipped = gzip_decompress(download.body) - upload_url = "{}/api/v1.0/org/sites/{}/import?_oid={}".format(SELF_BASE_URL, SELF_SITE_ID, SELF_ORG_ID) - upload = http_put(upload_url, headers={"Authorization": "Bearer {}".format(self_token), "Content-Type": "application/octet-stream", "Content-Encoding": "gzip"}, body=gzip_compress(unzipped), timeout=3600) - - if upload.status_code != 200: - print("Failed to upload task:", task_id) - return False - - print("Successfully synced task:", task_id) - - if HIDE_TASKS_ON_SYNC: - hide_url = "{}/api/v1.0/org/tasks/{}/hide?_oid={}".format(SAAS_BASE_URL, task_id, SAAS_ORG_ID) - hide = http_post(hide_url, headers={"Authorization": "Bearer {}".format(saas_token), "Content-Type": "application/json"}) - if hide.status_code == 200: - print("Task hidden:", task_id) - - return True - -def main(**kwargs): - saas_token = kwargs["access_key"] # SaaS token - self_token = kwargs["access_secret"] # Self-hosted token - - tasks = get_tasks(saas_token) - print("Got {} task(s) to sync".format(len(tasks))) - if not tasks: - print("No tasks found.") - return - - for task in tasks: - task_id = task.get("id", "") - if not task_id: - continue - success = sync_task(task_id, saas_token, self_token) - if not success: - print("Sync failed for task:", task_id) - - return None diff --git a/ubiquiti-unifi-network/README.md b/ubiquiti-unifi-network/README.md index 19c4b7e..04447d0 100644 --- a/ubiquiti-unifi-network/README.md +++ b/ubiquiti-unifi-network/README.md @@ -27,8 +27,7 @@ Custom Integration for retrieving clients and Unifi devices from the Unifi Netwo - (OPTIONAL) Modify `UNIFI_CLIENT_API_FILTER` 2. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials). - Select the type `Custom Integration Script Secrets`. - - Use the `access_secret` field for your Unifi API token. - - For `access_key`, input a placeholder value like `foo` (unused in this integration). + - Use the `api_key` field for your Unifi API token. 3. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new). - Add a Name and Icon for the integration (e.g., "unifi"). - Toggle `Enable custom integration script` to input the finalized script. diff --git a/ubiquiti-unifi-network/config.json b/ubiquiti-unifi-network/config.json deleted file mode 100644 index a5ef724..0000000 --- a/ubiquiti-unifi-network/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Ubiquiti Unifi Network", "type": "inbound" } diff --git a/ubiquiti-unifi-network/custom-integration-ubiquiti-unifi-network.star b/ubiquiti-unifi-network/ubiquiti-unifi-network.star similarity index 51% rename from ubiquiti-unifi-network/custom-integration-ubiquiti-unifi-network.star rename to ubiquiti-unifi-network/ubiquiti-unifi-network.star index a0de24e..2b19b2c 100644 --- a/ubiquiti-unifi-network/custom-integration-ubiquiti-unifi-network.star +++ b/ubiquiti-unifi-network/ubiquiti-unifi-network.star @@ -1,3 +1,67 @@ +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-ubiquiti-unifi-network", + "name": "Ubiquiti UniFi Network", + "type": "inbound", + "description": "Imports devices from a UniFi Network controller.", + "version": "26061000", + "params": [ + { + "key": "url", + "label": "UniFi controller URL", + "type": "url", + "required": True, + "placeholder": "https://controller.example.com", + }, + { + "key": "site_name", + "label": "Site name", + "type": "string", + "required": False, + "default": "Default", + }, + { + "key": "extract_clients", + "label": "Extract clients", + "type": "bool", + "required": False, + "default": True, + }, + { + "key": "extract_devices", + "label": "Extract devices", + "type": "bool", + "required": False, + "default": True, + }, + { + "key": "client_api_filter", + "label": "Client API filter", + "type": "string", + "required": False, + }, + { + "key": "page_limit", + "label": "Page size", + "type": "int", + "required": False, + "default": 100, + "min": 1, + "max": 1000, + }, + { + "key": "api_key", + "label": "API key", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} # # runZero Starlark script for Ubiquiti UniFi Network Integration API # @@ -6,34 +70,19 @@ # # Load necessary runZero and Starlark libraries -load('runzero.types', 'ImportAsset', 'NetworkInterface') -load('json', json_decode='decode') -load('net', 'ip_address') -load('http', http_get='get', url_encode='url_encode') +load('runzero.types', 'ImportAsset', 'to_custom_attributes') +load('net', 'network_interface') +load('http', 'get_json', url_encode='url_encode') +load('kwargs', 'get_url_base', 'get_http_options', 'get_bool', 'get_string', 'get_int') load('time', 'parse_time') -# --- USER CONFIGURATION --- -# IMPORTANT: Update these variables to match your UniFi Network Controller setup. - -# The base URL of your UniFi Network Controller (e.g., https://192.168.1.1) -UNIFI_CONTROLLER_URL = "https://" -# The NAME of the site you want to pull data from. -UNIFI_SITE_NAME = "Default" -# Set to True to extract client devices, False to skip. -EXTRACT_CLIENTS = True -# Set to True to extract UniFi network devices (switches, APs), False to skip. -EXTRACT_DEVICES = True -# (Optional) A filter to apply to the client query. Leave as "" to disable. -# Example: "ipAddress.eq('192.168.5.5')" or "type.eq('WIRED')" -UNIFI_CLIENT_API_FILTER = "" -# UniFi controllers often use self-signed certificates. Set to True to allow this. -INSECURE_SKIP_VERIFY = True -# The number of items to request per API call. 100 is a safe default. -PAGE_LIMIT = 100 - -# --- END OF USER CONFIGURATION --- - -def get_site_id(base_url, api_key, site_name): +DEFAULT_SITE_NAME = "Default" +DEFAULT_EXTRACT_CLIENTS = True +DEFAULT_EXTRACT_DEVICES = True +DEFAULT_CLIENT_API_FILTER = "" +DEFAULT_PAGE_LIMIT = 100 + +def get_site_id(base_url, api_key, site_name, config_kwargs): """ Finds the UUID for a given site name. """ @@ -41,14 +90,12 @@ def get_site_id(base_url, api_key, site_name): headers = { "X-API-KEY": api_key, "Accept": "application/json" } print("Attempting to find ID for site '{}'...".format(site_name)) - response = http_get(url=sites_url, headers=headers, insecure_skip_verify=INSECURE_SKIP_VERIFY) + response_json, err = get_json(url=sites_url, **get_http_options(config_kwargs, headers=headers)) - if response.status_code != 200: - print("Failed to get sites list. Status code: {}".format(response.status_code)) + if err: + print("Failed to get sites list:", err) return None - response_json = json_decode(response.body) - if type(response_json) != "dict" or "data" not in response_json: print("API did not return a valid sites object.") return None @@ -62,28 +109,28 @@ def get_site_id(base_url, api_key, site_name): print("Error: Could not find a site with the name '{}'.".format(site_name)) return None -def get_all_clients(base_url, api_key, site_id): +def get_all_clients(base_url, api_key, site_id, page_limit, client_filter, config_kwargs): """ - Fetches all client devices from the UniFi API, handling pagination and an optional filter. + Fetches all client devices from the UniFi API, building and streaming each + page of assets via report_assets so the full client set is never buffered. + Returns the number of client assets reported. """ - all_clients = [] + reported = 0 offset = 0 while True: - params = {"offset": str(offset), "limit": str(PAGE_LIMIT)} - - if UNIFI_CLIENT_API_FILTER: - params["filter"] = UNIFI_CLIENT_API_FILTER + params = {"offset": str(offset), "limit": str(page_limit)} + + if client_filter: + params["filter"] = client_filter clients_url = base_url + "/proxy/network/integration/v1/sites/{}/clients?".format(site_id) + url_encode(params) headers = { "X-API-KEY": api_key, "Accept": "application/json" } - response = http_get(url=clients_url, headers=headers, insecure_skip_verify=INSECURE_SKIP_VERIFY) + response_json, err = get_json(url=clients_url, **get_http_options(config_kwargs, headers=headers)) - if response.status_code != 200: - print("Failed to retrieve clients. Status code: {}".format(response.status_code)) + if err: + print("Failed to retrieve clients:", err) break - - response_json = json_decode(response.body) if type(response_json) != "dict": print("API did not return a valid JSON object while fetching clients.") break @@ -92,36 +139,36 @@ def get_all_clients(base_url, api_key, site_id): if not clients_batch: break - all_clients.extend(clients_batch) + reported += report_assets(build_client_assets(clients_batch)) total_count = response_json.get("totalCount", 0) - current_count = len(all_clients) + current_count = offset + len(clients_batch) print("Fetched {}/{} clients...".format(current_count, total_count)) if current_count >= total_count: break - - offset += PAGE_LIMIT - - return all_clients -def get_all_devices(base_url, api_key, site_id): + offset += page_limit + + return reported + +def get_all_devices(base_url, api_key, site_id, page_limit, config_kwargs): """ - Fetches all network devices (switches, APs, etc.) from the UniFi API, handling pagination. + Fetches all network devices (switches, APs, etc.) from the UniFi API, + building and streaming each page of assets via report_assets. Returns the + number of device assets reported. """ - all_devices = [] + reported = 0 offset = 0 while True: - params = {"offset": str(offset), "limit": str(PAGE_LIMIT)} + params = {"offset": str(offset), "limit": str(page_limit)} devices_url = base_url + "/proxy/network/integration/v1/sites/{}/devices?".format(site_id) + url_encode(params) headers = { "X-API-KEY": api_key, "Accept": "application/json" } - response = http_get(url=devices_url, headers=headers, insecure_skip_verify=INSECURE_SKIP_VERIFY) + response_json, err = get_json(url=devices_url, **get_http_options(config_kwargs, headers=headers)) - if response.status_code != 200: - print("Failed to retrieve devices. Status code: {}".format(response.status_code)) + if err: + print("Failed to retrieve devices:", err) break - - response_json = json_decode(response.body) if type(response_json) != "dict": print("API did not return a valid JSON object while fetching devices.") break @@ -130,32 +177,17 @@ def get_all_devices(base_url, api_key, site_id): if not devices_batch: break - all_devices.extend(devices_batch) + reported += report_assets(build_device_assets(devices_batch)) total_count = response_json.get("totalCount", 0) - current_count = len(all_devices) + current_count = offset + len(devices_batch) print("Fetched {}/{} devices...".format(current_count, total_count)) if current_count >= total_count: break - - offset += PAGE_LIMIT - - return all_devices -def build_network_interface(ips, mac): - """ - A helper function to build a runZero NetworkInterface object. - """ - ip4s = [] - ip6s = [] - for ip in ips[:99]: - if ip: - ip_addr = ip_address(ip) - if ip_addr.version == 4: - ip4s.append(ip_addr) - elif ip_addr.version == 6: - ip6s.append(ip_addr) - return NetworkInterface(macAddress=mac, ipv4Addresses=ip4s, ipv6Addresses=ip6s) + offset += page_limit + + return reported def build_client_assets(clients_json): """ @@ -178,8 +210,8 @@ def build_client_assets(clients_json): ip = client.get("ipAddress") ips = [ip] if ip else [] - network = build_network_interface(ips=ips, mac=mac) - hostnames = [hostname] if hostname else [] + network = network_interface(ips=ips, mac=mac) + hostnames=[hostname] connectedAt = parse_time(client.get("connectedAt")) custom_attrs = { @@ -195,7 +227,7 @@ def build_client_assets(clients_json): id=mac, hostnames=hostnames, networkInterfaces=[network], - customAttributes=custom_attrs + customAttributes=to_custom_attributes(custom_attrs), ) ) return assets @@ -212,10 +244,10 @@ def build_device_assets(devices_json): ip = device.get("ipAddress") ips = [ip] if ip else [] - network = build_network_interface(ips=ips, mac=mac) + network = network_interface(ips=ips, mac=mac) hostname = device.get("name") - hostnames = [hostname] if hostname else [] + hostnames=[hostname] model = device.get("model", "UniFi Device") device_type = "Unknown" @@ -242,7 +274,7 @@ def build_device_assets(devices_json): manufacturer="Ubiquiti", model=model, deviceType=device_type, - customAttributes=custom_attrs + customAttributes=to_custom_attributes(custom_attrs), ) ) return assets @@ -252,51 +284,45 @@ def main(**kwargs): """ The main entrypoint for the runZero custom integration script. """ - api_key = kwargs.get('access_secret') + base_url = get_url_base(kwargs) + site_name = get_string(kwargs, "site_name", DEFAULT_SITE_NAME) + extract_clients = get_bool(kwargs, "extract_clients", DEFAULT_EXTRACT_CLIENTS) + extract_devices = get_bool(kwargs, "extract_devices", DEFAULT_EXTRACT_DEVICES) + client_filter = get_string(kwargs, "client_api_filter", DEFAULT_CLIENT_API_FILTER) + page_limit = get_int(kwargs, "page_limit", DEFAULT_PAGE_LIMIT) - if not api_key: - print("UniFi Network API Key (access_secret) not provided in credentials.") - return [] + api_key = kwargs.get('api_key') - if UNIFI_CONTROLLER_URL == "https://": - print("ERROR: Please update the UNIFI_CONTROLLER_URL variable in the script.") - return [] + if not api_key: + print("UniFi Network API key not provided in credentials.") + return None # 1. Find the Site ID from the Site Name - site_id = get_site_id(UNIFI_CONTROLLER_URL, api_key, UNIFI_SITE_NAME) + site_id = get_site_id(base_url, api_key, site_name, kwargs) if not site_id: - return [] + return None - all_assets = [] + total = 0 # 2. Get and process clients if enabled - if EXTRACT_CLIENTS: + if extract_clients: print("--- Starting Client Extraction ---") - clients = get_all_clients(UNIFI_CONTROLLER_URL, api_key, site_id) - if not clients: - print("No clients returned. This could be due to the filter applied or none exist.") - else: - print("Total clients found: {}.".format(len(clients))) - client_assets = build_client_assets(clients) - all_assets.extend(client_assets) - print("Created {} client assets for import.".format(len(client_assets))) + client_count = get_all_clients(base_url, api_key, site_id, page_limit, client_filter, kwargs) + print("Created {} client assets for import.".format(client_count)) + total += client_count else: - print("--- Skipping Client Extraction (EXTRACT_CLIENTS is False) ---") + print("--- Skipping Client Extraction (extract_clients is False) ---") # 3. Get and process devices if enabled - if EXTRACT_DEVICES: + if extract_devices: print("--- Starting Device Extraction ---") - devices = get_all_devices(UNIFI_CONTROLLER_URL, api_key, site_id) - if not devices: - print("No devices found.") - else: - print("Total devices found: {}.".format(len(devices))) - device_assets = build_device_assets(devices) - all_assets.extend(device_assets) - print("Created {} device assets for import.".format(len(device_assets))) + device_count = get_all_devices(base_url, api_key, site_id, page_limit, kwargs) + print("Created {} device assets for import.".format(device_count)) + total += device_count else: - print("--- Skipping Device Extraction (EXTRACT_DEVICES is False) ---") + print("--- Skipping Device Extraction (extract_devices is False) ---") print("--- Import Summary ---") - print("Total assets created: {}.".format(len(all_assets))) - return all_assets + print("Total assets created: {}.".format(total)) + # Assets are streamed via report_assets above; nothing to return here. + return None diff --git a/vulnerability-workflow/config.json b/vulnerability-workflow/config.json deleted file mode 100644 index 5157950..0000000 --- a/vulnerability-workflow/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Vunerability Workflow", "type": "internal" } diff --git a/vulnerability-workflow/custom-integration-vulnerability-workflow.star b/vulnerability-workflow/custom-integration-vulnerability-workflow.star deleted file mode 100644 index 42d1f46..0000000 --- a/vulnerability-workflow/custom-integration-vulnerability-workflow.star +++ /dev/null @@ -1,80 +0,0 @@ -load("http", http_get="get", http_post="post", http_patch="patch") -load("json", json_encode="encode", json_decode="decode") - -WORKFLOW_ENDPOINT = "" -VULNERABILITY_SEARCH = "risk:critical" - -def main(**kwargs): - runzero_token = kwargs["access_secret"] - - # Step 1: Fetch vulnerabilities - response = http_get( - "https://console.runzero.com/api/v1.0/export/org/vulnerabilities.json?search={}".format(VULNERABILITY_SEARCH), - headers={"Authorization": "Bearer {}".format(runzero_token)}, - timeout=3600 - ) - - if response.status_code != 200: - print("Failed to fetch vulnerabilities") - return - - vulns = json_decode(response.body) - - # Step 2: Aggregate vulnerabilities - seen = {} - for v in vulns: - cve = v.get("vulnerability_cve", None) - asset_id = v.get("vulnerability_asset_id", None) - if cve and asset_id: - key = "{}:{}".format(asset_id, cve) - if key not in seen: - seen[key] = v - - # Step 3 and 4: POST and tag - for key, vuln in seen.items(): - - # Get attributes for the payload - asset_id = vuln.get("vulnerability_asset_id", None) - cve = vuln.get("vulnerability_cve", None) - tags = vuln.get("tags", {}) - vuln_name = vuln.get("vulnerability_name", "") - vulnerability_exploitable = vuln.get("vulnerability_exploitable", "") - os_vendor = vuln.get("os_vendor", "") - os_product = vuln.get("os_product", "") - addresses = vuln.get("addresses", []) - names = vuln.get("names", []) - macs = vuln.get("macs", []) - - # Verify the asset doesn't already have an open case for the CVE - if cve and asset_id and cve not in tags.keys(): - payload = { - "asset_id": asset_id, - "cve": cve, - "tags": tags, - "vulnerability_name": vuln_name, - "vulnerability_exploitable": vulnerability_exploitable, - "os_vendor": os_vendor, - "os_product": os_product, - "addresses": addresses, - "names": names, - "macs": macs - } - - http_post( - WORKFLOW_ENDPOINT, - headers={"Content-Type": "application/json"}, - body=bytes(json_encode(payload)) - ) - - tag_url = "https://console.runzero.com/api/v1.0/org/assets/{}/tags".format(asset_id) - http_patch( - tag_url, - headers={"Content-Type": "application/json", "Authorization": "Bearer {}".format(runzero_token)}, - body=bytes(json_encode({"tags": "{}=OPENED".format(cve)})), - ) - - else: - print("Already has an open case for {}".format(cve)) - continue - - diff --git a/wazuh/README.md b/wazuh/README.md index 6feb36a..58f9bbf 100644 --- a/wazuh/README.md +++ b/wazuh/README.md @@ -24,11 +24,14 @@ - Modify datapoints uploaded to runZero as needed. 2. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials). - Select the type `Custom Integration Script Secrets`. - - Set `access_key` to your Wazuh hostname or IP (do not include protocol or port). - - Set `access_secret` to `username::password`. + - Set `password` to your Wazuh API user password. 3. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new). - Add a Name and Icon for the integration (for example: wazuh). - Toggle `Enable custom integration script` to input the finalized script. + - Configure the integration parameters: + - `hostname`: your Wazuh manager hostname or IP (do not include protocol or port). + - `port`: the Wazuh API port (defaults to `55000`). + - `username`: your Wazuh API username. - Click `Validate` to ensure it has valid syntax. - Click `Save` to create the Custom Integration. 4. [Create the Custom Integration task](https://console.runzero.com/ingest/custom/). diff --git a/wazuh/config.json b/wazuh/config.json deleted file mode 100644 index 2e5db23..0000000 --- a/wazuh/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Wazuh", "type": "inbound" } diff --git a/wazuh/custom-integration-wazuh.star b/wazuh/wazuh.star similarity index 84% rename from wazuh/custom-integration-wazuh.star rename to wazuh/wazuh.star index 4664a54..a390f10 100644 --- a/wazuh/custom-integration-wazuh.star +++ b/wazuh/wazuh.star @@ -1,3 +1,45 @@ +# Copyright 2026 runZero, Inc. Available under the MIT License + +CONFIG = { + "id": "runzero-wazuh", + "name": "Wazuh", + "type": "inbound", + "description": "Imports agents from a Wazuh manager.", + "version": "26061000", + "params": [ + { + "key": "hostname", + "label": "Wazuh manager hostname or IP", + "type": "string", + "required": True, + "placeholder": "wazuh-manager or 10.1.2.3", + }, + { + "key": "port", + "label": "Wazuh API port", + "type": "int", + "required": False, + "default": 55000, + }, + { + "key": "username", + "label": "Username", + "type": "string", + "required": True, + }, + { + "key": "password", + "label": "Password", + "type": "secret", + "required": True, + }, + ], + "includes": { + "tls_": OPTIONS_TLS, + "http_": OPTIONS_HTTP, + }, +} + load('runzero.types', 'ImportAsset', 'NetworkInterface', 'Software') load('json', json_decode='decode') load('net', 'ip_address') @@ -5,9 +47,10 @@ load('http', http_post='post', http_get='get') load('uuid', 'new_uuid') load('base64', base64_encode='encode') load('time', 'parse_time') +load('kwargs', 'require', 'get_string', 'get_int', 'get_http_options') # --- Wazuh API helpers --- -def authenticate_wazuh(host, username, password): +def authenticate_wazuh(host, username, password, config): """ Authenticate with Wazuh API and retrieve JWT token. @@ -30,7 +73,7 @@ def authenticate_wazuh(host, username, password): 'Content-Type': 'application/json' } - response = http_post(auth_url, headers=headers, timeout=600) + response = http_post(auth_url, timeout=600, **get_http_options(config, headers=headers)) if response.status_code != 200: print("Wazuh authentication failed. Status:", response.status_code) @@ -50,7 +93,7 @@ def authenticate_wazuh(host, username, password): print("Successfully authenticated with Wazuh API") return token -def get_wazuh_agents(host, token): +def get_wazuh_agents(host, token, config): """ Retrieve agents from Wazuh using pagination. @@ -79,7 +122,7 @@ def get_wazuh_agents(host, token): 'limit': limit } - response = http_get(agents_url, headers=headers, params=params, timeout=600) + response = http_get(agents_url, params=params, timeout=600, **get_http_options(config, headers=headers)) if response.status_code != 200: print("Failed to fetch agents from Wazuh. Status:", response.status_code) @@ -103,7 +146,7 @@ def get_wazuh_agents(host, token): return all_agents # NEW FUNCTION: get network interfaces with MAC addresses -def get_agent_network_interfaces(host, token, agent_id): +def get_agent_network_interfaces(host, token, agent_id, config): """ Retrieve network interfaces for a specific agent. @@ -122,7 +165,7 @@ def get_agent_network_interfaces(host, token, agent_id): 'Content-Type': 'application/json' } - response = http_get(netiface_url, headers=headers, timeout=600) + response = http_get(netiface_url, timeout=600, **get_http_options(config, headers=headers)) if response.status_code != 200: if response.status_code != 401: # Don't print for 401, we'll handle that @@ -139,7 +182,7 @@ def get_agent_network_interfaces(host, token, agent_id): # NEW FUNCTION: get network addresses (IPs) for interfaces -def get_agent_network_addresses(host, token, agent_id): +def get_agent_network_addresses(host, token, agent_id, config): """ Retrieve network addresses (IP addresses) for a specific agent. @@ -158,7 +201,7 @@ def get_agent_network_addresses(host, token, agent_id): 'Content-Type': 'application/json' } - response = http_get(netaddr_url, headers=headers, timeout=600) + response = http_get(netaddr_url, timeout=600, **get_http_options(config, headers=headers)) if response.status_code != 200: if response.status_code != 401: # Don't print for 401, we'll handle that @@ -482,45 +525,32 @@ def main(**kwargs): Main function to retrieve and return Wazuh asset data. Expected kwargs: - access_key: Wazuh hostname or IP address (e.g., wazuh-manager or 10.1.2.3) - access_secret: Wazuh credentials in format "username::password" + hostname: Wazuh manager hostname or IP address (e.g., wazuh-manager or 10.1.2.3) + port: Wazuh API port (defaults to 55000) + username: Wazuh username + password: Wazuh password Returns: List of ImportAsset objects """ - wazuh_hostname = kwargs.get('access_key') - credentials = kwargs.get('access_secret') - - if not wazuh_hostname: - print("Error: Wazuh hostname/IP not provided in access_key") - return [] - - if not credentials: - print("Error: Wazuh credentials not provided in access_secret") - return [] - - if '::' not in credentials: - print("Error: Credentials should be in format 'username::password'") - return [] - - username, password = credentials.split('::', 1) - - if not username or not password: - print("Error: Invalid credentials format") - return [] - - wazuh_host = "https://{}:55000".format(wazuh_hostname) + require(kwargs, "hostname", "username", "password") + wazuh_hostname = get_string(kwargs, "hostname") + port = get_int(kwargs, "port", default=55000) + username = get_string(kwargs, "username") + password = get_string(kwargs, "password") + + wazuh_host = "https://{}:{}".format(wazuh_hostname, port) print("Connecting to Wazuh at:", wazuh_host) # Authenticate with Wazuh - token = authenticate_wazuh(wazuh_host, username, password) + token = authenticate_wazuh(wazuh_host, username, password, kwargs) if not token: print("Authentication to Wazuh failed; no token returned") return [] # Retrieve agents - agents = get_wazuh_agents(wazuh_host, token) + agents = get_wazuh_agents(wazuh_host, token, kwargs) if not agents: print("No agents retrieved from Wazuh") return [] @@ -528,49 +558,60 @@ def main(**kwargs): agent_net_interfaces = {} agent_net_addresses = {} print("Retrieving detailed network information for each agent...") - + + # Enrich active agents with per-agent network data and stream them to + # runZero in batches via report_assets so the full asset set is never held + # in memory. + reported = 0 + batch_size = 200 + batch = [] + for agent in agents: if agent.get('status') != "active": continue agent_id = agent.get('id') if agent_id: # Get network interfaces - interfaces, status_code = get_agent_network_interfaces(wazuh_host, token, agent_id) + interfaces, status_code = get_agent_network_interfaces(wazuh_host, token, agent_id, kwargs) # Check if token expired (401), re-authenticate and retry if status_code == 401: print("Token expired, re-authenticating...") - token = authenticate_wazuh(wazuh_host, username, password) + token = authenticate_wazuh(wazuh_host, username, password, kwargs) if not token: print("Re-authentication failed, stopping network data collection") break # Retry with new token - interfaces, status_code = get_agent_network_interfaces(wazuh_host, token, agent_id) + interfaces, status_code = get_agent_network_interfaces(wazuh_host, token, agent_id, kwargs) if interfaces: agent_net_interfaces[agent_id] = interfaces # Get network addresses - addresses, status_code = get_agent_network_addresses(wazuh_host, token, agent_id) + addresses, status_code = get_agent_network_addresses(wazuh_host, token, agent_id, kwargs) # Check if token expired (401), re-authenticate and retry if status_code == 401: print("Token expired, re-authenticating...") - token = authenticate_wazuh(wazuh_host, username, password) + token = authenticate_wazuh(wazuh_host, username, password, kwargs) if not token: print("Re-authentication failed, stopping network data collection") break # Retry with new token - addresses, status_code = get_agent_network_addresses(wazuh_host, token, agent_id) + addresses, status_code = get_agent_network_addresses(wazuh_host, token, agent_id, kwargs) if addresses: agent_net_addresses[agent_id] = addresses - - print("Retrieved network interfaces for {} agents".format(len(agent_net_interfaces))) - print("Retrieved network addresses for {} agents".format(len(agent_net_addresses))) - - # Convert to RunZero assets - assets = build_assets(agents, agent_net_interfaces, agent_net_addresses) - - print("Successfully processed {} Wazuh agents into RunZero assets".format(len(assets))) - return assets \ No newline at end of file + + batch.append(agent) + if len(batch) >= batch_size: + reported += report_assets(build_assets(batch, agent_net_interfaces, agent_net_addresses)) + batch = [] + agent_net_interfaces = {} + agent_net_addresses = {} + + if batch: + reported += report_assets(build_assets(batch, agent_net_interfaces, agent_net_addresses)) + + print("Successfully processed {} Wazuh agents into RunZero assets".format(reported)) + return None \ No newline at end of file diff --git a/windows-smb-shares/README.md b/windows-smb-shares/README.md new file mode 100644 index 0000000..29391a3 --- /dev/null +++ b/windows-smb-shares/README.md @@ -0,0 +1,28 @@ +# Windows SMB shares + +Inbound integration that authenticates to a Windows host over SMB2/3 +using NTLMv2, lists all advertised shares, and counts the entries in +each share that the supplied account can access. + +## Authentication + +Provide **either** a password **or** an NTLM hash (hex-encoded). NTLM +hashes let operators run the integration without exposing the +plaintext password. + +If the username includes a backslash (e.g. `ACME\svc-runzero`) the +domain is parsed automatically; otherwise pass `domain` explicitly. + +## Output + +A single asset is reported per target host with custom attributes: + +- `smb.target` — `host:port` +- `smb.share_count` — total advertised share count +- `smb.accessible_share_count` — shares whose root we listed + successfully +- `share.` — `listed` flag per advertised share +- `share..entries` — first 20 top-level entries (when readable) + +Admin shares ending in `$` (other than `ADMIN$`) are not enumerated by +default to avoid scanning user home directories. diff --git a/windows-smb-shares/windows-smb-shares.star b/windows-smb-shares/windows-smb-shares.star new file mode 100644 index 0000000..dce8179 --- /dev/null +++ b/windows-smb-shares/windows-smb-shares.star @@ -0,0 +1,83 @@ +CONFIG = { + "id": "runzero-windows-smb-shares", + "name": "Windows SMB shares", + "type": "inbound", + "description": "Enumerates SMB shares on a Windows host and reports the host plus accessible shares as a runZero asset.", + "version": "26052700", + "params": [ + {"key": "host", "label": "Target host", "type": "string", "required": True}, + {"key": "port", "label": "SMB port", "type": "int", "required": False, "default": 445, "min": 1, "max": 65535}, + {"key": "username", "label": "Username (may include DOMAIN\\user)", "type": "string", "required": True}, + {"key": "password", "label": "Password", "type": "secret", "required": False}, + {"key": "nt_hash", "label": "NTLM hash (hex)", "type": "string", "required": False}, + {"key": "domain", "label": "Domain (optional)", "type": "string", "required": False, "default": ""}, + {"key": "timeout", "label": "Connection timeout (seconds)", "type": "int", "required": False, "default": 30, "min": 1, "max": 600}, + ], +} + +load("runzero.types", "ImportAsset") +load("runzero.smb", smb_dial="dial") +load("kwargs", "require", "get_string", "get_int") + + +def main(*args, **kwargs): + require(kwargs, "host", "username") + host = get_string(kwargs, "host") + port = get_int(kwargs, "port", default=445) + username = get_string(kwargs, "username") + password = get_string(kwargs, "password", default="") + nt_hash = get_string(kwargs, "nt_hash", default="") + domain = get_string(kwargs, "domain", default="") + timeout = get_int(kwargs, "timeout", default=30) + + if not password and not nt_hash: + print("either password or nt_hash is required") + return [] + + session = smb_dial( + host=host, + port=port, + username=username, + password=password, + nt_hash=nt_hash, + domain=domain, + timeout=timeout, + ) + + shares = session.list_shares() + visible_count = 0 + share_attrs = {} + for name in shares: + share_attrs["share.{}".format(name)] = "listed" + # Attempt to mount each share and count top-level entries; we + # silently skip shares we cannot access (typical for IPC$ or + # admin shares without privilege). + if name.endswith("$") and name != "ADMIN$": + continue + entries = [] + share = session.mount(share=name) + result = share.list(path="/") + for e in result: + entries.append(e["name"]) + share.unmount() + if len(entries) > 0: + visible_count += 1 + share_attrs["share.{}.entries".format(name)] = ", ".join(entries[:20]) + session.close() + + attrs = { + "smb.target": "{}:{}".format(host, port), + "smb.share_count": "{}".format(len(shares)), + "smb.accessible_share_count": "{}".format(visible_count), + } + attrs.update(share_attrs) + + asset = ImportAsset( + id="smb://{}:{}".format(host, port), + hostnames=[host], + os="Windows", + customAttributes=attrs, + ) + # Stream the asset to runZero via report_assets instead of returning a list. + report_assets(asset) + return None diff --git a/windows-wmi/README.md b/windows-wmi/README.md new file mode 100644 index 0000000..bef7017 --- /dev/null +++ b/windows-wmi/README.md @@ -0,0 +1,42 @@ +# Windows WMI integration + +Imports basic Windows asset information into runZero by querying +`Win32_OperatingSystem`, `Win32_ComputerSystem`, `Win32_NetworkAdapterConfiguration`, +`Win32_Service`, and `Win32_Product` from a single Windows host. + +The script supports two transports: + +- **WinRM** (default): SOAP-over-HTTP/S using the Windows Remote Management + service. Default ports 5985/HTTP and 5986/HTTPS. NTLM auth is preferred; + Basic auth is only safe over HTTPS with a trusted CA. +- **DCE/RPC WMI**: Direct DCOM/MS-WMI. Uses TCP/135 (`ncacn_ip_tcp`) by + default or named pipes over TCP/445 (`ncacn_np`) when + `transport=wmi-smb`. + +The integration is "one host per run". To scan many hosts, schedule one +job per credential/target pair, or extend the script to loop. + +## Parameters + +| key | type | required | description | +| ---------------------- | ------ | -------- | -------------------------------------------------------- | +| `host` | string | yes | Hostname or IP of the Windows target | +| `username` | string | yes | `DOMAIN\user`, `user@domain`, or local account name | +| `password` | secret | yes | Account password | +| `transport` | enum | no | `winrm-https`, `winrm-http`, `wmi-tcp`, `wmi-smb` | +| `winrm_port` | int | no | Override default 5985/5986 | +| `winrm_insecure` | bool | no | Skip TLS verification (NOT for production) | +| `winrm_ca_cert` | string | no | PEM-encoded CA chain for the target | +| `winrm_auth` | enum | no | `ntlm` (default) or `basic` | +| `wmi_namespace` | string | no | Defaults to `//./root/cimv2` | +| `timeout` | int | no | Per-operation timeout in seconds (default 60, max 600) | + +## Example + +```sh +runzero script --filename windows-wmi/windows-wmi.star \ + --kwargs host=10.0.0.5 \ + --kwargs username='ACME\svc-runzero' \ + --kwargs password='…' \ + --kwargs transport=winrm-https +``` diff --git a/windows-wmi/windows-wmi.star b/windows-wmi/windows-wmi.star new file mode 100644 index 0000000..534f1a5 --- /dev/null +++ b/windows-wmi/windows-wmi.star @@ -0,0 +1,229 @@ +CONFIG = { + "id": "runzero-windows-wmi", + "name": "Windows WMI", + "type": "inbound", + "description": "Collect a Windows host's OS, hardware, network, services, and installed software via WinRM or DCE-RPC WMI.", + "version": "26052700", + "params": [ + { + "key": "host", + "label": "Host", + "description": "Hostname or IP of the Windows target.", + "type": "string", + "required": True, + }, + { + "key": "username", + "label": "Username", + "description": "DOMAIN\\\\user, user@domain, or local account.", + "type": "string", + "required": True, + }, + { + "key": "password", + "label": "Password", + "type": "secret", + "required": True, + }, + { + "key": "transport", + "label": "Transport", + "type": "enum", + "options": ["winrm-https", "winrm-http", "wmi-tcp", "wmi-smb"], + "default": "winrm-https", + }, + { + "key": "winrm_port", + "label": "WinRM port", + "type": "int", + "min": 0, + "max": 65535, + "default": 0, + }, + { + "key": "winrm_insecure", + "label": "Skip TLS verification (NOT for production)", + "type": "bool", + "default": False, + }, + { + "key": "winrm_ca_cert", + "label": "PEM CA bundle", + "type": "textarea", + }, + { + "key": "winrm_auth", + "label": "WinRM auth", + "type": "enum", + "options": ["ntlm", "basic"], + "default": "ntlm", + }, + { + "key": "wmi_namespace", + "label": "WMI namespace", + "type": "string", + "default": "//./root/cimv2", + }, + { + "key": "timeout", + "label": "Timeout (seconds)", + "type": "int", + "min": 1, + "max": 600, + "default": 60, + }, + ], +} + +load("runzero.types", "ImportAsset", "NetworkInterface", "Software") +load("runzero.winrm", winrm_dial="dial") +load("runzero.wmi", wmi_dial="dial") +load("kwargs", "require", "get_string", "get_int", "get_bool") +load("net", "ip_address") + +# Queries kept short so the WQL stays well under the 4 KiB limit. +Q_OS = "SELECT Caption, Version, BuildNumber, OSArchitecture, CSName, InstallDate FROM Win32_OperatingSystem" +Q_CS = "SELECT Manufacturer, Model, Domain, SystemType, TotalPhysicalMemory FROM Win32_ComputerSystem" +Q_NIC = "SELECT Description, MACAddress, IPAddress, DHCPEnabled, IPEnabled FROM Win32_NetworkAdapterConfiguration WHERE IPEnabled=true" +Q_SVC = "SELECT Name, DisplayName, State, StartMode, PathName FROM Win32_Service" +Q_PROD = "SELECT Name, Version, Vendor, InstallDate FROM Win32_Product" + + +def _ip_list(raw): + if raw == None: + return [], [] + if type(raw) == "string": + raw = [raw] + ip4, ip6 = [], [] + for s in raw: + if not s: + continue + addr = ip_address(s) + if addr == None: + continue + if addr.version == 4: + ip4.append(addr) + else: + ip6.append(addr) + return ip4, ip6 + + +def _build_nics(nic_rows): + out = [] + for r in nic_rows: + mac = r.get("MACAddress") or "" + ip4, ip6 = _ip_list(r.get("IPAddress")) + if not mac and not ip4 and not ip6: + continue + out.append(NetworkInterface(macAddress=mac, ipv4Addresses=ip4, ipv6Addresses=ip6)) + return out + + +def _build_software(prod_rows): + out = [] + for r in prod_rows: + name = r.get("Name") or "" + if not name: + continue + out.append(Software(name=name, version=r.get("Version") or "")) + return out + + +def _collect_winrm(host, username, password, transport, port, insecure, ca, auth, namespace, timeout): + https = transport == "winrm-https" + s = winrm_dial( + host=host, + username=username, + password=password, + port=port, + https=https, + insecure_skip_verify=insecure, + ca_cert=ca, + auth=auth, + timeout=timeout, + ) + os_rows = s.wql(Q_OS, namespace=namespace) + cs_rows = s.wql(Q_CS, namespace=namespace) + nic_rows = s.wql(Q_NIC, namespace=namespace) + svc_rows = s.wql(Q_SVC, namespace=namespace) + prod_rows = s.wql(Q_PROD, namespace=namespace) + s.close() + return os_rows, cs_rows, nic_rows, svc_rows, prod_rows + + +def _collect_wmi(host, username, password, transport, namespace, timeout): + s = wmi_dial( + host=host, + username=username, + password=password, + transport="smb" if transport == "wmi-smb" else "tcp", + namespace=namespace, + timeout=timeout, + ) + os_rows = s.query(Q_OS) + cs_rows = s.query(Q_CS) + nic_rows = s.query(Q_NIC) + svc_rows = s.query(Q_SVC) + prod_rows = s.query(Q_PROD) + s.close() + return os_rows, cs_rows, nic_rows, svc_rows, prod_rows + + +def main(*args, **kwargs): + require(kwargs, "host", "username", "password") + host = get_string(kwargs, "host") + username = get_string(kwargs, "username") + password = get_string(kwargs, "password") + transport = get_string(kwargs, "transport", default="winrm-https") + namespace = get_string(kwargs, "wmi_namespace", default="//./root/cimv2") + timeout = get_int(kwargs, "timeout", default=60) + + if transport.startswith("winrm-"): + port = get_int(kwargs, "winrm_port", default=0) + insecure = get_bool(kwargs, "winrm_insecure", default=False) + ca = get_string(kwargs, "winrm_ca_cert", default="") or None + auth = get_string(kwargs, "winrm_auth", default="ntlm") + os_rows, cs_rows, nic_rows, svc_rows, prod_rows = _collect_winrm( + host, username, password, transport, port, insecure, ca, auth, + # WinRM helper expects WMI namespace as "root/cimv2" style. + namespace.lstrip("/").lstrip("."), + timeout, + ) + else: + os_rows, cs_rows, nic_rows, svc_rows, prod_rows = _collect_wmi( + host, username, password, transport, namespace, timeout, + ) + + os_row = os_rows[0] if os_rows else {} + cs_row = cs_rows[0] if cs_rows else {} + + hostnames = [] + cs_name = os_row.get("CSName") or cs_row.get("Name") + if cs_name: + hostnames.append(cs_name) + + nics = _build_nics(nic_rows) + software = _build_software(prod_rows) + + custom_attrs = { + "wmi.manufacturer": cs_row.get("Manufacturer") or "", + "wmi.model": cs_row.get("Model") or "", + "wmi.domain": cs_row.get("Domain") or "", + "wmi.systemType": cs_row.get("SystemType") or "", + "wmi.serviceCount": str(len(svc_rows)), + "wmi.transport": transport, + } + custom_attrs = {k: v for k, v in custom_attrs.items() if v} + + asset = ImportAsset( + id=cs_name or host, + hostnames=hostnames, + os=os_row.get("Caption") or "", + osVersion=os_row.get("Version") or "", + networkInterfaces=nics, + software=software, + customAttributes=custom_attrs, + ) + # Stream the asset to runZero via report_assets instead of returning a list. + report_assets(asset) + return None