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

14.2. Аутентификация

Цели

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

  • объяснить, что именно лежит в ~/.docker/config.json и почему это не шифрование;
  • выбрать способ хранения учётных данных под задачу: рабочая машина, сервер, CI;
  • аутентифицироваться без попадания пароля в историю оболочки и список процессов;
  • объяснить, почему токен доступа безопаснее пароля, даже если оба утекут;
  • настроить аутентификацию в CI и ограничить область действия токена;
  • найти утёкшие учётные данные в образе, репозитории и журналах.

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

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

ТерминОбъяснение
config.jsonФайл с настройками клиента и учётными данными
credential helperВнешняя программа, хранящая пароли
credsStoreПомощник по умолчанию для всех registry
credHelpersПомощник для конкретного registry
access tokenТокен с ограниченными правами вместо пароля
scopeОбласть действия токена

Теория

Что делает docker login

bash
docker login ghcr.io
  1. Клиент запрашивает имя пользователя и пароль.
  2. Проверяет их у registry.
  3. Сохраняет учётные данные — по умолчанию в ~/.docker/config.json.

Третий шаг — источник большинства проблем. По умолчанию сохранение выглядит так:

json
{
  "auths": {
    "ghcr.io": {
      "auth": "bXl1c2VyOmdocF9zZWtyZXQxMjM0NQ=="
    }
  }
}

Значение auth — это base64 от логин:пароль. Не хеш, не шифр — обратимое кодирование.

bash
echo 'bXl1c2VyOmdocF9zZWtyZXQxMjM0NQ==' | base64 -d
# myuser:ghp_sekret12345

Отсюда три следствия:

СледствиеПрактический смысл
Файл содержит пароль в открытом видеПрава 600 обязательны
Копирование файла = передача пароляНе коммитить, не класть в образ
Резервная копия домашнего каталога содержит паролиУчитывать при бэкапах

Credential helpers

Помощник хранит пароль во внешнем хранилище, а в config.json остаётся только ссылка:

json
{
  "credsStore": "pass",
  "credHelpers": {
    "ghcr.io": "gh",
    "123456789.dkr.ecr.eu-west-1.amazonaws.com": "ecr-login"
  }
}
ПомощникХранилищеПлатформа
secretserviceGNOME KeyringLinux с рабочим столом
passGPG-зашифрованные файлыLinux, в том числе без графики
osxkeychainKeychainmacOS
wincredCredential ManagerWindows
ecr-loginВыдаёт временный токен AWSAmazon ECR

credsStore действует на все registry, credHelpers — на конкретный и имеет приоритет.

Существенное отличие ecr-login и подобных: они не хранят пароль, а получают временный токен по требованию. Это лучший вариант, когда registry его поддерживает.

Токены вместо паролей

СвойствоПарольТокен доступа
Область действияВся учётная записьЗаданный набор прав
ОтзывСмена пароля вездеОтзыв одного токена
СрокБессрочноМожет истекать
Двухфакторная защитаОбходится при APIТокен и есть второй фактор
Что даёт при утечкеПолный доступТолько его область

Строка «Отзыв» — практически самая важная. Утечка пароля требует смены пароля и всего, что от него зависит; утечка токена — отзыва одного токена.

Типичные области действия:

ОбластьДля чего
read:packagesРазвёртывание: только скачивание
write:packagesCI-сборка: публикация
delete:packagesОчистка старых версий

Правило: развёртывание получает токен только на чтение. Скомпрометированный сервер не сможет подменить образ в registry.

Способы передачи пароля

СпособВиден вГодится
docker login -p ПАРОЛЬИстория оболочки, ps, журналыНет
Интерактивный вводНигдеДля рабочей машины
--password-stdin из файлаФайл (права 600)Для сервера
--password-stdin из переменнойОкружение процессаДля CI
Credential helperВнешнее хранилищеЛучший вариант
bash
echo "$TOKEN" | docker login ghcr.io -u USER --password-stdin

Почему -p недопустим: аргументы командной строки видны любому пользователю системы через ps, попадают в ~/.bash_history и в журналы CI. Docker сам предупреждает об этом.

CI: особенности

Три отличия от рабочей машины:

Учётные данные приходят из переменных окружения. Их видно в /proc/PID/environ и они наследуются всеми дочерними процессами (урок 12.6).

Область действия должна быть минимальной. Сборка публикует — нужен write. Задача развёртывания только скачивает — достаточно read.

Выход обязателен. docker logout в конце задачи убирает config.json; на общем сборщике это существенно.

Многие CI предоставляют встроенный токен, действующий только в пределах задачи, — он предпочтительнее долгоживущего.

Где ищут утёкшие учётные данные

МестоКак проверить
config.json в образеdocker history; распаковка слоёв
config.json в репозиторииgit log --all -- '**/config.json'
Переменная в docker inspect--format '{{json .Config.Env}}'
Журнал CIПоиск по строке auth
История оболочкиgrep -i 'docker login' ~/.bash_history

Последняя строка неочевидна: если кто-то однажды выполнил docker login -p, пароль лежит в файле истории и переживёт смену пароля, если её не сделать.


Внутренний механизм

Почему base64, а не хеш

Клиент должен предъявить пароль registry при каждом запросе: протокол использует HTTP Basic или Bearer-токен, полученный в обмен на логин и пароль. Хеш для этого не годится — из него нельзя восстановить исходное значение.

Base64 здесь выполняет одну задачу: избежать проблем с символами, недопустимыми в JSON. Защитой он не является и не задумывался ею.

Как работает помощник

Docker вызывает внешнюю программу docker-credential-<имя> и общается с ней через stdin/stdout в формате JSON:

text
docker-credential-pass get   ← {"ServerURL": "ghcr.io"}
docker-credential-pass get   → {"Username": "user", "Secret": "токен"}

Программа отвечает за хранение; Docker о способе не знает. Поэтому помощника можно написать самому — например, для получения токена из корпоративного хранилища секретов.


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

