چه چیزی را هرگز نباید لاگ کرد: رمز، توکن و دادهٔ شخصی در لاگ

چه چیزی را هرگز نباید لاگ کرد: رمز، توکن و دادهٔ شخصی در لاگ
در این مقاله می‌خوانید
  1. چرا لاگ از دیتابیس خطرناک‌تر است
  2. فهرست سیاه: چیزهایی که هرگز نباید لاگ شوند
  3. اعتبارنامه‌ها و رازها
  4. دادهٔ مالی
  5. دادهٔ شخصی — با نگاه ایرانی
  6. چیزهایی که حساسیتشان پنهان است
  7. اصل اول: فهرست سفید، نه فهرست سیاه
  8. ابزارهای سمت برنامه
  9. Serilog: سیاست destructuring
  10. ⁦ASP.NET Core⁩: لاگ HTTP با فهرست سفید
  11. ماسک کردن وقتی شناسایی لازم است
  12. Python: فیلتر روی ماژول logging
  13. حذف در سمت سرور: تور ایمنی، نه جایگزین
  14. نگه‌داری و دسترسی: محدود کردن آسیب
  15. مدت نگه‌داری
  16. دسترسی
  17. اگر نشت رخ داد
  18. چک‌لیست بازبینی کد
  19. منابع و مطالعهٔ بیشتر

چند سال پیش، در بازبینی امنیتی یک سیستم، از تیم خواستم دسترسی خواندنی به لاگ‌ها بدهند. ده دقیقه بعد، بدون اینکه به هیچ دیتابیسی دست زده باشم، این‌ها را داشتم: رمز عبور چند کاربر (چون کسی بدنهٔ درخواست ورود را برای عیب‌یابی لاگ کرده بود)، توکن‌های دسترسی معتبر چند مدیر، و کد ملی و شمارهٔ موبایل صدها مشتری. دیتابیس رمزنگاری داشت، دسترسی‌اش محدود بود و رمزها با الگوریتم درست هش شده بودند. لاگ‌ها اما روی یک سرور مشترک بودند و نصف شرکت به آن دسترسی داشت.

این الگو را آن‌قدر تکرار دیده‌ام که دیگر تعجبم نمی‌کند. تیم‌ها از دیتابیس محافظت می‌کنند چون می‌دانند داده آنجاست. لاگ اما «فقط لاگ» است — و همین باعث می‌شود به مسیر نشت بی‌سروصدایی تبدیل شود که هیچ‌کس رصدش نمی‌کند. این مقاله دربارهٔ این است که چه چیزهایی هرگز نباید وارد لاگ شوند، چطور جلویشان را بگیرید، و اگر با همهٔ تلاش‌ها وارد شدند، چه چیزی آسیب را محدود می‌کند.

چرا لاگ از دیتابیس خطرناک‌تر است

شاید عجیب به نظر برسد، ولی در بسیاری از سازمان‌ها دادهٔ حساسی که به لاگ راه پیدا کرده، از نسخهٔ اصلی‌اش در دیتابیس بی‌دفاع‌تر است. چند دلیل:

  • دسترسی گسترده‌تر. به دیتابیس پروداکشن معمولاً چند نفر دسترسی دارند. به لاگ‌ها اغلب همهٔ برنامه‌نویس‌ها، پشتیبانی، تیم عملیات و گاهی پیمانکار.
  • کپی‌های بیشتر. لاگ روی دیسک سرور، در سامانهٔ متمرکز، در بکاپ آن، در فایلی که کسی برای همکارش فرستاده، در پیامی که در کانال تیم چسبانده شده.
  • نگه‌داری طولانی‌تر از انتظار. داده‌ای که کاربر از حسابش حذف کرده، ممکن است ماه‌ها در لاگ بماند.
  • بدون رمزنگاری در سطح فیلد. دیتابیس ممکن است ستون حساس را رمز کند؛ لاگ متن ساده است.
  • بدون ممیزی. معمولاً کسی ثبت نمی‌کند چه کسی کدام لاگ را خوانده.

