feat(audit): enhanced AuditActionFilter with body capture + enrichment hooks

- Capture request body (JSON, up to 64KB, PII-redacted) in Details["requestBody"]
- Capture response resource ID for create operations in Details["responseResourceId"]
- Add IAuditResourceEnricher interface for GUID -> human-readable name resolution
- Add IAuditBeforeStateProvider for before-state snapshots in Details["beforeState"]
- Add AuditPiiRedactor with configurable field patterns (recursive JSON walk)
- AuditActionAttribute gains CaptureBody (bool?) + SensitiveFields (string[]?)
- AuditEmissionOptions gains MaxBodySizeBytes (64KB) + RedactedFieldPatterns
- All enrichment is optional and fire-and-forget (never blocks response)
- Add AuditModules constants (15 modules) and AuditActions constants (~200 actions)
  organized as nested static classes per module for type-safe annotations
- All 17 consuming services verified to compile successfully

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-09 11:49:54 +03:00
parent 54e7f871a3
commit 2a69ad112c
9 changed files with 970 additions and 23 deletions

View File

@@ -37,6 +37,20 @@ public sealed class AuditActionAttribute : Attribute
/// </summary>
public string? ResourceType { get; set; }
/// <summary>
/// Whether the request body should be captured in the audit event's <c>Details["requestBody"]</c>.
/// Default is <c>true</c> for POST/PUT/PATCH and <c>false</c> for all other methods.
/// Set explicitly to override the default behavior.
/// </summary>
public bool? CaptureBody { get; set; }
/// <summary>
/// Additional field names to redact from the request body, beyond the defaults
/// in <see cref="AuditPiiRedactor.DefaultRedactedPatterns"/>.
/// Matching is case-insensitive and strips separators (underscores, hyphens).
/// </summary>
public string[]? SensitiveFields { get; set; }
/// <summary>
/// Creates a new audit action attribute.
/// </summary>
@@ -49,4 +63,19 @@ public sealed class AuditActionAttribute : Attribute
Module = module;
Action = action;
}
/// <summary>
/// Resolves whether the body should be captured for the given HTTP method.
/// If <see cref="CaptureBody"/> is explicitly set, that value wins.
/// Otherwise, defaults to <c>true</c> for POST/PUT/PATCH and <c>false</c> for everything else.
/// </summary>
internal bool ShouldCaptureBody(string httpMethod)
{
if (CaptureBody.HasValue)
{
return CaptureBody.Value;
}
return httpMethod is "POST" or "PUT" or "PATCH";
}
}

View File

