notify doctors work, audit work, new product advisory sprints

This commit is contained in:
master
2026-01-13 08:36:29 +02:00
parent b8868a5f13
commit 9ca7cb183e
343 changed files with 24492 additions and 3544 deletions

View File

@@ -0,0 +1,583 @@
// -----------------------------------------------------------------------------
// GreyQueueEndpoints.cs
// Description: Minimal API endpoints for Grey Queue management.
// Implements signed, replayable evidence pipeline for ambiguous unknowns.
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Unknowns.Core.Models;
using StellaOps.Unknowns.Core.Repositories;
namespace StellaOps.Unknowns.WebService.Endpoints;
/// <summary>
/// Minimal API endpoints for Grey Queue service.
/// </summary>
public static class GreyQueueEndpoints
{
/// <summary>
/// Maps all Grey Queue endpoints.
/// </summary>
public static IEndpointRouteBuilder MapGreyQueueEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/grey-queue")
.WithTags("GreyQueue")
.WithOpenApi();
// List and query
group.MapGet("/", ListEntries)
.WithName("ListGreyQueueEntries")
.WithSummary("List grey queue entries with pagination")
.WithDescription("Returns paginated list of grey queue entries. Supports filtering by status and reason.");
group.MapGet("/{id:guid}", GetEntryById)
.WithName("GetGreyQueueEntry")
.WithSummary("Get grey queue entry by ID")
.WithDescription("Returns a single grey queue entry with full evidence bundle.");
group.MapGet("/by-unknown/{unknownId:guid}", GetByUnknownId)
.WithName("GetGreyQueueByUnknownId")
.WithSummary("Get grey queue entry by unknown ID")
.WithDescription("Returns the grey queue entry for a specific unknown.");
group.MapGet("/ready", GetReadyForProcessing)
.WithName("GetReadyForProcessing")
.WithSummary("Get entries ready for processing")
.WithDescription("Returns entries that are ready to be processed (pending, not exhausted, past next processing time).");
// Triggers
group.MapGet("/triggers/feed/{feedId}", GetByFeedTrigger)
.WithName("GetByFeedTrigger")
.WithSummary("Get entries triggered by feed update")
.WithDescription("Returns entries that should be reprocessed due to a feed update.");
group.MapGet("/triggers/tool/{toolId}", GetByToolTrigger)
.WithName("GetByToolTrigger")
.WithSummary("Get entries triggered by tool update")
.WithDescription("Returns entries that should be reprocessed due to a tool update.");
group.MapGet("/triggers/cve/{cveId}", GetByCveTrigger)
.WithName("GetByCveTrigger")
.WithSummary("Get entries triggered by CVE update")
.WithDescription("Returns entries that should be reprocessed due to a CVE update.");
// Actions
group.MapPost("/", EnqueueEntry)
.WithName("EnqueueGreyQueueEntry")
.WithSummary("Enqueue a new grey queue entry")
.WithDescription("Creates a new grey queue entry with evidence bundle and trigger conditions.");
group.MapPost("/{id:guid}/process", StartProcessing)
.WithName("StartGreyQueueProcessing")
.WithSummary("Mark entry as processing")
.WithDescription("Marks an entry as currently being processed.");
group.MapPost("/{id:guid}/result", RecordResult)
.WithName("RecordGreyQueueResult")
.WithSummary("Record processing result")
.WithDescription("Records the result of a processing attempt.");
group.MapPost("/{id:guid}/resolve", ResolveEntry)
.WithName("ResolveGreyQueueEntry")
.WithSummary("Resolve a grey queue entry")
.WithDescription("Marks an entry as resolved with resolution type and reference.");
group.MapPost("/{id:guid}/dismiss", DismissEntry)
.WithName("DismissGreyQueueEntry")
.WithSummary("Dismiss a grey queue entry")
.WithDescription("Manually dismisses an entry from the queue.");
// Maintenance
group.MapPost("/expire", ExpireOldEntries)
.WithName("ExpireGreyQueueEntries")
.WithSummary("Expire old entries")
.WithDescription("Expires entries that have exceeded their TTL.");
// Statistics
group.MapGet("/summary", GetSummary)
.WithName("GetGreyQueueSummary")
.WithSummary("Get grey queue summary statistics")
.WithDescription("Returns summary counts by status, reason, and performance metrics.");
return routes;
}
// List entries with pagination
private static async Task<Ok<GreyQueueListResponse>> ListEntries(
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromQuery] int skip = 0,
[FromQuery] int take = 50,
[FromQuery] GreyQueueStatus? status = null,
[FromQuery] GreyQueueReason? reason = null,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
IReadOnlyList<GreyQueueEntry> entries;
if (status.HasValue)
{
entries = await repository.GetByStatusAsync(tenantId, status.Value, take, skip, ct);
}
else if (reason.HasValue)
{
entries = await repository.GetByReasonAsync(tenantId, reason.Value, take, ct);
}
else
{
entries = await repository.GetByStatusAsync(tenantId, GreyQueueStatus.Pending, take, skip, ct);
}
var total = await repository.CountPendingAsync(tenantId, ct);
var response = new GreyQueueListResponse
{
Items = entries.Select(MapToDto).ToList(),
Total = total,
Skip = skip,
Take = take
};
return TypedResults.Ok(response);
}
// Get entry by ID
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound>> GetEntryById(
Guid id,
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
var entry = await repository.GetByIdAsync(tenantId, id, ct);
if (entry is null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(MapToDto(entry));
}
// Get by unknown ID
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound>> GetByUnknownId(
Guid unknownId,
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
var entry = await repository.GetByUnknownIdAsync(tenantId, unknownId, ct);
if (entry is null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(MapToDto(entry));
}
// Get ready for processing
private static async Task<Ok<GreyQueueListResponse>> GetReadyForProcessing(
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromQuery] int limit = 50,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
var entries = await repository.GetReadyForProcessingAsync(tenantId, limit, ct);
var response = new GreyQueueListResponse
{
Items = entries.Select(MapToDto).ToList(),
Total = entries.Count,
Skip = 0,
Take = limit
};
return TypedResults.Ok(response);
}
// Get by feed trigger
private static async Task<Ok<GreyQueueListResponse>> GetByFeedTrigger(
string feedId,
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromQuery] string? version = null,
[FromQuery] int limit = 50,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
var entries = await repository.GetByFeedTriggerAsync(tenantId, feedId, version, limit, ct);
var response = new GreyQueueListResponse
{
Items = entries.Select(MapToDto).ToList(),
Total = entries.Count,
Skip = 0,
Take = limit
};
return TypedResults.Ok(response);
}
// Get by tool trigger
private static async Task<Ok<GreyQueueListResponse>> GetByToolTrigger(
string toolId,
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromQuery] string? version = null,
[FromQuery] int limit = 50,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
var entries = await repository.GetByToolTriggerAsync(tenantId, toolId, version, limit, ct);
var response = new GreyQueueListResponse
{
Items = entries.Select(MapToDto).ToList(),
Total = entries.Count,
Skip = 0,
Take = limit
};
return TypedResults.Ok(response);
}
// Get by CVE trigger
private static async Task<Ok<GreyQueueListResponse>> GetByCveTrigger(
string cveId,
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromQuery] int limit = 50,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
var entries = await repository.GetByCveTriggerAsync(tenantId, cveId, limit, ct);
var response = new GreyQueueListResponse
{
Items = entries.Select(MapToDto).ToList(),
Total = entries.Count,
Skip = 0,
Take = limit
};
return TypedResults.Ok(response);
}
// Enqueue new entry
private static async Task<Created<GreyQueueEntryDto>> EnqueueEntry(
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromBody] EnqueueGreyQueueRequest request,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
var evidence = request.Evidence is not null ? new GreyQueueEvidenceBundle
{
SbomSliceJson = request.Evidence.SbomSliceJson,
AdvisorySnippetJson = request.Evidence.AdvisorySnippetJson,
VexEvidenceJson = request.Evidence.VexEvidenceJson,
DiffTracesJson = request.Evidence.DiffTracesJson,
ReachabilityEvidenceJson = request.Evidence.ReachabilityEvidenceJson
} : null;
var triggers = request.Triggers is not null ? new GreyQueueTriggers
{
Feeds = request.Triggers.Feeds?.Select(f => new FeedTrigger(f.FeedId, f.MinVersion)).ToList() ?? [],
Tools = request.Triggers.Tools?.Select(t => new ToolTrigger(t.ToolId, t.MinVersion)).ToList() ?? [],
CveIds = request.Triggers.CveIds ?? [],
PurlPatterns = request.Triggers.PurlPatterns ?? []
} : null;
var entry = await repository.EnqueueAsync(
tenantId,
request.UnknownId,
request.Reason,
request.ReasonDetail,
evidence,
triggers,
request.Priority,
request.CreatedBy,
request.CorrelationId,
ct);
return TypedResults.Created($"/api/grey-queue/{entry.Id}", MapToDto(entry));
}
// Start processing
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound>> StartProcessing(
Guid id,
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
try
{
var entry = await repository.StartProcessingAsync(tenantId, id, ct);
return TypedResults.Ok(MapToDto(entry));
}
catch (KeyNotFoundException)
{
return TypedResults.NotFound();
}
}
// Record result
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound>> RecordResult(
Guid id,
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromBody] RecordResultRequest request,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
try
{
var entry = await repository.RecordProcessingResultAsync(
tenantId,
id,
request.Success,
request.Result,
request.NextProcessingAt,
ct);
return TypedResults.Ok(MapToDto(entry));
}
catch (KeyNotFoundException)
{
return TypedResults.NotFound();
}
}
// Resolve entry
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound>> ResolveEntry(
Guid id,
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromBody] ResolveEntryRequest request,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
try
{
var entry = await repository.ResolveAsync(
tenantId,
id,
request.Resolution,
request.ResolutionRef,
ct);
return TypedResults.Ok(MapToDto(entry));
}
catch (KeyNotFoundException)
{
return TypedResults.NotFound();
}
}
// Dismiss entry
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound>> DismissEntry(
Guid id,
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromBody] DismissEntryRequest request,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
try
{
var entry = await repository.DismissAsync(
tenantId,
id,
request.DismissedBy,
request.Reason,
ct);
return TypedResults.Ok(MapToDto(entry));
}
catch (KeyNotFoundException)
{
return TypedResults.NotFound();
}
}
// Expire old entries
private static async Task<Ok<ExpireResultResponse>> ExpireOldEntries(
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
var count = await repository.ExpireOldEntriesAsync(tenantId, ct);
return TypedResults.Ok(new ExpireResultResponse { ExpiredCount = count });
}
// Get summary
private static async Task<Ok<GreyQueueSummaryDto>> GetSummary(
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
IGreyQueueRepository repository = null!,
CancellationToken ct = default)
{
var summary = await repository.GetSummaryAsync(tenantId, ct);
var response = new GreyQueueSummaryDto
{
Total = summary.Total,
Pending = summary.Pending,
Processing = summary.Processing,
Retrying = summary.Retrying,
Resolved = summary.Resolved,
Failed = summary.Failed,
Expired = summary.Expired,
Dismissed = summary.Dismissed,
ByReason = summary.ByReason,
AvgAttemptsToResolve = summary.AvgAttemptsToResolve,
AvgHoursToResolve = summary.AvgHoursToResolve,
OldestPendingHours = summary.OldestPendingHours
};
return TypedResults.Ok(response);
}
// Mapping helpers
private static GreyQueueEntryDto MapToDto(GreyQueueEntry e) => new()
{
Id = e.Id,
TenantId = e.TenantId,
UnknownId = e.UnknownId,
Fingerprint = e.Fingerprint,
Status = e.Status.ToString(),
Priority = e.Priority,
Reason = e.Reason.ToString(),
ReasonDetail = e.ReasonDetail,
HasSbomSlice = e.SbomSlice is not null,
HasAdvisorySnippet = e.AdvisorySnippet is not null,
HasVexEvidence = e.VexEvidence is not null,
HasDiffTraces = e.DiffTraces is not null,
HasReachabilityEvidence = e.ReachabilityEvidence is not null,
ProcessingAttempts = e.ProcessingAttempts,
MaxAttempts = e.MaxAttempts,
LastProcessedAt = e.LastProcessedAt,
LastProcessingResult = e.LastProcessingResult,
NextProcessingAt = e.NextProcessingAt,
TriggerCveIds = e.TriggerOnCveUpdate.ToList(),
TriggerPurlPatterns = e.TriggerOnPurlMatch.ToList(),
CreatedAt = e.CreatedAt,
UpdatedAt = e.UpdatedAt,
ExpiresAt = e.ExpiresAt,
ResolvedAt = e.ResolvedAt,
Resolution = e.Resolution?.ToString(),
ResolutionRef = e.ResolutionRef,
IsPending = e.IsPending,
IsExhausted = e.IsExhausted,
IsReadyForProcessing = e.IsReadyForProcessing
};
}
// DTOs
public sealed record GreyQueueListResponse
{
public required IReadOnlyList<GreyQueueEntryDto> Items { get; init; }
public required long Total { get; init; }
public required int Skip { get; init; }
public required int Take { get; init; }
}
public sealed record GreyQueueEntryDto
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public required Guid UnknownId { get; init; }
public required string Fingerprint { get; init; }
public required string Status { get; init; }
public required int Priority { get; init; }
public required string Reason { get; init; }
public string? ReasonDetail { get; init; }
public required bool HasSbomSlice { get; init; }
public required bool HasAdvisorySnippet { get; init; }
public required bool HasVexEvidence { get; init; }
public required bool HasDiffTraces { get; init; }
public required bool HasReachabilityEvidence { get; init; }
public required int ProcessingAttempts { get; init; }
public required int MaxAttempts { get; init; }
public DateTimeOffset? LastProcessedAt { get; init; }
public string? LastProcessingResult { get; init; }
public DateTimeOffset? NextProcessingAt { get; init; }
public required IReadOnlyList<string> TriggerCveIds { get; init; }
public required IReadOnlyList<string> TriggerPurlPatterns { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required DateTimeOffset UpdatedAt { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
public DateTimeOffset? ResolvedAt { get; init; }
public string? Resolution { get; init; }
public string? ResolutionRef { get; init; }
public required bool IsPending { get; init; }
public required bool IsExhausted { get; init; }
public required bool IsReadyForProcessing { get; init; }
}
public sealed record GreyQueueSummaryDto
{
public required long Total { get; init; }
public required long Pending { get; init; }
public required long Processing { get; init; }
public required long Retrying { get; init; }
public required long Resolved { get; init; }
public required long Failed { get; init; }
public required long Expired { get; init; }
public required long Dismissed { get; init; }
public required IReadOnlyDictionary<string, long> ByReason { get; init; }
public double AvgAttemptsToResolve { get; init; }
public double AvgHoursToResolve { get; init; }
public double OldestPendingHours { get; init; }
}
public sealed record EnqueueGreyQueueRequest
{
public required Guid UnknownId { get; init; }
public required GreyQueueReason Reason { get; init; }
public string? ReasonDetail { get; init; }
public EvidenceBundleDto? Evidence { get; init; }
public TriggersDto? Triggers { get; init; }
public int Priority { get; init; } = 100;
public required string CreatedBy { get; init; }
public string? CorrelationId { get; init; }
}
public sealed record EvidenceBundleDto
{
public string? SbomSliceJson { get; init; }
public string? AdvisorySnippetJson { get; init; }
public string? VexEvidenceJson { get; init; }
public string? DiffTracesJson { get; init; }
public string? ReachabilityEvidenceJson { get; init; }
}
public sealed record TriggersDto
{
public IReadOnlyList<FeedTriggerDto>? Feeds { get; init; }
public IReadOnlyList<ToolTriggerDto>? Tools { get; init; }
public IReadOnlyList<string>? CveIds { get; init; }
public IReadOnlyList<string>? PurlPatterns { get; init; }
}
public sealed record FeedTriggerDto
{
public required string FeedId { get; init; }
public string? MinVersion { get; init; }
}
public sealed record ToolTriggerDto
{
public required string ToolId { get; init; }
public string? MinVersion { get; init; }
}
public sealed record RecordResultRequest
{
public required bool Success { get; init; }
public required string Result { get; init; }
public DateTimeOffset? NextProcessingAt { get; init; }
}
public sealed record ResolveEntryRequest
{
public required GreyQueueResolution Resolution { get; init; }
public string? ResolutionRef { get; init; }
}
public sealed record DismissEntryRequest
{
public required string DismissedBy { get; init; }
public string? Reason { get; init; }
}
public sealed record ExpireResultResponse
{
public required int ExpiredCount { get; init; }
}

View File

@@ -0,0 +1,235 @@
// -----------------------------------------------------------------------------
// GreyQueueEntry.cs
// Description: Grey Queue entry for unknowns requiring reprocessing when feeds/tools update.
// Implements signed, replayable evidence pipeline requirement for handling ambiguous cases.
// -----------------------------------------------------------------------------
using System.Text.Json;
namespace StellaOps.Unknowns.Core.Models;
/// <summary>
/// Represents an entry in the Grey Queue - unknowns that cannot be definitively resolved
/// and are queued for reprocessing when feeds or tools update.
/// </summary>
/// <remarks>
/// The Grey Queue implements the "Grey Queue" pattern from the signed SBOM-VEX-policy pipeline:
/// - Persists full DSSE bundle (SBOM slice, advisory snippet, diff traces)
/// - Uses deterministic fingerprints for deduplication and replay
/// - Reprocesses when feeds/tools update
/// </remarks>
public sealed record GreyQueueEntry
{
/// <summary>Unique identifier for this queue entry.</summary>
public required Guid Id { get; init; }
/// <summary>Tenant that owns this entry.</summary>
public required string TenantId { get; init; }
/// <summary>Reference to the Unknown this entry relates to.</summary>
public required Guid UnknownId { get; init; }
/// <summary>SHA-256 fingerprint for deterministic deduplication and replay.</summary>
public required string Fingerprint { get; init; }
/// <summary>Current status of the queue entry.</summary>
public required GreyQueueStatus Status { get; init; }
/// <summary>Priority for processing (lower = higher priority).</summary>
public required int Priority { get; init; }
/// <summary>Reason why this entry is in the grey queue.</summary>
public required GreyQueueReason Reason { get; init; }
/// <summary>Human-readable description of why resolution failed.</summary>
public string? ReasonDetail { get; init; }
// Evidence Bundle (DSSE-style)
/// <summary>SBOM slice relevant to this unknown (JSON).</summary>
public JsonDocument? SbomSlice { get; init; }
/// <summary>SHA-256 hash of the SBOM slice for integrity.</summary>
public string? SbomSliceHash { get; init; }
/// <summary>Advisory snippet relevant to this unknown (JSON).</summary>
public JsonDocument? AdvisorySnippet { get; init; }
/// <summary>SHA-256 hash of the advisory snippet for integrity.</summary>
public string? AdvisorySnippetHash { get; init; }
/// <summary>VEX evidence relevant to this unknown (JSON).</summary>
public JsonDocument? VexEvidence { get; init; }
/// <summary>SHA-256 hash of the VEX evidence for integrity.</summary>
public string? VexEvidenceHash { get; init; }
/// <summary>Diff/trace data for binary analysis (JSON).</summary>
public JsonDocument? DiffTraces { get; init; }
/// <summary>SHA-256 hash of diff traces for integrity.</summary>
public string? DiffTracesHash { get; init; }
/// <summary>Reachability evidence if applicable (JSON).</summary>
public JsonDocument? ReachabilityEvidence { get; init; }
/// <summary>SHA-256 hash of reachability evidence for integrity.</summary>
public string? ReachabilityEvidenceHash { get; init; }
// Processing Metadata
/// <summary>Number of processing attempts.</summary>
public int ProcessingAttempts { get; init; }
/// <summary>Maximum allowed processing attempts before marking as failed.</summary>
public int MaxAttempts { get; init; } = 10;
/// <summary>When the entry was last processed.</summary>
public DateTimeOffset? LastProcessedAt { get; init; }
/// <summary>Result of the last processing attempt.</summary>
public string? LastProcessingResult { get; init; }
/// <summary>When to next attempt processing.</summary>
public DateTimeOffset? NextProcessingAt { get; init; }
// Trigger Conditions
/// <summary>Feed versions that should trigger reprocessing (JSON array of feed IDs/versions).</summary>
public JsonDocument? TriggerOnFeedUpdate { get; init; }
/// <summary>Tool versions that should trigger reprocessing (JSON array of tool IDs/versions).</summary>
public JsonDocument? TriggerOnToolUpdate { get; init; }
/// <summary>Specific CVE IDs that should trigger reprocessing when updated.</summary>
public IReadOnlyList<string> TriggerOnCveUpdate { get; init; } = [];
/// <summary>Specific PURL patterns that should trigger reprocessing.</summary>
public IReadOnlyList<string> TriggerOnPurlMatch { get; init; } = [];
// Timestamps
/// <summary>When this entry was created.</summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>When this entry was last updated.</summary>
public DateTimeOffset UpdatedAt { get; init; }
/// <summary>When this entry expires (for automatic cleanup).</summary>
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>When this entry was resolved (moved out of grey queue).</summary>
public DateTimeOffset? ResolvedAt { get; init; }
/// <summary>How this entry was resolved.</summary>
public GreyQueueResolution? Resolution { get; init; }
/// <summary>Reference to resolving entity (e.g., feed update ID, tool version).</summary>
public string? ResolutionRef { get; init; }
// Audit
/// <summary>Who created this entry.</summary>
public required string CreatedBy { get; init; }
/// <summary>Correlation ID for tracing.</summary>
public string? CorrelationId { get; init; }
// Computed Properties
/// <summary>Whether this entry is still pending processing.</summary>
public bool IsPending => Status is GreyQueueStatus.Pending or GreyQueueStatus.Retrying;
/// <summary>Whether this entry has exceeded max attempts.</summary>
public bool IsExhausted => ProcessingAttempts >= MaxAttempts;
/// <summary>Whether this entry is ready for processing.</summary>
public bool IsReadyForProcessing =>
IsPending &&
!IsExhausted &&
(NextProcessingAt is null || NextProcessingAt <= DateTimeOffset.UtcNow);
}
/// <summary>Status of a grey queue entry.</summary>
public enum GreyQueueStatus
{
/// <summary>Pending initial processing.</summary>
Pending,
/// <summary>Currently being processed.</summary>
Processing,
/// <summary>Waiting for retry after failed attempt.</summary>
Retrying,
/// <summary>Successfully resolved - evidence now sufficient.</summary>
Resolved,
/// <summary>Failed after exhausting retries.</summary>
Failed,
/// <summary>Expired without resolution.</summary>
Expired,
/// <summary>Manually dismissed by operator.</summary>
Dismissed
}
/// <summary>Reason why an entry is in the grey queue.</summary>
public enum GreyQueueReason
{
/// <summary>Insufficient VEX coverage for verdict.</summary>
InsufficientVex,
/// <summary>Conflicting VEX statements from multiple sources.</summary>
ConflictingVex,
/// <summary>Missing reachability evidence.</summary>
MissingReachability,
/// <summary>Ambiguous package identity.</summary>
AmbiguousIdentity,
/// <summary>Feed not yet available for this component.</summary>
FeedNotAvailable,
/// <summary>Tool does not support this component type.</summary>
ToolUnsupported,
/// <summary>Binary analysis inconclusive.</summary>
BinaryAnalysisInconclusive,
/// <summary>Backport detection uncertain.</summary>
BackportUncertain,
/// <summary>Multiple valid interpretations possible.</summary>
MultipleInterpretations,
/// <summary>Waiting for upstream advisory.</summary>
AwaitingUpstreamAdvisory
}
/// <summary>How a grey queue entry was resolved.</summary>
public enum GreyQueueResolution
{
/// <summary>Resolved by feed update providing sufficient evidence.</summary>
FeedUpdate,
/// <summary>Resolved by tool update enabling analysis.</summary>
ToolUpdate,
/// <summary>Resolved by VEX statement from authoritative source.</summary>
VexReceived,
/// <summary>Resolved by manual operator decision.</summary>
ManualResolution,
/// <summary>Superseded by newer unknown/finding.</summary>
Superseded,
/// <summary>Determined to be not applicable.</summary>
NotApplicable,
/// <summary>Entry expired without resolution.</summary>
Expired
}

View File

@@ -0,0 +1,275 @@
// -----------------------------------------------------------------------------
// IGreyQueueRepository.cs
// Description: Repository interface for Grey Queue operations.
// Supports the signed, replayable evidence pipeline pattern.
// -----------------------------------------------------------------------------
using StellaOps.Unknowns.Core.Models;
namespace StellaOps.Unknowns.Core.Repositories;
/// <summary>
/// Repository interface for Grey Queue operations.
/// </summary>
public interface IGreyQueueRepository
{
/// <summary>
/// Enqueues an unknown into the grey queue with evidence bundle.
/// </summary>
Task<GreyQueueEntry> EnqueueAsync(
string tenantId,
Guid unknownId,
GreyQueueReason reason,
string? reasonDetail,
GreyQueueEvidenceBundle? evidence,
GreyQueueTriggers? triggers,
int priority,
string createdBy,
string? correlationId,
CancellationToken cancellationToken);
/// <summary>
/// Gets a grey queue entry by ID.
/// </summary>
Task<GreyQueueEntry?> GetByIdAsync(
string tenantId,
Guid id,
CancellationToken cancellationToken);
/// <summary>
/// Gets a grey queue entry by unknown ID.
/// </summary>
Task<GreyQueueEntry?> GetByUnknownIdAsync(
string tenantId,
Guid unknownId,
CancellationToken cancellationToken);
/// <summary>
/// Gets a grey queue entry by fingerprint (for deduplication).
/// </summary>
Task<GreyQueueEntry?> GetByFingerprintAsync(
string tenantId,
string fingerprint,
CancellationToken cancellationToken);
/// <summary>
/// Gets pending entries ready for processing.
/// </summary>
Task<IReadOnlyList<GreyQueueEntry>> GetReadyForProcessingAsync(
string tenantId,
int limit,
CancellationToken cancellationToken);
/// <summary>
/// Gets entries that should be triggered by a feed update.
/// </summary>
Task<IReadOnlyList<GreyQueueEntry>> GetByFeedTriggerAsync(
string tenantId,
string feedId,
string? feedVersion,
int limit,
CancellationToken cancellationToken);
/// <summary>
/// Gets entries that should be triggered by a tool update.
/// </summary>
Task<IReadOnlyList<GreyQueueEntry>> GetByToolTriggerAsync(
string tenantId,
string toolId,
string? toolVersion,
int limit,
CancellationToken cancellationToken);
/// <summary>
/// Gets entries that should be triggered by a CVE update.
/// </summary>
Task<IReadOnlyList<GreyQueueEntry>> GetByCveTriggerAsync(
string tenantId,
string cveId,
int limit,
CancellationToken cancellationToken);
/// <summary>
/// Gets entries filtered by status.
/// </summary>
Task<IReadOnlyList<GreyQueueEntry>> GetByStatusAsync(
string tenantId,
GreyQueueStatus status,
int? limit,
int? offset,
CancellationToken cancellationToken);
/// <summary>
/// Gets entries filtered by reason.
/// </summary>
Task<IReadOnlyList<GreyQueueEntry>> GetByReasonAsync(
string tenantId,
GreyQueueReason reason,
int? limit,
CancellationToken cancellationToken);
/// <summary>
/// Marks an entry as processing.
/// </summary>
Task<GreyQueueEntry> StartProcessingAsync(
string tenantId,
Guid id,
CancellationToken cancellationToken);
/// <summary>
/// Records a processing attempt result.
/// </summary>
Task<GreyQueueEntry> RecordProcessingResultAsync(
string tenantId,
Guid id,
bool success,
string result,
DateTimeOffset? nextProcessingAt,
CancellationToken cancellationToken);
/// <summary>
/// Resolves a grey queue entry.
/// </summary>
Task<GreyQueueEntry> ResolveAsync(
string tenantId,
Guid id,
GreyQueueResolution resolution,
string? resolutionRef,
CancellationToken cancellationToken);
/// <summary>
/// Dismisses a grey queue entry (manual operator action).
/// </summary>
Task<GreyQueueEntry> DismissAsync(
string tenantId,
Guid id,
string dismissedBy,
string? reason,
CancellationToken cancellationToken);
/// <summary>
/// Expires old entries that have exceeded their TTL.
/// </summary>
Task<int> ExpireOldEntriesAsync(
string tenantId,
CancellationToken cancellationToken);
/// <summary>
/// Gets summary statistics for the grey queue.
/// </summary>
Task<GreyQueueSummary> GetSummaryAsync(
string tenantId,
CancellationToken cancellationToken);
/// <summary>
/// Counts entries by status.
/// </summary>
Task<IReadOnlyDictionary<GreyQueueStatus, long>> CountByStatusAsync(
string tenantId,
CancellationToken cancellationToken);
/// <summary>
/// Counts entries by reason.
/// </summary>
Task<IReadOnlyDictionary<GreyQueueReason, long>> CountByReasonAsync(
string tenantId,
CancellationToken cancellationToken);
/// <summary>
/// Gets the total count of pending entries.
/// </summary>
Task<long> CountPendingAsync(
string tenantId,
CancellationToken cancellationToken);
}
/// <summary>
/// Evidence bundle for grey queue entries.
/// </summary>
public sealed record GreyQueueEvidenceBundle
{
/// <summary>SBOM slice JSON.</summary>
public string? SbomSliceJson { get; init; }
/// <summary>Advisory snippet JSON.</summary>
public string? AdvisorySnippetJson { get; init; }
/// <summary>VEX evidence JSON.</summary>
public string? VexEvidenceJson { get; init; }
/// <summary>Diff traces JSON.</summary>
public string? DiffTracesJson { get; init; }
/// <summary>Reachability evidence JSON.</summary>
public string? ReachabilityEvidenceJson { get; init; }
}
/// <summary>
/// Trigger conditions for reprocessing.
/// </summary>
public sealed record GreyQueueTriggers
{
/// <summary>Feed IDs/versions that should trigger reprocessing.</summary>
public IReadOnlyList<FeedTrigger> Feeds { get; init; } = [];
/// <summary>Tool IDs/versions that should trigger reprocessing.</summary>
public IReadOnlyList<ToolTrigger> Tools { get; init; } = [];
/// <summary>CVE IDs that should trigger reprocessing.</summary>
public IReadOnlyList<string> CveIds { get; init; } = [];
/// <summary>PURL patterns that should trigger reprocessing.</summary>
public IReadOnlyList<string> PurlPatterns { get; init; } = [];
}
/// <summary>
/// Feed trigger specification.
/// </summary>
public sealed record FeedTrigger(string FeedId, string? MinVersion);
/// <summary>
/// Tool trigger specification.
/// </summary>
public sealed record ToolTrigger(string ToolId, string? MinVersion);
/// <summary>
/// Summary statistics for the grey queue.
/// </summary>
public sealed record GreyQueueSummary
{
/// <summary>Total entries in the queue.</summary>
public required long Total { get; init; }
/// <summary>Pending entries awaiting processing.</summary>
public required long Pending { get; init; }
/// <summary>Entries currently being processed.</summary>
public required long Processing { get; init; }
/// <summary>Entries waiting for retry.</summary>
public required long Retrying { get; init; }
/// <summary>Successfully resolved entries.</summary>
public required long Resolved { get; init; }
/// <summary>Failed entries (exhausted retries).</summary>
public required long Failed { get; init; }
/// <summary>Expired entries.</summary>
public required long Expired { get; init; }
/// <summary>Dismissed entries.</summary>
public required long Dismissed { get; init; }
/// <summary>Entries by reason breakdown.</summary>
public required IReadOnlyDictionary<string, long> ByReason { get; init; }
/// <summary>Average processing attempts for resolved entries.</summary>
public double AvgAttemptsToResolve { get; init; }
/// <summary>Average time to resolution in hours.</summary>
public double AvgHoursToResolve { get; init; }
/// <summary>Oldest pending entry age in hours.</summary>
public double OldestPendingHours { get; init; }
}

View File

@@ -0,0 +1,240 @@
// -----------------------------------------------------------------------------
// GreyQueueEntryTests.cs
// Description: Unit tests for Grey Queue entry model.
// -----------------------------------------------------------------------------
using System.Text.Json;
using FluentAssertions;
using StellaOps.Unknowns.Core.Models;
using Xunit;
namespace StellaOps.Unknowns.Core.Tests.Models;
[Trait("Category", "Unit")]
[Trait("Category", "GreyQueue")]
public sealed class GreyQueueEntryTests
{
[Fact]
public void IsPending_WhenStatusPending_ReturnsTrue()
{
// Arrange
var entry = CreateEntry(GreyQueueStatus.Pending);
// Act & Assert
entry.IsPending.Should().BeTrue();
}
[Fact]
public void IsPending_WhenStatusRetrying_ReturnsTrue()
{
// Arrange
var entry = CreateEntry(GreyQueueStatus.Retrying);
// Act & Assert
entry.IsPending.Should().BeTrue();
}
[Theory]
[InlineData(GreyQueueStatus.Processing)]
[InlineData(GreyQueueStatus.Resolved)]
[InlineData(GreyQueueStatus.Failed)]
[InlineData(GreyQueueStatus.Expired)]
[InlineData(GreyQueueStatus.Dismissed)]
public void IsPending_WhenStatusNotPendingOrRetrying_ReturnsFalse(GreyQueueStatus status)
{
// Arrange
var entry = CreateEntry(status);
// Act & Assert
entry.IsPending.Should().BeFalse();
}
[Fact]
public void IsExhausted_WhenAttemptsEqualMax_ReturnsTrue()
{
// Arrange
var entry = CreateEntry(GreyQueueStatus.Pending) with
{
ProcessingAttempts = 10,
MaxAttempts = 10
};
// Act & Assert
entry.IsExhausted.Should().BeTrue();
}
[Fact]
public void IsExhausted_WhenAttemptsExceedMax_ReturnsTrue()
{
// Arrange
var entry = CreateEntry(GreyQueueStatus.Pending) with
{
ProcessingAttempts = 15,
MaxAttempts = 10
};
// Act & Assert
entry.IsExhausted.Should().BeTrue();
}
[Fact]
public void IsExhausted_WhenAttemptsBelowMax_ReturnsFalse()
{
// Arrange
var entry = CreateEntry(GreyQueueStatus.Pending) with
{
ProcessingAttempts = 5,
MaxAttempts = 10
};
// Act & Assert
entry.IsExhausted.Should().BeFalse();
}
[Fact]
public void IsReadyForProcessing_WhenPendingNotExhaustedAndPastTime_ReturnsTrue()
{
// Arrange
var entry = CreateEntry(GreyQueueStatus.Pending) with
{
ProcessingAttempts = 3,
MaxAttempts = 10,
NextProcessingAt = DateTimeOffset.UtcNow.AddHours(-1)
};
// Act & Assert
entry.IsReadyForProcessing.Should().BeTrue();
}
[Fact]
public void IsReadyForProcessing_WhenPendingNotExhaustedAndNullTime_ReturnsTrue()
{
// Arrange
var entry = CreateEntry(GreyQueueStatus.Pending) with
{
ProcessingAttempts = 3,
MaxAttempts = 10,
NextProcessingAt = null
};
// Act & Assert
entry.IsReadyForProcessing.Should().BeTrue();
}
[Fact]
public void IsReadyForProcessing_WhenFutureNextProcessingTime_ReturnsFalse()
{
// Arrange
var entry = CreateEntry(GreyQueueStatus.Pending) with
{
ProcessingAttempts = 3,
MaxAttempts = 10,
NextProcessingAt = DateTimeOffset.UtcNow.AddHours(1)
};
// Act & Assert
entry.IsReadyForProcessing.Should().BeFalse();
}
[Fact]
public void IsReadyForProcessing_WhenExhausted_ReturnsFalse()
{
// Arrange
var entry = CreateEntry(GreyQueueStatus.Pending) with
{
ProcessingAttempts = 10,
MaxAttempts = 10,
NextProcessingAt = DateTimeOffset.UtcNow.AddHours(-1)
};
// Act & Assert
entry.IsReadyForProcessing.Should().BeFalse();
}
[Fact]
public void IsReadyForProcessing_WhenNotPending_ReturnsFalse()
{
// Arrange
var entry = CreateEntry(GreyQueueStatus.Resolved) with
{
ProcessingAttempts = 3,
MaxAttempts = 10,
NextProcessingAt = DateTimeOffset.UtcNow.AddHours(-1)
};
// Act & Assert
entry.IsReadyForProcessing.Should().BeFalse();
}
[Fact]
public void CanSerializeToJson_WithAllFields()
{
// Arrange
var entry = CreateEntry(GreyQueueStatus.Pending) with
{
ReasonDetail = "Insufficient VEX coverage from vendors",
TriggerOnCveUpdate = ["CVE-2025-12345", "CVE-2025-12346"],
TriggerOnPurlMatch = ["pkg:npm/*"]
};
// Act
var json = JsonSerializer.Serialize(entry);
var deserialized = JsonSerializer.Deserialize<GreyQueueEntry>(json);
// Assert
deserialized.Should().NotBeNull();
deserialized!.Id.Should().Be(entry.Id);
deserialized.Reason.Should().Be(entry.Reason);
deserialized.TriggerOnCveUpdate.Should().BeEquivalentTo(entry.TriggerOnCveUpdate);
}
[Theory]
[InlineData(GreyQueueReason.InsufficientVex)]
[InlineData(GreyQueueReason.ConflictingVex)]
[InlineData(GreyQueueReason.MissingReachability)]
[InlineData(GreyQueueReason.AmbiguousIdentity)]
[InlineData(GreyQueueReason.FeedNotAvailable)]
[InlineData(GreyQueueReason.ToolUnsupported)]
[InlineData(GreyQueueReason.BinaryAnalysisInconclusive)]
[InlineData(GreyQueueReason.BackportUncertain)]
[InlineData(GreyQueueReason.MultipleInterpretations)]
[InlineData(GreyQueueReason.AwaitingUpstreamAdvisory)]
public void AllGreyQueueReasons_AreValid(GreyQueueReason reason)
{
// Arrange & Act
var entry = CreateEntry(GreyQueueStatus.Pending) with { Reason = reason };
// Assert
entry.Reason.Should().Be(reason);
}
[Theory]
[InlineData(GreyQueueResolution.FeedUpdate)]
[InlineData(GreyQueueResolution.ToolUpdate)]
[InlineData(GreyQueueResolution.VexReceived)]
[InlineData(GreyQueueResolution.ManualResolution)]
[InlineData(GreyQueueResolution.Superseded)]
[InlineData(GreyQueueResolution.NotApplicable)]
[InlineData(GreyQueueResolution.Expired)]
public void AllGreyQueueResolutions_AreValid(GreyQueueResolution resolution)
{
// Arrange & Act
var entry = CreateEntry(GreyQueueStatus.Resolved) with { Resolution = resolution };
// Assert
entry.Resolution.Should().Be(resolution);
}
private static GreyQueueEntry CreateEntry(GreyQueueStatus status) => new()
{
Id = Guid.NewGuid(),
TenantId = "test-tenant",
UnknownId = Guid.NewGuid(),
Fingerprint = "sha256:abc123",
Status = status,
Priority = 100,
Reason = GreyQueueReason.InsufficientVex,
CreatedAt = DateTimeOffset.UtcNow,
CreatedBy = "test-user"
};
}