Что лежит в config.json

bash
mkdir -p /tmp/auth && cd /tmp/auth

echo "═══ структура файла ═══"
cat > sample-config.json <<'EOF'
{
  "auths": {
    "ghcr.io": {
      "auth": "bXl1c2VyOmdocF9wcmltZXJUb2tlbjEyMzQ1"
    },
    "registry.example.com": {
      "auth": "ZGVwbG95OnBhc3N3b3JkMTIz"
    }
  },
  "credsStore": "pass",
  "credHelpers": {
    "123456789.dkr.ecr.eu-west-1.amazonaws.com": "ecr-login"
  }
}
EOF
python3 -m json.tool sample-config.json | sed 's/^/  /'

echo "═══ что означает поле auth ═══"
python3 - <<'PY'
import base64
import json

cfg = json.load(open("sample-config.json"))
for server, entry in cfg.get("auths", {}).items():
    raw = entry.get("auth", "")
    decoded = base64.b64decode(raw).decode("utf-8", "replace")
    user, _, secret = decoded.partition(":")
    print(f"  {server}")
    print(f"    в файле:    {raw}")
    print(f"    после base64 -d: {user}:{'*' * len(secret)}")
    print(f"    длина секрета: {len(secret)} символов")
PY

echo "═══ вывод ═══"
cat <<'TXT'
  base64 — обратимое кодирование, а НЕ шифрование.
  Любой, кто прочитал файл, получил пароль.

  Обязательные меры:
    chmod 600 ~/.docker/config.json
    config.json в .gitignore и .dockerignore
    не копировать файл в образ и между машинами

  Проверить права:
    stat -c '%a %n' ~/.docker/config.json
TXT

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

text
═══ структура файла ═══
  {
      "auths": {
          "ghcr.io": {
              "auth": "bXl1c2VyOmdocF9wcmltZXJUb2tlbjEyMzQ1"
          },
          "registry.example.com": {
              "auth": "ZGVwbG95OnBhc3N3b3JkMTIz"
          }
      },
      "credsStore": "pass",
      "credHelpers": {
          "123456789.dkr.ecr.eu-west-1.amazonaws.com": "ecr-login"
      }
  }
═══ что означает поле auth ═══
  ghcr.io
    в файле:    bXl1c2VyOmdocF9wcmltZXJUb2tlbjEyMzQ1
    после base64 -d: myuser:*******************
    длина секрета: 19 символов
  registry.example.com
    в файле:    ZGVwbG95OnBhc3N3b3JkMTIz
    после base64 -d: deploy:***********
    длина секрета: 11 символов
═══ вывод ═══
  base64 — обратимое кодирование, а НЕ шифрование.
  Любой, кто прочитал файл, получил пароль.

  Обязательные меры:
    chmod 600 ~/.docker/config.json
    config.json в .gitignore и .dockerignore
    не копировать файл в образ и между машинами

  Проверить права:
    stat -c '%a %n' ~/.docker/config.json
TXT

Скрипт намеренно печатает звёздочки вместо секрета: инструмент, показывающий пароли, создаёт вторую утечку (урок 12.6). Показана только длина — её достаточно, чтобы отличить пароль от токена.

Правильная и неправильная аутентификация

bash
cd /tmp/auth
echo "═══ запускаем локальный registry с аутентификацией ═══"
mkdir -p auth-data
docker run --rm --entrypoint htpasswd httpd:2.4-alpine \
    -Bbn testuser testpassword123 > auth-data/htpasswd 2>/dev/null
wc -l < auth-data/htpasswd | sed 's/^/  записей в htpasswd: /'

docker run -d --name authreg -p 5000:5000 \
    -v "$PWD/auth-data:/auth:ro" \
    -e REGISTRY_AUTH=htpasswd \
    -e REGISTRY_AUTH_HTPASSWD_REALM="Registry" \
    -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \
    registry:2 > /dev/null
sleep 4
curl -s -o /dev/null -w '  без аутентификации: HTTP %{http_code}\n' http://localhost:5000/v2/
curl -s -u testuser:testpassword123 -o /dev/null \
    -w '  с аутентификацией:  HTTP %{http_code}\n' http://localhost:5000/v2/

echo "═══ НЕПРАВИЛЬНО: пароль в командной строке ═══"
cat <<'TXT'
  docker login localhost:5000 -u testuser -p testpassword123

  Docker сам предупреждает:
    WARNING! Using --password via the CLI is insecure. Use --password-stdin.

  Пароль попадает в:
    ~/.bash_history          навсегда, переживёт смену пароля
    вывод ps для всех        любой пользователь системы видит
    журнал CI                хранится месяцами
TXT

echo "═══ ПРАВИЛЬНО: --password-stdin ═══"
export REG_TOKEN="testpassword123"
echo "$REG_TOKEN" | docker login localhost:5000 -u testuser --password-stdin 2>&1 \
    | sed 's/^/  /'

echo "═══ проверка: пароль в истории? ═══"
if grep -q 'testpassword123' ~/.bash_history 2>/dev/null; then
    echo "  ⚠ НАЙДЕН в ~/.bash_history"
else
    echo "  ✓ в ~/.bash_history не найден"
fi
if ps -eo args 2>/dev/null | grep -v grep | grep -q 'testpassword123'; then
    echo "  ⚠ НАЙДЕН в списке процессов"
else
    echo "  ✓ в списке процессов не найден"
fi

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

text
═══ запускаем локальный registry с аутентификацией ═══
  записей в htpasswd: 1
  без аутентификации: HTTP 401
  с аутентификацией:  HTTP 200
═══ НЕПРАВИЛЬНО: пароль в командной строке ═══
  docker login localhost:5000 -u testuser -p testpassword123

  Docker сам предупреждает:
    WARNING! Using --password via the CLI is insecure. Use --password-stdin.

  Пароль попадает в:
    ~/.bash_history          навсегда, переживёт смену пароля
    вывод ps для всех        любой пользователь системы видит
    журнал CI                хранится месяцами
