Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-27 21:45:32 +02:00
510 changed files with 138401 additions and 51276 deletions

View File

@@ -0,0 +1,213 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Policy.RiskProfile.Models;
namespace StellaOps.Policy.RiskProfile.Hashing;
/// <summary>
/// Service for computing deterministic hashes of risk profiles.
/// </summary>
public sealed class RiskProfileHasher
{
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters =
{
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase),
},
};
/// <summary>
/// Computes a deterministic SHA-256 hash of the risk profile.
/// </summary>
/// <param name="profile">The profile to hash.</param>
/// <returns>Lowercase hex-encoded SHA-256 hash.</returns>
public string ComputeHash(RiskProfileModel profile)
{
ArgumentNullException.ThrowIfNull(profile);
var canonical = CreateCanonicalForm(profile);
var json = JsonSerializer.Serialize(canonical, CanonicalJsonOptions);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return Convert.ToHexStringLower(hash);
}
/// <summary>
/// Computes a deterministic content hash that ignores identity fields (id, version).
/// Useful for detecting semantic changes regardless of versioning.
/// </summary>
/// <param name="profile">The profile to hash.</param>
/// <returns>Lowercase hex-encoded SHA-256 hash.</returns>
public string ComputeContentHash(RiskProfileModel profile)
{
ArgumentNullException.ThrowIfNull(profile);
var canonical = CreateCanonicalContentForm(profile);
var json = JsonSerializer.Serialize(canonical, CanonicalJsonOptions);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return Convert.ToHexStringLower(hash);
}
/// <summary>
/// Verifies that two profiles have the same semantic content (ignoring identity fields).
/// </summary>
public bool AreEquivalent(RiskProfileModel profile1, RiskProfileModel profile2)
{
ArgumentNullException.ThrowIfNull(profile1);
ArgumentNullException.ThrowIfNull(profile2);
return ComputeContentHash(profile1) == ComputeContentHash(profile2);
}
private static CanonicalRiskProfile CreateCanonicalForm(RiskProfileModel profile)
{
return new CanonicalRiskProfile
{
Id = profile.Id,
Version = profile.Version,
Description = profile.Description,
Extends = profile.Extends,
Signals = CreateCanonicalSignals(profile.Signals),
Weights = CreateCanonicalWeights(profile.Weights),
Overrides = CreateCanonicalOverrides(profile.Overrides),
Metadata = CreateCanonicalMetadata(profile.Metadata),
};
}
private static CanonicalRiskProfileContent CreateCanonicalContentForm(RiskProfileModel profile)
{
return new CanonicalRiskProfileContent
{
Signals = CreateCanonicalSignals(profile.Signals),
Weights = CreateCanonicalWeights(profile.Weights),
Overrides = CreateCanonicalOverrides(profile.Overrides),
};
}
private static List<CanonicalSignal> CreateCanonicalSignals(List<RiskSignal> signals)
{
return signals
.OrderBy(s => s.Name, StringComparer.Ordinal)
.Select(s => new CanonicalSignal
{
Name = s.Name,
Source = s.Source,
Type = s.Type.ToString().ToLowerInvariant(),
Path = s.Path,
Transform = s.Transform,
Unit = s.Unit,
})
.ToList();
}
private static SortedDictionary<string, double> CreateCanonicalWeights(Dictionary<string, double> weights)
{
return new SortedDictionary<string, double>(weights, StringComparer.Ordinal);
}
private static CanonicalOverrides CreateCanonicalOverrides(RiskOverrides overrides)
{
return new CanonicalOverrides
{
Severity = overrides.Severity
.Select(CreateCanonicalSeverityOverride)
.ToList(),
Decisions = overrides.Decisions
.Select(CreateCanonicalDecisionOverride)
.ToList(),
};
}
private static CanonicalSeverityOverride CreateCanonicalSeverityOverride(SeverityOverride rule)
{
return new CanonicalSeverityOverride
{
When = CreateCanonicalWhen(rule.When),
Set = rule.Set.ToString().ToLowerInvariant(),
};
}
private static CanonicalDecisionOverride CreateCanonicalDecisionOverride(DecisionOverride rule)
{
return new CanonicalDecisionOverride
{
When = CreateCanonicalWhen(rule.When),
Action = rule.Action.ToString().ToLowerInvariant(),
Reason = rule.Reason,
};
}
private static SortedDictionary<string, object> CreateCanonicalWhen(Dictionary<string, object> when)
{
return new SortedDictionary<string, object>(when, StringComparer.Ordinal);
}
private static SortedDictionary<string, object?>? CreateCanonicalMetadata(Dictionary<string, object?>? metadata)
{
if (metadata == null || metadata.Count == 0)
{
return null;
}
return new SortedDictionary<string, object?>(metadata, StringComparer.Ordinal);
}
#region Canonical Form Types
private sealed class CanonicalRiskProfile
{
public required string Id { get; init; }
public required string Version { get; init; }
public string? Description { get; init; }
public string? Extends { get; init; }
public required List<CanonicalSignal> Signals { get; init; }
public required SortedDictionary<string, double> Weights { get; init; }
public required CanonicalOverrides Overrides { get; init; }
public SortedDictionary<string, object?>? Metadata { get; init; }
}
private sealed class CanonicalRiskProfileContent
{
public required List<CanonicalSignal> Signals { get; init; }
public required SortedDictionary<string, double> Weights { get; init; }
public required CanonicalOverrides Overrides { get; init; }
}
private sealed class CanonicalSignal
{
public required string Name { get; init; }
public required string Source { get; init; }
public required string Type { get; init; }
public string? Path { get; init; }
public string? Transform { get; init; }
public string? Unit { get; init; }
}
private sealed class CanonicalOverrides
{
public required List<CanonicalSeverityOverride> Severity { get; init; }
public required List<CanonicalDecisionOverride> Decisions { get; init; }
}
private sealed class CanonicalSeverityOverride
{
public required SortedDictionary<string, object> When { get; init; }
public required string Set { get; init; }
}
private sealed class CanonicalDecisionOverride
{
public required SortedDictionary<string, object> When { get; init; }
public required string Action { get; init; }
public string? Reason { get; init; }
}
#endregion
}

