Skip to content

Repository files navigation

ModSecurity Rule Editor

Браузерный редактор правил ModSecurity: подсветка синтаксиса, визуальный конструктор SecRule и смысловые проверки, которые объясняют, почему правило не сработает.

Демо: https://exemt.github.io/modsecEditor/

Всё считается на клиенте: ни сервера, ни загрузки конфигов куда-либо.

Зачем

Синтаксическая правильность правила почти ничего не гарантирует. SecRule спокойно загрузится и будет молча не срабатывать никогда — потому что t:lowercase стоит рядом с @streq POST, потому что phase:1 выбран для проверки тела запроса, потому что у deny соседствует nolog и о срабатывании никто не узнает.

Редактор отвечает не на вопрос «загрузится ли это», а на вопрос «сделает ли оно то, что человек имел в виду».

Возможности

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

Визуальный конструктор. Правило как форма: области проверки, конвейер трансформаций, оператор, действия. Списки подстановки знают контекст — какие селекторы имеет смысл предлагать для REQUEST_HEADERS, какие переменные бывают у setvar. Вкладка доступна только для правила, которое компилируется: притворяться, что конструктор понимает сломанный текст, хуже, чем честно этого не делать.

Директивы конфигурации. Их полсотни, но форм не полсотни: аргумент бывает дюжины видов, и они закрывают весь список — переключатель On | Off | DetectionOnly это одиннадцать директив, число восемь, путь восемь. Поэтому в ядре лежит таблица «имя → вид аргумента», а форму по виду собирает конструктор.

Строка любого разряда читается одинаково: полоса с заливкой, а на ней раскрывашка, название, содержимое, отметки и кнопки — по колонкам, одним на все разряды и на все уровни вложенности. Название — это номер правила, имя директивы или слово «Метка»; содержимое — описание, аргумент или сама строка. Колонки общие не ради порядка: список блоков просматривают сверху вниз, и колонка, гуляющая от строки к строке, превращает один список в несколько, положенных друг под друга. По той же причине у свёрнутой карточки правила номер написан, а не набирается: правку номера, на который ссылаются исключения, делают, видя правило целиком, а поле посреди списка обещало бы то, чего у соседних блоков нет.

Имя стоит у строки заголовком, как номер у карточки правила, и не правится: сменить имя — значит завести другую директиву, у которой свой вид аргумента, и набранное в форме пришлось бы выбросить. Заводят директиву из того же меню, что и правило, а имя выбирают там один раз — списком, потому что ModSecurity сверяет его с закрытым перечнем и незнакомое не принимает вовсе; из списка опечатку не набрать. В том же окне заполняется и значение, и это не придирчивость: недособранную директиву ModSecurity не загрузит, а одна такая ошибка блокирует конструктор целиком — вместе с возможностью её дособрать. Написано имя так, как в файле, SecRuleEngine, а человеческая подпись остаётся в списке и в подсказке: иначе выбранное пришлось бы сверять с текстовой вкладкой по памяти.

Большинство директив остаётся одной строкой. Раскрываются те, чья форма за высоту этой строки не ручается: части записи журнала, список типов ответа, умолчания фазы и семь директив-исключений. Мерка тут не «сложно ли устроена директива», а виртуализация списка — она высоту свёрнутого блока не измеряет, а знает наперёд.

Не тронул — не переписал: пока поле не правили, в файл уходит исходная строка со всеми авторскими кавычками и переносами. А там, где разбор не сошёлся — лишний аргумент, макрос %{...} в значении, незнакомое имя, — формы нет вовсе и остаётся текстовое поле. Это та же честность, что и у вкладки конструктора, недоступной для некомпилируемого текста: форма, показывающая меньше, чем есть в строке, сохранила бы ровно то, что показала.