نتیجه: بهترین محافظت از دادهٔ حساس در لاگ، این است که اصلاً وارد لاگ نشود. هر چیز دیگری، خط دفاع دوم است.

فهرست سیاه: چیزهایی که هرگز نباید لاگ شوند

راهنمای لاگ OWASP فهرست مفصلی دارد؛ این نسخهٔ عملی آن است که در بازبینی کد دنبالش می‌گردم.

اعتبارنامه‌ها و رازها

  • رمز عبور — در هیچ شکلی: نه متن ساده، نه «فقط سه حرف اولش»، نه حتی هش آن. هش رمز در لاگ، ورودی حملهٔ حدس آفلاین است.
  • توکن‌های دسترسی و refresh، JWT کامل، هدر Authorization، کلیدهای API، توکن‌های بازنشانی رمز و لینک‌های ورود یک‌بارمصرف. هر کدام از این‌ها در لاگ یعنی هر کسی که لاگ را می‌خواند، می‌تواند تا زمان انقضا جای آن کاربر باشد.
  • کوکی‌ها و شناسهٔ نشست — به همان دلیل. شناسهٔ نشست در لاگ، هدیهٔ session hijacking است.
  • رشتهٔ اتصال دیتابیس و هر پیکربندی که رمز دارد. یک لاگ «Starting with configuration: …» در شروع برنامه، رایج‌ترین مسیر این نشت است.
  • کلیدهای رمزنگاری و گواهی‌ها.
  • کد یک‌بارمصرف (OTP) که با پیامک فرستاده می‌شود. لاگ «Sending OTP 482915 to …» را بیشتر از آنچه فکر کنید دیده‌ام.

دادهٔ مالی

  • شمارهٔ کامل کارت بانکی، CVV2، تاریخ انقضا و رمز دوم. اگر واقعاً به شناسایی کارت نیاز دارید، شش رقم اول و چهار رقم آخر کافی است — و آن را هم فقط وقتی لاگ کنید که لازم است.
  • شمارهٔ شبا و حساب به‌صورت کامل.
  • پاسخ کامل درگاه پرداخت. معمولاً شمارهٔ پیگیری و وضعیت کافی است؛ بقیهٔ پاسخ ممکن است دادهٔ کارت یا توکن داشته باشد.

دادهٔ شخصی — با نگاه ایرانی

  • کد ملی. در سیستم‌های ایرانی کد ملی تقریباً همه‌جا هست — احراز هویت، فاکتور، بیمه، سامانه‌های دولتی — و دقیقاً به همین دلیل در لاگ‌ها هم همه‌جا پیدا می‌شود. کد ملی را نمی‌شود عوض کرد؛ نشتش برای همیشه است.
  • شمارهٔ موبایل. در ایران شمارهٔ موبایل عملاً شناسهٔ هویتی است: با آن رمز یک‌بارمصرف بانک می‌آید، حساب پیام‌رسان باز می‌شود و در بسیاری از سامانه‌ها نام‌کاربری است.
  • نشانی پستی و کد پستی، تاریخ تولد، نام پدر، شمارهٔ شناسنامه.
  • دادهٔ سلامت، مذهب، وضعیت مالی و هر چیزی که در صورت نشت آسیب جدی به فرد می‌زند.
  • ایمیل — کم‌خطرتر، ولی هنوز دادهٔ شخصی است.

چیزهایی که حساسیتشان پنهان است

  • بدنهٔ کامل درخواست و پاسخ. مهم‌ترین مورد این فهرست. بدنهٔ درخواست ورود رمز دارد، بدنهٔ ثبت‌نام کد ملی، بدنهٔ پرداخت شمارهٔ کارت. «همهٔ درخواست‌ها را برای عیب‌یابی لاگ کن» تصمیمی است که یک‌بار گرفته می‌شود و همهٔ دادهٔ حساس آیندهٔ سیستم را به لاگ می‌ریزد.
  • نشانی کامل با query string. بسیاری از APIها هنوز توکن را در ?token= یا ?api_key= می‌گیرند، و لینک‌های بازنشانی رمز توکن را در نشانی دارند.
  • کل شیء کاربر یا موجودیت که با یک {@User} سریال‌سازی شده؛ امروز سه فیلد بی‌خطر دارد، شش ماه دیگر کسی NationalCode را به آن اضافه می‌کند.
  • پیام exception‌هایی که مقدار ورودی را در خود دارند — مثل خطای اعتبارسنجی که می‌گوید «مقدار ‘0012345678’ معتبر نیست».

