up
Some checks failed
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
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-03 00:10:19 +02:00
parent ea1d58a89b
commit 37cba83708
158 changed files with 147438 additions and 867 deletions

View File

@@ -0,0 +1,82 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.WebService.Contracts;
public sealed record LinksetSeverityDto
{
[JsonPropertyName("source")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Source { get; init; }
[JsonPropertyName("type")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Type { get; init; }
[JsonPropertyName("score")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? Score { get; init; }
[JsonPropertyName("vector")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Vector { get; init; }
[JsonPropertyName("origin")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Origin { get; init; }
[JsonPropertyName("labels")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyDictionary<string, string>? Labels { get; init; }
[JsonPropertyName("raw")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyDictionary<string, object?>? Raw { get; init; }
}
public sealed record LinksetConflictDto
{
[JsonPropertyName("field")]
public string Field { get; init; } = string.Empty;
[JsonPropertyName("reason")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Reason { get; init; }
[JsonPropertyName("values")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<string>? Values { get; init; }
[JsonPropertyName("sourceIds")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<string>? SourceIds { get; init; }
}
public sealed record LinksetSummaryDto
{
[JsonPropertyName("advisoryId")]
public string AdvisoryId { get; init; } = string.Empty;
[JsonPropertyName("source")]
public string Source { get; init; } = string.Empty;
[JsonPropertyName("confidence")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? Confidence { get; init; }
[JsonPropertyName("observationIds")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<string>? ObservationIds { get; init; }
[JsonPropertyName("references")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<string>? References { get; init; }
[JsonPropertyName("severities")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<LinksetSeverityDto>? Severities { get; init; }
[JsonPropertyName("conflicts")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<LinksetConflictDto>? Conflicts { get; init; }
}

View File

@@ -0,0 +1,27 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.WebService.Contracts;
public sealed record LinksetSummaryRequestDto
{
[JsonPropertyName("advisoryIds")]
public IReadOnlyList<string> AdvisoryIds { get; init; } = Array.Empty<string>();
[JsonPropertyName("imageDigest")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ImageDigest { get; init; }
[JsonPropertyName("includePolicyOverlay")]
public bool IncludePolicyOverlay { get; init; }
}
public sealed record LinksetSummaryResponseDto
{
[JsonPropertyName("linksets")]
public IReadOnlyList<LinksetSummaryDto> Linksets { get; init; } = Array.Empty<LinksetSummaryDto>();
[JsonPropertyName("policy")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public RuntimePolicyImageResponseDto? Policy { get; init; }
}

View File

@@ -62,7 +62,12 @@ public sealed record ReportDocumentDto
[JsonPropertyName("surface")]
[JsonPropertyOrder(8)]
public SurfacePointersDto? Surface { get; init; }
}
[JsonPropertyName("linksets")]
[JsonPropertyOrder(9)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<LinksetSummaryDto>? Linksets { get; init; }
}
public sealed record ReportPolicyDto
{

View File

@@ -66,14 +66,18 @@ public sealed record RuntimePolicyImageResponseDto
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? QuietedBy { get; init; }
[JsonPropertyName("metadata")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Metadata { get; init; }
[JsonPropertyName("buildIds")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<string>? BuildIds { get; init; }
}
[JsonPropertyName("metadata")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Metadata { get; init; }
[JsonPropertyName("buildIds")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<string>? BuildIds { get; init; }
[JsonPropertyName("linksets")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<LinksetSummaryDto>? Linksets { get; init; }
}
public sealed record RuntimePolicyRekorDto
{

View File

@@ -83,18 +83,30 @@ internal static class PolicyEndpoints
return operation;
});
policyGroup.MapPost("/overlay", HandlePolicyOverlayAsync)
.WithName("scanner.policy.overlay")
.Produces<PolicyOverlayResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.Reports)
.WithOpenApi(operation =>
{
operation.Summary = "Request policy overlays for graph nodes.";
operation.Description = "Returns deterministic policy overlays with runtime evidence for graph nodes (Cartographer integration). Overlay IDs are computed as sha256(tenant|nodeId|overlayKind).";
return operation;
});
}
policyGroup.MapPost("/overlay", HandlePolicyOverlayAsync)
.WithName("scanner.policy.overlay")
.Produces<PolicyOverlayResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.Reports)
.WithOpenApi(operation =>
{
operation.Summary = "Request policy overlays for graph nodes.";
operation.Description = "Returns deterministic policy overlays with runtime evidence for graph nodes (Cartographer integration). Overlay IDs are computed as sha256(tenant|nodeId|overlayKind).";
return operation;
});
policyGroup.MapPost("/linksets", HandleLinksetSummaryAsync)
.WithName("scanner.policy.linksets")
.Produces<LinksetSummaryResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.Reports)
.WithOpenApi(operation =>
{
operation.Summary = "Fetch advisory linkset summaries with optional policy overlay.";
operation.Description = "Returns linkset severities/conflicts for advisory IDs and, when requested, runtime policy overlay for the provided image digest.";
return operation;
});
}
private static IResult HandleSchemaAsync(HttpContext context)
{
@@ -188,11 +200,11 @@ internal static class PolicyEndpoints
return Json(payload);
}
private static async Task<IResult> HandleRuntimePolicyAsync(
RuntimePolicyRequestDto request,
IRuntimePolicyService runtimePolicyService,
HttpContext context,
CancellationToken cancellationToken)
private static async Task<IResult> HandleRuntimePolicyAsync(
RuntimePolicyRequestDto request,
IRuntimePolicyService runtimePolicyService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(runtimePolicyService);
@@ -273,8 +285,96 @@ internal static class PolicyEndpoints
var evaluation = await runtimePolicyService.EvaluateAsync(evaluationRequest, cancellationToken).ConfigureAwait(false);
var resultPayload = MapRuntimePolicyResponse(evaluation);
return Json(resultPayload);
}
return Json(resultPayload);
}
private static async Task<IResult> HandleLinksetSummaryAsync(
LinksetSummaryRequestDto request,
ILinksetResolver linksetResolver,
IRuntimePolicyService runtimePolicyService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(linksetResolver);
ArgumentNullException.ThrowIfNull(runtimePolicyService);
if (request.AdvisoryIds is null || request.AdvisoryIds.Count == 0)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid linkset request",
StatusCodes.Status400BadRequest,
detail: "advisoryIds must include at least one value.");
}
if (request.IncludePolicyOverlay && string.IsNullOrWhiteSpace(request.ImageDigest))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid linkset request",
StatusCodes.Status400BadRequest,
detail: "imageDigest is required when includePolicyOverlay is true.");
}
var linksets = await linksetResolver.ResolveByAdvisoryIdsAsync(request.AdvisoryIds, cancellationToken).ConfigureAwait(false);
RuntimePolicyImageResponseDto? policy = null;
if (request.IncludePolicyOverlay && !string.IsNullOrWhiteSpace(request.ImageDigest))
{
var runtimeRequest = new RuntimePolicyRequestDto
{
Images = new[] { request.ImageDigest!.Trim() }
};
var evaluation = await runtimePolicyService.EvaluateAsync(
new RuntimePolicyEvaluationRequest(
runtimeRequest.Namespace,
new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(StringComparer.Ordinal)),
runtimeRequest.Images),
cancellationToken).ConfigureAwait(false);
if (evaluation.Results.TryGetValue(request.ImageDigest!.Trim(), out var decision))
{
RuntimePolicyRekorDto? rekor = null;
if (decision.Rekor is not null)
{
rekor = new RuntimePolicyRekorDto
{
Uuid = decision.Rekor.Uuid,
Url = decision.Rekor.Url,
Verified = decision.Rekor.Verified
};
}
policy = new RuntimePolicyImageResponseDto
{
PolicyVerdict = decision.PolicyVerdict.ToString().ToLowerInvariant(),
Signed = decision.Signed,
HasSbomReferrers = decision.HasSbomReferrers,
HasSbomLegacy = decision.HasSbomReferrers,
Reasons = decision.Reasons,
Rekor = rekor,
Confidence = Math.Round(decision.Confidence, 6, MidpointRounding.AwayFromZero),
Quieted = decision.Quieted,
QuietedBy = decision.QuietedBy,
Metadata = decision.Metadata is { Count: > 0 } ? JsonSerializer.Serialize(decision.Metadata) : null,
BuildIds = decision.BuildIds,
Linksets = decision.Linksets
};
}
}
var response = new LinksetSummaryResponseDto
{
Linksets = linksets,
Policy = policy
};
return Json(response);
}
private static string NormalizeSegment(string segment)
{
@@ -322,15 +422,16 @@ internal static class PolicyEndpoints
Signed = decision.Signed,
HasSbomReferrers = decision.HasSbomReferrers,
HasSbomLegacy = decision.HasSbomReferrers,
Reasons = decision.Reasons.ToArray(),
Rekor = rekor,
Confidence = Math.Round(decision.Confidence, 6, MidpointRounding.AwayFromZero),
Quieted = decision.Quieted,
QuietedBy = decision.QuietedBy,
Metadata = metadata,
BuildIds = decision.BuildIds is { Count: > 0 } ? decision.BuildIds.ToArray() : null
};
}
Reasons = decision.Reasons.ToArray(),
Rekor = rekor,
Confidence = Math.Round(decision.Confidence, 6, MidpointRounding.AwayFromZero),
Quieted = decision.Quieted,
QuietedBy = decision.QuietedBy,
Metadata = metadata,
BuildIds = decision.BuildIds is { Count: > 0 } ? decision.BuildIds.ToArray() : null,
Linksets = decision.Linksets is { Count: > 0 } ? decision.Linksets.ToArray() : null
};
}
return new RuntimePolicyResponseDto
{

View File

@@ -57,6 +57,7 @@ internal static class ReportEndpoints
TimeProvider timeProvider,
IReportEventDispatcher eventDispatcher,
ISurfacePointerService surfacePointerService,
ILinksetResolver linksetResolver,
ILoggerFactory loggerFactory,
HttpContext context,
CancellationToken cancellationToken)
@@ -67,6 +68,7 @@ internal static class ReportEndpoints
ArgumentNullException.ThrowIfNull(timeProvider);
ArgumentNullException.ThrowIfNull(eventDispatcher);
ArgumentNullException.ThrowIfNull(surfacePointerService);
ArgumentNullException.ThrowIfNull(linksetResolver);
ArgumentNullException.ThrowIfNull(loggerFactory);
var logger = loggerFactory.CreateLogger("Scanner.WebService.Reports");
@@ -128,15 +130,16 @@ internal static class ReportEndpoints
extensions: extensions);
}
var projectedVerdicts = preview.Diffs
.Select(diff => PolicyDtoMapper.ToVerdictDto(diff.Projected))
.ToArray();
var issuesDto = preview.Issues.Select(PolicyDtoMapper.ToIssueDto).ToArray();
var projectedVerdicts = preview.Diffs
.Select(diff => PolicyDtoMapper.ToVerdictDto(diff.Projected))
.ToArray();
var issuesDto = preview.Issues.Select(PolicyDtoMapper.ToIssueDto).ToArray();
var summary = BuildSummary(projectedVerdicts);
var verdict = ComputeVerdict(projectedVerdicts);
var reportId = CreateReportId(request.ImageDigest!, preview.PolicyDigest);
var generatedAt = timeProvider.GetUtcNow();
var linksets = await linksetResolver.ResolveAsync(request.Findings, cancellationToken).ConfigureAwait(false);
SurfacePointersDto? surfacePointers = null;
try
@@ -171,7 +174,8 @@ internal static class ReportEndpoints
Summary = summary,
Verdicts = projectedVerdicts,
Issues = issuesDto,
Surface = surfacePointers
Surface = surfacePointers,
Linksets = linksets.Count == 0 ? null : linksets
};
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(document, SerializerOptions);

View File

@@ -0,0 +1,12 @@
namespace StellaOps.Scanner.WebService.Options;
public sealed class ConcelierLinksetOptions
{
public const string SectionName = "scanner:concelier";
public bool Enabled { get; set; }
public string? BaseUrl { get; set; }
public string? ApiKey { get; set; }
public string ApiKeyHeader { get; set; } = "Authorization";
public int TimeoutSeconds { get; set; } = 10;
}

View File

@@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using Serilog;
using Serilog.Events;
@@ -17,6 +18,7 @@ using StellaOps.Configuration;
using StellaOps.Plugin.DependencyInjection;
using StellaOps.Cryptography.DependencyInjection;
using StellaOps.Cryptography.Plugin.BouncyCastle;
using StellaOps.Concelier.Core.Linksets;
using StellaOps.Policy;
using StellaOps.Scanner.Cache;
using StellaOps.Scanner.Core.Contracts;
@@ -30,6 +32,7 @@ using StellaOps.Scanner.WebService.Endpoints;
using StellaOps.Scanner.WebService.Extensions;
using StellaOps.Scanner.WebService.Hosting;
using StellaOps.Scanner.WebService.Options;
using StellaOps.Scanner.WebService.Options;
using StellaOps.Scanner.WebService.Services;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Replay;
@@ -205,6 +208,7 @@ builder.Services.AddSingleton<IPostConfigureOptions<ScannerStorageOptions>, Scan
builder.Services.AddSingleton<RuntimeEventRateLimiter>();
builder.Services.AddSingleton<IRuntimeEventIngestionService, RuntimeEventIngestionService>();
builder.Services.AddSingleton<IRuntimeAttestationVerifier, RuntimeAttestationVerifier>();
builder.Services.AddSingleton<ILinksetResolver, LinksetResolver>();
builder.Services.AddSingleton<IRuntimePolicyService, RuntimePolicyService>();
var pluginHostOptions = ScannerPluginHostFactory.Build(bootstrapOptions, contentRoot);
@@ -429,3 +433,36 @@ internal sealed class SurfaceCacheOptionsConfigurator : IConfigureOptions<Surfac
options.RootDirectory = settings.CacheRoot.FullName;
}
}
builder.Services.Configure<ConcelierLinksetOptions>(builder.Configuration.GetSection(ConcelierLinksetOptions.SectionName));
builder.Services.AddHttpClient<ConcelierHttpLinksetQueryService>((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<ConcelierLinksetOptions>>().Value;
if (!string.IsNullOrWhiteSpace(options.BaseUrl))
{
client.BaseAddress = new Uri(options.BaseUrl);
}
client.Timeout = TimeSpan.FromSeconds(Math.Max(1, options.TimeoutSeconds));
if (!string.IsNullOrWhiteSpace(options.ApiKey))
{
var header = string.IsNullOrWhiteSpace(options.ApiKeyHeader) ? "Authorization" : options.ApiKeyHeader;
client.DefaultRequestHeaders.TryAddWithoutValidation(header, options.ApiKey);
}
})
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
AutomaticDecompression = System.Net.DecompressionMethods.All
});
builder.Services.AddSingleton<IAdvisoryLinksetQueryService>(sp =>
{
var options = sp.GetRequiredService<IOptions<ConcelierLinksetOptions>>().Value;
if (options.Enabled && !string.IsNullOrWhiteSpace(options.BaseUrl))
{
return sp.GetRequiredService<ConcelierHttpLinksetQueryService>();
}
return new NullAdvisoryLinksetQueryService();
});

