consolidation of some of the modules, localization fixes, product advisories work, qa work

This commit is contained in:
master
2026-03-05 03:54:22 +02:00
parent 7bafcc3eef
commit 8e1cb9448d
3878 changed files with 72600 additions and 46861 deletions

View File

@@ -0,0 +1,34 @@
# AGENTS
## Role
Normalize CSAF VEX profile documents into Excititor claims and provide CSAF export adapters.
## Scope
- CSAF ingestion helpers: provider metadata parsing, document revision handling, vulnerability/action mappings.
- Normalizer implementation fulfilling `INormalizer` for CSAF sources (Red Hat, Cisco, SUSE, MSRC, Oracle, Ubuntu).
- Export adapters producing CSAF-compliant output slices from consensus data.
- Schema/version compatibility checks (CSAF 2.0 profile validation).
## Participants
- Connectors deliver raw CSAF documents to this module for normalization.
- Export module leverages adapters when producing CSAF exports.
- Policy engine consumes normalized justification/status fields for gating.
## Interfaces & contracts
- Parser/normalizer classes, helper utilities for `product_tree`, `vulnerabilities`, and `notes`.
- Export writer interfaces for per-provider/per-product CSAF packaging.
## In/Out of scope
In: CSAF parsing/normalization/export, schema validation, mapping to canonical claims.
Out: HTTP fetching (connectors), storage persistence, attestation logic.
## Observability & security expectations
- Emit structured diagnostics when CSAF documents fail schema validation, including source URI and revision.
- Provide counters for normalization outcomes (status distribution, justification coverage).
## Tests
- Fixture-driven parsing/export tests will live in `../StellaOps.Excititor.Formats.CSAF.Tests` using real CSAF samples.
## Required Reading
- `docs/modules/excititor/architecture.md`
- `docs/modules/platform/architecture-overview.md`
## Working Agreement
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.

View File