View File

@@ -0,0 +1,139 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.RiskProfile.Lifecycle;
/// <summary>
/// Lifecycle status of a risk profile.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<RiskProfileLifecycleStatus>))]
public enum RiskProfileLifecycleStatus
{
/// <summary>
/// Profile is in draft/development.
/// </summary>
[JsonPropertyName("draft")]
Draft,
/// <summary>
/// Profile is active and available for use.
/// </summary>
[JsonPropertyName("active")]
Active,
/// <summary>
/// Profile is deprecated; use is discouraged.
/// </summary>
[JsonPropertyName("deprecated")]
Deprecated,
/// <summary>
/// Profile is archived; no longer available for new use.
/// </summary>
[JsonPropertyName("archived")]
Archived
}
/// <summary>
/// Metadata about a profile version.
/// </summary>
public sealed record RiskProfileVersionInfo(
[property: JsonPropertyName("profile_id")] string ProfileId,
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("status")] RiskProfileLifecycleStatus Status,
[property: JsonPropertyName("created_at")] DateTimeOffset CreatedAt,
[property: JsonPropertyName("created_by")] string? CreatedBy,
[property: JsonPropertyName("activated_at")] DateTimeOffset? ActivatedAt,
[property: JsonPropertyName("deprecated_at")] DateTimeOffset? DeprecatedAt,
[property: JsonPropertyName("archived_at")] DateTimeOffset? ArchivedAt,
[property: JsonPropertyName("content_hash")] string ContentHash,
[property: JsonPropertyName("successor_version")] string? SuccessorVersion = null,
[property: JsonPropertyName("deprecation_reason")] string? DeprecationReason = null);
/// <summary>
/// Event raised when a profile lifecycle changes.
/// </summary>
public sealed record RiskProfileLifecycleEvent(
[property: JsonPropertyName("event_id")] string EventId,
[property: JsonPropertyName("profile_id")] string ProfileId,
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("event_type")] RiskProfileLifecycleEventType EventType,
[property: JsonPropertyName("old_status")] RiskProfileLifecycleStatus? OldStatus,
[property: JsonPropertyName("new_status")] RiskProfileLifecycleStatus NewStatus,
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp,
[property: JsonPropertyName("actor")] string? Actor,
[property: JsonPropertyName("reason")] string? Reason = null);
/// <summary>
/// Types of lifecycle events.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<RiskProfileLifecycleEventType>))]
public enum RiskProfileLifecycleEventType
{
[JsonPropertyName("created")]
Created,
[JsonPropertyName("activated")]
Activated,
[JsonPropertyName("deprecated")]
Deprecated,
[JsonPropertyName("archived")]
Archived,
[JsonPropertyName("restored")]
Restored
}
/// <summary>
/// Result of a version comparison.
/// </summary>
public sealed record RiskProfileVersionComparison(
[property: JsonPropertyName("profile_id")] string ProfileId,
[property: JsonPropertyName("from_version")] string FromVersion,
[property: JsonPropertyName("to_version")] string ToVersion,
[property: JsonPropertyName("has_breaking_changes")] bool HasBreakingChanges,
[property: JsonPropertyName("changes")] IReadOnlyList<RiskProfileChange> Changes);
/// <summary>
/// A specific change between profile versions.
/// </summary>
public sealed record RiskProfileChange(
[property: JsonPropertyName("change_type")] RiskProfileChangeType ChangeType,
[property: JsonPropertyName("path")] string Path,
[property: JsonPropertyName("description")] string Description,
[property: JsonPropertyName("is_breaking")] bool IsBreaking);
/// <summary>
/// Types of changes between profile versions.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<RiskProfileChangeType>))]
public enum RiskProfileChangeType
{
[JsonPropertyName("signal_added")]
SignalAdded,
[JsonPropertyName("signal_removed")]
SignalRemoved,
[JsonPropertyName("signal_modified")]
SignalModified,
[JsonPropertyName("weight_changed")]
WeightChanged,
[JsonPropertyName("override_added")]
OverrideAdded,
[JsonPropertyName("override_removed")]
OverrideRemoved,
[JsonPropertyName("override_modified")]
OverrideModified,
[JsonPropertyName("metadata_changed")]
MetadataChanged,
[JsonPropertyName("inheritance_changed")]
InheritanceChanged
}

