audit work, fixed StellaOps.sln warnings/errors, fixed tests, sprints work, new advisories

This commit is contained in:
master
2026-01-07 18:49:59 +02:00
parent 04ec098046
commit 608a7f85c0
866 changed files with 56323 additions and 6231 deletions

29
src/VexLens/AGENTS.md Normal file
View File

@@ -0,0 +1,29 @@
# VexLens Module Charter
## Mission
- Compute deterministic consensus over VEX statements and expose conflict-aware APIs.
## Responsibilities
- Implement consensus algorithm and conflict detection.
- Persist consensus history and emit deterministic exports.
- Expose APIs for consensus, conflicts, and trust weight updates.
- Maintain offline bundle formats for Export Center.
## Required Reading
- docs/README.md
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
- docs/modules/platform/architecture-overview.md
- docs/modules/vex-lens/architecture.md
- docs/modules/excititor/architecture.md
## Working Agreement
- Deterministic ordering: sort by timestamps, trust tier, and confidence.
- Use TimeProvider and IGuidGenerator; UTC timestamps.
- Use InvariantCulture for formatting.
- Propagate CancellationToken in async flows.
- Preserve provenance fields in outputs.
## Testing Strategy
- Unit tests for consensus join, conflicts, and precedence.
- Integration tests for API endpoints and exports.
- Determinism tests for repeatable consensus and bundle outputs.

View File

