Rename Vexer to Excititor

This commit is contained in:
2025-10-18 20:00:46 +03:00
parent fbd1826ef3
commit 7e1b10d3b2
263 changed files with 848 additions and 848 deletions

View 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.

View 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;
}
}

View 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);
}

View File

@@ -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>

View 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.|

View 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);

View 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();
}

View 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);
}
}
}
}

View 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,
}

View 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);
}

View 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,
}

View 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();
}
}

View 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);

View 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,
}

View 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);

View 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));
}

View 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,
}

View 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());
}
}