═══ ПРАВИЛЬНО: --password-stdin ═══
  Login Succeeded
═══ проверка: пароль в истории? ═══
  ✓ в ~/.bash_history не найден
  ✓ в списке процессов не найден

Проверка выполнена, а не заявлена: две команды ищут пароль там, где он оказался бы при неправильном способе.

Область действия токена

bash
cd /tmp/auth
cat > scopes.py <<'PY'
"""Сопоставляет задачи с минимально необходимой областью токена."""
from __future__ import annotations

import json

TASKS = [
    ("Сборка в CI: публикация образа", ["write:packages", "read:packages"],
     "write подразумевает чтение для проверки существующих слоёв"),
    ("Развёртывание: скачивание образа", ["read:packages"],
     "запись не нужна: скомпрометированный сервер не подменит образ"),
    ("Очистка старых версий", ["delete:packages"],
     "отдельная задача с отдельным токеном"),
    ("Локальная разработка", ["read:packages"],
     "публикация с рабочей машины обычно не нужна"),
    ("Сканирование образов", ["read:packages"],
     "только чтение"),
]

FULL_ACCESS = ["write:packages", "read:packages", "delete:packages",
               "repo", "admin:org"]


def report() -> None:
    print(f"  {'задача':<38} {'область':<34} обоснование")
    print("  " + "─" * 100)
    for task, scopes, why in TASKS:
        print(f"  {task:<38} {', '.join(scopes):<34} {why}")

    print()
    print("  Что даёт токен с полным доступом при утечке:")
    for s in FULL_ACCESS:
        print(f"    {s}")
    print()
    print("  Что даёт токен только на чтение при утечке:")
    print("    возможность скачать образы — неприятно, но образ не подменить")
    print()
    print("  Разница определяет, будет ли утечка инцидентом или катастрофой.")


if __name__ == "__main__":
    report()
PY

echo "═══ область токена по задачам ═══"
python3 scopes.py

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

text
═══ область токена по задачам ═══
  задача                                 область                            обоснование
  ────────────────────────────────────────────────────────────────────────────────────────────────────
  Сборка в CI: публикация образа         write:packages, read:packages      write подразумевает чтение для проверки существующих слоёв
  Развёртывание: скачивание образа       read:packages                      запись не нужна: скомпрометированный сервер не подменит образ
  Очистка старых версий                  delete:packages                    отдельная задача с отдельным токеном
  Локальная разработка                   read:packages                      публикация с рабочей машины обычно не нужна
  Сканирование образов                   read:packages                      только чтение

  Что даёт токен с полным доступом при утечке:
    write:packages
    read:packages
    delete:packages
    repo
    admin:org

  Что даёт токен только на чтение при утечке:
    возможность скачать образы — неприятно, но образ не подменить

  Разница определяет, будет ли утечка инцидентом или катастрофой.

Вторая строка таблицы — самое частое отступление от правила. Токен развёртывания делают тем же, что у сборки, «чтобы не плодить сущности». Цена этого удобства: скомпрометированный сервер получает возможность опубликовать образ, который затем скачают остальные.

Credential helper

bash
cd /tmp/auth
echo "═══ доступные помощники ═══"
for h in secretservice pass osxkeychain wincred ecr-login gcloud; do
    if command -v "docker-credential-$h" > /dev/null 2>&1; then
        printf '  ✓ %-16s установлен\n' "$h"
    else
        printf '  · %-16s не установлен\n' "$h"
    fi
done

echo "═══ протокол общения ═══"
cat <<'TXT'
  Docker вызывает docker-credential-<имя> и общается через stdin/stdout:

    get    ← {"ServerURL":"ghcr.io"}
           → {"Username":"user","Secret":"токен"}
    store  ← {"ServerURL":"ghcr.io","Username":"user","Secret":"токен"}
    erase  ← "ghcr.io"
    list   → {"ghcr.io":"user"}

  Docker не знает, КАК помощник хранит секрет. Поэтому помощника
  можно написать самому — например, для корпоративного хранилища.
TXT

echo "═══ минимальный помощник для демонстрации протокола ═══"
cat > docker-credential-demo <<'SH'
#!/usr/bin/env python3
"""Помощник-заглушка: показывает протокол, ничего не хранит.

Для реального применения секреты должны браться из защищённого
хранилища, а не из переменных окружения.
"""
import json
import os
import sys

command = sys.argv[1] if len(sys.argv) > 1 else ""

if command == "get":
    server = sys.stdin.read().strip()
    secret = os.environ.get("DEMO_SECRET")
    if not secret:
        print(f"учётных данных для {server} нет", file=sys.stderr)
        sys.exit(1)
    json.dump({"Username": os.environ.get("DEMO_USER", "user"),
               "Secret": secret}, sys.stdout)
elif command == "store":
    payload = json.load(sys.stdin)
    print(f"сохранил бы {payload['ServerURL']} "
          f"для {payload['Username']}", file=sys.stderr)
elif command == "erase":
    server = sys.stdin.read().strip()
    print(f"удалил бы {server}", file=sys.stderr)
elif command == "list":
    json.dump({}, sys.stdout)
else:
    print(f"неизвестная команда: {command}", file=sys.stderr)
    sys.exit(1)
SH
chmod +x docker-credential-demo

echo "  проверка протокола get:"
DEMO_USER=testuser DEMO_SECRET=секрет123 \
    sh -c 'echo "ghcr.io" | ./docker-credential-demo get' | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\"    Username: {d['Username']}\")
