Как использовать парсеры и другие инструменты для анализа кода на JavaScript

Язык JavaScript появился больше 20 лет назад и до сих пор остается самым распространенным языком. Это единственный язык программирования, который работает на самой популярной платформе (в Интернете). На нем все чаще разрабатываются нативные (Visual Studio Code, Discord и Slack) и популярные мобильные приложения (Facebook, Skype, Tesla). Но знаете ли вы, в чем секрет его популярности? Программы Bug Bounty и обнаружение уязвимостей, которые приносят живые деньги.

В любом фильме про хакеров вы обязательно увидите сцену, где кто-то сидит перед компьютером и набирает загадочные команды на черном экране терминала (если только это не 3D-интерфейс UNIX из «Парка Юрского периода»).

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

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

Это регулярное выражение пытается извлечь все назначенные переменные из исходного файла, написанного на JavaScript. Таким образом можно получить общее представление о приложении и найти слабые звенья. Предложенное регулярное выражение прекрасно работает с простым кодом вроде такого:

Но если код будет чуть посложнее, ничего не выйдет:

 

В некоторых особо интересных случаях это регулярное выражение пропускает переменную или получает неверное значение:

 

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

 

https://twitter.com/d0nutptr/status/1143087965775679490
https://twitter.com/d0nutptr/status/1143087965775679490

На самом деле, регулярные выражения попросту не подходят для парсинга JavaScript. И точка.

Как парсить JavaScript

Полдюжины готовых к использованию парсеров JavaScript используются в среде Node.js. Другие используются вне этой среды, но если вы планируете манипулировать JavaScript, лучшего инструмента на другом языке вам не найти. Пользоваться парсерами так же просто, как любой другой библиотекой. Сложно лишь разобраться с тем, что же делать с результатами. Эти парсеры JavaScript создают абстрактное синтаксическое дерево (АСД). Это не что иное, как огромный объект, отражающий структуру исходного JavaScript-кода. Если необработанные HTTP-запросы не вызывают у вас нервного тика, то вас не напугают и АСД.

Если вы хотите узнать, как выглядят АСД, воспользуйтесь инструментом ASTExplorer.

ASTExplorer.net
ASTExplorer.net

В интерфейсе AST Explorer слева будет отображаться исходный JavaScript-код, а справа — полученное АСД. При необходимости можно переключаться между парсерами и сравнивать АСД.

Выбор парсера

АСД не похожи друг на друга. Есть разные версии протокола HTTP — 1.0, 1.1, 2.0 и т. д. Инструменты, которые отлично работают с одной версией, могут быть не столь удобными при работе с другими версиями. То же самое с парсерами и АСД. Когда-то существовал единый стандарт, но у него было столько недостатков, что программисты начали создавать собственные решения, которые существенно отличались друг от друга.

Я пользуюсь пакетом инструментов Shift, потому что 1) я уже не раз попадал в ловушки АСД и 2) его написали люди, которые знают, как не попадать в эти ловушки. АСД Shift было первым (и единственным?) АСД, основанным на спецификациях, то есть авторы подумали о том, как представить весь ECMAScript еще до того, как приступили к решению проблемы парсинга. Создатели инструментов JavaScript первого поколения пытались привести в порядок пограничные случаи, с которыми сталкивались в других АСД. Их было немало.

Для использования shift-parser нужно импортировать библиотеку и вызвать метод для парсинга строки исходного кода JavaScript. Если вы незнакомы со средой Node и не знаете, как устанавливать зависимости, установите node через nvm и прочитайте здесь, как пользоваться npm.

Вам не нужно ничего, кроме этого кода, чтобы создать АСД для любого исходника JavaScript.

const { parseScript } = require("shift-parser");
const ast = parseScript(javascriptSource);

Переход от АСД к JavaScript

Для того чтобы получить АСД и преобразовать его обратно в исходный код, понадобится генератор кода. Это чуть сложнее, поскольку форматирование кода зависит только от ваших предпочтений. Если вы хотите получить код, который легко читать, вам нужен генератор, который как минимум хорошо структурирует код. В shift-codegen встроены два базовых инструмента форматирования и один расширенный. В следующем примере код получает АСД, созданное парсером, и генерирует исходный код JavaScript с помощью shift-codegen. Если форматирование не играет для вас существенной роли, можно не импортировать и не устанавливать FormattedCodeGen

const { default: codegen, FormattedCodeGen } = require('shift-codegen'); 
console.log(codegen(ast, new FormattedCodeGen()));

АСД для манипуляций с JavaScript

АСД можно изменить в ручном режиме, как и любой другой объект JavaScript:

const { parseScript } = require('shift-parser');
const { default: codegen, FormattedCodeGen } = require('shift-codegen');

const source = `const myVar = "Hello World";`

const ast = parseScript(source);

ast
  .statements[0]
  .declaration
  .declarators[0]
  .init
  .value = 'Hello Reader';

console.log(codegen(ast, new FormattedCodeGen()));
// > const myVar = "Hello Reader";

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

Обход дерева