@@ -1,9 +1,13 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
using System.Buffers;
using System.Security.Claims;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Audit.Emission;
@@ -14,6 +18,15 @@ namespace StellaOps.Audit.Emission;
/// The filter reads <see cref="AuditActionAttribute"/> metadata from the endpoint.
/// If the attribute is not present, the filter is a no-op passthrough.
/// </para>
/// <para>
/// Enhanced capabilities:
/// <list type="bullet">
/// <item>Request body capture (JSON, up to configurable max size, PII-redacted)</item>
/// <item>Response resource ID extraction for create operations</item>
/// <item>Before-state capture via <see cref="IAuditBeforeStateProvider"/></item>
/// <item>Resource name enrichment via <see cref="IAuditResourceEnricher"/></item>
/// </list>
/// </para>
/// </summary>
/// <remarks>
/// Usage in minimal API registration:
@@ -27,41 +40,166 @@ public sealed class AuditActionFilter : IEndpointFilter
{
private readonly IAuditEventEmitter _emitter;
private readonly ILogger<AuditActionFilter> _logger;
private readonly IOptions<AuditEmissionOptions> _options;
public AuditActionFilter(IAuditEventEmitter emitter, ILogger<AuditActionFilter> logger)
public AuditActionFilter(
IAuditEventEmitter emitter,
ILogger<AuditActionFilter> logger,
IOptions<AuditEmissionOptions> options)
{
_emitter = emitter;
_logger = logger;
_options = options;
}
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
// Execute the endpoint first
var result = await next(context).ConfigureAwait(false);
// Check for the audit attribute
// Check for the audit attribute early so we can decide whether to capture pre-state
var auditAttr = context.HttpContext.GetEndpoint()?.Metadata.GetMetadata<AuditActionAttribute>();
if (auditAttr is null)
{
return result;
return await next(context).ConfigureAwait(false);
}
var httpContext = context.HttpContext;
var options = _options.Value;
// --- Pre-execution captures ---
// 1. Capture request body (if applicable)
JsonNode? capturedBody = null;
if (auditAttr.ShouldCaptureBody(httpContext.Request.Method))
{
capturedBody = await CaptureRequestBodyAsync(httpContext, auditAttr, options).ConfigureAwait(false);
}
// 2. Capture before-state (if a provider is registered)
Dictionary<string, object?>? beforeState = null;
var resourceId = ResolveResourceId(httpContext);
var resourceType = auditAttr.ResourceType ?? InferResourceType(httpContext.Request.Path.Value);
if (!string.IsNullOrWhiteSpace(resourceId))
{
beforeState = await CaptureBeforeStateAsync(httpContext, auditAttr, resourceType, resourceId).ConfigureAwait(false);
}
// --- Execute the endpoint ---
var result = await next(context).ConfigureAwait(false);
// --- Post-execution: emit audit event ---
// Fire-and-forget: emit the audit event asynchronously without blocking the response
_ = EmitAuditEventSafeAsync(context.HttpContext, auditAttr, result);
_ = EmitAuditEventSafeAsync(httpContext, auditAttr, result, capturedBody, beforeState, options);
return result;
}
private async Task<JsonNode?> CaptureRequestBodyAsync(
HttpContext httpContext,
AuditActionAttribute auditAttr,
AuditEmissionOptions options)
{
try
{
var request = httpContext.Request;
// Enable buffering so the body can be read multiple times
// (once here, once by the actual endpoint handler)
request.EnableBuffering();
var contentType = request.ContentType;
if (string.IsNullOrWhiteSpace(contentType) ||
!contentType.Contains("json", StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (request.ContentLength is > 0 and var length && length > options.MaxBodySizeBytes)
{
var truncated = new JsonObject
{
["_truncated"] = true,
["_originalSizeBytes"] = length
};
return truncated;
}
// Read the body
request.Body.Position = 0;
var maxSize = options.MaxBodySizeBytes;
var buffer = ArrayPool<byte>.Shared.Rent(maxSize);
try
{
var bytesRead = await request.Body.ReadAsync(buffer.AsMemory(0, maxSize)).ConfigureAwait(false);
request.Body.Position = 0; // Reset for the endpoint handler
if (bytesRead == 0)
{
return null;
}
var configuredPatterns = options.RedactedFieldPatterns is { Count: > 0 }
? (IReadOnlyList<string>)options.RedactedFieldPatterns
: null;
return AuditPiiRedactor.Redact(
buffer.AsSpan(0, bytesRead),
additionalPatterns: auditAttr.SensitiveFields,
configuredPatterns: configuredPatterns);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to capture request body for audit event");
return null;
}
}
private async Task<Dictionary<string, object?>?> CaptureBeforeStateAsync(
HttpContext httpContext,
AuditActionAttribute auditAttr,
string resourceType,
string resourceId)
{
try
{
var providers = httpContext.RequestServices.GetService<IEnumerable<IAuditBeforeStateProvider>>();
var provider = providers?.FirstOrDefault(p =>
string.Equals(p.Module, auditAttr.Module, StringComparison.OrdinalIgnoreCase));
if (provider is null)
{
return null;
}
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
return await provider.GetBeforeStateAsync(resourceType, resourceId, cts.Token).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to capture before-state for audit event ({Module}/{ResourceType}/{ResourceId})",
auditAttr.Module, resourceType, resourceId);
return null;
}
}
private async Task EmitAuditEventSafeAsync(
HttpContext httpContext,
AuditActionAttribute auditAttr,
object? result)
object? result,
JsonNode? capturedBody,
Dictionary<string, object?>? beforeState,
AuditEmissionOptions options)
{
try
{
var auditEvent = BuildAuditEvent(httpContext, auditAttr, result);
var auditEvent = await BuildAuditEventAsync(
httpContext, auditAttr, result, capturedBody, beforeState, options, _logger).ConfigureAwait(false);
await _emitter.EmitAsync(auditEvent, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
@@ -76,10 +214,14 @@ public sealed class AuditActionFilter : IEndpointFilter
}
}
internal static AuditEventPayload BuildAuditEvent(
internal static async ValueTask<AuditEventPayload> BuildAuditEventAsync(
HttpContext httpContext,
AuditActionAttribute auditAttr,
object? result)
object? result,
JsonNode? capturedBody,
Dictionary<string, object?>? beforeState,
AuditEmissionOptions options,
ILogger logger)
{
var now = DateTimeOffset.UtcNow;
var user = httpContext.User;
@@ -111,6 +253,48 @@ public sealed class AuditActionFilter : IEndpointFilter
? corrValues.FirstOrDefault()
: httpContext.TraceIdentifier;
// Build the resource payload, optionally enriched
var resourcePayload = new AuditResourcePayload
{
Type = resourceType,
Id = resourceId ?? "unknown"
};
resourcePayload = await TryEnrichResourceAsync(httpContext, auditAttr, resourcePayload, logger).ConfigureAwait(false);
// Build details dictionary
var details = new Dictionary<string, object?>
{
["httpMethod"] = request.Method,
["requestPath"] = request.Path.Value,
["statusCode"] = response.StatusCode
};
// Add request body (redacted)
if (capturedBody is not null)
{
details["requestBody"] = capturedBody;
}
// Add before-state
if (beforeState is not null)
{
details["beforeState"] = beforeState;
}
// Extract response resource ID for create operations
var responseResourceId = ExtractResponseResourceId(result);
if (responseResourceId is not null)
{
details["responseResourceId"] = responseResourceId;
// If we didn't have a resource ID from the route (common for POST/create),
// update the resource payload with the newly created ID
if (resourcePayload.Id == "unknown")
{
resourcePayload = resourcePayload with { Id = responseResourceId };
}
}
return new AuditEventPayload
{
Id = $"audit-{Guid.NewGuid():N}",
@@ -127,24 +311,117 @@ public sealed class AuditActionFilter : IEndpointFilter
IpAddress = httpContext.Connection.RemoteIpAddress?.ToString(),
UserAgent = request.Headers.UserAgent.FirstOrDefault()
},
Resource = new AuditResourcePayload
{
Type = resourceType,
Id = resourceId ?? "unknown"
},
Resource = resourcePayload,
Description = description,
Details = new Dictionary<string, object?>
{
["httpMethod"] = request.Method,
["requestPath"] = request.Path.Value,
["statusCode"] = response.StatusCode
},
Details = details,
CorrelationId = correlationId,
TenantId = tenantId,
Tags = [auditAttr.Module.ToLowerInvariant(), auditAttr.Action.ToLowerInvariant()]
};
}
/// <summary>
/// Backward-compatible static builder for tests that do not require body/enrichment.
/// </summary>
internal static AuditEventPayload BuildAuditEvent(
HttpContext httpContext,
AuditActionAttribute auditAttr,
object? result)
{
return BuildAuditEventAsync(
httpContext,
auditAttr,
result,
capturedBody: null,
beforeState: null,
new AuditEmissionOptions(),
Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance).AsTask().GetAwaiter().GetResult();
}
private static async ValueTask<AuditResourcePayload> TryEnrichResourceAsync(
HttpContext httpContext,
AuditActionAttribute auditAttr,
AuditResourcePayload resource,
ILogger logger)
{
try
{
if (resource.Id == "unknown")
{
return resource;
}
var enrichers = httpContext.RequestServices.GetService<IEnumerable<IAuditResourceEnricher>>();
var enricher = enrichers?.FirstOrDefault(e =>
string.Equals(e.Module, auditAttr.Module, StringComparison.OrdinalIgnoreCase));
if (enricher is null)
{
return resource;
}
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
return await enricher.EnrichAsync(resource.Type, resource.Id, cts.Token).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogDebug(ex, "Audit resource enrichment failed for {Module}/{Type}/{Id}",
auditAttr.Module, resource.Type, resource.Id);
return resource;
}
}
/// <summary>
/// Attempts to extract an "id" field from the endpoint result, useful for create operations
/// where the new resource ID is returned in the response body.
/// </summary>
internal static string? ExtractResponseResourceId(object? result)
{
if (result is null)
{
return null;
}
try
{
// Handle IResult wrappers (Results.Ok, Results.Created, etc.)
// by serializing to JSON and checking for an "id" property.
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
return null;
}
// Check for common ID field names
string[] idFields = ["id", "Id", "ID", "resourceId", "ResourceId"];
foreach (var field in idFields)
{
if (root.TryGetProperty(field, out var idProp))
{
return idProp.ValueKind switch
{
JsonValueKind.String => idProp.GetString(),
JsonValueKind.Number => idProp.GetRawText(),
_ => null
};
}
}
}
catch
{
// Best-effort; never fail for response inspection
}
return null;
}
private static string? ResolveClaimValue(ClaimsPrincipal user, params string[] claimTypes)
{
foreach (var claimType in claimTypes)
@@ -159,7 +436,7 @@ public sealed class AuditActionFilter : IEndpointFilter
return null;
}
private static string? ResolveResourceId(HttpContext httpContext)
internal static string? ResolveResourceId(HttpContext httpContext)
{
var routeValues = httpContext.Request.RouteValues;

View File

@@ -0,0 +1,356 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
namespace StellaOps.Audit.Emission;
/// <summary>
/// Well-known action name constants for audit event annotations, organized by module.
/// Use these instead of magic strings when calling
/// <see cref="AuditedRouteGroupExtensions.Audited"/> or constructing
/// <see cref="AuditActionAttribute"/> instances.
/// </summary>
public static class AuditActions
{
/// <summary>Actions for the Authority (OAuth/OIDC) module.</summary>
public static class Authority
{
public const string Bootstrap = "bootstrap";
public const string Seed = "seed";
public const string CreateUser = "create_user";
public const string UpdateUser = "update_user";
public const string LockUser = "lock_user";
public const string UnlockUser = "unlock_user";
public const string CreateTenant = "create_tenant";
public const string UpdateTenant = "update_tenant";
public const string SuspendTenant = "suspend_tenant";
public const string ResumeTenant = "resume_tenant";
public const string CreateRole = "create_role";
public const string UpdateRole = "update_role";
public const string CreateClient = "create_client";
public const string UpdateClient = "update_client";
public const string RotateClientSecret = "rotate_client_secret";
public const string RevokeToken = "revoke_token";
public const string RotateSigningKey = "rotate_signing_key";
public const string RotateAckTokenKey = "rotate_ack_token_key";
public const string ReloadPlugins = "reload_plugins";
public const string CreateVulnPermalink = "create_vuln_permalink";
public const string IssueAckToken = "issue_ack_token";
public const string IssueVulnCsrfToken = "issue_vuln_csrf_token";
public const string IssueAttachmentToken = "issue_attachment_token";
public const string LogRemoteInference = "log_remote_inference";
public const string CreateVulnTicket = "create_vuln_ticket";
public const string UpdateBranding = "update_branding";
public const string RecordAirgapAudit = "record_airgap_audit";
}
/// <summary>Actions for the Policy module.</summary>
public static class Policy
{
public const string EnableSealed = "enable_sealed";
public const string DisableSealed = "disable_sealed";
public const string CreateRiskProfile = "create_risk_profile";
public const string ActivateRiskProfile = "activate_risk_profile";
public const string DeprecateRiskProfile = "deprecate_risk_profile";
public const string ArchiveRiskProfile = "archive_risk_profile";
public const string UpdateRiskProfile = "update_risk_profile";
public const string DeleteRiskProfile = "delete_risk_profile";
public const string CreateSnapshot = "create_snapshot";
public const string CreatePack = "create_pack";
public const string CreateRevision = "create_revision";
public const string ActivateRevision = "activate_revision";
public const string CreateOverride = "create_override";
public const string DeleteOverride = "delete_override";
public const string ApproveOverride = "approve_override";
public const string DisableOverride = "disable_override";
public const string EnableShadowMode = "enable_shadow_mode";
public const string DisableShadowMode = "disable_shadow_mode";
public const string CreateSimulation = "create_simulation";
public const string UpdateSimulation = "update_simulation";
public const string CancelSimulation = "cancel_simulation";
public const string BypassGate = "bypass_gate";
public const string CreateException = "create_exception";
public const string UpdateException = "update_exception";
public const string RevokeException = "revoke_exception";
public const string ApproveException = "approve_exception";
public const string RejectException = "reject_exception";
public const string CancelException = "cancel_exception";
public const string ActivateException = "activate_exception";
public const string ExtendException = "extend_exception";
public const string ResolveConflict = "resolve_conflict";
public const string DismissConflict = "dismiss_conflict";
public const string CreateBatchEvaluation = "create_batch_evaluation";
public const string CancelBatchEvaluation = "cancel_batch_evaluation";
public const string ToggleSealedMode = "toggle_sealed_mode";
public const string EmergencyUnseal = "emergency_unseal";
public const string RevokeSealedOverride = "revoke_sealed_override";
public const string UpdateTrustWeight = "update_trust_weight";
public const string DeleteTrustWeight = "delete_trust_weight";
public const string UpdateStalenessConfig = "update_staleness_config";
public const string CreateEffectivePolicy = "create_effective_policy";
public const string UpdateEffectivePolicy = "update_effective_policy";
public const string DeleteEffectivePolicy = "delete_effective_policy";
public const string GrantScope = "grant_scope";
public const string RevokeScope = "revoke_scope";
public const string CreateConflict = "create_conflict";
public const string CreateDeltaSnapshot = "create_delta_snapshot";
public const string EvaluateGate = "evaluate_gate";
}
/// <summary>Actions for the Scanner module.</summary>
public static class Scanner
{
public const string Submit = "submit";
public const string SubmitSbom = "submit_sbom";
public const string Upload = "upload";
public const string Validate = "validate";
public const string Verify = "verify";
public const string Import = "import";
public const string Create = "create";
public const string Update = "update";
public const string Delete = "delete";
public const string Test = "test";
public const string Pause = "pause";
public const string Resume = "resume";
public const string Activate = "activate";
public const string TriggerScan = "trigger_scan";
public const string Revoke = "revoke";
public const string ReceiveWebhook = "receive_webhook";
public const string UpdateStatus = "update_status";
public const string SubmitVex = "submit_vex";
public const string BulkQuery = "bulk_query";
public const string GenerateProof = "generate_proof";
public const string UploadSarif = "upload_sarif";
public const string Analyze = "analyze";
public const string Upgrade = "upgrade";
public const string BatchAction = "batch_action";
public const string Review = "review";
public const string Replay = "replay";
public const string Compute = "compute";
public const string Ingest = "ingest";
public const string Reconcile = "reconcile";
public const string AttachEntropy = "attach_entropy";
public const string AttachReplay = "attach_replay";
}
/// <summary>Actions for the Findings module.</summary>
public static class Findings
{
public const string CreateLedgerEvent = "create_ledger_event";
public const string CreateAttestationPointer = "create_attestation_pointer";
public const string UpdateAttestationPointer = "update_attestation_pointer";
public const string CreateSnapshot = "create_snapshot";
public const string DeleteSnapshot = "delete_snapshot";
public const string CreateAlertDecision = "create_alert_decision";
public const string VerifyAlertBundle = "verify_alert_bundle";
public const string RegisterVexIssuer = "register_vex_issuer";
public const string TransitionFindingState = "transition_finding_state";
public const string CreateWebhook = "create_webhook";
public const string UpdateWebhook = "update_webhook";
public const string DeleteWebhook = "delete_webhook";
public const string CreateVexDecision = "create_vex_decision";
public const string UpdateVexDecision = "update_vex_decision";
public const string CreateFixVerification = "create_fix_verification";
public const string UpdateFixVerification = "update_fix_verification";
public const string CreateAuditBundle = "create_audit_bundle";
public const string CalculateScore = "calculate_score";
public const string IngestRuntimeTrace = "ingest_runtime_trace";
}
/// <summary>Actions for the Release Orchestrator module.</summary>
public static class Release
{
public const string CreateRelease = "create_release";
public const string UpdateRelease = "update_release";
public const string DeleteRelease = "delete_release";
public const string MarkReady = "mark_ready";
public const string PromoteRelease = "promote_release";
public const string DeployRelease = "deploy_release";
public const string RollbackRelease = "rollback_release";
public const string CloneRelease = "clone_release";
public const string AddComponent = "add_component";
public const string UpdateComponent = "update_component";
public const string RemoveComponent = "remove_component";
public const string ApprovePromotion = "approve_promotion";
public const string RejectPromotion = "reject_promotion";
public const string ApprovalDecision = "approval_decision";
public const string RollbackRun = "rollback_run";
public const string VerifyEvidence = "verify_evidence";
public const string CreateDeployment = "create_deployment";
public const string PauseDeployment = "pause_deployment";
public const string ResumeDeployment = "resume_deployment";
public const string CancelDeployment = "cancel_deployment";
public const string RollbackDeployment = "rollback_deployment";
public const string RetryTarget = "retry_target";
public const string ApproveRelease = "approve_release";
public const string RejectRelease = "reject_release";
public const string BatchApprove = "batch_approve";
public const string BatchReject = "batch_reject";
public const string CreateScript = "create_script";
public const string UpdateScript = "update_script";
public const string DeleteScript = "delete_script";
}
/// <summary>Actions for the Scheduler (JobEngine) module.</summary>
public static class Scheduler
{
public const string CreateSchedule = "create_schedule";
public const string UpdateSchedule = "update_schedule";
public const string DeleteSchedule = "delete_schedule";
public const string PauseSchedule = "pause_schedule";
public const string ResumeSchedule = "resume_schedule";
public const string CreateRun = "create_run";
public const string CancelRun = "cancel_run";
public const string RetryRun = "retry_run";
public const string CreatePolicyRun = "create_policy_run";
public const string CreateGraphBuild = "create_graph_build";
public const string CreateGraphOverlay = "create_graph_overlay";
}
/// <summary>Actions for the Notify module.</summary>
public static class Notify
{
public const string CreateTemplate = "create_template";
public const string UpdateTemplate = "update_template";
public const string DeleteTemplate = "delete_template";
public const string CreateRule = "create_rule";
public const string UpdateRule = "update_rule";
public const string DeleteRule = "delete_rule";
public const string CreateQuietHours = "create_quiet_hours";
public const string UpdateQuietHours = "update_quiet_hours";
public const string DeleteQuietHours = "delete_quiet_hours";
public const string CreateOverride = "create_override";
public const string RevokeOverride = "revoke_override";
public const string UpdateThrottleConfig = "update_throttle_config";
public const string DeleteThrottleConfig = "delete_throttle_config";
public const string AcknowledgeIncident = "acknowledge_incident";
public const string ResolveIncident = "resolve_incident";
public const string CreateEscalationPolicy = "create_escalation_policy";
public const string UpdateEscalationPolicy = "update_escalation_policy";
public const string DeleteEscalationPolicy = "delete_escalation_policy";
public const string CreateOncallSchedule = "create_oncall_schedule";
public const string UpdateOncallSchedule = "update_oncall_schedule";
public const string DeleteOncallSchedule = "delete_oncall_schedule";
public const string CreateOncallOverride = "create_oncall_override";
public const string DeleteOncallOverride = "delete_oncall_override";
public const string StartEscalation = "start_escalation";
public const string ManualEscalate = "manual_escalate";
public const string StopEscalation = "stop_escalation";
}
/// <summary>Actions for the Evidence Locker module.</summary>
public static class Evidence
{
public const string Store = "store";
public const string Snapshot = "snapshot";
public const string Verify = "verify";
public const string Hold = "hold";
}
/// <summary>Actions for the Attestor (Signer) module.</summary>
public static class Attestor
{
public const string SignDsse = "sign_dsse";
public const string VerifyDsse = "verify_dsse";
public const string AddKey = "add_key";
public const string RevokeKey = "revoke_key";
public const string CreateCeremony = "create_ceremony";
public const string ApproveCeremony = "approve_ceremony";
public const string ExecuteCeremony = "execute_ceremony";
public const string CancelCeremony = "cancel_ceremony";
}
/// <summary>Actions for the Integrations module.</summary>
public static class Integrations
{
public const string Create = "create";
public const string Update = "update";
public const string Delete = "delete";
public const string Test = "test";
public const string Discover = "discover";
public const string RunCodeGuard = "run_code_guard";
}
/// <summary>Actions for the Platform module.</summary>
public static class Platform
{
public const string CreateSetupSession = "create_setup_session";
public const string ExecuteSetupStep = "execute_setup_step";
public const string SkipSetupStep = "skip_setup_step";
public const string FinalizeSetup = "finalize_setup";
public const string CreateScript = "create_script";
public const string UpdateScript = "update_script";
public const string DeleteScript = "delete_script";
public const string EvaluateScore = "evaluate_score";
public const string VerifyScore = "verify_score";
public const string CreateEnvironment = "create_environment";
public const string UpdateEnvironment = "update_environment";
public const string DeleteEnvironment = "delete_environment";
public const string UpdateEnvironmentSettings = "update_environment_settings";
public const string CreateTarget = "create_target";
public const string UpdateTarget = "update_target";
public const string DeleteTarget = "delete_target";
public const string CreateFreezeWindow = "create_freeze_window";
public const string UpdateFreezeWindow = "update_freeze_window";
public const string DeleteFreezeWindow = "delete_freeze_window";
public const string CreateQuotaAlert = "create_quota_alert";
public const string CompleteOnboardingStep = "complete_onboarding_step";
public const string SkipOnboarding = "skip_onboarding";
public const string UpdateDashboardPreferences = "update_dashboard_preferences";
public const string UpdateLanguagePreference = "update_language_preference";
public const string UpdateEmailPreference = "update_email_preference";
public const string CreateDashboardProfile = "create_dashboard_profile";
public const string CreateIdentityProvider = "create_identity_provider";
public const string UpdateIdentityProvider = "update_identity_provider";
public const string DeleteIdentityProvider = "delete_identity_provider";
public const string EnableIdentityProvider = "enable_identity_provider";
public const string DisableIdentityProvider = "disable_identity_provider";
public const string TestIdentityProvider = "test_identity_provider";
public const string ApplyIdentityProvider = "apply_identity_provider";
public const string UpdateEnvironmentSetting = "update_environment_setting";
public const string DeleteEnvironmentSetting = "delete_environment_setting";
public const string UpdateCryptoPreference = "update_crypto_preference";
public const string DeleteCryptoPreference = "delete_crypto_preference";
public const string CreateTrustKey = "create_trust_key";
public const string RotateTrustKey = "rotate_trust_key";
public const string RevokeTrustKey = "revoke_trust_key";
public const string RegisterTrustIssuer = "register_trust_issuer";
public const string BlockTrustIssuer = "block_trust_issuer";
public const string UnblockTrustIssuer = "unblock_trust_issuer";
public const string RegisterTrustCertificate = "register_trust_certificate";
public const string RevokeTrustCertificate = "revoke_trust_certificate";
public const string ConfigureTransparencyLog = "configure_transparency_log";
}
/// <summary>Actions for the Doctor module.</summary>
public static class Doctor
{
public const string StartRun = "start_run";
public const string Diagnose = "diagnose";
public const string DeleteReport = "delete_report";
}
/// <summary>Actions for the Signals module.</summary>
public static class Signals
{
public const string IngestCallgraph = "ingest_callgraph";
public const string IngestRuntimeFact = "ingest_runtime_fact";
public const string ComputeReachability = "compute_reachability";
public const string IngestUnknowns = "ingest_unknowns";
public const string SubmitExecutionEvidence = "submit_execution_evidence";
public const string RegisterBeacon = "register_beacon";
}
/// <summary>Actions for the Advisory AI module.</summary>
public static class AdvisoryAi
{
public const string CreateRun = "create_run";
public const string RecordDecision = "record_decision";
public const string RecordOutcome = "record_outcome";
}
/// <summary>Actions for the Risk Engine module.</summary>
public static class RiskEngine
{
public const string CreateScoreJob = "create_score_job";
public const string CreateSimulation = "create_simulation";
}
}

View File

@@ -27,4 +27,18 @@ public sealed class AuditEmissionOptions
/// Default: 3 seconds. Audit emission should be fast and non-blocking.
/// </summary>
public int TimeoutSeconds { get; set; } = 3;
/// <summary>
/// Maximum request body size (in bytes) to capture in audit events.
/// Bodies larger than this limit are truncated with a marker.
/// Default: 65536 (64 KB).
/// </summary>
public int MaxBodySizeBytes { get; set; } = 65_536;
/// <summary>
/// Field name patterns for PII redaction. When set, these replace
/// <see cref="AuditPiiRedactor.DefaultRedactedPatterns"/> entirely.
/// Each entry is matched case-insensitively against JSON property names.
/// </summary>
public List<string>? RedactedFieldPatterns { get; set; }
}

