up
This commit is contained in:
@@ -61,6 +61,18 @@ public sealed record CvssThreatMetrics
|
||||
/// <summary>Exploit Maturity (E) - Optional, defaults to Not Defined.</summary>
|
||||
[JsonPropertyName("e")]
|
||||
public ExploitMaturity ExploitMaturity { get; init; } = ExploitMaturity.NotDefined;
|
||||
|
||||
/// <summary>When the threat signal was last observed (UTC).</summary>
|
||||
[JsonPropertyName("observedAt")]
|
||||
public DateTimeOffset? ObservedAt { get; init; }
|
||||
|
||||
/// <summary>When this threat signal should expire.</summary>
|
||||
[JsonPropertyName("expiresAt")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>Source of threat intelligence (kev, epss, internal).</summary>
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -85,6 +85,10 @@ public sealed record CvssScoreReceipt
|
||||
[JsonPropertyName("evidence")]
|
||||
public ImmutableList<CvssEvidenceItem> Evidence { get; init; } = [];
|
||||
|
||||
/// <summary>Export hash for deterministic exports (JSON/PDF).</summary>
|
||||
[JsonPropertyName("exportHash")]
|
||||
public string? ExportHash { get; init; }
|
||||
|
||||
/// <summary>DSSE attestation envelope references, if signed.</summary>
|
||||
[JsonPropertyName("attestationRefs")]
|
||||
public ImmutableList<string> AttestationRefs { get; init; } = [];
|
||||
@@ -101,6 +105,10 @@ public sealed record CvssScoreReceipt
|
||||
[JsonPropertyName("amendsReceiptId")]
|
||||
public string? AmendsReceiptId { get; init; }
|
||||
|
||||
/// <summary>Supersedes prior receipt when policy changes or replays occur.</summary>
|
||||
[JsonPropertyName("supersedesReceiptId")]
|
||||
public string? SupersedesReceiptId { get; init; }
|
||||
|
||||
/// <summary>Whether this receipt is the current active version.</summary>
|
||||
[JsonPropertyName("isActive")]
|
||||
public bool IsActive { get; init; } = true;
|
||||
@@ -224,6 +232,22 @@ public sealed record CvssEvidenceItem
|
||||
/// <summary>Whether this evidence is from the vendor/authority.</summary>
|
||||
[JsonPropertyName("isAuthoritative")]
|
||||
public bool IsAuthoritative { get; init; }
|
||||
|
||||
/// <summary>Retention class (short, standard, long) for this evidence.</summary>
|
||||
[JsonPropertyName("retentionClass")]
|
||||
public string? RetentionClass { get; init; }
|
||||
|
||||
/// <summary>DSSE reference for the evidence, if signed.</summary>
|
||||
[JsonPropertyName("dsseRef")]
|
||||
public string? DsseRef { get; init; }
|
||||
|
||||
/// <summary>Whether the evidence has been redacted to remove sensitive data.</summary>
|
||||
[JsonPropertyName("isRedacted")]
|
||||
public bool? IsRedacted { get; init; }
|
||||
|
||||
/// <summary>When the evidence hash was last verified against CAS.</summary>
|
||||
[JsonPropertyName("verifiedAt")]
|
||||
public DateTimeOffset? VerifiedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Policy.Scoring.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic interoperability helpers between CVSS v3.1 and v4.0 vectors.
|
||||
/// Covers common base metrics; fields without equivalents are omitted.
|
||||
/// </summary>
|
||||
public static class CvssVectorInterop
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> V31ToV4Map = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["AV:N"] = "AV:N",
|
||||
["AV:A"] = "AV:A",
|
||||
["AV:L"] = "AV:L",
|
||||
["AV:P"] = "AV:P",
|
||||
["AC:L"] = "AC:L",
|
||||
["AC:H"] = "AC:H",
|
||||
["PR:N"] = "PR:N",
|
||||
["PR:L"] = "PR:L",
|
||||
["PR:H"] = "PR:H",
|
||||
["UI:N"] = "UI:N",
|
||||
["UI:R"] = "UI:R",
|
||||
["S:U"] = "VC:H,VI:H,VA:H",
|
||||
["S:C"] = "VC:H,VI:H,VA:H",
|
||||
["C:H"] = "VC:H",
|
||||
["C:L"] = "VC:L",
|
||||
["I:H"] = "VI:H",
|
||||
["I:L"] = "VI:L",
|
||||
["A:H"] = "VA:H",
|
||||
["A:L"] = "VA:L"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Converts a CVSS v3.1 base vector into an approximate CVSS v4.0 base vector.
|
||||
/// Outputs only base metrics; threat/environmental must be provided separately.
|
||||
/// </summary>
|
||||
public static string ConvertV31ToV4(string v31Vector)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(v31Vector))
|
||||
{
|
||||
throw new ArgumentException("Vector cannot be null or empty", nameof(v31Vector));
|
||||
}
|
||||
|
||||
var parts = v31Vector.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(p => p.Contains(':'))
|
||||
.ToList();
|
||||
|
||||
var mapped = new List<string> { "CVSS:4.0" };
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (V31ToV4Map.TryGetValue(part, out var v4))
|
||||
{
|
||||
mapped.AddRange(v4.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
}
|
||||
}
|
||||
|
||||
var deduped = mapped.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(p => p == "CVSS:4.0" ? 0 : 1)
|
||||
.ThenBy(p => p, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return string.Join('/', deduped);
|
||||
}
|
||||
}
|
||||
@@ -116,7 +116,9 @@ public sealed class ReceiptBuilder : IReceiptBuilder
|
||||
}),
|
||||
AmendsReceiptId = null,
|
||||
IsActive = true,
|
||||
SupersededReason = null
|
||||
SupersedesReceiptId = null,
|
||||
SupersededReason = null,
|
||||
ExportHash = null
|
||||
};
|
||||
|
||||
if (request.SigningKey is not null)
|
||||
@@ -166,57 +168,24 @@ public sealed class ReceiptBuilder : IReceiptBuilder
|
||||
string vector,
|
||||
ImmutableList<CvssEvidenceItem> evidence)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions
|
||||
using var doc = JsonDocument.Parse(JsonSerializer.Serialize(new
|
||||
{
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Indented = false
|
||||
});
|
||||
vulnerabilityId = request.VulnerabilityId,
|
||||
tenantId = request.TenantId,
|
||||
policyId = policyRef.PolicyId,
|
||||
policyVersion = policyRef.Version,
|
||||
policyHash = policyRef.Hash,
|
||||
vector,
|
||||
baseMetrics = request.BaseMetrics,
|
||||
threatMetrics = request.ThreatMetrics,
|
||||
environmentalMetrics = request.EnvironmentalMetrics,
|
||||
supplementalMetrics = request.SupplementalMetrics,
|
||||
scores,
|
||||
evidence
|
||||
}, SerializerOptions));
|
||||
|
||||
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, SerializerOptions), writer);
|
||||
|
||||
writer.WritePropertyName("threatMetrics");
|
||||
if (request.ThreatMetrics is not null)
|
||||
WriteCanonical(JsonSerializer.SerializeToElement(request.ThreatMetrics, SerializerOptions), writer);
|
||||
else
|
||||
writer.WriteNullValue();
|
||||
|
||||
writer.WritePropertyName("environmentalMetrics");
|
||||
if (request.EnvironmentalMetrics is not null)
|
||||
WriteCanonical(JsonSerializer.SerializeToElement(request.EnvironmentalMetrics, SerializerOptions), writer);
|
||||
else
|
||||
writer.WriteNullValue();
|
||||
|
||||
writer.WritePropertyName("supplementalMetrics");
|
||||
if (request.SupplementalMetrics is not null)
|
||||
WriteCanonical(JsonSerializer.SerializeToElement(request.SupplementalMetrics, SerializerOptions), writer);
|
||||
else
|
||||
writer.WriteNullValue();
|
||||
|
||||
writer.WritePropertyName("scores");
|
||||
WriteCanonical(JsonSerializer.SerializeToElement(scores, SerializerOptions), writer);
|
||||
|
||||
writer.WritePropertyName("evidence");
|
||||
writer.WriteStartArray();
|
||||
foreach (var ev in evidence)
|
||||
{
|
||||
WriteCanonical(JsonSerializer.SerializeToElement(ev, SerializerOptions), writer);
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
|
||||
writer.WriteEndObject();
|
||||
writer.Flush();
|
||||
|
||||
var hash = SHA256.HashData(stream.ToArray());
|
||||
var canonicalBytes = ReceiptCanonicalizer.ToCanonicalBytes(doc.RootElement);
|
||||
var hash = SHA256.HashData(canonicalBytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Policy.Scoring.Receipts;
|
||||
|
||||
/// <summary>
|
||||
/// Provides deterministic JSON canonicalization for receipt hashing.
|
||||
/// Keys are sorted, numbers use invariant culture, and timestamps are formatted as ISO 8601 (O).
|
||||
/// </summary>
|
||||
internal static class ReceiptCanonicalizer
|
||||
{
|
||||
private static readonly JsonWriterOptions WriterOptions = new()
|
||||
{
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Indented = false
|
||||
};
|
||||
|
||||
public static byte[] ToCanonicalBytes(JsonElement element)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new Utf8JsonWriter(stream, WriterOptions);
|
||||
Write(element, writer);
|
||||
writer.Flush();
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
public static void Write(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);
|
||||
Write(prop.Value, writer);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case JsonValueKind.Array:
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
Write(item, writer);
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
break;
|
||||
case JsonValueKind.String:
|
||||
// If the value looks like a timestamp, normalize to ISO 8601 round-trip
|
||||
if (DateTimeOffset.TryParse(element.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dto))
|
||||
{
|
||||
writer.WriteStringValue(dto.ToUniversalTime().ToString("O"));
|
||||
}
|
||||
else
|
||||
{
|
||||
writer.WriteStringValue(element.GetString());
|
||||
}
|
||||
break;
|
||||
case JsonValueKind.Number:
|
||||
if (element.TryGetDouble(out var dbl))
|
||||
{
|
||||
writer.WriteRawValue(dbl.ToString("0.################", CultureInfo.InvariantCulture), skipInputValidation: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,8 @@
|
||||
"supplementalMetrics": {
|
||||
"$ref": "#/$defs/supplementalMetrics"
|
||||
},
|
||||
"exportHash": { "type": "string" },
|
||||
"supersedesReceiptId": { "type": "string" },
|
||||
"scores": {
|
||||
"$ref": "#/$defs/scores"
|
||||
},
|
||||
@@ -120,7 +122,10 @@
|
||||
"threatMetrics": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"e": { "type": "string", "enum": ["NotDefined", "Attacked", "ProofOfConcept", "Unreported"] }
|
||||
"e": { "type": "string", "enum": ["NotDefined", "Attacked", "ProofOfConcept", "Unreported"] },
|
||||
"observedAt": { "type": "string", "format": "date-time" },
|
||||
"expiresAt": { "type": "string", "format": "date-time" },
|
||||
"source": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"environmentalMetrics": {
|
||||
@@ -184,7 +189,11 @@
|
||||
"description": { "type": "string" },
|
||||
"collectedAt": { "type": "string", "format": "date-time" },
|
||||
"source": { "type": "string" },
|
||||
"isAuthoritative": { "type": "boolean", "default": false }
|
||||
"isAuthoritative": { "type": "boolean", "default": false },
|
||||
"retentionClass": { "type": "string" },
|
||||
"dsseRef": { "type": "string" },
|
||||
"isRedacted": { "type": "boolean" },
|
||||
"verifiedAt": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
},
|
||||
"historyEntry": {
|
||||
|
||||
Reference in New Issue
Block a user