@@ -0,0 +1,513 @@
using StellaOps.Excititor.Core;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Excititor.Formats.CSAF;
/// <summary>
/// Emits deterministic CSAF 2.0 VEX documents summarising normalized claims.
/// </summary>
public sealed class CsafExporter : IVexExporter
{
public CsafExporter()
{
}
public VexExportFormat Format => VexExportFormat.Csaf;
public VexContentAddress Digest(VexExportRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var document = BuildDocument(request, out _);
var json = VexCanonicalJsonSerializer.Serialize(document);
return ComputeDigest(json);
}
public async ValueTask<VexExportResult> SerializeAsync(
VexExportRequest request,
Stream output,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(output);
var document = BuildDocument(request, out var metadata);
var json = VexCanonicalJsonSerializer.Serialize(document);
var digest = ComputeDigest(json);
var buffer = Encoding.UTF8.GetBytes(json);
await output.WriteAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false);
return new VexExportResult(digest, buffer.LongLength, metadata);
}
private CsafExportDocument BuildDocument(VexExportRequest request, out ImmutableDictionary<string, string> metadata)
{
var signature = VexQuerySignature.FromQuery(request.Query);
var signatureHash = signature.ComputeHash();
var generatedAt = request.GeneratedAt.UtcDateTime.ToString("O", CultureInfo.InvariantCulture);
var productCatalog = new ProductCatalog();
var missingJustifications = new SortedSet<string>(StringComparer.Ordinal);
var vulnerabilityBuilders = new Dictionary<string, CsafVulnerabilityBuilder>(StringComparer.Ordinal);
foreach (var claim in request.Claims)
{
var productId = productCatalog.GetOrAddProductId(claim.Product);
if (!vulnerabilityBuilders.TryGetValue(claim.VulnerabilityId, out var builder))
{
builder = new CsafVulnerabilityBuilder(claim.VulnerabilityId);
vulnerabilityBuilders[claim.VulnerabilityId] = builder;
}
builder.AddClaim(claim, productId);
if (claim.Status == VexClaimStatus.NotAffected && claim.Justification is null)
{
missingJustifications.Add(FormattableString.Invariant($"{claim.VulnerabilityId}:{productId}"));
}
}
var products = productCatalog.Build();
var vulnerabilities = vulnerabilityBuilders.Values
.Select(builder => builder.ToVulnerability())
.Where(static vulnerability => vulnerability is not null)
.Select(static vulnerability => vulnerability!)
.OrderBy(static vulnerability => vulnerability.Cve ?? vulnerability.Id ?? string.Empty, StringComparer.Ordinal)
.ToImmutableArray();
var sourceProviders = request.Claims
.Select(static claim => claim.ProviderId)
.Distinct(StringComparer.Ordinal)
.OrderBy(static provider => provider, StringComparer.Ordinal)
.ToImmutableArray();
var documentSection = new CsafDocumentSection(
Category: "vex",
Title: "StellaOps VEX CSAF Export",
Tracking: new CsafTrackingSection(
Id: FormattableString.Invariant($"stellaops:csaf:{signatureHash.Digest}"),
Status: "final",
Version: "1",
Revision: "1",
InitialReleaseDate: generatedAt,
CurrentReleaseDate: generatedAt,
Generator: new CsafGeneratorSection("StellaOps Excititor")),
Publisher: new CsafPublisherSection("StellaOps Excititor", "coordinator"));
var metadataSection = new CsafExportMetadata(
generatedAt,
signature.Value,
sourceProviders,
missingJustifications.Count == 0
? ImmutableDictionary<string, string>.Empty
: ImmutableDictionary<string, string>.Empty.Add(
"policy.justification_missing",
string.Join(",", missingJustifications)));
metadata = BuildMetadata(signature, vulnerabilities.Length, products.Length, missingJustifications, sourceProviders, generatedAt);
var productTree = new CsafProductTreeSection(products);
return new CsafExportDocument(documentSection, productTree, vulnerabilities, metadataSection);
}
private static ImmutableDictionary<string, string> BuildMetadata(
VexQuerySignature signature,
int vulnerabilityCount,
int productCount,
IEnumerable<string> missingJustifications,
ImmutableArray<string> sourceProviders,
string generatedAt)
{
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
builder["csaf.querySignature"] = signature.Value;
builder["csaf.generatedAt"] = generatedAt;
builder["csaf.vulnerabilityCount"] = vulnerabilityCount.ToString(CultureInfo.InvariantCulture);
builder["csaf.productCount"] = productCount.ToString(CultureInfo.InvariantCulture);
builder["csaf.providerCount"] = sourceProviders.Length.ToString(CultureInfo.InvariantCulture);
var missing = missingJustifications.ToArray();
if (missing.Length > 0)
{
builder["policy.justification_missing"] = string.Join(",", missing);
}
return builder.ToImmutable();
}
private static VexContentAddress ComputeDigest(string json)
{
var bytes = Encoding.UTF8.GetBytes(json);
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
SHA256.HashData(bytes, hash);
var digest = Convert.ToHexString(hash).ToLowerInvariant();
return new VexContentAddress("sha256", digest);
}
private sealed class ProductCatalog
{
private readonly Dictionary<string, MutableProduct> _products = new(StringComparer.Ordinal);
private readonly HashSet<string> _usedIds = new(StringComparer.Ordinal);
public string GetOrAddProductId(VexProduct product)
{
if (_products.TryGetValue(product.Key, out var existing))
{
existing.Update(product);
return existing.ProductId;
}
var productId = GenerateProductId(product.Key);
var mutable = new MutableProduct(productId);
mutable.Update(product);
_products[product.Key] = mutable;
return productId;
}
public ImmutableArray<CsafProductEntry> Build()
=> _products.Values
.Select(static product => product.ToEntry())
.OrderBy(static entry => entry.ProductId, StringComparer.Ordinal)
.ToImmutableArray();
private string GenerateProductId(string key)
{
var sanitized = SanitizeIdentifier(key);
if (_usedIds.Add(sanitized))
{
return sanitized;
}
var hash = ComputeShortHash(key);
var candidate = FormattableString.Invariant($"{sanitized}-{hash}");
while (!_usedIds.Add(candidate))
{
candidate = FormattableString.Invariant($"{candidate}-{hash}");
}
return candidate;
}
private static string SanitizeIdentifier(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "product";
}
var builder = new StringBuilder(value.Length);
foreach (var ch in value)
{
builder.Append(char.IsLetterOrDigit(ch) ? char.ToLowerInvariant(ch) : '-');
}
var sanitized = builder.ToString().Trim('-');
return string.IsNullOrEmpty(sanitized) ? "product" : sanitized;
}
private static string ComputeShortHash(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
SHA256.HashData(bytes, hash);
return Convert.ToHexString(hash[..6]).ToLowerInvariant();
}
}
private sealed class MutableProduct
{
public MutableProduct(string productId)
{
ProductId = productId;
}
public string ProductId { get; }
private string? _name;
private string? _version;
private string? _purl;
private string? _cpe;
private readonly SortedSet<string> _componentIdentifiers = new(StringComparer.OrdinalIgnoreCase);
public void Update(VexProduct product)
{
if (!string.IsNullOrWhiteSpace(product.Name) && ShouldReplace(_name, product.Name))
{
_name = product.Name;
}
if (!string.IsNullOrWhiteSpace(product.Version) && ShouldReplace(_version, product.Version))
{
_version = product.Version;
}
if (!string.IsNullOrWhiteSpace(product.Purl) && ShouldReplace(_purl, product.Purl))
{
_purl = product.Purl;
}
if (!string.IsNullOrWhiteSpace(product.Cpe) && ShouldReplace(_cpe, product.Cpe))
{
_cpe = product.Cpe;
}
foreach (var identifier in product.ComponentIdentifiers)
{
if (!string.IsNullOrWhiteSpace(identifier))
{
_componentIdentifiers.Add(identifier.Trim());
}
}
}
private static bool ShouldReplace(string? existing, string candidate)
{
if (string.IsNullOrWhiteSpace(candidate))
{
return false;
}
if (string.IsNullOrWhiteSpace(existing))
{
return true;
}
return candidate.Length > existing.Length;
}
public CsafProductEntry ToEntry()
{
var helper = new CsafProductIdentificationHelper(
_purl,
_cpe,
_version,
_componentIdentifiers.Count == 0 ? null : _componentIdentifiers.ToImmutableArray());
return new CsafProductEntry(ProductId, _name ?? ProductId, helper);
}
}
private sealed class CsafVulnerabilityBuilder
{
private readonly string _vulnerabilityId;
private string? _title;
private readonly Dictionary<string, SortedSet<string>> _statusMap = new(StringComparer.Ordinal);
private readonly Dictionary<string, SortedSet<string>> _flags = new(StringComparer.Ordinal);
private readonly Dictionary<string, CsafReference> _references = new(StringComparer.Ordinal);
private readonly Dictionary<string, CsafNote> _notes = new(StringComparer.Ordinal);
public CsafVulnerabilityBuilder(string vulnerabilityId)
{
_vulnerabilityId = vulnerabilityId;
}
public void AddClaim(VexClaim claim, string productId)
{
var statusField = MapStatus(claim.Status);
if (!string.IsNullOrEmpty(statusField))
{
GetSet(_statusMap, statusField!).Add(productId);
}
if (claim.Justification is not null)
{
var label = claim.Justification.Value.ToString().ToLowerInvariant();
GetSet(_flags, label).Add(productId);
}
if (!string.IsNullOrWhiteSpace(claim.Detail))
{
var noteKey = FormattableString.Invariant($"{claim.ProviderId}|{productId}");
var text = claim.Detail!.Trim();
_notes[noteKey] = new CsafNote("description", claim.ProviderId, text, "external");
if (string.IsNullOrWhiteSpace(_title))
{
_title = text;
}
}
var referenceKey = claim.Document.Digest;
if (!_references.ContainsKey(referenceKey))
{
_references[referenceKey] = new CsafReference(
claim.Document.SourceUri.ToString(),
claim.ProviderId,
"advisory");
}
}
public CsafExportVulnerability? ToVulnerability()
{
if (_statusMap.Count == 0 && _flags.Count == 0 && _references.Count == 0 && _notes.Count == 0)
{
return null;
}
var productStatus = BuildProductStatus();
ImmutableArray<CsafFlag>? flags = _flags.Count == 0
? null
: _flags
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.Select(pair => new CsafFlag(pair.Key, pair.Value.ToImmutableArray()))
.ToImmutableArray();
ImmutableArray<CsafNote>? notes = _notes.Count == 0
? null
: _notes.Values
.OrderBy(static note => note.Title, StringComparer.Ordinal)
.ThenBy(static note => note.Text, StringComparer.Ordinal)
.ToImmutableArray();
ImmutableArray<CsafReference>? references = _references.Count == 0
? null
: _references.Values
.OrderBy(static reference => reference.Url, StringComparer.Ordinal)
.ToImmutableArray();
var isCve = _vulnerabilityId.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase);
return new CsafExportVulnerability(
Cve: isCve ? _vulnerabilityId.ToUpperInvariant() : null,
Id: isCve ? null : _vulnerabilityId,
Title: _title,
ProductStatus: productStatus,
Flags: flags,
Notes: notes,
References: references);
}
private CsafProductStatus? BuildProductStatus()
{
var knownAffected = GetStatusArray("known_affected");
var knownNotAffected = GetStatusArray("known_not_affected");
var fixedProducts = GetStatusArray("fixed");
var underInvestigation = GetStatusArray("under_investigation");
if (knownAffected is null && knownNotAffected is null && fixedProducts is null && underInvestigation is null)
{
return null;
}
return new CsafProductStatus(knownAffected, knownNotAffected, fixedProducts, underInvestigation);
}
private ImmutableArray<string>? GetStatusArray(string statusKey)
{
if (_statusMap.TryGetValue(statusKey, out var entries) && entries.Count > 0)
{
return entries.ToImmutableArray();
}
return null;
}
private static SortedSet<string> GetSet(Dictionary<string, SortedSet<string>> map, string key)
{
if (!map.TryGetValue(key, out var set))
{
set = new SortedSet<string>(StringComparer.Ordinal);
map[key] = set;
}
return set;
}
private static string? MapStatus(VexClaimStatus status)
=> status switch
{
VexClaimStatus.Affected => "known_affected",
VexClaimStatus.NotAffected => "known_not_affected",
VexClaimStatus.Fixed => "fixed",
VexClaimStatus.UnderInvestigation => "under_investigation",
_ => null,
};
}
}
internal sealed record CsafExportDocument(
[property: JsonPropertyName("document")] CsafDocumentSection Document,
[property: JsonPropertyName("product_tree")] CsafProductTreeSection ProductTree,
[property: JsonPropertyName("vulnerabilities")] ImmutableArray<CsafExportVulnerability> Vulnerabilities,
[property: JsonPropertyName("metadata")] CsafExportMetadata Metadata);
internal sealed record CsafDocumentSection(
[property: JsonPropertyName("category")] string Category,
[property: JsonPropertyName("title")] string Title,
[property: JsonPropertyName("tracking")] CsafTrackingSection Tracking,
[property: JsonPropertyName("publisher")] CsafPublisherSection Publisher);
internal sealed record CsafTrackingSection(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("revision")] string Revision,
[property: JsonPropertyName("initial_release_date")] string InitialReleaseDate,
[property: JsonPropertyName("current_release_date")] string CurrentReleaseDate,
[property: JsonPropertyName("generator")] CsafGeneratorSection Generator);
internal sealed record CsafGeneratorSection(
[property: JsonPropertyName("engine")] string Engine);
internal sealed record CsafPublisherSection(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("category")] string Category);
internal sealed record CsafProductTreeSection(
[property: JsonPropertyName("full_product_names")] ImmutableArray<CsafProductEntry> FullProductNames);
internal sealed record CsafProductEntry(
[property: JsonPropertyName("product_id")] string ProductId,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("product_identification_helper"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] CsafProductIdentificationHelper? IdentificationHelper);
internal sealed record CsafProductIdentificationHelper(
[property: JsonPropertyName("purl"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Purl,
[property: JsonPropertyName("cpe"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Cpe,
[property: JsonPropertyName("product_version"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? ProductVersion,
[property: JsonPropertyName("x_stellaops_component_identifiers"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<string>? ComponentIdentifiers);
internal sealed record CsafExportVulnerability(
[property: JsonPropertyName("cve"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Cve,
[property: JsonPropertyName("id"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Id,
[property: JsonPropertyName("title"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Title,
[property: JsonPropertyName("product_status"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] CsafProductStatus? ProductStatus,
[property: JsonPropertyName("flags"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<CsafFlag>? Flags,
[property: JsonPropertyName("notes"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<CsafNote>? Notes,
[property: JsonPropertyName("references"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<CsafReference>? References);
internal sealed record CsafProductStatus(
[property: JsonPropertyName("known_affected"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<string>? KnownAffected,
[property: JsonPropertyName("known_not_affected"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<string>? KnownNotAffected,
[property: JsonPropertyName("fixed"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<string>? Fixed,
[property: JsonPropertyName("under_investigation"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<string>? UnderInvestigation);
internal sealed record CsafFlag(
[property: JsonPropertyName("label")] string Label,
[property: JsonPropertyName("product_ids")] ImmutableArray<string> ProductIds);
internal sealed record CsafNote(
[property: JsonPropertyName("category")] string Category,
[property: JsonPropertyName("title")] string Title,
[property: JsonPropertyName("text")] string Text,
[property: JsonPropertyName("audience"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Audience);
internal sealed record CsafReference(
[property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("summary")] string Summary,
[property: JsonPropertyName("type")] string Type);
internal sealed record CsafExportMetadata(
[property: JsonPropertyName("generated_at")] string GeneratedAt,
[property: JsonPropertyName("query_signature")] string QuerySignature,
[property: JsonPropertyName("source_providers")] ImmutableArray<string> SourceProviders,
[property: JsonPropertyName("diagnostics")] ImmutableDictionary<string, string> Diagnostics);

View File

@@ -0,0 +1,900 @@
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Core;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Excititor.Formats.CSAF;
public sealed class CsafNormalizer : IVexNormalizer
{
private static readonly ImmutableDictionary<VexClaimStatus, int> StatusPrecedence = new Dictionary<VexClaimStatus, int>
{
[VexClaimStatus.UnderInvestigation] = 0,
[VexClaimStatus.Affected] = 1,
[VexClaimStatus.NotAffected] = 2,
[VexClaimStatus.Fixed] = 3,
}.ToImmutableDictionary();
private static readonly ImmutableDictionary<string, VexClaimStatus> StatusMap = new Dictionary<string, VexClaimStatus>(StringComparer.OrdinalIgnoreCase)
{
["known_affected"] = VexClaimStatus.Affected,
["first_affected"] = VexClaimStatus.Affected,
["last_affected"] = VexClaimStatus.Affected,
["affected"] = VexClaimStatus.Affected,
["fixed_after_release"] = VexClaimStatus.Fixed,
["fixed"] = VexClaimStatus.Fixed,
["first_fixed"] = VexClaimStatus.Fixed,
["last_fixed"] = VexClaimStatus.Fixed,
["recommended"] = VexClaimStatus.Fixed,
["known_not_affected"] = VexClaimStatus.NotAffected,
["first_not_affected"] = VexClaimStatus.NotAffected,
["last_not_affected"] = VexClaimStatus.NotAffected,
["not_affected"] = VexClaimStatus.NotAffected,
["under_investigation"] = VexClaimStatus.UnderInvestigation,
["investigating"] = VexClaimStatus.UnderInvestigation,
["in_investigation"] = VexClaimStatus.UnderInvestigation,
["in_triage"] = VexClaimStatus.UnderInvestigation,
["unknown"] = 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,
["protected_at_runtime"] = VexJustification.ProtectedAtRuntime,
["protected_at_perimeter"] = VexJustification.ProtectedAtPerimeter,
["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<CsafNormalizer> _logger;
public CsafNormalizer(ILogger<CsafNormalizer> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string Format => VexDocumentFormat.Csaf.ToString().ToLowerInvariant();
public bool CanHandle(VexRawDocument document)
=> document is not null && document.Format == VexDocumentFormat.Csaf;
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, VexProvider provider, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(document);
ArgumentNullException.ThrowIfNull(provider);
cancellationToken.ThrowIfCancellationRequested();
try
{
var result = CsafParser.Parse(document);
var claims = ImmutableArray.CreateBuilder<VexClaim>(result.Claims.Length);
foreach (var entry in result.Claims)
{
var product = new VexProduct(
entry.Product.ProductId,
entry.Product.Name,
entry.Product.Version,
entry.Product.Purl,
entry.Product.Cpe);
var claimDocument = new VexClaimDocument(
VexDocumentFormat.Csaf,
document.Digest,
document.SourceUri,
result.Revision,
signature: null);
var metadata = result.Metadata;
if (!string.IsNullOrWhiteSpace(entry.RawStatus))
{
metadata = metadata.SetItem("csaf.product_status.raw", entry.RawStatus);
}
if (!string.IsNullOrWhiteSpace(entry.RawJustification))
{
metadata = metadata.SetItem("csaf.justification.label", entry.RawJustification);
}
var claim = new VexClaim(
entry.VulnerabilityId,
provider.Id,
product,
entry.Status,
claimDocument,
result.FirstRelease,
result.LastRelease,
entry.Justification,
detail: entry.Detail,
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 CSAF document {Source} into {ClaimCount} claim(s).",
document.SourceUri,
orderedClaims.Length);
var diagnosticsBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
if (!result.UnsupportedStatuses.IsDefaultOrEmpty && result.UnsupportedStatuses.Length > 0)
{
diagnosticsBuilder["policy.unsupported_statuses"] = string.Join(",", result.UnsupportedStatuses);
}
if (!result.UnsupportedJustifications.IsDefaultOrEmpty && result.UnsupportedJustifications.Length > 0)
{
diagnosticsBuilder["policy.unsupported_justifications"] = string.Join(",", result.UnsupportedJustifications);
}
if (!result.ConflictingJustifications.IsDefaultOrEmpty && result.ConflictingJustifications.Length > 0)
{
diagnosticsBuilder["policy.justification_conflicts"] = string.Join(",", result.ConflictingJustifications);
}
if (!result.MissingRequiredJustifications.IsDefaultOrEmpty && result.MissingRequiredJustifications.Length > 0)
{
diagnosticsBuilder["policy.justification_missing"] = string.Join(",", result.MissingRequiredJustifications);
}
var diagnostics = diagnosticsBuilder.Count == 0
? ImmutableDictionary<string, string>.Empty
: diagnosticsBuilder.ToImmutable();
return ValueTask.FromResult(new VexClaimBatch(document, orderedClaims, diagnostics));
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse CSAF document {SourceUri}", document.SourceUri);
throw;
}
}
private static class CsafParser
{
public static CsafParseResult Parse(VexRawDocument document)
{
using var json = JsonDocument.Parse(document.Content.ToArray());
var root = json.RootElement;
var tracking = root.TryGetProperty("document", out var documentElement) &&
documentElement.ValueKind == JsonValueKind.Object &&
documentElement.TryGetProperty("tracking", out var trackingElement)
? trackingElement
: default;
var firstRelease = ParseDate(tracking, "initial_release_date") ?? document.RetrievedAt;
var lastRelease = ParseDate(tracking, "current_release_date") ?? firstRelease;
if (lastRelease < firstRelease)
{
lastRelease = firstRelease;
}
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
AddIfPresent(metadataBuilder, tracking, "id", "csaf.tracking.id");
AddIfPresent(metadataBuilder, tracking, "version", "csaf.tracking.version");
AddIfPresent(metadataBuilder, tracking, "status", "csaf.tracking.status");
AddPublisherMetadata(metadataBuilder, documentElement);
var revision = TryGetString(tracking, "revision");
var productCatalog = CollectProducts(root);
var productGroups = CollectProductGroups(root);
var unsupportedStatuses = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var unsupportedJustifications = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var conflictingJustifications = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var missingRequiredJustifications = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var claimsBuilder = ImmutableArray.CreateBuilder<CsafClaimEntry>();
if (root.TryGetProperty("vulnerabilities", out var vulnerabilitiesElement) &&
vulnerabilitiesElement.ValueKind == JsonValueKind.Array)
{
foreach (var vulnerability in vulnerabilitiesElement.EnumerateArray())
{
var vulnerabilityId = ResolveVulnerabilityId(vulnerability);
if (string.IsNullOrWhiteSpace(vulnerabilityId))
{
continue;
}
var detail = ResolveDetail(vulnerability);
var justifications = CollectJustifications(
vulnerability,
productCatalog,
productGroups,
unsupportedJustifications,
conflictingJustifications);
var productClaims = BuildClaimsForVulnerability(
vulnerabilityId,
vulnerability,
productCatalog,
justifications,
detail,
unsupportedStatuses,
missingRequiredJustifications);
claimsBuilder.AddRange(productClaims);
}
}
return new CsafParseResult(
firstRelease,
lastRelease,
revision,
metadataBuilder.ToImmutable(),
claimsBuilder.ToImmutable(),
unsupportedStatuses.OrderBy(static s => s, StringComparer.OrdinalIgnoreCase).ToImmutableArray(),
unsupportedJustifications.OrderBy(static s => s, StringComparer.OrdinalIgnoreCase).ToImmutableArray(),
conflictingJustifications.OrderBy(static s => s, StringComparer.OrdinalIgnoreCase).ToImmutableArray(),
missingRequiredJustifications.OrderBy(static s => s, StringComparer.OrdinalIgnoreCase).ToImmutableArray());
}
private static IReadOnlyList<CsafClaimEntry> BuildClaimsForVulnerability(
string vulnerabilityId,
JsonElement vulnerability,
IReadOnlyDictionary<string, CsafProductInfo> productCatalog,
ImmutableDictionary<string, CsafJustificationInfo> justifications,
string? detail,
ISet<string> unsupportedStatuses,
ISet<string> missingRequiredJustifications)
{
if (!vulnerability.TryGetProperty("product_status", out var statusElement) ||
statusElement.ValueKind != JsonValueKind.Object)
{
return Array.Empty<CsafClaimEntry>();
}
var claims = new Dictionary<string, CsafClaimEntryBuilder>(StringComparer.OrdinalIgnoreCase);
foreach (var statusProperty in statusElement.EnumerateObject())
{
var status = MapStatus(statusProperty.Name, unsupportedStatuses);
if (status is null)
{
continue;
}
if (statusProperty.Value.ValueKind != JsonValueKind.Array)
{
continue;
}
foreach (var productIdElement in statusProperty.Value.EnumerateArray())
{
var productId = productIdElement.GetString();
if (string.IsNullOrWhiteSpace(productId))
{
continue;
}
var trimmedProductId = productId.Trim();
var product = ResolveProduct(productCatalog, trimmedProductId);
justifications.TryGetValue(trimmedProductId, out var justificationInfo);
UpdateClaim(claims, product, status.Value, statusProperty.Name, detail, justificationInfo);
}
}
if (claims.Count == 0)
{
return Array.Empty<CsafClaimEntry>();
}
var builtClaims = claims.Values
.Select(builder => new CsafClaimEntry(
vulnerabilityId,
builder.Product,
builder.Status,
builder.RawStatus,
builder.Detail,
builder.Justification,
builder.RawJustification))
.ToArray();
foreach (var entry in builtClaims)
{
if (entry.Status == VexClaimStatus.NotAffected && entry.Justification is null)
{
missingRequiredJustifications.Add(FormattableString.Invariant($"{entry.VulnerabilityId}:{entry.Product.ProductId}"));
}
}
return builtClaims;
}
private static void UpdateClaim(
IDictionary<string, CsafClaimEntryBuilder> claims,
CsafProductInfo product,
VexClaimStatus status,
string rawStatus,
string? detail,
CsafJustificationInfo? justification)
{
if (!claims.TryGetValue(product.ProductId, out var existing) ||
StatusPrecedence[status] > StatusPrecedence[existing.Status])
{
claims[product.ProductId] = new CsafClaimEntryBuilder(
product,
status,
NormalizeRaw(rawStatus),
detail,
justification?.Normalized,
justification?.RawValue);
return;
}
if (StatusPrecedence[status] < StatusPrecedence[existing.Status])
{
return;
}
var updated = existing;
if (string.IsNullOrWhiteSpace(existing.RawStatus))
{
updated = updated with { RawStatus = NormalizeRaw(rawStatus) };
}
if (existing.Detail is null && detail is not null)
{
updated = updated with { Detail = detail };
}
if (justification is not null)
{
if (existing.Justification is null && justification.Normalized is not null)
{
updated = updated with
{
Justification = justification.Normalized,
RawJustification = justification.RawValue
};
}
else if (existing.Justification is null &&
justification.Normalized is null &&
string.IsNullOrWhiteSpace(existing.RawJustification) &&
!string.IsNullOrWhiteSpace(justification.RawValue))
{
updated = updated with { RawJustification = justification.RawValue };
}
}
claims[product.ProductId] = updated;
}
private static CsafProductInfo ResolveProduct(
IReadOnlyDictionary<string, CsafProductInfo> catalog,
string productId)
{
if (catalog.TryGetValue(productId, out var product))
{
return product;
}
return new CsafProductInfo(productId, productId, null, null, null);
}
private static string ResolveVulnerabilityId(JsonElement vulnerability)
{
var id = TryGetString(vulnerability, "cve")
?? TryGetString(vulnerability, "id")
?? TryGetString(vulnerability, "vuln_id");
return string.IsNullOrWhiteSpace(id) ? string.Empty : id.Trim();
}
private static string? ResolveDetail(JsonElement vulnerability)
{
var title = TryGetString(vulnerability, "title");
if (!string.IsNullOrWhiteSpace(title))
{
return title.Trim();
}
if (vulnerability.TryGetProperty("notes", out var notesElement) &&
notesElement.ValueKind == JsonValueKind.Array)
{
foreach (var note in notesElement.EnumerateArray())
{
if (note.ValueKind != JsonValueKind.Object)
{
continue;
}
var category = TryGetString(note, "category");
if (!string.IsNullOrWhiteSpace(category) &&
!string.Equals(category, "description", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var text = TryGetString(note, "text");
if (!string.IsNullOrWhiteSpace(text))
{
return text.Trim();
}
}
}
return null;
}
private static Dictionary<string, CsafProductInfo> CollectProducts(JsonElement root)
{
var products = new Dictionary<string, CsafProductInfo>(StringComparer.OrdinalIgnoreCase);
if (!root.TryGetProperty("product_tree", out var productTree) ||
productTree.ValueKind != JsonValueKind.Object)
{
return products;
}
if (productTree.TryGetProperty("full_product_names", out var fullNames) &&
fullNames.ValueKind == JsonValueKind.Array)
{
foreach (var productEntry in fullNames.EnumerateArray())
{
var product = ParseProduct(productEntry, parentBranchName: null);
if (product is not null)
{
AddOrUpdate(product);
}
}
}
if (productTree.TryGetProperty("branches", out var branches) &&
branches.ValueKind == JsonValueKind.Array)
{
foreach (var branch in branches.EnumerateArray())
{
VisitBranch(branch, parentBranchName: null);
}
}
return products;
void VisitBranch(JsonElement branch, string? parentBranchName)
{
if (branch.ValueKind != JsonValueKind.Object)
{
return;
}
var branchName = TryGetString(branch, "name") ?? parentBranchName;
if (branch.TryGetProperty("product", out var productElement))
{
var product = ParseProduct(productElement, branchName);
if (product is not null)
{
AddOrUpdate(product);
}
}
if (branch.TryGetProperty("branches", out var childBranches) &&
childBranches.ValueKind == JsonValueKind.Array)
{
foreach (var childBranch in childBranches.EnumerateArray())
{
VisitBranch(childBranch, branchName);
}
}
}
void AddOrUpdate(CsafProductInfo product)
{
if (products.TryGetValue(product.ProductId, out var existing))
{
products[product.ProductId] = MergeProducts(existing, product);
}
else
{
products[product.ProductId] = product;
}
}
static CsafProductInfo MergeProducts(CsafProductInfo existing, CsafProductInfo incoming)
{
static string ChooseName(string incoming, string fallback)
=> string.IsNullOrWhiteSpace(incoming) ? fallback : incoming;
static string? ChooseOptional(string? incoming, string? fallback)
=> string.IsNullOrWhiteSpace(incoming) ? fallback : incoming;
return new CsafProductInfo(
existing.ProductId,
ChooseName(incoming.Name, existing.Name),
ChooseOptional(incoming.Version, existing.Version),
ChooseOptional(incoming.Purl, existing.Purl),
ChooseOptional(incoming.Cpe, existing.Cpe));
}
}
private static ImmutableDictionary<string, ImmutableArray<string>> CollectProductGroups(JsonElement root)
{
if (!root.TryGetProperty("product_tree", out var productTree) ||
productTree.ValueKind != JsonValueKind.Object ||
!productTree.TryGetProperty("product_groups", out var groupsElement) ||
groupsElement.ValueKind != JsonValueKind.Array)
{
return ImmutableDictionary<string, ImmutableArray<string>>.Empty;
}
var groups = new Dictionary<string, ImmutableArray<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var group in groupsElement.EnumerateArray())
{
if (group.ValueKind != JsonValueKind.Object)
{
continue;
}
var groupId = TryGetString(group, "group_id");
if (string.IsNullOrWhiteSpace(groupId))
{
continue;
}
if (!group.TryGetProperty("product_ids", out var productIdsElement) ||
productIdsElement.ValueKind != JsonValueKind.Array)
{
continue;
}
var members = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var productIdElement in productIdsElement.EnumerateArray())
{
var productId = productIdElement.GetString();
if (string.IsNullOrWhiteSpace(productId))
{
continue;
}
members.Add(productId.Trim());
}
if (members.Count == 0)
{
continue;
}
groups[groupId.Trim()] = members
.OrderBy(static id => id, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
}
return groups.Count == 0
? ImmutableDictionary<string, ImmutableArray<string>>.Empty
: groups.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
}
private static ImmutableDictionary<string, CsafJustificationInfo> CollectJustifications(
JsonElement vulnerability,
IReadOnlyDictionary<string, CsafProductInfo> productCatalog,
ImmutableDictionary<string, ImmutableArray<string>> productGroups,
ISet<string> unsupportedJustifications,
ISet<string> conflictingJustifications)
{
if (!vulnerability.TryGetProperty("flags", out var flagsElement) ||
flagsElement.ValueKind != JsonValueKind.Array)
{
return ImmutableDictionary<string, CsafJustificationInfo>.Empty;
}
var map = new Dictionary<string, CsafJustificationInfo>(StringComparer.OrdinalIgnoreCase);
foreach (var flag in flagsElement.EnumerateArray())
{
if (flag.ValueKind != JsonValueKind.Object)
{
continue;
}
var label = TryGetString(flag, "label");
if (string.IsNullOrWhiteSpace(label))
{
continue;
}
var rawLabel = NormalizeRaw(label);
var normalized = MapJustification(rawLabel, unsupportedJustifications);
var targetIds = ExpandFlagProducts(flag, productGroups);
foreach (var productId in targetIds)
{
if (!productCatalog.ContainsKey(productId))
{
continue;
}
var info = new CsafJustificationInfo(rawLabel, normalized);
if (map.TryGetValue(productId, out var existing))
{
if (existing.Normalized is null && normalized is not null)
{
map[productId] = info;
}
else if (existing.Normalized is not null && normalized is not null && existing.Normalized != normalized)
{
conflictingJustifications.Add(productId);
}
else if (existing.Normalized is null &&
normalized is null &&
string.IsNullOrWhiteSpace(existing.RawValue) &&
!string.IsNullOrWhiteSpace(rawLabel))
{
map[productId] = info;
}
}
else
{
map[productId] = info;
}
}
}
return map.Count == 0
? ImmutableDictionary<string, CsafJustificationInfo>.Empty
: map.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
}
private static IEnumerable<string> ExpandFlagProducts(
JsonElement flag,
ImmutableDictionary<string, ImmutableArray<string>> productGroups)
{
var productIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (flag.TryGetProperty("product_ids", out var productIdsElement) &&
productIdsElement.ValueKind == JsonValueKind.Array)
{
foreach (var idElement in productIdsElement.EnumerateArray())
{
var id = idElement.GetString();
if (string.IsNullOrWhiteSpace(id))
{
continue;
}
productIds.Add(id.Trim());
}
}
if (flag.TryGetProperty("group_ids", out var groupIdsElement) &&
groupIdsElement.ValueKind == JsonValueKind.Array)
{
foreach (var groupIdElement in groupIdsElement.EnumerateArray())
{
var groupId = groupIdElement.GetString();
if (string.IsNullOrWhiteSpace(groupId))
{
continue;
}
if (productGroups.TryGetValue(groupId.Trim(), out var members))
{
foreach (var member in members)
{
productIds.Add(member);
}
}
}
}
return productIds;
}
private static VexJustification? MapJustification(string justification, ISet<string> unsupportedJustifications)
{
if (string.IsNullOrWhiteSpace(justification))
{
return null;
}
if (JustificationMap.TryGetValue(justification, out var mapped))
{
return mapped;
}
unsupportedJustifications.Add(justification);
return null;
}
private static string NormalizeRaw(string value)
=> string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim();
private static CsafProductInfo? ParseProduct(JsonElement element, string? parentBranchName)
{
if (element.ValueKind != JsonValueKind.Object)
{
return null;
}
JsonElement productElement = element;
if (!element.TryGetProperty("product_id", out var idElement) &&
element.TryGetProperty("product", out var nestedProduct) &&
nestedProduct.ValueKind == JsonValueKind.Object &&
nestedProduct.TryGetProperty("product_id", out idElement))
{
productElement = nestedProduct;
}
var productId = idElement.GetString();
if (string.IsNullOrWhiteSpace(productId))
{
return null;
}
var name = TryGetString(productElement, "name")
?? TryGetString(element, "name")
?? parentBranchName
?? productId;
var version = TryGetString(productElement, "product_version")
?? TryGetString(productElement, "version")
?? TryGetString(element, "product_version");
string? cpe = null;
string? purl = null;
if (productElement.TryGetProperty("product_identification_helper", out var helper) &&
helper.ValueKind == JsonValueKind.Object)
{
cpe = TryGetString(helper, "cpe");
purl = TryGetString(helper, "purl");
}
return new CsafProductInfo(productId.Trim(), name.Trim(), version?.Trim(), purl?.Trim(), cpe?.Trim());
}
private static VexClaimStatus? MapStatus(string statusName, ISet<string> unsupportedStatuses)
{
if (string.IsNullOrWhiteSpace(statusName))
{
return null;
}
var normalized = statusName.Trim();
if (StatusMap.TryGetValue(normalized, out var mapped))
{
return mapped;
}
unsupportedStatuses.Add(normalized);
return null;
}
private static DateTimeOffset? ParseDate(JsonElement element, string propertyName)
{
if (element.ValueKind != JsonValueKind.Object)
{
return null;
}
if (!element.TryGetProperty(propertyName, out var dateElement))
{
return null;
}
var value = dateElement.GetString();
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (DateTimeOffset.TryParse(value, out var parsed))
{
return parsed;
}
return null;
}
private static string? TryGetString(JsonElement element, string propertyName)
{
if (element.ValueKind != JsonValueKind.Object)
{
return null;
}
if (!element.TryGetProperty(propertyName, out var property))
{
return null;
}
return property.ValueKind == JsonValueKind.String ? property.GetString() : null;
}
private static void AddIfPresent(
ImmutableDictionary<string, string>.Builder builder,
JsonElement element,
string propertyName,
string metadataKey)
{
var value = TryGetString(element, propertyName);
if (!string.IsNullOrWhiteSpace(value))
{
builder[metadataKey] = value.Trim();
}
}
private static void AddPublisherMetadata(
ImmutableDictionary<string, string>.Builder builder,
JsonElement documentElement)
{
if (documentElement.ValueKind != JsonValueKind.Object ||
!documentElement.TryGetProperty("publisher", out var publisher) ||
publisher.ValueKind != JsonValueKind.Object)
{
return;
}
AddIfPresent(builder, publisher, "name", "csaf.publisher.name");
AddIfPresent(builder, publisher, "category", "csaf.publisher.category");
}
private readonly record struct CsafClaimEntryBuilder(
CsafProductInfo Product,
VexClaimStatus Status,
string RawStatus,
string? Detail,
VexJustification? Justification,
string? RawJustification);
}
private sealed record CsafParseResult(
DateTimeOffset FirstRelease,
DateTimeOffset LastRelease,
string? Revision,
ImmutableDictionary<string, string> Metadata,
ImmutableArray<CsafClaimEntry> Claims,
ImmutableArray<string> UnsupportedStatuses,
ImmutableArray<string> UnsupportedJustifications,
ImmutableArray<string> ConflictingJustifications,
ImmutableArray<string> MissingRequiredJustifications);
private sealed record CsafJustificationInfo(
string RawValue,
VexJustification? Normalized);
private sealed record CsafClaimEntry(
string VulnerabilityId,
CsafProductInfo Product,
VexClaimStatus Status,
string RawStatus,
string? Detail,
VexJustification? Justification,
string? RawJustification);
private sealed record CsafProductInfo(
string ProductId,
string Name,
string? Version,
string? Purl,
string? Cpe);
}

View File

@@ -0,0 +1,15 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Formats.CSAF;
public static class CsafFormatsServiceCollectionExtensions
{
public static IServiceCollection AddCsafNormalizer(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton<IVexNormalizer, CsafNormalizer>();
services.AddSingleton<IVexExporter, CsafExporter>();
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" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
# Completed Tasks
|EXCITITOR-FMT-CSAF-01-001 CSAF normalizer foundation|Team Excititor Formats|EXCITITOR-CORE-01-001|**DONE (2025-10-17)** Implemented CSAF normalizer + DI hook, parsing tracking metadata, product tree branches/full names, and mapping product statuses into canonical `VexClaim`s with baseline precedence. Regression added in `CsafNormalizerTests`.|
|EXCITITOR-FMT-CSAF-01-002 Status/justification mapping|Team Excititor Formats|EXCITITOR-FMT-CSAF-01-001, EXCITITOR-POLICY-01-001|**DONE (2025-10-29)** Added policy-aligned diagnostics for unsupported statuses/justifications and flagged missing not_affected evidence inside normalizer outputs.|
|EXCITITOR-FMT-CSAF-01-003 CSAF export adapter|Team Excititor Formats|EXCITITOR-EXPORT-01-001, EXCITITOR-FMT-CSAF-01-001|**DONE (2025-10-29)** Implemented deterministic CSAF exporter with product tree reconciliation, vulnerability status mapping, and metadata for downstream attestation.|

View File

@@ -0,0 +1,10 @@
# Excititor CSAF Formats Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0317-M | DONE | Revalidated 2026-01-07. |
| AUDIT-0317-T | DONE | Revalidated 2026-01-07. |
| AUDIT-0317-A | TODO | Revalidated 2026-01-07 (open findings; pending approval). |