diff --git a/docs/reference/tools/sentinel.md b/docs/reference/tools/sentinel.md index 1890c87..090f267 100644 --- a/docs/reference/tools/sentinel.md +++ b/docs/reference/tools/sentinel.md @@ -58,9 +58,11 @@ VPN sessions and failures. `indicator` is a domain, URL fragment, IP address, or an identity — Umbrella names the AD user or roaming-client machine behind each request, so pass a hostname or username to see what it did, or pass an IP or domain to see who was behind it. Every returned row -carries that identity, so you do not need another platform to answer "who -was this?". Without an indicator this returns an aggregate, not individual -events. For perimeter firewall connections by IP/port use hunt_firewall. +carries `identity_host` (the machine) and `identity_user` (the AD user) as +separate fields, so you do not need another platform — or any parsing — to +answer "who was this?" or "which machine was that?". Without an indicator +this returns an aggregate, not individual events. For perimeter firewall +connections by IP/port use hunt_firewall. | Parameter | Type | Default | |---|---|---| diff --git a/servers/sentinel-mcp/f0_sentinel_mcp/normalize.py b/servers/sentinel-mcp/f0_sentinel_mcp/normalize.py index 6a0bb36..5c85ea1 100644 --- a/servers/sentinel-mcp/f0_sentinel_mcp/normalize.py +++ b/servers/sentinel-mcp/f0_sentinel_mcp/normalize.py @@ -15,6 +15,7 @@ """ from __future__ import annotations +import json import re from dataclasses import dataclass, field @@ -57,6 +58,9 @@ class Surface: # must never appear in indicator_fields' `has` fallback. port_field: str | None = None junk: tuple[str, ...] = field(default_factory=tuple) + # Column holding a JSON array of identities, split into flat evidence keys + # before the row becomes a finding. See split_identities. + identity_field: str | None = None SURFACE_SPECS: dict[str, Surface] = { @@ -120,9 +124,11 @@ class Surface: indicator_fields=("Domain_s", "InternalIp_s", "ExternalIp_s", "Identities_s"), project=( "TimeGenerated", "Action_s", "Domain_s", "Categories_s", - "InternalIp_s", "ExternalIp_s", "Identities_s", "QueryType_s", + "InternalIp_s", "ExternalIp_s", "Identities_s", "Identity_Types_s", + "QueryType_s", ), junk=("Action",), + identity_field="Identities_s", ), "web": Surface( table="Cisco_Umbrella_proxy_CL", @@ -132,9 +138,11 @@ class Surface: indicator_fields=("URL_s", "Destination_IP_s", "Internal_IP_s", "Identities_s"), project=( "TimeGenerated", "Verdict_s", "URL_s", "Categories_s", - "Internal_IP_s", "Identities_s", "File_Name_s", "SHA_SHA256_s", + "Internal_IP_s", "Identities_s", "Identity_Types_s", "File_Name_s", + "SHA_SHA256_s", ), junk=("Action",), + identity_field="Identities_s", ), "vpn": Surface( table="Cisco_Umbrella_ravpnlogs_CL", @@ -193,6 +201,69 @@ def action_clause(spec: Surface, action: str) -> str: return f"| where {spec.action_field} in~ ({_kql_list(values)})" +# Umbrella identity-type vocabulary, live-observed on the validation tenant. +# Anything unrecognised falls through to `other` rather than being guessed into +# a hostname -- mislabelling a group as a machine is the same class of error +# this split exists to remove. +_HOST_TYPES = ("roaming", "computer", "mobile device", "machine") +_USER_TYPES = ("user",) + + +def _json_list(raw: object) -> list[str]: + """Parse a JSON array-of-strings column, tolerating anything that is not one.""" + text = str(raw or "").strip() + if not text: + return [] + if text.startswith("["): + try: + parsed = json.loads(text) + except (ValueError, TypeError): + return [text] + if isinstance(parsed, list): + return [str(x) for x in parsed if x not in (None, "")] + return [str(parsed)] + return [text] + + +def split_identities(raw: object, types_raw: object = "") -> tuple[str, str, str]: + """Split Umbrella's `Identities_s` array into (hosts, users, other). + + Live 2026-08-12: 98% of dns rows carry two identities -- the Anyconnect + roaming client (a machine name) and the AD user -- as a JSON array in one + string, ordered to match `Identity_Types_s`. The findings schema is flat by + contract, and a JSON array inside an evidence value is not flat: asked for + "the hostname", a model grepped for a key called hostname, found none, and + reported the column absent while the machine name sat in element 0. + + The type array classifies, not the position -- the ordering is a convention + of this tenant's connector, not a guarantee. When types are missing or do + not line up, fall back to shape: an "@" means a user, anything else is + treated as a host. Values are never dropped; unclassifiable ones surface as + the third element rather than silently disappearing. + """ + ids = _json_list(raw) + if not ids: + return "", "", "" + types = _json_list(types_raw) + hosts: list[str] = [] + users: list[str] = [] + other: list[str] = [] + for i, ident in enumerate(ids): + kind = types[i].lower() if i < len(types) else "" + if kind: + if any(t in kind for t in _USER_TYPES): + users.append(ident) + elif any(t in kind for t in _HOST_TYPES): + hosts.append(ident) + else: + other.append(ident) + elif "@" in ident: + users.append(ident) + else: + hosts.append(ident) + return ", ".join(hosts), ", ".join(users), ", ".join(other) + + def hygiene_clause(spec: Surface) -> str: """Drop CSV header rows that the connector ingested as data. diff --git a/servers/sentinel-mcp/f0_sentinel_mcp/server.py b/servers/sentinel-mcp/f0_sentinel_mcp/server.py index a549d1e..59ede4b 100644 --- a/servers/sentinel-mcp/f0_sentinel_mcp/server.py +++ b/servers/sentinel-mcp/f0_sentinel_mcp/server.py @@ -92,9 +92,11 @@ async def hunt_dns_web( address, or an identity — Umbrella names the AD user or roaming-client machine behind each request, so pass a hostname or username to see what it did, or pass an IP or domain to see who was behind it. Every returned row - carries that identity, so you do not need another platform to answer "who - was this?". Without an indicator this returns an aggregate, not individual - events. For perimeter firewall connections by IP/port use hunt_firewall.""" + carries `identity_host` (the machine) and `identity_user` (the AD user) as + separate fields, so you do not need another platform — or any parsing — to + answer "who was this?" or "which machine was that?". Without an indicator + this returns an aggregate, not individual events. For perimeter firewall + connections by IP/port use hunt_firewall.""" async with _client() as c: return _render( await tools.hunt_dns_web(c, surface, action, indicator, hours_back, limit) diff --git a/servers/sentinel-mcp/f0_sentinel_mcp/tools.py b/servers/sentinel-mcp/f0_sentinel_mcp/tools.py index b782cb4..58533ef 100644 --- a/servers/sentinel-mcp/f0_sentinel_mcp/tools.py +++ b/servers/sentinel-mcp/f0_sentinel_mcp/tools.py @@ -179,6 +179,33 @@ async def list_data_sources(client: Any, limit: int = 25) -> list[Finding]: } +def _expand_identities(row: dict[str, Any], spec: n.Surface) -> dict[str, Any]: + """Replace a surface's raw identity array with flat host/user evidence keys. + + Rewritten in place of the original column so field grouping survives, and + the classifier column is consumed rather than shown -- it exists to sort the + identities, and echoing it back would just be another array for the reader + to parse. + """ + if not spec.identity_field or spec.identity_field not in row: + return row + host, user, other = n.split_identities( + row.get(spec.identity_field), row.get("Identity_Types_s", "") + ) + out: dict[str, Any] = {} + for key, value in row.items(): + if key == spec.identity_field: + if host: + out["identity_host"] = host + if user: + out["identity_user"] = user + if other: + out["identity_other"] = other + elif key != "Identity_Types_s": + out[key] = value + return out + + def _fetch_bound(limit: int) -> int: """Ask the platform for one row more than we intend to show. @@ -305,6 +332,7 @@ async def _run_surface( title_key = spec.indicator_fields[0] if indicator else spec.action_field shown, has_more = _split_page(rows, limit) + shown = [_expand_identities(r, spec) for r in shown] findings = _rows_to_findings(shown, title_key, limit) return findings + _more( len(findings), has_more, "Narrow with an indicator or a shorter hours window." diff --git a/servers/sentinel-mcp/tests/test_normalize.py b/servers/sentinel-mcp/tests/test_normalize.py index ae84040..8631e5e 100644 --- a/servers/sentinel-mcp/tests/test_normalize.py +++ b/servers/sentinel-mcp/tests/test_normalize.py @@ -228,3 +228,75 @@ def test_a_flow_indicator_accepts_an_identity_and_a_port(): assert n.validate_indicator("someone@example.gob.do", "flow") is True assert n.validate_indicator("443", "flow") is True assert n.validate_indicator('x" or 1==1', "flow") is False + + +# --- Umbrella identity splitting ---------------------------------------- +# Live 2026-08-12 (1h, 362,944 dns rows): Identities_s is a 2-element array on +# 356,571 of them — element 0 the Anyconnect roaming client (a MACHINE NAME, +# 739 distinct, none containing "@"), element 1 the AD user (724 distinct, all +# UPNs) — in the same order as Identity_Types_s. Returned as one JSON array in +# a flat evidence value, a model asked for "the hostname" grepped for a key +# called hostname, found none, and reported the column absent. It was there. + +def test_identities_split_into_host_and_user(): + host, user, other = n.split_identities( + '["LT-TPL-L114","aborbon@example.gob.do"]', + '["Anyconnect Roaming Client","AD Users"]', + ) + assert host == "LT-TPL-L114" + assert user == "aborbon@example.gob.do" + assert other == "" + + +def test_identity_types_drive_the_split_not_position(): + """The array order is conventional, not guaranteed; the type array is truth.""" + host, user, _ = n.split_identities( + '["aborbon@example.gob.do","LT-TPL-L114"]', + '["AD Users","Anyconnect Roaming Client"]', + ) + assert host == "LT-TPL-L114" + assert user == "aborbon@example.gob.do" + + +def test_a_group_identity_is_neither_host_nor_user(): + """~1% of rows carry a third AD Groups element; calling it a hostname would + be the same class of wrong this split exists to fix.""" + host, user, other = n.split_identities( + '["LT-TPL-L114","aborbon@example.gob.do","Finance-RW"]', + '["Anyconnect Roaming Client","AD Users","AD Groups"]', + ) + assert host == "LT-TPL-L114" + assert user == "aborbon@example.gob.do" + assert other == "Finance-RW" + + +def test_split_falls_back_to_shape_when_types_are_missing(): + host, user, _ = n.split_identities('["LT-TPL-L114","aborbon@example.gob.do"]', "") + assert host == "LT-TPL-L114" + assert user == "aborbon@example.gob.do" + + +def test_split_survives_a_value_that_is_not_json(): + """Never lose the value to a parse error — degrade to shape-based sorting.""" + host, user, _ = n.split_identities("LT-TPL-L114", "") + assert host == "LT-TPL-L114" + assert user == "" + + +def test_split_of_an_empty_value_is_empty(): + assert n.split_identities("", "") == ("", "", "") + + +def test_umbrella_surfaces_declare_their_identity_columns(): + for surface in ("dns", "web"): + spec = n.SURFACE_SPECS[surface] + assert spec.identity_field == "Identities_s" + assert "Identity_Types_s" in spec.project, "needed to classify the split" + + +def test_split_survives_a_truncated_json_array(): + """A value that looks like JSON but is not must not vanish: a log field can + arrive truncated, and losing the identity entirely is worse than showing it + unparsed.""" + host, user, other = n.split_identities('["LT-TPL-L114","abo', "") + assert "LT-TPL-L114" in (host + user + other) diff --git a/servers/sentinel-mcp/tests/test_tools.py b/servers/sentinel-mcp/tests/test_tools.py index f8f10c2..fa2f360 100644 --- a/servers/sentinel-mcp/tests/test_tools.py +++ b/servers/sentinel-mcp/tests/test_tools.py @@ -1219,3 +1219,21 @@ async def test_aggregate_mode_silent_when_nothing_hidden(fake): out = await tools.hunt_firewall(client, surface="cloud", limit=5) assert not any("more results available" in f.title for f in out) assert sum(f.finding_type.value == "hunt_result" for f in out) == 5 + + +async def test_dns_rows_expose_hostname_and_user_as_flat_evidence(fake): + """The findings schema is flat by contract. Handing back a JSON array in an + evidence value made the hostname invisible to the model that asked for it.""" + rows = [{ + "TimeGenerated": "2026-08-12T12:00:00Z", "Action_s": "Blocked", + "Domain_s": "log.tailscale.com", "InternalIp_s": "192.168.68.56", + "Identities_s": '["LT-TPL-L114","aborbon@example.gob.do"]', + "Identity_Types_s": '["Anyconnect Roaming Client","AD Users"]', + }] + client = fake(rows={USAGE: _TABLES, "Cisco_Umbrella_dns_CL": rows}) + out = await tools.hunt_dns_web(client, surface="dns", indicator="tailscale") + ev = {e.key: e.value for e in out[0].evidence} + assert ev["identity_host"] == "LT-TPL-L114" + assert ev["identity_user"] == "aborbon@example.gob.do" + assert "Identities_s" not in ev, "the raw array is replaced, not duplicated" + assert "Identity_Types_s" not in ev, "the classifier column is consumed, not shown" diff --git a/skills/sentinel/network-investigation/SKILL.md b/skills/sentinel/network-investigation/SKILL.md index ad5a0e2..7dbbca1 100644 --- a/skills/sentinel/network-investigation/SKILL.md +++ b/skills/sentinel/network-investigation/SKILL.md @@ -41,7 +41,10 @@ Base tool names: `list_data_sources`, `hunt_firewall`, `hunt_dns_web`, (`surface="cloud"`) for their L3/L4 connections. Umbrella identities are searchable, so "what did this host resolve" is one call. Do not go hunting for an IP-to-user mapping in other platforms - before trying this: the identity is in the same row as the query. + before trying this: the identity is in the same row as the query, split + into `identity_host` (machine name) and `identity_user` (AD user). If you + are asked for hostnames, those rows already carry them — there is no + lookup to do in Tenable, LimaCharlie or Entra. - **Never send a domain to `hunt_firewall`.** The firewall (CEF) table carries essentially no URL data — on a validated workspace, well under 1% of rows had anything in a URL field. A domain query against it comes