12.6. Secrets
Цели
После этого материала вы сможете:
- извлечь секрет из чужого образа четырьмя способами и понять, почему это возможно;
- объяснить, почему
ENVи--build-argнепригодны для секретов; - передать секрет в сборку через
RUN --mount=type=secretи доказать его отсутствие в слоях; - обосновать выбор файлов вместо переменных окружения на этапе выполнения;
- провести аудит существующих образов на утечку секретов;
- составить порядок действий после обнаружения утечки — включая то, что удаление образа не помогает.
Предварительные знания
- 5.9. Build secrets — механика secret mounts;
- 9.5. Secrets и конфигурация — Compose;
- 11.5. Секреты в production — доставка и ротация.
Этот урок — авторитетный по теме. Он собирает механику воедино и добавляет то, чего нет в предыдущих: извлечение секретов из чужих образов, аудит и действия после утечки.
Ключевые термины
| Термин | Объяснение |
|---|---|
слой | Неизменяемый набор изменений файловой системы |
history | Метаданные образа с командами каждого слоя |
secret mount | Файл, доступный только во время выполнения RUN |
ротация | Замена секрета на новый с отзывом старого |
отзыв | Признание секрета недействительным на стороне сервиса |
Теория
Четыре места, где секрет остаётся в образе
| Способ передачи | Где остаётся | Как извлечь |
|---|---|---|
ENV SECRET=... | Конфигурация образа | docker inspect |
ARG + --build-arg | history образа | docker history --no-trunc |
COPY secret.txt . затем RUN rm | Слой ниже удаления | Распаковка слоёв |
Запись в файл внутри RUN | Слой этого RUN | Распаковка слоёв |
Общая причина одна: слои неизменяемы. Удаление файла в последующем слое добавляет запись «файл удалён», но не стирает данные из предыдущего слоя. Они остаются в архиве и извлекаются (урок 3.2).
Ключевой вывод: если секрет попал в образ, его нельзя оттуда убрать. Можно только пересобрать образ без него и отозвать сам секрет.
Почему --build-arg кажется безопасным
Аргумент не сохраняется в конечной файловой системе — это верно. Но значение попадает в history, потому что Docker записывает туда команду каждого слоя вместе с подставленными аргументами.
docker build --build-arg TOKEN=секрет .
docker history --no-trunc образ | grep TOKEN # значение видно
Даже если аргумент не использовался в RUN, а только объявлен, — он виден в метаданных.
Дополнительно: --build-arg попадает в журналы CI, историю оболочки и вывод ps во время сборки.
Правильный механизм: RUN --mount=type=secret
RUN --mount=type=secret,id=pip_token \
PIP_INDEX_URL="https://$(cat /run/secrets/pip_token)@pypi.example.com/simple" \
pip install --no-cache-dir private-package
docker build --secret id=pip_token,src=./token.txt .
docker build --secret id=pip_token,env=PIP_TOKEN . # из переменной окружения
Как это работает: BuildKit монтирует файл в /run/secrets/<id> на время выполнения одной инструкции RUN. Монтирование — не часть файловой системы образа, поэтому в слой не попадает. В history записывается сама инструкция, но не содержимое секрета.
| Свойство | --build-arg | --mount=type=secret |
|---|---|---|
| В слое | Может остаться | Нет |
В history | Да | Нет |
В inspect | Если ENV | Нет |
Доступен в других RUN | Да | Нет |
| В кэше сборки | Да | Нет |
Этап выполнения: файлы против переменных
| Способ | Виден в | Риск |
|---|---|---|
-e SECRET=... | docker inspect, /proc/PID/environ, дочерние процессы | Высокий |
Файл + --secret (Compose) | Только содержимое файла | Низкий |
| Внешний менеджер секретов | Ничего на диске | Низкий |
Переменные окружения наследуются всеми дочерними процессами. Библиотека, отправляющая отчёт об ошибке, может приложить окружение целиком — и секрет уйдёт наружу.
Соглашение _FILE решает задачу совместимости:
def get_secret(name: str) -> str:
path = os.environ.get(f"{name}_FILE")
if path:
return Path(path).read_text().strip()
return os.environ[name]
Многие официальные образы (postgres, mysql, redis) поддерживают это соглашение штатно (урок 9.5).
Ротация
Секрет должен заменяться регулярно и по инциденту. Порядок:
1. Создать НОВЫЙ секрет (старый ещё действует)
2. Доставить новый в конфигурацию
3. Перезапустить сервисы
4. Убедиться, что они используют новый
5. ОТОЗВАТЬ старый на стороне сервиса
Шаг 5 обязателен и часто пропускается. Без него старый секрет продолжает работать — а он мог утечь.
Порядок именно такой: сначала новый, потом отзыв старого. Обратный порядок даёт простой.
Что делать после утечки
Порядок действий, где первый пункт нарушает интуицию:
| Шаг | Действие | Почему |
|---|---|---|
| 1 | Отозвать секрет | Единственное, что действительно устраняет риск |
| 2 | Выпустить новый | Восстановить работу |
| 3 | Проверить журналы доступа | Установить, использовался ли утёкший |
| 4 | Пересобрать образ без секрета | Предотвратить повторение |
| 5 | Удалить образ из registry | Полезно, но недостаточно |
| 6 | Разобрать причину попадания | Исправить процесс |
Удаление образа не является устранением утечки. Образ мог быть скачан, находится в кэше CI, у разработчиков локально, в резервных копиях registry. Считать секрет скомпрометированным нужно с момента публикации.
Если секрет попал в git — то же самое: переписывание истории не отменяет того, что репозиторий был склонирован.
Внутренний механизм
Где именно лежит history
history — часть манифеста образа, а не файловой системы. Она передаётся вместе с образом при docker push и docker pull, доступна любому, кто может скачать образ.
Это значит, что публикация образа в публичный registry делает history публичной. Автоматические сканеры регулярно обходят публичные registry именно в поисках секретов в метаданных.
Как secret mount не попадает в слой
BuildKit создаёт tmpfs и монтирует его в точку /run/secrets/<id> перед выполнением RUN, а после — размонтирует. Слой формируется как разница файловой системы после размонтирования.
Точка монтирования при этом видна в mountinfo во время выполнения инструкции, но её содержимое не входит в снимок слоя. Это архитектурное свойство, а не фильтрация по имени.
Следствие: даже RUN cat /run/secrets/token > /tmp/copy сохранит секрет в слой — потому что /tmp/copy уже часть файловой системы. Механизм защищает точку монтирования, а не значение.
Команды и примеры
Извлечение секрета: четыре способа
mkdir -p /tmp/leak && cd /tmp/leak
cat > Dockerfile.leaky <<'EOF'
FROM alpine:3.21
ARG API_TOKEN=не-задан
ENV DATABASE_PASSWORD=пароль-в-env-12345
RUN echo "$API_TOKEN" > /tmp/token.txt && \
echo "секрет-записан-в-слой-67890" > /etc/app-secret && \
rm -f /etc/app-secret
COPY secret-file.txt /opt/
RUN rm -f /opt/secret-file.txt
CMD ["sh"]
EOF
echo "секрет-из-файла-скопирован-99999" > secret-file.txt
echo "═══ собираем образ с четырьмя утечками ═══"
docker build -f Dockerfile.leaky --build-arg API_TOKEN=токен-из-build-arg-54321 \
-t leaky:demo . 2>&1 | tail -2 | sed 's/^/ /'
echo "═══ способ 1: docker inspect (ENV) ═══"
docker inspect leaky:demo --format '{{range .Config.Env}}{{println " " .}}{{end}}' \
| grep -i password | sed 's/^/ /'
echo "═══ способ 2: docker history (build-arg) ═══"
docker history --no-trunc leaky:demo 2>/dev/null \
| grep -o 'API_TOKEN=[^ ]*' | head -1 | sed 's/^/ /'
echo "═══ способ 3: распаковка слоёв (удалённый файл) ═══"
docker save leaky:demo -o image.tar
mkdir -p unpacked && tar -xf image.tar -C unpacked
found=0
for layer in $(find unpacked -name '*.tar' -o -name 'layer.tar' | sort); do
if tar -tf "$layer" 2>/dev/null | grep -q 'etc/app-secret\|opt/secret-file'; then
tar -xOf "$layer" $(tar -tf "$layer" | grep 'etc/app-secret\|opt/secret-file' | head -1) 2>/dev/null \
| head -1 | sed 's/^/ найдено в слое: /'
found=$((found + 1))
fi
done
[ "$found" -eq 0 ] && echo " (слои в формате OCI — см. следующий блок)"
echo "═══ способ 4: запуск и чтение окружения ═══"
docker run --rm leaky:demo sh -c 'echo " $DATABASE_PASSWORD"'
Ожидаемый вывод:
═══ собираем образ с четырьмя утечками ═══
#9 exporting to image
#9 DONE 0.1s
═══ способ 1: docker inspect (ENV) ═══
DATABASE_PASSWORD=пароль-в-env-12345
═══ способ 2: docker history (build-arg) ═══
API_TOKEN=токен-из-build-arg-54321
═══ способ 3: распаковка слоёв (удалённый файл) ═══
найдено в слое: секрет-записан-в-слой-67890
найдено в слое: секрет-из-файла-скопирован-99999
═══ способ 4: запуск и чтение окружения ═══
пароль-в-env-12345
Четыре секрета — четыре способа извлечения, каждый в одну команду. Ни один не требует запуска образа с правами или особых инструментов.
Особенно показателен третий: файлы были удалены инструкциями rm, но остались в слоях ниже.
Полный поиск по всем слоям
cd /tmp/leak
cat > scan-layers.py <<'PY'
"""Поиск секретов во всех слоях образа и в его метаданных.
Работает с форматом OCI (containerd image store) и с классическим
форматом Docker: перебирает все tar-архивы внутри сохранённого образа.
"""
from __future__ import annotations
import json
import re
import sys
import tarfile
from pathlib import Path
# Признаки секретов: имена файлов и содержимое
FILE_PATTERNS = [
r"\.env$", r"\.pem$", r"\.key$", r"id_rsa", r"\.npmrc$", r"\.netrc$",
r"credentials$", r"\.pypirc$", r"secret", r"token",
]
CONTENT_PATTERNS = [
(r"AKIA[0-9A-Z]{16}", "ключ доступа AWS"),
(r"gh[pousr]_[A-Za-z0-9]{36,}", "токен GitHub"),
(r"-----BEGIN [A-Z ]*PRIVATE KEY-----", "приватный ключ"),
(r"(?i)(password|passwd|secret|token|api[_-]?key)\s*[=:]\s*\S{8,}", "пара ключ-значение"),
(r"postgres(?:ql)?://[^:]+:[^@]+@", "строка подключения с паролем"),
]
MAX_FILE_SIZE = 512 * 1024
TEXT_SUFFIXES = {".txt", ".env", ".json", ".yaml", ".yml", ".conf", ".cfg",
".ini", ".sh", ".py", ".pem", ".key", ""}
def scan_content(data: bytes, source: str) -> list[dict[str, str]]:
try:
text = data.decode("utf-8", errors="ignore")
except Exception:
return []
hits = []
for pattern, label in CONTENT_PATTERNS:
for m in re.finditer(pattern, text):
value = m.group()[:60]
hits.append({"источник": source, "тип": label, "фрагмент": value})
break # одно совпадение на шаблон
return hits
def scan_tar(path: Path, label: str) -> list[dict[str, str]]:
hits = []
try:
with tarfile.open(path) as tf:
for member in tf:
if not member.isfile():
continue
name = member.name
if any(re.search(p, name, re.I) for p in FILE_PATTERNS):
hits.append({"источник": f"{label}:{name}",
"тип": "подозрительное имя файла",
"фрагмент": name})
if member.size > MAX_FILE_SIZE:
continue
if Path(name).suffix.lower() not in TEXT_SUFFIXES:
continue
fh = tf.extractfile(member)
if fh is None:
continue
hits.extend(scan_content(fh.read(), f"{label}:{name}"))
except (tarfile.TarError, OSError):
pass
return hits
def scan_metadata(root: Path) -> list[dict[str, str]]:
hits = []
for js in root.rglob("*.json"):
try:
data = js.read_bytes()
except OSError:
continue
hits.extend(scan_content(data, f"метаданные:{js.name}"))
return hits
def main(unpacked: str) -> int:
root = Path(unpacked)
all_hits = scan_metadata(root)
tars = sorted(p for p in root.rglob("*") if p.is_file() and tarfile.is_tarfile(p))
for i, tar_path in enumerate(tars):
all_hits.extend(scan_tar(tar_path, f"слой{i}"))
# Убираем дубликаты по фрагменту
seen = set()
unique = []
for h in all_hits:
key = h["фрагмент"]
if key not in seen:
seen.add(key)
unique.append(h)
print(json.dumps({
"проверено_архивов": len(tars),
"находок": len(unique),
"детали": unique[:15],
}, ensure_ascii=False, indent=2))
return 1 if unique else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "unpacked"))
PY
echo "═══ полное сканирование образа ═══"
python3 scan-layers.py unpacked | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\" проверено архивов: {d['проверено_архивов']}\")
print(f\" находок: {d['находок']}\")
for h in d['детали']:
print(f\" [{h['тип']}]\")
print(f\" {h['источник'][:56]}\")
print(f\" {h['фрагмент'][:56]}\")
"
Ожидаемый вывод:
═══ полное сканирование образа ═══
проверено архивов: 3
находок: 3
[пара ключ-значение]
метаданные:manifest.json
API_TOKEN=токен-из-build-arg-54321
[пара ключ-значение]
метаданные:sha256:a3f2...json
DATABASE_PASSWORD=пароль-в-env-12345
[подозрительное имя файла]
слой1:opt/secret-file.txt
opt/secret-file.txt
Сканер находит утечки и в метаданных, и в слоях, не зная заранее, что искать.
Обратите внимание: opt/secret-file.txt найден, хотя в готовом образе этого файла нет — он удалён следующей инструкцией. Сканер видит его в слое.
Правильный способ: secret mount
cd /tmp/leak
echo "токен-переданный-безопасно-11111" > build-token.txt
cat > Dockerfile.safe <<'EOF'
# syntax=docker/dockerfile:1
FROM alpine:3.21
# Секрет доступен ТОЛЬКО во время этой инструкции
RUN --mount=type=secret,id=api_token \
TOKEN="$(cat /run/secrets/api_token)" && \
echo "используем токен длиной ${#TOKEN} символов" && \
echo "результат работы с токеном" > /opt/result.txt
CMD ["cat", "/opt/result.txt"]
EOF
echo "═══ сборка с secret mount ═══"
docker build -f Dockerfile.safe --secret id=api_token,src=./build-token.txt \
-t safe:demo . 2>&1 | grep -E 'используем|DONE|ERROR' | head -3 | sed 's/^/ /'
echo "═══ проверка: ENV ═══"
docker inspect safe:demo --format '{{range .Config.Env}}{{println .}}{{end}}' \
| grep -ci 'токен\|token' | sed 's/^/ совпадений в ENV: /'
echo "═══ проверка: history ═══"
docker history --no-trunc safe:demo 2>/dev/null \
| grep -c 'токен-переданный' | sed 's/^/ совпадений в history: /'
echo "═══ проверка: слои ═══"
rm -rf unpacked-safe && mkdir -p unpacked-safe
docker save safe:demo -o safe.tar && tar -xf safe.tar -C unpacked-safe
grep -rl 'токен-переданный' unpacked-safe 2>/dev/null | wc -l | sed 's/^/ файлов со строкой в слоях: /'
echo "═══ проверка: запуск ═══"
docker run --rm safe:demo | sed 's/^/ вывод container: /'
echo "═══ секрет в файле по-прежнему цел ═══"
cat build-token.txt | sed 's/^/ исходный файл: /'
Ожидаемый вывод:
═══ сборка с secret mount ═══
#5 [2/2] RUN --mount=type=secret,id=api_token
#5 0.184 используем токен длиной 32 символов
#5 DONE 0.2s
═══ проверка: ENV ═══
совпадений в ENV: 0
═══ проверка: history ═══
совпадений в history: 0
═══ проверка: слои ═══
файлов со строкой в слоях: 0
═══ проверка: запуск ═══
вывод container: результат работы с токеном
═══ секрет в файле по-прежнему цел ═══
исходный файл: токен-переданный-безопасно-11111
Ноль по всем трём каналам утечки. При этом сборка секретом воспользовалась — строка «используем токен длиной 32 символов» это подтверждает.
Секрет всё же можно испортить
cd /tmp/leak
cat > Dockerfile.mistake <<'EOF'
# syntax=docker/dockerfile:1
FROM alpine:3.21
RUN --mount=type=secret,id=api_token \
cp /run/secrets/api_token /opt/saved-token.txt
CMD ["sh"]
EOF
echo "═══ ошибка: секрет скопирован из точки монтирования ═══"
docker build -f Dockerfile.mistake --secret id=api_token,src=./build-token.txt \
-t mistake:demo . 2>&1 | tail -1 | sed 's/^/ /'
docker run --rm mistake:demo cat /opt/saved-token.txt | sed 's/^/ секрет в образе: /'
echo "═══ вывод ═══"
cat <<'TXT'
Механизм защищает ТОЧКУ МОНТИРОВАНИЯ, а не значение секрета.
Как только содержимое скопировано в обычный путь, оно попадает
в слой на общих основаниях.
То же произойдёт при:
- записи в файл конфигурации
- установке ENV из секрета
- выводе в лог сборки (попадёт в журналы CI)
Правило: секрет ИСПОЛЬЗУЮТ внутри RUN, но не СОХРАНЯЮТ.
TXT
Ожидаемый вывод:
═══ ошибка: секрет скопирован из точки монтирования ═══
=> => naming to docker.io/library/mistake:demo
секрет в образе: токен-переданный-безопасно-11111
═══ вывод ═══
Механизм защищает ТОЧКУ МОНТИРОВАНИЯ, а не значение секрета.
Как только содержимое скопировано в обычный путь, оно попадает
в слой на общих основаниях.
То же произойдёт при:
- записи в файл конфигурации
- установке ENV из секрета
- выводе в лог сборки (попадёт в журналы CI)
Правило: секрет ИСПОЛЬЗУЮТ внутри RUN, но не СОХРАНЯЮТ.
Важное уточнение к предыдущему примеру: secret mount не делает утечку невозможной, он лишь не создаёт её сам.
Этап выполнения: почему файлы лучше переменных
cd /tmp/leak
cat > envleak.py <<'PY'
"""Демонстрация: где виден секрет, переданный через переменную окружения."""
from __future__ import annotations
import json
import os
import subprocess
from pathlib import Path
SECRET_NAME = "DATABASE_PASSWORD"
def visible_in_environ_file() -> bool:
"""Виден ли в /proc/self/environ — читается любым процессом того же UID."""
try:
data = Path("/proc/self/environ").read_bytes()
return SECRET_NAME.encode() in data
except OSError:
return False
def visible_in_child() -> str:
"""Наследуется ли дочерним процессом."""
out = subprocess.run(
["sh", "-c", f'echo "${SECRET_NAME}"'],
capture_output=True, text=True,
)
return "наследуется" if out.stdout.strip() else "не наследуется"
def visible_in_traceback() -> str:
"""Попадёт ли в отчёт об ошибке, собирающий окружение.
Типичное поведение библиотек отчётности: приложить os.environ целиком.
"""
report = {"окружение": dict(os.environ)}
return "попадёт" if SECRET_NAME in json.dumps(report) else "не попадёт"
def file_based() -> dict[str, object]:
"""Тот же секрет через файл."""
path = os.environ.get(f"{SECRET_NAME}_FILE")
if not path or not Path(path).exists():
return {"доступен": False}
return {
"доступен": True,
"в_environ": SECRET_NAME.encode() in Path("/proc/self/environ").read_bytes(),
"в_отчёте": SECRET_NAME in json.dumps({"окружение": dict(os.environ)}),
"значение_прочитано": bool(Path(path).read_text().strip()),
}
if __name__ == "__main__":
via_env = SECRET_NAME in os.environ
print(json.dumps({
"через_переменную": {
"задана": via_env,
"в_proc_environ": visible_in_environ_file(),
"в_дочернем_процессе": visible_in_child(),
"в_отчёте_об_ошибке": visible_in_traceback(),
},
"через_файл": file_based(),
}, ensure_ascii=False, indent=2))
PY
echo "═══ секрет через переменную окружения ═══"
docker run --rm -e DATABASE_PASSWORD=пароль-12345 \
-v "$PWD/envleak.py:/e.py:ro" python:3.13-slim python /e.py \
| python3 -c "
import json, sys
d = json.load(sys.stdin)['через_переменную']
for k, v in d.items():
print(f' {k:<24} {v}')
"
echo "═══ тот же секрет через файл ═══"
echo "пароль-12345" > runtime-secret.txt
docker run --rm \
-e DATABASE_PASSWORD_FILE=/run/secrets/db \
-v "$PWD/runtime-secret.txt:/run/secrets/db:ro" \
-v "$PWD/envleak.py:/e.py:ro" python:3.13-slim python /e.py \
| python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\" через_переменную.задана {d['через_переменную']['задана']}\")
for k, v in d['через_файл'].items():
print(f' файл.{k:<20} {v}')
"
echo "═══ видно ли снаружи ═══"
docker run -d --name envtest -e DATABASE_PASSWORD=пароль-12345 \
alpine:3.21 sleep 60 > /dev/null
docker inspect envtest --format '{{range .Config.Env}}{{println .}}{{end}}' \
| grep DATABASE | sed 's/^/ docker inspect: /'
docker rm -f envtest > /dev/null
docker run -d --name filetest \
-e DATABASE_PASSWORD_FILE=/run/secrets/db \
-v "$PWD/runtime-secret.txt:/run/secrets/db:ro" \
alpine:3.21 sleep 60 > /dev/null
docker inspect filetest --format '{{range .Config.Env}}{{println .}}{{end}}' \
| grep DATABASE | sed 's/^/ docker inspect: /'
docker rm -f filetest > /dev/null
Ожидаемый вывод:
═══ секрет через переменную окружения ═══
задана True
в_proc_environ True
в_дочернем_процессе наследуется
в_отчёте_об_ошибке попадёт
═══ тот же секрет через файл ═══
через_переменную.задана False
файл.доступен True
файл.в_environ False
файл.в_отчёте False
файл.значение_прочитано True
═══ видно ли снаружи ═══
docker inspect: DATABASE_PASSWORD=пароль-12345
docker inspect: DATABASE_PASSWORD_FILE=/run/secrets/db
Три канала утечки закрываются одной сменой способа доставки.
Последние две строки — самые практичные. docker inspect доступен любому, кто имеет доступ к daemon; в первом случае он показывает пароль, во втором — только путь.
Аудит существующих образов
cd /tmp/leak
cat > audit-images.sh <<'SH'
#!/usr/bin/env bash
# Проверка образов на утечку секретов в ENV и history.
set -uo pipefail
PATTERN='(?i)(password|passwd|secret|token|api[_-]?key|private[_-]?key|credential)'
issues=0
checked=0
check_image() {
local image="$1"
local found=0
checked=$((checked + 1))
# ENV
local env_hits
env_hits="$(docker inspect "$image" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null \
| grep -Pi "$PATTERN=..\S{6,}" || true)"
if [ -n "$env_hits" ]; then
printf ' %-28s ENV\n' "$image"
echo "$env_hits" | while read -r line; do
printf ' %s=***\n' "${line%%=*}"
done
found=1
fi
# history
local hist_hits
hist_hits="$(docker history --no-trunc "$image" 2>/dev/null \
| grep -Poi "$PATTERN=\S{6,}" | sort -u || true)"
if [ -n "$hist_hits" ]; then
printf ' %-28s HISTORY\n' "$image"
echo "$hist_hits" | while read -r line; do
printf ' %s=***\n' "${line%%=*}"
done
found=1
fi
[ "$found" -eq 1 ] && issues=$((issues + 1))
return 0
}
printf '\n Аудит образов на секреты\n\n'
if [ "$#" -gt 0 ]; then
for img in "$@"; do check_image "$img"; done
else
while read -r img; do
[ -z "$img" ] && continue
case "$img" in *"<none>"*) continue ;; esac
check_image "$img"
done < <(docker images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | head -20)
fi
printf '\n проверено образов: %s, с находками: %s\n' "$checked" "$issues"
[ "$issues" -gt 0 ] && exit 1
printf ' секретов в ENV и history не найдено\n'
exit 0
SH
chmod +x audit-images.sh
echo "═══ аудит трёх образов ═══"
./audit-images.sh leaky:demo safe:demo alpine:3.21 || true
Ожидаемый вывод:
═══ аудит трёх образов ═══
Аудит образов на секреты
leaky:demo ENV
DATABASE_PASSWORD=***
leaky:demo HISTORY
API_TOKEN=***
проверено образов: 3, с находками: 1
Аудит различает два канала и не выводит сами значения — важное свойство для инструмента, чей вывод попадает в журналы CI.
safe:demo и alpine:3.21 чисты, хотя первый собирался с секретом.
Порядок действий после утечки
cd /tmp/leak
cat > INCIDENT.md <<'TXT'
# Порядок действий при утечке секрета в образ
## Немедленно (минуты)
1. **ОТОЗВАТЬ секрет** на стороне сервиса, который его выдал.
Это единственное действие, реально устраняющее риск.
Не откладывать до выяснения обстоятельств.
2. Выпустить новый секрет и доставить его в конфигурацию.
3. Перезапустить сервисы, проверить работу.
## В тот же день (часы)
4. Проверить журналы доступа сервиса: использовался ли утёкший секрет
с неизвестных адресов и когда.
5. Определить область: где ещё мог оказаться образ.
- публичный или внутренний registry
- кэш CI-сборщиков
- локальные машины разработчиков
- резервные копии registry
6. Пересобрать образ без секрета, опубликовать, обновить развёртывания.
7. Удалить образ из registry.
ВАЖНО: это НЕ устраняет утечку, а только сокращает распространение.
## В течение недели
8. Разобрать, как секрет попал в образ:
- ARG/--build-arg вместо secret mount
- COPY каталога с .env (проверить .dockerignore)
- ENV в Dockerfile
- копирование из /run/secrets в обычный путь
9. Добавить автоматическую проверку в CI: сканирование образа
перед публикацией, блокировка при находках.
10. Проверить остальные образы тем же сканером.
## Чего НЕ делать
- Не считать удаление образа устранением утечки.
- Не откладывать отзыв до полного расследования.
- Не переписывать историю git вместо отзыва — репозиторий уже склонирован.
- Не оставлять старый секрет действующим «на время перехода».
TXT
echo "═══ порядок действий записан ═══"
head -12 INCIDENT.md | sed 's/^/ /'
echo " ..."
echo "═══ ключевой пункт ═══"
cat <<'TXT'
Первое действие — ОТЗЫВ, а не удаление образа.
Причина: образ мог быть скачан кем угодно с момента публикации.
Удаление из registry не отменяет уже сделанных копий: кэш CI,
локальные машины, резервные копии.
Секрет считается скомпрометированным с момента публикации образа,
а не с момента обнаружения.
TXT
cd /tmp && rm -rf /tmp/leak
Ожидаемый вывод:
═══ порядок действий записан ═══
# Порядок действий при утечке секрета в образ
## Немедленно (минуты)
1. **ОТОЗВАТЬ секрет** на стороне сервиса, который его выдал.
Это единственное действие, реально устраняющее риск.
Не откладывать до выяснения обстоятельств.
2. Выпустить новый секрет и доставить его в конфигурацию.
3. Перезапустить сервисы, проверить работу.
...
═══ ключевой пункт ═══
Первое действие — ОТЗЫВ, а не удаление образа.
Причина: образ мог быть скачан кем угодно с момента публикации.
Удаление из registry не отменяет уже сделанных копий: кэш CI,
локальные машины, резервные копии.
Секрет считается скомпрометированным с момента публикации образа,
а не с момента обнаружения.
Практическое упражнение
Задание. Извлеките секрет из образа, переделайте сборку и постройте защиту от повторения.
Требования:
- Собрать образ с секретами, переданными тремя способами:
ENV,--build-arg,COPYс последующимrm. - Извлечь каждый из трёх, показав команду.
- Переделать сборку на
RUN --mount=type=secretи доказать отсутствие секрета во всех трёх каналах. - Показать, что secret mount не защищает от копирования содержимого в обычный путь.
- Показать разницу между переменной окружения и файлом на этапе выполнения.
- Написать проверку для CI, блокирующую публикацию образа с секретом.
Подсказки
Подсказка 1
Для пункта 2 три разных инструмента: docker inspect, docker history --no-trunc, распаковка docker save.
Подсказка 2
Пункт 3 требует проверить все три канала, а не только файловую систему.
Подсказка 3
Проверка для CI должна возвращать ненулевой код и не выводить сами значения — её вывод попадает в журнал.
Решение
Показать решение
mkdir -p /tmp/secrets-lab && cd /tmp/secrets-lab
# ─── Образ с тремя утечками ───────────────────────────────────────────
cat > Dockerfile.bad <<'EOF'
FROM alpine:3.21
ARG BUILD_TOKEN=не-задан
ENV RUNTIME_PASSWORD=утечка-через-env-AAAAA
RUN echo "использую $BUILD_TOKEN" > /dev/null
COPY app-secret.txt /opt/app-secret.txt
RUN rm -f /opt/app-secret.txt
CMD ["sh"]
EOF
echo "утечка-через-copy-CCCCC" > app-secret.txt
# ─── Образ без утечек ─────────────────────────────────────────────────
cat > Dockerfile.good <<'EOF'
# syntax=docker/dockerfile:1
FROM alpine:3.21
RUN --mount=type=secret,id=build_token \
--mount=type=secret,id=app_secret \
LEN_T="$(wc -c < /run/secrets/build_token)" && \
LEN_A="$(wc -c < /run/secrets/app_secret)" && \
echo "секреты прочитаны: ${LEN_T} и ${LEN_A} байт" && \
echo "сборка выполнена без сохранения секретов" > /opt/result.txt
CMD ["cat", "/opt/result.txt"]
EOF
# ─── Образ с ошибкой применения secret mount ──────────────────────────
cat > Dockerfile.mistake <<'EOF'
# syntax=docker/dockerfile:1
FROM alpine:3.21
RUN --mount=type=secret,id=build_token \
cp /run/secrets/build_token /opt/leaked.txt
CMD ["sh"]
EOF
echo "утечка-через-buildarg-BBBBB" > build-token.txt
# ─── Сканер ───────────────────────────────────────────────────────────
cat > scan.py <<'PY'
"""Поиск строки-маркера во всех каналах образа: ENV, history, слои.
Возвращает JSON с указанием, в каких каналах найдено.
"""
from __future__ import annotations
import json
import subprocess
import sys
import tarfile
import tempfile
from pathlib import Path
def run(cmd: list[str]) -> str:
r = subprocess.run(cmd, capture_output=True, text=True)
return r.stdout
def in_env(image: str, marker: str) -> bool:
out = run(["docker", "inspect", image,
"--format", "{{range .Config.Env}}{{println .}}{{end}}"])
return marker in out
def in_history(image: str, marker: str) -> bool:
out = run(["docker", "history", "--no-trunc", image])
return marker in out
def in_layers(image: str, marker: str) -> bool:
needle = marker.encode()
with tempfile.TemporaryDirectory() as tmp:
tar_path = Path(tmp) / "image.tar"
subprocess.run(["docker", "save", image, "-o", str(tar_path)],
capture_output=True)
if not tar_path.exists():
return False
unpacked = Path(tmp) / "unpacked"
unpacked.mkdir()
try:
with tarfile.open(tar_path) as tf:
tf.extractall(unpacked, filter="data")
except (tarfile.TarError, TypeError):
return False
for path in unpacked.rglob("*"):
if not path.is_file():
continue
if tarfile.is_tarfile(path):
try:
with tarfile.open(path) as inner:
for member in inner:
if not member.isfile() or member.size > 1 << 20:
continue
fh = inner.extractfile(member)
if fh and needle in fh.read():
return True
except (tarfile.TarError, OSError):
continue
else:
try:
if path.stat().st_size < (1 << 20) and needle in path.read_bytes():
return True
except OSError:
continue
return False
def main() -> int:
image = sys.argv[1]
markers = sys.argv[2:]
result = {}
for marker in markers:
channels = {
"ENV": in_env(image, marker),
"history": in_history(image, marker),
"слои": in_layers(image, marker),
}
result[marker] = {
"каналы": channels,
"найден": any(channels.values()),
}
print(json.dumps({
"образ": image,
"маркеры": result,
"утечек": sum(1 for v in result.values() if v["найден"]),
}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())
PY
# ─── Проверка для CI ──────────────────────────────────────────────────
cat > ci-secret-gate.sh <<'SH'
#!/usr/bin/env bash
# Блокирует публикацию образа при обнаружении секретов.
# Вывод НЕ содержит значений: попадает в журнал CI.
set -uo pipefail
IMAGE="${1:?укажите образ}"
PATTERNS='(?i)(password|passwd|secret|token|api[_-]?key|private[_-]?key|credential|access[_-]?key)'
KEY_SHAPES='AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{36,}|-----BEGIN [A-Z ]*PRIVATE KEY-----'
findings=0
report() { printf ' ✗ %-10s %s\n' "$1" "$2"; findings=$((findings + 1)); }
printf '\n Проверка образа: %s\n\n' "$IMAGE"
# 1. ENV с подозрительным именем и непустым значением
while read -r line; do
[ -z "$line" ] && continue
report "ENV" "переменная ${line%%=*} содержит значение"
done < <(docker inspect "$IMAGE" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null \
| grep -Pi "^${PATTERNS}[A-Z_]*=.{6,}" || true)
# 2. history с подставленными значениями
while read -r line; do
[ -z "$line" ] && continue
report "HISTORY" "аргумент ${line%%=*} записан в метаданные"
done < <(docker history --no-trunc "$IMAGE" 2>/dev/null \
| grep -Poi "${PATTERNS}[A-Z_]*=\S{6,}" | sort -u || true)
# 3. Характерные формы ключей в history
if docker history --no-trunc "$IMAGE" 2>/dev/null | grep -Pq "$KEY_SHAPES"; then
report "HISTORY" "обнаружена характерная форма ключа доступа"
fi
# 4. Подозрительные файлы в слоях
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
if docker save "$IMAGE" -o "$tmp/img.tar" 2>/dev/null; then
tar -xf "$tmp/img.tar" -C "$tmp" 2>/dev/null
while read -r layer; do
while read -r entry; do
[ -z "$entry" ] && continue
report "СЛОЙ" "файл $entry"
done < <(tar -tf "$layer" 2>/dev/null \
| grep -Ei '(^|/)(\.env|\.npmrc|\.netrc|\.pypirc|id_rsa|credentials)$' || true)
done < <(find "$tmp" -name '*.tar' -not -name 'img.tar' 2>/dev/null)
fi
printf '\n'
if [ "$findings" -gt 0 ]; then
printf ' РЕЗУЛЬТАТ: найдено %s, публикация ЗАБЛОКИРОВАНА\n' "$findings"
printf ' Значения намеренно не выведены: журнал CI доступен многим.\n'
exit 1
fi
printf ' РЕЗУЛЬТАТ: секретов не обнаружено, публикация разрешена\n'
exit 0
SH
chmod +x ci-secret-gate.sh
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
printf '\n═══ Требование 1: образ с тремя утечками ═══\n'
docker build -f Dockerfile.bad --build-arg BUILD_TOKEN=утечка-через-buildarg-BBBBB \
-t lab-bad:1 . > build-bad.log 2>&1
if [ -f build-bad.log ] && docker image inspect lab-bad:1 > /dev/null 2>&1; then
ok "образ собран"
else
bad "сборка не удалась"; tail -3 build-bad.log | sed 's/^/ /'
fi
printf '\n═══ Требование 2: извлечение каждого секрета ═══\n'
printf ' 1. ENV (docker inspect): '
docker inspect lab-bad:1 --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null \
| grep -o 'утечка-через-env-[A-Z]*' | head -1
printf ' 2. build-arg (docker history): '
docker history --no-trunc lab-bad:1 2>/dev/null \
| grep -o 'утечка-через-buildarg-[A-Z]*' | head -1
printf ' 3. COPY+rm (распаковка слоёв): '
rm -rf unp && mkdir unp
docker save lab-bad:1 -o bad.tar 2>/dev/null && tar -xf bad.tar -C unp 2>/dev/null
copy_leak=""
while read -r layer; do
if tar -tf "$layer" 2>/dev/null | grep -q 'opt/app-secret.txt'; then
copy_leak="$(tar -xOf "$layer" opt/app-secret.txt 2>/dev/null | tr -d '\n')"
[ -n "$copy_leak" ] && break
fi
done < <(find unp -name '*.tar' 2>/dev/null)
echo "${copy_leak:-НЕ НАЙДЕНО}"
scan_bad="$(python3 scan.py lab-bad:1 \
утечка-через-env-AAAAA утечка-через-buildarg-BBBBB утечка-через-copy-CCCCC 2>/dev/null)"
echo "$scan_bad" | python3 -c "
import json, sys
d = json.load(sys.stdin)
print()
print(f\" {'маркер':<32} {'ENV':>5} {'history':>8} {'слои':>6}\")
for m, info in d['маркеры'].items():
c = info['каналы']
print(f\" {m:<32} {str(c['ENV']):>5} {str(c['history']):>8} {str(c['слои']):>6}\")
print(f\" утечек: {d['утечек']} из 3\")
"
n_bad="$(echo "$scan_bad" | python3 -c "import json,sys; print(json.load(sys.stdin)['утечек'])")"
[ "$n_bad" -eq 3 ] && ok "все три секрета извлечены" || bad "извлечено $n_bad из 3"
printf '\n═══ Требование 3: сборка через secret mount ═══\n'
docker build -f Dockerfile.good \
--secret id=build_token,src=./build-token.txt \
--secret id=app_secret,src=./app-secret.txt \
-t lab-good:1 . > build-good.log 2>&1
grep -o 'секреты прочитаны: [0-9]* и [0-9]* байт' build-good.log | head -1 | sed 's/^/ сборка: /'
scan_good="$(python3 scan.py lab-good:1 \
утечка-через-buildarg-BBBBB утечка-через-copy-CCCCC 2>/dev/null)"
echo "$scan_good" | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\" {'маркер':<32} {'ENV':>5} {'history':>8} {'слои':>6}\")
for m, info in d['маркеры'].items():
c = info['каналы']
print(f\" {m:<32} {str(c['ENV']):>5} {str(c['history']):>8} {str(c['слои']):>6}\")
print(f\" утечек: {d['утечек']}\")
"
n_good="$(echo "$scan_good" | python3 -c "import json,sys; print(json.load(sys.stdin)['утечек'])")"
docker run --rm lab-good:1 | sed 's/^/ работа образа: /'
[ "$n_good" -eq 0 ] && ok "ни один секрет не попал в образ" || bad "утечек: $n_good"
printf '\n═══ Требование 4: secret mount не защищает от копирования ═══\n'
docker build -f Dockerfile.mistake --secret id=build_token,src=./build-token.txt \
-t lab-mistake:1 > /dev/null 2>&1
docker run --rm lab-mistake:1 cat /opt/leaked.txt 2>/dev/null | sed 's/^/ содержимое образа: /'
scan_mistake="$(python3 scan.py lab-mistake:1 утечка-через-buildarg-BBBBB 2>/dev/null)"
echo "$scan_mistake" | python3 -c "
import json, sys
d = json.load(sys.stdin)
c = list(d['маркеры'].values())[0]['каналы']
print(f\" ENV={c['ENV']} history={c['history']} слои={c['слои']}\")
"
n_mistake="$(echo "$scan_mistake" | python3 -c "import json,sys; print(json.load(sys.stdin)['утечек'])")"
[ "$n_mistake" -eq 1 ] \
&& ok "копирование из /run/secrets вернуло секрет в слой" \
|| bad "утечка не воспроизвелась"
printf '\n═══ Требование 5: переменная против файла ═══\n'
cat > check-runtime.py <<'PY'
import json, os, subprocess
from pathlib import Path
NAME = "APP_PASSWORD"
via_env = NAME in os.environ
environ_blob = Path("/proc/self/environ").read_bytes()
child = subprocess.run(["sh", "-c", f'echo "${NAME}"'], capture_output=True, text=True)
report_has = NAME in json.dumps(dict(os.environ))
file_path = os.environ.get(f"{NAME}_FILE")
via_file = bool(file_path and Path(file_path).exists())
print(json.dumps({
"способ": "переменная" if via_env else ("файл" if via_file else "нет"),
"в_proc_environ": NAME.encode() in environ_blob,
"наследуется_потомком": bool(child.stdout.strip()),
"в_отчёте_об_ошибке": report_has,
"значение_доступно": bool(
os.environ.get(NAME) or (via_file and Path(file_path).read_text().strip())),
}, ensure_ascii=False))
PY
echo "пароль-времени-выполнения" > runtime.txt
printf ' %-14s %-16s %-20s %-20s %s\n' \
"способ" "в /proc/environ" "наследуется потомком" "в отчёте об ошибке" "доступно"
printf ' %s\n' "──────────────────────────────────────────────────────────────────────────────────"
for mode in env file; do
if [ "$mode" = "env" ]; then
args=(-e APP_PASSWORD=пароль-времени-выполнения)
else
args=(-e APP_PASSWORD_FILE=/run/secrets/pw -v "$PWD/runtime.txt:/run/secrets/pw:ro")
fi
docker run --rm "${args[@]}" -v "$PWD/check-runtime.py:/c.py:ro" \
python:3.13-slim python /c.py 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\" {d['способ']:<14} {str(d['в_proc_environ']):<16} \" +
f\"{str(d['наследуется_потомком']):<20} {str(d['в_отчёте_об_ошибке']):<20} \" +
f\"{d['значение_доступно']}\")
"
done
env_leak="$(docker run --rm -e APP_PASSWORD=пароль -v "$PWD/check-runtime.py:/c.py:ro" \
python:3.13-slim python /c.py 2>/dev/null \
| python3 -c "import json,sys; print(json.load(sys.stdin)['в_отчёте_об_ошибке'])")"
file_leak="$(docker run --rm -e APP_PASSWORD_FILE=/run/secrets/pw \
-v "$PWD/runtime.txt:/run/secrets/pw:ro" -v "$PWD/check-runtime.py:/c.py:ro" \
python:3.13-slim python /c.py 2>/dev/null \
| python3 -c "import json,sys; print(json.load(sys.stdin)['в_отчёте_об_ошибке'])")"
[ "$env_leak" = "True" ] && [ "$file_leak" = "False" ] \
&& ok "файл закрывает три канала утечки, переменная — нет" \
|| bad "результаты: env=$env_leak file=$file_leak"
printf '\n═══ Требование 6: проверка для CI ═══\n'
printf ' на образе с утечками:\n'
./ci-secret-gate.sh lab-bad:1; rc_bad=$?
printf ' на образе без утечек:\n'
./ci-secret-gate.sh lab-good:1; rc_good=$?
printf ' коды возврата: с утечками=%s, без=%s\n' "$rc_bad" "$rc_good"
gate_out="$(./ci-secret-gate.sh lab-bad:1 2>&1)"
if echo "$gate_out" | grep -q 'AAAAA\|BBBBB\|CCCCC'; then
bad "проверка вывела значения секретов в журнал"
else
ok "значения секретов в вывод не попали"
fi
[ "$rc_bad" -eq 1 ] && [ "$rc_good" -eq 0 ] \
&& ok "проверка блокирует утечку и пропускает чистый образ" \
|| bad "коды: $rc_bad и $rc_good"
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все требования выполнены" || echo " ЕСТЬ ПРОВАЛЫ"
docker rmi -f lab-bad:1 lab-good:1 lab-mistake:1 > /dev/null 2>&1
cd /tmp && rm -rf /tmp/secrets-lab
exit "$fail"
Ожидаемый вывод:
═══ Требование 1: образ с тремя утечками ═══
✓ образ собран
═══ Требование 2: извлечение каждого секрета ═══
1. ENV (docker inspect): утечка-через-env-AAAAA
2. build-arg (docker history): утечка-через-buildarg-BBBBB
3. COPY+rm (распаковка слоёв): утечка-через-copy-CCCCC
маркер ENV history слои
утечка-через-env-AAAAA True False False
утечка-через-buildarg-BBBBB False True False
утечка-через-copy-CCCCC False False True
утечек: 3 из 3
✓ все три секрета извлечены
═══ Требование 3: сборка через secret mount ═══
сборка: секреты прочитаны: 28 и 24 байт
маркер ENV history слои
утечка-через-buildarg-BBBBB False False False
утечка-через-copy-CCCCC False False False
утечек: 0
работа образа: сборка выполнена без сохранения секретов
✓ ни один секрет не попал в образ
═══ Требование 4: secret mount не защищает от копирования ═══
содержимое образа: утечка-через-buildarg-BBBBB
ENV=False history=False слои=True
✓ копирование из /run/secrets вернуло секрет в слой
═══ Требование 5: переменная против файла ═══
способ в /proc/environ наследуется потомком в отчёте об ошибке доступно
──────────────────────────────────────────────────────────────────────────────────
переменная True True True True
файл False False False True
✓ файл закрывает три канала утечки, переменная — нет
═══ Требование 6: проверка для CI ═══
на образе с утечками:
Проверка образа: lab-bad:1
✗ ENV переменная RUNTIME_PASSWORD содержит значение
✗ HISTORY аргумент BUILD_TOKEN записан в метаданные
РЕЗУЛЬТАТ: найдено 2, публикация ЗАБЛОКИРОВАНА
Значения намеренно не выведены: журнал CI доступен многим.
на образе без утечек:
Проверка образа: lab-good:1
РЕЗУЛЬТАТ: секретов не обнаружено, публикация разрешена
коды возврата: с утечками=1, без=0
✓ значения секретов в вывод не попали
✓ проверка блокирует утечку и пропускает чистый образ
═══ ИТОГ ═══
все требования выполнены
Все требования выполнены.
Таблица требования 2 — главный результат: каждый способ передачи оставляет след в своём канале. Проверка одного канала находит одну утечку из трёх, поэтому сканер обязан проверять все.
Три решения, определяющие качество.
Сканер проверяет три канала независимо и показывает, в каком именно нашёл. Ответ «секрет найден» бесполезен для исправления. Ответ «найден в history» указывает на --build-arg; «в слоях» — на COPY или запись в файл; «в ENV» — на инструкцию ENV. Диагноз определяет исправление.
Требование 4 намеренно демонстрирует провал правильного механизма. Пункты 3 и 4 вместе дают полную картину: secret mount защищает точку монтирования, но не значение. Без пункта 4 у читателя осталось бы впечатление, что достаточно заменить флаг, — и он написал бы cp /run/secrets/token /etc/config.
Проверка для CI не печатает значения. Её вывод попадает в журнал сборки, доступный всей команде и часто хранящийся месяцами. Инструмент поиска секретов, печатающий секреты, создаёт вторую утечку — поэтому выводятся только имена переменных и каналы. Отдельная проверка это подтверждает.
Чего решение не делает. Сканер ищет заранее известные маркеры — в реальном аудите их нет, и работать приходится по эвристикам (характерные формы ключей, энтропия строк, имена файлов), которые дают ложные срабатывания. Ротация не демонстрируется: она требует внешнего сервиса, выдающего и отзывающего секреты. Наконец, проверка для CI смотрит на готовый образ, а секрет мог утечь раньше — в журнал сборки или в git; это отдельные рубежи контроля.
Проверка результата
docker history --no-trunc ВАШ_ОБРАЗ | grep -Ei 'password|token|secret|key='
docker inspect ВАШ_ОБРАЗ --format '{{range .Config.Env}}{{println .}}{{end}}' | grep -Ei 'password|token|secret'
docker save ВАШ_ОБРАЗ | tar -t | head
Ожидается пустой вывод первых двух команд.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
--build-arg для секрета | Не сохраняется в файловой системе | Попадает в history; secret mount |
ENV SECRET=... в Dockerfile | Удобно | Виден в docker inspect |
COPY секрета и RUN rm | Кажется, что удалён | Остаётся в слое ниже |
cp /run/secrets/x /etc/y | Механизм считают магией | Защищена точка монтирования, не значение |
Секрет через -e в production | Все примеры так делают | Файл и соглашение _FILE |
| Удаление образа как устранение утечки | Логично на первый взгляд | Образ уже скачан; отзывать секрет |
| Отзыв старого секрета до выпуска нового | Кажется безопаснее | Простой; порядок: новый → отзыв старого |
| Пропуск отзыва при ротации | Новый уже работает | Старый действует и мог утечь |
| Вывод найденных секретов в журнал CI | Удобно для отладки | Журнал доступен многим; печатать имена |
| Проверка только одного канала | Не знают об остальных | Три канала: ENV, history, слои |
Контрольные вопросы
На понимание:
- Почему
--build-argнепригоден для секретов, если аргумент не сохраняется в файловой системе? - Почему
RUN rm secret.txtне удаляет секрет из образа? - Что именно защищает
RUN --mount=type=secretи что не защищает? - Три канала утечки секрета в образе — назовите и укажите инструмент для каждого.
- Почему файл лучше переменной окружения на этапе выполнения?
На применение:
- Как передать токен PyPI в сборку, не оставив его в образе?
- Какой порядок действий при ротации секрета и почему именно такой?
- Как построить проверку для CI, не создающую вторую утечку?
На диагностику:
- В опубликованном образе найден действующий токен. Первое действие?
- Секрет передан через secret mount, но найден в слое. Причина?
Краткое резюме
- Слои неизменяемы: удаление файла не стирает его из слоя ниже.
- Если секрет попал в образ, убрать его оттуда нельзя — только пересобрать и отозвать.
- Три канала утечки:
ENV(docker inspect),history(--build-arg), слои (COPY, запись в файл). --build-argпопадает вhistory, доступную всем, кто может скачать образ.RUN --mount=type=secretмонтирует секрет только на время одной инструкции.- Механизм защищает точку монтирования, а не значение: копирование в обычный путь создаёт утечку.
- На этапе выполнения переменная окружения видна в
inspect,/proc/PID/environи наследуется потомками. - Файл плюс соглашение
_FILEзакрывают эти каналы; официальные образы его поддерживают. - Ротация: сначала выпустить новый, затем отозвать старый — обратный порядок даёт простой.
- При утечке первое действие — отзыв секрета, а не удаление образа.
- Удаление образа из registry не устраняет утечку: копии уже сделаны.
- Проверка для CI не должна печатать значения — её журнал доступен многим.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Docker: build secrets | https://docs.docker.com/build/building/secrets/ | --mount=type=secret, --secret |
Docker: ARG | https://docs.docker.com/reference/dockerfile/#arg | Предупреждение о history |
| Docker: Compose secrets | https://docs.docker.com/compose/how-tos/use-secrets/ | Секреты в Compose |
| Docker: image history | https://docs.docker.com/reference/cli/docker/image/history/ | Состав метаданных |
| OCI Image Spec | https://github.com/opencontainers/image-spec/blob/main/config.md | history как часть конфигурации |
| OWASP: secrets management | https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html | Ротация, отзыв, хранение |
| PostgreSQL image | https://hub.docker.com/_/postgres | Соглашение _FILE |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Supply chain security
Главное оглавление