Назад к блогу

Zod 4.6.6: что нашли в коммите d821685 и что это значит для установки пакета

Zod 4.6.6: что нашли в коммите d821685 и что это значит для установки пакета

В свежем коммите библиотеки Zod обнаружен `preinstall`-хук, который при установке пакета обращается к блокчейн-узлам, загружает внешний код и исполняет его — при этом под безобидным заголовком о багфиксах. Разбираем, что именно лежит в коммите, какие вопросы об авторстве и легитимности релиза остаются без ответа и как это влияет на установку пакета.

В репозитории 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 запускает этот файл при установке. Скрипты запускаются путём передачи строки как аргумента в 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.

Компиляция схемы строит парсер через 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% производительности.

Источники

Похожее