View File

@@ -0,0 +1,521 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Policy.RiskProfile.Hashing;
using StellaOps.Policy.RiskProfile.Models;
namespace StellaOps.Policy.RiskProfile.Lifecycle;
/// <summary>
/// Service for managing risk profile lifecycle and versioning.
/// </summary>
public sealed class RiskProfileLifecycleService
{
private readonly TimeProvider _timeProvider;
private readonly RiskProfileHasher _hasher;
private readonly ConcurrentDictionary<string, List<RiskProfileVersionInfo>> _versions;
private readonly ConcurrentDictionary<string, List<RiskProfileLifecycleEvent>> _events;
public RiskProfileLifecycleService(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_hasher = new RiskProfileHasher();
_versions = new ConcurrentDictionary<string, List<RiskProfileVersionInfo>>(StringComparer.OrdinalIgnoreCase);
_events = new ConcurrentDictionary<string, List<RiskProfileLifecycleEvent>>(StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Creates a new profile version in draft status.
/// </summary>
/// <param name="profile">The profile to create.</param>
/// <param name="createdBy">Creator identifier.</param>
/// <returns>Version info for the created profile.</returns>
public RiskProfileVersionInfo CreateVersion(RiskProfileModel profile, string? createdBy = null)
{
ArgumentNullException.ThrowIfNull(profile);
var now = _timeProvider.GetUtcNow();
var contentHash = _hasher.ComputeContentHash(profile);
var versionInfo = new RiskProfileVersionInfo(
ProfileId: profile.Id,
Version: profile.Version,
Status: RiskProfileLifecycleStatus.Draft,
CreatedAt: now,
CreatedBy: createdBy,
ActivatedAt: null,
DeprecatedAt: null,
ArchivedAt: null,
ContentHash: contentHash);
var versions = _versions.GetOrAdd(profile.Id, _ => new List<RiskProfileVersionInfo>());
lock (versions)
{
if (versions.Any(v => v.Version == profile.Version))
{
throw new InvalidOperationException($"Version {profile.Version} already exists for profile {profile.Id}.");
}
versions.Add(versionInfo);
}
RecordEvent(profile.Id, profile.Version, RiskProfileLifecycleEventType.Created, null, RiskProfileLifecycleStatus.Draft, createdBy);
return versionInfo;
}
/// <summary>
/// Activates a profile version, making it available for use.
/// </summary>
/// <param name="profileId">The profile ID.</param>
/// <param name="version">The version to activate.</param>
/// <param name="actor">Actor performing the activation.</param>
/// <returns>Updated version info.</returns>
public RiskProfileVersionInfo Activate(string profileId, string version, string? actor = null)
{
var info = GetVersionInfo(profileId, version);
if (info == null)
{
throw new InvalidOperationException($"Version {version} not found for profile {profileId}.");
}
if (info.Status != RiskProfileLifecycleStatus.Draft)
{
throw new InvalidOperationException($"Cannot activate profile in {info.Status} status. Only Draft profiles can be activated.");
}
var now = _timeProvider.GetUtcNow();
var updated = info with
{
Status = RiskProfileLifecycleStatus.Active,
ActivatedAt = now
};
UpdateVersionInfo(profileId, version, updated);
RecordEvent(profileId, version, RiskProfileLifecycleEventType.Activated, info.Status, RiskProfileLifecycleStatus.Active, actor);
return updated;
}
/// <summary>
/// Deprecates a profile version.
/// </summary>
/// <param name="profileId">The profile ID.</param>
/// <param name="version">The version to deprecate.</param>
/// <param name="successorVersion">Optional successor version to recommend.</param>
/// <param name="reason">Reason for deprecation.</param>
/// <param name="actor">Actor performing the deprecation.</param>
/// <returns>Updated version info.</returns>
public RiskProfileVersionInfo Deprecate(
string profileId,
string version,
string? successorVersion = null,
string? reason = null,
string? actor = null)
{
var info = GetVersionInfo(profileId, version);
if (info == null)
{
throw new InvalidOperationException($"Version {version} not found for profile {profileId}.");
}
if (info.Status != RiskProfileLifecycleStatus.Active)
{
throw new InvalidOperationException($"Cannot deprecate profile in {info.Status} status. Only Active profiles can be deprecated.");
}
var now = _timeProvider.GetUtcNow();
var updated = info with
{
Status = RiskProfileLifecycleStatus.Deprecated,
DeprecatedAt = now,
SuccessorVersion = successorVersion,
DeprecationReason = reason
};
UpdateVersionInfo(profileId, version, updated);
RecordEvent(profileId, version, RiskProfileLifecycleEventType.Deprecated, info.Status, RiskProfileLifecycleStatus.Deprecated, actor, reason);
return updated;
}
/// <summary>
/// Archives a profile version, removing it from active use.
/// </summary>
/// <param name="profileId">The profile ID.</param>
/// <param name="version">The version to archive.</param>
/// <param name="actor">Actor performing the archive.</param>
/// <returns>Updated version info.</returns>
public RiskProfileVersionInfo Archive(string profileId, string version, string? actor = null)
{
var info = GetVersionInfo(profileId, version);
if (info == null)
{
throw new InvalidOperationException($"Version {version} not found for profile {profileId}.");
}
if (info.Status == RiskProfileLifecycleStatus.Archived)
{
return info;
}
var now = _timeProvider.GetUtcNow();
var updated = info with
{
Status = RiskProfileLifecycleStatus.Archived,
ArchivedAt = now
};
UpdateVersionInfo(profileId, version, updated);
RecordEvent(profileId, version, RiskProfileLifecycleEventType.Archived, info.Status, RiskProfileLifecycleStatus.Archived, actor);
return updated;
}
/// <summary>
/// Restores an archived profile to deprecated status.
/// </summary>
/// <param name="profileId">The profile ID.</param>
/// <param name="version">The version to restore.</param>
/// <param name="actor">Actor performing the restoration.</param>
/// <returns>Updated version info.</returns>
public RiskProfileVersionInfo Restore(string profileId, string version, string? actor = null)
{
var info = GetVersionInfo(profileId, version);
if (info == null)
{
throw new InvalidOperationException($"Version {version} not found for profile {profileId}.");
}
if (info.Status != RiskProfileLifecycleStatus.Archived)
{
throw new InvalidOperationException($"Cannot restore profile in {info.Status} status. Only Archived profiles can be restored.");
}
var updated = info with
{
Status = RiskProfileLifecycleStatus.Deprecated,
ArchivedAt = null
};
UpdateVersionInfo(profileId, version, updated);
RecordEvent(profileId, version, RiskProfileLifecycleEventType.Restored, info.Status, RiskProfileLifecycleStatus.Deprecated, actor);
return updated;
}
/// <summary>
/// Gets version info for a specific profile version.
/// </summary>
public RiskProfileVersionInfo? GetVersionInfo(string profileId, string version)
{
if (_versions.TryGetValue(profileId, out var versions))
{
lock (versions)
{
return versions.FirstOrDefault(v => v.Version == version);
}
}
return null;
}
/// <summary>
/// Gets all versions for a profile.
/// </summary>
public IReadOnlyList<RiskProfileVersionInfo> GetAllVersions(string profileId)
{
if (_versions.TryGetValue(profileId, out var versions))
{
lock (versions)
{
return versions.OrderByDescending(v => ParseVersion(v.Version)).ToList().AsReadOnly();
}
}
return Array.Empty<RiskProfileVersionInfo>();
}
/// <summary>
/// Gets the latest active version for a profile.
/// </summary>
public RiskProfileVersionInfo? GetLatestActive(string profileId)
{
var versions = GetAllVersions(profileId);
return versions.FirstOrDefault(v => v.Status == RiskProfileLifecycleStatus.Active);
}
/// <summary>
/// Gets lifecycle events for a profile.
/// </summary>
public IReadOnlyList<RiskProfileLifecycleEvent> GetEvents(string profileId, int limit = 100)
{
if (_events.TryGetValue(profileId, out var events))
{
lock (events)
{
return events.OrderByDescending(e => e.Timestamp).Take(limit).ToList().AsReadOnly();
}
}
return Array.Empty<RiskProfileLifecycleEvent>();
}
/// <summary>
/// Compares two profile versions and returns the differences.
/// </summary>
public RiskProfileVersionComparison CompareVersions(
RiskProfileModel fromProfile,
RiskProfileModel toProfile)
{
ArgumentNullException.ThrowIfNull(fromProfile);
ArgumentNullException.ThrowIfNull(toProfile);
if (fromProfile.Id != toProfile.Id)
{
throw new ArgumentException("Profiles must have the same ID to compare.");
}
var changes = new List<RiskProfileChange>();
var hasBreaking = false;
CompareSignals(fromProfile, toProfile, changes, ref hasBreaking);
CompareWeights(fromProfile, toProfile, changes);
CompareOverrides(fromProfile, toProfile, changes);
CompareInheritance(fromProfile, toProfile, changes, ref hasBreaking);
CompareMetadata(fromProfile, toProfile, changes);
return new RiskProfileVersionComparison(
ProfileId: fromProfile.Id,
FromVersion: fromProfile.Version,
ToVersion: toProfile.Version,
HasBreakingChanges: hasBreaking,
Changes: changes.AsReadOnly());
}
/// <summary>
/// Determines if an upgrade from one version to another is safe (non-breaking).
/// </summary>
public bool IsSafeUpgrade(RiskProfileModel fromProfile, RiskProfileModel toProfile)
{
var comparison = CompareVersions(fromProfile, toProfile);
return !comparison.HasBreakingChanges;
}
private void UpdateVersionInfo(string profileId, string version, RiskProfileVersionInfo updated)
{
if (_versions.TryGetValue(profileId, out var versions))
{
lock (versions)
{
var index = versions.FindIndex(v => v.Version == version);
if (index >= 0)
{
versions[index] = updated;
}
}
}
}
private void RecordEvent(
string profileId,
string version,
RiskProfileLifecycleEventType eventType,
RiskProfileLifecycleStatus? oldStatus,
RiskProfileLifecycleStatus newStatus,
string? actor,
string? reason = null)
{
var eventId = GenerateEventId();
var evt = new RiskProfileLifecycleEvent(
EventId: eventId,
ProfileId: profileId,
Version: version,
EventType: eventType,
OldStatus: oldStatus,
NewStatus: newStatus,
Timestamp: _timeProvider.GetUtcNow(),
Actor: actor,
Reason: reason);
var events = _events.GetOrAdd(profileId, _ => new List<RiskProfileLifecycleEvent>());
lock (events)
{
events.Add(evt);
}
}
private static void CompareSignals(
RiskProfileModel from,
RiskProfileModel to,
List<RiskProfileChange> changes,
ref bool hasBreaking)
{
var fromSignals = from.Signals.ToDictionary(s => s.Name, StringComparer.OrdinalIgnoreCase);
var toSignals = to.Signals.ToDictionary(s => s.Name, StringComparer.OrdinalIgnoreCase);
foreach (var (name, signal) in fromSignals)
{
if (!toSignals.ContainsKey(name))
{
changes.Add(new RiskProfileChange(
RiskProfileChangeType.SignalRemoved,
$"/signals/{name}",
$"Signal '{name}' was removed",
IsBreaking: true));
hasBreaking = true;
}
else if (!SignalsEqual(signal, toSignals[name]))
{
changes.Add(new RiskProfileChange(
RiskProfileChangeType.SignalModified,
$"/signals/{name}",
$"Signal '{name}' was modified",
IsBreaking: false));
}
}
foreach (var name in toSignals.Keys)
{
if (!fromSignals.ContainsKey(name))
{
changes.Add(new RiskProfileChange(
RiskProfileChangeType.SignalAdded,
$"/signals/{name}",
$"Signal '{name}' was added",
IsBreaking: false));
}
}
}
private static void CompareWeights(
RiskProfileModel from,
RiskProfileModel to,
List<RiskProfileChange> changes)
{
var allKeys = from.Weights.Keys.Union(to.Weights.Keys, StringComparer.OrdinalIgnoreCase);
foreach (var key in allKeys)
{
var fromHas = from.Weights.TryGetValue(key, out var fromWeight);
var toHas = to.Weights.TryGetValue(key, out var toWeight);
if (fromHas && toHas && Math.Abs(fromWeight - toWeight) > 0.001)
{
changes.Add(new RiskProfileChange(
RiskProfileChangeType.WeightChanged,
$"/weights/{key}",
$"Weight for '{key}' changed from {fromWeight:F3} to {toWeight:F3}",
IsBreaking: false));
}
else if (fromHas && !toHas)
{
changes.Add(new RiskProfileChange(
RiskProfileChangeType.WeightChanged,
$"/weights/{key}",
$"Weight for '{key}' was removed",
IsBreaking: false));
}
else if (!fromHas && toHas)
{
changes.Add(new RiskProfileChange(
RiskProfileChangeType.WeightChanged,
$"/weights/{key}",
$"Weight for '{key}' was added with value {toWeight:F3}",
IsBreaking: false));
}
}
}
private static void CompareOverrides(
RiskProfileModel from,
RiskProfileModel to,
List<RiskProfileChange> changes)
{
if (from.Overrides.Severity.Count != to.Overrides.Severity.Count)
{
changes.Add(new RiskProfileChange(
RiskProfileChangeType.OverrideModified,
"/overrides/severity",
$"Severity overrides changed from {from.Overrides.Severity.Count} to {to.Overrides.Severity.Count} rules",
IsBreaking: false));
}
if (from.Overrides.Decisions.Count != to.Overrides.Decisions.Count)
{
changes.Add(new RiskProfileChange(
RiskProfileChangeType.OverrideModified,
"/overrides/decisions",
$"Decision overrides changed from {from.Overrides.Decisions.Count} to {to.Overrides.Decisions.Count} rules",
IsBreaking: false));
}
}
private static void CompareInheritance(
RiskProfileModel from,
RiskProfileModel to,
List<RiskProfileChange> changes,
ref bool hasBreaking)
{
if (!string.Equals(from.Extends, to.Extends, StringComparison.OrdinalIgnoreCase))
{
var fromExtends = from.Extends ?? "(none)";
var toExtends = to.Extends ?? "(none)";
changes.Add(new RiskProfileChange(
RiskProfileChangeType.InheritanceChanged,
"/extends",
$"Inheritance changed from '{fromExtends}' to '{toExtends}'",
IsBreaking: true));
hasBreaking = true;
}
}
private static void CompareMetadata(
RiskProfileModel from,
RiskProfileModel to,
List<RiskProfileChange> changes)
{
var fromKeys = from.Metadata?.Keys ?? Enumerable.Empty<string>();
var toKeys = to.Metadata?.Keys ?? Enumerable.Empty<string>();
var allKeys = fromKeys.Union(toKeys, StringComparer.OrdinalIgnoreCase);
foreach (var key in allKeys)
{
var fromHas = from.Metadata?.TryGetValue(key, out var fromValue) ?? false;
var toHas = to.Metadata?.TryGetValue(key, out var toValue) ?? false;
if (fromHas != toHas || (fromHas && toHas && !Equals(fromValue, toValue)))
{
changes.Add(new RiskProfileChange(
RiskProfileChangeType.MetadataChanged,
$"/metadata/{key}",
$"Metadata key '{key}' was changed",
IsBreaking: false));
}
}
}
private static bool SignalsEqual(RiskSignal a, RiskSignal b)
{
return a.Source == b.Source &&
a.Type == b.Type &&
a.Path == b.Path &&
a.Transform == b.Transform &&
a.Unit == b.Unit;
}
private static Version ParseVersion(string version)
{
var parts = version.Split(['-', '+'], 2);
if (Version.TryParse(parts[0], out var parsed))
{
return parsed;
}
return new Version(0, 0, 0);
}
private static string GenerateEventId()
{
var guid = Guid.NewGuid().ToByteArray();
return $"rple-{Convert.ToHexStringLower(guid)[..16]}";
}
}

View File

@@ -0,0 +1,241 @@
using StellaOps.Policy.RiskProfile.Models;
namespace StellaOps.Policy.RiskProfile.Merge;
/// <summary>
/// Service for merging and resolving inheritance in risk profiles.
/// </summary>
public sealed class RiskProfileMergeService
{
/// <summary>
/// Resolves a risk profile by applying inheritance from parent profiles.
/// </summary>
/// <param name="profile">The profile to resolve.</param>
/// <param name="profileResolver">Function to resolve parent profiles by ID.</param>
/// <param name="maxDepth">Maximum inheritance depth to prevent cycles.</param>
/// <returns>A fully resolved profile with inherited values merged.</returns>
public RiskProfileModel ResolveInheritance(
RiskProfileModel profile,
Func<string, RiskProfileModel?> profileResolver,
int maxDepth = 10)
{
ArgumentNullException.ThrowIfNull(profile);
ArgumentNullException.ThrowIfNull(profileResolver);
if (string.IsNullOrWhiteSpace(profile.Extends))
{
return profile;
}
var chain = BuildInheritanceChain(profile, profileResolver, maxDepth);
return MergeChain(chain);
}
/// <summary>
/// Merges multiple profiles in order (later profiles override earlier ones).
/// </summary>
/// <param name="profiles">Profiles to merge, in order of precedence (first = base, last = highest priority).</param>
/// <returns>A merged profile.</returns>
public RiskProfileModel MergeProfiles(IEnumerable<RiskProfileModel> profiles)
{
ArgumentNullException.ThrowIfNull(profiles);
var profileList = profiles.ToList();
if (profileList.Count == 0)
{
throw new ArgumentException("At least one profile is required.", nameof(profiles));
}
return MergeChain(profileList);
}
private List<RiskProfileModel> BuildInheritanceChain(
RiskProfileModel profile,
Func<string, RiskProfileModel?> resolver,
int maxDepth)
{
var chain = new List<RiskProfileModel>();
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var current = profile;
var depth = 0;
while (current != null && depth < maxDepth)
{
if (!visited.Add(current.Id))
{
throw new InvalidOperationException(
$"Circular inheritance detected: profile '{current.Id}' already in chain.");
}
chain.Add(current);
depth++;
if (string.IsNullOrWhiteSpace(current.Extends))
{
break;
}
var parent = resolver(current.Extends);
if (parent == null)
{
throw new InvalidOperationException(
$"Parent profile '{current.Extends}' not found for profile '{current.Id}'.");
}
current = parent;
}
if (depth >= maxDepth)
{
throw new InvalidOperationException(
$"Maximum inheritance depth ({maxDepth}) exceeded for profile '{profile.Id}'.");
}
// Reverse so base profiles come first
chain.Reverse();
return chain;
}
private RiskProfileModel MergeChain(List<RiskProfileModel> chain)
{
if (chain.Count == 1)
{
return CloneProfile(chain[0]);
}
var result = CloneProfile(chain[0]);
for (int i = 1; i < chain.Count; i++)
{
var overlay = chain[i];
MergeInto(result, overlay);
}
return result;
}
private void MergeInto(RiskProfileModel target, RiskProfileModel overlay)
{
// Override identity fields
target.Id = overlay.Id;
target.Version = overlay.Version;
if (!string.IsNullOrWhiteSpace(overlay.Description))
{
target.Description = overlay.Description;
}
// Clear extends since inheritance has been resolved
target.Extends = null;
// Merge signals (overlay signals replace by name, new ones are added)
MergeSignals(target.Signals, overlay.Signals);
// Merge weights (overlay weights override by key)
foreach (var kvp in overlay.Weights)
{
target.Weights[kvp.Key] = kvp.Value;
}
// Merge overrides (append overlay rules)
MergeOverrides(target.Overrides, overlay.Overrides);
// Merge metadata (overlay values override by key)
if (overlay.Metadata != null)
{
target.Metadata ??= new Dictionary<string, object?>();
foreach (var kvp in overlay.Metadata)
{
target.Metadata[kvp.Key] = kvp.Value;
}
}
}
private static void MergeSignals(List<RiskSignal> target, List<RiskSignal> overlay)
{
var signalsByName = target.ToDictionary(s => s.Name, StringComparer.OrdinalIgnoreCase);
foreach (var signal in overlay)
{
if (signalsByName.TryGetValue(signal.Name, out var existing))
{
// Replace existing signal
var index = target.IndexOf(existing);
target[index] = CloneSignal(signal);
}
else
{
// Add new signal
target.Add(CloneSignal(signal));
}
}
}
private static void MergeOverrides(RiskOverrides target, RiskOverrides overlay)
{
// Append severity overrides (overlay rules take precedence by being evaluated later)
foreach (var rule in overlay.Severity)
{
target.Severity.Add(CloneSeverityOverride(rule));
}
// Append decision overrides
foreach (var rule in overlay.Decisions)
{
target.Decisions.Add(CloneDecisionOverride(rule));
}
}
private static RiskProfileModel CloneProfile(RiskProfileModel source)
{
return new RiskProfileModel
{
Id = source.Id,
Version = source.Version,
Description = source.Description,
Extends = source.Extends,
Signals = source.Signals.Select(CloneSignal).ToList(),
Weights = new Dictionary<string, double>(source.Weights),
Overrides = new RiskOverrides
{
Severity = source.Overrides.Severity.Select(CloneSeverityOverride).ToList(),
Decisions = source.Overrides.Decisions.Select(CloneDecisionOverride).ToList(),
},
Metadata = source.Metadata != null
? new Dictionary<string, object?>(source.Metadata)
: null,
};
}
private static RiskSignal CloneSignal(RiskSignal source)
{
return new RiskSignal
{
Name = source.Name,
Source = source.Source,
Type = source.Type,
Path = source.Path,
Transform = source.Transform,
Unit = source.Unit,
};
}
private static SeverityOverride CloneSeverityOverride(SeverityOverride source)
{
return new SeverityOverride
{
When = new Dictionary<string, object>(source.When),
Set = source.Set,
};
}
private static DecisionOverride CloneDecisionOverride(DecisionOverride source)
{
return new DecisionOverride
{
When = new Dictionary<string, object>(source.When),
Action = source.Action,
Reason = source.Reason,
};
}
}

View File

@@ -0,0 +1,213 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.RiskProfile.Models;
/// <summary>
/// Represents a risk profile definition used to score and prioritize findings.
/// </summary>
public sealed class RiskProfileModel
{
/// <summary>
/// Stable identifier for the risk profile (slug or URN).
/// </summary>
[JsonPropertyName("id")]
public required string Id { get; set; }
/// <summary>
/// SemVer for the profile definition.
/// </summary>
[JsonPropertyName("version")]
public required string Version { get; set; }
/// <summary>
/// Human-readable summary of the profile intent.
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; set; }
/// <summary>
/// Optional parent profile ID for inheritance.
/// </summary>
[JsonPropertyName("extends")]
public string? Extends { get; set; }
/// <summary>
/// Signal definitions used for risk scoring.
/// </summary>
[JsonPropertyName("signals")]
public List<RiskSignal> Signals { get; set; } = new();
/// <summary>
/// Weight per signal name; weights are normalized by the consumer.
/// </summary>
[JsonPropertyName("weights")]
public Dictionary<string, double> Weights { get; set; } = new();
/// <summary>
/// Override rules for severity and decisions.
/// </summary>
[JsonPropertyName("overrides")]
public RiskOverrides Overrides { get; set; } = new();
/// <summary>
/// Free-form metadata with stable keys.
/// </summary>
[JsonPropertyName("metadata")]
public Dictionary<string, object?>? Metadata { get; set; }
}
/// <summary>
/// A signal definition used in risk scoring.
/// </summary>
public sealed class RiskSignal
{
/// <summary>
/// Logical signal key (e.g., reachability, kev, exploit_chain).
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; set; }
/// <summary>
/// Upstream provider or calculation origin.
/// </summary>
[JsonPropertyName("source")]
public required string Source { get; set; }
/// <summary>
/// Signal type.
/// </summary>
[JsonPropertyName("type")]
public required RiskSignalType Type { get; set; }
/// <summary>
/// JSON Pointer to the signal in the evidence document.
/// </summary>
[JsonPropertyName("path")]
public string? Path { get; set; }
/// <summary>
/// Optional transform applied before weighting.
/// </summary>
[JsonPropertyName("transform")]
public string? Transform { get; set; }
/// <summary>
/// Optional unit for numeric signals.
/// </summary>
[JsonPropertyName("unit")]
public string? Unit { get; set; }
}
/// <summary>
/// Signal type enumeration.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<RiskSignalType>))]
public enum RiskSignalType
{
[JsonPropertyName("boolean")]
Boolean,
[JsonPropertyName("numeric")]
Numeric,
[JsonPropertyName("categorical")]
Categorical,
}
/// <summary>
/// Override rules for severity and decisions.
/// </summary>
public sealed class RiskOverrides
{
/// <summary>
/// Severity override rules.
/// </summary>
[JsonPropertyName("severity")]
public List<SeverityOverride> Severity { get; set; } = new();
/// <summary>
/// Decision override rules.
/// </summary>
[JsonPropertyName("decisions")]
public List<DecisionOverride> Decisions { get; set; } = new();
}
/// <summary>
/// A severity override rule.
/// </summary>
public sealed class SeverityOverride
{
/// <summary>
/// Predicate over signals (key/value equals).
/// </summary>
[JsonPropertyName("when")]
public required Dictionary<string, object> When { get; set; }
/// <summary>
/// Severity to set when predicate matches.
/// </summary>
[JsonPropertyName("set")]
public required RiskSeverity Set { get; set; }
}
/// <summary>
/// A decision override rule.
/// </summary>
public sealed class DecisionOverride
{
/// <summary>
/// Predicate over signals (key/value equals).
/// </summary>
[JsonPropertyName("when")]
public required Dictionary<string, object> When { get; set; }
/// <summary>
/// Action to take when predicate matches.
/// </summary>
[JsonPropertyName("action")]
public required RiskAction Action { get; set; }
/// <summary>
/// Optional reason for the override.
/// </summary>
[JsonPropertyName("reason")]
public string? Reason { get; set; }
}
/// <summary>
/// Severity levels.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<RiskSeverity>))]
public enum RiskSeverity
{
[JsonPropertyName("critical")]
Critical,
[JsonPropertyName("high")]
High,
[JsonPropertyName("medium")]
Medium,
[JsonPropertyName("low")]
Low,
[JsonPropertyName("informational")]
Informational,
}
/// <summary>
/// Decision actions.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<RiskAction>))]
public enum RiskAction
{
[JsonPropertyName("allow")]
Allow,
[JsonPropertyName("review")]
Review,
[JsonPropertyName("deny")]
Deny,
}