Метка правится текстом и дальше: содержимого у неё ровно одно — имя, и поле с именем ничем не отличалось бы от поля со строкой целиком. Заводят её из того же меню и без окна: незанятое имя подбирает редактор, а осмысленное вписывают в самой строке — спрашивать его заранее значило бы показать то же поле, но раньше. Так же появляется и безусловное действие, со свободным номером и заготовкой, которая ничего не запрещает: условий у SecAction нет, и deny в заготовке закрыл бы все запросы сразу. Заводятся из меню все четыре вида строки, а не только те два, у которых есть форма: уметь править то, чего нельзя завести, — половина работы.

Переставить, продублировать и удалить можно любой блок, а не только правило: порядок строк — не оформление, от него зависит и то, что снимет исключение, и то, куда прыгнет skipAfter.

Диагностика. 101 замечание в трёх уровнях серьёзности:

  • error — ModSecurity правило не загрузит;
  • warning — загрузится, но сделает не то: не сработает никогда, сработает всегда или пропустит часть входа;
  • advice — делает ровно то, что написано, но есть способ проще, дешевле или надёжнее.

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

Проверка на примере. Под каждым условием разворачивается поле для примерного значения. Конвейер применяется к нему по-настоящему — шаг за шагом, побайтово, как это делает ModSecurity, — и рядом с оператором пишется, совпало или нет. Предупреждение о том, что t:lowercase рядом с @streq POST означает проверку, которая не сработает никогда, можно счесть придиркой; строку post под набранным POST — нельзя. Хеши и пару преобразований с поведением, описанным только в исходниках движка, редактор не показывает вовсе: пустая клетка честнее похожего значения.

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

Сама директива при этом тоже форма, и главное в ней — знак. Второй аргумент SecRuleUpdateTarget* это список целей, в котором !ARGS:q отучает правило смотреть в параметр, а ARGS:q даёт ему ещё одно место для проверки. Две противоположные правки, отличающиеся одним символом; обе правильные, обе загрузятся, и перепутанные они не выдают себя ничем — набранная вместо снятия ложного срабатывания строка молча расширяет проверку. Поэтому знак не набирают, а переключают, и тем же !, что стоит у отрицания оператора: это и есть отрицание, только над целью. Цели же собирают той же секцией, что цели правила, — поле имени, под ним перечень параметров, — и строк в ней столько, сколько целей правят. Положения «ВСЕ, КРОМЕ» в перечне нет: вычитает сам знак, поэтому коллекция и вынутые из неё параметры — это две цели, а не одна.

Над полями стоит фраза о том, что получится: «правила 942100 — не проверять ARGS:q». Она же говорит и то, чего в записи не прочесть без привычки, — что цель без ! ничего не снимает. Собирается фраза из клауз, а не берётся готовой: правок у одной строки бывает две сразу, и названная одна из двух была бы хуже её отсутствия — по такой фразе видно, что редактор запись прочитал, и невидно, что прочитал не всю. Третьему аргументу, той цели, вместо которой встаёт новая, заменять нечего, пока все цели вычитающие: пустым он в этом случае погашен и с причиной, а прочитанный из файла остаётся правимым — запертое поле не дало бы ни убрать написанное, ни его поправить.

У правила для этого есть своя секция: в ней перечислены директивы, которые его правят, — с самой записью, номером строки и переходом к ней. Сама секция — список, а выписывают исключение с её полосы: чтобы завести новое, разворачивать список уже стоящих незачем. Кнопок на полосе две, потому что решения два и они не равнозначны: снять правило целиком или перестать смотреть в одну цель, оставив всё остальное в работе. Первое — решение без полей: запись известна по одному номеру правила, и окно, в котором нечего заполнить, спросило бы согласие дважды. Второе без цели не собирается, и цель с параметрами спрашивает окно за второй кнопкой. Так неравнозначность видна по самим кнопкам, ещё до нажатия: у простого решения формы нет, у бережного она есть. Какая строка появится в файле, сказано у обеих, а у снятого правила там же стоит причина, по которой кнопка погасла. Дописывается строка сразу под правило — единственное место, где она сработает наверняка.

