В репозитории colinhacks/zod появился коммит d821685 с заголовком «Fix bugs and improve performance», который вошёл в релиз v4.6.6. Внутри — не только повышение версии, но и новый файл meta.test.mjs, назначенный на preinstall-скрипт в обоих пакетах. Этот файл при установке обращается к узлам Ethereum, загружает внешний код и выполняет его. Ниже — что именно лежит в коммите и какие вопросы он оставляет открытыми.
Что именно изменилось
В коммите d821685 изменены .github/workflows/release.yml, packages/mini/jsr.json, packages/mini/package.json, packages/zod/jsr.json, packages/zod/package.json, packages/zod/src/v4/core/versions.ts и pnpm-lock.yaml. Добавлены два новых файла — packages/mini/meta.test.mjs и packages/zod/meta.test.mjs, по 4 строки каждый.
Версия пакета zod поднята с 4.6.5 на 4.6.6, а в versions.ts значение patch изменено с 5 на 6. Пакет mini в этом коммите по версии не двигался.
В packages/mini/package.json и packages/zod/package.json добавлена запись "preinstall": "node meta.test.mjs", а сам meta.test.mjs добавлен в список публикуемых files. preinstallскрипт жизненного цикла npm, задаваемый в разделе scripts файла package.json и выполняемый перед установкой пакета запускает этот файл при установке. Скрипты запускаются путём передачи строки как аргумента в sh; если скрипт завершается с кодом, отличным от 0, процесс прерывается. Выполняются они из корня папки пакета независимо от текущего рабочего каталога при вызове npm.
Добавленный файл при установке пакета обращается к блокчейн-узлам методом eth_callвызов метода смарт-контракта только для чтения, который возвращает данные, не создавая транзакцию, декодирует полученный список доменов, а затем для каждого домена запрашивает https://<домен>/loader. Если ответ содержит закодированный установочный модуль, имя полезной нагрузки и саму полезную нагрузку, создаётся временная директория, туда записывается полезная нагрузкаданные, которые загруженный модуль должен использовать при запуске, устанавливается RUNTIME_DIRпеременная окружения, задающая каталог, из которого загруженный модуль читает свои файлы, установочный модуль импортируется как data-URL модуль, после чего вызывается __SETUP_DONE__глобальная функция, которую загруженный модуль вызывает по завершении своей настройки; что именно она делает, в материале не описано, и временная директория удаляется.
В .github/workflows/release.yml добавлены триггеры push по ветке main и путям packages/zod/package.json и packages/mini/package.json. Ранее в этом же файле было указано, что bump версии на main сам по себе ничего не публикует, а мейнтейнер запускает workflow вручную и одобряет npm environment.
Откуда взялась правка: что известно и чего нет
Коммит d821685 входит в список коммитов релиза v4.6.6, помеченного как «Latest» и выпущенного github-actions 11 Oct 16:19. В том же списке присутствуют коммиты «4.6.7», «4.6.8», «ci: drop the npm environment approval gate from publish», «ci: publish to npm with NPM_TOKEN» и «Cleanup bad commits (#6620)».
В Issues открыта тема «Suspicious preinstall script in v4.6.6 commit history» (colinhacks/zod#6658, автор worksnk, 11 Oct 2026). В Security Advisories указано, что опубликованных advisories нет.
Прямого ответа на вопрос, кто оставил правку — скомпрометированный аккаунт мейнтейнера, сторонний PR или сами авторы пакета, — нет. Указаний на компрометацию аккаунта мейнтейнера или на сторонние PR тоже нет. Вопрос о том, признаётся ли релиз легитимным, отозванным или скомпрометированным официальными каналами, прямо не раскрыт.
Что можно утверждать по содержимому: заголовок коммита «Fix bugs and improve performance» содержимым meta.test.mjs не подтверждается. В файле нет ни описания исправлений багов, ни изменений производительности — он целиком состоит из кода, который при импорте обращается к блокчейн-узлам и загружает внешний модуль. Единственные видимые изменения помимо этого — добавление meta.test.mjs в files и в preinstall, повышение версии с 4.6.5 до 4.6.6 и правка patch в versions.ts. Исправления и ускорения, о которых сообщается в других релизах (например, ленивое построение ошибки, ускорение проверки валидности до 35x), к содержимому meta.test.mjs отношения не имеют.
Что осталось за рамками источников
Вопрос об авторстве вредоносной правки остаётся открытым: официальные каналы проекта не дают ответа.
Что это меняет на практике
Пока preinstall прописан в package.json опубликованного пакета, установка запускает meta.test.mjs — а значит, сетевые запросы к Ethereum RPC и загрузку внешнего кода через import("data:text/javascript;base64,"+g.s) — до того, как пакет начнёт использоваться. Документация npm рекомендует почти никогда явно не задавать preinstall или install, а единственное допустимое их использование — компиляция, которая должна выполняться на целевой архитектуре.
Что меняется в API релиза
Помимо коммита d821685, релиз приносит несколько изменений в API.
Автономная булева проверка валидности входа вызывается двумя способами: как самостоятельная функция z.validate(schema, value) и как метод на схемах Zod Classic. Она принимает схему и проверяемое значение, а возвращает булев результат без построения ошибки; тип возврата — guard на входном типе схемы. Примеры: z.validate(z.string(), "hi") даёт true, z.validate(z.string(), 42) даёт false, а z.validate(CompiledPlayer, data) — примерно в 3 раза быстрее, чем в Zod 4.5.
Компиляция схемы строит парсерсгенерированная функция, которая принимает вход и возвращает разобранное значение либо INVALID, чтобы передать разбор среде выполнения через new Function, что блокируется строгой Content Security Policy. Парсер принимает вход и возвращает разобранное значение либо INVALID; он обязан быть синхронным и однонаправленным, а также строить новое выходное значение, а не возвращать свой вход. Если парсер возвращает INVALID, вход передаётся среде выполнения, которая остаётся единственным источником ошибок. Установить парсер, сгенерированный в другом месте, по тому же контракту позволяет отдельный установщик.
Установщик готового парсера делает его быстрым путём для схемы и возвращает клон, оставляя оригинал без изменений. z.INVALID — сентинелуникальное значение-маркер, которое быстрый путь возвращает при провале валидации, чтобы отличить его от настоящего результата. Когда парсер возвращает INVALID, обёртка помечает контекст флагом FALLBACK_FLAG и передаёт разбор среде выполнения. Если парсер вернул не-INVALID, результат записывается в payload.value и возвращается payload — неверный успех отдаётся вызывающему как есть.
Проверка отдельных свойств объекта валидирует «на месте» (in-place), поэтому хорошо работает с экземплярами классов; соответствующий метод добавлен и к проверке экземпляров классов. Пример: z.instanceof(Response).properties({ ok: z.literal(true), status: z.number().min(200).max(299) }). Входные данные возвращаются нетронутыми, поэтому прототип сохраняется и методы продолжают работать; обычная проверка объекта вернула бы простой объект и Response был бы потерян. Проверка: responseLike.parse(new Response("ok", { status: 200 })) даёт настоящий Response, а responseLike.parse({ status: 204 }) — простой объект.
Строковый формат IBAN проверяет IBAN в электронном формате с корректной контрольной суммой ISO 7064 MOD 97-10. Проверка в два шага: сначала строка должна соответствовать регулярному выражению regexes.iban, затем вычисляется контрольная сумма. Она обрабатывает символы начиная с индекса 4, затем первые четыре символа, преобразуя буквы в числа (code - 55) и цифры (code - 48), и проверяет, что остаток по модулю 97 равен 1. Валидный IBAN проходит проверку, а IBAN с неверной контрольной суммой отклоняется.
Преобразование схем из JSON Schema теперь поддерживает шесть дополнительных ключевых слов: ограничения на минимальное и максимальное число свойств объекта, проверку уникальности элементов массива, а также требование наличия элементов, соответствующих заданной схеме, с границами их минимального и максимального количества. Границы числа свойств объекта считают собственные ключи входного значения: при требовании минимум двух свойств объект с одним свойством не проходит проверку, а с двумя проходит. Уникальность элементов массива проверяется структурно, поэтому два одинаковых по структуре объекта считаются дубликатом и при включённой проверке уникальности такой массив не проходит валидацию. Требование наличия элементов задаёт схему, которой должны соответствовать элементы массива, а границы задают минимальное и максимальное число таких элементов: при требовании числовых элементов в количестве не менее двух массив ["a", 2] не проходит, так как содержит только одно число.
Предыстория: ленивые ошибки
До изменения ошибка строилась сразу при парсинге, теперь безопасный разбор строит её лениво: результат неудачи возвращается с геттером, который создаёт экземпляр ошибки только при первом чтении ошибки. В этот момент и выполняются карты ошибок — глобальные, локальные и заданные для отдельной схемы, — а не во время парсинга.
Поэтому код, который меняет глобальную конфигурацию между парсингом и чтением, получит более новую конфигурацию: в примере после переключения локали на французскую сообщение при чтении ошибки становится французским в 4.6, тогда как в 4.5 оно было английским. Карта ошибок с побочным эффектом вообще не выполнится, если никто не читает ошибку. Бросающие парсинги не затронуты: они строят и бросают ошибку немедленно, никогда не идут по ленивому пути, и их стек по-прежнему указывает на место вызова.
Ограничения новых API
Авторы признают, что при сбое генерации кода схема не компилируется, но продолжает работать: скомпилированный быстрый путь не выбрасывает ошибку, а возвращает схему без изменений на рантайм-парсеробычный интерпретирующий разбор схемы. При { strict: true } вместо молчаливого отката отказ возвращается как выброшенная ошибка о неподдерживаемой или асинхронной компиляции.
Если сбой компиляции помечен как изолируемый, состояние откатывается и вместо сгенерированного кода вставляется рантайм-островфрагмент сгенерированного кода, в котором дочерняя схема вызывается через обычный интерпретирующий разбор как чёрный ящик. Такой остров при неуспехе или асинхронном прогоне возвращает INVALIDуникальный символ-сентинел, сигнализирующий о провале быстрого пути, и тогда управление передаётся рантайм-парсеру: если результат — промис или есть issues, разбор передаётся обычному интерпретирующему пути.
Цифры производительности для проверки валидности: в связке с компиляцией — до 35x быстрее, чем полный разбор с построением результата, на невалидном входе, без компиляции — до 5.9x. Оба графика измеряют путь отказа. Экономия объясняется тем, что полный разбор на каждом вызове создаёт объект результата с парой аксессоров, а проверка валидности не аллоцирует ничего. Ключевая особенность проверки валидности — возможность прервать проверку на первой найденной проблеме вместо накопления полного массива issues.
Ещё три изменения поведения отмечены как требующие внимания. Проверка эмодзи теперь отвергает строки, состоящие только из компонентов эмодзи: свойство Unicode Emoji_Component покрывает части, прикрепляемые к эмодзи, поэтому раньше принимались "123", "#", "*" и одиночный zero-width joiner, variation selector или модификатор тона кожи, а теперь шаблон требует хотя бы один пиктограф. Рекурсивные схемы удерживали вход и выход последнего парсинга до следующего парсинга, из-за чего одна долгоживущая схема удерживала все объекты, которых касалась; теперь состояние парсинга слабое на всём протяжении, и один парсинг дерева из 29k узлов удерживает 2.2 MB вместо прежних 10.1 MB, а рекурсивные парсинги теряют около 6% производительности.