Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.BinaryIndex.VexBridge;
|
||||
|
||||
/// <summary>
|
||||
/// Constants and schema definitions for binary match evidence in VEX observations.
|
||||
/// </summary>
|
||||
public static class BinaryMatchEvidenceSchema
|
||||
{
|
||||
/// <summary>Evidence type identifier for binary fingerprint matches.</summary>
|
||||
public const string EvidenceType = "binary_fingerprint_match";
|
||||
|
||||
/// <summary>Schema version for evidence payloads.</summary>
|
||||
public const string SchemaVersion = "1.0";
|
||||
|
||||
/// <summary>Evidence field names.</summary>
|
||||
public static class Fields
|
||||
{
|
||||
public const string Type = "type";
|
||||
public const string SchemaVersion = "schema_version";
|
||||
public const string MatchType = "match_type";
|
||||
public const string BuildId = "build_id";
|
||||
public const string FileSha256 = "file_sha256";
|
||||
public const string TextSha256 = "text_sha256";
|
||||
public const string FingerprintAlgorithm = "fingerprint_algorithm";
|
||||
public const string Similarity = "similarity";
|
||||
public const string DistroRelease = "distro_release";
|
||||
public const string SourcePackage = "source_package";
|
||||
public const string FixedVersion = "fixed_version";
|
||||
public const string FixMethod = "fix_method";
|
||||
public const string FixConfidence = "fix_confidence";
|
||||
public const string EvidenceRef = "evidence_ref";
|
||||
public const string MatchedFunction = "matched_function";
|
||||
public const string BinaryKey = "binary_key";
|
||||
public const string Architecture = "architecture";
|
||||
public const string ResolvedAt = "resolved_at";
|
||||
}
|
||||
|
||||
/// <summary>Match type values.</summary>
|
||||
public static class MatchTypes
|
||||
{
|
||||
public const string BuildId = "build_id";
|
||||
public const string Fingerprint = "fingerprint";
|
||||
public const string HashExact = "hash_exact";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an evidence JSON object from the provided parameters.
|
||||
/// </summary>
|
||||
public static JsonObject CreateEvidence(
|
||||
string matchType,
|
||||
string? buildId = null,
|
||||
string? fileSha256 = null,
|
||||
string? textSha256 = null,
|
||||
string? fingerprintAlgorithm = null,
|
||||
decimal? similarity = null,
|
||||
string? distroRelease = null,
|
||||
string? sourcePackage = null,
|
||||
string? fixedVersion = null,
|
||||
string? fixMethod = null,
|
||||
decimal? fixConfidence = null,
|
||||
string? evidenceRef = null,
|
||||
string? matchedFunction = null,
|
||||
string? binaryKey = null,
|
||||
string? architecture = null,
|
||||
DateTimeOffset? resolvedAt = null)
|
||||
{
|
||||
var evidence = new JsonObject
|
||||
{
|
||||
[Fields.Type] = EvidenceType,
|
||||
[Fields.SchemaVersion] = SchemaVersion,
|
||||
[Fields.MatchType] = matchType
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(buildId))
|
||||
evidence[Fields.BuildId] = buildId;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(fileSha256))
|
||||
evidence[Fields.FileSha256] = fileSha256;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(textSha256))
|
||||
evidence[Fields.TextSha256] = textSha256;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(fingerprintAlgorithm))
|
||||
evidence[Fields.FingerprintAlgorithm] = fingerprintAlgorithm;
|
||||
|
||||
if (similarity.HasValue)
|
||||
evidence[Fields.Similarity] = similarity.Value;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(distroRelease))
|
||||
evidence[Fields.DistroRelease] = distroRelease;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(sourcePackage))
|
||||
evidence[Fields.SourcePackage] = sourcePackage;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(fixedVersion))
|
||||
evidence[Fields.FixedVersion] = fixedVersion;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(fixMethod))
|
||||
evidence[Fields.FixMethod] = fixMethod;
|
||||
|
||||
if (fixConfidence.HasValue)
|
||||
evidence[Fields.FixConfidence] = fixConfidence.Value;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(evidenceRef))
|
||||
evidence[Fields.EvidenceRef] = evidenceRef;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(matchedFunction))
|
||||
evidence[Fields.MatchedFunction] = matchedFunction;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(binaryKey))
|
||||
evidence[Fields.BinaryKey] = binaryKey;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(architecture))
|
||||
evidence[Fields.Architecture] = architecture;
|
||||
|
||||
if (resolvedAt.HasValue)
|
||||
evidence[Fields.ResolvedAt] = resolvedAt.Value.ToString("O");
|
||||
|
||||
return evidence;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IDsseSigningAdapter.cs
|
||||
// Sprint: SPRINT_1227_0001_0001_LB_binary_vex_generator
|
||||
// Task: T5 — DSSE signing integration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.BinaryIndex.VexBridge;
|
||||
|
||||
/// <summary>
|
||||
/// Adapter interface for DSSE signing operations.
|
||||
/// Abstracts the Attestor signing service for VexBridge use.
|
||||
/// </summary>
|
||||
public interface IDsseSigningAdapter
|
||||
{
|
||||
/// <summary>
|
||||
/// Sign a payload and return a DSSE envelope.
|
||||
/// </summary>
|
||||
/// <param name="payload">The payload bytes to sign.</param>
|
||||
/// <param name="payloadType">The DSSE payload type URI.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>DSSE envelope as JSON bytes.</returns>
|
||||
Task<byte[]> SignAsync(byte[] payload, string payloadType, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify a DSSE envelope signature.
|
||||
/// </summary>
|
||||
/// <param name="envelope">The DSSE envelope bytes.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>True if signature is valid.</returns>
|
||||
Task<bool> VerifyAsync(byte[] envelope, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get the key ID used for signing.
|
||||
/// </summary>
|
||||
string SigningKeyId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Check if signing is available.
|
||||
/// </summary>
|
||||
bool IsAvailable { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope result with metadata.
|
||||
/// </summary>
|
||||
public sealed record DsseEnvelopeResult
|
||||
{
|
||||
/// <summary>The DSSE envelope as JSON string.</summary>
|
||||
public required string Envelope { get; init; }
|
||||
|
||||
/// <summary>The signing key ID used.</summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>SHA-256 hash of the envelope.</summary>
|
||||
public required string EnvelopeHash { get; init; }
|
||||
|
||||
/// <summary>Timestamp when signed.</summary>
|
||||
public required DateTimeOffset SignedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.BinaryIndex.Core.Models;
|
||||
using StellaOps.BinaryIndex.Core.Services;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
|
||||
namespace StellaOps.BinaryIndex.VexBridge;
|
||||
|
||||
/// <summary>
|
||||
/// Generates VEX observations from binary vulnerability match results.
|
||||
/// Bridges the gap between binary fingerprint analysis and VEX decision flow.
|
||||
/// </summary>
|
||||
public interface IVexEvidenceGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generate a VEX observation from a binary vulnerability match.
|
||||
/// </summary>
|
||||
/// <param name="match">The binary vulnerability match result.</param>
|
||||
/// <param name="identity">The binary identity being analyzed.</param>
|
||||
/// <param name="fixStatus">Optional fix status from the fix index.</param>
|
||||
/// <param name="context">Generation context with tenant and scan metadata.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A VEX observation ready for Excititor persistence.</returns>
|
||||
Task<VexObservation> GenerateFromBinaryMatchAsync(
|
||||
BinaryVulnMatch match,
|
||||
BinaryIdentity identity,
|
||||
FixStatusResult? fixStatus,
|
||||
VexGenerationContext context,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Batch generation of VEX observations for scan performance.
|
||||
/// </summary>
|
||||
/// <param name="matches">Collection of matches with their context.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of VEX observations in deterministic order.</returns>
|
||||
Task<IReadOnlyList<VexObservation>> GenerateBatchAsync(
|
||||
IEnumerable<BinaryMatchWithContext> matches,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Generate observation ID deterministically for replay/idempotency.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="cveId">CVE identifier.</param>
|
||||
/// <param name="productKey">PURL or product key.</param>
|
||||
/// <param name="scanId">Scan identifier.</param>
|
||||
/// <returns>Deterministic UUID5-based observation ID.</returns>
|
||||
string GenerateObservationId(string tenantId, string cveId, string productKey, string scanId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for VEX observation generation.
|
||||
/// </summary>
|
||||
public sealed record VexGenerationContext
|
||||
{
|
||||
/// <summary>Tenant identifier.</summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>Scan identifier for traceability.</summary>
|
||||
public required string ScanId { get; init; }
|
||||
|
||||
/// <summary>Product key, typically a PURL.</summary>
|
||||
public required string ProductKey { get; init; }
|
||||
|
||||
/// <summary>Optional distro release identifier (e.g., "debian:bookworm").</summary>
|
||||
public string? DistroRelease { get; init; }
|
||||
|
||||
/// <summary>Whether to sign the observation with DSSE. Default true.</summary>
|
||||
public bool SignWithDsse { get; init; } = true;
|
||||
|
||||
/// <summary>Provider ID for the VEX observation. Defaults to "stellaops.binaryindex".</summary>
|
||||
public string ProviderId { get; init; } = "stellaops.binaryindex";
|
||||
|
||||
/// <summary>Stream ID for the VEX observation. Defaults to "binary_resolution".</summary>
|
||||
public string StreamId { get; init; } = "binary_resolution";
|
||||
|
||||
/// <summary>Optional version for the resolution evidence.</summary>
|
||||
public string? EvidenceVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper for a binary match with its full context.
|
||||
/// </summary>
|
||||
public sealed record BinaryMatchWithContext
|
||||
{
|
||||
/// <summary>The binary vulnerability match.</summary>
|
||||
public required BinaryVulnMatch Match { get; init; }
|
||||
|
||||
/// <summary>The binary identity being analyzed.</summary>
|
||||
public required BinaryIdentity Identity { get; init; }
|
||||
|
||||
/// <summary>Optional fix status from the fix index.</summary>
|
||||
public FixStatusResult? FixStatus { get; init; }
|
||||
|
||||
/// <summary>Generation context.</summary>
|
||||
public required VexGenerationContext Context { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.BinaryIndex.VexBridge;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering VexBridge services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds VEX Bridge services for converting binary matches to VEX observations.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">Configuration containing VexBridge section.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddBinaryVexBridge(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services.Configure<VexBridgeOptions>(
|
||||
configuration.GetSection(VexBridgeOptions.SectionName));
|
||||
|
||||
services.AddSingleton<IVexEvidenceGenerator, VexEvidenceGenerator>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds VEX Bridge services with custom options configuration.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureOptions">Action to configure options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddBinaryVexBridge(
|
||||
this IServiceCollection services,
|
||||
Action<VexBridgeOptions> configureOptions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configureOptions);
|
||||
|
||||
services.Configure(configureOptions);
|
||||
services.AddSingleton<IVexEvidenceGenerator, VexEvidenceGenerator>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<Description>Bridges binary fingerprint matching to VEX observation generation for StellaOps.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.BinaryIndex.Core/StellaOps.BinaryIndex.Core.csproj" />
|
||||
<ProjectReference Include="../StellaOps.BinaryIndex.FixIndex/StellaOps.BinaryIndex.FixIndex.csproj" />
|
||||
<ProjectReference Include="../../../Excititor/__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="../../../Attestor/StellaOps.Attestor.Envelope/StellaOps.Attestor.Envelope.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace StellaOps.BinaryIndex.VexBridge;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the VEX Bridge.
|
||||
/// </summary>
|
||||
public sealed class VexBridgeOptions
|
||||
{
|
||||
/// <summary>Configuration section name.</summary>
|
||||
public const string SectionName = "VexBridge";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to sign generated VEX observations with DSSE.
|
||||
/// Default: true
|
||||
/// </summary>
|
||||
public bool SignWithDsse { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Key ID to use for DSSE signing.
|
||||
/// If null, uses the default attestor key.
|
||||
/// </summary>
|
||||
public string? DsseKeyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Default provider ID for generated observations.
|
||||
/// </summary>
|
||||
public string DefaultProviderId { get; set; } = "stellaops.binaryindex";
|
||||
|
||||
/// <summary>
|
||||
/// Default stream ID for generated observations.
|
||||
/// </summary>
|
||||
public string DefaultStreamId { get; set; } = "binary_resolution";
|
||||
|
||||
/// <summary>
|
||||
/// Minimum confidence threshold for creating observations.
|
||||
/// Matches below this threshold will be skipped.
|
||||
/// </summary>
|
||||
public decimal MinConfidenceThreshold { get; set; } = 0.70m;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include function-level evidence when available.
|
||||
/// </summary>
|
||||
public bool IncludeFunctionEvidence { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of observations to generate in a single batch.
|
||||
/// </summary>
|
||||
public int MaxBatchSize { get; set; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Namespace UUID for generating deterministic observation IDs.
|
||||
/// Default: StellaOps BinaryIndex namespace.
|
||||
/// </summary>
|
||||
public Guid ObservationIdNamespace { get; set; } = new("d9e0a5f3-7b2c-4e8d-9a1f-6c3b5d8e2f0a");
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.BinaryIndex.Core.Models;
|
||||
using StellaOps.BinaryIndex.Core.Services;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
|
||||
namespace StellaOps.BinaryIndex.VexBridge;
|
||||
|
||||
/// <summary>
|
||||
/// Generates VEX observations from binary vulnerability matches.
|
||||
/// Maps FixState to VexClaimStatus with appropriate justifications.
|
||||
/// Supports optional DSSE signing for attestable proofs.
|
||||
/// </summary>
|
||||
public sealed class VexEvidenceGenerator : IVexEvidenceGenerator
|
||||
{
|
||||
private readonly ILogger<VexEvidenceGenerator> _logger;
|
||||
private readonly VexBridgeOptions _options;
|
||||
private readonly IDsseSigningAdapter? _dsseSigner;
|
||||
|
||||
public VexEvidenceGenerator(
|
||||
ILogger<VexEvidenceGenerator> logger,
|
||||
IOptions<VexBridgeOptions> options,
|
||||
IDsseSigningAdapter? dsseSigner = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_dsseSigner = dsseSigner;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<VexObservation> GenerateFromBinaryMatchAsync(
|
||||
BinaryVulnMatch match,
|
||||
BinaryIdentity identity,
|
||||
FixStatusResult? fixStatus,
|
||||
VexGenerationContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(match);
|
||||
ArgumentNullException.ThrowIfNull(identity);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// Check confidence threshold
|
||||
var effectiveConfidence = fixStatus?.Confidence ?? match.Confidence;
|
||||
if (effectiveConfidence < _options.MinConfidenceThreshold)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Skipping observation for {CveId}: confidence {Confidence} below threshold {Threshold}",
|
||||
match.CveId, effectiveConfidence, _options.MinConfidenceThreshold);
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Match confidence {effectiveConfidence} is below minimum threshold {_options.MinConfidenceThreshold}");
|
||||
}
|
||||
|
||||
var observation = await CreateObservationAsync(match, identity, fixStatus, context, ct);
|
||||
return observation;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<VexObservation>> GenerateBatchAsync(
|
||||
IEnumerable<BinaryMatchWithContext> matches,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(matches);
|
||||
|
||||
var results = new List<VexObservation>();
|
||||
var batchItems = matches.ToList();
|
||||
|
||||
if (batchItems.Count > _options.MaxBatchSize)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Batch size {Count} exceeds maximum {Max}, truncating",
|
||||
batchItems.Count, _options.MaxBatchSize);
|
||||
batchItems = batchItems.Take(_options.MaxBatchSize).ToList();
|
||||
}
|
||||
|
||||
foreach (var item in batchItems)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var observation = await GenerateFromBinaryMatchAsync(
|
||||
item.Match,
|
||||
item.Identity,
|
||||
item.FixStatus,
|
||||
item.Context,
|
||||
ct);
|
||||
|
||||
results.Add(observation);
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("below minimum threshold"))
|
||||
{
|
||||
// Skip items below threshold, continue with batch
|
||||
_logger.LogDebug("Skipping batch item: {Message}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// Return in deterministic order (by observation ID)
|
||||
return results.OrderBy(o => o.ObservationId, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GenerateObservationId(string tenantId, string cveId, string productKey, string scanId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(productKey);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
|
||||
|
||||
// UUID5 generation: namespace + name
|
||||
var name = $"{tenantId.ToLowerInvariant()}:{cveId.ToUpperInvariant()}:{productKey}:{scanId}";
|
||||
return GenerateUuid5(_options.ObservationIdNamespace, name).ToString();
|
||||
}
|
||||
|
||||
private async Task<VexObservation> CreateObservationAsync(
|
||||
BinaryVulnMatch match,
|
||||
BinaryIdentity identity,
|
||||
FixStatusResult? fixStatus,
|
||||
VexGenerationContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var observationId = GenerateObservationId(
|
||||
context.TenantId,
|
||||
match.CveId,
|
||||
context.ProductKey,
|
||||
context.ScanId);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Map fix status to VEX status and justification
|
||||
var (vexStatus, justification) = MapToVexStatus(fixStatus);
|
||||
|
||||
// Create evidence JSON
|
||||
var evidence = CreateEvidencePayload(match, identity, fixStatus, context, now);
|
||||
|
||||
// Create upstream metadata with optional DSSE signing
|
||||
var upstream = await CreateUpstreamAsync(observationId, evidence, now, context.SignWithDsse, ct);
|
||||
|
||||
// Create statement
|
||||
var statement = CreateStatement(match, context, vexStatus, justification, fixStatus);
|
||||
|
||||
// Create content
|
||||
var content = CreateContent(evidence);
|
||||
|
||||
// Create linkset
|
||||
var linkset = CreateLinkset(match, identity);
|
||||
|
||||
var attributes = ImmutableDictionary<string, string>.Empty
|
||||
.Add("generator", "StellaOps.BinaryIndex.VexBridge")
|
||||
.Add("generator_version", "1.0.0")
|
||||
.Add("scan_id", context.ScanId);
|
||||
|
||||
// Add DSSE signature info to attributes if signed
|
||||
if (context.SignWithDsse && upstream.Signature.Present)
|
||||
{
|
||||
attributes = attributes
|
||||
.Add("dsse_signed", "true")
|
||||
.Add("dsse_key_id", upstream.Signature.KeyId ?? "unknown");
|
||||
}
|
||||
|
||||
return new VexObservation(
|
||||
observationId: observationId,
|
||||
tenant: context.TenantId,
|
||||
providerId: context.ProviderId,
|
||||
streamId: context.StreamId,
|
||||
upstream: upstream,
|
||||
statements: ImmutableArray.Create(statement),
|
||||
content: content,
|
||||
linkset: linkset,
|
||||
createdAt: now,
|
||||
attributes: attributes);
|
||||
}
|
||||
|
||||
private static (VexClaimStatus Status, VexJustification? Justification) MapToVexStatus(FixStatusResult? fixStatus)
|
||||
{
|
||||
if (fixStatus is null)
|
||||
{
|
||||
return (VexClaimStatus.UnderInvestigation, null);
|
||||
}
|
||||
|
||||
return fixStatus.State switch
|
||||
{
|
||||
FixState.Fixed => (VexClaimStatus.NotAffected, VexJustification.VulnerableCodeNotPresent),
|
||||
FixState.Vulnerable => (VexClaimStatus.Affected, null),
|
||||
FixState.NotAffected => (VexClaimStatus.NotAffected, VexJustification.ComponentNotPresent),
|
||||
FixState.Wontfix => (VexClaimStatus.NotAffected, VexJustification.InlineMitigationsAlreadyExist),
|
||||
FixState.Unknown => (VexClaimStatus.UnderInvestigation, null),
|
||||
_ => (VexClaimStatus.UnderInvestigation, null)
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonObject CreateEvidencePayload(
|
||||
BinaryVulnMatch match,
|
||||
BinaryIdentity identity,
|
||||
FixStatusResult? fixStatus,
|
||||
VexGenerationContext context,
|
||||
DateTimeOffset resolvedAt)
|
||||
{
|
||||
var matchType = match.Method switch
|
||||
{
|
||||
MatchMethod.BuildIdCatalog => BinaryMatchEvidenceSchema.MatchTypes.BuildId,
|
||||
MatchMethod.FingerprintMatch => BinaryMatchEvidenceSchema.MatchTypes.Fingerprint,
|
||||
MatchMethod.RangeMatch => BinaryMatchEvidenceSchema.MatchTypes.HashExact,
|
||||
_ => BinaryMatchEvidenceSchema.MatchTypes.Fingerprint
|
||||
};
|
||||
|
||||
return BinaryMatchEvidenceSchema.CreateEvidence(
|
||||
matchType: matchType,
|
||||
buildId: identity.BuildId,
|
||||
fileSha256: identity.FileSha256,
|
||||
textSha256: identity.TextSha256,
|
||||
fingerprintAlgorithm: matchType == BinaryMatchEvidenceSchema.MatchTypes.Fingerprint ? "combined" : null,
|
||||
similarity: match.Evidence?.Similarity ?? match.Confidence,
|
||||
distroRelease: context.DistroRelease,
|
||||
sourcePackage: ExtractSourcePackage(match.VulnerablePurl),
|
||||
fixedVersion: fixStatus?.FixedVersion,
|
||||
fixMethod: fixStatus?.Method.ToString()?.ToLowerInvariant(),
|
||||
fixConfidence: fixStatus?.Confidence,
|
||||
evidenceRef: fixStatus?.EvidenceId?.ToString(),
|
||||
matchedFunction: match.Evidence?.MatchedFunction,
|
||||
binaryKey: identity.BinaryKey,
|
||||
architecture: identity.Architecture,
|
||||
resolvedAt: resolvedAt);
|
||||
}
|
||||
|
||||
private async Task<VexObservationUpstream> CreateUpstreamAsync(
|
||||
string observationId,
|
||||
JsonObject evidence,
|
||||
DateTimeOffset now,
|
||||
bool signWithDsse,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Compute content hash of the evidence
|
||||
var evidenceJson = evidence.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
|
||||
var contentHash = ComputeSha256(evidenceJson);
|
||||
|
||||
VexObservationSignature signature;
|
||||
|
||||
// Sign with DSSE if requested and signer is available
|
||||
if (signWithDsse && _dsseSigner is { IsAvailable: true })
|
||||
{
|
||||
try
|
||||
{
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(evidenceJson);
|
||||
var envelopeBytes = await _dsseSigner.SignAsync(
|
||||
payloadBytes,
|
||||
"application/vnd.stellaops.binary-resolution+json",
|
||||
ct);
|
||||
|
||||
var envelopeBase64 = Convert.ToBase64String(envelopeBytes);
|
||||
var envelopeHash = ComputeSha256(Encoding.UTF8.GetString(envelopeBytes));
|
||||
|
||||
signature = new VexObservationSignature(
|
||||
present: true,
|
||||
format: "dsse",
|
||||
keyId: _dsseSigner.SigningKeyId,
|
||||
signature: envelopeBase64);
|
||||
|
||||
_logger.LogDebug(
|
||||
"DSSE signature generated for observation {ObservationId} with key {KeyId}",
|
||||
observationId, _dsseSigner.SigningKeyId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to generate DSSE signature for observation {ObservationId}, proceeding unsigned",
|
||||
observationId);
|
||||
|
||||
signature = new VexObservationSignature(
|
||||
present: false,
|
||||
format: null,
|
||||
keyId: null,
|
||||
signature: null);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (signWithDsse && _dsseSigner is null)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"DSSE signing requested but no signer configured for observation {ObservationId}",
|
||||
observationId);
|
||||
}
|
||||
|
||||
signature = new VexObservationSignature(
|
||||
present: false,
|
||||
format: null,
|
||||
keyId: null,
|
||||
signature: null);
|
||||
}
|
||||
|
||||
return new VexObservationUpstream(
|
||||
upstreamId: $"binary:{observationId}",
|
||||
documentVersion: "1.0",
|
||||
fetchedAt: now,
|
||||
receivedAt: now,
|
||||
contentHash: contentHash,
|
||||
signature: signature,
|
||||
metadata: ImmutableDictionary<string, string>.Empty
|
||||
.Add("source", "binary_fingerprint_analysis"));
|
||||
}
|
||||
|
||||
private static VexObservationStatement CreateStatement(
|
||||
BinaryVulnMatch match,
|
||||
VexGenerationContext context,
|
||||
VexClaimStatus status,
|
||||
VexJustification? justification,
|
||||
FixStatusResult? fixStatus)
|
||||
{
|
||||
var detail = BuildStatementDetail(match, fixStatus);
|
||||
|
||||
return new VexObservationStatement(
|
||||
vulnerabilityId: match.CveId,
|
||||
productKey: context.ProductKey,
|
||||
status: status,
|
||||
lastObserved: DateTimeOffset.UtcNow,
|
||||
locator: null,
|
||||
justification: justification,
|
||||
introducedVersion: null,
|
||||
fixedVersion: fixStatus?.FixedVersion,
|
||||
purl: match.VulnerablePurl,
|
||||
cpe: null,
|
||||
evidence: null,
|
||||
metadata: ImmutableDictionary<string, string>.Empty
|
||||
.Add("impact_statement", detail));
|
||||
}
|
||||
|
||||
private static string BuildStatementDetail(BinaryVulnMatch match, FixStatusResult? fixStatus)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
if (fixStatus is { State: FixState.Fixed })
|
||||
{
|
||||
sb.Append($"Binary fingerprint analysis indicates this binary contains the patched version.");
|
||||
if (!string.IsNullOrEmpty(fixStatus.FixedVersion))
|
||||
{
|
||||
sb.Append($" Fixed in version: {fixStatus.FixedVersion}.");
|
||||
}
|
||||
}
|
||||
else if (fixStatus is { State: FixState.Vulnerable })
|
||||
{
|
||||
sb.Append("Binary fingerprint analysis indicates this binary contains vulnerable code.");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append($"Binary fingerprint match with confidence {match.Confidence:P0}.");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static VexObservationContent CreateContent(JsonObject evidence)
|
||||
{
|
||||
return new VexObservationContent(
|
||||
format: "application/json",
|
||||
specVersion: "1.0",
|
||||
raw: evidence);
|
||||
}
|
||||
|
||||
private static VexObservationLinkset CreateLinkset(
|
||||
BinaryVulnMatch match,
|
||||
BinaryIdentity identity)
|
||||
{
|
||||
var refs = new List<VexObservationReference>
|
||||
{
|
||||
new(type: "vulnerability", url: $"https://nvd.nist.gov/vuln/detail/{match.CveId}"),
|
||||
new(type: "package", url: match.VulnerablePurl)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(identity.BuildId))
|
||||
{
|
||||
refs.Add(new(type: "build_id", url: $"urn:build-id:{identity.BuildId}"));
|
||||
}
|
||||
|
||||
return new VexObservationLinkset(
|
||||
aliases: ImmutableArray.Create(match.CveId),
|
||||
purls: ImmutableArray.Create(match.VulnerablePurl),
|
||||
cpes: null,
|
||||
references: refs);
|
||||
}
|
||||
|
||||
private static string? ExtractSourcePackage(string purl)
|
||||
{
|
||||
// Simple extraction from PURL: pkg:deb/debian/openssl@3.0.7 → openssl
|
||||
if (string.IsNullOrEmpty(purl))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var parts = purl.Split('/');
|
||||
if (parts.Length >= 3)
|
||||
{
|
||||
var nameVersion = parts[^1];
|
||||
var atIndex = nameVersion.IndexOf('@');
|
||||
return atIndex > 0 ? nameVersion[..atIndex] : nameVersion;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore parsing errors
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate a UUID v5 (name-based, SHA-1) from namespace and name.
|
||||
/// </summary>
|
||||
private static Guid GenerateUuid5(Guid namespaceId, string name)
|
||||
{
|
||||
// Convert namespace GUID to bytes (big-endian format for UUID)
|
||||
var namespaceBytes = namespaceId.ToByteArray();
|
||||
|
||||
// Swap bytes for big-endian (UUID format)
|
||||
SwapGuidBytesForBigEndian(namespaceBytes);
|
||||
|
||||
var nameBytes = Encoding.UTF8.GetBytes(name);
|
||||
|
||||
// Concatenate namespace + name
|
||||
var combined = new byte[namespaceBytes.Length + nameBytes.Length];
|
||||
Buffer.BlockCopy(namespaceBytes, 0, combined, 0, namespaceBytes.Length);
|
||||
Buffer.BlockCopy(nameBytes, 0, combined, namespaceBytes.Length, nameBytes.Length);
|
||||
|
||||
// Hash with SHA-1
|
||||
var hash = SHA1.HashData(combined);
|
||||
|
||||
// Take first 16 bytes
|
||||
var guidBytes = new byte[16];
|
||||
Array.Copy(hash, guidBytes, 16);
|
||||
|
||||
// Set version (5) and variant (RFC 4122)
|
||||
guidBytes[6] = (byte)((guidBytes[6] & 0x0F) | 0x50); // Version 5
|
||||
guidBytes[8] = (byte)((guidBytes[8] & 0x3F) | 0x80); // Variant RFC 4122
|
||||
|
||||
// Swap back to little-endian for .NET Guid
|
||||
SwapGuidBytesForBigEndian(guidBytes);
|
||||
|
||||
return new Guid(guidBytes);
|
||||
}
|
||||
|
||||
private static void SwapGuidBytesForBigEndian(byte[] bytes)
|
||||
{
|
||||
// Swap first 4 bytes
|
||||
(bytes[0], bytes[3]) = (bytes[3], bytes[0]);
|
||||
(bytes[1], bytes[2]) = (bytes[2], bytes[1]);
|
||||
|
||||
// Swap bytes 4-5
|
||||
(bytes[4], bytes[5]) = (bytes[5], bytes[4]);
|
||||
|
||||
// Swap bytes 6-7
|
||||
(bytes[6], bytes[7]) = (bytes[7], bytes[6]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user