Git submodules как пакетный менеджер: разбор недостатков и четырёх CVE

Автор статьи добавил рабочее дерево (worktree) к репозиторию, чтобы попробовать ветку рядом с основной копией, выполнил в нём git submodule update --init, потому что сборке требовались зависимости из сабмодулей, а затем попытался удалить это дерево командой git worktree remove, и git отказал. Согласно man-странице, удалять без флага --force можно только «чистые» рабочие деревья, а «нечистые или те, что содержат сабмодули» требуют --force; сабмодули вынесены в этой формулировке отдельным пунктом, не как часть «грязного» состояния. Команда git worktree move ещё строже: она отказывается работать с любым деревом, где есть сабмодули, вообще без вариантов. Это натолкнуло автора на мысль разобрать сабмодули как пакетный менеджер целиком.

Команда git worktree появилась в анонсе git 2.5 от GitHub в июле 2015 года с оговоркой: использовать её в репозитории с сабмодулями не рекомендуется. Спустя одиннадцать лет git по-прежнему требует --force для удаления такого дерева и отказывается его перемещать. Между делом команду worktree add пришлось патчить, чтобы она игнорировала настройку submodule.recurse: если её соблюдать, внутренний reset --hard начинал рекурсивно заходить в пути сабмодулей, которые в свежем дереве ещё пусты.

Центральный тезис автора: у сабмодулей уже есть все составные части пакетного менеджера, просто они плохо состыкованы. Гитлинк, запись SHA коммита по пути с режимом 160000 в дереве суперпроекта, это запись лок-файла. Файл .gitmodules, сопоставляющий пути с URL для загрузки, это манифест. Команда git submodule update, которая читает оба и заполняет рабочее дерево, это шаг установки. Точность пина не уступает любому пакетному менеджеру: это точный коммит по идентификатору объекта.

Проблема разрешения адресов: гитлинк хранит только SHA коммита, а .gitmodules несёт URL для каждого сабмодуля, и клонирование оттуда, единственный способ разрешения. Если репозиторий переименован, перенесён на другой хостинг или закрыт, все нижестоящие пины ломаются, хотя сам SHA не менялся и объекты по-прежнему есть в каждом клоне, где они уже загружены, у git просто нет способа сопоставить идентификатор коммита с сервером, который его хранит. Более того, git копирует каждый URL в .git/config суперпроекта при первом запуске git submodule init под ключом submodule..url, и дальнейшие команды читают именно оттуда, игнорируя .gitmodules; правка закоммиченного .gitmodules на зеркало или форк не подействует на уже инициализированный клон, пока не выполнен git submodule sync. В CI это обходят настройкой url..insteadOf, которая переписывает URL по совпадающему префиксу перед загрузкой, например, https://github.com/ на git@github.com:, чтобы применился SSH-ключ доступа, или перенаправлением внутреннего адреса на зеркало.

Установка: обычный git clone записывает гитлинк в индекс, из-за чего каталог сабмодуля появляется, но остаётся пустым, пока не выполнен git submodule update --init или клонирование не сделано с флагом --recurse-submodules; настройка submodule.recurse, которая заставляет checkout, fetch, pull, grep и другие команды рекурсировать автоматически, по умолчанию выключена. По умолчанию update выкладывает закреплённый в гитлинке коммит в отсоединённом состоянии (detached HEAD); флаг --init копирует недостающие записи из .gitmodules в .git/config, а --remote вместо гитлинка выкладывает верхушку настроенной отслеживаемой ветки сабмодуля. Смена ветки в суперпроекте меняет гитлинк в индексе, но не трогает рабочее дерево сабмодуля, из-за чего git status сразу показывает его как изменённый, если только не использовать --recurse-submodules при checkout или ту же настройку submodule.recurse. Проект Rust в своём отчёте о переходе подпроектов компилятора с сабмодулей на другой механизм перечисляет ровно этот набор проблем на собственном опыте: пустые или неверно выложенные каталоги после клонирования, случайные изменения сабмодулей, попадающие в пул-реквесты из-за того, что переключение ветки оставило сабмодуль «грязным», и отдельную логику в инструменте сборки, которая перед сборкой принудительно выкладывает каждый сабмодуль на нужный коммит.

