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
536 lines
17 KiB
C#
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; }
|
|
}
|