С помощью утилиты Shift-traverser можно обойти АСД Shift и попутно манипулировать узлами. Библиотека представляет собой портированную версию estraverse, предназначенную для другого формата АСД. Shift-traverser работает так же, как estraverse. Вы указываете объект АСД, а также метод ввода и вывода. Shift-traverser вызывает эти методы в текущем узле и его родителе, когда обходчик впервые обнаруживает узел и когда он выходит из него.

const { traverse } = require("shift-traverser");

traverse(ast, {
  enter(node, parent) {
  },
  exit(node, parent) {
  }
});

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

Заставляем все это работать

В начале этой статьи я рассмотрел случаи, когда регулярные выражения неэффективны для парсинга JavaScript-кода. Теперь давайте посмотрим, можно ли добиться лучших результатов с помощью этих инструментов. Прежде всего, нужно найти все объявленные в исходном коде переменные. Они будут разбиты на узлы VariableDeclaration, в которых может быть ноль или более узлов типа VariableDeclarator. Посмотрите на этот код: let a = 2, b = 3. Здесь несколько переменных объявлены в одной строке. VariableDeclarator содержит обязательное (binding) и факультативное (init) начальное значение. Обязательное значение может быть простым идентификатором (let a = 2), объектом или массивом (let {c} = d, [a] = b;), поэтому нам нужно проверить обязательное значение и его свойства или элементы.

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

Вот как выглядит код:

const { traverse } = require("shift-traverser");

module.exports = function(ast) {
  const ids = [];
  traverse(ast, {
    enter(node, parent) {
      if (node.type === "VariableDeclarator") {
        if (node.binding.type === "ObjectBinding") {
          node.binding.properties.forEach(prop => ids.push(prop.binding.name));
          if (node.binding.rest) ids.push(node.binding.rest.name);
        } else if (node.binding.type === "ArrayBinding") {
          node.binding.elements.forEach(el => ids.push(el.name));
          if (node.binding.rest) ids.push(node.binding.rest.name);
        } else {
          ids.push(node.binding.name);
        }
      }
    }
  });
  return ids;
};

Я опубликовал пакет js-identifiers на npm, и вы можете использовать его в качестве утилиты командной строки. Считайте, что это строки, но только для идентификаторов JavaScript. Как он справится с тем коварным кодом, который мы рассмотрели в качестве примера?

$ js-identifiers scratch.js
findsThisOne
andThisOne
andOneMore
okSoFar
butMissesThisOne
whatAboutThis
or
these
missesTabs
missesNewLines
?_?

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

Что дальше

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

(?(DEFINE)(?'value'(?P>any)?))(?(DEFINE)(?'any'(?P>unbalanced_outer)*(?:(?P>paran)|(?P>curly_braces)|(?P>brackets)|(?P>string)|)+(?P>unbalanced_outer)*))(?(DEFINE)(?'any_inner'(?P>unbalanced_inner)*(?:(?P>paran)|(?P>curly_braces)|(?P>brackets)|(?P>string)|)+(?P>unbalanced_inner)*))(?(DEFINE)(?'paran'\(\s*(?P>any_inner)+\s*\)))(?(DEFINE)(?'curly_braces'\{\s*(?P>any_inner)+\s*\}))(?(DEFINE)(?'brackets'\[\s*(?P>any_inner)+\s*\]))(?(DEFINE)(?'string'((?P>string_double_quote)|(?P>string_single_quote)|(?P>string_tick))))(?(DEFINE)(?'string_double_quote'"(?P>string_context)?"))(?(DEFINE)(?'string_single_quote'\'(?P>string_context)?\'))(?(DEFINE)(?'string_tick'`(?P>string_context)?`))(?(DEFINE)(?'string_context'(?>\\[\s\S]|[^\\])*))(?(DEFINE)(?'unbalanced_outer'[^\(\)\{\}\[\]\"'`,;]))(?(DEFINE)(?'unbalanced_inner'(?:(?P>unbalanced_outer)|[,;])))(var|let|const|\G,)\s+(?:(?<variable_name>\w+)(?:\s*\=\s*(?P>value))?\s*);?

Если вы привыкли работать с регулярными выражениями, использование парсеров и инструментов обхода дерева для анализа JavaScript-кода может показаться вам фильмом ужасов. Но не забывайте, что регулярные выражения не очень эффективны и пользоваться ими непрактично. Частенько они нисколько не приближают нас к цели. Парсеры и АСД — это мощные инструменты, которые упрощают анализ и позволяют выполнять сложные трансформации.

Я привел самый простой пример, который лишь выводит строки в командной строке. Ваш инструмент может быть еще более эффективным, если для манипуляций с АСД и анализа вы будете использовать формат JSON. Ниже вы можете посмотреть пример использования shift-query и shift-codegen для командной строки, которые позволяют выполнять запросы и извлекать произвольный код из исходника на JavaScript. Дополнив это решение несколькими инструментами, вы получите мощнейшее средство обратного проектирования и взлома, для которого нужна только командная строка.


Перевод материала подготовлен в преддверии старта курса "JavaScript Developer. Basic".

Всех желающих приглашаем на открытый урок «Создание интерактивных страниц, работа с анимациями». Урок посвящен анимациям в вебе. Рассмотрим способы создания интерактивных страниц, научимся анимировать переходы состояний HTML элементов, а также создавать анимации как на CSS, так и на JavaScript.

> РЕГИСТРАЦИЯ