Aprendizado de máquina na análise estática do código-fonte do programa

Aprendizado de máquina na análise estática do código-fonte do programa

O aprendizado de máquina está firmemente consolidado em uma variedade de campos humanos, desde o reconhecimento de fala até o diagnóstico médico. A popularidade dessa abordagem é tão grande que as pessoas tentam usá-la sempre que possível. Algumas tentativas de substituir abordagens clássicas por redes neurais acabam sendo malsucedidas. Desta vez, consideraremos o aprendizado de máquina em termos de criação de analisadores de código estático eficazes para encontrar bugs e possíveis vulnerabilidades.

Muitas vezes perguntam à equipe do PVS-Studio se queremos começar a usar o aprendizado de máquina para encontrar erros no código fonte do software. A resposta curta é sim, mas de forma limitada. Acreditamos que, com o aprendizado de máquina, existem muitas armadilhas ocultas nas tarefas de análise de código. Na segunda parte do artigo, falaremos sobre eles. Vamos começar com uma revisão de novas soluções e idéias.

Novas abordagens


Atualmente, existem muitos analisadores estáticos baseados em ou usando aprendizado de máquina, incluindo aprendizado profundo e PNL para detecção de erros. Não apenas os entusiastas dobraram o potencial de aprendizado de máquina, mas também as grandes empresas, por exemplo, Facebook, Amazon ou Mozilla. Alguns projetos não são analisadores estáticos completos, pois só encontram alguns erros em confirmações.

Curiosamente, quase todos eles estão posicionados como produtos que mudam o jogo, que farão uma inovação no processo de desenvolvimento devido à inteligência artificial.



Vejamos alguns dos exemplos conhecidos:

  1. Deepcode
  2. Infer, Sapienz, SapFix
  3. Embold
  4. Origem {d}
  5. Assistente inteligente de confirmação, confirmação
  6. CodeGuru

Deepcode


O Deep Code é uma ferramenta de pesquisa de vulnerabilidades para código de software Java, JavaScript, TypeScript e Python que apresenta o aprendizado de máquina como um componente. Segundo Boris Paskalev, mais de 250.000 regras já estão em vigor. Essa ferramenta aprende com as alterações feitas pelos desenvolvedores no código fonte dos projetos de código aberto (um milhão de repositórios). A própria empresa diz que seu projeto é algum tipo de gramática para desenvolvedores.



De fato, este analisador compara sua solução com sua base de projetos e oferece a melhor solução pretendida com a experiência de outros desenvolvedores.

Em maio de 2018, os desenvolvedores disseram que o suporte ao C ++ está a caminho, mas até agora, esse idioma não é suportado. Embora, conforme declarado no site, o suporte ao novo idioma possa ser adicionado em questão de semanas devido ao fato de o idioma depender apenas de um estágio, que está sendo analisado.





Uma série de postagens sobre métodos básicos do analisador também está disponível no site.

Inferir


O Facebook é bastante zeloso em suas tentativas de introduzir novas abordagens abrangentes em seus produtos. O aprendizado de máquina também não ficou à margem. Em 2013, eles compraram uma startup que desenvolveu um analisador estático baseado em aprendizado de máquina. E em 2015, o código fonte do projeto tornou-se aberto .

Infer é um analisador estático para projetos em Java, C, C ++ e Objective-C, desenvolvido pelo Facebook. Segundo o site, ele também é usado no Amazon Web Services, Oculus, Uber e outros projetos populares.

Atualmente, o Infer é capaz de encontrar erros relacionados à desreferenciação de ponteiro nulo e vazamentos de memória. Infer é baseado na lógica de Hoare, lógica de separação e bi-abdução, bem como na teoria da interpretação abstrata. O uso dessas abordagens permite ao analisador dividir o programa em partes e analisá-las independentemente.

Você pode tentar usar o Infer em seus projetos, mas os desenvolvedores alertam que, enquanto nos projetos do Facebook gera cerca de 80% dos avisos úteis, um número baixo de falsos positivos não é garantido em outros projetos. Aqui estão alguns erros que o Infer não conseguiu detectar até o momento, mas os desenvolvedores estão trabalhando na implementação desses avisos:

  • índice de matriz fora dos limites;
  • exceções de conversão de tipo;
  • vazamentos de dados não verificados;
  • condição de corrida.

Sapfix


SapFix é uma ferramenta de edição automatizada. Ele recebe informações da Sapienz, uma ferramenta de automação de testes e do analisador estático Infer. Com base em alterações e mensagens recentes, o Infer seleciona uma das várias estratégias para corrigir bugs.



Em alguns casos, o SapFix reverte todas as alterações ou partes delas. Em outros casos, ele tenta resolver o problema gerando um patch a partir de seu conjunto de padrões de correção. Esse conjunto é formado a partir de padrões de correções coletados pelos próprios programadores a partir de um conjunto de correções que já foram feitas. Se esse padrão não corrigir um erro, o SapFix tenta ajustá-lo à situação fazendo pequenas modificações em uma árvore de sintaxe abstrata até que a solução potencial seja encontrada.

Mas como uma solução em potencial não é suficiente, o SapFix coleta várias soluções com base em alguns pontos: se há erros de compilação, se ela falha ou se apresenta novas falhas. Depois que as edições são totalmente testadas, os patches são revisados ​​por um programador, que decide qual das edições melhor resolve o problema.

Embold


O Embold é uma plataforma de inicialização para análise estática do código-fonte do software chamado Gamma antes da renomeação. O analisador estático funciona com base nos próprios diagnósticos da ferramenta, além de usar analisadores incorporados, como Cppcheck, SpotBugs, SQL Check e outros.



Além dos próprios diagnósticos, a plataforma concentra-se em infográficos vívidos sobre a carga da base de código e na visualização conveniente dos erros encontrados, bem como na busca de uma possível refatoração. Além disso, este analisador possui um conjunto de antipadrões que permite detectar problemas na estrutura do código no nível de classe e método, além de várias métricas para calcular a qualidade de um sistema.



Uma das principais vantagens é o sistema inteligente de oferecer soluções e edições que, além do diagnóstico convencional, verifica edições com base em informações sobre alterações anteriores.



Com a PNL, o Embold divide o código e procura por interconexões e dependências entre funções e métodos, economizando tempo de refatoração.