@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
@@ -269,7 +270,7 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
CancellationToken cancellationToken = default)
{
using var activity = ActivitySource.StartActivity("PurgeAsync");
activity?.SetTag("olderThan", olderThan.ToString("O"));
activity?.SetTag("olderThan", olderThan.ToString("O", CultureInfo.InvariantCulture));
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var cmd = new NpgsqlCommand(

View File

@@ -1,6 +1,7 @@
using StellaOps.VexLens.Consensus;
using StellaOps.VexLens.Models;
using StellaOps.VexLens.Storage;
using ModelsVexStatus = StellaOps.VexLens.Models.VexStatus;
using ModelsVexJustification = StellaOps.VexLens.Models.VexJustification;
namespace StellaOps.VexLens.Integration;
@@ -48,8 +49,8 @@ public sealed class PolicyEngineIntegration : IPolicyEngineIntegration
VulnerabilityId: vulnerabilityId,
ProductKey: productKey,
HasVexData: true,
Status: projection.Status,
Justification: projection.Justification,
Status: ConvertStatus(projection.Status),
Justification: ConvertJustification(projection.Justification),
ConfidenceScore: projection.ConfidenceScore,
MeetsConfidenceThreshold: meetsThreshold,
ProjectionId: projection.ProjectionId,
@@ -190,6 +191,45 @@ public sealed class PolicyEngineIntegration : IPolicyEngineIntegration
VexStatus: statusResult.Status,
AdjustmentReason: $"{reason} (confidence: {confidenceScale:P0})");
}
private static VexStatus? ConvertStatus(ModelsVexStatus? status)
{
if (!status.HasValue) return null;
return status.Value switch
{
ModelsVexStatus.Affected => VexStatus.Affected,
ModelsVexStatus.NotAffected => VexStatus.NotAffected,
ModelsVexStatus.Fixed => VexStatus.Fixed,
ModelsVexStatus.UnderInvestigation => VexStatus.UnderInvestigation,
_ => null
};
}
private static VexStatus ConvertStatusNonNull(ModelsVexStatus status)
{
return status switch
{
ModelsVexStatus.Affected => VexStatus.Affected,
ModelsVexStatus.NotAffected => VexStatus.NotAffected,
ModelsVexStatus.Fixed => VexStatus.Fixed,
ModelsVexStatus.UnderInvestigation => VexStatus.UnderInvestigation,
_ => VexStatus.UnderInvestigation
};
}
private static VexJustification? ConvertJustification(ModelsVexJustification? justification)
{
if (!justification.HasValue) return null;
return justification.Value switch
{
ModelsVexJustification.ComponentNotPresent => VexJustification.ComponentNotPresent,
ModelsVexJustification.VulnerableCodeNotPresent => VexJustification.VulnerableCodeNotPresent,
ModelsVexJustification.VulnerableCodeNotInExecutePath => VexJustification.VulnerableCodeNotInExecutePath,
ModelsVexJustification.VulnerableCodeCannotBeControlledByAdversary => VexJustification.VulnerableCodeCannotBeControlledByAdversary,
ModelsVexJustification.InlineMitigationsAlreadyExist => VexJustification.InlineMitigationsAlreadyExist,
_ => null
};
}
}
/// <summary>
@@ -247,8 +287,8 @@ public sealed class VulnExplorerIntegration : IVulnExplorerIntegration
.Select(p => new ProductVexStatus(
ProductKey: p.ProductKey,
ProductName: null,
Status: p.Status,
Justification: p.Justification,
Status: ConvertStatusNonNull(p.Status),
Justification: ConvertJustification(p.Justification),
ConfidenceScore: p.ConfidenceScore,
PrimaryIssuer: null,
ComputedAt: p.ComputedAt))
@@ -296,22 +336,23 @@ public sealed class VulnExplorerIntegration : IVulnExplorerIntegration
foreach (var projection in history.OrderBy(p => p.ComputedAt))
{
var currentStatus = ConvertStatusNonNull(projection.Status);
var eventType = previousStatus == null
? "initial"
: projection.Status != previousStatus
: currentStatus != previousStatus
? "status_change"
: "update";
entries.Add(new VexTimelineEntry(
Timestamp: projection.ComputedAt,
Status: projection.Status,
Justification: projection.Justification,
Status: currentStatus,
Justification: ConvertJustification(projection.Justification),
IssuerId: null,
IssuerName: null,
EventType: eventType,
Notes: projection.RationaleSummary));
previousStatus = projection.Status;
previousStatus = currentStatus;
}
var statusChangeCount = entries.Count(e => e.EventType == "status_change");
@@ -320,7 +361,7 @@ public sealed class VulnExplorerIntegration : IVulnExplorerIntegration
VulnerabilityId: vulnerabilityId,
ProductKey: productKey,
Entries: entries,
CurrentStatus: history.FirstOrDefault()?.Status,
CurrentStatus: history.FirstOrDefault() is { } first ? ConvertStatusNonNull(first.Status) : null,
StatusChangeCount: statusChangeCount);
}
@@ -361,12 +402,14 @@ public sealed class VulnExplorerIntegration : IVulnExplorerIntegration
}
var statusCounts = result.Projections
.GroupBy(p => p.Status)
.GroupBy(p => ConvertStatusNonNull(p.Status))
.ToDictionary(g => g.Key, g => g.Count());
var justificationCounts = result.Projections
.Where(p => p.Justification.HasValue)
.GroupBy(p => p.Justification!.Value)
.Select(p => ConvertJustification(p.Justification!.Value))
.Where(j => j.HasValue)
.GroupBy(j => j!.Value)
.ToDictionary(g => g.Key, g => g.Count());
var totalStatements = result.Projections.Sum(p => p.StatementCount);
@@ -396,7 +439,7 @@ public sealed class VulnExplorerIntegration : IVulnExplorerIntegration
TenantId: context.TenantId,
VulnerabilityId: searchQuery.VulnerabilityIdPattern,
ProductKey: searchQuery.ProductKeyPattern,
Status: searchQuery.Status,
Status: ConvertStatusToModels(searchQuery.Status),
Outcome: null,
MinimumConfidence: searchQuery.MinimumConfidence,
ComputedAfter: searchQuery.UpdatedAfter,
@@ -412,8 +455,8 @@ public sealed class VulnExplorerIntegration : IVulnExplorerIntegration
var hits = result.Projections.Select(p => new VexSearchHit(
VulnerabilityId: p.VulnerabilityId,
ProductKey: p.ProductKey,
Status: p.Status,
Justification: p.Justification,
Status: ConvertStatusNonNull(p.Status),
Justification: ConvertJustification(p.Justification),
ConfidenceScore: p.ConfidenceScore,
PrimaryIssuer: null,
ComputedAt: p.ComputedAt)).ToList();
@@ -424,4 +467,56 @@ public sealed class VulnExplorerIntegration : IVulnExplorerIntegration
Offset: result.Offset,
Limit: result.Limit);
}
private static VexStatus? ConvertStatus(ModelsVexStatus? status)
{
if (!status.HasValue) return null;
return status.Value switch
{
ModelsVexStatus.Affected => VexStatus.Affected,
ModelsVexStatus.NotAffected => VexStatus.NotAffected,
ModelsVexStatus.Fixed => VexStatus.Fixed,
ModelsVexStatus.UnderInvestigation => VexStatus.UnderInvestigation,
_ => null
};
}
private static VexStatus ConvertStatusNonNull(ModelsVexStatus status)
{
return status switch
{
ModelsVexStatus.Affected => VexStatus.Affected,
ModelsVexStatus.NotAffected => VexStatus.NotAffected,
ModelsVexStatus.Fixed => VexStatus.Fixed,
ModelsVexStatus.UnderInvestigation => VexStatus.UnderInvestigation,
_ => VexStatus.UnderInvestigation
};
}
private static ModelsVexStatus? ConvertStatusToModels(VexStatus? status)
{
if (!status.HasValue) return null;
return status.Value switch
{
VexStatus.Affected => ModelsVexStatus.Affected,
VexStatus.NotAffected => ModelsVexStatus.NotAffected,
VexStatus.Fixed => ModelsVexStatus.Fixed,
VexStatus.UnderInvestigation => ModelsVexStatus.UnderInvestigation,
_ => null
};
}
private static VexJustification? ConvertJustification(ModelsVexJustification? justification)
{
if (!justification.HasValue) return null;
return justification.Value switch
{
ModelsVexJustification.ComponentNotPresent => VexJustification.ComponentNotPresent,
ModelsVexJustification.VulnerableCodeNotPresent => VexJustification.VulnerableCodeNotPresent,
ModelsVexJustification.VulnerableCodeNotInExecutePath => VexJustification.VulnerableCodeNotInExecutePath,
ModelsVexJustification.VulnerableCodeCannotBeControlledByAdversary => VexJustification.VulnerableCodeCannotBeControlledByAdversary,
ModelsVexJustification.InlineMitigationsAlreadyExist => VexJustification.InlineMitigationsAlreadyExist,
_ => null
};
}
}

