Салют, Хабр!

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

Можно не писать самому регулярные выражения. Вообще не использовать их в коде. Но они всё равно там окажутся. Допустим, мы хотим написать проверку email-адреса, чтобы пускать людей с нужным корпоративным аккаунтом. Написали заглушку на FastAPI:

from fastapi import FastAPI
from pydantic import EmailStr

app = FastAPI()

@app.get("/")
async def validate_domains(email: EmailStr):
   return {"valid": True}

Выглядит легитимно, но, если выкатить, прод упадёт. Причина в ReDoS. Почему он возникает? Смотри ниже.

Откуда берётся backtracking? 

Проблема в так называемых возвратах регулярных выражений — backtracking. На некоторых текстах одни и те же символы обрабатываются очень много раз. В 2019 году эта невинная особенность регулярок на 27 минут положила CloudFlare. Вот он, преступник:

(?:(?:\"|'|\]|\}|\\|\d|(?:nan|infinity|true|false|null|undefined|symbol|math)|\`|\-|\+)+[)]*;?((?:\s|-|~|!|{}|\|\||\+)*.*(?:.*=.*)))

Паттерн .*.*=.*. породил бесконечный backtracking и обрушил систему. Полная история тут.

Причину возвратов нельзя понять без небольшого погружения в теорию. Регулярные выражения почти без изменений можно перерисовать в виде схем; для этого есть множество инструментов, например, regexper.com. И де‑факто эти схемы очень похожи на реальную математическую модель регулярок, недетерминированные конечные автоматы (НКА). НКА можно представить в виде графа, где каждая вершина — это некоторое состояние, каждое ребро — чтение символа (а пустые переходы — чтение пустого символа), плюс также зафиксированы начальное состояние и конечные.

Схема (x+)+y и "автомат" для неё
Схема (x+)+y и "автомат" для неё

Картинка выглядит более громоздко, но это уже полноценная модель. При этом сопоставляется с исходной схемой почти 1-в-1. Матч получается, если мы можем из начала попасть в конец хотя бы одним путём. Например, рассмотрим простой негативный пример, abc:

Матчинг abc
Матчинг abc

А вот простой позитивный пример, xy:

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

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

Можно рассмотреть ту же регулярку (x+)+y и пример xxxx. Он очевидно не подходит, но программа об этом ещё не знает. Регулярки матчат группы жадно (пытаются забрать как можно больше), значит, можно построить первые итерации:

  1. (xxxx) → нет y, остановка

  2. (xxx)(x)

  3. (xx)(xx)

  4. (xx)(x)(x)

  5. (x)(xxx) и другие

Количество вариантов растёт экспоненциально, а это значит, что даже небольшая, менее 40 символов, строка заблокирует этот поток полностью.

Теперь можно возвращаться к исходному сниппету FastAPI.

from fastapi import FastAPI
from pydantic import EmailStr


app = FastAPI()


@app.get("/")
async def validate_domains(email: EmailStr):
   return {"valid": True}

Внутри матрёшка:

  1. FastAPI принял запрос;

  2. валидирует pydantic;

  3. для валидации используется email-validator.

Можно предположить, что проблема лежит где‑то в валидации email. Но всё гораздо тривиальнее. Перед валидацией email надо извлечь из строк с именем, например, Whatever <foobar@example.com>. Проблема именно на этом этапе. 

Покажу кусок PR (#10601):

-    email_group = r<\s*(.+)\s*>
+    email_group = r<(.+)>

(лишних пробелов не будет, потому что позже всё равно делается .strip())

В изначальной версии регулярного выражения пробел может быть «съеден» в любом из трёх разных мест: \s*, (.+), \s*. Они идут подряд, поэтому в худшем случае это может привести как раз к кубической сложности O(N3).

PR влит, в свежих версиях проблема исправлена. Почти случайно нашлась проблема в очень популярном проекте. Что делать дальше? Нужно посмотреть на другие! Для анализа, после доработки Regexploit напильником, (ссылка в конце текста) проверяем три разных списка Python‑проектов с GitHub: Awesome Python, Top 100 Stars in Python и персональный Awesome Python. Они пересекаются, но не слишком сильно. Предупреждение: оценка может быть завышена, так как результаты проверены на ложные срабатывания выборочно.

Вывод: проблемы есть, и их много. Удобнее всего разбить их на два класса:

  1. Кубические (O(N3) и выше) — 12%, то есть каждый восьмой проект содержит потенциально проблемные регулярки! Задержка — примерно секунда на 2000 символах, как в pydantic.

  2. Экспоненциальные (O(eN)), как (x+)+y. По моим оценкам от 2 до 5%, немного по-разному для разных списков. Backtracking в них — почти бесконечность на твите, 140 символов.

В конце статьи оставил список проектов с моими PRами. На текущий момент все вмержены, так что можно аккуратно предположить, что проблема действительно была. Рассуждать о её серьёзности сложно — много экспоненциальных срабатываний на тестах, которые не так честно учитывать.

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

Как проще всего решить проблему регулярок?

Решение, внезапно, почти такое же старое, как сами недетерминированные автоматы: автоматы детерминированные. Для иллюстрации также возьмём регулярку [ab]*[ac]*a+[ac]+ и построим её схему. И сразу строим детерминированный автомат (ДКА):

Схема и НДКА для [ab]*[ac]*a+[ac]+
Схема и НДКА для [ab]*[ac]*a+[ac]+

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

С точки зрения математики любой НКА можно перевести в ДКА. Вместе с тем ДКА не используют всюду по трём с половиной причинам. Во‑первых, из‑за того, что связь с исходной схемой (=выражением) в целом теряется, у нас гораздо меньше контроля над процессом (об этом написано в .NET доках). Во‑вторых, перестаёт работать часть фич — например, бекреференсы (backreference) использовать не получится. В‑третьих, с оптимизациями сложнее, поэтому могут быть медленнее.

Кроме того, существует фундаментальная теоретическая проблема, хотя я не ловил её на реальных примерах: в худшем случае конвертация НКА → ДКА будет экспоненциальной, а значит, может потребовать очень много памяти. Дело в особенности процесса конвертации НКА в ДКА: это происходит через конвертацию каждого набора состояний НКА в одно состояние ДКА. Число наборов состояний — это экспонента 2N, где N — число состояний НКА. Вместе с тем реальной жизни эти состояния почти всегда пустые, то есть в ДКА не попадают.

Далее берём несколько доступных для Python движков регулярных выражений. Их много, но production‑ready гораздо меньше.

  • pcre2 — биндинг к PCRE2. Много оптимизаций, JIT. НКА (где‑то внутри есть ДКА, но в биндингах не видел).

  • RE2 — создана Google в 2010, используется в Sheets и др. ДКА. Есть много биндингов для всего.

  • Rust Regex с биндингом flpc. ДКА. flpc cейчас архивирован.

  • Regex. In-place замена Python re. Добавляет фичей.

Теперь можно посмотреть НКА и ДКА на реальных примерах. Чтобы показать разницу, посмотрим на максимально простом примере,  IP-адреса. И возьмём самый простой вариант, (\d+\.)+\d+ — матчит просто числа через точку.

Время работы от длины текста, (\d+\.)+\d+
Время работы от длины текста, (\d+\.)+\d+

Чётко видно, что ReDoS мы получили даже на примере, где его как будто и нет вовсе. Получается, проблема может быть специфична именно для одного движка.

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

x = (0|1|2|3|...|255)
(x\.){3}x

(выражение конструируется, но читается/валидируется максимально легко)

Скрытый текст

А реальные регулярки на этом примере начинают превращаться в нечитаемую кашу. Например, этот ответ на SO на почти 50 вариантов. Примеры для email лучше даже не смотреть.

Очевидно, никаких катастрофических возвратов быть не может — пример явно лишний. Но всё-таки проверим:

Время работы от длины текста, сложный пример IPv4
Время работы от длины текста, сложный пример IPv4

Движки с ДКА (обозначены пунктиром) лежат гораздо ниже остальных. Почему так? Дело в том, что возвраты всё равно возникают, просто их не больше 255 на каждую группу. Это даёт ещё одну причину выбирать ДКА‑движки. Они могут быть медленнее, но производительность остаётся стабильной всегда.

Но это были совсем простые примеры, хочется чего‑то сложнее. Строим бенчмарк для движков! Хотя тут есть ряд оговорок. Как было показано выше, реально оценить тяжело — всё зависит от входных данных и параметров. Время компиляции паттернов минимально даже при наличии JIT, я его не учитывал. Также для тестирования использованы плохие и неудобные примеры — как раз те, что я нашёл при ручном анализе репозиториев.

Ниже графики перцентилей времени работы: чем линия ниже, тем лучше. ДКА движки показаны пунктиром.

Общий бенчмарк, нижние перцентили (=лучшие примеры)
Общий бенчмарк, нижние перцентили (=лучшие примеры)

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

Согласно бенчмарку, в лучших запусках re2 значительно уступает всем, кроме pcre2. А вот встроенный движок re очень плохо справляется с возвратами: в случае сомнений лучше его вообще не рассматривать. ДКА движки на втором графики сливаются вместе, и это ожидаемо — они нечувствительны к возвратам. regex справляется значительно лучше pcre2, классические движки по-разному уязвимы к возвратам. Быстрее всех, на удивление, оказался  flpc (rust-regex).

Как ещё стоит работать с регулярными выражениями?

  • Если регулярки не приходят от пользователей, то достаточно их проверять.

  • Сверхжадные модификаторы (++, *+; python 3.11+) всех спасут, так как блокируют возвраты.

  • Возможно, лучше перейти на движки с ДКА. Для Python проще всего взять RE2: он может быть медленнее, но полностью защищает от попадания на ReDoS.

  • Rust Regex очень‑очень хорош, но актуальных биндингов я не нашёл (flpc и rure мертвы?). Если нужно много производительности на Python, то 100% заслуживает внимания.

Где узнать больше?

Ресурсы для проверки и валидации

Актуальные бенчмарки

  • Хороший набор — можно сравнивать производительность между движками и языками более глубоко, а также на разных задачах: https://github.com/BurntSushi/rebar

Под спойлером PRы с фиксами, которые я сделал при подготовке. Также указана сложность регулярок.

Скрытый текст

Комментарии (42)


  1. user-book
    08.10.2025 16:26

    появление нейронок было лучшим, что было сделано для регулярок!


    1. h1pp0 Автор
      08.10.2025 16:26

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

      • ^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$

      • Проверяет базовый формат: 4 числа от 0-255, разделенные точками

      и

      • ^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$

      • 25[0-5] - числа 250-255

      • 2[0-4][0-9] - числа 200-249

      • [01]?[0-9][0-9]? - числа 0-199

      Это удобно и понятно, но некорректно, к сожалению. Первый вариант будет матчить 333.121.422.112, а второй вариант матчит ведущие нули 012.3.02.100.
      После этого верить более сложным регуляркам не очень хочется. Гораздо надёжнее для популярных скопировать со SO, а непопулярные собрать вручную, например, тут.


      1. user-book
        08.10.2025 16:26

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

        то что вы предложили, оно удобно для проверки и отладки

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


        1. nin-jin
          08.10.2025 16:26

          А как вы его себе представляете? Что-то типа Scratch?


      1. riv9231
        08.10.2025 16:26

        Не знаю, я почему-то не сталкивался, например делал парсинг конфига VM proxmox регуляркой и все работало, правда это был не deepseek, а что-то из старых не больших нейросетей, типа Command R+ которые я запускал у себя.

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


  1. apevzner
    08.10.2025 16:26

    Я просто оставлю это здесь:

    Russ Cox. Regular Expression Matching Can Be Simple And Fast (but is slow in Java, Perl, PHP, Python, Ruby, ...)

    P.S. У Go реализация регулярных выражений из стандартной библиотеки гарантирует, что время сопоставления растёт, как O(n), где n - размер входного текста.


    1. h1pp0 Автор
      08.10.2025 16:26

      Видел эту статью, когда готовил материал. НКА vs ДКА тема очень старая, есть даже на Хабре статья про ReDoS. Но для меня стало неожиданностью, что

      1. Даже в самых звёздных проектах на GitHub уязвимых регулярок много.

      2. Далеко не во всех языках используется безопасная версия по умолчанию.

      3. Простых инструментов для автоматического поиска проблем я не нашёл.


      1. apevzner
        08.10.2025 16:26

        Регулярки, которые умеют в обратные ссылки (backreference) не являются регулярными грамматиками по Хомскому и не могут быть преобразованы в регулярный ДКА. Наверное, есть граждане, которым обратные ссылки действительно нужны...

        С другой стороны, синтаксис e-mail адреса (per RFC 822) не описывается регулярной грамматикой...


        1. h1pp0 Автор
          08.10.2025 16:26

          Регулярки, которые умеют в обратные ссылки (backreference) не являются регулярными грамматиками по Хомскому и не могут быть преобразованы в регулярный ДКА

          На практике этот смысл потерялся очень давно. Справка Python, например, вообще не упоминает Хомского или "регулярный язык". Не могу сказать, что это было часто, но продвинутые фичи включая бекреференсы я встречал, насколько помню, в ~3-5% регулярок от общего числа просмотренных.

          Я бы сказал, что реально это нужно редко, но когда нужно, то возможность остаться со знакомым инструментом подкупает. Последний раз что-то сложное я собирал на Boost.Spirit ~10 лет назад.


        1. nin-jin
          08.10.2025 16:26

          Это что же там не описывается регуляркой?


          1. h1pp0 Автор
            08.10.2025 16:26

            К своему стыду, не читал этот RFC (822) раньше, но, например, определение комментария само по себе регуляркой не парсится comment = "(" *(ctext / quoted-pair / comment) ")" (пункт 3.3), т.к. тут вложенные скобки произвольной глубины.


            1. apevzner
              08.10.2025 16:26

              Более того, есть две формы адреса:

              vasya@pupkin.com (Vasya Pupkin)
              Vasya Pupkin <vasya@pupkin.com>
              

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


            1. nin-jin
              08.10.2025 16:26

              Раздел 3 про парсинг самих сообщений, про адреса раздел 6, там нет никаких комментариев.


          1. apevzner
            08.10.2025 16:26

            Примеры валидных адресов:

            vasya@pupkin.com (Vasya Pupkin)
            Vasya Pupkin <vasya@pupkin.com>
            

            есть варианты и позаковыристее. Не надо думать, что e-mail address, это только user@example.com


            1. nin-jin
              08.10.2025 16:26

              Вы путаете собственно адрес, который идентифицирует мейлбокс с синтаксисом полей почтового сообщения, которые которые могут вообще ни одного мейлбокса не содержать, а только имя группы:

              foobar:;


              1. apevzner
                08.10.2025 16:26

                В RFC5822, на который вы ссылаетесь, address определён со всеми этими наворотами, а то, что имеете ввиду вы, это кусок адреса, addr-spec.

                Более того, RFC6068, определяющий схему mailto, специально оговаривает, что address, это как в RFC5822, со следующими оговорками: (...), фактически сводя address к addr-spec.

                addr-spec, наверное да, регулярен. Но со всеми интернационализационными наворотами это не точно.


                1. nin-jin
                  08.10.2025 16:26

                  Я смотрю тут у нас какая-то спец-олимпиада по чтению надписей на заборе.


  1. alpaca
    08.10.2025 16:26

    Прекрасный текст. Очень интересно.


  1. Alexandroppolus
    08.10.2025 16:26

    бекреференсы (backreference) использовать не получится.

    Видимо, есть какой-то обходной путь, потому что например регексовый движок в Сафари умеет в бекреференсы, но не боится плохих регулярок (даже если они содержат backreference)


    1. apevzner
      08.10.2025 16:26

      Можно упростить. Там в целом довольно понятная алгоритмика, хоть аккуратно её реализовать - муторно, аж жуть :)


  1. winorun
    08.10.2025 16:26

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

    Почему такое еще не придумали, идея то на поверхности?


    1. apevzner
      08.10.2025 16:26

      Придумали, конечно. ABNF, например, и всякое такое.

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

      Регулярки хороши, чтобы разбивать текст на лексемы, и классический парсер, он двухуровневый: лексемы выделяются регулярными выражениями, потом делается уже синтаксический анализ по грамматике, описанной каким-нибудь вариантом BNF (Backus–Naur form)


      1. nin-jin
        08.10.2025 16:26

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


        1. winorun
          08.10.2025 16:26

          Что вам мешает разбить XML на лексемы?


          1. nin-jin
            08.10.2025 16:26

            html - не xml


            1. winorun
              08.10.2025 16:26

              С каких пор? Что в html 5 появилось такого что он перестал быть подмножеством xml? Да еще на уровне лексем. Это не наезд, я реально спрашиваю, так как темы не касался довольно давно.


              1. nin-jin
                08.10.2025 16:26

                Он никогда и не был подмножеством xml, это подмножество sgml. А вот xhtml - подмножество xml.


                1. winorun
                  08.10.2025 16:26

                  Это всё очень интересно и дискусионно. Но меня больше интересует практический вопрос. Что мешает разбить html на лексемы? Пример можно? Я не могу придумать примера в который утнется конечный автомат при лексическом разборе. При синтаксическом он очевиден. Вы этот пример, по Вашим словам, знаете.


                  1. nin-jin
                    08.10.2025 16:26

                    <html><body>
                    	<script>
                    		alert(1)
                    		// <script>
                    		alert(2)
                    	   	// </script>
                    		alert(3)
                    	</script>
                    </body></html>


                    1. winorun
                      08.10.2025 16:26

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

                      html, body, script, text(который будет содержать примерно такой текст: "alert(1)// <script>alert(2) // </script>alert(3)"), /script, /body, /html
                      

                      В то время как лексический анализатор выдаст:

                      html, body, script, text("alert(1)//<script>alert(2) //"),/script, text("alert(3)") /script, /body, /html
                      

                      Пример классный. Но у меня он на практике не сработал. Браузер выдает второй вариант.


                      1. nin-jin
                        08.10.2025 16:26

                        Ну так в браузере синтаксический анализатор, а не лексический.


                      1. winorun
                        08.10.2025 16:26

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


                      1. nin-jin
                        08.10.2025 16:26

                        В браузере оба

                        Нет. По озвученной ранее причине - без синтаксического анализа невозможно разбить на лексемы.


                      1. winorun
                        08.10.2025 16:26

                        Так приведи пример. Если бы предыдущий пример работал, я бы с Вами согласился, покрайне мере в озвученной выше причине. Но он не работает.


                      1. winorun
                        08.10.2025 16:26

                        Если бы я не увидел в браузере эту картинку я бы с вами согласился.


                      1. winorun
                        08.10.2025 16:26

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


                      1. winorun
                        08.10.2025 16:26

                        А нет, не рабочая. можно сделать, условно <script>*</script>
                        Извините что спамлю комментариями.


                      1. nin-jin
                        08.10.2025 16:26

                        Думаю вам стоит самому попробовать реализовать лексер HTML - только тогда поймёте в чём проблема.


                      1. winorun
                        08.10.2025 16:26

                        Дело в том что парсер html я как раз делал. И делал я его лет 15 назад. Но так как читать стандарт мне было лень, а парсер я делал довольно ограниченный. Я ограничился проверкой на довольно маленькой выборки. И с лексическим анализом я проблем не встретил. В синтаксическом там да проблемы были.

                        Но Вы утверждаете что с лексическим анализом на регулярках будут проблемы. Вот мне и интересно в чём там может быть проблема. Но всё Ваши утверждения сводятся к: "Я так считаю". Пример ограниченности возможности использовании конечного автомата как раз любят показывать на синтаксисе html. Но если бы были проблемы с лексическим анализом, почему не показать на нем. Это же было бы нагляднее и проще.


                      1. nin-jin
                        08.10.2025 16:26

                        Разбор токенов после тега зависит от его имени.


                      1. winorun
                        08.10.2025 16:26

                        Вы имеете ввиду атрибуты тега? Так не зависит. Атрибуты у всех тегов одинаково записываются. Там проблема не забыть что кавычки разные и экранированный символ. Если вы имеете ввиду другие теги которые меняют логику работы парсера. То тут пример с тегом скрипт был бы идеальным, если бы работал. Из тегов меняющих логику парсера остаётся только style. Сейчас проверю, но думаю и тут не сработает.

                        Проверил. Не сработало.


  1. winorun
    08.10.2025 16:26

    dell