using System.Collections.Immutable; using System.Security.Cryptography; using System.Text; namespace StellaOps.Policy.Engine.IncrementalOrchestrator; /// /// Types of policy-relevant changes that trigger re-evaluation. /// public enum PolicyChangeType { /// Advisory was created or updated. AdvisoryUpdated, /// Advisory was retracted/withdrawn. AdvisoryRetracted, /// VEX statement was added or modified. VexStatementUpdated, /// VEX conflict detected. VexConflictDetected, /// SBOM was ingested or updated. SbomUpdated, /// SBOM component changed. SbomComponentChanged, /// Policy version was published. PolicyVersionPublished, /// Manual re-evaluation triggered. ManualTrigger } /// /// Priority levels for change processing. /// public enum PolicyChangePriority { /// Normal priority - standard processing. Normal = 0, /// High priority - process sooner. High = 1, /// Emergency - immediate processing (e.g., KEV addition). Emergency = 2 } /// /// Represents a change event that may trigger policy re-evaluation. /// public sealed record PolicyChangeEvent { /// /// Unique event identifier (deterministic based on content). /// public required string EventId { get; init; } /// /// Type of change. /// public required PolicyChangeType ChangeType { get; init; } /// /// Tenant context for the change. /// public required string TenantId { get; init; } /// /// Timestamp when the change occurred (from source system). /// public required DateTimeOffset OccurredAt { get; init; } /// /// Timestamp when the event was created. /// public required DateTimeOffset CreatedAt { get; init; } /// /// Processing priority. /// public required PolicyChangePriority Priority { get; init; } /// /// Source system that produced the change. /// public required string Source { get; init; } /// /// Correlation ID for tracing. /// public string? CorrelationId { get; init; } /// /// Advisory ID (for advisory/VEX changes). /// public string? AdvisoryId { get; init; } /// /// Vulnerability ID (CVE, GHSA, etc.). /// public string? VulnerabilityId { get; init; } /// /// Affected PURLs (package URLs). /// public ImmutableArray AffectedPurls { get; init; } = ImmutableArray.Empty; /// /// Affected product keys (for SBOM targeting). /// public ImmutableArray AffectedProductKeys { get; init; } = ImmutableArray.Empty; /// /// Affected SBOM IDs (for direct targeting). /// public ImmutableArray AffectedSbomIds { get; init; } = ImmutableArray.Empty; /// /// Policy IDs to re-evaluate (empty = all applicable). /// public ImmutableArray PolicyIds { get; init; } = ImmutableArray.Empty; /// /// Additional metadata for the change. /// public ImmutableDictionary Metadata { get; init; } = ImmutableDictionary.Empty; /// /// Content hash for deduplication. /// public required string ContentHash { get; init; } /// /// Computes a deterministic content hash for deduplication. /// public static string ComputeContentHash( PolicyChangeType changeType, string tenantId, string? advisoryId, string? vulnerabilityId, IEnumerable? affectedPurls, IEnumerable? affectedProductKeys, IEnumerable? affectedSbomIds) { var builder = new StringBuilder(); builder.Append(changeType.ToString()).Append('|'); builder.Append(tenantId.ToLowerInvariant()).Append('|'); builder.Append(advisoryId ?? string.Empty).Append('|'); builder.Append(vulnerabilityId ?? string.Empty).Append('|'); // Deterministic ordering var purls = (affectedPurls ?? Enumerable.Empty()) .Where(p => !string.IsNullOrWhiteSpace(p)) .Select(p => p.Trim()) .OrderBy(p => p, StringComparer.Ordinal); var productKeys = (affectedProductKeys ?? Enumerable.Empty()) .Where(k => !string.IsNullOrWhiteSpace(k)) .Select(k => k.Trim()) .OrderBy(k => k, StringComparer.Ordinal); var sbomIds = (affectedSbomIds ?? Enumerable.Empty()) .Where(s => !string.IsNullOrWhiteSpace(s)) .Select(s => s.Trim()) .OrderBy(s => s, StringComparer.Ordinal); foreach (var purl in purls) { builder.Append("purl:").Append(purl).Append('|'); } foreach (var key in productKeys) { builder.Append("pk:").Append(key).Append('|'); } foreach (var id in sbomIds) { builder.Append("sbom:").Append(id).Append('|'); } var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString())); return Convert.ToHexStringLower(bytes); } /// /// Creates a deterministic event ID. /// public static string CreateEventId( string tenantId, PolicyChangeType changeType, string source, DateTimeOffset occurredAt, string contentHash) { var seed = $"{tenantId}|{changeType}|{source}|{occurredAt:O}|{contentHash}"; var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(seed)); return $"pce-{Convert.ToHexStringLower(bytes)[..16]}"; } } /// /// Factory for creating normalized policy change events. /// public static class PolicyChangeEventFactory { /// /// Creates an advisory update event. /// public static PolicyChangeEvent CreateAdvisoryUpdated( string tenantId, string advisoryId, string? vulnerabilityId, IEnumerable affectedPurls, string source, DateTimeOffset occurredAt, DateTimeOffset createdAt, PolicyChangePriority priority = PolicyChangePriority.Normal, string? correlationId = null, ImmutableDictionary? metadata = null) { var normalizedTenant = NormalizeTenant(tenantId); var normalizedAdvisoryId = Normalize(advisoryId, nameof(advisoryId)); var normalizedVulnId = vulnerabilityId?.Trim(); var normalizedPurls = NormalizePurls(affectedPurls); var contentHash = PolicyChangeEvent.ComputeContentHash( PolicyChangeType.AdvisoryUpdated, normalizedTenant, normalizedAdvisoryId, normalizedVulnId, normalizedPurls, null, null); var eventId = PolicyChangeEvent.CreateEventId( normalizedTenant, PolicyChangeType.AdvisoryUpdated, source, occurredAt, contentHash); return new PolicyChangeEvent { EventId = eventId, ChangeType = PolicyChangeType.AdvisoryUpdated, TenantId = normalizedTenant, OccurredAt = occurredAt, CreatedAt = createdAt, Priority = priority, Source = source, CorrelationId = correlationId, AdvisoryId = normalizedAdvisoryId, VulnerabilityId = normalizedVulnId, AffectedPurls = normalizedPurls, ContentHash = contentHash, Metadata = metadata ?? ImmutableDictionary.Empty }; } /// /// Creates a VEX statement update event. /// public static PolicyChangeEvent CreateVexUpdated( string tenantId, string vulnerabilityId, IEnumerable affectedProductKeys, string source, DateTimeOffset occurredAt, DateTimeOffset createdAt, PolicyChangePriority priority = PolicyChangePriority.Normal, string? correlationId = null, ImmutableDictionary? metadata = null) { var normalizedTenant = NormalizeTenant(tenantId); var normalizedVulnId = Normalize(vulnerabilityId, nameof(vulnerabilityId)); var normalizedKeys = NormalizeProductKeys(affectedProductKeys); var contentHash = PolicyChangeEvent.ComputeContentHash( PolicyChangeType.VexStatementUpdated, normalizedTenant, null, normalizedVulnId, null, normalizedKeys, null); var eventId = PolicyChangeEvent.CreateEventId( normalizedTenant, PolicyChangeType.VexStatementUpdated, source, occurredAt, contentHash); return new PolicyChangeEvent { EventId = eventId, ChangeType = PolicyChangeType.VexStatementUpdated, TenantId = normalizedTenant, OccurredAt = occurredAt, CreatedAt = createdAt, Priority = priority, Source = source, CorrelationId = correlationId, VulnerabilityId = normalizedVulnId, AffectedProductKeys = normalizedKeys, ContentHash = contentHash, Metadata = metadata ?? ImmutableDictionary.Empty }; } /// /// Creates an SBOM update event. /// public static PolicyChangeEvent CreateSbomUpdated( string tenantId, string sbomId, string productKey, IEnumerable componentPurls, string source, DateTimeOffset occurredAt, DateTimeOffset createdAt, PolicyChangePriority priority = PolicyChangePriority.Normal, string? correlationId = null, ImmutableDictionary? metadata = null) { var normalizedTenant = NormalizeTenant(tenantId); var normalizedSbomId = Normalize(sbomId, nameof(sbomId)); var normalizedProductKey = Normalize(productKey, nameof(productKey)); var normalizedPurls = NormalizePurls(componentPurls); var contentHash = PolicyChangeEvent.ComputeContentHash( PolicyChangeType.SbomUpdated, normalizedTenant, null, null, normalizedPurls, ImmutableArray.Create(normalizedProductKey), ImmutableArray.Create(normalizedSbomId)); var eventId = PolicyChangeEvent.CreateEventId( normalizedTenant, PolicyChangeType.SbomUpdated, source, occurredAt, contentHash); return new PolicyChangeEvent { EventId = eventId, ChangeType = PolicyChangeType.SbomUpdated, TenantId = normalizedTenant, OccurredAt = occurredAt, CreatedAt = createdAt, Priority = priority, Source = source, CorrelationId = correlationId, AffectedPurls = normalizedPurls, AffectedProductKeys = ImmutableArray.Create(normalizedProductKey), AffectedSbomIds = ImmutableArray.Create(normalizedSbomId), ContentHash = contentHash, Metadata = metadata ?? ImmutableDictionary.Empty }; } /// /// Creates a manual trigger event. /// public static PolicyChangeEvent CreateManualTrigger( string tenantId, IEnumerable? policyIds, IEnumerable? sbomIds, IEnumerable? productKeys, string requestedBy, DateTimeOffset createdAt, PolicyChangePriority priority = PolicyChangePriority.Normal, string? correlationId = null, ImmutableDictionary? metadata = null) { var normalizedTenant = NormalizeTenant(tenantId); var normalizedPolicyIds = NormalizePolicyIds(policyIds); var normalizedSbomIds = NormalizeSbomIds(sbomIds); var normalizedProductKeys = NormalizeProductKeys(productKeys); var contentHash = PolicyChangeEvent.ComputeContentHash( PolicyChangeType.ManualTrigger, normalizedTenant, null, null, null, normalizedProductKeys, normalizedSbomIds); var eventId = PolicyChangeEvent.CreateEventId( normalizedTenant, PolicyChangeType.ManualTrigger, "manual", createdAt, contentHash); return new PolicyChangeEvent { EventId = eventId, ChangeType = PolicyChangeType.ManualTrigger, TenantId = normalizedTenant, OccurredAt = createdAt, CreatedAt = createdAt, Priority = priority, Source = "manual", CorrelationId = correlationId, PolicyIds = normalizedPolicyIds, AffectedProductKeys = normalizedProductKeys, AffectedSbomIds = normalizedSbomIds, ContentHash = contentHash, Metadata = (metadata ?? ImmutableDictionary.Empty) .SetItem("requestedBy", requestedBy) }; } private static string NormalizeTenant(string tenantId) { if (string.IsNullOrWhiteSpace(tenantId)) { throw new ArgumentException("Tenant ID cannot be null or whitespace", nameof(tenantId)); } return tenantId.Trim().ToLowerInvariant(); } private static string Normalize(string value, string name) { if (string.IsNullOrWhiteSpace(value)) { throw new ArgumentException($"{name} cannot be null or whitespace", name); } return value.Trim(); } private static ImmutableArray NormalizePurls(IEnumerable? purls) { return (purls ?? Enumerable.Empty()) .Where(p => !string.IsNullOrWhiteSpace(p)) .Select(p => p.Trim()) .Distinct(StringComparer.Ordinal) .OrderBy(p => p, StringComparer.Ordinal) .ToImmutableArray(); } private static ImmutableArray NormalizeProductKeys(IEnumerable? keys) { return (keys ?? Enumerable.Empty()) .Where(k => !string.IsNullOrWhiteSpace(k)) .Select(k => k.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(k => k, StringComparer.OrdinalIgnoreCase) .ToImmutableArray(); } private static ImmutableArray NormalizeSbomIds(IEnumerable? ids) { return (ids ?? Enumerable.Empty()) .Where(id => !string.IsNullOrWhiteSpace(id)) .Select(id => id.Trim()) .Distinct(StringComparer.Ordinal) .OrderBy(id => id, StringComparer.Ordinal) .ToImmutableArray(); } private static ImmutableArray NormalizePolicyIds(IEnumerable? ids) { return (ids ?? Enumerable.Empty()) .Where(id => !string.IsNullOrWhiteSpace(id)) .Select(id => id.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(id => id, StringComparer.OrdinalIgnoreCase) .ToImmutableArray(); } } /// /// A batch of change events to be processed together. /// public sealed record PolicyChangeBatch { /// /// Unique batch identifier. /// public required string BatchId { get; init; } /// /// Tenant context. /// public required string TenantId { get; init; } /// /// Events in this batch (deterministically ordered). /// public required ImmutableArray Events { get; init; } /// /// Highest priority in the batch. /// public required PolicyChangePriority Priority { get; init; } /// /// When the batch was created. /// public required DateTimeOffset CreatedAt { get; init; } /// /// Combined affected PURLs from all events. /// public required ImmutableArray AffectedPurls { get; init; } /// /// Combined affected product keys from all events. /// public required ImmutableArray AffectedProductKeys { get; init; } /// /// Combined affected SBOM IDs from all events. /// public required ImmutableArray AffectedSbomIds { get; init; } /// /// Combined vulnerability IDs from all events. /// public required ImmutableArray VulnerabilityIds { get; init; } }