DevSecOps подкрался незаметно, хотя заметен был издалека…
DevSecOps уверенно шагает по нашей индустрии, и горе тому, кто попадёт под его поступь… Эта статья про ультимативную сборку базовых образов для Ruby для удовлетворения самых параноидальных потребностей ИБ. Да, именно об этом мы и расскажем - что такое "инсталляция ruby", где, что, почему лежит и как с этим жить нашему пайплайну сборки и самому приложению.
Статья подойдёт тем, кто хочет более глубоко понимать, какие процессы происходят в системе, когда вызывается gem install
, bundle install
или (не дай Бог) gem update –system
Как было до?
С самого начала появления docker мы в RNDSOFT использовали парадигму "всё включено". Для нас это означало, что в образ мы включаем всё, что нам надо не только для продуктовой эксплуатации, но и для проведения всех тестов. При прохождении CI пайплайна на следующие стадии продвигался образ целиком и, в конце концов, выкатывался на прод.
Это было очень удобно и позволяло быть максимально (насколько это вообще возможно) уверенным в работоспособности, однако имело ряд серьёзных минусов, с которыми мы прекрасно жили достаточно долгое время:
Сканеры сканировали, сканировали, да не высканировали…
И вот однажды один (на данный момент уже далеко не один) наш клиент захотел устранения всех замечаний, которые смог выявить сканнер Trivy. А их, как не трудно догадаться, было достаточно много. И главная проблема заключается в том, что большая часть замечаний никак не связана с непосредственными зависимостями вашего приложения (теми, которые фиксируются в Gemfile.lock).
Так откуда же они берутся?
Если коротко отвечать - отовсюду :) При сборке проекта необходимо поставить его зависимости - это план минимум. Кроме того, часто появляется необходимость установить bundle определенной версии или даже обновить ruby целиком, выполнив команду gem update --system. В результате этих действий в ваш образ в разнообразные папки ставятся разнообразные гемы, но
старые версии гемов и сама базовая "инсталляция ruby" остаётся в системе со всеми своими "устаревшими" и "уязвимыми" версиями. И эти версии очень нравятся сканерам для того, чтобы поднять тревогу.
И еще два слова о том, что же именно сканирует Trivy (касательно ruby конечно):
- сканирует .gemspec файлы на всей файловой системе и не важно, установлен гем или нет - если в .gemspec указана версия "с уязвимостью" - алярм;
- сканирует .gem файлы на всей файловой системе и не важно, установлен гем, лежит просто в папке cache, используется или нет в вашем приложении.
Значит для удовлетворения хотелок сканера, надо сделать так, чтоб нигде не лежало ничего лишнего или не используемого, включая stdlib - стандартную библиотеку ruby.
Проблема ясна, задача очевидна - можно приступать к решению и начинать надо с базовых образов. Их надо собрать так, чтобы сами базовые образы уже не содержали никаких уязвимостей.
Что такое инсталляция ruby?
Если мы возьмём любой дистрибутив с установленным там ruby, то увидим, что само ruby будет находиться где-то в районе:
- /usr/lib/ruby - например в alpine после
apk add ruby
- /usr/local/lib/ruby - например в
ruby:alpine
, где руби собирается отдельно от apk
А дальше начинается интересное. Если посмотреть в финальный образ, в котором сделано много различных операций (обновление ruby, установка гемов через gem install
, установка через bundle install
), то в корневой папке ruby обнаружится несколько папок, по которым тем или иным способом будут распределены установленные вами гемы:
/usr/local/lib/ruby ├── 3.2.0 ├── gems/3.2.0 ├── site_ruby/3.2.0 └── vendor_ruby/3.2.0
Сразу добавим к этому списку другие папки (которые можно посмотреть в выводе команды gem env
):
- INSTALLATION DIRECTORY: /usr/local/bundle - USER INSTALLATION DIRECTORY: /root/.local/share/gem/ruby/3.3.0 - SPEC CACHE DIRECTORY: /root/.cache/gem/specs - GEM PATHS: - /usr/lib/ruby/gems/3.3.0 - /root/.local/share/gem/ruby/3.3.0
И вишенкой на торте будет настройка вашего bundle, если вы используете кеширование сборки (bundle cache или bundle config set cache_path vendor/cache
), и используемые в этом зоопарке переменные окружения GEM_HOME
, BUNDLE_CACHE_PATH
, BUNDLE_PATH
и скорее всего еще какие-то скрытые в глубинах экосистемы ruby.
Немного путано и в результате беспорядочных связей установок и обновлений во всех этих папках могут (и будут) появляться гемы. Надо исправить!
Постараюсь дать верхнеуровневое описание, что же именно это за папочки, не углубляясь в подробности и исключения:
/usr/local/lib/ruby/3.3.0
Именно в этой папке установлены основные файлы ruby и stdlib, а также компилируемые расширения в папке x86_64-linux-musl (в нашем случае собранные alpine для x86_64). Эти файлы принадлежат условной "системе" и никак не будут изменяться при дальнейших модификациях, например при установке или обновлении гемов через gem install
. Вместо этого библиотеки будут ставиться в папку /usr/local/lib/ruby/gems/3.3.0
/usr/local/lib/ruby/gems/3.3.0
Тут собрано всё, что вы ставите (без модификации GEM_PATH
) командами gem install,
включая компилируемые расширения.
/usr/local/lib/ruby/vendor_ruby/3.3.0
Сюда должны ставиться дополнительные гемы и/или патчи от команды мейнтейнеров дистрибутива. Об этом сложно найти информацию, но несколько слов есть в книге The Ruby Programming Language
или, что интереснее, в changelog для NEWS for Ruby 1.8.7 (да, окаменелое…):
vendor_ruby directory
A new library directory named vendor_ruby
is introduced in addition to site_ruby
. The idea is to separate libraries installed by the package system (vendor
) from manually (site
) installed libraries preventing the former from getting overwritten by the latter, while preserving the user option to override vendor libraries with site libraries. (site_ruby
takes precedence over vendor_ruby
)
If you are a package maintainer, make each library package configure the library passing the --vendor
option to extconf.rb
so that the library files will get installed under vendor_ruby
.
You can change the directory locations using configure options such as --with-sitedir=DIR
and --with-vendordir=DIR
.
/usr/local/lib/ruby/site_ruby/3.3.0
Сюда будут ставиться "системные" файлы, но не от OS, а от самого ruby, например после выполнения gem update –system
:
site_ruby/ └── 3.4.0 ├── bundler ├── rubygems └── x86_64-linux-musl
/usr/local/lib/ruby/gems/3.3.0/specifications/
…а также INSTALLATION DIRECTORY /usr/local/bundle/specifications
…
…а также USER INSTALLATION DIRECTORY: /root/.local/share/gem/ruby/3.3.0
…
…а также SPEC CACHE DIRECTORY /root/.cache/gem/specs
…
…а также BUNDLE_CACHE_PATH …
Сюда ставятся .gemspec файлы, которые собственно и говорят пакетному менеджеру ruby (gem и bundle), какие именно версии каких гемов установлены.
/usr/local/lib/ruby/gems/3.3.0/specifications/default
Default, Карл… Default - это особое состояние гема, и эти гемы нельзя удалить. Если вы обновили гем до новой версии, то старая всё равно останется, и Trivy вам этого не простит. Весьма вредная папка с точки зрения сканирования.
/usr/local/lib/ruby/gems/3.3.0/cache/
…а также INSTALLATION DIRECTORY /usr/local/bundle/cache
…
…а также BUNDLE_PATH …
Сюда пакетный менеджер ruby (gem или bundle) скачивает гемы (допустим вы ставите faraday.gem) перед установкой. Этот кеш очень часто используется для ускорения сборки, например в вашем Gitlab.
Что здесь у вас происходит?!
После того как мы в процессе исследования детально разобрались и увидели всё это многообразие мест, где могут находиться файлы, вызывающие панические атаки у Trivy, мы решили радикально решить эту проблему: свести все файлы, все гемы и все спеки (.gemspec) в одно место. Это позволит легко следить за всеми (всеми!) фактическими зависимостями и эффективно пользоваться командами gem cleanup
и bundle clean
. Тут надо отметить, что для вашей рабочей OS (системы общего назначения) такое решение приведёт к поломке системного пакетного менеджера (Gentoo Portage, dpkg/apt, rpm/zypper и пр.), но мы ведь говорим о конкретной сборке ruby под ваш конкретный проект - и тут никаких проблем не будет.
Для того чтобы узнать что куда, когда и зачем ставится, мы использовали git прямо на корне файловой системы внутри контейнера (ruby:alpine, просто alpine, ruby:debian и другие образы для сравнения) и фиксировали изменения после различных команд ⏳
Но это еще не всё. Остаётся еще проблема с default gemspec, но её относительно легко решить:
mv /usr/local/lib/ruby/gems/3.3.0/specifications/default/* /usr/local/lib/ruby/gems/3.3.0/specifications/
Теперь мы можем сформулировать План:
- Все дороги ведут в Рим - делаем ссылочки для
vendor_ruby
,site_ruby
и пр. Также явно прописываем системные переменныеGEM_HOME
иBUNDLE_APP_CONFIG.
- Разбираемся с default gems.
- Обновляем ruby (имеется в виду stdlib) до последней требуемой версии.
- Обновляем bundle до нужной версии.
- Удаляем лишние гемы (например rdoc).
- Переставляем (!) системные (на текущий момент сборки базового образа - все) гемы, потому что, как оказалось,
mv
для default gemspec имеет не очень хорошие последствия. - profit!
Сказано - сделано, и добро пожаловать под кат!
ARG BASE_RUBY=3.2 ARG BASE_ALPINE=alpine3.16 ARG BASE_IMAGE=ruby:${BASE_RUBY}-${BASE_ALPINE} FROM ${BASE_IMAGE} ARG BASE_RUBY=3.2 ARG RUBYGEMS_VERSION=3.5.20 ARG BUNDLER_VERSION=2.5.20 # эта переменная есть в старом alpine но нет в debian и новом # добавляем потому что она очень нужна для работы с папочками ENV RUBY_MAJOR=${BASE_RUBY} ENV RUBYGEMS_VERSION=${RUBYGEMS_VERSION} \ BUNDLER_VERSION=${BUNDLER_VERSION} RUN apk update && apk upgrade # Это наш костыльный скрипт который удаляет всякие лишние кеши, # man-файлы и прочий мусор. COPY common/scripts/cleanallbuilds.sh /usr/bin/ # dumb-init всегда используем как PID-1 но это немного другая история RUN set -ex \ && apk add dumb-init \ && cleanallbuilds.sh ###### # Надругиваемся над диструбутивом, чтоб иметь строго # одну версию руби в систему и управлять ею целиком через gem/bundle # все пути ведут в Рим ENV GEM_HOME=/usr/local/lib/ruby/gems/${RUBY_MAJOR}.0/ ENV BUNDLE_APP_CONFIG=/usr/local/lib/ruby/gems/${RUBY_MAJOR}.0/ # Сводим vendor_ruby, site_ruby и GEM_HOME в одно место RUN set -ex \ && rm -rf /usr/local/lib/ruby/site_ruby /usr/local/lib/ruby/vendor_ruby \ && ln -sf /usr/local/lib/ruby /usr/local/lib/ruby/site_ruby \ && ln -sf /usr/local/lib/ruby /usr/local/lib/ruby/vendor_ruby \ && mkdir -p /root/.local/share/gem/ruby/ \ && ln -sf ${GEM_HOME} /root/.local/share/gem/ruby/${RUBY_MAJOR}.0 # Чутка тюним bundle config чтоб в дальнейшем не забыть RUN set -ex \ && bundle config --local disable_version_check true \ && bundle config --local clean false \ && bundle config --local no_prune false \ && bundle config --local disable_local_branch_check true \ && bundle config --local jobs 2 \ && bundle config --local allow_offline_install true # настраиваем .gemrc чтоб не было ничего лишнего, вклюячая rdoc RUN set -ex \ && echo 'gem: --no-document' > /usr/local/etc/gemrc \ && echo 'update_sources: false' >> /usr/local/etc/gemrc \ && echo 'verbose: false' >> /usr/local/etc/gemrc \ && echo 'update: --no-suggestions' >> /usr/local/etc/gemrc \ && echo 'install: --no-suggestions --conservative' >> /usr/local/etc/gemrc # пытаемся удалить ненужные гемы с самого начала - попытка не пытка RUN set -ex \ && gem uninstall -a -x --quiet --force `gem list | cut -f 1 -d " "` \ && gem cleanup RUN set -ex \ && apk add --virtual .build-deps \ autoconf \ bison \ bzip2 \ bzip2-dev \ coreutils \ curl-dev \ dpkg-dev dpkg \ g++ \ gcc \ gdbm-dev \ git \ glib-dev \ libc-dev \ libffi-dev \ libxml2-dev \ libxslt-dev \ linux-headers \ make \ ncurses-dev \ procps \ readline-dev \ tar \ xz \ yaml-dev \ zlib-dev \ shared-mime-info \ && mv /usr/local/lib/ruby/gems/${RUBY_MAJOR}.0/specifications/default/* /usr/local/lib/ruby/gems/${RUBY_MAJOR}.0/specifications/ \ && gem cleanup \ && gem update --system "${RUBYGEMS_VERSION}" \ && gem uninstall bundler --all --silent || true \ && gem install bundler -v "${BUNDLER_VERSION}" \ && gem uninstall rdoc --all --silent || true \ && GMS=`gem list | sed s/default:\ // | sed -E 's/\ \((.*)\)/:\1/' | sort` \ && DEBUG_FLAGS="-Wno-calloc-transposed-args" gem pristine --all --extensions \ && commands=$(for x in $GMS; do \ g=$(echo "$x" | cut -f 1 -d ":"); \ v=$(echo "$x" | cut -f 2 -d ":"); \ echo gem pristine $g --version "$v"; \ done) \ && echo -e "$commands" | xargs -I CMD -P 3 bash -c CMD\ && gem cleanup \ && rm -rf root/.local/share/gem/specs \ && apk del .build-deps \ && cleanallbuilds.sh WORKDIR /home/app RUN set -ex \ && adduser -D -s /sbin/nologin app \ && chown -R app:app /home/app ENTRYPOINT ["/usr/bin/entrypoint.sh"] SHELL ["/bin/sh", "-c"]
Самое интересное, конечно, находится в одном слое самого пухленького RUN, и по некоторым командам надо дать пояснения:
GMS=`gem list | sed s/default:\ // | sed -E 's/\ \((.*)\)/:\1/' | sort`
- получаем список установленных гемов с версиями в формате "yaml:0.4.0 zlib:3.2.1". Это нам потребуется дальше из-за странного поведенияgem pristine
gem pristine --all --extensions
- должен для всех установленных гемов сделать "чистовую" установку, включая скачивание и перекомпиляцию расширений. Но нет. На самом деле он делает не для всех и не всё :(DEBUG_FLAGS="-Wno-calloc-transposed-args"
- мы используем один Dockerfile для сборки базовых образов ruby 2.7, 3.0, 3.1, 3.2, и по умолчанию все расширения должны компилироваться без единогоразрываворнинга компиляции, но для некоторых версий руби (кажется 3.0) это не так, и именно этот ворнинг всё портит, поэтому его подавляем без затей.commands=$(for x in $GMS; do …
- а вы знали, что в bash тоже есть пул потоков? Строго говоря, он пул процессов, и не в баше, а в xargs, но это не важно. В общем тут список$GMS
в виде "yaml:0.4.0 zlib:3.2.1" трансформируется в список$commands
, потому что в такой формеgem pristine
работает именно так как надо:
gem pristine yaml --version "0.4.0" gem pristine zlib --version "3.2.1" ...
echo -e "$commands" | xargs -I CMD -P 3 bash -c CMD
- отправляем$commands
в пул из трех процессов для одновременной установки гемов.cleanallbuilds.sh
- зовём в самом конце кустарный скрипт, который удаляет всякий мусор из системы:
#!/bin/sh # ruby (alpine + debian) # Скрипт единый идля debian и для alpine, поэтому ошибки, # возникающие из-за разницы дистрибутивов просто игнорируем rm -rf /usr/src/ruby/ || true rm -rf /root/.local/share/gem/specs/ || true rm -rf /root/.local/state/ || true rm -rf /root/.cache/gem/ || true rm -rf /usr/local/lib/ruby/gems/3.2.0/cache/ || true rm -rf /root/.bundle/cache/ || true gem cleanup || true # alpine apk cache clean || true # debian apt-get clean autoclean || true apt-get autoclean --yes || true apt-get autoremove --yes || true find /var/log/ -type f -delete || true find /var/lib/log/ -type f -delete || true find /usr/share/doc/ -type f -delete || true
Результат
Старый образ занимал 900MB, новый - 152MB (не спрашивайте...)
Что дальше?
Теперь эти базовые образы надо начать использовать непосредственно в CI пайплайнах боевых сервисов и, конечно, процедуру сборки и тестирования придётся поменять. Точнее, мы уже перешли на новые образы и сборку уже пару недель назад - дело теперь только за статьёй. А в качестве приманки приведу результаты перехода для одного из самых "толстых" наших сервисов:
image:old size:1.49GB vulns:255 image:new-prod size:259MB vulns:1 (Вчерашняя!! СVE-2025-25186) image:new-test size:269MB vulns:1 (СVE-2025-25186)
Если взглянуть ретроспективно, то совсем не весело - так что:
профилируйте чаще не только ваш код и инфраструктуру сборки.