Машинное обучение прочно вошло в самые разные человеческие области, от распознавания речи до медицинской диагностики. Популярность этого подхода настолько велика, что люди стараются использовать его везде, где только можно. Некоторые попытки заменить классические подходы нейронными сетями оказались безуспешными. На этот раз мы рассмотрим машинное обучение с точки зрения создания эффективных статических анализаторов кода для поиска ошибок и потенциальных уязвимостей.

Команду PVS-Studio часто спрашивают, хотим ли мы начать использовать машинное обучение для поиска ошибок в исходном коде программного обеспечения. Короткий ответ - да, но в ограниченной степени. Мы считаем, что с машинным обучением в задачах анализа кода таится много подводных камней. Во второй части статьи мы расскажем о них. Начнем с обзора новых решений и идей.

Новые подходы

В настоящее время существует множество статических анализаторов, основанных или использующих машинное обучение, включая глубокое обучение и NLP для обнаружения ошибок. Удвоили потенциал машинного обучения не только энтузиасты, но и крупные компании, например Facebook, Amazon или Mozilla. Некоторые проекты не являются полноценными статическими анализаторами, так как они находят только определенные ошибки в коммитах.

Интересно, что почти все они позиционируются как продукты, которые изменят правила игры, которые сделают прорыв в процессе разработки за счет искусственного интеллекта.

Давайте посмотрим на некоторые из хорошо известных примеров:

  • DeepCode
  • Инфер, Сапиенц, СапФикс
  • Вышивать
  • Источник {d}
  • Умная фиксация, помощник по фиксации
  • CodeGuru

DeepCode

Deep Code - это инструмент поиска уязвимостей для программного кода Java, JavaScript, TypeScript и Python, который включает машинное обучение в качестве компонента. По словам Бориса Паскалева, уже действует более 250 000 правил. Этот инструмент учится на изменениях, внесенных разработчиками в исходный код проектов с открытым исходным кодом (миллион репозиториев). Сама компания заявляет, что их проект - это своего рода Grammarly для разработчиков.

Фактически, этот анализатор сравнивает ваше решение с базой проекта и предлагает вам задуманное лучшее решение, основанное на опыте других разработчиков.

В мае 2018 года разработчики заявили, что поддержка C ++ находится на подходе, но пока этот язык не поддерживается. Хотя, как заявлено на сайте, новую языковую поддержку можно добавить в течение нескольких недель из-за того, что язык зависит только от одного этапа - синтаксического анализа.

Также на сайте доступна серия постов об основных методах работы анализатора.

Сделать вывод

Facebook довольно усердно пытается внедрить новые комплексные подходы в свои продукты. Не осталось в стороне и машинное обучение. В 2013 году они купили стартап, который разработал статический анализатор на основе машинного обучения. А в 2015 году исходный код проекта стал открытым.

Infer - статический анализатор проектов на Java, C, C ++ и Objective-C, разработанный Facebook. Согласно сайту, он также используется в Amazon Web Services, Oculus, Uber и других популярных проектах.

В настоящее время Infer может находить ошибки, связанные с разыменованием нулевого указателя и утечками памяти. Infer основан на логике Хора, логике разделения и двойного похищения, а также на теории абстрактной интерпретации. Использование этих подходов позволяет анализатору разбивать программу на части и анализировать их независимо.

Вы можете попробовать использовать Infer в своих проектах, но разработчики предупреждают, что хотя в проектах Facebook он генерирует около 80% полезных предупреждений, небольшое количество ложных срабатываний не гарантируется в других проектах. Вот некоторые ошибки, которые Infer пока не может обнаружить, но разработчики работают над реализацией этих предупреждений:

  • индекс массива за пределами;
  • исключения при приведении типов;
  • непроверенные утечки данных;
  • состояние гонки.

SapFix

SapFix - это инструмент автоматического редактирования. Он получает информацию от Sapienz, инструмента автоматизации тестирования, и статического анализатора Infer. Основываясь на последних изменениях и сообщениях, Infer выбирает одну из нескольких стратегий исправления ошибок.

В некоторых случаях SapFix откатывает все изменения или их части. В других случаях он пытается решить проблему, генерируя патч из своего набора шаблонов исправлений. Этот набор формируется из шаблонов исправлений, собранных самими программистами из набора исправлений, которые уже были сделаны. Если такой шаблон не исправляет ошибку, SapFix пытается приспособить его к ситуации, внося небольшие изменения в абстрактное синтаксическое дерево, пока не будет найдено потенциальное решение.

Но одного потенциального решения недостаточно, поэтому SapFix собирает несколько решений на основании нескольких моментов: есть ли ошибки компиляции, происходит ли сбой, возникают ли новые сбои. После того, как правки полностью протестированы, исправления просматриваются программистом, который решает, какое из правок лучше всего решает проблему.

Вышивать

Embold - это стартовая платформа для статического анализа исходного кода программного обеспечения, которая до переименования называлась Gamma. Статический анализатор работает на основе собственной диагностики инструмента, а также с использованием встроенных анализаторов, таких как Cppcheck, SpotBugs, SQL Check и другие.

Помимо собственно диагностики, платформа ориентирована на яркую инфографику по загрузке кодовой базы и удобный просмотр найденных ошибок, а также поиск возможных рефакторингов. Кроме того, в этом анализаторе есть набор антипаттернов, позволяющий обнаруживать проблемы в структуре кода на уровне классов и методов, а также различные метрики для расчета качества системы.

Одним из основных преимуществ является интеллектуальная система предложений решений и правок, которая, помимо стандартной диагностики, проверяет правки на основе информации о предыдущих изменениях.

