Mert Özen Her Satırda Daha İleri
Güvenlik Threat Modeling ve Güvenli Geliştirme Süreci — #06

Uygulama Güvenlik Incident'ına Müdahale: Tespit, Kapsam Belirleme, Kanıtı Korumak, İletişim ve Bildirim

Mert Özen 15 Kas 2026 12 dk 8 görüntülenme
Uygulama Güvenlik Incident'ına Müdahale: Tespit, Kapsam Belirleme, Kanıtı Korumak, İletişim ve Bildirim

Bir güvenlik incident'ında önce kanıt korunur, etkilenen kayıtlar tahminle değil audit log'dan çıkarılır, sonra düzeltilir ve hukuk ile kişisel verilerden sorumlu kişi ilk saatte dahil edilir. Bookstore'da bir kurumun siparişlerinin başka kurumca okunduğu BOLA vakası: NIST SP 800-61r3, hash'li kanıt kopyası, etkilenen kayıt sorgusu ve testi, düzeltme, müşteri ve KVKK bildirimi, runbook ve tabletop exercise.

Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; testler xUnit v3 4.0.3 ile çalıştırılmıştır. Anlatılan incident bir senaryodur: audit kayıtları demo-data komutuyla üretilmiş sentetik verilerdir, araçların ve testlerin çıktıları gerçektir.

Önce Tahmin Et

12 Kasım sabahı 08:40. Lakeside School'un satın alma sorumlusu Nevin Hanım, sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) kurumsal satış sorumlusu Hakan'ı arıyor: Hillcrest University'den biri bir toplantıda okulun Bookstore siparişlerinden bahsetmiş, listeyi görmüş. Hakan konuyu ekibe iletiyor. Emre on dakikada nedeni buluyor: release 2'de kurum portalı için eklenen GET /api/institutions/{institutionId}/orders endpoint'i, URL'deki kurum id'sini çağıranın kurumuyla karşılaştırmıyor. Düzeltme tek bir if.

Tahmin et: Emre düzeltmeyi hemen deploy edip, araştırma sırasında oluşan gürültülü log'ları temizleyip "düzelttik" derse ne kaybedilir? Cevap: neredeyse her şey. Kimin, hangi kurumun hangi siparişlerini, ne zaman gördüğü artık bilinemez; müşteriye söylenecek tek cümle tahmin olur; olası bir hukuki bildirimde dayanılacak kayıt kalmaz. Bu yazıda bir uygulama güvenlik incident'ına müdahaleyi konuşuyoruz: tespit, kanıtı korumak, kapsamı kayıtlardan çıkarmak, düzeltmek, iletişim ve bildirim.

Güvenlik Incident'ı Neden Farklı?

Severity seviyeleri, incident commander rolü, durum güncellemeleri ve postmortem SRE (Site Reliability Engineering; sistemlerin güvenilirliğini yazılım mühendisliğiyle yöneten disiplin) ve Production Incident'ları serisinin incident management ve postmortem yazılarında anlatıldı; Bookstore aynı süreci kullanıyor. Güvenlik incident'ı bu sürecin üzerine dört fark ekler. Birincisi kanıt: bir kesintide amaç servisi olabildiğince hızlı geri getirmektir; bir güvenlik incident'ında ise "ne oldu?" sorusunun cevabı, düzeltmenin kendisi kadar önemlidir ve aceleyle yapılan bir değişiklik bu cevabı silebilir. İkincisi karşı taraf: kesintide bir hata vardır, güvenlik incident'ında ise bazen hâlâ sistemde olan biri. Hesabı kapatmak, IP engellemek ya da herkese duyuru yapmak onu uyarabilir ve izlerini silmesine zaman tanıyabilir. Üçüncüsü hukuk: kişisel veri etkilendiyse KVKK'ya (Kişisel Verilerin Korunması Kanunu) göre bildirim süresi işlemeye başlamıştır, bu yüzden kişisel verilerden sorumlu kişi ve hukuk ilk saatte haberdar edilir. Dördüncüsü kapsam: etkilenen kullanıcıların listesi tahminle değil, kayıtlarla çıkarılmalıdır.

