Стояла задача простая и ясная как летний день - сканировать Docker-образы в своём registry.
Но по мере работы стали проявляться нюансы, которые как Газманов забирали этот ясный день:
Тормозной и неудобный API Nexus (частная проблема, решается использованием Registry API);
Большое количество и объем образов (3к+ образов и более 1.5Tb объема);
Не все используют тэг latest;
В роли сборщика репортов не подходил DefectDojo.
Intro
Info
Тут не будет готового решения, скорей концептуальное описание пройденного пути.
Так же примеры кода будут в виде shell-скриптов, так как с языками программирования у меня не всё просто, а так же многое завязано за jq.
В статье будет про Nexus в роли репозитория, но основные моменты могут быть применимы и к другим решениям.
Выбор тулкита
Какие требования были к сканеру:
Сканирование образов Registry;
Сканирование Dockerfile в gitlab-репозитории и локально;
Open-Source (Можно было бы всё купить, но обходимся тем, что есть);
Отчёты в приемлемом виде (json, опционально html);
Интеграция в CI/CD (скорей задел на будущее).
Варианты выбора
Сканеры на выбор есть, но нельзя сказать, что их много.
grype
grype - ранее известный как Anchore-Engine. Сканер уязвимостей для образов контейнеров и файловых систем, не сканирует git-репозиторий. Возможности:
Сканирование содержимого образа контейнера или файловой системы для поиска известных уязвимостей.
Обнаруживает уязвимости для распространенных систем
Обнаруживает уязвимости в пакетах языков программирования
dockle - линтер \ сканер. Ищет CVE в ПО образа, проверяет корректность и безопасность конкретного образа, анализируя его слои и конфигурацию. Используется CIS Benchmark. Работает как отдельно, так и интегрируется с GitLab CI, Jenkins.
docker-bench-security
docker-bench-security - набор bash скриптов для проверки безопасности конфигурации образов и контейнеров. docker-bench-security больше рассчитан на интеграцию, standalone сканирование в целом есть, но не без страданий в работе.
Могут быть проблемы с интерпретацией вывода, так как используется CIS Benchmark. Давно не обновлялся, последняя версия от 20 декабря 2023 г.
Clair
Clair - проект для статического анализа уязвимостей в контейнерах приложений (в настоящее время включает OCI и Docker). Клиенты используют API Clair для индексации своих образов контейнеров и затем могут сопоставить их с известными уязвимостями.
Проблемно разворачивается, не очень удобен в использовании.
Harbor
Harbor - open source Docker Registry с безопасностью «из коробки». Под капотом уже имеет trivy, всё круто-здорово, но это Registry, так что не подходит.
Dagda
Dagda - инструмент для статического анализа известных уязвимостей, вредоносного ПО и других угроз в образах/контейнерах, а также для мониторинга демона docker и запущенных docker контейнеров для выявления аномальных действий.
Давно не обновлялся, последний релиз Jul 27, 2021
Trivy
Trivy - довольно известный сканер от Aqua Security. Находит уязвимые версии софта в зависимостях, секреты, мисконфиги. Из удобств: можно использовать как standalone сканирование, например проверить репозиторий целиком, так и интегрировать в CI/CD (GitLab CI, GitHub Actions). Есть плагин для VSCode и плагин для интеграции с Vulners.
Итоговый выбор
В итоге выбор пал на Trivy. Функционал trivy позволяет запускать его автоматизированно, сканировать как контейнеры, так и git-репозитории. Данные будут передаваться в одно место для хранения\агрегации\обработки, поэтому не требуется использовать сложный и комплексный инструмент, вроде Dagda или Clair.
Плагин trivy-plugin-vulners-db позволяет обогащать данными сам отчёт (EPSS, CVSS, наличие эксплоитов, экслуатация “in-the-wild”, работает так себе, но есть).
Плагин требует API-ключ для получения обновления баз, однако он запрашивается только при загрузке, а не каждом сканировании.
Превозмогая трудности
Общие проблемы
Но то, что неплохо работает в небольшом скоупе, может очень плохо масштабироваться. Либо делать это с компромиссами.
Сравнительное удобство Trivy имеет ряд своих компромиссов, одним из которых является boltDB. Сам boltDB является хранилищем на основе “ключ-значение”, и из-за его ограничений параллельная работа Trivy по сути не работает.
Bolt obtains a file lock on the data file so multiple processes cannot open the same database at the same time. Opening an already open Bolt database will cause it to hang until the other process closes it.
Из коробки Trivy нельзя скормить список образов, но в целом это тоже решается, пускай и циклом на Bash.
А так же в дальнейшем вскрылся неприятный нюанс: плагин trivy-plugin-vulners-db EPSS не отдаёт. Этот вопрос решаем, но нюанс неприятный.
Прикладное велосипедостроение
Получаем образы и тэги
Объемы большие, поэтому просто сканировать по очереди будет долго. Образы будем пуллить. Для начала нам нужно получить список образов с последним тэгом:
Тут стоит сделать небольшую сноску. Я использую опицию --ignore-policy, так как я дальше обрабатываю данные от плагина vulners. В не-CVE уязвимостях может быть описание в markdown, что вызывает проблемы.
Если вы в дальнейшем не планируете обрабатывать его, то можно ignore policy не использовать.
На выходе сканирования мы получаем довольно массивные JSON-файлы с множеством полезной и не очень информации. Референсные ссылки, информацию о слоях и прочую крайне полезную информацию отправлять в какой-то коллектор смысла нет. А так же вспоминаем тот факт, что EPSS нам нужно добавить самостоятельно.
Так что чистим и обогащаем наши репорты. Для начала нужно отформатировать Description, которое получаем от плагина vulners:
# Форматирование тела JSONecho"$(date): Format JSON Body: Start"# Форматирование и обрезка лишнегоfor filename in $(ls $tmp_dir);do jq '{
SchemaVersion,
RepoTags: .Metadata.RepoTags[0],
RepoDigests: .Metadata.RepoDigests[],
Vulnerabilities: [
.Results[].Vulnerabilities[]? | {
Title,
VulnerabilityID,
Severity,
PkgName,
PkgPath,
InstalledVersion,
FixedVersion,
Status,
Cvss2_Score: (if (.Description | type) == "object" then .Description.Cvss2?.Score else null end),
Cvss3_Score: (if (.Description | type) == "object" then .Description.Cvss3?.Score else null end),
Epss,
Epss_percent,
VulnersScore: (if (.Description | type) == "object" then .Description.VulnersScore?.Value else null end),
WildExploited: (if (.Description | type) == "object" then .Description.WildExploited else null end),
ExploitsCount: (if (.Description | type) == "object" then .Description.ExploitsCount else null end),
Vulners_Description: (if (.Description | type) == "object" then .Description.Description else null end),
Href: (if (.Description | type) == "object" then .Description.Href else null end),
}
] | unique
}'"$tmp_dir"/"$filename" > "$tmp_dir"/"$filename".tmp && mv "$tmp_dir"/"$filename".tmp "$tmp_dir"/"$filename"doneecho"$(date): Format JSON Body: Done"echo"$(date): Enrich process: Start"# Создаем кэш-файл, если он не существуетtouch "$cve_cache_file"# Функция для получения EPSS с кэшированиемfetch_epss(){localcve_id=$1localcached=$(grep -E "^$cve_id\s""$cve_cache_file"| awk '{print $2}')if[ -n "$cached"];thenecho"$cached"elselocalapi_response=$(curl -s "https://api.first.org/data/v1/epss?cve=$cve_id")localepss_value=$(echo"$api_response"| jq -r '.data[]?.epss // "null"')echo"$cve_id$epss_value" >> "$cve_cache_file"echo"$epss_value"fi}# Функция обработки одного файлаprocess_file(){localfilename=$1localresults=()# Проверяем, что файл существуетif[ ! -f "$filename"];thenecho"Ошибка: файл $filename не найден."returnfi# Извлекаем данные одним вызовом jqmetadata=$(jq -r '{
cve_ids: [.Vulnerabilities[].VulnerabilityID // empty],
repotags: (.RepoTags // empty),
repodigests: (.RepoDigests // empty),
namespace: (.RepoTags | if . then split("/")[1] else null end)
}'"$filename")if[$? -ne 0];thenecho"Ошибка обработки JSON файла $filename с помощью jq."returnficve_ids=$(echo"$metadata"| jq -r '.cve_ids[]')repotags=$(echo"$metadata"| jq -r '.repotags')repodigests=$(echo"$metadata"| jq -r '.repodigests')namespace=$(echo"$metadata"| jq -r '.namespace')# Обработка каждого CVE IDfor cve_id in $cve_ids;do# Получаем значение EPSSepss_value=$(fetch_epss "$cve_id")# Устанавливаем значение по умолчанию, если epss_value пустое или "null"if[[ -z "$epss_value"||"$epss_value"=="null"]];thenepss_value=0fi# Рассчитываем процентное значениеepss_percent=$(awk "BEGIN {printf \"%.2f\", $epss_value * 100}")# Генерация JSON processed_json=$(jq --arg cve "$cve_id"\
--arg Source "nexus"\
--arg epss "$epss_value"\
--arg Epss_percent "$epss_percent"\
--arg repotags "$repotags"\
--arg repodigests "$repodigests"'
.Vulnerabilities[] | select(.VulnerabilityID == $cve) |
{"reposcanner": {
Source: $Source,
RepoTags: $repotags,
RepoDigests: $repodigests,
TeamId: $teamId,
Title,
VulnerabilityID: $cve,
Severity: .Severity,
PkgName: .PkgName,
PkgPath: .PkgPath,
InstalledVersion: .InstalledVersion,
FixedVersion: .FixedVersion,
Status: .Status,
Cvss2_Score: .Cvss2_Score,
Cvss3_Score: .Cvss3_Score,
Epss: ($epss // "custom_null"),
Epss_percent: $Epss_percent,
VulnersScore: .VulnersScore,
WildExploited: .WildExploited,
ExploitsCount: .ExploitsCount,
Vulners_Description: .Vulners_Description,
Href: .Href
}}
'"$filename")if[ -n "$processed_json"];thenresults+=("$processed_json")elseecho"Не удалось обработать данные для $cve_id."fidone# Объединяем результаты в один JSONif[${#results[@]} -eq 0];thenecho"Не найдено результатов для обработки файла $filename."elsefinal_output=$(printf'%s\n'"${results[@]}"| jq -s '.')output_file="$json_dir/$(basename "$filename" .json).json"echo"$final_output" > "$output_file"fi}# Параллельная обработка всех файловexport -f fetch_epss process_file
export json_dir cve_cache_file
find "$tmp_dir" -type f | parallel -j "$(nproc)" process_file {}echo"$(date): Enrich process: Done"
Дальнейшие действия уже зависят от того, куда мы будем полученные репорты отправлять. Например, если нам нужен JSONL, мы можем с помощью jq репорты без проблем преобразовать:
1
2
3
4
5
6
7
8
9
# JSON to JSONLecho"$(date): Convert JSON to JSONL: Start"# Читаем исходный JSON из файлаfor filename in "$json_dir"/*;do# преобразовать JSON в JSONL jq -c '.[]'$filename > $jsonl_dir/$(basename "$filename" .json).jsonl
doneecho"$(date): Convert JSON to JSONL: Done"
Итоги
Образы спуллены и отсканированы, репорты отформатированы и обмазаны EPSS. Дальше можно отправлять всё в коллектор, будь то Elastic, Graylog, DataDog, в какое-то кастомное решение или просто читать перед сном визуализировать в чём-то.
Хорошее ли это решение? Скорей нет. Это громадный и медленный костыль с кучей точек отказа. Жизнеспособное ли это решение? В целом, да. Если собирать и обрабатывать репорты где-то, с этим можно работать. Если денег нет, а скоуп небольшой, особых проблем быть не должно.
И самое главное: если нет процесса patch management, то никакие сканеры ситуацию не исправят.