consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -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.
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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.|
|
||||
|
||||
@@ -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). |
|
||||
Reference in New Issue
Block a user