NIST'in (ABD Ulusal Standartlar ve Teknoloji Enstitüsü) incident response rehberinin üçüncü revizyonu, SP 800-61r3, 3 Nisan 2025'te yayımlandı. Önceki revizyondaki dört aşamalı yaşam döngüsü (hazırlık; tespit ve analiz; sınırlama, temizleme ve kurtarma; olay sonrası) artık belgenin ana modeli değil. Rev. 3 önerilerini CSF'in (Cybersecurity Framework; NIST'in siber güvenlik risk çerçevesi) 2.0 sürümündeki altı fonksiyona göre düzenliyor: Govern, Identify ve Protect hazırlığı; Detect, Respond ve Recover müdahalenin kendisini kapsıyor. Belge kurumların kendine uyan bir yaşam döngüsü modeli kullanabileceğini de söylüyor. Bu yazının pratik dersi şu: müdahale incident'tan önce başlar. Audit log'u, runbook'u ve provası olmayan bir ekip, incident günü bunları icat etmeye çalışır.

Tespit: Müşteriden Önce Fark Etmek

Bu incident'ı bir müşteri telefonu tespit etti; bu, tespit için en kötü kanaldır. Oysa gereken sinyal sistemde vardı. Endpoint her çağrıda bir audit kaydı yazıyordu: kim, hangi kurumdan, hangi kurumun verisini istedi, hangi sipariş id'leri döndü, sonuç ne oldu, hangi trace'e ait. Çağıranın kurumu ile istenen kurumun farklı olduğu tek bir allowed satırı bile bir alarm sebebidir. Bookstore'da bu alarm incident'tan sonra kuruldu; nasıl kurulacağı ASP.NET Core Güvenliği serisinin güvenlik olaylarını gözlemleme yazısında. Audit kaydı yazan class kısa; içine siparişin içeriğini değil yalnız kimlikleri yazar:

public sealed class AccessAuditEntry
{
    public long Id { get; set; }
    public DateTime OccurredAtUtc { get; set; }
    public required string UserId { get; set; }
    public int CallerInstitutionId { get; set; }
    public required string Action { get; set; }
    public int ResourceInstitutionId { get; set; }
    public string ResourceIds { get; set; } = "";
    public required string Outcome { get; set; }
    public string? TraceId { get; set; }
}

