up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user