Сканер нашёл уязвимость в библиотеке, которую вы не подключали
В сборке сканер зависимостей начал ругаться на пакет, которого я не находил в проекте. Я открыл файл зависимостей, поискал по имени - нет его там. Поискал по всему репозиторию - нет. Импорта в коде тоже нет.
Первая версия была, что сканер смотрит не на тот проект. Вторая - что он тянет базу с ошибкой. Обе неверные. Пакет действительно был установлен, просто его туда никто не клал руками.
Один объявленный пакет, пять установленных
Проверяется это за одну команду. Файл зависимостей содержит ровно одну строку:
$ cat requirements.txt
requests==2.31.0
$ pip install -r requirements.txt
$ pip freeze
certifi==2026.7.22
charset-normalizer==3.5.1
idna==3.19
requests==2.31.0
urllib3==2.7.0
Объявлен один пакет, установлено пять. Четыре из них - зависимости requests: HTTP-транспорт, разбор кодировок, разбор международных имён, набор корневых сертификатов. Все они попадают в ваш процесс, исполняются в нём и обрабатывают те же данные.
Это не особенность Python. В экосистемах, где пакеты мельче - в первую очередь в npm, - дерево куда глубже: одна строка в манифесте разворачивается в сотни установленных пакетов, и это обычная, а не аварийная картина.
Ваш код - это всё дерево, а не его корень
Здесь и лежит расхождение между тем, как разработчик думает о проекте, и тем, что реально работает на сервере.
Разработчик думает списком: вот пакеты, которые я выбрал, прочитал про них, доверяю авторам. Работает - граф: выбранные пакеты плюс всё, что они привели с собой, плюс всё, что привели те.
Уязвимость в разборе кодировок, в клиенте HTTP, в парсере дат - это уязвимость в вашем приложении. Тот факт, что вы этот пакет не выбирали и его имени не знали, не меняет ни то, где он исполняется, ни то, к каким данным у него доступ.
Сканер, в отличие от вас, читает граф. Поэтому он и «находит то, чего нет».
Как это выглядит в реальном отчёте
Возьмём такой же проект, но со старым закреплённым urllib3 - типичная картина для окружения, которое не пересобирали год. Выгрузим состав окружения в файл и прогоним его через pip-audit: это официальный инструмент, который сверяет версии из такого файла с базой уязвимостей.
$ pip freeze > installed.txt
$ pip-audit -r installed.txt
Found 13 known vulnerabilities in 2 packages
Name Version ID Fix Versions
-------- ------- --------------- -------------
requests 2.31.0 PYSEC-2026-1873 2.32.0
requests 2.31.0 PYSEC-2026-1872 2.32.4
requests 2.31.0 PYSEC-2026-2275 2.33.0
urllib3 1.26.5 PYSEC-2023-192 1.26.17,2.0.6
urllib3 1.26.5 PYSEC-2023-192 1.26.17,2.0.6
urllib3 1.26.5 PYSEC-2023-212 1.26.18,2.0.7
urllib3 1.26.5 PYSEC-2023-212 1.26.18,2.0.7
urllib3 1.26.5 PYSEC-2026-141 2.7.0
urllib3 1.26.5 PYSEC-2026-1999 2.5.0
urllib3 1.26.5 PYSEC-2026-1998 2.6.0
urllib3 1.26.5 PYSEC-2026-1995 1.26.19,2.2.2
urllib3 1.26.5 PYSEC-2026-1994 2.6.0
urllib3 1.26.5 PYSEC-2026-1996 2.6.3
Вывод приведён целиком, включая две записи, которые продублировались, - они пришли из двух источников базы. Уникальных уязвимостей тут одиннадцать: три в requests и восемь в urllib3. При этом в файле зависимостей упомянут только requests, а бо́льшая часть находок - в urllib3, который туда попал сам.
Почему обновить прямую зависимость не всегда достаточно
Здесь я и застрял в первый раз. Логика была такая: urllib3 приехал вместе с requests, значит, обновлю requests - подтянется и новый urllib3.
Иногда так и происходит. Но условие в манифесте пакета выглядит как диапазон, а не как точная версия: «мне нужен urllib3 не ниже такой-то и ниже такой-то». Пока установленная версия попадает в диапазон, менеджер пакетов её не заменяет: условие уже выполнено. Обновление корня само по себе ничего не гарантирует.
Прямой способ - задать транзитивному пакету собственное требование. В Python это отдельный файл ограничений, в других экосистемах - секция переопределений в манифесте. Механизм есть везде, называется по-разному.
Порог берётся по максимальной из версий в колонке Fix Versions - среди строк того пакета, который вы чините, а не по первой попавшейся. Для urllib3 самая старшая там 2.7.0, и меньшее значение оставит часть находок открытыми:
$ cat constraints.txt
urllib3>=2.7.0
$ pip install -r requirements.txt -c constraints.txt
Здесь важно различать два случая, и они сильно разные по цене.
Если нужная версия попадает в диапазон, который объявила прямая зависимость, - а requests требует urllib3 <3,>=1.21.1, то есть 2.7.0 туда попадает, - вы просто подняли нижнюю границу внутри разрешённого. Это безопасно, и именно так работает файл ограничений.
Если же исправление вышло за верхнюю границу, которую объявил корневой пакет, - файлом ограничений вы уже не обойдётесь, нужен явный обход, и вместе с ним вы берёте на себя ответственность за совместимость. Границу объявили не просто так, и поломка вылезет в проде. Такой обход - временная мера до выхода версии корневого пакета с исправленным требованием, и у неё должен быть срок.
Почему без фиксации версий весь разговор бессмыслен
Всё вышесказанное имеет смысл, только если состав дерева воспроизводим. Если в файле зависимостей стоят диапазоны, то сборка в понедельник и сборка в пятницу дадут разные версии транзитивных пакетов, и результат вчерашней проверки к сегодняшней сборке отношения не имеет.
Отсюда простое требование: в сборку идёт зафиксированный полный список - lock-файл или его эквивалент, где перечислены все пакеты дерева с точными версиями. Проверяется этот же файл.
Следующий уровень - фиксация не версий, а содержимого: хеш каждого файла пакета. Тогда подмена уже опубликованной версии в репозитории пакетов ломает установку, а не проходит незаметно. В Python это режим --require-hashes, который надо включить явно. В npm и в современных менеджерах для Python на базе lock-файлов контрольные суммы пишутся в lock сами и проверяются при установке - там достаточно не выкидывать lock-файл из репозитория.
Ограничения
Список уязвимостей - это список известных уязвимостей, то есть тех, для которых кто-то оформил запись в базе. Чистый отчёт означает «ничего не заявлено», а не «ничего нет».
Большая часть находок в дереве неприменима: уязвимость в функции, которую ваш код не вызывает ни прямо, ни через библиотеку. Разбор этого стоит времени, и тратится оно каждую неделю. Инструменты, рассчитывающие достижимость кода, существуют, но ошибаются в обе стороны, и я бы не выключал по их вердикту ничего важного.
Отсюда практическое: не выставлять сборке порог «ноль находок». Такой порог живёт до первой срочной выкатки, после чего его отключают и больше не включают. Разумнее делить по критичности и по тому, доступна ли уязвимая функция извне.
И SBOM - перечень состава сборки - сам по себе ничего не защищает. Его ценность в другом: когда завтра объявят уязвимость в пакете из глубины дерева, вопрос «а он у нас есть и где» должен решаться запросом к списку, а не обходом репозиториев руками.
Что посмотреть у себя
Сравнить число строк в файле зависимостей с числом реально установленных пакетов. Разница в разы - норма, и она же ответ на вопрос, почему сканер находит незнакомые имена.
Проверить, есть ли в сборке фиксация всего дерева, а не только прямых зависимостей. Если менеджер пакетов при повторной сборке может выбрать другую версию - воспроизводимости нет.
Прогнать pip-audit (или аналог для своей экосистемы) на зафиксированном составе окружения и посмотреть, сколько находок приходится на пакет, которого нет в манифесте. Обычно это большинство.
Найти, кто у вас смотрит эти отчёты и что делает по итогам. Отчёт, который формируется и никем не открывается, - самая частая находка в этой теме, и она не техническая.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.