С помощью NLP Embold разбивает код на части и ищет взаимосвязи и зависимости между функциями и методами, экономя время на рефакторинг.

Таким образом, Embold в основном предлагает удобную визуализацию результатов анализа исходного кода с помощью различных анализаторов, а также с помощью собственной диагностики, некоторые из которых основаны на машинном обучении.

Источник {d}

Source {d} - наиболее открытый инструмент с точки зрения способов его реализации по сравнению с анализаторами, которые мы рассмотрели. Это также решение с открытым исходным кодом. На их веб-сайте вы можете получить в обмен на свой адрес электронной почты буклет с описанием используемых ими технологий. Кроме того, на сайте есть ссылка на базу публикаций, связанных с использованием машинного обучения для анализа кода, а также репозиторий с набором данных для обучения на основе кода. Сам продукт представляет собой целую платформу для анализа исходного кода и программного продукта и ориентирован не на разработчиков, а на менеджеров. Среди его возможностей - расчет размера технического долга, узких мест в процессе разработки и другая глобальная статистика по проекту.

Их подход к анализу кода посредством машинного обучения основан на естественной гипотезе, изложенной в статье О естественности программного обеспечения.

«Языки программирования, в теории, сложны, гибки и мощны, но программы, которые на самом деле пишут реальные люди, в основном просты и довольно часто повторяются, и, таким образом, они имеют полезные предсказуемые статистические свойства, которые могут быть зафиксированы в статистических языковых моделях и использованы для задач разработки программного обеспечения ».

Основываясь на этой гипотезе, чем больше кодовая база, тем лучше статистические свойства и тем точнее будут метрики, достигаемые посредством обучения.

Для анализа кода в исходном коде {d} используется сервис Babelfish, который может анализировать файл кода на любом из доступных языков, получать абстрактное синтаксическое дерево и преобразовывать его в универсальное синтаксическое дерево.

Однако источник {d} не ищет ошибок в коде. На основе дерева, использующего машинное обучение для всего проекта, источник {d} определяет форматирование кода, стиль, примененный в проекте и в фиксации. Если новый код не соответствует стилю кода проекта, он вносит некоторые изменения.

В процессе обучения основное внимание уделяется нескольким основным элементам: пробелам, табуляции, переносам строк и т. Д.

Подробнее об этом читайте в их публикации: STYLE-ANALYZER: устранение несоответствий стиля кода с помощью интерпретируемых неконтролируемых алгоритмов.

В общем, source {d} - это широкая платформа для сбора разнообразной статистики по исходному коду и процессу разработки проекта: от расчетов эффективности разработчиков до временных затрат на проверку кода.

Умная фиксация

Clever-Commit - это анализатор, созданный Mozilla в сотрудничестве с Ubisoft. Он основан на исследовании CLEVER (сочетание уровней предотвращения и устранения ошибок), проведенном Ubisoft и ее дочерним продуктом Commit Assistant, которое обнаруживает подозрительные коммиты, которые могут содержать ошибку. Поскольку CLEVER основан на сравнении кода, он может указывать как на опасный код, так и предлагать возможные изменения. Согласно описанию, в 60–70% случаев Clever-Commit с одинаковой вероятностью находит проблемные места и предлагает правильные правки. В общем, информации об этом проекте и об ошибках, которые он умеет находить, мало.

CodeGuru

Недавно CodeGuru, продукт от Amazon, стал одним из анализаторов, использующих машинное обучение. Это сервис машинного обучения, который позволяет находить ошибки в коде, а также выявлять в нем дорогостоящие области. Пока что анализ доступен только для Java-кода, но авторы обещают поддерживать и другие языки в будущем. Хотя об этом было объявлено совсем недавно, Энди Ясси, генеральный директор AWS (Amazon Web Services), говорит, что он уже давно используется в Amazon.

На веб-сайте говорится, что CodeGuru учился на базе кода Amazon, а также на более чем 10 000 проектов с открытым исходным кодом.

По сути, сервис разделен на две части: CodeGuru Reviewer, обучающий с помощью поиска ассоциативных правил и поиска ошибок в коде, и CodeGuru Profiler, отслеживающего производительность приложений.

В общем, информации об этом проекте мало. Как указано на веб-сайте, Reviewer анализирует базы кода Amazon и ищет запросы на вытягивание, содержащие вызовы API AWS, чтобы узнать, как выявлять отклонения от «лучших практик». Затем он просматривает внесенные изменения и сравнивает их с данными из документации, которые одновременно анализируются. В результате получилась модель «лучших практик».

Также говорят, что рекомендации для пользовательского кода имеют тенденцию улучшаться после получения отзывов о них.

Список ошибок, на которые реагирует Reviewer, довольно расплывчатый, поскольку документация по конкретной ошибке не опубликована:

  • «Лучшие практики» AWS
  • Параллелизм
  • Утечки ресурсов
  • Утечка конфиденциальной информации
  • Общие «лучшие практики» кодирования

Наш скептицизм

Теперь рассмотрим поиск ошибок с точки зрения нашей команды, которая много лет занимается разработкой статических анализаторов. Мы видим ряд высокоуровневых проблем применения методов обучения, которые мы хотели бы осветить. Для начала разделим все подходы к машинному обучению на два типа:

  • Те, которые вручную обучают статический анализатор поиску различных проблем на синтетических и реальных примерах кода;
  • Те, которые обучают алгоритмам на большом количестве открытого исходного кода и истории изменений (GitHub), после чего анализатор начнет обнаруживать ошибки и даже предлагать правки.