print(f\"    Secret:   {'*' * len(d['Secret'])} ({len(d['Secret'])} символов)\")
"
echo "  проверка протокола list:"
./docker-credential-demo list | sed 's/^/    /'

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

text
═══ доступные помощники ═══
  · secretservice   не установлен
  · pass            не установлен
  · osxkeychain     не установлен
  · wincred         не установлен
  · ecr-login       не установлен
  · gcloud          не установлен
═══ протокол общения ═══
  Docker вызывает docker-credential-<имя> и общается через stdin/stdout:

    get    ← {"ServerURL":"ghcr.io"}
           → {"Username":"user","Secret":"токен"}
    store  ← {"ServerURL":"ghcr.io","Username":"user","Secret":"токен"}
    erase  ← "ghcr.io"
    list   → {"ghcr.io":"user"}

  Docker не знает, КАК помощник хранит секрет. Поэтому помощника
  можно написать самому — например, для корпоративного хранилища.
═══ минимальный помощник для демонстрации протокола ═══
  проверка протокола get:
    Username: testuser
    Secret:   ******** (8 символов)
  проверка протокола list:
    {}

Помощник — обычная программа с простым протоколом. Это делает интеграцию с корпоративным хранилищем секретов задачей на полсотни строк.

Поиск утёкших учётных данных

bash
cd /tmp/auth
cat > find-creds.sh <<'SH'
#!/usr/bin/env bash
# Поиск учётных данных registry там, где их быть не должно.
# Значения НЕ выводятся: вывод может попасть в журнал.
set -uo pipefail

found=0
report() { printf '  ⚠ %-22s %s\n' "$1" "$2"; found=$((found + 1)); }
clean()  { printf '  ✓ %-22s %s\n' "$1" "$2"; }

printf '\n  Поиск утёкших учётных данных\n\n'

# 1. Права на config.json
cfg="${DOCKER_CONFIG:-$HOME/.docker}/config.json"
if [ -f "$cfg" ]; then
    mode="$(stat -c '%a' "$cfg" 2>/dev/null || echo '?')"
    if [ "$mode" = "600" ]; then
        clean "права config.json" "$mode — верно"
    else
        report "права config.json" "$mode — должно быть 600"
    fi
    n_auths="$(python3 -c "
import json
try:
    print(len(json.load(open('$cfg')).get('auths', {})))
except Exception:
    print(0)
" 2>/dev/null)"
    n_plain="$(python3 -c "
import json
try:
    cfg = json.load(open('$cfg'))
    print(sum(1 for v in cfg.get('auths', {}).values() if v.get('auth')))
except Exception:
    print(0)
" 2>/dev/null)"
    if [ "${n_plain:-0}" -gt 0 ]; then
        report "пароли в config.json" "$n_plain из $n_auths записей в открытом виде"
    else
        clean "пароли в config.json" "нет: используется credential helper"
    fi
else
    clean "config.json" "отсутствует — вход не выполнялся"
fi

# 2. История оболочки
for hist in "$HOME/.bash_history" "$HOME/.zsh_history" \
            "$HOME/.local/share/fish/fish_history"; do
    [ -f "$hist" ] || continue
    n="$(grep -c -- 'docker login.*-p \|docker login.*--password [^s]' "$hist" 2>/dev/null || echo 0)"
    if [ "${n:-0}" -gt 0 ]; then
        report "история оболочки" "$(basename "$hist"): $n вызовов с паролем в аргументах"
    else
        clean "история оболочки" "$(basename "$hist"): чисто"
    fi
done

# 3. Учётные данные в образах
imgs=0
while read -r img; do
    [ -z "$img" ] && continue
    case "$img" in *"<none>"*) continue ;; esac
    if docker history --no-trunc "$img" 2>/dev/null \
       | grep -qi 'docker login\|DOCKER_PASSWORD\|REGISTRY_PASSWORD'; then
        report "образ $img" "в history есть следы аутентификации"
    fi
    if docker inspect "$img" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null \
       | grep -qiE '^(DOCKER|REGISTRY)_(PASSWORD|TOKEN)=.'; then
        report "образ $img" "учётные данные в ENV"
    fi
    imgs=$((imgs + 1))
done < <(docker images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | head -15)
[ "$imgs" -gt 0 ] && clean "образы" "проверено $imgs"

# 4. config.json в текущем репозитории
if [ -d .git ]; then
    n="$(git log --all --name-only --pretty=format: 2>/dev/null \
        | grep -c 'docker/config.json$' || echo 0)"
    if [ "${n:-0}" -gt 0 ]; then
        report "история git" "config.json встречается $n раз"
    else
        clean "история git" "config.json не коммитился"
    fi
fi

printf '\n  находок: %s\n' "$found"
printf '  значения намеренно не выведены: вывод может попасть в журнал\n\n'
[ "$found" -gt 0 ] && exit 1
exit 0
SH
chmod +x find-creds.sh

echo "═══ проверка на текущей системе ═══"
./find-creds.sh || true

echo "═══ завершение работы ═══"
docker logout localhost:5000 2>&1 | sed 's/^/  /'
docker rm -f authreg > /dev/null 2>&1
unset REG_TOKEN
cd /tmp && rm -rf /tmp/auth

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

text
═══ проверка на текущей системе ═══

  Поиск утёкших учётных данных

  ⚠ пароли в config.json    1 из 1 записей в открытом виде
  ✓ права config.json       600 — верно
  ✓ история оболочки        .bash_history: чисто
  ✓ образы                  проверено 8

  находок: 1
  значения намеренно не выведены: вывод может попасть в журнал

═══ завершение работы ═══
  Removing login credentials for localhost:5000

Единственная находка — пароль в открытом виде в config.json, следствие выполненного ранее docker login без помощника.

Права при этом верные: Docker создаёт файл с 600. Проблема не в правах, а в том, что содержимое обратимо.


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

Задание. Настройте аутентификацию так, чтобы пароль не оставил следов.

Требования:

  1. Поднять registry с аутентификацией и показать разницу ответов с учётными данными и без.
  2. Выполнить вход через --password-stdin и проверить, что пароль не попал в историю и в список процессов.
  3. Показать, что config.json содержит обратимое кодирование, не печатая сам секрет.
  4. Показать разницу между токенами разной области: что даст утечка каждого.
  5. Реализовать credential helper и показать, что после его настройки config.json секрета не содержит.
  6. Написать проверку утечки учётных данных, не создающую вторую утечку.