View File

@@ -0,0 +1,171 @@
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Concelier.Core.Linksets;
using StellaOps.Scanner.WebService.Options;
using Microsoft.Extensions.Options;
namespace StellaOps.Scanner.WebService.Services;
internal sealed class ConcelierHttpLinksetQueryService : IAdvisoryLinksetQueryService
{
private readonly HttpClient _client;
private readonly ConcelierLinksetOptions _options;
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNameCaseInsensitive = true
};
public ConcelierHttpLinksetQueryService(HttpClient client, IOptions<ConcelierLinksetOptions> options)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
}
public async Task<AdvisoryLinksetQueryResult> QueryAsync(AdvisoryLinksetQueryOptions options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
cancellationToken.ThrowIfCancellationRequested();
if (options.AdvisoryIds is null)
{
return new AdvisoryLinksetQueryResult(ImmutableArray<AdvisoryLinkset>.Empty, null, false);
}
var results = ImmutableArray.CreateBuilder<AdvisoryLinkset>();
foreach (var advisoryId in options.AdvisoryIds)
{
if (string.IsNullOrWhiteSpace(advisoryId))
{
continue;
}
var path = $"/v1/lnm/linksets/{Uri.EscapeDataString(advisoryId)}?tenant={Uri.EscapeDataString(options.Tenant)}&includeConflicts=true&includeObservations=false&includeTimeline=false";
try
{
using var response = await _client.GetAsync(path, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
continue;
}
var payload = await response.Content.ReadFromJsonAsync<LinksetDetailResponse>(SerializerOptions, cancellationToken).ConfigureAwait(false);
if (payload?.Linksets is null || payload.Linksets.Length == 0)
{
continue;
}
foreach (var linkset in payload.Linksets)
{
results.Add(Map(linkset));
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
// swallow and continue; caller will see partial results
}
}
var linksets = results.ToImmutable();
return new AdvisoryLinksetQueryResult(linksets, null, false);
}
private static AdvisoryLinkset Map(LinksetDto dto)
{
var normalized = dto.Normalized is null
? null
: new AdvisoryLinksetNormalized(
dto.Normalized.Purls,
dto.Normalized.Cpes,
dto.Normalized.Versions,
dto.Normalized.Ranges,
dto.Normalized.Severities);
var conflicts = dto.Conflicts is null
? null
: dto.Conflicts.Select(c => new AdvisoryLinksetConflict(c.Field, c.Reason ?? string.Empty, c.Values, c.SourceIds)).ToList();
return new AdvisoryLinkset(
TenantId: dto.Tenant ?? string.Empty,
Source: dto.Source ?? string.Empty,
AdvisoryId: dto.AdvisoryId ?? string.Empty,
ObservationIds: dto.ObservationIds?.ToImmutableArray() ?? ImmutableArray<string>.Empty,
Normalized: normalized,
Provenance: null,
Confidence: dto.Confidence,
Conflicts: conflicts,
CreatedAt: dto.CreatedAt ?? DateTimeOffset.MinValue,
BuiltByJobId: dto.BuiltByJobId);
}
private sealed record LinksetDetailResponse([property: JsonPropertyName("linksets")] LinksetDto[] Linksets);
private sealed record LinksetDto
{
[JsonPropertyName("advisoryId")]
public string? AdvisoryId { get; init; }
[JsonPropertyName("source")]
public string? Source { get; init; }
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("confidence")]
public double? Confidence { get; init; }
[JsonPropertyName("createdAt")]
public DateTimeOffset? CreatedAt { get; init; }
[JsonPropertyName("builtByJobId")]
public string? BuiltByJobId { get; init; }
[JsonPropertyName("observationIds")]
public string[]? ObservationIds { get; init; }
[JsonPropertyName("normalized")]
public LinksetNormalizedDto? Normalized { get; init; }
[JsonPropertyName("conflicts")]
public LinksetConflictDto[]? Conflicts { get; init; }
}
private sealed record LinksetNormalizedDto
{
[JsonPropertyName("purls")]
public IReadOnlyList<string>? Purls { get; init; }
[JsonPropertyName("cpes")]
public IReadOnlyList<string>? Cpes { get; init; }
[JsonPropertyName("versions")]
public IReadOnlyList<string>? Versions { get; init; }
[JsonPropertyName("ranges")]
public IReadOnlyList<Dictionary<string, object?>>? Ranges { get; init; }
[JsonPropertyName("severities")]
public IReadOnlyList<Dictionary<string, object?>>? Severities { get; init; }
}
private sealed record LinksetConflictDto
{
[JsonPropertyName("field")]
public string Field { get; init; } = string.Empty;
[JsonPropertyName("reason")]
public string? Reason { get; init; }
[JsonPropertyName("values")]
public IReadOnlyList<string>? Values { get; init; }
[JsonPropertyName("sourceIds")]
public IReadOnlyList<string>? SourceIds { get; init; }
}
}