اصل اول: فهرست سفید، نه فهرست سیاه

فهرست بالا را خواندید و شاید وسوسه شده‌اید یک فیلتر بنویسید که این‌ها را پیدا و حذف کند. این کار لازم است — در بخش‌های بعد به آن می‌رسیم — ولی کافی نیست. مشکل فهرست سیاه این است که همیشه یک قدم عقب است: فیلدی به نام pwd یا pin یا nid را پوشش نمی‌دهد، و فیلد تازه‌ای که فردا اضافه می‌شود را نمی‌شناسد.

روش مقاوم برعکس است: به جای اینکه بگویید چه چیزی لاگ نشود، بگویید دقیقاً چه چیزی لاگ شود. در عمل یعنی:

  • اشیای کامل را لاگ نکنید. فیلدهایی را که برای عیب‌یابی لازم دارید، تک‌تک و با نام ویژگی بنویسید.
  • برای هر نوعی که ممکن است لاگ شود، یک «نمای لاگ» مشخص تعریف کنید.
  • از درخواست HTTP فقط متد، مسیر (بدون query)، کد وضعیت و مدت را به‌طور پیش‌فرض بگیرید؛ هر چیز دیگر یک تصمیم آگاهانه باشد.

تفاوت را در یک خط ببینید:

// بد: کل شیء، با هر فیلدی که امروز دارد یا فردا پیدا می‌کند
logger.LogInformation("Customer registered: {@Customer}", customer);

// خوب: فقط آنچه برای عیب‌یابی لازم است
logger.LogInformation("Customer {CustomerId} registered via {Channel}",
    customer.Id, customer.SignupChannel);

خط دوم یک مزیت جانبی هم دارد: لاگ ساخت‌یافته با ویژگی‌های مشخص، قابل‌جستجوتر و کم‌حجم‌تر است. این همان چیزی است که در مقالهٔ لاگ ساخت‌یافته از زاویهٔ دیگری گفته‌ام. شناسه‌ها — شناسهٔ داخلی مشتری، شمارهٔ سفارش، trace_id — تقریباً همیشه برای عیب‌یابی کافی‌اند. اگر بعداً لازم شد بدانید مشتری ۴۱۲۷ کیست، به دیتابیس می‌روید؛ جایی که دسترسی کنترل‌شده و ممیزی وجود دارد.

ابزارهای سمت برنامه

Serilog: سیاست destructuring

در Serilog، عملگر @ در قالب پیام ({@Order}) شیء را با همهٔ ویژگی‌هایش سریال‌سازی می‌کند. این قدرتمند و خطرناک است. Serilog اجازه می‌دهد برای هر نوع تعیین کنید هنگام destructuring دقیقاً چه چیزی بیرون بیاید:

Log.Logger = new LoggerConfiguration()
    // برای این نوع‌ها فقط همین فیلدها — هر فیلد تازه‌ای خودکار بیرون می‌ماند
    .Destructure.ByTransforming<LoginRequest>(r => new { r.UserName })
    .Destructure.ByTransforming<Customer>(c => new { c.Id, c.City, c.SignupChannel })
    // سقف‌های ایمنی برای اشیای بزرگ
    .Destructure.ToMaximumDepth(4)
    .Destructure.ToMaximumStringLength(1024)
    .Destructure.ToMaximumCollectionCount(20)
    .WriteTo.Console()
    .CreateLogger();

ByTransforming در واقع همان فهرست سفید است، در سطح نوع: حتی اگر کسی {@Customer} بنویسد، فقط سه فیلد مجاز بیرون می‌آید. اگر ترجیح می‌دهید قاعده کنار خود مدل باشد، پکیج Destructurama.Attributed ویژگی‌هایی مثل [NotLogged] و [LogMasked] اضافه می‌کند:

