Add unit tests for PackRunAttestation and SealedInstallEnforcer
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
release-manifest-verify / verify (push) Has been cancelled

- Implement comprehensive tests for PackRunAttestationService, covering attestation generation, verification, and event emission.
- Add tests for SealedInstallEnforcer to validate sealed install requirements and enforcement logic.
- Introduce a MonacoLoaderService stub for testing purposes to prevent Monaco workers/styles from loading during Karma runs.
This commit is contained in:
StellaOps Bot
2025-12-06 22:25:30 +02:00
parent dd0067ea0b
commit 4042fc2184
110 changed files with 20084 additions and 639 deletions

View File

@@ -0,0 +1,303 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.RiskFeed;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Services;
namespace StellaOps.Excititor.WebService.Endpoints;
/// <summary>
/// Risk feed API endpoints (EXCITITOR-RISK-66-001).
/// Publishes risk-engine ready feeds with status, justification, and provenance
/// without derived severity (aggregation-only per AOC baseline).
/// </summary>
public static class RiskFeedEndpoints
{
public static void MapRiskFeedEndpoints(this WebApplication app)
{
var group = app.MapGroup("/risk/v1");
// POST /risk/v1/feed - Generate risk feed
group.MapPost("/feed", async (
HttpContext context,
IOptions<VexMongoStorageOptions> storageOptions,
[FromServices] IRiskFeedService riskFeedService,
[FromBody] RiskFeedRequestDto request,
CancellationToken cancellationToken) =>
{
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
{
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
{
return tenantError;
}
if (request is null)
{
return Results.BadRequest(new
{
error = new { code = "ERR_RISK_PARAMS", message = "Request body is required" }
});
}
var domainRequest = new RiskFeedRequest(
tenantId: tenant,
advisoryKeys: request.AdvisoryKeys,
artifacts: request.Artifacts,
since: request.Since,
limit: request.Limit ?? 1000);
var feedResponse = await riskFeedService
.GenerateFeedAsync(domainRequest, cancellationToken)
.ConfigureAwait(false);
var responseDto = MapToResponse(feedResponse);
return Results.Ok(responseDto);
}).WithName("GenerateRiskFeed");
// GET /risk/v1/feed/item - Get single risk feed item
group.MapGet("/feed/item", async (
HttpContext context,
IOptions<VexMongoStorageOptions> storageOptions,
[FromServices] IRiskFeedService riskFeedService,
[FromQuery] string? advisoryKey,
[FromQuery] string? artifact,
CancellationToken cancellationToken) =>
{
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
{
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
{
return tenantError;
}
if (string.IsNullOrWhiteSpace(advisoryKey) || string.IsNullOrWhiteSpace(artifact))
{
return Results.BadRequest(new
{
error = new { code = "ERR_RISK_PARAMS", message = "advisoryKey and artifact query parameters are required" }
});
}
var item = await riskFeedService
.GetItemAsync(tenant, advisoryKey, artifact, cancellationToken)
.ConfigureAwait(false);
if (item is null)
{
return Results.NotFound(new
{
error = new { code = "ERR_RISK_NOT_FOUND", message = "No risk feed item found for the specified advisory and artifact" }
});
}
var dto = MapToItemDto(item);
return Results.Ok(dto);
}).WithName("GetRiskFeedItem");
// GET /risk/v1/feed/by-advisory - Get risk feed items by advisory key
group.MapGet("/feed/by-advisory/{advisoryKey}", async (
HttpContext context,
string advisoryKey,
IOptions<VexMongoStorageOptions> storageOptions,
[FromServices] IRiskFeedService riskFeedService,
[FromQuery] int? limit,
CancellationToken cancellationToken) =>
{
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
{
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
{
return tenantError;
}
if (string.IsNullOrWhiteSpace(advisoryKey))
{
return Results.BadRequest(new
{
error = new { code = "ERR_RISK_PARAMS", message = "advisoryKey is required" }
});
}
var request = new RiskFeedRequest(
tenantId: tenant,
advisoryKeys: [advisoryKey],
limit: limit ?? 100);
var feedResponse = await riskFeedService
.GenerateFeedAsync(request, cancellationToken)
.ConfigureAwait(false);
var responseDto = MapToResponse(feedResponse);
return Results.Ok(responseDto);
}).WithName("GetRiskFeedByAdvisory");
// GET /risk/v1/feed/by-artifact/{artifact} - Get risk feed items by artifact
group.MapGet("/feed/by-artifact/{**artifact}", async (
HttpContext context,
string artifact,
IOptions<VexMongoStorageOptions> storageOptions,
[FromServices] IRiskFeedService riskFeedService,
[FromQuery] int? limit,
CancellationToken cancellationToken) =>
{
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
{
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
{
return tenantError;
}
if (string.IsNullOrWhiteSpace(artifact))
{
return Results.BadRequest(new
{
error = new { code = "ERR_RISK_PARAMS", message = "artifact is required" }
});
}
var request = new RiskFeedRequest(
tenantId: tenant,
artifacts: [artifact],
limit: limit ?? 100);
var feedResponse = await riskFeedService
.GenerateFeedAsync(request, cancellationToken)
.ConfigureAwait(false);
var responseDto = MapToResponse(feedResponse);
return Results.Ok(responseDto);
}).WithName("GetRiskFeedByArtifact");
}
private static RiskFeedResponseDto MapToResponse(RiskFeedResponse response)
{
var items = response.Items
.Select(MapToItemDto)
.ToList();
return new RiskFeedResponseDto(
Items: items,
GeneratedAt: response.GeneratedAt,
NextPageToken: response.NextPageToken);
}
private static RiskFeedItemDto MapToItemDto(RiskFeedItem item)
{
var provenance = new RiskFeedProvenanceDto(
TenantId: item.Provenance.TenantId,
LinksetId: item.Provenance.LinksetId,
ContentHash: item.Provenance.ContentHash,
Confidence: item.Provenance.Confidence.ToString().ToLowerInvariant(),
HasConflicts: item.Provenance.HasConflicts,
GeneratedAt: item.Provenance.GeneratedAt,
AttestationId: item.Provenance.AttestationId);
var sources = item.Sources
.Select(s => new RiskFeedSourceDto(
ObservationId: s.ObservationId,
ProviderId: s.ProviderId,
Status: s.Status,
Justification: s.Justification,
Confidence: s.Confidence))
.ToList();
return new RiskFeedItemDto(
AdvisoryKey: item.AdvisoryKey,
Artifact: item.Artifact,
Status: item.Status.ToString().ToLowerInvariant(),
Justification: item.Justification?.ToString().ToLowerInvariant(),
Provenance: provenance,
ObservedAt: item.ObservedAt,
Sources: sources);
}
private static bool TryResolveTenant(
HttpContext context,
VexMongoStorageOptions options,
out string tenant,
out IResult? problem)
{
problem = null;
tenant = string.Empty;
var headerTenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(headerTenant))
{
tenant = headerTenant.Trim().ToLowerInvariant();
}
else if (!string.IsNullOrWhiteSpace(options.DefaultTenant))
{
tenant = options.DefaultTenant.Trim().ToLowerInvariant();
}
else
{
problem = Results.BadRequest(new
{
error = new { code = "ERR_TENANT", message = "X-Stella-Tenant header is required" }
});
return false;
}
return true;
}
}
// Request DTO
public sealed record RiskFeedRequestDto(
[property: JsonPropertyName("advisoryKeys")] IEnumerable<string>? AdvisoryKeys,
[property: JsonPropertyName("artifacts")] IEnumerable<string>? Artifacts,
[property: JsonPropertyName("since")] DateTimeOffset? Since,
[property: JsonPropertyName("limit")] int? Limit);
// Response DTOs
public sealed record RiskFeedResponseDto(
[property: JsonPropertyName("items")] IReadOnlyList<RiskFeedItemDto> Items,
[property: JsonPropertyName("generatedAt")] DateTimeOffset GeneratedAt,
[property: JsonPropertyName("nextPageToken")] string? NextPageToken);
public sealed record RiskFeedItemDto(
[property: JsonPropertyName("advisoryKey")] string AdvisoryKey,
[property: JsonPropertyName("artifact")] string Artifact,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("justification")] string? Justification,
[property: JsonPropertyName("provenance")] RiskFeedProvenanceDto Provenance,
[property: JsonPropertyName("observedAt")] DateTimeOffset ObservedAt,
[property: JsonPropertyName("sources")] IReadOnlyList<RiskFeedSourceDto> Sources);
public sealed record RiskFeedProvenanceDto(
[property: JsonPropertyName("tenantId")] string TenantId,
[property: JsonPropertyName("linksetId")] string LinksetId,
[property: JsonPropertyName("contentHash")] string ContentHash,
[property: JsonPropertyName("confidence")] string Confidence,
[property: JsonPropertyName("hasConflicts")] bool HasConflicts,
[property: JsonPropertyName("generatedAt")] DateTimeOffset GeneratedAt,
[property: JsonPropertyName("attestationId")] string? AttestationId);
public sealed record RiskFeedSourceDto(
[property: JsonPropertyName("observationId")] string ObservationId,
[property: JsonPropertyName("providerId")] string ProviderId,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("justification")] string? Justification,
[property: JsonPropertyName("confidence")] double? Confidence);

View File

@@ -90,6 +90,9 @@ services.AddSingleton<IVexHashingService>(sp =>
services.AddSingleton<IVexObservationProjectionService, VexObservationProjectionService>();
services.AddScoped<IVexObservationQueryService, VexObservationQueryService>();
// EXCITITOR-RISK-66-001: Risk feed service for Risk Engine integration
services.AddScoped<StellaOps.Excititor.Core.RiskFeed.IRiskFeedService, StellaOps.Excititor.Core.RiskFeed.RiskFeedService>();
var rekorSection = configuration.GetSection("Excititor:Attestation:Rekor");
if (rekorSection.Exists())
{
@@ -2323,6 +2326,9 @@ PolicyEndpoints.MapPolicyEndpoints(app);
ObservationEndpoints.MapObservationEndpoints(app);
LinksetEndpoints.MapLinksetEndpoints(app);
// Risk Feed APIs (EXCITITOR-RISK-66-001)
RiskFeedEndpoints.MapRiskFeedEndpoints(app);
app.Run();
internal sealed record ExcititorTimelineEvent(

View File

@@ -0,0 +1,54 @@
namespace StellaOps.Excititor.Core.RiskFeed;
/// <summary>
/// Service for generating risk-engine ready feeds from VEX linksets.
/// Produces status/justification/provenance without derived severity (aggregation-only).
/// </summary>
public interface IRiskFeedService
{
/// <summary>
/// Generates risk feed items from linksets matching the request criteria.
/// </summary>
/// <param name="request">Filter criteria for the feed.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Risk feed response with items and pagination info.</returns>
Task<RiskFeedResponse> GenerateFeedAsync(RiskFeedRequest request, CancellationToken cancellationToken);
/// <summary>
/// Gets a single risk feed item for a specific advisory/artifact pair.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="advisoryKey">Advisory/CVE identifier.</param>
/// <param name="artifact">Package URL or product key.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Risk feed item if found.</returns>
Task<RiskFeedItem?> GetItemAsync(
string tenantId,
string advisoryKey,
string artifact,
CancellationToken cancellationToken);
}
/// <summary>
/// Null implementation of <see cref="IRiskFeedService"/> for testing and fallback.
/// </summary>
public sealed class NullRiskFeedService : IRiskFeedService
{
public static readonly NullRiskFeedService Instance = new();
public Task<RiskFeedResponse> GenerateFeedAsync(RiskFeedRequest request, CancellationToken cancellationToken)
{
return Task.FromResult(new RiskFeedResponse(
Enumerable.Empty<RiskFeedItem>(),
DateTimeOffset.UtcNow));
}
public Task<RiskFeedItem?> GetItemAsync(
string tenantId,
string advisoryKey,
string artifact,
CancellationToken cancellationToken)
{
return Task.FromResult<RiskFeedItem?>(null);
}
}

View File

@@ -0,0 +1,312 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.Excititor.Core.Observations;
namespace StellaOps.Excititor.Core.RiskFeed;
/// <summary>
/// Generates risk-engine ready feeds from VEX linksets.
/// Produces status/justification/provenance without derived severity (aggregation-only per AOC baseline).
/// </summary>
public sealed class RiskFeedService : IRiskFeedService
{
private readonly IVexLinksetStore _linksetStore;
public RiskFeedService(IVexLinksetStore linksetStore)
{
_linksetStore = linksetStore ?? throw new ArgumentNullException(nameof(linksetStore));
}
public async Task<RiskFeedResponse> GenerateFeedAsync(
RiskFeedRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var feedItems = new List<RiskFeedItem>();
var generatedAt = DateTimeOffset.UtcNow;
// If specific advisory keys are requested, query by vulnerability
if (!request.AdvisoryKeys.IsDefaultOrEmpty)
{
foreach (var advisoryKey in request.AdvisoryKeys)
{
if (feedItems.Count >= request.Limit)
{
break;
}
var linksets = await _linksetStore.FindByVulnerabilityAsync(
request.TenantId,
advisoryKey,
request.Limit - feedItems.Count,
cancellationToken);
var items = linksets
.Where(ls => PassesSinceFilter(ls, request.Since))
.Select(ls => BuildFeedItem(ls, generatedAt))
.Where(item => item is not null)
.Cast<RiskFeedItem>();
feedItems.AddRange(items);
}
}
// If specific artifacts are requested, query by product key
else if (!request.Artifacts.IsDefaultOrEmpty)
{
foreach (var artifact in request.Artifacts)
{
if (feedItems.Count >= request.Limit)
{
break;
}
var linksets = await _linksetStore.FindByProductKeyAsync(
request.TenantId,
artifact,
request.Limit - feedItems.Count,
cancellationToken);
var items = linksets
.Where(ls => PassesSinceFilter(ls, request.Since))
.Select(ls => BuildFeedItem(ls, generatedAt))
.Where(item => item is not null)
.Cast<RiskFeedItem>();
feedItems.AddRange(items);
}
}
// Otherwise query linksets with conflicts (high-value for risk assessment)
else
{
var linksets = await _linksetStore.FindWithConflictsAsync(
request.TenantId,
request.Limit,
cancellationToken);
var items = linksets
.Where(ls => PassesSinceFilter(ls, request.Since))
.Select(ls => BuildFeedItem(ls, generatedAt))
.Where(item => item is not null)
.Cast<RiskFeedItem>();
feedItems.AddRange(items);
}
// Sort for deterministic output
var sortedItems = feedItems
.OrderBy(item => item.AdvisoryKey, StringComparer.OrdinalIgnoreCase)
.ThenBy(item => item.Artifact, StringComparer.Ordinal)
.Take(request.Limit)
.ToImmutableArray();
return new RiskFeedResponse(sortedItems, generatedAt);
}
public async Task<RiskFeedItem?> GetItemAsync(
string tenantId,
string advisoryKey,
string artifact,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
throw new ArgumentException("Tenant ID must be provided.", nameof(tenantId));
}
if (string.IsNullOrWhiteSpace(advisoryKey))
{
throw new ArgumentException("Advisory key must be provided.", nameof(advisoryKey));
}
if (string.IsNullOrWhiteSpace(artifact))
{
throw new ArgumentException("Artifact must be provided.", nameof(artifact));
}
var normalizedTenant = tenantId.Trim().ToLowerInvariant();
var linksetId = VexLinkset.CreateLinksetId(normalizedTenant, advisoryKey.Trim(), artifact.Trim());
var linkset = await _linksetStore.GetByIdAsync(
normalizedTenant,
linksetId,
cancellationToken);
if (linkset is null)
{
return null;
}
return BuildFeedItem(linkset, DateTimeOffset.UtcNow);
}
private static bool PassesSinceFilter(VexLinkset linkset, DateTimeOffset? since)
{
if (!since.HasValue)
{
return true;
}
return linkset.UpdatedAt >= since.Value;
}
private RiskFeedItem? BuildFeedItem(VexLinkset linkset, DateTimeOffset generatedAt)
{
if (linkset.Observations.IsDefaultOrEmpty)
{
return null;
}
// Get the dominant status from observations (most common status)
var statusGroups = linkset.Observations
.GroupBy(obs => obs.Status, StringComparer.OrdinalIgnoreCase)
.OrderByDescending(g => g.Count())
.ThenBy(g => g.Key, StringComparer.OrdinalIgnoreCase)
.ToList();
if (statusGroups.Count == 0)
{
return null;
}
var dominantStatusStr = statusGroups[0].Key;
if (!TryParseStatus(dominantStatusStr, out var status))
{
// Unknown status - skip this linkset
return null;
}
// Try to get justification from disagreements or observation references
VexJustification? justification = null;
foreach (var disagreement in linkset.Disagreements)
{
if (TryParseJustification(disagreement.Justification, out var parsed))
{
justification = parsed;
break;
}
}
// Build provenance
var contentHash = ComputeContentHash(linkset);
var provenance = new RiskFeedProvenance(
tenantId: linkset.Tenant,
linksetId: linkset.LinksetId,
contentHash: contentHash,
confidence: linkset.Confidence,
hasConflicts: linkset.HasConflicts,
generatedAt: generatedAt);
// Build source references
var sources = linkset.Observations
.Select(obs => new RiskFeedObservationSource(
observationId: obs.ObservationId,
providerId: obs.ProviderId,
status: obs.Status,
justification: null,
confidence: obs.Confidence))
.ToImmutableArray();
return new RiskFeedItem(
advisoryKey: linkset.VulnerabilityId,
artifact: linkset.ProductKey,
status: status,
justification: justification,
provenance: provenance,
observedAt: linkset.UpdatedAt,
sources: sources);
}
private static string ComputeContentHash(VexLinkset linkset)
{
var canonical = new
{
linkset.LinksetId,
linkset.Tenant,
linkset.VulnerabilityId,
linkset.ProductKey,
Observations = linkset.Observations
.Select(o => new { o.ObservationId, o.ProviderId, o.Status, o.Confidence })
.OrderBy(o => o.ProviderId)
.ThenBy(o => o.ObservationId)
.ToArray(),
linkset.UpdatedAt
};
var json = JsonSerializer.Serialize(canonical, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
});
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static bool TryParseStatus(string? statusStr, out VexClaimStatus status)
{
status = VexClaimStatus.Affected;
if (string.IsNullOrWhiteSpace(statusStr))
{
return false;
}
var normalized = statusStr.Trim().ToLowerInvariant().Replace("_", "");
return normalized switch
{
"affected" => AssignStatus(VexClaimStatus.Affected, out status),
"notaffected" => AssignStatus(VexClaimStatus.NotAffected, out status),
"fixed" => AssignStatus(VexClaimStatus.Fixed, out status),
"underinvestigation" => AssignStatus(VexClaimStatus.UnderInvestigation, out status),
_ => false
};
}
private static bool AssignStatus(VexClaimStatus value, out VexClaimStatus status)
{
status = value;
return true;
}
private static bool TryParseJustification(string? justificationStr, out VexJustification justification)
{
justification = VexJustification.ComponentNotPresent;
if (string.IsNullOrWhiteSpace(justificationStr))
{
return false;
}
var normalized = justificationStr.Trim().ToLowerInvariant().Replace("_", "");
return normalized switch
{
"componentnotpresent" => AssignJustification(VexJustification.ComponentNotPresent, out justification),
"componentnotconfigured" => AssignJustification(VexJustification.ComponentNotConfigured, out justification),
"vulnerablecodenotpresent" => AssignJustification(VexJustification.VulnerableCodeNotPresent, out justification),
"vulnerablecodenotinexecutepath" => AssignJustification(VexJustification.VulnerableCodeNotInExecutePath, out justification),
"vulnerablecodecannotbecontrolledbyadversary" => AssignJustification(VexJustification.VulnerableCodeCannotBeControlledByAdversary, out justification),
"inlinemitigationsalreadyexist" => AssignJustification(VexJustification.InlineMitigationsAlreadyExist, out justification),
"protectedbymitigatingcontrol" => AssignJustification(VexJustification.ProtectedByMitigatingControl, out justification),
"codenotpresent" => AssignJustification(VexJustification.CodeNotPresent, out justification),
"codenotreachable" => AssignJustification(VexJustification.CodeNotReachable, out justification),
"requiresconfiguration" => AssignJustification(VexJustification.RequiresConfiguration, out justification),
"requiresdependency" => AssignJustification(VexJustification.RequiresDependency, out justification),
"requiresenvironment" => AssignJustification(VexJustification.RequiresEnvironment, out justification),
"protectedbycompensatingcontrol" => AssignJustification(VexJustification.ProtectedByCompensatingControl, out justification),
"protectedatperimeter" => AssignJustification(VexJustification.ProtectedAtPerimeter, out justification),
"protectedatruntime" => AssignJustification(VexJustification.ProtectedAtRuntime, out justification),
_ => false
};
}
private static bool AssignJustification(VexJustification value, out VexJustification justification)
{
justification = value;
return true;
}
}