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
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user