Любой кластерный сканер рано или поздно обрастает скрытыми уязвимостями (suppressions). Что-то помечается как проблема, команда решает, что пока это приемлемо, и алерт замолкает. Интересный вопрос не в том, можно ли заглушить проверку. Вопрос в том, есть ли у этой тишины срок годности.

Я провел вечер за плагином KubeBuddy для Headlamp, и именно эта фича оказалась достойной отдельного поста.

Что такое KubeBuddy

Это плагин для Headlamp, который прогоняет каталог из 137 проверок (RBAC, безопасность, сеть, рабочие нагрузки, хранилища) по активному кластеру и выдает взвешенную оценку безопасности.

Архитектурное решение, которое имеет значение: в сам кластер ничего не разворачивается. Никаких операторов, CRD или агентов. Плагин оценивает данные ресурсов, которые Headlamp и так считывает с использованием ваших собственных учетных данных. Trivy Operator и Kubescape требуют установки внутрикластерных компонентов, что означает деплой, запрос на расширенные права и, в клиентской среде, долгие согласования.

Для консалтинга это главное преимущество. Я могу провести аудит клиентского кластера, не прося ни у кого разрешения на деплой.

Механизм подавления (suppression)

Lifecycle of a suppressed finding A finding starts as detected and visible in the findings list. When annotated with ignore-until, it is suppressed and moved to the suppressedFindings array. When the deadline passes, it expires and automatically returns to the active findings list. moved to suppressedFindings array detectedin findings list suppressedignore-until future expireddeadline passed visibleback in findings list returns to active findings automatically
The expiration is evaluated on demand when the report is generated, against the current wall clock time.

Три аннотации на ресурсе:

kubebuddy.io/ignore-checks
kubebuddy.io/ignore-reason
kubebuddy.io/ignore-until

В документации утверждается, что ignore-until истекает. Это утверждение стоит проверить, потому что слово “истекает” может означать многое: аннотация игнорируется, ошибка возвращается в список, или же где-то появляется предупреждение, которое никто никогда не читает.

Вот фактический код из бандла плагина, с восстановленными минифицированными именами переменных (ja = ignore-checks, za = ignore-reason, Ga = ignore-until):

// истек ли срок?
function vl(s){
  const e = s.trim();
  if (!e) return false;
  const t = Date.parse(e);
  return Number.isFinite(t) && Date.now() > t;
}

// следует ли скрыть эту ошибку?
function bl(s, e){
  const t = e.sourceAnnotations || {},
        n = String(t[ja] || "").trim();
  if (!n || !yl(n, s)) return null;         // проверки нет в списке игнорируемых
  const i = String(t[Ga] || "").trim();
  return i && vl(i)
    ? null                                   // срок истек, не скрывать
    : { reason: String(t[za] || "").trim(), until: i };
}

Из этих пятнадцати строк следуют три вывода:

  1. Дедлайн сравнивается с реальным временем (wall clock time). Если дата в прошлом, возвращается null, и ошибка снова считается активной.
  2. Скрытые ошибки не удаляются. Они перемещаются в отдельный массив suppressedFindings, помеченные как suppressionReason и suppressionUntil.
  3. ignore-checks принимает список, разделенный запятыми, пробелами или точкой с запятой, и * для всех проверок. Регистр не имеет значения.

Проверка на реальном кластере

Чтение кода не доказывает механику. Десять минут на kind показывают реальность.

kind create cluster --name kb

npx @kinvolk/headlamp-plugin install \
  https://artifacthub.io/packages/headlamp/kubebuddy/kubebuddy-headlamp-plugin \
  --folderName ./plugins/kubebuddy

Один нюанс, которого нет ни в одной документации: Headlamp ожидает структуру plugins/<name>/main.js, а CLI создает лишний уровень вложенности директорий. Пока вы его не уберете, /plugins будет возвращать пустой список, и плагин никогда не появится в боковом меню.

Далее три деплоймента. Один без аннотаций в качестве контрольного, два других с исключениями по разные стороны от сегодняшней даты:

kubectl create deploy nginx-bad    --image=nginx:latest
kubectl create deploy sup-active   --image=nginx:latest
kubectl create deploy sup-expired  --image=nginx:latest

# исключение действительно до 2030 года
kubectl annotate deploy sup-active \
  kubebuddy.io/ignore-checks='*' \
  kubebuddy.io/ignore-reason='approved by security, ticket SEC-42' \
  kubebuddy.io/ignore-until='2030-01-01T00:00:00Z'

# срок исключения истек в 2020 году
kubectl annotate deploy sup-expired \
  kubebuddy.io/ignore-checks='*' \
  kubebuddy.io/ignore-reason='temporary waiver' \
  kubebuddy.io/ignore-until='2020-01-01T00:00:00Z'
deployment.apps/nginx-bad created
deployment.apps/sup-active created
deployment.apps/sup-expired created
deployment.apps/sup-active annotated
deployment.apps/sup-expired annotated

