Skip to content
Back to skills

Dotnet Audit Fields

ASecurity

Audits EF Core entities for audit-trail hygiene — verifies CreatedAt/CreatedBy/ModifiedAt/ModifiedBy are populated centrally via a SaveChanges override or ISaveChangesInterceptor rather than duplicated per service method, checks for an IAuditable marker interface, validates CreatedBy/ModifiedBy resolve from an injected current-user abstraction typed as Guid (not string), checks Modified fields only update on actually-changed entities, and flags DateTime.Now instead of DateTime.UtcNow. Outputs...

  • 2 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 6, 2026
developmentgo

Security analysis

A100/100

Scanned September 6, 2026

npx -y skills add AgenticPawan/FullStack-Pilot --skill dotnet-audit-fields --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Dotnet Audit Fields?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Dotnet Audit Fields
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/agenticpawan-dotnet-audit-fields/badge)](https://www.skillsdirectory.com/skills/agenticpawan-dotnet-audit-fields)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: dotnet-audit-fields
description: Audits EF Core entities for audit-trail hygiene — verifies CreatedAt/CreatedBy/ModifiedAt/ModifiedBy are populated centrally via a SaveChanges override or ISaveChangesInterceptor rather than duplicated per service method, checks for an IAuditable marker interface, validates CreatedBy/ModifiedBy resolve from an injected current-user abstraction typed as Guid (not string), checks Modified fields only update on actually-changed entities, and flags DateTime.Now instead of DateTime.UtcNow. Outputs findings with pilot-dotnet audit-fields standard IDs.
when_to_use: audit trail, CreatedAt, CreatedBy, ModifiedAt, ModifiedBy, IAuditable, SaveChangesInterceptor, ChangeTracker, ICurrentUserService, DateTime.Now, DateTime.UtcNow, audit columns, timestamp fields, Guid CreatedBy, Guid ModifiedBy
---

## Standard IDs

| ID | Severity | What it checks |
|----|----------|----------------|
| AUD-001 | P1 | Audit fields populated manually per service method instead of centrally |
| AUD-002 | P1 | No IAuditable marker interface — duplicated per-entity audit logic |
| AUD-003 | P2 | CreatedBy/ModifiedBy hardcoded instead of resolved from ICurrentUserService |
| AUD-004 | P1 | ModifiedAt/ModifiedBy updated on unchanged entities, or not updated on EntityState.Modified |
| AUD-005 | P2 | DateTime.Now used instead of DateTime.UtcNow for audit timestamps |
| AUD-006 | P1 | CreatedBy/ModifiedBy typed as `string` instead of `Guid` |

---

## Check A — Centralized audit population

### Detection

1. Grep service/repository classes for direct assignment to `CreatedAt`, `CreatedBy`, `ModifiedAt`, or `ModifiedBy` properties outside of a `DbContext.SaveChanges`/`SaveChangesAsync` override or an `ISaveChangesInterceptor` implementation.
2. If found in more than one service method → AUD-001.

### BAD — audit fields set in every service method

```csharp
public class OrderService
{
    private readonly AppDbContext _db;

    public OrderService(AppDbContext db) => _db = db;

    public async Task<Order> CreateOrderAsync(Order order)
    {
        order.CreatedAt = DateTime.Now;
        order.CreatedBy = "system";
        _db.Orders.Add(order);
        await _db.SaveChangesAsync();
        return order;
    }

    public async Task UpdateOrderAsync(Order order)
    {
        order.ModifiedAt = DateTime.Now;
        order.ModifiedBy = "system";
        _db.Orders.Update(order);
        await _db.SaveChangesAsync();
    }
}
```

### GOOD — centralized via SaveChanges override

```csharp
public class AppDbContext : DbContext
{
    public override int SaveChanges()
    {
        ApplyAuditInfo();
        return base.SaveChanges();
    }

    public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        ApplyAuditInfo();
        return base.SaveChangesAsync(cancellationToken);
    }

    private void ApplyAuditInfo()
    {
        var now = DateTime.UtcNow;
        foreach (var entry in ChangeTracker.Entries<IAuditable>())
        {
            if (entry.State == EntityState.Added)
            {
                entry.Entity.CreatedAt = now;
                entry.Entity.CreatedBy = _currentUser.UserId;
            }
            if (entry.State == EntityState.Modified)
            {
                entry.Entity.ModifiedAt = now;
                entry.Entity.ModifiedBy = _currentUser.UserId;
            }
        }
    }
}
```

---

## Check B — IAuditable marker interface

### Detection

1. Search for entity classes that each declare their own `CreatedAt`/`CreatedBy`/`ModifiedAt`/`ModifiedBy` properties with separate ad-hoc population code, and no shared interface ties them together.
2. If two or more entities duplicate this population logic independently → AUD-002.

### BAD — duplicated per-entity audit logic

```csharp
public class Order
{
    public int Id { get; set; }
    public DateTime CreatedAt { get; set; }
    public string CreatedBy { get; set; } = string.Empty;
}

public class Invoice
{
    public int Id { get; set; }
    public DateTime CreatedAt { get; set; }
    public string CreatedBy { get; set; } = string.Empty;
}

// Each service duplicates the same "set CreatedAt/CreatedBy" logic independently.
```

### GOOD — shared marker interface handled by one interceptor

```csharp
public interface IAuditable
{
    DateTime CreatedAt { get; set; }
    string CreatedBy { get; set; }
    DateTime? ModifiedAt { get; set; }
    string? ModifiedBy { get; set; }
}

public class Order : IAuditable
{
    public int Id { get; set; }
    public DateTime CreatedAt { get; set; }
    public string CreatedBy { get; set; } = string.Empty;
    public DateTime? ModifiedAt { get; set; }
    public string? ModifiedBy { get; set; }
}

public class Invoice : IAuditable
{
    public int Id { get; set; }
    public DateTime CreatedAt { get; set; }
    public string CreatedBy { get; set; } = string.Empty;
    public DateTime? ModifiedAt { get; set; }
    public string? ModifiedBy { get; set; }
}

public class AuditableEntitiesInterceptor : ISaveChangesInterceptor
{
    private readonly ICurrentUserService _currentUser;

    public AuditableEntitiesInterceptor(ICurrentUserService currentUser)
        => _currentUser = currentUser;

    public override InterceptionResult<int> SavingChanges(
        DbContextEventData eventData, InterceptionResult<int> result)
    {
        ApplyAuditInfo(eventData.Context);
        return base.SavingChanges(eventData, result);
    }

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData, InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        ApplyAuditInfo(eventData.Context);
        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }

    private void ApplyAuditInfo(DbContext? context)
    {
        if (context is null) return;
        var now = DateTime.UtcNow;

        foreach (var entry in context.ChangeTracker.Entries<IAuditable>())
        {
            switch (entry.State)
            {
                case EntityState.Added:
                    entry.Entity.CreatedAt = now;
                    entry.Entity.CreatedBy = _currentUser.UserId;
                    break;
                case EntityState.Modified:
                    entry.Entity.ModifiedAt = now;
                    entry.Entity.ModifiedBy = _currentUser.UserId;
                    break;
            }
        }
    }
}
```

---

## Check C — CreatedBy/ModifiedBy resolved from current-user abstraction

### Detection

1. Search interceptor/`SaveChanges` code for hardcoded string literals (`"system"`, `"admin"`, `"unknown"`) assigned to `CreatedBy`/`ModifiedBy`.
2. If no `ICurrentUserService`/`IUserContext` dependency is injected and used instead → AUD-003.

### BAD — hardcoded system value

```csharp
private void ApplyAuditInfo(DbContext context)
{
    foreach (var entry in context.ChangeTracker.Entries<IAuditable>())
    {
        if (entry.State == EntityState.Added)
        {
            entry.Entity.CreatedBy = "system"; // no idea which user actually did this
        }
    }
}
```

### GOOD — resolved from injected current-user service

```csharp
public interface ICurrentUserService
{
    string UserId { get; }
}

public class HttpContextCurrentUserService : ICurrentUserService
{
    private readonly IHttpContextAccessor _accessor;

    public HttpContextCurrentUserService(IHttpContextAccessor accessor)
        => _accessor = accessor;

    public string UserId =>
        _accessor.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value
        ?? "background-job";
}

// Registered in the interceptor and resolved per operation instead of hardcoded.
```

---

## Check D — Modified fields updated only on real changes

### Detection

1. Check whether the interceptor/`SaveChanges` override branches only on `EntityState.Added` and `EntityState.Modified`, and whether `EntityState.Modified` is actually handled (a common bug is checking only `Added`).
2. Check whether `ChangeTracker.DetectChanges()` runs before the audit pass so no-op updates (entity attached and saved without real property changes) don't touch `ModifiedAt`.
3. If `ModifiedAt` is stamped on entities with no actual modified properties, or `EntityState.Modified` is never handled → AUD-004.

### BAD — only handles Added, and stamps ModifiedAt unconditionally

```csharp
private void ApplyAuditInfo(DbContext context)
{
    foreach (var entry in context.ChangeTracker.Entries<IAuditable>())
    {
        if (entry.State == EntityState.Added)
        {
            entry.Entity.CreatedAt = DateTime.UtcNow;
        }
        // BUG: EntityState.Modified never handled — ModifiedAt/ModifiedBy stay stale forever.
    }
}
```

### GOOD — handles both states and skips genuinely unchanged entities

```csharp
private void ApplyAuditInfo(DbContext context)
{
    context.ChangeTracker.DetectChanges();
    var now = DateTime.UtcNow;

    foreach (var entry in context.ChangeTracker.Entries<IAuditable>())
    {
        if (entry.State == EntityState.Added)
        {
            entry.Entity.CreatedAt = now;
            entry.Entity.CreatedBy = _currentUser.UserId;
        }
        else if (entry.State == EntityState.Modified)
        {
            // Only stamp when a property other than the audit fields themselves changed.
            var hasRealChanges = entry.Properties.Any(p =>
                p.IsModified &&
                p.Metadata.Name is not (nameof(IAuditable.ModifiedAt) or nameof(IAuditable.ModifiedBy)));

            if (hasRealChanges)
            {
                entry.Entity.ModifiedAt = now;
                entry.Entity.ModifiedBy = _currentUser.UserId;
            }
        }
    }
}
```

---

## Check E — UtcNow instead of local Now

### Detection

Grep for `DateTime.Now` assigned to any property named `CreatedAt`, `ModifiedAt`, or ending in `At`/`Date`/`Timestamp`. Local server time is ambiguous across deployment regions and breaks comparisons across servers in different time zones.

### BAD — local time

```csharp
entry.Entity.CreatedAt = DateTime.Now;
entry.Entity.ModifiedAt = DateTime.Now;
```

### GOOD — UTC time

```csharp
entry.Entity.CreatedAt = DateTime.UtcNow;
entry.Entity.ModifiedAt = DateTime.UtcNow;
```

**Detection rule:** flag `DateTime.Now` (or `DateTimeOffset.Now`, unless immediately converted `.ToUniversalTime()`) anywhere it is assigned to a property whose name matches `Created*`, `Modified*`, `*At`, `*Date`, or `*Timestamp`.

---

## Check F — CreatedBy/ModifiedBy typed as Guid, not string

### Detection

1. Grep `IAuditable`/entity classes for `string CreatedBy` / `string ModifiedBy` / `string? ModifiedBy` properties, and `ICurrentUserService.UserId` (or equivalent) returning `string`.
2. A `string` user identifier invites free-text values (`"system"`, `"unknown"`, display names) instead of a stable, joinable identity key, and can't be foreign-keyed to a `Users` table without an implicit string-to-Guid cast at every join.
3. Flag any `CreatedBy`/`ModifiedBy` property, or the `ICurrentUserService` member that feeds it, typed as `string` where the underlying identity provider issues a GUID subject/object identifier (Entra ID `oid`/`sub`, ASP.NET Identity `Guid` user key).

### BAD — string-typed audit user fields

```csharp
public interface IAuditable
{
    DateTime CreatedAt { get; set; }
    string CreatedBy { get; set; }       // free text — not joinable, allows "system"/"unknown"
    DateTime? ModifiedAt { get; set; }
    string? ModifiedBy { get; set; }
}

public interface ICurrentUserService
{
    string UserId { get; } // parsed ad-hoc from claims wherever it's consumed
}
```

### GOOD — Guid-typed audit user fields resolved once from claims

```csharp
public interface IAuditable
{
    DateTime CreatedAt { get; set; }
    Guid CreatedBy { get; set; }
    DateTime? ModifiedAt { get; set; }
    Guid? ModifiedBy { get; set; }
}

public interface ICurrentUserService
{
    Guid UserId { get; }
}

public class HttpContextCurrentUserService : ICurrentUserService
{
    private readonly IHttpContextAccessor _accessor;

    public HttpContextCurrentUserService(IHttpContextAccessor accessor) => _accessor = accessor;

    public Guid UserId
    {
        get
        {
            var claim = _accessor.HttpContext?.User?.FindFirst("oid")?.Value
                ?? _accessor.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
            return Guid.TryParse(claim, out var id) ? id : Guid.Empty; // Guid.Empty = background-job/system
        }
    }
}
```

Ties to `dotnet-entity-keys` (entity `Id` should also be `Guid`) and `dotnet-authorization`
AZ-007 — the JWT should carry the subject as a `Guid`-parseable claim, not a display name.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…