О каждом направлении поговорим отдельно, так как у них разные недостатки. Думаю, после этого читатели поймут, почему мы не отрицаем возможности машинного обучения, но все же не разделяем энтузиазма.

Примечание. Мы смотрим с точки зрения разработки универсального статического анализатора общего назначения. Мы сосредоточены на разработке анализатора, который сможет использовать любая команда, а не ориентированного на конкретную кодовую базу.

Ручное обучение статического анализатора

Допустим, мы хотим использовать машинное обучение, чтобы начать поиск следующих недостатков в коде:

if (A == A)

Странно сравнивать переменную с самой собой. Мы можем написать множество примеров правильного и неправильного кода и научить анализатор искать такие ошибки. Дополнительно вы можете добавить в тесты реальные примеры уже найденных ошибок. Что ж, вопрос в том, где найти такие примеры. Хорошо, допустим, это возможно. Например, у нас есть ряд примеров таких ошибок: V501, V3001, V6001.

Так можно ли выявить такие дефекты в коде с помощью алгоритмов машинного обучения? Да, это. Дело в том, зачем оно нам?

Видите ли, чтобы научить анализатор, нам нужно потратить много усилий на подготовку примеров для обучения. Другой вариант - пометить код реальных приложений с указанием фрагментов, на которых анализатор должен выдавать предупреждение. В любом случае необходимо будет проделать большую работу, так как примеров для обучения должны быть тысячи. Или десятки тысяч.

В конце концов, мы хотим обнаруживать не только (A == A) случаи, но также:

  • если (X && A == A)
  • if (A + 1 == A + 1)
  • if (A[i] == A[i])
  • if ((A) == (A))
  • и так далее.

Посмотрим на возможную реализацию такой простой диагностики в 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");
  }
}

И это все! Вам не нужна база примеров для машинного обучения!

В будущем диагностика должна научиться учитывать ряд исключений и выдавать предупреждения для (A [0] == A [1–1]). Как известно, его легко запрограммировать. Напротив, в этом случае с базой примеров будет плохо.

Учтите, что в обоих случаях нам понадобится система тестирования, документация и так далее. Что касается трудозатрат на создание новой диагностики, то здесь лидирует классический подход, когда правило жестко запрограммировано в коде.

Хорошо, пришло время для другого правила. Например, тот, где нужно использовать результат каких-то функций. Нет смысла звонить им и не использовать их результат. Вот некоторые из таких функций:

  • маллок
  • memcmp
  • строка :: пусто

Именно этим занимается диагностика PVS-Studio V530.

Итак, мы хотим обнаруживать вызовы таких функций, результат которых не используется. Для этого вы можете сгенерировать множество тестов. И мы думаем, что все будет хорошо. Но опять же непонятно, зачем это нужно.

Реализация диагностики V530 со всеми исключениями заняла 258 строк кода в анализаторе PVS-Studio, 64 из которых являются комментариями. Также есть таблица с аннотациями функций, где указано, что их результат должен использоваться. Пополнить эту таблицу намного проще, чем создавать синтетические примеры.

Еще хуже обстоит дело с диагностикой, использующей анализ потока данных. Например, анализатор PVS-Studio может отслеживать значение указателей, что позволяет найти такую ​​утечку памяти:

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;   // <=
  ....
}

Пример взят из статьи Хром: утечки памяти. Если условие (pkey.n0inv == 0) истинно, функция завершается без освобождения буфера, указатель на который хранится в переменной n.

С точки зрения PVS-Studio ничего сложного здесь нет. Анализатор изучил функцию BnNew и вспомнил, что она вернула указатель на выделенный блок памяти. В другой функции он заметил, что буфер может не освободиться и указатель на него теряется в момент выхода из функции.

Это обычный алгоритм отслеживания ценностей. Неважно, как написан код. Не имеет значения, что еще в функции не имеет отношения к работе указателя. Алгоритм универсален и диагностика V773 выявляет множество ошибок в различных проектах. Посмотрите, насколько разные фрагменты кода с обнаруженными ошибками!

Мы не являемся экспертами в области машинного обучения, но у нас есть ощущение, что здесь уже не за горами большие проблемы. Существует невероятное количество способов написать код с утечками памяти. Даже если бы машина хорошо научилась отслеживать значения переменных, ей нужно было бы понимать, что существуют и вызовы функций.

Мы подозреваем, что для обучения потребуется так много примеров, что задача становится непостижимой. Мы не говорим, что это нереально. Мы сомневаемся, что затраты на создание анализатора окупятся.

Аналогия. Мне приходит в голову аналогия с калькулятором, где вместо диагностики нужно программировать арифметические действия. Мы уверены, что вы можете научить калькулятор на основе ML хорошо суммировать числа, передав ему результаты операций 1 + 1 = 2, 1 + 2 = 3, 2 + 1 = 3, 100 + 200 = 300 и т. Д. . Как вы понимаете, возможность разработки такого калькулятора - большой вопрос (если только не будет выделен грант :). С помощью простой операции «+» в коде можно написать гораздо более простой, быстрый, точный и надежный калькулятор.

Заключение Что ж, так получится. Но использовать его, на наш взгляд, не имеет практического смысла. Разработка займет больше времени, но результат - менее надежен и точен, особенно когда речь идет о внедрении сложной диагностики на основе анализа потока данных.

Изучение большого количества открытого исходного кода

Хорошо, мы разобрались с ручными синтетическими примерами, но есть еще GitHub. Вы можете отслеживать историю коммитов и выявлять закономерности изменения / исправления кода. Тогда вы сможете не только указать на фрагменты подозрительного кода, но даже предложить способ исправить код.