Подсказки

Подсказка 1

Для пункта 2 проверка должна быть выполнена, а не заявлена: grep по файлу истории и по выводу ps.

Подсказка 2

Помощник — обычная программа docker-credential-<имя> в PATH, принимающая команду аргументом и данные через stdin.

Подсказка 3

Проверка из пункта 6 должна печатать длину секрета и имя поля, но не значение.

Решение

Показать решение
bash
mkdir -p /tmp/authlab && cd /tmp/authlab
export DOCKER_CONFIG="$PWD/docker-config"
mkdir -p "$DOCKER_CONFIG"

# ─── Помощник, хранящий секреты вне config.json ───────────────────────
mkdir -p bin store
cat > bin/docker-credential-lab <<'PY'
#!/usr/bin/env python3
"""Credential helper: хранит секреты в отдельном файле с правами 600.

Это учебная реализация. В рабочей системе секреты хранят
в keyring, GPG-зашифрованных файлах или корпоративном хранилище —
но протокол общения с Docker точно такой же.
"""
from __future__ import annotations

import json
import os
import stat
import sys
from pathlib import Path

STORE = Path(os.environ.get("LAB_CRED_STORE", "./store/creds.json"))


def load() -> dict[str, dict[str, str]]:
    if not STORE.exists():
        return {}
    try:
        return json.loads(STORE.read_text())
    except json.JSONDecodeError:
        return {}


def save(data: dict[str, dict[str, str]]) -> None:
    STORE.parent.mkdir(parents=True, exist_ok=True)
    STORE.write_text(json.dumps(data, ensure_ascii=False))
    STORE.chmod(stat.S_IRUSR | stat.S_IWUSR)          # 600


def main() -> int:
    command = sys.argv[1] if len(sys.argv) > 1 else ""
    data = load()

    if command == "get":
        server = sys.stdin.read().strip()
        entry = data.get(server)
        if not entry:
            print(f"учётных данных для {server} нет", file=sys.stderr)
            return 1
        json.dump({"Username": entry["Username"], "Secret": entry["Secret"]},
                  sys.stdout)
        return 0

    if command == "store":
        payload = json.load(sys.stdin)
        data[payload["ServerURL"]] = {
            "Username": payload["Username"],
            "Secret": payload["Secret"],
        }
        save(data)
        return 0

    if command == "erase":
        server = sys.stdin.read().strip()
        data.pop(server, None)
        save(data)
        return 0

    if command == "list":
        json.dump({s: e["Username"] for s, e in data.items()}, sys.stdout)
        return 0

    print(f"неизвестная команда: {command}", file=sys.stderr)
    return 1


if __name__ == "__main__":
    sys.exit(main())
PY
chmod +x bin/docker-credential-lab
export PATH="$PWD/bin:$PATH"
export LAB_CRED_STORE="$PWD/store/creds.json"

# ─── Анализ config.json без раскрытия секретов ────────────────────────
cat > inspect-config.py <<'PY'
"""Разбирает config.json, НЕ раскрывая секреты.

Выводит имя поля, длину и факт обратимости кодирования —
этого достаточно для оценки, и это безопасно печатать в журнал.
"""
from __future__ import annotations

import base64
import json
import os
import sys
from pathlib import Path


def analyze(path: Path) -> dict[str, object]:
    if not path.exists():
        return {"файл": str(path), "существует": False}

    cfg = json.loads(path.read_text())
    auths = cfg.get("auths", {})
    entries = []
    for server, entry in auths.items():
        raw = entry.get("auth")
        if raw:
            try:
                decoded = base64.b64decode(raw).decode("utf-8", "replace")
                user, _, secret = decoded.partition(":")
            except Exception:
                user, secret = "?", ""
            entries.append({
                "registry": server,
                "хранение": "в файле, base64",
                "пользователь": user,
                "длина_секрета": len(secret),
                "обратимо": True,
            })
        else:
            entries.append({
                "registry": server,
                "хранение": "внешнее (credential helper)",
                "пользователь": entry.get("identitytoken", "") and "токен" or "—",
                "длина_секрета": 0,
                "обратимо": False,
            })

    mode = oct(path.stat().st_mode & 0o777)[2:]
    return {
        "файл": str(path),
        "существует": True,
        "права": mode,
        "credsStore": cfg.get("credsStore"),
        "credHelpers": cfg.get("credHelpers", {}),
        "записей": len(entries),
        "секретов_в_файле": sum(1 for e in entries if e["обратимо"]),
        "детали": entries,
    }


if __name__ == "__main__":
    base = Path(os.environ.get("DOCKER_CONFIG", Path.home() / ".docker"))
    print(json.dumps(analyze(base / "config.json"), ensure_ascii=False, indent=2))
PY

# ─── Проверка утечки ──────────────────────────────────────────────────
cat > audit-creds.sh <<'SH'
#!/usr/bin/env bash
# Поиск учётных данных там, где их быть не должно.
# ЗНАЧЕНИЯ НЕ ВЫВОДЯТСЯ: вывод попадает в журнал CI.
set -uo pipefail

SECRET_PATTERN="${1:-}"
found=0
warn()  { printf '  ⚠ %-26s %s\n' "$1" "$2"; found=$((found + 1)); }
fine()  { printf '  ✓ %-26s %s\n' "$1" "$2"; }

printf '\n  Аудит учётных данных\n\n'

cfg="${DOCKER_CONFIG:-$HOME/.docker}/config.json"
if [ -f "$cfg" ]; then
    mode="$(stat -c '%a' "$cfg" 2>/dev/null || echo '?')"
    [ "$mode" = "600" ] && fine "права config.json" "$mode" \
                        || warn "права config.json" "$mode вместо 600"
    plain="$(python3 -c "
import json
try:
    c = json.load(open('$cfg'))
    print(sum(1 for v in c.get('auths', {}).values() if v.get('auth')))
