14.2. Аутентификация
Цели
После этого материала вы сможете:
- объяснить, что именно лежит в
~/.docker/config.jsonи почему это не шифрование; - выбрать способ хранения учётных данных под задачу: рабочая машина, сервер, CI;
- аутентифицироваться без попадания пароля в историю оболочки и список процессов;
- объяснить, почему токен доступа безопаснее пароля, даже если оба утекут;
- настроить аутентификацию в CI и ограничить область действия токена;
- найти утёкшие учётные данные в образе, репозитории и журналах.
Предварительные знания
- 14.1. Основы registry;
- 12.6. Secrets — каналы утечки;
- 1.5. Docker group и socket.
Ключевые термины
| Термин | Объяснение |
|---|---|
config.json | Файл с настройками клиента и учётными данными |
credential helper | Внешняя программа, хранящая пароли |
credsStore | Помощник по умолчанию для всех registry |
credHelpers | Помощник для конкретного registry |
access token | Токен с ограниченными правами вместо пароля |
scope | Область действия токена |
Теория
Что делает docker login
docker login ghcr.io
- Клиент запрашивает имя пользователя и пароль.
- Проверяет их у registry.
- Сохраняет учётные данные — по умолчанию в
~/.docker/config.json.
Третий шаг — источник большинства проблем. По умолчанию сохранение выглядит так:
{
"auths": {
"ghcr.io": {
"auth": "bXl1c2VyOmdocF9zZWtyZXQxMjM0NQ=="
}
}
}
Значение auth — это base64 от логин:пароль. Не хеш, не шифр — обратимое кодирование.
echo 'bXl1c2VyOmdocF9zZWtyZXQxMjM0NQ==' | base64 -d
# myuser:ghp_sekret12345
Отсюда три следствия:
| Следствие | Практический смысл |
|---|---|
| Файл содержит пароль в открытом виде | Права 600 обязательны |
| Копирование файла = передача пароля | Не коммитить, не класть в образ |
| Резервная копия домашнего каталога содержит пароли | Учитывать при бэкапах |
Credential helpers
Помощник хранит пароль во внешнем хранилище, а в config.json остаётся только ссылка:
{
"credsStore": "pass",
"credHelpers": {
"ghcr.io": "gh",
"123456789.dkr.ecr.eu-west-1.amazonaws.com": "ecr-login"
}
}
| Помощник | Хранилище | Платформа |
|---|---|---|
secretservice | GNOME Keyring | Linux с рабочим столом |
pass | GPG-зашифрованные файлы | Linux, в том числе без графики |
osxkeychain | Keychain | macOS |
wincred | Credential Manager | Windows |
ecr-login | Выдаёт временный токен AWS | Amazon ECR |
credsStore действует на все registry, credHelpers — на конкретный и имеет приоритет.
Существенное отличие ecr-login и подобных: они не хранят пароль, а получают временный токен по требованию. Это лучший вариант, когда registry его поддерживает.
Токены вместо паролей
| Свойство | Пароль | Токен доступа |
|---|---|---|
| Область действия | Вся учётная запись | Заданный набор прав |
| Отзыв | Смена пароля везде | Отзыв одного токена |
| Срок | Бессрочно | Может истекать |
| Двухфакторная защита | Обходится при API | Токен и есть второй фактор |
| Что даёт при утечке | Полный доступ | Только его область |
Строка «Отзыв» — практически самая важная. Утечка пароля требует смены пароля и всего, что от него зависит; утечка токена — отзыва одного токена.
Типичные области действия:
| Область | Для чего |
|---|---|
read:packages | Развёртывание: только скачивание |
write:packages | CI-сборка: публикация |
delete:packages | Очистка старых версий |
Правило: развёртывание получает токен только на чтение. Скомпрометированный сервер не сможет подменить образ в registry.
Способы передачи пароля
| Способ | Виден в | Годится |
|---|---|---|
docker login -p ПАРОЛЬ | История оболочки, ps, журналы | Нет |
| Интерактивный ввод | Нигде | Для рабочей машины |
--password-stdin из файла | Файл (права 600) | Для сервера |
--password-stdin из переменной | Окружение процесса | Для CI |
| Credential helper | Внешнее хранилище | Лучший вариант |
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:
docker-credential-pass get ← {"ServerURL": "ghcr.io"}
docker-credential-pass get → {"Username": "user", "Secret": "токен"}
Программа отвечает за хранение; Docker о способе не знает. Поэтому помощника можно написать самому — например, для получения токена из корпоративного хранилища секретов.
Команды и примеры
Что лежит в config.json
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
Ожидаемый вывод:
═══ структура файла ═══
{
"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). Показана только длина — её достаточно, чтобы отличить пароль от токена.
Правильная и неправильная аутентификация
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
Ожидаемый вывод:
═══ запускаем локальный 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 не найден
✓ в списке процессов не найден
Проверка выполнена, а не заявлена: две команды ищут пароль там, где он оказался бы при неправильном способе.
Область действия токена
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
Ожидаемый вывод:
═══ область токена по задачам ═══
задача область обоснование
────────────────────────────────────────────────────────────────────────────────────────────────────
Сборка в 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
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/^/ /'
Ожидаемый вывод:
═══ доступные помощники ═══
· 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:
{}
Помощник — обычная программа с простым протоколом. Это делает интеграцию с корпоративным хранилищем секретов задачей на полсотни строк.
Поиск утёкших учётных данных
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
Ожидаемый вывод:
═══ проверка на текущей системе ═══
Поиск утёкших учётных данных
⚠ пароли в config.json 1 из 1 записей в открытом виде
✓ права config.json 600 — верно
✓ история оболочки .bash_history: чисто
✓ образы проверено 8
находок: 1
значения намеренно не выведены: вывод может попасть в журнал
═══ завершение работы ═══
Removing login credentials for localhost:5000
Единственная находка — пароль в открытом виде в config.json, следствие выполненного ранее docker login без помощника.
Права при этом верные: Docker создаёт файл с 600. Проблема не в правах, а в том, что содержимое обратимо.
Практическое упражнение
Задание. Настройте аутентификацию так, чтобы пароль не оставил следов.
Требования:
- Поднять registry с аутентификацией и показать разницу ответов с учётными данными и без.
- Выполнить вход через
--password-stdinи проверить, что пароль не попал в историю и в список процессов. - Показать, что
config.jsonсодержит обратимое кодирование, не печатая сам секрет. - Показать разницу между токенами разной области: что даст утечка каждого.
- Реализовать credential helper и показать, что после его настройки
config.jsonсекрета не содержит. - Написать проверку утечки учётных данных, не создающую вторую утечку.
Подсказки
Подсказка 1
Для пункта 2 проверка должна быть выполнена, а не заявлена: grep по файлу истории и по выводу ps.
Подсказка 2
Помощник — обычная программа docker-credential-<имя> в PATH, принимающая команду аргументом и данные через stdin.
Подсказка 3
Проверка из пункта 6 должна печатать длину секрета и имя поля, но не значение.
Решение
Показать решение
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"
Ожидаемый вывод:
═══ Требование 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 и подобные помощники, выдающие временные токены, только упомянуты: они требуют облачной учётной записи.
Проверка результата
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 | Задача и так завершится | На общем сборщике файл остаётся |
| Пароль вместо токена | Уже есть | Отзыв токена дешевле смены пароля |
| Не проверяют историю после ошибки | Пароль сменили | Старый остаётся в файле истории |
| Инструмент поиска печатает найденное | Удобно для отладки | Вывод в журнале = вторая утечка |
Контрольные вопросы
На понимание:
- Что означает поле
authвconfig.jsonи почему это не защита? - Почему
docker login -pнебезопасен? Назовите три места, куда попадёт пароль. - Чем токен доступа лучше пароля, если утекли оба?
- В чём разница между
credsStoreиcredHelpers? - Почему
ecr-loginбезопаснее хранящих помощников?
На применение:
- Как аутентифицироваться в CI, не оставив пароль в журнале?
- Какую область токена дать задаче развёртывания и почему?
- Как проверить, что учётные данные не утекли, не создав вторую утечку?
На диагностику:
config.jsonслучайно закоммитили. Порядок действий?- На общем CI-сборщике чужая задача может публиковать в ваш registry. Как это возможно?
Краткое резюме
docker loginсохраняет учётные данные; по умолчанию — в~/.docker/config.json.- Поле
auth— base64 отлогин:пароль, обратимое кодирование, а не шифрование. - Копирование
config.jsonравносильно передаче пароля. - Credential helper хранит секрет вне файла; Docker общается с ним по простому протоколу.
credHelpersзадаёт помощника для конкретного registry и имеет приоритет надcredsStore.- Помощники вида
ecr-loginне хранят пароль, а выдают временный токен. - Токен доступа отзывается по одному; пароль требует смены везде.
- Развёртыванию дают токен только на чтение — тогда подмена образа невозможна.
-p ПАРОЛЬпопадает в историю оболочки, выводpsи журналы CI.--password-stdin— единственный приемлемый способ передачи в скрипте.docker logoutв конце задачи CI обязателен на общем сборщике.- Инструмент поиска утечек не должен печатать найденные значения.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
Docker: docker login | https://docs.docker.com/reference/cli/docker/login/ | --password-stdin, предупреждение о -p |
| Docker: credential stores | https://docs.docker.com/reference/cli/docker/login/#credential-stores | credsStore, credHelpers |
Docker: config.json | https://docs.docker.com/reference/cli/docker/#configuration-files | Структура файла |
| docker-credential-helpers | https://github.com/docker/docker-credential-helpers | Протокол помощника |
| Docker Hub: access tokens | https://docs.docker.com/security/access-tokens/ | Токены вместо паролей |
GitHub: ghcr.io authentication | https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry | Области read:packages, write:packages |
| Amazon ECR: credential helper | https://github.com/awslabs/amazon-ecr-credential-helper | Временные токены |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Локальный registry
Главное оглавление