İçeriğe geç
Hüseyin Bozdemir
Tüm yazılar

Log maskeleme: .NET'te kart numarası sızmasın

Ödeme sistemleri rehberinin 4. bölümü

Endpoint başına regex ile log maskeleme unutulur. .NET'te kart numarasını kendi tipine sarmak, Redaction ve log'u tarayan bir testle sızıntıyı önlemek.

5 dk okumaGüncellendi:

'Unutulan tek endpoint yeter.' başlığı; yan yana beş endpoint kutusu (/odeme, /iade, /taksit, /kart, /iptal), dördünde 'mask' etiketi var, /kart turuncu çerçeveli ve etiket yuvası boş; altından kesikli çizgiyle maskelenmemiş test kart numarası 4111111111111111 düşüyor.
İçindekiler
  1. Kart numarası log’a hangi yollardan düşer
  2. Varsayılanı tersine çevir: kart numarası kendi tipini taşısın
  3. .NET’in yerleşik çözümü: Redaction
  4. Regex’in doğru yeri: son kontrol
  5. Sık sorulanlar
  6. Kart numarasının hangi haneleri log’da görünebilir?
  7. Serilog’da kart numarası nasıl maskelenir?
  8. .NET Redaction tek başına yeterli mi?

Kart numarasını log’a yazmadan önce regex’le maskelemek yaygın bir yöntem: her endpoint kendi log satırında maskeleme fonksiyonunu çağırır. Çağırmayı unutan tek bir endpoint olduğunda kart numarası log’a açık düşer.

Log maskeleme, hassas bir değeri log’a yazılmadan önce okunamaz hale getirmektir. Kart numarasında genelde ilk 6 ve son 4 hane kalır: 411111******1111. PCI DSS de gösterimde en fazla BIN ve son dört haneye izin veriyor (PCI SSC SSS 1492). Bu bir üst sınır; BIN 8 haneyse “ilk 6” kuralı da değişiyor, log’da daha azını göstermek daha güvenli.

Sorun maskelemenin kendisinde değil, nerede yapıldığında. Endpoint başına yapılan maskeleme, birinin hatırlamasına bağlıdır.

Bu notta maskelemeyi hatırlamaya bırakmayan bir yapı öneriyorum: kart numarasını kendi tipine sarmak, .NET’in yerleşik Microsoft.Extensions.Compliance.Redaction aracı ve log’u tarayan bir test. Örneklerin hepsini .NET 10’da küçük bir projede çalıştırıp çıktısına baktım.

Kart numarası log’a hangi yollardan düşer

Tek yol unutulan bir LogInformation satırı değil. Örnek projede şunların hepsi numarayı açık yazdı:

  • Nesneyi olduğu gibi loglamak. record tipinin ToString’i bütün alanları yazar; log.LogInformation("Ödeme {Istek}", istek) çıktısında KartNo = 4111111111111111 görünür, CVV de. Aynı DTO class iken yalnızca tip adı yazılıyordu; yani DTO’yu record’a çevirmek, log koduna dokunmadan sızıntı başlatıyor. Serilog’da {@Istek} aynısını JSON olarak yapar.
  • Exception mesajı. long.Parse("4111 1111 1111 1111") fırlattığı FormatException’ın mesajına girdinin kendisini koyar; KeyNotFoundException da aranan anahtarı yazar. Kendi kodun numarayı hiç loglamasa da LogError(ex, ...) onu log’a taşır.
  • Query string. GET /kart?pan=... gibi bir istek, ASP.NET Core’un kendi “Request starting” log’unda tam URL’iyle görünür. Şablonda Microsoft.AspNetCore seviyesi Warning olduğu için çoğu zaman görünmez; hata ayıklarken Information’a çektiğin gün görünür.
  • HttpLogging. HttpLogging middleware’inde RequestBody açıksa gövde olduğu gibi yazılır.
  • EF Core. EnableSensitiveDataLogging açıksa sorgu parametreleri düz yazılır: @p0='4111111111111111'. Kapalıyken '?' görünür.

Regex bunların bir kısmını yakalar, bir kısmını yakalamaz. Hangisini yakaladığını da çoğu zaman kimse bilmez.

Varsayılanı tersine çevir: kart numarası kendi tipini taşısın

Önerim kart numarasını string olarak dolaştırmamak. İstek gövdesi sınırda bir KartNo tipine bağlanır ve bu tipin ToString’i her zaman maskeli döner. Açık değer yalnızca bankaya giden istekte, adıyla çağrılan bir metotla alınır.

[JsonConverter(typeof(KartNoJsonConverter))]
public readonly struct KartNo
{
    private readonly string _deger;
    private KartNo(string deger) => _deger = deger;

    public static KartNo Parse(string girdi)
    {
        var rakamlar = new string(girdi.Where(char.IsDigit).ToArray());
        if (rakamlar.Length is < 13 or > 19 || !Luhn.Gecerli(rakamlar))
            throw new FormatException("Kart numarası geçersiz."); // değeri mesaja koyma
        return new KartNo(rakamlar);
    }

    // Açık değer yalnızca bankaya giden istekte.
    public string Acik() => _deger;

    public override string ToString() =>
        _deger is null ? "" : $"{_deger[..6]}{new string('*', _deger.Length - 10)}{_deger[^4..]}";
}