View File

@@ -0,0 +1,292 @@
// -----------------------------------------------------------------------------
// VexSignalEmitter.cs
// Sprint: SPRINT_20260106_001_004_BE_determinization_integration
// Tasks: DBI-005, DBI-006, DBI-007 - VEX signal emitter and mapper
// Description: Emits VEX signals to the determinization pipeline
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
namespace StellaOps.VexLens.Integration;
/// <summary>
/// VEX signal for the determinization pipeline.
/// </summary>
public sealed record VexSignal
{
/// <summary>The CVE ID.</summary>
public required string CveId { get; init; }
/// <summary>The product (purl or cpe).</summary>
public required string Product { get; init; }
/// <summary>VEX status (affected, not_affected, fixed, under_investigation).</summary>
public required VexStatus Status { get; init; }
/// <summary>Justification (if not_affected).</summary>
public VexJustification? Justification { get; init; }
/// <summary>Impact statement.</summary>
public string? ImpactStatement { get; init; }
/// <summary>Action statement.</summary>
public string? ActionStatement { get; init; }
/// <summary>Version range affected.</summary>
public string? VersionRange { get; init; }
/// <summary>VEX document ID.</summary>
public required string DocumentId { get; init; }
/// <summary>VEX statement timestamp.</summary>
public required DateTimeOffset Timestamp { get; init; }
/// <summary>Supplier/author of the VEX.</summary>
public string? Supplier { get; init; }
/// <summary>Trust score of the VEX source (0.0-1.0).</summary>
public double TrustScore { get; init; }
}
/// <summary>
/// VEX status values.
/// </summary>
public enum VexStatus
{
/// <summary>Affected by the vulnerability.</summary>
Affected,
/// <summary>Not affected by the vulnerability.</summary>
NotAffected,
/// <summary>Fixed in this or a later version.</summary>
Fixed,
/// <summary>Under investigation.</summary>
UnderInvestigation
}
/// <summary>
/// Justification for not_affected status.
/// </summary>
public enum VexJustification
{
/// <summary>Component is not present.</summary>
ComponentNotPresent,
/// <summary>Vulnerable code is not present.</summary>
VulnerableCodeNotPresent,
/// <summary>Vulnerable code is not in execute path.</summary>
VulnerableCodeNotInExecutePath,
/// <summary>Vulnerable code cannot be controlled by adversary.</summary>
VulnerableCodeCannotBeControlledByAdversary,
/// <summary>Inline mitigations already exist.</summary>
InlineMitigationsAlreadyExist
}
/// <summary>
/// Event emitted when VEX status changes.
/// </summary>
public sealed record VexStatusChangedEvent
{
/// <summary>The CVE ID.</summary>
public required string CveId { get; init; }
/// <summary>The product.</summary>
public required string Product { get; init; }
/// <summary>Previous status.</summary>
public VexStatus? PreviousStatus { get; init; }
/// <summary>New status.</summary>
public required VexStatus NewStatus { get; init; }
/// <summary>VEX document ID.</summary>
public required string DocumentId { get; init; }
/// <summary>When the change occurred.</summary>
public required DateTimeOffset ChangedAt { get; init; }
/// <summary>Correlation ID for tracing.</summary>
public string? CorrelationId { get; init; }
}
/// <summary>
/// Emits VEX signals for downstream processing.
/// </summary>
public interface IVexSignalEmitter
{
/// <summary>
/// Emits a VEX signal.
/// </summary>
Task EmitAsync(VexSignal signal, CancellationToken ct = default);
/// <summary>
/// Emits VEX signals in batch.
/// </summary>
Task EmitBatchAsync(IReadOnlyList<VexSignal> signals, CancellationToken ct = default);
/// <summary>
/// Emits a VEX status change event.
/// </summary>
Task EmitStatusChangeAsync(VexStatusChangedEvent @event, CancellationToken ct = default);
}
/// <summary>
/// Default VEX signal emitter.
/// </summary>
public sealed class VexSignalEmitter : IVexSignalEmitter
{
private readonly IVexSignalStore _store;
private readonly IVexEventPublisher _publisher;
private readonly TimeProvider _timeProvider;
private readonly ILogger<VexSignalEmitter> _logger;
public VexSignalEmitter(
IVexSignalStore store,
IVexEventPublisher publisher,
TimeProvider timeProvider,
ILogger<VexSignalEmitter> logger)
{
_store = store;
_publisher = publisher;
_timeProvider = timeProvider;
_logger = logger;
}
/// <inheritdoc />
public async Task EmitAsync(VexSignal signal, CancellationToken ct = default)
{
// Get previous state if exists
var previous = await _store.GetLatestAsync(signal.CveId, signal.Product, ct);
// Store new signal
await _store.StoreAsync(signal, ct);
_logger.LogDebug(
"VEX signal emitted: {CveId} on {Product} = {Status}",
signal.CveId, signal.Product, signal.Status);
// Emit change event if status changed
if (previous is null || previous.Status != signal.Status)
{
var @event = new VexStatusChangedEvent
{
CveId = signal.CveId,
Product = signal.Product,
PreviousStatus = previous?.Status,
NewStatus = signal.Status,
DocumentId = signal.DocumentId,
ChangedAt = _timeProvider.GetUtcNow()
};
await EmitStatusChangeAsync(@event, ct);
}
}
/// <inheritdoc />
public async Task EmitBatchAsync(IReadOnlyList<VexSignal> signals, CancellationToken ct = default)
{
foreach (var signal in signals)
{
await EmitAsync(signal, ct);
}
}
/// <inheritdoc />
public async Task EmitStatusChangeAsync(VexStatusChangedEvent @event, CancellationToken ct = default)
{
await _publisher.PublishAsync(@event, ct);
_logger.LogInformation(
"VEX status changed: {CveId} on {Product}: {PreviousStatus} -> {NewStatus}",
@event.CveId, @event.Product, @event.PreviousStatus, @event.NewStatus);
}
}
/// <summary>
/// Store for VEX signals.
/// </summary>
public interface IVexSignalStore
{
/// <summary>
/// Stores a VEX signal.
/// </summary>
Task StoreAsync(VexSignal signal, CancellationToken ct = default);
/// <summary>
/// Gets the latest VEX signal for a CVE/product pair.
/// </summary>
Task<VexSignal?> GetLatestAsync(string cveId, string product, CancellationToken ct = default);
/// <summary>
/// Gets all VEX signals for a CVE.
/// </summary>
Task<IReadOnlyList<VexSignal>> GetByCveAsync(string cveId, CancellationToken ct = default);
/// <summary>
/// Gets all VEX signals for a product.
/// </summary>
Task<IReadOnlyList<VexSignal>> GetByProductAsync(string product, CancellationToken ct = default);
}
/// <summary>
/// Publisher for VEX events.
/// </summary>
public interface IVexEventPublisher
{
/// <summary>
/// Publishes a VEX status change event.
/// </summary>
Task PublishAsync(VexStatusChangedEvent @event, CancellationToken ct = default);
}
/// <summary>
/// Maps VEX claims to summary format.
/// </summary>
public static class VexClaimSummaryMapper
{
/// <summary>
/// Maps a VEX signal to a claim summary.
/// </summary>
public static VexClaimSummary ToSummary(VexSignal signal)
{
return new VexClaimSummary
{
CveId = signal.CveId,
Product = signal.Product,
Status = signal.Status.ToString().ToLowerInvariant(),
Justification = signal.Justification?.ToString(),
ImpactStatement = signal.ImpactStatement,
DocumentId = signal.DocumentId,
Timestamp = signal.Timestamp,
TrustScore = signal.TrustScore
};
}
/// <summary>
/// Maps multiple VEX signals to claim summaries.
/// </summary>
public static IReadOnlyList<VexClaimSummary> ToSummaries(IEnumerable<VexSignal> signals)
{
return signals.Select(ToSummary).ToList();
}
}
/// <summary>
/// Summary of a VEX claim for display/export.
/// </summary>
public sealed record VexClaimSummary
{
public required string CveId { get; init; }
public required string Product { get; init; }
public required string Status { get; init; }
public string? Justification { get; init; }
public string? ImpactStatement { get; init; }
public required string DocumentId { get; init; }
public required DateTimeOffset Timestamp { get; init; }
public double TrustScore { get; init; }
}

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text.Json;
using StellaOps.VexLens.Consensus;
using StellaOps.VexLens.Export;
@@ -285,7 +286,7 @@ public sealed class ConsensusJobService : IConsensusJobService
JobType: ConsensusJobTypes.ProjectionRefresh,
TenantId: tenantId,
Priority: ConsensusJobTypes.GetDefaultPriority(ConsensusJobTypes.ProjectionRefresh),
IdempotencyKey: $"refresh:{tenantId}:{since?.ToString("O") ?? "all"}:{status?.ToString() ?? "all"}",
IdempotencyKey: $"refresh:{tenantId}:{since?.ToString("O", CultureInfo.InvariantCulture) ?? "all"}:{status?.ToString() ?? "all"}",
Payload: JsonSerializer.Serialize(payload, JsonOptions));
}

