Главная/Security/Урок

12.6. Secrets

Цели

После этого материала вы сможете:

  • извлечь секрет из чужого образа четырьмя способами и понять, почему это возможно;
  • объяснить, почему ENV и --build-arg непригодны для секретов;
  • передать секрет в сборку через RUN --mount=type=secret и доказать его отсутствие в слоях;
  • обосновать выбор файлов вместо переменных окружения на этапе выполнения;
  • провести аудит существующих образов на утечку секретов;
  • составить порядок действий после обнаружения утечки — включая то, что удаление образа не помогает.

Предварительные знания

Этот урок — авторитетный по теме. Он собирает механику воедино и добавляет то, чего нет в предыдущих: извлечение секретов из чужих образов, аудит и действия после утечки.

Ключевые термины

ТерминОбъяснение
слойНеизменяемый набор изменений файловой системы
historyМетаданные образа с командами каждого слоя
secret mountФайл, доступный только во время выполнения RUN
ротацияЗамена секрета на новый с отзывом старого
отзывПризнание секрета недействительным на стороне сервиса

Теория

Четыре места, где секрет остаётся в образе

Способ передачиГде остаётсяКак извлечь
ENV SECRET=...Конфигурация образаdocker inspect
ARG + --build-arghistory образаdocker history --no-trunc
COPY secret.txt . затем RUN rmСлой ниже удаленияРаспаковка слоёв
Запись в файл внутри RUNСлой этого RUNРаспаковка слоёв

Общая причина одна: слои неизменяемы. Удаление файла в последующем слое добавляет запись «файл удалён», но не стирает данные из предыдущего слоя. Они остаются в архиве и извлекаются (урок 3.2).

Ключевой вывод: если секрет попал в образ, его нельзя оттуда убрать. Можно только пересобрать образ без него и отозвать сам секрет.

Почему --build-arg кажется безопасным

Аргумент не сохраняется в конечной файловой системе — это верно. Но значение попадает в history, потому что Docker записывает туда команду каждого слоя вместе с подставленными аргументами.

bash
docker build --build-arg TOKEN=секрет .
docker history --no-trunc образ | grep TOKEN     # значение видно

Даже если аргумент не использовался в RUN, а только объявлен, — он виден в метаданных.

Дополнительно: --build-arg попадает в журналы CI, историю оболочки и вывод ps во время сборки.

Правильный механизм: RUN --mount=type=secret

dockerfile
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
bash
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 решает задачу совместимости:

python
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).

Ротация

Секрет должен заменяться регулярно и по инциденту. Порядок:

text
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 уже часть файловой системы. Механизм защищает точку монтирования, а не значение.


Команды и примеры

Извлечение секрета: четыре способа

bash
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"'

Ожидаемый вывод:

text
═══ собираем образ с четырьмя утечками ═══
  #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, но остались в слоях ниже.

Полный поиск по всем слоям

bash
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]}\")
"

Ожидаемый вывод:

text
═══ полное сканирование образа ═══
  проверено архивов: 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

bash
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/^/  исходный файл: /'

Ожидаемый вывод:

text
═══ сборка с 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 символов» это подтверждает.

Секрет всё же можно испортить

bash
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

Ожидаемый вывод:

text
═══ ошибка: секрет скопирован из точки монтирования ═══
   => => naming to docker.io/library/mistake:demo
  секрет в образе: токен-переданный-безопасно-11111
═══ вывод ═══
  Механизм защищает ТОЧКУ МОНТИРОВАНИЯ, а не значение секрета.
  Как только содержимое скопировано в обычный путь, оно попадает
  в слой на общих основаниях.

  То же произойдёт при:
    - записи в файл конфигурации
    - установке ENV из секрета
    - выводе в лог сборки (попадёт в журналы CI)

  Правило: секрет ИСПОЛЬЗУЮТ внутри RUN, но не СОХРАНЯЮТ.

Важное уточнение к предыдущему примеру: secret mount не делает утечку невозможной, он лишь не создаёт её сам.

Этап выполнения: почему файлы лучше переменных

bash
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

Ожидаемый вывод:

text
═══ секрет через переменную окружения ═══
  задана                   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; в первом случае он показывает пароль, во втором — только путь.

Аудит существующих образов

bash
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

Ожидаемый вывод:

text
═══ аудит трёх образов ═══

  Аудит образов на секреты

  leaky:demo                   ENV
      DATABASE_PASSWORD=***
  leaky:demo                   HISTORY
      API_TOKEN=***
  
  проверено образов: 3, с находками: 1

Аудит различает два канала и не выводит сами значения — важное свойство для инструмента, чей вывод попадает в журналы CI.

safe:demo и alpine:3.21 чисты, хотя первый собирался с секретом.

Порядок действий после утечки

bash
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

