diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 636f405..2ac53ab 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,11 +1,16 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: +# Dependabot version updates. # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: - - package-ecosystem: "nuget" # See documentation for possible values - directory: "/src/" # Location of package manifests + # NuGet: "/" lets dotnet discover every manifest (src, tests, samples). + - package-ecosystem: "nuget" + directory: "/" + schedule: + interval: "weekly" + + # Keep GitHub Actions (checkout, setup-dotnet, upload-artifact) up to date. + - package-ecosystem: "github-actions" + directory: "/" schedule: interval: "weekly" diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index d95eb18..309eb9c 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -2,24 +2,39 @@ name: .NET CI on: push: - branches: [ main, master ] + branches: [ main, master, 'claude/**' ] pull_request: branches: [ main, master ] + workflow_dispatch: jobs: - build: + build-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: '8.0.x' - - name: Restore NuGet Packages + + - name: Restore run: dotnet restore - - name: Clean solution - run: dotnet clean + - name: Build run: dotnet build --configuration Release --no-restore + - name: Test - run: dotnet test --configuration Release --no-build --verbosity normal + run: >- + dotnet test --configuration Release --no-build --verbosity normal + --logger "trx;LogFileName=test-results.trx" + --results-directory ${{ github.workspace }}/TestResults + --collect:"XPlat Code Coverage" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: ${{ github.workspace }}/TestResults + if-no-files-found: warn diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..22be483 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,40 @@ +# Changelog + +Bu projedeki önemli değişiklikler bu dosyada belgelenir. + +## [1.1.0] - Yayınlanmadı + +### Düzeltildi — koruma doğruluğu (davranış değişikliği) +- **IBAN tip anahtarı uyumsuzluğu.** Tanıyıcı `IBAN` üretirken `Weights`/`RedactTypes` `IBAN_TR` bekliyordu; IBAN'lar ağırlık 1 alıyor ve hiç redakte edilmiyordu. Tüm tip anahtarları tek kaynakta (`PiiTypes`) birleştirildi ve `IBAN` olarak hizalandı. **`appsettings` içinde `Weights`/`RedactTypes` için `IBAN_TR` kullananlar `IBAN` olarak güncellemelidir.** +- **TCKN dil kapısı.** TCKN artık dilden bağımsız tespit edilir; Türkçe diakritik içermeyen (ör. ASCII JSON) gövdelerde de bulunur. +- **Telefon ulusal formatı.** `PhoneRecognizer` artık `+` öneksiz numaraları `DefaultPhoneRegion`/dil bölgesiyle ayrıştırır (`05xx…` gibi numaralar tespit edilir). +- **IBAN boşluklu gösterim.** 4'lü gruplu (boşluklu) IBAN'lar tespit edilir. +- **Adres yanlış pozitifleri.** Anahtar sözcükler kelime sınırıyla eşleştirilir (`have→ave`, `katalog→kat` artık eşleşmez). +- **DOB doğrulaması.** Geçersiz (ör. `31.02.2024`) ve gelecekteki tarihler DOB sayılmaz. +- **Kredi kartı.** Regex erken kesmesi giderildi; Troy, Discover, JCB, Maestro, Diners ve 13/19 haneli Visa eklendi. + +### Düzeltildi — middleware / HTTP +- Yanıt gövdesi swap'ı `try/finally` ile korunur; downstream exception'da orijinal stream geri yüklenir. +- Block modunda 403 gövdesi istemciye ulaşır; `-Detected` header'ları bloklanan yanıtta temizlenir. +- Redaksiyon sonrası `Content-Length` güncellenir (Kestrel uyuşmazlık hatası giderildi). +- Yanıtlar yalnızca analiz edilebilir içerik türleri için ve `MaxBodySizeBytes`'a kadar tamponlanır; büyük/streaming yanıtlar doğrudan akıtılır (OOM ve SSE bozulması giderildi). +- İstek gövdesi de boyut sınırıyla okunur. +- UTF-8/ASCII olmayan charset'li yanıtlar bozulmasın diye redakte edilmez. +- Risk header'ları `InvariantCulture` ile üretilir; metod filtreleri büyük/küçük harf duyarsızdır. + +### Düzeltildi — edge +- Snippet, geçersiz/eksik eşik ve risk header'larını güvenli şekilde ele alır; virgüllü ondalıkları tolere eder; `DATAGUARDIAN_FAIL_MODE=closed` ile fail-closed seçeneği sunar. + +### Eklendi +- `PiiTypes` kanonik tip sabitleri. +- `DataGuardianOptions.Validate()` ve başlangıçta konfigürasyon doğrulaması. +- `AddDataGuardian(IConfiguration)` / `AddDataGuardian(Action<>)` ve `UseDataGuardian()` / `UseDataGuardian(Action<>)` overload'ları (DI + IOptions benzeri bağlama). +- Yeni seçenekler: `DefaultPhoneRegion`, `BlockOversizeBodies`, `RedactionHashKey`, `BlockMessage`; `AnalyzableContentTypes`'a `text/html`, `text/csv`. +- Skorlamada çakışma (overlap) çözümü ve risk clamp; açık `0` ağırlığı tipi devre dışı bırakır. +- Entity tip filtreleri artık NER hit'lerine de uygulanır. +- Hash redaksiyonu anahtarlı HMAC-SHA256'ya geçti; Partial maske kısa değerleri tamamen maskeler. +- Kapsamlı test paketi (recognizer + engine + middleware TestHost) ve `docs/` belgeleri. + +### Güvenlik +- NER'in v1.x'te redaksiyon için olgun olmadığı belgelendi; skor/etiketleme için kullanılmalı ([docs/recognizers.md](docs/recognizers.md#ner-onnx)). +- Header oracle riski ve öneriler belgelendi ([docs/security.md](docs/security.md)). diff --git a/README.md b/README.md index 7ef1fc4..2088a51 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,16 @@ [![NuGet Version](https://img.shields.io/nuget/v/Devoplus.DataGuardian)](https://www.nuget.org/packages/Devoplus.DataGuardian) ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/devoplus/DataGuardian/dotnet.yml) ![GitHub License](https://img.shields.io/github/license/devoplus/DataGuardian) ![Devoplus Open Source](https://img.shields.io/badge/Open_Source-DP?label=Devoplus&labelColor=%23B60017&color=%2319191a) -DataGuardian, ASP.NET Core için **request ve response** gövdelerinde PII/Sensitive Data tespiti yapar, **0–10 risk skoru** üretir, isteğe bağlı olarak **response header yazar, redaksiyon yapar veya bloklar**. Türkçe ve İngilizce dillerini destekler. +DataGuardian, ASP.NET Core için **request ve response** gövdelerinde PII/hassas veri tespiti yapar, **0–10 risk skoru** üretir, isteğe bağlı olarak **response header yazar, redaksiyon yapar veya bloklar**. Türkçe ve İngilizce dillerini destekler. -- ✅ Kural tabanlı dedektörler: TCKN (checksum), IBAN, Kredi Kartı (Luhn), E-posta, Telefon, Tarih, Adres anahtar sözcükleri +- ✅ Kural tabanlı dedektörler: TCKN (checksum), IBAN (mod-97), Kredi Kartı (Luhn + şema), E-posta, Telefon, Tarih, Adres anahtar sözcükleri - ✅ TR/EN dil tahmini veya `LanguageOverride` - ✅ Konfigürasyon: ağırlıklar, eşikler, path/metot filtreleri, entity include/exclude, header öneki - ✅ Aksiyon modları: **Tag**, **Redact** (MaskAll/Partial/Hash), **Block** - ✅ Opsiyonel **BERT NER (ONNX)**: serbest metinde `PERSON/ADDRESS/...` +> Ayrıntılı belgeler [`docs/`](docs/) klasöründedir. Değişiklik geçmişi için [`CHANGELOG.md`](CHANGELOG.md). + --- ## Hızlı Başlangıç @@ -20,36 +22,23 @@ dotnet test cd samples/Devoplus.DataGuardian.SampleApi dotnet run -# POST JSON to /echo and check headers: +# POST JSON to /echo and check the response headers: # X-DataGuardian-Request-Risk, X-DataGuardian-Response-Risk ``` -### Program.cs kullanım örneği +### Kullanım (DI + konfigürasyon) + +Önerilen yol, seçenekleri `appsettings.json`'daki `DataGuardian` bölümünden bağlamaktır: ```csharp using Devoplus.DataGuardian; var builder = WebApplication.CreateBuilder(args); -var app = builder.Build(); -var opt = new DataGuardianOptions -{ - AnalyzeRequests = true, - AnalyzeResponses = true, - HeaderPrefix = "X-DataGuardian", - Action = ActionMode.Tag, // None | Tag | Redact | Block - BlockAt = -1, // Block modunda eşik - RedactAt = 4.0, // Redact modunda eşik - Redaction = RedactionStyle.MaskAll, - IncludePaths = new() { "/api/" }, - ExcludePaths = new() { "/health", "/metrics" }, - IncludeMethods = new() { "POST", "PUT" }, - ExcludeMethods = new() { "GET" }, - EnableNer = false, // ONNX model ekleyince true - // LanguageOverride = "tr" -}; - -app.UseDataGuardian(opt); +builder.Services.AddDataGuardian(builder.Configuration.GetSection("DataGuardian")); + +var app = builder.Build(); +app.UseDataGuardian(); // AddDataGuardian ile kaydedilen seçenekleri çözer app.MapPost("/echo", async (HttpContext ctx) => { @@ -61,12 +50,31 @@ app.MapPost("/echo", async (HttpContext ctx) => app.Run(); ``` +### Kullanım (kod içinde inline) + +```csharp +app.UseDataGuardian(o => +{ + o.Action = ActionMode.Tag; // None | Tag | Redact | Block + o.BlockAt = 8.0; // Block modunda eşik + o.RedactAt = 4.0; // Redact modunda eşik + o.Redaction = RedactionStyle.MaskAll; + o.ExcludePaths = new() { "/health", "/metrics" }; + o.EnableNer = false; // ONNX model ekleyince true + // o.LanguageOverride = "tr"; +}); +``` + +> Geçersiz bir konfigürasyon (`K <= 0`, `MaxCountPerType < 1`, eşiklerin aralık dışı olması vb.) başlangıçta `ArgumentException` ile **erken** hata verir; sessizce yok sayılmaz. + ### Header’lar -- `X-DataGuardian-Request-Risk: 0..10` +- `X-DataGuardian-Request-Risk: 0..10` (nokta ondalık, kültürden bağımsız) - `X-DataGuardian-Request-Detected: EMAIL=2;PHONE=1;...` - `X-DataGuardian-Response-Risk: 0..10` - `X-DataGuardian-Response-Detected: ...` -- `X-DataGuardian-Response-Skip-Reason: ...` +- `X-DataGuardian-Response-Skip-Reason: ...` (ör. `ContentTypeNotAnalyzable`, `BodyTooLarge`, `UnsupportedCharset`, `NoRedactableTypes`) + +> **Güvenlik notu:** `-Detected` header’ları hangi PII tiplerinin bulunduğunu açığa vurur. Bunları yalnızca güvenilir iç tüketicilere (ör. edge) verin; dış istemcilere dönen yanıtlarda `EmitHeaders = false` yapın veya güven sınırında temizleyin. Ayrıntı: [`docs/security.md`](docs/security.md). ### Postman ile test örneği Projede yer alan **Devoplus.DataGuardian.SampleApi** projesini başlatarak Postman üzerinden veri göndererek header'ları test edebilirsiniz. @@ -88,15 +96,17 @@ Projede yer alan **Devoplus.DataGuardian.SampleApi** projesini başlatarak Postm "RedactAt": 4.0, "Redaction": "MaskAll", // MaskAll | Partial | Hash "IncludePaths": ["/api/"], - "ExcludePaths": ["/health","/metrics"], - "IncludeMethods": ["POST","PUT"], + "ExcludePaths": ["/health", "/metrics"], + "IncludeMethods": ["POST", "PUT"], "ExcludeMethods": ["GET"], "IncludeEntityTypes": [], "ExcludeEntityTypes": ["ADDRESS"], - "Weights": { "TCKN":10, "CREDIT_CARD":9, "IBAN_TR":8, "DOB":7, "ADDRESS":6, "PHONE":5, "EMAIL":4, "PERSON":3 }, + "Weights": { "TCKN": 10, "CREDIT_CARD": 9, "IBAN": 8, "DOB": 7, "ADDRESS": 6, "PHONE": 5, "EMAIL": 4, "PERSON": 3 }, "MaxCountPerType": 5, "K": 0.15, "MaxBodySizeBytes": 524288, + "BlockOversizeBodies": false, + "DefaultPhoneRegion": "TR", "EnableNer": false, "NerModelPath": "models/kvkk-ner.onnx", "NerTokenizerPath": "models/tokenizer.json", @@ -107,101 +117,29 @@ Projede yer alan **Devoplus.DataGuardian.SampleApi** projesini başlatarak Postm } } ``` -> `IOptions` ile bağlayabilirsiniz. - ---- - -## Cloudflare Snippet / Worker ile Edge’de Uygulama - -Cloudflare üzerinde aşağıdaki snippet, **origin’den dönen** DataGuardian header’larını kontrol eder. **Hariç tutulan path’ler** (exclude) dışındaysa ve risk **eşiği aşmışsa**, **403** döndürür. - -**Environment Değişkenleri:** - -- `DATAGUARDIAN_THRESHOLD` - örn. `8.0` -- `DATAGUARDIAN_HEADER_PREFIX` - varsayılan `X-DataGuardian` -- `DATAGUARDIAN_EXCLUDED_PATHS` - `/health,/metrics,/public` - -> Not: Header’lar origin’de üretildiğinden istek bir kez origin’e ulaşır. Request'i uygulama katmanında daha erken durdurmak isterseniz `Action = Block` + `BlockAt` ayarını kullanmanız gerekir. - -```js -// edge/dataguardian-snippet.js -export default { - async fetch(request, env, ctx) { - const THRESHOLD = parseFloat(env.DATAGUARDIAN_THRESHOLD ?? "8.0"); - const HEADER_PREFIX = env.DATAGUARDIAN_HEADER_PREFIX || "X-DataGuardian"; - const EXCLUDED = (env.DATAGUARDIAN_EXCLUDED_PATHS || "/health,/metrics") - .split(",").map(s => s.trim()).filter(Boolean); - - const url = new URL(request.url); - if (EXCLUDED.some(p => url.pathname.startsWith(p))) { - return fetch(request); - } - - const originResp = await fetch(request); - const reqRisk = originResp.headers.get(`${HEADER_PREFIX}-Request-Risk`); - const resRisk = originResp.headers.get(`${HEADER_PREFIX}-Response-Risk`); - const risk = Math.max(parseFloat(reqRisk ?? "-1"), parseFloat(resRisk ?? "-1")); - - if (!Number.isNaN(risk) && risk >= THRESHOLD) { - return new Response("Blocked by DataGuardian policy (edge).", { status: 403 }); - } - return originResp; - } -}; -``` +Tüm seçeneklerin ayrıntısı: [`docs/configuration.md`](docs/configuration.md). -### Hangi yaklaşımı kullanmalıyım? - -**Kısa cevap:** -- **Sadece gözlem/uyarı** istiyorsanız → **Origin’de _Tag_ (sadece response header yazar)** -- **Maliyet ve risk kritik** (erken kes) → **Origin’de _Block_** -- **Merkezi politika + çok-çekirdek/origin** → **Edge’de (Cloudflare) blok** -- **En sıkı** senaryo → **Origin’de _Block_ + Edge’de ikinci bariyer** +> **Not:** Entity tip anahtarları tek bir kaynakta (`PiiTypes`) tanımlıdır. `Weights`, `RedactTypes` ve tanıyıcı çıktıları aynı anahtarları kullanır (ör. IBAN için `IBAN`). Bir birim testi bu tutarlılığı sabitler. --- -**Karşılaştırma** - -| Kriter | Origin (Middleware) | Edge (Cloudflare Snippet/Worker) | -|---|---|---| -| Bloklama noktası | Uygulama katmanı (erken) | Kullanıcıya en yakın nokta | -| Uygulama maliyeti | Düşer (erken durur) | Origin’e yine gider (header okumak için) | -| Çoklu origin / ortak politika | Zor (her service ayrı ayar) | Kolay (tek yerde politika) | -| Gözlemlenebilirlik | Uygulama logları | Edge logları ile merkezi + Cloudflare Logpush desteği | -| Hata modları | Uygulama hatası etkiler | Origin hatası olsa da Edge karar verebilir | -| Streaming / SSE | İçerik değişmeden önce durdurma/redaksiyon | Çoğu zaman içerik geldikten sonra karar | -| Cache entegrasyonu | App tarafında | Cloudflare Cache ile kolay | -| Rollout / kademeli geçiş | Feature flag ile | Route/hostname bazlı çok kolay | - ---- - -**Ne zaman hangisini seçelim?** - -- **Regülasyon-kritik uçlar** (KYC, ödeme, veri ihracı): - → *Öncelik Origin Block.* `Action=Block`, `BlockAt=…` ile **erken kes**. - Gerekirse **Edge**’de de aynı eşiği uygulayıp ikinci bariyer kur. - -- **Tek merkezden yönetim** (çok mikroservis, çok dil/yığın): - → *Öncelik Edge.* - Snippet/Worker ile **tek yerde politika**. Origin’de **Tag** kullanılır (response header üretir), edge üzerinde bloklama yapılabilir. ---- - -**Pratik ipuçları** - -- **Gözlemleme:** “blok nedenleri” için Cloudflare Worker’a D1 kullanarak *log* eklenebilir. -- **Hata toleransı:** Bir yapılandırma hatası nedeniyle origin header üretmezse Edge "risk yok" varsayabilir **veya** default-deny modunda kullanılabilir. +## Belgeler +| Belge | İçerik | +|---|---| +| [docs/configuration.md](docs/configuration.md) | Tüm seçeneklerin referansı | +| [docs/recognizers.md](docs/recognizers.md) | Tanıyıcılar, ne tespit eder, doğrulama | +| [docs/scoring.md](docs/scoring.md) | Risk skoru formülü | +| [docs/actions.md](docs/actions.md) | Tag / Redact / Block ve redaksiyon stilleri | +| [docs/security.md](docs/security.md) | Güvenlik modeli, sınırlar ve öneriler | +| [docs/edge.md](docs/edge.md) | Cloudflare Snippet/Worker ile edge dağıtımı | +| [docs/contributing.md](docs/contributing.md) | Geliştirme, test, yeni tanıyıcı ekleme | --- ## ONNX NER (Opsiyonel) -`models/` altına: -- `kvkk-ner.onnx` -- `tokenizer.json` -- `labels.txt` (etiket listesi) - -`EnableNer = true` yaparak etkinleştirin. Çıktı etiketleriniz `PERSON`, `ADDRESS`, `EMAIL`, `PHONE`, `DATE` vb. olabilir; DataGuardian bunları ağırlıklandırıp risk skoruna dahil eder. +`models/` altına `kvkk-ner.onnx`, `tokenizer.json` ve `labels.txt` ekleyip `EnableNer = true` yaparak etkinleştirin. Çıktı etiketleriniz `PERSON`, `ADDRESS`, `EMAIL`, `PHONE`, `DATE` vb. olabilir; DataGuardian bunları ağırlıklandırıp risk skoruna dahil eder. NER entegrasyonunun bilinen sınırları ve olgunluk durumu için [`docs/recognizers.md`](docs/recognizers.md#ner-onnx) bölümüne bakın. --- @@ -209,19 +147,20 @@ export default { DataGuardian için planlanan geliştirmeler ve hedefler: -### v1.0 (Mevcut) -- [x] Kural tabanlı PII tespitleri (T.C. kimlik numarası, IBAN, kredi kartı, e-posta, telefon, tarih, adres) +### v1.x (Mevcut) +- [x] Kural tabanlı PII tespitleri (TCKN, IBAN, kredi kartı, e-posta, telefon, tarih, adres) - [x] Risk skorlaması (0–10) ve header üretimi (`X-DataGuardian-*`) - [x] Aksiyon modları: **Tag**, **Redact**, **Block** - [x] Konfigürasyon: path/method filtreleri, entity include/exclude, redaksiyon stili -- [x] Opsiyonel NER entegrasyonu (ONNX) +- [x] DI / `AddDataGuardian` + konfigürasyon bağlama ve başlangıçta doğrulama +- [x] Opsiyonel NER entegrasyonu (ONNX, deneysel) ### v2.0 (Kısa vadeli hedeflenen) - [ ] .NET Standard 2.0 desteği - [ ] Edge (Cloudflare) snippet için logging ve metric forwarding -- [ ] JSON-safe redaksiyon (sadece değerleri maskeleme, key’lere dokunmama) +- [ ] JSON-aware redaksiyon (sadece değerleri maskeleme, key’lere dokunmama) - [ ] Farklı risk içeren veriler için kurallar (VKN, SGK sicil numarası, plaka, pasaport numarası, IP adresi, MAC adresi, konum verileri vb.) -- [ ] Daha gelişmiş dil tespiti (Türkçe–İngilizce dışı diller için destek) +- [ ] NER için token→karakter ofset eşlemesi ve pencereleme (uzun gövdeler) - [ ] CLI aracı ile dosya/batch analizi (`dataguardian analyze file.json`) ### v3.0 (Uzun vadeli hedeflenen) @@ -232,4 +171,4 @@ DataGuardian için planlanan geliştirmeler ve hedefler: --- ## Lisans -MIT © Devoplus \ No newline at end of file +MIT © Devoplus diff --git a/docs/actions.md b/docs/actions.md new file mode 100644 index 0000000..6ce5258 --- /dev/null +++ b/docs/actions.md @@ -0,0 +1,47 @@ +# Aksiyon Modları ve Redaksiyon + +`Action` seçeneği, risk skoruna göre ne yapılacağını belirler. + +| Mod | Davranış | +|---|---| +| `None` | Hiçbir şey yapmaz (isteğe bağlı header hariç). | +| `Tag` | Yalnızca `X-DataGuardian-*` header'larını yazar. | +| `Redact` | Yanıt gövdesinde `RedactTypes` içindeki eşleşmeleri maskeler (risk ≥ `RedactAt`). | +| `Block` | Risk ≥ `BlockAt` ise 403 döndürür (istek veya yanıt aşamasında). | + +## Block + +- İstek aşamasında bloklanırsa, downstream endpoint hiç çalışmaz (erken kesme, maliyet düşer). +- Bloklanan yanıt **gövdeli** bir 403'tür (`BlockMessage`); `-Detected` header'ları oracle oluşturmamak için temizlenir. +- `BlockAt` negatifse bloklama devre dışıdır (varsayılan `-1`). + +```csharp +o.Action = ActionMode.Block; +o.BlockAt = 8.0; +``` + +## Redact + +Risk `RedactAt`'i aşarsa ve redakte edilebilir hit varsa, yanıt gövdesi yeniden yazılır ve `Content-Length` güncellenir. Redakte edilebilir bir tip yoksa yanıt değiştirilmez ve `-Response-Skip-Reason: NoRedactableTypes` yazılır. + +### Redaksiyon stilleri + +| Stil | Örnek (`secret@example.com`) | Notlar | +|---|---|---| +| `MaskAll` | `******************` | Tüm karakterleri `*` yapar (uzunluk korunur). | +| `Partial` | `se************om` | İlk/son 2 karakteri gösterir; **uzunluk ≤ 4 ise tamamen maskeler**. | +| `Hash` | `a1b2c3d4e5f6a7b8` | Anahtarlı HMAC-SHA256 önekiyle değiştirir (uzunluk değişir). | + +### Hash hakkında güvenlik notu + +`Hash`, değeri **gizli anahtarlı** HMAC-SHA256'nın 16 hex karakterine indirger. Anahtar (`RedactionHashKey`) gizli kaldığı sürece geri döndürme (özellikle düşük entropili TCKN/kart için) pratik değildir. + +- `RedactionHashKey` boşsa süreç başına rastgele bir anahtar kullanılır (yeniden başlatmada değişir). +- Aynı değeri kayıtlar arasında ilişkilendirmeniz gerekiyorsa sabit bir gizli anahtar verin — ancak **anahtarı asla açığa çıkarmayın**. Aksi halde hash geri döndürülebilir. +- Yalnızca gizleme istiyorsanız `MaskAll` daha basit ve güvenlidir. + +## İçerik türü ve boyut + +- Analiz yalnızca `AnalyzableContentTypes` içindeki (veya eksik/boş) içerik türlerine uygulanır. Diğer türler (ör. `application/octet-stream`, `text/event-stream`) tamponlanmadan doğrudan akıtılır (OOM ve streaming bozulması önlenir). +- `MaxBodySizeBytes`'ı aşan gövdeler tamponlanmaz; varsayılan olarak analiz edilmeden geçer ve `BodyTooLarge` skip nedeni yazılır. Güvenlik-kritik uçlar için `BlockOversizeBodies = true` ile fail-closed davranışı seçilebilir. +- UTF-8/ASCII olmayan charset'li yanıtlar bozulmasın diye redakte edilmez (`UnsupportedCharset`). diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..6f3ab3d --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,61 @@ +# Yapılandırma Referansı + +DataGuardian, `DataGuardianOptions` ile yapılandırılır. Seçenekler üç yolla verilebilir: + +```csharp +// 1) Konfigürasyondan bağlama +builder.Services.AddDataGuardian(builder.Configuration.GetSection("DataGuardian")); +app.UseDataGuardian(); + +// 2) Kod içinde inline +app.UseDataGuardian(o => { o.Action = ActionMode.Block; o.BlockAt = 8; }); + +// 3) Hazır bir nesne ile +var opt = new DataGuardianOptions { Action = ActionMode.Tag }; +app.UseDataGuardian(opt); +``` + +Her üç yol da başlangıçta `DataGuardianOptions.Validate()` çağırır; geçersiz değerler `ArgumentException` fırlatır. + +## Seçenekler + +| Seçenek | Tip | Varsayılan | Açıklama | +|---|---|---|---| +| `AnalyzeRequests` | `bool` | `true` | İstek gövdelerini analiz et. | +| `AnalyzeResponses` | `bool` | `true` | Yanıt gövdelerini analiz et. | +| `Action` | `ActionMode` | `Tag` | `None` / `Tag` / `Redact` / `Block`. | +| `BlockAt` | `double` | `-1` | Block eşiği (0–10). Negatif değer bloklamayı kapatır. | +| `RedactAt` | `double` | `0` | Redact eşiği (0–10). Risk ≥ bu değerse redakte eder. | +| `Redaction` | `RedactionStyle` | `MaskAll` | `MaskAll` / `Partial` / `Hash`. | +| `RedactTypes` | `HashSet` | EMAIL, PHONE, TCKN, CREDIT_CARD, IBAN, DOB | Redakte edilecek tipler. | +| `RedactionHashKey` | `string?` | `null` | `Hash` stili için gizli anahtar. Boşsa süreç başına rastgele anahtar. | +| `Weights` | `Dictionary` | bkz. aşağı | Tip başına ağırlık. Anahtar bulunmazsa 1; açık `0` tipi devre dışı bırakır. | +| `MaxCountPerType` | `int` | `5` | Skora katkıda tip başına üst sınır. `>= 1` olmalı. | +| `K` | `double` | `0.15` | Risk eğrisinin dikliği. `> 0` olmalı. | +| `MaxBodySizeBytes` | `int` | `524288` | Tamponlanıp analiz edilen azami gövde boyutu. | +| `BlockOversizeBodies` | `bool` | `false` | Bu sınırı aşan gövdeler Block modunda bloklansın (fail-closed) mı? | +| `HeaderPrefix` | `string` | `X-DataGuardian` | Emit edilen header öneki. | +| `EmitHeaders` | `bool` | `true` | `X-DataGuardian-*` header’larını yaz. Dış istemcilere kapatmayı düşünün. | +| `IncludePaths` / `ExcludePaths` | `List` | boş | Path öneki filtreleri (ordinal, büyük/küçük harf duyarsız). | +| `IncludeMethods` / `ExcludeMethods` | `List` | boş | HTTP metod filtreleri (büyük/küçük harf duyarsız). | +| `IncludeEntityTypes` / `ExcludeEntityTypes` | `HashSet` | boş | Entity tip filtreleri (NER dahil tüm hit’lere uygulanır). | +| `DefaultPhoneRegion` | `string` | `TR` | Ulusal formatlı telefonları ayrıştırmak için varsayılan bölge. | +| `AnalyzableContentTypes` | `HashSet` | JSON, text/plain, text/html, text/csv, XML, form-urlencoded | Analiz edilecek içerik tipleri. Eksik/boş tip de analiz edilir. | +| `LanguageOverride` | `string?` | `null` | `null` = otomatik, `"tr"` veya `"en"`. | +| `EnableNer` | `bool` | `false` | Opsiyonel ONNX NER. | +| `NerModelPath` / `NerTokenizerPath` / `NerLabelsPath` | `string` | `models/…` | NER dosya yolları. | +| `NerMaxSequenceLength` | `int` | `256` | Modele verilen azami token uzunluğu. | +| `MinNerConfidence` | `double` | `0.6` | NER entity için asgari güven (0–1). | +| `BlockMessage` | `string` | `Blocked by DataGuardian policy.` | Bloklanan yanıtın gövdesi. | + +### Varsayılan ağırlıklar + +``` +TCKN=10, CREDIT_CARD=9, IBAN=8, DOB=7, ADDRESS=6, PHONE=5, EMAIL=4, PERSON=3 +``` + +Anahtarlar `PiiTypes` sabitleriyle aynıdır. `Weights` / `RedactTypes` / tanıyıcı çıktıları arasındaki tutarlılık bir testle (`TypeConsistencyTests`) güvenceye alınır. + +## Doğrulama kuralları + +`Validate()` şunları zorunlu kılar: `K > 0`, `MaxCountPerType >= 1`, `MaxBodySizeBytes >= 1`, `BlockAt <= 10`, `0 <= RedactAt <= 10`, `NerMaxSequenceLength >= 1`, `0 <= MinNerConfidence <= 1`, `HeaderPrefix` boş değil. diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..49ce2eb --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,52 @@ +# Katkı Rehberi + +## Geliştirme ortamı + +- .NET SDK 8.0 +- Depoyu klonlayın ve: + +```bash +dotnet restore +dotnet build +dotnet test +``` + +## Proje yapısı + +``` +src/Devoplus.DataGuardian/ # Kütüphane + Recognizers/ # Kural tabanlı tanıyıcılar + Ner/ # Opsiyonel ONNX NER + Internal/ # Dahili yardımcılar (ör. ResponseCaptureStream) + PiiTypes.cs # Kanonik tip anahtarları +tests/Devoplus.DataGuardian.Tests # xUnit + TestHost testleri +samples/ # Örnek minimal API +edge/ # Cloudflare snippet +docs/ # Belgeler +``` + +## Yeni tanıyıcı ekleme + +1. `PiiTypes`'a bir sabit ekleyin (ör. `public const string Vkn = "VKN";`). +2. `IPiiRecognizer` uygulayan bir sınıf yazın; regex'i `static readonly` tutun ve bir `MatchTimeout` verin. +3. `DataGuardianEngine` kurucusundaki tanıyıcı listesine ekleyin. +4. `DataGuardianOptions.Weights`'e aynı anahtarla bir ağırlık ekleyin; redakte edilebilir olacaksa `RedactTypes`'a da ekleyin. +5. Pozitif ve negatif testler yazın. + +> Tip anahtarları `PiiTypes` üzerinden tek kaynaktan gelmelidir. `TypeConsistencyTests`, `RedactTypes` ile `Weights` uyumunu doğrular. + +## Test kuralları + +- Her tanıyıcı için en az bir pozitif ve bir negatif vaka. +- Middleware davranışları `Microsoft.AspNetCore.TestHost` ile uçtan uca test edilir (Tag/Block/Redact/filtreler). +- Regresyonu önlemek için düzeltilen her hata için bir test bırakın. + +## Kod stili + +- `Nullable` etkin; kütüphane projesinde nullable uyarıları hata sayılır. +- Kullanıcıya görünen metinlerde resmî dil kullanın. +- Genel API'lere XML doc yorumu ekleyin. + +## CI + +`.github/workflows/dotnet.yml`, push ve PR'larda restore/build/test çalıştırır, test sonuçlarını (`trx`) ve kod kapsamını artifact olarak yükler. diff --git a/docs/edge.md b/docs/edge.md new file mode 100644 index 0000000..becad15 --- /dev/null +++ b/docs/edge.md @@ -0,0 +1,75 @@ +# Edge Dağıtımı (Cloudflare Snippet / Worker) + +`edge/dataguardian-snippet.js`, origin'den dönen DataGuardian risk header'larını okuyup eşik aşıldığında yanıtı 403 ile bloklar. Origin'de `Action = Tag` (veya `Block`) ile header üretin, edge'de merkezi politikayı uygulayın. + +## Ortam değişkenleri + +| Değişken | Örnek | Açıklama | +|---|---|---| +| `DATAGUARDIAN_THRESHOLD` | `8.0` | Sayısal eşik. Geçersiz/eksikse `8.0` varsayılır. | +| `DATAGUARDIAN_HEADER_PREFIX` | `X-DataGuardian` | Header öneki (origin'deki `HeaderPrefix` ile aynı olmalı). | +| `DATAGUARDIAN_EXCLUDED_PATHS` | `/health,/metrics,/public` | Bypass edilecek path önekleri. | +| `DATAGUARDIAN_FAIL_MODE` | `open` / `closed` | Risk header'ları yok/geçersizse davranış. Varsayılan `open`. | + +## Fail-open vs fail-closed + +Risk header'ları hiç yoksa (middleware devre dışı, `EmitHeaders=false`, önbellekten dönen yanıt, origin hata sayfası vb.): + +- `open` (varsayılan): yanıt geçirilir. Gözlem/uyarı senaryoları için. +- `closed`: yanıt 403 ile bloklanır. **Regülasyon-kritik uçlar için önerilir.** + +## Kültür / ondalık ayıracı + +Origin risk değerlerini `InvariantCulture` ile (`8.90` gibi, nokta ondalık) yazar. Snippet yine de savunmacı olarak virgüllü değerleri (`8,90`) noktaya çevirip ayrıştırır; böylece farklı locale'lerde eşik yanlış okunmaz. + +## Snippet + +Güncel kaynak: [`edge/dataguardian-snippet.js`](../edge/dataguardian-snippet.js). + +```js +export default { + async fetch(request, env, ctx) { + const parsedThreshold = parseNumber(env.DATAGUARDIAN_THRESHOLD); + const THRESHOLD = parsedThreshold === null ? 8.0 : parsedThreshold; + const HEADER_PREFIX = env.DATAGUARDIAN_HEADER_PREFIX || "X-DataGuardian"; + const EXCLUDED = (env.DATAGUARDIAN_EXCLUDED_PATHS || "/health,/metrics") + .split(",").map(s => s.trim()).filter(Boolean); + const FAIL_CLOSED = (env.DATAGUARDIAN_FAIL_MODE || "open").toLowerCase() === "closed"; + + const url = new URL(request.url); + if (EXCLUDED.some(p => url.pathname.startsWith(p))) return fetch(request); + + const originResp = await fetch(request); + const reqRisk = parseNumber(originResp.headers.get(`${HEADER_PREFIX}-Request-Risk`)); + const resRisk = parseNumber(originResp.headers.get(`${HEADER_PREFIX}-Response-Risk`)); + + if (reqRisk === null && resRisk === null) { + return FAIL_CLOSED + ? new Response("Blocked by DataGuardian policy (edge, missing risk headers).", { status: 403 }) + : originResp; + } + + const risk = Math.max(reqRisk ?? -1, resRisk ?? -1); + return risk >= THRESHOLD + ? new Response("Blocked by DataGuardian policy (edge).", { status: 403 }) + : originResp; + } +}; +``` + +## Origin mi, edge mi? + +| Kriter | Origin (Middleware) | Edge (Cloudflare) | +|---|---|---| +| Bloklama noktası | Uygulama katmanı (erken) | Kullanıcıya en yakın nokta | +| Uygulama maliyeti | Düşer (erken durur) | Origin'e yine gider (header okumak için) | +| Çoklu origin / ortak politika | Zor (her servis ayrı) | Kolay (tek yerde politika) | +| Streaming / SSE | İçerik değişmeden önce karar | Çoğu zaman içerik geldikten sonra | + +**Kısaca:** +- Sadece gözlem → Origin `Tag`. +- Maliyet/risk kritik → Origin `Block`. +- Merkezi politika → Edge blok. +- En sıkı → Origin `Block` + Edge fail-closed ikinci bariyer. + +> `X-DataGuardian-*-Detected` header'larını dış istemcilere sızdırmayın; edge'de güven sınırından önce temizlemeyi düşünün (bkz. [security.md](security.md)). diff --git a/docs/recognizers.md b/docs/recognizers.md new file mode 100644 index 0000000..f79b9b6 --- /dev/null +++ b/docs/recognizers.md @@ -0,0 +1,38 @@ +# Tanıyıcılar (Recognizers) + +Her tanıyıcı `IPiiRecognizer` arayüzünü uygular ve metin içinde bir tip için `PiiHit` (tip, başlangıç, uzunluk) listesi döndürür. Tip adları `PiiTypes` sabitlerinden gelir. + +| Tip | Tanıyıcı | Doğrulama | Notlar | +|---|---|---|---| +| `TCKN` | `TcknRecognizer` | Resmî T.C. Kimlik No checksum'ı (10. ve 11. hane) | **Dilden bağımsız** çalışır; checksum yanlış pozitifleri eler. | +| `CREDIT_CARD` | `CreditCardRecognizer` | Luhn + bilinen şema öneki | Visa, Mastercard, Amex, **Troy**, Discover, JCB, Maestro, Diners. | +| `IBAN` | `IbanRecognizer` | Ülke uzunluğu + mod-97 | Kompakt ve 4'lü gruplu (boşluklu) gösterimi tanır. | +| `EMAIL` | `EmailRecognizer` | Desen | Geri-izleme güvenli desen (ReDoS önlemli). | +| `PHONE` | `PhoneRecognizer` | libphonenumber `IsValidNumber` | Ulusal format için `DefaultPhoneRegion`/dil bölgesi kullanılır. | +| `DOB` | `DobRecognizer` | Takvim geçerliliği + gelecek reddi | `yyyy-mm-dd` ve `dd-mm-yyyy` (`/`, `.`, `-`). | +| `ADDRESS` | `AddressRecognizer` | Kelime sınırlı anahtar sözcük | Yalnızca anahtar sözcüğü kapsar; tag/skor amaçlıdır. | + +## Önemli davranışlar + +- **Dil ve tanıyıcı seçimi.** TCKN, IBAN, kredi kartı ve e-posta yapısal desenlerdir ve **her dilde** çalışır. Yalnızca adres anahtar sözcükleri (TR/EN listesi) ve telefon varsayılan bölgesi dile bağlıdır. Dil, `LanguageOverride` yoksa `SimpleLanguage.Guess` ile (Türkçe diakritik veya yaygın Türkçe kelimeler) tahmin edilir. +- **ReDoS koruması.** Tüm regex tabanlı tanıyıcılar `Compiled` + 200 ms `MatchTimeout` ile kurulur; e-posta deseni katastrofik geri izleme yapmayacak biçimde yazılmıştır. +- **Çakışma çözümü.** Aynı metin aralığını birden fazla desen tanıyıcı yakalarsa, motor en yüksek ağırlıklı (eşitse en uzun) hit'i tutar; böylece çifte sayım olmaz. +- **ADDRESS ve redaksiyon.** ADDRESS hit'i yalnızca anahtar sözcüğü (`mah.`, `no:` vb.) kapsadığından varsayılan `RedactTypes` içinde **değildir**; anahtar sözcüğü maskelemek adresi gizlemez. ADDRESS'i skor/etiketleme için kullanın. + +## Yeni tanıyıcı ekleme + +`IPiiRecognizer` uygulayan bir sınıf yazın ve `PiiTypes`'a yeni bir sabit ekleyip `Weights` (ve gerekiyorsa `RedactTypes`) içine aynı anahtarı koyun. Ayrıntı: [contributing.md](contributing.md). + + +## NER (ONNX) — deneysel + +Opsiyonel BERT-NER, serbest metinde `PERSON/ADDRESS/...` gibi varlıkları bulur. `EnableNer = true` ve `models/` altında `kvkk-ner.onnx`, `tokenizer.json`, `labels.txt` gerektirir. + +**Bilinen sınırlar (v1.x):** + +- NER varlık ofsetleri **token indeksi** olarak üretilir; redaksiyon karakter ofseti beklediğinden, NER tabanlı redaksiyon yanlış konumu maskeleyebilir. Bu nedenle NER'i şu an **yalnızca skor/etiketleme** amacıyla kullanın; hassas redaksiyon için NER'e güvenmeyin. +- Girdi tensör isimleri (`input_ids`, `attention_mask`, `token_type_ids`) sabittir; `token_type_ids` beklemeyen modellerde (DistilBERT/RoBERTa) çalışmayabilir. +- `NerMaxSequenceLength`'ten uzun metin kırpılır (pencereleme yoktur). +- Model/tokenizer dosyaları yüklenemezse NER sessizce devre dışı kalır. Üretimde NER'e güveniyorsanız dosyaların dağıtıma dahil olduğunu ve yüklendiğini doğrulayın. + +Bu maddeler yol haritasında ele alınmaktadır (README → Roadmap v2.0). diff --git a/docs/scoring.md b/docs/scoring.md new file mode 100644 index 0000000..708c81e --- /dev/null +++ b/docs/scoring.md @@ -0,0 +1,47 @@ +# Risk Skorlaması + +DataGuardian, tespit edilen PII'ları 0–10 arası tek bir risk skoruna indirger. + +## Formül + +``` +sum = Σ_type ( min(count_type, MaxCountPerType) × weight_type ) +risk = 10 × (1 − e^(−K × sum)) +risk = clamp(risk, 0, 10) +``` + +- `count_type`: o tipten kaç hit bulunduğu (çakışma çözümü sonrası). +- `weight_type`: `Weights` sözlüğündeki ağırlık. Anahtar yoksa **1**; açık **0** tipi devre dışı bırakır; negatif değer 0 sayılır. +- `MaxCountPerType`: tek tipin katkısını sınırlar (varsayılan 5). +- `K`: eğrinin dikliği (varsayılan 0.15). Büyük `K` daha az veriyle eşiğe ulaşır. +- Sonuç her zaman `[0, 10]` aralığına clamp edilir. + +## Sezgi + +Fonksiyon monoton artan ve doyumludur: ilk tespitler skoru hızla yükseltir, çok sayıda tespit skoru 10'a asimptotik yaklaştırır. Bu, "bir TCKN de on TCKN de yüksek risktir" davranışını verir. + +### Örnek + +Varsayılan ağırlıklarla tek bir TCKN (ağırlık 10): + +``` +sum = min(1,5) × 10 = 10 +risk = 10 × (1 − e^(−0.15 × 10)) = 10 × (1 − e^(−1.5)) ≈ 7.77 +``` + +Tek bir e-posta (ağırlık 4): + +``` +sum = 4 +risk = 10 × (1 − e^(−0.6)) ≈ 4.51 +``` + +## Ağırlıkla ayar + +- Bir tipi tamamen skordan çıkarmak için `Weights[type] = 0` verin. +- Bir tipin etkisini artırmak/azaltmak için ağırlığı değiştirin. +- Eşikleri (`BlockAt`, `RedactAt`) bu skala üzerinden seçin; örnek politikalar için [actions.md](actions.md). + +## Doğrulama + +`K > 0` ve `MaxCountPerType >= 1` başlangıçta zorunludur (aksi halde `ArgumentException`). Bu, negatif/sonsuz risk gibi sessiz konfigürasyon hatalarını engeller. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..227a9be --- /dev/null +++ b/docs/security.md @@ -0,0 +1,54 @@ +# Güvenlik Modeli, Sınırlar ve Öneriler + +DataGuardian bir **derinlemesine savunma** katmanıdır; tek başına bir veri sızıntısı garantisi değildir. Bu belge tehdit modelini, bilinen sınırları ve sıkılaştırma önerilerini özetler. + +## Ne sağlar + +- Request/response gövdelerinde kural tabanlı PII tespiti ve 0–10 risk skoru. +- İsteğe bağlı redaksiyon (MaskAll/Partial/Hash) ve bloklama. +- Edge (Cloudflare) ile ikinci bir bariyer. + +## Bilinen sınırlar + +- **Kapsam gövdeyledir.** Query string, path, header, cookie ve dosya adları analiz edilmez. +- **İçerik türü/boyut.** `AnalyzableContentTypes` dışındaki türler ve `MaxBodySizeBytes` üstü gövdeler varsayılan olarak analiz edilmeden geçer. Sıkı senaryolarda `BlockOversizeBodies = true` kullanın ve tür listesini gözden geçirin. +- **Kural tabanlı tespit eksiksiz değildir.** Yeni/nadir formatlar kaçabilir; adres tespiti anahtar sözcük temellidir. NER opsiyoneldir ve v1.x'te redaksiyon için olgun değildir (bkz. [recognizers.md](recognizers.md#ner-onnx)). +- **Charset.** UTF-8/ASCII olmayan gövdeler bozulmasın diye redakte edilmez. + +## Header'lar bir oracle olabilir + +`EmitHeaders = true` (varsayılan) iken `X-DataGuardian-*-Detected` header'ları hangi PII tiplerinin ve kaç adedinin bulunduğunu açığa vurur. Bu bilgi bir saldırgan için değerlidir. + +**Öneri:** +- Bu header'ları yalnızca güvenilir iç tüketicilere (ör. edge) verin. +- Dış istemcilere dönen yanıtlarda `EmitHeaders = false` yapın veya güven sınırında (reverse proxy/edge) `X-DataGuardian-*` header'larını **temizleyin**. +- Bloklanan yanıtlarda `-Detected` header'ları zaten temizlenir. + +## Redaksiyon geri döndürülebilirliği + +- `Hash` stili gizli anahtarlı HMAC kullanır; anahtar gizli kalmalıdır. Zayıf/paylaşılan anahtar veya `MaskAll`'a kıyasla yanlış beklenti geri döndürme riskini artırır. Ayrıntı: [actions.md](actions.md). +- Düşük entropili tanımlayıcılar (TCKN, kart) için en güvenli seçenek `MaskAll`'dır. + +## Denial of Service + +- İstek ve yanıt gövdeleri `MaxBodySizeBytes` ile sınırlanır; büyük gövdeler tamponlanmadan akıtılır. +- Regex tanıyıcılarında `MatchTimeout` vardır ve e-posta deseni ReDoS'a karşı yazılmıştır. +- Yine de üst katmanda (Kestrel `MaxRequestBodySize`, reverse proxy limitleri, rate limiting) sınırlar koymanız önerilir. + +## Edge (Cloudflare) sıkılaştırma + +- Risk header'ları kültürden bağımsız (`InvariantCulture`) üretilir; edge snippet virgüllü ondalıkları da tolere eder. +- Snippet, risk header'ları eksik/geçersizse `DATAGUARDIAN_FAIL_MODE=closed` ile **fail-closed** yapılandırılabilir. Regülasyon-kritik uçlarda bunu tercih edin. +- Ayrıntı: [edge.md](edge.md). + +## Önerilen üretim yapılandırması (özet) + +- `Action = Block` + uygun `BlockAt` (regülasyon-kritik uçlarda), gerekiyorsa edge'de ikinci bariyer. +- Dış yüzeyde `EmitHeaders = false`. +- `BlockOversizeBodies = true` (kritik uçlarda). +- `Hash` kullanılacaksa gizli ve sabit `RedactionHashKey`; aksi halde `MaskAll`. +- NER'e redaksiyon için güvenmeyin; skor/etiketleme için kullanın. + +## Zafiyet bildirimi + +Güvenlik açığı bildirimlerini herkese açık bir issue yerine deponun güvenlik/iletişim kanalları üzerinden iletin. diff --git a/edge/dataguardian-snippet.js b/edge/dataguardian-snippet.js index 9440875..5ebb921 100644 --- a/edge/dataguardian-snippet.js +++ b/edge/dataguardian-snippet.js @@ -1,9 +1,24 @@ +/** + * DataGuardian edge policy (Cloudflare Snippet / Worker). + * + * Reads the DataGuardian risk headers produced by the origin and blocks the response with 403 when the + * risk meets the threshold. + * + * Environment variables: + * DATAGUARDIAN_THRESHOLD Numeric threshold, e.g. "8.0" (default 8.0 when unset/invalid). + * DATAGUARDIAN_HEADER_PREFIX Header prefix (default "X-DataGuardian"). + * DATAGUARDIAN_EXCLUDED_PATHS Comma-separated path prefixes to bypass (default "/health,/metrics"). + * DATAGUARDIAN_FAIL_MODE "open" (default) forwards when risk headers are missing/unparseable; + * "closed" blocks in that case (recommended for regulated endpoints). + */ export default { async fetch(request, env, ctx) { - const THRESHOLD = parseFloat(env.DATAGUARDIAN_THRESHOLD ?? "8.0"); + const parsedThreshold = parseNumber(env.DATAGUARDIAN_THRESHOLD); + const THRESHOLD = parsedThreshold === null ? 8.0 : parsedThreshold; const HEADER_PREFIX = env.DATAGUARDIAN_HEADER_PREFIX || "X-DataGuardian"; const EXCLUDED = (env.DATAGUARDIAN_EXCLUDED_PATHS || "/health,/metrics") .split(",").map(s => s.trim()).filter(Boolean); + const FAIL_CLOSED = (env.DATAGUARDIAN_FAIL_MODE || "open").toLowerCase() === "closed"; const url = new URL(request.url); if (EXCLUDED.some(p => url.pathname.startsWith(p))) { @@ -11,13 +26,34 @@ export default { } const originResp = await fetch(request); - const reqRisk = originResp.headers.get(`${HEADER_PREFIX}-Request-Risk`); - const resRisk = originResp.headers.get(`${HEADER_PREFIX}-Response-Risk`); - const risk = Math.max(parseFloat(reqRisk ?? "-1"), parseFloat(resRisk ?? "-1")); - if (!Number.isNaN(risk) && risk >= THRESHOLD) { + const reqRisk = parseNumber(originResp.headers.get(`${HEADER_PREFIX}-Request-Risk`)); + const resRisk = parseNumber(originResp.headers.get(`${HEADER_PREFIX}-Response-Risk`)); + + // No usable risk signal at all: honor the configured fail mode instead of silently allowing. + if (reqRisk === null && resRisk === null) { + if (FAIL_CLOSED) { + return new Response("Blocked by DataGuardian policy (edge, missing risk headers).", { status: 403 }); + } + return originResp; + } + + const risk = Math.max(reqRisk ?? -1, resRisk ?? -1); + if (risk >= THRESHOLD) { return new Response("Blocked by DataGuardian policy (edge).", { status: 403 }); } return originResp; } }; + +/** + * Parses a header/env value into a finite number, tolerating decimal commas (e.g. tr-TR "8,90"). + * Returns null when the value is missing or not numeric. + */ +function parseNumber(value) { + if (value === null || value === undefined) return null; + const normalized = String(value).trim().replace(",", "."); + if (normalized === "") return null; + const n = Number(normalized); + return Number.isFinite(n) ? n : null; +} diff --git a/samples/Devoplus.DataGuardian.SampleApi/Program.cs b/samples/Devoplus.DataGuardian.SampleApi/Program.cs index 3ef2dc0..cafd601 100644 --- a/samples/Devoplus.DataGuardian.SampleApi/Program.cs +++ b/samples/Devoplus.DataGuardian.SampleApi/Program.cs @@ -1,20 +1,15 @@ using Devoplus.DataGuardian; var builder = WebApplication.CreateBuilder(args); -var app = builder.Build(); -var opt = new DataGuardianOptions -{ - AnalyzeRequests = true, - AnalyzeResponses = true, - HeaderPrefix = "X-DataGuardian", - Action = ActionMode.Tag, - LanguageOverride = "tr", - BlockAt = -1, - EnableNer = false, -}; +// Bind DataGuardian options from the "DataGuardian" section of appsettings.json. +// (You can also configure inline: builder.Services.AddDataGuardian(o => { o.Action = ActionMode.Tag; });) +builder.Services.AddDataGuardian(builder.Configuration.GetSection("DataGuardian")); + +var app = builder.Build(); -app.UseDataGuardian(opt); +// Resolves the options registered above. +app.UseDataGuardian(); app.MapPost("/echo", async (HttpContext ctx) => { diff --git a/samples/Devoplus.DataGuardian.SampleApi/appsettings.json b/samples/Devoplus.DataGuardian.SampleApi/appsettings.json index 10f68b8..34b5057 100644 --- a/samples/Devoplus.DataGuardian.SampleApi/appsettings.json +++ b/samples/Devoplus.DataGuardian.SampleApi/appsettings.json @@ -5,5 +5,12 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "DataGuardian": { + "AnalyzeRequests": true, + "AnalyzeResponses": true, + "Action": "Tag", + "LanguageOverride": "tr", + "EmitHeaders": true + } } diff --git a/src/Devoplus.DataGuardian/DataGuardianEngine.cs b/src/Devoplus.DataGuardian/DataGuardianEngine.cs index cdd0eb6..01f1941 100644 --- a/src/Devoplus.DataGuardian/DataGuardianEngine.cs +++ b/src/Devoplus.DataGuardian/DataGuardianEngine.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; namespace Devoplus.DataGuardian; +/// +/// Runs the configured recognizers (and optional NER) over a piece of text and produces a 0–10 risk score. +/// public sealed class DataGuardianEngine { private readonly DataGuardianOptions _opt; @@ -17,7 +19,7 @@ public DataGuardianEngine(DataGuardianOptions opt, Ner.INerRecognizer? ner = nul _recognizers = new() { new Recognizers.EmailRecognizer(), - new Recognizers.PhoneRecognizer(), + new Recognizers.PhoneRecognizer(opt.DefaultPhoneRegion), new Recognizers.IbanRecognizer(), new Recognizers.CreditCardRecognizer(), new Recognizers.TcknRecognizer(), @@ -27,47 +29,89 @@ public DataGuardianEngine(DataGuardianOptions opt, Ner.INerRecognizer? ner = nul _ner = ner; } - public (double risk, Dictionary counts) Analyze(string text) + public (double risk, Dictionary counts) Analyze(string text) { var result = AnalyzeDetailed(text); return (result.risk, result.counts); } - public (double risk, Dictionary counts, List hits) AnalyzeDetailed(string text) + public (double risk, Dictionary counts, List hits) AnalyzeDetailed(string text) { var hits = new List(); if (!string.IsNullOrWhiteSpace(text)) { var lang = _opt.LanguageOverride ?? SimpleLanguage.Guess(text); - // Pattern recognizers - hits = _recognizers.SelectMany(r => r.Analyze(text, lang)).ToList(); + // Pattern recognizers, with overlap resolution so the same span is not counted twice + // (e.g. a phone-shaped e-mail local part matched by both EMAIL and PHONE). + var patternHits = _recognizers.SelectMany(r => r.Analyze(text, lang)).ToList(); + hits.AddRange(ResolveOverlaps(patternHits)); - // Entity filters + // Optional NER. Its offsets are token based today, so it does not participate in the + // (character-offset) overlap resolution above. + if (_opt.EnableNer && _ner is not null) + { + foreach (var e in _ner.Recognize(text, lang).Where(e => e.Confidence >= _opt.MinNerConfidence)) + hits.Add(new PiiHit(e.Type, e.Start, e.End - e.Start)); + } + + // Entity type filters apply to ALL hits (pattern + NER). if (_opt.IncludeEntityTypes.Count > 0) hits = hits.Where(h => _opt.IncludeEntityTypes.Contains(h.Type)).ToList(); if (_opt.ExcludeEntityTypes.Count > 0) hits = hits.Where(h => !_opt.ExcludeEntityTypes.Contains(h.Type)).ToList(); - - // Optional NER - if (_opt.EnableNer && _ner is not null && File.Exists(_opt.NerModelPath)) - { - var ents = _ner.Recognize(text, lang); - var filtered = ents.Where(e => e.Confidence >= _opt.MinNerConfidence); - foreach (var e in filtered) - hits.Add(new PiiHit(e.Type, e.Start, e.End - e.Start)); - } } var groups = hits.GroupBy(h => h.Type).ToDictionary(g => g.Key, g => g.Count()); double sum = 0; foreach (var (type, cnt) in groups) - { - _opt.Weights.TryGetValue(type, out var w); - if (w <= 0) w = 1; - sum += Math.Min(cnt, _opt.MaxCountPerType) * w; - } + sum += Math.Min(cnt, _opt.MaxCountPerType) * WeightOf(type); + var risk = 10 * (1 - Math.Exp(-_opt.K * sum)); + risk = Math.Clamp(risk, 0, 10); return (Math.Round(risk, 2), groups, hits); } + + private double WeightOf(string type) + { + // Missing key -> default 1. An explicit 0 disables the type; negatives are treated as 0. + if (!_opt.Weights.TryGetValue(type, out var w)) return 1; + return w < 0 ? 0 : w; + } + + private List ResolveOverlaps(List hits) + { + if (hits.Count <= 1) return hits; + + var ordered = hits.OrderBy(h => h.Start).ThenByDescending(h => h.Length).ToList(); + var result = new List(); + foreach (var h in ordered) + { + int overlapIdx = -1; + for (int i = 0; i < result.Count; i++) + { + var e = result[i]; + if (h.Start < e.Start + e.Length && e.Start < h.Start + h.Length) + { + overlapIdx = i; + break; + } + } + + if (overlapIdx < 0) + { + result.Add(h); + continue; + } + + // Keep the higher-weighted type; tie-break on the longer span. + var existing = result[overlapIdx]; + if (WeightOf(h.Type) > WeightOf(existing.Type) || + (WeightOf(h.Type) == WeightOf(existing.Type) && h.Length > existing.Length)) + { + result[overlapIdx] = h; + } + } + return result; + } } diff --git a/src/Devoplus.DataGuardian/DataGuardianExtensions.cs b/src/Devoplus.DataGuardian/DataGuardianExtensions.cs index fef4b81..1767c32 100644 --- a/src/Devoplus.DataGuardian/DataGuardianExtensions.cs +++ b/src/Devoplus.DataGuardian/DataGuardianExtensions.cs @@ -1,9 +1,87 @@ using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using System; namespace Devoplus.DataGuardian; +/// Service-registration helpers for DataGuardian. +public static class DataGuardianServiceCollectionExtensions +{ + /// Registers DataGuardian options configured from a delegate. + public static IServiceCollection AddDataGuardian(this IServiceCollection services, Action configure) + { + if (configure is null) throw new ArgumentNullException(nameof(configure)); + var options = new DataGuardianOptions(); + configure(options); + options.Validate(); + services.AddSingleton(options); + return services; + } + + /// Registers DataGuardian options bound from a configuration section (e.g. "DataGuardian"). + public static IServiceCollection AddDataGuardian(this IServiceCollection services, IConfiguration section) + { + if (section is null) throw new ArgumentNullException(nameof(section)); + var options = new DataGuardianOptions(); + section.Bind(options); + options.Validate(); + services.AddSingleton(options); + return services; + } +} + +/// Pipeline helpers for DataGuardian. +public static class DataGuardianApplicationBuilderExtensions +{ + /// Adds the middleware, resolving options registered via AddDataGuardian (or defaults). + public static IApplicationBuilder UseDataGuardian(this IApplicationBuilder app) + { + var options = app.ApplicationServices.GetService(typeof(DataGuardianOptions)) as DataGuardianOptions + ?? new DataGuardianOptions(); + options.Validate(); + return app.UseMiddleware(options); + } + + /// Adds the middleware with an explicit options instance. + public static IApplicationBuilder UseDataGuardian(this IApplicationBuilder app, DataGuardianOptions options) + { + if (options is null) throw new ArgumentNullException(nameof(options)); + options.Validate(); + return app.UseMiddleware(options); + } + + /// Adds the middleware with options configured inline. + public static IApplicationBuilder UseDataGuardian(this IApplicationBuilder app, Action configure) + { + if (configure is null) throw new ArgumentNullException(nameof(configure)); + var options = new DataGuardianOptions(); + configure(options); + options.Validate(); + return app.UseMiddleware(options); + } +} + +/// +/// Compatibility shim retained so callers that reference DataGuardianExtensions by type name +/// (e.g. DataGuardianExtensions.UseDataGuardian(app, opt)) continue to compile. +/// Use or instead. +/// +[Obsolete("Use DataGuardianApplicationBuilderExtensions or DataGuardianServiceCollectionExtensions instead. This type will be removed in a future major version.")] public static class DataGuardianExtensions { - public static IApplicationBuilder UseDataGuardian(this IApplicationBuilder app, DataGuardianOptions? options = null) - => app.UseMiddleware(options ?? new DataGuardianOptions()); + /// + [Obsolete("Use DataGuardianApplicationBuilderExtensions.UseDataGuardian instead.")] + public static IApplicationBuilder UseDataGuardian(IApplicationBuilder app) + => DataGuardianApplicationBuilderExtensions.UseDataGuardian(app); + + /// + [Obsolete("Use DataGuardianApplicationBuilderExtensions.UseDataGuardian instead.")] + public static IApplicationBuilder UseDataGuardian(IApplicationBuilder app, DataGuardianOptions options) + => DataGuardianApplicationBuilderExtensions.UseDataGuardian(app, options); + + /// + [Obsolete("Use DataGuardianApplicationBuilderExtensions.UseDataGuardian instead.")] + public static IApplicationBuilder UseDataGuardian(IApplicationBuilder app, Action configure) + => DataGuardianApplicationBuilderExtensions.UseDataGuardian(app, configure); } diff --git a/src/Devoplus.DataGuardian/DataGuardianMiddleware.cs b/src/Devoplus.DataGuardian/DataGuardianMiddleware.cs index ea42718..50a0ce4 100644 --- a/src/Devoplus.DataGuardian/DataGuardianMiddleware.cs +++ b/src/Devoplus.DataGuardian/DataGuardianMiddleware.cs @@ -1,15 +1,24 @@ +using Devoplus.DataGuardian.Internal; using Microsoft.AspNetCore.Http; +using Microsoft.Net.Http.Headers; using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; +using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; namespace Devoplus.DataGuardian; +/// +/// ASP.NET Core middleware that scores request/response bodies for PII and optionally tags, redacts or blocks. +/// public sealed class DataGuardianMiddleware { + private static readonly byte[] DefaultHashKey = RandomNumberGenerator.GetBytes(32); + private readonly RequestDelegate _next; private readonly DataGuardianEngine _engine; private readonly DataGuardianOptions _opt; @@ -17,14 +26,16 @@ public sealed class DataGuardianMiddleware public DataGuardianMiddleware(RequestDelegate next, DataGuardianOptions opt) { _next = next; - _opt = opt; + _opt = opt ?? throw new ArgumentNullException(nameof(opt)); + _opt.Validate(); Ner.INerRecognizer? ner = null; - if (opt.EnableNer) + if (_opt.EnableNer) { - try { ner = new Ner.BertNerOnnx(opt); } catch { /* fall back */ } + try { ner = new Ner.BertNerOnnx(_opt); } + catch { ner = null; /* NER stays disabled; see docs/security.md */ } } - _engine = new DataGuardianEngine(opt, ner); + _engine = new DataGuardianEngine(_opt, ner); } public async Task Invoke(HttpContext ctx) @@ -35,120 +46,199 @@ public async Task Invoke(HttpContext ctx) return; } - string? reqBody = null; + // ---------------- Request analysis (before the response body is swapped) ---------------- if (_opt.AnalyzeRequests && IsTextContent(ctx.Request.ContentType)) - reqBody = await ReadRequestBodyAsync(ctx); + { + var (reqBody, oversize) = await ReadRequestBodyAsync(ctx); + if (oversize) + { + if (_opt.EmitHeaders) + ctx.Response.Headers[$"{_opt.HeaderPrefix}-Request-Skip-Reason"] = "BodyTooLarge"; + if (_opt.BlockOversizeBodies && _opt.Action == ActionMode.Block) + { + await WriteBlockAsync(ctx); + return; + } + } + else if (reqBody is not null) + { + var (risk, counts, _) = _engine.AnalyzeDetailed(reqBody); + if (_opt.EmitHeaders) + { + ctx.Response.Headers[$"{_opt.HeaderPrefix}-Request-Risk"] = Fmt(risk); + ctx.Response.Headers[$"{_opt.HeaderPrefix}-Request-Detected"] = FormatCounts(counts); + } + if (ShouldBlock(risk)) + { + await WriteBlockAsync(ctx); + return; + } + } + } - Stream? originalBody = null; - MemoryStream? buffer = null; - if (_opt.AnalyzeResponses) + if (!_opt.AnalyzeResponses) { - originalBody = ctx.Response.Body; - buffer = new MemoryStream(); - ctx.Response.Body = buffer; + await _next(ctx); + return; } - // Analyze request; possibly block - if (reqBody != null) + // ---------------- Response analysis ---------------- + var originalBody = ctx.Response.Body; + var capture = new ResponseCaptureStream(originalBody, _opt.MaxBodySizeBytes, () => IsTextContent(ctx.Response.ContentType)); + ctx.Response.Body = capture; + try { - var (r, counts, hits) = _engine.AnalyzeDetailed(reqBody); - if (_opt.EmitHeaders) - { - ctx.Response.Headers[$"{_opt.HeaderPrefix}-Request-Risk"] = r.ToString("F2"); - ctx.Response.Headers[$"{_opt.HeaderPrefix}-Request-Detected"] = FormatCounts(counts); - } + await _next(ctx); - if (_opt.Action == ActionMode.Block && _opt.BlockAt >= 0 && r >= _opt.BlockAt) + // If the body was streamed straight through (non-text or oversize), it is already on the wire. + if (capture.DidPassthrough) { - ctx.Response.StatusCode = StatusCodes.Status403Forbidden; - await ctx.Response.WriteAsync("Blocked by DataGuardian policy."); + if (_opt.EmitHeaders && capture.Overflowed && !ctx.Response.HasStarted) + ctx.Response.Headers[$"{_opt.HeaderPrefix}-Response-Skip-Reason"] = "BodyTooLarge"; return; } - } - await _next(ctx); + var buffered = capture.GetBufferedBytes(); + var responseText = TryDecodeText(ctx.Response.ContentType, buffered, out var skipReason); - // Analyze response; tag/redact/block - if (_opt.AnalyzeResponses && buffer != null && originalBody != null) - { - buffer.Position = 0; - var responseText = string.Empty; + if (responseText is null) + { + if (_opt.EmitHeaders && skipReason is not null) + ctx.Response.Headers[$"{_opt.HeaderPrefix}-Response-Skip-Reason"] = skipReason; + await originalBody.WriteAsync(buffered); + return; + } - if (IsTextContent(ctx.Response.ContentType) && buffer.Length <= _opt.MaxBodySizeBytes) + var (risk, counts, hits) = _engine.AnalyzeDetailed(responseText); + if (_opt.EmitHeaders) { - using var reader = new StreamReader(buffer, Encoding.UTF8, leaveOpen: true); - responseText = await reader.ReadToEndAsync(); - buffer.Position = 0; + ctx.Response.Headers[$"{_opt.HeaderPrefix}-Response-Risk"] = Fmt(risk); + ctx.Response.Headers[$"{_opt.HeaderPrefix}-Response-Detected"] = FormatCounts(counts); } - if (!string.IsNullOrEmpty(responseText)) + if (ShouldBlock(risk)) { - var (r, counts, hits) = _engine.AnalyzeDetailed(responseText); - if (_opt.EmitHeaders) - { - ctx.Response.Headers[$"{_opt.HeaderPrefix}-Response-Risk"] = r.ToString("F2"); - ctx.Response.Headers[$"{_opt.HeaderPrefix}-Response-Detected"] = FormatCounts(counts); - } + ctx.Response.Body = originalBody; + await WriteBlockAsync(ctx); + return; + } - if (_opt.Action == ActionMode.Block && _opt.BlockAt >= 0 && r >= _opt.BlockAt) - { - ctx.Response.Clear(); - ctx.Response.StatusCode = StatusCodes.Status403Forbidden; - await ctx.Response.WriteAsync("Blocked by DataGuardian policy."); - return; - } - if (_opt.Action == ActionMode.Redact && r >= _opt.RedactAt && hits.Count > 0) + if (_opt.Action == ActionMode.Redact && risk >= _opt.RedactAt && hits.Count > 0) + { + var redactable = hits.Where(h => _opt.RedactTypes.Contains(h.Type)).ToList(); + if (redactable.Count > 0) { - var redacted = Redact(responseText, hits, _opt); + var redacted = Redact(responseText, redactable, _opt); var bytes = Encoding.UTF8.GetBytes(redacted); - await originalBody.WriteAsync(bytes, 0, bytes.Length); - ctx.Response.Body = originalBody; + ctx.Response.ContentLength = bytes.Length; + await originalBody.WriteAsync(bytes); return; } + if (_opt.EmitHeaders) + ctx.Response.Headers[$"{_opt.HeaderPrefix}-Response-Skip-Reason"] = "NoRedactableTypes"; } - if (!_opt.AnalyzeResponses) - ctx.Response.Headers["X-DataGuardian-Response-Skip-Reason"] = "AnalyzeResponses=false"; - else if (!IsTextContent(ctx.Response.ContentType)) - ctx.Response.Headers["X-DataGuardian-Response-Skip-Reason"] = "ContentTypeNotAnalyzable"; - else if (buffer.Length > _opt.MaxBodySizeBytes) - ctx.Response.Headers["X-DataGuardian-Response-Skip-Reason"] = "BodyTooLarge"; - else if (string.IsNullOrEmpty(responseText)) - ctx.Response.Headers["X-DataGuardian-Response-Skip-Reason"] = "EmptyResponse"; - - await buffer.CopyToAsync(originalBody); + await originalBody.WriteAsync(buffered); + } + finally + { ctx.Response.Body = originalBody; + capture.Dispose(); + } + } + + private async Task WriteBlockAsync(HttpContext ctx) + { + if (!ctx.Response.HasStarted) + { + ctx.Response.Clear(); + // Do not leak which PII types were found on a blocked response. + ctx.Response.Headers.Remove($"{_opt.HeaderPrefix}-Request-Detected"); + ctx.Response.Headers.Remove($"{_opt.HeaderPrefix}-Response-Detected"); } + ctx.Response.StatusCode = StatusCodes.Status403Forbidden; + ctx.Response.ContentType = "text/plain; charset=utf-8"; + await ctx.Response.WriteAsync(_opt.BlockMessage); } - private static string FormatCounts(Dictionary counts) + private string Fmt(double risk) => risk.ToString("F2", CultureInfo.InvariantCulture); + + private static string FormatCounts(Dictionary counts) => string.Join(";", counts.Select(kv => $"{kv.Key}={kv.Value}")); + private bool ShouldBlock(double risk) + => _opt.Action == ActionMode.Block && _opt.BlockAt >= 0 && risk >= _opt.BlockAt; + private bool IsTextContent(string? contentType) - => !string.IsNullOrEmpty(contentType) && - _opt.AnalyzableContentTypes.Any(ct => contentType.StartsWith(ct, StringComparison.OrdinalIgnoreCase)); + { + // A missing/empty content type is treated as analyzable so it cannot be used to bypass the control. + if (string.IsNullOrEmpty(contentType)) return true; + return _opt.AnalyzableContentTypes.Any(ct => contentType.StartsWith(ct, StringComparison.OrdinalIgnoreCase)); + } - private static async Task ReadRequestBodyAsync(HttpContext ctx) + /// Reads the request body up to the size cap. Returns (body, oversize); body is null when skipped. + private async Task<(string? body, bool oversize)> ReadRequestBodyAsync(HttpContext ctx) { + // Check ContentLength before enabling buffering to avoid unnecessary disk-buffering overhead + // for oversized requests that we will immediately reject. + if (ctx.Request.ContentLength is long declared && declared > _opt.MaxBodySizeBytes) + return (null, true); + ctx.Request.EnableBuffering(); - using var reader = new StreamReader(ctx.Request.Body, Encoding.UTF8, leaveOpen: true); - var body = await reader.ReadToEndAsync(); - ctx.Request.Body.Position = 0; - return body; + var stream = ctx.Request.Body; + + using var ms = new MemoryStream(); + var buffer = new byte[8192]; + long total = 0; + int read; + while ((read = await stream.ReadAsync(buffer)) > 0) + { + total += read; + if (total > _opt.MaxBodySizeBytes) + { + stream.Position = 0; + return (null, true); + } + ms.Write(buffer, 0, read); + } + + stream.Position = 0; + return (Encoding.UTF8.GetString(ms.ToArray()), false); + } + + /// Decodes bytes to text when safe; otherwise returns null and sets a skip reason. + private string? TryDecodeText(string? contentType, byte[] bytes, out string? skipReason) + { + skipReason = null; + if (bytes.Length == 0) { skipReason = "EmptyResponse"; return null; } + if (!IsTextContent(contentType)) { skipReason = "ContentTypeNotAnalyzable"; return null; } + if (!IsUtf8Compatible(contentType)) { skipReason = "UnsupportedCharset"; return null; } + try { return Encoding.UTF8.GetString(bytes); } + catch { skipReason = "DecodeError"; return null; } + } + + private static bool IsUtf8Compatible(string? contentType) + { + // Only decode/redact when the charset is UTF-8/ASCII (or unspecified). Re-encoding another + // charset as UTF-8 would corrupt the body, so those are skipped rather than mangled. + if (string.IsNullOrEmpty(contentType)) return true; + if (!MediaTypeHeaderValue.TryParse(contentType, out var mt) || !mt.Charset.HasValue) return true; + var cs = mt.Charset.ToString().Trim('"').ToLowerInvariant(); + return cs is "utf-8" or "utf8" or "us-ascii" or "ascii" or ""; } private bool IsAllowed(HttpContext ctx) { var path = ctx.Request.Path.HasValue ? ctx.Request.Path.Value! : string.Empty; - var method = ctx.Request.Method?.ToUpperInvariant() ?? ""; + var method = ctx.Request.Method ?? string.Empty; if (_opt.IncludePaths.Count > 0 && !_opt.IncludePaths.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase))) return false; if (_opt.ExcludePaths.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase))) return false; - if (_opt.IncludeMethods.Count > 0 && !_opt.IncludeMethods.Contains(method)) + if (_opt.IncludeMethods.Count > 0 && !_opt.IncludeMethods.Any(m => string.Equals(m, method, StringComparison.OrdinalIgnoreCase))) return false; - if (_opt.ExcludeMethods.Contains(method)) + if (_opt.ExcludeMethods.Any(m => string.Equals(m, method, StringComparison.OrdinalIgnoreCase))) return false; return true; } @@ -159,25 +249,42 @@ private static string Redact(string text, IEnumerable hits, DataGuardian var toRedact = hits.Where(h => opt.RedactTypes.Contains(h.Type)).OrderByDescending(h => h.Start).ToList(); foreach (var h in toRedact) { - if (h.Start < 0 || h.Start + h.Length > sb.Length) continue; + if (h.Start < 0 || h.Start + h.Length > sb.Length || h.Length <= 0) continue; + if (opt.Redaction == RedactionStyle.MaskAll) { for (int i = 0; i < h.Length; i++) sb[h.Start + i] = '*'; } else if (opt.Redaction == RedactionStyle.Partial) { - for (int i = 0; i < h.Length; i++) - sb[h.Start + i] = (i < 2 || i >= h.Length - 2) ? sb[h.Start + i] : '*'; + if (h.Length <= 4) + { + for (int i = 0; i < h.Length; i++) sb[h.Start + i] = '*'; + } + else + { + for (int i = 0; i < h.Length; i++) + if (!(i < 2 || i >= h.Length - 2)) sb[h.Start + i] = '*'; + } } else // Hash { var segment = sb.ToString(h.Start, h.Length); - var hash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(segment))); - var repl = hash[..12]; + var repl = HashValue(segment, opt); sb.Remove(h.Start, h.Length); sb.Insert(h.Start, repl); } } return sb.ToString(); } -} \ No newline at end of file + + private static string HashValue(string value, DataGuardianOptions opt) + { + var key = string.IsNullOrEmpty(opt.RedactionHashKey) + ? DefaultHashKey + : Encoding.UTF8.GetBytes(opt.RedactionHashKey); + using var hmac = new HMACSHA256(key); + var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(value)); + return Convert.ToHexString(hash)[..16]; + } +} diff --git a/src/Devoplus.DataGuardian/DataGuardianOptions.cs b/src/Devoplus.DataGuardian/DataGuardianOptions.cs index 06424c9..d68e53b 100644 --- a/src/Devoplus.DataGuardian/DataGuardianOptions.cs +++ b/src/Devoplus.DataGuardian/DataGuardianOptions.cs @@ -3,60 +3,152 @@ namespace Devoplus.DataGuardian; +/// Configuration for the DataGuardian middleware and engine. public sealed class DataGuardianOptions { + /// Content types whose bodies are analyzed. A missing/empty content type is also analyzed. public HashSet AnalyzableContentTypes { get; set; } = new(StringComparer.OrdinalIgnoreCase) - { "application/json", "text/plain", "application/xml", "application/x-www-form-urlencoded" }; + { "application/json", "text/plain", "text/html", "text/csv", "application/xml", "application/x-www-form-urlencoded" }; + /// Per-type risk weights. Keys must match the recognizer output types (see ). public Dictionary Weights { get; set; } = new() { - ["TCKN"] = 10, ["CREDIT_CARD"] = 9, ["IBAN_TR"] = 8, - ["DOB"] = 7, ["ADDRESS"] = 6, ["PHONE"] = 5, ["EMAIL"] = 4, ["PERSON"] = 3 + [PiiTypes.Tckn] = 10, [PiiTypes.CreditCard] = 9, [PiiTypes.Iban] = 8, + [PiiTypes.Dob] = 7, [PiiTypes.Address] = 6, [PiiTypes.Phone] = 5, + [PiiTypes.Email] = 4, [PiiTypes.Person] = 3 }; + /// Maximum number of hits of a single type that contribute to the score. public int MaxCountPerType { get; set; } = 5; + + /// Risk curve steepness in 10 * (1 - e^(-K * weightedCount)). Must be > 0. public double K { get; set; } = 0.15; + + /// Analyze request bodies. public bool AnalyzeRequests { get; set; } = true; + + /// Analyze response bodies. public bool AnalyzeResponses { get; set; } = true; + + /// Block threshold (0–10). A negative value disables blocking even in . public double BlockAt { get; set; } = -1; + /// Maximum request/response body size (bytes) that is buffered and analyzed. public int MaxBodySizeBytes { get; set; } = 512 * 1024; + /// + /// When true and a body exceeds in mode, + /// the request is blocked (fail closed) instead of passed through unanalyzed. Off by default. + /// + public bool BlockOversizeBodies { get; set; } = false; + // NER (optional) + /// Enable the optional ONNX NER recognizer. public bool EnableNer { get; set; } = false; + /// Path to the ONNX NER model. public string NerModelPath { get; set; } = "models/kvkk-ner.onnx"; + /// Path to the tokenizer JSON. public string NerTokenizerPath { get; set; } = "models/tokenizer.json"; + /// Path to the label list. public string NerLabelsPath { get; set; } = "models/labels.txt"; + /// Maximum token sequence length passed to the model. public int NerMaxSequenceLength { get; set; } = 256; + /// Minimum confidence (0–1) for an NER entity to be kept. public double MinNerConfidence { get; set; } = 0.6; - // Header prefix (e.g., X-DataGuardian) + /// Prefix for the emitted headers (e.g. X-DataGuardian). public string HeaderPrefix { get; set; } = "X-DataGuardian"; // Path & method filters + /// If non-empty, only paths starting with one of these are processed. public List IncludePaths { get; set; } = new(); + /// Paths starting with one of these are skipped. public List ExcludePaths { get; set; } = new(); - public List IncludeMethods { get; set; } = new(); // POST, PUT + /// If non-empty, only these HTTP methods are processed (case-insensitive). + public List IncludeMethods { get; set; } = new(); + /// These HTTP methods are skipped (case-insensitive). public List ExcludeMethods { get; set; } = new(); // Entity filters - public HashSet IncludeEntityTypes { get; set; } = new(); // empty = all + /// If non-empty, only these entity types are kept (empty = all). + public HashSet IncludeEntityTypes { get; set; } = new(); + /// These entity types are dropped. public HashSet ExcludeEntityTypes { get; set; } = new(); // Action mode - public ActionMode Action { get; set; } = ActionMode.Tag; // Tag by default - public double RedactAt { get; set; } = 0; // Redact when risk >= RedactAt - public HashSet RedactTypes { get; set; } = new() { "EMAIL","PHONE","TCKN","CREDIT_CARD","IBAN_TR","DOB" }; + /// Action to take: , , or . + public ActionMode Action { get; set; } = ActionMode.Tag; + /// Redaction threshold (0–10). Response is redacted when risk >= this value. + public double RedactAt { get; set; } = 0; + /// Entity types eligible for redaction. Keys must match . + public HashSet RedactTypes { get; set; } = new() + { PiiTypes.Email, PiiTypes.Phone, PiiTypes.Tckn, PiiTypes.CreditCard, PiiTypes.Iban, PiiTypes.Dob }; + /// Redaction style: mask all, partial or hash. public RedactionStyle Redaction { get; set; } = RedactionStyle.MaskAll; + /// + /// Optional secret key for . When null, a random per-process key is used. + /// Set a stable secret to make hashes deterministic across instances (needed for cross-record correlation), + /// but never expose it: the hash is only irreversible if the key stays secret. + /// + public string? RedactionHashKey { get; set; } = null; + + /// Body returned to the client when a request/response is blocked. + public string BlockMessage { get; set; } = "Blocked by DataGuardian policy."; + // Headers toggle + /// Emit the X-DataGuardian-* headers. These reveal detected PII types; keep them internal. public bool EmitHeaders { get; set; } = true; - // Language override (null=auto, "tr" or "en") + /// Default region (ISO 3166-1 alpha-2) used to parse national-format phone numbers. + public string DefaultPhoneRegion { get; set; } = "TR"; + + /// Language override: null = auto-detect, otherwise "tr" or "en". public string? LanguageOverride { get; set; } = null; + + /// Validates the configuration and throws on invalid values. + public void Validate() + { + if (K <= 0) + throw new ArgumentException("K must be greater than 0.", nameof(K)); + if (MaxCountPerType < 1) + throw new ArgumentException("MaxCountPerType must be at least 1.", nameof(MaxCountPerType)); + if (MaxBodySizeBytes < 1) + throw new ArgumentException("MaxBodySizeBytes must be positive.", nameof(MaxBodySizeBytes)); + if (BlockAt > 10) + throw new ArgumentException("BlockAt cannot exceed 10.", nameof(BlockAt)); + if (RedactAt < 0 || RedactAt > 10) + throw new ArgumentException("RedactAt must be between 0 and 10.", nameof(RedactAt)); + if (NerMaxSequenceLength < 1) + throw new ArgumentException("NerMaxSequenceLength must be positive.", nameof(NerMaxSequenceLength)); + if (MinNerConfidence < 0 || MinNerConfidence > 1) + throw new ArgumentException("MinNerConfidence must be between 0 and 1.", nameof(MinNerConfidence)); + if (string.IsNullOrWhiteSpace(HeaderPrefix)) + throw new ArgumentException("HeaderPrefix must not be empty.", nameof(HeaderPrefix)); + } } -// Supporting enums -public enum ActionMode { None, Tag, Redact, Block } -public enum RedactionStyle { MaskAll, Partial, Hash } +/// Action the middleware takes based on the risk score. +public enum ActionMode +{ + /// Do nothing. + None, + /// Emit headers only. + Tag, + /// Redact matching values in the response body. + Redact, + /// Return 403 when the risk threshold is exceeded. + Block +} + +/// How matched values are redacted. +public enum RedactionStyle +{ + /// Replace every character with *. + MaskAll, + /// Keep the first and last two characters (values of length <= 4 are fully masked). + Partial, + /// Replace with a keyed HMAC-SHA256 prefix. + Hash +} diff --git a/src/Devoplus.DataGuardian/Devoplus.DataGuardian.csproj b/src/Devoplus.DataGuardian/Devoplus.DataGuardian.csproj index 8b20e8f..5e39d60 100644 --- a/src/Devoplus.DataGuardian/Devoplus.DataGuardian.csproj +++ b/src/Devoplus.DataGuardian/Devoplus.DataGuardian.csproj @@ -4,25 +4,27 @@ enable latest nullable + true + $(NoWarn);CS1591 True Devoplus Data Guardian DataGuardian, ASP.NET Core için request/response gövdelerinde PII/Sensitive Data tespiti yapar ve 0–10 risk skoru üretir. İsteğe bağlı olarak response header yazar, redaksiyon yapar veya bloklar. Türkçe ve İngilizce dillerini destekler. + Devoplus Devoplus + pii;kvkk;gdpr;privacy;middleware;aspnetcore;redaction;security;tckn;iban https://github.com/devoplus/DataGuardian README.md https://github.com/devoplus/DataGuardian git - 1.0.1 - 1.0.1 + 1.1.0 + 1.1.0 MIT - True - 1.0.1 + 1.1.0 - diff --git a/src/Devoplus.DataGuardian/Internal/ResponseCaptureStream.cs b/src/Devoplus.DataGuardian/Internal/ResponseCaptureStream.cs new file mode 100644 index 0000000..2c591a8 --- /dev/null +++ b/src/Devoplus.DataGuardian/Internal/ResponseCaptureStream.cs @@ -0,0 +1,135 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Devoplus.DataGuardian.Internal; + +/// +/// A write-only response wrapper that buffers the body in memory only when it is worth analyzing. +/// +/// +/// On the first write it asks shouldBuffer (typically "is this an analyzable content type?"). +/// +/// If not, it streams straight to the original body (no buffering) — this keeps large downloads +/// and text/event-stream responses working and bounds memory. +/// If yes, it buffers up to cap bytes for the middleware to inspect. If the body grows past +/// the cap, the buffered bytes are flushed to the original stream and it switches to passthrough. +/// +/// When is true the body has already been sent and must not be rewritten. +/// +internal sealed class ResponseCaptureStream : Stream +{ + private readonly Stream _inner; + private readonly long _cap; + private readonly Func _shouldBuffer; + private MemoryStream? _buffer = new(); + private bool _decided; + private bool _buffering; + + public ResponseCaptureStream(Stream inner, long cap, Func shouldBuffer) + { + _inner = inner; + _cap = cap > 0 ? cap : 0; + _shouldBuffer = shouldBuffer; + } + + /// True once any bytes have been written directly to the original stream. + public bool DidPassthrough { get; private set; } + + /// True if buffering started but the body exceeded the cap. + public bool Overflowed { get; private set; } + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + + public byte[] GetBufferedBytes() => _buffer?.ToArray() ?? Array.Empty(); + + private void EnsureDecision() + { + if (_decided) return; + _decided = true; + _buffering = _shouldBuffer(); + if (!_buffering) DidPassthrough = true; + } + + public override void Write(byte[] buffer, int offset, int count) + { + EnsureDecision(); + if (!_buffering) + { + _inner.Write(buffer, offset, count); + return; + } + if (_buffer!.Length + count > _cap) + { + SpillToInner(); + _inner.Write(buffer, offset, count); + return; + } + _buffer!.Write(buffer, offset, count); + } + + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => await WriteAsync(new ReadOnlyMemory(buffer, offset, count), cancellationToken); + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + EnsureDecision(); + if (!_buffering) + { + await _inner.WriteAsync(buffer, cancellationToken); + return; + } + if (_buffer!.Length + buffer.Length > _cap) + { + await SpillToInnerAsync(cancellationToken); + await _inner.WriteAsync(buffer, cancellationToken); + return; + } + _buffer!.Write(buffer.Span); + } + + private void SpillToInner() + { + Overflowed = true; + _buffering = false; + DidPassthrough = true; + _buffer!.Position = 0; + _buffer.CopyTo(_inner); + _buffer.Dispose(); + _buffer = null; + } + + private async Task SpillToInnerAsync(CancellationToken ct) + { + Overflowed = true; + _buffering = false; + DidPassthrough = true; + _buffer!.Position = 0; + await _buffer.CopyToAsync(_inner, ct); + _buffer.Dispose(); + _buffer = null; + } + + public override void Flush() + { + if (_decided && !_buffering) _inner.Flush(); + } + + public override Task FlushAsync(CancellationToken cancellationToken) + => (_decided && !_buffering) ? _inner.FlushAsync(cancellationToken) : Task.CompletedTask; + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) _buffer?.Dispose(); + base.Dispose(disposing); + } +} diff --git a/src/Devoplus.DataGuardian/PiiTypes.cs b/src/Devoplus.DataGuardian/PiiTypes.cs new file mode 100644 index 0000000..0f1ae3c --- /dev/null +++ b/src/Devoplus.DataGuardian/PiiTypes.cs @@ -0,0 +1,37 @@ +namespace Devoplus.DataGuardian; + +/// +/// Canonical PII entity type identifiers shared by recognizers, scoring weights and redaction rules. +/// +/// +/// Using these constants everywhere prevents key-mismatch defects between a recognizer's output type +/// and the keys used in and +/// . A mismatch would silently drop a type's weight to the +/// default and skip its redaction. The TypeConsistencyTests assert this alignment. +/// +public static class PiiTypes +{ + /// Turkish national identity number (T.C. Kimlik No). + public const string Tckn = "TCKN"; + + /// Payment card number (Luhn + known scheme prefixes). + public const string CreditCard = "CREDIT_CARD"; + + /// International Bank Account Number (any registered country). + public const string Iban = "IBAN"; + + /// Date that may represent a date of birth. + public const string Dob = "DOB"; + + /// Postal address (keyword based). + public const string Address = "ADDRESS"; + + /// Telephone number. + public const string Phone = "PHONE"; + + /// E-mail address. + public const string Email = "EMAIL"; + + /// Person name (produced by the optional NER model). + public const string Person = "PERSON"; +} diff --git a/src/Devoplus.DataGuardian/Recognizers/AddressRecognizer.cs b/src/Devoplus.DataGuardian/Recognizers/AddressRecognizer.cs index 1e139ce..8f7c3df 100644 --- a/src/Devoplus.DataGuardian/Recognizers/AddressRecognizer.cs +++ b/src/Devoplus.DataGuardian/Recognizers/AddressRecognizer.cs @@ -1,27 +1,47 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; namespace Devoplus.DataGuardian.Recognizers; +/// Flags likely postal-address text by matching whole-word address keywords. +/// +/// Keywords are matched on word boundaries so common substrings no longer produce false positives +/// (e.g. "have"→"ave", "third"/"word"→"rd", "katalog"→"kat"). The hit spans only the keyword; ADDRESS +/// is therefore intended for tagging/scoring and is excluded from the default redaction set, since +/// masking a keyword would not remove the identifying address text around it. +/// public sealed class AddressRecognizer : IPiiRecognizer { static readonly string[] TrKeys = { "mah.", "mahalle", "cad.", "caddesi", "sok.", "sokak", "bulvar", "no:", "daire", "kat" }; static readonly string[] EnKeys = { "street", "st.", "avenue", "ave", "road", "rd", "no.", "apartment", "zip", "suite" }; + static readonly Regex TrRx = BuildKeywordRegex(TrKeys); + static readonly Regex EnRx = BuildKeywordRegex(EnKeys); + public IReadOnlyList Analyze(string text, string lang) { + var rx = (lang == "tr") ? TrRx : EnRx; var hits = new List(); - var low = text.ToLowerInvariant(); - var keys = (lang == "tr") ? TrKeys : EnKeys; - foreach (var k in keys) - { - int idx = 0; - while ((idx = low.IndexOf(k, idx, StringComparison.Ordinal)) >= 0) - { - hits.Add(new PiiHit("ADDRESS", idx, k.Length)); - idx += k.Length; - } - } + foreach (Match m in rx.Matches(text)) + hits.Add(new PiiHit(PiiTypes.Address, m.Index, m.Length)); return hits; } -} \ No newline at end of file + + static Regex BuildKeywordRegex(string[] keys) + { + var alternatives = keys.Select(raw => + { + var k = raw.Trim(); + var pre = char.IsLetterOrDigit(k[0]) ? @"\b" : ""; + var post = char.IsLetterOrDigit(k[^1]) ? @"\b" : ""; + return pre + Regex.Escape(k) + post; + }); + + return new Regex( + "(?:" + string.Join("|", alternatives) + ")", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled, + TimeSpan.FromMilliseconds(200)); + } +} diff --git a/src/Devoplus.DataGuardian/Recognizers/CreditCardRecognizer.cs b/src/Devoplus.DataGuardian/Recognizers/CreditCardRecognizer.cs index 5cd5e84..c17c5ce 100644 --- a/src/Devoplus.DataGuardian/Recognizers/CreditCardRecognizer.cs +++ b/src/Devoplus.DataGuardian/Recognizers/CreditCardRecognizer.cs @@ -1,12 +1,22 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; namespace Devoplus.DataGuardian.Recognizers; +/// Detects payment card numbers (Luhn valid, known scheme prefix). +/// +/// The pattern binds each separator to a following digit so grouped numbers are not truncated at the +/// first space. Scheme detection includes Troy (the Turkish national scheme), Discover, JCB, Maestro +/// and Diners in addition to Visa/Mastercard/Amex. +/// public sealed class CreditCardRecognizer : IPiiRecognizer { - static readonly Regex Rx = new(@"\b(?:\d[ \-]*?){13,19}\b", RegexOptions.Compiled); + static readonly Regex Rx = new( + @"\b\d(?:[ \-]?\d){11,18}\b", + RegexOptions.Compiled, + TimeSpan.FromMilliseconds(200)); public IReadOnlyList Analyze(string text, string lang) { @@ -14,9 +24,9 @@ public IReadOnlyList Analyze(string text, string lang) foreach (Match m in Rx.Matches(text)) { var digits = new string(m.Value.Where(char.IsDigit).ToArray()); - if (digits.Length < 13 || digits.Length > 19) continue; + if (digits.Length < 12 || digits.Length > 19) continue; if (IsLuhnValid(digits) && IsKnownCardType(digits)) - list.Add(new PiiHit("CREDIT_CARD", m.Index, m.Length)); + list.Add(new PiiHit(PiiTypes.CreditCard, m.Index, m.Length)); } return list; } @@ -33,22 +43,30 @@ static bool IsLuhnValid(string s) return sum % 10 == 0; } - static bool IsKnownCardType(string digits) + static bool IsKnownCardType(string d) { - // Visa: 16 hane, 4 ile başlar - if (digits.Length == 16 && digits.StartsWith("4")) - return true; - // MasterCard: 16 hane, 51-55 veya 2221-2720 ile başlar - if (digits.Length == 16) - { - int prefix2 = int.Parse(digits.Substring(0, 2)); - int prefix4 = int.Parse(digits.Substring(0, 4)); - if ((prefix2 >= 51 && prefix2 <= 55) || (prefix4 >= 2221 && prefix4 <= 2720)) - return true; - } - // Amex: 15 hane, 34 veya 37 ile başlar - if (digits.Length == 15 && (digits.StartsWith("34") || digits.StartsWith("37"))) - return true; + int len = d.Length; + int p2 = int.Parse(d.Substring(0, 2)); + int p3 = int.Parse(d.Substring(0, 3)); + int p4 = int.Parse(d.Substring(0, 4)); + + // Visa: 13/16/19 digits, starts with 4 + if ((len == 13 || len == 16 || len == 19) && d[0] == '4') return true; + // Mastercard: 16 digits, 51-55 or 2221-2720 + if (len == 16 && ((p2 >= 51 && p2 <= 55) || (p4 >= 2221 && p4 <= 2720))) return true; + // Amex: 15 digits, 34/37 + if (len == 15 && (p2 == 34 || p2 == 37)) return true; + // Troy (Türkiye): 16 digits, prefix 9792 + if (len == 16 && p4 == 9792) return true; + // Discover: 16 digits, 6011 / 65 / 644-649 + if (len == 16 && (p4 == 6011 || p2 == 65 || (p3 >= 644 && p3 <= 649))) return true; + // JCB: 16 digits, 3528-3589 + if (len == 16 && p4 >= 3528 && p4 <= 3589) return true; + // Maestro: 12-19 digits, 50 / 56-69 + if (len >= 12 && len <= 19 && (p2 == 50 || (p2 >= 56 && p2 <= 69))) return true; + // Diners Club: 14/16 digits, 36 / 38 / 300-305 / 309 + if ((len == 14 || len == 16) && (p2 == 36 || p2 == 38 || (p3 >= 300 && p3 <= 305) || p3 == 309)) return true; + return false; } -} \ No newline at end of file +} diff --git a/src/Devoplus.DataGuardian/Recognizers/DobRecognizer.cs b/src/Devoplus.DataGuardian/Recognizers/DobRecognizer.cs index 3755759..3e3775d 100644 --- a/src/Devoplus.DataGuardian/Recognizers/DobRecognizer.cs +++ b/src/Devoplus.DataGuardian/Recognizers/DobRecognizer.cs @@ -1,12 +1,52 @@ +using System; using System.Collections.Generic; -using System.Linq; using System.Text.RegularExpressions; namespace Devoplus.DataGuardian.Recognizers; +/// Detects calendar dates that may represent a date of birth. +/// +/// Matches yyyy-mm-dd and dd-mm-yyyy (also with / or . separators) and rejects +/// impossible dates (e.g. 31.02.2024, 99/99/2024) and dates in the future. The day/month +/// order for the day-first form is interpreted as day-month-year (common in Türkiye/EU). +/// public sealed class DobRecognizer : IPiiRecognizer { - static readonly Regex Rx = new(@"\b(?:(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})|(\d{1,2})[-/.](\d{1,2})[-/.](\d{4}))\b", RegexOptions.Compiled); + static readonly Regex Rx = new( + @"\b(?:(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})|(\d{1,2})[-/.](\d{1,2})[-/.](\d{4}))\b", + RegexOptions.Compiled, + TimeSpan.FromMilliseconds(200)); + public IReadOnlyList Analyze(string text, string lang) - => Rx.Matches(text).Select(m => new PiiHit("DOB", m.Index, m.Length)).ToList(); -} \ No newline at end of file + { + var list = new List(); + foreach (Match m in Rx.Matches(text)) + { + int year, month, day; + if (m.Groups[1].Success) + { + year = int.Parse(m.Groups[1].Value); + month = int.Parse(m.Groups[2].Value); + day = int.Parse(m.Groups[3].Value); + } + else + { + day = int.Parse(m.Groups[4].Value); + month = int.Parse(m.Groups[5].Value); + year = int.Parse(m.Groups[6].Value); + } + + if (IsPlausibleBirthDate(year, month, day)) + list.Add(new PiiHit(PiiTypes.Dob, m.Index, m.Length)); + } + return list; + } + + static bool IsPlausibleBirthDate(int year, int month, int day) + { + if (year < 1900 || year > DateTime.UtcNow.Year) return false; + if (month < 1 || month > 12) return false; + if (day < 1 || day > DateTime.DaysInMonth(year, month)) return false; + return new DateTime(year, month, day) <= DateTime.UtcNow.Date; + } +} diff --git a/src/Devoplus.DataGuardian/Recognizers/EmailRecognizer.cs b/src/Devoplus.DataGuardian/Recognizers/EmailRecognizer.cs index db125b6..f979f5d 100644 --- a/src/Devoplus.DataGuardian/Recognizers/EmailRecognizer.cs +++ b/src/Devoplus.DataGuardian/Recognizers/EmailRecognizer.cs @@ -1,12 +1,23 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; namespace Devoplus.DataGuardian.Recognizers; +/// Detects e-mail addresses. +/// +/// The domain part is written as repeated label "." groups instead of a single greedy class that +/// also matches the dot. This removes the catastrophic-backtracking (ReDoS) behaviour the naive pattern +/// exhibited on long inputs without a TLD dot. A match timeout is also applied as a safety net. +/// public sealed class EmailRecognizer : IPiiRecognizer { - static readonly Regex Rx = new(@"[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}", RegexOptions.IgnoreCase | RegexOptions.Compiled); + static readonly Regex Rx = new( + @"[A-Z0-9._%+\-]+@(?:[A-Z0-9\-]+\.)+[A-Z]{2,}", + RegexOptions.IgnoreCase | RegexOptions.Compiled, + TimeSpan.FromMilliseconds(200)); + public IReadOnlyList Analyze(string text, string lang) - => Rx.Matches(text).Select(m => new PiiHit("EMAIL", m.Index, m.Length)).ToList(); -} \ No newline at end of file + => Rx.Matches(text).Select(m => new PiiHit(PiiTypes.Email, m.Index, m.Length)).ToList(); +} diff --git a/src/Devoplus.DataGuardian/Recognizers/IBANRecognizer.cs b/src/Devoplus.DataGuardian/Recognizers/IBANRecognizer.cs index 48b7406..f11de61 100644 --- a/src/Devoplus.DataGuardian/Recognizers/IBANRecognizer.cs +++ b/src/Devoplus.DataGuardian/Recognizers/IBANRecognizer.cs @@ -1,28 +1,43 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; namespace Devoplus.DataGuardian.Recognizers; +/// Detects IBANs of any registered country and validates them with the mod-97 checksum. +/// +/// The pattern tolerates the optional single spaces banks use to group an IBAN in blocks of four +/// (e.g. TR33 0006 1005 1978 6457 8413 26); the value is normalised (spaces removed) before the +/// country-length and mod-97 checks. The hit offset/length always refer to the raw matched text so +/// redaction masks the on-screen characters. +/// public sealed class IbanRecognizer : IPiiRecognizer { - static readonly Regex Rx = new(@"\b([A-Z]{2})(\d{2})([A-Z0-9]{11,30})\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); + // Matches either the compact form (no spaces) or the grouped form banks print + // (blocks of four separated by single spaces). The grouped branch requires fixed + // 4-character blocks so it cannot bridge a trailing word after the IBAN. + static readonly Regex Rx = new( + @"\b[A-Z]{2}\d{2}(?:[A-Z0-9]{11,30}|(?: [A-Z0-9]{4}){2,7}(?: [A-Z0-9]{1,4})?)\b", + RegexOptions.IgnoreCase | RegexOptions.Compiled, + TimeSpan.FromMilliseconds(200)); + // Length of the full IBAN per ISO 13616 / SWIFT IBAN Registry. static readonly Dictionary IbanLengths = new() { { "AL", 28 }, { "AD", 24 }, { "AT", 20 }, { "AZ", 28 }, { "BH", 22 }, { "BE", 16 }, - { "BA", 20 }, { "BR", 29 }, { "BG", 22 }, { "CR", 22 }, { "HR", 21 }, { "CY", 28 }, - { "CZ", 24 }, { "DK", 18 }, { "DO", 28 }, { "EE", 20 }, { "FO", 18 }, { "FI", 18 }, - { "FR", 27 }, { "GE", 22 }, { "DE", 22 }, { "GI", 23 }, { "GR", 27 }, { "GL", 18 }, - { "GT", 28 }, { "HU", 28 }, { "IS", 26 }, { "IE", 22 }, { "IL", 23 }, { "IT", 27 }, - { "JO", 30 }, { "KZ", 20 }, { "KW", 30 }, { "LV", 21 }, { "LB", 28 }, { "LI", 21 }, - { "LT", 20 }, { "LU", 20 }, { "MK", 19 }, { "MT", 31 }, { "MR", 27 }, { "MU", 30 }, - { "MC", 27 }, { "MD", 24 }, { "ME", 22 }, { "NL", 18 }, { "NO", 15 }, { "PK", 24 }, - { "PS", 29 }, { "PL", 28 }, { "PT", 25 }, { "QA", 29 }, { "RO", 24 }, { "SM", 27 }, - { "SA", 24 }, { "RS", 22 }, { "SK", 24 }, { "SI", 19 }, { "ES", 24 }, { "SE", 24 }, - { "CH", 21 }, { "TN", 24 }, { "TR", 26 }, { "AE", 23 }, { "GB", 22 }, { "VG", 24 } - - // Liste yapay zeka kullanılarak oluşturuldu. Bazı ülkeler için teyit etmek gerekir. + { "BA", 20 }, { "BR", 29 }, { "BG", 22 }, { "BY", 28 }, { "CR", 22 }, { "HR", 21 }, + { "CY", 28 }, { "CZ", 24 }, { "DK", 18 }, { "DO", 28 }, { "EG", 29 }, { "EE", 20 }, + { "FO", 18 }, { "FI", 18 }, { "FR", 27 }, { "GE", 22 }, { "DE", 22 }, { "GI", 23 }, + { "GR", 27 }, { "GL", 18 }, { "GT", 28 }, { "HU", 28 }, { "IS", 26 }, { "IE", 22 }, + { "IL", 23 }, { "IQ", 23 }, { "IT", 27 }, { "JO", 30 }, { "KZ", 20 }, { "XK", 20 }, + { "KW", 30 }, { "LV", 21 }, { "LB", 28 }, { "LI", 21 }, { "LT", 20 }, { "LU", 20 }, + { "LC", 32 }, { "MK", 19 }, { "MT", 31 }, { "MR", 27 }, { "MU", 30 }, { "MC", 27 }, + { "MD", 24 }, { "ME", 22 }, { "NL", 18 }, { "NO", 15 }, { "PK", 24 }, { "PS", 29 }, + { "PL", 28 }, { "PT", 25 }, { "QA", 29 }, { "RO", 24 }, { "SM", 27 }, { "ST", 25 }, + { "SA", 24 }, { "RS", 22 }, { "SC", 31 }, { "SK", 24 }, { "SI", 19 }, { "ES", 24 }, + { "SE", 24 }, { "CH", 21 }, { "SV", 28 }, { "TL", 23 }, { "TN", 24 }, { "TR", 26 }, + { "UA", 29 }, { "AE", 23 }, { "GB", 22 }, { "VA", 22 }, { "VG", 24 } }; public IReadOnlyList Analyze(string text, string lang) @@ -36,20 +51,25 @@ public IReadOnlyList Analyze(string text, string lang) if (IbanLengths.TryGetValue(country, out int expectedLen) && iban.Length == expectedLen) { if (IsIbanValid(iban)) - list.Add(new PiiHit("IBAN", m.Index, m.Length)); + list.Add(new PiiHit(PiiTypes.Iban, m.Index, m.Length)); } } return list; } - // IBAN Mod-97 doğrulaması + // IBAN mod-97 validation (ISO 7064). static bool IsIbanValid(string iban) { string rearranged = iban.Substring(4) + iban.Substring(0, 4); - string numeric = string.Concat(rearranged.Select(c => char.IsLetter(c) ? (c - 'A' + 10).ToString() : c.ToString())); int remainder = 0; - foreach (char ch in numeric) - remainder = (remainder * 10 + (ch - '0')) % 97; + foreach (char c in rearranged) + { + int value = char.IsLetter(c) ? (c - 'A' + 10) : (c - '0'); + if (value < 0 || value > 35) return false; + remainder = value > 9 + ? (remainder * 100 + value) % 97 + : (remainder * 10 + value) % 97; + } return remainder == 1; } -} \ No newline at end of file +} diff --git a/src/Devoplus.DataGuardian/Recognizers/PhoneRecognizer.cs b/src/Devoplus.DataGuardian/Recognizers/PhoneRecognizer.cs index 55bce54..7b980a3 100644 --- a/src/Devoplus.DataGuardian/Recognizers/PhoneRecognizer.cs +++ b/src/Devoplus.DataGuardian/Recognizers/PhoneRecognizer.cs @@ -1,42 +1,53 @@ using PhoneNumbers; +using System; using System.Collections.Generic; using System.Text.RegularExpressions; namespace Devoplus.DataGuardian.Recognizers; +/// Detects telephone numbers using libphonenumber for validation. +/// +/// National-format numbers (without a leading +) require a valid default region to parse. +/// The previous implementation passed "ZZ" (unknown region), which always threw for national +/// numbers, so numbers such as 05xx xxx xx xx were never detected. The region is now derived +/// from the detected language and a configurable default (see ). +/// public sealed class PhoneRecognizer : IPiiRecognizer { + static readonly Regex Rx = new( + @"\+?\d[\d \-()]{6,}\d", + RegexOptions.Compiled, + TimeSpan.FromMilliseconds(200)); + + static readonly PhoneNumberUtil PhoneUtil = PhoneNumberUtil.GetInstance(); + + readonly string _defaultRegion; + + public PhoneRecognizer(string? defaultRegion = null) + => _defaultRegion = string.IsNullOrWhiteSpace(defaultRegion) ? "TR" : defaultRegion!.ToUpperInvariant(); + public IReadOnlyList Analyze(string text, string lang) { var list = new List(); - var phoneUtil = PhoneNumberUtil.GetInstance(); + var region = lang == "tr" ? "TR" : _defaultRegion; - var rx = new Regex(@"\+?\d[\d\s\-()]{7,}", RegexOptions.Compiled); - foreach (Match m in rx.Matches(text)) + foreach (Match m in Rx.Matches(text)) { var candidate = m.Value; try { - PhoneNumber number; - if (candidate.TrimStart().StartsWith("+")) - { - number = phoneUtil.Parse(candidate, null); - } - else - { - number = phoneUtil.Parse(candidate, "ZZ"); - } - - if (phoneUtil.IsValidNumber(number)) - { - list.Add(new PiiHit("PHONE", m.Index, m.Length)); - } + var number = candidate.TrimStart().StartsWith('+') + ? PhoneUtil.Parse(candidate, null) + : PhoneUtil.Parse(candidate, region); + + if (PhoneUtil.IsValidNumber(number)) + list.Add(new PiiHit(PiiTypes.Phone, m.Index, m.Length)); } catch (NumberParseException) { - // Geçersiz ise atla + // Not a parseable phone number; skip. } } return list; } -} \ No newline at end of file +} diff --git a/src/Devoplus.DataGuardian/Recognizers/TcknRecognizer.cs b/src/Devoplus.DataGuardian/Recognizers/TcknRecognizer.cs index 38efcf8..00ee2b7 100644 --- a/src/Devoplus.DataGuardian/Recognizers/TcknRecognizer.cs +++ b/src/Devoplus.DataGuardian/Recognizers/TcknRecognizer.cs @@ -5,19 +5,25 @@ namespace Devoplus.DataGuardian.Recognizers; +/// +/// Detects Turkish national identity numbers (T.C. Kimlik No) and validates them with the official checksum. +/// +/// +/// The recognizer is language independent: the checksum is strong enough to eliminate false positives, +/// so gating on the detected language would only cause missed detections in bodies without Turkish +/// diacritics (e.g. JSON payloads with English field names). +/// public sealed class TcknRecognizer : IPiiRecognizer { - static readonly Regex Rx = new(@"\b[1-9]\d{10}\b", RegexOptions.Compiled); + static readonly Regex Rx = new(@"\b[1-9]\d{10}\b", RegexOptions.Compiled, TimeSpan.FromMilliseconds(200)); public IReadOnlyList Analyze(string text, string lang) { - if (lang != "tr") return Array.Empty(); var list = new List(); foreach (Match m in Rx.Matches(text)) { - var v = m.Value; - if (IsValid(v)) - list.Add(new PiiHit("TCKN", m.Index, m.Length)); + if (IsValid(m.Value)) + list.Add(new PiiHit(PiiTypes.Tckn, m.Index, m.Length)); } return list; } @@ -33,4 +39,4 @@ static bool IsValid(string s) int d11 = (d.Take(10).Sum()) % 10; return d[10] == d11; } -} \ No newline at end of file +} diff --git a/src/Devoplus.DataGuardian/SimpleLanguage.cs b/src/Devoplus.DataGuardian/SimpleLanguage.cs index 17f61ba..04211ec 100644 --- a/src/Devoplus.DataGuardian/SimpleLanguage.cs +++ b/src/Devoplus.DataGuardian/SimpleLanguage.cs @@ -1,14 +1,37 @@ +using System; using System.Linq; +using System.Text.RegularExpressions; namespace Devoplus.DataGuardian; +/// +/// Lightweight Turkish/English guesser. Returns "tr" when Turkish diacritics are present or when +/// several common Turkish words appear (so ASCII-written Turkish is still recognised), otherwise "en". +/// +/// +/// This only influences language-dependent recognizers (address keywords, phone default region). +/// Structural detectors such as TCKN, IBAN, credit card and e-mail run regardless of language. +/// public static class SimpleLanguage { + const string TurkishChars = "ğĞşŞıİçÇöÖüÜ"; + + static readonly Regex TurkishWords = new( + @"\b(ve|bir|ile|bu|icin|için|adres|mahalle|sokak|cadde|telefon|numara|tarih|isim|soyisim|merhaba)\b", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled, + TimeSpan.FromMilliseconds(100)); + public static string Guess(string text) { if (string.IsNullOrEmpty(text)) return "en"; - var tChars = "ğĞşŞıİçÇöÖüÜ"; - int trScore = text.Count(c => tChars.Contains(c)); - return trScore > 0 ? "tr" : "en"; + if (text.Any(c => TurkishChars.IndexOf(c) >= 0)) return "tr"; + + // ASCII-written Turkish: require at least two distinct common Turkish words to avoid + // misfiring on English text that happens to contain "bu", "bir", etc. + var distinct = TurkishWords.Matches(text) + .Select(m => m.Value.ToLowerInvariant()) + .Distinct() + .Count(); + return distinct >= 2 ? "tr" : "en"; } } diff --git a/tests/Devoplus.DataGuardian.Tests/Devoplus.DataGuardian.Tests.csproj b/tests/Devoplus.DataGuardian.Tests/Devoplus.DataGuardian.Tests.csproj index 791d044..441fc08 100644 --- a/tests/Devoplus.DataGuardian.Tests/Devoplus.DataGuardian.Tests.csproj +++ b/tests/Devoplus.DataGuardian.Tests/Devoplus.DataGuardian.Tests.csproj @@ -3,14 +3,22 @@ net8.0 false enable - true + true + + + + + - - - - - + + + + + + + + diff --git a/tests/Devoplus.DataGuardian.Tests/EngineTests.cs b/tests/Devoplus.DataGuardian.Tests/EngineTests.cs new file mode 100644 index 0000000..de0f25b --- /dev/null +++ b/tests/Devoplus.DataGuardian.Tests/EngineTests.cs @@ -0,0 +1,72 @@ +using System; +using Devoplus.DataGuardian; +using Xunit; + +namespace Devoplus.DataGuardian.Tests; + +public class EngineTests +{ + private static DataGuardianEngine NewEngine(Action? configure = null) + { + var opt = new DataGuardianOptions(); + configure?.Invoke(opt); + return new DataGuardianEngine(opt); + } + + [Fact] + public void Produces_Risk_For_Email() + { + var (risk, counts) = NewEngine().Analyze("Email: a@b.com"); + Assert.True(risk > 0); + Assert.Contains(PiiTypes.Email, counts.Keys); + } + + [Fact] + public void Iban_Uses_Configured_Weight_Not_Fallback() + { + // Regression: recognizer type "IBAN" must match the "IBAN" weight key (was "IBAN_TR"). + var (risk, counts) = NewEngine().Analyze("IBAN: TR330006100519786457841326"); + Assert.Contains(PiiTypes.Iban, counts.Keys); + // Weight 8 => 10*(1-e^(-0.15*8)) ~= 6.99, far above the weight-1 fallback (~1.39). + Assert.True(risk > 5, $"expected IBAN weight 8 to dominate, got {risk}"); + } + + [Fact] + public void Tckn_Detected_In_Ascii_Json_Body() + { + var (_, counts) = NewEngine().Analyze("{\"name\":\"Mehmet\",\"tckn\":\"10000000146\"}"); + Assert.Contains(PiiTypes.Tckn, counts.Keys); + } + + [Fact] + public void Risk_Is_Clamped_To_Ten() + { + var (risk, _) = NewEngine(o => o.K = 5).Analyze( + "10000000146 10000000146 10000000146 10000000146 10000000146"); + Assert.True(risk <= 10); + } + + [Fact] + public void Explicit_Zero_Weight_Disables_Type() + { + var (risk, _) = NewEngine(o => o.Weights[PiiTypes.Email] = 0) + .Analyze("a@b.com c@d.com e@f.com"); + Assert.Equal(0, risk); + } + + [Fact] + public void Exclude_Entity_Types_Removes_From_Counts() + { + var (_, counts) = NewEngine(o => o.ExcludeEntityTypes.Add(PiiTypes.Email)) + .Analyze("a@b.com and 4111 1111 1111 1111"); + Assert.DoesNotContain(PiiTypes.Email, counts.Keys); + } + + [Fact] + public void Empty_Text_Has_Zero_Risk() + { + var (risk, counts) = NewEngine().Analyze(" "); + Assert.Equal(0, risk); + Assert.Empty(counts); + } +} diff --git a/tests/Devoplus.DataGuardian.Tests/MiddlewareTests.cs b/tests/Devoplus.DataGuardian.Tests/MiddlewareTests.cs new file mode 100644 index 0000000..62ac2e4 --- /dev/null +++ b/tests/Devoplus.DataGuardian.Tests/MiddlewareTests.cs @@ -0,0 +1,181 @@ +using System; +using System.Globalization; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Devoplus.DataGuardian; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Devoplus.DataGuardian.Tests; + +public class MiddlewareTests +{ + // Echo endpoint: writes the request body back as JSON. + private static async Task StartEchoAsync(DataGuardianOptions opt) + { + return await new HostBuilder() + .ConfigureWebHost(web => + { + web.UseTestServer(); + web.Configure(app => + { + app.UseDataGuardian(opt); + app.Run(async ctx => + { + ctx.Response.ContentType = "application/json; charset=utf-8"; + using var sr = new System.IO.StreamReader(ctx.Request.Body); + var body = await sr.ReadToEndAsync(); + await ctx.Response.WriteAsync(body); + }); + }); + }) + .StartAsync(); + } + + private static StringContent Json(string body) => new(body, Encoding.UTF8, "application/json"); + + [Fact] + public async Task Tag_Mode_Emits_Response_Headers() + { + using var host = await StartEchoAsync(new DataGuardianOptions { Action = ActionMode.Tag }); + var client = host.GetTestClient(); + + var resp = await client.PostAsync("/", Json("{\"email\":\"a@b.com\"}")); + + Assert.True(resp.Headers.TryGetValues("X-DataGuardian-Response-Detected", out var detected)); + Assert.Contains("EMAIL", string.Join(";", detected)); + } + + [Fact] + public async Task Risk_Header_Uses_Invariant_Decimal_Point() + { + var prev = CultureInfo.DefaultThreadCurrentCulture; + CultureInfo.DefaultThreadCurrentCulture = new CultureInfo("tr-TR"); + try + { + using var host = await StartEchoAsync(new DataGuardianOptions { Action = ActionMode.Tag }); + var client = host.GetTestClient(); + + var resp = await client.PostAsync("/", Json("{\"iban\":\"TR330006100519786457841326\"}")); + + Assert.True(resp.Headers.TryGetValues("X-DataGuardian-Response-Risk", out var risk)); + var value = string.Join("", risk); + Assert.DoesNotContain(",", value); // must not be culture-formatted "6,99" + } + finally + { + CultureInfo.DefaultThreadCurrentCulture = prev; + } + } + + [Fact] + public async Task Block_Mode_Returns_403_With_Body() + { + using var host = await StartEchoAsync(new DataGuardianOptions { Action = ActionMode.Block, BlockAt = 1 }); + var client = host.GetTestClient(); + + var resp = await client.PostAsync("/", Json("{\"tckn\":\"10000000146\"}")); + + Assert.Equal(HttpStatusCode.Forbidden, resp.StatusCode); + var body = await resp.Content.ReadAsStringAsync(); + Assert.False(string.IsNullOrEmpty(body)); // regression: body was previously lost + Assert.Contains("Blocked", body); + } + + [Fact] + public async Task Block_Response_Strips_Detection_Header() + { + using var host = await StartEchoAsync(new DataGuardianOptions { Action = ActionMode.Block, BlockAt = 1 }); + var client = host.GetTestClient(); + + var resp = await client.PostAsync("/", Json("{\"tckn\":\"10000000146\"}")); + + Assert.False(resp.Headers.Contains("X-DataGuardian-Request-Detected")); + } + + [Fact] + public async Task Redact_MaskAll_Removes_Value_And_Sets_ContentLength() + { + using var host = await StartEchoAsync(new DataGuardianOptions + { + Action = ActionMode.Redact, + RedactAt = 0, + Redaction = RedactionStyle.MaskAll + }); + var client = host.GetTestClient(); + + var resp = await client.PostAsync("/", Json("{\"email\":\"secret@example.com\"}")); + var body = await resp.Content.ReadAsStringAsync(); + + Assert.DoesNotContain("secret@example.com", body); + Assert.Contains("*", body); + Assert.Equal(Encoding.UTF8.GetByteCount(body), resp.Content.Headers.ContentLength); + } + + [Fact] + public async Task Redact_Hash_Changes_Length_Consistently() + { + using var host = await StartEchoAsync(new DataGuardianOptions + { + Action = ActionMode.Redact, + RedactAt = 0, + Redaction = RedactionStyle.Hash + }); + var client = host.GetTestClient(); + + var resp = await client.PostAsync("/", Json("{\"card\":\"4111111111111111\"}")); + var body = await resp.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); // regression: Content-Length mismatch crashed + Assert.DoesNotContain("4111111111111111", body); + Assert.Equal(Encoding.UTF8.GetByteCount(body), resp.Content.Headers.ContentLength); + } + + [Fact] + public async Task ExcludePaths_Skips_Processing() + { + using var host = await StartEchoAsync(new DataGuardianOptions + { + Action = ActionMode.Tag, + ExcludePaths = { "/health" } + }); + var client = host.GetTestClient(); + + var resp = await client.PostAsync("/health", Json("{\"email\":\"a@b.com\"}")); + + Assert.False(resp.Headers.Contains("X-DataGuardian-Response-Detected")); + } + + [Fact] + public async Task Method_Filter_Is_Case_Insensitive() + { + using var host = await StartEchoAsync(new DataGuardianOptions + { + Action = ActionMode.Tag, + IncludeMethods = { "post" } // lower-case config must still match a POST request + }); + var client = host.GetTestClient(); + + var resp = await client.PostAsync("/", Json("{\"email\":\"a@b.com\"}")); + + Assert.True(resp.Headers.Contains("X-DataGuardian-Response-Detected")); + } + + [Fact] + public async Task Request_Body_Is_Analyzed() + { + using var host = await StartEchoAsync(new DataGuardianOptions { Action = ActionMode.Tag }); + var client = host.GetTestClient(); + + var resp = await client.PostAsync("/", Json("{\"tckn\":\"10000000146\"}")); + + Assert.True(resp.Headers.TryGetValues("X-DataGuardian-Request-Detected", out var detected)); + Assert.Contains("TCKN", string.Join(";", detected)); + } +} diff --git a/tests/Devoplus.DataGuardian.Tests/OptionsTests.cs b/tests/Devoplus.DataGuardian.Tests/OptionsTests.cs new file mode 100644 index 0000000..8eea03d --- /dev/null +++ b/tests/Devoplus.DataGuardian.Tests/OptionsTests.cs @@ -0,0 +1,59 @@ +using System; +using System.Linq; +using Devoplus.DataGuardian; +using Xunit; + +namespace Devoplus.DataGuardian.Tests; + +public class OptionsTests +{ + [Fact] + public void Default_Options_Are_Valid() + { + var ex = Record.Exception(() => new DataGuardianOptions().Validate()); + Assert.Null(ex); + } + + [Theory] + [InlineData("K")] + [InlineData("MaxCountPerType")] + [InlineData("MaxBodySizeBytes")] + [InlineData("MinNerConfidence")] + public void Validate_Rejects_Invalid_Values(string field) + { + var opt = new DataGuardianOptions(); + switch (field) + { + case "K": opt.K = 0; break; + case "MaxCountPerType": opt.MaxCountPerType = 0; break; + case "MaxBodySizeBytes": opt.MaxBodySizeBytes = 0; break; + case "MinNerConfidence": opt.MinNerConfidence = 2; break; + } + Assert.Throws(() => opt.Validate()); + } +} + +public class TypeConsistencyTests +{ + // Guards against a recognizer type / options key mismatch like the historical "IBAN" vs "IBAN_TR". + [Fact] + public void Every_RedactType_Has_A_Weight() + { + var opt = new DataGuardianOptions(); + foreach (var type in opt.RedactTypes) + Assert.True(opt.Weights.ContainsKey(type), $"RedactTypes contains '{type}' with no matching weight key"); + } + + [Fact] + public void Weight_Keys_Are_Known_PiiTypes() + { + var known = new[] + { + PiiTypes.Tckn, PiiTypes.CreditCard, PiiTypes.Iban, PiiTypes.Dob, + PiiTypes.Address, PiiTypes.Phone, PiiTypes.Email, PiiTypes.Person + }; + var opt = new DataGuardianOptions(); + foreach (var key in opt.Weights.Keys) + Assert.Contains(key, known); + } +} diff --git a/tests/Devoplus.DataGuardian.Tests/RecognizerTests.cs b/tests/Devoplus.DataGuardian.Tests/RecognizerTests.cs index da3a83e..9925872 100644 --- a/tests/Devoplus.DataGuardian.Tests/RecognizerTests.cs +++ b/tests/Devoplus.DataGuardian.Tests/RecognizerTests.cs @@ -1,49 +1,143 @@ +using System.Linq; using Devoplus.DataGuardian; using Devoplus.DataGuardian.Recognizers; using Xunit; +namespace Devoplus.DataGuardian.Tests; + public class RecognizerTests { + // ---------------- Email ---------------- + [Fact] + public void Email_Detects_Address() + { + var hits = new EmailRecognizer().Analyze("{ \"EmailAddress\": \"test@example.com\" }", "en"); + Assert.Contains(hits, h => h.Type == PiiTypes.Email); + } + + [Fact] + public void Email_Ignores_Text_Without_Tld_Dot() + { + var hits = new EmailRecognizer().Analyze("write to a@b then stop", "en"); + Assert.Empty(hits); + } + + // ---------------- TCKN ---------------- + [Fact] + public void Tckn_Valid_Detected_Without_Turkish_Chars() + { + // Regression: TCKN must be detected even when the text has no Turkish diacritics (lang => "en"). + var hits = new TcknRecognizer().Analyze("{\"tckn\":\"10000000146\"}", "en"); + Assert.Contains(hits, h => h.Type == PiiTypes.Tckn); + } + + [Fact] + public void Tckn_Invalid_Checksums_Rejected() + { + var hits = new TcknRecognizer().Analyze("00000000000 12345678901 11111111111", "tr"); + Assert.Empty(hits); + } + + // ---------------- Phone ---------------- + [Fact] + public void Phone_Detects_International_Format() + { + var hits = new PhoneRecognizer().Analyze("call +90 532 123 45 67 now", "en"); + Assert.Contains(hits, h => h.Type == PiiTypes.Phone); + } + + [Fact] + public void Phone_Detects_National_Format_With_Region() + { + // Regression: national numbers (no +) were never detected because of Parse(.., "ZZ"). + var hits = new PhoneRecognizer("TR").Analyze("0532 123 45 67", "tr"); + Assert.Contains(hits, h => h.Type == PiiTypes.Phone); + } + [Fact] - public void Email_Should_Detect() + public void Phone_Ignores_Short_Digit_Runs() { - var r = new EmailRecognizer(); - var hits = r.Analyze("{ \"EmailAddress\": \"test@example.com\" }", "tr"); - Assert.NotEmpty(hits); + var hits = new PhoneRecognizer("TR").Analyze("order 12 of 34", "en"); + Assert.Empty(hits); + } + + // ---------------- IBAN ---------------- + [Fact] + public void Iban_Detects_Compact() + { + var hits = new IbanRecognizer().Analyze("IBAN: TR330006100519786457841326", "tr"); + Assert.Contains(hits, h => h.Type == PiiTypes.Iban); + } + + [Fact] + public void Iban_Detects_Grouped_With_Spaces() + { + // Regression: space-grouped IBANs (the standard printed form) were missed. + var hits = new IbanRecognizer().Analyze("IBAN: TR33 0006 1005 1978 6457 8413 26", "tr"); + Assert.Contains(hits, h => h.Type == PiiTypes.Iban); } [Fact] - public void Tckn_Checksum_Works() + public void Iban_Rejects_Invalid_Checksum() { - var r = new TcknRecognizer(); - // Hepsi geersiz rnekler: - var hits = r.Analyze("00000000000 10000000147 12345678901 11111111111", "tr"); + var hits = new IbanRecognizer().Analyze("TR000000000000000000000000", "tr"); Assert.Empty(hits); } + // ---------------- Credit card ---------------- [Fact] - public void Tckn_Valid_Sample_Is_Detected() + public void CreditCard_Detects_Visa() { - var r = new TcknRecognizer(); - var hits = r.Analyze("Foo 10000000146 Bar", "tr"); - Assert.Contains(hits, h => h.Type == "TCKN"); + var hits = new CreditCardRecognizer().Analyze("card 4111 1111 1111 1111 ok", "en"); + Assert.Contains(hits, h => h.Type == PiiTypes.CreditCard); } [Fact] - public void CreditCard_Luhn() + public void CreditCard_Detects_Troy() { - var r = new CreditCardRecognizer(); - var hits = r.Analyze("My card 4111 1111 1111 1111 ok?", "en"); - Assert.NotEmpty(hits); + // Luhn-valid Troy (TR national scheme) number, prefix 9792. + var hits = new CreditCardRecognizer().Analyze("kart 9792030000000000", "tr"); + Assert.Contains(hits, h => h.Type == PiiTypes.CreditCard); + } + + [Fact] + public void CreditCard_Rejects_Non_Luhn() + { + var hits = new CreditCardRecognizer().Analyze("1234 5678 9012 3456", "en"); + Assert.Empty(hits); } + // ---------------- DOB ---------------- [Fact] - public void Engine_Produces_Risk() + public void Dob_Detects_Valid_Past_Date() { - var opt = new DataGuardianOptions(); - var engine = new DataGuardianEngine(opt); - var (risk, counts) = engine.Analyze("Email: a@b.com, Phone: 05551234567, IBAN: TR000000000000000000000000"); - Assert.True(risk > 0); - Assert.Contains("EMAIL", counts.Keys); + var hits = new DobRecognizer().Analyze("dogum: 12.05.1990", "tr"); + Assert.Contains(hits, h => h.Type == PiiTypes.Dob); + } + + [Theory] + [InlineData("31.02.2024")] // impossible day + [InlineData("99/99/2024")] // impossible month/day + [InlineData("2024-13-45")] // impossible month/day + public void Dob_Rejects_Impossible_Dates(string value) + { + var hits = new DobRecognizer().Analyze($"date {value} here", "tr"); + Assert.Empty(hits); + } + + // ---------------- Address ---------------- + [Fact] + public void Address_Detects_Turkish_Keywords() + { + var hits = new AddressRecognizer().Analyze("Ataturk Mah. Cicek Sok. No: 5", "tr"); + Assert.Contains(hits, h => h.Type == PiiTypes.Address); + } + + [Fact] + public void Address_Does_Not_Match_Substrings() + { + // Regression: "have"->"ave", "third"/"word"->"rd" must not be flagged. + var hits = new AddressRecognizer().Analyze("I have a third word to say", "en"); + Assert.Empty(hits); } -} \ No newline at end of file +}