Хранение: git-каталог сабмодуля лежит под $GIT_DIR/modules// суперпроекта, а в рабочем дереве сабмодуля вместо каталога .git лежит файл-указатель на него; команда git submodule absorbgitdirs переносит старые клоны, где ещё сохранился вложенный .git/. Удаление сабмодуля разнесено по трём местам: git rm убирает гитлинк и запись в .gitmodules, git submodule deinit очищает рабочее дерево и запись в .git/config, а оставшийся после обеих команд каталог $GIT_DIR/modules/ по документации удаляется вручную через rm -rf. Рабочие деревья сталкиваются с этой схемой хранения напрямую: связанное рабочее дерево использует общий с суперпроектом $GIT_DIR, но у него своё рабочее дерево, HEAD и индекс под $GIT_DIR/worktrees//. Если два рабочих дерева стоят на разных ветках суперпроекта, они указывают на один и тот же сабмодуль в разных коммитах, и каждому нужен собственный чекаут и индекс сабмодуля при хранении, которое лишь частично разделено по деревьям. Именно поэтому worktree remove требует принудительного флага вместо проверки, можно ли это состояние безопасно отбросить, а worktree move отказывается работать, потому что перезапись файла-указателя для этого случая не реализована. В марте (год не указан) Ксавье Морель спросил в рассылке git, может ли чекаут сабмодуля сам быть рабочим деревом уже существующего общего клона, он обнаружил, что голые репозитории с рабочими деревьями хорошо подходят для набора связанных проектов, но добавление сабмодулей поверх всегда приводило к клонированию заново. В апреле за этим последовали RFC и серия из трёх патчей, добавляющая флаг --recurse-submodules к git worktree add: каждое связанное рабочее дерево получает собственный git-каталог сабмодуля под $GIT_COMMON_DIR/worktrees//modules/, а хранилище объектов между ними разделяется через жёсткие ссылки; принята ли эта серия в git, в статье не говорится. Та же проблема размножения возникает и в одном клоне с одним рабочим деревом, если два сабмодуля зависят от одного и того же третьего репозитория: каждый путь в суперпроекте получает собственную запись modules/, собственное хранилище объектов (если вручную не настроены alternates) и собственный гитлинк, и два пина могут указывать на разные коммиты одного и того же репозитория, git считает их независимыми чекаутами. Пакетные менеджеры с общим кешем, реестровый кеш cargo, файловое хранилище pnpm, кеш модулей Go, хранят байты один раз и лишь раскладывают их по нужным местам.

Обновление: гитлинк хранит один SHA, поэтому продвижение сабмодуля вперёд означает зайти в него, выполнить fetch, выложить новый коммит, выйти и записать новый гитлинк командой git add в суперпроекте. Команда git submodule update --remote вместо этого загружает верхушку настроенной ветки и выкладывает её; закоммитить результат в суперпроекте, и есть способ сдвинуть пин. В .gitmodules можно указать ветку для каждого сабмодуля, её использует --remote и боты обновлений, но обычный update это поле игнорирует и всегда выкладывает SHA из гитлинка. Синтаксиса для диапазона версий, шаблона тега или минимального коммита не существует: единственная плавающая ссылка в манифесте, имя ветки, а единственный пин, гитлинк. И Dependabot, и Renovate умеют открывать пул-реквесты, сдвигающие гитлинк: экосистема gitsubmodule у Dependabot предлагает новый SHA, когда сдвигается настроенная ветка сабмодуля, менеджер git-submodules у Renovate делает то же самое, но поставляется отключённым по умолчанию, оба следят именно за верхушкой ветки, потому что имя ветки это единственная ссылка, которую даёт манифест.