Если остановиться на этом уровне детализации, все выглядит хорошо. Дьявол, как всегда, кроется в деталях. Итак, давайте поговорим об этих деталях.

Первый нюанс. Источник данных.

Правки GitHub довольно случайны и разнообразны. Люди часто ленивы делать атомарные коммиты и вносить несколько правок в код одновременно. Вы знаете, как это происходит: вы исправляете ошибку, а заодно немного реорганизуете ее («А тут я добавлю обработку такого случая…»). Тогда даже человеку может быть непонятно, связаны ли эти фиксированные друг с другом или нет.

Проблема в том, как отличить реальные ошибки от добавления новых функций или чего-то еще. Вы, конечно, можете получить 1000 человек, которые будут вручную отмечать коммиты. Люди должны будут указать: здесь исправили ошибку, вот рефакторинг, вот какой-то новый функционал, вот требования изменились и так далее.

Возможна ли такая разметка? Ага! Но обратите внимание, как быстро происходит спуфинг. Вместо того, чтобы «алгоритм учится на базе GitHub», мы уже давно обсуждаем, как озадачить сотни людей. Работа и стоимость создания инструмента резко возрастают.

Вы можете попытаться автоматически определить, где были исправлены ошибки. Для этого вам следует проанализировать комментарии к коммитам, обратить внимание на небольшие локальные правки, которые, скорее всего, и являются исправлениями ошибок. Трудно сказать, насколько хорошо вы можете автоматически искать исправления ошибок. В любом случае это большая задача, требующая отдельного исследования и программирования.

Итак, нам еще даже не пришлось учиться, а нюансы уже есть :).

Второй нюанс. Отставание в развитии.

Анализаторы, которые будут учиться на таких платформах, как GitHub, всегда будут подвержены такому синдрому, как «задержка умственного развития». Это потому, что языки программирования со временем меняются.

Начиная с C # 8.0, появились ссылочные типы, допускающие значение NULL, что помогает бороться с исключениями по нулевым ссылкам (NRE). В JDK 12 появился новый оператор переключения (JEP 325). В C ++ 17 есть возможность выполнять условные конструкции во время компиляции (constexpr if). И так далее.

Языки программирования развиваются. Более того, такие, как C ++, очень быстро развиваются. Появляются новые конструкции, добавляются новые стандартные функции и так далее. Наряду с новыми функциями есть новые шаблоны ошибок, которые мы также хотели бы идентифицировать с помощью статического анализа кода.

На этом этапе метод машинного обучения сталкивается с проблемой: шаблон ошибки уже ясен, мы хотели бы его обнаружить, но кодовой базы для обучения нет.

Давайте рассмотрим эту проблему на конкретном примере. Цикл for на основе диапазона появился в C ++ 11. Вы можете написать следующий код, проходящий по всем элементам в контейнере:

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

Новый цикл принес с собой новый паттерн ошибки. Если мы изменим контейнер внутри цикла, это приведет к недействительности «теневых» итераторов.

Давайте посмотрим на следующий неправильный код:

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

Компилятор превратит это примерно так:

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

Во время push_back, итераторы __begin и __end могут стать недействительными, если память перемещена внутри вектора. Результатом будет неопределенное поведение программы.

Таким образом, характер ошибок давно известен и описан в литературе. Анализатор PVS-Studio диагностирует это с помощью диагностики V789 и уже обнаружил настоящие ошибки в open source проектах.

Как скоро GitHub получит достаточно нового кода, чтобы заметить эту закономерность? Хороший вопрос ... Важно помнить, что наличие цикла for на основе диапазона не означает, что все программисты сразу начнут его использовать. Могут пройти годы, прежде чем новый цикл будет использоваться в большом количестве кода. Более того, необходимо сделать много ошибок, а затем их нужно исправить, чтобы алгоритм мог заметить закономерность в правках.

Сколько на это уйдет лет? Пять? Десять?

Десять - это слишком много, или это пессимистический прогноз? Отнюдь не. К моменту написания статьи прошло восемь лет с тех пор, как цикл for на основе диапазона появился в C ++ 11. Но пока в нашей базе данных всего три случая такой ошибки. Три ошибки - это не много и не мало. Из этого числа не следует делать никаких выводов. Главное - подтвердить, что такая картина ошибки реальна и есть смысл ее обнаруживать.

Теперь сравните это число, например, с этим шаблоном ошибки: перед проверкой разыменовывается указатель. Всего при проверке open-source проектов мы уже выявили 1716 таких случаев.

Может, вообще не стоит искать ошибки в циклах for на основе диапазона? Нет. Просто программисты инерционны, и этот оператор очень медленно набирает популярность. Постепенно и кода с ним будет больше, и ошибок соответственно.

Вероятно, это произойдет всего через 10–15 лет после появления C ++ 11. Это приводит к философскому вопросу. Предположим, мы уже знаем образец ошибки, мы просто подождем много лет, пока у нас не появится много ошибок в проектах с открытым исходным кодом. Так и будет?

Если «да», можно безопасно диагностировать «задержку умственного развития» для всех анализаторов на основе машинного обучения.

Если «нет», что нам делать? Нет примеров. Напишите их вручную? Но таким образом мы возвращаемся к предыдущей главе, где мы подробно описали вариант, когда люди будут писать целый пакет примеров для обучения.

Это можно сделать, но снова возникает вопрос о целесообразности. Реализация диагностики V789 за всеми исключениями в анализаторе PVS-Studio занимает всего 118 строк кода, из которых 13 строк являются комментариями. То есть это очень простая диагностика, которую легко запрограммировать классическим способом.

Ситуация будет аналогична любым другим нововведениям, которые появятся на любых других языках. Как говорится, есть над чем подумать.

Третий нюанс. Документация.

Важной составляющей любого статического анализатора является документация, описывающая каждую диагностику. Без него пользоваться анализатором будет крайне сложно или невозможно. В документации PVS-Studio есть описание каждой диагностики, в котором приводится пример ошибочного кода и способы его устранения. Также даем ссылку на CWE, где можно прочитать альтернативное описание проблемы. И все же иногда пользователи чего-то не понимают и задают нам уточняющие вопросы.

В случае статических анализаторов на основе машинного обучения проблема документации как-то замалчивается. Предполагается, что анализатор просто укажет на место, которое ему покажется подозрительным, и даже может подсказать, как это исправить. Решение о том, вносить или нет редактирование, зависит от человека. Вот тут и начинаются проблемы ... Нелегко принять решение, не умея читать, что заставляет анализатор с подозрением относиться к определенному месту в коде.

Конечно, в некоторых случаях все будет очевидно. Допустим, анализатор указывает на этот код:

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

И предлагаем заменить его на:

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

Сразу видно, что программист допустил опечатку и добавил 1 не туда. В результате будет выделено меньше памяти, чем необходимо.

Здесь все понятно и без документации. Однако так будет не всегда.

Представьте, что анализатор «молча» указывает на этот код:

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

И предлагает изменить тип char возвращаемого значения для int:

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

Для предупреждения нет документации. Судя по всему, в предупреждении тоже не будет текста, если речь идет о полностью независимом анализаторе.

Что нам следует сделать? Какая разница? Стоит ли делать такую ​​замену?

Собственно, я мог рискнуть и согласиться исправить код. Хотя соглашаться на исправления, не понимая их, - грубая практика… :) Вы можете заглянуть в описание функции memcmp и узнать, что функция действительно возвращает значения типа int : 0, больше нуля и меньше нуля. Но все равно может быть непонятно, зачем вносить правки, если код уже исправен.

Теперь, если вы не знаете, что это за редактирование, ознакомьтесь с описанием диагностики V642. Сразу становится понятно, что это настоящий баг. Более того, это может вызвать уязвимость.

Возможно, пример показался неубедительным. Ведь анализатор подсказал код, который наверняка будет лучше. В порядке. Давайте посмотрим на другой пример псевдокода, на этот раз для разнообразия, на Java.

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

Есть объект. Это сериализация. Затем состояние объекта изменяется, и он повторно сериализуется. Выглядит нормально. А теперь представьте, что анализатору вдруг не понравился код, и он хочет заменить его следующим:

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

Вместо изменения объекта и его перезаписи создается новый объект, который будет сериализован.

Нет описания проблемы. Нет документации. Код стал длиннее. По какой-то причине создается новый объект. Готовы ли вы внести такую ​​правку в свой код?

Вы скажете, что непонятно. Действительно, непонятно. И так будет всегда. Работа с таким «тихим» анализатором будет бесконечным исследованием в попытке понять, почему анализатору ничего не нравится.

Если есть документация, все становится прозрачным. Класс java.io.ObjectOuputStream, который используется для сериализации, кэширует записанные объекты. Это означает, что один и тот же объект не будет сериализован дважды. Класс сериализует объект один раз, а второй раз просто записывает в поток ссылку на тот же первый объект. Подробнее: V6076 - при повторной сериализации будет использоваться состояние кэшированного объекта из первой сериализации.

Надеемся, нам удалось объяснить важность документации. Возникает вопрос. Как появится документация на анализатор на основе ML?

Когда разрабатывается классический анализатор кода, все просто и понятно. Есть набор ошибок. Описываем в документации и проводим диагностику.

В случае ML процесс обратный. Да, анализатор может заметить аномалию в коде и указать на нее. Но он ничего не знает о сути дефекта. Он не понимает и не скажет вам, почему вы не можете писать такой код. Это слишком высокоуровневые абстракции. Таким образом, анализатор также должен научиться читать и понимать документацию по функциям.

Как я уже сказал, поскольку в статьях по машинному обучению проблема документации избегается, мы не готовы останавливаться на ней дальше. Еще один важный нюанс, о котором мы говорили.

Примечание. Вы можете возразить, что документация не обязательна. Анализатор может ссылаться на множество примеров исправлений на GitHub и человек, просматривая коммиты и комментарии к ним, поймет, что к чему. Да, это так. Но идея не выглядит привлекательной. Здесь анализатор - плохой чувак, который скорее озадачит программиста, чем поможет ему.

Четвертый нюанс. Узкоспециализированные языки.

Описанный подход неприменим к узкоспециализированным языкам, для которых статический анализ также может быть чрезвычайно полезен. Причина в том, что GitHub и другие источники просто не имеют достаточно большой базы исходного кода, чтобы обеспечить эффективное обучение.

Давайте посмотрим на это на конкретном примере. Во-первых, давайте зайдем на GitHub и поищем репозитории популярного языка Java.

Результат: язык: "Java": 3 128 884 доступных результатов репозитория

Теперь возьмем специализированный язык 1С Предприятие, используемый в бухгалтерских приложениях российской компании .

Результат: язык: «1С Предприятие»: 551 доступный репозиторий результатов

Может быть, для этого языка не нужны анализаторы? Нет, они являются. Есть практическая необходимость в анализе таких программ, и уже есть соответствующие анализаторы. Например, есть плагин SonarQube 1C (BSL) производства компании Серебряная пуля.

Я думаю, что не нужно никаких конкретных объяснений того, почему подход машинного обучения будет трудным для специализированных языков.

Пятый нюанс. C, C ++, # включить.

