TL;DR
Analytics dashboard'un günde 100 ziyaretçi gösteriyorsa gerçek insan sayın yaklaşık 33'tür. Geri kalan %67 datacenter, VPN ve null ASN. Canlı prod sistemde PageHits + AttackProbes + IsSuspectNetwork üçlüsüyle bot/insan ayrımını yapıyorum, MERGE+OUTPUT sliding-window burst counter'la otomatik block ediyorum, KVKK uyumlu PII'siz tasarımla 90 gün saklıyorum. Tam SQL şema, kod ve gerçek 24 saatlik prod ölçümleri aşağıda.
Yazar notu: Bu yazıdaki SQL şemaları, kod ve metrikler bilalkose.com.tr canlı sistemden alındı. PageHits + AttackProbes tabloları 2026-05-10'da prod'a girdi; 24 saat içinde %33/%67 oranı, 157 attack probe, 246 backfill telemetry kaydı ölçüldü. Sub-Faz A++/A+++ tasarım kararları (datacenter ASN registry, IsSuspectNetwork flag, AttackBurstCounter MERGE+OUTPUT pattern) doğrudan üretim deneyiminden.
İçindekiler
- Security Telemetry Nedir, Neden Origin-Side Lazım?
- PageHits Middleware Mimarisi
- AttackProbes + ThreatScore Formülü
- AttackBurstCounter: MERGE+OUTPUT Sliding Window
- IsSuspectNetwork Flag: Datacenter / VPN / Null ASN
- MaxMind GeoIP Singleton Reader Pattern
- Log → AttackProbes Backfill ETL
- Origin Engelleme Döngüsü
- Real Visitor Admin Dashboard
- Prometheus Metric Export
- Privacy + KVKK: PII'siz Tasarım
- Sıkça Sorulan Sorular
- İlgili Yazılar
Security Telemetry Nedir, Neden Origin-Side Lazım?
Cloudflare gibi bir CDN edge'i kullanıyorsan saldırıların büyük çoğunluğunu zaten edge'de durduruyorsundur — Pillar 1 yazısında bunu detaylıca ele aldım. Ama edge stateless: pattern match, IP lookup, UA contains. Burst behavior, session geçmişi, ThreatScore birikimi gibi stateful kararlar origin'de verilmeli. Origin-side security telemetry buranın merkezi.
Edge yetmediği yerler
Edge filter'ı şu üç senaryoda yetersiz kalır:
- Low-and-slow attacker: Saatte 1 request, ama saatte farklı bir sensitive path. Edge "tek request" görüyor, pattern eşleştirmiyor; origin "aynı IP'den 8 saatte 8 farklı admin path" görüyor.
- Distributed credential stuffing: Farklı IP'lerden aynı user'a brute force. Edge IP rate limit'i aşmaz, origin user-bound lockout uygular.
- Behavioral anomaly: Authenticated kullanıcı normal davranıştan saparsa (gece 03:00'te 1000 page view), edge bilmez — origin user session context'iyle yakalar.
Analytics dashboard yanıltır
Daha temel bir problem: standart analytics tool'ları (GA, Plausible, CF Analytics) bot/datacenter trafiğini "gerçek visitor" olarak sayıyor. Sub-Faz A telemetry'mde 24 saat içinde 126 unique IP görünüyordu, ama detaylı analizde gerçek insan sayısı 25-30 civarıydı. Geriye kalan %75-80 datacenter scraper, VPN, null ASN trafiği — özellikle Mart 2026 sonrası AI crawler patlamasıyla artan oran.
Bu fark sadece "egom kırıldı" meselesi değil; iş kararlarını yanlış yere yönlendiriyor: hangi içerik gerçekten popüler, conversion rate ne, hangi locale yatırım yapmaya değer? Doğru karar için doğru sayıma ihtiyaç var.
KVKK m.5/2-(f) çerçevesinde meşru menfaat
Türkiye'de Raw IP saklamak, KVKK madde 5/2-(f) "veri sorumlusunun meşru menfaatleri için veri işlenmesinin zorunlu olması" hükmüne dayanıyor — sistem güvenliği meşru menfaat. AttackProbes tablosunda onay aranmıyor (saldırgan rıza istemiyor); PageHits için ise opsiyonel ConsentGranted flag'i var. 90 gün retention sonrası otomatik DELETE.

PageHits Middleware Mimarisi
Sistemin kalbi tek bir ASP.NET Core middleware: TrafficClassifierMiddleware. Her HTTP request'i 4 karar noktasından geçiriyor ve uygun tabloya yazıyor. Önceki VisitorAnalyticsMiddleware'i yerine geçti, gerçek visitor vs bot ayrımını burada yapıyorum.
4 karar noktası
Request gelir
↓
1. IpBlocklist aktif kayıt? ──── evet ──→ 403 Forbidden + log
↓ hayır
2. Attack pattern eşleşmesi? ── evet ──→ dbo.AttackProbes INSERT
↓ hayır
3. Meşru bot (Googlebot, Bingbot, IndexNowBot)? ── evet ──→ atla
↓ hayır
4. Gerçek-insan kabul → dbo.PageHits INSERT
Tek middleware, tek source-of-truth. Multiple middleware'lere bölünmüyor çünkü karar sıralaması kritik — IpBlocklist'i AttackProbes'tan önce kontrol etmen lazım yoksa bloklanmış IP DB'ye yine kayıt geçiyor.
dbo.PageHits şeması
CREATE TABLE dbo.PageHits (
Id BIGINT IDENTITY(1,1) PRIMARY KEY,
DayIso CHAR(10) NOT NULL, -- "2026-05-11"
VisitorHash CHAR(32) NOT NULL, -- MD5(IP + daily salt)
Path NVARCHAR(256) NOT NULL,
Culture CHAR(2) NULL, -- tr/en/ar
Referer NVARCHAR(512) NULL,
Country CHAR(2) NULL, -- MaxMind GeoLite2-City
City NVARCHAR(80) NULL,
Asn NVARCHAR(120) NULL,
AsnNumber INT NULL, -- MaxMind GeoLite2-ASN
StatusCode SMALLINT NULL,
RawIp NVARCHAR(45) NULL, -- IPv4 + IPv6
ConsentGranted BIT NOT NULL DEFAULT 0,
HitTime DATETIME2(0) NOT NULL DEFAULT SYSUTCDATETIME()
);
CREATE NONCLUSTERED INDEX IX_PageHits_DayIso_Hash
ON dbo.PageHits(DayIso, VisitorHash) INCLUDE (Path, HitTime);
İki kritik karar: (1) VisitorHash günlük rotating salt'lı MD5 — gün-aşırı korelasyon engelleniyor, KVKK uyumu kolaylaşıyor; (2) RawIp saklanıyor ama 90 gün retention sonrası otomatik DELETE. AttackProbes ve PageHits ayrı tablolar — saldırı kaydı analytics'i bozmuyor.
Dedup: 30 saniyelik in-memory cache
Yüksek-trafik bir page için aynı kullanıcı 30 saniye içinde 10-20 request atabilir (resim yüklenmesi, AJAX poll, vb.). Hepsini DB'ye yazmak gereksiz. IMemoryCache vh: prefix'iyle (IP, path) tuple'ı cache'liyorum, 30 saniye TTL:
var cacheKey = $"vh:{ipHash}:{path}";
if (_cache.TryGetValue(cacheKey, out _)) return; // duplicate atla
_cache.Set(cacheKey, true, PageDedupTtl);
// ... PageHits INSERT
Bu basit kontrol DB yükünün yaklaşık %70'ini düşürüyor — high-traffic page'lerde özellikle.
VisitorHash hesaplama
private static string ComputeVisitorHash(string ip, string dayIso)
{
var salt = _config["Security:VisitorHashSalt"]; // Vault'tan
var input = $"{ip}|{dayIso}|{salt}";
var bytes = Encoding.UTF8.GetBytes(input);
var hash = MD5.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
MD5 cryptographic değil ama VisitorHash'ı reverse engineering yapacak bir attacker zaten Raw IP'ye sahip olur (PageHits'te de var). Burada amaç salt'lı unique ID — sha256 overkill, MD5 yeterli. Salt günlük rotating olsa daha güvenli ama o noktada daily aggregate query'leri zorlaşıyor. Mevcut salt prod salt'ı sabit; tek-yön hash.

AttackProbes + ThreatScore Formülü
Saldırı denemelerinin kaydı için ayrı tablo. Niye ayrı? Üç sebep: (1) AttackProbes 19 kolon, PageHits 13 kolon — schema overlap az; (2) farklı retention politikaları (AttackProbes evidence, daha uzun tutulabilir); (3) farklı sorgu pattern'ları (AttackProbes ASN/category aggregate, PageHits VisitorHash daily count).
dbo.AttackProbes şeması ve PERSISTED ThreatScore
CREATE TABLE dbo.AttackProbes (
Id BIGINT IDENTITY(1,1) PRIMARY KEY,
HitTime DATETIME2(0) NOT NULL,
DayIso CHAR(10) NOT NULL,
Host NVARCHAR(80) NULL,
Source VARCHAR(20) NOT NULL, -- 'middleware' veya 'log_backfill'
RawIp NVARCHAR(45) NOT NULL,
Country CHAR(2) NULL,
City NVARCHAR(80) NULL,
Asn NVARCHAR(120) NULL,
AsnNumber INT NULL,
Method VARCHAR(10) NOT NULL,
Path NVARCHAR(512) NOT NULL,
QueryString NVARCHAR(1024) NULL,
UserAgent NVARCHAR(512) NULL,
Referer NVARCHAR(512) NULL,
StatusCode SMALLINT NULL,
Category VARCHAR(40) NOT NULL,
Severity TINYINT NOT NULL, -- 1-5
Confidence TINYINT NOT NULL, -- 1-100
ThreatScore AS (CAST(Severity AS INT) * Confidence / 20) PERSISTED,
PatternMatched NVARCHAR(120) NULL,
CloudflareRayId VARCHAR(20) NULL,
CONSTRAINT CK_AttackProbes_Severity CHECK (Severity BETWEEN 1 AND 5),
CONSTRAINT CK_AttackProbes_Confidence CHECK (Confidence BETWEEN 1 AND 100)
);
Kritik nokta: ThreatScore computed PERSISTED column. Insert anında hesaplanıyor, index'leyebiliyor, sorguda re-compute olmuyor. Bu, "threat ≥ 11" filter sorgularını hızlandıran detay.
ThreatScore formülü ve eşik kararı
ThreatScore = (Severity × Confidence) / 20
- Severity (1-5): saldırının teknik ciddiyeti (1=düşük, 5=kritik)
- Confidence (1-100): pattern match güveni (50=eşit ihtimal, 100=kesin)
Range: 0 ile 25 arası. AutoBlockThreshold = 11. Eski projede 12 idi ama integer division gözden kaçırıyordu: 3 × 75 / 20 = 11 (border-zone admin probe gibi orta-Severity yüksek-Confidence kombinasyonu), eşik 12 olunca bu yakalanmıyordu. 11'e indirilince false-positive de görülmedi.
Attack path pattern tablosu
AttackPaths static array, middleware'de path başlangıcı match'i yapıyor. Aşağıda yüksek-ThreatScore subset:
| Path | Kategori | Severity | Confidence | ThreatScore |
|---|---|---|---|---|
/.env |
secret_disclosure | 5 | 95 | 23 |
/.aws |
cloud_creds_probe | 5 | 95 | 23 |
/.ssh |
secret_disclosure | 5 | 95 | 23 |
/.htpasswd |
secret_disclosure | 5 | 90 | 22 |
/credentials |
cloud_creds_probe | 5 | 90 | 22 |
/database.yml |
secret_disclosure | 5 | 90 | 22 |
/.git/ |
vcs_disclosure | 4 | 95 | 19 |
/xmlrpc.php |
wordpress_probe | 4 | 90 | 18 |
/phpmyadmin |
phpmyadmin_probe | 4 | 90 | 18 |
/wp-admin |
wordpress_probe | 3 | 85 | 12 |
/admin.php |
admin_panel_probe | 3 | 80 | 12 |
Tam liste yaklaşık 50 pattern, 9 kategori. Yeni pattern eklemek statik array'e bir satır eklemek + pod restart kadar basit.
9 kategori kümesi
| Kategori | Anlam | Örnek pattern |
|---|---|---|
| secret_disclosure | Dosya açığa çıkarma | /.env, /.ssh, /.htpasswd |
| cloud_creds_probe | Bulut kimlik tarama | /.aws, /credentials, /config.json |
| vcs_disclosure | Sürüm kontrol açığa çıkarma | /.git/, /.svn/ |
| wordpress_probe | WP varsayım tarama | /wp-admin, /wp-login, /xmlrpc.php |
| phpmyadmin_probe | PMA paneli tarama | /phpmyadmin, /pma/, /myadmin |
| admin_panel_probe | Generic admin endpoint | /admin.php, /login.php, /console |
| exchange_probe | MS Exchange / OWA | /owa/, /ews/ |
| sqli_payload | Query string SQL injection | union select, ' or 1=1 |
| log4shell | Log4j JNDI exploit | ${jndi:ldap://...} |
Her kategori için ayrı Prometheus counter label var (bilal_attack_probe_blocks_total{category="..."}) — Grafana panel'inde kategori bazlı kırılım rahat.
📊 [GRAFİK: Son 24h kategori dağılım pie chart — secret_disclosure %48, cms-probe %29, sqli %10, vb.]
AttackBurstCounter: MERGE+OUTPUT Sliding Window
ThreatScore eşiği saldırı pattern'inin tek istekini yakalıyor. Ama bazı saldırgan davranışlar bunu aşıyor: trickle attacker dakikada 1 düşük-Severity request gönderiyor, hiçbiri tek başına 11 puanı aşmıyor ama 15 dakikada 5-10 farklı sensitive path tarıyor. Bunu yakalamak için sliding-window counter lazım.
dbo.AttackBurstCounter tablosu
CREATE TABLE dbo.AttackBurstCounter (
RawIp NVARCHAR(45) NOT NULL PRIMARY KEY,
WindowStart DATETIME2(0) NOT NULL,
Count INT NOT NULL,
LastHitAt DATETIME2(0) NOT NULL
);
Tek satır per IP. Pencere genişliği 15 dakika (BurstWindow = TimeSpan.FromMinutes(15)). Trigger eşiği 3 (AttackBurstThreshold = 3).
Atomic MERGE + OUTPUT SQL pattern
Race condition'a karşı atomic increment lazım — multi-replica K8s pod setup'ında pod-A ve pod-B aynı IP için aynı anda gelirse counter yarış kaybeder. SQL Server'ın MERGE + OUTPUT inserted.Count kombinasyonu bunu tek roundtrip'te yapıyor:
DECLARE @Now DATETIME2 = SYSUTCDATETIME();
DECLARE @WindowStart DATETIME2 = DATEADD(MINUTE, -15, @Now);
MERGE dbo.AttackBurstCounter AS target
USING (SELECT @ip AS RawIp, @Now AS NowTs) AS source
ON target.RawIp = source.RawIp
WHEN MATCHED AND target.WindowStart > @WindowStart
-- Aynı pencere içinde: counter++
THEN UPDATE SET Count = target.Count + 1, LastHitAt = @Now
WHEN MATCHED AND target.WindowStart <= @WindowStart
-- Pencere kaymış: counter=1, yeni window
THEN UPDATE SET Count = 1, WindowStart = @Now, LastHitAt = @Now
WHEN NOT MATCHED
-- İlk attack: insert
THEN INSERT (RawIp, WindowStart, Count, LastHitAt)
VALUES (@ip, @Now, 1, @Now)
OUTPUT inserted.Count AS NewCount;
OUTPUT inserted.Count sayesinde middleware tek query'de yeni counter'ı alıyor; if NewCount >= 3 → block trigger. Eski "SELECT then UPDATE" yaklaşımı multi-replica'da race condition'a düşüyordu, bu sorunu yakaladık ve MERGE'e geçtik.
Niye DB'de, in-memory dictionary'de değil?
Üç sebep:
- Multi-replica tutarlılık: Pod-A counter'ı pod-B görmüyor; DB tek source-of-truth
- Pod restart direnci: in-memory restart'ta sıfırlanır; DB persist
- Audit trail: "saldırgan ne zaman, kaç defa, hangi pencerede" sorgulanabilir
Trade-off: her saldırı request'ine DB roundtrip ekleniyor (~3-5ms). Ama saldırı request'leri zaten yavaş cevap alacak — origin'e gelmiş olması bile zaman kazandırmıyor. Normal request'ler bu yolu hiç görmüyor.
In-memory fallback (DB unreachable)
DB connection problem'i durumunda middleware crash etmemeli. IMemoryCache ab: prefix'iyle pod-local fallback:
if (dbUnavailable) {
var cacheKey = $"ab:{ip}";
int count = _cache.GetOrCreate(cacheKey, entry => {
entry.AbsoluteExpirationRelativeToNow = BurstWindow;
return 0;
});
_cache.Set(cacheKey, count + 1, BurstWindow);
return count + 1;
}
Multi-replica'da kayıp tolere ediliyor — pod-local counter pod-B'nin counter'ını bilmiyor. Trade-off: tam doğru sayım yerine "geçici çözüm". DB recovery'den sonra normal yol.
Hourly cleanup hosted service
AttackBurstCounterCleanupHostedService her saat çalışıyor, expired window'ları siliyor:
DELETE FROM dbo.AttackBurstCounter
WHERE WindowStart < DATEADD(MINUTE, -30, SYSUTCDATETIME());
30 dakika güvenlik marjı (window 15 dakika + 15 dakika buffer). DB büyümesi sınırlı kalıyor, query performance bozulmuyor.
📊 [METRIK: 24h burst-triggered block sayısı vs threshold-triggered block sayısı]
IsSuspectNetwork Flag: Datacenter / VPN / Null ASN
Sub-Faz A'da PageHits prod'a girdiğinde ilk 24 saatte sürpriz çıktı: 126 unique IP analytics'te göründü ama gerçek insan sayısı yaklaşık 25-30. Bu sayının ne kadar kirli olduğunu anında fark ettim: gerçek kullanıcı sayısını ölçemediğim sürece dashboard'a güvenemiyordum. Bu noktada Sub-Faz A+++ tasarımı çıktı.
Karar: ayır, silme
İki yaklaşım vardı: (1) datacenter trafiğini PageHits'ten dışlamak (filter at write), veya (2) tüm trafiği saklayıp flag'lemek (filter at read). İkincisi seçildi çünkü:
- Datacenter trafiği da bilgi taşıyor (scraper davranışı, hangi sayfalar tarınıyor)
- Karar geriye dönük değiştirilebilir (flag'i UPDATE'lemek silmekten kolay)
- Privacy audit için "ne tuttuğumuz" netlik daha önemli
PageHits.IsSuspectNetwork BIT kolonu
ALTER TABLE ile eklendi:
ALTER TABLE dbo.PageHits
ADD IsSuspectNetwork BIT NOT NULL CONSTRAINT DF_PageHits_IsSuspect DEFAULT 0;
-- Backfill mevcut kayıtları
UPDATE dbo.PageHits
SET IsSuspectNetwork = 1
WHERE AsnNumber IS NULL
OR AsnNumber IN (16509, 14618, 14061, 24940, 8075, /* ... 42 ASN */);
Middleware Classify step 8'de set ediliyor:
var asnNumber = geoIpResult?.AsnNumber;
bool isSuspect = DatacenterAsnRegistry.IsAnonymousOrDataCenter(asnNumber);
// PageHits INSERT'inde IsSuspectNetwork = isSuspect
IsAnonymousOrDataCenter karar mantığı
public static bool IsAnonymousOrDataCenter(int? asnNumber)
=> !asnNumber.HasValue || DataCenterAsns.Contains(asnNumber.Value);
İki koşul:
!asnNumber.HasValue(null ASN): MaxMind ASN database'inde yok. Modern internet'te tüm meşru ISP'leri çözüyor (Turkcell, TTNet, Vodafone TR, vb.), null = yeni allocation gap veya garbage IP. Sub-Faz A++'da bu kararla 60'a yakın sızıntı kaynağı düzeldi.DataCenterAsns.Contains(asnNumber.Value): 42 ASN listesi (tam liste Pillar 1'de) — AWS, GCP, Azure, DigitalOcean, OVH, Hetzner, M247 (VPN backbone), HostHero, IPXO, vb.
ISP'ler (Turkcell 16135, TTNet 9121, Vodafone TR 15897, Bharti Airtel IN 45609) dışında, false-positive riski sıfır.
Admin dashboard "Trafik özeti" kartı
┌─ Trafik Özeti (son 24 saat) ───────────────────────────┐
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐│
│ │ 15 │ │ 5 │ │ 10 │ │ 15 ││
│ │ Toplam │ │ Gerçek │ │ Şüpheli │ │ Block ││
│ │ unique │ │ (%33) │ │ (%67) │ │ (CF) ││
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘│
│ │
│ Gerçek vs Şüpheli oran: │
│ ████░░░░░░░░░░░░░░░░░░░░░░░░░░░ 33% gerçek │
│ │
│ Süre: [Bugün] [Son 7 gün] [Son 30 gün] │
└─────────────────────────────────────────────────────────┘
7 günlük rolling: 117 gerçek vs 246 toplam, %67.8 şüpheli oranı. Aylık trend takibi için zaman serisi tablosu altta.
Backfill 246 satır
Mevcut PageHits'te 246 satır vardı. Toplu UPDATE ile IsSuspectNetwork set edildi:
UPDATE dbo.PageHits
SET IsSuspectNetwork = 1
WHERE AsnNumber IS NULL OR AsnNumber IN (/* 42 ASN listesi */);
-- Doğrulama
SELECT
COUNT(*) AS Total,
SUM(CASE WHEN IsSuspectNetwork = 1 THEN 1 ELSE 0 END) AS Suspect,
SUM(CASE WHEN IsSuspectNetwork = 0 THEN 1 ELSE 0 END) AS Real
FROM dbo.PageHits;
-- Sonuç: 246 / 165 suspect / 81 real
İlk hafta gerçek insan sayısının %33 civarında olduğu doğrulandı. Bu Pillar 2 yazısının ortaya çıkma sebebi: orijinal data, fact-based içerik.

MaxMind GeoIP Singleton Reader Pattern
PageHits ve AttackProbes tablolarında Country, City, ASN, AsnNumber kolonları var. Bu enrichment'ı MaxMind.GeoIP2.DatabaseReader ile yapıyorum. Doğru kurulum kritik: yanlış yapılırsa her request'te file open/close ile dakikada binlerce IO yer.
DatabaseReader thread safety
MaxMind dokümantasyonu net: DatabaseReader thread-safe, tek bir instance tüm uygulamada paylaşılabilir. Hatta paylaşılmalı — file-backed memory-mapped struct, açma maliyetli (~80MB City + ~6MB ASN). Singleton pattern doğru çözüm.
GeoIPDatabaseReaders wrapper
DI'ye singleton kaydı için minimal wrapper:
public sealed class GeoIPDatabaseReaders : IDisposable
{
public DatabaseReader CityReader { get; }
public DatabaseReader AsnReader { get; }
public GeoIPDatabaseReaders(IWebHostEnvironment env)
{
var dir = Path.Combine(env.ContentRootPath, "data", "geoip");
CityReader = new DatabaseReader(Path.Combine(dir, "GeoLite2-City.mmdb"));
AsnReader = new DatabaseReader(Path.Combine(dir, "GeoLite2-ASN.mmdb"));
}
public void Dispose() { CityReader?.Dispose(); AsnReader?.Dispose(); }
}
// Program.cs
services.AddSingleton<GeoIPDatabaseReaders>();
Disposable çünkü process exit anında file handle'lar düzgün kapansın. Normal request flow'da Dispose tetiklenmez.
Performans ölçümü
Tek IP enrichment latency:
| Metrik | p50 | p95 | p99 |
|---|---|---|---|
| City lookup | 0.12ms | 0.34ms | 0.81ms |
| ASN lookup | 0.08ms | 0.21ms | 0.52ms |
| Toplam | 0.20ms | 0.55ms | 1.33ms |
Pod başına ~85MB memory mapped (City + ASN dosyaları). 24/7 prod'da bu rakamlar stabil; spike yok.
DB güncelleme stratejisi
MaxMind GeoLite2 ücretsiz tier'ı haftalık güncelleniyor. İki yaklaşım:
- Cron + pod restart:
wgetyeni mmdb dosyasını çek, k8s ConfigMap update, pod restart - Init container: Pod boot'ta
init-geoipcontainer mmdb'yi indirir, main container singleton ile yükler
Bizim seçim #1: kontrolü açık, audit log var, restart ihtiyacında zaten oluyor. Yeni ISP eklemeleri haftada ~50 satır; bizim ASN registry zaten 42 ASN, ISP değişimi minimal etkili.
Boot süresi
İlk DB load'da pod ~1.5 saniye daha geç hazır oluyor (Kestrel start + GeoIP load). Liveness probe initial delay 10 saniyeye ayarlı, sorun değil. Readiness probe DB connection check'i bekliyor, GeoIP bekleyiştende değil — bağımsız.
📊 [METRIK: GeoIP lookup p50/p95/p99 Grafana panel]
Log → AttackProbes Backfill ETL
AttackProbes tablosu 2026-05-10'da prod'a girdi. Önceki dönem saldırı kayıtları dbo.Logs tablosunda (Serilog) var; geriye dönük analiz için bunları AttackProbes'a aktarmak gerekiyor. Idempotent ETL ile yapıldı.
Niye backfill?
Üç değer ekledi:
- Trend analizi: "Mart 2026 öncesi vs sonrası" saldırı pattern değişimi grafiği
- Audit: geçmişteki saldırgan IP'lerin block listesine girip girmediği teyit
- Veri tutarlılığı: Pillar 2 yazısının "157 probe 24h" rakamı geçmiş ile karşılaştırılabilir olsun
Source kolonu marker'ı
Backfill kayıtlarını canlı middleware insertlerinden ayırmak için AttackProbes.Source kolonu var:
Source = 'middleware'→ canlı, TrafficClassifierMiddleware'denSource = 'log_backfill'→ ETL, geçmiş log'lardan
Reporting query'leri WHERE Source = 'middleware' ile filtreliyor; geriye dönük temizlik gerekirse DELETE FROM AttackProbes WHERE Source = 'log_backfill' tek satırla yapılır.
ETL script: 2026-05-10-backfill-attack-probes-from-logs.sql
-- Idempotent: zaten varsa atlar (NOT EXISTS guard)
INSERT INTO dbo.AttackProbes (
HitTime, DayIso, Source, RawIp, Method, Path,
UserAgent, Referer, StatusCode,
Category, Severity, Confidence, PatternMatched
)
SELECT
e.LogEvent_Date AS HitTime,
CONVERT(CHAR(10), e.LogEvent_Date, 23) AS DayIso,
'log_backfill' AS Source,
-- IP regex extract from log message
SUBSTRING(e.Message,
CHARINDEX('"ClientIp":"', e.Message) + 12,
CHARINDEX('"', e.Message, CHARINDEX('"ClientIp":"', e.Message) + 12)
- CHARINDEX('"ClientIp":"', e.Message) - 12
) AS RawIp,
'GET' AS Method,
-- Path regex extract
SUBSTRING(e.Message, /* ... */) AS Path,
NULL AS UserAgent,
NULL AS Referer,
NULL AS StatusCode,
-- Pattern → category mapping
CASE
WHEN e.Message LIKE '%/.env%' THEN 'secret_disclosure'
WHEN e.Message LIKE '%/.aws%' THEN 'cloud_creds_probe'
WHEN e.Message LIKE '%/.git/%' THEN 'vcs_disclosure'
WHEN e.Message LIKE '%/wp-admin%' THEN 'wordpress_probe'
WHEN e.Message LIKE '%/phpmyadmin%' THEN 'phpmyadmin_probe'
WHEN e.Message LIKE '%/admin.php%' THEN 'admin_panel_probe'
ELSE 'unknown'
END AS Category,
-- Severity / confidence ortalama (path'e göre fine-tune)
3 AS Severity, 75 AS Confidence,
'log-pattern-match' AS PatternMatched
FROM dbo.Logs e
WHERE e.Level = 'Warning'
AND e.Message LIKE '%TrafficClassifier%'
AND NOT EXISTS (
SELECT 1 FROM dbo.AttackProbes ap
WHERE ap.Source = 'log_backfill'
AND ap.HitTime = e.LogEvent_Date
AND ap.RawIp = SUBSTRING(e.Message, /* ... */)
);
Sonuç
| Metrik | Sayı |
|---|---|
| Toplam log satırı taranan | ~8500 |
| AttackProbes'a eklenen satır | 503 |
| Unique IP | 53 |
| Unique kategori | 9 |
| ETL süresi | ~12 saniye |
| Re-run güvenli | ✅ NOT EXISTS guard |
503 yeni satır canlı 246 ile birleşince 24 saatlik bugünkü 157 + geçmiş 503 = 660 kayıt analiz edilebilir oldu — temel istatistiğe yeterli.
Backfill öncesi/sonrası karşılaştırma
SELECT Source, COUNT(*) AS Records,
MIN(HitTime) AS FirstHit, MAX(HitTime) AS LastHit
FROM dbo.AttackProbes
GROUP BY Source;
Sonuç:
| Source | Records | First | Last |
|---|---|---|---|
| middleware | 157 | 2026-05-10 09:14 | 2026-05-11 09:14 |
| log_backfill | 503 | 2026-04-22 03:51 | 2026-05-10 08:55 |
📊 [GRAFİK: Source bazlı cumulative attack probes line chart]
Origin Engelleme Döngüsü: TrafficClassifier → IpBlocklist → CF List
Sistemin uç noktası: saldırı pattern'i yakalandığında otomatik block. Origin classifier → DB blocklist → Cloudflare edge push üç-adımlı zincir.
Akış diyagramı
Request → TrafficClassifier → Attack pattern eşleşti?
↓ evet
AttackProbe INSERT
↓
AttackBurstCounter MERGE+OUTPUT
↓
ThreatScore ≥ 11 VEYA Count ≥ 3?
↓ evet
IpBlocklist INSERT (24h TTL)
↓ fire-and-forget
CloudflareIpListService.AddBlockedIpAsync()
↓
UPDATE dbo.IpBlocklist SET CloudflareListItemId = @id
↓
(sonraki request edge'de WAF rule ile durur)
Çift-katman block
İlk request bloklamadan SONRA origin'e bir kez daha gelir (24h içinde başka bir IP'den) — DB lookup yapar, IMemoryCache bl: prefix 60 saniye cache'ler. Aynı IP 60 saniye içinde 100 request atsa bile sadece 1 DB query — sonrası cache hit, 403 hızlı dönüyor.
Cloudflare edge tarafında ise WAF rule "ip.src in $bilalkose_auto_block" zaten yakalıyor (Pillar 1 H2-3 detayı). İlk request origin'e gelir, sonraki edge'de durur — origin trafiği temizlenir.
IpBlocklist schema
CREATE TABLE dbo.IpBlocklist (
RawIp NVARCHAR(45) PRIMARY KEY,
BlockedAt DATETIME2(0) NOT NULL DEFAULT SYSUTCDATETIME(),
ExpiresAt DATETIME2(0) NOT NULL,
Reason NVARCHAR(200) NOT NULL,
ThreatScore INT NOT NULL,
HitCount INT NOT NULL DEFAULT 1,
LastHitAt DATETIME2(0) NOT NULL,
CloudflareListItemId NVARCHAR(64) NULL
);
Tek-satır per IP. CloudflareListItemId ilk null, Cloudflare push başarılı olduğunda UPDATE ile dolduruluyor — TTL expirer için zorunlu (item ID ile silme).
TTL expirer hosted service
IpBlocklistExpiryHostedService her dakika çalışıyor:
public async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var expired = await _db.IpBlocklist
.Where(x => x.ExpiresAt < DateTime.UtcNow)
.ToListAsync(ct);
foreach (var entry in expired)
{
if (entry.CloudflareListItemId != null)
await _cf.RemoveBlockedIpAsync(entry.CloudflareListItemId, ct);
_db.IpBlocklist.Remove(entry);
}
await _db.SaveChangesAsync(ct);
await Task.Delay(TimeSpan.FromMinutes(1), ct);
}
}
DB'den silinen kayıt audit için AttackProbes'ta zaten var; saldırı geçmişi kaybolmuyor, sadece aktif block listesi temizleniyor.
Manuel override
/admin/security/ip-blocklist Razor view dört aksiyon sunuyor (Pillar 1 H2-4 paralel):
- Index: Aktif block listesi tablosu
- PushCf: Tek IP push (CF List'e)
- PushAll: CloudflareListItemId boş tüm IP'leri toplu push
- Remove: DB + CF List senkron silme

Cross-pillar: Bu döngünün edge tarafı Pillar 1 H2-4'te. İki pillar birlikte tam pipeline'ı kapsıyor.
Real Visitor Admin Dashboard
Veriyi DB'ye yazmak yetmez — admin için anlamlı bir görselleştirme katmanı gerekiyor. Razor + Chart.js minimal yaklaşımla kuruldu, hiçbir SPA framework yok.
Sayfa yapısı
┌─────────────────────────────────────────────────────┐
│ Header — 4-sayı kart (Toplam, Gerçek, Şüpheli, Block)│
├─────────────────────────────────────────────────────┤
│ Trafik saatlik line chart (24h) │
├─────────────────────────────────────────────────────┤
│ Top 10 Country │ Top 10 ASN │ Top 10 Path │
├─────────────────────────────────────────────────────┤
│ Son 50 Attack Probe (real-time table) │
├─────────────────────────────────────────────────────┤
│ 7-gün özet tablo │
└─────────────────────────────────────────────────────┘
Razor + Chart.js minimal stack
Hiç React, hiç Vue, hiç SignalR (henüz). Razor view inline <script> ile Chart.js initialize. AJAX endpoint JSON döndürüyor, polling 30 saniye.
<canvas id="hourlyChart" height="100"></canvas>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
fetch('/admin/security/api/hourly?days=1')
.then(r => r.json())
.then(data => new Chart('hourlyChart', {
type: 'line',
data: {
labels: data.hours,
datasets: [
{ label: 'Gerçek', data: data.real, borderColor: '#10b981' },
{ label: 'Şüpheli', data: data.suspect, borderColor: '#6b7280' }
]
}
}));
</script>
Performans
Aggregate query'leri SQL Server'da yapıyor (window function, GROUP BY DayIso). Output cache 60 saniye — admin sayfası dakikada 60 hit alsa bile 1 DB query yetiyor.
Pod başına ~5MB memory overhead, network ~30KB/page (Chart.js CDN).
Auth + RBAC
ASP.NET Core Identity, [Authorize(Roles = "Admin")]. Cookie-based auth, 12 saat session. Admin panelinin tüm endpoint'leri bu attribute ile korunuyor:
[Authorize(Roles = "Admin")]
[Route("admin/security")]
public class SecurityController : Controller { /* ... */ }

Prometheus Metric Export
DB'deki veri admin için; canlı/alerting için Prometheus + Grafana yolu lazım. Pull-based, K8s-native, ücretsiz.
prometheus-net library entegrasyonu
// Program.cs
app.UseMetricServer(); // /metrics endpoint açar
app.UseHttpMetrics(); // ASP.NET Core request count + latency otomatik
// K8s Service annotation
metadata:
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
Prometheus 15 saniyede bir /metrics'i scrape ediyor, time-series storage'a yazıyor (default retention 15 gün).
Custom counter'lar
public static class SecurityMetrics
{
public static readonly Counter AttackProbeBlocksTotal =
Metrics.CreateCounter(
"bilal_attack_probe_blocks_total",
"Origin-side attack probe blocks",
"category");
public static readonly Counter IpListItemsAddedTotal =
Metrics.CreateCounter(
"bilal_cf_ip_list_items_added_total",
"Cloudflare List item additions",
"status");
public static readonly Histogram ClassifyLatency =
Metrics.CreateHistogram(
"bilal_classify_latency_seconds",
"TrafficClassifier middleware execution time");
}
Middleware'in critical path'inde using (SecurityMetrics.ClassifyLatency.NewTimer()) { ... } ile latency ölçümü, "category" label'ı ile attack kategori dağılımı.
Grafana panel'leri
- Block rate:
rate(bilal_attack_probe_blocks_total[5m])line chart - Category breakdown:
sum by (category) (bilal_attack_probe_blocks_total)pie chart - Latency p95:
histogram_quantile(0.95, rate(bilal_classify_latency_seconds_bucket[5m])) - CF push success rate:
bilal_cf_ip_list_items_added_total{status="ok"} / sum(bilal_cf_ip_list_items_added_total)
Tier1-critical-security alert grubu
Alerting rule 10 madde (memory'deki project_grafana_alerting_2026_may referansı):
- alert: AttackProbeSurge
expr: rate(bilal_attack_probe_blocks_total[5m]) > 100/60
for: 2m
labels: { severity: critical, tier: 1 }
annotations:
summary: "Saldırı atışı normal trafiğin çok üstünde"
description: "Son 5 dakikada {{ $value }} req/sec attack probe — sahte kullanıcı patlaması veya WAF rule yetersizliği"
Telegram routing tier-1'e — telefonuma anında bildirim.
Cardinality bütçesi
Prometheus label kombinasyonu high-cardinality olursa storage patlar. Path veya IP'yi label yapmak felaket. Bizde sadece 2 label:
category(9 unique value)status(3 unique value)
Toplam time-series sayısı: 9 + 3 = 12 yeni series. Memory overhead ~0.1MB.

Privacy + KVKK: PII'siz Telemetry Tasarımı
Türkiye'de blog çalıştırıyorsan KVKK (6698 sayılı kanun) uyumu zorunlu. Avrupa kullanıcı varsa ek olarak GDPR. PageHits + AttackProbes design'ı baştan PII'siz olacak şekilde kuruldu.
Hangi veri tutulur, hangi tutulmaz
Tutulur (legitimate):
- ✅
VisitorHash— MD5(IP + daily salt), bir gün-içi unique ama gün-aşırı korelasyonsuz - ✅ City-level GeoIP — sokak/adres değil, şehir bazlı
- ✅ ASN (ISP/Organization name)
- ✅ User-Agent string
- ✅ Path + StatusCode + HitTime
Tutulmaz (banned):
- ❌ SessionId, UserId — PageHits için (giriş yapan kullanıcı ayrı tracking)
- ❌ Email, telefon, isim
- ❌ Form data, request body
- ❌ Cookie content (sadece consent flag)
Raw IP saklama gerekçesi (KVKK m.5/2-(f))
AttackProbes.RawIp ve PageHits.RawIp saklanıyor. KVKK madde 5/2-(f):
Veri sorumlusunun meşru menfaatleri için veri işlenmesinin zorunlu olması, ilgili kişinin temel hak ve özgürlüklerine zarar vermemek kaydıyla, açık rıza aranmaksızın kişisel verilerin işlenebileceği halleri kapsar.
Sistem güvenliği (saldırı denemelerini bloklamak, abuse pattern tespit etmek) meşru menfaat. Aydınlatma metni Privacy.tr.resx'te 13. bölümde detaylı; KVKK Aydınlatma Yükümlülüğü doğrudan karşılanıyor.
ConsentGranted flag (bk_consent_v1 cookie)
Cookie banner Consent v2 (Sub-Faz A'da yenilendi). Granted olunca:
var consent = httpContext.Request.Cookies["bk_consent_v1"] == "granted";
// PageHits INSERT'inde ConsentGranted = consent
Granted olmayan kullanıcıların kayıtları da saklanıyor (sistem güvenliği meşru menfaat), ama analytics/marketing kullanımı için ConsentGranted=1 filtresi uygulanıyor. Bu ayrım GDPR Article 6(1)(a) açık rıza vs (f) legitimate interest ayrımına paralel.
90 gün otomatik retention
-- Daily scheduled job
DELETE FROM dbo.PageHits
WHERE HitTime < DATEADD(DAY, -90, SYSUTCDATETIME());
DELETE FROM dbo.AttackProbes
WHERE HitTime < DATEADD(DAY, -90, SYSUTCDATETIME());
90 gün KVKK saklama süresi içinde "makul". Aydınlatma metninde belirtildi.
Backup'lar başka bir konu — DB backup'larında da PII'siz tasarım sayesinde sorun yok; backup encryption + access control ile yeterli.
KVKK başvuru süreci
Veri sahibi (kullanıcı) KVKK m.11 haklarını kullanmak isterse:
- [email protected] mail (Privacy.tr.resx'te belirtilen iletişim)
- Talep: "veri silinme", "düzeltme", "amaç sorgusu"
- 30 gün içinde cevap (KVKK m.13)
PII'siz tasarım sayesinde "spesifik kullanıcının verilerini bul" mümkün olmuyor — bunu KVKK kurumuna proaktif bildiriyoruz ("Kişisel veri silinme talebi: kullanıcıyı tekil olarak tanımlayamadığımız için spesifik kayıt bulunamadı; sistem güvenliği amaçlı tüm IP'ler 90 günde otomatik silindi.")

Sıkça Sorulan Sorular
Cloudflare Analytics zaten visitor sayısı veriyor, ek tablo neden lazım?
Cloudflare Analytics free plan 5 dakika retention veriyor (Pro plan 24 saat). Daha kötüsü: datacenter/VPN trafiğini "real visitor" olarak sayıyor — bizim ölçümümüzde gerçek-vs-şüpheli oranın %33/%67 olduğu ortaya çıkamıyor. Kendi DB'mizde 90 gün retention + IsSuspectNetwork ayrımı + custom dashboard sağlıyoruz. Pillar 1'deki WAF Event Collector ile CF tarafını da topluyoruz, ikisi tamamlayıcı.
PageHits her request'e DB INSERT yapıyor, ölçeklenmez mi?
30 saniyelik IMemoryCache dedup (vh: prefix) yükün yaklaşık %70'ini düşürüyor. Aynı kullanıcı 30 saniyede 10 kez request atsa bile sadece 1 INSERT. Trafik 10K req/sec'e çıkarsa partition'lı async batch INSERT pattern'a geçilebilir (Kafka/RabbitMQ ara katmanı). Şu anki trafik (50-150 req/dakika) için over-engineer.
AttackBurstCounter MERGE neden in-memory dictionary yerine DB'de?
Pod restart direnci + multi-replica tutarlılığı. K8s 2+ replica setup'ta pod-A'nın counter'ı pod-B görmüyor; DB tek source-of-truth. Per-request overhead ~3-5ms (cache hit'te 0.1ms). Trafik küçük blog için optimal trade-off; high-throughput API'de Redis-backed counter (atomic INCR) daha uygun.
KVKK gerçekten Raw IP saklamaya izin veriyor mu?
Evet, madde 5/2-(f) "veri sorumlusunun meşru menfaatleri için veri işlenmesinin zorunlu olması" hükmüne göre. Sistem güvenliği (saldırı denemelerini bloklamak) meşru menfaat. Aydınlatma metni Privacy.tr.resx'te 13. bölümde detaylı. 90 gün retention sonra otomatik silinme + admin override + KVKK başvuru için iletişim kanalı ([email protected]).
Prometheus + Grafana on-prem maliyet ne?
prometheus-net MIT lisans, ücretsiz. Prometheus + Grafana OSS ücretsiz. K8s pod overhead: Prometheus ~500MB RAM (scrape interval 15s, retention 30 gün), Grafana ~200MB. Toplam $0 lisans + ~$5/ay K8s node payı. Alternatif SaaS: Grafana Cloud free 10K series, $19/ay 50K series. On-prem versiyon kontrol açısından üstün; alerting rule'ları, dashboard'lar git-tracked. Bu gözlemlenebilirlik pod'larının node bütçesine ve aylık maliyete etkisini önceden hesaplamak için Kubernetes kaynak bütçesi hesaplayıcısını kullanabilirsiniz.
IsSuspectNetwork flag'ini false-positive yapma riski yok mu?
42 ASN listesi sıkı: sadece datacenter/cloud/VPN provider'ları. ISP'ler (Turkcell 16135, TTNet 9121, Vodafone TR 15897, Bharti Airtel IN 45609) dışlanmış. Google ASN 15169 da dışlanmış (Googlebot zaten LegitBotUaPatterns'da, kalan = Google Cloud-hosted gerçek user). 24 saatlik gözlemde false-positive sayısı 0. Yeni ISP eklemeleri tracking için MaxMind ASN database haftalık güncellenmeli.
İlgili Yazılar
Bu pillar'ın cluster'ları (yayın sırası):
- C1 PageHits middleware: minimal allocation request sayım pipeline
- C2 AttackProbes: ThreatScore + 9 attack kategorisi detay
- C3 AttackBurstCounter: MERGE+OUTPUT SQL deseni derinlik
- C4 IsSuspectNetwork flag: VPN/datacenter karar matrisi
- C5 MaxMind GeoIP entegrasyonu: singleton reader thread safety + lookup latency
- C6 Log → AttackProbes idempotent backfill ETL pattern
- C7 Origin engelleme + Cloudflare List senkron döngüsü
- C8 Real Visitor Dashboard: Razor + Chart.js minimal stack
- C9 Prometheus metric export: counter cardinality bütçesi
- C10 KVKK uyumlu PageHits: PII'siz tasarım
Cross-pillar bridge (Pillar 1):
🌉 Cloudflare Edge Security + .NET on K8s — Pillar 1 — WAF rule + IP Blocklist + ASN registry + Origin firewall. İki pillar birlikte edge + origin tam güvenlik pipeline'ını kapsıyor.
Linkable assets:
- 🔗 ASN Registry CSV (gist) — 42 datacenter ASN, MIT lisans (Pillar 1 ile paylaşılan)
- 🔗 24h Anonimleştirilmiş Telemetry Snapshot CSV (gist) — gerçek vs şüpheli oran breakdown
- 🔗 PageHits Middleware Starter for ASP.NET Core 10 (GitHub repo) — minimal allocation referans
Yorumlar (0)
Henüz yorum yapılmamış. İlk yorumu sen bırak.