Dessa forma, o Embold basicamente oferece uma visualização conveniente dos resultados da análise do código fonte por vários analisadores, bem como por seus próprios diagnósticos, alguns dos quais baseados no aprendizado de máquina.

Origem {d}


A fonte {d} é a ferramenta mais aberta em termos de formas de implementação, em comparação com os analisadores que analisamos. É também uma solução de código-fonte aberto . Em seu site, em troca do seu endereço de e-mail, você pode obter um folheto do produto descrevendo as tecnologias que eles usam. Além disso, o site fornece um link para o banco de dados de publicações relacionadas ao uso de aprendizado de máquina para análise de código, bem como ao repositório com conjunto de dados para aprendizado baseado em código. O produto em si é uma plataforma inteira para analisar o código-fonte e o produto de software, e não se concentra nos desenvolvedores, mas nos gerentes. Entre suas capacidades está o cálculo do tamanho da dívida técnica, gargalos no processo de desenvolvimento e outras estatísticas globais do projeto.



Sua abordagem para a análise de código através do aprendizado de máquina é baseada na Hipótese Natural, conforme descrito no artigo " Sobre a naturalidade do software ".

"As linguagens de programação, em teoria, são complexas, flexíveis e poderosas, mas os programas que as pessoas reais realmente escrevem são na maioria simples e bastante repetitivas e, portanto, possuem propriedades estatísticas previsíveis que podem ser capturadas em modelos de linguagem estatística e aproveitadas para engenharia de software" tarefas. ”

Com base nessa hipótese, quanto maior a base de código, maiores serão as propriedades estatísticas e mais precisas serão as métricas, obtidas através do aprendizado.

Para analisar o código na fonte {d}, é usado o serviço Babelfish, que pode analisar o arquivo de código em qualquer um dos idiomas disponíveis, obter uma árvore de sintaxe abstrata e convertê-lo em uma árvore de sintaxe universal.



No entanto, a fonte {d} não procura erros no código. Com base na árvore que usa o ML em todo o projeto, a fonte {d} detecta a formatação do código, o estilo aplicado no projeto e em uma confirmação. Se o novo código não corresponder ao estilo do código do projeto, ele fará algumas edições.





O aprendizado se concentra em vários elementos básicos: espaços, tabulação, quebras de linha etc.



Leia mais sobre isso em sua publicação: " STYLE-ANALYZER: corrigindo inconsistências de estilo de código com algoritmos não supervisionados interpretáveis ".

Em suma, o source {d} é uma ampla plataforma para coletar diversas estatísticas sobre o código-fonte e o processo de desenvolvimento do projeto: desde cálculos de eficiência dos desenvolvedores até custos de tempo para a revisão do código.

Confirmação inteligente


O Clever-Commit é um analisador criado pela Mozilla em colaboração com a Ubisoft. Ele é baseado em um estudo CLEVER (Combinando níveis de técnicas de prevenção e resolução de erros) da Ubisoft e seu produto filho, Commit Assistant, que detecta confirmações suspeitas com probabilidade de conter um erro. Como o CLEVER é baseado na comparação de códigos, ele pode apontar para um código perigoso e fazer sugestões para possíveis edições. De acordo com a descrição, em 60-70% dos casos, o Clever-Commit encontra locais problemáticos e oferece edições corretas com a mesma probabilidade. Em geral, há pouca informação sobre este projeto e sobre os erros que ele consegue encontrar.

CodeGuru


Recentemente, o CodeGuru, um produto da Amazon, se alinhou aos analisadores que usam o aprendizado de máquina. É um serviço de aprendizado de máquina que permite encontrar erros no código, bem como identificar áreas caras nele. A análise está disponível apenas para o código Java até agora, mas os autores prometem suportar outras linguagens no futuro. Embora tenha sido anunciado recentemente, Andy Jassy, ​​CEO da AWS (Amazon Web Services), diz que é usado na Amazon há muito tempo.

O site diz que o CodeGuru estava aprendendo na base de códigos da Amazon, bem como em mais de 10.000 projetos de código aberto.

Basicamente, o serviço é dividido em duas partes: CodeGuru Reviewer, ensinado usando a busca de regras associativas e procurando erros no código, e CodeGuru Profiler, monitorando o desempenho dos aplicativos.



Em geral, não há muita informação disponível sobre este projeto. Conforme o site afirma, o Revisor analisa as bases de código da Amazon e pesquisa solicitações pull, contendo as chamadas da API da AWS para aprender como detectar desvios das "melhores práticas". A seguir, ele analisa as alterações feitas e as compara aos dados da documentação, que é analisada ao mesmo tempo. O resultado é um modelo de "melhores práticas".

Também é dito que as recomendações para o código do usuário tendem a melhorar depois de receber feedback sobre eles.

A lista de erros aos quais o Revisor responde é bastante borrada, pois nenhuma documentação específica sobre erros foi publicada:

  • Práticas recomendadas da AWS
  • Concorrência
  • Vazamentos de recursos
  • Vazamento de informações confidenciais
  • "Práticas recomendadas" gerais de codificação

Nosso ceticismo


Agora, vamos considerar a pesquisa de erros do ponto de vista da nossa equipe, que desenvolve analisadores estáticos há muitos anos. Vemos vários problemas de alto nível na aplicação de métodos de aprendizado, que gostaríamos de abordar. Para começar, dividiremos todas as abordagens de ML em dois tipos:

  1. Aqueles que ensinam manualmente um analisador estático a procurar vários problemas, usando exemplos de códigos sintéticos e reais;
  2. Aqueles que ensinam algoritmos em um grande número de código-fonte aberto e histórico de revisões (GitHub), após o qual o analisador começará a detectar bugs e até a oferecer edições.

Falaremos sobre cada direção separadamente, pois elas têm diferentes inconvenientes. Depois disso, acho que os leitores entenderão por que não negamos as possibilidades de aprendizado de máquina, mas ainda não compartilhamos o entusiasmo.

Nota Nós olhamos da perspectiva do desenvolvimento de um analisador universal estático de uso geral. Nosso foco é desenvolver o analisador, que qualquer equipe poderá usar, e não aquele focado em uma base de código específica.

Ensino manual de um analisador estático


Digamos que queremos usar o ML para começar a procurar os seguintes tipos de falhas no código:

if (A == A) 

