Браузерный редактор правил 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 лицензия.