View File

@@ -1,4 +1,6 @@
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using Json.Schema;
namespace StellaOps.Policy.RiskProfile.Schema;
@@ -6,14 +8,54 @@ namespace StellaOps.Policy.RiskProfile.Schema;
public static class RiskProfileSchemaProvider
{
private const string SchemaResource = "StellaOps.Policy.RiskProfile.Schemas.risk-profile-schema@1.json";
private const string SchemaVersion = "1";
private static string? _cachedSchemaText;
private static string? _cachedETag;
public static JsonSchema GetSchema()
{
var schemaText = GetSchemaText();
return JsonSchema.FromText(schemaText);
}
/// <summary>
/// Returns the raw JSON schema text.
/// </summary>
public static string GetSchemaText()
{
if (_cachedSchemaText is not null)
{
return _cachedSchemaText;
}
using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(SchemaResource)
?? throw new InvalidOperationException($"Schema resource '{SchemaResource}' not found.");
using var reader = new StreamReader(stream);
var schemaText = reader.ReadToEnd();
_cachedSchemaText = reader.ReadToEnd();
return JsonSchema.FromText(schemaText);
return _cachedSchemaText;
}
/// <summary>
/// Returns the schema version identifier.
/// </summary>
public static string GetSchemaVersion() => SchemaVersion;
/// <summary>
/// Returns an ETag for the schema content.
/// </summary>
public static string GetETag()
{
if (_cachedETag is not null)
{
return _cachedETag;
}
var schemaText = GetSchemaText();
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(schemaText));
_cachedETag = $"\"{Convert.ToHexStringLower(hash)[..16]}\"";
return _cachedETag;
}
}