Files
git.stella-ops.org/src/Policy/StellaOps.Policy.Engine/IncrementalOrchestrator/PolicyChangeEvent.cs
StellaOps Bot 1c6730a1d2
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
up
2025-11-28 00:45:16 +02:00

536 lines
17 KiB
C#

using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Policy.Engine.IncrementalOrchestrator;
/// <summary>
/// Types of policy-relevant changes that trigger re-evaluation.
/// </summary>
public enum PolicyChangeType
{
/// <summary>Advisory was created or updated.</summary>
AdvisoryUpdated,
/// <summary>Advisory was retracted/withdrawn.</summary>
AdvisoryRetracted,
/// <summary>VEX statement was added or modified.</summary>
VexStatementUpdated,
/// <summary>VEX conflict detected.</summary>
VexConflictDetected,
/// <summary>SBOM was ingested or updated.</summary>
SbomUpdated,
/// <summary>SBOM component changed.</summary>
SbomComponentChanged,
/// <summary>Policy version was published.</summary>
PolicyVersionPublished,
/// <summary>Manual re-evaluation triggered.</summary>
ManualTrigger
}
/// <summary>
/// Priority levels for change processing.
/// </summary>
public enum PolicyChangePriority
{
/// <summary>Normal priority - standard processing.</summary>
Normal = 0,
/// <summary>High priority - process sooner.</summary>
High = 1,
/// <summary>Emergency - immediate processing (e.g., KEV addition).</summary>
Emergency = 2
}
/// <summary>
/// Represents a change event that may trigger policy re-evaluation.
/// </summary>
public sealed record PolicyChangeEvent
{
/// <summary>
/// Unique event identifier (deterministic based on content).
/// </summary>
public required string EventId { get; init; }
/// <summary>
/// Type of change.
/// </summary>
public required PolicyChangeType ChangeType { get; init; }
/// <summary>
/// Tenant context for the change.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Timestamp when the change occurred (from source system).
/// </summary>
public required DateTimeOffset OccurredAt { get; init; }
/// <summary>
/// Timestamp when the event was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Processing priority.
/// </summary>
public required PolicyChangePriority Priority { get; init; }
/// <summary>
/// Source system that produced the change.
/// </summary>
public required string Source { get; init; }
/// <summary>
/// Correlation ID for tracing.
/// </summary>
public string? CorrelationId { get; init; }
/// <summary>
/// Advisory ID (for advisory/VEX changes).
/// </summary>
public string? AdvisoryId { get; init; }
/// <summary>
/// Vulnerability ID (CVE, GHSA, etc.).
/// </summary>
public string? VulnerabilityId { get; init; }
/// <summary>
/// Affected PURLs (package URLs).
/// </summary>
public ImmutableArray<string> AffectedPurls { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Affected product keys (for SBOM targeting).
/// </summary>
public ImmutableArray<string> AffectedProductKeys { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Affected SBOM IDs (for direct targeting).
/// </summary>
public ImmutableArray<string> AffectedSbomIds { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Policy IDs to re-evaluate (empty = all applicable).
/// </summary>
public ImmutableArray<string> PolicyIds { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Additional metadata for the change.
/// </summary>
public ImmutableDictionary<string, string> Metadata { get; init; } =
ImmutableDictionary<string, string>.Empty;
/// <summary>
/// Content hash for deduplication.
/// </summary>
public required string ContentHash { get; init; }
/// <summary>
/// Computes a deterministic content hash for deduplication.
/// </summary>
public static string ComputeContentHash(
PolicyChangeType changeType,
string tenantId,
string? advisoryId,
string? vulnerabilityId,
IEnumerable<string>? affectedPurls,
IEnumerable<string>? affectedProductKeys,
IEnumerable<string>? 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<string>())
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => p.Trim())
.OrderBy(p => p, StringComparer.Ordinal);
var productKeys = (affectedProductKeys ?? Enumerable.Empty<string>())
.Where(k => !string.IsNullOrWhiteSpace(k))
.Select(k => k.Trim())
.OrderBy(k => k, StringComparer.Ordinal);
var sbomIds = (affectedSbomIds ?? Enumerable.Empty<string>())
.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);
}
/// <summary>
/// Creates a deterministic event ID.
/// </summary>
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]}";
}
}
/// <summary>
/// Factory for creating normalized policy change events.
/// </summary>
public static class PolicyChangeEventFactory
{
/// <summary>
/// Creates an advisory update event.
/// </summary>
public static PolicyChangeEvent CreateAdvisoryUpdated(
string tenantId,
string advisoryId,
string? vulnerabilityId,
IEnumerable<string> affectedPurls,
string source,
DateTimeOffset occurredAt,
DateTimeOffset createdAt,
PolicyChangePriority priority = PolicyChangePriority.Normal,
string? correlationId = null,
ImmutableDictionary<string, string>? 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<string, string>.Empty
};
}
/// <summary>
/// Creates a VEX statement update event.
/// </summary>
public static PolicyChangeEvent CreateVexUpdated(
string tenantId,
string vulnerabilityId,
IEnumerable<string> affectedProductKeys,
string source,
DateTimeOffset occurredAt,
DateTimeOffset createdAt,
PolicyChangePriority priority = PolicyChangePriority.Normal,
string? correlationId = null,
ImmutableDictionary<string, string>? 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<string, string>.Empty
};
}
/// <summary>
/// Creates an SBOM update event.
/// </summary>
public static PolicyChangeEvent CreateSbomUpdated(
string tenantId,
string sbomId,
string productKey,
IEnumerable<string> componentPurls,
string source,
DateTimeOffset occurredAt,
DateTimeOffset createdAt,
PolicyChangePriority priority = PolicyChangePriority.Normal,
string? correlationId = null,
ImmutableDictionary<string, string>? 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<string, string>.Empty
};
}
/// <summary>
/// Creates a manual trigger event.
/// </summary>
public static PolicyChangeEvent CreateManualTrigger(
string tenantId,
IEnumerable<string>? policyIds,
IEnumerable<string>? sbomIds,
IEnumerable<string>? productKeys,
string requestedBy,
DateTimeOffset createdAt,
PolicyChangePriority priority = PolicyChangePriority.Normal,
string? correlationId = null,
ImmutableDictionary<string, string>? 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<string, string>.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<string> NormalizePurls(IEnumerable<string>? purls)
{
return (purls ?? Enumerable.Empty<string>())
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => p.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(p => p, StringComparer.Ordinal)
.ToImmutableArray();
}
private static ImmutableArray<string> NormalizeProductKeys(IEnumerable<string>? keys)
{
return (keys ?? Enumerable.Empty<string>())
.Where(k => !string.IsNullOrWhiteSpace(k))
.Select(k => k.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(k => k, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
}
private static ImmutableArray<string> NormalizeSbomIds(IEnumerable<string>? ids)
{
return (ids ?? Enumerable.Empty<string>())
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal)
.ToImmutableArray();
}
private static ImmutableArray<string> NormalizePolicyIds(IEnumerable<string>? ids)
{
return (ids ?? Enumerable.Empty<string>())
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
}
}
/// <summary>
/// A batch of change events to be processed together.
/// </summary>
public sealed record PolicyChangeBatch
{
/// <summary>
/// Unique batch identifier.
/// </summary>
public required string BatchId { get; init; }
/// <summary>
/// Tenant context.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Events in this batch (deterministically ordered).
/// </summary>
public required ImmutableArray<PolicyChangeEvent> Events { get; init; }
/// <summary>
/// Highest priority in the batch.
/// </summary>
public required PolicyChangePriority Priority { get; init; }
/// <summary>
/// When the batch was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Combined affected PURLs from all events.
/// </summary>
public required ImmutableArray<string> AffectedPurls { get; init; }
/// <summary>
/// Combined affected product keys from all events.
/// </summary>
public required ImmutableArray<string> AffectedProductKeys { get; init; }
/// <summary>
/// Combined affected SBOM IDs from all events.
/// </summary>
public required ImmutableArray<string> AffectedSbomIds { get; init; }
/// <summary>
/// Combined vulnerability IDs from all events.
/// </summary>
public required ImmutableArray<string> VulnerabilityIds { get; init; }
}