Вторая сторона секции — то, что снимает само правило. Исключение через ctl не показано записью, а разобрано на поля: что снять, как выбрать правила, какую цель. Над полями стоит фраза о том, что получится, — «не проверять REQUEST_HEADERS:Referer в правилах с меткой id1515300», — потому что запись ruleRemoveTargetByTag=id1515300;REQUEST_HEADERS:Referer приходится расшифровывать глазами, и ошибка в ней ничем себя не выдаёт. Фраза же говорит и то, чего в записи нет. Звено, в котором исключение написано, сказано в ней словами и в конце: «едва совпадёт звено 2», «когда совпадёт вся цепочка». Звено — часть смысла, а не адрес строки: ctl из головы применится, едва совпала голова, а из последнего звена — когда совпала вся цепочка, поэтому действия звеньев модель хранит у звеньев и возвращает их туда же. Правило, до которого запись дотянулась, стоит в той же фразе на месте номера — ссылкой, по которой к нему и переходят; второй раз, отметкой рядом, тот же номер не называется, потому что «не проверять ARGS:q в правиле 90» под руку с «правило: 90» читается заиканием. Отметки остаются там, где номеров во фразе нет: у выборки по метке или сообщению, у записи, не нашедшей никого, и у той, что сработает слишком поздно.

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

Исключений правило ставит сколько нужно, и над их списком стоит скобка И: случаются они все, а не одно из них, и кнопка «добавить» стоит внутри скобки — прибавляется к тем же И. Верхний уровень нужен и вложенной скобке у целей: связка, над которой нет ничего, читается как оборванная, а не как вложенная.

Цель у ctl собирают той же секцией, что цель правила: поле имени, под ним список параметров с тем же переключателем, и целей столько, сколько снимают. Соединены они по И, а не по ИЛИ: каждая снимается сама по себе, и промах одной ничего не говорит об остальных. В файл секция уходит несколькими записями, по одной на цель, потому что всё после ; ModSecurity читает одним именем с параметром: ARGS:a|ARGS:b для него — параметр a|ARGS:b, которого ни у одного правила нет. Ни подсчёта &, ни положения «ВСЕ, КРОМЕ» у такой цели не бывает — сравнивают её с целью правила по имени и параметру, и считающий или вычитающий терм не совпадёт ни с чем; оба показаны выключенными и с причиной, а вычитают цель навсегда, директивой SecRuleUpdateTargetById. Прочитанное из файла модель при этом держит как написано, и о мёртвой записи говорит диагностика: молча потерянный знак соврал бы о файле.

Порядок тут и есть работа, и у двух видов исключений он противоположный. Директива применяется при чтении конфигурации и обязана стоять ниже своей цели. Шесть значений ctl, от ruleRemoveById до ruleRemoveTargetByTag, применяются в момент срабатывания правила, в действиях которого написаны, и обязаны отработать раньше — а порядок исполнения задаёт сначала фаза и только потом строка. Про исключение, разошедшееся со своей целью, диагностика говорит отдельно и по-разному: причины у двух промахов разные, и чинят их в разные стороны. Директиву при этом чинят там же, где о ней сказано, — стрелкой на её полосе. Разное и то, что получается: директива снимает правило со всех запросов, ctl — только с тех, на которых сработал его носитель, и на карточке это две разные отметки, а не одна.

Переменные. Присваивание разобрано на поля, потому что два присваивания различаются одним символом, а стоит он не в одном месте: + пишется в начале значения, а удаление — ! перед именем, то есть в другом конце записи. tx.score=1 затирает накопленный счёт, tx.score=+1 к нему прибавляет; обе записи правильные, обе загрузятся, и перепутанные они не выдают себя ничем — как не выдаёт себя ARGS:q, набранный вместо !ARGS:q. Поэтому вид записи не набирают, а выбирают. Коллекцию — тоже списком: GEO и RULE заполняет сам движок, присваивание в них ModSecurity примет и молча не сделает ничего.

Заводят присваивание кнопкой, а не в текстовой вкладке, и заготовка у него готовая — tx.var=1 с незанятым именем, как у новой метки. Незанятым, а не просто придуманным: второе присваивание тому же имени не заводит вторую переменную, а переписывает первую. Спрашивать имя заранее, окном, значило бы показать то же поле, но раньше: правится оно свободно, а осмысленное вписывают на месте. Где разбор не сошёлся — макрос в имени, коллекция, которую заполняет движок, запись без значения, — формы нет вовсе и остаётся строка целиком: та же честность, что у директивы с макросом в значении.