// Program.cs
.Destructure.UsingAttributes()

// مدل
public class CustomerDto
{
    public int Id { get; set; }

    [NotLogged]
    public string NationalCode { get; set; } = "";

    [LogMasked(ShowFirst = 4, ShowLast = 2)]
    public string Mobile { get; set; } = "";   // 0912*****67
}

پیکربندی کامل Serilog در ⁦ASP.NET Core⁩ را در Serilog در ⁦ASP.NET Core⁩ نوشته‌ام؛ این سیاست‌ها را از همان روز اول در آن پیکربندی بگذارید.

⁦ASP.NET Core⁩: لاگ HTTP با فهرست سفید

میان‌افزار HttpLogging در ⁦ASP.NET Core⁩ خودش بر اساس فهرست سفید کار می‌کند: هدرهایی که صریحاً اجازه نداده‌اید، با مقدار [Redacted] ثبت می‌شوند و بدنه فقط وقتی لاگ می‌شود که خودتان روشنش کنید. ولی پیش‌فرض‌ها را هم کم کنید:

using Microsoft.AspNetCore.HttpLogging;

builder.Services.AddHttpLogging(o =>
{
    o.LoggingFields = HttpLoggingFields.RequestMethod
                    | HttpLoggingFields.RequestPath
                    | HttpLoggingFields.ResponseStatusCode
                    | HttpLoggingFields.Duration;
    // RequestBody و ResponseBody عمداً خاموش‌اند
});

app.UseHttpLogging();

توجه کنید RequestPath شامل query string نیست؛ query فیلد جداگانه‌ای است (RequestQuery) که اینجا عمداً نیامده. در ⁦.NET 8⁩ به بعد، Microsoft کتابخانه‌های Microsoft.Extensions.Compliance.Redaction را هم دارد که با طبقه‌بندی داده (data classification) روی ویژگی‌ها، redaction را در خود لایهٔ لاگ اعمال می‌کند — برای سیستم‌های بزرگ با قواعد انطباق سخت‌گیرانه ارزش بررسی دارد.

ماسک کردن وقتی شناسایی لازم است

گاهی واقعاً لازم است بدانید با کدام شماره سروکار دارید — مثلاً پشتیبانی می‌گوید «کاربر با شمارهٔ ۰۹۱۲… پیامک نگرفته». ماسک کردن (masking) یعنی بخشی از مقدار را نگه دارید و بقیه را پنهان کنید. چند قاعده:

  • موبایل: چهار رقم اول و دو رقم آخر — 0912*****67 — برای تطبیق با گزارش پشتیبانی معمولاً کافی است.
  • کارت: شش رقم اول (شناسهٔ بانک) و چهار رقم آخر.
  • کد ملی: ترجیحاً هیچ. اگر لازم است، فقط سه رقم آخر.
  • ماسک را در خود برنامه و پیش از لاگ اعمال کنید، نه با امید به فیلتر بعدی.

اگر می‌خواهید در متن آزاد هم دنبال کد ملی بگردید، به الگوی «ده رقم» بسنده نکنید؛ هر شمارهٔ سفارش ده‌رقمی را هم می‌گیرد. کد ملی رقم کنترل دارد و با بررسی آن، مثبت کاذب خیلی کم می‌شود:

static bool IsIranianNationalCode(string s)
{
    if (s.Length != 10 || !s.All(char.IsAsciiDigit)) return false;
    if (s.Distinct().Count() == 1) return false;   // 0000000000، 1111111111 و ...

    int sum = 0;
    for (int i = 0; i < 9; i++)
        sum += (s[i] - '0') * (10 - i);

    int r = sum % 11;
    int check = s[9] - '0';
    return r < 2 ? check == r : check == 11 - r;
}

Python: فیلتر روی ماژول logging

در پایتون یک logging.Filter روی handler اصلی، آخرین فرصت پیش از خروج رکورد از برنامه است:

import logging
import re

MOBILE = re.compile(r"(?<!\d)(?:\+98|0098|0)?9\d{9}(?!\d)")
SENSITIVE_KEYS = {"password", "token", "authorization", "cookie", "national_code", "otp"}

class RedactFilter(logging.Filter):
    def filter(self, record: logging.LogRecord) -> bool:
        record.msg = MOBILE.sub("[mobile]", str(record.msg))
        if isinstance(record.args, tuple):
            record.args = tuple(MOBILE.sub("[mobile]", a) if isinstance(a, str) else a
                                for a in record.args)
        for key in list(vars(record)):
            if key.lower() in SENSITIVE_KEYS:
                setattr(record, key, "[redacted]")
        return True

handler = logging.StreamHandler()
handler.addFilter(RedactFilter())
logging.basicConfig(level=logging.INFO, handlers=[handler])

این فیلتر را به‌عنوان تور ایمنی ببینید، نه سیاست اصلی. سیاست اصلی همان است که در بخش قبل گفتم: چیز حساس را اصلاً در فراخوانی لاگ نگذارید.

حذف در سمت سرور: تور ایمنی، نه جایگزین

لایهٔ بعدی، حذف داده در مسیر — در OpenTelemetry Collector (با processorهایی مثل attributes یا transform) یا در خود سامانهٔ مدیریت لاگ — پیش از ذخیره است. برای نمونه، LogMug شمارهٔ کارت (با بررسی Luhn تا شمارهٔ سفارش‌ها دست نخورند)، توکن‌های Bearer و Basic، پارامترهایی مثل password و token و api_key در نشانی‌ها، و مقدار ویژگی‌هایی که نامشان به password یا token یا cookie و مانند آن شبیه است را همیشه پیش از ذخیره حذف می‌کند، و حذف ایمیل و شمارهٔ موبایل ایرانی را هم به‌صورت اختیاری برای هر پروژه دارد. جزئیاتش در آموزش حریم خصوصی و حذف خودکار دادهٔ حساس آمده.

این لایه ارزشمند است، چون اشتباه انسانی همیشه رخ می‌دهد. ولی سه دلیل دارد که نباید به آن تکیه کرد:

  1. داده پیش از رسیدن به سرور، از جاهای دیگری گذشته است: کنسول، فایل محلی، بافر exporter، شبکه. حذف در مقصد، آن کپی‌ها را پاک نمی‌کند.
  2. الگو همه‌چیز را نمی‌شناسد. سرور نمی‌داند فیلد x7 در سیستم شما کد ملی است، یا رشتهٔ هشت‌حرفی وسط پیام، رمز موقت.
  3. فرهنگ را خراب می‌کند. وقتی تیم بداند «سرور خودش پاک می‌کند»، در بازبینی کد دیگر کسی به لاگ‌ها دقت نمی‌کند.

پس ترتیب درست این است: فهرست سفید در کد، ماسک برای موارد لازم، فیلتر در برنامه، و حذف سمت سرور به‌عنوان آخرین تور. هر لایه چیزی را می‌گیرد که از لایهٔ قبل رد شده.

نگه‌داری و دسترسی: محدود کردن آسیب

فرض کنید با همهٔ این‌ها، چیزی رد شد. دو عامل تعیین می‌کنند آسیب چقدر بزرگ باشد: داده چقدر می‌ماند، و چه کسانی می‌بینندش.

مدت نگه‌داری

لاگی که وجود ندارد، نشت نمی‌کند. برای لاگ عملیاتی معمولی، بیشتر تیم‌ها با چند هفته تا سه ماه کاملاً کار می‌کنند. بیشتر از آن را فقط وقتی نگه دارید که دلیل مشخصی دارید — الزام قانونی، ممیزی، یا الگوی خطایی که فاصلهٔ رخدادهایش طولانی است. لاگ‌های ممیزی امنیتی (ورود، تغییر دسترسی) معمولاً عمر طولانی‌تری لازم دارند؛ آن‌ها را از لاگ عملیاتی جدا کنید تا لازم نباشد همه‌چیز را به خاطر آن‌ها طولانی نگه دارید. حساب‌وکتاب هزینه و مدت را در مقالهٔ لاگ را چند روز نگه داریم؟ آورده‌ام.

