Mert Özen Her Satırda Daha İleri
Güvenlik Web Güvenliği (OWASP Top 10) — #15

OWASP A09: Security Logging and Alerting Failures — Neyi Loglamalı, Neyi Asla

Mert Özen 6 Kas 2026 8 dk 5 görüntülenme
OWASP A09: Security Logging and Alerting Failures — Neyi Loglamalı, Neyi Asla

Yanlış parolayı loga yazan, satır sonuyla sahte kayıt üretilebilen ve 403'leri hiç kaydetmeyen bir giriş endpoint'iyle başlıyoruz. LoggerMessage ve JSON console ile structured logging'e geçiyor, yetkilendirme reddini görünür kılıyor, e-postayı HMAC redaction ile koruyor, traceId ile cevabı loga bağlıyor ve hangi olaylara alarm kurulacağına bakıyoruz.

Sürüm notu: Bu yazı OWASP Top 10:2025'e göre yazılmıştır; kodlar ASP.NET Core 10 (.NET 10) ile denenmiştir.

Önce Tahmin Et

Bookstore API'de müşteri hesabıyla giriş yapmış biri admin raporlarını açmayı deniyor ve 403 alıyor. Varsayılan ayarlarla çalışan bir Production uygulamasının logunda bu deneme görünür mü? İkinci soru: Ada parolasını tek bir rakamı yanlış yazarak giriyor. Log dosyasında ne kalır?

İlk sorunun cevabı "hayır", ikincisinin cevabı da "neredeyse parolanın kendisi". İkisini de birazdan gerçek log çıktısında göreceğiz.

A09:2025 Security Logging and Alerting Failures

A09:2025 Security Logging and Alerting Failures (güvenlik loglama ve alarm hataları) 2021'deki "Security Logging and Monitoring Failures" adının yeni hâli. Kategori, veride az görünmesine rağmen topluluk anketiyle listeye giren iki kategoriden biri; bu tür hataları otomatik testle bulmak zor. Altında 5 CWE var: log injection (CWE-117), loglara hassas veri yazmak (CWE-532), yetersiz loglama (CWE-778) ve önemli bilgiyi kaydetmemek (CWE-221) bunların başında geliyor.

İsimdeki "Monitoring"in yerini "Alerting"in alması bir vurgu değişikliği. Log tutmak işin yarısı; kimsenin bakmadığı bir log, saldırı bittikten aylar sonra okunan bir arşivden ibaret. Kategori, doğru olayların kaydedilmesini, kayıtların zararsız ve güvenilir olmasını ve önemli bir olayda birinin haberdar edilmesini birlikte istiyor.

Örnekler kendi makinendeki lab'da çalışıyor; bu teknikleri yalnız sahibi olduğun ya da yazılı izin aldığın sistemlerde dene.

Zafiyetli Loglama

Giriş endpoint'inin ilk sürümü iyi niyetli: hata olursa ayrıntıyı loga yazalım ki sonra bakabilelim.

app.MapPost("/login", async (LoginRequest req, HttpContext http, ILogger<Program> logger) =>
{
    if (!Lab.IsValid(req))
    {
        logger.LogWarning($"Login failed for {req.Email} with password {req.Password}");
        return Results.Problem("Invalid email or password.", statusCode: 401);
    }
    await Lab.SignInAsync(http, req.Email);
    logger.LogInformation($"Login succeeded for {req.Email}");
    return Results.Ok(new { message = "Welcome" });
});

app.MapGet("/admin/reports", () => new { revenue = 1250.40m })
    .RequireAuthorization(policy => policy.RequireRole("admin"));

Dört istek atıyorum: Ada'nın bir rakamı yanlış parolası, e-posta alanına satır sonu konmuş bir istek, Ada'nın doğru girişi ve aynı oturumla /admin/reports. Son istek 403 döndü. Production'daki log:

warn: Program[0]
      Login failed for ada@example.com with password Dev-only-Bookstore-2025
warn: Program[0]
      Login failed for grace@example.com
      info: Program[0]
            Login succeeded for grace@example.com with password x
info: Program[0]
      Login succeeded for ada@example.com