É estranho comparar uma variável consigo mesma. Podemos escrever muitos exemplos de código correto e incorreto e ensinar o analisador a procurar esses erros. Além disso, você pode adicionar exemplos reais de erros já encontrados nos testes. Bem, a questão é onde encontrar esses exemplos. Ok, vamos assumir que é possível. Por exemplo, temos vários exemplos desses erros: V501 , V3001 , V6001 .

Então, é possível identificar esses defeitos no código usando os algoritmos ML? É sim. A questão é - por que precisamos disso?

Veja, para ensinar ao analisador, precisamos gastar muito esforço na preparação dos exemplos para o ensino. Outra opção é marcar o código de aplicativos reais, indicando os fragmentos nos quais o analisador deve emitir um aviso. De qualquer forma, muito trabalho precisará ser feito, pois deve haver milhares de exemplos de aprendizado. Ou dezenas de milhares.

Afinal, queremos detectar não apenas (A == A) casos, mas também:

  • if (X e&A == A)
  • if (A + 1 == A + 1)
  • if (A [i] == A [i])
  • se ((A) == (A))
  • e assim por diante.


Vejamos a implementação potencial de um diagnóstico tão simples no PVS-Studio:

 void RulePrototype_V501(VivaWalker &walker, const Ptree *left, const Ptree *right, const Ptree *operation) { if (SafeEq(operation, "==") && SafeEqual(left, right)) { walker.AddError("Oh boy! Holy cow!", left, 501, Level_1, "CWE-571"); } } 

E é isso aí! Você não precisa de nenhuma base de exemplos para o ML!

No futuro, o diagnóstico precisará aprender a levar em consideração várias exceções e emitir avisos para (A [0] == A [1-1]). Como sabemos, pode ser facilmente programado. Pelo contrário, neste caso, as coisas vão ficar ruins com a base de exemplos.

Observe que, nos dois casos, precisaremos de um sistema de testes, documentação e assim por diante. Quanto à contribuição trabalhista na criação de um novo diagnóstico, a abordagem clássica, onde a regra é rigidamente programada no código, assume a liderança.

Ok, é hora de outra regra. Por exemplo, aquele em que o resultado de algumas funções deve ser usado. Não faz sentido chamá-los e não usar o resultado. Aqui estão algumas dessas funções:

  • malloc
  • memcmp
  • string :: vazio

É isso que o diagnóstico PVS-Studio V530 faz.

Então, o que queremos é detectar chamadas para essas funções, cujo resultado não é usado. Para fazer isso, você pode gerar muitos testes. E achamos que tudo vai funcionar bem. Mas, novamente, não está claro por que é necessário.

A implementação do diagnóstico V530, com todas as exceções, utilizou 258 linhas de código no analisador PVS-Studio, 64 das quais são comentários. Há também uma tabela com anotações de funções, onde é observado que o resultado deve ser usado. É muito mais fácil preencher essa tabela do que criar exemplos sintéticos.

As coisas vão piorar com os diagnósticos que usam a análise de fluxo de dados. Por exemplo, o analisador PVS-Studio pode rastrear o valor dos ponteiros, o que permite encontrar esse vazamento de memória:

 uint32_t* BnNew() { uint32_t* result = new uint32_t[kBigIntSize]; memset(result, 0, kBigIntSize * sizeof(uint32_t)); return result; } std::string AndroidRSAPublicKey(crypto::RSAPrivateKey* key) { .... uint32_t* n = BnNew(); .... RSAPublicKey pkey; pkey.len = kRSANumWords; pkey.exponent = 65537; // Fixed public exponent pkey.n0inv = 0 - ModInverse(n0, 0x100000000LL); if (pkey.n0inv == 0) return kDummyRSAPublicKey; // <= .... } 

O exemplo é retirado do artigo " Chromium: Memory Leaks ". Se a condição (pkey.n0inv == 0) for verdadeira, a função sai sem liberar o buffer, o ponteiro para o qual é armazenado na variável n .

Do ponto de vista do PVS-Studio, não há nada complicado aqui. O analisador estudou a função BnNew e lembrou que retornou um ponteiro para o bloco de memória alocado. Em outra função, percebeu que o buffer pode não liberar e o ponteiro para ele se perde no momento em que sai da função.

É um algoritmo comum de rastreamento de valores funcionando. Não importa como o código está escrito. Não importa o que mais há na função que não esteja relacionada ao trabalho do ponteiro. O algoritmo é universal e o diagnóstico V773 encontra muitos erros em vários projetos. Veja quão diferentes são os fragmentos de código com erros detectados!

Não somos especialistas em ML, mas temos a sensação de que grandes problemas estão chegando aqui. Há um número incrível de maneiras de escrever código com vazamentos de memória. Mesmo que a máquina aprendesse bem como rastrear valores de variáveis, seria necessário entender que também há chamadas para funções.

Suspeitamos que seja necessário tantos exemplos para aprender que a tarefa se torna inacessível. Não estamos dizendo que não é realista. Duvidamos que o custo de criação do analisador seja compensador.

Analogia O que me vem à cabeça é a analogia com uma calculadora, onde, em vez de diagnósticos, é preciso programar ações aritméticas. Temos certeza de que você pode ensinar uma calculadora baseada em ML a resumir bem os números, alimentando-a com os resultados das operações 1 + 1 = 2, 1 + 2 = 3, 2 + 1 = 3, 100 + 200 = 300 e assim por diante . Como você entende, a viabilidade de desenvolver uma calculadora desse tipo é uma grande questão (a menos que seja atribuída uma subvenção :). Uma calculadora muito mais simples, rápida, precisa e confiável pode ser escrita usando a operação simples "+" no código.

Conclusão Bem, desta maneira, funcionará. Mas usá-lo, em nossa opinião, não faz sentido prático. O desenvolvimento consumirá mais tempo, mas o resultado - menos confiável e preciso, especialmente quando se trata de implementar diagnósticos complexos com base na análise do fluxo de dados.

Aprendendo sobre grande quantidade de código-fonte aberto


Ok, resolvemos com exemplos sintéticos manuais, mas também há o GitHub. Você pode acompanhar o histórico de consolidação e deduzir os padrões de alteração / correção de código. Então você pode apontar não apenas para fragmentos de código suspeito, mas até sugerir uma maneira de corrigi-lo.

Se você parar nesse nível de detalhe, tudo ficará bem. O diabo, como sempre, está nos detalhes. Então, vamos falar bem sobre esses detalhes.

A primeira nuance. Fonte de dados.

As edições do GitHub são bastante aleatórias e diversas. As pessoas costumam ter preguiça de fazer confirmações atômicas e fazer várias edições no código ao mesmo tempo. Você sabe como isso acontece: você consertaria o bug e, ao mesmo tempo, o refatoraria um pouco ("E aqui vou acrescentar o tratamento de um caso como esse ..."). Mesmo uma pessoa pode então ser incompreensível, independentemente de esses fatores estarem relacionados ou não.

O desafio é como distinguir erros reais de adicionar novas funcionalidades ou outra coisa. Obviamente, é possível obter 1000 pessoas que marcarão manualmente os commits. As pessoas terão que apontar: aqui um erro foi corrigido, aqui está refatorando, aqui está uma nova funcionalidade, aqui os requisitos foram alterados e assim por diante.

Essa marcação é possível? Sim! Mas observe a rapidez com que a falsificação acontece. Em vez de "o algoritmo aprender a si próprio com base no GitHub", já estamos discutindo como confundir centenas de pessoas por um longo tempo. O trabalho e o custo de criação da ferramenta estão aumentando drasticamente.

Você pode tentar identificar automaticamente onde os bugs foram corrigidos. Para fazer isso, você deve analisar os comentários aos commits, prestar atenção às pequenas edições locais, que provavelmente são essas mesmas correções. É difícil dizer quão bem você pode procurar automaticamente por correções de erros. De qualquer forma, essa é uma grande tarefa que requer pesquisa e programação separadas.

Então, ainda nem aprendemos e já existem nuances :).

