Middle: питання на співбесіді з теми «Мережі й безпека контейнерів»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
5 питань
Класична продакшен-пастка на Ubuntu й Debian:
sudo ufw default deny incoming
sudo ufw allow 22
sudo ufw allow 443
# фаєрвол налаштовано, «все закрито»
А потім docker run -p 6379:6379 redis - і Redis доступний з інтернету, попри правила ufw.
Чому так: Docker сам керує правилами фаєрвола (iptables чи nftables) - без них не працювали б мережі bridge і публікація портів. Трафік на опублікований порт перенаправляється в контейнер у таблиці NAT ще до того, як потрапить у ланцюжки, якими керує ufw. Документація Docker прямо попереджає: Docker і ufw використовують правила фаєрвола несумісно.
Як захиститися:
1. Не публікувати те, що не має бути публічним - найнадійніше:
services:
redis:
# без ports - доступний лише іншим контейнерам у мережі
postgres:
ports:
- "127.0.0.1:5432:5432" # лише з самого хоста
2. Ланцюжок DOCKER-USER - Docker пропускає через нього трафік до контейнерів перед власними правилами. Туди додають обмеження (наприклад, дозволити порт лише з певних адрес). Правила в інших ланцюжках, створених Docker, змінювати не можна - Docker перезаписує їх.
3. Фаєрвол на рівні мережі чи хмари - security groups у AWS, фаєрвол провайдера (Hetzner Cloud Firewall) працюють поза хостом і не залежать від правил, які створює Docker.
4. Не вимикати керування фаєрволом Docker ("iptables": false) без глибокого розуміння - зламаються мережі контейнерів.
Перевірка ззовні, а не з самого сервера:
nmap -p 1-65535 ваш-сервер
і docker ps для переліку опублікованих портів.
Висновок для співбесіди: фаєрвол хоста не захищає опубліковані порти Docker; захищає правильна публікація (127.0.0.1 чи без ports) і фаєрвол на рівні мережі.
У Linux права root розділені на окремі можливості (capabilities): змінювати власника файлів (CAP_CHOWN), відкривати порти нижче 1024 (CAP_NET_BIND_SERVICE), налаштовувати мережу (CAP_NET_ADMIN), завантажувати модулі ядра (CAP_SYS_MODULE) тощо.
Docker за замовчуванням дає контейнеру обмежений набір - близько півтора десятка можливостей (CHOWN, SETUID, NET_BIND_SERVICE, KILL та інші), а небезпечні (SYS_ADMIN, NET_ADMIN, SYS_MODULE) прибирає. Тому root у контейнері слабший за root на хості.
Принцип найменших привілеїв - прибрати все й додати лише потрібне:
docker run --cap-drop ALL --cap-add NET_BIND_SERVICE nginx
services:
app:
cap_drop: [ALL]
cap_add: [NET_BIND_SERVICE]
security_opt:
- no-new-privileges:true
docker run --rm --cap-drop ALL alpine chown nobody /tmp
# chown: /tmp: Operation not permitted - навіть від root
no-new-privileges забороняє процесу отримати нові права через setuid-бінарні файли (наприклад, sudo, su) - навіть якщо вони є в образі.
Скільки можливостей потрібно типовому вебзастосунку? Часто - жодної, якщо він працює від звичайного користувача й слухає порт вище 1024. Процеси, що стартують від root і перемикаються на іншого користувача (nginx, php-fpm master), потребують SETUID, SETGID, CHOWN. Підібрати мінімальний набір - експериментально: прибрати все й додавати те, без чого контейнер не запускається.
Небезпечні можливості, які додають «щоб працювало»:
SYS_ADMIN- фактично майже повний root (монтування, багато адміністративних операцій) - один із класичних шляхів виходу з контейнера;NET_ADMIN- керування мережею хоста;SYS_PTRACE- відстеження інших процесів.
Якщо документація сторонньої програми вимагає таких можливостей, варто зрозуміти навіщо, а не додавати бездумно.
Перевірити ефективні можливості в контейнері: grep CapEff /proc/self/status і розшифрувати capsh --decode=....
Докладніше в документації: Запуск контейнерів: привілеї й можливості
--read-only робить кореневу файлову систему контейнера доступною лише для читання. Запис дозволено тільки в явно змонтовані томи й tmpfs.
docker run --rm --read-only alpine touch /x
# touch: /x: Read-only file system
docker run --rm --read-only --tmpfs /tmp alpine sh -c 'touch /tmp/y && echo ok'
# ok
Навіщо:
- зламаний застосунок не може змінити себе: зловмисник, що отримав виконання коду, не запише веб-шелл у каталог з кодом, не підмінить бінарні файли, не встановить інструменти через пакетний менеджер;
- незмінність: контейнер гарантовано відповідає образу - жодного «дрейфу» конфігурації через ручні зміни;
- видно всі місця запису: кожен каталог, куди застосунок пише, треба оголосити явно - це документує поведінку застосунку.
Що зазвичай потрібно для запису й чим це закрити:
- тимчасові файли (
/tmp,/var/run, кеш PHP-сесій) -tmpfs(у пам'яті, зникає при зупинці); - дані, що мають жити - іменовані томи;
- для Laravel:
storage/framework(кеш, сесії, скомпільовані шаблони),storage/logs,bootstrap/cache- tmpfs або томи. Логи краще писати вstderr(LOG_CHANNEL=stderr), тоді каталог логів не потрібен.
services:
app:
read_only: true
tmpfs:
- /tmp
- /var/www/html/storage/framework:uid=1000,gid=1000
volumes:
- uploads:/var/www/html/storage/app
Особливості tmpfs:
- зберігається в пам'яті й зараховується до ліміту пам'яті контейнера - великі тимчасові файли (обробка відео) там не місце;
- вміст зникає при зупинці контейнера;
- права й розмір задаються опціями монтування.
Поєднання захисних прапорців - стандартний «посилений» запуск:
read_only: true
user: "1000:1000"
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
Це закриває більшість сценаріїв закріплення зловмисника в контейнері після злому застосунку.
Найпоширеніший спосіб передати пароль бази чи ключ API в контейнер - змінна оточення. Але змінні оточення легко витікають:
docker inspectпоказує всі змінні контейнера відкритим текстом - будь-хто з доступом до Docker на хості їх бачить;/proc/<pid>/environ- процеси з достатніми правами читають оточення інших процесів;- дочірні процеси успадковують оточення: сторонній бінарний файл, запущений застосунком, отримує всі секрети;
- логи й звіти про помилки: сторінки налагодження,
phpinfo(), дампи оточення в системах моніторингу; ENVу Dockerfile зберігається в образі - будь-хто з доступом до образу (реєстр) прочитаєdocker history/docker inspectобразу.
Секрети Compose монтують значення файлом у /run/secrets/<назва>:
services:
app:
image: myapp
secrets:
- db_password
environment:
DB_PASSWORD_FILE: /run/secrets/db_password
secrets:
db_password:
file: ./secrets/db_password.txt # або environment: DB_PASSWORD з оточення хоста
- значення не видно в
docker inspectконтейнера; - доступ лише для сервісів, яким секрет явно надано;
- багато офіційних образів підтримують змінні з суфіксом
_FILE(POSTGRES_PASSWORD_FILE,MYSQL_ROOT_PASSWORD_FILE) - читають секрет з файлу.
Застосунок має вміти читати секрет з файлу. Для Laravel - невеликий код у конфігурації чи завантаження змінних з файлу на старті контейнера (скрипт-обгортка, що читає /run/secrets/* і експортує їх лише для процесу PHP).
Що варто пам'ятати:
- секрети Compose без Swarm - це по суті bind mount файлу: захищають від
inspectі випадкового витоку, але не від root на хості; - у продакшені - менеджери секретів (Vault, AWS Secrets Manager, Doppler) чи механізми оркестратора (Kubernetes Secrets з шифруванням);
- на етапі збирання образу секрети передаються через
RUN --mount=type=secret, а неARGчиENV; - файл
.envз секретами - у.dockerignoreі.gitignore.
Образ містить не лише ваш код, а й базову ОС, системні бібліотеки, PHP і розширення, Composer- і npm-залежності. У кожному шарі можуть бути відомі вразливості (CVE).
Інструменти сканування:
docker scout cves myapp:1.4 # Docker Scout (вбудований у Docker CLI)
docker scout quickview myapp:1.4 # коротке зведення з порадами
trivy image myapp:1.4 # Trivy від Aqua Security - відкритий і популярний у CI
grype myapp:1.4 # Grype від Anchore
Сканер визначає пакети в образі (за базами пакетного менеджера ОС і файлами залежностей мов) і зіставляє їх з базами вразливостей.
Як вбудувати в процес:
- у CI на кожне збирання: падати на вразливостях рівня
CRITICAL/HIGH, для яких є виправлення (trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1); - регулярне сканування вже зібраних образів - нові CVE з'являються щодня, а образ, чистий тиждень тому, сьогодні може бути вразливим;
- SBOM (перелік компонентів образу) -
docker scout sbomчиsyft: швидко зрозуміти, де використовується вразлива бібліотека, коли виходить нове повідомлення.
Що робити з результатами - не намагатися виправити все:
- оновити базовий образ - найчастіше прибирає більшість знахідок. Закріплюйте мінорну версію (
php:8.5-fpm-alpine) і регулярно перезбирайте; - менший образ - менше вразливостей: Alpine,
-slim, distroless, multi-stage без інструментів збирання у фінальному образі; - оцінювати досяжність: вразливість у бібліотеці, яку застосунок не викликає, чи в пакеті, що потрапив в образ випадково, - менш пріоритетна, ніж у вебсервері;
- документувати свідомі винятки (VEX, файл ігнорування з причиною й датою перегляду), щоб звіт лишався корисним, а не з сотнею проігнорованих рядків.
Пастки:
- «нуль CVE» - не мета: багато знахідок у базових ОС не мають виправлень або не стосуються вашого використання;
- сканер бачить лише відомі пакети: бінарні файли, скопійовані вручну (
curl ... | tar), часто невидимі для нього; - автоматичні оновлення (Dependabot, Renovate) для базових образів і залежностей роблять процес постійним, а не авральним.