- پس از خداحافظی با شرکتی که در آن کار میکردی، شخصی با پوزخند به شما میگوید که «میدونستی در برنامهی حق و دستمزد شما، بچههای ادمین شبکه، دیتابیس برنامه رو مستقیما دستکاری میکردند و تعداد ساعات کاری بیشتری رو وارد میکردند»؟!
- مسئول فروشی/مسئول پذیرشی که یاد گرفته چطور به صورت مستقیم به بانک اطلاعاتی دسترسی پیدا کند و آمار فروش/پذیرش روز خودش را در بانک اطلاعاتی، با دستکاری مستقیم و خارج از برنامه، کمتر از مقدار واقعی نمایش دهد.
- باز هم مدیر سیستمی/شبکهای که دسترسی مستقیم به بانک اطلاعاتی دارد، در ساعاتی مشخص، کلمهی عبور هش شدهی خودش را مستقیما، بجای کلمهی عبور ادمین برنامه در بانک اطلاعاتی وارد کرده و پس از آن ...
این موارد متاسفانه واقعی هستند! اکنون سؤال اینجا است که آیا برنامهی شما قادر است تشخیص دهد رکوردهایی که هم اکنون در بانک اطلاعاتی ثبت شدهاند، واقعا توسط برنامه و تمام سطوح دسترسی که برای آن طراحی کردهاید، به این شکل درآمدهاند، یا اینکه توسط اشخاصی به صورت مستقیم و با دور زدن کامل برنامه، از طریق management studioهای مختلف، در سیستم وارد و دستکاری شدهاند؟! در ادامه راه حلی را برای بررسی این مشکل مهم، مرور خواهیم کرد.
چگونه تغییرات رکوردها را در بانکهای اطلاعاتی ردیابی کنیم؟
روش متداولی که برای بررسی تغییرات رکوردها مورد استفاده قرار میگیرد، هش کردن تمام اطلاعات یک ردیف از جدول است و سپس مقایسهی این هشها با هم. علت استفادهی از الگوریتمهای هش نیز، حداقل به دو علت است:
- با تغییر حتی یک بیت از اطلاعات، مقدار هش تولید شده تغییر میکند.
- طول نهایی مقدار هش شدهی اطلاعاتی حجیم، بسیار کم است و به راحتی توسط بانکهای اطلاعاتی، قابل مدیریت و جستجو است.
اگر از SQL Server استفاده میکنید، یک چنین قابلیتی را به صورت توکار به همراه دارد:
SELECT [Id], (SELECT top 1 * FROM [AppUsers] FOR XML auto), HASHBYTES ('SHA2_256', (SELECT top 1 * FROM [AppUsers] FOR XML auto)) AS [hash] -- varbinary(n), since 2012 FROM [AppUsers]
کاری که این کوئری انجام میدهد شامل دو مرحله است:
الف) کوئری "SELECT top 1 * FROM [AppUsers] FOR XML auto" کاری شبیه به serialization را انجام میدهد. همانطور که مشاهده میکنید، نام و مقادیر تمام فیلدهای یک ردیف را به صورت یک خروجی XML در میآورد. بنابراین دیگر نیازی نیست تا کار تبدیل مقادیر تمام ستونهای یک ردیف را به عبارتی قابل هش، به صورت دستی انجام دهیم؛ رشتهی XML ای آن هم اکنون آمادهاست.
ب) متد HASHBYTES، این خروجی serialized را با الگوریتم SHA2_256، هش میکند. الگوریتمهای SHA2_256 و همچنین SHA2_512، از سال 2012 به بعد به SQL Server اضافه شدهاند.
اکنون اگر این هش را به نحوی ذخیره کنیم (برنامه باید این هش را ذخیره و یا به روز رسانی کند) و سپس شخصی به صورت مستقیم ردیف فوق را در بانک اطلاعاتی تغییر دهد، هش جدید این ردیف، با هش قبلی ذخیره شدهی توسط برنامه، یکی نخواهد بود که بیانگر دستکاری مستقیم این ردیف، خارج از برنامه و با دور زدن کامل تمام سطوح دسترسی آن است.
چگونه تغییرات رکوردها را در بانکهای اطلاعاتی، توسط EF Core ردیابی کنیم؟
مزیت روش فوق، توکار بودن آن است که کارآیی فوق العادهای را نیز به همراه دارد. اما چون در ادامه قصد داریم از یک ORM استفاده کنیم و ORMها نیز قرار است توانایی کار کردن با انواع و اقسام بانکهای اطلاعاتی را داشته باشند، دو مرحلهی serialization و هش کردن را در کدهای برنامه و با مدیریت EF Core، مستقل از بانک اطلاعاتی خاصی، انجام خواهیم داد.
معرفی موجودیتهای برنامه
در مثالی که بررسی خواهیم کرد، دو موجودیت Blog و Post تعریف شدهاند:
using System.Collections.Generic; namespace EFCoreRowIntegrity { public interface IAuditableEntity { string Hash { set; get; } } public static class AuditableShadowProperties { public static readonly string CreatedDateTime = nameof(CreatedDateTime); public static readonly string ModifiedDateTime = nameof(ModifiedDateTime); } public class Blog : IAuditableEntity { public int BlogId { get; set; } public string Url { get; set; } public List<Post> Posts { get; set; } public string Hash { get; set; } } public class Post : IAuditableEntity { public int PostId { get; set; } public string Title { get; set; } public string Content { get; set; } public int BlogId { get; set; } public Blog Blog { get; set; } public string Hash { get; set; } } }
- به علاوه جهت تکمیل بحث، دو خاصیت سایهای نیز تعریف شدهاند تا بررسی کنیم که آیا هش اینها نیز درست محاسبه میشود یا خیر.
- علت اینکه خاصیت Hash، سایهای تعریف نشد، سهولت دسترسی و بالا بردن کارآیی آن بود.
معرفی ظرفی برای نگهداری نام خواص و مقادیر متناظر با یک موجودیت
در ادامه دو کلاس AuditEntry و AuditProperty را مشاهده میکنید:
using System.Collections.Generic; using Microsoft.EntityFrameworkCore.ChangeTracking; namespace EFCoreRowIntegrity { public class AuditEntry { public EntityEntry EntityEntry { set; get; } public IList<AuditProperty> AuditProperties { set; get; } = new List<AuditProperty>(); public AuditEntry() { } public AuditEntry(EntityEntry entry) { EntityEntry = entry; } } public class AuditProperty { public string Name { set; get; } public object Value { set; get; } public bool IsTemporary { set; get; } public PropertyEntry PropertyEntry { set; get; } public AuditProperty() { } public AuditProperty(string name, object value, bool isTemporary, PropertyEntry property) { Name = name; Value = value; IsTemporary = isTemporary; PropertyEntry = property; } } }
معرفی روشی برای هش کردن مقادیر یک شیء
زمانیکه توسط سیستم Tracking، در حال کاربر بر روی موجودیتهای اضافه شده و یا ویرایش شده هستیم، میخواهیم فیلد هش آنها را نیز به صورت خودکار ویرایش و مقدار دهی کنیم. کلاس زیر، منطق ارائه دهندهی این مقدار هش را بیان میکند:
using System; using System.Collections.Generic; using System.Security.Cryptography; using System.Text; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Newtonsoft.Json; namespace EFCoreRowIntegrity { public static class HashingExtensions { public static string GenerateObjectHash(this object @object) { if (@object == null) { return string.Empty; } var jsonData = JsonConvert.SerializeObject(@object, Formatting.Indented); using (var hashAlgorithm = new SHA256CryptoServiceProvider()) { var byteValue = Encoding.UTF8.GetBytes(jsonData); var byteHash = hashAlgorithm.ComputeHash(byteValue); return Convert.ToBase64String(byteHash); } } public static string GenerateEntityEntryHash(this EntityEntry entry, string propertyToIgnore) { var auditEntry = new Dictionary<string, object>(); foreach (var property in entry.Properties) { var propertyName = property.Metadata.Name; if (propertyName == propertyToIgnore) { continue; } auditEntry[propertyName] = property.CurrentValue; } return auditEntry.GenerateObjectHash(); } public static string GenerateEntityHash<TEntity>(this DbContext context, TEntity entity, string propertyToIgnore) { return context.Entry(entity).GenerateEntityEntryHash(propertyToIgnore); } } }
- نکتهی مهم: ما نمیخواهیم تمام خواص یک موجودیت را هش کنیم. برای مثال اگر موجودیتی دارای چندین رابطه با جداول دیگری بود، ما مقادیر اینها را هش نمیکنیم (چون رکوردهای متناظر با آنها در جداول خودشان میتوانند دارای فیلد هش مخصوصی باشند). بنابراین یک Dictionary را از خواص و مقادیر متناظر با آنها تشکیل داده و این Dictionary را تبدیل به JSON میکنیم.
- همچنین در این بین، مقدار خود فیلد Hash یک شیء نیز نباید در هش محاسبه شده، حضور داشته باشد. به همین جهت پارامتر propertyToIgnore را مشاهده میکنید.
معرفی Context برنامه که کار هش کردن خودکار موجودیتها را انجام میدهد
اکنون نوبت استفاده از تنظیمات انجام شدهی تا این مرحلهاست:
using System; using System.Collections.Generic; using System.IO; using System.Linq; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.Extensions.Logging; namespace EFCoreRowIntegrity { public class BloggingContext : DbContext { public BloggingContext() { } public BloggingContext(DbContextOptions options) : base(options) { } public DbSet<Blog> Blogs { get; set; } public DbSet<Post> Posts { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { if (!optionsBuilder.IsConfigured) { optionsBuilder.EnableSensitiveDataLogging(); var path = Path.Combine(Directory.GetCurrentDirectory(), "app_data", "EFCore.RowIntegrity.mdf"); optionsBuilder.UseSqlServer($"Server=(localdb)\\mssqllocaldb;Database=EFCore.RowIntegrity;AttachDbFilename={path};Trusted_Connection=True;"); optionsBuilder.UseLoggerFactory(new LoggerFactory().AddConsole((message, logLevel) => logLevel == LogLevel.Debug && message.StartsWith("Microsoft.EntityFrameworkCore.Database.Command"))); } } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); foreach (var entityType in modelBuilder.Model .GetEntityTypes() .Where(e => typeof(IAuditableEntity) .IsAssignableFrom(e.ClrType))) { modelBuilder.Entity(entityType.ClrType) .Property<DateTimeOffset?>(AuditableShadowProperties.CreatedDateTime); modelBuilder.Entity(entityType.ClrType) .Property<DateTimeOffset?>(AuditableShadowProperties.ModifiedDateTime); } } public override int SaveChanges() { var auditEntries = OnBeforeSaveChanges(); var result = base.SaveChanges(); OnAfterSaveChanges(auditEntries); return result; } private IList<AuditEntry> OnBeforeSaveChanges() { var auditEntries = new List<AuditEntry>(); foreach (var entry in ChangeTracker.Entries<IAuditableEntity>()) { if (entry.State == EntityState.Detached || entry.State == EntityState.Unchanged) { continue; } var auditEntry = new AuditEntry(entry); auditEntries.Add(auditEntry); var now = DateTimeOffset.UtcNow; foreach (var property in entry.Properties) { var propertyName = property.Metadata.Name; if (propertyName == nameof(IAuditableEntity.Hash)) { continue; } if (property.IsTemporary) { // It's an auto-generated value and should be retrieved from the DB after calling the base.SaveChanges(). auditEntry.AuditProperties.Add(new AuditProperty(propertyName, null, true, property)); continue; } switch (entry.State) { case EntityState.Added: entry.Property(AuditableShadowProperties.CreatedDateTime).CurrentValue = now; auditEntry.AuditProperties.Add(new AuditProperty(propertyName, property.CurrentValue, false, property)); break; case EntityState.Modified: auditEntry.AuditProperties.Add(new AuditProperty(propertyName, property.CurrentValue, false, property)); entry.Property(AuditableShadowProperties.ModifiedDateTime).CurrentValue = now; break; } } } return auditEntries; } private void OnAfterSaveChanges(IList<AuditEntry> auditEntries) { foreach (var auditEntry in auditEntries) { foreach (var auditProperty in auditEntry.AuditProperties.Where(x => x.IsTemporary)) { // Now we have the auto-generated value from the DB. auditProperty.Value = auditProperty.PropertyEntry.CurrentValue; auditProperty.IsTemporary = false; } auditEntry.EntityEntry.Property(nameof(IAuditableEntity.Hash)).CurrentValue = auditEntry.AuditProperties.ToDictionary(x => x.Name, x => x.Value).GenerateObjectHash(); } base.SaveChanges(); } } }
public override int SaveChanges() { var auditEntries = OnBeforeSaveChanges(); var result = base.SaveChanges(); OnAfterSaveChanges(auditEntries); return result; }
if (property.IsTemporary) { // It's an auto-generated value and should be retrieved from the DB after calling the base.SaveChanges(). auditEntry.AuditProperties.Add(new AuditProperty(propertyName, null, true, property)); continue; }
همین مقدار تنظیم، برای محاسبه و به روز رسانی خودکار فیلد هش، کفایت میکند.
روش بررسی اصالت یک موجودیت
در متد زیر، روش محاسبهی هش واقعی یک موجودیت دریافت شدهی از بانک اطلاعاتی را توسط متد الحاقی GenerateEntityHash مشاهده میکنید. اگر این هش واقعی (بر اساس مقادیر فعلی این ردیف که حتی ممکن است به صورت دستی و خارج از برنامه تغییر کرده باشد)، با مقدار Hash ثبت شدهی پیشین در آن ردیف یکی بود، اصالت این ردیف تائید خواهد شد:
private static void CheckRow1IsAuthentic() { using (var context = new BloggingContext()) { var blog1 = context.Blogs.Single(x => x.BlogId == 1); var entityHash = context.GenerateEntityHash(blog1, propertyToIgnore: nameof(IAuditableEntity.Hash)); var dbRowHash = blog1.Hash; Console.WriteLine($"entityHash: {entityHash}\ndbRowHash: {dbRowHash}"); if (entityHash == dbRowHash) { Console.WriteLine("This row is authentic!"); } else { Console.WriteLine("This row is tampered outside of the application!"); } } }
entityHash: P110cYquWpoaZuTpCWaqBn6HPSGdoQdmaAN05s1zYqo= dbRowHash: P110cYquWpoaZuTpCWaqBn6HPSGdoQdmaAN05s1zYqo= This row is authentic!
اکنون بانک اطلاعاتی را خارج از برنامه، مستقیما دستکاری میکنیم و برای مثال Url اولین ردیف را تغییر میدهیم:
در ادامه یکبار دیگر برنامه را اجرا خواهیم کرد:
entityHash: tdiZhKMJRnROGLLam1WpldA0fy/CbjJaR2Y2jNU9izk= dbRowHash: P110cYquWpoaZuTpCWaqBn6HPSGdoQdmaAN05s1zYqo= This row is tampered outside of the application!
به علاوه باید درنظر داشت، محاسبهی این هش بدون خود برنامه، کار سادهای نیست. به همین جهت به روز رسانی دستی آن تقریبا غیرممکن است؛ خصوصا اگر متد GenerateObjectHash، کمی با پیچ و تاب بیشتری نیز تهیه شود.
چگونه وضعیت اصالت تعدادی ردیف را بررسی کنیم؟
مثال قبل، در مورد روش بررسی اصالت یک تک ردیف بود. کوئری زیر روش محاسبهی فیلد جدید IsAuthentic را در بین لیستی از ردیفها نمایش میدهد:
var blogs = (from blog in context.Blogs.ToList() // Note: this `ToList()` is necessary here for having Shadow properties values, otherwise they will considered `null`. let computedHash = context.GenerateEntityHash(blog, nameof(IAuditableEntity.Hash)) select new { blog.BlogId, blog.Url, RowHash = blog.Hash, ComputedHash = computedHash, IsAuthentic = blog.Hash == computedHash }).ToList();
کدهای کامل این مثال را از اینجا میتوانید دریافت کنید: EFCoreRowIntegrity.zip