Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

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"]

Це закриває більшість сценаріїв закріплення зловмисника в контейнері після злому застосунку.

Докладніше в документації: Монтування tmpfs

Найпоширеніший спосіб передати пароль бази чи ключ 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.

Докладніше в документації: Секрети в Docker Compose

Образ містить не лише ваш код, а й базову ОС, системні бібліотеки, 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) для базових образів і залежностей роблять процес постійним, а не авральним.

Докладніше в документації: Docker Scout