Implement ledger metrics for observability and add tests for Ruby packages endpoints
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
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:
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user