Ищем баги в PHP коде без статических анализаторов
Моя самая любимая часть в статическом анализе кода — это выдвижение гипотез о потенциальных ошибках в коде с последующей их проверкой.
Пример гипотезы:
Функции strpos легко передать аргументы в неправильном порядке.
Но есть вероятность, что даже на нескольких миллионах строк кода подобная диагностика не «выстрелит», поэтому на неудачные гипотезы тратить много времени не хочется.
Сегодня я покажу как выполнять простейший статический анализ с помощью утилиты phpgrep без написания кода.
Вот уже несколько месяцев я занимаюсь поддержкой PHP линтера NoVerify (почитать о котором можно в статье NoVerify: линтер для PHP от Команды ВКонтакте).
Время от времени в команде появляются идеи для новых диагностик. Идей может быть много, а проверить хочется все, особенно если предложенная проверка нацелена на выявление критических дефектов.
Ранее я активно разрабатывал go-critic и ситуация была схожей, с той лишь разницей, что анализировались исходники на Go, а не на PHP. Когда я узнал об утилите gogrep, мой мир перевернулся. Как видно из названия, эта утилита имеет что-то общее с grep’ом, только поиск производится не по регулярным выражениям, а по синтаксическим шаблонам (позже объясню, что это значит).
Я уже не хотел жить без умного grep, поэтому в один вечер решил сесть и написать phpgrep
.
Чтобы было увлекательно, мы сразу погрузимся в применение. Будем анализировать небольшой набор достаточно известных и крупных PHP проектов, доступных на GitHub.
В наш набор попали следующие проекты:
Для людей, которые замышляют то, что мы с вами замышляем, это очень аппетитный набор.
Итак, поехали!
Если присваивание используется как выражение, причём:
- контекст ожидает результат логической операции (логическое условие) и
- правая часть выражения не имеет побочных эффектов и является константной,
то, скорее всего, в коде ошибка.
Для начала, возьмём за «логический контекст» следующие конструкции:
- Выражение внутри »
if ($cond)
». - Условие тернарного оператора:»
$cond ? $x : $y
». - Условия продолжений циклов »
while ($cond)
» и »for ($init; $cond; $post)
».
В правой части присваивания мы ожидаем константы или литералы.
Начнём с (1):
# утилита поиска по синтаксическим шаблонам
# | производим поиск по текущей директории (и всем дочерним)
# | |
# | |
phpgrep . 'if ($_ = []) $_' # 1
# |
# |
# строка шаблона поиска
# Дополнительные 3 шаблона через отдельные запуски.
phpgrep . 'if ($_ = ${"const"}) $_' # 2
phpgrep . 'if ($_ = ${"str"}) $_' # 3
phpgrep . 'if ($_ = ${"num"}) $_' # 4
Здесь мы видим 4 шаблона, единственным различием между которыми выступает присваиваемое выражение (RHS). Начнём с первого из них.
Шаблон »if ($_ = []) $_
» захватывает любой if
, у которого любому выражению присваивается пустой массив. $_
сопоставляется с любым expression или statement.
литерал пустого массива (RHS)
|
if ($_ = []) $_
| |
| любое тело if'а, причём не важно, с {} или без
любой LHS у присваивания
В следующих примерах используются более сложные группы const, str и num. В отличие от $_
они описывают ограничения на совместимые операции.
const
— именованная константа или константа класса.str
— строковой литерал любого типа.num
— числовой литерал любого типа.
Этих шаблонов достаточно, чтобы добиться нескольких срабатываний на корпусе.
⎆ moodle/blocks/rss_client/viewfeed.php#L37:
if ($courseid = SITEID) {
$courseid = 0;
}
Вторым срабатыванием в moodle стала зависимость ADOdb. В upstream библиотеки проблема всё ещё присутствует.
⎆ 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-код, который они находят.
$_
обозначает «что угодно». Можно сравнить с.
в регулярных выражениях.${"
работает как"} $_
с ограничением на тип элементов AST.
Стоит ещё подчеркнуть, что всё, кроме переменных, сопоставляется дословно (literally). Это значит, что шаблону »array(1, 2 + 3)
» будет удовлетворять лишь идентичный по синтаксической структуре код (пробелы не влияют). С другой стороны, шаблону »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 является использование группирующих скобочек везде, где важно иметь правильный порядок вычислений.
Во многих языках программирования выражение »x & mask != 0
» имеет интуитивный смысл. Если mask
описывает какой-то бит, то данный код проверяет, что в x
этот бит не равен нулю. К сожалению, для 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;
}
По задумке автора кода, мы проверяем URL на совпадение с одним из 3-х вариантов. К сожалению, символ .
не экранирован, что приведёт к тому, что вместо falloutvault.com
мы можем завести себе falloutvaultxcom
на любом домене и пройти проверку.
Это не PHP-специфичная ошибка. В любом приложении, где валидация выполняется через регулярные выражения и частью проверяемой строки является мета-символ, есть риск забыть экранирование там, где оно нужно, и получить уязвимость.
Найти такие места можно с помощью запуска phpgrep
:
phpgrep . 'preg_match(${"pat:str"}, ${"*"})' 'pat~[^\\]\.(com|ru|net|org)\b'
Мы вводим именованный подшаблон pat
, который захватывает любой строковой литерал, а затем применяем к нему фильтр из регулярного выражения.
Фильтры можно применять к любой переменной шаблона. Кроме регулярных выражений есть также структурные операторы =
и !=
. Полный список можно найти в документации.
${"*"}
захватывает произвольное количество любых аргументов, поэтому нам можно не волноваться за опциональные параметры функции preg_match
.
В PHP вы не получите никакого предупреждения, если выполните этот код:
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’а.
Вы вводите »$x
», находите »$x
». Вам могут быть доступны регулярные выражения, тогда вы можете пытаться, по сути, парсить PHP-код регулярками. Иногда это даже работает, если ищется что-то вполне определённое и простое — например, «любая переменная с некоторым суффиксом». Но если эта переменная с суффиксом должна быть частью другого составного выражения, возникают трудности.
phpgrep — это инструмент для удобного поиска PHP-кода, который позволяет искать не с помощью text-oriented регулярок, а с помощью syntax-aware шаблонов.
Syntax-aware означает, что язык шаблонов отражает целевой язык, а не оперирует отдельными символами, как это делают регулярные выражения. Нам также нет никакой разницы до форматирования кода, важна лишь его структура.
Установка
Для amd64 есть готовые релизные сборки под Linux и Windows, но если у вас установлен Go, то достаточно одной команды, чтобы получить свежий бинарник под вашу платформу:
go get -v github.com/quasilyte/phpgrep/cmd/phpgrep
Если $GOPATH/bin
находится в системном $PATH
, то команда phpgrep
станет сразу же доступной. Чтобы это проверить, попробуйте запустить команду с параметром -help
:
phpgrep -help
Если же ничего не происходит, найдите, куда Go установил бинарник и добавьте его в переменную окружения $PATH
.
Старый и надёжный способ посмотреть $GOPATH
, даже если он не выставлен явно:
go env GOPATH
Использование
Создайте тестовый файл hello.php
:
Запустите на нём phpgrep
:
# phpgrep hello.php 'f(${"x:int"})' 'x!=20'
hello.php:3: f(10)
hello.php:5: f(30)
Мы нашли все вызовы функции f
с одним аргументом-числом, значение которого не равно 20.
Для разбора PHP используется библиотека github.com/z7zmey/php-parser. Она достаточно хороша, но некоторые ограничения phpgrep
следуют из особенностей используемого парсера. Особенно много трудностей возникает при попытках нормально работать со скобочками.
Принцип работы phpgrep
прост:
- строится AST из входного шаблона, разбираются фильтры;
- для каждого входного файла строится полное AST-дерево;
- обходим AST каждого файла, пытаясь найти такие поддеревья, которые совпадают с шаблоном;
- для каждого результата применяется список фильтров;
- все результаты, которые прошли фильтры, печатаются на экран.
Самое интересное — это как именно сопоставляются на равенство два AST-узла. Иногда тривиально: один-к-одному, а мета-узлы могут захватывать более одного элемента. Примерами мета-узлов является ${"*"}
и ${"str"}
.
Было бы нечестно говорить о phpgrep
, не упомянув structural search and replace (SSR) из PhpStorm. Они решают похожие задачи, причём у SSR есть свои преимущества, например, интеграция в IDE, а phpgrep
может похвастаться тем, что является standalone программой, которую гораздо проще поставить, например, на CI.
Помимо прочего, phpgrep
— это ещё и библиотека, которую можно использовать в своих программах для матчинга PHP кода. Особенно это полезно для линтеров и кодогенерации.
Буду рад, если этот инструмент будет вам полезен. Если же эта статья просто мотивирует вас посмотреть в сторону вышеупомянутого SSR, тоже хорошо.
Полный список шаблонов, который был использован для анализа, можно найти в файле patterns.txt. Рядом с этим файлом можно найти скрипт phpgrep-lint.sh
, упрощающий запуск phpgrep
со списком шаблонов.
В статье не дан полный список срабатываний, но вы можете воспроизвести эксперимент, произведя клонирование всех названых репозиториев и запустив phpgrep-lint.sh
на них.
Черпать вдохновение на шаблоны проверок можно, например, из статей PVS studio. Мне очень понравилась Logical Expressions: Mistakes Made by Professionals, которая трансформируется во что-то такое:
# Для "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.