Безопасность: файл .gitmodules коммитится вместе с репозиторием, поэтому его содержимое контролирует вышестоящий репозиторий, а git разбирает этот файл при clone --recurse-submodules ещё до того, как пользователь увидел хоть один из загруженных файлов, именно эта комбинация неоднократно приводила к удалённому выполнению кода. CVE-2018-11235 использовала последовательность ../ в имени сабмодуля, из-за чего его git-каталог вместе с хуками записывался за пределы $GIT_DIR/modules/, и во время клонирования срабатывал хук post-checkout. В CVE-2018-17456 URL сабмодуля начинался с дефиса, из-за чего дочерний процесс git clone разбирал его как опцию командной строки, тот самый класс уязвимостей, от которого защищает разделитель --end-of-options. CVE-2022-39253 была уязвимостью раскрытия данных: символическая ссылка в объектном каталоге сабмодуля заставляла клонирование по локальному транспорту скопировать произвольные файлы с диска жертвы; исправление сменило значение по умолчанию для protocol.file.allow на user, из-за чего сабмодули с локальным путём теперь требуют явного разрешения. CVE-2024-32002 сочетала символическую ссылку с нечувствительной к регистру файловой системой, чтобы записать хук в каталог .git/ во время рекурсивного клонирования.

Вывод автора: сабмодули напрямую обнажают внутренности git, идентификаторы объектов как пин, отсоединённый HEAD после update, структуру каталогов $GIT_DIR/modules/, транспортные URL прямо в манифесте, тогда как пакетный менеджер оборачивает те же сущности в формат манифеста, резолвер и локальный кеш. Большинство пробелов, по мнению автора, совпадают с уже решёнными где-то ещё задачами: общий кеш объектов, рекурсия в зависимости по умолчанию при клонировании и checkout, единый жизненный цикл добавления и удаления зависимости, ограничения диапазона версий в манифесте. Апрельская серия патчей с --recurse-submodules для git worktree add закрывает ровно одну часть проблемы хранения, давая каждому рабочему дереву собственный чекаут сабмодуля поверх общего хранилища через жёсткие ссылки. Разрешение адресов автор называет более сложной, до сих пор нерешённой задачей: SHA коммита, это независимый от хоста идентификатор объекта, а URL в .gitmodules остаётся единственным известным git отображением этого идентификатора на сервер, который его хранит.

Ключевые факты

  • Отправная точка: git worktree remove требует флаг --force для рабочего дерева с сабмодулями, а git worktree move с сабмодулями не работает вовсе, при том что сама команда git worktree появилась в анонсе git 2.5 в июле 2015 года с оговоркой не использовать её вместе с сабмодулями; спустя одиннадцать лет ограничение никуда не делось.
  • Центральный тезис: у сабмодулей уже есть все части пакетного менеджера, гитлинк (SHA коммита в дереве) как запись лок-файла.gitmodules как манифест, git submodule update как установка, но они хуже состыкованы на каждом шаге.
  • Разрешение адресов ломается структурно: URL хранится в .gitmodules и дублируется в .git/config при первой инициализации, поэтому перенос или переименование апстрима рвёт пины даже при неизменном SHA, а правка .gitmodules не подхватывается без git submodule sync.
  • Общего кеша объектов нет: если два сабмодуля зависят от одного и того же репозитория, каждый получает свой каталог modules/ и свой набор объектов, в отличие от кеша cargo, хранилища pnpm или кеша модулей Go; апрельская серия патчей с --recurse-submodules для git worktree add решает похожую проблему только для рабочих деревьев, через жёсткие ссылки.
  • На стыке коммита в репозиторий и автоматического парсинга .gitmodules при clone --recurse-submodules нашли четыре уязвимости: CVE-2018-11235, CVE-2018-17456 (обе, RCE), CVE-2022-39253 (раскрытие файлов через символическую ссылку) и CVE-2024-32002 (запись хука через символическую ссылку на нечувствительной к регистру ФС).

