diff --git a/README.md b/README.md index ae08c91..803112d 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,10 @@ If you need help setting up a custom integration, you can create an [issue](http - [Ubiquiti Unifi Network](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ubiquiti-unifi-network/) ## Export from runZero - [Audit Log to Webhook](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/audit-events-to-webhook/) -- [runZero Vunerability Workflow](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/vulnerability-workflow/) - [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/) ## The boilerplate folder has examples to follow 1. Sample [README.md](./boilerplate/README.md) for contributing diff --git a/docs/integrations.json b/docs/integrations.json index 1ee5640..de88720 100644 --- a/docs/integrations.json +++ b/docs/integrations.json @@ -1,6 +1,6 @@ { - "lastUpdated": "2026-01-15T15:30:47.444549Z", - "totalIntegrations": 31, + "lastUpdated": "2026-01-15T15:32:00.895289Z", + "totalIntegrations": 32, "integrationDetails": [ { "name": "Moysle", @@ -39,8 +39,8 @@ "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/tanium/custom-integration-tanium.star" }, { - "name": "runZero Vunerability Workflow", - "type": "outbound", + "name": "Vunerability Workflow", + "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" }, @@ -116,6 +116,12 @@ "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": "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": "NinjaOne", "type": "inbound", diff --git a/scan-passive-assets/README.md b/scan-passive-assets/README.md new file mode 100644 index 0000000..cff6169 --- /dev/null +++ b/scan-passive-assets/README.md @@ -0,0 +1,55 @@ +# Custom Integration: Scan Passive Assets + +This custom integration finds assets discovered only by passive sources, creates targeted scans from the last-seen agent, and can optionally delete the original passive assets after the scans are scheduled. + +## runZero requirements + +- Superuser access to the [Custom Integrations configuration](https://console.runzero.com/custom-integrations) in runZero. +- A runZero Organization API token. + +## Scan Passive Assets requirements + +- A runZero **Site ID** to target for scans (set in `SITE_ID`). +- A CIDR allow list to scope targets (set in `ALLOW_LIST`). +- A decision on whether to delete passive assets after scan creation (set in `DELETE_ASSETS`). + +## Steps + +### Script configuration + +1. Open `scan-passive-assets/custom-integrastion-scan-passive-assets.star`. +2. Update the global configuration values: + - `SITE_ID`: runZero site ID where scans should run. + - `ALLOW_LIST`: list of allowed IPv4 CIDR ranges. + - `DELETE_ASSETS`: set to `False` to keep passive assets after scans are created. +3. (Optional) Adjust the search filter in the export request if you want to include more than `source:sample source_count:1`. + +### runZero configuration + +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). +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. + - Click `Validate` to ensure it has valid syntax. + - Click `Save` to create the Custom Integration. +3. [Create the Custom Integration task](https://console.runzero.com/ingest/custom/). + - Select the Credential and Custom Integration created above. + - Update the task schedule to recur at the desired timeframes. + - Select the Explorer you'd like the Custom Integration to run from. + - Click `Save` to kick off the first task. + +### What's next? + +- The task exports passive assets matching the search filter and groups allowed IPv4 addresses by `last_agent_id`. +- The script creates one scan per agent with the matching targets. +- If `DELETE_ASSETS` is enabled, the matching passive assets are removed after scan creation. +- You can review task activity on the [tasks](https://console.runzero.com/tasks) page. + +### Notes + +- Only IPv4 addresses are considered; IPv6 addresses are skipped. +- The allow list applies before scans are created, so verify `ALLOW_LIST` matches your internal ranges. +- Disabling `DELETE_ASSETS` is recommended for initial testing. diff --git a/scan-passive-assets/config.json b/scan-passive-assets/config.json new file mode 100644 index 0000000..3261451 --- /dev/null +++ b/scan-passive-assets/config.json @@ -0,0 +1 @@ +{ "name": "Scan Passive Assets", "type": "internal" } \ No newline at end of file diff --git a/scan-passive-assets/custom-integration-scan-passive-assets.star b/scan-passive-assets/custom-integration-scan-passive-assets.star new file mode 100644 index 0000000..d2fec19 --- /dev/null +++ b/scan-passive-assets/custom-integration-scan-passive-assets.star @@ -0,0 +1,116 @@ +load('requests', 'Session') +load('json', json_encode='encode', json_decode='decode') +load('net', 'ip_address') +load('http', 'url_encode') + +# ------------------------- +# Global Configuration +# ------------------------- +SITE_ID = "UPDATE_ME" +DELETE_ASSETS = True +ALLOW_LIST = ["10.0.0.0/8", "192.168.0.0/16"] + +# ------------------------- +# IP Filtering Functions +# ------------------------- +def ip_to_int(ip): + parts = ip.split('.') + return (int(parts[0]) << 24) + (int(parts[1]) << 16) + (int(parts[2]) << 8) + int(parts[3]) + +def cidr_to_netmask(bits): + return ~((1 << (32 - bits)) - 1) & 0xFFFFFFFF + +def ip_in_cidr(ip_str, cidr): + ip_int = ip_to_int(ip_str) + base, mask_bits = cidr.split('/') + base_int = ip_to_int(base) + mask = cidr_to_netmask(int(mask_bits)) + return (ip_int & mask) == (base_int & mask) + +def is_ip_allowed(ip_str, allow_list): + ip_obj = ip_address(ip_str) + if ip_obj.version != 4: + return False + for cidr in allow_list: + if ip_in_cidr(ip_str, cidr): + return True + return False + +# ------------------------- +# Entrypoint +# ------------------------- +def main(*args, **kwargs): + org_token = kwargs["access_secret"] + + session = Session() + session.headers.set("Authorization", "Bearer {}".format(org_token)) + session.headers.set("Content-Type", "application/json") + + # 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)) + response = session.get(asset_url, timeout=3600) + + if not response or response.status_code != 200: + print("Failed to fetch assets") + return [] + + data = json_decode(response.body) + + # Step 2: Filter assets and group IPs by agent + agent_ip_map = {} # {agent_id: [ip, ip, ...]} + asset_ids = [] + + for asset in data: + agent_id = asset.get("last_agent_id") + if not agent_id: + continue + for ip in asset.get("addresses", []): + print("Evaluating IP: {}".format(ip)) + if is_ip_allowed(ip, ALLOW_LIST): + if not agent_ip_map.get(agent_id): + agent_ip_map[agent_id] = [] + agent_ip_map[agent_id].append(ip) + if asset["id"] not in asset_ids: + asset_ids.append(asset["id"]) + + + # 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_payload = { + "targets": "\n".join(ips), + "scan-name": "Auto Scan Sample Only Assets", + "scan-description": "This scan was automatically created to scan assets discovered by the 'sample' source only.", + "scan-frequency": "once", + "scan-start": "0", + "scan-tags": "type=AUTOMATED", + "scan-grace-period": "0", + "agent": agent_id, + "rate": "1000", + "max-host-rate": "20", + "passes": "3", + "max-attempts": "3", + "max-sockets": "500", + "max-group-size": "4096", + "max-ttl": "255", + "screenshots": "true", + } + print(scan_payload) + post = session.put(scan_url, body=bytes(json_encode(scan_payload))) + if post and post.status_code == 200: + print("Scan created for agent {}".format(agent_id)) + else: + print("Scan failed for agent {}".format(agent_id)) + + # 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_payload = {"asset_ids": asset_ids} + del_resp = session.post(delete_url, body=bytes(json_encode(delete_payload))) + if del_resp and del_resp.status_code == 204: + print("Deleted {} assets".format(len(asset_ids))) + else: + print("Asset deletion {} failed".format(del_resp.body)) + + return [] diff --git a/scripts/generate_integration_json.py b/scripts/generate_integration_json.py index f128b46..0095e82 100644 --- a/scripts/generate_integration_json.py +++ b/scripts/generate_integration_json.py @@ -41,6 +41,16 @@ except Exception as e: print(f"⚠️ Failed to read config.json in {entry}: {e}") + if not integration_type: + integration_type = "inbound" + integration_type = str(integration_type).lower() + + if integration_type not in {"inbound", "outbound", "internal"}: + print( + f"⚠️ Unknown integration type '{integration_type}' in {entry}, defaulting to inbound." + ) + integration_type = "inbound" + integration_details.append( { "name": friendly_name, @@ -75,11 +85,12 @@ new_lines = [] in_inbound_section = False in_outbound_section = False -in_section = None +in_internal_section = False # Prepare the new sections inbound_links = [] outbound_links = [] +internal_links = [] for integration in sorted(integration_details, key=lambda x: x["name"].lower()): link = ( @@ -87,6 +98,8 @@ ) if integration["type"] == "outbound": outbound_links.append(link) + elif integration["type"] == "internal": + internal_links.append(link) else: inbound_links.append(link) @@ -103,10 +116,17 @@ new_lines.extend([f"{link}\n" for link in outbound_links]) in_outbound_section = True continue - elif stripped.startswith("## ") and (in_inbound_section or in_outbound_section): - in_inbound_section = in_outbound_section = False + elif stripped == "## Internal Integrations": + new_lines.append(line) + new_lines.extend([f"{link}\n" for link in internal_links]) + in_internal_section = True + continue + elif stripped.startswith("## ") and ( + in_inbound_section or in_outbound_section or in_internal_section + ): + in_inbound_section = in_outbound_section = in_internal_section = False - if not in_inbound_section and not in_outbound_section: + if not in_inbound_section and not in_outbound_section and not in_internal_section: new_lines.append(line) with open(readme_path, "w") as f: diff --git a/vulnerability-workflow/config.json b/vulnerability-workflow/config.json index 423c073..5157950 100644 --- a/vulnerability-workflow/config.json +++ b/vulnerability-workflow/config.json @@ -1 +1 @@ -{ "name": "runZero Vunerability Workflow", "type": "outbound" } +{ "name": "Vunerability Workflow", "type": "internal" }