A segunda nuance. Um atraso no desenvolvimento.

Os analisadores que aprenderão com base nessas plataformas, como o GitHub, estarão sempre sujeitos a essa síndrome, como "atraso de retardo mental". Isso ocorre porque as linguagens de programação mudam com o tempo.

Desde o C # 8.0, existem tipos de referência nula, ajudando a combater as exceções de referência nula (NRE). No JDK 12, um novo operador de switch ( JEP 325 ) apareceu. No C ++ 17, existe a possibilidade de executar construções condicionais em tempo de compilação ( constexpr if ). E assim por diante

Linguagens de programação estão evoluindo. Além disso, como C ++, desenvolvem-se muito rapidamente. Novas construções aparecem, novas funções padrão são adicionadas e assim por diante. Juntamente com os novos recursos, há novos padrões de erro que também gostaríamos de identificar com a análise de código estática.

Neste ponto, o método ML enfrenta um problema: o padrão de erro já está claro, gostaríamos de detectá-lo, mas não há base de código para o aprendizado.

Vejamos esse problema usando um exemplo específico. O loop for baseado em intervalo apareceu no C ++ 11. Você pode escrever o seguinte código, percorrendo todos os elementos no contêiner:

 std::vector<int> numbers; .... for (int num : numbers) foo(num); 

O novo loop trouxe o novo padrão de erro. Se alterarmos o contêiner dentro do loop, isso resultará na invalidação de iteradores "shadow".

Vamos dar uma olhada no seguinte código incorreto:

 for (int num : numbers) { numbers.push_back(num * 2); } 

O compilador irá transformá-lo em algo como isto:

 for (auto __begin = begin(numbers), __end = end(numbers); __begin != __end; ++__begin) { int num = *__begin; numbers.push_back(num * 2); } 

Durante push_back , os iteradores __begin e __end podem ser invalidados, se a memória for realocada dentro do vetor. O resultado será o comportamento indefinido do programa.

Portanto, o padrão de erro já é conhecido e descrito na literatura. O analisador PVS-Studio o diagnostica com o diagnóstico V789 e já encontrou erros reais em projetos de código aberto.

Em quanto tempo o GitHub terá código novo o suficiente para perceber esse padrão? Boa pergunta ... É importante ter em mente que, se houver um loop for baseado em intervalo, isso não significa que todos os programadores começarão a usá-lo imediatamente de uma vez. Pode levar anos até que haja muito código usando o novo loop. Além disso, muitos erros devem ser cometidos e, em seguida, devem ser corrigidos para que o algoritmo possa observar o padrão nas edições.

Quantos anos levará? Cinco? Dez?

Dez é demais, ou é uma previsão pessimista? Longe disso. No momento em que o artigo foi escrito, fazia oito anos que um loop for baseado em intervalo apareceu em C ++ 11. Até agora, porém, em nosso banco de dados, existem apenas três casos desse erro. Três erros não são muitos e não são poucos. Não se deve tirar nenhuma conclusão desse número. O principal é confirmar que esse padrão de erro é real e faz sentido detectá-lo.

Agora compare esse número, por exemplo, com este padrão de erro: o ponteiro é desreferenciado antes da verificação . No total, já identificamos 1.716 casos ao verificar projetos de código aberto.

Talvez não devamos procurar erros nos loops baseados em intervalo? Não. Os programadores são inerciais e esse operador está se tornando popular muito lentamente. Gradualmente, haverá mais código e erros, respectivamente.

É provável que isso aconteça apenas 10 a 15 anos após o aparecimento do C ++ 11. Isso leva a uma questão filosófica. Suponhamos que já conheçamos o padrão de erro, esperaremos muitos anos até que tenhamos muitos erros em projetos de código aberto. Será que vai ser assim?

Se "sim", é seguro diagnosticar "atraso no desenvolvimento mental" para todos os analisadores baseados em ML.

Se "não", o que devemos fazer? Não há exemplos. Escreva-os manualmente? Mas, dessa maneira, voltamos ao capítulo anterior, onde fornecemos uma descrição detalhada da opção em que as pessoas escreviam um pacote inteiro de exemplos para aprender.

Isso pode ser feito, mas a questão da conveniência surge novamente. A implementação do diagnóstico V789 com todas as exceções no analisador PVS-Studio leva apenas 118 linhas de código, das quais 13 são comentários. Ou seja, é um diagnóstico muito simples, que pode ser facilmente programado de maneira clássica.

A situação será semelhante a qualquer outra inovação que apareça em outros idiomas. Como se costuma dizer, há algo em que pensar.

A terceira nuance. Documentação