KartNoJsonConverter okurken KartNo.Parse’ı çağırıyor, yazarken ToString’i. Yani bu tipi taşıyan bir nesne yanlışlıkla JSON olarak loglansa da maskeli çıkar; bankaya giden istek ayrı bir modelde Acik() ile kurulur.

Aynı örnek projede bu tiple {KartNo}, {Istek}, string interpolasyonu ve kendi yazdığımız exception mesajı hep 411111******1111 yazdı. Endpoint’i yazan kişinin maskelemeyi hatırlaması gerekmiyor: unutursa log’a maskeli bir satır düşüyor. Açık değeri istemek ise Acik() diye ayrıca yazılması gereken, code review’da göze batan bir çağrı. IDOR yazısında aitlik kontrolü için de aynı şeyi savunmuştum: unutulabilen kontrolü unutulamaz hale getirmek.

Serilog kullanıyorsan {@Istek} içinde de ToString’in kullanılması için yapılandırmaya bir satır ekledim: .Destructure.AsScalar<KartNo>().

Hazır bir paket istersen Serilog.Enrichers.Sensitive kart numarasını regex’le maskeliyor. Ama denediğim sürüm (2.1.0) yalnızca 16 haneyi tanıdı ve Luhn kontrolü yapmadı: 15 haneli bir Amex test kartını açık bıraktı, 16 haneli bir sipariş numarasını maskeledi.

.NET’in yerleşik çözümü: Redaction

.NET’in kendi aracı Microsoft.Extensions.Compliance.Redaction. Veriyi bir attribute ile sınıflandırıyorsun, bir redactor kaydediyorsun, log pipeline’ında redaction’ı açıyorsun. Kaynak üretimli [LoggerMessage] metotlarında ve [LogProperties] ile loglanan nesnelerde iyi çalıştı.

Ama aynı logger’la yazılan klasik LogInformation("Kart {KartNo}", ...) satırı, record’un ToString’i ve exception mesajı açık kaldı. Redaction, doğru log metodunu kullanmayı hatırlamaya bağlı; yani endpoint başına regex’le aynı soruyu taşıyor. Tip yaklaşımıyla birlikte iyi bir katman, tek başına değil.

Kendi 6+4 redactor’ını yazacaksan bir ayrıntı: ApplyDiscriminator varsayılan olarak açık ve redactor’a değer yerine değer:alanAdı geliyor. Maskelenmiş çıktının sonunda kart hanesi yerine alan adının son harflerini görürsen sebep bu; kapatman gerekiyor.

Regex’in doğru yeri: son kontrol

Regex atılmaz, yeri değişir. Entegrasyon testlerinde bütün log çıktısını toplayıp kart numarasına benzeyen ve Luhn kontrolünden geçen bir dizi arayan bir kontrol, unutulan yeri prod’a çıkmadan yakalar.

public static partial class KartTarayici
{
    [GeneratedRegex(@"(?<!\d)(?:\d[ -]?){12,18}\d(?!\d)")]
    private static partial Regex Aday();

    public static bool KartVar(string satir) =>
        Aday().Matches(satir).Any(m =>
            Luhn.Gecerli(new string(m.Value.Where(char.IsDigit).ToArray())));
}

Luhn kontrolü sipariş numarası gibi 16 haneli ama kart olmayan değerlerin çoğunu eler. Örnek projede test kartı yakalandı, Luhn’dan geçmeyen 16 haneli bir sipariş numarası yakalanmadı.

Bir numaranın Luhn’dan geçip geçmediğini ve hesabın adımlarını Luhn doğrulama aracında görebilirsin.

Tipin göremediği iki yer için iki kural daha gerekiyor: ödeme endpoint’lerinde HttpLogging’in gövde loglaması kapalı kalır, kart numarası query string’e hiç girmez. İkisi de istek tipe bağlanmadan önce log’a düşüyor.

Regex’in ne kadar iyi yazıldığı ikinci planda kalıyor; asıl risk, birinin onu çağırmayı hatırlamasına dayanması.

Sık sorulanlar

Kart numarasının hangi haneleri log’da görünebilir?

PCI DSS gösterimde en fazla BIN ve son dört haneye izin veriyor; yaygın biçim 411111******1111. Bu bir üst sınır, zorunluluk değil: BIN 8 haneyse “ilk 6” kuralı da değişiyor, log’da daha azını göstermek daha güvenli.

Serilog’da kart numarası nasıl maskelenir?

En sağlam yol kart numarasını kendi tipine sarmak ve ToString’i maskeli döndürmek; {@Istek} gibi yapılandırılmış loglamada da aynı davranış için .Destructure.AsScalar<KartNo>() eklenir. Serilog.Enrichers.Sensitive gibi regex tabanlı paketler var ama denediğim sürüm 15 haneli Amex’i açık bıraktı, 16 haneli sipariş numarasını maskeledi.

.NET Redaction tek başına yeterli mi?

Değil. Microsoft.Extensions.Compliance.Redaction kaynak üretimli [LoggerMessage] metotlarında ve [LogProperties] ile iyi çalışıyor; ama klasik LogInformation satırı, record’un ToString’i ve exception mesajı açık kalıyor. Doğru log metodunu hatırlamaya bağlı olduğu için tip yaklaşımıyla birlikte iyi bir katman, tek başına değil.