Ожидаемый вывод:

text
═══ порядок действий записан ═══
  # Порядок действий при утечке секрета в образ
  
  ## Немедленно (минуты)
  
  1. **ОТОЗВАТЬ секрет** на стороне сервиса, который его выдал.
     Это единственное действие, реально устраняющее риск.
     Не откладывать до выяснения обстоятельств.
  
  2. Выпустить новый секрет и доставить его в конфигурацию.
  
  3. Перезапустить сервисы, проверить работу.
  ...
═══ ключевой пункт ═══
  Первое действие — ОТЗЫВ, а не удаление образа.

  Причина: образ мог быть скачан кем угодно с момента публикации.
  Удаление из registry не отменяет уже сделанных копий: кэш CI,
  локальные машины, резервные копии.

  Секрет считается скомпрометированным с момента публикации образа,
  а не с момента обнаружения.

Практическое упражнение

Задание. Извлеките секрет из образа, переделайте сборку и постройте защиту от повторения.

Требования:

  1. Собрать образ с секретами, переданными тремя способами: ENV, --build-arg, COPY с последующим rm.
  2. Извлечь каждый из трёх, показав команду.
  3. Переделать сборку на RUN --mount=type=secret и доказать отсутствие секрета во всех трёх каналах.
  4. Показать, что secret mount не защищает от копирования содержимого в обычный путь.
  5. Показать разницу между переменной окружения и файлом на этапе выполнения.
  6. Написать проверку для CI, блокирующую публикацию образа с секретом.

Подсказки

Подсказка 1

Для пункта 2 три разных инструмента: docker inspect, docker history --no-trunc, распаковка docker save.

Подсказка 2

Пункт 3 требует проверить все три канала, а не только файловую систему.

Подсказка 3

Проверка для CI должна возвращать ненулевой код и не выводить сами значения — её вывод попадает в журнал.

Решение

Показать решение
bash
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"

Ожидаемый вывод:

text
═══ Требование 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; это отдельные рубежи контроля.

Проверка результата

bash
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, слои

Контрольные вопросы

На понимание:

  1. Почему --build-arg непригоден для секретов, если аргумент не сохраняется в файловой системе?
  2. Почему RUN rm secret.txt не удаляет секрет из образа?
  3. Что именно защищает RUN --mount=type=secret и что не защищает?
  4. Три канала утечки секрета в образе — назовите и укажите инструмент для каждого.
  5. Почему файл лучше переменной окружения на этапе выполнения?

На применение:

  1. Как передать токен PyPI в сборку, не оставив его в образе?
  2. Какой порядок действий при ротации секрета и почему именно такой?
  3. Как построить проверку для CI, не создающую вторую утечку?

На диагностику:

  1. В опубликованном образе найден действующий токен. Первое действие?
  2. Секрет передан через secret mount, но найден в слое. Причина?

Краткое резюме

  1. Слои неизменяемы: удаление файла не стирает его из слоя ниже.
  2. Если секрет попал в образ, убрать его оттуда нельзя — только пересобрать и отозвать.
  3. Три канала утечки: ENV (docker inspect), history (--build-arg), слои (COPY, запись в файл).
  4. --build-arg попадает в history, доступную всем, кто может скачать образ.
  5. RUN --mount=type=secret монтирует секрет только на время одной инструкции.
  6. Механизм защищает точку монтирования, а не значение: копирование в обычный путь создаёт утечку.
  7. На этапе выполнения переменная окружения видна в inspect, /proc/PID/environ и наследуется потомками.
  8. Файл плюс соглашение _FILE закрывают эти каналы; официальные образы его поддерживают.
  9. Ротация: сначала выпустить новый, затем отозвать старый — обратный порядок даёт простой.
  10. При утечке первое действие — отзыв секрета, а не удаление образа.
  11. Удаление образа из registry не устраняет утечку: копии уже сделаны.
  12. Проверка для CI не должна печатать значения — её журнал доступен многим.

Официальные источники

ИсточникСсылкаЧто подтверждает
Docker: build secretshttps://docs.docker.com/build/building/secrets/--mount=type=secret, --secret
Docker: ARGhttps://docs.docker.com/reference/dockerfile/#argПредупреждение о history
Docker: Compose secretshttps://docs.docker.com/compose/how-tos/use-secrets/Секреты в Compose
Docker: image historyhttps://docs.docker.com/reference/cli/docker/image/history/Состав метаданных
OCI Image Spechttps://github.com/opencontainers/image-spec/blob/main/config.mdhistory как часть конфигурации
OWASP: secrets managementhttps://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.htmlРотация, отзыв, хранение
PostgreSQL imagehttps://hub.docker.com/_/postgresСоглашение _FILE

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Supply chain security
Главное оглавление

Markdown на GitHub ↗