Skip to content

Instantly share code, notes, and snippets.

@ddamenova
Created March 17, 2026 17:39
Show Gist options
  • Select an option

  • Save ddamenova/43696f1e7c63c66f924637e9577316ee to your computer and use it in GitHub Desktop.

Select an option

Save ddamenova/43696f1e7c63c66f924637e9577316ee to your computer and use it in GitHub Desktop.

Kusto Graph Functions for Cybersecurity Investigations

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.

Why Graphs?

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.

Functions

Lift_To_Graph(T, mappingJson)

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 type
  • key — column name in T whose value becomes the node's name
  • type — the node type label (e.g. "IP", "Account", "Device")
  • props — array of column names to carry as node properties. Must include the columns named in key and any columns named in defaults, displayName, color, size, and iconColor. Note that id and type do not need to be listed.
  • displayName — (optional) column name to use for display; defaults to id
  • defaults — (optional) object of default values for properties when the column is null/empty
  • defIcon — (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 edges
  • type — edge type label
  • props — (optional) array of column names to carry as edge properties
  • displayName / 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

Graph_Render_View(T)

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()

Graph_Fold_By_Property(T, NodeType, PropertyName)

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:

  1. Finds all nodes of NodeType that share the same value for PropertyName
  2. Replaces groups of 2+ matching nodes with a single folded node whose id is PropertyName/value
  3. Rewires all edges that pointed to/from the original nodes to point to the folded node instead
  4. The folded node's properties bag contains folded (the shared value), memberCount, and members (list of original node IDs)

Usage:

MyQuery
| invoke Lift_To_Graph(mappingJson)
| invoke Graph_Fold_By_Property("IP", "ASN")
| invoke Graph_Render_View()

Example Workflow with KC7 Open Source Data

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.

Function Definitions

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
}

Prerequisites

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment