Хабрахабр

Ищем баги в PHP коде без статических анализаторов

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

Пример гипотезы:

Функции strpos легко передать аргументы в неправильном порядке.

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

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

Вот уже несколько месяцев я занимаюсь поддержкой PHP линтера NoVerify (почитать о котором можно в статье NoVerify: линтер для PHP от Команды ВКонтакте).

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

Когда я узнал об утилите gogrep, мой мир перевернулся. Ранее я активно разрабатывал go-critic и ситуация была схожей, с той лишь разницей, что анализировались исходники на Go, а не на PHP. Как видно из названия, эта утилита имеет что-то общее с grep'ом, только поиск производится не по регулярным выражениям, а по синтаксическим шаблонам (позже объясню, что это значит).

Я уже не хотел жить без умного grep, поэтому в один вечер решил сесть и написать phpgrep.

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

В наш набор попали следующие проекты:

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

Итак, поехали!

Если присваивание используется как выражение, причём:

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

то, скорее всего, в коде ошибка.

Для начала, возьмём за "логический контекст" следующие конструкции:

  1. Выражение внутри "if ($cond)".
  2. Условие тернарного оператора: "$cond ? $x : $y".
  3. Условия продолжений циклов "while ($cond)" и "for ($init; $cond; $post)".

В правой части присваивания мы ожидаем константы или литералы.

Зачем на нужны такие ограничения?

Начнём с (1):

# утилита поиска по синтаксическим шаблонам
# | производим поиск по текущей директории (и всем дочерним)
# | |
# | |
phpgrep . 'if ($_ = []) $_' # 1
# | # | # строка шаблона поиска # Дополнительные 3 шаблона через отдельные запуски.
phpgrep . 'if ($_ = $) $_' # 2
phpgrep . 'if ($_ = ${"str"}) $_' # 3
phpgrep . 'if ($_ = ${"num"}) $_' # 4

Начнём с первого из них. Здесь мы видим 4 шаблона, единственным различием между которыми выступает присваиваемое выражение (RHS).

$_ сопоставляется с любым expression или statement. Шаблон "if ($_ = []) $_" захватывает if, у которого любому выражению присваивается пустой массив.

литерал пустого массива (RHS) |
if ($_ = []) $_ | | | любое тело if'а, причём не важно, с {} или без любой LHS у присваивания

В отличие от $_ они описывают ограничения на совместимые операции. В следующих примерах используются более сложные группы const, str и num.

  • const — именованная константа или константа класса.
  • str — строковой литерал любого типа.
  • num — числовой литерал любого типа.

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

⎆ moodle/blocks/rss_client/viewfeed.php#L37:

if ($courseid = SITEID) { $courseid = 0;
}

В upstream библиотеки проблема всё ещё присутствует. Вторым срабатыванием в moodle стала зависимость ADOdb.

⎆ ADOdb/drivers/adodb-odbtp.inc.php#L741:

Вместо сравнения поля databaseType мы выполняем присваивание и всегда входим внутрь условия. В этом фрагменте прекрасно многое, но для нас релевантна только первая строка.

Ещё одно интересное место, где мы хотим выполнять действия только для "корректных" записей, но, вместо этого, выполняем их всегда и, более того, отмечаем любую запись как корректную!

⎆ moodle/question/format/blackboard_six/formatqti.php#L598:

// For BB Fill in the Blank, only interested in correct answers.
if ($response->feedback = 'correct') { // ...
}

Расширенный список шаблонов для этой проверки

Повторим то, что мы изучили:

  • Шаблоны выглядят как PHP-код, который они находят.
  • $_ обозначает "что угодно". Можно сравнить с . в регулярных выражениях.
  • ${"<class>"} работает как $_ с ограничением на тип элементов AST.

Это значит, что шаблону "array(1, 2 + 3)" будет удовлетворять лишь идентичный по синтаксической структуре код (пробелы не влияют). Стоит ещё подчеркнуть, что всё, кроме переменных, сопоставляется дословно (literally). С другой стороны, шаблону "array($_, $_)" удовлетворяет любой литерал массива из двух элементов.

Это может быть проверка на NaN, но как минимум в половине случаев это ошибка copy/paste. Потребность сравнить что-либо с самим собой возникает очень редко.

⎆ Wikia/app/extensions/SemanticDrilldown/includes/SD_FilterValue.php#L103:

if ( $fv1->month == $fv1->month ) return 0;

Справа должно быть "$fv2->month".

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

Вместо "x" может использоваться любое имя. Шаблон "$x == $x" будет как раз тем, что найдёт пример выше. Переменные шаблона, которые имеют различающиеся имена, не обязаны иметь совпадающее содержимое при захвате. Здесь важно лишь то, чтобы имена были идентичны.