Статьи по статическому анализу кода на основе машинного обучения в основном посвящены таким языкам, как Java, JavaScript и Python. Объясняется это их чрезвычайной популярностью. Что касается C и C ++, то их как бы игнорируют, хотя их нельзя назвать непопулярными.

Мы полагаем, что дело не в их популярности / перспективности, а в проблемах языков C и C ++. А теперь мы выскажем одну неприятную проблему.

Абстрактный файл c / cpp может быть очень сложно скомпилировать. По крайней мере, вы не можете загрузить проект с GitHub, выбрать случайный файл cpp и просто скомпилировать его. Теперь мы объясним, какое отношение все это имеет к ML.

Итак, мы хотим научить анализатор. Скачали проект с GitHub. Мы знаем патч и предполагаем, что он исправляет ошибку. Мы хотим, чтобы это изменение было одним из примеров для обучения. Другими словами, у нас есть файл .cpp до и после редактирования.

Вот где начинается проблема. Недостаточно просто изучить исправления. Также требуется полный контекст. Вам нужно знать объявление используемых классов, вам нужно знать прототипы используемых функций, вам нужно знать, как расширяются макросы и так далее. А для этого нужно провести полную препроцессинг файла.

Давайте посмотрим на пример. Сначала код выглядел так:

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

Это было исправлено таким образом:

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

Следует ли анализатору начать обучение, чтобы предложить (x == «y») замену для strcmp (x, «y»)?

Вы не можете ответить на этот вопрос, не зная, как член m_name объявлен в классе. Возможны, например, такие варианты:

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

Правки будут внесены в том случае, если речь идет об обычном указателе. Если не учитывать тип переменной, анализатор может научиться выдавать как хорошие, так и плохие предупреждения (в случае с std :: string).

Объявления классов обычно находятся в файлах заголовков. Здесь возникла необходимость выполнить предварительную обработку, чтобы получить всю необходимую информацию. Это чрезвычайно важно для C и C ++.

Если кто-то говорит, что можно обойтись без предварительной обработки, он либо мошенник, либо просто не знаком с языками C или C ++.

Чтобы собрать всю необходимую информацию, нужна правильная предварительная обработка. Для этого нужно знать, где и какие файлы заголовков находятся, какие макросы задаются в процессе сборки. Вам также необходимо знать, как компилируется конкретный файл cpp.

Это проблема. Нельзя просто компилировать файл (или, скорее, указать ключ компилятору, чтобы он генерировал файл препроцессора). Нам нужно выяснить, как этот файл компилируется. Эта информация есть в скриптах сборки, но вопрос в том, как ее оттуда получить. В общем, задача сложная.

Более того, многие проекты на GitHub - беспорядок. Если вы берете оттуда абстрактный проект, вам часто придется возиться, чтобы скомпилировать его. Однажды у вас не будет библиотеки, и вам нужно будет найти и загрузить ее вручную. В другой раз используется какая-то самописная система сборки, с которой нужно разобраться. Это могло быть что угодно. Иногда загруженный проект просто отказывается собираться, и его нужно как-то подправить. Вы не можете просто взять и автоматически получить предварительно обработанное (.i) представление для файлов .cpp. Это может быть сложно, даже если делать это вручную.

Можно сказать, что проблема с проектами, не связанными со строительством, понятна, но не принципиальна. Давайте работать только с проектами, которые можно построить. По-прежнему существует задача предварительной обработки конкретного файла. Не говоря уже о тех случаях, когда мы имеем дело с некоторыми специализированными компиляторами, например, для встраиваемых систем.

Ведь описанная проблема не является непреодолимой. Однако все это очень сложно и трудозатратно. В случае C и C ++ исходный код, расположенный на GitHub, ничего не делает. Предстоит проделать большую работу, чтобы научиться автоматически запускать компиляторы.

Примечание. Если читатель все еще не понимает сути проблемы, мы приглашаем вас принять участие в следующем эксперименте. Возьмите десять случайных проектов среднего размера с GitHub и попробуйте скомпилировать их, а затем получить их предварительно обработанную версию для файлов .cpp. После этого отпадёт вопрос о трудоемкости этой задачи :).

Подобные проблемы могут быть и с другими языками, но они особенно очевидны в C и C ++.

Шестой нюанс. Цена устранения ложных срабатываний.

Статические анализаторы склонны генерировать ложные срабатывания, и нам необходимо постоянно совершенствовать диагностику, чтобы уменьшить количество ложных предупреждений.

Теперь вернемся к ранее рассмотренной диагностике V789, обнаруживающей изменения контейнера внутри цикла for на основе диапазона. Допустим, мы не были достаточно внимательны при написании, и клиент сообщает о ложном срабатывании. Пишет, что анализатор не учитывает сценарий, когда цикл заканчивается после смены контейнера, а значит, проблем нет. Затем он приводит следующий пример кода, в котором анализатор дает ложное срабатывание:

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

Да, это недостаток. В классическом анализаторе его устранение происходит чрезвычайно быстро и дешево. В PVS-Studio реализация этого исключения состоит из 26 строк кода.

Этот недостаток также можно исправить, если анализатор построен на алгоритмах обучения. Конечно, этому можно научить, собрав десятки или сотни примеров кода, который следует считать правильным.

Опять же, вопрос не в целесообразности, а в практическом подходе. Мы подозреваем, что борьба с определенными ложными срабатываниями, которые беспокоят клиентов, обходится гораздо дороже в случае ML. То есть поддержка клиентов в части устранения ложных срабатываний будет стоить больше денег.

Седьмой нюанс. Редко используемые функции и длинный хвост.

