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

Тайни в кода и в git

Накратко: Тайна (ключ или парола), качена в git, остава в историята и след изтриването: първо се сменя, после се чисти историята, а кодът я чете от променливите на средата.

Проблем

Ключът, който изтри

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

Колега бърза и пише ключа направо в кода:

config.js
export const config = {
paymentApiKey: "pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd",
};

На прегледа на кода някой го вижда. Колегата го трие с нов commit „Маха ключа от кода“. Файлът вече е чист.

Три месеца по-късно repo-то става публично заради отворения SDK на магазина. На следващата нощ от акаунта на магазина тръгват връщания на пари към чужди карти. Някой е пуснал една команда:

$ git log -p -S pay_live_
commit 9c41e07 Маха ключа от кода
- "pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd";
commit 3a9f2d1 Добавя плащанията
+ "pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd";

Изтриването не е махнало ключа. То е добавило още едно място, където той се вижда.

Идеята

Git помни всичко

Всеки commit е снимка на файловете. Новият commit не променя стария, а стои над него. Затова git log -p показва и добавянето на ключа, и изтриването му.

Тайна, която е влязла в git и е излязла от машината ти, е изтекла. Не „може би“: копия има в клонингите на колегите, във fork-овете, в кеша на CI, в огледала. Не можеш да ги намериш, камо ли да ги изтриеш.

Затова редът е винаги един и същ:

  1. Смяна (rotation). В панела на доставчика отменяш стария ключ и издаваш нов. Сега старият е безполезен, където и да е копиран.
  2. Чистене на историята. git filter-repo пренаписва всички commit-и без ключа. После git push --force и всички клонират наново.
  3. Новият ключ никога не влиза в кода. Програмата го чете отвън: от средата (environment variables) или от хранилище за тайни (secret store).

Обратният ред е грешка: докато чистиш историята, старият ключ още работи.

Тайна е всичко, което дава достъп: API ключ, парола за базата, ключ за подписване на токени, частен ключ на сертификат. В урока за враждебния вход пазеше програмата от чуждите данни. Тук пазиш чуждите от своите.

Виж го

Ключът в историята

function paymentKey() {
const key = process.env.PAYMENT_API_KEY?.trim();
if (!key) {
throw new Error("няма PAYMENT_API_KEY в средата");
}
return key;
}
Стъпка по стъпка, с думи
  1. Ключът за плащания е в кода. Commit 3a9f2d1 „Добавя плащанията“ го записва в историята.
  2. Следващият commit трие тези редове. Файлът е чист, но 3a9f2d1 още пази ключа.
  3. Някой има копие на repo-то: клонинг, fork, кеш на CI. git log -p вади ключа от стария commit.
  4. Първо смяна: доставчикът отменя стария ключ. Копието навън вече е безполезно.
  5. После историята: git filter-repo пренаписва commit-ите без ключа. Hash-овете са нови, всички клонират наново.
  6. Новият ключ живее само в средата (или в хранилище за тайни). Кодът го чете по име, PAYMENT_API_KEY.
  7. Ако името липсва, програмата спира веднага и казва какво липсва: fail closed. Без ключ по подразбиране.
  8. Със зададена променлива магазинът тръгва. В git няма тайна, а изтеклата вече не работи.

Сцената започва с кода от проблема и свършва с кода, който чете ключа от средата. Изтеклият ключ остава в копието навън, но вече е отменен. Историята е пренаписана: commit-ите имат нови hash-ове и ключа го няма в тях.

Терминал
# 1. Смяна: отмени pay_live_…6wHd при доставчика и издай нов ключ.
# 2. Историята: replacements.txt съдържа реда
# pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd==>***REMOVED***
git filter-repo --replace-text replacements.txt
git push --force --all

Идеята

Тайната идва отвън, а без нея програмата спира

Програмата чете ключа от средата: таблица от имена и стойности, която операционната система дава на всеки процес. На твоята машина го задаваш с export PAYMENT_API_KEY=… (в PowerShell $env:PAYMENT_API_KEY = "…"). На сървъра го задава системата, която пуска приложението, обикновено от хранилище за тайни.

Важното е какво става, когато ключа го няма. Нов сървър, сгрешено име, забравена настройка: програмата спира веднага и казва кое липсва. Това се казва fail closed. Обратното, fail open, е да тръгне с празен ключ или с ключ „по подразбиране“ и да гръмне при първия купувач, или по-лошо, да работи с грешния ключ.

function paymentKey() {
const key = process.env.PAYMENT_API_KEY?.trim();
if (!key) {
throw new Error("няма PAYMENT_API_KEY в средата");
}
return key;
}

Ключът не се печата цял никъде, дори в лога. Ако трябва да се види кой ключ е зареден, се показват само началото и последните четири знака:

function masked(key) {
return key.slice(0, 9) + "…" + key.slice(-4);
}
try {
const key = paymentKey();
console.log(`Плащанията са готови, ключ ${masked(key)}`);
} catch (error) {
console.log(`Магазинът не тръгва: ${error.message}`);
}
Изход
Магазинът не тръгва: няма PAYMENT_API_KEY в средата

При проверката на кода на сайта такава променлива няма, затова изходът е отказът. При теб, със зададен ключ, програмата печата Плащанията са готови, ключ pay_live_…6wHd.

process.env.PAYMENT_API_KEY е undefined, когато името го няма, а ?.trim() тогава връща пак undefined. Тук catch е само за едно чисто изречение. В истинско приложение оставяш грешката да спре стартирането: процесът свършва с код 1. От Node 20.6 node --env-file=.env app.js зарежда файл .env в средата без пакет.

Виж го

Скенер за тайни

