xzotique
Grey Team
- 24.03.2020
- 126
- 462
Ты, вероятно, как и я, устал от нарратива. От этой всеобщей эйфории, где каждый второй мануал начинается с docker run hello-world, а каждый первый архитектор рисует в креативе бесконечные облака с коробочками, соединенными стрелочками. Мир, где сложность спрятали за абстракциями, а безопасность - за галочками в Saas-панельке какого-нибудь сканера.
Слышишь этот гул? Это гул тысяч docker pull, бездумно исполняемых в CI/CD пайплайнах. Это шепот FROM node:latest в начале бесчисленных Dockerfile. Это ритуальное заклинание современной разработки: «Оно работает в контейнере, значит, оно изолировано, значит, оно безопасно». И все дружно кивают, не вдаваясь в детали. Потому что детали - это скучно. Потому что детали - это не про масштабирование и time-to-market. А мы с тобой — про детали. Нас не устраивает магия, пока мы не разберем фокус до винтика.
Давай посмотрим правде в глаза: Docker (и вся контейнерная экосистема) - это один из величайших парадоксов нашей индустрии. Инструмент, рожденный из философии UNIX и идеи легковесной изоляции, превратился в фабрику по производству церемониальных зависимостей и, что гораздо хуже, в кладезь критических секретов. Публичный реестр образов - это не аккуратная библиотека. Это гибрид помойки и музея. На одной полке лежит nginx в стоковой сборке, на другой - legacy-backend-service какого-нибудь умершего fintech-стартапа, внутри которого, как в капсуле времени, застыли ключи от его банковских API. Рядом - ci-runner с захардкоженным токеном GitLab, test-db с дампом реальных (анонимизированных? хах, хорошая шутка) пользовательских данных, и сотни my-app от инженеров, которые просто хотели попробовать эту штуку с Docker и забыли про выложенный на Hub образ.
Почему это происходит? Потому что общество продаёт простоту. «Собери раз, запускай где угодно». Но оно не продает понимание цены этой простоты. Слой за слоем, команда за командой, мы упаковываем в образ не только наше приложение, но и весь контекст его создания: временные файлы, кэши, ключи для доступа к зависимостям, конфиги с локальной машины. Мы делаем это на автомате, потому что COPY . . - это просто. А потом, когда все работает, мы делаем docker push - и наш цифровой отпечаток, со всеми его родинками и шрамами, улетает в публичное пространство. Навсегда. Или до тех пор, пока кто-то вроде нас не решит покопаться.
Кто этот «кто-то вроде нас»? Это не обязательно злобный хакер в капюшоне. Это может быть:
- Аудитор безопасности, проверяющий поверхность атаки компании.
- Ответственный инженер, оценивающий риски от использования сторонних образов.
- Любопытный исследователь, изучающий реальные практики разработки.
- Скрипт-кидди, запустивший сканер с GitHub и радостно наблюдающий, как тот выплевывает найденные AWS-ключи.
Готовься к долгой дороге. Мы поговорим о том:
- Как устроен образ на физическом уровне (это не просто «виртуальный диск», чувак).
- Почему docker history - это лишь приветственная открытка, а реальная история написана в самих слоях.
- Как метаданные и временные метки могут рассказать больше, чем содержимое файлов.
- Какие инструменты использовать не потому, что они модные, а потому что они решают конкретную задачу: jq, tar, binwalk, dive, skopeo, и конечно, наш верный Bash и Python.
- Как написать свой анализатор, который будет искать не только по шаблонам, но и понимать контекст.
- Что реально находят в этих образах (и ты удивишься банальности и масштабу этих находок).
- Как строить свою защиту не на страхе, а на архитектуре: multi-stage сборки, BuildKit secrets и менталитет «нулевого доверия к образу».
И понимание. Именно то, что отличает ремесленника от пользователя. Погнали.
Часть 1: Философия «слоеного пирога» и почему docker images врет
Контейнер - это не виртуальная машина. Забудь. Это изоляция на уровне процессов, построенная на cgroups и namespaces. Образ - это набор read-only слоев, накрытых верхним слоем. Каждая команда в Dockerfile (RUN, COPY, ADD) создает новый слой. И вот здесь начинается магия и одновременно - трагедия.
Когда ты делаешь docker pull nginx:latest, ты качаешь не монолитную картинку. Ты качаешь связку диффов. И многие из этих диффов содержат файлы, которые в последующих слоях были удалены. Да, ты правильно прочитал. Удаление файла в Docker - это не физическое удаление из нижележащего слоя. Это создание нового слоя, в котором этого файла «как бы нет». Но старый слой с ним остается. Он все еще там, в образе. Он все еще скачивается при pull. И он все еще доступен для того, кто знает, как его достать.
Распространённые инструменты сканирования (docker scan, Trivy, Clair) часто смотрят только на финальный слой или на установленные пакеты. Они ищут уязвимости в libc. Но они пропускают файл .env, скопированный на третьем слое и удаленный на пятом. Они пропускают SSH-ключ в /root/.ssh/id_rsa, добавленный для клонирования приватного репозитория и «удаленный» потом. Они пропускают историю bash в /root/.bash_history, которая не затерлась.
Наша первая и главная заповедь: Контейнерный образ - это не текущее состояние файловой системы. Это хронологическая летопись всех операций, которые совершал над ним создатель. И эту летопись можно прочитать.
Часть 2: Инструментарий археолога. Не Skynet, а Bash и Python.
Забудь про дорогие Enterprise-решения на первые 15 минут анализа. Наш базовый набор - это то, что есть везде, или ставится в два клика.
1. docker и docker save - наш швейцарский нож.
Первое, что нужно сделать - вытащить образ из облака к себе локально, в сыром виде.
Bash:
docker pull интересный/образ:тег
docker save интересный/образ:тег -o образ.tar
Теперь у тебя есть .tar архив. Распакуй его:
Bash:
mkdir image_extract && cd image_extract
tar -xf ../образ.tar
Внутри ты увидишь кучу файлов: manifest.json, repositories, и папки со странными хеш-именами - это и есть слои. Каждая такая папка - это tar архив слоя. manifest.json - это инструкция по сборке конечной файловой системы: в каком порядке накладывать слои.
2. jq - твой лучший друг для разбора JSON.
Без него никуда. Установи (apt install jq, brew install jq). С его помощью мы будем парсить manifest.json и другие мета-файлы.
3. Простой Python/Bash для автоматизации.
Мы не ищем сложные фреймворки. Мы напишем несколько скриптов, которые делают ровно то, что нам нужно. Я приведу куски кода, ты их соберешь воедино под свои нужды.
4. binwalk, strings, grep, find - классика, которая никогда не стареет.
Именно они будут искать в бинарных слоях текстовые строки, паттерны, знакомые форматы.
5. Специализированные утилиты: dive и skopeo.
- dive (GitHub - wagoodman/dive: A tool for exploring each layer in a docker image) - великолепный инструмент для интерактивного просмотра слоев. Он показывает, какие файлы в каком слое были добавлены, изменены или удалены. Идеально для первичной разведки. Но мы пойдем дальше его GUI.
- skopeo (GitHub - containers/skopeo: Work with remote images registries - retrieving information, images, signing content) - утилита от Red Hat для работы с образами без необходимости запускать демон Docker. Полезна для копирования, инспекции и, что важно для нас, скачивания образов и их манифестов в чистом виде.
Часть 3: Практика. От слоя к слою, от артефакта к артефакту.
Давай перейдем от теории к практике. Представим, что мы нашли подозрительный/интересный публичный образ somecompany/internal-tool:latest. Наша цель - исследовать его вдоль и поперек.
Шаг 0: Предварительный осмотр с docker history и docker inspect.
Быстро и грязно:
Bash:
docker history --no-trunc somecompany/internal-tool:latest
Bash:
RUN apt-get update && apt-get install -y curl && curl -H "Authorization: Bearer ghp_xxxxxxxxxx" https://api.github.com/repos/... && rm -rf /var/lib/apt/lists/*
docker inspect даст нам мета-информацию: переменные окружения (Env), точку входа (Cmd, Entrypoint), рабочий каталог (WorkingDir). Иногда в Env уже могут быть жестко зашитые пароли или URLs с креденшелами.
Шаг 1: Выгрузка и разбор образа.
Делаем, как описано выше:
Bash:
docker pull somecompany/internal-tool:latest
docker save somecompany/internal-tool:latest -o internal-tool.tar
mkdir -p analysis && cd analysis
tar -xvf ../internal-tool.tar
Теперь смотрим в manifest.json:
Bash:
cat manifest.json | jq .
JSON:
[{
"Config": "a123...bcd.json",
"RepoTags": ["somecompany/internal-tool:latest"],
"Layers": ["layer1.tar/sha256:...", "layer2.tar/sha256:...", ...]
}]
Запоминаем имя конфига (a123...bcd.json). Он содержит историю команд и параметры сборки, аналогичные docker inspect. Слои перечислены в порядке наложения: первый в списке - самый нижний, последний - верхний.
Шаг 2: Распаковка и анализ каждого слоя.
Пишем небольшой bash-скрипт unpack_layers.sh:
Bash:
#!/bin/bash
MANIFEST="manifest.json"
CONFIG=$(jq -r '.[0].Config' $MANIFEST)
echo "Конфиг: $CONFIG"
jq . $CONFIG # Смотрим конфиг
LAYERS=$(jq -r '.[0].Layers[]' $MANIFEST)
echo "Слои:"
echo "$LAYERS"
# Создаем директорию для каждого слоя и распаковываем его
for LAYER in $LAYERS; do
LAYER_NAME=$(basename $LAYER .tar)
echo "Работаем со слоем: $LAYER_NAME"
mkdir -p "layers/$LAYER_NAME"
tar -xf "$LAYER" -C "layers/$LAYER_NAME/"
done
Запускаем. Теперь у нас есть папка layers/, в которой подпапки с именами слоев, и внутри - актуальная файловая система на момент этого слоя.
Шаг 3: Поисковая археология.
Вот где начинается самое интересное. Мы должны искать не только в последнем слое, а во всех. Напишем search_artifacts.sh:
Bash:
#!/bin/bash
SEARCH_DIR="layers"
OUTPUT_FILE="findings.txt"
echo "# Отчет по артефактам в образах" > $OUTPUT_FILE
# 1. Ищем файлы с определенными именами (классика)
echo "### Файлы с подозрительными именами ###" >> $OUTPUT_FILE
find $SEARCH_DIR -type f \( -iname "*.pem" -o -iname "*.key" -o -iname "id_rsa" -o -iname "id_dsa" -o -iname "*.ppk" -o -iname "*.env" -o -iname ".git-credentials" -o -iname "*.kdbx" -o -iname "config" -path "*ssh*" \) 2>/dev/null | sort >> $OUTPUT_FILE
# 2. Ищем файлы с определенным содержимым (паттерны)
echo -e "\n### Файлы, содержащие паттерны (пароли, токены, ключи) ###" >> $OUTPUT_FILE
# Проходим по всем распакованным файлам
for LAYER in $SEARCH_DIR/*; do
echo "Сканируем слой: $(basename $LAYER)" >> $OUTPUT_FILE
# Ищем строки, похожие на API keys, JWTs, пароли
# Важно: grep может долго работать на бинарниках, но оно того стоит
grep -r -E -i "(password|passwd|pwd|secret|key|token|auth|bearer|api[_-]?key|access[_-]?token|gh[pus]_|aws[_-]|AKIA|secret_?key|private[_-]?key|ssh-rsa|BEGIN RSA|BEGIN OPENSSG|BEGIN PRIVATE)" "$LAYER" 2>/dev/null | grep -v "Binary file" | head -20 >> $OUTPUT_FILE
done
# 3. Анализ истории bash (если есть)
echo -e "\n### Содержимое файлов истории оболочки ###" >> $OUTPUT_FILE
find $SEARCH_DIR -type f -name ".bash_history" -o -name ".zsh_history" 2>/dev/null | while read hist; do
echo "--- $hist ---" >> $OUTPUT_FILE
tail -50 "$hist" >> $OUTPUT_FILE 2>/dev/null
done
# 4. Поиск дампов баз данных, sql файлов
echo -e "\n### Файлы баз данных и SQL дампы ###" >> $OUTPUT_FILE
find $SEARCH_DIR -type f \( -iname "*.db" -o -iname "*.sqlite" -o -iname "*.sql" -o -iname "*.dump" -o -iname "*.bak" \) 2>/dev/null >> $OUTPUT_FILE
# 5. Поиск конфигов облачных провайдеров
echo -e "\n### Конфигурации облаков и инструментов ###" >> $OUTPUT_FILE
find $SEARCH_DIR -type f \( -path "*/.aws/credentials" -o -path "*/.azure/*" -o -path "*/.kube/config" -o -path "*/.docker/config.json" -o -name "credentials.xml" -o -name "settings.xml" \) 2>/dev/null >> $OUTPUT_FILE
echo "Поиск завершен. Результаты в $OUTPUT_FILE"
Этот скрипт - наше основное орудие. Он грубый, но эффективный. Он найдет много ложных срабатываний (например, слово «private» в документации), но лучше ложное срабатывание, чем пропущенный ключ.
Шаг 4: Глубокий анализ бинарных слоев.
Некоторые артефакты могут быть зашиты в бинарники или в архив внутри слоя. Тут нам помогут binwalk и strings.
Bash:
# Для каждого слоевого tar-архива (до распаковки) можно проверить на вложенность
for LAYER_TAR in *.tar; do
echo "Анализ архива слоя: $LAYER_TAR"
binwalk "$LAYER_TAR" | head -30
# Или ищем строки прямо в tar
strings "$LAYER_TAR" | grep -E "(passw|token|key|secret)" | head -10
done
Шаг 5: Воссоздание «удаленных» файлов.
Помнишь про удаление? Чтобы увидеть файл, который был добавлен в слое N, а удален в слое M>N, нам нужно посмотреть на файловую систему слоя N. Наш скрипт find уже делает это, потому что обходит все распакованные слои. Но давай визуализируем:
Допустим, в layers/layer3/ мы видим файл /app/config.json. В layers/layer5/ этого файла уже нет. Значит, он был удален командой RUN rm /app/config.json в слое 5. Но в слое 3 он лежит целиком и полностью. Мы его просто копируем оттуда.
Шаг 6: Анализ метаданных и временных меток.
Иногда важно не только «что», но и «когда». Временные метки файлов в слоях могут подсказать, в каком порядке велась разработка, были ли в образ включены файлы, созданные вне процесса сборки (например, скопированные с локальной машины). Для этого можно использовать stat внутри каждого слоя.
Часть 4: Живые примеры - что находили в реальном мире (обезличенно).
Ты не поверишь, что люди заливают. Я сам иногда не верю. Вот лишь несколько категорий находок из публичных образов на Docker Hub:
- Ключи доступа к облаку: Файлы ~/.aws/credentials с активными AWS_ACCESS_KEY_ID и AWS_SECRET_ACCESS_KEY. Образы для деплоя в AWS, собранные на CI-сервере, где эти переменные были в окружении и «случайно» просочились в слой. Или gcloud конфиги.
- Токены GitHub/GitLab: RUN git clone https://oauth2:ghp_xxxxx@github.com/company/private-repo. Токен в команде RUN виден в docker history, но его же могут закопировать из локального ~/.git-credentials.
- SSH ключи: Полные пары id_rsa и id_rsa.pub в /root/.ssh/. Часто для доступа к приватным репозиториям или хостам при сборке.
- Переменные окружения в plain text: Файлы .env, application.properties, config.yaml, лежащие прямо в образе со всеми паролями к базам данных, API-ключами внешних сервисов.
- Дампы и бэкапы: Полноценные SQL-дампы производственных баз данных в /tmp или /docker-entrypoint-initdb.d/, оставленные для инициализации тестовых сред, но забытые там.
- Исходный код внутренних библиотек: В образах для сборки часто копируется весь контекст, включая vendor/, node_modules/, и иногда - субмодули или приватные пакеты, которые не должны были уйти за пределы компании.
- Конфиги оркестраторов: Файлы kubeconfig для доступа к кластерам Kubernetes. Одна находка - и ты внутри продакшн-сети.
- Креденшелы к внутренним репозиториям артефактов: settings.xml для Maven или .npmrc с токенами к приватным npm-реестрам.
Часть 5: Защита. Как не стать донором артефактов.
Если после прочтения у тебя зашевелились волосы на затылке, потому что ты вспомнил про свои образы - отлично. Значит, сознание прочищается. Давай теперь о защите. Это не просто «используй .dockerignore» (хотя это обязательно). Это комплексный подход.
1. Многостадийная сборка (Multi-stage builds) - твой священный грааль.
Это главное оружие против утечек. Смысл в том, что у тебя несколько FROM в одном Dockerfile. Ты компилируешь/собираешь в одном образе (с полным SDK, ключами, исходниками), а потом копирушь только готовые артефакты (бинарники, jar-файлы, статику) в чистый, финальный образ (часто на alpine или scratch).
Код:
// dockerfile
# Стадия сборки
FROM golang:1.19 AS builder
WORKDIR /app
COPY . .
# Здесь могут использоваться приватные ключи для go modules
RUN go mod download
RUN go build -o myapp .
# Здесь лежит бинарник и всё, что было в исходниках и кэше
# Финальная стадия
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/myapp .
# Копируем ТОЛЬКО бинарник. Ни исходников, ни ключей, ни кэша модулей.
CMD ["./myapp"]
Ключи и исходники остаются в промежуточном образе builder, который не попадает в финальный образ и не пушится в реестр (если не использовать флаг --target при пуше). Это кардинально решает 90% проблем.
2. Никаких секретов в Dockerfile. Никогда.
- Не используй ARG для паролей, которые потом становятся ENV. ARG остаются в истории слоев.
- Не делай RUN с curl/wget, передавая ключи в URL или заголовках. Даже если потом удалишь curl, команда будет видна в истории.
- Не копируй файлы с секретами (*.key, .env.prod) в образ, даже чтобы потом удалить.
Для передачи чувствительных данных при сборке (например, для установки приватных пакетов):
Код:
// dockerfile
# syntax=docker/dockerfile:1
FROM alpine:latest
RUN --mount=type=secret,id=npm_token,target=/root/.npmrc \
npm install
Секрет монтируется временно только на время выполнения этой команды RUN и не попадает ни в один слой. Вызывается так:
Bash:
DOCKER_BUILDKIT=1 docker build --secret id=npm_token,src=~/.npmrc -t myimage .
Этот файл должен быть таким же важным, как .gitignore. Игнорируй все, что не нужно для сборки: .git, .md, *.log, env/, secrets/, *.pem, *.key, docker-compose.yml, .idea, .vscode. Чем меньше мусора скопировано в контекст, тем меньше шансов, что что-то просочится.
5. Чистка истории и слоев.
Объединяй команды в одну RUN, чтобы минимизировать слои и точки сохранения состояния:
Код:
// dockerfile
RUN apt-get update && \
apt-get install -y some-package && \
rm -rf /var/lib/apt/lists/* && \
# Делай всё, что нужно
apt-get clean
Используй docker image prune для удаления промежуточных образов после multi-stage сборки.
6. Сканируй свои же образы перед пушем.
Используй те же инструменты, что и мы, но против себя:
Bash:
# Пример с dive для просмотра изменений в слоях
dive myimage:latest
# Сканирование на уязвимости и секреты (есть инструменты)
trivy image --security-checks secret myimage:latest
# или
grype myimage:latest
Существуют инструменты вроде git-secrets, которые можно интегрировать в CI/CD, чтобы предотвратить коммит ключей в репозиторий, а значит, и их копирование в образ.
7. Используй приватные реестры с контролем доступа.
Docker Hub - публичная площадка. Для всего внутреннего используй приватные реестры (GitLab Container Registry, Harbor, AWS ECR, GCR). Это не панацея от утечек внутри образов, но сильно сужает круг лиц, которые могут их скачать.
8. Менталитет «нулевого доверия» к образу.
Воспринимай любой образ (даже свой вчерашний) как потенциально опасный. Перед деплоем в прод проверяй его происхождение, целостность (digest), сканируй. В Kubernetes используй Admission Controllers (например, OPA Gatekeeper, Kyverno) для запрета запуска образов без определенных подписей (Cosign) или из непроверенных реестров.
Часть 6: Автоматизация для пентеста/аудита. Собираем свой сканер.
Если ты проводишь аудит безопасности компании или хочешь мониторить утечки в образах своих зависимостей, ручной процесс не подойдет. Давай набросаем каркас простого, но мощного сканера на Python.
Мы будем использовать:
- docker-py для низкоуровневого взаимодействия с Docker (но можно и через subprocess).
- requests для прямого доступа к Docker Registry API (если не хочешь тянуть демон Docker).
- Все тот же jq и tarfile из стандартной библиотеки.
- Выбор цели: Список образов из файла или поиск через Docker Hub API (осторожно с лимитами).
- Скачивание: Либо через docker pull, либо напрямую качать манифест и слои по HTTP (это быстрее и не требует демона).
- Разбор: Распаковка слоев в временную директорию.
- Анализ: Запуск нашего набора эвристик (поиск файлов, grep по содержимому, анализ истории).
- Отчет: Вывод в структурированном виде (JSON, HTML) с указанием: образ, слой, путь, тип артефакта, предварительная оценка критичности.
Python:
import tarfile
import json
import os
import subprocess
import tempfile
import re
from pathlib import Path
class DockerImageArchaeologist:
def __init__(self, image_name):
self.image_name = image_name
self.temp_dir = tempfile.mkdtemp(prefix="docker_scan_")
self.findings = []
def pull_and_save(self):
"""Качает образ и сохраняет его в tar."""
# Внимание: требует установленного docker и прав
tar_path = os.path.join(self.temp_dir, "image.tar")
subprocess.run(["docker", "pull", self.image_name], check=True)
subprocess.run(["docker", "save", self.image_name, "-o", tar_path], check=True)
return tar_path
def extract_and_analyze(self, tar_path):
"""Распаковывает tar и анализирует слои."""
with tarfile.open(tar_path, 'r') as tar:
tar.extractall(path=self.temp_dir)
manifest_path = os.path.join(self.temp_dir, "manifest.json")
with open(manifest_path, 'r') as f:
manifest = json.load(f)[0] # берем первый элемент
config_file = manifest['Config']
layers = manifest['Layers']
# Анализ конфига
self._analyze_config(os.path.join(self.temp_dir, config_file))
# Анализ каждого слоя
for layer_tar in layers:
layer_path = os.path.join(self.temp_dir, layer_tar)
layer_name = os.path.basename(layer_tar).replace('.tar', '')
self._analyze_layer(layer_path, layer_name)
def _analyze_config(self, config_path):
with open(config_path, 'r') as f:
config = json.load(f)
# Проверяем переменные окружения
for env in config.get('config', {}).get('Env', []):
if self._is_sensitive(env):
self.findings.append({
'type': 'Sensitive Env in Config',
'layer': 'Config',
'data': env[:50] + '...' # обрезаем для вывода
})
# Можно разбирать историю команд (config['history'])
def _analyze_layer(self, layer_tar_path, layer_name):
"""Распаковывает слой и ищет артефакты."""
layer_extract_path = os.path.join(self.temp_dir, layer_name)
os.makedirs(layer_extract_path, exist_ok=True)
with tarfile.open(layer_tar_path, 'r') as tar:
tar.extractall(path=layer_extract_path)
# 1. Поиск файлов по именам
sensitive_patterns = ['**/.ssh/id_rsa', '**/.aws/credentials', '**/.env', '**/*.pem']
for pattern in sensitive_patterns:
for file_path in Path(layer_extract_path).glob(pattern):
self.findings.append({
'type': 'Sensitive File',
'layer': layer_name,
'file': str(file_path.relative_to(layer_extract_path))
})
# 2. Grep по содержимому
for root, dirs, files in os.walk(layer_extract_path):
for file in files:
full_path = os.path.join(root, file)
try:
with open(full_path, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read(50000) # Читаем первые 50к символов
if self._is_sensitive(content):
rel_path = os.path.relpath(full_path, layer_extract_path)
self.findings.append({
'type': 'Sensitive Content',
'layer': layer_name,
'file': rel_path,
'snippet': content[:100]
})
except:
continue # Пропускаем бинарные файлы
def _is_sensitive(self, text):
"""Эвристика для определения чувствительной информации."""
patterns = [
r'password[=:]\s*[\'"]?\w+',
r'api[_-]?key[=:]\s*[\'"]?\w+',
r'bearer\s+\w+',
r'gh[pus]_[a-zA-Z0-9]{36}',
r'Ak[aA][0-9A-Z]{16}',
r'-----BEGIN (RSA|OPENSSH|DSA|EC) PRIVATE KEY-----'
]
for pattern in patterns:
if re.search(pattern, text, re.IGNORECASE):
return True
return False
def report(self):
"""Выводит отчет."""
for finding in self.findings:
print(f"[{finding['type']}] в слое {finding['layer']}: {finding.get('file', 'N/A')}")
if 'snippet' in finding:
print(f" Сниппет: {finding['snippet']}")
def cleanup(self):
"""Очищает временные файлы."""
import shutil
shutil.rmtree(self.temp_dir)
# Использование
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Использование: python scanner.py <имя_образа>")
sys.exit(1)
archaeologist = DockerImageArchaeologist(sys.argv[1])
try:
tar_path = archaeologist.pull_and_save()
archaeologist.extract_and_analyze(tar_path)
archaeologist.report()
finally:
archaeologist.cleanup()
Это база. Ее можно расширять: добавить поддержку приватных реестров, параллельную обработку, интеграцию с базами данных для хранения результатов, более умные эвристики, сравнение diff между тегами одного образа.
Часть 7: Рамки.
Важно. Весь этот инструментарий - обоюдоострое оружие.
- Не нарушай закон. Сканирование публичных образов на Docker Hub, вероятно, не нарушает их ToS (но прочти их!), однако использование найденных креденшелов для доступа к системам - это уже компьютерное мошенничество в большинстве юрисдикций.
- Ответственное раскрытие. Если ты найдешь критическую утечку в образе компании, попробуй связаться с ними ответственно. У многих есть страницы для сообщения об уязвимостях (Security.txt, HackerOne, Bugcrowd).
- Цель - защита. Используй эти знания для аудита своих собственных образов, образов, которые использует твоя компания, и для обучения коллег. Создавай культуру безопасности, а не паранойи.
Вот мы и добрались до конца этой прогулки по слоёным пустошам публичных реестров. Если ты читал это не просто по диагонали, а пропускал через себя, проверяя команды, возможно, даже запускал скрипты на своих же образах - в твоей голове сейчас должна произойти тихая, но фундаментальная перестройка. Ты больше никогда не сможешь смотреть на команду docker pull как на безобидное действие. Ты будешь видеть за ней граф зависимостей, каждый узел которого - это потенциальная капсула времени с цифровым ДНК своего создателя. И это хорошо. Это и есть та самая осознанность, которой так не хватает в нарративе «законтейнеризируй и забудь».
Давай резюмируем не просто выводы, а сдвиг в парадигме, который мы с тобой прошли.
1. Образ - это не состояние, это процесс.
Главный вывод, который переворачивает всё с ног на голову. Мы привыкли думать об образе как о статичном артефакте, «замороженной ВМ». На деле -это протокол сборки, последовательность снимков, каждый из которых хранит не только добавленное, но и контекст того, что было до. Удаление файла - это иллюзия для runtime, но не для исследователя. Поэтому безопасность образа определяется не тем, что в нём есть в финале, а тем, что в него попало на любом этапе сборки. Это меняет точку приложения сил: защищать нужно не столько итоговый образ, сколько процесс его сборки.
2. Инструменты смотрят в прошлое. Мы научились смотреть в историю.
Trivy, Clair, Docker Scan -они прекрасно ищут CVE в libssl. Они борются с известными уязвимостями. Это важно, это гигиена. Но это реактивная защита. Наш подход -проактивный и исследовательский. Мы ищем не уязвимости в библиотеках, а ошибки в процессах людей. Утечка секрета - это не баг в коде, это сбой в workflow разработчика, в настройках CI/CD. Наши инструменты (dive, skopeo, кастомные скрипты) - это не конкуренты сканерам CVE. Это инструменты более высокого, почти социотехнического уровня. Они отвечают на вопрос «как работали люди, создавшие этот артефакт?» и, как следствие, «где в их процессе дыра?».
3. Сложность - не враг, а союзник, если её понять.
Что если «Одна команда -и всё работает»? Мы поступили наоборот: мы разобрали сложность Docker-образа на составляющие (манифесты, слои, диффы, JSON-конфиги) и посмотрели, как каждая из них может стать источником утечки. Это даёт нам не слепое доверие, а обоснованный контроль. Теперь ты знаешь, что:
- docker history вытягивается из конфига образа и может быть отредактирована лишь частично.
- Слой - это tar-архив с своими метаданными, который можно анализировать без Docker daemon.
- ADD http://... может закешировать в слое не только бинарные данные, но и HTTP-заголовки, включая Authorization.
- Переменная окружения, заданная через ENV в Dockerfile, навсегда остаётся в истории сборки, даже если в финальном образе её переопределили.
4. Это не про взлом, а про понимание уязвимости как системы.
После этого руководства ты начинаешь мыслить не как защитник крепости, а как её проектировщик, который также знает все слабые места кладки. Ты понимаешь, что разработчик, который вставил токен в RUN git clone, не идиот. Он, вероятно, спешил, работал в рамках сломанного процесса (нет доступа к приватным пакетам иначе), или ему просто никто не объяснил последствий. Твоя задача, обладая этим пониманием, -не тыкать его носом в ошибку, а построить систему, которая сделает ошибку невозможной или немедленно обнаружит её. Multi-stage сборки, BuildKit secrets, жёсткий .dockerignore - это не «best practices» из книжки. Это инженерные решения, возникающие напрямую из понимания устройства слоёв.
5. Защита - это не одна серебряная пуля, это многослойная стратегия (снова слои!).
Исходя из всего вышесказанного, выстраивается не список пунктов, а стратегия, где каждый уровень защищает от сбоя на предыдущем:
- Уровень процесса (люди): Обучение, код-ревью Dockerfile, шаблоны.
- Уровень сборки (инструменты): Обязательное использование multi-stage, запрет на COPY . ., использование секретов BuildKit, линтеры (hadolint).
- Уровень контроля версий (исходники): Pre-commit хуки с git-secrets или truffleHog, чтобы секрет даже не попал в репозиторий.
- Уровень CI/CD (автоматика): Сборка только в чистых, изолированных окружениях; сканирование артефактов перед пушем в реестр (не только на CVE, но и на секреты тем же методом, что и мы); подпись образов (Cosign).
- Уровень реестра (хранение): Приватные реестры с минимальными правами; политики, запрещающие пушить образы с тегами latest или без диджитальной подписи.
- Уровень деплоя (запуск): Admission Controllers в Kubernetes, проверяющие происхождение, подпись и отсутствие известных секретов в образе перед запуском.
- Уровень мониторинга (реагирование): Постфактумное сканирование уже работающих образов в кластере (Falco, сторонние сканеры) и мониторинг логов на предмет попыток использования утекших ключей.
6. Этичность как продолжение компетентности.
Теперь, когда ты знаешь, как просто найти секреты в публичных образах, возникает искушение. Искушение провести массовое сканирование, собрать «коллекцию» ключей, почувствовать себя повелителем утекших данных. Остановись. Помни: твоя сила -в понимании механизма, а не в злоупотреблении его результатами. Найденный в публичном доступе AWS-ключ -это не подарок судьбы, это чья-то критическая ошибка. Правильный путь - ответственное раскрытие. Не ради награды или благодарности (их часто не будет), а ради того, чтобы экосистема стала чуть менее дырявой. Ради того принципа «солидарности с читателем», который распространяется и на тех самых нерадивых разработчиков - они тоже часть нашего сообщества, просто им не хватило знаний, которые теперь есть у тебя.
Финальный аккорд: что делать прямо сейчас?
- Личный аудит. Запусти docker images, выбери свой самый старый или самый странный образ. Проведи над ним весь ритуал: docker save, распаковка, dive, grep по слоям. Уверен на 99%, что найдешь что-то, от чего станет не по себе. Это самый ценный урок.
- Документировать и делиться. Напиши в интренал-вики своей компании краткую выжимку из этой статьи. Не как приказ сверху, а как «эй, ребята, посмотрите, какую штуку я узнал, давайте проверим наши пайплайны».
- Внедрить одну практику. Выбери ОДНУ вещь: multi-stage для нового сервиса, или .dockerignore в старый проект, или линтер для Dockerfile в CI. Не пытайся изменить всё сразу. Главное - начать.
- Сменить перспективу. В следующий раз, когда будешь гуглить проблему и найдешь решение в виде docker run -e KEY=value some-image, остановись. Спроси себя: «А что внутри этого some-image? Откуда он? Можно ли ему доверять?». Это и есть момент перехода от пользователя к технологу.
Держи порох сухим, а слои - чистыми.