Раньше мы сталкивались с проблемой узкоспециализированных языков, для изучения которых может не хватать исходного кода. Аналогичная проблема возникает и с редко используемыми функциями (системными, WinAPI, из популярных библиотек и т. Д.).

Если мы говорим о таких функциях языка C, как strcmp, то на самом деле база для изучения есть. GitHub, доступные результаты кода:

  • strcmp - 40 462 158
  • stricmp - 1 256 053

Да, примеров использования много. Возможно, анализатор научится замечать, например, следующие закономерности:

  • Странно, если струна сравнивается сама с собой. Это исправляется.
  • Странно, если один из указателей имеет значение NULL. Это исправляется.
  • Странно, что результат этой функции не используется. Это исправляется.
  • И так далее.

Разве это не круто? Нет. Здесь мы сталкиваемся с проблемой «длинного хвоста». Очень кратко суть «длинного хвоста» в следующем. Нецелесообразно продавать в книжном магазине только 50 самых популярных и читаемых книг. Да, каждую такую ​​книгу будут покупать, скажем, в 100 раз чаще, чем книги не из этого списка. Однако большую часть выручки составят другие книги, которые, как говорится, найдут своего читателя. Например, интернет-магазин Amazon.com получает более половины прибыли от того, что выходит за рамки 130 000 «самых популярных товаров».

Есть популярные функции, а их немного. Есть непопулярные, но их много. Например, существуют следующие варианты функции сравнения строк:

  • g_ascii_strncasecmp - 35 695
  • lstrcmpiA - 27 512
  • _wcsicmp_l - 5 737
  • _strnicmp_l - 5 848
  • _mbscmp_l - 2 458
  • и другие.

Как видите, они используются гораздо реже, но при их использовании можно совершать те же ошибки. Слишком мало примеров для выявления закономерностей. Однако эти функции нельзя игнорировать. По отдельности они используются редко, но с их использованием написано много кода, который лучше проверить. Вот тут и проявляется «длинный хвост».

В PVS-Studio мы вручную аннотируем объекты. Например, к настоящему моменту аннотировано около 7200 функций для C и C ++. Вот что мы отмечаем:

  • WinAPI
  • Стандартная библиотека C,
  • Стандартная библиотека шаблонов (STL),
  • glibc (библиотека GNU C)
  • Qt
  • MFC
  • zlib
  • libpng
  • OpenSSL
  • и другие.

С одной стороны, это тупиковый путь. Аннотировать все нельзя. С другой стороны, это работает.

А теперь вопрос. Какие преимущества может иметь ML? Существенные преимущества не так очевидны, но вы можете увидеть сложность.

Вы можете возразить, что алгоритмы, построенные на ML, сами обнаруживают шаблоны с часто используемыми функциями, и их не нужно аннотировать. Да, это правда. Однако нет никаких проблем с независимым аннотированием таких популярных функций, как strcmp или malloc.

Тем не менее, длинный хвост вызывает проблемы. Вы можете учить на синтетических примерах. Однако здесь мы возвращаемся к той части статьи, где мы говорили, что проще и быстрее написать классическую диагностику, чем генерировать множество примеров.

Возьмем, к примеру, такую ​​функцию, как _fread_nolock. Конечно, он используется реже, чем fread. Но когда вы его используете, вы можете совершить те же ошибки. Например, буфер должен быть достаточно большим. Этот размер должен быть не меньше результата умножения второго и третьего аргумента. То есть вы хотите найти такой некорректный код:

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

Вот как выглядит аннотация этой функции в 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);

На первый взгляд такие аннотации могут показаться сложными, но на самом деле, когда вы начинаете их писать, они становятся простыми. Кроме того, это код только для записи. Написал и забыл. Аннотации меняются редко.

Теперь поговорим об этой функции с точки зрения машинного обучения. GitHub нам не поможет. Об этой функции упоминается около 15 000 раз. Хорошего кода еще меньше. Значительная часть результатов поиска занимает следующее:

#define fread_unlocked _fread_nolock

Какие есть варианты?

  • Ничего не делай. Это путь в никуда.
  • Только представьте, научите анализатор, написав сотни примеров только для одной функции, чтобы анализатор понимал взаимосвязь между буфером и другими аргументами. Да, можно, но это экономически нерационально. Это тупиковая улица.
  • Вы можете придумать способ, аналогичный нашему, когда аннотации к функциям будут выставляться вручную. Это хороший, разумный способ. Это всего лишь ML, не имеющее к этому никакого отношения :). Это возврат к классическому способу написания статических анализаторов.

Как видите, машинное обучение и длинный хвост редко используемых функций несовместимы.

На этом этапе были люди, связанные с машинным обучением, которые возражали и говорили, что мы не учли вариант, когда анализатор изучит все функции и сделает выводы о том, что они делают. Здесь, видимо, либо мы не понимаем экспертов, либо они не понимают нашей точки зрения.

Органы функций могут быть неизвестны. Например, это может быть функция, связанная с WinAPI. Если это редко используемая функция, как анализатор поймет, что он делает? Мы можем представить, что анализатор сам воспользуется Google, найдет описание функции, прочитает и поймет. Более того, из документации пришлось бы делать общие выводы. Описание _fread_nolock ничего не говорит о взаимосвязи между буфером, вторым и третьим аргументом. Это сравнение должно быть произведено искусственным интеллектом самостоятельно на основе понимания общих принципов программирования и того, как работает язык C ++. Я считаю, что через 20 лет мы должны серьезно задуматься обо всем этом.

Тела функций могут быть доступны, но толку от этого нет. Давайте посмотрим на функцию, например memmove. Часто это реализуется примерно так:

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