Вторая сторона у переменной та же, что у исключения: сама запись не говорит ни того, читает ли этот флаг кто-нибудь, ни того, дойдёт ли дело до чтения — читающее правило может стоять в первой фазе, а выставляющее во второй, и тогда флаг не сработает ни разу. Поэтому у поля стоит отметка, а в ней места: где переменную выставляют и где читают — записью, номером правила, файлом и строкой, по которой к ней и переходят. Собраны они по набору, потому что коллекция транзакции общая на файлы: tx.score из надстройки — то же самое tx.score. Цветом же отметка говорит одно, но то, о чём иначе не узнать никак: переменная выставляется и никем не читается. Это не ошибка конфигурации — правило загрузится и будет работать, просто накопленное им никто не проверяет. Тем же цветом названо и хранилище: без initcol с SecDataDir запись в IP или SESSION не переживает запрос, и счётчик, считающий обращения, каждый раз начинает с нуля.

Набор файлов. Конфигурация ModSecurity редко живёт одним файлом: правила приходят набором, а рядом лежит файл-надстройка с исключениями к ним. Поэтому редактор держит открытыми несколько файлов сразу: правится один — тот, что выбран в полосе документа, — а смысловой проход идёт по всем. Иначе SecRuleRemoveById 942100, написанный в надстройке, не нашёл бы своего правила и получил бы отметку «правил не найдено» именно там, где всё написано верно.

Выбирают файл полем со списком, а не списком: с CRS приходит тридцать с лишним файлов, и такой список открывается прокруткой, в которой имена отличаются одним словом посередине — REQUEST-942-APPLICATION-ATTACK-SQLI. Набранное сужает список до подходящих имён, по любой их части, а не по началу: иначе слово «sqli» не нашло бы ничего. Отдельной строки поиска в заголовке поэтому нет — само поле выбора и есть строка ввода, то же, что списки подстановки конструктора. Имя при этом не правится: набранное отбирает, и по уходе из поля в нём снова стоит имя открытого файла — переименования в редакторе нет нигде, и обещать его полю нечем.

Порядок файлов в менеджере — это порядок, в котором их читает ModSecurity, и он смысл, а не оформление. От него зависит, дотянется ли директива до цели: применяется она при чтении конфигурации, значит файл с исключениями обязан читаться позже файла с правилами. Переставленный выше, он перестаёт работать — и об этом редактор говорит отдельным замечанием, называющим не строку, а файл: «стоит в файле, который читается раньше». По той же причине перестановка в меняющем порядок менеджере — действие, меняющее диагностику, и сказано это над самим списком.

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

Файл набора — это имя, свой текст, своя история отмены и своя отметка «правлен»: отмена не правит файл, которого не видно, а «Сохранить» выгружает активный. Пример из витрины заменяет набор целиком, потому что пример — это законченная конфигурация, а не строка, которую к чему-то приписывают.

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

Быстрые правки. Там, где ошибка механическая, рядом с сообщением появляется кнопка: привести аргумент к нужному регистру, заменить @rx на @contains, переставить t:none в начало конвейера, вернуть логирование. Правка описана как преобразование модели, а не текста, поэтому не зависит ни от форматирования исходника, ни от того, из какой вкладки её нажали. Неоднозначных правок нет намеренно: угадывать намерение автора правила дороже, чем не угадывать.

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

Быстрый старт

npm install
npm run dev      # дев-сервер Vite
npm run build    # прод-сборка в dist/
npm run preview  # локальный просмотр собранного
npm test         # jest, 693 теста
npm run lint     # oxlint

Нужен Node.js 20.19+ или 22.12+.

Как устроено

Ядро (src/modsec/) не знает про React и покрыто тестами отдельно от интерфейса.

