Към съдържанието
wannacode

Зависимостите: чужд код, който пуска твоят

Накратко: Зависимостите са чужд код, който твоята програма пуска; lock файлът и hash-ът на пакета пазят от подмяна, а скенерът намира известните уязвимости.

Проблем

Пакетът, който никой не е избирал

В package.json на магазина пише 14 пакета. В node_modules са 900. Всеки от 14-те води свои зависимости, а те свои.

Една нощ някой влиза в акаунта на автора на малък пакет за формат на дати. Пакетът е дълбоко в дървото: магазинът го ползва през библиотеката за PDF фактури и никой в екипа не знае, че го има. Атакуващият качва нова версия с един ред отгоре: ред, който чете средата на процеса и праща PAYMENT_API_KEY навън. Сутринта build-ът на магазина тегли новата версия, защото диапазонът ^2.1.3 я позволява.

Ключът, който в предишния урок извади от кода и сложи в средата, изтича оттам. Магазинът не е сбъркал нищо в своя код.

Това не е измислено. Така се случи с event-stream (2018), с ua-parser-js (2021), а през 2024 с xz utils подмяната беше открита малко преди да стигне до сървърите по света.

Идеята

Твоят процес, чуждият код

Всеки пакет, който програмата зарежда, върви в твоя процес и с твоите права. Чете същата среда, същите файлове, говори със същата мрежа. Някои пакети пускат код още при инсталиране (скриптовете postinstall на npm, setup.py при стари пакети на Python).

Пакетите, които ти си поискал, са преки зависимости. Тези, които те водят, са преходни (transitive). Преходните обикновено са повечето, а никой не ги е избирал.

Това е веригата на доставка (supply chain): от автора на пакета през регистъра (npm, Maven Central, NuGet, PyPI) до твоя build. Атакуващият може да влезе във всяко звено:

  • превзет акаунт на автор: нова версия с вреден код;
  • typosquatting: пакет с име като на известен, но с една буква разлика (reqeusts, lodahs), който чака правописна грешка;
  • подмяна по пътя: огледало, кеш или прокси, което дава други байтове под същото име и версия;
  • изоставен пакет, който някой поема и след месеци „обновява“.

Срещу всичко това има две прости правила. Знай точно кои версии влизат. И провери, че байтовете са същите, които си проверил.

Виж го

Дървото и подмененият пакет

function verify(name, bytes, lockFile) {
const expected = lockFile[name];
const actual = integrity(bytes);
if (actual !== expected) {
console.log(`${name}: ПОДМЕНЕН`);
console.log(` lock: ${brief(expected)}`);
console.log(` свален: ${brief(actual)}`);
return false;
}
console.log(`${name}: OK`);
return true;
}
Стъпка по стъпка, с думи
  1. Магазинът иска два пакета. Те водят още три свои. И петте вървят вътре в магазина.
  2. Някой подменя date-fmt по пътя. Без проверка кодът му върви в магазина, с неговата среда и файлове.
  3. Lock файлът пази точната версия и hash-а на всеки пакет, и на дълбоките.
  4. Hash-ът на изтегленото: SHA-512, записан с Base64.
  5. Същият hash като в lock файла: това са байтовете, които са проверени. Инсталира се.
  6. date-fmt има един ред в повече, а hash-ът е съвсем друг. Не се инсталира.
  7. Инсталирани: 4 от 5. Build-ът спира и никой не пуска подменения код.

Hash-ът тук е роднина на hash кода от урока за hash кода, но е криптографски. SHA-512 дава 64 байта и никой не може да направи друг пакет със същия отпечатък. Hash кодът в колекциите е 4 байта и лесно се повтаря: става за кофи в таблица, но не и за проверка на чужд код.

Идеята

Lock файлът: точната версия и отпечатъкът ѝ

Манифестът казва какво искаш: "pdf-invoice": "^5.0.0" значи „5.0.0 или по-нова 5.x“. Lock файлът казва какво точно е инсталирано: версията на всеки пакет в дървото, и на преходните, и hash на съдържанието му. Той се пази в git, а build-ът инсталира точно него.

В JavaScript това е package-lock.json (или pnpm-lock.yaml, yarn.lock). Всеки запис има integrity:

"node_modules/date-fmt": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/date-fmt/-/date-fmt-2.1.3.tgz",
"integrity": "sha512-Jeb+bpiC7IqY/wWOY2a0umsIM6ce0XcMc8Xl…"
}