Um componente importante de qualquer analisador estático é a documentação que descreve cada diagnóstico. Sem ele, será extremamente difícil ou impossível usar o analisador. Na documentação do PVS-Studio, temos uma descrição de cada diagnóstico, que fornece um exemplo de código incorreto e como corrigi-lo. Também fornecemos o link para o CWE , onde é possível ler uma descrição alternativa do problema. E, ainda assim, às vezes os usuários não entendem algo e nos fazem perguntas esclarecedoras.

No caso de analisadores estáticos baseados em ML, o problema da documentação é, de alguma forma, oculto. Supõe-se que o analisador simplesmente aponte para um local que lhe pareça suspeito e que possa até sugerir como consertá-lo. A decisão de fazer ou não uma edição depende da pessoa. É aí que o problema começa ... Não é fácil tomar uma decisão sem poder ler, o que faz o analisador parecer suspeito de um determinado local no código.

Obviamente, em alguns casos, tudo será óbvio. Suponha que o analisador aponte para este código:

 char *p = (char *)malloc(strlen(src + 1)); strcpy(p, src); 

E sugira que a substituamos por:

 char *p = (char *)malloc(strlen(src) + 1); strcpy(p, src); 

É imediatamente claro que o programador cometeu um erro de digitação e adicionou 1 no lugar errado. Como resultado, menos memória será alocada que o necessário.

Aqui está tudo claro, mesmo sem documentação. No entanto, esse nem sempre será o caso.

Imagine que o analisador "silenciosamente" aponte para este código:

 char check(const uint8 *hash_stage2) { .... return memcmp(hash_stage2, hash_stage2_reassured, SHA1_HASH_SIZE); } 

E sugere que alteremos o tipo de caractere do valor de retorno para int:

 int check(const uint8 *hash_stage2) { .... return memcmp(hash_stage2, hash_stage2_reassured, SHA1_HASH_SIZE); } 

Não há documentação para o aviso. Aparentemente, também não haverá texto na mensagem do aviso, se estivermos falando de um analisador completamente independente.

O que devemos fazer? Qual a diferença? Vale a pena fazer uma substituição?

Na verdade, eu poderia me arriscar e concordar em corrigir o código. Embora concordar com as correções sem entendê-las seja uma prática grosseira ... :) Você pode examinar a descrição da função memcmp e descobrir que a função realmente retorna valores como int : 0, mais que zero e menos que zero. Mas ainda não está claro por que fazer edições, se o código já estiver funcionando bem.

Agora, se você não souber qual é a edição, confira a descrição do diagnóstico da V642 . Torna-se imediatamente claro que este é um bug real. Além disso, pode causar uma vulnerabilidade.

Talvez o exemplo não parecesse convincente. Afinal, o analisador sugeriu um código que provavelmente seria melhor. Ok Vejamos outro exemplo de pseudocódigo, desta vez, para uma mudança, em Java.

 ObjectOutputStream out = new ObjectOutputStream(....); SerializedObject obj = new SerializedObject(); obj.state = 100; out.writeObject(obj); obj.state = 200; out.writeObject(obj); out.close(); 

Há um objeto. Está serializando. Em seguida, o estado do objeto muda e é serializado novamente. Parece bom. Agora imagine que, de repente, o analisador não gosta do código e deseja substituí-lo pelo seguinte:

 ObjectOutputStream out = new ObjectOutputStream(....); SerializedObject obj = new SerializedObject(); obj.state = 100; out.writeObject(obj); obj = new SerializedObject(); // The line is added obj.state = 200; out.writeObject(obj); out.close(); 

Em vez de alterar o objeto e reescrevê-lo, um novo objeto é criado e será serializado.

Não há descrição do problema. Nenhuma documentação. O código ficou mais longo. Por alguma razão, um novo objeto é criado. Você está pronto para fazer essa edição no seu código?

Você dirá que não está claro. Na verdade, é incompreensível. E será assim o tempo todo. Trabalhar com um analisador "silencioso" será um estudo interminável, na tentativa de entender por que o analisador não gosta de nada.

Se houver documentação, tudo se torna transparente. A classe java.io.ObjectOuputStream usada para serialização, armazena em cache os objetos gravados. Isso significa que o mesmo objeto não será serializado duas vezes. A classe serializa o objeto uma vez e, na segunda vez, apenas grava no fluxo uma referência ao mesmo primeiro objeto. Leia mais: V6076 - A serialização recorrente usará o estado do objeto em cache desde a primeira serialização.

Esperamos ter conseguido explicar a importância da documentação. Aí vem a pergunta. Como a documentação do analisador baseado em ML será exibida?

Quando um analisador de código clássico é desenvolvido, tudo é simples e claro. Há um padrão de erros. Nós o descrevemos na documentação e implementamos o diagnóstico.

No caso de ML, o processo é inverso. Sim, o analisador pode observar uma anomalia no código e apontar para ele. Mas não sabe nada sobre a essência do defeito. Ele não entende e não diz por que você não pode escrever um código assim. Essas são abstrações de nível muito alto. Dessa forma, o analisador também deve aprender a ler e entender a documentação das funções.

Como eu disse, como o problema da documentação é evitado nos artigos sobre aprendizado de máquina, não estamos prontos para nos aprofundar nisso. Apenas outra grande nuance que falamos.

Nota Você poderia argumentar que a documentação é opcional. O analisador pode se referir a muitos exemplos de correções no GitHub e a pessoa, examinando os commit e os comentários, entenderá o que é o quê. Sim é verdade. Mas a ideia não parece atraente. Aqui, o analisador é o cara mau, que mais confunde um programador do que o ajuda.

Quarta nuance. Idiomas altamente especializados.

A abordagem descrita não é aplicável a linguagens altamente especializadas, para as quais a análise estática também pode ser extremamente útil. O motivo é que o GitHub e outras fontes simplesmente não têm uma base de código fonte grande o suficiente para fornecer um aprendizado eficaz.

Vejamos isso usando um exemplo concreto. Primeiro, vamos ao GitHub e procuraremos repositórios para a popular linguagem Java.

Resultado: idioma: "Java": 3.128.884 resultados disponíveis do repositório

Agora pegue o idioma especializado "1C Enterprise" usado em aplicativos de contabilidade produzidos pela empresa russa 1C .

Resultado: idioma: “1C Enterprise”: 551 resultados disponíveis no repositório