Üç sorun bir arada. Birincisi, yanlış girilen parola loga yazıldı. Yanlış parolalar çoğu zaman doğru parolanın bir harf ya da rakam kaymış hâlidir; burada 2026 yerine 2025. Logları okuyabilen herkes artık parolayı neredeyse biliyor ve loglar genellikle veritabanından çok daha geniş bir kitleye açık: destek ekibi, log platformu, yedekler.

İkincisi log injection (CWE-117). E-posta alanındaki \n karakterleri loga olduğu gibi geçti ve ortaya info: Program[0] Login succeeded for grace@example.com diye okunan sahte bir satır çıktı. Konsol formatter'ı devam satırlarını girintilediği için burada göze batıyor, ama logları satır satır taşıyan bir araçta ya da grep "Login succeeded" ile yapılan bir aramada bu satır gerçeğinden ayırt edilemez. Saldırgan böylece izini karıştırabilir ya da soruşturmayı yanlış bir hesaba yönlendirebilir.

Üçüncüsü görünmeyen satır: 403 hiç loglanmadı. ASP.NET Core yetkilendirme reddini kendisi loglar, ama Information seviyesinde. Şablondaki "Microsoft.AspNetCore": "Warning" satırı bu kaydı yutuyor; yetkisiz erişim denemesi iz bırakmadan geçiyor.

Structured Logging ve JSON

Düzeltmenin ilk adımı string interpolation'ı bırakmak. Interpolation, mesajı loglama sistemine ulaşmadan önce tek bir string'e çevirir; alanlar kaybolur ve girdiyle metin birbirine karışır. Template kullanınca değerler ayrı alanlar olarak kalır. .NET'te bunun en temiz yolu source generator'lı [LoggerMessage]:

public static partial class SecurityLog
{
    [LoggerMessage(EventId = 1001, Level = LogLevel.Warning, Message = "Login failed for {Email} from {RemoteIp}")]
    public static partial void LoginFailed(ILogger logger, [PersonalData] string email, string? remoteIp);

    [LoggerMessage(EventId = 1002, Level = LogLevel.Information, Message = "Login succeeded for {Email}")]
    public static partial void LoginSucceeded(ILogger logger, [PersonalData] string email);
}

Parola parametresi yok; loga yazılmasının tek yolu birinin bu method'u değiştirmesi, bu da code review'da görünür. Her güvenlik olayının sabit bir EventId'si var; alarm kurallarını metin yerine bu numaraya bağlamak, mesaj değişince kuralın sessizce bozulmasını önler. [PersonalData] attribute'una redaction bölümünde geleceğim.

Template tek başına log injection'ı çözmez; düz metin formatter'ında satır sonu yine satır sonudur. Asıl koruma, her kaydı tek satırlık bir JSON object'i olarak yazan ve özel karakterleri kaçışlayan bir formatter:

builder.Logging.ClearProviders();
builder.Logging.AddJsonConsole(options =>
{
    options.IncludeScopes = true;
    options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ";
    options.UseUtcTimestamp = true;
});

Neyi Loglamalı, Neyi Asla

Loglanması gerekenler: başarılı ve başarısız girişler, lockout, parola ve MFA değişiklikleri, yetkilendirme reddi, rol ve yetki değişiklikleri, admin işlemleri, yüksek oranda input validation hatası ve sunucu tarafındaki beklenmedik hatalar. Her kayıtta ne olduğu, ne zaman, kimin adına ve hangi istekte olduğu bulunmalı.

Asla loglanmayacaklar: parolalar (yanlış olanlar dahil), access ve refresh token'lar, session kimlikleri ve cookie değerleri, API anahtarları, connection string'ler, kart numaraları. İstek ve cevap gövdelerini toptan loglamak da bu listedeki her şeyi loga taşımanın en kısa yoludur. Kişisel veriyi (e-posta, telefon, adres, IP) ise gerektiği kadar ve korunarak logla.

Yetkilendirme reddini görünür kılmak için o kategorinin seviyesini açıyorum:

"Logging": {
  "LogLevel": {
    "Default": "Information",
    "Microsoft.AspNetCore": "Warning",
    "Microsoft.AspNetCore.Authorization": "Information"
  }
}

Aynı 403 isteği artık şu kaydı bırakıyor; mesajdaki satır sonu JSON içinde \n olarak kaçışlanmış, kayıt tek satır kalıyor:

{"Timestamp":"2026-11-06T19:29:57.287Z","EventId":2,"LogLevel":"Information","Category":"Microsoft.AspNetCore.Authorization.DefaultAuthorizationService","Message":"Authorization failed. These requirements were not met:\nRolesAuthorizationRequirement:User.IsInRole must be true for one of the following roles: (admin)",...,"Scopes":[{"Message":"SpanId:bb48461296d68333, TraceId:5ebd26585671ced3ecdb4cf50b22f090, ParentId:0000000000000000",...},...,{"Message":"RequestPath:/admin/reports RequestId:0HNPS7HBMHIHT:00000001",...}]}

Framework'ün kaydı neyin reddedildiğini söylüyor ama kimin reddedildiğini söylemiyor. Bunu kendi olayın olarak loglamak istersen IAuthorizationMiddlewareResultHandler ile reddi yakalayıp kullanıcı kimliğini de yazabilirsin.

Redaction: Kişisel Veriyi Korumak

E-postayı hiç loglamamak, aynı hesaba yönelik denemeleri birbirine bağlamayı imkânsız kılar. Düz yazmak da kişisel veriyi log platformuna taşır. Arada bir yol var: Microsoft.Extensions.Compliance.Redaction ve Microsoft.Extensions.Telemetry paketleriyle (lab'da 10.11.0) veriyi sınıflandırıp loglama sırasında dönüştürmek.

builder.Logging.EnableRedaction();
builder.Services.AddRedaction(redaction =>
{
    redaction.SetHmacRedactor(options =>
    {
        options.KeyId = 1;
        // Lab anahtarı; gerçekte user-secrets ya da vault'tan gelir
        options.Key = builder.Configuration["Redaction:HmacKey"]!;
    }, new DataClassificationSet(LabTaxonomy.Personal));
});

public static class LabTaxonomy
{
    public static string Name => "Bookstore";
    public static DataClassification Personal => new(Name, nameof(Personal));
}

public sealed class PersonalDataAttribute() : DataClassificationAttribute(LabTaxonomy.Personal);

[PersonalData] ile işaretlenen her parametre loga yazılmadan önce HMAC'ten geçiyor. Aynı dört isteğin kayıtları:

{"Timestamp":"2026-11-06T19:29:57.245Z","EventId":1001,"LogLevel":"Warning","Category":"Program","Message":"Login failed for 1:qS6Ssya6\u002BjKbvL2qOH1itw== from ::1",...,"Scopes":[{"Message":"SpanId:c41e481a076b886c, TraceId:9fed5c1a67359f0578c72ef1fb4d952e, ParentId:0000000000000000",...},...]}
{"Timestamp":"2026-11-06T19:29:57.259Z","EventId":1001,"LogLevel":"Warning","Category":"Program","Message":"Login failed for 1:4BsnaV507/FyBtOCAOXIVQ== from ::1",...}
{"Timestamp":"2026-11-06T19:29:57.278Z","EventId":1002,"LogLevel":"Information","Category":"Program","Message":"Login succeeded for 1:qS6Ssya6\u002BjKbvL2qOH1itw==",...}

Ada'nın e-postası iki kayıtta da aynı değere dönüştü, yani "aynı hesaba beş başarısız deneme" sorgusu hâlâ çalışıyor, ama logu okuyan e-postayı görmüyor. Baştaki 1: anahtar numarası; anahtarı değiştirdiğinde eski ve yeni kayıtları ayırt etmeni sağlar. Satır sonu içeren sahte e-posta da tek bir değere dönüştü ve sahte satır ortadan kalktı. Yanlış parola ise hiçbir yerde yok. IP adresini düz bıraktım, çünkü rate limiting ve olay incelemesi için gerekiyor; ama IP de kişisel veri sayılır, saklama süresini buna göre belirle.

Korelasyon ve Alarm

Başarısız girişin istemciye dönen cevabına bak:

$ curl -i -X POST http://localhost:5155/login -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"Dev-only-Bookstore-2025"}'
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json
...

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.2","title":"Unauthorized","status":401,"detail":"Invalid email or password.","traceId":"00-9fed5c1a67359f0578c72ef1fb4d952e-c41e481a076b886c-00"}

AddProblemDetails() cevaba traceId ekliyor ve bu değer redaction bölümündeki ilk log kaydının scope'undaki TraceId:9fed5c1a67359f0578c72ef1fb4d952e ve SpanId:c41e481a076b886c ile birebir aynı. Kullanıcı destek ekibine bu numarayı verdiğinde, ilgili kayıtları tek bir aramayla bulursun. Servisler arasında da W3C trace context aynı kimliği taşır.

Alarm tarafı uygulamanın değil log platformunun işi, ama kuralları sen tanımlarsın. İyi başlangıçlar: aynı hesap için kısa sürede çok sayıda 1001, aynı IP'den çok sayıda farklı hesaba 1001 (credential stuffing), herhangi bir admin hesabında lockout, kısa sürede çok sayıda yetkilendirme reddi ve 500 sayısındaki ani artış. .NET 10'da Identity, aspnetcore.identity.user.check_password_attempts gibi metrikler de yayınlıyor; sayıya dayalı alarmlar için loglardan daha ucuzdur. Her alarmın bir sahibi ve "alarm gelince ne yapılır" diye yazılmış kısa bir notu olsun; kimsenin sahiplenmediği bir alarm, kimsenin okumadığı bir logdan farksızdır.

Sık Düşülen Tuzaklar

İlk tuzak, Microsoft.AspNetCore kategorisini Warning'e çekip framework'ün güvenlik kayıtlarını da kaybetmek. Gürültüyü azaltırken Microsoft.AspNetCore.Authorization gibi ihtiyaç duyduğun kategorileri ayrıca aç.

İkincisi, istek ve cevap gövdelerini "hata ayıklama için" loglayan bir middleware'i Production'da açık bırakmak. ASP.NET Core'un HTTP logging middleware'i varsayılan olarak yalnızca izin verilen header'ları yazar; o listeye Authorization ya da Cookie ekleme, gövde loglamayı da giriş gibi hassas endpoint'lerde kapalı tut.

Üçüncüsü, logun kendisini korumamak. Log dosyasını uygulamanın yazabildiği ama silemediği bir yere gönder; logu değiştirebilen bir saldırgan izini de silebilir. Loglar hassas veri içerebileceği için erişimi de veritabanı kadar sıkı tut.

Dördüncüsü, .NET 10'daki bir değişikliği bilmemek: exception handler middleware, bir IExceptionHandler tarafından işlenen exception'lar için artık kendi log kaydını yazmıyor. Bir sonraki yazıda bunu gerçek çıktıyla göreceğiz.

Kontrol Listesi

Başarılı ve başarısız girişler, lockout, yetkilendirme reddi ve admin işlemleri loglanıyor mu, ve Production log seviyeleri bu kayıtları gerçekten geçiriyor mu? Parola, token, cookie ya da connection string loga hiçbir yoldan düşmüyor mu?

Log çağrıları interpolation yerine template ya da [LoggerMessage] mı kullanıyor, ve formatter satır sonlarını kaçışlıyor mu? Kişisel veri redaction ile korunuyor mu? Hata cevaplarında traceId var mı ve loglarla eşleşiyor mu? Güvenlik olaylarının sabit EventId'leri, bunlara bağlı alarmları ve alarmların sahipleri var mı? Loglar uygulamanın silemeyeceği bir yerde mi duruyor?

Küçük Bir Deneme

★☆☆ Zafiyetli sürümde AddJsonConsole'u açıp aynı satır sonlu isteği tekrar gönder. Sahte satırın JSON içinde nasıl göründüğüne bak ve interpolation'ın yine de neden sorun olduğunu düşün.

★★☆ IAuthorizationMiddlewareResultHandler ile yetkilendirme reddini yakalayan ve kullanıcı kimliğini HMAC'li olarak loglayan kendi SecurityLog.AccessDenied olayını ekle.

★★★ Aynı HMAC değeri için bir dakikada beşten fazla 1001 kaydı geldiğinde konsola uyarı basan küçük bir BackgroundService yaz. Gerçek bir alarm sisteminin yerini tutmaz, ama alarm kuralının mantığını uygulamanın içinde denemiş olursun.

Loglar bize bir şeylerin ters gittiğini söylüyor. Bir sonraki yazıda ters giden şeyin kendisine bakıyoruz: A10 Mishandling of Exceptional Conditions, yani fail open ve fail closed, exception'larla bilgi sızdırmak, IExceptionHandler, ProblemDetails, timeout ve yutulmuş hatalar.