В CI се пуска npm ci, не npm install: инсталира точно lock файла и спира, ако той не съвпада с package.json или ако hash не съвпада. npm install може да го промени.

Проверката е проста: hash на изтеглените байтове и сравнение със записа. Ето я като програма, с hash във вида на npm, sha512- и Base64:

function integrity(bytes) {
const digest = createHash("sha512").update(bytes).digest("base64");
return "sha512-" + digest;
}
function brief(hash) {
return hash.slice(0, 19) + "…";
}
function verify(name, bytes, lockFile) {
const expected = lockFile[name];
const actual = integrity(bytes);
if (actual !== expected) {
console.log(`${name}: ПОДМЕНЕН`);
console.log(` lock: ${brief(expected)}`);
console.log(` свален: ${brief(actual)}`);
return false;
}
console.log(`${name}: OK`);
return true;
}
const lockFile = {
"pdf-invoice@5.0.2": "sha512-IN0lH/YKriHbj87FMea7idgW4qdE8vgdeYTdEVVqz/GwAnMAzmU8X7TOO2TJ20CSQLaDtD9hKB7J5bw8JLos0A==",
"font-kit@3.1.0": "sha512-19+g7wktnKQh+JGfMRNeNdrG3//S+BsOUeuBcAp8p10kuHCpM3HFgbQzKnzTQ+DRLcA2b1MmnHFfHYvjFrjm9g==",
"date-fmt@2.1.3": "sha512-Jeb+bpiC7IqY/wWOY2a0umsIM6ce0XcMc8XlRr9udQktm8kZCNIV+qcJtad0Uip8hhhdn9P+8yE6p56qsZkZSw==",
"pay-sdk@4.2.0": "sha512-qyQmG1zNAk4HmkwNd+yxZ0GkyOsQoLn9CGHMW2LsBWmkhwpMo7rXHYT2keUIu6n50HuYBjv411Twk69Wqc0nig==",
"http-lite@1.8.0": "sha512-TI5SSzlpujK57oie/hoakeRXqVh5YuW22qwmU7I6moo75Gl3qXY5ZqRuEsy0sKdy6dz7uqhlJzDDliKF4tUQQA==",
};
const downloaded = [
["pdf-invoice@5.0.2", Buffer.from("pdf-invoice 5.0.2: renders invoices to PDF")],
["font-kit@3.1.0", Buffer.from("font-kit 3.1.0: loads and measures fonts")],
["date-fmt@2.1.3", Buffer.from("date-fmt 2.1.3: formats dates and times\n+ one line added by the attacker")],
["pay-sdk@4.2.0", Buffer.from("pay-sdk 4.2.0: talks to the payment provider")],
["http-lite@1.8.0", Buffer.from("http-lite 1.8.0: a small HTTP client")],
];
const installed = downloaded.filter(([name, bytes]) => verify(name, bytes, lockFile)).length;
console.log(`Инсталирани: ${installed} от ${downloaded.length}`);
Изход
pdf-invoice@5.0.2: OK
font-kit@3.1.0: OK
date-fmt@2.1.3: ПОДМЕНЕН
lock: sha512-Jeb+bpiC7IqY…
свален: sha512-sMR370edweyw…
pay-sdk@4.2.0: OK
http-lite@1.8.0: OK
Инсталирани: 4 от 5

Пакетите тук са кратък текст, но за hash-а няма разлика: байтове са. Един ред в повече в date-fmt и hash-ът е съвсем друг, още от първия знак.

Пробвай

Предвиди изхода

Двата текста се различават само с един интервал накрая. Какво ще отпечата?

const a = integrity(Buffer.from("total 49.90"));
const b = integrity(Buffer.from("total 49.90 "));
console.log(a.length);
console.log(b.length);
console.log(a === b);
Отговор

Пробвай

Попълни

Кой алгоритъм дава hash-а в lock файла?

const digest = createHash("").update(bytes).digest("base64");

Пробвай

Намери грешката

Колега пише проверката наново. Сървърът на регистъра връща заедно с пакета и неговия hash, затова колегата сравнява с него. Кой ред е грешката?

Избери реда с грешката.

Идеята

Известните уязвимости и обновяването