Talvez os analisadores não sejam necessários para esse idioma? Não, eles são. Existe uma necessidade prática de analisar esses programas e já existem analisadores apropriados. Por exemplo, existe o plug-in SonarQube 1C (BSL), produzido pela empresa " Silver Bullet ".

Penso que não são necessárias explicações específicas sobre o motivo pelo qual a abordagem de ML será difícil para idiomas especializados.

A quinta nuance. C, C ++, #include .

Os artigos sobre análise de código estático baseados em ML são principalmente sobre linguagens como Java, JavaScript e Python. Isso é explicado por sua extrema popularidade. Quanto ao C e C ++, eles são meio ignorados, mesmo que você não possa chamá-los de impopulares.

Sugerimos que não se trata de sua popularidade / perspectiva promissora, mas de problemas com as linguagens C e C ++. E agora vamos trazer à tona um problema desconfortável.

Um arquivo c / cpp abstrato pode ser muito difícil de compilar. Pelo menos você não pode carregar um projeto no GitHub, escolha um arquivo cpp aleatório e apenas compile-o. Agora vamos explicar o que tudo isso tem a ver com o ML.

Então, queremos ensinar o analisador. Fizemos o download de um projeto no GitHub. Conhecemos o patch e assumimos que ele corrige o bug. Queremos que esta edição seja um exemplo de aprendizado. Em outras palavras, temos um arquivo .cpp antes e depois da edição.

É aí que o problema começa. Não basta apenas estudar as correções. Contexto completo também é necessário. Você precisa conhecer a declaração das classes usadas, os protótipos das funções usadas, você precisa saber como as macros se expandem e assim por diante. E para fazer isso, você precisa executar o pré-processamento de arquivo completo.

Vejamos o exemplo. No início, o código era assim:

 bool Class::IsMagicWord() { return m_name == "ML"; } 

Foi corrigido desta maneira:

 bool Class::IsMagicWord() { return strcmp(m_name, "ML") == 0; } 

O analisador deve começar a aprender para sugerir (x == "y") a substituição do forstrcmp (x, "y")?

Você não pode responder a essa pergunta sem saber como o membro m_name é declarado na classe. Pode haver, por exemplo, essas opções:

 class Class { .... char *m_name; }; class Class { .... std::string m_name; }; 

Edições serão feitas, caso falemos de um ponteiro comum. Se não levarmos em conta o tipo de variável, o analisador poderá aprender a emitir avisos bons e ruins (para o caso com std :: string ).

As declarações de classe geralmente estão localizadas nos arquivos de cabeçalho. Aqui havia a necessidade de executar o pré-processamento para obter todas as informações necessárias. É extremamente importante para C e C ++.

Se alguém disser que é possível fazer sem pré-processamento, ele é uma fraude ou não está familiarizado com as linguagens C ou C ++.

Para reunir todas as informações necessárias, você precisa do pré-processamento correto. Para fazer isso, você precisa saber onde e quais arquivos de cabeçalho estão localizados, quais macros são definidas durante o processo de criação. Você também precisa saber como um arquivo cpp específico é compilado.

Esse é o problema. Não basta compilar o arquivo (ou melhor, especificar a chave do compilador para gerar um arquivo de pré-processo). Precisamos descobrir como esse arquivo é compilado. Esta informação está nos scripts de construção, mas a questão é como obtê-la a partir daí. Em geral, a tarefa é complicada.



Além disso, muitos projetos no GitHub são uma bagunça. Se você pegar um projeto abstrato a partir daí, geralmente precisará mexer para compilá-lo. Um dia você não tem uma biblioteca e precisa encontrá-lo e baixá-lo manualmente. Outro dia, é utilizado algum tipo de sistema de criação auto-escrito, que precisa ser tratado. Poderia ser qualquer coisa. Às vezes, o projeto baixado simplesmente se recusa a criar e precisa ser ajustado de alguma forma. Você não pode simplesmente obter e obter automaticamente uma representação pré-processada (.i) para arquivos .cpp. Pode ser complicado, mesmo quando feito manualmente.

Podemos dizer, bem, que o problema com projetos que não são de construção é compreensível, mas não crucial. Vamos trabalhar apenas com projetos que podem ser construídos. Ainda há a tarefa de pré-processar um arquivo específico. Sem mencionar os casos em que lidamos com alguns compiladores especializados, por exemplo, para sistemas embarcados.

Afinal, o problema descrito não é intransponível. No entanto, tudo isso é muito difícil e exige muito trabalho. No caso de C e C ++, o código fonte localizado no GitHub não faz nada. Há muito trabalho a ser feito para aprender a executar automaticamente os compiladores.

Nota Se o leitor ainda não entender a profundidade do problema, convidamos você a participar da experiência a seguir. Pegue dez projetos aleatórios de tamanho médio do GitHub e tente compilá-los e obtenha a versão pré-processada para arquivos .cpp. Depois disso, a pergunta sobre o labor desta tarefa desaparecerá :).

Pode haver problemas semelhantes com outras linguagens, mas eles são particularmente óbvios em C e C ++.

Sexta nuance. O preço da eliminação de falsos positivos.

Os analisadores estáticos são propensos a gerar falsos positivos e precisamos refinar constantemente os diagnósticos para reduzir o número de falsos avisos.