Най-добре е тайната изобщо да не стигне до commit. Затова преди всеки commit върви скенер: програма, която търси в промените низове, приличащи на тайни. Ето малък, с три правила (регулярни изрази):

  • ключът на доставчика има известна форма: pay_live_ и 24 знака;
  • адрес с парола вътре: ://потребител:парола@;
  • присвояване на password, secret или key на низ от поне 8 знака, който не започва с $.

Последният ред на commit-а е "${PAYMENT_API_KEY}": това е заместител, който системата за конфигурация попълва от средата. Той не е тайна и скенерът не бива да го спира.

const RULES = [
["ключ за плащания", /pay_live_[A-Za-z0-9]{24}/],
["парола в адрес", /:\/\/[^:/\s]+:[^@\s]+@/],
["парола или ключ", /(password|secret|key)\s*=\s*"[^"$]{8,}"/i],
];
function scan(text) {
let found = 0;
text.split("\n").forEach((line, i) => {
const rule = RULES.find(([, pattern]) => pattern.test(line));
if (rule) {
console.log(`ред ${i + 1}: ${rule[0]}`);
found++;
}
});
return found;
}
const diff = `
+ payment.url = "https://api.pay.example/v1"
+ payment.key = "pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd"
+ db.url = "postgres://shop:Kq7vR2pX@db.internal/shop"
+ mail.password = "winter-sale-2026"
+ payment.key = "\${PAYMENT_API_KEY}"
`.trim();
const found = scan(diff);
console.log(found > 0 ? `Намерени: ${found}. Commit-ът спира.` : "Чисто.");
Изход
ред 2: ключ за плащания
ред 3: парола в адрес
ред 4: парола или ключ
Намерени: 3. Commit-ът спира.

Истинските скенери имат стотици правила и мерят и колко „случаен“ е низът (ентропия), за да хванат ключове без известна форма. Идеята е същата.

Пробвай

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

Същият скенер, друг конфигурационен файл. Какво ще отпечата?

const config = `
mail.user = "orders@shop.example"
mail.password = "\${MAIL_PASSWORD}"
db.url = "postgres://shop:Kq7vR2pX@db/shop"
`.trim();
const found = scan(config);
console.log(found > 0 ? `Намерени: ${found}. Commit-ът спира.` : "Чисто.");
Отговор

Пробвай

Попълни

Откъде идва ключът?

const key = .PAYMENT_API_KEY?.trim();
if (!key) {
  throw new Error("няма PAYMENT_API_KEY в средата");
}

Пробвай

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

Колега не иска магазинът да „пада заради една настройка“ и добавя резервен вариант. Кой ред е грешката?

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

По-точно, за любопитните

.gitignore и .env.example. Ако локалните тайни са във файл .env, името му е в .gitignore още от първия commit. В git влиза .env.example с имената и празни стойности: PAYMENT_API_KEY=. Така новият колега знае какво да попълни, без да вижда стойностите.

Скенер преди всеки commit. Ръчно не става: хората забравят. Скенерът се пуска сам с git hook (pre-commit). Готовите са gitleaks, trufflehog и detect-secrets, обикновено през рамката pre-commit. GitHub има и push protection: отказва push с ключ на известна услуга. Скенерът е мрежа под въжето, а не причина да пишеш тайни в кода.

Хранилище за тайни. Променливите на средата са по-добри от кода, но не са идеални. Виждат ги дъщерните процеси, понякога влизат в дъмп при срив, лесно се отпечатват по грешка. Затова на сървъра тайните идват от хранилище: HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, Google Secret Manager. Те пазят кой кога е чел, сменят ключовете по график и дават краткотрайни ключове.

node --env-file=.env (от Node 20.6) или process.loadEnvFile() зареждат .env без пакет. Пакетът dotenv прави същото за по-стари версии.

Чистенето, подробно. git filter-repo маха нарочно връзката origin след пренаписването, за да не пратиш случайно новата история на грешното място: добавяш я наново и пускаш git push --force --all и --tags. Колегите трябва да клонират наново: стар клонинг, слят обратно, връща ключа в историята. Услуги като GitHub пазят и стари референции на pull request-и, затова там се пише и на поддръжката. Старият инструмент за същото е BFG Repo-Cleaner.

Тайните в CI (ключове за deploy, достъп до регистъра на контейнерите, тайните на pipeline-а) са тема на DevOps курса ZeroToOps. Тук е частта, която пише програмистът.

Упражнение

Ключът в дневника

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

Тайните изтичат и през логовете: заявката към доставчика се записва цялата, с ключа вътре. Напиши clean, която заменя всеки ключ pay_live_ + 24 знака в ред от дневника с маскирания му вид, и я пусни на тези три реда:

POST /pay 200 key=pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd amount=49.90
POST /refund 200 key=pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd amount=12.00
GET /health 200
Изход
POST /pay 200 key=pay_live_…6wHd amount=49.90
POST /refund 200 key=pay_live_…6wHd amount=12.00
GET /health 200

Още задачи

  • Още едно правило. Добави към скенера правило за частен ключ: ред, който започва с -----BEGIN и завършва с PRIVATE KEY-----.
  • Изключения. Добави списък с позволени стойности (например примерният ключ в документацията), които скенерът прескача.
  • Проверка при старта. Направи функция, която проверява наведнъж всички нужни имена (PAYMENT_API_KEY, DB_PASSWORD, MAIL_PASSWORD) и съобщава всички липсващи, не само първото.

Тест

Провери се

1. Ключът за плащания е бил в публичното repo един ден. Трият го с нов commit. Какво още трябва да стане и в какъв ред?
2. Защо чистата история без смяна на ключа не стига?
3. Средата няма PAYMENT_API_KEY. Какво е правилното поведение на магазина?
4. Къде стои файлът .env с истинските стойности?

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