Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/reference/tools/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|---|
Expand Down
75 changes: 73 additions & 2 deletions servers/sentinel-mcp/f0_sentinel_mcp/normalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"""
from __future__ import annotations

import json
import re
from dataclasses import dataclass, field

Expand Down Expand Up @@ -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] = {
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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.

Expand Down
8 changes: 5 additions & 3 deletions servers/sentinel-mcp/f0_sentinel_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions servers/sentinel-mcp/f0_sentinel_mcp/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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."
Expand Down
72 changes: 72 additions & 0 deletions servers/sentinel-mcp/tests/test_normalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
18 changes: 18 additions & 0 deletions servers/sentinel-mcp/tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
5 changes: 4 additions & 1 deletion skills/sentinel/network-investigation/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading