Skip to main content

Lições aprendidas ao aprimorar a busca de texto completo na Snyk com Elasticsearch

Escrito por
Headshot of Sergey Vasilkov

Sergey Vasilkov

blog banner snyk preview early access

4 de novembro de 2021

0 minutos de leitura

Elasticsearch é um mecanismo de busca de código aberto bastante popular. Por oferecer velocidade em tempo real e uma API robusta, é uma escolha frequente entre desenvolvedores que precisam adicionar recursos de busca de texto completo aos projetos. Além de ser popular em geral, é também o mecanismo para o qual estamos migrando a funcionalidade de relatórios da Snyk para issues! Quando tudo estiver ajustado em issues, começaremos a usar o Elasticsearch em outras áreas de relatórios.

Painel de problemas da Snyk com vulnerabilidades de segurança, licenças, pontuações, identificadores CVE, projetos afetados e maturidade da exploração

Embora o Elasticsearch seja poderoso, ele também pode parecer complicado no início (a menos que você já tenha experiência com mecanismos de busca). Como aprendi bastante sobre essa ferramenta recentemente, enquanto a implementava na Snyk, achei que valia a pena compartilhar um pouco desse conhecimento com você — de desenvolvedor para desenvolvedor!

Às vezes, um analisador padrão não é suficiente

O Elasticsearch mantém a velocidade tratando dados diferentes de formas diferentes. Entre os vários tipos de campos, há os campos de texto — campos comuns para conteúdo textual (ou seja, strings). Para tornar pesquisáveis as informações armazenadas nesse campo, o Elasticsearch faz a análise do texto durante a ingestão, convertendo os dados em tokens (termos) e armazenando esses tokens e outras informações relevantes, como comprimento e posição, no índice. Por padrão, ele usa um analisador padrão para a análise de texto.

Da documentação oficial: “O analisador padrão oferece suporte imediato à maioria dos idiomas naturais e casos de uso. Se você optar por usar o analisador padrão sem alterações, não será necessária nenhuma configuração adicional.”

Em casos simples, a análise padrão pode ser suficiente. Vamos ver como ela funciona: com o comando a seguir, podemos conferir a análise gerada para qualquer texto que fornecermos:

POST _analyze
{
  "analyzer": "standard",
  "text": "Regular Expression Denial of Service (ReDoS)"
}

And the result will look like:

{
  "tokens" : [
    {
      "token" : "regular",
      "start_offset" : 0,
      "end_offset" : 7,
      "type" : "<ALPHANUM>",
      "position" : 0
    },
    {
      "token" : "expression",
      "start_offset" : 8,
      "end_offset" : 18,
      "type" : "<ALPHANUM>",
      "position" : 1
    },
    {
      "token" : "denial",
      "start_offset" : 19,
      "end_offset" : 25,
      "type" : "<ALPHANUM>",
      "position" : 2
    },
    {
      "token" : "of",
      "start_offset" : 26,
      "end_offset" : 28,
      "type" : "<ALPHANUM>",
      "position" : 3
    },
    {
      "token" : "service",
      "start_offset" : 29,
      "end_offset" : 36,
      "type" : "<ALPHANUM>",
      "position" : 4
    },
    {
      "token" : "redos",
      "start_offset" : 38,
      "end_offset" : 43,
      "type" : "<ALPHANUM>",
      "position" : 5
    }
  ]
}

Aqui, podemos ver exatamente o que o analisador padrão faz: ele divide o texto em tokens e gera informações de serviço para cada trecho, que são armazenadas no índice.

Pesquisando o texto indexado

Depois de indexado o conteúdo, podemos testar a busca de texto completo com um analisador padrão. Vamos criar um documento de exemplo com dois campos: title e description. O Elasticsearch mapeará o documento automaticamente e identificará os campos de texto.

PUT text-search-index/_doc/1
{
  "title": "Regular Expression Denial of Service (ReDoS)",
  "description": "The Regular expression Denial of Service (ReDoS) is a type of Denial of Service attack. Regular expressions are incredibly powerful, but they aren'\''t very intuitive and can ultimately end up making it easy for attackers to take your site down."
}

Agora, vamos pesquisar por “express” com a seguinte consulta:

GET text-search-index/_search
{
  "query": {
    "match": {
      "title": {
    "query": "express"
      }
    }
  }
}

Nenhum documento será retornado, embora “expression” esteja no título. Esse resultado é esperado (pelo menos para mim, já que já passei por isso!), pois o analisador não criou nenhum token “express” (como podemos ver nos tokens gerados que consultamos acima).

Se alterarmos a consulta de busca para “expression”, o documento será retornado:

GET text-search-index/_search
{
  "query": {
    "match": {
      "title": {
    "query": "expression"
      }
    }
  }
}

Há consultas mais custosas que podem contornar esse problema de correspondência de tokens, como as consultas fuzzy e wildcard, mas elas exigem muito poder computacional. Então, em vez de gastar ciclos de CPU com o problema, precisamos reavaliar nosso analisador.

Fica claro que um analisador padrão pode não ser suficiente se quisermos resultados de busca melhores e mais flexíveis. Para alcançar esse objetivo, vamos configurar um analisador personalizado. Estes serão nossos requisitos:

  • Usar consultas menores

  • Busca sem distinção entre maiúsculas e minúsculas

Para isso, os próximos passos serão:

  1. Criar um analisador personalizado

  2. Configurar um tokenizador mais adequado ao caso de uso

  3. Ativar o novo analisador personalizado para os campos escolhidos no índice do Elasticsearch

Analisadores em mais detalhes

Com o analisador padrão, facilitamos as coisas e não precisamos entender exatamente o que ele fazia. Agora que vamos criar um analisador personalizado, precisamos entender o que acontece durante a análise de texto:

  1. Os filtros de caracteres (opcionais) são aplicados ao texto analisado para remover caracteres.

  2. Um tokenizador divide o texto em tokens ou termos. Isso pode ser feito de diferentes formas, gerando tokens por espaços em branco, letras etc.

  3. Os filtros de tokens (opcionais) fazem alterações adicionais nos tokens, como convertê-los para minúsculas, remover tokens específicos e muito mais.

Um analisador é uma combinação de tokenizadores e filtros. Ele analisa o texto e o prepara para a busca. Todo analisador precisa ter um único tokenizador, mas pode ter poucos ou muitos filtros dos dois tipos. A ordem das operações é sempre a mesma: filtro de caracteres > tokenizador > filtro de tokens.

Criando nosso primeiro analisador personalizado

Como vimos antes, por padrão, todos os campos de texto usam o analisador padrão. O Elasticsearch oferece uma ampla variedade de analisadores integrados, que podem ser usados em qualquer índice sem configuração adicional, e também permite criar os seus próprios. Para este caso de uso, vamos precisar criar um.

Os analisadores podem ser configurados em diferentes níveis: índice, campo ou consulta. Vamos criar um analisador personalizado e defini-lo para um campo. Veja como configurar um analisador personalizado. Observação: as configurações são armazenadas nos ajustes do índice:

PUT text-search-index
{
  "settings":{
     "analysis":{
        "analyzer":{
           "my_analyzer":{
              "type":"custom",
              "tokenizer":"standard"
           }
        }
     }
  },
  "mappings":{
      "properties":{
         "title": {
            "type":"text",
            "analyzer":"my_analyzer"
         },
         "text": {
           "type": "text"
         }
      }
   }
}

Veja o que fizemos:

  1. Um novo analisador personalizado chamado my_analyzer é definido para o índice

  2. my_analyzer usa o tokenizador standard e o filtro lowercase

  3. my_analyzer foi ativado como analisador da propriedade title no mapeamento do índice

Este exemplo é bem simples e não altera o comportamento do analisador padrão. Agora podemos ajustá-lo para permitir buscas sem distinção entre maiúsculas e minúsculas usando palavras incompletas.

Escolha e configure um tokenizador melhor

No exemplo anterior, usamos o tokenizador padrão. Agora é hora de substituí-lo pelo edge_ngram, que divide os tokens de acordo com o comprimento (e não pelos espaços em branco). Se você está se perguntando por que estou usando edge_ngram em vez de stemmer, continue lendo!

...
"analysis":{
  "analyzer":{
    "my_analyzer":{
      "type":"custom",
      "tokenizer":"my_tokenizer"
    }
  },
  "tokenizer": {
    "my_tokenizer": {
      "type": "edge_ngram",
      "min": 3,
      "max": 8,
      "token_chars": ["letter", "digit"]
    }
  }
}
...

Veja o que fizemos:

  1. my_tokenizer usa o tokenizador edge_ngram

  2. Ele criará tokens com comprimento entre 3 e 8 caracteres

  3. Os tokens incluirão apenas letras e dígitos

