Зачем я это написал
Kubernetes сегодня повсюду. Большинство команд не пишут YAML-манифесты вручную — они используют чарты Helm (Helm charts). Чарт — это шаблон: задаёшь значения, и Helm превращает его в готовые манифесты Kubernetes.
Для производительности это отлично. Для безопасности — рискованно.
Один чарт способен создать Deployment, Service, Secret, ConfigMap и многое другое. У каждого из этих объектов масса параметров безопасности, и ошибиться в одном из них очень легко. Контейнер может запускаться от root. Может монтировать Docker-сокет. Может разделять сетевое пространство имён хоста. Большинство таких ошибок незаметны, пока их кто-нибудь не использует.
Теперь представьте, что у вас не один чарт, а десять тысяч. Вы исследуете публичный реестр чартов или проводите аудит всех чартов внутри крупной компании. Открывать десять тысяч YAML-файлов вручную невозможно.
Именно эту проблему решает helmsniff. Это небольшой и быстрый инструмент командной строки: он читает готовые манифесты Kubernetes и формирует простой отчёт. Одна строка на объект Kubernetes, один столбец на каждую проверку безопасности. Отчёт выдаётся в формате CSV или JSON, поэтому его легко загрузить в pandas, таблицу или базу данных и изучить закономерности по тысячам чартов.
В этой статье рассказывается, что делает helmsniff, как им пользоваться, как работает каждая проверка, где этот инструмент вписывается среди других решений и каковы его ограничения. Материал рассчитан на разработчиков и исследователей безопасности.
Инструмент с открытым исходным кодом. Код, инструкции по сборке и трекер задач: github.com/VahidR/helmsniff.
Что такое helmsniff (и чем он не является)
Сначала определим область применения — в инструментах безопасности это принципиально важно.
helmsniff является:
-
Статическим анализатором (static analyzer). Он читает текст YAML и никогда не обращается к кластеру.
-
Инструментом для работы с уже отрендеренными манифестами. Сначала вы рендерите чарт с помощью
helm template, а затем передаёте результат на проверку. -
Пакетным (batch) инструментом, рассчитанным на тысячи параллельных запусков — по одному на каждый чарт.
-
Поставщиком данных: он превращает запутанный YAML в аккуратную таблицу из
0и1.
helmsniff не является:
-
Сканером живого кластера. Он не подключается к API-серверу.
-
Движком политик. Сам по себе он не блокирует деплои и не роняет CI.
-
Рендерером Helm. Он не разворачивает шаблоны — это вы делаете сами.
-
Инструментом исправления. Он находит проблемы, но не изменяет файлы.
Если держать эту область применения в голове, всё остальное встанет на своё место.
Немного контекста: почему эти мисконфигурации важны
Прежде чем переходить к командам, объясню, зачем эти проверки существуют. Для разработчика это ответ на вопрос «и что с того?». Для исследователя безопасности — это поверхность атаки (attack surface).
Pod в Kubernetes — это просто группа контейнеров на узле. По умолчанию контейнер изолирован от хоста. Многие мисконфигурации нарушают эту изоляцию. Когда изоляция нарушена, становится возможным побег из контейнера (container escape): злоумышленник, получивший управление над одним Pod, может захватить весь узел и нередко весь кластер.
Ниже — классические способы сломать изоляцию. helmsniff ищет их все.
-
hostPID,hostIPC,hostNetwork: помещают контейнер в пространство имён процессов, IPC или сети хоста. СhostPIDконтейнер видит процессы хоста и может посылать им сигналы. СhostNetworkон может перехватывать трафик узла и обращаться к сервисам, привязанным к localhost. -
Монтирование
/var/run/docker.sock: Docker-сокет — это полный контроль над средой выполнения контейнеров. Контейнер, получивший к нему доступ, может запустить на хосте новый привилегированный контейнер. Это побег в один шаг. -
Возможность
SYS_ADMIN: её называют «новым root». Она открывает множество путей для побега, включая монтирование файловых систем и злоупотребление cgroup. -
Возможность
SYS_MODULE: позволяет контейнеру загружать модули ядра. Вредоносный модуль ядра — это полное владение ядром. -
allowPrivilegeEscalation: true: позволяет процессу получить привилегии выше, чем у родителя (например, через setuid-бинарники). -
seccompProfile: Unconfined: seccomp фильтрует системные вызовы, доступные контейнеру. РежимUnconfinedотключает эту защиту, и контейнер может использовать любой syscall, который предоставляет ядро. -
hostAliases: пользовательские записи в/etc/hostsвнутри Pod. Злоумышленник может использовать их, чтобы перенаправить доверенное имя хоста на подконтрольный ему сервер. -
Открытый текст
http://: данные и учётные данные передаются без шифрования — их легко прочитать и изменить в пути. -
Секреты прямо в манифесте (
Secretс данными): секреты, хранящиеся непосредственно в чарте, нередко попадают в историю Git и логи CI. Base64 — это не шифрование. -
Отсутствие
securityContext: без него контейнер зачастую запускается от root с расслабленными настройками по умолчанию. -
Отсутствие лимитов ресурсов: один контейнер может съесть весь CPU и всю память на узле. Это риск отказа в обслуживании (denial-of-service), а не просто вопрос аккуратности.
-
Отсутствие
NetworkPolicy: по умолчанию каждый Pod может разговаривать с любым другим. Один скомпрометированный Pod может сканировать и атаковать весь namespace. -
Отсутствие скользящего обновления: стратегия
Recreateвызывает простой во время деплоя. Это проблема надёжности, а не прямая дыра в безопасности, но сигнал о небрежно написанном чарте.
Каждый из этих пунктов соответствует одному столбцу в отчёте. Теперь давайте его получим.
Начало работы
Требования
Нужны только две вещи:
-
Go 1.22 или новее (для сборки бинарника).
-
GNU Make (в проекте есть небольшой
Makefile).
Всё. Инструмент имеет одну внешнюю зависимость (gopkg.in/yaml.v3) — и больше ничего. Это делает бинарник маленьким, сборку быстрой, а цепочку поставок (supply chain) почти пустой, что важно, когда ты сам занимаешься безопасностью.
Сборка
Клонируйте репозиторий и выполните:
make build
Бинарник окажется в bin/helmsniff. Запустите его без флагов, чтобы увидеть справку:
$ ./bin/helmsniff
usage: ./bin/helmsniff --root <rendered_dir|-> --out <path|-> [--format csv|json]
Всего три флага — это весь интерфейс.
| Флаг | Значение |
|---|---|
|
Директория с готовыми манифестами или |
|
Путь для записи отчёта или |
|
|
Коды завершения предсказуемы, что упрощает написание скриптов:
-
0— успех. -
1— ошибка во время выполнения (например, stdin не удалось прочитать). -
2— неправильное использование (отсутствует флаг или неверное значение--format).
Первое сканирование
helmsniff читает готовый YAML, а не шаблоны чартов. Поэтому первый шаг — всегда рендеринг. Helm делает это за вас:
helm template my-release ./charts/payment-api > rendered/payment-api/manifest.yaml
Чтобы сделать пример конкретным, возьмём небольшой, но намеренно опасный манифест. Это Deployment и Secret, в которых почти всё сделано неправильно специально:
apiVersion: apps/v1
kind: Deployment
metadata:
name: payment-api
labels:
app.kubernetes.io/managed-by: Helm
helm.sh/chart: payment-api-1.2.0
spec:
strategy:
type: Recreate # no rolling update
template:
spec:
hostPID: true # host PID namespace
hostNetwork: true # host network namespace
hostAliases: # custom /etc/hosts
- ip: "10.0.0.1"
hostnames: ["internal.db"]
containers:
- name: api
image: registry.example.com/payment-api:latest
env:
- name: WEBHOOK_URL
value: "http://webhook.internal/notify" # plaintext http
securityContext:
allowPrivilegeEscalation: true # privilege escalation
seccompProfile:
type: Unconfined # seccomp off
capabilities:
add: ["SYS_ADMIN", "SYS_MODULE"] # dangerous caps
volumes:
- name: docker
hostPath:
path: /var/run/docker.sock # docker socket mount
---
apiVersion: v1
kind: Secret
metadata:
name: payment-api-secrets
namespace: payments
type: Opaque
stringData:
DB_PASSWORD: "super-secret-123" # secret in the manifest
Теперь просканируем директорию и выведем результат в терминал:
./bin/helmsniff --root rendered/payment-api --out -
Вот реальный вывод:
DIR,YAML_FULL_PATH,WITHIN_MANIFEST_SECRET,VALID_TAINT_SECRET,SEC_CONT_OVER_PRIVIL,INSECURE_HTTP,NO_SECU_CONTEXT,NO_DEFAULT_NSPACE,NO_RESO,NO_ROLLING_UPDATE,NO_NETWORK_POLICY,TRUE_HOST_PID,TRUE_HOST_IPC,DOCKERSOCK_PATH,TRUE_HOST_NET,CAP_SYS_ADMIN,HOST_ALIAS,ALLOW_PRIVI,SECCOMP_UNCONFINED,CAP_SYS_MODULE,K8S_STATUS,HELM_STATUS
rendered/payment-api,rendered/payment-api/manifest.yaml,0,0,0,1,0,1,1,1,1,1,0,1,1,1,1,1,1,1,true,true
rendered/payment-api,rendered/payment-api/manifest.yaml,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,true,false
На входе было два YAML-документа — на выходе две строки. Первая строка — это Deployment. Читаем единицы: небезопасный HTTP, нет namespace, нет лимитов ресурсов, нет скользящего обновления, нет NetworkPolicy, host PID, Docker-сокет, host network, SYS_ADMIN, host alias, разрешена эскалация привилегий, seccomp отключён, SYS_MODULE. Длинный список. Этот чарт — настоящий полигон для злоумышленника.
Вторая строка — Secret. Только две единицы: WITHIN_MANIFEST_SECRET (объект содержит данные) и NO_NETWORK_POLICY (флаг уровня чарта, подробнее ниже). Обратите внимание: HELM_STATUS у Secret равен false, потому что у него нет меток Helm, тогда как у Deployment они есть — и там true.
Вот и весь основной цикл: отрендерили — просканировали — прочитали таблицу. Всё остальное — детали.
Чтение отчёта: все 22 столбца
Каждая строка имеет одинаковую структуру. Два текстовых столбца с контекстом, затем проверки. Каждый столбец проверки — двоичное целое число: 1 означает «найдено / нарушение», 0 — «не найдено / всё в порядке». Последние два столбца — булевы значения (true/false), описывающие сам документ.
Вот полная схема в порядке следования столбцов.
| # | Столбец | Тип | Значение 1 (или true) |
|---|---|---|---|
1 |
|
текст |
Директория просканированного чарта (или |
2 |
|
текст |
Точный файл, из которого получена строка (или |
3 |
|
0/1 |
Объект |
4 |
|
0/1 |
Зарезервировано. Сейчас всегда |
5 |
|
0/1 |
Зарезервированный заполнитель. Сейчас всегда |
6 |
|
0/1 |
Строка |
7 |
|
0/1 |
Нет |
8 |
|
0/1 |
Поле |
9 |
|
0/1 |
У контейнера нет ни |
10 |
|
0/1 |
|
11 |
|
0/1 |
В чарте нет ни одного объекта |
12 |
|
0/1 |
|
13 |
|
0/1 |
|
14 |
|
0/1 |
|
15 |
|
0/1 |
|
16 |
|
0/1 |
Контейнер добавляет возможность |
17 |
|
0/1 |
В спецификации Pod определены |
18 |
|
0/1 |
Контейнер устанавливает |
19 |
|
0/1 |
Контейнер устанавливает |
20 |
|
0/1 |
Контейнер добавляет возможность |
21 |
|
bool |
Документ содержит |
22 |
|
bool |
Документ содержит метки Helm. |
Почему 0 и 1, а не true/false?
Это сознательное решение, и для исследователей оно удачное. Поскольку столбцы проверок — целые числа, их можно суммировать. Сумма по столбцу среди десяти тысяч строк — это количество чартов с данной проблемой. Сумма по строке — грубая «оценка опасности» одного объекта. Всё это делается одной строкой в pandas или даже в таблице. Булевы значения потребовали бы предварительного преобразования.
Как на самом деле работает каждая проверка
Именно это интересует исследователей безопасности больше всего. Сканер полезен лишь тогда, когда вы точно знаете, что его триггерит. Ложная уверенность опасна. Поэтому — вот реальная логика каждой проверки, взятая непосредственно из исходного кода. Я также указываю на граничные случаи (edge cases), потому что именно там прячутся баги.
Проверки контекста и статуса
K8S_STATUS равен true, когда документ содержит непустые apiVersion и kind. Это быстрый фильтр «является ли это вообще объектом Kubernetes». Случайный файл значений или фрагмент README даст false.
HELM_STATUS равен true, когда metadata.labels содержит app.kubernetes.io/managed-by: Helm, или когда там есть метка helm.sh/chart. Helm добавляет эти метки автоматически. Столбец позволяет отличить объекты, которыми управляет Helm, от объектов, попавших другим путём.
Секреты
WITHIN_MANIFEST_SECRET равен 1 только когда kind равен Secret и объект имеет хотя бы одну запись в data или stringData. Пустая оболочка Secret не триггерит проверку. Цель — найти настоящие учётные данные, вшитые прямо в чарт. Помните: данные Secret в Kubernetes закодированы в base64, а не зашифрованы. Кто прочитает манифест — прочитает и секрет.
Небезопасная передача данных
INSECURE_HTTP обходит весь документ — каждую вложенную структуру (map), каждый список, каждую строку и даже ключи структур — и возвращает 1, если любой из них содержит подстроку http:// (без учёта регистра). При первом совпадении останавливается.
Это широкая и простая проверка. Знайте обе её стороны:
-
Она тщательна: поймает
http://URL, спрятанный в аннотации или переменной окружения. -
Она буквальна: это поиск подстроки. Значение вроде
"http://"внутри безобидной строки всё равно триггерит проверку, аhttps://— никогда нет. Контекст не анализируется. Воспринимайте1как «загляни сюда», а не как «однозначная уязвимость».
Побеги на уровне Pod
Эти проверки читают спецификацию Pod. Для голого Pod это поле spec. Для рабочих нагрузок (workload) вроде Deployment, StatefulSet, DaemonSet, Job или CronJob — это spec.template.spec. Для нерабочих нагрузок (Service, ConfigMap) спецификации Pod нет, и все такие проверки возвращают 0.
TRUE_HOST_PID, TRUE_HOST_IPC, TRUE_HOST_NET — каждая возвращает 1, когда соответствующее поле (hostPID, hostIPC, hostNetwork) имеет булево значение true в спецификации Pod.
HOST_ALIAS возвращает 1, когда в спецификации Pod есть непустой список hostAliases.
DOCKERSOCK_PATH перебирает spec.volumes и возвращает 1, если хотя бы один том является hostPath, чей path содержит /var/run/docker.sock.
Опасности на уровне контейнера
Эти проверки смотрят на каждый контейнер — как обычные containers, так и initContainers. Если опасен хотя бы один контейнер, вся строка получает 1.
CAP_SYS_ADMIN и CAP_SYS_MODULE читают список securityContext.capabilities.add каждого контейнера. Сравнение регистронезависимо (значение переводится в верхний регистр), поэтому SYS_ADMIN и sys_admin оба сработают.
ALLOW_PRIVI возвращает 1, если хотя бы у одного контейнера установлено securityContext.allowPrivilegeEscalation: true.
SECCOMP_UNCONFINED возвращает 1, если хотя бы у одного контейнера поле securityContext.seccompProfile.type равно Unconfined (без учёта регистра).
Гигиена и надёжность
NO_SECU_CONTEXT возвращает 1 только тогда, когда у Pod нет securityContext ни на уровне Pod, ни ни в одном контейнере. Это важно и немного неочевидно: если даже у одного контейнера есть securityContext — пусть даже очень плохой — этот столбец равен 0. В нашем примере выше NO_SECU_CONTEXT равен 0, потому что у контейнера есть (крайне опасный) securityContext. Поэтому 0 здесь не означает «безопасно». Это означает «securityContext где-то существует». Читайте этот столбец вместе с остальными.
NO_RESO возвращает 1, если у хотя бы одного контейнера нет ни resources.limits, ни resources.requests. Одного контейнера без лимитов достаточно, чтобы пометить строку. Это ваш сигнал о «шумном соседе» и риске отказа в обслуживании.
NO_ROLLING_UPDATE применяется только к объектам Deployment. Возвращает 1, когда тип стратегии равен Recreate, или когда блок rollingUpdate отсутствует полностью. Для всех остальных типов объектов всегда 0.
NO_DEFAULT_NSPACE возвращает 1, когда metadata.namespace отсутствует или пусто. Честная оговорка: кластерные объекты (такие как ClusterRole) и объекты, полагающиеся на namespace, заданный при установке, тоже будут помечены. Поэтому 1 — это повод проверить, а не всегда реальная проблема.
Единственная проверка уровня чарта
NO_NETWORK_POLICY особенная. Она вычисляется не на уровне документа, а один раз для всего чарта. Прежде чем сканировать любой файл, helmsniff делает быстрый первый проход по всем YAML-файлам в директории и спрашивает: «есть ли среди объектов хоть один с kind: NetworkPolicy?» Если да — каждая строка этого чарта получает NO_NETWORK_POLICY = 0. Если нет — каждая строка получает 1.
Именно поэтому в первом примере строка Secret тоже показала NO_NETWORK_POLICY = 1. Флаг характеризует чарт и копируется в каждую строку. Это удобно: достаточно отфильтровать по любой одной строке, чтобы узнать, есть ли в чарте сетевая изоляция.
Чистый чарт для сравнения
Чтобы доверять сканеру, нужно убедиться, что он молчит, когда всё в порядке. Вот безопасный чарт: с скользящим обновлением, контекстом безопасности, лимитами ресурсов, namespace и NetworkPolicy:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
namespace: web
labels:
app.kubernetes.io/managed-by: Helm
spec:
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
template:
spec:
securityContext:
runAsNonRoot: true
containers:
- name: web
image: web:1.0.0
resources:
requests: { cpu: "100m" }
limits: { cpu: "200m" }
securityContext:
allowPrivilegeEscalation: false
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: web-default-deny
namespace: web
spec:
podSelector: {}
policyTypes: ["Ingress"]
Сканируем:
DIR,YAML_FULL_PATH,WITHIN_MANIFEST_SECRET,...,NO_NETWORK_POLICY,...,K8S_STATUS,HELM_STATUS
rendered/safechart,rendered/safechart/manifest.yaml,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,true,true
rendered/safechart,rendered/safechart/manifest.yaml,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,true,false
Все проверки равны 0. Обратите внимание: NO_NETWORK_POLICY равен 0 в обеих строках, включая сам объект NetworkPolicy, — потому что предварительный проход на уровне чарта обнаружил NetworkPolicy. Именно это и нужно.
Три способа подать данные
helmsniff гибко принимает входные данные и отправляет результаты.
1. Режим директории
Укажите --root директорию. Инструмент обойдёт её рекурсивно и прочитает все файлы .yaml и .yml:
./bin/helmsniff --root rendered/payment-api --out report.csv
Если в --out указать настоящий путь к файлу, инструмент создаст все недостающие родительские директории.
2. Режим pipe через stdin
Используйте --root -, чтобы читать YAML прямо из потока. Это избавляет от промежуточного файла. Отлично сочетается с helm template:
helm template my-release ./charts/payment-api \
| ./bin/helmsniff --root - --out - --format json
В этом режиме столбцы DIR и YAML_FULL_PATH оба принимают значение stdin, поскольку файла на диске нет. Предварительный проход NetworkPolicy по-прежнему работает: он выполняется по документам, поступившим в поток.
3. Вывод в stdout или в файл
--out - пишет в stdout (удобно для передачи в jq, grep или другой скрипт). Реальный путь пишет в файл. Оба варианта работают с любым форматом.
Вывод в формате JSON
CSV лучше всего подходит для работы с большими данными. JSON удобнее, когда нужно внимательно изучить один объект или передать данные другой программе. Добавьте --format json:
./bin/helmsniff --root rendered/payment-api --out - --format json
Получите красиво отформатированный массив, один элемент на документ:
[
{
"dir": "rendered/payment-api",
"yaml_full_path": "rendered/payment-api/manifest.yaml",
"within_manifest_secret": 0,
"insecure_http": 1,
"no_default_nspace": 1,
"no_reso": 1,
"no_rolling_update": 1,
"no_network_policy": 1,
"true_host_pid": 1,
"dockersock_path": 1,
"true_host_net": 1,
"cap_sys_admin": 1,
"host_alias": 1,
"allow_privi": 1,
"seccomp_unconfined": 1,
"cap_sys_module": 1,
"k8s_status": true,
"helm_status": true
}
]
Поскольку это стандартный JSON, его легко обрабатывать через jq. Например, вывести только документы, монтирующие Docker-сокет:
./bin/helmsniff --root rendered/payment-api --out - --format json \
| jq '.[] | select(.dockersock_path == 1) | .yaml_full_path'
Параллельное сканирование тысяч чартов
Именно для этого helmsniff и создавался. Один запуск обрабатывает один чарт. Чтобы сканировать множество чартов, запустите несколько копий одновременно. GNU parallel сворачивает это в одну строку:
parallel ./bin/helmsniff --root {} --out reports/{/}.csv ::: rendered/*/
Здесь {} — это каждая директория чарта, а {/} — только её имя. В итоге в reports/ появляется по одному CSV на чарт. На обычном ноутбуке каждый небольшой чарт сканируется за несколько миллисекунд, так что даже десятки тысяч чартов обрабатываются быстро.
Чтобы получить одну большую таблицу вместо множества маленьких, сохраните заголовок из первого файла и дополните его строками данных из остальных:
# заголовок — один раз
head -n 1 "$(ls reports/*.csv | head -n 1)" > all.csv
# строки данных из каждого файла
for f in reports/*.csv; do tail -n +2 "$f" >> all.csv; done
Теперь all.csv содержит все объекты из всех чартов в одном месте. Это ваш датасет.
Превращение отчёта в находки с помощью pandas
Весь дизайн — двоичные столбцы, одна строка на объект — существует ради этого шага. Вот небольшой анализ, который можно запустить на all.csv:
import pandas as pd
df = pd.read_csv("all.csv")
# Столбцы с проверками безопасности (пропускаем два текстовых и два булевых).
checks = [
"WITHIN_MANIFEST_SECRET", "INSECURE_HTTP", "NO_SECU_CONTEXT",
"NO_DEFAULT_NSPACE", "NO_RESO", "NO_ROLLING_UPDATE", "NO_NETWORK_POLICY",
"TRUE_HOST_PID", "TRUE_HOST_IPC", "DOCKERSOCK_PATH", "TRUE_HOST_NET",
"CAP_SYS_ADMIN", "HOST_ALIAS", "ALLOW_PRIVI", "SECCOMP_UNCONFINED",
"CAP_SYS_MODULE",
]
# 1. Насколько часто встречается каждая проблема среди всех объектов?
print(df[checks].sum().sort_values(ascending=False))
# 2. Простая оценка опасности для каждого объекта, затем — худшие.
df["score"] = df[checks].sum(axis=1)
print(df.sort_values("score", ascending=False)[["YAML_FULL_PATH", "score"]].head(10))
# 3. Какие чарты монтируют Docker-сокет? (почти мгновенный побег)
print(df[df["DOCKERSOCK_PATH"] == 1]["DIR"].unique())
# 4. В скольких чартах вообще нет NetworkPolicy?
no_np_charts = df[df["NO_NETWORK_POLICY"] == 1]["DIR"].nunique()
print(f"Чарты без NetworkPolicy: {no_np_charts}")
Поскольку каждая проверка — это 0 или 1, sum() сразу даёт счётчики. Можно строить графики частот, ранжировать наиболее опасные чарты и фильтровать по любому столбцу. Именно такой анализ превращает «мне кажется, чарты небезопасны» в «37% чартов в этом реестре монтируют сеть хоста».
Как helmsniff устроен изнутри
Для использования инструмента это знать не обязательно. Но если вы хотите ему доверять, расширять его или разобраться в неожиданном результате — вот его устройство простыми словами.
Два прохода. По директории helmsniff проходит дважды. Первый проход ищет только NetworkPolicy и останавливается на первом найденном. Это дёшево за счёт раннего выхода. Второй проход выполняет настоящий анализ по документам. Два прохода нужны потому, что NO_NETWORK_POLICY — свойство всего чарта, а не отдельного файла, и оно должно быть известно до начала поофайловой работы.
Нетипизированный YAML. Инструмент не использует официальные структуры Go для Kubernetes. Каждый документ парсится в обобщённую map[string]interface{}. Это может казаться небрежностью, но на самом деле умное решение: в Kubernetes сотни видов ресурсов, а инструмент читает лишь горстку полей. Обобщённые структуры означают, что неизвестные и пользовательские ресурсы (CRD) никогда не сломают парсер. Компромисс в том, что проверки должны аккуратно обходить структуру с помощью небольших вспомогательных функций (asMap, asSlice, str, boolVal), которые безопасно обрабатывают отсутствующие или неправильно типизированные поля.
Нормализация спецификации Pod. Один вспомогательный метод, podSpecFromDoc, скрывает разницу между голым Pod (spec) и рабочей нагрузкой (spec.template.spec). Все проверки уровня Pod вызывают его, не зная о каждом конкретном виде рабочей нагрузки.
Устойчивость к ошибкам по замыслу. Если файл не является валидным YAML, инструмент тихо пропускает его и продолжает. При работе с одним файлом это может удивить. При обработке десяти тысяч чартов это необходимо: один сломанный файл не должен остановить сканирование остальных 9 999. Имейте в виду: файл, не создавший ни одной строки, возможно, просто не распарсился.
Минимальный след. Весь инструмент — несколько сотен строк кода на Go и одна зависимость. Его можно прочитать целиком за полдня. Для инструмента безопасности возможность аудита — это тоже функция.
Честные ограничения
Хороший инструмент безопасности честно говорит о том, чего он не делает. Вот острые углы, чтобы вы не доверяли 0 больше, чем следует.
SEC_CONT_OVER_PRIVIL и VALID_TAINT_SECRET — заполнители. Они есть в схеме, но текущий код никогда не устанавливает их. Они всегда равны 0. Это значит, что helmsniff пока не обнаруживает контейнеры с privileged: true через этот столбец. Это самая важная оговорка: не читайте 0 в SEC_CONT_OVER_PRIVIL как «привилегированных контейнеров нет». Проверка просто не реализована. (Столбцы сохранены для совместимости с более старой цепочкой инструментов и для будущей работы.)
NO_SECU_CONTEXT проверяет только наличие, а не качество. Как показано выше, контейнер с ужасным securityContext всё равно даёт 0 в этом столбце. Всегда читайте его вместе со столбцами возможностей и привилегий.
INSECURE_HTTP — поиск подстроки. Он тщательный, но буквальный. Не анализирует URL и не понимает контекст. Ожидайте шума.
NO_DEFAULT_NSPACE помечает и объекты уровня кластера. Многие ресурсы намеренно опускают metadata.namespace, полагаясь на namespace, заданный при установке. Воспринимайте 1 как «проверь», а не «ошибка».
Инструмент сканирует то, что Helm отрендерил с вашими значениями. Если опасная настройка появляется только с другим values.yaml, отрендерите и эту комбинацию тоже. Инструмент видит только тот YAML, который вы ему передали.
Один чарт — один процесс; параллелизм внешний. Один очень большой чарт сканируется в одном потоке. Масштабирование достигается запуском множества процессов через parallel, а не внутренней многопоточностью.
Инструмент находит, но не исправляет. Нет автоматического исправления и нет встроенного CI-шлюза. Это вы выстраиваете вокруг него сами.
Ни одно из этих ограничений не делает инструмент менее полезным. Они просто говорят о том, как правильно читать его вывод. В безопасности знать границы своего инструмента — это половина работы.
Добавление собственной проверки
Поскольку кодовая база небольшая, добавить проверку — значит пройти пять шагов по устоявшемуся шаблону. Допустим, вы хотите по-настоящему обнаруживать привилегированные контейнеры (и наконец реализовать эту идею).
Добавьте поле в структуру Row в internal/scanner/row.go:
SecContOverPrivil int \`json:"sec_cont_over_privil"\`
(Это поле уже существует — нужно лишь его подключить. Для совершенно новой проверки добавьте новое поле.)
Напишите функцию проверки в internal/scanner/security_checks.go. Следуйте устоявшемуся стилю: принимать структуру (map), обрабатывать nil, возвращать 1 или 0.
func hasPrivileged(spec map[string]interface{}) int {
if spec == nil { return 0 }
for _, c := range containersFromSpec(spec) {
sc := asMap(asMap(c)["securityContext"])
if boolVal(sc["privileged"]) { return 1 }
}
return 0
}
Вызовите её в AnalyzeFile (и AnalyzeReader) внутри internal/scanner/scanner.go:
row.SecContOverPrivil = hasPrivileged(ps)
Добавьте заголовок столбца в internal/config/constants.go (только для совершенно нового столбца; порядок должен совпадать).
Добавьте его в срез записей CSV в cmd/main.go, чтобы значение записывалось на нужной позиции.
Затем запустите тесты через make test — и готово. Порядок столбцов — единственное правило, которое необходимо соблюдать: список заголовков, срез записей и структура должны оставаться выровненными, иначе CSV сдвинется.
Место helmsniff среди других инструментов
Существует множество инструментов безопасности для Kubernetes: kubesec, kube-score, checkov, kube-linter, trivy и другие. Большинство из них богаче helmsniff: у них есть уровни серьёзности, советы по исправлению, языки политик и красивый вывод в терминале. Если ваша цель — «заблокировать плохой Deployment в CI» или «дать разработчику список задач на исправление», лучше воспользоваться одним из них.
Зачем тогда использовать helmsniff? Его ниша — исследования в масштабе. Он делает одну вещь, которую большие инструменты не умеют хорошо: превращает тысячи чартов в одну чистую числовую таблицу, которую можно загрузить в pandas и исследовать. Вывод намеренно скучный. Скучные столбцы 0/1 — это именно то, что нужно для вопросов вроде:
-
«Насколько распространена каждая мисконфигурация по всему этому публичному реестру?»
-
«Выросло или упало использование host-network между двумя снимками репозитория чартов?»
-
«Какие 1% чартов наиболее опасны по количеству красных флагов?»
Для таких вопросов небольшой, быстрый, узкоспециализированный и поддающийся аудиту инструмент лучше тяжёлого. Используйте большие сканеры для блокировки и исправления. Используйте helmsniff для измерений и исследований.
Рекомендуемый исследовательский процесс
Вот как это выглядит в работе от начала до конца:
# 1. Отрендерить каждый собранный чарт.
for chart in charts/*/; do
name=$(basename "$chart")
mkdir -p "rendered/$name"
helm template "$name" "$chart" > "rendered/$name/manifest.yaml" 2>/dev/null
done
# 2. Просканировать все параллельно, по одному CSV на чарт.
parallel ./bin/helmsniff --root {} --out reports/{/}.csv ::: rendered/*/
# 3. Объединить в одну таблицу.
head -n 1 "$(ls reports/*.csv | head -n 1)" > all.csv
for f in reports/*.csv; do tail -n +2 "$f" >> all.csv; done
# 4. Проанализировать в pandas (см. скрипт выше), найти худшие чарты,
# затем открыть их вручную и подтвердить находки.
Шаг 4 — самый важный. helmsniff не даёт доказательств эксплуатации. Он даёт карту того, куда смотреть. Совпадение http:// может оказаться безобидным. Отсутствующий namespace — допустимым. Но чарт, у которого горят hostPID, Docker-сокет, SYS_ADMIN и неограниченный profil seccomp одновременно? Он заслуживает вашего полного внимания. Задача инструмента — быстро указать именно на него среди стога, слишком большого для ручного поиска.
Исследования, лежащие в основе helmsniff
helmsniff — не просто скрипт, написанный за выходные. Он вырос из моей магистерской диссертации, в которой я изучал мисконфигурации безопасности Kubernetes и Helm в масштабе. Диссертация объясняет, почему были выбраны именно эти проверки, как часто встречается каждая проблема в реальных чартах и что измерения говорят о состоянии безопасности чартов Helm в реальном мире. Инструмент — это практический движок того исследования: именно он превратил тысячи чартов в числа, которые анализируются в диссертации.
Если хотите получить полный контекст — методологию, датасет и подробные результаты — можно прочитать и скачать полный PDF диссертации здесь:
Рекомендую прочитать её, если вы планируете использовать helmsniff для серьёзных исследований: там гораздо подробнее изложены обоснование каждой проверки и ограничения подхода — глубже, чем позволяет формат статьи.
Заключение
helmsniff маленький намеренно. Он ничего не рендерит, ничего не исправляет и ни с чем не соединяется. Он читает Kubernetes YAML и выдаёт чистую таблицу сигналов безопасности. Эта узкая направленность и есть его сила.
Если вы разработчик — запустите его на своём чарте перед выкаткой. Первая строка с единицами сразу откроет глаза. Если вы исследователь безопасности — направьте его на целый реестр, загрузите CSV в pandas и пусть данные сами покажут, какие чарты заслуживают более глубокого изучения.
Одна и та же закономерность встречается снова и снова в безопасности: опасность редко заключается в одной экзотической уязвимости. Это груда маленьких, скучных настроек по умолчанию, оставленных неправильными и повторяющихся в тысячах чартов. Простой сканер, который считает эти настройки — честно и в масштабе — полезнее, чем кажется.
Отрендерили. Просканировали. Прочитали таблицу. Теперь идите смотреть на чарты, которые напугали инструмент.
Ссылки: код находится на github.com/VahidR/helmsniff, а полное исследование — в моей магистерской диссертации, доступной на Google Scholar.
По вопросам и комментариям пишите 📧 на почту.