View File

@@ -0,0 +1,181 @@
using System.Collections.Immutable;
using System.Globalization;
using Microsoft.Extensions.Logging;
using StellaOps.Concelier.Core.Linksets;
using StellaOps.Scanner.Surface.Env;
using StellaOps.Scanner.WebService.Contracts;
namespace StellaOps.Scanner.WebService.Services;
internal interface ILinksetResolver
{
Task<IReadOnlyList<LinksetSummaryDto>> ResolveAsync(IEnumerable<PolicyPreviewFindingDto>? findings, CancellationToken cancellationToken);
Task<IReadOnlyList<LinksetSummaryDto>> ResolveAsync(IEnumerable<StellaOps.Policy.PolicyVerdict> verdicts, CancellationToken cancellationToken);
Task<IReadOnlyList<LinksetSummaryDto>> ResolveByAdvisoryIdsAsync(IEnumerable<string> advisoryIds, CancellationToken cancellationToken);
}
internal sealed class LinksetResolver : ILinksetResolver
{
private readonly IAdvisoryLinksetQueryService _queryService;
private readonly ISurfaceEnvironment _surfaceEnvironment;
private readonly ILogger<LinksetResolver> _logger;
public LinksetResolver(
IAdvisoryLinksetQueryService queryService,
ISurfaceEnvironment surfaceEnvironment,
ILogger<LinksetResolver> logger)
{
_queryService = queryService ?? throw new ArgumentNullException(nameof(queryService));
_surfaceEnvironment = surfaceEnvironment ?? throw new ArgumentNullException(nameof(surfaceEnvironment));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<IReadOnlyList<LinksetSummaryDto>> ResolveAsync(IEnumerable<PolicyPreviewFindingDto>? findings, CancellationToken cancellationToken)
{
var advisoryIds = findings?
.SelectMany(f => new[] { f?.Id, f?.Cve })
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id!.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray() ?? Array.Empty<string>();
return ResolveInternalAsync(advisoryIds, cancellationToken);
}
public Task<IReadOnlyList<LinksetSummaryDto>> ResolveAsync(IEnumerable<StellaOps.Policy.PolicyVerdict> verdicts, CancellationToken cancellationToken)
{
var advisoryIds = verdicts?
.Where(v => !string.IsNullOrWhiteSpace(v.FindingId))
.Select(v => v.FindingId!.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray() ?? Array.Empty<string>();
return ResolveInternalAsync(advisoryIds, cancellationToken);
}
public Task<IReadOnlyList<LinksetSummaryDto>> ResolveByAdvisoryIdsAsync(IEnumerable<string> advisoryIds, CancellationToken cancellationToken)
{
var normalized = advisoryIds?
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray() ?? Array.Empty<string>();
return ResolveInternalAsync(normalized, cancellationToken);
}
private async Task<IReadOnlyList<LinksetSummaryDto>> ResolveInternalAsync(IReadOnlyList<string> advisoryIds, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (advisoryIds.Count == 0)
{
return Array.Empty<LinksetSummaryDto>();
}
var tenant = string.IsNullOrWhiteSpace(_surfaceEnvironment.Settings.Tenant)
? "default"
: _surfaceEnvironment.Settings.Tenant.Trim();
try
{
var options = new AdvisoryLinksetQueryOptions(tenant, advisoryIds, Sources: null, Limit: advisoryIds.Count);
var result = await _queryService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
if (result.Linksets.IsDefaultOrEmpty)
{
return Array.Empty<LinksetSummaryDto>();
}
return result.Linksets
.Select(MapSummary)
.OrderBy(ls => ls.AdvisoryId, StringComparer.Ordinal)
.ToArray();
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
_logger.LogWarning(ex, "Failed to resolve linksets for {Count} advisories (tenant={Tenant}).", advisoryIds.Count, tenant);
return Array.Empty<LinksetSummaryDto>();
}
}
private static LinksetSummaryDto MapSummary(AdvisoryLinkset linkset)
{
var severities = linkset.Normalized?.Severities?.Select(MapSeverity).ToArray();
var conflicts = linkset.Conflicts?.Select(MapConflict).ToArray();
return new LinksetSummaryDto
{
AdvisoryId = linkset.AdvisoryId,
Source = linkset.Source,
Confidence = linkset.Confidence,
ObservationIds = linkset.ObservationIds.Length > 0 ? linkset.ObservationIds : null,
References = null,
Severities = severities?.Length > 0 ? severities : null,
Conflicts = conflicts?.Length > 0 ? conflicts : null
};
}
private static LinksetSeverityDto MapSeverity(Dictionary<string, object?> payload)
{
payload ??= new Dictionary<string, object?>(StringComparer.Ordinal);
string? GetString(string key)
=> payload.TryGetValue(key, out var value) ? value?.ToString() : null;
double? GetDouble(string key)
{
if (!payload.TryGetValue(key, out var value) || value is null)
{
return null;
}
if (value is double d)
{
return d;
}
if (value is float f)
{
return Convert.ToDouble(f, CultureInfo.InvariantCulture);
}
if (double.TryParse(value.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed))
{
return parsed;
}
return null;
}
var labels = payload.TryGetValue("labels", out var labelsValue) && labelsValue is Dictionary<string, object?> labelsDict
? labelsDict.ToDictionary(kv => kv.Key, kv => kv.Value?.ToString() ?? string.Empty, StringComparer.Ordinal)
: null;
var raw = payload.Count == 0
? null
: payload.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal);
return new LinksetSeverityDto
{
Source = GetString("source"),
Type = GetString("type"),
Score = GetDouble("score"),
Vector = GetString("vector"),
Origin = GetString("origin"),
Labels = labels,
Raw = raw
};
}
private static LinksetConflictDto MapConflict(AdvisoryLinksetConflict conflict)
{
return new LinksetConflictDto
{
Field = conflict.Field,
Reason = conflict.Reason,
Values = conflict.Values,
SourceIds = conflict.SourceIds
};
}
}

View File

@@ -0,0 +1,14 @@
using System.Collections.Immutable;
using StellaOps.Concelier.Core.Linksets;
namespace StellaOps.Scanner.WebService.Services;
internal sealed class NullAdvisoryLinksetQueryService : IAdvisoryLinksetQueryService
{
public Task<AdvisoryLinksetQueryResult> QueryAsync(AdvisoryLinksetQueryOptions options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(new AdvisoryLinksetQueryResult(ImmutableArray<AdvisoryLinkset>.Empty, null, false));
}
}

View File

@@ -7,13 +7,14 @@ using System.Linq;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy;
using StellaOps.Scanner.Storage.Catalog;
using StellaOps.Scanner.Storage.Repositories;
using StellaOps.Scanner.WebService.Options;
using StellaOps.Zastava.Core.Contracts;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy;
using StellaOps.Scanner.Storage.Catalog;
using StellaOps.Scanner.Storage.Repositories;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Options;
using StellaOps.Zastava.Core.Contracts;
using RuntimePolicyVerdict = StellaOps.Zastava.Core.Contracts.PolicyVerdict;
using CanonicalPolicyVerdict = StellaOps.Policy.PolicyVerdict;
using CanonicalPolicyVerdictStatus = StellaOps.Policy.PolicyVerdictStatus;
@@ -35,35 +36,38 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
private readonly LinkRepository _linkRepository;
private readonly ArtifactRepository _artifactRepository;
private readonly RuntimeEventRepository _runtimeEventRepository;
private readonly PolicySnapshotStore _policySnapshotStore;
private readonly PolicyPreviewService _policyPreviewService;
private readonly IOptionsMonitor<ScannerWebServiceOptions> _optionsMonitor;
private readonly TimeProvider _timeProvider;
private readonly IRuntimeAttestationVerifier _attestationVerifier;
private readonly ILogger<RuntimePolicyService> _logger;
private readonly RuntimeEventRepository _runtimeEventRepository;
private readonly PolicySnapshotStore _policySnapshotStore;
private readonly PolicyPreviewService _policyPreviewService;
private readonly ILinksetResolver _linksetResolver;
private readonly IOptionsMonitor<ScannerWebServiceOptions> _optionsMonitor;
private readonly TimeProvider _timeProvider;
private readonly IRuntimeAttestationVerifier _attestationVerifier;
private readonly ILogger<RuntimePolicyService> _logger;
public RuntimePolicyService(
LinkRepository linkRepository,
ArtifactRepository artifactRepository,
RuntimeEventRepository runtimeEventRepository,
PolicySnapshotStore policySnapshotStore,
PolicyPreviewService policyPreviewService,
IOptionsMonitor<ScannerWebServiceOptions> optionsMonitor,
TimeProvider timeProvider,
IRuntimeAttestationVerifier attestationVerifier,
ILogger<RuntimePolicyService> logger)
{
_linkRepository = linkRepository ?? throw new ArgumentNullException(nameof(linkRepository));
_artifactRepository = artifactRepository ?? throw new ArgumentNullException(nameof(artifactRepository));
_runtimeEventRepository = runtimeEventRepository ?? throw new ArgumentNullException(nameof(runtimeEventRepository));
_policySnapshotStore = policySnapshotStore ?? throw new ArgumentNullException(nameof(policySnapshotStore));
_policyPreviewService = policyPreviewService ?? throw new ArgumentNullException(nameof(policyPreviewService));
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_attestationVerifier = attestationVerifier ?? throw new ArgumentNullException(nameof(attestationVerifier));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
LinkRepository linkRepository,
ArtifactRepository artifactRepository,
RuntimeEventRepository runtimeEventRepository,
PolicySnapshotStore policySnapshotStore,
PolicyPreviewService policyPreviewService,
ILinksetResolver linksetResolver,
IOptionsMonitor<ScannerWebServiceOptions> optionsMonitor,
TimeProvider timeProvider,
IRuntimeAttestationVerifier attestationVerifier,
ILogger<RuntimePolicyService> logger)
{
_linkRepository = linkRepository ?? throw new ArgumentNullException(nameof(linkRepository));
_artifactRepository = artifactRepository ?? throw new ArgumentNullException(nameof(artifactRepository));
_runtimeEventRepository = runtimeEventRepository ?? throw new ArgumentNullException(nameof(runtimeEventRepository));
_policySnapshotStore = policySnapshotStore ?? throw new ArgumentNullException(nameof(policySnapshotStore));
_policyPreviewService = policyPreviewService ?? throw new ArgumentNullException(nameof(policyPreviewService));
_linksetResolver = linksetResolver ?? throw new ArgumentNullException(nameof(linksetResolver));
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_attestationVerifier = attestationVerifier ?? throw new ArgumentNullException(nameof(attestationVerifier));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<RuntimePolicyEvaluationResult> EvaluateAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken)
{
@@ -118,13 +122,14 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
heuristicReasons.Add("policy.snapshot.missing");
}
ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts = ImmutableArray<CanonicalPolicyVerdict>.Empty;
ImmutableArray<PolicyIssue> issues = ImmutableArray<PolicyIssue>.Empty;
try
{
if (!findings.IsDefaultOrEmpty && findings.Length > 0)
{
ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts = ImmutableArray<CanonicalPolicyVerdict>.Empty;
ImmutableArray<PolicyIssue> issues = ImmutableArray<PolicyIssue>.Empty;
IReadOnlyList<LinksetSummaryDto> linksets = Array.Empty<LinksetSummaryDto>();
try
{
if (!findings.IsDefaultOrEmpty && findings.Length > 0)
{
var previewRequest = new PolicyPreviewRequest(
image,
findings,
@@ -133,14 +138,15 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
ProposedPolicy: null);
var preview = await _policyPreviewService.PreviewAsync(previewRequest, cancellationToken).ConfigureAwait(false);
issues = preview.Issues;
if (!preview.Diffs.IsDefaultOrEmpty)
{
projectedVerdicts = preview.Diffs.Select(diff => diff.Projected).ToImmutableArray();
}
}
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
issues = preview.Issues;
if (!preview.Diffs.IsDefaultOrEmpty)
{
projectedVerdicts = preview.Diffs.Select(diff => diff.Projected).ToImmutableArray();
linksets = await _linksetResolver.ResolveAsync(projectedVerdicts, cancellationToken).ConfigureAwait(false);
}
}
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
_logger.LogWarning(ex, "Runtime policy preview failed for image {ImageDigest}; falling back to heuristic evaluation.", image);
}
@@ -151,12 +157,13 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
var decision = await BuildDecisionAsync(
image,
metadata,
heuristicReasons,
projectedVerdicts,
issues,
policyDigest,
buildIdObservation?.BuildIds,
cancellationToken).ConfigureAwait(false);
heuristicReasons,
projectedVerdicts,
issues,
policyDigest,
linksets,
buildIdObservation?.BuildIds,
cancellationToken).ConfigureAwait(false);
results[image] = decision;
@@ -279,12 +286,13 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
private async Task<RuntimePolicyImageDecision> BuildDecisionAsync(
string imageDigest,
RuntimeImageMetadata metadata,
List<string> heuristicReasons,
ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts,
ImmutableArray<PolicyIssue> issues,
string? policyDigest,
IReadOnlyList<string>? buildIds,
CancellationToken cancellationToken)
List<string> heuristicReasons,
ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts,
ImmutableArray<PolicyIssue> issues,
string? policyDigest,
IReadOnlyList<LinksetSummaryDto> linksets,
IReadOnlyList<string>? buildIds,
CancellationToken cancellationToken)
{
var reasons = new List<string>(heuristicReasons);
@@ -330,18 +338,19 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
.Distinct(StringComparer.Ordinal)
.ToArray();
return new RuntimePolicyImageDecision(
overallVerdict,
metadata.Signed,
metadata.HasSbomReferrers,
normalizedReasons,
rekor,
metadataPayload,
confidence,
quieted,
quietedBy,
buildIds);
}
return new RuntimePolicyImageDecision(
overallVerdict,
metadata.Signed,
metadata.HasSbomReferrers,
normalizedReasons,
rekor,
metadataPayload,
confidence,
quieted,
quietedBy,
buildIds,
linksets);
}
private RuntimePolicyVerdict MapVerdict(ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts, IReadOnlyList<string> heuristicReasons)
{
@@ -501,17 +510,18 @@ internal sealed record RuntimePolicyEvaluationResult(
string? PolicyRevision,
IReadOnlyDictionary<string, RuntimePolicyImageDecision> Results);
internal sealed record RuntimePolicyImageDecision(
RuntimePolicyVerdict PolicyVerdict,
bool Signed,
bool HasSbomReferrers,
IReadOnlyList<string> Reasons,
RuntimePolicyRekorReference? Rekor,
IDictionary<string, object?>? Metadata,
double Confidence,
bool Quieted,
string? QuietedBy,
IReadOnlyList<string>? BuildIds);
internal sealed record RuntimePolicyImageDecision(
RuntimePolicyVerdict PolicyVerdict,
bool Signed,
bool HasSbomReferrers,
IReadOnlyList<string> Reasons,
RuntimePolicyRekorReference? Rekor,
IDictionary<string, object?>? Metadata,
double Confidence,
bool Quieted,
string? QuietedBy,
IReadOnlyList<string>? BuildIds,
IReadOnlyList<LinksetSummaryDto> Linksets);
internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url, bool? Verified);

View File

@@ -36,5 +36,7 @@
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Core/StellaOps.Scanner.Core.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj" />
<ProjectReference Include="../../Zastava/__Libraries/StellaOps.Zastava.Core/StellaOps.Zastava.Core.csproj" />
<ProjectReference Include="../../Concelier/__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
<ProjectReference Include="../../Concelier/__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
</ItemGroup>
</Project>