View File

@@ -53,6 +53,17 @@ public static class AuditEmissionServiceExtensions
{
options.TimeoutSeconds = timeout;
}
if (int.TryParse(configuration["AuditEmission:MaxBodySizeBytes"], out var maxBody) && maxBody > 0)
{
options.MaxBodySizeBytes = maxBody;
}
var redactedPatterns = configuration.GetSection("AuditEmission:RedactedFieldPatterns").Get<List<string>>();
if (redactedPatterns is { Count: > 0 })
{
options.RedactedFieldPatterns = redactedPatterns;
}
});
services.AddHttpClient(HttpAuditEventEmitter.HttpClientName, (provider, client) =>

View File

@@ -0,0 +1,28 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
namespace StellaOps.Audit.Emission;
/// <summary>
/// Well-known module name constants for audit event annotations.
/// Use these instead of magic strings when calling
/// <see cref="AuditedRouteGroupExtensions.Audited"/> or constructing
/// <see cref="AuditActionAttribute"/> instances.
/// </summary>
public static class AuditModules
{
public const string Authority = "authority";
public const string Policy = "policy";
public const string Scanner = "scanner";
public const string Findings = "findings";
public const string Release = "release";
public const string Scheduler = "scheduler";
public const string Notify = "notify";
public const string Evidence = "evidence";
public const string Attestor = "attestor";
public const string Integrations = "integrations";
public const string Platform = "platform";
public const string Doctor = "doctor";
public const string Signals = "signals";
public const string AdvisoryAi = "advisory-ai";
public const string RiskEngine = "riskengine";
}

View File

@@ -0,0 +1,164 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace StellaOps.Audit.Emission;
/// <summary>
/// Recursively walks a JSON structure and replaces values whose keys match
/// configurable PII/secret patterns with <c>"[REDACTED]"</c>.
/// </summary>
public static class AuditPiiRedactor
{
/// <summary>
/// Default field name patterns that are always redacted.
/// Matching is case-insensitive and uses <c>Contains</c> semantics.
/// </summary>
public static readonly IReadOnlyList<string> DefaultRedactedPatterns =
[
"password",
"secret",
"token",
"apikey",
"connectionstring",
"credential",
"privatekey",
"signingkey"
];
private const string RedactedValue = "[REDACTED]";
/// <summary>
/// Redacts sensitive fields in a parsed <see cref="JsonNode"/> tree.
/// Returns a new tree; the original is not mutated.
/// </summary>
/// <param name="node">The JSON node to redact. May be null.</param>
/// <param name="additionalPatterns">
/// Extra field-name patterns to redact beyond <see cref="DefaultRedactedPatterns"/>.
/// </param>
/// <param name="configuredPatterns">
/// Patterns from <see cref="AuditEmissionOptions.RedactedFieldPatterns"/>.
/// When non-null these replace the defaults entirely.
/// </param>
/// <returns>A redacted copy, or null if the input was null.</returns>
public static JsonNode? Redact(
JsonNode? node,
IReadOnlyList<string>? additionalPatterns = null,
IReadOnlyList<string>? configuredPatterns = null)
{
if (node is null)
{
return null;
}
var patterns = BuildPatternSet(additionalPatterns, configuredPatterns);
return RedactNode(node, patterns);
}
/// <summary>
/// Convenience overload that accepts raw JSON bytes, redacts, and returns
/// the redacted <see cref="JsonNode"/>.
/// </summary>
public static JsonNode? Redact(
ReadOnlySpan<byte> utf8Json,
IReadOnlyList<string>? additionalPatterns = null,
IReadOnlyList<string>? configuredPatterns = null)
{
var node = JsonNode.Parse(utf8Json);
return Redact(node, additionalPatterns, configuredPatterns);
}
private static HashSet<string> BuildPatternSet(
IReadOnlyList<string>? additionalPatterns,
IReadOnlyList<string>? configuredPatterns)
{
var basePatterns = configuredPatterns ?? DefaultRedactedPatterns;
var set = new HashSet<string>(basePatterns.Count + (additionalPatterns?.Count ?? 0), StringComparer.OrdinalIgnoreCase);
foreach (var p in basePatterns)
{
set.Add(NormalizePattern(p));
}
if (additionalPatterns is not null)
{
foreach (var p in additionalPatterns)
{
set.Add(NormalizePattern(p));
}
}
return set;
}
private static string NormalizePattern(string pattern)
{
// Strip non-alphanumeric chars so "api_key", "apiKey", "api-key" all match "apikey"
return Regex.Replace(pattern, @"[^a-zA-Z0-9]", "", RegexOptions.None, TimeSpan.FromMilliseconds(100))
.ToLowerInvariant();
}
private static string NormalizeFieldName(string fieldName)
{
return Regex.Replace(fieldName, @"[^a-zA-Z0-9]", "", RegexOptions.None, TimeSpan.FromMilliseconds(100))
.ToLowerInvariant();
}
private static bool ShouldRedact(string fieldName, HashSet<string> patterns)
{
var normalized = NormalizeFieldName(fieldName);
foreach (var pattern in patterns)
{
if (normalized.Contains(pattern, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static JsonNode? RedactNode(JsonNode node, HashSet<string> patterns)
{
switch (node)
{
case JsonObject obj:
var result = new JsonObject();
foreach (var kvp in obj)
{
if (ShouldRedact(kvp.Key, patterns))
{
result[kvp.Key] = JsonValue.Create(RedactedValue);
}
else if (kvp.Value is null)
{
result[kvp.Key] = null;
}
else
{
result[kvp.Key] = RedactNode(kvp.Value, patterns);
}
}
return result;
case JsonArray arr:
var resultArr = new JsonArray();
foreach (var item in arr)
{
resultArr.Add(item is null ? null : RedactNode(item, patterns));
}
return resultArr;
case JsonValue val:
// Deep-copy primitive values
return JsonNode.Parse(val.ToJsonString());
default:
return JsonNode.Parse(node.ToJsonString());
}
}
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
namespace StellaOps.Audit.Emission;
/// <summary>
/// Optional service interface that captures the "before" state of a resource
/// prior to endpoint execution, enabling before/after diff in audit events.
/// <para>
/// Services register implementations in DI. The <see cref="AuditActionFilter"/>
/// finds the provider whose <see cref="Module"/> matches the audited endpoint's module
/// and calls <see cref="GetBeforeStateAsync"/> before the endpoint executes.
/// If no provider is registered or it throws, the before-state is simply omitted.
/// </para>
/// </summary>
public interface IAuditBeforeStateProvider
{
/// <summary>
/// The module this provider handles (e.g., "scanner", "release").
/// Must match the module string on the <see cref="AuditActionAttribute"/>.
/// </summary>
string Module { get; }
/// <summary>
/// Retrieves the current state of a resource before the endpoint mutates it.
/// </summary>
/// <param name="resourceType">The type of resource (e.g., "environment", "scan_policy").</param>
/// <param name="resourceId">The raw resource identifier (usually a GUID string).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// A dictionary of key/value pairs representing the resource's current state,
/// or null if the resource does not exist or state capture is not applicable.
/// </returns>
ValueTask<Dictionary<string, object?>?> GetBeforeStateAsync(string resourceType, string resourceId, CancellationToken ct);
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
namespace StellaOps.Audit.Emission;
/// <summary>
/// Optional service interface that enriches audit resource payloads by resolving
/// a raw resource ID (typically a GUID) into a human-readable name.
/// <para>
/// Services register implementations in DI. The <see cref="AuditActionFilter"/>
/// finds the enricher whose <see cref="Module"/> matches the audited endpoint's module
/// and calls <see cref="EnrichAsync"/> in a fire-and-forget fashion.
/// If no enricher is registered or it throws, the filter falls back to the raw ID.
/// </para>
/// </summary>
public interface IAuditResourceEnricher
{
/// <summary>
/// The module this enricher handles (e.g., "scanner", "release").
/// Must match the module string on the <see cref="AuditActionAttribute"/>.
/// </summary>
string Module { get; }
/// <summary>
/// Resolves a resource ID to a human-readable payload containing the display name.
/// </summary>
/// <param name="resourceType">The type of resource (e.g., "environment", "scan_policy").</param>
/// <param name="resourceId">The raw resource identifier (usually a GUID string).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// An <see cref="AuditResourcePayload"/> with the <see cref="AuditResourcePayload.Name"/>
/// populated. Returns a payload with the original ID and no name if resolution fails.
/// </returns>
ValueTask<AuditResourcePayload> EnrichAsync(string resourceType, string resourceId, CancellationToken ct);
}