diff --git a/src/__Libraries/StellaOps.Audit.Emission/AuditActionAttribute.cs b/src/__Libraries/StellaOps.Audit.Emission/AuditActionAttribute.cs index be3e10f8c..701f141f2 100644 --- a/src/__Libraries/StellaOps.Audit.Emission/AuditActionAttribute.cs +++ b/src/__Libraries/StellaOps.Audit.Emission/AuditActionAttribute.cs @@ -37,6 +37,20 @@ public sealed class AuditActionAttribute : Attribute /// public string? ResourceType { get; set; } + /// + /// Whether the request body should be captured in the audit event's Details["requestBody"]. + /// Default is true for POST/PUT/PATCH and false for all other methods. + /// Set explicitly to override the default behavior. + /// + public bool? CaptureBody { get; set; } + + /// + /// Additional field names to redact from the request body, beyond the defaults + /// in . + /// Matching is case-insensitive and strips separators (underscores, hyphens). + /// + public string[]? SensitiveFields { get; set; } + /// /// Creates a new audit action attribute. /// @@ -49,4 +63,19 @@ public sealed class AuditActionAttribute : Attribute Module = module; Action = action; } + + /// + /// Resolves whether the body should be captured for the given HTTP method. + /// If is explicitly set, that value wins. + /// Otherwise, defaults to true for POST/PUT/PATCH and false for everything else. + /// + internal bool ShouldCaptureBody(string httpMethod) + { + if (CaptureBody.HasValue) + { + return CaptureBody.Value; + } + + return httpMethod is "POST" or "PUT" or "PATCH"; + } } diff --git a/src/__Libraries/StellaOps.Audit.Emission/AuditActionFilter.cs b/src/__Libraries/StellaOps.Audit.Emission/AuditActionFilter.cs index c139c1e77..c447fbdf3 100644 --- a/src/__Libraries/StellaOps.Audit.Emission/AuditActionFilter.cs +++ b/src/__Libraries/StellaOps.Audit.Emission/AuditActionFilter.cs @@ -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 metadata from the endpoint. /// If the attribute is not present, the filter is a no-op passthrough. /// +/// +/// Enhanced capabilities: +/// +/// Request body capture (JSON, up to configurable max size, PII-redacted) +/// Response resource ID extraction for create operations +/// Before-state capture via +/// Resource name enrichment via +/// +/// /// /// /// Usage in minimal API registration: @@ -27,41 +40,166 @@ public sealed class AuditActionFilter : IEndpointFilter { private readonly IAuditEventEmitter _emitter; private readonly ILogger _logger; + private readonly IOptions _options; - public AuditActionFilter(IAuditEventEmitter emitter, ILogger logger) + public AuditActionFilter( + IAuditEventEmitter emitter, + ILogger logger, + IOptions options) { _emitter = emitter; _logger = logger; + _options = options; } public async ValueTask 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(); 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? 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 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.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)options.RedactedFieldPatterns + : null; + + return AuditPiiRedactor.Redact( + buffer.AsSpan(0, bytesRead), + additionalPatterns: auditAttr.SensitiveFields, + configuredPatterns: configuredPatterns); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to capture request body for audit event"); + return null; + } + } + + private async Task?> CaptureBeforeStateAsync( + HttpContext httpContext, + AuditActionAttribute auditAttr, + string resourceType, + string resourceId) + { + try + { + var providers = httpContext.RequestServices.GetService>(); + 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? 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 BuildAuditEventAsync( HttpContext httpContext, AuditActionAttribute auditAttr, - object? result) + object? result, + JsonNode? capturedBody, + Dictionary? 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 + { + ["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 - { - ["httpMethod"] = request.Method, - ["requestPath"] = request.Path.Value, - ["statusCode"] = response.StatusCode - }, + Details = details, CorrelationId = correlationId, TenantId = tenantId, Tags = [auditAttr.Module.ToLowerInvariant(), auditAttr.Action.ToLowerInvariant()] }; } + /// + /// Backward-compatible static builder for tests that do not require body/enrichment. + /// + 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 TryEnrichResourceAsync( + HttpContext httpContext, + AuditActionAttribute auditAttr, + AuditResourcePayload resource, + ILogger logger) + { + try + { + if (resource.Id == "unknown") + { + return resource; + } + + var enrichers = httpContext.RequestServices.GetService>(); + 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; + } + } + + /// + /// 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. + /// + 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; diff --git a/src/__Libraries/StellaOps.Audit.Emission/AuditActions.cs b/src/__Libraries/StellaOps.Audit.Emission/AuditActions.cs new file mode 100644 index 000000000..5a17269ae --- /dev/null +++ b/src/__Libraries/StellaOps.Audit.Emission/AuditActions.cs @@ -0,0 +1,356 @@ +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. + +namespace StellaOps.Audit.Emission; + +/// +/// Well-known action name constants for audit event annotations, organized by module. +/// Use these instead of magic strings when calling +/// or constructing +/// instances. +/// +public static class AuditActions +{ + /// Actions for the Authority (OAuth/OIDC) module. + 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"; + } + + /// Actions for the Policy module. + 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"; + } + + /// Actions for the Scanner module. + 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"; + } + + /// Actions for the Findings module. + 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"; + } + + /// Actions for the Release Orchestrator module. + 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"; + } + + /// Actions for the Scheduler (JobEngine) module. + 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"; + } + + /// Actions for the Notify module. + 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"; + } + + /// Actions for the Evidence Locker module. + public static class Evidence + { + public const string Store = "store"; + public const string Snapshot = "snapshot"; + public const string Verify = "verify"; + public const string Hold = "hold"; + } + + /// Actions for the Attestor (Signer) module. + 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"; + } + + /// Actions for the Integrations module. + 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"; + } + + /// Actions for the Platform module. + 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"; + } + + /// Actions for the Doctor module. + public static class Doctor + { + public const string StartRun = "start_run"; + public const string Diagnose = "diagnose"; + public const string DeleteReport = "delete_report"; + } + + /// Actions for the Signals module. + 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"; + } + + /// Actions for the Advisory AI module. + public static class AdvisoryAi + { + public const string CreateRun = "create_run"; + public const string RecordDecision = "record_decision"; + public const string RecordOutcome = "record_outcome"; + } + + /// Actions for the Risk Engine module. + public static class RiskEngine + { + public const string CreateScoreJob = "create_score_job"; + public const string CreateSimulation = "create_simulation"; + } +} diff --git a/src/__Libraries/StellaOps.Audit.Emission/AuditEmissionOptions.cs b/src/__Libraries/StellaOps.Audit.Emission/AuditEmissionOptions.cs index 920e4b273..912bda381 100644 --- a/src/__Libraries/StellaOps.Audit.Emission/AuditEmissionOptions.cs +++ b/src/__Libraries/StellaOps.Audit.Emission/AuditEmissionOptions.cs @@ -27,4 +27,18 @@ public sealed class AuditEmissionOptions /// Default: 3 seconds. Audit emission should be fast and non-blocking. /// public int TimeoutSeconds { get; set; } = 3; + + /// + /// 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). + /// + public int MaxBodySizeBytes { get; set; } = 65_536; + + /// + /// Field name patterns for PII redaction. When set, these replace + /// entirely. + /// Each entry is matched case-insensitively against JSON property names. + /// + public List? RedactedFieldPatterns { get; set; } } diff --git a/src/__Libraries/StellaOps.Audit.Emission/AuditEmissionServiceExtensions.cs b/src/__Libraries/StellaOps.Audit.Emission/AuditEmissionServiceExtensions.cs index 30422e5c8..5403a2679 100644 --- a/src/__Libraries/StellaOps.Audit.Emission/AuditEmissionServiceExtensions.cs +++ b/src/__Libraries/StellaOps.Audit.Emission/AuditEmissionServiceExtensions.cs @@ -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>(); + if (redactedPatterns is { Count: > 0 }) + { + options.RedactedFieldPatterns = redactedPatterns; + } }); services.AddHttpClient(HttpAuditEventEmitter.HttpClientName, (provider, client) => diff --git a/src/__Libraries/StellaOps.Audit.Emission/AuditModules.cs b/src/__Libraries/StellaOps.Audit.Emission/AuditModules.cs new file mode 100644 index 000000000..98768b06d --- /dev/null +++ b/src/__Libraries/StellaOps.Audit.Emission/AuditModules.cs @@ -0,0 +1,28 @@ +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. + +namespace StellaOps.Audit.Emission; + +/// +/// Well-known module name constants for audit event annotations. +/// Use these instead of magic strings when calling +/// or constructing +/// instances. +/// +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"; +} diff --git a/src/__Libraries/StellaOps.Audit.Emission/AuditPiiRedactor.cs b/src/__Libraries/StellaOps.Audit.Emission/AuditPiiRedactor.cs new file mode 100644 index 000000000..db4474b29 --- /dev/null +++ b/src/__Libraries/StellaOps.Audit.Emission/AuditPiiRedactor.cs @@ -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; + +/// +/// Recursively walks a JSON structure and replaces values whose keys match +/// configurable PII/secret patterns with "[REDACTED]". +/// +public static class AuditPiiRedactor +{ + /// + /// Default field name patterns that are always redacted. + /// Matching is case-insensitive and uses Contains semantics. + /// + public static readonly IReadOnlyList DefaultRedactedPatterns = + [ + "password", + "secret", + "token", + "apikey", + "connectionstring", + "credential", + "privatekey", + "signingkey" + ]; + + private const string RedactedValue = "[REDACTED]"; + + /// + /// Redacts sensitive fields in a parsed tree. + /// Returns a new tree; the original is not mutated. + /// + /// The JSON node to redact. May be null. + /// + /// Extra field-name patterns to redact beyond . + /// + /// + /// Patterns from . + /// When non-null these replace the defaults entirely. + /// + /// A redacted copy, or null if the input was null. + public static JsonNode? Redact( + JsonNode? node, + IReadOnlyList? additionalPatterns = null, + IReadOnlyList? configuredPatterns = null) + { + if (node is null) + { + return null; + } + + var patterns = BuildPatternSet(additionalPatterns, configuredPatterns); + return RedactNode(node, patterns); + } + + /// + /// Convenience overload that accepts raw JSON bytes, redacts, and returns + /// the redacted . + /// + public static JsonNode? Redact( + ReadOnlySpan utf8Json, + IReadOnlyList? additionalPatterns = null, + IReadOnlyList? configuredPatterns = null) + { + var node = JsonNode.Parse(utf8Json); + return Redact(node, additionalPatterns, configuredPatterns); + } + + private static HashSet BuildPatternSet( + IReadOnlyList? additionalPatterns, + IReadOnlyList? configuredPatterns) + { + var basePatterns = configuredPatterns ?? DefaultRedactedPatterns; + var set = new HashSet(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 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 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()); + } + } +} diff --git a/src/__Libraries/StellaOps.Audit.Emission/IAuditBeforeStateProvider.cs b/src/__Libraries/StellaOps.Audit.Emission/IAuditBeforeStateProvider.cs new file mode 100644 index 000000000..e4d4e1c93 --- /dev/null +++ b/src/__Libraries/StellaOps.Audit.Emission/IAuditBeforeStateProvider.cs @@ -0,0 +1,34 @@ +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. + +namespace StellaOps.Audit.Emission; + +/// +/// Optional service interface that captures the "before" state of a resource +/// prior to endpoint execution, enabling before/after diff in audit events. +/// +/// Services register implementations in DI. The +/// finds the provider whose matches the audited endpoint's module +/// and calls before the endpoint executes. +/// If no provider is registered or it throws, the before-state is simply omitted. +/// +/// +public interface IAuditBeforeStateProvider +{ + /// + /// The module this provider handles (e.g., "scanner", "release"). + /// Must match the module string on the . + /// + string Module { get; } + + /// + /// Retrieves the current state of a resource before the endpoint mutates it. + /// + /// The type of resource (e.g., "environment", "scan_policy"). + /// The raw resource identifier (usually a GUID string). + /// Cancellation token. + /// + /// 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. + /// + ValueTask?> GetBeforeStateAsync(string resourceType, string resourceId, CancellationToken ct); +} diff --git a/src/__Libraries/StellaOps.Audit.Emission/IAuditResourceEnricher.cs b/src/__Libraries/StellaOps.Audit.Emission/IAuditResourceEnricher.cs new file mode 100644 index 000000000..d7144091c --- /dev/null +++ b/src/__Libraries/StellaOps.Audit.Emission/IAuditResourceEnricher.cs @@ -0,0 +1,34 @@ +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. + +namespace StellaOps.Audit.Emission; + +/// +/// Optional service interface that enriches audit resource payloads by resolving +/// a raw resource ID (typically a GUID) into a human-readable name. +/// +/// Services register implementations in DI. The +/// finds the enricher whose matches the audited endpoint's module +/// and calls in a fire-and-forget fashion. +/// If no enricher is registered or it throws, the filter falls back to the raw ID. +/// +/// +public interface IAuditResourceEnricher +{ + /// + /// The module this enricher handles (e.g., "scanner", "release"). + /// Must match the module string on the . + /// + string Module { get; } + + /// + /// Resolves a resource ID to a human-readable payload containing the display name. + /// + /// The type of resource (e.g., "environment", "scan_policy"). + /// The raw resource identifier (usually a GUID string). + /// Cancellation token. + /// + /// An with the + /// populated. Returns a payload with the original ID and no name if resolution fails. + /// + ValueTask EnrichAsync(string resourceType, string resourceId, CancellationToken ct); +}