Почему это важно

Сабмодули git, одна из самых критикуемых и при этом до сих пор массово используемых частей git: разработчики регулярно внедряют их для управления зависимостями и вендоринга кода, а затем отказываются от них после серии практических проблем, автор напрямую называет «почему сабмодули git настолько плохи» повторяющейся темой обсуждений. Статья впервые системно раскладывает, где именно рвётся аналогия с полноценным пакетным менеджером: не в самой идее (гитлинк как лок-файл.gitmodules как манифест, работает), а в реализации каждого шага, разрешения адресов, установки, хранения, обновления и безопасности.

Кому это важно

Разработчикам и командам, которые уже используют сабмодули для вендоринга зависимостей или сборки монорепозитория из нескольких git-репозиториев; авторам build-инструментов и CI-пайплайнов, которым приходится обходить ограничения через url.insteadOf или писать собственную логику проверки коммитов сабмодулей (как это в итоге сделал проект Rust); специалистам по безопасности, оценивающим риск удалённого выполнения кода при клонировании репозиториев с недоверенным содержимым .gitmodules; и разработчикам самого git, которые в апреле предложили патч, решающий часть проблемы хранения для рабочих деревьев.

Как это применить

Если сабмодули уже используются: держать git обновлённым, четыре описанных CVE закрыты патчами git, а не настройками пользователя; при работе с недоверенными репозиториями явно проверять protocol.file.allow и не клонировать с --recurse-submodules вслепую; для переноса апстрима сабмодуля на зеркало или форк не забывать про git submodule sync после правки .gitmodules, иначе уже инициализированные клоны продолжат тянуть старый URL; в CI переписывать адреса через url..insteadOf, а не хранить SSH-специфичные URL прямо в манифесте; при использовании git worktree вместе с сабмодулями закладываться на то, что remove потребует --force, а move не сработает вовсе. Если зависимостей много и нужны диапазоны версий или общий кеш, сабмодули для этой задачи структурно не подходят, и стоит рассмотреть пакетный менеджер языка или инструмент вроде git subtree.

Можно ли доверять

Материал написан в жанре подробного технического разбора: автор цитирует конкретные команды, вывод man-страницы, формулировку оригинального анонса git 2.5, ветку рассылки git (вопрос Ксавье Мореля) и серию патчей, а также приводит номера четырёх CVE с описанием механизма каждой уязвимости и итоговым исправлением. Это стиль, подкреплённый проверяемыми первоисточниками (документация git, список рассылки, база CVE), а не пересказ чужого мнения, оценка достоверности источника (82) отражает именно это. Как и в любом авторском разборе, общая рамка «сабмодули как пакетный менеджер», точка зрения автора, но фактические детали (версии, номера CVE, поведение команд) сформулированы как проверяемые технические утверждения, а не догадки.

Риски и подводные камни

Главный риск, сочетание доверия к содержимому репозитория и автоматизма: .gitmodules коммитится вместе с кодом и разбирается git ещё до того, как пользователь видит загруженные файлы, и именно эта комбинация уже как минимум четырежды приводила к RCE или раскрытию файлов (CVE-2018-11235, CVE-2018-17456, CVE-2022-39253, CVE-2024-32002). Второй риск, тихая рассинхронизация состояния: смена ветки в суперпроекте не обновляет рабочее дерево сабмодуля автоматически, из-за чего git status сразу показывает изменения, а случайные откаты сабмодулей могут попасть в пул-реквест незамеченными, ровно это описывает опыт проекта Rust. Третий, отсутствие диапазонов версий: единственный плавающий ориентир в манифесте это имя ветки, а единственный пин, гитлинк, поэтому автоматическое обновление через Dependabot или Renovate работает только по принципу «подтянуть верхушку ветки», без промежуточных ограничений.

«Использовать git worktree в репозитории, где есть сабмодули, не рекомендуется.»

— анонс git 2.5, GitHub, июль 2015