Hatanın kendisi API Güvenliği serisinin BOLA (Broken Object Level Authorization; kullanıcının erişim yetkisi olmayan bir kayda id'sini değiştirerek ulaşması) yazısında sistematik olarak anlatılan türden. Regression testini, düzeltmeden önceki kod üzerinde çalıştırdığımızda:

failed Bookstore.Api.Tests.InstitutionOrdersIncidentTests.Another_institutions_order_list_is_not_found_and_the_attempt_is_audited (3ms)
  Assert.Equal() Failure: Values differ
  Expected: NotFound
  Actual:   OK
Test run summary: Failed!
  total: 3
  failed: 1
  succeeded: 2

Kanıtı Korumak

İlk saatin en önemli kuralı: hiçbir şeyi değiştirmeden önce kopyala. Bookstore'da bunun için repoda küçük bir araç var. preserve komutu, API çalışırken bile tutarlı bir kopya alabilmek için SQLite'ın online backup'ını kullanıyor, audit tablosunu satır satır JSON olarak dışa aktarıyor ve her dosyanın hash'ini (SHA-256) bir manifest'e yazıyor. Production'da SQL Server kullanıyorsan aynı adım bir veritabanı yedeği ve log/trace deposundan zaman aralığının dışa aktarılmasıdır; ilke değişmez. Burada audit kayıtları sentetik; tatbikat için demo-data komutuyla üretildi:

dotnet run --project tools/Bookstore.IncidentTools -- demo-data incident/bookstore.db
dotnet run --project tools/Bookstore.IncidentTools -- preserve incident/bookstore.db incident/evidence
23 synthetic audit rows written to bookstore.db
copied database, exported 23 audit rows
57ca1efb32b48bb5f66ba418c42f2ab6413ed63d8b597289eca480e7aa4cd91b  bookstore-evidence.db
bb8e8b639d38dddc9734e9323b8925575f6b8d9d5599a7d9456bfdc71e8fa219  access-audit.jsonl

Kopyanın kendisi bir veritabanı dosyası ve SQLite onu açarken yanına -wal ve -shm dosyaları oluşturabilir. Araç bu yüzden kopyanın journal modunu DELETE'e çeviriyor; kanıt klasöründe tek, kendi içinde tamam bir dosya kalıyor:

// SQLite online backup: API yazarken bile tutarlı bir kopya.
await using (SqliteConnection source = new($"Data Source={dbPath};Mode=ReadOnly"))
await using (SqliteConnection target = new($"Data Source={copy};Pooling=False"))
{
    await source.OpenAsync();
    await target.OpenAsync();
    source.BackupDatabase(target);
    // Tek ve kendi içinde tamam bir dosya: kanıtın yanında -wal/-shm dosyası kalmasın.
    await using SqliteCommand journal = target.CreateCommand();
    journal.CommandText = "PRAGMA journal_mode=DELETE;";
    await journal.ExecuteNonQueryAsync();
}

Kanıt klasörüne bir daha yazılmaz. Analiz bir kopya üzerinde yapılır ve iş bitince kanıtın hash'i manifest'le karşılaştırılır. Kimin hangi dosyayı ne zaman aldığı incident log'una UTC (Coordinated Universal Time; eşgüdümlü evrensel zaman) saatiyle yazılır; buna chain of custody (kanıtın kimin elinden geçtiğinin kaydı) denir ve bir hukuki süreçte kanıtın değerini belirler. Kanıtı korumanın bir parçası da log saklama süresini uzatmaktır: 14 gün sonra silinecek log'lar, incident kapanmadan silinmemelidir.

Kapsamı Kayıtlardan Çıkarmak

"Muhtemelen yalnız Lakeside etkilendi" bir cevap değildir. Etkilenen kayıtların listesi audit log'dan çıkarılır ve bu sorgu da kodun bir parçası olarak testlenir. Sorgu yalnız allowed satırlarını sayar: reddedilen bir istek veri döndürmemiştir. Çağıranın kurumu ile verinin kurumu farklı olan satırları, verisi okunan kuruma ve okuyan kullanıcıya göre gruplar:

public static async Task<IReadOnlyList<CrossInstitutionRead>> CrossInstitutionReadsAsync(
    BookstoreContext db, DateTime fromUtc, DateTime toUtc, CancellationToken ct)
{
    List<AccessAuditEntry> rows = await db.AccessAuditEntries.AsNoTracking()
        .Where(e => e.Action == "institution.orders.list" && e.Outcome == "allowed"
            && e.CallerInstitutionId != e.ResourceInstitutionId
            && e.OccurredAtUtc >= fromUtc && e.OccurredAtUtc < toUtc)
        .OrderBy(e => e.OccurredAtUtc)
        .ToListAsync(ct);

    return
    [
        .. rows.GroupBy(e => (e.ResourceInstitutionId, e.UserId, e.CallerInstitutionId))
            .Select(g => new CrossInstitutionRead(
                g.Key.ResourceInstitutionId, g.Key.UserId, g.Key.CallerInstitutionId, g.Count(),
                g.First().OccurredAtUtc, g.Last().OccurredAtUtc,
                [.. g.SelectMany(e => e.ResourceIds.Split(',', StringSplitOptions.RemoveEmptyEntries)).Select(int.Parse).Distinct().Order()]))
            .OrderBy(r => r.ResourceInstitutionId).ThenBy(r => r.UserId)
    ];
}

Komut, kanıtın kendisine değil, analiz için alınmış kopyasına karşı çalışır; sonra kanıtın hash'i PowerShell ile tekrar kontrol edilir:

Copy-Item incident/evidence/bookstore-evidence.db incident/analysis.db
dotnet run --project tools/Bookstore.IncidentTools -- affected incident/analysis.db 2026-11-11T00:00:00Z 2026-11-12T00:00:00Z
(Get-FileHash -Algorithm SHA256 incident/evidence/bookstore-evidence.db).Hash.ToLower()
victim  reader  reader-inst  requests  first (UTC)          last (UTC)           orders
     1  alan              2         2  2026-11-11 14:03:00  2026-11-11 14:04:00  101,102,103,104
     1  edsger            2         1  2026-11-11 15:41:00  2026-11-11 15:41:00  101,102,103,104
affected institutions: 1, affected orders: 4
57ca1efb32b48bb5f66ba418c42f2ab6413ed63d8b597289eca480e7aa4cd91b

Sonuç kesin bir cümle kurdurur: 11 Kasım'da 14:03 ile 15:41 arasında Hillcrest University'den iki kullanıcı Lakeside School'un dört siparişinin listesini gördü. Hash manifest'tekiyle aynı, yani analiz kanıta dokunmadı. Aynı netlikte yazılması gereken bir şey daha var: kontrol edilemeyen pencere. Audit kaydı endpoint'in yayına girdiği release 2 ile başladıysa ondan öncesi zaten yok; ama log saklama süresi daha kısa olsaydı "şu tarihten öncesini bilemiyoruz" cümlesi müşteriye söylenecek metne girmek zorunda olurdu. Her satırdaki TraceId, gerekirse aynı isteğin trace'ine gidip başka hangi çağrıları yaptığını görmeyi sağlıyor.

Sorgunun testi, kendi kurumunun verisini okuyan bir satırı, iki çapraz okumayı ve reddedilmiş bir isteği içeren sentetik kayıtlarla, sonucun yalnız gerçekten veri döndüren çapraz okumaları ve doğru sipariş id'lerini verdiğini doğruluyor. İncident günü çalıştırılacak bir sorgunun ilk kez o gün çalıştırılmaması gerekir.

Düzeltme ve Regression Testi

Düzeltme, kanıt korunduktan sonra deploy edildi. Kural basit: kurum token'dan gelir, URL'den değil. Uyuşmazlıkta istek 404 alır ve deneme denied olarak audit'e yazılır; bu, ASVS'nin (Application Security Verification Standard; uygulama güvenliği doğrulama standardı) başarısız yetkilendirme denemelerinin loglanmasını isteyen v5.0.0-16.3.2 maddesini de karşılıyor:

private static async Task<IResult> ListOrders(
    int institutionId, ClaimsPrincipal user, BookstoreContext db, AccessAudit audit, CancellationToken ct)
{
    if (!InstitutionalCaller.TryRead(user, out InstitutionalCaller? caller))
    {
        return TypedResults.Forbid();
    }
    // INC-2026-11-12: route'taki kurum çağıranın kendi kurumu olmalı; karar token'ın, URL'in değil.
    if (institutionId != caller.InstitutionId)
    {
        await audit.WriteAsync(caller, "institution.orders.list", institutionId, [], "denied", ct);
        return TypedResults.NotFound();
    }
    List<InstitutionOrderView> orders = await db.Orders.AsNoTracking()
        .Where(o => o.InstitutionId == institutionId)
        .OrderBy(o => o.Id)
        .Select(o => new InstitutionOrderView(o.Id, o.Status.ToString(), o.PlacedAtUtc, o.Total))
        .ToListAsync(ct);
    await audit.WriteAsync(caller, "institution.orders.list", institutionId, orders.Select(o => o.Id), "allowed", ct);
    return TypedResults.Ok(orders);
}
[Fact]
[Trait("incident", "INC-2026-11-12")]
[Trait("asvs", "v5.0.0-8.4.1")]
[Trait("asvs", "v5.0.0-16.3.2")]
public async Task Another_institutions_order_list_is_not_found_and_the_attempt_is_audited()
{
    HttpResponseMessage response = await factory.CreateClientAs(TestUsers.Alan).GetAsync("/api/institutions/1/orders", ct);

    Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
    AccessAuditEntry? entry = await LastAuditEntryAsync("alan");
    Assert.Equal(("denied", 2, 1), (entry?.Outcome, entry?.CallerInstitutionId, entry?.ResourceInstitutionId));
}
Test run summary: Passed!
  total: 28
  failed: 0
  succeeded: 28
  skipped: 0
  duration: 1s 035ms

Yeni testler requirement dosyasına da yansıdı: çok kiracılı uygulamalarda kiracılar arası kontrol isteyen v5.0.0-8.4.1 listeye eklendi, üçüncü yazıdan beri planned olan v5.0.0-16.3.2 artık test. Threat model'e T13 eklendi; mitigation alanında "bu model değil, incident buldu" notu var. Kök neden de oraya işaret ediyor: endpoint'i ekleyen PR'da (pull request; kod değişikliği isteği) ilk yazıdaki threat model kutusu işaretlenmemişti, yeni flow modele hiç girmedi. Düzeltilmesi gereken yalnız kod değil, o kutuyu soran review alışkanlığı.

İletişim ve Bildirim

Lakeside School'a giden mesajın içeriği kayıtlardan çıktığı için net: ne oldu, hangi verileri (dört siparişin numarası, durumu, tarihi ve tutarı), kim gördü (başka bir kurumun iki kullanıcısı), ne zaman, ne yapıldı ve soruları için kime ulaşacakları. Hillcrest University'ye ise ayrı bir mesajla, kullanıcılarının bu veriye erişmiş olduğu ve verinin silinmesi istendiği bildirilir; kullanıcıları suçlayan bir dil kullanılmaz, çünkü kayıtlar bir merak sonucu URL değiştirildiğini gösteriyor, saldırı olduğunu değil.

Hukuki bildirim mühendisliğin kararı değildir. Bookstore'da kişisel verilerden sorumlu kişi, etkilenen verinin kişisel veri olup olmadığını değerlendirir; bu incident'ta dönen alanlar bir kuruma ait sipariş bilgileriydi ve kişisel veri içermiyordu, ama sözleşmedeki gizlilik maddesi yine de müşteriye bildirimi gerektiriyordu. Kişisel veri etkilenseydi KVKK Kurulu'nun 2019 tarihli kararındaki 72 saatlik bildirim süresi devreye girerdi; bunun hukuki çerçevesi ve hazırlığı Veri Gizliliği ve KVKK serisinin veri ihlali yazısında. Mühendisliğin bu süreçteki işi, sorumlu kişiye ilk saatte haber vermek ve ona kesin bir etkilenen kayıt listesi vermektir.

Status page'e yazılan cümle kısa tutulur: "Kurumsal sipariş listelerine yetkisiz erişimi inceliyoruz; etkilenen müşterilerle doğrudan iletişime geçiyoruz." İstismar adımları, URL kalıbı ya da kişi adları status page'e, ticket'lara ve genel kanallara girmez. Postmortem'de de aynı ayrım geçerli: zaman çizelgesi, kök neden ve aksiyonlar ekiple paylaşılır; kişi adları, ham kanıt ve henüz düzeltilmemiş benzer sorunların istismar ayrıntıları paylaşılmaz. Bookstore'un incident kaydı repoda docs/security/incidents/INC-2026-11-12.md olarak duruyor ve yalnız bu paylaşılabilir kısmı içeriyor.

Runbook ve Tabletop Exercise

Bu yazıdaki adımların hiçbiri incident günü düşünülmemeli. Bookstore'un güvenlik runbook'u genel incident sürecine eklenen kısa bir belge; ilk saat kısmı:

## First hour

1. Open a private incident channel; invite only who is needed. No details in public channels or tickets.
2. Name the roles: incident commander, technical lead, scribe, and a communications owner.
   Call the data protection contact (KVKK) and legal early: if personal data may be affected, the 72-hour
   clock for notifying the authority may already be running.
3. Preserve evidence before you change anything:
   `dotnet run --project tools/Bookstore.IncidentTools -- preserve <db> <evidence-dir>`,
   export the logs and traces of the time window, record who did what and when (UTC) in the incident log.
4. Contain without tipping off: prefer a quiet fix or a feature flag over deleting accounts or data.
   Do not rotate or delete anything that is evidence until it is preserved.

Runbook'u sınamanın yolu tabletop exercise'dır: ekip bir masa etrafında (ya da bir görüntülü görüşmede) bir senaryo kartını okur ve sırayla "ilk kimi arıyoruz, ilk hangi komutu çalıştırıyoruz, müşteriye ne diyoruz?" sorularını cevaplar. Hiçbir sistem değişmez; amaç runbook'taki boşlukları bulmaktır. Bookstore'da her altı ayda bir, 60 dakikalık bir tatbikat yapılıyor. Bu yazıdaki senaryo da artık runbook'taki kartlardan biri.

Üretimde Dikkat

Audit log'un saklama süresi, bir incident'ın fark edilme süresinden uzun olmalı. Bu incident bir gün sonra fark edildi; birçok sızıntı aylar sonra fark edilir. Saklama süresini KVKK'nın veri minimizasyonu ilkesiyle dengelemek gerekir; audit kaydına içerik değil kimlik yazmak bu dengeyi kolaylaştırır.

Audit tablosu, uygulamanın kendisinin silemeyeceği bir yerde olmalı. Uygulamanın veritabanı kullanıcısı audit tablosuna yalnız ekleme yapabiliyorsa, uygulamayı ele geçiren biri izlerini silemez. Production'da bu ayrı bir şema yetkisiyle ya da log'ların uygulamanın dışındaki bir depoya akmasıyla sağlanır; ayrıntısı ASP.NET Core Güvenliği serisinin audit trail anlatılan yazısında.

Saatler UTC olmalı ve sunucular arasında senkron olmalı. Farklı saat dilimlerinde ya da kaymış saatlerle yazılmış kayıtları sıraya koymak, incident gününün en çok zaman kaybettiren işlerinden biridir.

Sık Düşülen Tuzaklar

Log'ları silip "düzelttik" demek, en büyük hatadır; çoğu zaman kötü niyetten değil, "ortalığı temizleyelim" refleksinden yapılır. Silinen log, etkilenen kişileri belirlemenin tek yolunu da siler ve kurumun söyleyebileceği her cümleyi tahmine çevirir.

İkinci tuzak etkilenen kullanıcıları tahminle belirlemek. "Muhtemelen yalnız bir kurum" ya da tersine "güvenli tarafta kalalım, herkese yazalım" ikisi de yanlış: ilki gerçekten etkilenen birini dışarıda bırakabilir, ikincisi etkilenmeyen yüzlerce müşteriyi gereksiz yere endişelendirir ve gerçek mesajı sulandırır.

Üçüncüsü kanıtı korumadan düzeltmeye başlamak, dördüncüsü hukuku ve kişisel verilerden sorumlu kişiyi son gün haberdar etmek. İkisi de dakikalar kazandırıp günler kaybettirir.

Küçük Bir Deneme

★☆☆ demo-data ile üretilen veriye bir kurumun kendi verisini okuyan ama denied sonuçlanan bir satır ekle ve affected komutunu tekrar çalıştır. Çıktı neden değişmiyor?

★★☆ Çağıranın kurumu ile verinin kurumunun farklı olduğu her allowed audit satırı için bir alarm tasarla: hangi sorgu, hangi sıklıkla, kime gider? Bu alarm bu incident'ı ne kadar erken yakalardı?

★★★ Runbook'taki üç tabletop kartından ikincisini (sızan webhook secret'ı) ekibinle oyna. Configuration ve Secret Management serisinin sızıntı müdahalesi yazısındaki adımlarla bu runbook'u karşılaştır; çakışan ya da eksik adım var mı?

Bu yazıyla seri tamamlandı. İlk yazıda bir DFD (Data Flow Diagram; verinin sistemdeki hareketini gösteren diyagram) ve dört soruyla başladık; ikinci yazıda tehditleri STRIDE (altı tehdit türünün baş harflerinden oluşan sınıflandırma) ile bulup teste çevirdik, üçüncüde kararları ASVS 5.0 requirement'larına bağladık, dördüncüde dışarıdan gelen bildirimi karşılamayı, beşincide pentest bulgularını, bu yazıda da bir incident'ı konuştuk. Hepsinin ortak noktası, güvenliğin tek bir araçta ya da tek bir kişide değil, Bookstore ekibinin her sprint'te tekrar ettiği küçük ve kayıtlı alışkanlıklarda yaşaması. Bu incident'ın kök nedeni de bir araç eksikliği değil, işaretlenmemiş bir kutuydu.