Тайни в кода и в git
Накратко: Тайна (ключ или парола), качена в git, остава в историята и след изтриването: първо се сменя, после се чисти историята, а кодът я чете от променливите на средата.
Проблем
Ключът, който изтри
Магазинът приема плащания през доставчик. Програмата праща на доставчика API ключ: дълъг низ, който доказва „това е магазинът“. Който има ключа, може от името на магазина да пуска плащания и връщания на пари.
Колега бърза и пише ключа направо в кода:
static class PaymentConfig{ public const string ApiKey = "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, в огледала. Не можеш да ги намериш, камо ли да ги изтриеш.
Затова редът е винаги един и същ:
- Смяна (rotation). В панела на доставчика отменяш стария ключ и издаваш нов. Сега старият е безполезен, където и да е копиран.
- Чистене на историята.
git filter-repoпренаписва всички commit-и без ключа. Послеgit push --forceи всички клонират наново. - Новият ключ никога не влиза в кода. Програмата го чете отвън: от средата (environment variables) или от хранилище за тайни (secret store).
Обратният ред е грешка: докато чистиш историята, старият ключ още работи.
Тайна е всичко, което дава достъп: API ключ, парола за базата, ключ за подписване на токени, частен ключ на сертификат. В урока за враждебния вход пазеше програмата от чуждите данни. Тук пазиш чуждите от своите.
Виж го
Ключът в историята
static string PaymentKey(){ var key = Environment.GetEnvironmentVariable( "PAYMENT_API_KEY"); if (string.IsNullOrWhiteSpace(key)) { throw new InvalidOperationException( "няма PAYMENT_API_KEY в средата"); } return key;}static class PaymentConfig{ public const string ApiKey = "pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd";}static string PaymentKey(){ var key = Environment.GetEnvironmentVariable( "PAYMENT_API_KEY"); if (string.IsNullOrWhiteSpace(key)) { throw new InvalidOperationException( "няма PAYMENT_API_KEY в средата"); } return key;}Стъпка по стъпка, с думи
- Ключът за плащания е в кода. Commit 3a9f2d1 „Добавя плащанията“ го записва в историята.
- Следващият commit трие тези редове. Файлът е чист, но 3a9f2d1 още пази ключа.
- Някой има копие на repo-то: клонинг, fork, кеш на CI. git log -p вади ключа от стария commit.
- Първо смяна: доставчикът отменя стария ключ. Копието навън вече е безполезно.
- После историята: git filter-repo пренаписва commit-ите без ключа. Hash-овете са нови, всички клонират наново.
- Новият ключ живее само в средата (или в хранилище за тайни). Кодът го чете по име, PAYMENT_API_KEY.
- Ако името липсва, програмата спира веднага и казва какво липсва: fail closed. Без ключ по подразбиране.
- Със зададена променлива магазинът тръгва. В git няма тайна, а изтеклата вече не работи.
Сцената започва с кода от проблема и свършва с кода, който чете ключа от средата. Изтеклият ключ остава в копието навън, но вече е отменен. Историята е пренаписана: commit-ите имат нови hash-ове и ключа го няма в тях.
# 1. Смяна: отмени pay_live_…6wHd при доставчика и издай нов ключ.# 2. Историята: replacements.txt съдържа реда# pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd==>***REMOVED***git filter-repo --replace-text replacements.txtgit push --force --allИдеята
Тайната идва отвън, а без нея програмата спира
Програмата чете ключа от средата: таблица от имена и стойности, която операционната система дава на всеки процес. На твоята машина го задаваш с export PAYMENT_API_KEY=… (в PowerShell $env:PAYMENT_API_KEY = "…"). На сървъра го задава системата, която пуска приложението, обикновено от хранилище за тайни.
Важното е какво става, когато ключа го няма. Нов сървър, сгрешено име, забравена настройка: програмата спира веднага и казва кое липсва. Това се казва fail closed. Обратното, fail open, е да тръгне с празен ключ или с ключ „по подразбиране“ и да гръмне при първия купувач, или по-лошо, да работи с грешния ключ.
static string PaymentKey(){ var key = Environment.GetEnvironmentVariable( "PAYMENT_API_KEY"); if (string.IsNullOrWhiteSpace(key)) { throw new InvalidOperationException( "няма PAYMENT_API_KEY в средата"); } return key;}Ключът не се печата цял никъде, дори в лога. Ако трябва да се види кой ключ е зареден, се показват само началото и последните четири знака:
static string Masked(string key) => key[..9] + "…" + key[^4..];try{ var key = PaymentKey(); Console.WriteLine($"Плащанията са готови, ключ {Masked(key)}");}catch (InvalidOperationException e){ Console.WriteLine($"Магазинът не тръгва: {e.Message}");}Магазинът не тръгва: няма PAYMENT_API_KEY в средатаПри проверката на кода на сайта такава променлива няма, затова изходът е отказът. При теб, със зададен ключ, програмата печата Плащанията са готови, ключ pay_live_…6wHd.
Environment.GetEnvironmentVariable връща null, когато името го няма. Тук кодът хваща изключението само за да отпечата едно чисто изречение. В истинско приложение го оставяш да спре стартирането: процесът свършва с код, различен от 0, и системата, която го пуска, вижда, че не е тръгнал. В ASP.NET Core същото става с builder.Configuration["PAYMENT_API_KEY"] и проверка при старта.Виж го
Скенер за тайни
Най-добре е тайната изобщо да не стигне до commit. Затова преди всеки commit върви скенер: програма, която търси в промените низове, приличащи на тайни. Ето малък, с три правила (регулярни изрази):
- ключът на доставчика има известна форма:
pay_live_и 24 знака; - адрес с парола вътре:
://потребител:парола@; - присвояване на
password,secretилиkeyна низ от поне 8 знака, който не започва с$.
Последният ред на commit-а е "${PAYMENT_API_KEY}": това е заместител, който системата за конфигурация попълва от средата. Той не е тайна и скенерът не бива да го спира.
static class SecretScanner{ static readonly (string Name, Regex Pattern)[] Rules = [ ("ключ за плащания", new Regex(@"pay_live_[A-Za-z0-9]{24}")), ("парола в адрес", new Regex(@"://[^:/\s]+:[^@\s]+@")), ("парола или ключ", new Regex( @"(password|secret|key)\s*=\s*""[^""$]{8,}""", RegexOptions.IgnoreCase)), ];
public static int Scan(string text) { var lines = text.Split('\n'); int found = 0; for (int i = 0; i < lines.Length; i++) { foreach (var (name, pattern) in Rules) { if (pattern.IsMatch(lines[i])) { Console.WriteLine($"ред {i + 1}: {name}"); found++; break; } } } return found; }}var 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}" """;int found = SecretScanner.Scan(diff);Console.WriteLine(found > 0 ? $"Намерени: {found}. Commit-ът спира." : "Чисто.");ред 2: ключ за плащанияред 3: парола в адресред 4: парола или ключНамерени: 3. Commit-ът спира.Истинските скенери имат стотици правила и мерят и колко „случаен“ е низът (ентропия), за да хванат ключове без известна форма. Идеята е същата.
Пробвай
Предвиди изхода
Същият скенер, друг конфигурационен файл. Какво ще отпечата?
var config = """ mail.user = "orders@shop.example" mail.password = "${MAIL_PASSWORD}" db.url = "postgres://shop:Kq7vR2pX@db/shop" """;int found = SecretScanner.Scan(config);Console.WriteLine(found > 0 ? $"Намерени: {found}. Commit-ът спира." : "Чисто.");Ред 1 е имейл: в него няма ://, а user не е в третото правило. Ред 2 е заместител: третото правило иска низът да не започва с $. Остава ред 3, където паролата е в самия адрес на базата.
Пробвай
Попълни
Откъде идва ключът?
var key = ("PAYMENT_API_KEY");
if (string.IsNullOrWhiteSpace(key))
{
throw new InvalidOperationException("няма PAYMENT_API_KEY в средата");
}Environment.GetEnvironmentVariable(име) чете една променлива от средата или връща null.Пробвай
Намери грешката
Колега не иска магазинът да „пада заради една настройка“ и добавя резервен вариант. Кой ред е грешката?
Избери реда с грешката.
Резервният ключ е пак тайна в кода, а програмата тръгва тихо, когато настройката липсва: fail open. На нов сървър магазинът ще работи със стария ключ, който вече може да е изтекъл, и никой няма да разбере. Правилното е да спре с ясно съобщение.
По-точно, за любопитните
.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. Те пазят кой кога е чел, сменят ключовете по график и дават краткотрайни ключове.
За разработка .NET има User Secrets:dotnet user-secrets set "Payment:ApiKey" "…" пази стойността извън папката на проекта. В продукция IConfiguration чете от средата или от Azure Key Vault.Чистенето, подробно. 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.90POST /refund 200 key=pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd amount=12.00GET /health 200POST /pay 200 key=pay_live_…6wHd amount=49.90POST /refund 200 key=pay_live_…6wHd amount=12.00GET /health 200Още задачи
- Още едно правило. Добави към скенера правило за частен ключ: ред, който започва с
-----BEGINи завършва сPRIVATE KEY-----. - Изключения. Добави списък с позволени стойности (например примерният ключ в документацията), които скенерът прескача.
- Проверка при старта. Направи функция, която проверява наведнъж всички нужни имена (
PAYMENT_API_KEY,DB_PASSWORD,MAIL_PASSWORD) и съобщава всички липсващи, не само първото.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Хвърляне и свои изключения · Списъци · Ламбди и делегати · Недоверен вход
- Продължава в: Зависимостите: чужд код, който пуска твоят · Криптографията, която не пишеш сам · Промптът е код · Предпазители: проверки преди и след модела
- Задачи по темата: Сигурност за програмисти