В статье будет рассмотрен приём, который можно назвать «автоматизированный рефакторинг» — использование самописных скриптов, которые делают нужную работу за вас, позволяя провести рефакторинг после слияния всех веток и перед непосредственной выкладкой на staging/production.
На примере phpBB будет показано, как можно «отрефакторить» вызовы SQL-запросов, чтобы они использовали автоматическое экранирование входных данных (и таким образом помочь в решении проблемы SQL-инъекций).
Описание подхода
Начнем с теории: опишем проблему и предлагаемые способы её решения.
Проблемы автоматического слияния изменений
Предположим, что мы занимаемся рефакторингом кода и хотим переименовать функцию (в примерах — код на PHP). Допустим, мы изменили код следующим образом (первый символ «-» означает удаление строки, а «+» означает добавление строки):
<?php -function do_something() +function print_hello() { echo "Hello world!\n"; } $a = 1; -do_something(); +print_hello(); $b = 2; $c = 3;
Пока мы это делали, наш коллега успел добавить использование старого имени функции в другой ветке:
<?php function do_something() { echo "Hello world!\n"; } $a = 1; do_something(); $b = 2; +do_something(); $c = 3;
Поскольку изменения непротиворечивы (мы работали с разными строками кода), после автоматического слияния наших веток с изменениями получится следующий код:
<?php function print_hello() { echo "Hello world!\n"; } $a = 1; print_hello(); $b = 2; do_something(); $c = 3;
Таким образом, в конечном варианте будет вызов более несуществующей функции (do_something). Такой код, очевидно, не будет работать. Некоторые приводят возможность такого поведения в качестве аргумента против использования «feature branches», когда под одну задачу используют одну ветку. Можно предлагать различные решения этой проблемы, но понятно одно: она не решается легко, и за возможность рефакторинга в активно развивающемся проекте приходится дорого платить.
Автоматизированный рефакторинг
Суть подхода заключается в том, что мы должны отложить выполнение большинства изменений в коде таким образом, чтобы замены выполнялись уже после того, как произошло слияние всех веток. То есть сначала нужно применить изменения, сделанные коллегами, и только потом приступить к своей работе и выполнить переименование после всех остальных изменений. Также нужно ввести механизм, который бы запрещал использование кода в старом стиле.
Следовательно, для выполнения рефакторинга нужно написание следующих скриптов:
- проверка на использование старого кода;
- выполнение автоматической замены;
- сбор статистики использования старого и нового кода (опционально).
Подробнее о каждом из них.
Проверка на использование старого кода
Чтобы не допустить использования кода в старом стиле в процессе рефакторинга и после его завершения, нужно написать скрипт, который бы позволил однозначно и надежно установить, что код более не используется. Для нашего простого примера скрипт будет выглядеть примерно так:
#!/bin/sh grep do_something file.php
Скрипт возвращает строки в файле, которые содержат старое имя функции — do_something.
Выполнение автоматической замены
Для нашего простенького примера будет достаточно использовать sed, чтобы заменить все вхождения do_something на print_hello. В более сложных случаях этого будет недостаточно и понадобится более аккуратная обработка (например, если подстрока do_something может присутствовать в проекте и не являться вызовом функции).
#!/bin/sh sed -i 's/do_something/print_hello/g' file.php
Сбор статистики использования старого и нового кода
Наш скрипт для замены хоть и должен заменить все вхождения do_something на print_hello, но он ничего не сможет сделать, например, с таким кодом:
$func_prefix = "do_"; $func_name = $func_prefix . "something"; // да, PHP позволяет вызывать функцию по её имени, записанному в переменной $func_name();
Такая проблема существует даже в статически типизированных языках, например С и Java. В C можно всегда написать #define и наделать макросов, а в Java существует механизм Reflection. Для этого введем новую версию функции do_something (нужно позаботиться о том, чтобы эта функция не была автоматически заменена скриптом выше):
function do_something() { error_log(__FUNCTION__ . " found. Trace: " . (new Exception())); return print_hello(); }
В результате, после выполнения юнит-тестов или после выкладки этого кода в production (если у вас нет 100% покрытия), вы сможете найти пропущенные упоминания старой функции. Эти фрагменты можно проанализировать и выполнить соответствующую замену вручную.
Этапы применения написанных скриптов
После того как соответствующие скрипты написаны, нужно их правильно применить. Предлагается делать это следующим образом:
- выполнить слияние всех последних изменений разработчиков;
- выполнить скрипт по автоматической замене и зафиксировать изменения в системе контроля версий;
- проверить, что упоминаний старого кода больше нет;
- добавить логирование использования старого кода;
- прогнать юнит-тесты, выложить код на production;
- сразу после попадания кода в основную ветку разработки нужно настроить «хуки» своей системы контроля версий так, чтобы она более не пропускала использование старого кода;
- добавить проверку использования старого кода в скрипты, которые запускаются перед попаданием кода на production;
- (опционально) собрать с production логи использования старого кода и исправить соответствующие места вручную.
- Вы успешно выполнили рефакторинг!
Наличие пункта 7 важно, потому что в репозитории может присутствовать старый код в других ветках, которые ещё не были слиты с основной веткой разработки. Слияние таких веток может пройти без конфликтов, но в них могут присутствовать упоминания старого кода.
Важным моментом в описываемой методике является возможность применения изменений одномоментно, с помощью скриптов и небольшого количества ручной работы. Выполнение большого рефакторинга полностью вручную также возможно, но это очень тяжелый путь, по которому нужно идти, только если у вас не остается других вариантов.
Рефакторинг использования SQL в phpBB
Для иллюстрации подхода на более приближенных к реальности примерах мы бы хотели показать, как отрефакторить код phpBB, чтобы максимально избавить его от SQL-инъекций. Эта часть может рассматриваться отдельно от остальной статьи и не является обязательной к прочтению, поэтому она помещена под спойлер:
Работа с SQL в phpBB
Давайте разберёмся, как же устроена работа с SQL в phpBB и почему используемый механизм несовершенен.
Во-первых, реализация самих классов работы с базой данных находится в «includes/db», и в «common.php» создается глобальная переменная $db, которая содержит экземпляр соответствующего класса для работы с БД.
$ ls includes/db db_tools.php index.htm mssqlnative.php oracle.php dbal.php mssql.php mysql.php postgres.php firebird.php mssql_odbc.php mysqli.php sqlite.php
Таким образом, директория «includes/db» должна исключаться из наших скриптов при автоматической замене — соответствующие замены мы проведем вручную.
Чтобы оценить масштаб проблемы, воспользуемся замечательным средством под названием grep:
$ grep -RF 'sql_query(' * | wc -l 1611
То есть примерно 1 600 обращений к sql_query(). Полагаем, становится понятно, что вручную заменить такое количество мест — не самая лучшая идея. Вероятно, такое количество мест является одной из причин, почему разработчики phpBB до сих пор ничего не сделали с этими вызовами.
Давайте теперь всё же посмотрим, что с ними не так?
Вот определение sql_query:
function sql_query($query = '', $cache_ttl = 0) …
Отсюда мы можем легко видеть, что метод получает только лишь сам запрос, а экранирование значений должна выполнять вызывающая сторона самостоятельно.
Пример из viewtopic.php:
$sql = 'SELECT forum_id FROM ' . TOPICS_TABLE . " WHERE topic_id = $topic_id"; $result = $db->sql_query($sql);
Поскольку $topic_id приходит извне и может не обрабатываться должным образом, существует возможность получить как ошибку SQL, так и настоящую SQL-инъекцию. Использование UNION может даже позволить нам вытащить данные из другой таблицы, поэтому стоит всеми силами избегать написания такого кода.
Вместо этого мы могли бы написать что-нибудь наподобие следующего:
$sql = 'SELECT forum_id FROM ' . TOPICS_TABLE . ' WHERE topic_id = ?d'; $result = $db->sql_query_escaped($sql, $topic_id);
То есть вместо «$topic_id» написать «?d», которое будет воспринято методом sql_query_escaped и будет всегда явно обработано как число.
SQL-запросы с автоматическим экранированием значений
Предлагается для примера сделать простейшую обёртку следующего вида и поместить её в dbal.php:
/** * Execute escaped SQL query * * First parameter, $query_template is the SQL template that has the following placeholders: * * ?d - value is integer * ?s - value is string (escaped by default) * ?a - array of integers for usage like IN(?a) * ?r - raw SQL (e.g. for use in dynamic part of SQL expression) * * Example: * * sql_query_escaped("SELECT * FROM table WHERE table_id = ?d", 42) will be executed as * * SELECT * FROM table WHERE table_id = 42 * * sql_query_escaped("SELECT * FROM table WHERE table_id IN(?a)", array(1, 2, 3)) will be executed as * * SELECT * FROM table WHERE table_id IN(1, 2, 3) * */ function sql_query_escaped($query_template) { $values = func_get_args(); array_shift($values); $regexp = '/\\?[dsar]/s'; preg_match_all($regexp, $query_template, $matches); foreach ($matches[0] as $i => $m) { if ($m == '?d') $values[$i] = intval($values[$i]); else if ($m == '?s') $values[$i] = "'" . $this->sql_escape($values[$i]) . "'"; else if ($m == '?a') $values[$i] = implode(',', array_map('intval', $values[$i])); } $idx = 0; $replace_func = function($placeholder) use ($values, &$idx) { $placeholder = $placeholder[0]; return $values[$idx++]; }; $query = preg_replace_callback($regexp, $replace_func, $query_template); return $this->sql_query($query); }
Реализация и способ решения проблемы для нас не столь принципиальны, потому что это просто пример. Если вы действительно захотите делать что-то подобное, то можно придумать решение получше или использовать стандартные решения, например PDO.
Отладка SQL-запросов
В phpBB по какой-то причине отсутствует простой способ выводить SQL-запросы на текущей странице, поэтому добавим небольшой временный патч:
--- a/includes/db/mysqli.php +++ b/includes/db/mysqli.php @@ -151,6 +151,8 @@ class dbal_mysqli extends dbal return true; } + var $queries = array(); + /** * Base query method * @@ -162,6 +164,7 @@ class dbal_mysqli extends dbal */ function sql_query($query = '', $cache_ttl = 0) { + $this->queries[] = $query; if ($query != '') { global $cache; --- a/includes/functions.php +++ b/includes/functions.php @@ -4735,6 +4735,10 @@ function page_footer($run_cron = true) } $debug_output .= ' | <a href="' . build_url() . '&explain=1">Explain</a>'; + + foreach ($db->queries as $query) { + $debug_output .= "<pre style='text-align: left; margin-bottom: 10px;'>" . htmlspecialchars($query) . "</pre>\n<hr/>\n"; + } } }
Анализ использования sql_query()
Одним из самых важных моментов во время написания скриптов для автоматического рефакторинга является так называемый «метод пристального взгляда». Мы должны посмотреть, как используется тот метод, который мы собираемся поменять, и найти основные паттерны его использования путём внимательного всматривания в код.
Итак, ещё раз воспользуемся grep, но на этот раз просмотрим глазами упоминания sql_query() и попытаемся сформулировать основные найденные паттерны:
$ grep -RF 'sql_query(' * | less
Основные паттерны:
- $result = $db->sql_query($sql); // переменная $sql содержит текст запроса;
- $db->sql_query(«DELETE|UPDATE|SELECT …»); // запрос указывается явно.
Также можно заметить, что всегда используется одно и то же имя для объекта базы данных — «$db». Тем не менее, нужно обязательно убедиться, что объект базы данных не используется под другим именем и в других классах нет метода sql_query:
$ grep -RF 'sql_query(' * | grep -vF '$db->sql_query('
Выполнив этот код, мы увидим, что упоминания всё же есть. Их можно разделить на несколько категорий:
- docs/*.html — пропускаем, поскольку это документация;
- includes/db/db_tools.php — класс содержит методы sql_table_exists, sql_alter_table и другие DDL;
- includes/db/dbal.php: используется в методе sql_query_limit, а также в методах insert, которые сами экранируют значения;
- includes/db/(mysql|mssql,firebird|…).php — вызовы mysql_query, mssql_query и др.;
- includes/functions_convert.php — функции для конвертации базы (с одной версии phpBB на другую);
- install/convertors/convert_*.php — скрипты для конвертации базы;
- install/install_convert.php — также скрипт для конвертации базы.
Из всего этого нам действительно стоит обратить внимание на вызовы sql_query() в наследниках dbal, а также в самом dbal. Таким образом, из нашего дополнительного анализа стало понятно, что нам требуется также отрефакторить все вызовы sql_query_limit.
Скрипты для конвертации базы теоретически тоже могут содержать SQL-инъекции. Тем не менее, эти скрипты составляют малую часть всех SQL-запросов и скорее всего SQL-инъекций не содержат из-за их специфики. Поэтому эти места мы пропустим.
Анализ паттерна $db->sql_query(‘SELECT/UPDATE/DELETE …’)
Давайте теперь напишем скрипт, который найдет нам все упоминания sql_query, когда SQL-запрос написан напрямую. Таких мест не слишком много по сравнению с sql_query($sql), но для начала выберем более простую задачу.
Алгоритм следующий: разобьём содержимое файлов на токены и найдем там последовательность «$db», «->», и «sql_query». После этого выражение в скобках будет тем самым запросом. Код будем писать на PHP 5.3 с использованием расширения tokenizer.
<?php // Для начала оставим только файлы с тем паттерном, который мы собираемся заменять: $files = exec('grep -RF \'$db->sql_query(\' * | awk -F: \'{print $1;}\'', $out, $retval); if ($retval) exit(1); // Наш скрипт будет жить в папке replacer/ $excludes = array('includes/db/', 'replacer/'); $num_lines = 0; foreach (array_unique($out) as $filename) { foreach ($excludes as $excl) { if (strpos($filename, $excl) === 0) continue(2); } $contents = file_get_contents($filename); if ($contents === false) exit(1); $lines = explode("\n", $contents); // Получаем массив всех токенов в файле $tokens = token_get_all($contents); $num = count($tokens); $line = 1; $type = $text = ''; // Нам нужно найти 5 идущих подряд токенов: '$db', '->', 'sql_query', '(' и начало запроса // Сам запрос должен начинаться с токена с типом «строка» $accepted_value_types = array(T_CONSTANT_ENCAPSED_STRING, T_ENCAPSED_AND_WHITESPACE, '"'); foreach ($tokens as $cur_idx => $tok) { parse_token($tok, $type, $text, $line); // Пропускаем токены в самом конце, где точно не могут уместиться наши 5 токенов if ($cur_idx >= $num - 5) continue; $ln = $line; $i = $cur_idx + 1; // Список токенов доступен в документации (http://php.net/manual/tokens.php) if ($type !== T_VARIABLE || $text !== '$db') continue; parse_token($tokens[$i++], $type, $text, $ln); if ($type !== T_OBJECT_OPERATOR || $text !== '->') continue; parse_token($tokens[$i++], $type, $text, $ln); if ($type !== T_STRING || $text !== 'sql_query') continue; parse_token($tokens[$i++], $type, $text, $ln); if ($type !== '(') continue; parse_token($tokens[$i++], $type, $text, $ln); if (!in_array($type, $accepted_value_types, true)) continue; // Теперь получим весь запрос: нам нужно всё, что находится до закрывающей скобки $depth = 1; $query = ''; for ($i = $i - 1; $i < $num; $i++) { parse_token($tokens[$i], $type, $text, $ln); if ($type === '(') $depth++; else if ($type === ')') $depth--; if ($depth == 0) break; $query .= $text; } $query = preg_replace(array("/[\t ]+/s", '/^/m'), array(' ', ' '), $query); $results[$filename][] = $query; $num_lines++; } } foreach ($results as $filename => $list) { echo "$filename\n"; foreach ($list as $row) echo "$row\n"; echo "\n"; } echo "Total lines recognized: $num_lines\n"; // См. формат возвращаемых значений в документации к token_get_all function parse_token($tok, &$type, &$text, &$line) { if (is_array($tok)) list($type, $text, $line) = $tok; else $type = $text = $tok; }
После запуска увидим, что наш скрипт распознал примерно 130 строк, что составляет около 8% всех текстовых вхождений «sql_query» в проекте. Можно также заметить, что значительная часть найденных строк — это строки вида
includes/acp/acp_attachments.php 'INSERT INTO ' . EXTENSIONS_TABLE . ' ' . $db->sql_build_array('INSERT', $sql_ary) includes/acp/acp_bbcodes.php 'INSERT INTO ' . BBCODES_TABLE . $db->sql_build_array('INSERT', $sql_ary)
То есть используется метод sql_build_array для вставки значений в поля, а значения в этом методе уже экранируются и здесь можно ничего не трогать.
Анализ паттерна $db->sql_query($sql)
Теперь давайте узнаем, как получить текст SQL-запроса в случае, когда его значение берется из переменной $sql. Посмотрим на контекст вызова запроса и попробуем найти способ однозначно получить сам запрос:
$ grep -A 5 -B 5 -RF '$db->sql_query($sql' * | less
Из кода можно увидеть, что большая часть запросов вызывается следующим образом::
$sql = '...'; // текст запроса $db->sql_query($sql);
Другими словами, определение переменной $sql и присвоение значения происходит непосредственно перед вызовом sql_query(). Поэтому напишем обработку именно такого случая как наиболее распространенного и простого. Прежде всего распечатаем строки с запросами, чтобы было понятно, с чем мы имеем дело:
<?php // Для начала оставим только файлы с тем паттерном, который мы собираемся заменять $files = exec('grep -RF \'$db->sql_query($sql\' * | awk -F: \'{print $1;}\'', $out, $retval); if ($retval) exit(1); // Наш скрипт будет жить в папке replacer/ $excludes = array('includes/db/', 'replacer/'); $num_lines = 0; foreach (array_unique($out) as $filename) { foreach ($excludes as $excl) { if (strpos($filename, $excl) === 0) continue(2); } echo "$filename\n"; $contents = file_get_contents($filename); if ($contents === false) exit(1); $lines = explode("\n", $contents); // Получаем массив всех токенов в файле $tokens = token_get_all($contents); $num = count($tokens); $line = 1; $type = $text = ''; // Нам нужно найти 5 идущих подряд токенов: '$db', '->', 'sql_query', '(' и '$sql' foreach ($tokens as $cur_idx => $tok) { parse_token($tok, $type, $text, $line); // Пропускаем токены в самом конце, где точно не могут уместиться наши 5 токенов if ($cur_idx >= $num - 5) continue; $ln = $line; $i = $cur_idx + 1; // Список токенов доступен в документации (http://php.net/manual/tokens.php) if ($type !== T_VARIABLE || $text !== '$db') continue; parse_token($tokens[$i++], $type, $text, $ln); if ($type !== T_OBJECT_OPERATOR || $text !== '->') continue; parse_token($tokens[$i++], $type, $text, $ln); if ($type !== T_STRING || $text !== 'sql_query') continue; parse_token($tokens[$i++], $type, $text, $ln); if ($type !== '(') continue; parse_token($tokens[$i++], $type, $text, $ln); if ($type !== T_VARIABLE || $text !== '$sql') continue; // Для проверки выведем найденные строки. Вывод должен быть аналогичен grep printf("%6d %s\n", $line, ltrim($lines[$line - 1])); $positions = find_dollar_sql_assignment($tokens, $cur_idx); if (!is_array($positions)) { echo " $positions\n\n"; continue; } $query = ''; for ($i = $positions['begin']; $i <= $positions['end']; $i++) { parse_token($tokens[$i], $type, $text, $ln); $query .= $text; } $query = preg_replace(array("/[\t ]+/s", '/^/m'), array(' ', ' '), $query); echo "\n" . $query . "\n\n"; $num_lines++; } echo "\n"; } echo "Total lines recognized: $num_lines\n"; // См. формат возвращаемых значений в документации к token_get_all function parse_token($tok, &$type, &$text, &$line) { if (is_array($tok)) list($type, $text, $line) = $tok; else $type = $text = $tok; } // Найти $sql = '...'; перед $db->sql_query($sql function find_dollar_sql_assignment($tokens, $db_idx) { $depth = 0; $line = 1; $type = $text = ''; // Пройдемся по предыдущим токенам и найдем упоминания $sql for ($idx = $db_idx - 1; $idx > 0; $idx--) { parse_token($tokens[$idx], $type, $text, $line); // учет вложенности скобок if ($type === ')') $depth++; else if ($type === '(') $depth--; if ($depth < 0) { return "depth < 0 on line " . __LINE__; } /* Мы ничего не можем сделать в случаях следующего вида, поэтому их нужно определять и пропускать: if (...) { $sql = 'SELECT * FROM Table1 WHERE user_id = $user_id'; } else { $sql = 'SELECT * FROM Table2'; } $result = $db->sql_query($sql); */ if ($type === '{' || $type === '}') { return "open/close brace found on line " . __LINE__; } if ($type === T_VARIABLE && $text === '$sql') { $i = $idx + 1; parse_token($tokens[$i++], $type, $text, $line); if ($type === T_WHITESPACE) parse_token($tokens[$i++], $type, $text, $line); if ($type !== '=') continue; // мы нашли "$sql = " ! $begin_pos = $i; break; } } // Мы уже нашли начало присваивания «$sql = », теперь нужно найти конец // Будем считать, что выражение у нас всегда оканчивается на «;» $depth = 0; for ($idx = $begin_pos; $idx < $db_idx; $idx++) { parse_token($tokens[$idx], $type, $text, $line); if ($type === '(') $depth++; else if ($type === ')') $depth--; if ($depth < 0) { return "depth < 0 on line " . __LINE__; } if ($depth === 0 && $type === ';') { return array('begin' => $begin_pos, 'end' => $idx - 1); } } // Если цикл закончился, значит между найденным нами началом и $db->sql_query нет «;» // То есть, наше предположение было неверно return "Statement does not terminate on line " . __LINE__; }
Помимо непосредственного вывода запросов мы также выводим в конце количество распознанных строк (около 1150). Это составляет примерно 70% от всех текстовых вхождений sql_query! Давайте теперь приведём примеры вывода нашего скрипта:
cron.php 59 $db->sql_query($sql); 'UPDATE ' . CONFIG_TABLE . " SET config_value = '" . $db->sql_escape(CRON_ID) . "' WHERE config_name = 'cron_lock' AND config_value = '" . $db->sql_escape($config['cron_lock']) . "'" … includes/acp/acp_forums.php 243 $result = $db->sql_query($sql); 'SELECT * FROM ' . FORUMS_TABLE . " WHERE forum_id = $forum_id" ... 1096 $db->sql_query($sql); 'DELETE FROM ' . FORUMS_TABLE . ' WHERE ' . $db->sql_in_set('forum_id', $forum_ids) includes/acp/acp_reasons.php ... 96 $result = $db->sql_query($sql); 'SELECT reason_id FROM ' . REPORTS_REASONS_TABLE . " WHERE reason_title = '" . $db->sql_escape($reason_row['reason_title']) . "'" ... includes/acp/acp_search.php 320 $result = $db->sql_query($sql); 'SELECT post_id, poster_id, forum_id FROM ' . POSTS_TABLE . ' WHERE post_id >= ' . (int) ($post_counter + 1) . ' AND post_id <= ' . (int) ($post_counter + $this->batch_size)
Обратите внимание на прямое использование методов $db->sql_escape и $db->sql_in_set: вызовы таких методов нам стоит обрабатывать отдельно и учитывать наличие экранирования в непосредственном коде. Также вызовы intval() и приведения типа к int тоже стоит учесть при переписывании этого кода.
Преобразование текста запроса
Теперь, когда мы научились вычленять текст запроса, пришло время написать функцию с возможностью принимать на вход массив токенов, соответствующих запросу, а на выходе — возвращать другой массив токенов, в котором находится шаблон запроса для sql_query_escaped, а также массив аргументов, которые будут передаваться этому методу. Другими словами, мы хотим получить следующее:
$sql = /* начало токенов запроса */ 'SELECT user_id, author_id FROM ' . PRIVMSGS_TO_TABLE . ' WHERE msg_id = ' . $attachment['post_msg_id'] /* конец токенов */; $result = $db->sql_query($sql); // должно быть преобразовано в $sql = 'SELECT user_id, author_id FROM ' . PRIVMSGS_TO_TABLE . ' WHERE msg_id = ?d'; $result = $db->sql_query_escaped($sql, $attachment['post_msg_id']);
Мы сразу пишем «?d», потому что можем смело предположить, что если это поле, имя которого оканчивается на «_id», то это должно быть число. Также мы не будем трогать имя таблицы, поскольку оно встречается в каждом запросе, и это значение всегда должно вставляться в «сыром» виде.
Что ж, задача сформулирована, давайте приступим к реализации. В этой статье мы напишем лишь простейший код для трансляции, который не будет анализировать значения, вставляемые в запрос. Поэтому будем вставлять только ‘?r’, то есть просто перепишем вызовы sql_query() так, чтобы они использовали sql_query_escaped(), но делали то же самое.
function rewrite_tokens($in_tokens) { static $string_types = array(T_CONSTANT_ENCAPSED_STRING, T_ENCAPSED_AND_WHITESPACE, '"'); $type = $text = null; $line = 1; // для начала проверим, что запрос начинается со строки, для обработки, к примеру, такого случая: // ($action == 'add') ? 'INSERT INTO ' . EXTENSION_GROUPS_TABLE . ' ' : 'UPDATE ' . EXTENSION_GROUPS_TABLE . ' SET ' parse_token($in_tokens[0], $type, $text, $line); if ($type === T_WHITESPACE) parse_token($in_tokens[1], $type, $text, $line); if (!in_array($type, $string_types, true)) return "Expected first token to be string (got $type)"; $out_params = $out_tokens = array(); // разобьём токены на группы, считая за разделитель оператор конкатенации: // // 'SELECT * FROM ' . TABLE_SOMETHING . ' WHERE lang_id = ' . intval($lang_id) // // будет превращено в // // array(array('SELECT * FROM ', TABLE_SOMETHING), array(' WHERE lang_id = ', intval($lang_id))) // // заметим, что первый токен всегда должен быть строкой, а второй может быть любым $state = 'begin'; $left_tokens = $right_tokens = $groups = array(); $depth = 0; // глубина скобок foreach ($in_tokens as $tok) { parse_token($tok, $type, $text, $line); if ($type === '(') $depth++; else if ($type === ')') $depth--; if ($depth < 0) return "Brace depth less than zero"; if ($state == 'begin') { // добавляем в массив «левых» токенов, пока не встретим оператор конкатенации if ($depth > 0 || $type !== '.') { $left_tokens[] = $tok; } else { $state = 'end'; } } else { // всё аналогично предыдущему, но в конце мы добавим нашу группу в массив $groups if ($depth > 0 || $type !== '.') { $right_tokens[] = $tok; } else { $state = 'begin'; $groups[] = array($left_tokens, $right_tokens); $left_tokens = $right_tokens = array(); } } } // если осталось что-то в конце, добавим это в группу if (count($left_tokens)) $groups[] = array($left_tokens, $right_tokens); foreach ($groups as $grp) { list($left_tokens, $right_tokens) = $grp; // как мы заметили ранее, первый токен в группе должен быть строкой parse_token($left_tokens[0], $type, $text, $line); if ($type === T_WHITESPACE) parse_token($left_tokens[1], $type, $text, $line); if (!in_array($type, $string_types, true)) return "first token in a group is not string"; // пересоберём всё обратно, заменив значения на плейсхолдеры foreach ($left_tokens as $tok) { parse_token($left_tokens[0], $type, $text, $line); $out_tokens[] = $tok; } if (!count($right_tokens)) break; $out_tokens[] = '.'; // если значение - это константа вида *_TABLE, оставим её, как есть $param = tokens_to_string($right_tokens); if (preg_match('/^\\s*([A-Z0-9_]+)_TABLE\\s*$/s', $param)) { foreach ($right_tokens as $tok) $out_tokens[] = $tok; } else { // вставим просто строку '?r', «сырое» значение $out_tokens[] = array(T_CONSTANT_ENCAPSED_STRING, "'?r'", $line); $out_params[] = $param; } $out_tokens[] = '.'; } // если мы последним токеном вставили символ конкатенации, // уберем его, чтобы не было syntax error parse_token(end($out_tokens), $type, $text, $line); if ($type === '.') array_pop($out_tokens); return array( 'tokens' => $out_tokens, 'params' => $out_params, ); } function tokens_to_string($tokens) { $str = ''; $type = $text = null; $line = 1; foreach ($tokens as $tok) { parse_token($tok, $type, $text, $line); $str .= $text; } $str = preg_replace(array("/[\t ]+/s", '/^/m'), array(' ', ' '), $str); return $str; }
Сделав необходимые изменения в скриптах для замены, получим следующее (пример из cron.php):
$sql = 'UPDATE ' . CONFIG_TABLE . " SET config_value = '" .'?r'. "' WHERE config_name = 'cron_lock' AND config_value = '" .'?r'. "'"; $db->sql_query_escaped($sql, $db->sql_escape(CRON_ID), $db->sql_escape($config['cron_lock']));
или в виде диффа:
$sql = 'UPDATE ' . CONFIG_TABLE . " - SET config_value = '" . $db->sql_escape(CRON_ID) . "' - WHERE config_name = 'cron_lock' AND config_value = '" . $db->sql_escape($config['cron_lock']) . "'"; -$db->sql_query($sql); + SET config_value = '" .'?r'. "' + WHERE config_name = 'cron_lock' AND config_value = '" .'?r'. "'"; +$db->sql_query_escaped($sql, $db->sql_escape(CRON_ID), $db->sql_escape($config['cron_lock']));
Пример для случая, когда запрос пишется без использования $sql:
- $db->sql_query('UPDATE ' . CONFIG_TABLE . ' SET config_value = ' . $sql_update . " WHERE config_name = '" . $db->sql_escape($config_name) . "'"); + $db->sql_query_escaped('UPDATE ' . CONFIG_TABLE . ' SET config_value = ' .'?r'. " WHERE config_name = '" .'?r'. "'", $sql_update, $db->sql_escape($config_name));
Полученный результат сложно назвать красивым из-за ‘?r’, которая всегда стоит отдельно. Также наш скрипт не обрабатывает случаи с использованием двойных кавычек (выражения вида «WHERE id = $forum_id»). В рамках данной статьи мы остановимся на этом варианте, «причёсывание» и аккуратная склейка строк остаётся на усмотрение читателя. Стоит также отдельно отметить, что после выполнения всех замен сам форум работает без видимых ошибок.
Посмотрим, сколько ещё осталось:
$ grep -RF 'sql_query(' * | wc -l 335
Таким образом, двумя скриптами мы покрыли 80% всех случаев. Оставшиеся 20% нужно обработать вручную и, по правилу Парето, они займут у нас 80% всего времени, потраченного на задачу.
Пункты, не вошедшие в этот пример
Мы написали скрипт по выполнению автоматической замены, но только на вариант с «сырыми» данными. После того, как выполнены эти замены, нужно выполнить оставшиеся действия:
- собрать статистику использования sql_query_escaped и старого метода sql_query;
- на основе статистики составить более правильные замены вместо "?r";
- выполнить замены и продолжать собирать статистику;
- после выполнения работы должно остаться мало мест, где требуется вставлять «сырой» SQL.
Чтобы на основе статистики составить правильные значения плейсхолдеров вместо "?r", можно воспользоваться следующими догадками:
- из имени поля, используемого в SQL-запросе, можно получить тип требуемого значения;
- если вставляемые значения — это только числа, то можно использовать «?d»;
- если в запросе используется конструкция
"... '?r' ..."
, то скорее всего её можно заменить на"... ?s ..."
; - если в значении всегда присутствуют ключевые слова SQL, то это динамическая часть запроса.
Исходный код скриптов для замены
replacer/direct_sql.php — Непосредственно SQL в вызове sql_query()
replacer/sql_assignment.php — Обработка sql_query($sql)
replacer/rewrite.php — Функция по преобразованию SQL запроса
ссылка на оригинал статьи http://habrahabr.ru/company/badoo/blog/167337/
Добавить комментарий