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
Concelier Attestation Tests / attestation-tests (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-28 20:55:22 +02:00
parent d040c001ac
commit 2548abc56f
231 changed files with 47468 additions and 68 deletions

View File

@@ -0,0 +1,12 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Policy.Scoring.Receipts;
/// <summary>
/// Persists CVSS score receipts.
/// </summary>
public interface IReceiptRepository
{
Task<CvssScoreReceipt> SaveAsync(CvssScoreReceipt receipt, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,252 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Policy.Scoring.Engine;
namespace StellaOps.Policy.Scoring.Receipts;
public sealed record CreateReceiptRequest
{
public required string VulnerabilityId { get; init; }
public required string TenantId { get; init; }
public required string CreatedBy { get; init; }
public DateTimeOffset? CreatedAt { get; init; }
public required CvssPolicy Policy { get; init; }
public required CvssBaseMetrics BaseMetrics { get; init; }
public CvssThreatMetrics? ThreatMetrics { get; init; }
public CvssEnvironmentalMetrics? EnvironmentalMetrics { get; init; }
public CvssSupplementalMetrics? SupplementalMetrics { get; init; }
public ImmutableList<CvssEvidenceItem> Evidence { get; init; } = [];
}
public interface IReceiptBuilder
{
Task<CvssScoreReceipt> CreateAsync(CreateReceiptRequest request, CancellationToken cancellationToken = default);
}
/// <summary>
/// Builds CVSS score receipts deterministically.
/// </summary>
public sealed class ReceiptBuilder : IReceiptBuilder
{
private static readonly JsonSerializerOptions CanonicalSerializerOptions = new()
{
PropertyNamingPolicy = null,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
private readonly ICvssV4Engine _engine;
private readonly IReceiptRepository _repository;
public ReceiptBuilder(ICvssV4Engine engine, IReceiptRepository repository)
{
_engine = engine;
_repository = repository;
}
public async Task<CvssScoreReceipt> CreateAsync(CreateReceiptRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(request.Policy);
ValidateEvidence(request.Policy, request.Evidence);
var createdAt = request.CreatedAt ?? DateTimeOffset.UtcNow;
// Compute scores and vector
var scores = _engine.ComputeScores(request.BaseMetrics, request.ThreatMetrics, request.EnvironmentalMetrics);
var vector = _engine.BuildVectorString(request.BaseMetrics, request.ThreatMetrics, request.EnvironmentalMetrics, request.SupplementalMetrics);
var severity = _engine.GetSeverity(scores.EffectiveScore, request.Policy.SeverityThresholds);
var policyRef = new CvssPolicyReference
{
PolicyId = request.Policy.PolicyId,
Version = request.Policy.Version,
Hash = request.Policy.Hash ?? throw new InvalidOperationException("Policy hash must be set before building receipts."),
ActivatedAt = request.Policy.EffectiveFrom
};
var evidence = request.Evidence
.OrderBy(e => e.Uri, StringComparer.Ordinal)
.ThenBy(e => e.Type, StringComparer.Ordinal)
.ToImmutableList();
var receipt = new CvssScoreReceipt
{
ReceiptId = Guid.NewGuid().ToString("N"),
TenantId = request.TenantId,
VulnerabilityId = request.VulnerabilityId,
CreatedAt = createdAt,
CreatedBy = request.CreatedBy,
ModifiedAt = null,
ModifiedBy = null,
BaseMetrics = request.BaseMetrics,
ThreatMetrics = request.ThreatMetrics,
EnvironmentalMetrics = request.EnvironmentalMetrics,
SupplementalMetrics = request.SupplementalMetrics,
Scores = scores,
VectorString = vector,
Severity = severity,
PolicyRef = policyRef,
Evidence = evidence,
AttestationRefs = ImmutableList<string>.Empty,
InputHash = ComputeInputHash(request, scores, policyRef, vector, evidence),
History = ImmutableList<ReceiptHistoryEntry>.Empty.Add(new ReceiptHistoryEntry
{
HistoryId = Guid.NewGuid().ToString("N"),
Timestamp = createdAt,
Actor = request.CreatedBy,
ChangeType = ReceiptChangeType.Created,
Field = "receipt",
PreviousValue = null,
NewValue = null,
Reason = "Initial creation",
ReferenceUri = null,
Signature = null
}),
AmendsReceiptId = null,
IsActive = true,
SupersededReason = null
};
return await _repository.SaveAsync(receipt, cancellationToken).ConfigureAwait(false);
}
private static void ValidateEvidence(CvssPolicy policy, ImmutableList<CvssEvidenceItem> evidence)
{
var req = policy.EvidenceRequirements;
if (req is null)
{
return;
}
if (req.MinimumCount > 0 && evidence.Count < req.MinimumCount)
{
throw new InvalidOperationException($"Evidence minimum count {req.MinimumCount} not met (found {evidence.Count}).");
}
if (req.RequiredTypes is { Count: > 0 })
{
var providedTypes = evidence.Select(e => e.Type).ToHashSet(StringComparer.OrdinalIgnoreCase);
var missing = req.RequiredTypes.Where(t => !providedTypes.Contains(t)).ToList();
if (missing.Count > 0)
{
throw new InvalidOperationException($"Evidence missing required types: {string.Join(",", missing)}");
}
}
if (req.RequireAuthoritative && evidence.All(e => !e.IsAuthoritative))
{
throw new InvalidOperationException("At least one authoritative evidence item is required.");
}
}
private static string ComputeInputHash(
CreateReceiptRequest request,
CvssScores scores,
CvssPolicyReference policyRef,
string vector,
ImmutableList<CvssEvidenceItem> evidence)
{
using var stream = new MemoryStream();
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
Indented = false
});
writer.WriteStartObject();
writer.WriteString("vulnerabilityId", request.VulnerabilityId);
writer.WriteString("tenantId", request.TenantId);
writer.WriteString("policyId", policyRef.PolicyId);
writer.WriteString("policyVersion", policyRef.Version);
writer.WriteString("policyHash", policyRef.Hash);
writer.WriteString("vector", vector);
writer.WritePropertyName("baseMetrics");
WriteCanonical(JsonSerializer.SerializeToElement(request.BaseMetrics, CanonicalSerializerOptions), writer);
writer.WritePropertyName("threatMetrics");
if (request.ThreatMetrics is not null)
WriteCanonical(JsonSerializer.SerializeToElement(request.ThreatMetrics, CanonicalSerializerOptions), writer);
else
writer.WriteNullValue();
writer.WritePropertyName("environmentalMetrics");
if (request.EnvironmentalMetrics is not null)
WriteCanonical(JsonSerializer.SerializeToElement(request.EnvironmentalMetrics, CanonicalSerializerOptions), writer);
else
writer.WriteNullValue();
writer.WritePropertyName("supplementalMetrics");
if (request.SupplementalMetrics is not null)
WriteCanonical(JsonSerializer.SerializeToElement(request.SupplementalMetrics, CanonicalSerializerOptions), writer);
else
writer.WriteNullValue();
writer.WritePropertyName("scores");
WriteCanonical(JsonSerializer.SerializeToElement(scores, CanonicalSerializerOptions), writer);
writer.WritePropertyName("evidence");
writer.WriteStartArray();
foreach (var ev in evidence)
{
WriteCanonical(JsonSerializer.SerializeToElement(ev, CanonicalSerializerOptions), writer);
}
writer.WriteEndArray();
writer.WriteEndObject();
writer.Flush();
var hash = SHA256.HashData(stream.ToArray());
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static void WriteCanonical(JsonElement element, Utf8JsonWriter writer)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
writer.WriteStartObject();
foreach (var prop in element.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal))
{
writer.WritePropertyName(prop.Name);
WriteCanonical(prop.Value, writer);
}
writer.WriteEndObject();
break;
case JsonValueKind.Array:
writer.WriteStartArray();
foreach (var item in element.EnumerateArray())
{
WriteCanonical(item, writer);
}
writer.WriteEndArray();
break;
case JsonValueKind.String:
writer.WriteStringValue(element.GetString());
break;
case JsonValueKind.Number:
writer.WriteRawValue(element.GetRawText(), skipInputValidation: true);
break;
case JsonValueKind.True:
writer.WriteBooleanValue(true);
break;
case JsonValueKind.False:
writer.WriteBooleanValue(false);
break;
case JsonValueKind.Null:
case JsonValueKind.Undefined:
writer.WriteNullValue();
break;
default:
throw new InvalidOperationException($"Unsupported JSON value kind: {element.ValueKind}");
}
}
}