Rename Vexer to Excititor
This commit is contained in:
26
src/StellaOps.Excititor.Core/AGENTS.md
Normal file
26
src/StellaOps.Excititor.Core/AGENTS.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# AGENTS
|
||||
## Role
|
||||
Domain source of truth for VEX statements, consensus rollups, and trust policy orchestration across all Excititor services.
|
||||
## Scope
|
||||
- Records for raw document metadata, normalized claims, consensus projections, and export descriptors.
|
||||
- Policy + weighting engine that projects provider trust tiers into consensus status outcomes.
|
||||
- Connector, normalizer, export, and attestation contracts shared by WebService, Worker, and plug-ins.
|
||||
- Deterministic hashing utilities (query signatures, artifact digests, attestation subjects).
|
||||
## Participants
|
||||
- Excititor WebService uses the models to persist ingress/egress payloads and to perform consensus mutations.
|
||||
- Excititor Worker executes reconciliation and verification routines using policy helpers defined here.
|
||||
- Export/Attestation modules depend on record definitions for envelopes and manifest payloads.
|
||||
## Interfaces & contracts
|
||||
- `IVexConnector`, `INormalizer`, `IExportEngine`, `ITransparencyLogClient`, `IArtifactStore`, and policy abstractions for consensus resolution.
|
||||
- Value objects for provider metadata, VexClaim, VexConsensusEntry, ExportManifest, QuerySignature.
|
||||
- Deterministic comparer utilities and stable JSON serialization helpers for tests and cache keys.
|
||||
## In/Out of scope
|
||||
In: domain invariants, policy evaluation helpers, deterministic serialization, shared abstractions.
|
||||
Out: Mongo persistence implementations, HTTP endpoints, background scheduling, concrete connector logic.
|
||||
## Observability & security expectations
|
||||
- Avoid secret handling; provide structured logging extension methods for consensus decisions.
|
||||
- Emit correlation identifiers and query signatures without embedding PII.
|
||||
- Ensure deterministic logging order to keep reproducibility guarantees intact.
|
||||
## Tests
|
||||
- Unit coverage lives in `../StellaOps.Excititor.Core.Tests` (to be scaffolded) focusing on consensus, policy gates, and serialization determinism.
|
||||
- Golden fixtures must rely on canonical JSON snapshots produced via stable serializers.
|
||||
61
src/StellaOps.Excititor.Core/BaselineVexConsensusPolicy.cs
Normal file
61
src/StellaOps.Excititor.Core/BaselineVexConsensusPolicy.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
namespace StellaOps.Excititor.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Baseline consensus policy applying tier-based weights and enforcing justification gates.
|
||||
/// </summary>
|
||||
public sealed class BaselineVexConsensusPolicy : IVexConsensusPolicy
|
||||
{
|
||||
private readonly VexConsensusPolicyOptions _options;
|
||||
|
||||
public BaselineVexConsensusPolicy(VexConsensusPolicyOptions? options = null)
|
||||
{
|
||||
_options = options ?? new VexConsensusPolicyOptions();
|
||||
}
|
||||
|
||||
public string Version => _options.Version;
|
||||
|
||||
public double GetProviderWeight(VexProvider provider)
|
||||
{
|
||||
if (provider is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(provider));
|
||||
}
|
||||
|
||||
if (_options.ProviderOverrides.TryGetValue(provider.Id, out var overrideWeight))
|
||||
{
|
||||
return overrideWeight;
|
||||
}
|
||||
|
||||
return provider.Kind switch
|
||||
{
|
||||
VexProviderKind.Vendor => _options.VendorWeight,
|
||||
VexProviderKind.Distro => _options.DistroWeight,
|
||||
VexProviderKind.Platform => _options.PlatformWeight,
|
||||
VexProviderKind.Hub => _options.HubWeight,
|
||||
VexProviderKind.Attestation => _options.AttestationWeight,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
|
||||
public bool IsClaimEligible(VexClaim claim, VexProvider provider, out string? rejectionReason)
|
||||
{
|
||||
if (claim is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(claim));
|
||||
}
|
||||
|
||||
if (provider is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(provider));
|
||||
}
|
||||
|
||||
if (claim.Status is VexClaimStatus.NotAffected && claim.Justification is null)
|
||||
{
|
||||
rejectionReason = "missing_justification";
|
||||
return false;
|
||||
}
|
||||
|
||||
rejectionReason = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
26
src/StellaOps.Excititor.Core/IVexConsensusPolicy.cs
Normal file
26
src/StellaOps.Excititor.Core/IVexConsensusPolicy.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
namespace StellaOps.Excititor.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Policy abstraction supplying trust weights and gating logic for consensus decisions.
|
||||
/// </summary>
|
||||
public interface IVexConsensusPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Semantic version describing the active policy.
|
||||
/// </summary>
|
||||
string Version { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns the effective weight (0-1) to apply for the provided VEX source.
|
||||
/// </summary>
|
||||
double GetProviderWeight(VexProvider provider);
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the claim is eligible to participate in consensus.
|
||||
/// </summary>
|
||||
/// <param name="claim">Normalized claim to evaluate.</param>
|
||||
/// <param name="provider">Provider metadata for the claim.</param>
|
||||
/// <param name="rejectionReason">Textual reason when the claim is rejected.</param>
|
||||
/// <returns><c>true</c> if the claim should participate; <c>false</c> otherwise.</returns>
|
||||
bool IsClaimEligible(VexClaim claim, VexProvider provider, out string? rejectionReason);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
9
src/StellaOps.Excititor.Core/TASKS.md
Normal file
9
src/StellaOps.Excititor.Core/TASKS.md
Normal file
@@ -0,0 +1,9 @@
|
||||
If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md and ./AGENTS.md).
|
||||
# TASKS
|
||||
| Task | Owner(s) | Depends on | Notes |
|
||||
|---|---|---|---|
|
||||
|EXCITITOR-CORE-01-001 – Canonical VEX domain records|Team Excititor Core & Policy|docs/ARCHITECTURE_EXCITITOR.md|DONE (2025-10-15) – Introduced `VexClaim`, `VexConsensus`, provider metadata, export manifest records, and deterministic JSON serialization with tests covering canonical ordering and query signatures.|
|
||||
|EXCITITOR-CORE-01-002 – Trust-weighted consensus resolver|Team Excititor Core & Policy|EXCITITOR-CORE-01-001|DONE (2025-10-15) – Added consensus resolver, baseline policy (tier weights + justification gate), telemetry output, and tests covering acceptance, conflict ties, and determinism.|
|
||||
|EXCITITOR-CORE-01-003 – Shared contracts & query signatures|Team Excititor Core & Policy|EXCITITOR-CORE-01-001|DONE (2025-10-15) – Published connector/normalizer/exporter/attestation abstractions and expanded deterministic `VexQuerySignature`/hash utilities with test coverage.|
|
||||
|EXCITITOR-CORE-02-001 – Context signal schema prep|Team Excititor Core & Policy|EXCITITOR-POLICY-02-001|TODO – Extend `VexClaim`/`VexConsensus` with optional severity/KEV/EPSS payloads, update canonical serializer/hashes, and coordinate migration notes with Storage.|
|
||||
|EXCITITOR-CORE-02-002 – Deterministic risk scoring engine|Team Excititor Core & Policy|EXCITITOR-CORE-02-001, EXCITITOR-POLICY-02-001|BACKLOG – Introduce the scoring calculator invoked by consensus, persist score envelopes with audit trails, and add regression fixtures covering gate/boost behaviour before enabling exports.|
|
||||
30
src/StellaOps.Excititor.Core/VexAttestationAbstractions.cs
Normal file
30
src/StellaOps.Excititor.Core/VexAttestationAbstractions.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Excititor.Core;
|
||||
|
||||
public interface IVexAttestationClient
|
||||
{
|
||||
ValueTask<VexAttestationResponse> SignAsync(VexAttestationRequest request, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record VexAttestationRequest(
|
||||
string ExportId,
|
||||
VexQuerySignature QuerySignature,
|
||||
VexContentAddress Artifact,
|
||||
VexExportFormat Format,
|
||||
DateTimeOffset CreatedAt,
|
||||
ImmutableArray<string> SourceProviders,
|
||||
ImmutableDictionary<string, string> Metadata);
|
||||
|
||||
public sealed record VexAttestationResponse(
|
||||
VexAttestationMetadata Attestation,
|
||||
ImmutableDictionary<string, string> Diagnostics);
|
||||
|
||||
public sealed record VexAttestationVerification(
|
||||
bool IsValid,
|
||||
ImmutableDictionary<string, string> Diagnostics);
|
||||
56
src/StellaOps.Excititor.Core/VexCacheEntry.cs
Normal file
56
src/StellaOps.Excititor.Core/VexCacheEntry.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Excititor.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Cached export artifact metadata allowing reuse of previously generated manifests.
|
||||
/// </summary>
|
||||
public sealed class VexCacheEntry
|
||||
{
|
||||
public VexCacheEntry(
|
||||
VexQuerySignature querySignature,
|
||||
VexExportFormat format,
|
||||
VexContentAddress artifact,
|
||||
DateTimeOffset createdAt,
|
||||
long sizeBytes,
|
||||
string? manifestId = null,
|
||||
string? gridFsObjectId = null,
|
||||
DateTimeOffset? expiresAt = null)
|
||||
{
|
||||
QuerySignature = querySignature ?? throw new ArgumentNullException(nameof(querySignature));
|
||||
Artifact = artifact ?? throw new ArgumentNullException(nameof(artifact));
|
||||
Format = format;
|
||||
CreatedAt = createdAt;
|
||||
SizeBytes = sizeBytes >= 0
|
||||
? sizeBytes
|
||||
: throw new ArgumentOutOfRangeException(nameof(sizeBytes), sizeBytes, "Size must be non-negative.");
|
||||
ManifestId = Normalize(manifestId);
|
||||
GridFsObjectId = Normalize(gridFsObjectId);
|
||||
|
||||
if (expiresAt.HasValue && expiresAt.Value < createdAt)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(expiresAt), expiresAt, "Expiration cannot be before creation.");
|
||||
}
|
||||
|
||||
ExpiresAt = expiresAt;
|
||||
}
|
||||
|
||||
public VexQuerySignature QuerySignature { get; }
|
||||
|
||||
public VexExportFormat Format { get; }
|
||||
|
||||
public VexContentAddress Artifact { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public long SizeBytes { get; }
|
||||
|
||||
public string? ManifestId { get; }
|
||||
|
||||
public string? GridFsObjectId { get; }
|
||||
|
||||
public DateTimeOffset? ExpiresAt { get; }
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
494
src/StellaOps.Excititor.Core/VexCanonicalJsonSerializer.cs
Normal file
494
src/StellaOps.Excititor.Core/VexCanonicalJsonSerializer.cs
Normal file
@@ -0,0 +1,494 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
namespace StellaOps.Excititor.Core;
|
||||
|
||||
public static class VexCanonicalJsonSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions CompactOptions = CreateOptions(writeIndented: false);
|
||||
private static readonly JsonSerializerOptions PrettyOptions = CreateOptions(writeIndented: true);
|
||||
|
||||
private static readonly IReadOnlyDictionary<Type, string[]> PropertyOrderOverrides = new Dictionary<Type, string[]>
|
||||
{
|
||||
{
|
||||
typeof(VexProvider),
|
||||
new[]
|
||||
{
|
||||
"id",
|
||||
"displayName",
|
||||
"kind",
|
||||
"baseUris",
|
||||
"discovery",
|
||||
"trust",
|
||||
"enabled",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexProviderDiscovery),
|
||||
new[]
|
||||
{
|
||||
"wellKnownMetadata",
|
||||
"rolIeService",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexProviderTrust),
|
||||
new[]
|
||||
{
|
||||
"weight",
|
||||
"cosign",
|
||||
"pgpFingerprints",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexCosignTrust),
|
||||
new[]
|
||||
{
|
||||
"issuer",
|
||||
"identityPattern",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexClaim),
|
||||
new[]
|
||||
{
|
||||
"vulnerabilityId",
|
||||
"providerId",
|
||||
"product",
|
||||
"status",
|
||||
"justification",
|
||||
"detail",
|
||||
"document",
|
||||
"firstSeen",
|
||||
"lastSeen",
|
||||
"confidence",
|
||||
"additionalMetadata",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexProduct),
|
||||
new[]
|
||||
{
|
||||
"key",
|
||||
"name",
|
||||
"version",
|
||||
"purl",
|
||||
"cpe",
|
||||
"componentIdentifiers",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexClaimDocument),
|
||||
new[]
|
||||
{
|
||||
"format",
|
||||
"digest",
|
||||
"sourceUri",
|
||||
"revision",
|
||||
"signature",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexSignatureMetadata),
|
||||
new[]
|
||||
{
|
||||
"type",
|
||||
"subject",
|
||||
"issuer",
|
||||
"keyId",
|
||||
"verifiedAt",
|
||||
"transparencyLogReference",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexConfidence),
|
||||
new[]
|
||||
{
|
||||
"level",
|
||||
"score",
|
||||
"method",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexConsensus),
|
||||
new[]
|
||||
{
|
||||
"vulnerabilityId",
|
||||
"product",
|
||||
"status",
|
||||
"calculatedAt",
|
||||
"sources",
|
||||
"conflicts",
|
||||
"policyVersion",
|
||||
"summary",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexConsensusSource),
|
||||
new[]
|
||||
{
|
||||
"providerId",
|
||||
"status",
|
||||
"documentDigest",
|
||||
"weight",
|
||||
"justification",
|
||||
"detail",
|
||||
"confidence",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexConsensusConflict),
|
||||
new[]
|
||||
{
|
||||
"providerId",
|
||||
"status",
|
||||
"documentDigest",
|
||||
"justification",
|
||||
"detail",
|
||||
"reason",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexConnectorSettings),
|
||||
new[]
|
||||
{
|
||||
"values",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexConnectorContext),
|
||||
new[]
|
||||
{
|
||||
"since",
|
||||
"settings",
|
||||
"rawSink",
|
||||
"signatureVerifier",
|
||||
"normalizers",
|
||||
"services",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexRawDocument),
|
||||
new[]
|
||||
{
|
||||
"documentId",
|
||||
"providerId",
|
||||
"format",
|
||||
"sourceUri",
|
||||
"retrievedAt",
|
||||
"digest",
|
||||
"content",
|
||||
"metadata",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexClaimBatch),
|
||||
new[]
|
||||
{
|
||||
"source",
|
||||
"claims",
|
||||
"diagnostics",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexExportManifest),
|
||||
new[]
|
||||
{
|
||||
"exportId",
|
||||
"querySignature",
|
||||
"format",
|
||||
"createdAt",
|
||||
"artifact",
|
||||
"claimCount",
|
||||
"fromCache",
|
||||
"sourceProviders",
|
||||
"consensusRevision",
|
||||
"attestation",
|
||||
"sizeBytes",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexContentAddress),
|
||||
new[]
|
||||
{
|
||||
"algorithm",
|
||||
"digest",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexAttestationMetadata),
|
||||
new[]
|
||||
{
|
||||
"predicateType",
|
||||
"rekor",
|
||||
"envelopeDigest",
|
||||
"signedAt",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexRekorReference),
|
||||
new[]
|
||||
{
|
||||
"apiVersion",
|
||||
"location",
|
||||
"logIndex",
|
||||
"inclusionProofUri",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexQuerySignature),
|
||||
new[]
|
||||
{
|
||||
"value",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexQuery),
|
||||
new[]
|
||||
{
|
||||
"filters",
|
||||
"sort",
|
||||
"limit",
|
||||
"offset",
|
||||
"view",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexQueryFilter),
|
||||
new[]
|
||||
{
|
||||
"key",
|
||||
"value",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexQuerySort),
|
||||
new[]
|
||||
{
|
||||
"field",
|
||||
"descending",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexExportRequest),
|
||||
new[]
|
||||
{
|
||||
"query",
|
||||
"consensus",
|
||||
"claims",
|
||||
"generatedAt",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexExportResult),
|
||||
new[]
|
||||
{
|
||||
"digest",
|
||||
"bytesWritten",
|
||||
"metadata",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexAttestationRequest),
|
||||
new[]
|
||||
{
|
||||
"querySignature",
|
||||
"artifact",
|
||||
"format",
|
||||
"createdAt",
|
||||
"metadata",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexAttestationResponse),
|
||||
new[]
|
||||
{
|
||||
"attestation",
|
||||
"diagnostics",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(VexAttestationVerification),
|
||||
new[]
|
||||
{
|
||||
"isValid",
|
||||
"diagnostics",
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
public static string Serialize<T>(T value)
|
||||
=> JsonSerializer.Serialize(value, CompactOptions);
|
||||
|
||||
public static string SerializeIndented<T>(T value)
|
||||
=> JsonSerializer.Serialize(value, PrettyOptions);
|
||||
|
||||
public static T Deserialize<T>(string json)
|
||||
=> JsonSerializer.Deserialize<T>(json, PrettyOptions)
|
||||
?? throw new InvalidOperationException($"Unable to deserialize type {typeof(T).Name}.");
|
||||
|
||||
private static JsonSerializerOptions CreateOptions(bool writeIndented)
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
WriteIndented = writeIndented,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
var baselineResolver = options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver();
|
||||
options.TypeInfoResolver = new DeterministicTypeInfoResolver(baselineResolver);
|
||||
options.Converters.Add(new EnumMemberJsonConverterFactory());
|
||||
return options;
|
||||
}
|
||||
|
||||
private sealed class DeterministicTypeInfoResolver : IJsonTypeInfoResolver
|
||||
{
|
||||
private readonly IJsonTypeInfoResolver _inner;
|
||||
|
||||
public DeterministicTypeInfoResolver(IJsonTypeInfoResolver inner)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
}
|
||||
|
||||
public JsonTypeInfo? GetTypeInfo(Type type, JsonSerializerOptions options)
|
||||
{
|
||||
var info = _inner.GetTypeInfo(type, options);
|
||||
if (info is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (info.Kind is JsonTypeInfoKind.Object && info.Properties is { Count: > 1 })
|
||||
{
|
||||
var ordered = info.Properties
|
||||
.OrderBy(property => GetPropertyOrder(type, property.Name))
|
||||
.ThenBy(property => property.Name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
info.Properties.Clear();
|
||||
foreach (var property in ordered)
|
||||
{
|
||||
info.Properties.Add(property);
|
||||
}
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
private static int GetPropertyOrder(Type type, string propertyName)
|
||||
{
|
||||
if (PropertyOrderOverrides.TryGetValue(type, out var order) &&
|
||||
Array.IndexOf(order, propertyName) is var index &&
|
||||
index >= 0)
|
||||
{
|
||||
return index;
|
||||
}
|
||||
|
||||
return int.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EnumMemberJsonConverterFactory : JsonConverterFactory
|
||||
{
|
||||
public override bool CanConvert(Type typeToConvert)
|
||||
{
|
||||
var type = Nullable.GetUnderlyingType(typeToConvert) ?? typeToConvert;
|
||||
return type.IsEnum;
|
||||
}
|
||||
|
||||
public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var underlying = Nullable.GetUnderlyingType(typeToConvert);
|
||||
if (underlying is not null)
|
||||
{
|
||||
var nullableConverterType = typeof(NullableEnumMemberJsonConverter<>).MakeGenericType(underlying);
|
||||
return (JsonConverter)Activator.CreateInstance(nullableConverterType)!;
|
||||
}
|
||||
|
||||
var converterType = typeof(EnumMemberJsonConverter<>).MakeGenericType(typeToConvert);
|
||||
return (JsonConverter)Activator.CreateInstance(converterType)!;
|
||||
}
|
||||
|
||||
private sealed class EnumMemberJsonConverter<T> : JsonConverter<T>
|
||||
where T : struct, Enum
|
||||
{
|
||||
private readonly Dictionary<string, T> _nameToValue;
|
||||
private readonly Dictionary<T, string> _valueToName;
|
||||
|
||||
public EnumMemberJsonConverter()
|
||||
{
|
||||
_nameToValue = new Dictionary<string, T>(StringComparer.Ordinal);
|
||||
_valueToName = new Dictionary<T, string>();
|
||||
foreach (var value in Enum.GetValues<T>())
|
||||
{
|
||||
var name = value.ToString();
|
||||
var enumMember = typeof(T).GetField(name)!.GetCustomAttribute<EnumMemberAttribute>();
|
||||
var text = enumMember?.Value ?? name;
|
||||
_nameToValue[text] = value;
|
||||
_valueToName[value] = text;
|
||||
}
|
||||
}
|
||||
|
||||
public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType != JsonTokenType.String)
|
||||
{
|
||||
throw new JsonException($"Unexpected token '{reader.TokenType}' when parsing enum '{typeof(T).Name}'.");
|
||||
}
|
||||
|
||||
var text = reader.GetString();
|
||||
if (text is null || !_nameToValue.TryGetValue(text, out var value))
|
||||
{
|
||||
throw new JsonException($"Value '{text}' is not defined for enum '{typeof(T).Name}'.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
|
||||
{
|
||||
if (!_valueToName.TryGetValue(value, out var text))
|
||||
{
|
||||
throw new JsonException($"Value '{value}' is not defined for enum '{typeof(T).Name}'.");
|
||||
}
|
||||
|
||||
writer.WriteStringValue(text);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NullableEnumMemberJsonConverter<T> : JsonConverter<T?>
|
||||
where T : struct, Enum
|
||||
{
|
||||
private readonly EnumMemberJsonConverter<T> _inner = new();
|
||||
|
||||
public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.Null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return _inner.Read(ref reader, typeof(T), options);
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, T? value, JsonSerializerOptions options)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
writer.WriteNullValue();
|
||||
return;
|
||||
}
|
||||
|
||||
_inner.Write(writer, value.Value, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
326
src/StellaOps.Excititor.Core/VexClaim.cs
Normal file
326
src/StellaOps.Excititor.Core/VexClaim.cs
Normal file
@@ -0,0 +1,326 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Runtime.Serialization;
|
||||
|
||||
namespace StellaOps.Excititor.Core;
|
||||
|
||||
public sealed record VexClaim
|
||||
{
|
||||
public VexClaim(
|
||||
string vulnerabilityId,
|
||||
string providerId,
|
||||
VexProduct product,
|
||||
VexClaimStatus status,
|
||||
VexClaimDocument document,
|
||||
DateTimeOffset firstSeen,
|
||||
DateTimeOffset lastSeen,
|
||||
VexJustification? justification = null,
|
||||
string? detail = null,
|
||||
VexConfidence? confidence = null,
|
||||
ImmutableDictionary<string, string>? additionalMetadata = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(vulnerabilityId))
|
||||
{
|
||||
throw new ArgumentException("Vulnerability id must be provided.", nameof(vulnerabilityId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(providerId))
|
||||
{
|
||||
throw new ArgumentException("Provider id must be provided.", nameof(providerId));
|
||||
}
|
||||
|
||||
if (lastSeen < firstSeen)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(lastSeen), "Last seen timestamp cannot be earlier than first seen.");
|
||||
}
|
||||
|
||||
VulnerabilityId = vulnerabilityId.Trim();
|
||||
ProviderId = providerId.Trim();
|
||||
Product = product ?? throw new ArgumentNullException(nameof(product));
|
||||
Status = status;
|
||||
Document = document ?? throw new ArgumentNullException(nameof(document));
|
||||
FirstSeen = firstSeen;
|
||||
LastSeen = lastSeen;
|
||||
Justification = justification;
|
||||
Detail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim();
|
||||
Confidence = confidence;
|
||||
AdditionalMetadata = NormalizeMetadata(additionalMetadata);
|
||||
}
|
||||
|
||||
public string VulnerabilityId { get; }
|
||||
|
||||
public string ProviderId { get; }
|
||||
|
||||
public VexProduct Product { get; }
|
||||
|
||||
public VexClaimStatus Status { get; }
|
||||
|
||||
public VexJustification? Justification { get; }
|
||||
|
||||
public string? Detail { get; }
|
||||
|
||||
public VexClaimDocument Document { get; }
|
||||
|
||||
public DateTimeOffset FirstSeen { get; }
|
||||
|
||||
public DateTimeOffset LastSeen { get; }
|
||||
|
||||
public VexConfidence? Confidence { get; }
|
||||
|
||||
public ImmutableSortedDictionary<string, string> AdditionalMetadata { get; }
|
||||
|
||||
private static ImmutableSortedDictionary<string, string> NormalizeMetadata(
|
||||
ImmutableDictionary<string, string>? additionalMetadata)
|
||||
{
|
||||
if (additionalMetadata is null || additionalMetadata.Count == 0)
|
||||
{
|
||||
return ImmutableSortedDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in additionalMetadata)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder[key.Trim()] = value?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record VexProduct
|
||||
{
|
||||
public VexProduct(
|
||||
string key,
|
||||
string? name,
|
||||
string? version = null,
|
||||
string? purl = null,
|
||||
string? cpe = null,
|
||||
IEnumerable<string>? componentIdentifiers = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
throw new ArgumentException("Product key must be provided.", nameof(key));
|
||||
}
|
||||
|
||||
Key = key.Trim();
|
||||
Name = string.IsNullOrWhiteSpace(name) ? null : name.Trim();
|
||||
Version = string.IsNullOrWhiteSpace(version) ? null : version.Trim();
|
||||
Purl = string.IsNullOrWhiteSpace(purl) ? null : purl.Trim();
|
||||
Cpe = string.IsNullOrWhiteSpace(cpe) ? null : cpe.Trim();
|
||||
ComponentIdentifiers = NormalizeComponentIdentifiers(componentIdentifiers);
|
||||
}
|
||||
|
||||
public string Key { get; }
|
||||
|
||||
public string? Name { get; }
|
||||
|
||||
public string? Version { get; }
|
||||
|
||||
public string? Purl { get; }
|
||||
|
||||
public string? Cpe { get; }
|
||||
|
||||
public ImmutableArray<string> ComponentIdentifiers { get; }
|
||||
|
||||
private static ImmutableArray<string> NormalizeComponentIdentifiers(IEnumerable<string>? identifiers)
|
||||
{
|
||||
if (identifiers is null)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
var set = new SortedSet<string>(StringComparer.Ordinal);
|
||||
foreach (var identifier in identifiers)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(identifier))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
set.Add(identifier.Trim());
|
||||
}
|
||||
|
||||
return set.Count == 0 ? ImmutableArray<string>.Empty : set.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record VexClaimDocument
|
||||
{
|
||||
public VexClaimDocument(
|
||||
VexDocumentFormat format,
|
||||
string digest,
|
||||
Uri sourceUri,
|
||||
string? revision = null,
|
||||
VexSignatureMetadata? signature = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
throw new ArgumentException("Document digest must be provided.", nameof(digest));
|
||||
}
|
||||
|
||||
Format = format;
|
||||
Digest = digest.Trim();
|
||||
SourceUri = sourceUri ?? throw new ArgumentNullException(nameof(sourceUri));
|
||||
Revision = string.IsNullOrWhiteSpace(revision) ? null : revision.Trim();
|
||||
Signature = signature;
|
||||
}
|
||||
|
||||
public VexDocumentFormat Format { get; }
|
||||
|
||||
public string Digest { get; }
|
||||
|
||||
public Uri SourceUri { get; }
|
||||
|
||||
public string? Revision { get; }
|
||||
|
||||
public VexSignatureMetadata? Signature { get; }
|
||||
}
|
||||
|
||||
public sealed record VexSignatureMetadata
|
||||
{
|
||||
public VexSignatureMetadata(
|
||||
string type,
|
||||
string? subject = null,
|
||||
string? issuer = null,
|
||||
string? keyId = null,
|
||||
DateTimeOffset? verifiedAt = null,
|
||||
string? transparencyLogReference = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(type))
|
||||
{
|
||||
throw new ArgumentException("Signature type must be provided.", nameof(type));
|
||||
}
|
||||
|
||||
Type = type.Trim();
|
||||
Subject = string.IsNullOrWhiteSpace(subject) ? null : subject.Trim();
|
||||
Issuer = string.IsNullOrWhiteSpace(issuer) ? null : issuer.Trim();
|
||||
KeyId = string.IsNullOrWhiteSpace(keyId) ? null : keyId.Trim();
|
||||
VerifiedAt = verifiedAt;
|
||||
TransparencyLogReference = string.IsNullOrWhiteSpace(transparencyLogReference)
|
||||
? null
|
||||
: transparencyLogReference.Trim();
|
||||
}
|
||||
|
||||
public string Type { get; }
|
||||
|
||||
public string? Subject { get; }
|
||||
|
||||
public string? Issuer { get; }
|
||||
|
||||
public string? KeyId { get; }
|
||||
|
||||
public DateTimeOffset? VerifiedAt { get; }
|
||||
|
||||
public string? TransparencyLogReference { get; }
|
||||
}
|
||||
|
||||
public sealed record VexConfidence
|
||||
{
|
||||
public VexConfidence(string level, double? score = null, string? method = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(level))
|
||||
{
|
||||
throw new ArgumentException("Confidence level must be provided.", nameof(level));
|
||||
}
|
||||
|
||||
if (score is not null && (double.IsNaN(score.Value) || double.IsInfinity(score.Value)))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(score), "Confidence score must be a finite number.");
|
||||
}
|
||||
|
||||
Level = level.Trim();
|
||||
Score = score;
|
||||
Method = string.IsNullOrWhiteSpace(method) ? null : method.Trim();
|
||||
}
|
||||
|
||||
public string Level { get; }
|
||||
|
||||
public double? Score { get; }
|
||||
|
||||
public string? Method { get; }
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public enum VexDocumentFormat
|
||||
{
|
||||
[EnumMember(Value = "csaf")]
|
||||
Csaf,
|
||||
|
||||
[EnumMember(Value = "cyclonedx")]
|
||||
CycloneDx,
|
||||
|
||||
[EnumMember(Value = "openvex")]
|
||||
OpenVex,
|
||||
|
||||
[EnumMember(Value = "oci_attestation")]
|
||||
OciAttestation,
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public enum VexClaimStatus
|
||||
{
|
||||
[EnumMember(Value = "affected")]
|
||||
Affected,
|
||||
|
||||
[EnumMember(Value = "not_affected")]
|
||||
NotAffected,
|
||||
|
||||
[EnumMember(Value = "fixed")]
|
||||
Fixed,
|
||||
|
||||
[EnumMember(Value = "under_investigation")]
|
||||
UnderInvestigation,
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public enum VexJustification
|
||||
{
|
||||
[EnumMember(Value = "component_not_present")]
|
||||
ComponentNotPresent,
|
||||
|
||||
[EnumMember(Value = "component_not_configured")]
|
||||
ComponentNotConfigured,
|
||||
|
||||
[EnumMember(Value = "vulnerable_code_not_present")]
|
||||
VulnerableCodeNotPresent,
|
||||
|
||||
[EnumMember(Value = "vulnerable_code_not_in_execute_path")]
|
||||
VulnerableCodeNotInExecutePath,
|
||||
|
||||
[EnumMember(Value = "vulnerable_code_cannot_be_controlled_by_adversary")]
|
||||
VulnerableCodeCannotBeControlledByAdversary,
|
||||
|
||||
[EnumMember(Value = "inline_mitigations_already_exist")]
|
||||
InlineMitigationsAlreadyExist,
|
||||
|
||||
[EnumMember(Value = "protected_by_mitigating_control")]
|
||||
ProtectedByMitigatingControl,
|
||||
|
||||
[EnumMember(Value = "code_not_present")]
|
||||
CodeNotPresent,
|
||||
|
||||
[EnumMember(Value = "code_not_reachable")]
|
||||
CodeNotReachable,
|
||||
|
||||
[EnumMember(Value = "requires_configuration")]
|
||||
RequiresConfiguration,
|
||||
|
||||
[EnumMember(Value = "requires_dependency")]
|
||||
RequiresDependency,
|
||||
|
||||
[EnumMember(Value = "requires_environment")]
|
||||
RequiresEnvironment,
|
||||
|
||||
[EnumMember(Value = "protected_by_compensating_control")]
|
||||
ProtectedByCompensatingControl,
|
||||
|
||||
[EnumMember(Value = "protected_at_perimeter")]
|
||||
ProtectedAtPerimeter,
|
||||
|
||||
[EnumMember(Value = "protected_at_runtime")]
|
||||
ProtectedAtRuntime,
|
||||
}
|
||||
88
src/StellaOps.Excititor.Core/VexConnectorAbstractions.cs
Normal file
88
src/StellaOps.Excititor.Core/VexConnectorAbstractions.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Excititor.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Shared connector contract for fetching and normalizing provider-specific VEX data.
|
||||
/// </summary>
|
||||
public interface IVexConnector
|
||||
{
|
||||
string Id { get; }
|
||||
|
||||
VexProviderKind Kind { get; }
|
||||
|
||||
ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken);
|
||||
|
||||
IAsyncEnumerable<VexRawDocument> FetchAsync(VexConnectorContext context, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connector context populated by the orchestrator/worker.
|
||||
/// </summary>
|
||||
public sealed record VexConnectorContext(
|
||||
DateTimeOffset? Since,
|
||||
VexConnectorSettings Settings,
|
||||
IVexRawDocumentSink RawSink,
|
||||
IVexSignatureVerifier SignatureVerifier,
|
||||
IVexNormalizerRouter Normalizers,
|
||||
IServiceProvider Services);
|
||||
|
||||
/// <summary>
|
||||
/// Normalized connector configuration values.
|
||||
/// </summary>
|
||||
public sealed record VexConnectorSettings(ImmutableDictionary<string, string> Values)
|
||||
{
|
||||
public static VexConnectorSettings Empty { get; } = new(ImmutableDictionary<string, string>.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raw document retrieved from a connector pull.
|
||||
/// </summary>
|
||||
public sealed record VexRawDocument(
|
||||
string ProviderId,
|
||||
VexDocumentFormat Format,
|
||||
Uri SourceUri,
|
||||
DateTimeOffset RetrievedAt,
|
||||
string Digest,
|
||||
ReadOnlyMemory<byte> Content,
|
||||
ImmutableDictionary<string, string> Metadata)
|
||||
{
|
||||
public Guid DocumentId { get; init; } = Guid.NewGuid();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch of normalized claims derived from a raw document.
|
||||
/// </summary>
|
||||
public sealed record VexClaimBatch(
|
||||
VexRawDocument Source,
|
||||
ImmutableArray<VexClaim> Claims,
|
||||
ImmutableDictionary<string, string> Diagnostics);
|
||||
|
||||
/// <summary>
|
||||
/// Sink abstraction allowing connectors to stream raw documents for persistence.
|
||||
/// </summary>
|
||||
public interface IVexRawDocumentSink
|
||||
{
|
||||
ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature/attestation verification service used while ingesting documents.
|
||||
/// </summary>
|
||||
public interface IVexSignatureVerifier
|
||||
{
|
||||
ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizer router providing format-specific normalization helpers.
|
||||
/// </summary>
|
||||
public interface IVexNormalizerRouter
|
||||
{
|
||||
ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken);
|
||||
}
|
||||
202
src/StellaOps.Excititor.Core/VexConsensus.cs
Normal file
202
src/StellaOps.Excititor.Core/VexConsensus.cs
Normal file
@@ -0,0 +1,202 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Runtime.Serialization;
|
||||
|
||||
namespace StellaOps.Excititor.Core;
|
||||
|
||||
public sealed record VexConsensus
|
||||
{
|
||||
public VexConsensus(
|
||||
string vulnerabilityId,
|
||||
VexProduct product,
|
||||
VexConsensusStatus status,
|
||||
DateTimeOffset calculatedAt,
|
||||
IEnumerable<VexConsensusSource> sources,
|
||||
IEnumerable<VexConsensusConflict>? conflicts = null,
|
||||
string? policyVersion = null,
|
||||
string? summary = null,
|
||||
string? policyRevisionId = null,
|
||||
string? policyDigest = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(vulnerabilityId))
|
||||
{
|
||||
throw new ArgumentException("Vulnerability id must be provided.", nameof(vulnerabilityId));
|
||||
}
|
||||
|
||||
VulnerabilityId = vulnerabilityId.Trim();
|
||||
Product = product ?? throw new ArgumentNullException(nameof(product));
|
||||
Status = status;
|
||||
CalculatedAt = calculatedAt;
|
||||
Sources = NormalizeSources(sources);
|
||||
Conflicts = NormalizeConflicts(conflicts);
|
||||
PolicyVersion = string.IsNullOrWhiteSpace(policyVersion) ? null : policyVersion.Trim();
|
||||
Summary = string.IsNullOrWhiteSpace(summary) ? null : summary.Trim();
|
||||
PolicyRevisionId = string.IsNullOrWhiteSpace(policyRevisionId) ? null : policyRevisionId.Trim();
|
||||
PolicyDigest = string.IsNullOrWhiteSpace(policyDigest) ? null : policyDigest.Trim();
|
||||
}
|
||||
|
||||
public string VulnerabilityId { get; }
|
||||
|
||||
public VexProduct Product { get; }
|
||||
|
||||
public VexConsensusStatus Status { get; }
|
||||
|
||||
public DateTimeOffset CalculatedAt { get; }
|
||||
|
||||
public ImmutableArray<VexConsensusSource> Sources { get; }
|
||||
|
||||
public ImmutableArray<VexConsensusConflict> Conflicts { get; }
|
||||
|
||||
public string? PolicyVersion { get; }
|
||||
|
||||
public string? Summary { get; }
|
||||
|
||||
public string? PolicyRevisionId { get; }
|
||||
|
||||
public string? PolicyDigest { get; }
|
||||
|
||||
private static ImmutableArray<VexConsensusSource> NormalizeSources(IEnumerable<VexConsensusSource> sources)
|
||||
{
|
||||
if (sources is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(sources));
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<VexConsensusSource>();
|
||||
builder.AddRange(sources);
|
||||
if (builder.Count == 0)
|
||||
{
|
||||
return ImmutableArray<VexConsensusSource>.Empty;
|
||||
}
|
||||
|
||||
return builder
|
||||
.OrderBy(static x => x.ProviderId, StringComparer.Ordinal)
|
||||
.ThenBy(static x => x.DocumentDigest, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<VexConsensusConflict> NormalizeConflicts(IEnumerable<VexConsensusConflict>? conflicts)
|
||||
{
|
||||
if (conflicts is null)
|
||||
{
|
||||
return ImmutableArray<VexConsensusConflict>.Empty;
|
||||
}
|
||||
|
||||
var items = conflicts.ToArray();
|
||||
return items.Length == 0
|
||||
? ImmutableArray<VexConsensusConflict>.Empty
|
||||
: items
|
||||
.OrderBy(static x => x.ProviderId, StringComparer.Ordinal)
|
||||
.ThenBy(static x => x.DocumentDigest, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record VexConsensusSource
|
||||
{
|
||||
public VexConsensusSource(
|
||||
string providerId,
|
||||
VexClaimStatus status,
|
||||
string documentDigest,
|
||||
double weight,
|
||||
VexJustification? justification = null,
|
||||
string? detail = null,
|
||||
VexConfidence? confidence = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(providerId))
|
||||
{
|
||||
throw new ArgumentException("Provider id must be provided.", nameof(providerId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(documentDigest))
|
||||
{
|
||||
throw new ArgumentException("Document digest must be provided.", nameof(documentDigest));
|
||||
}
|
||||
|
||||
if (double.IsNaN(weight) || double.IsInfinity(weight) || weight < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(weight), "Weight must be a finite, non-negative number.");
|
||||
}
|
||||
|
||||
ProviderId = providerId.Trim();
|
||||
Status = status;
|
||||
DocumentDigest = documentDigest.Trim();
|
||||
Weight = weight;
|
||||
Justification = justification;
|
||||
Detail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim();
|
||||
Confidence = confidence;
|
||||
}
|
||||
|
||||
public string ProviderId { get; }
|
||||
|
||||
public VexClaimStatus Status { get; }
|
||||
|
||||
public string DocumentDigest { get; }
|
||||
|
||||
public double Weight { get; }
|
||||
|
||||
public VexJustification? Justification { get; }
|
||||
|
||||
public string? Detail { get; }
|
||||
|
||||
public VexConfidence? Confidence { get; }
|
||||
}
|
||||
|
||||
public sealed record VexConsensusConflict
|
||||
{
|
||||
public VexConsensusConflict(
|
||||
string providerId,
|
||||
VexClaimStatus status,
|
||||
string documentDigest,
|
||||
VexJustification? justification = null,
|
||||
string? detail = null,
|
||||
string? reason = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(providerId))
|
||||
{
|
||||
throw new ArgumentException("Provider id must be provided.", nameof(providerId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(documentDigest))
|
||||
{
|
||||
throw new ArgumentException("Document digest must be provided.", nameof(documentDigest));
|
||||
}
|
||||
|
||||
ProviderId = providerId.Trim();
|
||||
Status = status;
|
||||
DocumentDigest = documentDigest.Trim();
|
||||
Justification = justification;
|
||||
Detail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim();
|
||||
Reason = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim();
|
||||
}
|
||||
|
||||
public string ProviderId { get; }
|
||||
|
||||
public VexClaimStatus Status { get; }
|
||||
|
||||
public string DocumentDigest { get; }
|
||||
|
||||
public VexJustification? Justification { get; }
|
||||
|
||||
public string? Detail { get; }
|
||||
|
||||
public string? Reason { get; }
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public enum VexConsensusStatus
|
||||
{
|
||||
[EnumMember(Value = "affected")]
|
||||
Affected,
|
||||
|
||||
[EnumMember(Value = "not_affected")]
|
||||
NotAffected,
|
||||
|
||||
[EnumMember(Value = "fixed")]
|
||||
Fixed,
|
||||
|
||||
[EnumMember(Value = "under_investigation")]
|
||||
UnderInvestigation,
|
||||
|
||||
[EnumMember(Value = "divergent")]
|
||||
Divergent,
|
||||
}
|
||||
82
src/StellaOps.Excititor.Core/VexConsensusPolicyOptions.cs
Normal file
82
src/StellaOps.Excititor.Core/VexConsensusPolicyOptions.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Excititor.Core;
|
||||
|
||||
public sealed record VexConsensusPolicyOptions
|
||||
{
|
||||
public const string BaselineVersion = "baseline/v1";
|
||||
|
||||
public VexConsensusPolicyOptions(
|
||||
string? version = null,
|
||||
double vendorWeight = 1.0,
|
||||
double distroWeight = 0.9,
|
||||
double platformWeight = 0.7,
|
||||
double hubWeight = 0.5,
|
||||
double attestationWeight = 0.6,
|
||||
IEnumerable<KeyValuePair<string, double>>? providerOverrides = null)
|
||||
{
|
||||
Version = string.IsNullOrWhiteSpace(version) ? BaselineVersion : version.Trim();
|
||||
VendorWeight = NormalizeWeight(vendorWeight);
|
||||
DistroWeight = NormalizeWeight(distroWeight);
|
||||
PlatformWeight = NormalizeWeight(platformWeight);
|
||||
HubWeight = NormalizeWeight(hubWeight);
|
||||
AttestationWeight = NormalizeWeight(attestationWeight);
|
||||
ProviderOverrides = NormalizeOverrides(providerOverrides);
|
||||
}
|
||||
|
||||
public string Version { get; }
|
||||
|
||||
public double VendorWeight { get; }
|
||||
|
||||
public double DistroWeight { get; }
|
||||
|
||||
public double PlatformWeight { get; }
|
||||
|
||||
public double HubWeight { get; }
|
||||
|
||||
public double AttestationWeight { get; }
|
||||
|
||||
public ImmutableDictionary<string, double> ProviderOverrides { get; }
|
||||
|
||||
private static double NormalizeWeight(double weight)
|
||||
{
|
||||
if (double.IsNaN(weight) || double.IsInfinity(weight))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(weight), "Weight must be a finite number.");
|
||||
}
|
||||
|
||||
if (weight <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (weight >= 1)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
return weight;
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, double> NormalizeOverrides(
|
||||
IEnumerable<KeyValuePair<string, double>>? overrides)
|
||||
{
|
||||
if (overrides is null)
|
||||
{
|
||||
return ImmutableDictionary<string, double>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.Ordinal);
|
||||
foreach (var (key, weight) in overrides)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder[key.Trim()] = NormalizeWeight(weight);
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
293
src/StellaOps.Excititor.Core/VexConsensusResolver.cs
Normal file
293
src/StellaOps.Excititor.Core/VexConsensusResolver.cs
Normal file
@@ -0,0 +1,293 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Excititor.Core;
|
||||
|
||||
public sealed class VexConsensusResolver
|
||||
{
|
||||
private readonly IVexConsensusPolicy _policy;
|
||||
|
||||
public VexConsensusResolver(IVexConsensusPolicy policy)
|
||||
{
|
||||
_policy = policy ?? throw new ArgumentNullException(nameof(policy));
|
||||
}
|
||||
|
||||
public VexConsensusResolution Resolve(VexConsensusRequest request)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
var orderedClaims = request.Claims
|
||||
.OrderBy(static claim => claim.ProviderId, StringComparer.Ordinal)
|
||||
.ThenBy(static claim => claim.Document.Digest, StringComparer.Ordinal)
|
||||
.ThenBy(static claim => claim.Document.SourceUri.ToString(), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var decisions = ImmutableArray.CreateBuilder<VexConsensusDecisionTelemetry>(orderedClaims.Length);
|
||||
var acceptedSources = new List<VexConsensusSource>(orderedClaims.Length);
|
||||
var conflicts = new List<VexConsensusConflict>();
|
||||
var conflictKeys = new HashSet<string>(StringComparer.Ordinal);
|
||||
var weightByStatus = new Dictionary<VexClaimStatus, double>();
|
||||
|
||||
foreach (var claim in orderedClaims)
|
||||
{
|
||||
request.Providers.TryGetValue(claim.ProviderId, out var provider);
|
||||
|
||||
string? rejectionReason = null;
|
||||
double weight = 0;
|
||||
var included = false;
|
||||
|
||||
if (provider is null)
|
||||
{
|
||||
rejectionReason = "provider_not_registered";
|
||||
}
|
||||
else
|
||||
{
|
||||
weight = NormalizeWeight(_policy.GetProviderWeight(provider));
|
||||
if (weight <= 0)
|
||||
{
|
||||
rejectionReason = "weight_not_positive";
|
||||
}
|
||||
else if (!_policy.IsClaimEligible(claim, provider, out rejectionReason))
|
||||
{
|
||||
rejectionReason ??= "rejected_by_policy";
|
||||
}
|
||||
else
|
||||
{
|
||||
included = true;
|
||||
TrackStatusWeight(weightByStatus, claim.Status, weight);
|
||||
acceptedSources.Add(new VexConsensusSource(
|
||||
claim.ProviderId,
|
||||
claim.Status,
|
||||
claim.Document.Digest,
|
||||
weight,
|
||||
claim.Justification,
|
||||
claim.Detail,
|
||||
claim.Confidence));
|
||||
}
|
||||
}
|
||||
|
||||
if (!included)
|
||||
{
|
||||
var conflict = new VexConsensusConflict(
|
||||
claim.ProviderId,
|
||||
claim.Status,
|
||||
claim.Document.Digest,
|
||||
claim.Justification,
|
||||
claim.Detail,
|
||||
rejectionReason);
|
||||
if (conflictKeys.Add(CreateConflictKey(conflict.ProviderId, conflict.DocumentDigest)))
|
||||
{
|
||||
conflicts.Add(conflict);
|
||||
}
|
||||
}
|
||||
|
||||
decisions.Add(new VexConsensusDecisionTelemetry(
|
||||
claim.ProviderId,
|
||||
claim.Document.Digest,
|
||||
claim.Status,
|
||||
included,
|
||||
weight,
|
||||
rejectionReason,
|
||||
claim.Justification,
|
||||
claim.Detail));
|
||||
}
|
||||
|
||||
var consensusStatus = DetermineConsensusStatus(weightByStatus);
|
||||
var summary = BuildSummary(weightByStatus, consensusStatus);
|
||||
|
||||
var consensus = new VexConsensus(
|
||||
request.VulnerabilityId,
|
||||
request.Product,
|
||||
consensusStatus,
|
||||
request.CalculatedAt,
|
||||
acceptedSources,
|
||||
AttachConflictDetails(conflicts, acceptedSources, consensusStatus, conflictKeys),
|
||||
_policy.Version,
|
||||
summary,
|
||||
request.PolicyRevisionId,
|
||||
request.PolicyDigest);
|
||||
|
||||
return new VexConsensusResolution(consensus, decisions.ToImmutable());
|
||||
}
|
||||
|
||||
private static Dictionary<VexClaimStatus, double> TrackStatusWeight(
|
||||
Dictionary<VexClaimStatus, double> accumulator,
|
||||
VexClaimStatus status,
|
||||
double weight)
|
||||
{
|
||||
if (accumulator.TryGetValue(status, out var current))
|
||||
{
|
||||
accumulator[status] = current + weight;
|
||||
}
|
||||
else
|
||||
{
|
||||
accumulator[status] = weight;
|
||||
}
|
||||
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
private static double NormalizeWeight(double weight)
|
||||
{
|
||||
if (double.IsNaN(weight) || double.IsInfinity(weight) || weight <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (weight >= 1)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
return weight;
|
||||
}
|
||||
|
||||
private static VexConsensusStatus DetermineConsensusStatus(
|
||||
IReadOnlyDictionary<VexClaimStatus, double> weights)
|
||||
{
|
||||
if (weights.Count == 0)
|
||||
{
|
||||
return VexConsensusStatus.UnderInvestigation;
|
||||
}
|
||||
|
||||
var ordered = weights
|
||||
.OrderByDescending(static pair => pair.Value)
|
||||
.ThenBy(static pair => pair.Key)
|
||||
.ToArray();
|
||||
|
||||
var topStatus = ordered[0].Key;
|
||||
var topWeight = ordered[0].Value;
|
||||
var totalWeight = ordered.Sum(static pair => pair.Value);
|
||||
var remainder = totalWeight - topWeight;
|
||||
|
||||
if (topWeight <= 0)
|
||||
{
|
||||
return VexConsensusStatus.UnderInvestigation;
|
||||
}
|
||||
|
||||
if (topWeight > remainder)
|
||||
{
|
||||
return topStatus switch
|
||||
{
|
||||
VexClaimStatus.Affected => VexConsensusStatus.Affected,
|
||||
VexClaimStatus.Fixed => VexConsensusStatus.Fixed,
|
||||
VexClaimStatus.NotAffected => VexConsensusStatus.NotAffected,
|
||||
_ => VexConsensusStatus.UnderInvestigation,
|
||||
};
|
||||
}
|
||||
|
||||
return VexConsensusStatus.UnderInvestigation;
|
||||
}
|
||||
|
||||
private static string BuildSummary(
|
||||
IReadOnlyDictionary<VexClaimStatus, double> weights,
|
||||
VexConsensusStatus status)
|
||||
{
|
||||
if (weights.Count == 0)
|
||||
{
|
||||
return "No eligible claims met policy requirements.";
|
||||
}
|
||||
|
||||
var breakdown = string.Join(
|
||||
", ",
|
||||
weights
|
||||
.OrderByDescending(static pair => pair.Value)
|
||||
.ThenBy(static pair => pair.Key)
|
||||
.Select(pair => $"{FormatStatus(pair.Key)}={pair.Value.ToString("0.###", CultureInfo.InvariantCulture)}"));
|
||||
|
||||
if (status == VexConsensusStatus.UnderInvestigation)
|
||||
{
|
||||
return $"No majority consensus; weighted breakdown {breakdown}.";
|
||||
}
|
||||
|
||||
return $"{FormatStatus(status)} determined via weighted majority; breakdown {breakdown}.";
|
||||
}
|
||||
|
||||
private static List<VexConsensusConflict> AttachConflictDetails(
|
||||
List<VexConsensusConflict> conflicts,
|
||||
IEnumerable<VexConsensusSource> acceptedSources,
|
||||
VexConsensusStatus status,
|
||||
HashSet<string> conflictKeys)
|
||||
{
|
||||
var consensusClaimStatus = status switch
|
||||
{
|
||||
VexConsensusStatus.Affected => VexClaimStatus.Affected,
|
||||
VexConsensusStatus.NotAffected => VexClaimStatus.NotAffected,
|
||||
VexConsensusStatus.Fixed => VexClaimStatus.Fixed,
|
||||
VexConsensusStatus.UnderInvestigation => (VexClaimStatus?)null,
|
||||
VexConsensusStatus.Divergent => (VexClaimStatus?)null,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
foreach (var source in acceptedSources)
|
||||
{
|
||||
if (consensusClaimStatus is null || source.Status != consensusClaimStatus.Value)
|
||||
{
|
||||
var conflict = new VexConsensusConflict(
|
||||
source.ProviderId,
|
||||
source.Status,
|
||||
source.DocumentDigest,
|
||||
source.Justification,
|
||||
source.Detail,
|
||||
consensusClaimStatus is null ? "no_majority" : "status_conflict");
|
||||
|
||||
if (conflictKeys.Add(CreateConflictKey(conflict.ProviderId, conflict.DocumentDigest)))
|
||||
{
|
||||
conflicts.Add(conflict);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
private static string FormatStatus(VexClaimStatus status)
|
||||
=> status switch
|
||||
{
|
||||
VexClaimStatus.Affected => "affected",
|
||||
VexClaimStatus.NotAffected => "not_affected",
|
||||
VexClaimStatus.Fixed => "fixed",
|
||||
VexClaimStatus.UnderInvestigation => "under_investigation",
|
||||
_ => status.ToString().ToLowerInvariant(),
|
||||
};
|
||||
|
||||
private static string CreateConflictKey(string providerId, string documentDigest)
|
||||
=> $"{providerId}|{documentDigest}";
|
||||
|
||||
private static string FormatStatus(VexConsensusStatus status)
|
||||
=> status switch
|
||||
{
|
||||
VexConsensusStatus.Affected => "affected",
|
||||
VexConsensusStatus.NotAffected => "not_affected",
|
||||
VexConsensusStatus.Fixed => "fixed",
|
||||
VexConsensusStatus.UnderInvestigation => "under_investigation",
|
||||
VexConsensusStatus.Divergent => "divergent",
|
||||
_ => status.ToString().ToLowerInvariant(),
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record VexConsensusRequest(
|
||||
string VulnerabilityId,
|
||||
VexProduct Product,
|
||||
IReadOnlyList<VexClaim> Claims,
|
||||
IReadOnlyDictionary<string, VexProvider> Providers,
|
||||
DateTimeOffset CalculatedAt,
|
||||
string? PolicyRevisionId = null,
|
||||
string? PolicyDigest = null);
|
||||
|
||||
public sealed record VexConsensusResolution(
|
||||
VexConsensus Consensus,
|
||||
ImmutableArray<VexConsensusDecisionTelemetry> DecisionLog);
|
||||
|
||||
public sealed record VexConsensusDecisionTelemetry(
|
||||
string ProviderId,
|
||||
string DocumentDigest,
|
||||
VexClaimStatus Status,
|
||||
bool Included,
|
||||
double Weight,
|
||||
string? Reason,
|
||||
VexJustification? Justification,
|
||||
string? Detail);
|
||||
257
src/StellaOps.Excititor.Core/VexExportManifest.cs
Normal file
257
src/StellaOps.Excititor.Core/VexExportManifest.cs
Normal file
@@ -0,0 +1,257 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Excititor.Core;
|
||||
|
||||
public sealed record VexExportManifest
|
||||
{
|
||||
public VexExportManifest(
|
||||
string exportId,
|
||||
VexQuerySignature querySignature,
|
||||
VexExportFormat format,
|
||||
DateTimeOffset createdAt,
|
||||
VexContentAddress artifact,
|
||||
int claimCount,
|
||||
IEnumerable<string> sourceProviders,
|
||||
bool fromCache = false,
|
||||
string? consensusRevision = null,
|
||||
VexAttestationMetadata? attestation = null,
|
||||
long sizeBytes = 0)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(exportId))
|
||||
{
|
||||
throw new ArgumentException("Export id must be provided.", nameof(exportId));
|
||||
}
|
||||
|
||||
if (claimCount < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(claimCount), "Claim count cannot be negative.");
|
||||
}
|
||||
|
||||
if (sizeBytes < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(sizeBytes), "Export size cannot be negative.");
|
||||
}
|
||||
|
||||
ExportId = exportId.Trim();
|
||||
QuerySignature = querySignature ?? throw new ArgumentNullException(nameof(querySignature));
|
||||
Format = format;
|
||||
CreatedAt = createdAt;
|
||||
Artifact = artifact ?? throw new ArgumentNullException(nameof(artifact));
|
||||
ClaimCount = claimCount;
|
||||
FromCache = fromCache;
|
||||
SourceProviders = NormalizeProviders(sourceProviders);
|
||||
ConsensusRevision = string.IsNullOrWhiteSpace(consensusRevision) ? null : consensusRevision.Trim();
|
||||
Attestation = attestation;
|
||||
SizeBytes = sizeBytes;
|
||||
}
|
||||
|
||||
public string ExportId { get; }
|
||||
|
||||
public VexQuerySignature QuerySignature { get; }
|
||||
|
||||
public VexExportFormat Format { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public VexContentAddress Artifact { get; }
|
||||
|
||||
public int ClaimCount { get; }
|
||||
|
||||
public bool FromCache { get; }
|
||||
|
||||
public ImmutableArray<string> SourceProviders { get; }
|
||||
|
||||
public string? ConsensusRevision { get; }
|
||||
|
||||
public VexAttestationMetadata? Attestation { get; }
|
||||
|
||||
public long SizeBytes { get; }
|
||||
|
||||
private static ImmutableArray<string> NormalizeProviders(IEnumerable<string> providers)
|
||||
{
|
||||
if (providers is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(providers));
|
||||
}
|
||||
|
||||
var set = new SortedSet<string>(StringComparer.Ordinal);
|
||||
foreach (var provider in providers)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(provider))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
set.Add(provider.Trim());
|
||||
}
|
||||
|
||||
return set.Count == 0
|
||||
? ImmutableArray<string>.Empty
|
||||
: set.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record VexContentAddress
|
||||
{
|
||||
public VexContentAddress(string algorithm, string digest)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(algorithm))
|
||||
{
|
||||
throw new ArgumentException("Content algorithm must be provided.", nameof(algorithm));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
throw new ArgumentException("Content digest must be provided.", nameof(digest));
|
||||
}
|
||||
|
||||
Algorithm = algorithm.Trim();
|
||||
Digest = digest.Trim();
|
||||
}
|
||||
|
||||
public string Algorithm { get; }
|
||||
|
||||
public string Digest { get; }
|
||||
|
||||
public string ToUri() => $"{Algorithm}:{Digest}";
|
||||
|
||||
public override string ToString() => ToUri();
|
||||
}
|
||||
|
||||
public sealed record VexAttestationMetadata
|
||||
{
|
||||
public VexAttestationMetadata(
|
||||
string predicateType,
|
||||
VexRekorReference? rekor = null,
|
||||
string? envelopeDigest = null,
|
||||
DateTimeOffset? signedAt = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(predicateType))
|
||||
{
|
||||
throw new ArgumentException("Predicate type must be provided.", nameof(predicateType));
|
||||
}
|
||||
|
||||
PredicateType = predicateType.Trim();
|
||||
Rekor = rekor;
|
||||
EnvelopeDigest = string.IsNullOrWhiteSpace(envelopeDigest) ? null : envelopeDigest.Trim();
|
||||
SignedAt = signedAt;
|
||||
}
|
||||
|
||||
public string PredicateType { get; }
|
||||
|
||||
public VexRekorReference? Rekor { get; }
|
||||
|
||||
public string? EnvelopeDigest { get; }
|
||||
|
||||
public DateTimeOffset? SignedAt { get; }
|
||||
}
|
||||
|
||||
public sealed record VexRekorReference
|
||||
{
|
||||
public VexRekorReference(string apiVersion, string location, string? logIndex = null, Uri? inclusionProofUri = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(apiVersion))
|
||||
{
|
||||
throw new ArgumentException("Rekor API version must be provided.", nameof(apiVersion));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(location))
|
||||
{
|
||||
throw new ArgumentException("Rekor location must be provided.", nameof(location));
|
||||
}
|
||||
|
||||
ApiVersion = apiVersion.Trim();
|
||||
Location = location.Trim();
|
||||
LogIndex = string.IsNullOrWhiteSpace(logIndex) ? null : logIndex.Trim();
|
||||
InclusionProofUri = inclusionProofUri;
|
||||
}
|
||||
|
||||
public string ApiVersion { get; }
|
||||
|
||||
public string Location { get; }
|
||||
|
||||
public string? LogIndex { get; }
|
||||
|
||||
public Uri? InclusionProofUri { get; }
|
||||
}
|
||||
|
||||
public sealed partial record VexQuerySignature
|
||||
{
|
||||
public VexQuerySignature(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Query signature must be provided.", nameof(value));
|
||||
}
|
||||
|
||||
Value = value.Trim();
|
||||
}
|
||||
|
||||
public string Value { get; }
|
||||
|
||||
public static VexQuerySignature FromFilters(IEnumerable<KeyValuePair<string, string>> filters)
|
||||
{
|
||||
if (filters is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(filters));
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<KeyValuePair<string, string>>();
|
||||
foreach (var pair in filters)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = pair.Key.Trim();
|
||||
var value = pair.Value?.Trim() ?? string.Empty;
|
||||
builder.Add(new KeyValuePair<string, string>(key, value));
|
||||
}
|
||||
|
||||
if (builder.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one filter is required to build a query signature.", nameof(filters));
|
||||
}
|
||||
|
||||
var ordered = builder
|
||||
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
|
||||
.ThenBy(static pair => pair.Value, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 0; i < ordered.Length; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
{
|
||||
sb.Append('&');
|
||||
}
|
||||
|
||||
sb.Append(ordered[i].Key);
|
||||
sb.Append('=');
|
||||
sb.Append(ordered[i].Value);
|
||||
}
|
||||
|
||||
return new VexQuerySignature(sb.ToString());
|
||||
}
|
||||
|
||||
public override string ToString() => Value;
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public enum VexExportFormat
|
||||
{
|
||||
[EnumMember(Value = "json")]
|
||||
Json,
|
||||
|
||||
[EnumMember(Value = "jsonl")]
|
||||
JsonLines,
|
||||
|
||||
[EnumMember(Value = "openvex")]
|
||||
OpenVex,
|
||||
|
||||
[EnumMember(Value = "csaf")]
|
||||
Csaf,
|
||||
}
|
||||
30
src/StellaOps.Excititor.Core/VexExporterAbstractions.cs
Normal file
30
src/StellaOps.Excititor.Core/VexExporterAbstractions.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Excititor.Core;
|
||||
|
||||
public interface IVexExporter
|
||||
{
|
||||
VexExportFormat Format { get; }
|
||||
|
||||
VexContentAddress Digest(VexExportRequest request);
|
||||
|
||||
ValueTask<VexExportResult> SerializeAsync(
|
||||
VexExportRequest request,
|
||||
Stream output,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record VexExportRequest(
|
||||
VexQuery Query,
|
||||
ImmutableArray<VexConsensus> Consensus,
|
||||
ImmutableArray<VexClaim> Claims,
|
||||
DateTimeOffset GeneratedAt);
|
||||
|
||||
public sealed record VexExportResult(
|
||||
VexContentAddress Digest,
|
||||
long BytesWritten,
|
||||
ImmutableDictionary<string, string> Metadata);
|
||||
28
src/StellaOps.Excititor.Core/VexNormalizerAbstractions.cs
Normal file
28
src/StellaOps.Excititor.Core/VexNormalizerAbstractions.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Excititor.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizer contract for translating raw connector documents into canonical claims.
|
||||
/// </summary>
|
||||
public interface IVexNormalizer
|
||||
{
|
||||
string Format { get; }
|
||||
|
||||
bool CanHandle(VexRawDocument document);
|
||||
|
||||
ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, VexProvider provider, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registry that maps formats to registered normalizers.
|
||||
/// </summary>
|
||||
public sealed record VexNormalizerRegistry(ImmutableArray<IVexNormalizer> Normalizers)
|
||||
{
|
||||
public IVexNormalizer? Resolve(VexRawDocument document)
|
||||
=> Normalizers.FirstOrDefault(normalizer => normalizer.CanHandle(document));
|
||||
}
|
||||
206
src/StellaOps.Excititor.Core/VexProvider.cs
Normal file
206
src/StellaOps.Excititor.Core/VexProvider.cs
Normal file
@@ -0,0 +1,206 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Runtime.Serialization;
|
||||
|
||||
namespace StellaOps.Excititor.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata describing a VEX provider (vendor, distro, hub, platform).
|
||||
/// </summary>
|
||||
public sealed record VexProvider
|
||||
{
|
||||
public VexProvider(
|
||||
string id,
|
||||
string displayName,
|
||||
VexProviderKind kind,
|
||||
IEnumerable<Uri>? baseUris = null,
|
||||
VexProviderDiscovery? discovery = null,
|
||||
VexProviderTrust? trust = null,
|
||||
bool enabled = true)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
throw new ArgumentException("Provider id must be non-empty.", nameof(id));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(displayName))
|
||||
{
|
||||
throw new ArgumentException("Provider display name must be non-empty.", nameof(displayName));
|
||||
}
|
||||
|
||||
Id = id.Trim();
|
||||
DisplayName = displayName.Trim();
|
||||
Kind = kind;
|
||||
BaseUris = NormalizeUris(baseUris);
|
||||
Discovery = discovery ?? VexProviderDiscovery.Empty;
|
||||
Trust = trust ?? VexProviderTrust.Default;
|
||||
Enabled = enabled;
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string DisplayName { get; }
|
||||
|
||||
public VexProviderKind Kind { get; }
|
||||
|
||||
public ImmutableArray<Uri> BaseUris { get; }
|
||||
|
||||
public VexProviderDiscovery Discovery { get; }
|
||||
|
||||
public VexProviderTrust Trust { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
private static ImmutableArray<Uri> NormalizeUris(IEnumerable<Uri>? baseUris)
|
||||
{
|
||||
if (baseUris is null)
|
||||
{
|
||||
return ImmutableArray<Uri>.Empty;
|
||||
}
|
||||
|
||||
var distinct = new HashSet<string>(StringComparer.Ordinal);
|
||||
var builder = ImmutableArray.CreateBuilder<Uri>();
|
||||
foreach (var uri in baseUris)
|
||||
{
|
||||
if (uri is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var canonical = uri.ToString();
|
||||
if (distinct.Add(canonical))
|
||||
{
|
||||
builder.Add(uri);
|
||||
}
|
||||
}
|
||||
|
||||
if (builder.Count == 0)
|
||||
{
|
||||
return ImmutableArray<Uri>.Empty;
|
||||
}
|
||||
|
||||
return builder
|
||||
.OrderBy(static x => x.ToString(), StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record VexProviderDiscovery
|
||||
{
|
||||
public static readonly VexProviderDiscovery Empty = new(null, null);
|
||||
|
||||
public VexProviderDiscovery(Uri? wellKnownMetadata, Uri? rolieService)
|
||||
{
|
||||
WellKnownMetadata = wellKnownMetadata;
|
||||
RolIeService = rolieService;
|
||||
}
|
||||
|
||||
public Uri? WellKnownMetadata { get; }
|
||||
|
||||
public Uri? RolIeService { get; }
|
||||
}
|
||||
|
||||
public sealed record VexProviderTrust
|
||||
{
|
||||
public static readonly VexProviderTrust Default = new(1.0, null, ImmutableArray<string>.Empty);
|
||||
|
||||
public VexProviderTrust(
|
||||
double weight,
|
||||
VexCosignTrust? cosign,
|
||||
IEnumerable<string>? pgpFingerprints = null)
|
||||
{
|
||||
Weight = NormalizeWeight(weight);
|
||||
Cosign = cosign;
|
||||
PgpFingerprints = NormalizeFingerprints(pgpFingerprints);
|
||||
}
|
||||
|
||||
public double Weight { get; }
|
||||
|
||||
public VexCosignTrust? Cosign { get; }
|
||||
|
||||
public ImmutableArray<string> PgpFingerprints { get; }
|
||||
|
||||
private static double NormalizeWeight(double weight)
|
||||
{
|
||||
if (double.IsNaN(weight) || double.IsInfinity(weight))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(weight), "Weight must be a finite number.");
|
||||
}
|
||||
|
||||
if (weight <= 0)
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
if (weight >= 1.0)
|
||||
{
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
return weight;
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> NormalizeFingerprints(IEnumerable<string>? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
var set = new SortedSet<string>(StringComparer.Ordinal);
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
set.Add(value.Trim());
|
||||
}
|
||||
|
||||
return set.Count == 0
|
||||
? ImmutableArray<string>.Empty
|
||||
: set.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record VexCosignTrust
|
||||
{
|
||||
public VexCosignTrust(string issuer, string identityPattern)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(issuer))
|
||||
{
|
||||
throw new ArgumentException("Issuer must be provided for cosign trust metadata.", nameof(issuer));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(identityPattern))
|
||||
{
|
||||
throw new ArgumentException("Identity pattern must be provided for cosign trust metadata.", nameof(identityPattern));
|
||||
}
|
||||
|
||||
Issuer = issuer.Trim();
|
||||
IdentityPattern = identityPattern.Trim();
|
||||
}
|
||||
|
||||
public string Issuer { get; }
|
||||
|
||||
public string IdentityPattern { get; }
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public enum VexProviderKind
|
||||
{
|
||||
[EnumMember(Value = "vendor")]
|
||||
Vendor,
|
||||
|
||||
[EnumMember(Value = "distro")]
|
||||
Distro,
|
||||
|
||||
[EnumMember(Value = "hub")]
|
||||
Hub,
|
||||
|
||||
[EnumMember(Value = "platform")]
|
||||
Platform,
|
||||
|
||||
[EnumMember(Value = "attestation")]
|
||||
Attestation,
|
||||
}
|
||||
143
src/StellaOps.Excititor.Core/VexQuery.cs
Normal file
143
src/StellaOps.Excititor.Core/VexQuery.cs
Normal file
@@ -0,0 +1,143 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Excititor.Core;
|
||||
|
||||
public sealed record VexQuery(
|
||||
ImmutableArray<VexQueryFilter> Filters,
|
||||
ImmutableArray<VexQuerySort> Sort,
|
||||
int? Limit = null,
|
||||
int? Offset = null,
|
||||
string? View = null)
|
||||
{
|
||||
public static VexQuery Empty { get; } = new(
|
||||
ImmutableArray<VexQueryFilter>.Empty,
|
||||
ImmutableArray<VexQuerySort>.Empty);
|
||||
|
||||
public static VexQuery Create(
|
||||
IEnumerable<VexQueryFilter>? filters = null,
|
||||
IEnumerable<VexQuerySort>? sort = null,
|
||||
int? limit = null,
|
||||
int? offset = null,
|
||||
string? view = null)
|
||||
{
|
||||
var normalizedFilters = NormalizeFilters(filters);
|
||||
var normalizedSort = NormalizeSort(sort);
|
||||
return new VexQuery(normalizedFilters, normalizedSort, NormalizeBound(limit), NormalizeBound(offset), NormalizeView(view));
|
||||
}
|
||||
|
||||
public VexQuery WithFilters(IEnumerable<VexQueryFilter> filters)
|
||||
=> this with { Filters = NormalizeFilters(filters) };
|
||||
|
||||
public VexQuery WithSort(IEnumerable<VexQuerySort> sort)
|
||||
=> this with { Sort = NormalizeSort(sort) };
|
||||
|
||||
public VexQuery WithBounds(int? limit = null, int? offset = null)
|
||||
=> this with { Limit = NormalizeBound(limit), Offset = NormalizeBound(offset) };
|
||||
|
||||
public VexQuery WithView(string? view)
|
||||
=> this with { View = NormalizeView(view) };
|
||||
|
||||
private static ImmutableArray<VexQueryFilter> NormalizeFilters(IEnumerable<VexQueryFilter>? filters)
|
||||
{
|
||||
if (filters is null)
|
||||
{
|
||||
return ImmutableArray<VexQueryFilter>.Empty;
|
||||
}
|
||||
|
||||
return filters
|
||||
.Where(filter => !string.IsNullOrWhiteSpace(filter.Key))
|
||||
.Select(filter => new VexQueryFilter(filter.Key.Trim(), filter.Value?.Trim() ?? string.Empty))
|
||||
.OrderBy(filter => filter.Key, StringComparer.Ordinal)
|
||||
.ThenBy(filter => filter.Value, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<VexQuerySort> NormalizeSort(IEnumerable<VexQuerySort>? sort)
|
||||
{
|
||||
if (sort is null)
|
||||
{
|
||||
return ImmutableArray<VexQuerySort>.Empty;
|
||||
}
|
||||
|
||||
return sort
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s.Field))
|
||||
.Select(s => new VexQuerySort(s.Field.Trim(), s.Descending))
|
||||
.OrderBy(s => s.Field, StringComparer.Ordinal)
|
||||
.ThenBy(s => s.Descending)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static int? NormalizeBound(int? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.Value < 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return value.Value;
|
||||
}
|
||||
|
||||
private static string? NormalizeView(string? view)
|
||||
=> string.IsNullOrWhiteSpace(view) ? null : view.Trim();
|
||||
}
|
||||
|
||||
public sealed record VexQueryFilter(string Key, string Value);
|
||||
|
||||
public sealed record VexQuerySort(string Field, bool Descending);
|
||||
|
||||
public sealed partial record VexQuerySignature
|
||||
{
|
||||
public static VexQuerySignature FromQuery(VexQuery query)
|
||||
{
|
||||
if (query is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(query));
|
||||
}
|
||||
|
||||
var components = new List<string>(query.Filters.Length + query.Sort.Length + 3);
|
||||
components.AddRange(query.Filters.Select(filter => $"{filter.Key}={filter.Value}"));
|
||||
components.AddRange(query.Sort.Select(sort => sort.Descending ? $"sort=-{sort.Field}" : $"sort=+{sort.Field}"));
|
||||
|
||||
if (query.Limit is not null)
|
||||
{
|
||||
components.Add($"limit={query.Limit.Value.ToString(CultureInfo.InvariantCulture)}");
|
||||
}
|
||||
|
||||
if (query.Offset is not null)
|
||||
{
|
||||
components.Add($"offset={query.Offset.Value.ToString(CultureInfo.InvariantCulture)}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.View))
|
||||
{
|
||||
components.Add($"view={query.View}");
|
||||
}
|
||||
|
||||
return new VexQuerySignature(string.Join('&', components));
|
||||
}
|
||||
|
||||
public VexContentAddress ComputeHash()
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var bytes = Encoding.UTF8.GetBytes(Value);
|
||||
var digest = sha.ComputeHash(bytes);
|
||||
var builder = new StringBuilder(digest.Length * 2);
|
||||
foreach (var b in digest)
|
||||
{
|
||||
_ = builder.Append(b.ToString("x2", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return new VexContentAddress("sha256", builder.ToString());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user