Skip to content

Commit 8436db9

Browse files
authored
Merge pull request #27 from runZeroInc/new/scan-passive-assets
Scan assets found in passive scanning
2 parents f2bb9d4 + 70d68eb commit 8436db9

File tree

7 files changed

+210
-10
lines changed

7 files changed

+210
-10
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,10 @@ If you need help setting up a custom integration, you can create an [issue](http
4949
- [Ubiquiti Unifi Network](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ubiquiti-unifi-network/)
5050
## Export from runZero
5151
- [Audit Log to Webhook](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/audit-events-to-webhook/)
52-
- [runZero Vunerability Workflow](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/vulnerability-workflow/)
5352
- [Sumo Logic](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/sumo-logic/)
53+
## Internal Integrations
54+
- [Scan Passive Assets](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/scan-passive-assets/)
55+
- [Vunerability Workflow](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/vulnerability-workflow/)
5456
## The boilerplate folder has examples to follow
5557

5658
1. Sample [README.md](./boilerplate/README.md) for contributing

docs/integrations.json

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
2-
"lastUpdated": "2026-01-15T15:30:47.444549Z",
3-
"totalIntegrations": 31,
2+
"lastUpdated": "2026-01-15T15:32:00.895289Z",
3+
"totalIntegrations": 32,
44
"integrationDetails": [
55
{
66
"name": "Moysle",
@@ -39,8 +39,8 @@
3939
"integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/tanium/custom-integration-tanium.star"
4040
},
4141
{
42-
"name": "runZero Vunerability Workflow",
43-
"type": "outbound",
42+
"name": "Vunerability Workflow",
43+
"type": "internal",
4444
"readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/vulnerability-workflow/README.md",
4545
"integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/vulnerability-workflow/custom-integration-vulnerability-workflow.star"
4646
},
@@ -116,6 +116,12 @@
116116
"readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/audit-events-to-webhook/README.md",
117117
"integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/audit-events-to-webhook/custom-integration-audit-events.star"
118118
},
119+
{
120+
"name": "Scan Passive Assets",
121+
"type": "internal",
122+
"readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/scan-passive-assets/README.md",
123+
"integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/scan-passive-assets/custom-integration-scan-passive-assets.star"
124+
},
119125
{
120126
"name": "NinjaOne",
121127
"type": "inbound",

scan-passive-assets/README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Custom Integration: Scan Passive Assets
2+
3+
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.
4+
5+
## runZero requirements
6+
7+
- Superuser access to the [Custom Integrations configuration](https://console.runzero.com/custom-integrations) in runZero.
8+
- A runZero Organization API token.
9+
10+
## Scan Passive Assets requirements
11+
12+
- A runZero **Site ID** to target for scans (set in `SITE_ID`).
13+
- A CIDR allow list to scope targets (set in `ALLOW_LIST`).
14+
- A decision on whether to delete passive assets after scan creation (set in `DELETE_ASSETS`).
15+
16+
## Steps
17+
18+
### Script configuration
19+
20+
1. Open `scan-passive-assets/custom-integrastion-scan-passive-assets.star`.
21+
2. Update the global configuration values:
22+
- `SITE_ID`: runZero site ID where scans should run.
23+
- `ALLOW_LIST`: list of allowed IPv4 CIDR ranges.
24+
- `DELETE_ASSETS`: set to `False` to keep passive assets after scans are created.
25+
3. (Optional) Adjust the search filter in the export request if you want to include more than `source:sample source_count:1`.
26+
27+
### runZero configuration
28+
29+
1. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials).
30+
- Select the type `Custom Integration Script Secrets`.
31+
- Set `access_secret` to your runZero API token.
32+
- Set `access_key` to a placeholder value like `foo` (unused).
33+
2. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new).
34+
- Add a Name and Icon (e.g., `scan-passive-assets`).
35+
- Toggle `Enable custom integration script` to input the finalized script.
36+
- Click `Validate` to ensure it has valid syntax.
37+
- Click `Save` to create the Custom Integration.
38+
3. [Create the Custom Integration task](https://console.runzero.com/ingest/custom/).
39+
- Select the Credential and Custom Integration created above.
40+
- Update the task schedule to recur at the desired timeframes.
41+
- Select the Explorer you'd like the Custom Integration to run from.
42+
- Click `Save` to kick off the first task.
43+
44+
### What's next?
45+
46+
- The task exports passive assets matching the search filter and groups allowed IPv4 addresses by `last_agent_id`.
47+
- The script creates one scan per agent with the matching targets.
48+
- If `DELETE_ASSETS` is enabled, the matching passive assets are removed after scan creation.
49+
- You can review task activity on the [tasks](https://console.runzero.com/tasks) page.
50+
51+
### Notes
52+
53+
- Only IPv4 addresses are considered; IPv6 addresses are skipped.
54+
- The allow list applies before scans are created, so verify `ALLOW_LIST` matches your internal ranges.
55+
- Disabling `DELETE_ASSETS` is recommended for initial testing.

scan-passive-assets/config.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{ "name": "Scan Passive Assets", "type": "internal" }
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
load('requests', 'Session')
2+
load('json', json_encode='encode', json_decode='decode')
3+
load('net', 'ip_address')
4+
load('http', 'url_encode')
5+
6+
# -------------------------
7+
# Global Configuration
8+
# -------------------------
9+
SITE_ID = "UPDATE_ME"
10+
DELETE_ASSETS = True
11+
ALLOW_LIST = ["10.0.0.0/8", "192.168.0.0/16"]
12+
13+
# -------------------------
14+
# IP Filtering Functions
15+
# -------------------------
16+
def ip_to_int(ip):
17+
parts = ip.split('.')
18+
return (int(parts[0]) << 24) + (int(parts[1]) << 16) + (int(parts[2]) << 8) + int(parts[3])
19+
20+
def cidr_to_netmask(bits):
21+
return ~((1 << (32 - bits)) - 1) & 0xFFFFFFFF
22+
23+
def ip_in_cidr(ip_str, cidr):
24+
ip_int = ip_to_int(ip_str)
25+
base, mask_bits = cidr.split('/')
26+
base_int = ip_to_int(base)
27+
mask = cidr_to_netmask(int(mask_bits))
28+
return (ip_int & mask) == (base_int & mask)
29+
30+
def is_ip_allowed(ip_str, allow_list):
31+
ip_obj = ip_address(ip_str)
32+
if ip_obj.version != 4:
33+
return False
34+
for cidr in allow_list:
35+
if ip_in_cidr(ip_str, cidr):
36+
return True
37+
return False
38+
39+
# -------------------------
40+
# Entrypoint
41+
# -------------------------
42+
def main(*args, **kwargs):
43+
org_token = kwargs["access_secret"]
44+
45+
session = Session()
46+
session.headers.set("Authorization", "Bearer {}".format(org_token))
47+
session.headers.set("Content-Type", "application/json")
48+
49+
# Step 1: Export assets
50+
params = {"search": "source:sample source_count:1", "fields": "id,addresses,last_agent_id"}
51+
asset_url = "https://console.runzero.com/api/v1.0/export/org/assets.json?{}".format(url_encode(params))
52+
response = session.get(asset_url, timeout=3600)
53+
54+
if not response or response.status_code != 200:
55+
print("Failed to fetch assets")
56+
return []
57+
58+
data = json_decode(response.body)
59+
60+
# Step 2: Filter assets and group IPs by agent
61+
agent_ip_map = {} # {agent_id: [ip, ip, ...]}
62+
asset_ids = []
63+
64+
for asset in data:
65+
agent_id = asset.get("last_agent_id")
66+
if not agent_id:
67+
continue
68+
for ip in asset.get("addresses", []):
69+
print("Evaluating IP: {}".format(ip))
70+
if is_ip_allowed(ip, ALLOW_LIST):
71+
if not agent_ip_map.get(agent_id):
72+
agent_ip_map[agent_id] = []
73+
agent_ip_map[agent_id].append(ip)
74+
if asset["id"] not in asset_ids:
75+
asset_ids.append(asset["id"])
76+
77+
78+
# Step 3: Create scan task per explorer/agent
79+
for agent_id, ips in agent_ip_map.items():
80+
scan_url = "https://console.runzero.com/api/v1.0/org/sites/{}/scan".format(SITE_ID)
81+
scan_payload = {
82+
"targets": "\n".join(ips),
83+
"scan-name": "Auto Scan Sample Only Assets",
84+
"scan-description": "This scan was automatically created to scan assets discovered by the 'sample' source only.",
85+
"scan-frequency": "once",
86+
"scan-start": "0",
87+
"scan-tags": "type=AUTOMATED",
88+
"scan-grace-period": "0",
89+
"agent": agent_id,
90+
"rate": "1000",
91+
"max-host-rate": "20",
92+
"passes": "3",
93+
"max-attempts": "3",
94+
"max-sockets": "500",
95+
"max-group-size": "4096",
96+
"max-ttl": "255",
97+
"screenshots": "true",
98+
}
99+
print(scan_payload)
100+
post = session.put(scan_url, body=bytes(json_encode(scan_payload)))
101+
if post and post.status_code == 200:
102+
print("Scan created for agent {}".format(agent_id))
103+
else:
104+
print("Scan failed for agent {}".format(agent_id))
105+
106+
# Step 4: Optional asset deletion
107+
if DELETE_ASSETS and len(asset_ids) > 0:
108+
delete_url = "https://console.runzero.com/api/v1.0/org/assets/bulk/delete"
109+
delete_payload = {"asset_ids": asset_ids}
110+
del_resp = session.post(delete_url, body=bytes(json_encode(delete_payload)))
111+
if del_resp and del_resp.status_code == 204:
112+
print("Deleted {} assets".format(len(asset_ids)))
113+
else:
114+
print("Asset deletion {} failed".format(del_resp.body))
115+
116+
return []

scripts/generate_integration_json.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@
4141
except Exception as e:
4242
print(f"⚠️ Failed to read config.json in {entry}: {e}")
4343

44+
if not integration_type:
45+
integration_type = "inbound"
46+
integration_type = str(integration_type).lower()
47+
48+
if integration_type not in {"inbound", "outbound", "internal"}:
49+
print(
50+
f"⚠️ Unknown integration type '{integration_type}' in {entry}, defaulting to inbound."
51+
)
52+
integration_type = "inbound"
53+
4454
integration_details.append(
4555
{
4656
"name": friendly_name,
@@ -75,18 +85,21 @@
7585
new_lines = []
7686
in_inbound_section = False
7787
in_outbound_section = False
78-
in_section = None
88+
in_internal_section = False
7989

8090
# Prepare the new sections
8191
inbound_links = []
8292
outbound_links = []
93+
internal_links = []
8394

8495
for integration in sorted(integration_details, key=lambda x: x["name"].lower()):
8596
link = (
8697
f"- [{integration['name']}]({integration['readme'].replace('/README.md', '/')})"
8798
)
8899
if integration["type"] == "outbound":
89100
outbound_links.append(link)
101+
elif integration["type"] == "internal":
102+
internal_links.append(link)
90103
else:
91104
inbound_links.append(link)
92105

@@ -103,10 +116,17 @@
103116
new_lines.extend([f"{link}\n" for link in outbound_links])
104117
in_outbound_section = True
105118
continue
106-
elif stripped.startswith("## ") and (in_inbound_section or in_outbound_section):
107-
in_inbound_section = in_outbound_section = False
119+
elif stripped == "## Internal Integrations":
120+
new_lines.append(line)
121+
new_lines.extend([f"{link}\n" for link in internal_links])
122+
in_internal_section = True
123+
continue
124+
elif stripped.startswith("## ") and (
125+
in_inbound_section or in_outbound_section or in_internal_section
126+
):
127+
in_inbound_section = in_outbound_section = in_internal_section = False
108128

109-
if not in_inbound_section and not in_outbound_section:
129+
if not in_inbound_section and not in_outbound_section and not in_internal_section:
110130
new_lines.append(line)
111131

112132
with open(readme_path, "w") as f:

vulnerability-workflow/config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{ "name": "runZero Vunerability Workflow", "type": "outbound" }
1+
{ "name": "Vunerability Workflow", "type": "internal" }

0 commit comments

Comments
 (0)