Escolha e configure um filtro de tokens melhor

O analisador que criamos acima agora permite buscar palavras incompletas com 3 a 8 caracteres. Falta apenas permitir buscas sem distinção entre maiúsculas e minúsculas. Para isso, basta adicionar o filtro de tokens lowercase adequado a my_analyzer:

...
"analysis":{
  "analyzer":{
    "my_analyzer":{
      "type":"custom",
      "tokenizer":"my_tokenizer",
      "filter":[
        "lowercase"
      ]
    }
  },
  "tokenizer": {
    "my_tokenizer": {
      "type": "edge_ngram",
      "min": 3,
      "max": 8,
      "token_chars": ["letter", "digit"]
    }
  }
}
...

Resultado final

Depois de todas as alterações, já podemos criar um novo índice com um analisador personalizado. Vale lembrar que, antes de alterar o analisador, não é possível atualizar essa configuração em campos existentes usando a API de atualização de mapeamento.

Veja como ficarão as configurações e os mapeamentos do nosso índice de teste básico:

PUT text-search-index
{
  "settings":{
    "analysis":{
      "analyzer":{
        "my_analyzer":{
          "type":"custom",
          "tokenizer":"my_tokenizer",
          "filter":[
            "lowercase"
          ]
        }
      },
      "tokenizer": {
        "my_tokenizer": {
          "type": "edge_ngram",
          "min": 3,
          "max": 8,
          "token_chars": ["letter", "digit"]
        }
      }
    }
  },
  "mappings":{
    "properties":{
      "title": {
        "type":"text",
        "analyzer":"my_analyzer"
      },
      "text": {
        "type": "text"
      }
    }
  }
}

Agora podemos adicionar um documento e executar diferentes consultas para garantir que nossa busca funcione como esperado:

PUT text-search-index/_doc/1
{
  "title": "Regular Expression Denial of Service (ReDoS)",
  "description": "The Regular expression Denial of Service (ReDoS) is a type of Denial of Service attack. Regular expressions are incredibly powerful, but they aren'\''t very intuitive and can ultimately end up making it easy for attackers to take your site down."
}

Pesquise títulos que contenham “express” ou outros tokens incompletos, sem distinção entre maiúsculas e minúsculas:

GET text-search-index/_search
{
  "query": {
    "match": {
      "title": {
    "query": "express" // try: EXPRESS, exp, expression
      }
    }
  }
}

Observe que não configuramos um analisador personalizado para a propriedade de texto do nosso índice. Portanto, nesse campo, ainda temos a boa e velha busca com o analisador padrão.

E agora?

Neste artigo simples, configuramos um analisador personalizado para ter uma busca de texto mais flexível no Elasticsearch e abordamos algumas configurações básicas e específicas. O Elasticsearch oferece uma ampla variedade de analisadores, tokenizadores, filtros, normalizadores e configurações de stop words integrados, entre muitos outros recursos.

Combinando esses recursos, podemos obter ótimos resultados de busca personalizados. Se você está começando a usar o Elasticsearch, espero que este artigo tenha sido útil. Fique de olho, pois planejamos compartilhar mais sobre nossa jornada com buscas. O próximo assunto serão as agregações! (Pode contar com isso...)

Se você gostaria de fazer parte da Snyk e criar ótimas ferramentas de segurança para desenvolvedores, confira nossas vagas abertas em engenharia.

Leia mais

Blog

Modelos de ponta encontraram as vulnerabilidades. Só o atacante encontrou as cadeias.

A análise estática encontrou as falhas, mas só os testes de ataque em aplicações ativas provaram como elas poderiam ser encadeadas para causar invasões. Uma comparação entre Evo COS, Claude Security e Claude Code Security.

feature insights context
Blog

Os ataques autônomos já chegaram. A defesa precisa acompanhar o ritmo.

Os atacantes autônomos estão reduzindo o tempo disponível para a defesa. Saiba como a descoberta, a correção, a validação e a prevenção contínuas ajudam as equipes de segurança a acompanhar esse ritmo.

Blog

Por que agentes de programação com IA continuam criando falhas de controle de acesso

Agentes de programação com IA podem gerar uma lógica de autorização que compila e passa pela revisão, mas permite que um tenant acesse os dados de outro. Saiba por que é difícil detectar falhas de controle de acesso e como evitá-las.