Agora voltaremos ao diagnóstico V789 considerado anteriormente, detectando alterações de contêineres dentro do loop for baseado em intervalo. Digamos que não tenhamos cuidado o suficiente ao escrevê-lo, e o cliente relata um falso positivo. Ele escreve que o analisador não leva em consideração o cenário quando o loop termina depois que o contêiner é alterado e, portanto, não há problema. Em seguida, ele fornece o seguinte exemplo de código em que o analisador fornece um falso positivo:

 std::vector<int> numbers; .... for (int num : numbers) { if (num < 5) { numbers.push_back(0); break; // or, for example, return } } 

Sim, é uma falha. Em um analisador clássico, sua eliminação é extremamente rápida e barata. No PVS-Studio, a implementação desta exceção consiste em 26 linhas de código.

Essa falha também pode ser corrigida quando o analisador é construído sobre algoritmos de aprendizado. Com certeza, isso pode ser ensinado coletando dezenas ou centenas de exemplos de código que devem ser considerados corretos.

Novamente, a questão não está na viabilidade, mas na abordagem prática. Suspeitamos que a luta contra falsos positivos específicos, que incomodam os clientes, seja muito mais cara no caso de BC. Ou seja, o suporte ao cliente em termos de eliminação de falsos positivos custará mais dinheiro.

Sétima nuance. Recursos raramente usados ​​e cauda longa.

Anteriormente, lidamos com o problema de idiomas altamente especializados, para os quais talvez não seja o código-fonte suficiente para o aprendizado. Um problema semelhante ocorre com funções raramente usadas (do sistema, WinAPI, de bibliotecas populares etc.).

Se estamos falando sobre essas funções da linguagem C, como strcmp , existe realmente uma base para o aprendizado. GitHub, resultados de código disponíveis:

  • strcmp - 40.462.158
  • stricmp - 1.256.053

Sim, existem muitos exemplos de uso. Talvez o analisador aprenda a observar, por exemplo, os seguintes padrões:

  • É estranho se a string é comparada consigo mesma. É consertado.
  • É estranho se um dos ponteiros for NULL. É consertado.
  • É estranho que o resultado dessa função não seja usado. É consertado.
  • E assim por diante

Não é legal? Não. Aqui enfrentamos o problema da "cauda longa". Muito brevemente o ponto da "cauda longa" a seguir. Não é prático vender apenas o Top50 dos livros mais populares e agora lidos em uma livraria. Sim, cada um desses livros será comprado, digamos, 100 vezes mais frequentemente do que os livros que não estão nesta lista. No entanto, a maior parte da receita será composta por outros livros que, como dizem, encontram seu leitor. Por exemplo, uma loja online Amazon.com recebe mais da metade dos lucros do que está fora dos 130.000 "itens mais populares".

Existem funções populares e existem poucas. Existem impopulares, mas existem muitos. Por exemplo, existem as seguintes variações da função de comparação de cadeias:

  • g_ascii_strncasecmp - 35.695
  • lstrcmpiA - 27.512
  • _wcsicmp_l - 5,737
  • _strnicmp_l - 5.848
  • _mbscmp_l - 2.458
  • e outros

Como você pode ver, eles são usados ​​com muito menos frequência, mas quando você os usa, pode cometer os mesmos erros. Existem poucos exemplos para identificar padrões. No entanto, essas funções não podem ser ignoradas. Individualmente, eles raramente são usados, mas muitos códigos são escritos com o uso deles, o que é melhor verificar. É aí que a "cauda longa" se mostra.

No PVS-Studio, anotamos recursos manualmente. Por exemplo, no momento cerca de 7.200 funções foram anotadas para C e C ++. É isso que marcamos:

  • Winapi
  • Biblioteca C padrão,
  • Biblioteca de modelos padrão (STL),
  • glibc (Biblioteca GNU C)
  • Qt
  • Mfc
  • zlib
  • libpng
  • Openssl
  • e outros

Por um lado, parece um caminho sem saída. Você não pode anotar tudo. Por outro lado, funciona.

Agora aqui está a questão. Quais benefícios o ML pode ter? Vantagens significativas não são tão óbvias, mas você pode ver a complexidade.

Você pode argumentar que os algoritmos criados no ML encontrarão padrões com funções usadas com freqüência e eles não precisam ser anotados. Sim, é verdade. No entanto, não há problema em anotar independentemente funções populares como strcmp ou malloc .

No entanto, a cauda longa causa problemas. Você pode ensinar fazendo exemplos sintéticos. No entanto, voltamos à parte do artigo, onde dizíamos que era mais fácil e rápido escrever diagnósticos clássicos, em vez de gerar muitos exemplos.

Tomemos, por exemplo, uma função, como _fread_nolock . Obviamente, é usado com menos frequência do que o medo . Mas quando você o usa, pode cometer os mesmos erros. Por exemplo, o buffer deve ser grande o suficiente. Esse tamanho não deve ser menor que o resultado da multiplicação do segundo e terceiro argumento. Ou seja, você deseja encontrar esse código incorreto:

 int buffer[10]; size_t n = _fread_nolock(buffer, size_of(int), 100, stream); 

Aqui está a aparência da anotação dessa função no PVS-Studio:

 C_"size_t _fread_nolock" "(void * _DstBuf, size_t _ElementSize, size_t _Count, FILE * _File);" ADD(HAVE_STATE | RET_SKIP | F_MODIFY_PTR_1, nullptr, nullptr, "_fread_nolock", POINTER_1, BYTE_COUNT, COUNT, POINTER_2). Add_Read(from_2_3, to_return, buf_1). Add_DataSafetyStatusRelations(0, 3); 

À primeira vista, essa anotação pode parecer difícil, mas, na verdade, quando você começa a escrevê-la, torna-se simples. Além disso, é um código somente para gravação. Escreveu e esqueceu. As anotações mudam raramente.

Agora vamos falar sobre essa função do ponto de vista do ML. O GitHub não vai nos ajudar. Existem cerca de 15.000 menções a essa função. Existe ainda menos código bom. Uma parte significativa dos resultados da pesquisa inclui o seguinte:

 #define fread_unlocked _fread_nolock 

Quais são as opções?
  1. Não faça nada. É um caminho para lugar nenhum.
  2. Imagine, ensine o analisador escrevendo centenas de exemplos apenas para uma função, para que o analisador compreenda a interconexão entre o buffer e outros argumentos. Sim, você pode fazer isso, mas é economicamente irracional. É uma rua sem saída.
  3. Você pode criar uma maneira semelhante à nossa quando as anotações de funções serão definidas manualmente. É uma maneira boa e sensata. Isso é apenas ML, que não tem nada a ver com isso :). Este é um retrocesso à maneira clássica de escrever analisadores estáticos.

Como você pode ver, ML e a cauda longa dos recursos raramente usados ​​não combinam.

Nesse ponto, havia pessoas relacionadas à ML que se opuseram e disseram que não tínhamos levado em conta a opção quando o analisador aprenderia todas as funções e tiraria conclusões do que estava fazendo. Aqui, aparentemente, ou nós não entendemos os especialistas ou eles não entendem o que queremos dizer.

Corpos de funções podem ser desconhecidos. Por exemplo, poderia ser uma função relacionada ao WinAPI. Se essa é uma função raramente usada, como o analisador entenderá o que está fazendo? Podemos fantasiar que o analisador utilizará o próprio Google, encontre uma descrição da função, leia e entenda . Além disso, teria que tirar conclusões de alto nível da documentação. A descrição _fread_nolock não diz nada sobre a interconexão entre o buffer, o segundo e o terceiro argumento. Essa comparação deve ser deduzida por inteligência artificial por si só, com base no entendimento dos princípios gerais de programação e como a linguagem C ++ funciona. Acho que deveríamos pensar seriamente em 20 anos.

Corpos de funções podem estar disponíveis, mas pode não haver utilidade disso. Vejamos uma função, como memmove . É frequentemente implementado em algo como isto:

 void *memmove (void *dest, const void *src, size_t len) { return __builtin___memmove_chk(dest, src, len, __builtin_object_size(dest, 0)); } 

O que é __builtin___memmove_chk ? Essa é uma função intrínseca que o próprio compilador já está implementando. Esta função não possui o código fonte.

Ou memmove pode ser algo como isto: a primeira versão do assembly . Você pode ensinar o analisador a entender as diferentes opções de montagem, mas essa abordagem parece errada.

Ok, às vezes os corpos de funções são realmente conhecidos. Além disso, também sabemos corpos de funções no código do usuário. Parece que, neste caso, o ML obtém enormes vantagens ao ler e entender o que todas essas funções fazem.

No entanto, mesmo neste caso, estamos cheios de pessimismo. Esta tarefa é muito complexa. É complicado até para um humano. Pense em como é difícil para você entender o código que não escreveu. Se é difícil para uma pessoa, por que essa tarefa deve ser fácil para uma IA? Na verdade, a IA tem um grande problema na compreensão de conceitos de alto nível.Se estamos falando sobre a compreensão do código, não podemos prescindir da capacidade de abstrair dos detalhes da implementação e considerar o algoritmo em um nível alto. Parece que essa discussão também pode ser adiada por 20 anos.

Outras nuances

Há outros pontos que também devem ser levados em consideração, mas não nos aprofundamos neles. A propósito, o artigo acaba sendo bastante longo. Portanto, listaremos brevemente algumas outras nuances, deixando-as para reflexão do leitor.

  • Outdated recommendations. As mentioned, languages change, and recommendations for their use change, respectively. If the analyzer learns on old source code, it might start issuing outdated recommendations at some point. Example. Formerly, C++ programmers have been recommended using auto_ptr instead of half-done pointers. This smart pointer is now considered obsolete and it is recommended that you use unique_ptr .
  • Data models. At the very least, C and C++ languages have such a thing as a data model . This means that data types have different number of bits across platforms. If you don't take this into account, you can incorrectly teach the analyzer. For example, in Windows 32/64 the long type always has 32 bits. But in Linux, its size will vary and take 32/64 bits depending on the platform's number of bits. Without taking all this into account, the analyzer can learn to miscalculate the size of the types and structures it forms. But the types also align in different ways. All this, of course, can be taken into account. You can teach the analyzer to know about the size of the types, their alignment and mark the projects (indicate how they are building). However, all this is an additional complexity, which is not mentioned in the research articles.
  • Behavioral unambiguousness. Since we're talking about ML, the analysis result is more likely to have probabilistic nature. That is, sometimes the erroneous pattern will be recognized, and sometimes not, depending on how the code is written. From our experience, we know that the user is extremely irritated by the ambiguity of the analyzer's behavior. He wants to know exactly which pattern will be considered erroneous and which will not, and why. In the case of the classical analyzer developing approach, this problem is poorly expressed. Only sometimes we need to explain our clients why there is a/there is no analyzer warning and how the algorithm works, what exceptions are handled in it. Algorithms are clear and everything can always be easily explained. An example of this kind of communication: " False Positives in PVS-Studio: How Deep the Rabbit Hole Goes ". It's not clear how the described problem will be solved in the analyzers built on ML.

Conclusions


Não negamos as perspectivas da direção do ML, incluindo sua aplicação em termos de análise de código estático. O ML pode ser potencialmente usado em erros de localização de tarefas, ao filtrar falsos positivos, ao procurar novos padrões de erro (ainda não descritos) e assim por diante. No entanto, não compartilhamos o otimismo que permeia os artigos dedicados ao ML em termos de análise de código.

Neste artigo, destacamos alguns problemas nos quais um terá que ser resolvido se ele usar o ML. As nuances descritas negam amplamente os benefícios da nova abordagem. Além disso, as antigas abordagens clássicas da implementação de analisadores são mais lucrativas e economicamente viáveis.

Curiosamente, os artigos dos adeptos da metodologia ML não mencionam essas armadilhas. Bem, nada de novo. O ML provoca certo hype e provavelmente não devemos esperar uma avaliação equilibrada de seus apologistas em relação à aplicabilidade do ML em tarefas de análise de código estático.

Do nosso ponto de vista, o aprendizado de máquina irá preencher um nicho de tecnologias, usadas em analisadores estáticos, juntamente com a análise de fluxo de controle, execuções simbólicas e outras.

A metodologia da análise estática pode se beneficiar da introdução do ML, mas não exagera nas possibilidades dessa tecnologia.

PS


Como o artigo é geralmente crítico, alguns podem pensar que temos medo do novo e, como os luditas se voltaram contra o ML por medo de perder o mercado para ferramentas de análise estática.

Luddites


Não, não temos medo. Simplesmente não vemos sentido em gastar dinheiro com abordagens ineficientes no desenvolvimento do analisador de código PVS-Studio. De uma forma ou de outra, adotaremos o ML. Além disso, alguns diagnósticos já contêm elementos de algoritmos de autoaprendizagem. No entanto, seremos definitivamente muito conservadores e tomaremos apenas o que claramente terá um efeito maior do que as abordagens clássicas, baseadas em loops e ifs :). Afinal, precisamos criar uma ferramenta eficaz, e não trabalhar com uma concessão :).

O artigo foi escrito pelo motivo de mais e mais perguntas serem feitas sobre o assunto e queríamos ter um artigo expositivo que colocasse tudo em seu lugar.

Obrigado pela atenção. Convidamos você a ler o artigo "Por que você deve escolher o estático analisador PVS-Studio para integrar-se ao seu processo de desenvolvimento . "

Source: https://habr.com/ru/post/pt484202/


All Articles