Следующий пример найден с помощью "$x <= $x".

⎆ Drupal/core/modules/views/tests/src/Unit/ViewsDataTest.php#L166:

$prev = $base_tables[$base_tables_keys[$i - 1]];
$current = $base_tables[$base_tables_keys[$i]];
$this->assertTrue( $prev['weight'] <= $current['weight'] && $prev['title'] <= $prev['title'], // <-------------- ошибка 'The tables are sorted as expected.');

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

$x : $x".
Это тернарный оператор, у которого true/false ветки идентичны. Один из моих любимцев — "$_ ?

⎆ joomla-cms/libraries/src/User/UserHelper.php#L522:

return ($show_encrypt) ? '{SHA256}' . $encrypted : '{SHA256}' . $encrypted;

Если мы посмотрим на код вокруг, то сможем понять, что там должно было быть вместо этого. Обе ветви дублируются, что подсказывает о потенциальной проблеме в коде. В угоду читабельности я вырезал часть кода и сократил название переменной $encrypted до $enc.

case 'crypt-blowfish': return ($show_encrypt ? '{crypt}' : '') . crypt($plaintext, $salt);
case 'md5-base64': return ($show_encrypt) ? '{MD5}' . $enc : $enc;
case 'ssha': return ($show_encrypt) ? '{SSHA}' . $enc : $enc;
case 'smd5': return ($show_encrypt) ? '{SMD5}' . $enc : $enc;
case 'sha256': return ($show_encrypt) ? '{SHA256}' . $enc : '{SHA256}' . $enc;
default: return ($show_encrypt) ? '{MD5}' . $enc : $enc;

Я бы поставил на то, что коду необходим следующий патч:

- ($show_encrypt) ? '{SHA256}' . $encrypted : '{SHA256}' . $encrypted;
+ ($show_encrypt) ? '{SHA256}' . $encrypted : $encrypted;

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

Если mask описывает какой-то бит, то данный код проверяет, что в x этот бит не равен нулю. Во многих языках программирования выражение "x & mask != 0" имеет интуитивный смысл. К сожалению, для PHP это выражение будет вычисляться так: "x & (mask != 0)", что почти всегда не то что вам нужно.

WordPress, Joomla и moodle используют SimplePie.

⎆ SimplePie/library/SimplePie/Locator.php#L254
⎆ SimplePie/library/SimplePie/Locator.php#L384
⎆ SimplePie/library/SimplePie/Locator.php#L412
⎆ SimplePie/library/SimplePie/Sanitize.php#L349
⎆ SimplePie/library/SimplePie.php#L1634

$feed->method & SIMPLEPIE_FILE_SOURCE_REMOTE === 0

SIMPLEPIE_FILE_SOURCE_REMOTE определён как 1, поэтому выражение будет эквивалентно:

$feed->method & (1 === 0)
// =>
$feed->method & false

Примеры шаблонов для поиска

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

Ответ: да! Можно ли такие места найти с помощью phpgrep?

phpgrep . '$_ == $_ ? $_ : $_ ? $_ : $_'
phpgrep . '$_ != $_ ? $_ : $_ ? $_ : $_'

⎆ Wikia/app/maintenance/wikia/updateCentralInterwiki.inc#L95:

if ( preg_match( '/(wowwiki.com|wikia.com|falloutvault.com)/', $url ) ) { $local = 1;
} else { $local = 0;
}

К сожалению, символ . не экранирован, что приведёт к тому, что вместо falloutvault.com мы можем завести себе falloutvaultxcom на любом домене и пройти проверку. По задумке автора кода, мы проверяем URL на совпадение с одним из 3-х вариантов.

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

Найти такие места можно с помощью запуска phpgrep:

phpgrep . 'preg_match(${"pat:str"}, ${"*"})' 'pat~[^\\]\.(com|ru|net|org)\b'

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

Кроме регулярных выражений есть также структурные операторы = и !=. Фильтры можно применять к любой переменной шаблона. Полный список можно найти в документации.

${"*"} захватывает произвольное количество любых аргументов, поэтому нам можно не волноваться за опциональные параметры функции preg_match.

В PHP вы не получите никакого предупреждения, если выполните этот код:

<?php
var_dump(['a' => 1, 'a' => 2]);
// Результат: array(1) {["a"]=> int(2)}

Мы можем найти такие массивы с помощью phpgrep:

[${"*"}, $k => $_, ${"*"}, $k => $_, ${"*"}]

Выражения ${"*"} помогают нам описать "произвольную позицию", допуская 0-N элементов до, между и после интересующих нас ключей. Этот шаблон можно расшифровать так: "литерал массива, в котором есть хотя бы два идентичных ключа в произвольной позиции".

⎆ Wikia/app/extensions/wikia/WikiaMiniUpload/WikiaMiniUpload_body.php#L23:

$script_a = [ 'wmu_back' => wfMessage( 'wmu_back' )->escaped(), 'wmu_back' => wfMessage( 'wmu_back' )->escaped(), // ...
];

В данном случае это не является грубой ошибкой, но мне известны случаи, когда дублирование ключей в крупных (100+ элементов) массивах несло как минимум неожиданное поведение, в котором один из ключей перекрывал значение другого.

Если вам хочется ещё, в конце статьи описано, как получить все результаты. На этом наш краткий экскурс на примерах окончен.

Большая часть редакторов и IDE используют для поиска кода (если это не поиск по специальному символу типа класса или переменной) обычный текстовой поиск — проще говоря, что-то вроде grep'а.

Вам могут быть доступны регулярные выражения, тогда вы можете пытаться, по сути, парсить PHP-код регулярками. Вы вводите "$x", находите "$x". Но если эта переменная с суффиксом должна быть частью другого составного выражения, возникают трудности. Иногда это даже работает, если ищется что-то вполне определённое и простое — например, «любая переменная с некоторым суффиксом».

phpgrep — это инструмент для удобного поиска PHP-кода, который позволяет искать не с помощью text-oriented регулярок, а с помощью syntax-aware шаблонов.

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

Опциональный контент: Quick Start

Установка

Для amd64 есть готовые релизные сборки под Linux и Windows, но если у вас установлен Go, то достаточно одной команды, чтобы получить свежий бинарник под вашу платформу:

go get -v github.com/quasilyte/phpgrep/cmd/phpgrep

Чтобы это проверить, попробуйте запустить команду с параметром -help: Если $GOPATH/bin находится в системном $PATH, то команда phpgrep станет сразу же доступной.

phpgrep -help

Если же ничего не происходит, найдите, куда Go установил бинарник и добавьте его в переменную окружения $PATH.

Старый и надёжный способ посмотреть $GOPATH, даже если он не выставлен явно:

go env GOPATH

Использование

Создайте тестовый файл hello.php:

<?php
function f(...$xs) {}
f(10);
f(20);
f(30);
f($x);
f();

Запустите на нём phpgrep:

# phpgrep hello.php 'f(${"x:int"})' 'x!=20'
hello.php:3: f(10)
hello.php:5: f(30)

Мы нашли все вызовы функции f с одним аргументом-числом, значение которого не равно 20.

Она достаточно хороша, но некоторые ограничения phpgrep следуют из особенностей используемого парсера. Для разбора PHP используется библиотека github.com/z7zmey/php-parser. Особенно много трудностей возникает при попытках нормально работать со скобочками.

Принцип работы phpgrep прост:

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

Иногда тривиально: один-к-одному, а мета-узлы могут захватывать более одного элемента. Самое интересное — это как именно сопоставляются на равенство два AST-узла. Примерами мета-узлов является ${"*"} и ${"str"}.

Они решают похожие задачи, причём у SSR есть свои преимущества, например, интеграция в IDE, а phpgrep может похвастаться тем, что является standalone программой, которую гораздо проще поставить, например, на CI. Было бы нечестно говорить о phpgrep, не упомянув structural search and replace (SSR) из PhpStorm.

Особенно это полезно для линтеров и кодогенерации. Помимо прочего, phpgrep — это ещё и библиотека, которую можно использовать в своих программах для матчинга PHP кода.

Если же эта статья просто мотивирует вас посмотреть в сторону вышеупомянутого SSR, тоже хорошо. Буду рад, если этот инструмент будет вам полезен.

Рядом с этим файлом можно найти скрипт phpgrep-lint.sh, упрощающий запуск phpgrep со списком шаблонов. Полный список шаблонов, который был использован для анализа, можно найти в файле patterns.txt.

В статье не дан полный список срабатываний, но вы можете воспроизвести эксперимент, произведя клонирование всех названых репозиториев и запустив phpgrep-lint.sh на них.

Мне очень понравилась Logical Expressions: Mistakes Made by Professionals, которая трансформируется во что-то такое: Черпать вдохновение на шаблоны проверок можно, например, из статей PVS studio.

# Для "x != y || x != z":
phpgrep . '$x != $a || $x != $b'
phpgrep . '$x !== $a || $x != $b'
phpgrep . '$x != $a || $x !== $b'
phpgrep . '$x !== $a || $x !== $b'

Вам также может быть интересна презентация phpgrep: syntax-aware code search.

В статье используются изображения гоферов, которые были созданы через gopherkon.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»