Зупиняє встановлення пакетів, яких не існує.
Мовні моделі вигадують назви бібліотек. Зловмисники реєструють ці назви наперед.
ghostpkg перевіряє назву в реальному реєстрі — до того, як pip або npm щось завантажать.
English · Українська
- Проблема
- Рішення
- Встановлення
- Використання
- Монорепо й приватні пакети
- Куди це вбудовується
- Що саме перевіряється
- Чому він не блокує «підозріле»
- Як це влаштовано всередині
- Заміряно — і не побудовано
- Порівняння
- Чесні обмеження
Дослідження ~200 000 запитів на генерацію коду виявило 205 474 унікальні вигадані назви пакетів. Найгірше не це, а ось що:
43 % галюцинацій повторювалися в усіх десяти повторних запусках того самого запиту.
Тобто вони передбачувані. Зловмисник може заздалегідь дізнатися, що саме вигадає модель,
зареєструвати цю назву в PyPI чи npm — і чекати. Коли ваш агент упевнено напише
pip install fastapi-middleware, пакет там уже буде.
Атака має назву — slopsquatting.
Код виходу — 1, якщо щось заблоковано. Тому інструмент без переробок стає на місце
в CI або в хук перед встановленням.
pip install ghostpkgІнші способи
uvx ghostpkg check requests # запустити без встановлення
pipx install ghostpkg # ізольовано, глобальна командаNote
Нуль залежностей. Тільки стандартна бібліотека Python. Інструмент безпеки ланцюга постачання, який сам тягне за собою дерево залежностей, — сумнівна ідея. Тут його немає.
# перевірити конкретні назви (можна із закріпленою версією)
ghostpkg check fastapi-middleware requests==99.99.99
# перевірити назву в npm
ghostpkg check react-router-dom-utils -e npm
# перевірити всі залежності з маніфесту
ghostpkg scan requirements.txt
ghostpkg scan pyproject.toml
ghostpkg scan package.json
# машинозчитуваний вивід
ghostpkg check somepkg --json
# видалити кеш
ghostpkg clear-cacheРезультати запитів кешуються на диск: повторна перевірка маніфесту зі 150 залежностей займає 0,4 с замість 4,7 с. Відповідь «пакета не існує» не кешується взагалі — це єдина відповідь, яка блокує, тож вона має бути свіжою в обидва боки. Вільне ім'я можуть зареєструвати будь-якої миті, і саме в цьому вся атака; а щойно опублікований пакет якийсь час віддає 404, бо стрічка PyPI оголошує його раніше, ніж його починає віддавати JSON API. Кешування цієї відповіді на годину вже дало справжнє хибне блокування живого пакета. Закріплена версія теж перевіряється за свіжим списком, перш ніж заблокувати.
| Прапорець | Призначення |
|---|---|
-e, --ecosystem |
pypi (типово) або npm. Лише для check — scan визначає екосистему з файлу |
--format |
text (типово), json, або github для анотацій на діф |
--config PATH |
Ignore-файл. Ніколи не читається з каталогу, який сканують |
--strict |
Підвищує попередження до блокувань |
--json |
Вивід у JSON для скриптів і CI |
-q, --quiet |
Ховає пакети, які пройшли перевірку |
--no-cache |
Не читати й не писати кеш |
--deep |
Завантажити свіжі пакети й статично перевірити їхні скрипти встановлення |
--workers N |
Скільки запитів паралельно (типово 8) |
--timeout SECONDS |
Тайм-аут на запит |
scan розпізнає requirements*.txt, pyproject.toml (PEP 621, PEP 735 і Poetry),
package.json, а також lock-файли: package-lock.json, yarn.lock
(classic і berry), pnpm-lock.yaml, poetry.lock, uv.lock.
Невідомий формат він відхиляє з помилкою, а не вгадує.
Lock-файли варто перевіряти навіть якщо ви вже перевіряєте маніфест: CI встановлює саме з них, тож там лежать імена, які справді завантажуються — включно з транзитивними, яких у маніфесті немає. І кожен запис має точну версію, отже перевіряється повністю.
Читає й прозу — README, AGENTS.md, CLAUDE.md, .cursorrules,
.windsurfrules, і будь-який .md, названий прямо. При пошуку в каталозі
беруться лише файли інструкцій для агентів та README у корені — інакше
великий репозиторій завалив би вивід чейнджлогами.
Це закриває проблему порядку: галюцинація з'являється раніше за маніфест. Модель
пише pip install foo-bar у README, людина копіює рядок — і встановлення вже відбулось,
коли назва тільки потрапляє в requirements.txt.
Читаються й команди, написані всередині речення, у зворотних лапках —
«Install using pip install -U pydantic». Три з чотирнадцяти популярних README
пишуть їх лише так, і читання цілих рядків не знаходило в них нічого.
Витяг навмисно вузький, бо README повний слів, схожих на назви пакетів. Замір на 22 реальних README: 18 назв, хибних спрацювань 0%.
Можна передати кілька файлів одразу, навіть із різних екосистем:
ghostpkg scan requirements.txt package.json pnpm-lock.yamlАбо просто вказати каталог — чи взагалі нічого, і тоді перевіриться поточний:
ghostpkg scan # шукає маніфести у поточному каталозі
ghostpkg scan ../інший-проєктПошук обходить node_modules, .venv, .git, dist та інші чужі й
згенеровані теки, а lock-файл витісняє маніфест поруч із собою — lock це
той самий маніфест, уже вирішений, тож читати обидва означає двічі надрукувати
ті самі пакети.
Залежність, яка сама називає своє джерело, публічного реєстру не стосується —
і ghostpkg її не шукає. Без цього правила монорепо перетворюється на стіну
хибних блокувань: замір на одному справжньому package.json дав шість із дев'яти.
| Як написано | Що відбувається |
|---|---|
"@acme/ui": "workspace:*", "catalog:default" |
Пропускаємо — вирішується всередині репозиторію |
"lib": "file:../lib", "link:", "portal:", "../sibling" |
Пропускаємо — тека на диску |
"forked": "git+https://...", "owner/repo", "github:owner/repo" |
Пропускаємо — git-хост |
"dep": "https://.../x.tgz" |
Пропускаємо — URL |
"ui": "npm:@scope/real@^2" |
Перевіряємо @scope/real — псевдонім встановлює не те, що в ключі |
internal @ git+https://... у requirements |
Пропускаємо |
[tool.uv.sources], Poetry { git = ... } / { path = ... } |
Пропускаємо |
[package.source] у poetry.lock, source = { git = ... } в uv.lock |
Пропускаємо |
Учасник робочої області або git-джерело в package-lock.json |
Пропускаємо |
Те саме правило закриває приватний реєстр. Artifactory, Nexus і Verdaccio
проксюють публічні імена і хостять власні під тим самим хостом, а lock-файл
не каже, що є що — тож такі записи ми лишаємо без судження, а не вгадуємо.
Публічні дзеркала (registry.yarnpkg.com, registry.npmmirror.com) перевіряємо
далі, бо вони віддають той самий публічний простір імен.
Це знайшлося, коли
ghostpkgнавели на власнийpyproject.tomlпроєктуpydantic— і він його заблокував.pydantic-docsоголошено в групі залежностей і перенаправлено на git через[tool.uv.sources]; на PyPI його немає, і називати це відсутньою залежністю було просто неправильно.
Єдине, що потрібно знати, щоб вбудувати інструмент у будь-який конвеєр.
| Код | Значення |
|---|---|
0 |
Перевірено, все гаразд |
1 |
Щось заблоковано |
2 |
Помилка: файл не читається, або реєстр недоступний. Не пропуск — назва, яку не вдалося перевірити, не є чистою |
3 |
Нічого не знайдено для перевірки. Окремо від нуля, бо маніфест, який ми не зрозуміли, раніше давав 0 і читався в CI як успіх |
$ ghostpkg scan requirements.txt --json
{
"schema": 1,
"tool": { "name": "ghostpkg", "version": "0.24.5" },
"summary": { "checked": 2, "blocked": 1, "warned": 0, "errored": 0 },
"findings": [
{
"name": "fastapi-auth-helper",
"ecosystem": "pypi",
"verdict": "BLOCK",
"source": "requirements.txt",
"line": 12,
"exists": false,
"latest_version": null,
"reasons": [
{ "rule": "GP001", "text": "does not exist on pypi" }
]
}
]
}schema дозволяє споживачеві зрозуміти, що саме він читає; source і line
кажуть, де назву написано. --format github замість цього видає анотації на
діф, а --format text — типовий вивід.
- uses: M1rwana12/ghostpkg@v0.24.5Це весь крок. Він шукає маніфести в чекауті, обходить node_modules і подібні
теки, і лишає анотацію просто на рядку в дифі pull request, а не відповідь
десь у логах:
::error file=requirements.txt,line=12,title=ghostpkg GP001::fastapi-auth-helper: does not exist on pypi
Блокувальна знахідка — помилка, м'який сигнал — попередження, тож анотації й код виходу не суперечать одне одному.
Необов'язкові входи: paths, strict, deep, version, install, config,
python-version, fail-on-error. config вказує на ignore-файл у самому
репозиторії — цей проєкт тримає свій у .github/ghostpkg-ignore.json, тож
придушення проходить рев'ю як звичайна зміна.
repos:
- repo: https://github.com/M1rwana12/ghostpkg
rev: v0.24.5
hooks:
- id: ghostpkgХук отримує лише ті staged-файли, що підходять, тож коштує один запит на змінену залежність, а не повний скан на кожен коміт.
| Сигнал | Вердикт |
|---|---|
| Немає в реєстрі | 🔴 Заблоковано. Назва — привид, встановлювати нічого. |
| ...і вона за один-два символи від популярної | 🔴 Заблоковано з підказкою: did you mean requests? Віковий фільтр, який стримує це порівняння в інших місцях, захищає легітимні пакети від звинувачення в опечатці — у неіснуючої назви захищати нічого, вона вже заблокована. Замір: правильно 11 з 11 на опечатках, мовчить 6 з 6 на вигаданих назвах. |
| Реєстр вилучив це ім'я через шкідливість | 🔴 Заблоковано. npm не видаляє таке ім'я, а замінює його заглушкою — тому воно досі відповідає, і раніше ми казали «ok». Приклади: crossenv, ffmepg. |
| Закріплену версію відкликав мейнтейнер | 🟡 Попередження з його ж поясненням. Не блокування: pip встановлює відкликану версію, якщо її закріпили явно. |
Закріплена версія не існує (requests==99.99.99) |
🔴 Заблоковано. Вигадана версія — той самий клас помилки, що й вигадана назва. Діапазони (>=2.31, ^4.18) не перевіряються: їх може задовольнити інша версія. |
| Опубліковано днями тому | 🟡 Попередження. Зловмисники реєструють швидко — але й чесні автори теж. |
| Лише один реліз, і пакету менше року | 🟡 Попередження. Усталений пакет з одним релізом — просто завершений, тож віковий фільтр тут частина сигналу. |
| Немає посилання на репозиторій, і пакету менше року | 🟡 Попередження. Віковий фільтр із тієї ж причини, що й рядком вище. |
| За один-два символи від популярної назви, і пакет свіжий або занедбаний (≤2 релізи й немає репозиторію) | 🟡 Попередження. Схоже на typosquat. |
Стабільні. Жоден більше не перенумеровується: ignore-файл десь у світі на них посилається, і зміна значення тихо змінила б те, що він придушує.
| Правило | Про що |
|---|---|
GP001 |
Пакета не існує |
GP002 |
Закріпленої версії не існує |
GP003 |
Опубліковано нещодавно |
GP004 |
Єдиний реліз |
GP005 |
Немає посилання на репозиторій чи домівку |
GP006 |
Схоже на популярну назву |
GP007 |
Скрипт встановлення робить щось незвичне |
GP008 |
Реєстр недоступний |
GP009 |
--deep не зміг прочитати пакет |
GP010 |
Закріплену версію відкликав мейнтейнер |
GP011 |
Реєстр вилучив ім'я через шкідливість |
Очевидний підхід — нарахувати пакету бали ризику й блокувати все сумнівне. Я спершу зробив саме так і виміряв результат на живій стрічці свіжих публікацій PyPI.
Ця версія позначила 100 % легітимних пакетів, опублікованих того дня.
Це не проблема налаштування порогів, це форма самих даних. Шкідливий slopsquat, зареєстрований три дні тому, і чесна нова бібліотека, опублікована три дні тому, — ззовні той самий пакет. Обидва молоді, обидва з одним релізом, обидва часто без репозиторію.
Тому ghostpkg блокує рівно за одним сигналом — пакета не існує. Він точний,
і саме він відповідає власне галюцинації. Усе м'якше — попередження для людини.
$ ghostpkg check react-router-dom-utils -e npm
WARNING react-router-dom-utils
- first published 176 days ago
- only one release
- no repository or homepage linkЯкщо потрібна агресивна поведінка — --strict підвищує попередження до блокувань.
Це не типова поведінка, і вона ловитиме справжні пакети.
Перевірка існування не бачить найнебезпечнішого випадку — імені, яке зловмисник уже зареєстрував. Такий пакет існує, тож проходить; він молодий, з одним релізом і без репозиторію — як і будь-який чесний новий пакет. Вік їх не розрізняє.
Поведінка при встановленні — розрізняє. Слопсквот мусить щось виконати під час встановлення, інакше в ньому немає сенсу. Чесна нова бібліотека цього майже ніколи не робить.
ghostpkg scan requirements.txt --deep--deep завантажує архів лише для свіжих пакетів, читає з нього тільки
setup.py (або скрипти встановлення з package.json) і зіставляє текст із
шаблонами. Нічого не виконується. Розмір архіва обмежений, тож бомба
розпакування не з'їсть пам'ять.
Що вважається сигналом: читання змінних середовища разом із мережевим запитом, мережевий запит, запуск оболонки, розкодування прихованого блоба, виконання щойно розкодованого коду.
Виміряно перед тим, як вмикати блокування:
| Група | Позначено |
|---|---|
| 27 усталених легітимних пакетів | 0 % |
| 32 пакети, опубліковані того ж дня | 0 % |
| 6 відомих шкідливих шаблонів | 6 з 6 |
Для порівняння: вік позначав 100 % свіжих легітимних пакетів. Саме тому молодий пакет із такими сигналами блокується, а вік — лише попереджає.
Вісім видів сигналів, у коді встановлення та в хуках npm. Нічого не виконується — архів читається як текст, у пам'яті, з обмеженням розміру.
| Сигнал | Приклад |
|---|---|
pipe-to-shell |
curl … | sh у хуку postinstall |
exfiltration |
Надсилання даних на зовнішню адресу під час встановлення |
network |
Мережевий виклик у setup.py |
subprocess |
Запуск стороннього процесу під час збірки |
inline-script |
node -e '…' у хуку |
encoded-payload |
Base64 чи hex, що розкодовується й виконується |
dynamic-exec |
eval, exec, compile над зібраним рядком |
environment |
Читання облікових даних із оточення під час встановлення |
Блокує лише для свіжого пакета. Усталений пакет із тими самими сигналами отримує попередження: старі пакети справді щось збирають при встановленні, а вибірка, на якій це судження зроблено, мала.
Одного хибного спрацювання досить, щоб команда прибрала перевірку з CI назавжди. Тому є спосіб сказати «про цей випадок ми знаємо».
{
"ignore": [
{ "package": "acme-*", "rule": "GP001",
"reason": "внутрішній, живе на нашому індексі" }
]
}ghostpkg scan requirements.txt --config ~/ghostpkg-ignore.jsonФайл ніколи не читається з каталогу проєкту. ghostpkg призначений стояти
гачком перед агентом, а агент із доступом до оболонки може редагувати файли
в репозиторії, над яким працює — список придушень поруч із кодом був би списком,
який те, що ми стережемо, здатне переписати. Файл береться з --config,
зі змінної GHOSTPKG_CONFIG або з вашого каталогу конфігурації.
Поле reason обовʼязкове, expires — необовʼязкове, але бажане. Зіпсований
файл зупиняє запуск, а не лишає вас без захисту мовчки.
Кожну з цих ідей запропоновано, зроблено прототип, заміряно й відкинуто. Вони тут тому, що відмови інструменту безпеки кажуть про його судження більше, ніж перелік можливостей: кожна з них добре виглядала б у чейнджлозі.
| Ідея | Що показав замір | |
|---|---|---|
| Оцінювати пакети за віком і кількістю релізів, блокувати підозрілі | 100% легітимних публікацій того самого дня позначено | Відхилено |
Вважати читання os.environ у скрипті встановлення сигналом |
37% усталених пакетів це роблять | Відхилено |
| Прибрати віковий фільтр із детектора опечаток | 2,54% хибних; нуля досягає лише пара умов | Відхилено |
| Кешувати «не існує» на годину | Справжнє хибне блокування живого пакета: стрічка PyPI оголошує ім'я раніше, ніж його віддає JSON API | Негативні відповіді не кешуються |
| Атестації PEP 740 як гасник попереджень | З 38 пакетів 13 мали атестацію, 8 не мали посилання на репозиторій — в обох групах лише 2 | Користь 5% за два зайві запити. Відхилено |
npm deprecated як аналог yanked у PyPI |
5,78% усіх версій, і 160 зі 168 у glob |
Шум. Відхилено |
| Постачати перелік відомих галюцинованих імен | — | Готовий перелік цілей для атакуючого. Відхилено принципово |
Про атестації варто окреме речення, бо це була найпривабливіша ідея набору: вона знімала б попередження, а не додавала сигнали. Провалилась вона на логіці, не на числах — атестація доводить походження, а не добросовісність. Зловмисник так само може опублікувати слопсквот через Trusted Publishing зі свого репозиторію.
Чотирнадцять модулів, жодної зовнішньої залежності.
| Модуль | Обов'язок |
|---|---|
manifests.py |
Розбір усіх текстових форматів. Кожен упізнається явно — здогади колись змусили читати pyproject.toml парсером requirements |
jslocks.py |
yarn.lock і pnpm-lock.yaml, рядково, без YAML-бібліотеки |
prose.py |
Команди встановлення в README та файлах інструкцій для агентів |
discover.py |
Пошук маніфестів у каталозі, з обходом чужих і згенерованих тек |
registries.py |
Клієнти PyPI і npm поверх urllib, із повторами й відступом |
cache.py |
Кеш на диску. Негативні відповіді не кешуються ніколи |
assess.py |
Вердикт. Єдине місце, де вирішується «блокувати чи ні» |
inspection.py |
Статичний огляд коду встановлення для --deep |
scanner.py |
Оркестрація, пул потоків. Не потребує термінала |
report.py |
Текст, JSON, анотації GitHub |
policy.py |
Ignore-файл, який ніколи не читається з каталогу, що сканують |
cli.py |
Аргументи й коди виходу, і більше нічого |
rules.py |
Ідентифікатори GP001–GP011 |
data.py |
По 2 000 найпопулярніших назв на кожну екосистему, лише для відстані до опечатки |
Гейт релізу. scripts/fieldtest.py клонує шістнадцять справжніх
репозиторіїв і падає на будь-якому блокуванні без пояснення. Кожна назва в них —
залежність, яку тисячі людей ставлять щодня, тож будь-який блок вважається
хибним, поки не доведено протилежне. Останній повний прогін: 88 904 пакети,
шість блокувань, усі шість справжні. Він ходить щоночі в CI.
ghostpkg |
SCA-сканери (Snyk, Socket) | Просто pip install |
|
|---|---|---|---|
| Ловить неіснуючу назву | ✅ до встановлення | після встановлення / у PR | ❌ |
| Працює без облікового запису | ✅ | ❌ | — |
| Залежностей під час виконання | 0 | багато | — |
| Блокує легітимні нові пакети | ❌ ні | по-різному | — |
| PyPI + npm в одному інструменті | ✅ | ✅ | ❌ |
ghostpkg навмисно вузький. Він не сканує код, не шукає шкідливе ПЗ
і не замінює SCA-продукт. Він добре відповідає на одне питання.
Warning
Складний випадок частково закрито прапорцем --deep, але не повністю.
Вигадану назву, яку зловмисник уже зареєстрував, перевірка існування
пропустить. --deep дивиться на поведінку при встановленні й ловить типові
шаблони, але зловмисник, який нічого не робить під час встановлення, пройде.
Обговорення — #1.
- Виявлення опечаток порівнює з 2 000 найпопулярніших проєктів кожної екосистеми, тому підробка під менш популярний пакет як схожа назва не позначиться.
- Імена коротші за 5 символів не порівнюються: там простір назв надто щільний, щоб відстань редагування щось означала.
- Кеш зберігається локально.
ghostpkg clear-cacheвидаляє його,GHOSTPKG_CACHE_DIRзмінює розташування.
Масштаб проблеми встановили Spracklen та ін., «We Have a Package for You! A Comprehensive Analysis of Package Hallucinations by Code Generating LLMs» (USENIX Security 2025).
Ці автори свідомо не опублікували свій список вигаданих назв — бо такий список
є готовим переліком цілей для зловмисника. ghostpkg дотримується того самого рішення
й не постачає жодного корпусу галюцинацій: він перевіряє назви наживо.
Issues і pull requests вітаються — див. CONTRIBUTING.md.
git clone https://github.com/M1rwana12/ghostpkg
cd ghostpkg
pip install -e ".[dev]"
pytestНайцінніший внесок — повідомлення про хибне спрацювання. Якщо ghostpkg позначив
справжній пакет, це баг: інструмент, який кричить «вовк» на легітимні пакети, вимикають,
і далі він не захищає нічого.
Модель загроз і те, чого інструмент не ловить, описані в SECURITY.md.
