Compartilhar via


graph_path_discovery_fl()

Aplica-se a: ✅Microsoft FabricAzure Data ExplorerAzure MonitorMicrosoft Sentinel

Descubra caminhos válidos entre pontos de extremidade relevantes (fontes e destinos) sobre dados de grafo (borda e nós).

A função graph_path_discovery_fl() é uma UDF (função definida pelo usuário) que permite descobrir caminhos válidos entre pontos de extremidade relevantes em relação aos dados do grafo. Os dados do Graph 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 potencial invasor pode utilizar. Estamos interessados em caminhos que conectam pontos de extremidade definidos como relevantes por alguns critérios , por exemplo, fontes expostas conectadas a destinos 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 de entrada dessa função devem incluir uma tabela de bordas no formato 'SourceId, EdgeId, TargetId' e uma lista de nós com nós opcionais' propriedades que podem ser usadas para definir caminhos válidos. Como alternativa, a entrada de grafo 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]->(recurso B)'. A lista de usuários e recursos distintos pode ser modelada como nós.

Fazemos várias suposições:

  • Todas as bordas são válidas para descoberta de caminho. As bordas irrelevantes devem ser filtradas antes de executar a descoberta do caminho.
  • As bordas são sem peso, independentes e incondicional, o que significa que todas as bordas têm a mesma probabilidade e mover de B para C não depende da mudança 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 de grafo na função.

Essas suposições podem ser adaptadas 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 destinos válidos, sob restrições opcionais, como limites de comprimento do caminho, tamanho máximo de saída etc. A saída é uma lista de caminhos descobertos com IDs de origem e de destino, bem como uma lista de bordas e nós de conexão. A função usa apenas os campos necessá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 em dados de entrada, eles podem ser adicionados à lógica e à saída alterando a definição da função.

Sintaxe

edgesTableName, , nodesTableName, scopeColumnName, isValidPathStartColumnName, isValidPathEndColumnName, sourceIdColumnName, edgeIdColumnName, sourceIdColumnName, targetIdColumnName, [minPathLength], [maxPathLength], [resultCountLimit]

Saiba mais sobre convenções de sintaxe.

Parâmetros

Nome Tipo Necessário Descrição
edgesTableName string ✔️ O nome da tabela de entrada que contém as bordas do grafo.
nodesTableName string ✔️ O nome da tabela de entrada que contém os nós do grafo.
scopeColumnName string ✔️ O nome da coluna em nós e tabelas de bordas que contêm a partição ou o escopo (por exemplo, assinatura ou conta), de modo que um modelo de anomalias diferente seja criado para cada escopo.
isValidPathStartColumnName string ✔️ O nome da coluna na tabela de nós que contém um sinalizador booliano para um nó, True significando 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 booliano para um nó, True significando que o nó é um ponto de extremidade 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 a ID de borda.
sourceIdColumnName string ✔️ O nome da coluna na tabela de bordas que contém a 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 inserindo 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:

  • definidos por consulta
  • Armazenados

Defina a função usando a instrução let a seguir. Nenhuma permissão é necessária.

Importante

Uma instrução não pode ser executada por conta própria. Ele deve ser seguido por uma instrução de expressão tabular . Para executar um exemplo de trabalho 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 de invocação para executar a função.

  • definidos por consulta
  • Armazenados

Para usar uma função definida por consulta, invoque-a após a definição da função inserida.

executar o de consulta

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
);
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
};
graph_path_discovery_fl(edgesTableName          = 'edges'
                , nodesTableName                = 'nodesEnriched'
                , scopeColumnName               = 'Region'
                , nodeIdColumnName              = 'NodeName'
                , edgeIdColumnName              = 'EdgeName'
                , sourceIdColumnName            = 'SourceNodeName'
                , targetIdColumnName            = 'TargetNodeName'
                , isValidPathStartColumnName    = 'IsValidStart'
                , isValidPathEndColumnName      = 'IsValidEnd'
)

de saída

código-fonte isSourceValidPathStart ID do alvo isTargetValidPathEnd âmbito edgeIds pathLength pathId pathAllNodeIds fullPath
test-machine Verdadeiro storage_DevBox Verdadeiro NOS ["e9","e10","e14","e16"] 4 00605d35b6e1d28024fd846f217b43ac ["test-machine","hub_router","remote_DT","backup_prc","storage_DevBox"] (test-machine)-[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 partida válidos (isSourceValidPathStart == True) para todos os destinos sinalizados como pontos de extremidade válidos (isTargetValidPathEnd == True). A saída é uma tabela em que 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 booliano para o 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 booliano para nó de destino sendo um final de caminho válido; deve ser sempre igual a True.
  • scope: o escopo que contém o caminho.
  • edgeIds: uma lista ordenada de bordas no caminho.
  • pathLength: o número de bordas (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 anterior, pré-processamos a tabela de nós e adicionamos várias opções de definições de ponto de extremidade possíveis. Ao comentar/descompactar diferentes opções, vários cenários podem ser descobertos:

  • Opção 1: localizar caminhos entre máquinas virtuais para recursos de armazenamento em nuvem. Útil na exploração de padrões de conexão entre tipos de nós.
  • Opção 2: localizar 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 os críticos conhecidos.
  • Opção 3: localizar 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 localizar todos os caminhos entre VMs para recursos de armazenamento em nuvem, que podem ser usados por potenciais invasores 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 que contêm dados confidenciais.

A função graph_path_discovery_fl() pode ser usada no domínio de segurança cibernética para descobrir caminhos interessantes, como caminhos de movimentação lateral, sobre dados modelados como um grafo.