Implement ledger metrics for observability and add tests for Ruby packages endpoints
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Added `LedgerMetrics` class to record write latency and total events for ledger operations.
- Created comprehensive tests for Ruby packages endpoints, covering scenarios for missing inventory, successful retrieval, and identifier handling.
- Introduced `TestSurfaceSecretsScope` for managing environment variables during tests.
- Developed `ProvenanceMongoExtensions` for attaching DSSE provenance and trust information to event documents.
- Implemented `EventProvenanceWriter` and `EventWriter` classes for managing event provenance in MongoDB.
- Established MongoDB indexes for efficient querying of events based on provenance and trust.
- Added models and JSON parsing logic for DSSE provenance and trust information.
This commit is contained in:
master
2025-11-13 09:29:09 +02:00
parent 151f6b35cc
commit 61f963fd52
101 changed files with 5881 additions and 1776 deletions

View File

@@ -1,26 +1,72 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Concelier.WebService.Contracts;
public sealed record AdvisoryChunkCollectionResponse(
public sealed record AdvisoryStructuredFieldResponse(
string AdvisoryKey,
int Total,
bool Truncated,
IReadOnlyList<AdvisoryChunkItemResponse> Chunks,
IReadOnlyList<AdvisoryChunkSourceResponse> Sources);
IReadOnlyList<AdvisoryStructuredFieldEntry> Entries);
public sealed record AdvisoryChunkItemResponse(
public sealed record AdvisoryStructuredFieldEntry(
string Type,
string DocumentId,
string FieldPath,
string ChunkId,
string Section,
string ParagraphId,
string Text,
IReadOnlyDictionary<string, string> Metadata);
AdvisoryStructuredFieldContent Content,
AdvisoryStructuredFieldProvenance Provenance);
public sealed record AdvisoryChunkSourceResponse(
string ObservationId,
string DocumentId,
string Format,
string Vendor,
string ContentHash,
DateTimeOffset CreatedAt);
public sealed record AdvisoryStructuredFieldContent
{
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Title { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Description { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Url { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Note { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public AdvisoryStructuredFixContent? Fix { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public AdvisoryStructuredCvssContent? Cvss { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public AdvisoryStructuredAffectedContent? Affected { get; init; }
}
public sealed record AdvisoryStructuredFixContent(
string? PackageType,
string? PackageIdentifier,
string? FixedVersion,
string? ReferenceUrl);
public sealed record AdvisoryStructuredCvssContent(
string Version,
string Vector,
double BaseScore,
string Severity);
public sealed record AdvisoryStructuredAffectedContent(
string PackageType,
string PackageIdentifier,
string? Platform,
string RangeKind,
string? IntroducedVersion,
string? FixedVersion,
string? LastAffectedVersion,
string? RangeExpression,
string? Status);
public sealed record AdvisoryStructuredFieldProvenance(
string Source,
string Kind,
string? Value,
DateTimeOffset RecordedAt,
IReadOnlyList<string> FieldMask);

View File

@@ -24,9 +24,9 @@ using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Concelier.Core.Events;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.Core.Observations;
using StellaOps.Concelier.Core.Linksets;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.WebService.Diagnostics;
using Serilog;
using StellaOps.Concelier.Merge;
@@ -50,6 +50,10 @@ using StellaOps.Concelier.WebService.Contracts;
using StellaOps.Concelier.Core.Aoc;
using StellaOps.Concelier.Core.Raw;
using StellaOps.Concelier.RawModels;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.Storage.Mongo.Advisories;
using StellaOps.Concelier.Storage.Mongo.Aliases;
using StellaOps.Provenance.Mongo;
var builder = WebApplication.CreateBuilder(args);
@@ -812,6 +816,8 @@ var advisoryChunksEndpoint = app.MapGet("/advisories/{advisoryKey}/chunks", asyn
[FromServices] IAdvisoryObservationQueryService observationService,
[FromServices] AdvisoryChunkBuilder chunkBuilder,
[FromServices] IAdvisoryChunkCache chunkCache,
[FromServices] IAdvisoryStore advisoryStore,
[FromServices] IAliasStore aliasStore,
[FromServices] IAdvisoryAiTelemetry telemetry,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) =>
@@ -854,21 +860,37 @@ var advisoryChunksEndpoint = app.MapGet("/advisories/{advisoryKey}/chunks", asyn
var sectionFilter = BuildFilterSet(context.Request.Query["section"]);
var formatFilter = BuildFilterSet(context.Request.Query["format"]);
var resolution = await ResolveAdvisoryAsync(
normalizedKey,
advisoryStore,
aliasStore,
cancellationToken).ConfigureAwait(false);
if (resolution is null)
{
telemetry.TrackChunkFailure(tenant, normalizedKey, "advisory_not_found", "not_found");
return Problem(context, "Advisory not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"No advisory found for {normalizedKey}.");
}
var (advisory, aliasList, fingerprint) = resolution.Value;
var aliasCandidates = aliasList.IsDefaultOrEmpty
? ImmutableArray.Create(advisory.AdvisoryKey)
: aliasList;
var queryOptions = new AdvisoryObservationQueryOptions(
tenant,
aliases: new[] { normalizedKey },
aliases: aliasCandidates,
limit: observationLimit);
var observationResult = await observationService.QueryAsync(queryOptions, cancellationToken).ConfigureAwait(false);
if (observationResult.Observations.IsDefaultOrEmpty || observationResult.Observations.Length == 0)
{
telemetry.TrackChunkFailure(tenant, normalizedKey, "advisory_not_found", "not_found");
return Problem(context, "Advisory not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"No observations available for {normalizedKey}.");
telemetry.TrackChunkFailure(tenant, advisory.AdvisoryKey, "advisory_not_found", "not_found");
return Problem(context, "Advisory not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"No observations available for {advisory.AdvisoryKey}.");
}
var observations = observationResult.Observations.ToArray();
var buildOptions = new AdvisoryChunkBuildOptions(
normalizedKey,
advisory.AdvisoryKey,
chunkLimit,
observationLimit,
sectionFilter,
@@ -884,7 +906,7 @@ var advisoryChunksEndpoint = app.MapGet("/advisories/{advisoryKey}/chunks", asyn
if (cacheDuration > TimeSpan.Zero)
{
var cacheKey = AdvisoryChunkCacheKey.Create(tenant, normalizedKey, buildOptions, observations);
var cacheKey = AdvisoryChunkCacheKey.Create(tenant, advisory.AdvisoryKey, buildOptions, observations, fingerprint);
if (chunkCache.TryGet(cacheKey, out var cachedResult))
{
buildResult = cachedResult;
@@ -892,13 +914,13 @@ var advisoryChunksEndpoint = app.MapGet("/advisories/{advisoryKey}/chunks", asyn
}
else
{
buildResult = chunkBuilder.Build(buildOptions, observations);
buildResult = chunkBuilder.Build(buildOptions, advisory, observations);
chunkCache.Set(cacheKey, buildResult, cacheDuration);
}
}
else
{
buildResult = chunkBuilder.Build(buildOptions, observations);
buildResult = chunkBuilder.Build(buildOptions, advisory, observations);
}
var duration = timeProvider.GetElapsedTime(requestStart);
@@ -907,13 +929,13 @@ var advisoryChunksEndpoint = app.MapGet("/advisories/{advisoryKey}/chunks", asyn
telemetry.TrackChunkResult(new AdvisoryAiChunkRequestTelemetry(
tenant,
normalizedKey,
advisory.AdvisoryKey,
"ok",
buildResult.Response.Truncated,
cacheHit,
observations.Length,
buildResult.Telemetry.SourceCount,
buildResult.Response.Chunks.Count,
buildResult.Response.Entries.Count,
duration,
guardrailCounts));
@@ -1055,6 +1077,52 @@ app.MapGet("/concelier/advisories/{vulnerabilityKey}/replay", async (
return JsonResult(response);
});
var statementProvenanceEndpoint = app.MapPost("/events/statements/{statementId:guid}/provenance", async (
Guid statementId,
HttpContext context,
[FromServices] IAdvisoryEventLog eventLog,
CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
{
return tenantError;
}
var authorizationError = EnsureTenantAuthorized(context, tenant);
if (authorizationError is not null)
{
return authorizationError;
}
try
{
using var document = await JsonDocument.ParseAsync(context.Request.Body, cancellationToken: cancellationToken).ConfigureAwait(false);
var (dsse, trust) = ProvenanceJsonParser.Parse(document.RootElement);
if (!trust.Verified)
{
return Problem(context, "Unverified provenance", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "trust.verified must be true.");
}
await eventLog.AttachStatementProvenanceAsync(statementId, dsse, trust, cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
return Problem(context, "Invalid provenance payload", StatusCodes.Status400BadRequest, ProblemTypes.Validation, ex.Message);
}
catch (InvalidOperationException ex)
{
return Problem(context, "Statement not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, ex.Message);
}
return Results.Accepted($"/events/statements/{statementId}");
});
if (authorityConfigured)
{
statementProvenanceEndpoint.RequireAuthorization(AdvisoryIngestPolicyName);
}
var loggingEnabled = concelierOptions.Telemetry?.EnableLogging ?? true;
if (loggingEnabled)
@@ -1250,6 +1318,149 @@ IResult? EnsureTenantAuthorized(HttpContext context, string tenant)
return null;
}
async Task<(Advisory Advisory, ImmutableArray<string> Aliases, string Fingerprint)?> ResolveAdvisoryAsync(
string advisoryKey,
IAdvisoryStore advisoryStore,
IAliasStore aliasStore,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(advisoryStore);
ArgumentNullException.ThrowIfNull(aliasStore);
var directCandidates = new List<string>();
if (!string.IsNullOrWhiteSpace(advisoryKey))
{
var trimmed = advisoryKey.Trim();
if (!string.IsNullOrWhiteSpace(trimmed))
{
directCandidates.Add(trimmed);
var upper = trimmed.ToUpperInvariant();
if (!string.Equals(upper, trimmed, StringComparison.Ordinal))
{
directCandidates.Add(upper);
}
}
}
foreach (var candidate in directCandidates.Distinct(StringComparer.OrdinalIgnoreCase))
{
var advisory = await advisoryStore.FindAsync(candidate, cancellationToken).ConfigureAwait(false);
if (advisory is not null)
{
return CreateResolution(advisory);
}
}
var aliasMatches = new List<AliasRecord>();
foreach (var (scheme, value) in BuildAliasLookups(advisoryKey))
{
var records = await aliasStore.GetByAliasAsync(scheme, value, cancellationToken).ConfigureAwait(false);
if (records.Count > 0)
{
aliasMatches.AddRange(records);
}
}
if (aliasMatches.Count == 0)
{
return null;
}
foreach (var candidate in aliasMatches
.OrderByDescending(record => record.UpdatedAt)
.ThenBy(record => record.AdvisoryKey, StringComparer.Ordinal)
.Select(record => record.AdvisoryKey)
.Distinct(StringComparer.OrdinalIgnoreCase))
{
var advisory = await advisoryStore.FindAsync(candidate, cancellationToken).ConfigureAwait(false);
if (advisory is not null)
{
return CreateResolution(advisory);
}
}
return null;
}
static (Advisory Advisory, ImmutableArray<string> Aliases, string Fingerprint) CreateResolution(Advisory advisory)
{
var fingerprint = AdvisoryFingerprint.Compute(advisory);
var aliases = BuildAliasQuery(advisory);
return (advisory, aliases, fingerprint);
}
static ImmutableArray<string> BuildAliasQuery(Advisory advisory)
{
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(advisory.AdvisoryKey))
{
set.Add(advisory.AdvisoryKey.Trim());
}
foreach (var alias in advisory.Aliases)
{
if (!string.IsNullOrWhiteSpace(alias))
{
set.Add(alias.Trim());
}
}
if (set.Count == 0)
{
return ImmutableArray<string>.Empty;
}
var ordered = set
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
.ToList();
var canonical = advisory.AdvisoryKey?.Trim();
if (!string.IsNullOrWhiteSpace(canonical))
{
ordered.RemoveAll(value => string.Equals(value, canonical, StringComparison.OrdinalIgnoreCase));
ordered.Insert(0, canonical);
}
return ordered.ToImmutableArray();
}
static IReadOnlyList<(string Scheme, string Value)> BuildAliasLookups(string? candidate)
{
var pairs = new List<(string Scheme, string Value)>();
var seen = new HashSet<string>(StringComparer.Ordinal);
void Add(string scheme, string? value)
{
if (string.IsNullOrWhiteSpace(scheme) || string.IsNullOrWhiteSpace(value))
{
return;
}
var trimmed = value.Trim();
if (trimmed.Length == 0)
{
return;
}
var key = $"{scheme}\u0001{trimmed}";
if (seen.Add(key))
{
pairs.Add((scheme, trimmed));
}
}
if (AliasSchemeRegistry.TryNormalize(candidate, out var normalized, out var scheme))
{
Add(scheme, normalized);
}
Add(AliasStoreConstants.UnscopedScheme, candidate);
Add(AliasStoreConstants.PrimaryScheme, candidate);
return pairs;
}
ImmutableHashSet<string> BuildFilterSet(StringValues values)
{
if (values.Count == 0)

View File

@@ -3,8 +3,7 @@ using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Models.Observations;
using StellaOps.Concelier.WebService.Contracts;
using StellaOps.Cryptography;
@@ -21,7 +20,24 @@ internal sealed record AdvisoryChunkBuildOptions(
internal sealed class AdvisoryChunkBuilder
{
private const int DefaultMinLength = 40;
private const string SectionWorkaround = "workaround";
private const string SectionFix = "fix";
private const string SectionCvss = "cvss";
private const string SectionAffected = "affected";
private static readonly ImmutableArray<string> SectionOrder = ImmutableArray.Create(
SectionWorkaround,
SectionFix,
SectionCvss,
SectionAffected);
private static readonly ImmutableHashSet<string> WorkaroundKinds = ImmutableHashSet.Create(
StringComparer.OrdinalIgnoreCase,
"workaround",
"mitigation",
"temporary_fix",
"work-around");
private readonly ICryptoHash _hash;
public AdvisoryChunkBuilder(ICryptoHash hash)
@@ -31,275 +47,330 @@ internal sealed class AdvisoryChunkBuilder
public AdvisoryChunkBuildResult Build(
AdvisoryChunkBuildOptions options,
Advisory advisory,
IReadOnlyList<AdvisoryObservation> observations)
{
var chunks = new List<AdvisoryChunkItemResponse>(Math.Min(options.ChunkLimit, 256));
var sources = new List<AdvisoryChunkSourceResponse>();
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(advisory);
ArgumentNullException.ThrowIfNull(observations);
var vendorIndex = new ObservationIndex(observations);
var chunkLimit = Math.Max(1, options.ChunkLimit);
var entries = new List<AdvisoryStructuredFieldEntry>(chunkLimit);
var total = 0;
var truncated = false;
var guardrailCounts = new Dictionary<AdvisoryChunkGuardrailReason, int>();
var sectionFilter = options.SectionFilter ?? ImmutableHashSet<string>.Empty;
foreach (var observation in observations
.OrderByDescending(o => o.CreatedAt))
foreach (var section in SectionOrder)
{
if (sources.Count >= options.ObservationLimit)
{
truncated = truncated || chunks.Count == options.ChunkLimit;
break;
}
if (options.FormatFilter.Count > 0 &&
!options.FormatFilter.Contains(observation.Content.Format))
if (!ShouldInclude(sectionFilter, section))
{
continue;
}
var documentId = DetermineDocumentId(observation);
sources.Add(new AdvisoryChunkSourceResponse(
observation.ObservationId,
documentId,
observation.Content.Format,
observation.Source.Vendor,
observation.Upstream.ContentHash,
observation.CreatedAt));
foreach (var chunk in ExtractChunks(observation, documentId, options, guardrailCounts))
IReadOnlyList<AdvisoryStructuredFieldEntry> bucket = section switch
{
total++;
if (chunks.Count < options.ChunkLimit)
{
chunks.Add(chunk);
}
else
{
truncated = true;
break;
}
SectionWorkaround => BuildWorkaroundEntries(advisory, vendorIndex),
SectionFix => BuildFixEntries(advisory, vendorIndex),
SectionCvss => BuildCvssEntries(advisory, vendorIndex),
SectionAffected => BuildAffectedEntries(advisory, vendorIndex),
_ => Array.Empty<AdvisoryStructuredFieldEntry>()
};
if (bucket.Count == 0)
{
continue;
}
if (truncated)
total += bucket.Count;
if (entries.Count >= chunkLimit)
{
break;
truncated = true;
continue;
}
var remaining = chunkLimit - entries.Count;
if (bucket.Count <= remaining)
{
entries.AddRange(bucket);
}
else
{
entries.AddRange(bucket.Take(remaining));
truncated = true;
}
}
if (!truncated)
{
total = chunks.Count;
}
var response = new AdvisoryChunkCollectionResponse(
var response = new AdvisoryStructuredFieldResponse(
options.AdvisoryKey,
total,
truncated,
chunks,
sources);
var guardrailSnapshot = guardrailCounts.Count == 0
? ImmutableDictionary<AdvisoryChunkGuardrailReason, int>.Empty
: guardrailCounts.ToImmutableDictionary();
entries);
var telemetry = new AdvisoryChunkTelemetrySummary(
sources.Count,
vendorIndex.SourceCount,
truncated,
guardrailSnapshot);
ImmutableDictionary<AdvisoryChunkGuardrailReason, int>.Empty);
return new AdvisoryChunkBuildResult(response, telemetry);
}
private static string DetermineDocumentId(AdvisoryObservation observation)
private IReadOnlyList<AdvisoryStructuredFieldEntry> BuildWorkaroundEntries(Advisory advisory, ObservationIndex index)
{
if (!string.IsNullOrWhiteSpace(observation.Upstream.UpstreamId))
if (advisory.References.Length == 0)
{
return observation.Upstream.UpstreamId;
return Array.Empty<AdvisoryStructuredFieldEntry>();
}
return observation.ObservationId;
}
private IEnumerable<AdvisoryChunkItemResponse> ExtractChunks(
AdvisoryObservation observation,
string documentId,
AdvisoryChunkBuildOptions options,
IDictionary<AdvisoryChunkGuardrailReason, int> guardrailCounts)
{
var root = observation.Content.Raw;
if (root is null)
var list = new List<AdvisoryStructuredFieldEntry>();
for (var i = 0; i < advisory.References.Length; i++)
{
yield break;
}
var stack = new Stack<(JsonNode Node, string Path, string Section)>();
stack.Push((root, string.Empty, string.Empty));
while (stack.Count > 0)
{
var (node, path, section) = stack.Pop();
if (node is null)
var reference = advisory.References[i];
if (string.IsNullOrWhiteSpace(reference.Kind) || !WorkaroundKinds.Contains(reference.Kind))
{
continue;
}
switch (node)
var content = new AdvisoryStructuredFieldContent
{
case JsonValue value:
if (!TryNormalize(value, out var text))
{
IncrementGuardrailCount(guardrailCounts, AdvisoryChunkGuardrailReason.NormalizationFailed);
break;
}
Title = reference.SourceTag ?? reference.Kind,
Description = reference.Summary,
Url = reference.Url
};
if (text.Length < Math.Max(options.MinimumLength, DefaultMinLength))
{
IncrementGuardrailCount(guardrailCounts, AdvisoryChunkGuardrailReason.BelowMinimumLength);
break;
}
if (!ContainsLetter(text))
{
IncrementGuardrailCount(guardrailCounts, AdvisoryChunkGuardrailReason.MissingAlphabeticCharacters);
break;
}
var resolvedSection = string.IsNullOrEmpty(section) ? documentId : section;
if (options.SectionFilter.Count > 0 && !options.SectionFilter.Contains(resolvedSection))
{
break;
}
var paragraphId = string.IsNullOrEmpty(path) ? resolvedSection : path;
var chunkId = CreateChunkId(documentId, paragraphId);
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["path"] = paragraphId,
["section"] = resolvedSection,
["format"] = observation.Content.Format
};
if (!string.IsNullOrEmpty(observation.Content.SpecVersion))
{
metadata["specVersion"] = observation.Content.SpecVersion!;
}
yield return new AdvisoryChunkItemResponse(
documentId,
chunkId,
resolvedSection,
paragraphId,
text,
metadata);
break;
case JsonObject obj:
foreach (var property in obj.Reverse())
{
var childSection = string.IsNullOrEmpty(section) ? property.Key : section;
var childPath = AppendPath(path, property.Key);
if (property.Value is { } childNode)
{
stack.Push((childNode, childPath, childSection));
}
}
break;
case JsonArray array:
for (var index = array.Count - 1; index >= 0; index--)
{
var childPath = AppendIndex(path, index);
if (array[index] is { } childNode)
{
stack.Push((childNode, childPath, section));
}
}
break;
}
list.Add(CreateEntry(
SectionWorkaround,
index.Resolve(reference.Provenance),
$"/references/{i}",
content,
reference.Provenance));
}
return list.Count == 0 ? Array.Empty<AdvisoryStructuredFieldEntry>() : list;
}
private static bool TryNormalize(JsonValue value, out string normalized)
private IReadOnlyList<AdvisoryStructuredFieldEntry> BuildFixEntries(Advisory advisory, ObservationIndex index)
{
normalized = string.Empty;
if (!value.TryGetValue(out string? text) || text is null)
if (advisory.AffectedPackages.Length == 0)
{
return false;
return Array.Empty<AdvisoryStructuredFieldEntry>();
}
var span = text.AsSpan();
var builder = new StringBuilder(span.Length);
var previousWhitespace = false;
var list = new List<AdvisoryStructuredFieldEntry>();
foreach (var ch in span)
for (var packageIndex = 0; packageIndex < advisory.AffectedPackages.Length; packageIndex++)
{
if (char.IsControl(ch) && !char.IsWhiteSpace(ch))
var package = advisory.AffectedPackages[packageIndex];
for (var rangeIndex = 0; rangeIndex < package.VersionRanges.Length; rangeIndex++)
{
continue;
}
if (char.IsWhiteSpace(ch))
{
if (previousWhitespace)
var range = package.VersionRanges[rangeIndex];
if (string.IsNullOrWhiteSpace(range.FixedVersion))
{
continue;
}
builder.Append(' ');
previousWhitespace = true;
var fix = new AdvisoryStructuredFixContent(
package.Type,
package.Identifier,
range.FixedVersion,
null);
var content = new AdvisoryStructuredFieldContent
{
Fix = fix,
Note = package.Provenance.FirstOrDefault()?.Value
};
list.Add(CreateEntry(
SectionFix,
index.Resolve(range.Provenance),
$"/affectedPackages/{packageIndex}/versionRanges/{rangeIndex}/fix",
content,
range.Provenance));
}
else
}
return list.Count == 0 ? Array.Empty<AdvisoryStructuredFieldEntry>() : list;
}
private IReadOnlyList<AdvisoryStructuredFieldEntry> BuildCvssEntries(Advisory advisory, ObservationIndex index)
{
if (advisory.CvssMetrics.Length == 0)
{
return Array.Empty<AdvisoryStructuredFieldEntry>();
}
var list = new List<AdvisoryStructuredFieldEntry>(advisory.CvssMetrics.Length);
for (var i = 0; i < advisory.CvssMetrics.Length; i++)
{
var metric = advisory.CvssMetrics[i];
var cvss = new AdvisoryStructuredCvssContent(
metric.Version,
metric.Vector,
metric.BaseScore,
metric.BaseSeverity);
var content = new AdvisoryStructuredFieldContent
{
builder.Append(ch);
previousWhitespace = false;
Cvss = cvss
};
list.Add(CreateEntry(
SectionCvss,
index.Resolve(metric.Provenance),
$"/cvssMetrics/{i}",
content,
metric.Provenance));
}
return list;
}
private IReadOnlyList<AdvisoryStructuredFieldEntry> BuildAffectedEntries(Advisory advisory, ObservationIndex index)
{
if (advisory.AffectedPackages.Length == 0)
{
return Array.Empty<AdvisoryStructuredFieldEntry>();
}
var list = new List<AdvisoryStructuredFieldEntry>();
for (var packageIndex = 0; packageIndex < advisory.AffectedPackages.Length; packageIndex++)
{
var package = advisory.AffectedPackages[packageIndex];
var status = package.Statuses.Length > 0 ? package.Statuses[0].Status : null;
for (var rangeIndex = 0; rangeIndex < package.VersionRanges.Length; rangeIndex++)
{
var range = package.VersionRanges[rangeIndex];
var affected = new AdvisoryStructuredAffectedContent(
package.Type,
package.Identifier,
package.Platform,
range.RangeKind,
range.IntroducedVersion,
range.FixedVersion,
range.LastAffectedVersion,
range.RangeExpression,
status);
var content = new AdvisoryStructuredFieldContent
{
Affected = affected
};
list.Add(CreateEntry(
SectionAffected,
index.Resolve(range.Provenance),
$"/affectedPackages/{packageIndex}/versionRanges/{rangeIndex}",
content,
range.Provenance));
}
}
normalized = builder.ToString().Trim();
return normalized.Length > 0;
return list.Count == 0 ? Array.Empty<AdvisoryStructuredFieldEntry>() : list;
}
private static bool ContainsLetter(string text)
=> text.Any(static ch => char.IsLetter(ch));
private static string AppendPath(string path, string? segment)
private AdvisoryStructuredFieldEntry CreateEntry(
string type,
string documentId,
string fieldPath,
AdvisoryStructuredFieldContent content,
AdvisoryProvenance provenance)
{
var safeSegment = segment ?? string.Empty;
return string.IsNullOrEmpty(path) ? safeSegment : string.Concat(path, '.', safeSegment);
var fingerprint = string.Concat(documentId, '|', fieldPath);
var chunkId = CreateChunkId(fingerprint);
return new AdvisoryStructuredFieldEntry(
type,
documentId,
fieldPath,
chunkId,
content,
new AdvisoryStructuredFieldProvenance(
provenance.Source,
provenance.Kind,
provenance.Value,
provenance.RecordedAt,
NormalizeFieldMask(provenance.FieldMask)));
}
private static string AppendIndex(string path, int index)
private static IReadOnlyList<string> NormalizeFieldMask(ImmutableArray<string> mask)
=> mask.IsDefaultOrEmpty ? Array.Empty<string>() : mask;
private string CreateChunkId(string input)
{
if (string.IsNullOrEmpty(path))
var bytes = Encoding.UTF8.GetBytes(input);
var digest = _hash.ComputeHash(bytes, HashAlgorithms.Sha256);
return Convert.ToHexString(digest.AsSpan(0, 8));
}
private static bool ShouldInclude(ImmutableHashSet<string> filter, string type)
=> filter.Count == 0 || filter.Contains(type);
private sealed class ObservationIndex
{
private const string UnknownObservationId = "unknown";
private readonly Dictionary<string, AdvisoryObservation> _byVendor;
private readonly Dictionary<string, AdvisoryObservation> _byObservationId;
private readonly Dictionary<string, AdvisoryObservation> _byUpstreamId;
private readonly string _fallbackId;
public ObservationIndex(IReadOnlyList<AdvisoryObservation> observations)
{
return $"[{index}]";
_byVendor = new Dictionary<string, AdvisoryObservation>(StringComparer.OrdinalIgnoreCase);
_byObservationId = new Dictionary<string, AdvisoryObservation>(StringComparer.OrdinalIgnoreCase);
_byUpstreamId = new Dictionary<string, AdvisoryObservation>(StringComparer.OrdinalIgnoreCase);
foreach (var observation in observations)
{
_byObservationId[observation.ObservationId] = observation;
if (!string.IsNullOrWhiteSpace(observation.Source.Vendor))
{
_byVendor[observation.Source.Vendor] = observation;
}
if (!string.IsNullOrWhiteSpace(observation.Upstream.UpstreamId))
{
_byUpstreamId[observation.Upstream.UpstreamId] = observation;
}
}
_fallbackId = observations.Count > 0 ? observations[0].ObservationId : UnknownObservationId;
SourceCount = observations.Count;
}
return string.Concat(path, '[', index.ToString(CultureInfo.InvariantCulture), ']');
}
public int SourceCount { get; }
private string CreateChunkId(string documentId, string paragraphId)
{
var input = string.Concat(documentId, '|', paragraphId);
var digest = _hash.ComputeHash(Encoding.UTF8.GetBytes(input), HashAlgorithms.Sha256);
return string.Concat(documentId, ':', Convert.ToHexString(digest.AsSpan(0, 8)));
}
private static void IncrementGuardrailCount(
IDictionary<AdvisoryChunkGuardrailReason, int> counts,
AdvisoryChunkGuardrailReason reason)
{
if (!counts.TryGetValue(reason, out var current))
public string Resolve(AdvisoryProvenance provenance)
{
current = 0;
}
if (!string.IsNullOrWhiteSpace(provenance.Value))
{
if (_byObservationId.TryGetValue(provenance.Value, out var obs))
{
return obs.ObservationId;
}
counts[reason] = current + 1;
if (_byUpstreamId.TryGetValue(provenance.Value, out obs))
{
return obs.ObservationId;
}
}
if (!string.IsNullOrWhiteSpace(provenance.Source) &&
_byVendor.TryGetValue(provenance.Source, out var vendorMatch))
{
return vendorMatch.ObservationId;
}
return _fallbackId;
}
}
}
internal sealed record AdvisoryChunkBuildResult(
AdvisoryChunkCollectionResponse Response,
AdvisoryStructuredFieldResponse Response,
AdvisoryChunkTelemetrySummary Telemetry);
internal sealed record AdvisoryChunkTelemetrySummary(

View File

@@ -53,7 +53,8 @@ internal readonly record struct AdvisoryChunkCacheKey(string Value)
string tenant,
string advisoryKey,
AdvisoryChunkBuildOptions options,
IReadOnlyList<AdvisoryObservation> observations)
IReadOnlyList<AdvisoryObservation> observations,
string advisoryFingerprint)
{
var builder = new StringBuilder();
builder.Append(tenant);
@@ -70,6 +71,8 @@ internal readonly record struct AdvisoryChunkCacheKey(string Value)
builder.Append('|');
AppendSet(builder, options.FormatFilter);
builder.Append('|');
builder.Append(advisoryFingerprint);
builder.Append('|');
foreach (var observation in observations
.OrderBy(static o => o.ObservationId, StringComparer.Ordinal))

View File

@@ -0,0 +1,20 @@
using System.Security.Cryptography;
using System.Text;
using StellaOps.Concelier.Core;
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.WebService.Services;
internal static class AdvisoryFingerprint
{
public static string Compute(Advisory advisory)
{
ArgumentNullException.ThrowIfNull(advisory);
var canonical = CanonicalJsonSerializer.Serialize(advisory);
var bytes = Encoding.UTF8.GetBytes(canonical);
using var sha256 = SHA256.Create();
var hash = sha256.ComputeHash(bytes);
return Convert.ToHexString(hash);
}
}