View File

@@ -6,6 +6,7 @@
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Globalization;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Attestor.Core.Delta;
@@ -293,7 +294,7 @@ public sealed class VexDeltaComputeService : IVexDeltaComputeService
context.ArtifactDigest,
context.PreviousStatus,
context.NewStatus,
context.ComputedAt.ToUniversalTime().ToString("O"));
context.ComputedAt.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));
var bytes = System.Text.Encoding.UTF8.GetBytes(input);
var hash = System.Security.Cryptography.SHA256.HashData(bytes);

View File

@@ -2,6 +2,7 @@
// © StellaOps Contributors. See LICENSE and NOTICE.md in the repository root.
using System.Diagnostics;
using System.Globalization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.VexLens.Consensus;
@@ -207,7 +208,7 @@ public sealed class DualWriteConsensusProjectionStore : IConsensusProjectionStor
CancellationToken cancellationToken = default)
{
using var activity = ActivitySource.StartActivity("PurgeAsync.DualWrite");
activity?.SetTag("olderThan", olderThan.ToString("O"));
activity?.SetTag("olderThan", olderThan.ToString("O", CultureInfo.InvariantCulture));
// Purge from both stores
var primaryCount = await PrimaryStore.PurgeAsync(olderThan, tenantId, cancellationToken);

View File

@@ -2,6 +2,7 @@
// © StellaOps Contributors. See LICENSE and NOTICE.md in the repository root.
using System.Diagnostics;
using System.Globalization;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Determinism;
@@ -325,7 +326,7 @@ public sealed class PostgresConsensusProjectionStoreProxy : IConsensusProjection
CancellationToken cancellationToken = default)
{
using var activity = ActivitySource.StartActivity("PurgeAsync");
activity?.SetTag("olderThan", olderThan.ToString("O"));
activity?.SetTag("olderThan", olderThan.ToString("O", CultureInfo.InvariantCulture));
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var cmd = new NpgsqlCommand(PurgeSql, connection);

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -438,7 +439,7 @@ public static class VexLensTestData
@context = "https://openvex.dev/ns/v0.2.0",
@id = $"urn:uuid:{Guid.NewGuid()}",
author = new { @id = "test-vendor", name = "Test Vendor" },
timestamp = DateTimeOffset.UtcNow.ToString("O"),
timestamp = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture),
statements = new[]
{
new

View File

@@ -9,10 +9,6 @@
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Moq" />
<PackageReference Include="xunit.runner.visualstudio" >
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" >
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>

View File

@@ -14,11 +14,6 @@
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
<PackageReference Include="Moq" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>