Что такое __builtin___memmove_chk? Это внутренняя функция, которую уже реализует сам компилятор. У этой функции нет исходного кода.

Или memmove может выглядеть примерно так: первая версия сборки. Можно научить анализатор разбираться в разных вариантах сборки, но такой подход кажется неправильным.

Хорошо, иногда тела функций действительно известны. Более того, мы также знаем тела функций в пользовательском коде. Казалось бы, в этом случае ML получает огромные преимущества, читая и понимая, что делают все эти функции.

Однако даже в этом случае мы полны пессимизма. Это слишком сложная задача. Это сложно даже для человека. Подумайте, как сложно вам понять код, который вы не писали. Если человеку сложно, то почему эта задача должна быть легкой для ИИ? На самом деле у ИИ есть большая проблема с пониманием концепций высокого уровня. Если мы говорим о понимании кода, мы не можем обойтись без умения абстрагироваться от деталей реализации и рассматривать алгоритм на высоком уровне. Кажется, что и это обсуждение можно отложить на 20 лет.

Прочие нюансы

Есть и другие моменты, которые также следует учитывать, но мы не вдавались в подробности. Кстати, статья получилась довольно длинной. Поэтому вкратце перечислим некоторые другие нюансы, оставив их на размышление читателю.

  • Устаревшие рекомендации. Как уже упоминалось, языки меняются, и, соответственно, меняются рекомендации по их использованию. Если анализатор учится на старом исходном коде, он может в какой-то момент начать выдавать устаревшие рекомендации. Пример. Раньше программистам на C ++ рекомендовалось использовать auto_ptr вместо незавершенных указателей. Этот интеллектуальный указатель теперь считается устаревшим, и рекомендуется использовать unique_ptr.
  • Модели данных. По крайней мере, в языках C и C ++ есть такая вещь, как модель данных. Это означает, что типы данных имеют разное количество бит на разных платформах. Если этого не учитывать, можно неправильно обучить анализатор. Например, в Windows 32/64 тип long всегда имеет 32 бита. Но в Linux его размер будет варьироваться и составлять 32/64 бит в зависимости от количества бит на платформе. Без учета всего этого анализатор может научиться просчитывать размеры формируемых им типов и структур. Но типы также совпадают по-разному. Все это, конечно, можно учесть. Вы можете научить анализатор знать размер типов, их расположение и отмечать проекты (указывать, как они строятся). Однако все это дополнительная сложность, о которой в исследовательских статьях не говорится.
  • Однозначность поведения. Поскольку мы говорим о машинном обучении, результат анализа, скорее всего, будет иметь вероятностный характер. То есть иногда ошибочный шаблон будет распознан, а иногда нет, в зависимости от того, как написан код. По своему опыту мы знаем, что пользователя крайне раздражает неоднозначность поведения анализатора. Он хочет точно знать, какой шаблон будет считаться ошибочным, а какой нет и почему. В случае подхода к разработке классического анализатора эта проблема выражена слабо. Только иногда нам нужно объяснять нашим клиентам, почему есть / нет предупреждение анализатора и как работает алгоритм, какие исключения в нем обрабатываются. Алгоритмы понятны и всегда все легко объяснимо. Пример такого общения: Ложные срабатывания в PVS-Studio: Как глубоко заходит кроличья нора. Непонятно, как описанная проблема будет решена в анализаторах, построенных на ML.

Выводы

Мы не отрицаем перспективность направления машинного обучения, в том числе его применение с точки зрения статического анализа кода. ML потенциально может использоваться в задачах поиска опечаток, при фильтрации ложных срабатываний, при поиске новых (еще не описанных) шаблонов ошибок и т. Д. Однако мы не разделяем оптимизма, который пронизывает статьи, посвященные машинному обучению с точки зрения анализа кода.

В этой статье мы очертили несколько проблем, над которыми придется поработать, если он собирается использовать машинное обучение. Описанные нюансы во многом сводят на нет преимущества нового подхода. Кроме того, старые классические подходы к реализации анализаторов более рентабельны и экономически целесообразны.

Интересно, что в статьях приверженцев методики машинного обучения эти подводные камни не упоминаются. Ну ничего нового. ML вызывает определенную шумиху и, вероятно, не стоит ожидать от его апологетов взвешенной оценки применимости ML в задачах статического анализа кода.

С нашей точки зрения, машинное обучение заполнит нишу в технологиях, используемых в статических анализаторах наряду с анализом потока управления, символьными выполнениями и другими.

Методология статического анализа может выиграть от внедрения машинного обучения, но не преувеличивайте возможности этой технологии.

P.S.

Поскольку статья в целом критическая, некоторые могут подумать, что мы опасаемся нового и, поскольку луддиты выступили против машинного обучения, опасаясь потерять рынок инструментов статического анализа.

Нет, мы не боимся. Мы просто не видим смысла тратить деньги на неэффективные подходы при разработке анализатора кода PVS-Studio. В той или иной форме мы будем применять ML. Более того, некоторые диагностики уже содержат элементы самообучающихся алгоритмов. Однако мы определенно будем очень консервативны и возьмем только то, что явно будет иметь больший эффект, чем классические подходы, построенные на циклах и «если» :). Ведь нам нужно создать действенный инструмент, а не отработать грант :).

Статья написана по той причине, что по теме задается все больше и больше вопросов, и мы хотели иметь пояснительную статью, которая расставит все по своим местам.

Спасибо за внимание. Предлагаем вам прочитать статью Почему стоит выбрать статический анализатор PVS-Studio для интеграции в процесс разработки.