یک نکته که اغلب فراموش می‌شود: مدت نگه‌داری باید همه‌جا اعمال شود — در سامانهٔ متمرکز، روی فایل‌های لاگ سرورها (با چرخش و حذف خودکار)، و در بکاپ‌ها.

دسترسی

  • اصل حداقل دسترسی: کسی که فقط باید لاگ بخواند، نقش فقط‌خواندنی بگیرد؛ همه به همهٔ پروژه‌ها دسترسی نداشته باشند.
  • کلیدهای ارسال لاگ را برای هر سرور یا سرویس جدا بسازید تا اگر یکی لو رفت، بشود فقط همان را باطل کرد. این را در آموزش پروژه‌ها، کلیدها و محیط‌ها توضیح داده‌ام.
  • محیط آزمایشی با دادهٔ واقعی، لاگ واقعی تولید می‌کند. یا دادهٔ آزمایشی را ناشناس کنید، یا لاگ‌های آن محیط را با همان سخت‌گیری پروداکشن نگه دارید.
  • لاگ را در کانال‌های گفت‌وگوی تیم کپی نکنید؛ لینک جستجو را بفرستید. لینک به سامانه‌ای اشاره می‌کند که دسترسی و مدت نگه‌داری دارد؛ متن کپی‌شده برای همیشه در تاریخچهٔ پیام‌رسان می‌ماند.

اگر نشت رخ داد

اگر فهمیدید دادهٔ حساسی وارد لاگ شده: اول منبع را در کد ببندید؛ بعد هر راز نشت‌کرده (رمز، توکن، کلید) را باطل و تعویض کنید — حذف لاگ کافی نیست، چون نمی‌دانید چه کسی پیش از حذف دیده است؛ سپس لاگ‌های آلوده را در همهٔ کپی‌ها پاک کنید؛ و در آخر بررسی کنید آیا باید به کاربران یا مرجعی اطلاع دهید.

چک‌لیست بازبینی کد

این سؤال‌ها را در هر بازبینی کدی که لاگ اضافه یا عوض می‌کند، می‌پرسم:

  1. آیا شیء کاملی با @ یا ToString یا سریال‌سازی JSON لاگ می‌شود؟ چرا فیلدهای مشخص نه؟
  2. آیا بدنهٔ درخواست یا پاسخ، یا نشانی با query string، لاگ می‌شود؟
  3. آیا هیچ هدری — مخصوصاً Authorization و Cookie — لاگ می‌شود؟
  4. آیا پیام exception ممکن است مقدار ورودی کاربر را در خود داشته باشد؟
  5. آیا در شروع برنامه پیکربندی یا رشتهٔ اتصال چاپ می‌شود؟
  6. آیا کد ملی، موبایل، کارت یا OTP جایی در قالب پیام هست؟ اگر لازم است، ماسک شده؟
  7. آیا لاگ متن ورودی کاربر را بدون پاک‌سازی نویسه‌های خط جدید می‌نویسد؟ (این مسئلهٔ دیگری است — تزریق لاگ — ولی در همین بازبینی پیدا می‌شود.)

این چک‌لیست را در قالب pull request تیم بگذارید. دو هفته طول می‌کشد تا عادت شود و بعد از آن، به ندرت چیزی از آن رد می‌شود. اگر دنبال تصویر بزرگ‌تری از اینکه چه چیزی باید لاگ شود هستید، راهنمای جامع لاگ‌نویسی در بک‌اند را بخوانید؛ این مقاله نیمهٔ دیگر همان تصویر است.

منابع و مطالعهٔ بیشتر

لاگ همهٔ سرویس‌هایتان را در یک جا جستجو کنید

C#، Java یا هر زبان دیگر — با چند خط پیکربندی وصل می‌شود. پلن رایگان کارت بانکی نمی‌خواهد.

شروع رایگان