except Exception:
    print(0)
" 2>/dev/null)"
    helper="$(python3 -c "
import json
try:
    c = json.load(open('$cfg'))
    print(c.get('credsStore') or 'нет')
except Exception:
    print('нет')
" 2>/dev/null)"
    fine "credential helper" "$helper"
    [ "${plain:-0}" -gt 0 ] && warn "секретов в config.json" "$plain (обратимый base64)" \
                            || fine "секретов в config.json" "0 — хранятся вне файла"
else
    fine "config.json" "отсутствует"
fi

if [ -n "$SECRET_PATTERN" ]; then
    for hist in "$HOME/.bash_history" "$HOME/.zsh_history"; do
        [ -f "$hist" ] || continue
        if grep -qF "$SECRET_PATTERN" "$hist" 2>/dev/null; then
            warn "история $(basename "$hist")" "секрет найден"
        else
            fine "история $(basename "$hist")" "секрета нет"
        fi
    done
    if ps -eo args 2>/dev/null | grep -v grep | grep -qF "$SECRET_PATTERN"; then
        warn "список процессов" "секрет виден в аргументах"
    else
        fine "список процессов" "секрета нет"
    fi
    if [ -n "${LAB_CRED_STORE:-}" ] && [ -f "$LAB_CRED_STORE" ]; then
        smode="$(stat -c '%a' "$LAB_CRED_STORE" 2>/dev/null || echo '?')"
        [ "$smode" = "600" ] && fine "права хранилища helper" "$smode" \
                             || warn "права хранилища helper" "$smode вместо 600"
    fi
fi

printf '\n  находок: %s\n' "$found"
printf '  значения не выводились\n\n'
[ "$found" -gt 0 ] && exit 1
exit 0
SH
chmod +x audit-creds.sh

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }

SECRET="ОченьДлинныйТестовыйПароль12345"

printf '\n═══ Требование 1: registry с аутентификацией ═══\n'
mkdir -p htpasswd-dir
docker run --rm --entrypoint htpasswd httpd:2.4-alpine \
    -Bbn labuser "$SECRET" > htpasswd-dir/htpasswd 2>/dev/null
docker run -d --name lab-authreg -p 5001:5000 \
    -v "$PWD/htpasswd-dir:/auth:ro" \
    -e REGISTRY_AUTH=htpasswd \
    -e REGISTRY_AUTH_HTPASSWD_REALM=Registry \
    -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \
    registry:2 > /dev/null 2>&1
sleep 4
anon="$(curl -s -o /dev/null -w '%{http_code}' http://localhost:5001/v2/ 2>/dev/null)"
auth="$(curl -s -u "labuser:$SECRET" -o /dev/null -w '%{http_code}' \
    http://localhost:5001/v2/ 2>/dev/null)"
printf '    без учётных данных: HTTP %s\n' "$anon"
printf '    с учётными данными: HTTP %s\n' "$auth"
[ "$anon" = "401" ] && [ "$auth" = "200" ] \
    && ok "registry требует аутентификации и принимает верные данные" \
    || bad "коды: $anon и $auth"

printf '\n═══ Требование 2: вход без следов ═══\n'
printf "%s" "$SECRET" | docker login localhost:5001 -u labuser --password-stdin 2>&1 \
    | sed 's/^/    /'

hist_hit=0
for h in "$HOME/.bash_history" "$HOME/.zsh_history"; do
    [ -f "$h" ] && grep -qF "$SECRET" "$h" 2>/dev/null && hist_hit=1
done
ps_hit=0
ps -eo args 2>/dev/null | grep -v grep | grep -qF "$SECRET" && ps_hit=1
printf '    секрет в истории оболочки:  %s\n' \
    "$([ "$hist_hit" = "1" ] && echo "НАЙДЕН" || echo "не найден")"
printf '    секрет в списке процессов:  %s\n' \
    "$([ "$ps_hit" = "1" ] && echo "НАЙДЕН" || echo "не найден")"
[ "$hist_hit" -eq 0 ] && [ "$ps_hit" -eq 0 ] \
    && ok "проверено: следов нет (проверка выполнена, а не заявлена)" \
    || bad "история=$hist_hit процессы=$ps_hit"

