Add unit tests for RancherHubConnector and various exporters
- Implemented tests for RancherHubConnector to validate fetching documents, handling errors, and managing state. - Added tests for CsafExporter to ensure deterministic serialization of CSAF documents. - Created tests for CycloneDX exporters and reconciler to verify correct handling of VEX claims and output structure. - Developed OpenVEX exporter tests to confirm the generation of canonical OpenVEX documents and statement merging logic. - Introduced Rust file caching and license scanning functionality, including a cache key structure and hash computation. - Added sample Cargo.toml and LICENSE files for testing Rust license scanning functionality.
This commit is contained in:
@@ -1,15 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Connectors.Cisco.CSAF.Metadata;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.Cisco.CSAF.Configuration;
|
||||
using StellaOps.Excititor.Connectors.Cisco.CSAF.Metadata;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
|
||||
namespace StellaOps.Excititor.Connectors.Cisco.CSAF;
|
||||
|
||||
@@ -71,12 +73,14 @@ public sealed class CiscoCsafConnector : VexConnectorBase
|
||||
throw new InvalidOperationException("Connector must be validated before fetch operations.");
|
||||
}
|
||||
|
||||
if (_providerMetadata is null)
|
||||
{
|
||||
_providerMetadata = await _metadataLoader.LoadAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var state = await _stateRepository.GetAsync(Descriptor.Id, cancellationToken).ConfigureAwait(false);
|
||||
if (_providerMetadata is null)
|
||||
{
|
||||
_providerMetadata = await _metadataLoader.LoadAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await UpsertProviderAsync(context.Services, _providerMetadata.Provider, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var state = await _stateRepository.GetAsync(Descriptor.Id, cancellationToken).ConfigureAwait(false);
|
||||
var knownDigests = state?.DocumentDigests ?? ImmutableArray<string>.Empty;
|
||||
var digestSet = new HashSet<string>(knownDigests, StringComparer.OrdinalIgnoreCase);
|
||||
var digestList = new List<string>(knownDigests);
|
||||
@@ -99,16 +103,23 @@ public sealed class CiscoCsafConnector : VexConnectorBase
|
||||
contentResponse.EnsureSuccessStatusCode();
|
||||
var payload = await contentResponse.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var rawDocument = CreateRawDocument(
|
||||
VexDocumentFormat.Csaf,
|
||||
advisory.DocumentUri,
|
||||
payload,
|
||||
BuildMetadata(builder => builder
|
||||
.Add("cisco.csaf.advisoryId", advisory.Id)
|
||||
.Add("cisco.csaf.revision", advisory.Revision)
|
||||
.Add("cisco.csaf.published", advisory.Published?.ToString("O"))
|
||||
.Add("cisco.csaf.modified", advisory.LastModified?.ToString("O"))
|
||||
.Add("cisco.csaf.sha256", advisory.Sha256)));
|
||||
var metadata = BuildMetadata(builder =>
|
||||
{
|
||||
builder
|
||||
.Add("cisco.csaf.advisoryId", advisory.Id)
|
||||
.Add("cisco.csaf.revision", advisory.Revision)
|
||||
.Add("cisco.csaf.published", advisory.Published?.ToString("O"))
|
||||
.Add("cisco.csaf.modified", advisory.LastModified?.ToString("O"))
|
||||
.Add("cisco.csaf.sha256", advisory.Sha256);
|
||||
|
||||
AddProvenanceMetadata(builder);
|
||||
});
|
||||
|
||||
var rawDocument = CreateRawDocument(
|
||||
VexDocumentFormat.Csaf,
|
||||
advisory.DocumentUri,
|
||||
payload,
|
||||
metadata);
|
||||
|
||||
if (!digestSet.Add(rawDocument.Digest))
|
||||
{
|
||||
@@ -221,19 +232,59 @@ public sealed class CiscoCsafConnector : VexConnectorBase
|
||||
return new Uri(new Uri(baseTextRelative, UriKind.Absolute), relative);
|
||||
}
|
||||
|
||||
private static Uri? ResolveNextUri(Uri directory, string? next)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(next))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildIndexUri(directory, next);
|
||||
}
|
||||
|
||||
private sealed record CiscoAdvisoryIndex
|
||||
{
|
||||
public List<CiscoAdvisory>? Advisories { get; init; }
|
||||
private static Uri? ResolveNextUri(Uri directory, string? next)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(next))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildIndexUri(directory, next);
|
||||
}
|
||||
|
||||
private void AddProvenanceMetadata(VexConnectorMetadataBuilder builder)
|
||||
{
|
||||
if (_providerMetadata is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var provider = _providerMetadata.Provider;
|
||||
builder.Add("vex.provenance.provider", provider.Id);
|
||||
builder.Add("vex.provenance.providerName", provider.DisplayName);
|
||||
builder.Add("vex.provenance.trust.weight", provider.Trust.Weight.ToString("0.###", CultureInfo.InvariantCulture));
|
||||
|
||||
if (provider.Trust.Cosign is { } cosign)
|
||||
{
|
||||
builder.Add("vex.provenance.cosign.issuer", cosign.Issuer);
|
||||
builder.Add("vex.provenance.cosign.identityPattern", cosign.IdentityPattern);
|
||||
}
|
||||
|
||||
if (!provider.Trust.PgpFingerprints.IsDefaultOrEmpty && provider.Trust.PgpFingerprints.Length > 0)
|
||||
{
|
||||
builder.Add("vex.provenance.pgp.fingerprints", string.Join(",", provider.Trust.PgpFingerprints));
|
||||
}
|
||||
}
|
||||
|
||||
private static async ValueTask UpsertProviderAsync(IServiceProvider services, VexProvider provider, CancellationToken cancellationToken)
|
||||
{
|
||||
if (services is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var store = services.GetService<IVexProviderStore>();
|
||||
if (store is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await store.SaveAsync(provider, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private sealed record CiscoAdvisoryIndex
|
||||
{
|
||||
public List<CiscoAdvisory>? Advisories { get; init; }
|
||||
public string? Next { get; init; }
|
||||
}
|
||||
|
||||
|
||||
@@ -4,4 +4,4 @@ If you are working on this file you need to read docs/modules/excititor/ARCHITEC
|
||||
|---|---|---|---|
|
||||
|EXCITITOR-CONN-CISCO-01-001 – Endpoint discovery & auth plumbing|Team Excititor Connectors – Cisco|EXCITITOR-CONN-ABS-01-001|**DONE (2025-10-17)** – Added `CiscoProviderMetadataLoader` with bearer token support, offline snapshot fallback, DI helpers, and tests covering network/offline discovery to unblock subsequent fetch work.|
|
||||
|EXCITITOR-CONN-CISCO-01-002 – CSAF pull loop & pagination|Team Excititor Connectors – Cisco|EXCITITOR-CONN-CISCO-01-001, EXCITITOR-STORAGE-01-003|**DONE (2025-10-17)** – Implemented paginated advisory fetch using provider directories, raw document persistence with dedupe/state tracking, offline resiliency, and unit coverage.|
|
||||
|EXCITITOR-CONN-CISCO-01-003 – Provider trust metadata|Team Excititor Connectors – Cisco|EXCITITOR-CONN-CISCO-01-002, EXCITITOR-POLICY-01-001|**DOING (2025-10-19)** – Prereqs confirmed (both DONE); implementing cosign/PGP trust metadata emission and advisory provenance hints for policy weighting.|
|
||||
|EXCITITOR-CONN-CISCO-01-003 – Provider trust metadata|Team Excititor Connectors – Cisco|EXCITITOR-CONN-CISCO-01-002, EXCITITOR-POLICY-01-001|**DONE (2025-10-29)** – Connector now annotates raw documents with provider trust + cosign/PGP provenance and upserts `VexProvider` entries; new unit coverage asserts metadata emission and provider-store invocation.|
|
||||
|
||||
@@ -3,5 +3,5 @@ If you are working on this file you need to read docs/modules/excititor/ARCHITEC
|
||||
| Task | Owner(s) | Depends on | Notes |
|
||||
|---|---|---|---|
|
||||
|EXCITITOR-CONN-MS-01-001 – AAD onboarding & token cache|Team Excititor Connectors – MSRC|EXCITITOR-CONN-ABS-01-001|**DONE (2025-10-17)** – Added MSRC connector project with configurable AAD options, token provider (offline/online modes), DI wiring, and unit tests covering caching and fallback scenarios.|
|
||||
|EXCITITOR-CONN-MS-01-002 – CSAF download pipeline|Team Excititor Connectors – MSRC|EXCITITOR-CONN-MS-01-001, EXCITITOR-STORAGE-01-003|**DOING (2025-10-19)** – Prereqs verified (EXCITITOR-CONN-MS-01-001, EXCITITOR-STORAGE-01-003); drafting fetch/retry plan and storage wiring before implementation of CSAF package download, checksum validation, and quarantine flows.|
|
||||
|EXCITITOR-CONN-MS-01-002 – CSAF download pipeline|Team Excititor Connectors – MSRC|EXCITITOR-CONN-MS-01-001, EXCITITOR-STORAGE-01-003|**DONE (2025-10-29)** – Implemented authenticated CSAF retrieval with retry/backoff, checksum enforcement, quarantine for invalid archives, and regression tests covering dedupe + idempotent state updates.|
|
||||
|EXCITITOR-CONN-MS-01-003 – Trust metadata & provenance hints|Team Excititor Connectors – MSRC|EXCITITOR-CONN-MS-01-002, EXCITITOR-POLICY-01-001|TODO – Emit cosign/AAD issuer metadata, attach provenance details, and document policy integration.|
|
||||
|
||||
@@ -2,12 +2,14 @@ using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Authentication;
|
||||
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
|
||||
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Metadata;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Excititor.Connectors.Abstractions;
|
||||
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Authentication;
|
||||
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
|
||||
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Events;
|
||||
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Metadata;
|
||||
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.State;
|
||||
using StellaOps.Excititor.Core;
|
||||
using System.IO.Abstractions;
|
||||
|
||||
@@ -28,10 +30,12 @@ public static class RancherHubConnectorServiceCollectionExtensions
|
||||
configure?.Invoke(options);
|
||||
});
|
||||
|
||||
services.AddSingleton<IVexConnectorOptionsValidator<RancherHubConnectorOptions>, RancherHubConnectorOptionsValidator>();
|
||||
services.AddSingleton<RancherHubTokenProvider>();
|
||||
services.AddSingleton<RancherHubMetadataLoader>();
|
||||
services.AddSingleton<IVexConnector, RancherHubConnector>();
|
||||
services.AddSingleton<IVexConnectorOptionsValidator<RancherHubConnectorOptions>, RancherHubConnectorOptionsValidator>();
|
||||
services.AddSingleton<RancherHubCheckpointManager>();
|
||||
services.AddSingleton<RancherHubEventClient>();
|
||||
services.AddSingleton<RancherHubTokenProvider>();
|
||||
services.AddSingleton<RancherHubMetadataLoader>();
|
||||
services.AddSingleton<IVexConnector, RancherHubConnector>();
|
||||
|
||||
services.AddHttpClient(RancherHubConnectorOptions.HttpClientName, client =>
|
||||
{
|
||||
|
||||
@@ -22,6 +22,8 @@ namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub;
|
||||
|
||||
public sealed class RancherHubConnector : VexConnectorBase
|
||||
{
|
||||
private const int MaxDigestHistory = 200;
|
||||
|
||||
private static readonly VexConnectorDescriptor StaticDescriptor = new(
|
||||
id: "excititor:suse.rancher",
|
||||
kind: VexProviderKind.Hub,
|
||||
@@ -153,6 +155,12 @@ public sealed class RancherHubConnector : VexConnectorBase
|
||||
}
|
||||
}
|
||||
|
||||
var trimmed = TrimHistory(digestHistory);
|
||||
if (trimmed)
|
||||
{
|
||||
stateChanged = true;
|
||||
}
|
||||
|
||||
if (stateChanged || !string.Equals(latestCursor, checkpoint.Cursor, StringComparison.Ordinal) || latestPublishedAt != checkpoint.LastPublishedAt)
|
||||
{
|
||||
await _checkpointManager.SaveAsync(
|
||||
@@ -236,6 +244,18 @@ public sealed class RancherHubConnector : VexConnectorBase
|
||||
return new EventProcessingResult(document, false, publishedAt);
|
||||
}
|
||||
|
||||
private static bool TrimHistory(List<string> digestHistory)
|
||||
{
|
||||
if (digestHistory.Count <= MaxDigestHistory)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var excess = digestHistory.Count - MaxDigestHistory;
|
||||
digestHistory.RemoveRange(0, excess);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<HttpRequestMessage> CreateDocumentRequestAsync(Uri documentUri, CancellationToken cancellationToken)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, documentUri);
|
||||
|
||||
@@ -3,5 +3,5 @@ If you are working on this file you need to read docs/modules/excititor/ARCHITEC
|
||||
| Task | Owner(s) | Depends on | Notes |
|
||||
|---|---|---|---|
|
||||
|EXCITITOR-CONN-SUSE-01-001 – Rancher hub discovery & auth|Team Excititor Connectors – SUSE|EXCITITOR-CONN-ABS-01-001|**DONE (2025-10-17)** – Added Rancher hub options/token provider, discovery metadata loader with offline snapshots + caching, connector shell, DI wiring, and unit tests covering network/offline paths.|
|
||||
|EXCITITOR-CONN-SUSE-01-002 – Checkpointed event ingestion|Team Excititor Connectors – SUSE|EXCITITOR-CONN-SUSE-01-001, EXCITITOR-STORAGE-01-003|**DOING (2025-10-19)** – Process hub events with resume checkpoints, deduplication, and quarantine path for malformed payloads.<br>2025-10-19: Prereqs EXCITITOR-CONN-SUSE-01-001 & EXCITITOR-STORAGE-01-003 confirmed complete; initiating checkpoint/resume implementation plan.|
|
||||
|EXCITITOR-CONN-SUSE-01-002 – Checkpointed event ingestion|Team Excititor Connectors – SUSE|EXCITITOR-CONN-SUSE-01-001, EXCITITOR-STORAGE-01-003|**DONE (2025-10-29)** – Wired checkpoint manager/event client into DI, bounded digest history, and exercised offline snapshot/dedup/quarantine flows with new connector tests ensuring state persistence and replay determinism.|
|
||||
|EXCITITOR-CONN-SUSE-01-003 – Trust metadata & policy hints|Team Excititor Connectors – SUSE|EXCITITOR-CONN-SUSE-01-002, EXCITITOR-POLICY-01-001|TODO – Emit provider trust configuration (signers, weight overrides) and attach provenance hints for consensus engine.|
|
||||
|
||||
@@ -3,6 +3,6 @@ If you are working on this file you need to read docs/modules/excititor/ARCHITEC
|
||||
| Task | Owner(s) | Depends on | Notes |
|
||||
|---|---|---|---|
|
||||
|EXCITITOR-CONN-UBUNTU-01-001 – Ubuntu CSAF discovery & channels|Team Excititor Connectors – Ubuntu|EXCITITOR-CONN-ABS-01-001|**DONE (2025-10-17)** – Added Ubuntu connector project with configurable channel options, catalog loader (network/offline), DI wiring, and discovery unit tests.|
|
||||
|EXCITITOR-CONN-UBUNTU-01-002 – Incremental fetch & deduplication|Team Excititor Connectors – Ubuntu|EXCITITOR-CONN-UBUNTU-01-001, EXCITITOR-STORAGE-01-003|**DOING (2025-10-19)** – Fetch CSAF bundles with ETag handling, checksum validation, deduplication, and raw persistence.|
|
||||
|EXCITITOR-CONN-UBUNTU-01-002 – Incremental fetch & deduplication|Team Excititor Connectors – Ubuntu|EXCITITOR-CONN-UBUNTU-01-001, EXCITITOR-STORAGE-01-003|**DONE (2025-10-29)** – Incremental pull loop now enforces ETag/sha validation, resumes from persisted state, and includes regression tests covering checksum mismatch quarantine and If-None-Match replay.|
|
||||
|EXCITITOR-CONN-UBUNTU-01-003 – Trust metadata & provenance|Team Excititor Connectors – Ubuntu|EXCITITOR-CONN-UBUNTU-01-002, EXCITITOR-POLICY-01-001|TODO – Emit Ubuntu signing metadata (GPG fingerprints) plus provenance hints for policy weighting and diagnostics.|
|
||||
> Remark (2025-10-19, EXCITITOR-CONN-UBUNTU-01-002): Prerequisites EXCITITOR-CONN-UBUNTU-01-001 and EXCITITOR-STORAGE-01-003 verified as **DONE**; advancing to DOING per Wave 0 kickoff.
|
||||
> Remark (2025-10-29, EXCITITOR-CONN-UBUNTU-01-002): Offline + network regression pass validated resume tokens, dedupe skips, checksum enforcement, and ETag handling before closing the task.
|
||||
|
||||
@@ -397,10 +397,10 @@ public static class VexCanonicalJsonSerializer
|
||||
new[]
|
||||
{
|
||||
"isValid",
|
||||
"diagnostics",
|
||||
}
|
||||
},
|
||||
};
|
||||
"diagnostics",
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
public static string Serialize<T>(T value)
|
||||
=> JsonSerializer.Serialize(value, CompactOptions);
|
||||
|
||||
@@ -274,18 +274,21 @@ public sealed partial record VexQuerySignature
|
||||
public override string ToString() => Value;
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public enum VexExportFormat
|
||||
{
|
||||
[EnumMember(Value = "json")]
|
||||
Json,
|
||||
|
||||
[EnumMember(Value = "jsonl")]
|
||||
JsonLines,
|
||||
|
||||
[EnumMember(Value = "openvex")]
|
||||
OpenVex,
|
||||
|
||||
[EnumMember(Value = "csaf")]
|
||||
Csaf,
|
||||
}
|
||||
[DataContract]
|
||||
public enum VexExportFormat
|
||||
{
|
||||
[EnumMember(Value = "json")]
|
||||
Json,
|
||||
|
||||
[EnumMember(Value = "jsonl")]
|
||||
JsonLines,
|
||||
|
||||
[EnumMember(Value = "openvex")]
|
||||
OpenVex,
|
||||
|
||||
[EnumMember(Value = "csaf")]
|
||||
Csaf,
|
||||
|
||||
[EnumMember(Value = "cyclonedx")]
|
||||
CycloneDx,
|
||||
}
|
||||
|
||||
@@ -133,16 +133,17 @@ public sealed class FileSystemArtifactStore : IVexArtifactStore
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GetExtension(VexExportFormat format)
|
||||
=> format switch
|
||||
{
|
||||
VexExportFormat.Json => ".json",
|
||||
VexExportFormat.JsonLines => ".jsonl",
|
||||
VexExportFormat.OpenVex => ".json",
|
||||
VexExportFormat.Csaf => ".json",
|
||||
_ => ".bin",
|
||||
};
|
||||
}
|
||||
private static string GetExtension(VexExportFormat format)
|
||||
=> format switch
|
||||
{
|
||||
VexExportFormat.Json => ".json",
|
||||
VexExportFormat.JsonLines => ".jsonl",
|
||||
VexExportFormat.OpenVex => ".json",
|
||||
VexExportFormat.Csaf => ".json",
|
||||
VexExportFormat.CycloneDx => ".json",
|
||||
_ => ".bin",
|
||||
};
|
||||
}
|
||||
|
||||
public static class FileSystemArtifactStoreServiceCollectionExtensions
|
||||
{
|
||||
|
||||
@@ -213,16 +213,17 @@ public sealed class OfflineBundleArtifactStore : IVexArtifactStore
|
||||
|
||||
private string GetBundlesRoot() => _fileSystem.Path.Combine(_options.RootPath, _options.BundlesFolder);
|
||||
|
||||
private static string GetExtension(VexExportFormat format)
|
||||
=> format switch
|
||||
{
|
||||
VexExportFormat.Json => ".json",
|
||||
VexExportFormat.JsonLines => ".jsonl",
|
||||
VexExportFormat.OpenVex => ".json",
|
||||
VexExportFormat.Csaf => ".json",
|
||||
_ => ".bin",
|
||||
};
|
||||
|
||||
private static string GetExtension(VexExportFormat format)
|
||||
=> format switch
|
||||
{
|
||||
VexExportFormat.Json => ".json",
|
||||
VexExportFormat.JsonLines => ".jsonl",
|
||||
VexExportFormat.OpenVex => ".json",
|
||||
VexExportFormat.Csaf => ".json",
|
||||
VexExportFormat.CycloneDx => ".json",
|
||||
_ => ".bin",
|
||||
};
|
||||
|
||||
private sealed record ManifestDocument(ImmutableArray<ManifestEntry> Artifacts);
|
||||
|
||||
private sealed record ManifestEntry(string Digest, string Format, string Path, long SizeBytes, IReadOnlyDictionary<string, string> Metadata);
|
||||
|
||||
@@ -139,16 +139,17 @@ public sealed class S3ArtifactStore : IVexArtifactStore
|
||||
var metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["vex-format"] = artifact.Format.ToString().ToLowerInvariant(),
|
||||
["vex-digest"] = artifact.ContentAddress.ToUri(),
|
||||
["content-type"] = artifact.Format switch
|
||||
{
|
||||
VexExportFormat.Json => "application/json",
|
||||
VexExportFormat.JsonLines => "application/json",
|
||||
VexExportFormat.OpenVex => "application/vnd.openvex+json",
|
||||
VexExportFormat.Csaf => "application/json",
|
||||
_ => "application/octet-stream",
|
||||
},
|
||||
};
|
||||
["vex-digest"] = artifact.ContentAddress.ToUri(),
|
||||
["content-type"] = artifact.Format switch
|
||||
{
|
||||
VexExportFormat.Json => "application/json",
|
||||
VexExportFormat.JsonLines => "application/json",
|
||||
VexExportFormat.OpenVex => "application/vnd.openvex+json",
|
||||
VexExportFormat.Csaf => "application/json",
|
||||
VexExportFormat.CycloneDx => "application/vnd.cyclonedx+json",
|
||||
_ => "application/octet-stream",
|
||||
},
|
||||
};
|
||||
|
||||
foreach (var kvp in artifact.Metadata)
|
||||
{
|
||||
@@ -158,16 +159,17 @@ public sealed class S3ArtifactStore : IVexArtifactStore
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private static string GetExtension(VexExportFormat format)
|
||||
=> format switch
|
||||
{
|
||||
VexExportFormat.Json => ".json",
|
||||
VexExportFormat.JsonLines => ".jsonl",
|
||||
VexExportFormat.OpenVex => ".json",
|
||||
VexExportFormat.Csaf => ".json",
|
||||
_ => ".bin",
|
||||
};
|
||||
}
|
||||
private static string GetExtension(VexExportFormat format)
|
||||
=> format switch
|
||||
{
|
||||
VexExportFormat.Json => ".json",
|
||||
VexExportFormat.JsonLines => ".jsonl",
|
||||
VexExportFormat.OpenVex => ".json",
|
||||
VexExportFormat.Csaf => ".json",
|
||||
VexExportFormat.CycloneDx => ".json",
|
||||
_ => ".bin",
|
||||
};
|
||||
}
|
||||
|
||||
public static class S3ArtifactStoreServiceCollectionExtensions
|
||||
{
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
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;
|
||||
using StellaOps.Excititor.Core;
|
||||
|
||||
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(
|
||||
CsafDocumentSection Document,
|
||||
CsafProductTreeSection ProductTree,
|
||||
ImmutableArray<CsafExportVulnerability> Vulnerabilities,
|
||||
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);
|
||||
@@ -141,17 +141,22 @@ public sealed class CsafNormalizer : IVexNormalizer
|
||||
var diagnosticsBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
if (!result.UnsupportedStatuses.IsDefaultOrEmpty && result.UnsupportedStatuses.Length > 0)
|
||||
{
|
||||
diagnosticsBuilder["csaf.unsupported_statuses"] = string.Join(",", result.UnsupportedStatuses);
|
||||
diagnosticsBuilder["policy.unsupported_statuses"] = string.Join(",", result.UnsupportedStatuses);
|
||||
}
|
||||
|
||||
if (!result.UnsupportedJustifications.IsDefaultOrEmpty && result.UnsupportedJustifications.Length > 0)
|
||||
{
|
||||
diagnosticsBuilder["csaf.unsupported_justifications"] = string.Join(",", result.UnsupportedJustifications);
|
||||
diagnosticsBuilder["policy.unsupported_justifications"] = string.Join(",", result.UnsupportedJustifications);
|
||||
}
|
||||
|
||||
if (!result.ConflictingJustifications.IsDefaultOrEmpty && result.ConflictingJustifications.Length > 0)
|
||||
{
|
||||
diagnosticsBuilder["csaf.justification_conflicts"] = string.Join(",", result.ConflictingJustifications);
|
||||
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
|
||||
@@ -202,6 +207,7 @@ public sealed class CsafNormalizer : IVexNormalizer
|
||||
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>();
|
||||
|
||||
@@ -230,7 +236,8 @@ public sealed class CsafNormalizer : IVexNormalizer
|
||||
productCatalog,
|
||||
justifications,
|
||||
detail,
|
||||
unsupportedStatuses);
|
||||
unsupportedStatuses,
|
||||
missingRequiredJustifications);
|
||||
|
||||
claimsBuilder.AddRange(productClaims);
|
||||
}
|
||||
@@ -244,7 +251,8 @@ public sealed class CsafNormalizer : IVexNormalizer
|
||||
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());
|
||||
conflictingJustifications.OrderBy(static s => s, StringComparer.OrdinalIgnoreCase).ToImmutableArray(),
|
||||
missingRequiredJustifications.OrderBy(static s => s, StringComparer.OrdinalIgnoreCase).ToImmutableArray());
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CsafClaimEntry> BuildClaimsForVulnerability(
|
||||
@@ -253,7 +261,8 @@ public sealed class CsafNormalizer : IVexNormalizer
|
||||
IReadOnlyDictionary<string, CsafProductInfo> productCatalog,
|
||||
ImmutableDictionary<string, CsafJustificationInfo> justifications,
|
||||
string? detail,
|
||||
ISet<string> unsupportedStatuses)
|
||||
ISet<string> unsupportedStatuses,
|
||||
ISet<string> missingRequiredJustifications)
|
||||
{
|
||||
if (!vulnerability.TryGetProperty("product_status", out var statusElement) ||
|
||||
statusElement.ValueKind != JsonValueKind.Object)
|
||||
@@ -297,7 +306,7 @@ public sealed class CsafNormalizer : IVexNormalizer
|
||||
return Array.Empty<CsafClaimEntry>();
|
||||
}
|
||||
|
||||
return claims.Values
|
||||
var builtClaims = claims.Values
|
||||
.Select(builder => new CsafClaimEntry(
|
||||
vulnerabilityId,
|
||||
builder.Product,
|
||||
@@ -307,6 +316,16 @@ public sealed class CsafNormalizer : IVexNormalizer
|
||||
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(
|
||||
@@ -855,7 +874,8 @@ public sealed class CsafNormalizer : IVexNormalizer
|
||||
ImmutableArray<CsafClaimEntry> Claims,
|
||||
ImmutableArray<string> UnsupportedStatuses,
|
||||
ImmutableArray<string> UnsupportedJustifications,
|
||||
ImmutableArray<string> ConflictingJustifications);
|
||||
ImmutableArray<string> ConflictingJustifications,
|
||||
ImmutableArray<string> MissingRequiredJustifications);
|
||||
|
||||
private sealed record CsafJustificationInfo(
|
||||
string RawValue,
|
||||
|
||||
@@ -5,10 +5,11 @@ namespace StellaOps.Excititor.Formats.CSAF;
|
||||
|
||||
public static class CsafFormatsServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddCsafNormalizer(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
services.AddSingleton<IVexNormalizer, CsafNormalizer>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
public static IServiceCollection AddCsafNormalizer(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
services.AddSingleton<IVexNormalizer, CsafNormalizer>();
|
||||
services.AddSingleton<IVexExporter, CsafExporter>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@ If you are working on this file you need to read docs/modules/excititor/ARCHITEC
|
||||
| Task | Owner(s) | Depends on | Notes |
|
||||
|---|---|---|---|
|
||||
|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|**DOING (2025-10-19)** – Prereqs EXCITITOR-FMT-CSAF-01-001 & EXCITITOR-POLICY-01-001 verified DONE; starting normalization of `product_status`/`justification` values with policy-aligned diagnostics.|
|
||||
|EXCITITOR-FMT-CSAF-01-003 – CSAF export adapter|Team Excititor Formats|EXCITITOR-EXPORT-01-001, EXCITITOR-FMT-CSAF-01-001|**DOING (2025-10-19)** – Prereqs EXCITITOR-EXPORT-01-001 & EXCITITOR-FMT-CSAF-01-001 confirmed DONE; drafting deterministic CSAF exporter and manifest metadata flow.|
|
||||
|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,242 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Excititor.Core;
|
||||
|
||||
namespace StellaOps.Excititor.Formats.CycloneDX;
|
||||
|
||||
internal static class CycloneDxComponentReconciler
|
||||
{
|
||||
public static CycloneDxReconciliationResult Reconcile(IEnumerable<VexClaim> claims)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(claims);
|
||||
|
||||
var catalog = new ComponentCatalog();
|
||||
var diagnostics = new Dictionary<string, SortedSet<string>>(StringComparer.Ordinal);
|
||||
var componentRefs = new Dictionary<(string VulnerabilityId, string ProductKey), string>();
|
||||
|
||||
foreach (var claim in claims)
|
||||
{
|
||||
if (claim is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var component = catalog.GetOrAdd(claim.Product, claim.ProviderId, diagnostics);
|
||||
componentRefs[(claim.VulnerabilityId, claim.Product.Key)] = component.BomRef;
|
||||
}
|
||||
|
||||
var components = catalog.Build();
|
||||
var orderedDiagnostics = diagnostics.Count == 0
|
||||
? ImmutableDictionary<string, string>.Empty
|
||||
: diagnostics.ToImmutableDictionary(
|
||||
static pair => pair.Key,
|
||||
pair => string.Join(",", pair.Value.OrderBy(static value => value, StringComparer.Ordinal)),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
return new CycloneDxReconciliationResult(
|
||||
components,
|
||||
componentRefs.ToImmutableDictionary(),
|
||||
orderedDiagnostics);
|
||||
}
|
||||
|
||||
private sealed class ComponentCatalog
|
||||
{
|
||||
private readonly Dictionary<string, MutableComponent> _components = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> _bomRefs = new(StringComparer.Ordinal);
|
||||
|
||||
public MutableComponent GetOrAdd(VexProduct product, string providerId, IDictionary<string, SortedSet<string>> diagnostics)
|
||||
{
|
||||
if (_components.TryGetValue(product.Key, out var existing))
|
||||
{
|
||||
existing.Update(product, providerId, diagnostics);
|
||||
return existing;
|
||||
}
|
||||
|
||||
var bomRef = GenerateBomRef(product);
|
||||
var component = new MutableComponent(product.Key, bomRef);
|
||||
component.Update(product, providerId, diagnostics);
|
||||
_components[product.Key] = component;
|
||||
return component;
|
||||
}
|
||||
|
||||
public ImmutableArray<CycloneDxComponentEntry> Build()
|
||||
=> _components.Values
|
||||
.Select(static component => component.ToEntry())
|
||||
.OrderBy(static entry => entry.BomRef, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
private string GenerateBomRef(VexProduct product)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(product.Purl))
|
||||
{
|
||||
var normalized = product.Purl!.Trim();
|
||||
if (_bomRefs.Add(normalized))
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
var baseRef = Sanitize(product.Key);
|
||||
if (_bomRefs.Add(baseRef))
|
||||
{
|
||||
return baseRef;
|
||||
}
|
||||
|
||||
var hash = ComputeShortHash(product.Key + product.Name);
|
||||
var candidate = FormattableString.Invariant($"{baseRef}-{hash}");
|
||||
while (!_bomRefs.Add(candidate))
|
||||
{
|
||||
candidate = FormattableString.Invariant($"{candidate}-{hash}");
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private static string Sanitize(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "component";
|
||||
}
|
||||
|
||||
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) ? "component" : 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 MutableComponent
|
||||
{
|
||||
public MutableComponent(string key, string bomRef)
|
||||
{
|
||||
ProductKey = key;
|
||||
BomRef = bomRef;
|
||||
}
|
||||
|
||||
public string ProductKey { get; }
|
||||
|
||||
public string BomRef { get; }
|
||||
|
||||
private string? _name;
|
||||
private string? _version;
|
||||
private string? _purl;
|
||||
private string? _cpe;
|
||||
private readonly SortedDictionary<string, string> _properties = new(StringComparer.Ordinal);
|
||||
|
||||
public void Update(VexProduct product, string providerId, IDictionary<string, SortedSet<string>> diagnostics)
|
||||
{
|
||||
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))
|
||||
{
|
||||
var trimmed = product.Purl!.Trim();
|
||||
if (string.IsNullOrWhiteSpace(_purl))
|
||||
{
|
||||
_purl = trimmed;
|
||||
}
|
||||
else if (!string.Equals(_purl, trimmed, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AddDiagnostic(diagnostics, "purl_conflict", FormattableString.Invariant($"{ProductKey}:{_purl}->{trimmed}"));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AddDiagnostic(diagnostics, "missing_purl", FormattableString.Invariant($"{ProductKey}:{providerId}"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(product.Cpe))
|
||||
{
|
||||
_cpe = product.Cpe;
|
||||
}
|
||||
|
||||
if (product.ComponentIdentifiers.Length > 0)
|
||||
{
|
||||
_properties["stellaops/componentIdentifiers"] = string.Join(';', product.ComponentIdentifiers.OrderBy(static identifier => identifier, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
public CycloneDxComponentEntry ToEntry()
|
||||
{
|
||||
ImmutableArray<CycloneDxProperty>? properties = _properties.Count == 0
|
||||
? null
|
||||
: _properties.Select(static pair => new CycloneDxProperty(pair.Key, pair.Value)).ToImmutableArray();
|
||||
|
||||
return new CycloneDxComponentEntry(
|
||||
BomRef,
|
||||
_name ?? ProductKey,
|
||||
_version,
|
||||
_purl,
|
||||
_cpe,
|
||||
properties);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private static void AddDiagnostic(IDictionary<string, SortedSet<string>> diagnostics, string key, string value)
|
||||
{
|
||||
if (!diagnostics.TryGetValue(key, out var set))
|
||||
{
|
||||
set = new SortedSet<string>(StringComparer.Ordinal);
|
||||
diagnostics[key] = set;
|
||||
}
|
||||
|
||||
set.Add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record CycloneDxReconciliationResult(
|
||||
ImmutableArray<CycloneDxComponentEntry> Components,
|
||||
ImmutableDictionary<(string VulnerabilityId, string ProductKey), string> ComponentRefs,
|
||||
ImmutableDictionary<string, string> Diagnostics);
|
||||
|
||||
internal sealed record CycloneDxComponentEntry(
|
||||
[property: JsonPropertyName("bom-ref")] string BomRef,
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("version"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Version,
|
||||
[property: JsonPropertyName("purl"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Purl,
|
||||
[property: JsonPropertyName("cpe"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Cpe,
|
||||
[property: JsonPropertyName("properties"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<CycloneDxProperty>? Properties);
|
||||
|
||||
internal sealed record CycloneDxProperty(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("value")] string Value);
|
||||
@@ -0,0 +1,228 @@
|
||||
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;
|
||||
using StellaOps.Excititor.Core;
|
||||
|
||||
namespace StellaOps.Excititor.Formats.CycloneDX;
|
||||
|
||||
/// <summary>
|
||||
/// Serialises normalized VEX claims into CycloneDX VEX documents with reconciled component references.
|
||||
/// </summary>
|
||||
public sealed class CycloneDxExporter : IVexExporter
|
||||
{
|
||||
public VexExportFormat Format => VexExportFormat.CycloneDx;
|
||||
|
||||
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 CycloneDxExportDocument 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 reconciliation = CycloneDxComponentReconciler.Reconcile(request.Claims);
|
||||
var vulnerabilityEntries = BuildVulnerabilities(request.Claims, reconciliation.ComponentRefs);
|
||||
|
||||
var missingJustifications = request.Claims
|
||||
.Where(static claim => claim.Status == VexClaimStatus.NotAffected && claim.Justification is null)
|
||||
.Select(static claim => FormattableString.Invariant($"{claim.VulnerabilityId}:{claim.Product.Key}"))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static key => key, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var properties = ImmutableArray.Create(new CycloneDxProperty("stellaops/querySignature", signature.Value));
|
||||
|
||||
metadata = BuildMetadata(signature, reconciliation.Diagnostics, generatedAt, vulnerabilityEntries.Length, reconciliation.Components.Length, missingJustifications);
|
||||
|
||||
var document = new CycloneDxExportDocument(
|
||||
BomFormat: "CycloneDX",
|
||||
SpecVersion: "1.6",
|
||||
SerialNumber: FormattableString.Invariant($"urn:uuid:{BuildDeterministicGuid(signatureHash.Digest)}"),
|
||||
Version: 1,
|
||||
Metadata: new CycloneDxMetadata(generatedAt),
|
||||
Components: reconciliation.Components,
|
||||
Vulnerabilities: vulnerabilityEntries,
|
||||
Properties: properties);
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
private static ImmutableArray<CycloneDxVulnerabilityEntry> BuildVulnerabilities(
|
||||
ImmutableArray<VexClaim> claims,
|
||||
ImmutableDictionary<(string VulnerabilityId, string ProductKey), string> componentRefs)
|
||||
{
|
||||
var entries = ImmutableArray.CreateBuilder<CycloneDxVulnerabilityEntry>();
|
||||
|
||||
foreach (var claim in claims)
|
||||
{
|
||||
if (!componentRefs.TryGetValue((claim.VulnerabilityId, claim.Product.Key), out var componentRef))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var analysis = new CycloneDxAnalysis(
|
||||
State: MapStatus(claim.Status),
|
||||
Justification: claim.Justification?.ToString().ToLowerInvariant(),
|
||||
Responses: null);
|
||||
|
||||
var affects = ImmutableArray.Create(new CycloneDxAffectEntry(componentRef));
|
||||
|
||||
var properties = ImmutableArray.Create(
|
||||
new CycloneDxProperty("stellaops/providerId", claim.ProviderId),
|
||||
new CycloneDxProperty("stellaops/documentDigest", claim.Document.Digest));
|
||||
|
||||
var vulnerabilityId = claim.VulnerabilityId;
|
||||
var bomRef = FormattableString.Invariant($"{vulnerabilityId}#{Normalize(componentRef)}");
|
||||
|
||||
entries.Add(new CycloneDxVulnerabilityEntry(
|
||||
Id: vulnerabilityId,
|
||||
BomRef: bomRef,
|
||||
Description: claim.Detail,
|
||||
Analysis: analysis,
|
||||
Affects: affects,
|
||||
Properties: properties));
|
||||
}
|
||||
|
||||
return entries
|
||||
.ToImmutable()
|
||||
.OrderBy(static entry => entry.Id, StringComparer.Ordinal)
|
||||
.ThenBy(static entry => entry.BomRef, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static string Normalize(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "component";
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(value.Length);
|
||||
foreach (var ch in value)
|
||||
{
|
||||
builder.Append(char.IsLetterOrDigit(ch) ? char.ToLowerInvariant(ch) : '-');
|
||||
}
|
||||
|
||||
var normalized = builder.ToString().Trim('-');
|
||||
return string.IsNullOrEmpty(normalized) ? "component" : normalized;
|
||||
}
|
||||
|
||||
private static string MapStatus(VexClaimStatus status)
|
||||
=> status switch
|
||||
{
|
||||
VexClaimStatus.Affected => "affected",
|
||||
VexClaimStatus.NotAffected => "not_affected",
|
||||
VexClaimStatus.Fixed => "resolved",
|
||||
VexClaimStatus.UnderInvestigation => "under_investigation",
|
||||
_ => "unknown",
|
||||
};
|
||||
|
||||
private static ImmutableDictionary<string, string> BuildMetadata(
|
||||
VexQuerySignature signature,
|
||||
ImmutableDictionary<string, string> diagnostics,
|
||||
string generatedAt,
|
||||
int vulnerabilityCount,
|
||||
int componentCount,
|
||||
ImmutableArray<string> missingJustifications)
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
builder["cyclonedx.querySignature"] = signature.Value;
|
||||
builder["cyclonedx.generatedAt"] = generatedAt;
|
||||
builder["cyclonedx.vulnerabilityCount"] = vulnerabilityCount.ToString(CultureInfo.InvariantCulture);
|
||||
builder["cyclonedx.componentCount"] = componentCount.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
foreach (var diagnostic in diagnostics.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
builder[$"cyclonedx.{diagnostic.Key}"] = diagnostic.Value;
|
||||
}
|
||||
|
||||
if (!missingJustifications.IsDefaultOrEmpty && missingJustifications.Length > 0)
|
||||
{
|
||||
builder["policy.justification_missing"] = string.Join(",", missingJustifications);
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static string BuildDeterministicGuid(string digest)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(digest) || digest.Length < 32)
|
||||
{
|
||||
return Guid.NewGuid().ToString();
|
||||
}
|
||||
|
||||
var hex = digest[..32];
|
||||
var bytes = Enumerable.Range(0, hex.Length / 2)
|
||||
.Select(i => byte.Parse(hex.Substring(i * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture))
|
||||
.ToArray();
|
||||
|
||||
return new Guid(bytes).ToString();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record CycloneDxExportDocument(
|
||||
[property: JsonPropertyName("bomFormat")] string BomFormat,
|
||||
[property: JsonPropertyName("specVersion")] string SpecVersion,
|
||||
[property: JsonPropertyName("serialNumber")] string SerialNumber,
|
||||
[property: JsonPropertyName("version")] int Version,
|
||||
[property: JsonPropertyName("metadata")] CycloneDxMetadata Metadata,
|
||||
[property: JsonPropertyName("components")] ImmutableArray<CycloneDxComponentEntry> Components,
|
||||
[property: JsonPropertyName("vulnerabilities")] ImmutableArray<CycloneDxVulnerabilityEntry> Vulnerabilities,
|
||||
[property: JsonPropertyName("properties"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<CycloneDxProperty>? Properties);
|
||||
|
||||
internal sealed record CycloneDxMetadata(
|
||||
[property: JsonPropertyName("timestamp")] string Timestamp);
|
||||
|
||||
internal sealed record CycloneDxVulnerabilityEntry(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("bom-ref")] string BomRef,
|
||||
[property: JsonPropertyName("description"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Description,
|
||||
[property: JsonPropertyName("analysis")] CycloneDxAnalysis Analysis,
|
||||
[property: JsonPropertyName("affects")] ImmutableArray<CycloneDxAffectEntry> Affects,
|
||||
[property: JsonPropertyName("properties")] ImmutableArray<CycloneDxProperty> Properties);
|
||||
|
||||
internal sealed record CycloneDxAnalysis(
|
||||
[property: JsonPropertyName("state")] string State,
|
||||
[property: JsonPropertyName("justification"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Justification,
|
||||
[property: JsonPropertyName("response"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<string>? Responses);
|
||||
|
||||
internal sealed record CycloneDxAffectEntry(
|
||||
[property: JsonPropertyName("ref")] string Reference);
|
||||
@@ -5,10 +5,11 @@ namespace StellaOps.Excititor.Formats.CycloneDX;
|
||||
|
||||
public static class CycloneDxFormatsServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddCycloneDxNormalizer(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
services.AddSingleton<IVexNormalizer, CycloneDxNormalizer>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
public static IServiceCollection AddCycloneDxNormalizer(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
services.AddSingleton<IVexNormalizer, CycloneDxNormalizer>();
|
||||
services.AddSingleton<IVexExporter, CycloneDxExporter>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@ If you are working on this file you need to read docs/modules/excititor/ARCHITEC
|
||||
| Task | Owner(s) | Depends on | Notes |
|
||||
|---|---|---|---|
|
||||
|EXCITITOR-FMT-CYCLONE-01-001 – CycloneDX VEX normalizer|Team Excititor Formats|EXCITITOR-CORE-01-001|**DONE (2025-10-17)** – CycloneDX normalizer parses `analysis` data, resolves component references, and emits canonical `VexClaim`s; regression lives in `CycloneDxNormalizerTests`.|
|
||||
|EXCITITOR-FMT-CYCLONE-01-002 – Component reference reconciliation|Team Excititor Formats|EXCITITOR-FMT-CYCLONE-01-001|**DOING (2025-10-19)** – Prereq EXCITITOR-FMT-CYCLONE-01-001 confirmed DONE; proceeding with reference reconciliation helpers and diagnostics for missing SBOM links.|
|
||||
|EXCITITOR-FMT-CYCLONE-01-003 – CycloneDX export serializer|Team Excititor Formats|EXCITITOR-EXPORT-01-001, EXCITITOR-FMT-CYCLONE-01-001|**DOING (2025-10-19)** – Prereqs EXCITITOR-EXPORT-01-001 & EXCITITOR-FMT-CYCLONE-01-001 verified DONE; initiating deterministic CycloneDX VEX exporter work.|
|
||||
|EXCITITOR-FMT-CYCLONE-01-002 – Component reference reconciliation|Team Excititor Formats|EXCITITOR-FMT-CYCLONE-01-001|**DONE (2025-10-29)** – Added reconciler producing stable bom-refs, aggregating component metadata, and reporting missing PURL diagnostics for policy gating.|
|
||||
|EXCITITOR-FMT-CYCLONE-01-003 – CycloneDX export serializer|Team Excititor Formats|EXCITITOR-EXPORT-01-001, EXCITITOR-FMT-CYCLONE-01-001|**DONE (2025-10-29)** – Implemented CycloneDX VEX exporter emitting reconciled components, vulnerability analysis blocks, and canonical metadata.|
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
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;
|
||||
using StellaOps.Excititor.Core;
|
||||
|
||||
namespace StellaOps.Excititor.Formats.OpenVEX;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes merged VEX statements into canonical OpenVEX export documents.
|
||||
/// </summary>
|
||||
public sealed class OpenVexExporter : IVexExporter
|
||||
{
|
||||
public OpenVexExporter()
|
||||
{
|
||||
}
|
||||
|
||||
public VexExportFormat Format => VexExportFormat.OpenVex;
|
||||
|
||||
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 metadata = BuildDocument(request, out var exportMetadata);
|
||||
var json = VexCanonicalJsonSerializer.Serialize(metadata);
|
||||
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, exportMetadata);
|
||||
}
|
||||
|
||||
private OpenVexExportDocument BuildDocument(VexExportRequest request, out ImmutableDictionary<string, string> metadata)
|
||||
{
|
||||
var mergeResult = OpenVexStatementMerger.Merge(request.Claims);
|
||||
var signature = VexQuerySignature.FromQuery(request.Query);
|
||||
var signatureHash = signature.ComputeHash();
|
||||
var generatedAt = request.GeneratedAt.UtcDateTime.ToString("O", CultureInfo.InvariantCulture);
|
||||
var sourceProviders = request.Claims
|
||||
.Select(static claim => claim.ProviderId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static provider => provider, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var statements = mergeResult.Statements
|
||||
.Select(statement => MapStatement(statement))
|
||||
.ToImmutableArray();
|
||||
|
||||
var document = new OpenVexDocumentSection(
|
||||
Id: FormattableString.Invariant($"openvex:export:{signatureHash.Digest}"),
|
||||
Author: "StellaOps Excititor",
|
||||
Version: "1",
|
||||
Created: generatedAt,
|
||||
LastUpdated: generatedAt,
|
||||
Profile: "stellaops-export/v1");
|
||||
|
||||
var metadataSection = new OpenVexExportMetadata(
|
||||
generatedAt,
|
||||
signature.Value,
|
||||
sourceProviders,
|
||||
mergeResult.Diagnostics);
|
||||
|
||||
metadata = BuildMetadata(signature, mergeResult, sourceProviders, generatedAt);
|
||||
|
||||
return new OpenVexExportDocument(document, statements, metadataSection);
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string> BuildMetadata(
|
||||
VexQuerySignature signature,
|
||||
OpenVexMergeResult mergeResult,
|
||||
ImmutableArray<string> sourceProviders,
|
||||
string generatedAt)
|
||||
{
|
||||
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
metadataBuilder["openvex.querySignature"] = signature.Value;
|
||||
metadataBuilder["openvex.generatedAt"] = generatedAt;
|
||||
metadataBuilder["openvex.statementCount"] = mergeResult.Statements.Length.ToString(CultureInfo.InvariantCulture);
|
||||
metadataBuilder["openvex.providerCount"] = sourceProviders.Length.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
var sourceCount = mergeResult.Statements.Sum(static statement => statement.Sources.Length);
|
||||
metadataBuilder["openvex.sourceCount"] = sourceCount.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
foreach (var diagnostic in mergeResult.Diagnostics.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
metadataBuilder[$"openvex.diagnostic.{diagnostic.Key}"] = diagnostic.Value;
|
||||
}
|
||||
|
||||
return metadataBuilder.ToImmutable();
|
||||
}
|
||||
|
||||
private static OpenVexExportStatement MapStatement(OpenVexMergedStatement statement)
|
||||
{
|
||||
var products = ImmutableArray.Create(
|
||||
new OpenVexExportProduct(
|
||||
Id: statement.Product.Key,
|
||||
Name: statement.Product.Name ?? statement.Product.Key,
|
||||
Version: statement.Product.Version,
|
||||
Purl: statement.Product.Purl,
|
||||
Cpe: statement.Product.Cpe));
|
||||
|
||||
var sources = statement.Sources
|
||||
.Select(source => new OpenVexExportSource(
|
||||
Provider: source.ProviderId,
|
||||
Status: source.Status.ToString().ToLowerInvariant(),
|
||||
Justification: source.Justification?.ToString().ToLowerInvariant(),
|
||||
DocumentDigest: source.DocumentDigest,
|
||||
SourceUri: source.DocumentSource.ToString(),
|
||||
Detail: source.Detail,
|
||||
FirstObserved: source.FirstSeen.UtcDateTime.ToString("O", CultureInfo.InvariantCulture),
|
||||
LastObserved: source.LastSeen.UtcDateTime.ToString("O", CultureInfo.InvariantCulture)))
|
||||
.ToImmutableArray();
|
||||
|
||||
var statementId = FormattableString.Invariant($"{statement.VulnerabilityId}#{NormalizeProductKey(statement.Product.Key)}");
|
||||
|
||||
return new OpenVexExportStatement(
|
||||
Id: statementId,
|
||||
Vulnerability: statement.VulnerabilityId,
|
||||
Status: statement.Status.ToString().ToLowerInvariant(),
|
||||
Justification: statement.Justification?.ToString().ToLowerInvariant(),
|
||||
Timestamp: statement.FirstObserved.UtcDateTime.ToString("O", CultureInfo.InvariantCulture),
|
||||
LastUpdated: statement.LastObserved.UtcDateTime.ToString("O", CultureInfo.InvariantCulture),
|
||||
Products: products,
|
||||
Statement: statement.Detail,
|
||||
Sources: sources);
|
||||
}
|
||||
|
||||
private static string NormalizeProductKey(string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(key.Length);
|
||||
foreach (var ch in key)
|
||||
{
|
||||
builder.Append(char.IsLetterOrDigit(ch) ? char.ToLowerInvariant(ch) : '-');
|
||||
}
|
||||
|
||||
var normalized = builder.ToString().Trim('-');
|
||||
return string.IsNullOrEmpty(normalized) ? "unknown" : normalized;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record OpenVexExportDocument(
|
||||
OpenVexDocumentSection Document,
|
||||
ImmutableArray<OpenVexExportStatement> Statements,
|
||||
OpenVexExportMetadata Metadata);
|
||||
|
||||
internal sealed record OpenVexDocumentSection(
|
||||
[property: JsonPropertyName("@context")] string Context = "https://openvex.dev/ns/v0.2",
|
||||
[property: JsonPropertyName("id")] string Id = "",
|
||||
[property: JsonPropertyName("author")] string Author = "",
|
||||
[property: JsonPropertyName("version")] string Version = "1",
|
||||
[property: JsonPropertyName("created")] string Created = "",
|
||||
[property: JsonPropertyName("last_updated")] string LastUpdated = "",
|
||||
[property: JsonPropertyName("profile")] string Profile = "");
|
||||
|
||||
internal sealed record OpenVexExportStatement(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("vulnerability")] string Vulnerability,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("justification"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Justification,
|
||||
[property: JsonPropertyName("timestamp")] string Timestamp,
|
||||
[property: JsonPropertyName("last_updated")] string LastUpdated,
|
||||
[property: JsonPropertyName("products")] ImmutableArray<OpenVexExportProduct> Products,
|
||||
[property: JsonPropertyName("statement"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Statement,
|
||||
[property: JsonPropertyName("sources")] ImmutableArray<OpenVexExportSource> Sources);
|
||||
|
||||
internal sealed record OpenVexExportProduct(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("version"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Version,
|
||||
[property: JsonPropertyName("purl"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Purl,
|
||||
[property: JsonPropertyName("cpe"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Cpe);
|
||||
|
||||
internal sealed record OpenVexExportSource(
|
||||
[property: JsonPropertyName("provider")] string Provider,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("justification"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Justification,
|
||||
[property: JsonPropertyName("document_digest")] string DocumentDigest,
|
||||
[property: JsonPropertyName("source_uri")] string SourceUri,
|
||||
[property: JsonPropertyName("detail"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Detail,
|
||||
[property: JsonPropertyName("first_observed")] string FirstObserved,
|
||||
[property: JsonPropertyName("last_observed")] string LastObserved);
|
||||
|
||||
internal sealed record OpenVexExportMetadata(
|
||||
[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,282 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using StellaOps.Excititor.Core;
|
||||
|
||||
namespace StellaOps.Excititor.Formats.OpenVEX;
|
||||
|
||||
/// <summary>
|
||||
/// Provides deterministic merging utilities for OpenVEX statements derived from normalized VEX claims.
|
||||
/// </summary>
|
||||
public static class OpenVexStatementMerger
|
||||
{
|
||||
private static readonly ImmutableDictionary<VexClaimStatus, int> StatusRiskPrecedence = new Dictionary<VexClaimStatus, int>
|
||||
{
|
||||
[VexClaimStatus.Affected] = 3,
|
||||
[VexClaimStatus.UnderInvestigation] = 2,
|
||||
[VexClaimStatus.Fixed] = 1,
|
||||
[VexClaimStatus.NotAffected] = 0,
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
public static OpenVexMergeResult Merge(IEnumerable<VexClaim> claims)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(claims);
|
||||
|
||||
var statements = new List<OpenVexMergedStatement>();
|
||||
var diagnostics = new Dictionary<string, SortedSet<string>>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var group in claims
|
||||
.Where(static claim => claim is not null)
|
||||
.GroupBy(static claim => (claim.VulnerabilityId, claim.Product.Key)))
|
||||
{
|
||||
var orderedClaims = group
|
||||
.OrderBy(static claim => claim.ProviderId, StringComparer.Ordinal)
|
||||
.ThenBy(static claim => claim.Document.Digest, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
if (orderedClaims.IsDefaultOrEmpty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var mergedProduct = MergeProduct(orderedClaims);
|
||||
var sources = BuildSources(orderedClaims);
|
||||
var firstSeen = orderedClaims.Min(static claim => claim.FirstSeen);
|
||||
var lastSeen = orderedClaims.Max(static claim => claim.LastSeen);
|
||||
var statusSet = orderedClaims
|
||||
.Select(static claim => claim.Status)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
if (statusSet.Length > 1)
|
||||
{
|
||||
AddDiagnostic(
|
||||
diagnostics,
|
||||
"openvex.status_conflict",
|
||||
FormattableString.Invariant($"{group.Key.VulnerabilityId}:{group.Key.Key}={string.Join('|', statusSet.Select(static status => status.ToString().ToLowerInvariant()))}"));
|
||||
}
|
||||
|
||||
var canonicalStatus = SelectCanonicalStatus(statusSet);
|
||||
var justification = SelectJustification(canonicalStatus, orderedClaims, diagnostics, group.Key);
|
||||
|
||||
if (canonicalStatus == VexClaimStatus.NotAffected && justification is null)
|
||||
{
|
||||
AddDiagnostic(
|
||||
diagnostics,
|
||||
"policy.justification_missing",
|
||||
FormattableString.Invariant($"{group.Key.VulnerabilityId}:{group.Key.Key}"));
|
||||
}
|
||||
|
||||
var detail = BuildDetail(orderedClaims);
|
||||
|
||||
statements.Add(new OpenVexMergedStatement(
|
||||
group.Key.VulnerabilityId,
|
||||
mergedProduct,
|
||||
canonicalStatus,
|
||||
justification,
|
||||
detail,
|
||||
sources,
|
||||
firstSeen,
|
||||
lastSeen));
|
||||
}
|
||||
|
||||
var orderedStatements = statements
|
||||
.OrderBy(static statement => statement.VulnerabilityId, StringComparer.Ordinal)
|
||||
.ThenBy(static statement => statement.Product.Key, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var orderedDiagnostics = diagnostics.Count == 0
|
||||
? ImmutableDictionary<string, string>.Empty
|
||||
: diagnostics.ToImmutableDictionary(
|
||||
static pair => pair.Key,
|
||||
pair => string.Join(",", pair.Value.OrderBy(static entry => entry, StringComparer.Ordinal)),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
return new OpenVexMergeResult(orderedStatements, orderedDiagnostics);
|
||||
}
|
||||
|
||||
private static VexClaimStatus SelectCanonicalStatus(IReadOnlyCollection<VexClaimStatus> statuses)
|
||||
{
|
||||
if (statuses.Count == 0)
|
||||
{
|
||||
return VexClaimStatus.UnderInvestigation;
|
||||
}
|
||||
|
||||
return statuses
|
||||
.OrderByDescending(static status => StatusRiskPrecedence.GetValueOrDefault(status, -1))
|
||||
.ThenBy(static status => status.ToString(), StringComparer.Ordinal)
|
||||
.First();
|
||||
}
|
||||
|
||||
private static VexJustification? SelectJustification(
|
||||
VexClaimStatus canonicalStatus,
|
||||
ImmutableArray<VexClaim> claims,
|
||||
IDictionary<string, SortedSet<string>> diagnostics,
|
||||
(string Vulnerability, string ProductKey) groupKey)
|
||||
{
|
||||
var relevantClaims = claims
|
||||
.Where(claim => claim.Status == canonicalStatus)
|
||||
.ToArray();
|
||||
|
||||
if (relevantClaims.Length == 0)
|
||||
{
|
||||
relevantClaims = claims.ToArray();
|
||||
}
|
||||
|
||||
var justifications = relevantClaims
|
||||
.Select(static claim => claim.Justification)
|
||||
.Where(static justification => justification is not null)
|
||||
.Cast<VexJustification>()
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
if (justifications.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (justifications.Length > 1)
|
||||
{
|
||||
AddDiagnostic(
|
||||
diagnostics,
|
||||
"openvex.justification_conflict",
|
||||
FormattableString.Invariant($"{groupKey.Vulnerability}:{groupKey.ProductKey}={string.Join('|', justifications.Select(static justification => justification.ToString().ToLowerInvariant()))}"));
|
||||
}
|
||||
|
||||
return justifications
|
||||
.OrderBy(static justification => justification.ToString(), StringComparer.Ordinal)
|
||||
.First();
|
||||
}
|
||||
|
||||
private static string? BuildDetail(ImmutableArray<VexClaim> claims)
|
||||
{
|
||||
var details = claims
|
||||
.Select(static claim => claim.Detail)
|
||||
.Where(static detail => !string.IsNullOrWhiteSpace(detail))
|
||||
.Select(static detail => detail!.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (details.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return string.Join("; ", details.OrderBy(static detail => detail, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
private static ImmutableArray<OpenVexSourceEntry> BuildSources(ImmutableArray<VexClaim> claims)
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<OpenVexSourceEntry>(claims.Length);
|
||||
foreach (var claim in claims)
|
||||
{
|
||||
builder.Add(new OpenVexSourceEntry(
|
||||
claim.ProviderId,
|
||||
claim.Status,
|
||||
claim.Justification,
|
||||
claim.Document.Digest,
|
||||
claim.Document.SourceUri,
|
||||
claim.Detail,
|
||||
claim.FirstSeen,
|
||||
claim.LastSeen));
|
||||
}
|
||||
|
||||
return builder
|
||||
.ToImmutable()
|
||||
.OrderBy(static source => source.ProviderId, StringComparer.Ordinal)
|
||||
.ThenBy(static source => source.DocumentDigest, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static VexProduct MergeProduct(ImmutableArray<VexClaim> claims)
|
||||
{
|
||||
var key = claims[0].Product.Key;
|
||||
var names = claims
|
||||
.Select(static claim => claim.Product.Name)
|
||||
.Where(static name => !string.IsNullOrWhiteSpace(name))
|
||||
.Select(static name => name!)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var versions = claims
|
||||
.Select(static claim => claim.Product.Version)
|
||||
.Where(static version => !string.IsNullOrWhiteSpace(version))
|
||||
.Select(static version => version!)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var purls = claims
|
||||
.Select(static claim => claim.Product.Purl)
|
||||
.Where(static purl => !string.IsNullOrWhiteSpace(purl))
|
||||
.Select(static purl => purl!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var cpes = claims
|
||||
.Select(static claim => claim.Product.Cpe)
|
||||
.Where(static cpe => !string.IsNullOrWhiteSpace(cpe))
|
||||
.Select(static cpe => cpe!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var identifiers = claims
|
||||
.SelectMany(static claim => claim.Product.ComponentIdentifiers)
|
||||
.Where(static identifier => !string.IsNullOrWhiteSpace(identifier))
|
||||
.Select(static identifier => identifier!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static identifier => identifier, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new VexProduct(
|
||||
key,
|
||||
names.Length == 0 ? claims[0].Product.Name : names.OrderByDescending(static name => name.Length).ThenBy(static name => name, StringComparer.Ordinal).First(),
|
||||
versions.Length == 0 ? claims[0].Product.Version : versions.OrderByDescending(static version => version.Length).ThenBy(static version => version, StringComparer.Ordinal).First(),
|
||||
purls.Length == 0 ? claims[0].Product.Purl : purls.OrderBy(static purl => purl, StringComparer.OrdinalIgnoreCase).First(),
|
||||
cpes.Length == 0 ? claims[0].Product.Cpe : cpes.OrderBy(static cpe => cpe, StringComparer.OrdinalIgnoreCase).First(),
|
||||
identifiers);
|
||||
}
|
||||
|
||||
private static void AddDiagnostic(
|
||||
IDictionary<string, SortedSet<string>> diagnostics,
|
||||
string code,
|
||||
string value)
|
||||
{
|
||||
if (!diagnostics.TryGetValue(code, out var entries))
|
||||
{
|
||||
entries = new SortedSet<string>(StringComparer.Ordinal);
|
||||
diagnostics[code] = entries;
|
||||
}
|
||||
|
||||
entries.Add(value);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record OpenVexMergeResult(
|
||||
ImmutableArray<OpenVexMergedStatement> Statements,
|
||||
ImmutableDictionary<string, string> Diagnostics);
|
||||
|
||||
public sealed record OpenVexMergedStatement(
|
||||
string VulnerabilityId,
|
||||
VexProduct Product,
|
||||
VexClaimStatus Status,
|
||||
VexJustification? Justification,
|
||||
string? Detail,
|
||||
ImmutableArray<OpenVexSourceEntry> Sources,
|
||||
DateTimeOffset FirstObserved,
|
||||
DateTimeOffset LastObserved);
|
||||
|
||||
public sealed record OpenVexSourceEntry(
|
||||
string ProviderId,
|
||||
VexClaimStatus Status,
|
||||
VexJustification? Justification,
|
||||
string DocumentDigest,
|
||||
Uri DocumentSource,
|
||||
string? Detail,
|
||||
DateTimeOffset FirstSeen,
|
||||
DateTimeOffset LastSeen)
|
||||
{
|
||||
public string DocumentDigest { get; } = string.IsNullOrWhiteSpace(DocumentDigest)
|
||||
? throw new ArgumentException("Document digest must be provided.", nameof(DocumentDigest))
|
||||
: DocumentDigest.Trim();
|
||||
}
|
||||
@@ -5,10 +5,11 @@ namespace StellaOps.Excititor.Formats.OpenVEX;
|
||||
|
||||
public static class OpenVexFormatsServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddOpenVexNormalizer(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
services.AddSingleton<IVexNormalizer, OpenVexNormalizer>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
public static IServiceCollection AddOpenVexNormalizer(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
services.AddSingleton<IVexNormalizer, OpenVexNormalizer>();
|
||||
services.AddSingleton<IVexExporter, OpenVexExporter>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@ If you are working on this file you need to read docs/modules/excititor/ARCHITEC
|
||||
| Task | Owner(s) | Depends on | Notes |
|
||||
|---|---|---|---|
|
||||
|EXCITITOR-FMT-OPENVEX-01-001 – OpenVEX normalizer|Team Excititor Formats|EXCITITOR-CORE-01-001|**DONE (2025-10-17)** – OpenVEX normalizer parses statements/products, maps status/justification, and surfaces provenance metadata; coverage in `OpenVexNormalizerTests`.|
|
||||
|EXCITITOR-FMT-OPENVEX-01-002 – Statement merge utilities|Team Excititor Formats|EXCITITOR-FMT-OPENVEX-01-001|**DOING (2025-10-19)** – Prereq EXCITITOR-FMT-OPENVEX-01-001 confirmed DONE; building deterministic merge reducers with policy diagnostics.|
|
||||
|EXCITITOR-FMT-OPENVEX-01-003 – OpenVEX export writer|Team Excititor Formats|EXCITITOR-EXPORT-01-001, EXCITITOR-FMT-OPENVEX-01-001|**DOING (2025-10-19)** – Prereqs EXCITITOR-EXPORT-01-001 & EXCITITOR-FMT-OPENVEX-01-001 verified DONE; starting canonical OpenVEX exporter with stable ordering/SBOM references.|
|
||||
|EXCITITOR-FMT-OPENVEX-01-002 – Statement merge utilities|Team Excititor Formats|EXCITITOR-FMT-OPENVEX-01-001|**DONE (2025-10-29)** – Delivered deterministic statement merger prioritising risk status, preserving source provenance, and surfacing conflict diagnostics.|
|
||||
|EXCITITOR-FMT-OPENVEX-01-003 – OpenVEX export writer|Team Excititor Formats|EXCITITOR-EXPORT-01-001, EXCITITOR-FMT-OPENVEX-01-001|**DONE (2025-10-29)** – Shipped canonical OpenVEX exporter emitting merged statements, metadata, and stable digests for attested distribution.|
|
||||
|
||||
Reference in New Issue
Block a user