A set of Kusto (KQL) functions that transform tabular query results into graph structures — nodes and edges — for visual exploration in Kusto Explorer. Designed for lifting cybersecurity activity logs into graphs to aid in threat hunting and incident investigations. These set of functions were created by Saar Ron, John Lambert, and Diana Damenova.
Security logs are inherently relational: IPs connect to domains, users authenticate to devices, processes spawn other processes. Tabular views flatten these relationships, making it harder to spot patterns. These functions let you take any Kusto query result and, with a simple JSON mapping, project it into a graph you can explore visually with make-graph in the Kusto Explorer desktop app.
The core function. Takes a generic table and a JSON mapping string, and produces a unified node + edge table ready for rendering.
Parameters
| Name | Type | Description |
|---|---|---|
T |
(*) |
Any tabular input — the results of your KQL query |
mappingJson |
string |
A JSON string defining how columns map to node types, edge types, and their properties |
Mapping JSON Schema
The mapping JSON has two top-level arrays: node_types and edges.
{
"node_types": [
{
"type": "SrcIp",
"id": "src_ip",
"key": "src_ip",
"props": ["src_ip"],
"defaults": {},
"defIcon": "https://raw.githubusercontent.com/benc-uk/icon-collection/master/azure-icons/Public-IP-Addresses-(Classic).svg"
},
{
"type": "Host",
"id": "hostname",
"key": "hostname",
"props": ["hostname"],
"defaults": {},
"defIcon": "https://raw.githubusercontent.com/benc-uk/icon-collection/master/azure-icons/Virtual-Machine.svg"
},
{
"type": "User",
"id": "username",
"key": "username",
"props": ["username"],
"defaults": {},
"defIcon": "https://raw.githubusercontent.com/benc-uk/icon-collection/master/azure-icons/Users.svg"
},
{
"type": "AuthEvent",
"id": "AuthEventId",
"key": "AuthEventId",
"props": ["AuthEventId", "timestamp", "user_agent", "result", "password_hash", "description"],
"defaults": { "result": "unknown" },
"defIcon": "https://raw.githubusercontent.com/benc-uk/icon-collection/master/azure-icons/Activity-Log.svg"
}
],
"edges": [
{
"type": "RequestsAuth",
"source": {"id": "src_ip", "type": "SrcIp"},
"target": {"id": "AuthEventId", "type": "AuthEvent"},
"props": ["timestamp"]
},
{
"type": "TargetsUser",
"source": {"id": "AuthEventId", "type": "AuthEvent"},
"target": {"id": "username", "type": "User"},
"props": ["timestamp"]
},
{
"type": "AgainstHost",
"source": {"id": "AuthEventId", "type": "AuthEvent"},
"target": {"id": "hostname", "type": "Host"},
"props": ["timestamp"]
}
]
}Node type fields:
id— prefix for the node's unique identifier; in most cases the same as typekey— column name inTwhose value becomes the node's nametype— the node type label (e.g."IP","Account","Device")props— array of column names to carry as node properties. Must include the columns named inkeyand any columns named indefaults,displayName,color,size, andiconColor. Note thatidandtypedo not need to be listed.displayName— (optional) column name to use for display; defaults toiddefaults— (optional) object of default values for properties when the column is null/emptydefIcon— (optional) default icon url, may reference externally hosted, community‑maintained Azure icon repositories (e.g., https://github.com/benc-uk/icon-collection) for convenience.color/size/iconColor— (optional) column names to source visual attributes from
Edge fields:
source.type/target.type— match against node type labels to wire up edgestype— edge type labelprops— (optional) array of column names to carry as edge propertiesdisplayName/color— (optional) visual overrides
Output schema:
| Column | Description |
|---|---|
EntityType |
"node" or "edge" |
id |
Node identifier |
type |
Node or edge type |
properties |
Dynamic bag of properties |
nodeDisplayName |
Display label for nodes |
nodeColor / nodeSize / iconUrl / iconColor |
Node visual attributes |
SourceId / TargetId |
Edge endpoints |
edgeType / edgeProperties / edgeDisplayName / edgeColor |
Edge attributes |
Takes the output of Lift_To_Graph and renders it using make-graph in Kusto Explorer with a sensible default graph style (grouped layout, icon-based coloring).
Parameters
| Name | Type | Description |
|---|---|---|
T |
See schema below | The unified node + edge table produced by Lift_To_Graph |
Expected input schema:
(id:string, type:string, properties:dynamic, nodeDisplayName:string,
nodeColor:string, nodeSize:real, iconUrl:string, iconColor:string,
SourceId:string, TargetId:string, edgeType:string, edgeProperties:dynamic,
edgeDisplayName:string, edgeColor:string, EntityType:string)
Usage:
MyQuery
| invoke Lift_To_Graph(mappingJson)
| invoke Graph_Render_View()Collapses nodes of a given type that share the same value for a specific property into a single "folded" node. This is useful for spotting shared infrastructure patterns — for example, folding IP nodes by carrier name or ASN reveals which IPs share the same network provider, making common-infrastructure clusters immediately visible.
Parameters
| Name | Type | Description |
|---|---|---|
T |
See schema below | The unified node + edge table |
NodeType |
string |
The node type to fold (e.g. "IP") |
PropertyName |
string |
The property to fold on (e.g. "ASN", "Carrier") |
Expected input schema:
(EntityType:string, id:string, type:string, properties:dynamic, iconUrl:string,
SourceId:string, TargetId:string, edgeType:string, edgeProperties:dynamic)
What it does:
- Finds all nodes of
NodeTypethat share the same value forPropertyName - Replaces groups of 2+ matching nodes with a single folded node whose
idisPropertyName/value - Rewires all edges that pointed to/from the original nodes to point to the folded node instead
- The folded node's
propertiesbag containsfolded(the shared value),memberCount, andmembers(list of original node IDs)
Usage:
MyQuery
| invoke Lift_To_Graph(mappingJson)
| invoke Graph_Fold_By_Property("IP", "ASN")
| invoke Graph_Render_View()You can copy and paste this exact query in Kusto Explorer and run.
let mailLogs = cluster('kc7001.eastus.kusto.windows.net').database('AzureCrest').Email| take 400; // 1. Define your security log query
let mail_mapping = todynamic(```{"node_types":[{"type":"EmailMessage","id":"Message","key":"subject","props":["message_id","timestamp","subject","verdict"],"defaults":{},"defIcon":"https://raw.githubusercontent.com/benc-uk/icon-collection/master/azure-icons/Media-File.svg"},{"type":"Sender","id":"Email","key":"sender","props":["sender"],"defaults":{},"defIcon":"https://raw.githubusercontent.com/benc-uk/icon-collection/master/azure-cds/command-1070-Mail.svg"},{"type":"Reciever","id":"Email","key":"recipient","props":["recipient"],"defaults":{},"defIcon":"https://raw.githubusercontent.com/benc-uk/icon-collection/master/azure-cds/command-1070-Mail.svg"}],"edges":[{"type":"SentBy","source":{"id":"Message","type":"EmailMessage"},"target":{"id":"Email","type":"Sender"},"props":["timestamp","verdict"]},{"type":"DeliveredTo","source":{"id":"Message","type":"EmailMessage"},"target":{"id":"Email","type":"Reciever"},"props":["timestamp","verdict"]}]}```);
mailLogs // 2. Define a json mapping (or reference an existing one on your cluster)
| invoke Lift_To_Graph(mail_mapping) // 3. Run lift to graph to transform your query results into a table of nodes and edges
| invoke Graph_Fold_By_Property("EmailMessage","verdict") // 4. Fold message nodes by their verdict
| invoke Graph_Render_View() // 5. Render the graph in KE to visualize and run graph queries.Lift_To_Graph
.create-or-alter function with
(folder="irql_draft", docstring="Transforms a generic table to a Kusto graph table using the given JSON mapping")
Lift_To_Graph(T:(*), mappingJson:string)
{
let calcIcon = (T:(type:string, defIcon:string)) {
T
| extend iconUrl = defIcon
| project-away defIcon
};
let mapping = (mapping_json:string) {
parse_json(mapping_json)
};
let Tpacked = (T:(*)) {
T | extend _row = pack_all()
};
let KustoResultsToNodes = (T:(*), mapping_json:dynamic) {
let NodeExpanded =
Tpacked(T)
| mv-expand nodeDef = mapping(mapping_json).node_types to typeof(dynamic)
| extend name=tostring(_row[tostring(nodeDef.key)]), type=tostring(nodeDef.type), _nodeKeys=iif(isnull(nodeDef.props),dynamic([]),nodeDef.props), defaults=nodeDef.defaults
| extend nodeColor=iff(isnotnull(nodeDef.color), tostring(_row[tostring(nodeDef.color)]), "")
| extend nodeSize=iff(isnotnull(nodeDef.size), toreal(_row[tostring(nodeDef.size)]), 1.0)
| extend iconColor=iff(isnotnull(nodeDef.color), tostring(_row[tostring(nodeDef.iconColor)]), "")
| extend id = strcat(nodeDef.id,"/",name)
| extend nodeDisplayName=iff(isnotnull(nodeDef.displayName), strcat(type,'/',tostring(_row[tostring(nodeDef.displayName)])), id)
| extend defIcon = iif(isnotempty(nodeDef.defIcon), nodeDef.defIcon, "")
| where isnotempty(split(id, "/")[-1])
| extend type = tostring(nodeDef.type)
| extend iconUrl=""
| invoke calcIcon();
let NodePropsFilled = (T:(_row:dynamic, _nodeKeys:dynamic, id:string, type:string, nodeDisplayName:string, nodeColor:string, nodeSize:real, iconUrl:string, iconColor:string, defaults:dynamic)) {
T
| mv-expand k=_nodeKeys to typeof(string)
| extend v=_row[k], def=defaults[k]
| extend v = iif(isnull(v) or isempty(tostring(v)), iif(isnull(def), v, def), v)
| summarize properties=make_bag(bag_pack(k,v)) by id,type,nodeDisplayName,nodeColor,nodeSize,iconUrl,iconColor
};
let NodeNoProps = (T:(_nodeKeys:dynamic, id:string, type:string, nodeDisplayName:string, nodeColor:string, nodeSize:real, iconUrl:string, iconColor:string)) {
T
| where array_length(_nodeKeys)==0
| extend properties=dynamic({})
| project id,type,properties,nodeDisplayName,nodeColor,nodeSize,iconUrl, iconColor
};
let Nodes = (T:(_row:dynamic, _nodeKeys:dynamic, id:string, type:string, nodeDisplayName:string, nodeColor:string, nodeSize:real, iconUrl:string, iconColor:string, defaults:dynamic))
{
union
NodePropsFilled(T),
NodeNoProps(T)
| project id,type,properties,nodeDisplayName,nodeColor,nodeSize,iconUrl, iconColor
};
union
(T | extend EntityType = "data"),
(Nodes(NodeExpanded) | extend EntityType = "node")
};
let KustoResultsToEdges = (T:(EntityType:string, ),mapping_json:dynamic) {
let edges = datatable(SourceId:string, TargetId:string) [];
let EdgeExpanded =
Tpacked((T | where EntityType == "data"))
| extend nodeDef = mapping(mapping_json).node_types
| mv-expand edgeDef = mapping(mapping_json).edges to typeof(dynamic)
| mv-apply nodeDefSrc = nodeDef on (
where tostring(nodeDefSrc["type"]) == tostring(edgeDef.source.type)
)
| extend SourceId = strcat(nodeDefSrc.id,"/",tostring(_row[tostring(nodeDefSrc.key)]))
| mv-apply nodeDefTgt = nodeDef on (
where tostring(nodeDefTgt["type"]) == tostring(edgeDef.target.type)
)
| extend TargetId = strcat(nodeDefTgt.id,"/",tostring(_row[tostring(nodeDefTgt.key)]))
| extend edgeType=tostring(edgeDef.type), _edgeKeys=iif(isnull(edgeDef.props),dynamic([]),edgeDef.props)
| extend edgeDisplayName = iff(isnotnull(edgeDef.displayName), strcat(edgeType,'/',tostring(_row[tostring(edgeDef.displayName)])), edgeType)
| extend edgeColor= iff(isnotnull(edgeDef.color), tostring(_row[tostring(edgeDef.color)]), edgeType);
let EdgePropsFilled = (T:(_row:dynamic, _edgeKeys:dynamic, SourceId:string,TargetId:string,edgeType:string,edgeDisplayName:string,edgeColor:string)) {
T
| mv-expand k=_edgeKeys to typeof(string)
| extend v=_row[k]
| summarize edgeProperties=make_bag(bag_pack(k,v)) by SourceId,TargetId,edgeType,edgeDisplayName,edgeColor
};
let EdgeNoProps = (T:(_edgeKeys:dynamic, SourceId:string,TargetId:string,edgeType:string,edgeDisplayName:string,edgeColor:string)) {
T
| where array_length(_edgeKeys)==0
| extend properties=dynamic({})
| project SourceId,TargetId,edgeType, properties,edgeDisplayName,edgeColor
};
let Edges = (T:(_edgeKeys:dynamic, SourceId:string,TargetId:string,edgeType:string,edgeDisplayName:string,edgeColor:string)) {
union
EdgePropsFilled(EdgeExpanded),
EdgeNoProps(EdgeExpanded)
| where isnotempty(split(SourceId, "/")[-1]) and isnotempty(split(TargetId, "/")[-1])
| project-reorder SourceId,TargetId,edgeType,edgeProperties,edgeDisplayName,edgeColor
};
union
(T | where EntityType=="node"),
(Edges(EdgeExpanded) | extend EntityType = "edge")
};
T
| invoke KustoResultsToNodes(mappingJson)
| invoke KustoResultsToEdges(mappingJson)
| where EntityType != "data"
| project EntityType, id, type, properties, nodeDisplayName, nodeColor, nodeSize, iconUrl, iconColor, SourceId, TargetId, edgeType, edgeProperties, edgeDisplayName, edgeColor
}Graph_Render_View
.create-or-alter function with
(folder="irql_draft", docstring="Renders a graph table using make-graph in Kusto Explorer")
Graph_Render_View(T:(id:string,type:string,properties:dynamic,nodeDisplayName:string,nodeColor:string,nodeSize:real,iconUrl:string,iconColor:string,SourceId:string,TargetId:string,edgeType:string,edgeProperties:dynamic,edgeDisplayName:string,edgeColor:string,EntityType:string))
{
let NodesTable =
T
| where EntityType=="node"
| project id, type, properties, nodeDisplayName, nodeColor, nodeSize, iconUrl, iconColor;
let EdgesTable =
T
| where EntityType=="edge"
| project SourceId, TargetId, type=edgeType, properties=edgeProperties, edgeDisplayName, edgeColor;
// #graph-style("Default")
let Default = dynamic({
"name":"Default",
"graph_style":{
"layout":{"kind":"Grouped"},
"nodes_config":{
"density":80.0,
"label_by":"id",
"color_by":"iconUrl",
"lifetime_start_by":"",
"lifetime_end_by":"",
"image_url_by":"iconUrl",
"image_size":2.0
},
"edges_config":{
"lifetime_start_by":"",
"lifetime_end_by":""
}
},
"script":"// Use right-click on the nodes to explore interactive operations over the graph.",
"matches":[]
});
EdgesTable
| make-graph SourceId --> TargetId with (NodesTable) on id
}Graph_Fold_By_Property
.create-or-alter function with
(folder="irql_draft", docstring="Folds nodes of a given type by a shared property value into single collapsed nodes")
Graph_Fold_By_Property(T:(EntityType:string,id:string,type:string,properties:dynamic,iconUrl:string,SourceId:string,TargetId:string,edgeType:string,edgeProperties:dynamic), NodeType:string, PropertyName:string)
{
let Nodes =
T
| where EntityType == "node"
| project EntityType, id, type, properties, iconUrl,
SourceId="", TargetId="", edgeType="", edgeProperties=dynamic(null);
let Edges =
T
| where EntityType == "edge"
| project EntityType, id="", type="", properties=dynamic({}), iconUrl="",
SourceId, TargetId, edgeType, edgeProperties;
let FoldedNodes =
Nodes
| where type == NodeType
| where isnotempty(properties[PropertyName])
| extend val = tostring(properties[PropertyName])
| summarize members = make_list(id), memberCount = count() by val
| where memberCount > 1
| extend
id = strcat(PropertyName, "/", val),
type = PropertyName,
EntityType = "node",
properties = pack(
"folded", val,
"memberCount", memberCount,
"members", members
),
iconUrl = ""
| project EntityType, id, type, properties, iconUrl,
SourceId="", TargetId="", edgeType="", edgeProperties=dynamic(null);
let MemberToFold =
Nodes
| where type == NodeType
| where isnotempty(properties[PropertyName])
| extend val = tostring(properties[PropertyName])
| join kind=inner (
FoldedNodes
| extend val = tostring(properties["folded"])
| project val, foldId=id
) on val
| project memberId=id, foldId;
let RewiredEdges =
Edges
| lookup kind=leftouter (MemberToFold | project SourceId=memberId, FoldSourceId=foldId) on SourceId
| lookup kind=leftouter (MemberToFold | project TargetId=memberId, FoldTargetId=foldId) on TargetId
| extend
NewSourceId = coalesce(FoldSourceId, SourceId),
NewTargetId = coalesce(FoldTargetId, TargetId)
| where NewSourceId != NewTargetId
| project EntityType="edge",
id="", type="", properties=dynamic({}), iconUrl="",
SourceId=NewSourceId, TargetId=NewTargetId, edgeType, edgeProperties;
let FoldedMemberIds = MemberToFold | distinct memberId;
union
(Nodes | where id !in (FoldedMemberIds)),
FoldedNodes,
RewiredEdges
}- Kusto Explorer with
make-graphrendering support. Install here: https://learn.microsoft.com/en-us/kusto/tools/kusto-explorer?view=microsoft-fabric - Kusto data to lift. Start at KC7: https://kc7cyber.com