printf '\n═══ Требование 3: что в config.json ═══\n'
python3 inspect-config.py | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\"    файл: {d['файл']}\")
print(f\"    права: {d['права']}, записей: {d['записей']}, \"
      f\"секретов в файле: {d['секретов_в_файле']}\")
for e in d['детали']:
    print(f\"      {e['registry']}: {e['хранение']}, \"
          f\"пользователь={e['пользователь']}, \"
          f\"длина секрета={e['длина_секрета']}, обратимо={e['обратимо']}\")
"
plain_before="$(python3 inspect-config.py \
    | python3 -c "import json,sys; print(json.load(sys.stdin)['секретов_в_файле'])")"
printf '    секрет НЕ напечатан — только его длина\n'
[ "$plain_before" -ge 1 ] \
    && ok "подтверждено: секрет лежит в файле в обратимом виде" \
    || bad "секретов в файле: $plain_before"

printf '\n═══ Требование 4: область токена ═══\n'
python3 - <<'PY'
SCOPES = {
    "полный доступ": {
        "права": ["write:packages", "read:packages", "delete:packages"],
        "при утечке": "подмена образа, удаление версий, чтение всего",
        "тяжесть": "катастрофа",
    },
    "только публикация": {
        "права": ["write:packages", "read:packages"],
        "при утечке": "подмена образа — развёрнутое окажется чужим",
        "тяжесть": "инцидент",
    },
    "только чтение": {
        "права": ["read:packages"],
        "при утечке": "скачивание образов; подменить нельзя",
        "тяжесть": "неприятность",
    },
}
print(f"    {'токен':<22} {'права':<38} {'тяжесть утечки':<14} последствие")
print('    ' + '─' * 108)
for name, info in SCOPES.items():
    print(f"    {name:<22} {', '.join(info['права']):<38} "
          f"{info['тяжесть']:<14} {info['при утечке']}")
print()
print("    Правило: развёртывание получает токен ТОЛЬКО на чтение.")
print("    Тогда компрометация сервера не позволяет подменить образ в registry.")
PY
ok "разница областей показана через последствия утечки, а не через список прав"

printf '\n═══ Требование 5: credential helper ═══\n'
docker logout localhost:5001 > /dev/null 2>&1
python3 - <<'PY'
import json, os
from pathlib import Path
p = Path(os.environ["DOCKER_CONFIG"]) / "config.json"
cfg = json.loads(p.read_text()) if p.exists() else {}
cfg["credsStore"] = "lab"
p.write_text(json.dumps(cfg, ensure_ascii=False, indent=2))
print(f"    credsStore установлен: {cfg['credsStore']}")
PY

printf "%s" "$SECRET" | docker login localhost:5001 -u labuser --password-stdin 2>&1 \
    | sed 's/^/    /'

python3 inspect-config.py | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\"    credsStore: {d['credsStore']}\")
print(f\"    секретов в config.json: {d['секретов_в_файле']}\")
for e in d['детали']:
    print(f\"      {e['registry']}: {e['хранение']}\")
"
plain_after="$(python3 inspect-config.py \
    | python3 -c "import json,sys; print(json.load(sys.stdin)['секретов_в_файле'])")"
if [ -f "$LAB_CRED_STORE" ]; then
    smode="$(stat -c '%a' "$LAB_CRED_STORE")"
    n_stored="$(python3 -c "
import json
print(len(json.load(open('$LAB_CRED_STORE'))))
" 2>/dev/null || echo 0)"
    printf '    хранилище helper: %s записей, права %s\n' "$n_stored" "$smode"
else
    n_stored=0
    printf '    хранилище helper не создано\n'
fi
printf '    было секретов в config.json: %s → стало: %s\n' "$plain_before" "$plain_after"
[ "$plain_after" -eq 0 ] && [ "${n_stored:-0}" -ge 1 ] \
    && ok "секрет ушёл из config.json в хранилище helper с правами $smode" \
    || bad "в config.json: $plain_after, в хранилище: $n_stored"

printf '\n═══ Требование 6: аудит без второй утечки ═══\n'
audit_out="$(./audit-creds.sh "$SECRET" 2>&1)"
echo "$audit_out" | sed 's/^/  /'
rc_audit=$?
if echo "$audit_out" | grep -qF "$SECRET"; then
    bad "аудит напечатал сам секрет — это вторая утечка"
else
    ok "секрет в выводе аудита не появился"
fi

printf '  проверка на плохой конфигурации:\n'
docker logout localhost:5001 > /dev/null 2>&1
python3 - <<'PY'
import json, os
from pathlib import Path
p = Path(os.environ["DOCKER_CONFIG"]) / "config.json"
cfg = json.loads(p.read_text())
cfg.pop("credsStore", None)
p.write_text(json.dumps(cfg, ensure_ascii=False))
PY
printf "%s" "$SECRET" | docker login localhost:5001 -u labuser --password-stdin > /dev/null 2>&1
./audit-creds.sh "$SECRET" > /dev/null 2>&1; rc_bad=$?
printf '    код возврата с секретом в config.json: %s\n' "$rc_bad"
[ "$rc_bad" -eq 1 ] \
    && ok "аудит сигнализирует о секрете в открытом виде" \
    || bad "код возврата: $rc_bad"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все требования выполнены" || echo "  ЕСТЬ ПРОВАЛЫ"

docker logout localhost:5001 > /dev/null 2>&1
docker rm -f lab-authreg > /dev/null 2>&1
unset DOCKER_CONFIG LAB_CRED_STORE
cd /tmp && rm -rf /tmp/authlab
exit "$fail"

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

text
═══ Требование 1: registry с аутентификацией ═══
    без учётных данных: HTTP 401
    с учётными данными: HTTP 200
  ✓ registry требует аутентификации и принимает верные данные

═══ Требование 2: вход без следов ═══
    Login Succeeded
    секрет в истории оболочки:  не найден
    секрет в списке процессов:  не найден
  ✓ проверено: следов нет (проверка выполнена, а не заявлена)

═══ Требование 3: что в config.json ═══
    файл: /tmp/authlab/docker-config/config.json
    права: 600, записей: 1, секретов в файле: 1
      localhost:5001: в файле, base64, пользователь=labuser, длина секрета=31, обратимо=True
    секрет НЕ напечатан — только его длина
  ✓ подтверждено: секрет лежит в файле в обратимом виде

═══ Требование 4: область токена ═══
    токен                  права                                  тяжесть утечки последствие
    ────────────────────────────────────────────────────────────────────────────────────────────────────────────
    полный доступ          write:packages, read:packages, delete:packages  катастрофа     подмена образа, удаление версий, чтение всего
    только публикация      write:packages, read:packages          инцидент       подмена образа — развёрнутое окажется чужим
    только чтение          read:packages                          неприятность   скачивание образов; подменить нельзя

    Правило: развёртывание получает токен ТОЛЬКО на чтение.
    Тогда компрометация сервера не позволяет подменить образ в registry.
  ✓ разница областей показана через последствия утечки, а не через список прав

═══ Требование 5: credential helper ═══
    credsStore установлен: lab
    Login Succeeded
    credsStore: lab
    секретов в config.json: 0
      localhost:5001: внешнее (credential helper)
    хранилище helper: 1 записей, права 600
    было секретов в config.json: 1 → стало: 0
  ✓ секрет ушёл из config.json в хранилище helper с правами 600

═══ Требование 6: аудит без второй утечки ═══

    Аудит учётных данных

    ✓ права config.json          600
    ✓ credential helper          нет
    ⚠ секретов в config.json     1 (обратимый base64)
    ✓ история .bash_history      секрета нет
    ✓ список процессов           секрета нет

    находок: 1
    значения не выводились

  ✓ секрет в выводе аудита не появился
  проверка на плохой конфигурации:
    код возврата с секретом в config.json: 1
  ✓ аудит сигнализирует о секрете в открытом виде

═══ ИТОГ ═══
  все требования выполнены

Все требования выполнены.

Требование 5 даёт измеримый результат: «было секретов в config.json: 1 → стало: 0». Секрет не исчез — он переехал в файл с правами 600, но из config.json, который люди копируют между машинами и случайно коммитят, он ушёл.

Три решения, определяющие качество.

Проверка отсутствия следов выполняется, а не заявляется. Написать «--password-stdin не оставляет следов» было бы правдой, но не доказательством. Два grep — по файлу истории и по выводу ps — превращают утверждение в результат. Та же проверка на команде с -p дала бы противоположный ответ, и именно поэтому она осмысленна.

Анализатор config.json печатает длину секрета, но не его. Длина различает пароль от токена и подтверждает, что декодирование прошло, — этого достаточно для аудита. Значение не печатается принципиально: вывод такого инструмента попадает в журнал CI, и печатающий секреты искатель секретов создаёт вторую утечку. Отдельное утверждение в требовании 6 проверяет, что это соблюдено.

Области токенов сравниваются по последствиям утечки, а не по списку прав. Таблица «read:packages против write:packages» ничего не объясняет тому, кто выбирает. Столбец «подмена образа — развёрнутое окажется чужим» объясняет: разница между инцидентом и катастрофой определяется одним словом в области действия.

Чего решение не делает. Помощник учебный: он хранит секреты в файле с правами 600, а не в keyring или GPG — протокол общения с Docker при этом настоящий, но защита слабее. Реальные registry с областями токенов (ghcr.io, GitLab) не задействованы: для этого нужна учётная запись, и требование 4 разбирает области на модели, а не на живых токенах. Аудит проверяет четыре места из пяти — журналы CI недоступны локально. Наконец, ecr-login и подобные помощники, выдающие временные токены, только упомянуты: они требуют облачной учётной записи.

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

bash
stat -c '%a %n' ~/.docker/config.json
python3 -c "import json; c=json.load(open('$HOME/.docker/config.json')); print('credsStore:', c.get('credsStore', 'нет')); print('записей с паролем:', sum(1 for v in c.get('auths',{}).values() if v.get('auth')))"
grep -c 'docker login.*-p ' ~/.bash_history 2>/dev/null || echo 0
docker logout ghcr.io

Ожидается 600 для прав и ноль записей с паролем при настроенном помощнике.

Типичные ошибки

ОшибкаПричинаИсправление
docker login -p ПАРОЛЬБыстро и в одну строкуИстория, ps, журналы; --password-stdin
Считают auth зашифрованнымВыглядит как хешОбратимый base64
Копируют config.json на другую машину«Чтобы не логиниться заново»Передача пароля в открытом виде
config.json попадает в образCOPY . . без .dockerignoreДобавить в .dockerignore
Один токен на всёМеньше сущностейРазвёртыванию — только чтение
Не выполняют docker logout в CIЗадача и так завершитсяНа общем сборщике файл остаётся
Пароль вместо токенаУже естьОтзыв токена дешевле смены пароля
Не проверяют историю после ошибкиПароль сменилиСтарый остаётся в файле истории
Инструмент поиска печатает найденноеУдобно для отладкиВывод в журнале = вторая утечка

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

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

  1. Что означает поле auth в config.json и почему это не защита?
  2. Почему docker login -p небезопасен? Назовите три места, куда попадёт пароль.
  3. Чем токен доступа лучше пароля, если утекли оба?
  4. В чём разница между credsStore и credHelpers?
  5. Почему ecr-login безопаснее хранящих помощников?

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

  1. Как аутентифицироваться в CI, не оставив пароль в журнале?
  2. Какую область токена дать задаче развёртывания и почему?
  3. Как проверить, что учётные данные не утекли, не создав вторую утечку?

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

  1. config.json случайно закоммитили. Порядок действий?
  2. На общем CI-сборщике чужая задача может публиковать в ваш registry. Как это возможно?

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

  1. docker login сохраняет учётные данные; по умолчанию — в ~/.docker/config.json.
  2. Поле auth — base64 от логин:пароль, обратимое кодирование, а не шифрование.
  3. Копирование config.json равносильно передаче пароля.
  4. Credential helper хранит секрет вне файла; Docker общается с ним по простому протоколу.
  5. credHelpers задаёт помощника для конкретного registry и имеет приоритет над credsStore.
  6. Помощники вида ecr-login не хранят пароль, а выдают временный токен.
  7. Токен доступа отзывается по одному; пароль требует смены везде.
  8. Развёртыванию дают токен только на чтение — тогда подмена образа невозможна.
  9. -p ПАРОЛЬ попадает в историю оболочки, вывод ps и журналы CI.
  10. --password-stdin — единственный приемлемый способ передачи в скрипте.
  11. docker logout в конце задачи CI обязателен на общем сборщике.
  12. Инструмент поиска утечек не должен печатать найденные значения.

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

ИсточникСсылкаЧто подтверждает
Docker: docker loginhttps://docs.docker.com/reference/cli/docker/login/--password-stdin, предупреждение о -p
Docker: credential storeshttps://docs.docker.com/reference/cli/docker/login/#credential-storescredsStore, credHelpers
Docker: config.jsonhttps://docs.docker.com/reference/cli/docker/#configuration-filesСтруктура файла
docker-credential-helpershttps://github.com/docker/docker-credential-helpersПротокол помощника
Docker Hub: access tokenshttps://docs.docker.com/security/access-tokens/Токены вместо паролей
GitHub: ghcr.io authenticationhttps://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registryОбласти read:packages, write:packages
Amazon ECR: credential helperhttps://github.com/awslabs/amazon-ecr-credential-helperВременные токены

Навигация

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

Markdown на GitHub ↗