graph_path_discovery_fl()
Aplica-se a: ✅Microsoft Fabric✅Azure Data Explorer✅Azure Monitor✅Microsoft Sentinel
Descubra caminhos válidos entre pontos de extremidade relevantes (fontes e destinos) sobre dados gráficos (borda e nós).
A função graph_path_discovery_fl()
é um UDF (função definida pelo usuário) que permite descobrir caminhos válidos entre pontos finais relevantes sobre dados gráficos. Os dados do gráfico consistem em nós (por exemplo - recursos, aplicativos ou usuários) e bordas (por exemplo - permissões de acesso existentes). No contexto de segurança cibernética, esses caminhos podem representar possíveis caminhos de movimento lateral que um invasor em potencial pode utilizar. Estamos interessados em caminhos que conectam pontos de extremidade definidos como relevantes por alguns critérios - por exemplo, fontes expostas conectadas a alvos críticos. Com base na configuração da função, outros tipos de caminhos, adequados para outros cenários de segurança, podem ser descobertos.
Os dados que podem ser usados como entrada para esta função são uma tabela de bordas no formato 'SourceId, EdgeId, TargetId' e uma lista de nós com propriedades de nós opcionais que podem ser usadas para definir caminhos válidos. Alternativamente, a entrada de gráficos pode ser extraída de outros tipos de dados. Por exemplo, os logs de tráfego com entradas do tipo 'Usuário A conectado ao recurso B' podem ser modelados como bordas do tipo '(Usuário A)-[conectado a]->(recurso B)', enquanto a lista de usuários e recursos distintos pode ser modelada como nós.
Partimos de vários pressupostos:
- Todas as bordas são válidas para a descoberta de caminho. As arestas que são irrelevantes devem ser filtradas antes de executar a descoberta de caminho.
- As arestas são não ponderadas, independentes e incondicionais, o que significa que todas as arestas têm a mesma probabilidade e a mudança de B para C não depende do movimento anterior de A para B.
- Os caminhos que queremos descobrir são caminhos direcionais simples, sem ciclos, do tipo A->B->C. Definições mais complexas podem ser feitas alterando a sintaxe interna do operador de correspondência gráfica na função.
Estes pressupostos podem ser adaptados conforme necessário, alterando a lógica interna da função.
A função descobre todos os caminhos possíveis entre fontes válidas para alvos válidos, sob restrições opcionais, como limites de comprimento de caminho, tamanho máximo de saída, etc. A saída é uma lista de caminhos descobertos com IDs de origem e destino, bem como uma lista de bordas e nós de conexão. A função usa apenas os campos obrigatórios, como Ids de nó e Ids de borda. Caso outros campos relevantes - como tipos, listas de propriedades, pontuações relacionadas à segurança ou sinais externos - estejam disponíveis nos dados de entrada, eles podem ser adicionados à lógica e à saída alterando a definição da função.
Sintaxe
graph_path_discovery_fl(
edgesTableName, , nodesTableName, scopeColumnName, isValidPathStartColumnName, isValidPathEndColumnName, nodeIdColumnName, edgeIdColumnName, , sourceIdColumnName, targetIdColumnName, [minPathLength], [maxPathLength], [resultCountLimit])
Saiba mais sobre convenções de sintaxe.
Parâmetros
Designação | Tipo | Necessário | Descrição |
---|---|---|---|
edgesTableName | string |
✔️ | O nome da tabela de entrada que contém as bordas do gráfico. |
nodesTableName | string |
✔️ | O nome da tabela de entrada que contém os nós do gráfico. |
scopeColumnName | string |
✔️ | O nome da coluna em tabelas de nós e bordas que contêm a partição ou o escopo (por exemplo, assinatura ou conta), para que um modelo de anomalia diferente seja criado para cada escopo. |
isValidPathStartColumnName | string |
✔️ | O nome da coluna na tabela de nós que contém um sinalizador booleano para um nó, True significa que o nó é um ponto de partida válido para um caminho e False - não um válido. |
isValidPathEndColumnName | string |
✔️ | O nome da coluna na tabela de nós que contém um sinalizador booleano para um nó, True significa que o nó é um ponto final válido para um caminho e False - não um válido. |
nodeIdColumnName | string |
✔️ | O nome da coluna na tabela de nós que contém a ID do nó. |
edgeIdColumnName | string |
✔️ | O nome da coluna na tabela de bordas que contém o ID da borda. |
sourceIdColumnName | string |
✔️ | O nome da coluna na tabela de bordas que contém o ID do nó de origem da borda. |
targetIdColumnName | string |
✔️ | O nome da coluna na tabela de bordas que contém a ID do nó de destino da borda. |
minPathLength | long |
O número mínimo de etapas (bordas) no caminho. O valor padrão é 1. | |
maxPathLength | long |
O número máximo de etapas (bordas) no caminho. O valor padrão é 8. | |
resultCountLimit | long |
O número máximo de caminhos retornados para a saída. O valor padrão é 100000. |
Definição de função
Você pode definir a função incorporando seu código como uma função definida por consulta ou criando-a como uma função armazenada em seu banco de dados, da seguinte maneira:
- definido por consulta
- Armazenado
Defina a função usando a seguinte instrução let. Não são necessárias permissões.
Importante
Uma declaração let não pode ser executada sozinha. Ela deve ser seguida por uma instrução de expressão tabular . Para executar um exemplo funcional de graph_path_discovery_fl()
, consulte Exemplo.
let graph_path_discovery_fl = ( edgesTableName:string, nodesTableName:string, scopeColumnName:string
, isValidPathStartColumnName:string, isValidPathEndColumnName:string
, nodeIdColumnName:string, edgeIdColumnName:string, sourceIdColumnName:string, targetIdColumnName:string
, minPathLength:long = 1, maxPathLength:long = 8, resultCountLimit:long = 100000)
{
let edges = (
table(edgesTableName)
| extend sourceId = column_ifexists(sourceIdColumnName, '')
| extend targetId = column_ifexists(targetIdColumnName, '')
| extend edgeId = column_ifexists(edgeIdColumnName, '')
| extend scope = column_ifexists(scopeColumnName, '')
);
let nodes = (
table(nodesTableName)
| extend nodeId = column_ifexists(nodeIdColumnName, '')
| extend isValidPathStart = column_ifexists(isValidPathStartColumnName, '')
| extend isValidPathEnd = column_ifexists(isValidPathEndColumnName, '')
| extend scope = column_ifexists(scopeColumnName, '')
);
let paths = (
edges
// Build graph object partitioned by scope, so that no connections are allowed between scopes.
// In case no scopes are relevant, partitioning should be removed for better performance.
| make-graph sourceId --> targetId with nodes on nodeId partitioned-by scope (
// Look for existing paths between source nodes and target nodes with less than predefined number of hops
// Current configurations looks for directed paths without any cycles; this can be changed if needed
graph-match cycles = none (s)-[e*minPathLength..maxPathLength]->(t)
// Filter only by paths with that connect valid endpoints
where ((s.isValidPathStart) and (t.isValidPathEnd))
project sourceId = s.nodeId
, isSourceValidPathStart = s.isValidPathStart
, targetId = t.nodeId
, isTargetValidPathEnd = t.isValidPathEnd
, scope = s.scope
, edgeIds = e.edgeId
, edgeAllTargetIds = e.targetId
| limit resultCountLimit
)
| extend pathLength = array_length(edgeIds)
, pathId = hash_md5(strcat(sourceId, strcat(edgeIds), targetId))
, pathAllNodeIds = array_concat(pack_array(sourceId), edgeAllTargetIds)
| project-away edgeAllTargetIds
| mv-apply with_itemindex = SortIndex nodesInPath = pathAllNodeIds to typeof(string), edgesInPath = edgeIds to typeof(string) on (
extend step = strcat(
iff(isnotempty(nodesInPath), strcat('(', nodesInPath, ')'), '')
, iff(isnotempty(edgesInPath), strcat('-[', edgesInPath, ']->'), ''))
| summarize fullPath = array_strcat(make_list(step), '')
)
);
paths
};
// Write your query to use the function here.
Exemplo
O exemplo a seguir usa o operador invoke para executar a função.
- definido por consulta
- Armazenado
Para usar uma função definida por consulta, invoque-a após a definição da função incorporada.
let graph_path_discovery_fl = ( edgesTableName:string, nodesTableName:string, scopeColumnName:string
, isValidPathStartColumnName:string, isValidPathEndColumnName:string
, nodeIdColumnName:string, edgeIdColumnName:string, sourceIdColumnName:string, targetIdColumnName:string
, minPathLength:long = 1, maxPathLength:long = 8, resultCountLimit:long = 100000)
{
let edges = (
table(edgesTableName)
| extend sourceId = column_ifexists(sourceIdColumnName, '')
| extend targetId = column_ifexists(targetIdColumnName, '')
| extend edgeId = column_ifexists(edgeIdColumnName, '')
| extend scope = column_ifexists(scopeColumnName, '')
);
let nodes = (
table(nodesTableName)
| extend nodeId = column_ifexists(nodeIdColumnName, '')
| extend isValidPathStart = column_ifexists(isValidPathStartColumnName, '')
| extend isValidPathEnd = column_ifexists(isValidPathEndColumnName, '')
| extend scope = column_ifexists(scopeColumnName, '')
);
let paths = (
edges
// Build graph object partitioned by scope, so that no connections are allowed between scopes.
// In case no scopes are relevant, partitioning should be removed for better performance.
| make-graph sourceId --> targetId with nodes on nodeId partitioned-by scope (
// Look for existing paths between source nodes and target nodes with less than predefined number of hops
// Current configurations looks for directed paths without any cycles; this can be changed if needed
graph-match cycles = none (s)-[e*minPathLength..maxPathLength]->(t)
// Filter only by paths with that connect valid endpoints
where ((s.isValidPathStart) and (t.isValidPathEnd))
project sourceId = s.nodeId
, isSourceValidPathStart = s.isValidPathStart
, targetId = t.nodeId
, isTargetValidPathEnd = t.isValidPathEnd
, scope = s.scope
, edgeIds = e.edgeId
, edgeAllTargetIds = e.targetId
| limit resultCountLimit
)
| extend pathLength = array_length(edgeIds)
, pathId = hash_md5(strcat(sourceId, strcat(edgeIds), targetId))
, pathAllNodeIds = array_concat(pack_array(sourceId), edgeAllTargetIds)
| project-away edgeAllTargetIds
| mv-apply with_itemindex = SortIndex nodesInPath = pathAllNodeIds to typeof(string), edgesInPath = edgeIds to typeof(string) on (
extend step = strcat(
iff(isnotempty(nodesInPath), strcat('(', nodesInPath, ')'), '')
, iff(isnotempty(edgesInPath), strcat('-[', edgesInPath, ']->'), ''))
| summarize fullPath = array_strcat(make_list(step), '')
)
);
paths
};
let edges = datatable (SourceNodeName:string, EdgeName:string, EdgeType:string, TargetNodeName:string, Region:string)[
'vm-work-1', 'e1', 'can use', 'webapp-prd', 'US',
'vm-custom', 'e2', 'can use', 'webapp-prd', 'US',
'webapp-prd', 'e3', 'can access', 'vm-custom', 'US',
'webapp-prd', 'e4', 'can access', 'test-machine', 'US',
'vm-custom', 'e5', 'can access', 'server-0126', 'US',
'vm-custom', 'e6', 'can access', 'hub_router', 'US',
'webapp-prd', 'e7', 'can access', 'hub_router', 'US',
'test-machine', 'e8', 'can access', 'vm-custom', 'US',
'test-machine', 'e9', 'can access', 'hub_router', 'US',
'hub_router', 'e10', 'routes traffic to', 'remote_DT', 'US',
'vm-work-1', 'e11', 'can access', 'storage_main_backup', 'US',
'hub_router', 'e12', 'routes traffic to', 'vm-work-2', 'US',
'vm-work-2', 'e13', 'can access', 'backup_prc', 'US',
'remote_DT', 'e14', 'can access', 'backup_prc', 'US',
'backup_prc', 'e15', 'moves data to', 'storage_main_backup', 'US',
'backup_prc', 'e16', 'moves data to', 'storage_DevBox', 'US',
'device_A1', 'e17', 'is connected to', 'sevice_B2', 'EU',
'sevice_B2', 'e18', 'is connected to', 'device_A1', 'EU'
];
let nodes = datatable (NodeName:string, NodeType:string, NodeEnvironment:string, Region:string) [
'vm-work-1', 'Virtual Machine', 'Production', 'US',
'vm-custom', 'Virtual Machine', 'Production', 'US',
'webapp-prd', 'Application', 'None', 'US',
'test-machine', 'Virtual Machine', 'Test', 'US',
'hub_router', 'Traffic Router', 'None', 'US',
'vm-work-2', 'Virtual Machine', 'Production', 'US',
'remote_DT', 'Virtual Machine', 'Production', 'US',
'backup_prc', 'Service', 'Production', 'US',
'server-0126', 'Server', 'Production', 'US',
'storage_main_backup', 'Cloud Storage', 'Production', 'US',
'storage_DevBox', 'Cloud Storage', 'Test', 'US',
'device_A1', 'Device', 'Backend', 'EU',
'device_B2', 'Device', 'Backend', 'EU'
];
let nodesEnriched = (
nodes
| extend IsValidStart = (NodeType == 'Virtual Machine'), IsValidEnd = (NodeType == 'Cloud Storage') // option 1
//| extend IsValidStart = (NodeName in('vm-work-1', 'vm-work-2')), IsValidEnd = (NodeName in('storage_main_backup')) // option 2
//| extend IsValidStart = (NodeEnvironment == 'Test'), IsValidEnd = (NodeEnvironment == 'Production') // option 3
);
graph_path_discovery_fl(edgesTableName = 'edges'
, nodesTableName = 'nodesEnriched'
, scopeColumnName = 'Region'
, nodeIdColumnName = 'NodeName'
, edgeIdColumnName = 'EdgeName'
, sourceIdColumnName = 'SourceNodeName'
, targetIdColumnName = 'TargetNodeName'
, isValidPathStartColumnName = 'IsValidStart'
, isValidPathEndColumnName = 'IsValidEnd'
)
Output
fonteId | isSourceValidPathStart | targetId | isTargetValidPathEnd | Âmbito de aplicação | edgeIds | pathLength | pathId | pathAllNodeIds | caminho completo |
---|---|---|---|---|---|---|---|---|---|
máquina-teste | Verdadeiro | storage_DevBox | Verdadeiro | EUA | ["e9","e10","e14","e16"] | 4 | 00605d35b6e1d28024fd846f217b43ac | ["máquina de teste","hub_router","remote_DT","backup_prc","storage_DevBox"] | (máquina de ensaio)-[e9]->(hub_router)-[e10]->(remote_DT)-[e14]->(backup_prc)-[e16]->(storage_DevBox) |
A execução da função localiza todos os caminhos usando bordas de entrada que se conectam entre nós de origem sinalizados como pontos de início válidos (isSourceValidPathStart == True) para todos os destinos sinalizados como pontos finais válidos (isTargetValidPathEnd == True). A saída é uma tabela onde cada linha descreve um único caminho (limitado ao número máximo de linhas pelo parâmetro resultCountLimit). Cada linha contém os seguintes campos:
-
sourceId
: nodeId da origem - primeiro nó no caminho. -
isSourceValidPathStart
: Sinalizador booleano para nó de origem sendo um início de caminho válido; deve ser igual a True. -
targetId
: nodeId do destino - último nó no caminho. -
isTargetValidPathEnd
: Sinalizador booleano para nó de destino sendo um fim de caminho válido; deve ser sempre igual a True. -
scope
: o escopo que contém o caminho. -
edgeIds
: uma lista ordenada de arestas no caminho. -
pathLength
: o número de arestas (saltos) no caminho. -
pathId
: um hash de pontos de extremidade e etapas do caminho pode ser usado como identificador exclusivo para o caminho. -
pathAllNodeIds
: uma lista ordenada de nós no caminho. -
fullPath
: uma cadeia de caracteres que representa o caminho completo, em formato (nó de origem)-[borda 1]->(nó2)-.....->(nó de destino).
No exemplo acima, pré-processamos a tabela de nós e adicionamos várias opções de possíveis definições de ponto final. Ao comentar/descomentar diferentes opções, vários cenários podem ser descobertos:
- Opção 1: Encontre caminhos entre máquinas virtuais e recursos de armazenamento em nuvem. Útil para explorar padrões de conexão entre tipos de nós.
- Opção 2: Encontre caminhos entre qualquer um dos nós específicos (vm-work-1, vm-work-2) para um nó específico (storage_main_backup). Útil na investigação de casos conhecidos - como caminhos de ativos comprometidos conhecidos para ativos críticos conhecidos.
- Opção 3: Encontre caminhos entre grupos de nós, como nós em ambientes diferentes. Útil para monitorar caminhos inseguros, como caminhos entre ambientes de teste e produção.
No exemplo acima, usamos a primeira opção para encontrar todos os caminhos entre VMs e recursos de armazenamento em nuvem, que podem ser usados por invasores em potencial que desejam acessar dados armazenados. Esse cenário pode ser reforçado adicionando mais filtros a pontos de extremidade válidos - por exemplo, conectando VMs com vulnerabilidades conhecidas a contas de armazenamento contendo dados confidenciais.
A função graph_path_discovery_fl()
pode ser usada no domínio da cibersegurança para descobrir caminhos interessantes, como caminhos de movimento lateral, sobre dados modelados como um gráfico.