Lock файлът пази от подмяна, но замразява и старите грешки. Пакет без вреден код може да има уязвимост: бъг, през който се влиза. Пример е Log4Shell (2021): един ред в лога на сървър с log4j-core преди 2.17.1 стигаше, за да пусне атакуващият свой код.

Уязвимостите се публикуват в бази (CVE, GitHub Advisory Database, OSV), а инструментите сравняват твоето дърво с тях:

  • JavaScript: npm audit;
  • Python: pip-audit;
  • .NET: dotnet list package --vulnerable --include-transitive;
  • Java: OWASP Dependency-Check (plugin за Maven и Gradle);
  • за всички: Dependabot и Renovate отварят pull request с обновяването.

Хигиената на обновяването:

  • Малко и често. Обновяване на една версия с тестове зад него е безопасно. Пет години наведнъж не е. Тук помагат тестовете като предпазна мрежа.
  • Критичните веднага, особено когато поправката вече е публична.
  • Чети какво се сменя, преди да приемеш голяма версия.
  • Махай ненужното. Всеки пакет е код, който пускаш. Шест реда собствен код понякога са по-евтини от пакет с 40 зависимости.
  • Нова зависимост е решение. Кой я поддържа, колко души, колко зависимости води, кога е последната версия.
По-точно, за любопитните

Диапазоните. В npm ^2.1.3 позволява всяка 2.x.y от 2.1.3 нагоре, ~2.1.3 само 2.1.y. Затова без lock файл два build-а от един и същ код дават различни дървета. Библиотеките пишат диапазони, за да не карат всички да ползват една версия; приложенията заключват точното дърво.

Скриптовете при инсталиране. npm ci --ignore-scripts не пуска postinstall и подобните. Много проекти го правят по подразбиране в CI и разрешават скриптове само на изброени пакети.

Собствен регистър. Фирмите често теглят пакетите през свой прокси регистър (Artifactory, Nexus, Azure Artifacts). Той пази копие, пуска само одобрени пакети и следи кое къде се ползва.

SBOM. Software Bill of Materials е списък на всички компоненти в продукта, във формат CycloneDX или SPDX. Когато излезе следващият Log4Shell, отговорът на „засегнати ли сме?“ е търсене в него, не седмица ровене.

Подписи. Hash-ът казва „същите байтове като при проверката“. Подписът казва „публикувано от този автор“: npm provenance, Sigstore, подписаните пакети в NuGet и Maven Central.

Как виждаш дървото: npm ls --all, mvn dependency:tree, gradle dependencies, dotnet list package --include-transitive, pipdeptree.

Упражнение

Малък audit

Реши задачата в редактора си.

Инсталирани са log-kit 2.14.1, http-lite 1.8.0, pay-sdk 4.2.0 и qr-code 2.9.0. Базата с уязвимости казва (пакет, поправен във версия, тежест): log-kit 2.17.1 critical, http-lite 1.8.2 high, pay-sdk 3.9.0 moderate, qr-code 2.10.0 low. Отпечатай за всеки дали е уязвим и до коя версия да се обнови, и колко са уязвимите:

Изход
log-kit 2.14.1: уязвим (critical), обнови до 2.17.1
http-lite 1.8.0: уязвим (high), обнови до 1.8.2
pay-sdk 4.2.0: наред
qr-code 2.9.0: уязвим (low), обнови до 2.10.0
Уязвими: 3

Внимание с qr-code: като текст "2.9.0" е след "2.10.0", защото 9 е след 1. Версиите се сравняват по части, като числа.

Още задачи

  • Тежест. Подреди уязвимите по тежест: critical, high, moderate, low.
  • Lock от текст. Прочети lock файла като редове име@версия hash и провери с него изтеглените пакети от урока.
  • Typosquatting. Сравни името на нов пакет със списък от популярни и предупреди, ако се различава с една буква (една смяна, вмъкване или изтриване).

Тест

Провери се

1. Магазинът зависи пряко от pdf-invoice. Той зависи от date-fmt. Чий код върви, когато магазинът печата фактура?
2. Lock файлът на npm пише за date-fmt версия 2.1.3 и hash sha512-Jeb+…. Сървърът дава пакет, чийто hash е sha512-sMR3…. Какво прави npm ci?
3. Какво е typosquatting?
4. npm audit казва, че log-kit 2.14.1 има критична уязвимост, поправена в 2.17.1. Кое е правилното?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·