Skip to content

Commit 28440d4

Browse files
authored
feat(sentinelone): ingest agent IP addresses (#2858)
### Type of change - [x] New feature (non-breaking change that adds functionality) - [x] Documentation update ### Summary Adds SentinelOne agent IP address fields to `S1Agent`: - `public_ip` from SentinelOne `externalIp` - `local_ips` from non-loopback values in `networkInterfaces[].inet` This helps correlate endpoint inventory with security findings that reference either public source IPs or local endpoint IPs. Loopback interface addresses are filtered out because they are not useful for graph correlation. ### Related issues or links - Fixes # ### Breaking changes None. This only adds optional properties to existing `S1Agent` nodes. ### How was this tested? - Validated against a live SentinelOne site-scoped API response in a temporary local Neo4j database. The provider response included non-null `externalIp` values and local interface `inet` values. The local `S1Agent` sync loaded 5 agents, persisted `public_ip` for 5 agents, and persisted non-loopback `local_ips` for 5 agents. ### Checklist #### General - [ ] I have read the [contributing guidelines](https://cartography-cncf.github.io/cartography/dev/developer-guide.html). - [x] The linter passes locally (`make test_lint`). - [x] I have added/updated tests that prove my fix is effective or my feature works. #### Proof of functionality - [ ] Screenshot showing the graph before and after changes. - [x] New or updated unit/integration tests. #### If you are adding or modifying a synced entity - [x] Included Cartography sync logs from a real environment demonstrating successful synchronization of the new/modified entity. Logs should show: - The sync job starting and completing without errors - The number of nodes/relationships created or updated - Example: ``` INFO:cartography.intel.aws.ec2:Loading 42 EC2 instances for region us-east-1 INFO:cartography.intel.aws.ec2:Synced EC2 instances in 3.21 seconds ``` #### If you are changing a node or relationship - [x] Updated the [schema documentation](https://github.com/cartography-cncf/cartography/tree/master/docs/root/modules). - [ ] Updated the [schema README](https://github.com/cartography-cncf/cartography/blob/master/docs/schema/README.md). #### If you are implementing a new intel module - [ ] Used the NodeSchema [data model](https://cartography-cncf.github.io/cartography/dev/writing-intel-modules.html#defining-a-node). ### Notes for reviewers `public_ip` and `local_ips` are intentionally optional because SentinelOne may omit those fields for some agents or scopes. --------- Signed-off-by: Kunaal Sikka <kunaal@subimage.io>
1 parent e185eb5 commit 28440d4

6 files changed

Lines changed: 86 additions & 0 deletions

File tree

cartography/intel/sentinelone/agent.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
from ipaddress import ip_address
23
from typing import Any
34

45
import neo4j
@@ -13,6 +14,25 @@
1314
logger = logging.getLogger(__name__)
1415

1516

17+
def _get_local_ips(agent: dict[str, Any]) -> list[str]:
18+
local_ips: list[str] = []
19+
for network_interface in agent.get("networkInterfaces") or []:
20+
inet_values = network_interface.get("inet") or []
21+
if isinstance(inet_values, str):
22+
inet_values = [inet_values]
23+
for ip in inet_values:
24+
if not ip:
25+
continue
26+
try:
27+
parsed_ip = ip_address(ip)
28+
except ValueError:
29+
continue
30+
if parsed_ip.is_loopback:
31+
continue
32+
local_ips.append(ip)
33+
return local_ips
34+
35+
1636
@timeit
1737
def get_agents(
1838
api_url: str,
@@ -61,6 +81,8 @@ def transform_agents(agent_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
6181
# Optional fields - use .get() with None default
6282
"uuid": agent.get("uuid"),
6383
"computer_name": agent.get("computerName"),
84+
"public_ip": agent.get("externalIp"),
85+
"local_ips": _get_local_ips(agent),
6486
"firewall_enabled": agent.get("firewallEnabled"),
6587
"os_name": agent.get("osName"),
6688
"os_revision": agent.get("osRevision"),

cartography/models/sentinelone/agent.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ class S1AgentNodeProperties(CartographyNodeProperties):
1515
id: PropertyRef = PropertyRef("id", extra_index=True)
1616
uuid: PropertyRef = PropertyRef("uuid", extra_index=True)
1717
computer_name: PropertyRef = PropertyRef("computer_name", extra_index=True)
18+
public_ip: PropertyRef = PropertyRef("public_ip", extra_index=True)
19+
local_ips: PropertyRef = PropertyRef("local_ips")
1820
firewall_enabled: PropertyRef = PropertyRef("firewall_enabled")
1921
os_name: PropertyRef = PropertyRef("os_name")
2022
os_revision: PropertyRef = PropertyRef("os_revision")

docs/root/modules/sentinelone/schema.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ Represents a SentinelOne agent installed on an endpoint device.
6666
| **uuid** | The UUID of the agent |
6767
| **computer_name** | The name of the computer where the agent is installed |
6868
| **serial_number** | The serial number of the endpoint device |
69+
| public_ip | The public IP address reported by SentinelOne for the endpoint device |
70+
| local_ips | Local IPv4 addresses reported by SentinelOne network interfaces for the endpoint device |
6971
| firewall_enabled | Boolean indicating if the firewall is enabled |
7072
| os_name | The name of the operating system |
7173
| os_revision | The operating system revision/version |

tests/data/sentinelone/agent.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77
"id": AGENT_ID,
88
"uuid": "uuid-123-456-789",
99
"computerName": "test-computer-01",
10+
"externalIp": "203.0.113.10",
11+
"networkInterfaces": [
12+
{
13+
"inet": ["192.168.1.10", "127.0.0.1"],
14+
},
15+
],
1016
"firewallEnabled": True,
1117
"osName": "Windows 10",
1218
"osRevision": "1909",
@@ -20,6 +26,15 @@
2026
"id": AGENT_ID_2,
2127
"uuid": "uuid-456-789-123",
2228
"computerName": "test-computer-02",
29+
"externalIp": "203.0.113.11",
30+
"networkInterfaces": [
31+
{
32+
"inet": ["10.0.0.20"],
33+
},
34+
{
35+
"inet": ["127.0.0.1"],
36+
},
37+
],
2338
"firewallEnabled": False,
2439
"osName": "Ubuntu 20.04",
2540
"osRevision": "5.4.0-89-generic",
@@ -33,6 +48,8 @@
3348
"id": AGENT_ID_3,
3449
"uuid": "uuid-789-123-456",
3550
"computerName": "test-computer-03",
51+
"externalIp": None,
52+
"networkInterfaces": [],
3653
"firewallEnabled": True,
3754
"osName": "macOS",
3855
"osRevision": "12.6.1",

tests/integration/cartography/intel/sentinelone/test_agent.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def test_sync_agents(neo4j_session, mocker):
4949
AGENT_ID,
5050
"uuid-123-456-789",
5151
"test-computer-01",
52+
"203.0.113.10",
5253
True,
5354
"Windows 10",
5455
"1909",
@@ -62,6 +63,7 @@ def test_sync_agents(neo4j_session, mocker):
6263
AGENT_ID_2,
6364
"uuid-456-789-123",
6465
"test-computer-02",
66+
"203.0.113.11",
6567
False,
6668
"Ubuntu 20.04",
6769
"5.4.0-89-generic",
@@ -75,6 +77,7 @@ def test_sync_agents(neo4j_session, mocker):
7577
AGENT_ID_3,
7678
"uuid-789-123-456",
7779
"test-computer-03",
80+
None,
7881
True,
7982
"macOS",
8083
"12.6.1",
@@ -93,6 +96,7 @@ def test_sync_agents(neo4j_session, mocker):
9396
"id",
9497
"uuid",
9598
"computer_name",
99+
"public_ip",
96100
"firewall_enabled",
97101
"os_name",
98102
"os_revision",
@@ -106,6 +110,18 @@ def test_sync_agents(neo4j_session, mocker):
106110

107111
assert actual_nodes == expected_nodes
108112

113+
local_ips = {
114+
(record["id"], tuple(record["local_ips"]))
115+
for record in neo4j_session.run(
116+
"MATCH (a:S1Agent) RETURN a.id AS id, a.local_ips AS local_ips",
117+
)
118+
}
119+
assert local_ips == {
120+
(AGENT_ID, ("192.168.1.10",)),
121+
(AGENT_ID_2, ("10.0.0.20",)),
122+
(AGENT_ID_3, ()),
123+
}
124+
109125
# Verify that relationships to the account were created
110126
expected_rels = {
111127
(AGENT_ID, TEST_ACCOUNT_ID),

tests/unit/cartography/intel/sentinelone/test_agent.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ def test_transform_agents():
6363
assert agent1["id"] == AGENT_ID
6464
assert agent1["uuid"] == "uuid-123-456-789"
6565
assert agent1["computer_name"] == "test-computer-01"
66+
assert agent1["public_ip"] == "203.0.113.10"
67+
assert agent1["local_ips"] == ["192.168.1.10"]
6668
assert agent1["firewall_enabled"] is True
6769
assert agent1["os_name"] == "Windows 10"
6870
assert agent1["os_revision"] == "1909"
@@ -75,12 +77,16 @@ def test_transform_agents():
7577
# Test second agent (Linux with different values)
7678
agent2 = result[1]
7779
assert agent2["id"] == AGENT_ID_2
80+
assert agent2["public_ip"] == "203.0.113.11"
81+
assert agent2["local_ips"] == ["10.0.0.20"]
7882
assert agent2["firewall_enabled"] is False # Boolean type preservation
7983
assert agent2["os_name"] == "Ubuntu 20.04"
8084

8185
# Test third agent (macOS with None fields)
8286
agent3 = result[2]
8387
assert agent3["id"] == AGENT_ID_3
88+
assert agent3["public_ip"] is None
89+
assert agent3["local_ips"] == []
8490
assert agent3["domain"] is None # None value handling
8591
assert agent3["last_successful_scan"] is None # None value handling
8692

@@ -100,6 +106,8 @@ def test_transform_agents_missing_optional_fields():
100106
# Optional fields should be None
101107
assert agent["uuid"] is None
102108
assert agent["computer_name"] is None
109+
assert agent["public_ip"] is None
110+
assert agent["local_ips"] == []
103111
assert agent["firewall_enabled"] is None
104112
assert agent["os_name"] is None
105113
assert agent["os_revision"] is None
@@ -126,6 +134,25 @@ def test_transform_agents_missing_required_field():
126134
transform_agents(test_data)
127135

128136

137+
def test_transform_agents_handles_unexpected_local_ip_shapes():
138+
"""
139+
Test that transform_agents handles scalar local IPs and invalid local IPs.
140+
"""
141+
result = transform_agents(
142+
[
143+
{
144+
"id": "unexpected-local-ip-agent",
145+
"networkInterfaces": [
146+
{"inet": "192.168.1.11"},
147+
{"inet": ["not-an-ip-address", "127.0.0.1", "10.0.0.11"]},
148+
],
149+
},
150+
],
151+
)
152+
153+
assert result[0]["local_ips"] == ["192.168.1.11", "10.0.0.11"]
154+
155+
129156
def test_transform_agents_empty_list():
130157
"""
131158
Test that transform_agents handles empty input list

0 commit comments

Comments
 (0)