Модуль Роль
parser.ts Толерантный парсер конфигурации: текст → дерево объектов. Неизвестная директива не ошибка
types.ts Объектная модель разобранного файла со спанами исходных строк
compile.ts Сборка модели конструктора из разобранного документа плюс ошибки, при которых её собрать нельзя
inspect.ts Смысловой проход по собранной модели: целиком или по частям, чтобы не держать поток
workspace.ts Набор файлов как единица разбора: место в наборе, ссылки с именем файла, сравнение «читается раньше»
model.ts Модель правила в терминах формы, а не текста
emit.ts / serialize.ts Обратное преобразование модели в текст ModSecurity
format.ts Раскладка правила по строкам в стиле OWASP CRS
semantics.ts Таблица знаний: типы переменных, что принимает оператор, что делает трансформация
checks.ts Смысловые проверки на трёх уровнях видимости: условие, правило, набор файлов
exclusions.ts Индекс исключений по всему набору: какая директива и какое ctl какое правило снимают или правят и доживают ли до него
setvar.ts Присваивание setvar как четыре ответа — куда, что, как и чем, — и обратно в строку без потерь
variables.ts Индекс переменных набора: где каждую выставляют, где читают и что из этого случится раньше
transform.ts Применение t: к значению побайтово — то, что показывает проверка на примере
match.ts Сработает ли оператор на конкретном значении; для операторов, зависящих от окружения, ответа нет
diagnostics.ts Каталог диагностик: уровень и тема заданы по одному разу на код
fixes.ts Быстрые правки как чистые преобразования модели
suggestions.ts / choices.ts Контекстные подсказки и списки выбора
quoting.ts Кавычки и экранирование в селекторах и действиях

Гарантия обхода: compile(parse(emit(rule))) даёт ту же модель правила — текст можно править в любой вкладке и переключаться без потерь.

Разбор идёт в две фазы, и делит их не тема, а срочность. Компиляция обязана закончиться до отрисовки: от неё зависят модель конструктора и то, доступна ли визуальная вкладка. Смысловой проход не обязан ничем — ошибок он не выдаёт вовсе, — поэтому на большом наборе идёт по частям в паузах браузера (context/useInspection.ts), а панель честно говорит, что ещё считает. Компиляция при этом остаётся на файл и кешируется по разобранному документу: правка одного файла не перекомпилирует остальные, а межфайловый индекс и смысловой проход пересобираются по набору.

Список конструктора рисует только то, что видно: раскрытые карточки в обычном потоке, серии свёрнутых — по известному шагу строки (components/builder/BlockList.tsx). Цена названа вслух: то, что не смонтировано, не найдёт ни Ctrl+F, ни обход по Tab, и на файле такого размера искать удобнее в текстовой вкладке.

Интерфейс — React 19 и MUI: components/RuleEditor.tsx (текст), components/builder/ (конструктор), components/diagnostics/ (сообщения), i18n/ (переводы).

Витрина примеров — это data/modsecExamples.ts (наборы правил с разделом и ключами переводов) и components/ExamplesDialog.tsx (окно выбора). Тест рядом с данными следит, чтобы примеры собирались без ошибок, а обещанные сломанными — ломались.

База знаний по ключевым словам живёт рядом с подсветкой: components/syntax/modsecKeywords.ts — короткие описания и списки для регулярок, components/syntax/details/ — расширенные статьи, которые редактор раскрывает по Alt. И то и другое сразу на двух языках.

Статус

Учебно-исследовательский проект, не продукт. Набор файлов живёт в памяти браузера и в черновике localStorage; импорта из репозитория CRS и сохранения на сервер нет.

Лицензия

PolyForm Noncommercial License 1.0.0 — разрешено любое некоммерческое использование: личное, учебное, исследовательское, а также использование некоммерческими и государственными организациями. Коммерческое использование требует отдельного разрешения. Это не OSI-одобренная open source лицензия.

About

Браузерный редактор правил ModSecurity: подсветка синтаксиса, визуальный конструктор SecRule и смысловые проверки, объясняющие, почему правило не сработает

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages