Rename Vexer to Excititor

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

View File

@@ -0,0 +1,21 @@
# AGENTS
## Role
Provides OpenVEX statement normalization and export writers for lightweight attestation-oriented outputs.
## Scope
- Parse OpenVEX documents/attestations into canonical claims with provenance metadata.
- Utilities to merge multiple OpenVEX statements and resolve conflicts for consensus ingestion.
- Export writer emitting OpenVEX envelopes from consensus data with deterministic ordering.
- Optional SBOM linkage helpers referencing component digests or PURLs.
## Participants
- OCI/OpenVEX connector and other attest-based sources depend on this module for normalization.
- Export module uses writers for `--format openvex` requests.
- Attestation layer references emitted statements to populate predicate subjects.
## Interfaces & contracts
- Normalizer classes implementing `INormalizer`, reducer utilities to consolidate OpenVEX events, export serializer.
## In/Out of scope
In: OpenVEX parsing, normalization, export serialization, helper utilities.
Out: OCI registry access, policy evaluation, attestation signing (handled by other modules).
## Observability & security expectations
- Log normalization anomalies with subject digest and justification mapping while respecting offline constraints.
## Tests
- Snapshot-driven normalization/export tests will be placed in `../StellaOps.Excititor.Formats.OpenVEX.Tests`.

View File

@@ -0,0 +1,367 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Formats.OpenVEX;
public sealed class OpenVexNormalizer : IVexNormalizer
{
private static readonly ImmutableDictionary<string, VexClaimStatus> StatusMap = new Dictionary<string, VexClaimStatus>(StringComparer.OrdinalIgnoreCase)
{
["affected"] = VexClaimStatus.Affected,
["not_affected"] = VexClaimStatus.NotAffected,
["fixed"] = VexClaimStatus.Fixed,
["under_investigation"] = VexClaimStatus.UnderInvestigation,
}.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
private static readonly ImmutableDictionary<string, VexJustification> JustificationMap = new Dictionary<string, VexJustification>(StringComparer.OrdinalIgnoreCase)
{
["component_not_present"] = VexJustification.ComponentNotPresent,
["component_not_configured"] = VexJustification.ComponentNotConfigured,
["vulnerable_code_not_present"] = VexJustification.VulnerableCodeNotPresent,
["vulnerable_code_not_in_execute_path"] = VexJustification.VulnerableCodeNotInExecutePath,
["vulnerable_code_cannot_be_controlled_by_adversary"] = VexJustification.VulnerableCodeCannotBeControlledByAdversary,
["inline_mitigations_already_exist"] = VexJustification.InlineMitigationsAlreadyExist,
["protected_by_mitigating_control"] = VexJustification.ProtectedByMitigatingControl,
["protected_by_compensating_control"] = VexJustification.ProtectedByCompensatingControl,
["code_not_present"] = VexJustification.CodeNotPresent,
["code_not_reachable"] = VexJustification.CodeNotReachable,
["requires_configuration"] = VexJustification.RequiresConfiguration,
["requires_dependency"] = VexJustification.RequiresDependency,
["requires_environment"] = VexJustification.RequiresEnvironment,
}.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
private readonly ILogger<OpenVexNormalizer> _logger;
public OpenVexNormalizer(ILogger<OpenVexNormalizer> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string Format => VexDocumentFormat.OpenVex.ToString().ToLowerInvariant();
public bool CanHandle(VexRawDocument document)
=> document is not null && document.Format == VexDocumentFormat.OpenVex;
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, VexProvider provider, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(document);
ArgumentNullException.ThrowIfNull(provider);
cancellationToken.ThrowIfCancellationRequested();
try
{
var result = OpenVexParser.Parse(document);
var claims = ImmutableArray.CreateBuilder<VexClaim>(result.Statements.Length);
foreach (var statement in result.Statements)
{
cancellationToken.ThrowIfCancellationRequested();
var status = MapStatus(statement.Status);
var justification = MapJustification(statement.Justification);
foreach (var product in statement.Products)
{
var vexProduct = new VexProduct(
product.Key,
product.Name,
product.Version,
product.Purl,
product.Cpe);
var metadata = result.Metadata;
metadata = metadata.SetItem("openvex.statement.id", statement.Id);
if (!string.IsNullOrWhiteSpace(statement.Status))
{
metadata = metadata.SetItem("openvex.statement.status", statement.Status!);
}
if (!string.IsNullOrWhiteSpace(statement.Justification))
{
metadata = metadata.SetItem("openvex.statement.justification", statement.Justification!);
}
if (!string.IsNullOrWhiteSpace(product.OriginalId))
{
metadata = metadata.SetItem("openvex.product.source", product.OriginalId!);
}
var claimDocument = new VexClaimDocument(
VexDocumentFormat.OpenVex,
document.Digest,
document.SourceUri,
result.DocumentVersion,
signature: null);
var claim = new VexClaim(
statement.Vulnerability,
provider.Id,
vexProduct,
status,
claimDocument,
result.FirstObserved,
result.LastObserved,
justification,
statement.Remarks,
confidence: null,
additionalMetadata: metadata);
claims.Add(claim);
}
}
var orderedClaims = claims
.ToImmutable()
.OrderBy(static claim => claim.VulnerabilityId, StringComparer.Ordinal)
.ThenBy(static claim => claim.Product.Key, StringComparer.Ordinal)
.ToImmutableArray();
_logger.LogInformation(
"Normalized OpenVEX document {Source} into {ClaimCount} claim(s).",
document.SourceUri,
orderedClaims.Length);
return ValueTask.FromResult(new VexClaimBatch(
document,
orderedClaims,
ImmutableDictionary<string, string>.Empty));
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse OpenVEX document {SourceUri}", document.SourceUri);
throw;
}
}
private static VexClaimStatus MapStatus(string? status)
{
if (!string.IsNullOrWhiteSpace(status) && StatusMap.TryGetValue(status.Trim(), out var mapped))
{
return mapped;
}
return VexClaimStatus.UnderInvestigation;
}
private static VexJustification? MapJustification(string? justification)
{
if (string.IsNullOrWhiteSpace(justification))
{
return null;
}
return JustificationMap.TryGetValue(justification.Trim(), out var mapped)
? mapped
: null;
}
private static class OpenVexParser
{
public static OpenVexParseResult Parse(VexRawDocument document)
{
using var json = JsonDocument.Parse(document.Content.ToArray());
var root = json.RootElement;
var metadata = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
var documentElement = TryGetProperty(root, "document");
var version = TryGetString(documentElement, "version");
var author = TryGetString(documentElement, "author");
if (!string.IsNullOrWhiteSpace(version))
{
metadata["openvex.document.version"] = version!;
}
if (!string.IsNullOrWhiteSpace(author))
{
metadata["openvex.document.author"] = author!;
}
var issued = ParseDate(documentElement, "issued");
var lastUpdated = ParseDate(documentElement, "last_updated") ?? issued ?? document.RetrievedAt;
var effectiveDate = ParseDate(documentElement, "effective_date") ?? issued ?? document.RetrievedAt;
var statements = CollectStatements(root);
return new OpenVexParseResult(
metadata.ToImmutable(),
version,
effectiveDate,
lastUpdated,
statements);
}
private static ImmutableArray<OpenVexStatement> CollectStatements(JsonElement root)
{
if (!root.TryGetProperty("statements", out var statementsElement) ||
statementsElement.ValueKind != JsonValueKind.Array)
{
return ImmutableArray<OpenVexStatement>.Empty;
}
var builder = ImmutableArray.CreateBuilder<OpenVexStatement>();
foreach (var statement in statementsElement.EnumerateArray())
{
if (statement.ValueKind != JsonValueKind.Object)
{
continue;
}
var vulnerability = TryGetString(statement, "vulnerability") ?? TryGetString(statement, "vuln") ?? string.Empty;
if (string.IsNullOrWhiteSpace(vulnerability))
{
continue;
}
var id = TryGetString(statement, "id") ?? Guid.NewGuid().ToString();
var status = TryGetString(statement, "status");
var justification = TryGetString(statement, "justification");
var remarks = TryGetString(statement, "remediation") ?? TryGetString(statement, "statement");
var products = CollectProducts(statement);
if (products.Length == 0)
{
continue;
}
builder.Add(new OpenVexStatement(
id,
vulnerability.Trim(),
status,
justification,
remarks,
products));
}
return builder.ToImmutable();
}
private static ImmutableArray<OpenVexProduct> CollectProducts(JsonElement statement)
{
if (!statement.TryGetProperty("products", out var productsElement) ||
productsElement.ValueKind != JsonValueKind.Array)
{
return ImmutableArray<OpenVexProduct>.Empty;
}
var builder = ImmutableArray.CreateBuilder<OpenVexProduct>();
foreach (var product in productsElement.EnumerateArray())
{
if (product.ValueKind != JsonValueKind.String && product.ValueKind != JsonValueKind.Object)
{
continue;
}
if (product.ValueKind == JsonValueKind.String)
{
var value = product.GetString();
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
builder.Add(OpenVexProduct.FromString(value.Trim()));
continue;
}
var id = TryGetString(product, "id") ?? TryGetString(product, "product_id");
var name = TryGetString(product, "name");
var version = TryGetString(product, "version");
var purl = TryGetString(product, "purl");
var cpe = TryGetString(product, "cpe");
if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(purl))
{
continue;
}
builder.Add(new OpenVexProduct(
id ?? purl!,
name ?? id ?? purl!,
version,
purl,
cpe,
OriginalId: id));
}
return builder.ToImmutable();
}
private static JsonElement TryGetProperty(JsonElement element, string propertyName)
=> element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out var value)
? value
: default;
private static string? TryGetString(JsonElement element, string propertyName)
{
if (element.ValueKind != JsonValueKind.Object)
{
return null;
}
if (!element.TryGetProperty(propertyName, out var value))
{
return null;
}
return value.ValueKind == JsonValueKind.String ? value.GetString() : null;
}
private static DateTimeOffset? ParseDate(JsonElement element, string propertyName)
{
var value = TryGetString(element, propertyName);
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return DateTimeOffset.TryParse(value, out var parsed) ? parsed : null;
}
}
private sealed record OpenVexParseResult(
ImmutableDictionary<string, string> Metadata,
string? DocumentVersion,
DateTimeOffset FirstObserved,
DateTimeOffset LastObserved,
ImmutableArray<OpenVexStatement> Statements);
private sealed record OpenVexStatement(
string Id,
string Vulnerability,
string? Status,
string? Justification,
string? Remarks,
ImmutableArray<OpenVexProduct> Products);
private sealed record OpenVexProduct(
string Key,
string Name,
string? Version,
string? Purl,
string? Cpe,
string? OriginalId)
{
public static OpenVexProduct FromString(string value)
{
var key = value;
string? purl = null;
string? name = value;
if (value.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
{
purl = value;
}
return new OpenVexProduct(key, name, null, purl, null, OriginalId: value);
}
}
}

View File

@@ -0,0 +1,14 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Formats.OpenVEX;
public static class OpenVexFormatsServiceCollectionExtensions
{
public static IServiceCollection AddOpenVexNormalizer(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton<IVexNormalizer, OpenVexNormalizer>();
return services;
}
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,7 @@
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-FMT-OPENVEX-01-001 OpenVEX normalizer|Team Excititor Formats|EXCITITOR-CORE-01-001|**DONE (2025-10-17)** OpenVEX normalizer parses statements/products, maps status/justification, and surfaces provenance metadata; coverage in `OpenVexNormalizerTests`.|
|EXCITITOR-FMT-OPENVEX-01-002 Statement merge utilities|Team Excititor Formats|EXCITITOR-FMT-OPENVEX-01-001|TODO Add reducers merging multiple OpenVEX statements, resolving conflicts deterministically, and emitting policy diagnostics.|
|EXCITITOR-FMT-OPENVEX-01-003 OpenVEX export writer|Team Excititor Formats|EXCITITOR-EXPORT-01-001, EXCITITOR-FMT-OPENVEX-01-001|TODO Provide export serializer generating canonical OpenVEX documents with optional SBOM references and hash-stable ordering.|