ASP.NET Core'da Güvenlik Olaylarını Gözlemlemek: Authentication, Authorization ve Identity Metrikleri, Audit Trail ve Alarm
.NET 10'un authentication, authorization ve Identity metrikleri 401, 403 ve hesap kilitlenmelerini sayar ama kullanıcı adını ve policy adını bilinçli olarak taşımaz; kim, neyi, ne zaman sorusu audit trail'in işidir. Metrikleri OpenTelemetry ve Prometheus ile topluyor, gerçek etiketleri testle doğruluyor, alarm kuralları yazıyor ve değişikliği yakalayan hash zincirli bir audit trail kuruyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (ASP.NET Core 10.0.13 runtime) ile derlenip denenmiştir; OpenTelemetry.Extensions.Hosting 1.20.1, OpenTelemetry.Exporter.Prometheus.AspNetCore 1.20.1-beta.1, Microsoft.Extensions.Diagnostics.Testing 10.11.0 ve Prometheus 3.16.0 kullanılmıştır.
Önce Tahmin Et
Bookstore'da Alan'ın hesabına art arda yanlış parola giriliyor ve hesap kilitleniyor. Aynı dakikalarda Grace, yetkisi olmayan bir admin endpoint'ine 46 istek gönderiyor. .NET 10'un yerleşik metrikleriyle bu iki event'ten hangisini, ne kadar ayrıntıyla görebilirsin? Metrik sana kilitlenen hesabın adını söyler mi? Reddedilen isteğin hangi policy'ye takıldığını söyler mi?
İkisine de cevap "hayır" ve bu bir eksik değil, bilinçli bir tasarım. Bu yazıda serinin önceki yazılarında kurduğumuz authorization, antiforgery, rate limiting ve Kestrel limitlerinin production'da nasıl görüneceğini kuruyoruz: .NET 10'un authentication, authorization ve Identity metrikleri, OpenTelemetry ile toplama, Prometheus alarm kuralları ve metriklerin söyleyemediği "kim, neyi, ne zaman" sorusu için bir audit trail. Log'a neyin yazılıp neyin asla yazılmayacağı, OWASP (Open Worldwide Application Security Project) Top 10 listesini anlatan Web Güvenliği serisinin "OWASP A09: Security Logging and Alerting Failures" yazısının konusu. Burada onu tekrar etmiyoruz.
.NET 10'un Güvenlik Metrikleri
.NET 10 ile ASP.NET Core üç yeni meter yayınlıyor. Microsoft.AspNetCore.Authentication meter'ı aspnetcore.authentication.authenticate.duration histogramını ve challenges, forbids, sign_ins, sign_outs sayaçlarını üretir. Microsoft.AspNetCore.Authorization meter'ında tek bir sayaç var: aspnetcore.authorization.attempts, sonucu success ya da failure olarak etiketler. Microsoft.AspNetCore.Identity meter'ı ise UserManager ve SignInManager işlemlerini sayar: parola kontrolleri, sign-in sonuçları (success, failure, locked_out), kullanıcı oluşturma ve güncelleme süreleri, token üretimi ve doğrulaması. Bunlara .NET 8'den beri var olan Microsoft.AspNetCore.RateLimiting meter'ını da ekliyoruz.
OpenTelemetry ile Toplamak
Meter'lar kimse dinlemezse hiçbir maliyet üretmez; OpenTelemetry SDK'sına (software development kit) hangi meter'ları istediğimizi söylüyoruz ve Prometheus'un çekebileceği bir endpoint açıyoruz:
builder.Services.AddOpenTelemetry().WithMetrics(metrics => metrics
.AddMeter("Microsoft.AspNetCore.Authentication", "Microsoft.AspNetCore.Authorization",
"Microsoft.AspNetCore.Identity", "Microsoft.AspNetCore.RateLimiting", SecurityMetrics.MeterName)
.AddPrometheusExporter());
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.MapPrometheusScrapingEndpoint();
Prometheus exporter paketi hâlâ beta; production'da genellikle OTLP (OpenTelemetry Protocol) ile bir collector'a gönderip oradan Prometheus'a ya da başka bir backend'e aktarırsın. Ayrıca /metrics endpoint'ini internete açık bırakma; ayrı bir port ya da ağ politikasıyla yalnız Prometheus'un erişebileceği yere koy. Yerel uygulamaya senaryoyu oynattım: üç anonim sipariş isteği, Alan için altı yanlış parola, Ada'nın iki rol ataması ve Grace'in admin endpoint'ine üç isteği. /metrics çıktısından ilgili satırlar:
aspnetcore_authentication_challenges_total{otel_scope_name="Microsoft.AspNetCore.Authentication"} 3
aspnetcore_authentication_forbids_total{otel_scope_name="Microsoft.AspNetCore.Authentication"} 3
aspnetcore_authentication_sign_ins_total{otel_scope_name="Microsoft.AspNetCore.Authentication",aspnetcore_authentication_scheme="Identity.Application"} 2
aspnetcore_authorization_attempts_total{otel_scope_name="Microsoft.AspNetCore.Authorization",aspnetcore_authorization_result="failure",aspnetcore_user_is_authenticated="false"} 3
aspnetcore_authorization_attempts_total{otel_scope_name="Microsoft.AspNetCore.Authorization",aspnetcore_authorization_result="success",aspnetcore_user_is_authenticated="true"} 3
aspnetcore_authorization_attempts_total{otel_scope_name="Microsoft.AspNetCore.Authorization",aspnetcore_authorization_result="failure",aspnetcore_user_is_authenticated="true"} 3
aspnetcore_identity_sign_in_check_password_attempts_total{otel_scope_name="Microsoft.AspNetCore.Identity",aspnetcore_identity_sign_in_result="failure",aspnetcore_identity_user_type="Microsoft.AspNetCore.Identity.IdentityUser"} 4
aspnetcore_identity_sign_in_check_password_attempts_total{otel_scope_name="Microsoft.AspNetCore.Identity",aspnetcore_identity_sign_in_result="locked_out",aspnetcore_identity_user_type="Microsoft.AspNetCore.Identity.IdentityUser"} 2
aspnetcore_identity_sign_in_check_password_attempts_total{otel_scope_name="Microsoft.AspNetCore.Identity",aspnetcore_identity_sign_in_result="success",aspnetcore_identity_user_type="Microsoft.AspNetCore.Identity.IdentityUser"} 2
aspnetcore_rate_limiting_requests_total{otel_scope_name="Microsoft.AspNetCore.RateLimiting",aspnetcore_rate_limiting_policy="login",aspnetcore_rate_limiting_result="acquired"} 8
bookstore_audit_events_total{otel_scope_name="Bookstore.Security",bookstore_audit_action="role_granted"} 2
Alan'ın altı denemesi 4 failure ve 2 locked_out olarak sayıldı: MaxFailedAccessAttempts = 5 olduğu için beşinci yanlış parola hesabı kilitledi ve o deneme de locked_out sonucuyla döndü. Authorization tarafında anonim reddedilmeler (401) ile authenticate olmuş kullanıcının reddedilmeleri (403) aspnetcore_user_is_authenticated etiketiyle ayrılıyor; alarm kurarken bu ayrım çok değerli, çünkü ikincisi neredeyse her zaman ya bir hata ya da bir yetki yoklaması.
Metrik Neyi Söylemez?
Çıktıda kullanıcı adı yok, IP (Internet Protocol) adresi yok. Bu bilinçli: her farklı etiket değeri yeni bir zaman serisi demek ve kullanıcı adı gibi sınırsız bir etiket metrik sistemini birkaç gün içinde çökertir; üstelik kişisel veriyi metrik sistemine taşır. "Hangi hesap kilitlendi" sorusunun cevabı log'da ya da audit trail'de.
İki sürpriz daha var. Birincisi, metrikte policy adı da yok. Microsoft Learn'deki tabloda aspnetcore.authorization.policy etiketi policy kullanıldığında var diye geçiyor; ama RequireAuthorization("Admin") ile korunan endpoint'te middleware policy'yi önce birleştirdiği için ad etikete taşınmadı. İkincisi, dokümandaki etiket adı user.is_authenticated, gerçek çıktıda ise aspnetcore.user.is_authenticated. İkisini de bir test ile sabitledim; doküman ile davranış arasındaki bu tür farklar, alarm sorgusunu yazdıktan sonra değil önce ortaya çıkmalı:
[Fact]
public async Task Forbidden_request_is_counted_as_authorization_failure()
{
string db = Path.Combine(Path.GetTempPath(), $"bookstore-{Guid.NewGuid():N}.db");
await using WebApplicationFactory<Program> factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(b => b.UseSetting("ConnectionStrings:Bookstore", $"Data Source={db}"));
HttpClient client = factory.CreateClient();
IMeterFactory meterFactory = factory.Services.GetRequiredService<IMeterFactory>();
using MetricCollector<long> attempts = new(meterFactory, "Microsoft.AspNetCore.Authorization", "aspnetcore.authorization.attempts");
HttpResponseMessage login = await client.PostAsJsonAsync("/login",
new LoginRequest("grace", "dev-only-Passw0rd!"), TestContext.Current.CancellationToken);
HttpResponseMessage response = await client.GetAsync("/admin/audit/verify", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
CollectedMeasurement<long> measurement = Assert.Single(attempts.GetMeasurementSnapshot());
Assert.Equal("failure", measurement.Tags["aspnetcore.authorization.result"]);
// endpoint policy'si middleware'de birleştirildiği için ad etikete taşınmıyor
Assert.False(measurement.Tags.ContainsKey("aspnetcore.authorization.policy"));
Assert.Equal(true, measurement.Tags["aspnetcore.user.is_authenticated"]);
}
MetricCollector<T>, Microsoft.Extensions.Diagnostics.Testing paketinden geliyor ve uygulamanın kendi IMeterFactory'sine bağlandığı için yalnız bu test sunucusunun ölçümlerini topluyor. Hangi policy'nin reddettiğini görmek istiyorsan bunu serinin ilk yazısındaki IAuthorizationMiddlewareResultHandler'da kendi log'unla yaparsın.
Uygulamaya Özel Metrik: Bookstore.Security
Framework'ün bilmediği güvenlik event'leri de var: rol ataması, admin'in başka bir kullanıcı adına işlem yapması (impersonation), API (Application Programming Interface) key üretimi. Bunlar için küçük bir meter yazıyoruz. Etiket olarak yalnız sınırlı sayıda değeri olan aksiyon adını kullanıyoruz:
public sealed class SecurityMetrics
{
public const string MeterName = "Bookstore.Security";
private readonly Counter<long> _auditEvents;
public SecurityMetrics(IMeterFactory meterFactory)
{
Meter meter = meterFactory.Create(MeterName);
_auditEvents = meter.CreateCounter<long>("bookstore.audit.events", unit: "{event}",
description: "Security-relevant actions written to the audit trail.");
}
public void AuditEventRecorded(string action) =>
_auditEvents.Add(1, new KeyValuePair<string, object?>("bookstore.audit.action", action));
}
IMeterFactory ile oluşturulan meter DI (dependency injection) container'ının ömrüne bağlıdır ve testte izole edilebilir; new Meter(...) ile statik bir alan açmak, test'ler arasında ölçümlerin karışmasına yol açar.
Alarm Kuralları
Metrikler toplanıyor; şimdi hangi durumların bir insanı uyandırması gerektiğine karar veriyoruz. Prometheus kurallarını kendi geçici container'ımda (Prometheus 3.16.0) yerel uygulamayı 5 saniyede bir çekecek şekilde çalıştırdım. Eşikler demo için küçük:
groups:
- name: bookstore-security
rules:
- alert: AuthorizationFailureSpike
expr: sum(rate(aspnetcore_authorization_attempts_total{aspnetcore_authorization_result="failure"}[1m])) > 0.5
labels: { severity: warning }
annotations:
summary: "More than 30 authorization failures per minute"
- alert: AccountLockedOut
expr: sum(increase(aspnetcore_identity_sign_in_check_password_attempts_total{aspnetcore_identity_sign_in_result="locked_out"}[5m])) > 0
labels: { severity: warning }
annotations:
summary: "Sign-in attempts against a locked-out account"
- alert: LoginRateLimited
expr: sum(increase(aspnetcore_rate_limiting_requests_total{aspnetcore_rate_limiting_result!="acquired"}[5m])) > 0
labels: { severity: info }
annotations:
summary: "Login requests rejected by the rate limiter"
- alert: PrivilegeGranted
expr: sum by (bookstore_audit_action) (increase(bookstore_audit_events_total{bookstore_audit_action="role_granted"}[10m])) > 0
labels: { severity: info }
annotations:
summary: "A role was granted; check the audit trail"
Senaryonun ardından Grace'in 25 saniye boyunca admin endpoint'ine 46 istek göndermesi ve Ada'nın üçüncü bir rol ataması yapmasıyla Prometheus API'sinin döndüğü alarmlar:
firing AuthorizationFailureSpike
firing AccountLockedOut
firing LoginRateLimited
firing PrivilegeGranted role_granted
Burada öğretici bir hata yaptım. İlk iki rol atamasından sonra PrivilegeGranted alarmı tetiklenmedi. Sayaç, uygulama açıldıktan sonra ilk kez iki atamayla birlikte oluştu ve Prometheus'un gördüğü ilk değer 2 oldu. increase() iki örnek arasındaki farkı ölçer; ilk örnekten önce ne olduğunu bilemez. Nadir event'leri sayan seriler için bu ciddi bir sorun: ilk event hiçbir zaman alarm üretmeyebilir. Çözüm, sayacı uygulama açılışında bilinen aksiyonlar için sıfırla yayınlamak (Add(0, ...)) ya da bu tür event'leri metrik yerine doğrudan audit kaydından alarm üreten bir kurala bağlamak.
Antiforgery hataları için bir framework metriği yok; serinin antiforgery yazısındaki filter'a Bookstore.Security meter'ında bir sayaç eklemek aynı pattern'le olur. Data Protection yazısındaki Unprotect ticket failed ise bir log satırı; onu metrik sistemine değil log tabanlı bir alarma bağlamak daha doğru.
Audit Trail: Kim, Neyi, Ne Zaman
Log, sistemin ne yaptığını anlatır ve rotasyonla silinir. Audit trail ise bir insanın ne yaptığının hesabıdır: kim, kime, hangi yetkiyi verdi; hangi admin hangi kullanıcı adına işlem yaptı. Ayrı bir tabloda, ayrı bir saklama süresiyle tutulur ve bir incident incelemesinde ilk bakılan yerdir. Bookstore'da rol ataması şöyle kaydediliyor:
admin.MapPost("/users/{userName}/roles/{role}", async (string userName, string role, ClaimsPrincipal actor,
UserManager<IdentityUser> users, AuditTrail audit) =>
{
IdentityUser? user = await users.FindByNameAsync(userName);
if (user is null)
{
return Results.NotFound();
}
IdentityResult result = await users.AddToRoleAsync(user, role);
if (!result.Succeeded)
{
return Results.ValidationProblem(result.Errors.ToDictionary(e => e.Code, e => new[] { e.Description }));
}
await audit.RecordAsync(actor.Identity!.Name!, "role_granted", userName, $"role={role}");
return Results.NoContent();
});
Audit kaydı, işlemin başarılı olduğu kesinleştikten sonra yazılıyor; başarısız denemeler de değerlidir ama ayrı bir aksiyon adıyla (role_grant_failed) kaydedilmeli. Daha sağlam bir kurulumda rol değişikliği ile audit kaydı aynı veritabanı transaction'ında yazılır, böylece biri olup diğeri olmayan bir durum kalmaz.
Değiştirilemezlik: Hash Zinciri ve Ötesi
Veritabanına yazma yetkisi olan biri audit kaydını sessizce değiştirebilir. Her kaydın hash'ini bir öncekinin hash'iyle zincirlersek, değişikliği engelleyemeyiz ama fark edebiliriz:
public sealed class AuditTrail(BookstoreDbContext db, TimeProvider clock, SecurityMetrics metrics)
{
private const string Genesis = "GENESIS";
public async Task RecordAsync(string actor, string action, string target, string? details = null,
CancellationToken cancellationToken = default)
{
// tek yazar varsayımı; çok instance'ta sıralı yazım için DB tarafında kilit gerekir
string previous = await db.AuditEvents.OrderByDescending(e => e.Id).Select(e => e.Hash)
.FirstOrDefaultAsync(cancellationToken) ?? Genesis;
AuditEvent entry = new()
{
OccurredAt = clock.GetUtcNow(),
Actor = actor,
Action = action,
Target = target,
Details = details,
PreviousHash = previous,
Hash = ""
};
entry.Hash = ComputeHash(entry);
db.AuditEvents.Add(entry);
await db.SaveChangesAsync(cancellationToken);
metrics.AuditEventRecorded(action);
}
public async Task<long?> FindFirstBrokenLinkAsync(CancellationToken cancellationToken = default)
{
string previous = Genesis;
await foreach (AuditEvent entry in db.AuditEvents.AsNoTracking().OrderBy(e => e.Id)
.AsAsyncEnumerable().WithCancellation(cancellationToken))
{
if (entry.PreviousHash != previous || entry.Hash != ComputeHash(entry))
{
return entry.Id;
}
previous = entry.Hash;
}
return null;
}
private static string ComputeHash(AuditEvent e)
{
string canonical = string.Join('\n', e.PreviousHash, e.OccurredAt.ToUnixTimeMilliseconds(),
e.Actor, e.Action, e.Target, e.Details ?? "");
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical)));
}
}
Yerel veritabanında Alan'a verilen admin rolünü elle support yaptım; yani birinin izlerini örttüğü durumu taklit ettim:
$ sqlite3 Bookstore.Api/bookstore.db "select Id, Actor, Action, Target, Details, substr(PreviousHash,1,12), substr(Hash,1,12) from AuditEvents;"
1|ada|role_granted|grace|role=support|GENESIS|19F6040A12CF
2|ada|role_granted|alan|role=admin|19F6040A12CF|72361A39EA98
$ sqlite3 Bookstore.Api/bookstore.db "update AuditEvents set Details='role=support' where Id=2;"
$ curl -s -b run2/ada http://127.0.0.1:19350/admin/audit/verify
{"intact":false,"firstBrokenId":2}
Zincirin sınırını bilmek önemli. Veritabanında tam yetkisi olan biri bütün zinciri baştan hesaplayıp yeniden yazabilir; hash zinciri yalnız kısmi değişikliği yakalar. Gerçek değiştirilemezlik için zincirin son hash'ini düzenli aralıklarla başka bir yere (ayrı bir hesapta tutulan bir log sistemine, object lock özellikli bir blob storage'a) yazmak, uygulamanın veritabanı kullanıcısına audit tablosunda yalnız INSERT yetkisi vermek ve kayıtları WORM (write once, read many) özellikli bir depoya akıtmak gerekir. SQL (Structured Query Language) Server'ın ledger tabloları ve PostgreSQL için pgAudit eklentisi gibi veritabanı özellikleri de bu ihtiyacın parçalarını karşılıyor.
Üretimde Dikkat
Alarm sayısını küçük tut. Bir güvenlik alarmı her tetiklendiğinde biri bakmalı; günde on kez çalan bir alarm bir hafta içinde görmezden gelinir. Bu yazıdaki dört alarmdan yalnız ikisi (AuthorizationFailureSpike ve AccountLockedOut) gece birini uyandırmalı, diğerleri bir kanala mesaj olarak gitmeli. Eşikleri tahminle değil, iki hafta boyunca normal trafiğin dağılımına bakarak koy.
Kilitlenen hesap alarmı tek başına bir şey söylemez; asıl soru kaç farklı hesabın kilitlendiği. Bir kullanıcının parolasını unutması normaldir, aynı saatte yüz hesabın kilitlenmesi credential stuffing'dir. Metrikte hesap adı olmadığı için bu soruyu log sorgusuyla cevaplarsın: lockout log'larını hesap bazında say.
Audit trail'in saklama süresi log'unkinden uzun olmalı ve silinmesi ayrı bir yetki gerektirmeli. KVKK (Kişisel Verilerin Korunması Kanunu) ve GDPR (General Data Protection Regulation) gibi düzenlemeler bir yandan saklamayı, bir yandan kişisel veriyi minimumda tutmayı ister; audit kaydına kullanıcının e-postasını değil kimliğini yaz.
Sık Düşülen Tuzaklar
Metriğe kullanıcı adı, e-posta ya da IP etiketi koymak. Cardinality patlaması ve metrik sisteminde kişisel veri; bu bilgi log'da ve audit trail'de durmalı.
Nadir event'leri sayan sayaçlarda increase()'in ilk event'i kaçırması. Yukarıdaki PrivilegeGranted örneği gibi; sayacı açılışta sıfırla yayınla.
Dokümandaki etiket adına güvenip alarm yazmak. Bu yazıdaki aspnetcore.user.is_authenticated örneği gibi, gerçek etiketi /metrics çıktısından ya da bir testten doğrula.
Audit kaydını işlemden önce yazmak. Başarısız bir işlem "yapıldı" olarak görünür; kaydı sonuç kesinleştikten sonra ve mümkünse aynı transaction'da yaz.
Küçük Bir Deneme
★☆☆ SecurityMetrics'e uygulama açılışında role_granted için Add(0, ...) çağrısı ekle, Prometheus'u yeniden başlat ve ilk rol atamasında PrivilegeGranted alarmının tetiklendiğini doğrula.
★★☆ Antiforgery yazısındaki ValidateAntiforgeryFilter'ı bu projeye taşı ve hataları Bookstore.Security meter'ında bookstore.antiforgery.failures sayacıyla say. Dakikada 10'dan fazla hata için bir alarm kuralı yaz.
★★★ Audit zincirinin son hash'ini her 100 kayıtta bir ayrı bir dosyaya "checkpoint" olarak yaz ve doğrulamayı checkpoint'lerle karşılaştıracak şekilde genişlet. Tüm zinciri yeniden hesaplayan bir saldırganı bu yöntemin nasıl yakaladığını test et.
Bu yazıyla serinin .NET 10 bloğu tamamlandı. Son yazıda .NET 11'in ASP.NET Core güvenliğine getirdiklerine bakıyoruz: Fetch Metadata tabanlı otomatik CSRF (cross-site request forgery) koruması, Device Bound Session Credentials ve TLS (Transport Layer Security) channel binding.