Сканирование показало оценку 87 из 100 по 146 проверкам. 31 тест провален, причем RBAC002 RBAC Overexposure оказался на самом верху, потому что я также создал привязку cluster-admin, чтобы сканеру было что находить.

На карточке WRK005 Missing Resource Requests написано 2 findings и 1 suppressed. Список ниже выглядит так:

DeploymentВ списке ошибокПочему
nginx-badданет аннотаций
sup-expiredдаисключение истекло в 2020
sup-activeнет, скрытаисключение до 2030

Итак, ошибка с истекшим сроком действительно возвращается. Не как предупреждение где-то в логах, а прямо в списке проблем, куда вы обычно смотрите.

А скрытая ошибка не выбрасывается:

{
  "resource": "sup-active",
  "kind": "Deployment",
  "details": "Container nginx missing CPU request, MEMORY request",
  "suppressionReason": "approved by security, ticket SEC-42",
  "suppressionUntil": "2030-01-01T00:00:00Z",
  "link": "/c/kind-kb/deployments/default/sup-active"
}

То же самое происходит с восемью другими проверками одновременно, потому что в аннотации использовалась *.

Почему именно эта часть имеет значение

Подавление уязвимости без даты окончания становится решением, которое переживает свою причину. Рабочая нагрузка в конце концов получает свои лимиты, устаревший сервис выводится из эксплуатации, человек, одобривший исключение, увольняется, а аннотация остается. Сканер продолжает молчать о проблеме, которую уже никто не считает допустимой, и оценка продолжает показывать здоровье, которого не существует.

Отсутствие возможности подавления вообще выглядит гораздо честнее. Это раздражает, но не врет.

Исключение с обязательным обоснованием и дедлайном остается единственной версией, которая остается правдивой: она превращает “мы приняли этот риск” в “мы приняли этот риск до марта”, а после марта система сама спросит снова.

На что обратить внимание

Оценка меняется по неверным причинам. Любой инструмент с большой цифрой на главной странице провоцирует людей “подкручивать” её самым дешевым способом. ignore-checks: '*' на одном деплойменте убрал восемь ошибок одной аннотацией. Если такие аннотации не проверяются на код-ревью (pull requests), метрика деградирует в самообман за пару месяцев.

Сканирование умирает при уходе со страницы. Плагин регистрирует обработчик beforeunload с сообщением KubeBuddy is still scanning. Leave this page and the scan will stop. Это осознанно сделано для ревью по запросу, а не для постоянного мониторинга. Непрерывный контроль требует использования CLI в CI/CD пайплайне.

Покрытие плагина не равно покрытию CLI. Здесь нет проверок AKS или GKE, требующих облачных API, нет проверок Prometheus, нет нативного Go-движка. Сравнение отчета плагина с отчетом CLI покажет ложные расхождения по проверкам, которых в плагине просто нет. Сравнивайте только пересекающиеся ID проверок.

История оценок живет в вашем браузере. Ключ kubebuddy:score-history:<cluster> лежит в localStorage, помеченный как Local cache only в UI. Другой браузер, другой инженер, очистка данных сайта, и тренд исчезает.

Скрытые ошибки работают как счетчик, а не список. Раскрытая карточка показывает 1 suppressed без подробностей. Причина и дедлайн находятся в полном отчете и экспорте. Если вы выстраиваете процесс ревью вокруг этого, проверяйте экспорт, а не экран.

Безопасно ли запускать сам инструмент

Полезно уметь отвечать на этот вопрос с доказательствами, а не ссылкой на маркетинговую страницу, особенно перед тем как натравливать инструмент на чужой кластер.

# мутирующие запросы
grep -oE '"(POST|PUT|PATCH|DELETE)"' main.js | sort | uniq -c

# любые собственные сетевые вызовы
grep -oE 'fetch\(|XMLHttpRequest|axios' main.js | sort | uniq -c

# куда он вообще обращается
grep -o 'https\?://[a-zA-Z0-9./-]*' main.js | sort -u

Первые две команды ничего не возвращают. Чтение идет через клиент API Headlamp, и бандл не имеет собственного сетевого кода. Третья возвращает только ссылки на документацию. Хостинг сервиса вендора упоминается ровно один раз, как строка внутри сгенерированного kubebuddy-config.yaml, в секции, которая отключена по умолчанию.

Итог: работает с вашими правами, только на чтение, ничего не деплоит и не имеет путей в коде для отправки результатов куда-либо. Три команды, и вы можете сказать это клиенту с невозмутимым лицом.

Очистка

kind delete cluster --name kb

Вывод

Если вы выбираете сканер кластера, смотрите не только на количество проверок. Спросите, что происходит с исключением через шесть месяцев после того, как кто-то его добавил. Этот ответ скажет вам, будет ли оценка на дашборде вообще что-то значить к тому времени.

Проверено на плагине версии 0.5.1, включающем проверки KubeBuddy из v0.0.36, на kind кластере, сентябрь 2026 года.