Files
git.stella-ops.org/src/Concelier/StellaOps.Concelier.WebService/Program.cs
StellaOps Bot f47d2d1377 blocker move 1
2025-11-23 14:53:13 +02:00

2735 lines
97 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Concelier.Core.Events;
using StellaOps.Concelier.Core.Jobs;
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;
using StellaOps.Concelier.Merge.Services;
using StellaOps.Concelier.WebService.Extensions;
using StellaOps.Concelier.WebService.Jobs;
using StellaOps.Concelier.WebService.Options;
using StellaOps.Concelier.WebService.Filters;
using StellaOps.Concelier.WebService.Services;
using StellaOps.Concelier.WebService.Telemetry;
using Serilog.Events;
using StellaOps.Plugin.DependencyInjection;
using StellaOps.Plugin.Hosting;
using StellaOps.Configuration;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Aoc;
using StellaOps.Aoc.AspNetCore.Routing;
using StellaOps.Aoc.AspNetCore.Results;
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;
using StellaOps.Concelier.Core.Attestation;
using StellaOps.Concelier.Storage.Mongo.Orchestrator;
using System.Security.Cryptography;
using StellaOps.Concelier.WebService.Contracts;
var builder = WebApplication.CreateBuilder(args);
const string JobsPolicyName = "Concelier.Jobs.Trigger";
const string ObservationsPolicyName = "Concelier.Observations.Read";
const string AdvisoryIngestPolicyName = "Concelier.Advisories.Ingest";
const string AdvisoryReadPolicyName = "Concelier.Advisories.Read";
const string AocVerifyPolicyName = "Concelier.Aoc.Verify";
const string TenantHeaderName = "X-Stella-Tenant";
builder.Configuration.AddStellaOpsDefaults(options =>
{
options.BasePath = builder.Environment.ContentRootPath;
options.EnvironmentPrefix = "CONCELIER_";
options.ConfigureBuilder = configurationBuilder =>
{
configurationBuilder.AddConcelierYaml(Path.Combine(builder.Environment.ContentRootPath, "../etc/concelier.yaml"));
};
});
var contentRootPath = builder.Environment.ContentRootPath;
var concelierOptions = builder.Configuration.BindOptions<ConcelierOptions>(postConfigure: (opts, _) =>
{
ConcelierOptionsPostConfigure.Apply(opts, contentRootPath);
ConcelierOptionsValidator.Validate(opts);
});
builder.Services.AddOptions<ConcelierOptions>()
.Bind(builder.Configuration)
.PostConfigure(options =>
{
ConcelierOptionsPostConfigure.Apply(options, contentRootPath);
ConcelierOptionsValidator.Validate(options);
})
.ValidateOnStart();
builder.Services.AddStellaOpsCrypto(concelierOptions.Crypto);
builder.ConfigureConcelierTelemetry(concelierOptions);
builder.Services.TryAddSingleton<TimeProvider>(_ => TimeProvider.System);
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<MirrorRateLimiter>();
builder.Services.AddSingleton<MirrorFileLocator>();
builder.Services.AddMongoStorage(storageOptions =>
{
storageOptions.ConnectionString = concelierOptions.Storage.Dsn;
storageOptions.DatabaseName = concelierOptions.Storage.Database;
storageOptions.CommandTimeout = TimeSpan.FromSeconds(concelierOptions.Storage.CommandTimeoutSeconds);
});
builder.Services.AddOptions<AdvisoryObservationEventPublisherOptions>()
.Bind(builder.Configuration.GetSection("advisoryObservationEvents"))
.PostConfigure(options =>
{
options.Subject ??= "concelier.advisory.observation.updated.v1";
options.Stream ??= "CONCELIER_OBS";
options.Transport = string.IsNullOrWhiteSpace(options.Transport) ? "mongo" : options.Transport;
})
.ValidateOnStart();
builder.Services.AddConcelierAocGuards();
builder.Services.AddConcelierLinksetMappers();
builder.Services.TryAddSingleton<IAdvisoryLinksetQueryService, AdvisoryLinksetQueryService>();
builder.Services.AddSingleton<LinksetCacheTelemetry>();
builder.Services.AddAdvisoryRawServices();
builder.Services.AddSingleton<IAdvisoryObservationQueryService, AdvisoryObservationQueryService>();
builder.Services.AddSingleton<AdvisoryChunkBuilder>();
builder.Services.AddSingleton<IAdvisoryChunkCache, AdvisoryChunkCache>();
builder.Services.AddSingleton<IAdvisoryAiTelemetry, AdvisoryAiTelemetry>();
builder.Services.AddSingleton<EvidenceBundleAttestationBuilder>();
var features = concelierOptions.Features ?? new ConcelierOptions.FeaturesOptions();
if (!features.NoMergeEnabled)
{
#pragma warning disable CS0618, CONCELIER0001, CONCELIER0002 // Legacy merge service is intentionally supported behind a feature toggle.
builder.Services.AddMergeModule(builder.Configuration);
#pragma warning restore CS0618, CONCELIER0001, CONCELIER0002
}
builder.Services.AddJobScheduler();
builder.Services.AddBuiltInConcelierJobs();
builder.Services.PostConfigure<JobSchedulerOptions>(options =>
{
if (features.NoMergeEnabled)
{
options.Definitions.Remove("merge:reconcile");
return;
}
if (features.MergeJobAllowlist is { Count: > 0 })
{
var allowMergeJob = features.MergeJobAllowlist.Any(value =>
string.Equals(value, "merge:reconcile", StringComparison.OrdinalIgnoreCase));
if (!allowMergeJob)
{
options.Definitions.Remove("merge:reconcile");
}
}
});
builder.Services.AddSingleton<OpenApiDiscoveryDocumentProvider>();
builder.Services.AddSingleton<ServiceStatus>(sp => new ServiceStatus(sp.GetRequiredService<TimeProvider>()));
builder.Services.AddAocGuard();
var authorityConfigured = concelierOptions.Authority is { Enabled: true };
if (authorityConfigured)
{
builder.Services.AddStellaOpsAuthClient(clientOptions =>
{
clientOptions.Authority = concelierOptions.Authority.Issuer;
clientOptions.ClientId = concelierOptions.Authority.ClientId ?? string.Empty;
clientOptions.ClientSecret = concelierOptions.Authority.ClientSecret;
clientOptions.HttpTimeout = TimeSpan.FromSeconds(concelierOptions.Authority.BackchannelTimeoutSeconds);
clientOptions.DefaultScopes.Clear();
foreach (var scope in concelierOptions.Authority.ClientScopes)
{
clientOptions.DefaultScopes.Add(scope);
}
var resilience = concelierOptions.Authority.Resilience ?? new ConcelierOptions.AuthorityOptions.ResilienceOptions();
if (resilience.EnableRetries.HasValue)
{
clientOptions.EnableRetries = resilience.EnableRetries.Value;
}
if (resilience.RetryDelays is { Count: > 0 })
{
clientOptions.RetryDelays.Clear();
foreach (var delay in resilience.RetryDelays)
{
clientOptions.RetryDelays.Add(delay);
}
}
if (resilience.AllowOfflineCacheFallback.HasValue)
{
clientOptions.AllowOfflineCacheFallback = resilience.AllowOfflineCacheFallback.Value;
}
if (resilience.OfflineCacheTolerance.HasValue)
{
clientOptions.OfflineCacheTolerance = resilience.OfflineCacheTolerance.Value;
}
});
if (string.IsNullOrWhiteSpace(concelierOptions.Authority.TestSigningSecret))
{
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configurationSection: null,
configure: resourceOptions =>
{
resourceOptions.Authority = concelierOptions.Authority.Issuer;
resourceOptions.RequireHttpsMetadata = concelierOptions.Authority.RequireHttpsMetadata;
resourceOptions.BackchannelTimeout = TimeSpan.FromSeconds(concelierOptions.Authority.BackchannelTimeoutSeconds);
resourceOptions.TokenClockSkew = TimeSpan.FromSeconds(concelierOptions.Authority.TokenClockSkewSeconds);
if (!string.IsNullOrWhiteSpace(concelierOptions.Authority.MetadataAddress))
{
resourceOptions.MetadataAddress = concelierOptions.Authority.MetadataAddress;
}
foreach (var audience in concelierOptions.Authority.Audiences)
{
resourceOptions.Audiences.Add(audience);
}
foreach (var scope in concelierOptions.Authority.RequiredScopes)
{
resourceOptions.RequiredScopes.Add(scope);
}
foreach (var network in concelierOptions.Authority.BypassNetworks)
{
resourceOptions.BypassNetworks.Add(network);
}
});
}
else
{
builder.Services
.AddAuthentication(StellaOpsAuthenticationDefaults.AuthenticationScheme)
.AddJwtBearer(StellaOpsAuthenticationDefaults.AuthenticationScheme, options =>
{
options.RequireHttpsMetadata = concelierOptions.Authority.RequireHttpsMetadata;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(concelierOptions.Authority.TestSigningSecret!)),
ValidateIssuer = true,
ValidIssuer = concelierOptions.Authority.Issuer,
ValidateAudience = concelierOptions.Authority.Audiences.Count > 0,
ValidAudiences = concelierOptions.Authority.Audiences,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(concelierOptions.Authority.TokenClockSkewSeconds),
NameClaimType = StellaOpsClaimTypes.Subject,
RoleClaimType = ClaimTypes.Role
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<Program>>();
string? token = null;
if (context.HttpContext.Request.Headers.TryGetValue("Authorization", out var authorizationValues))
{
var authorization = authorizationValues.ToString();
if (!string.IsNullOrWhiteSpace(authorization) &&
authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) &&
authorization.Length > 7)
{
token = authorization.Substring("Bearer ".Length).Trim();
}
}
if (string.IsNullOrEmpty(token))
{
token = context.Token;
}
if (!string.IsNullOrWhiteSpace(token))
{
var parts = token.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length > 0)
{
token = parts[^1];
}
token = token.Trim().Trim('"');
}
if (string.IsNullOrWhiteSpace(token))
{
logger.LogWarning("JWT token missing from request to {Path}", context.HttpContext.Request.Path);
return Task.CompletedTask;
}
context.Token = token;
return Task.CompletedTask;
}
};
});
}
}
builder.Services.AddAuthorization(options =>
{
options.AddStellaOpsScopePolicy(JobsPolicyName, concelierOptions.Authority.RequiredScopes.ToArray());
options.AddStellaOpsScopePolicy(ObservationsPolicyName, StellaOpsScopes.VulnView);
options.AddStellaOpsScopePolicy(AdvisoryIngestPolicyName, StellaOpsScopes.AdvisoryIngest);
options.AddStellaOpsScopePolicy(AdvisoryReadPolicyName, StellaOpsScopes.AdvisoryRead);
options.AddStellaOpsScopePolicy(AocVerifyPolicyName, StellaOpsScopes.AdvisoryRead, StellaOpsScopes.AocVerify);
});
var pluginHostOptions = BuildPluginOptions(concelierOptions, builder.Environment.ContentRootPath);
builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions);
builder.Services.AddEndpointsApiExplorer();
var app = builder.Build();
app.Logger.LogWarning("Authority enabled: {AuthorityEnabled}, test signing secret configured: {HasTestSecret}", authorityConfigured, !string.IsNullOrWhiteSpace(concelierOptions.Authority?.TestSigningSecret));
if (features.NoMergeEnabled)
{
app.Logger.LogWarning("Legacy merge module disabled via concelier:features:noMergeEnabled; Link-Not-Merge mode active.");
}
var resolvedConcelierOptions = app.Services.GetRequiredService<IOptions<ConcelierOptions>>().Value;
var resolvedAuthority = resolvedConcelierOptions.Authority ?? new ConcelierOptions.AuthorityOptions();
authorityConfigured = resolvedAuthority.Enabled;
var enforceAuthority = resolvedAuthority.Enabled && !resolvedAuthority.AllowAnonymousFallback;
var requiredTenants = (resolvedAuthority.RequiredTenants ?? Array.Empty<string>())
.Select(static tenant => tenant?.Trim().ToLowerInvariant())
.Where(static tenant => !string.IsNullOrWhiteSpace(tenant))
.Distinct(StringComparer.Ordinal)
.ToImmutableHashSet(StringComparer.Ordinal);
var enforceTenantAllowlist = !requiredTenants.IsEmpty;
if (resolvedAuthority.Enabled && resolvedAuthority.AllowAnonymousFallback)
{
app.Logger.LogWarning(
"Authority authentication is configured but anonymous fallback remains enabled. Set authority.allowAnonymousFallback to false before 2025-12-31 to complete the rollout.");
}
if (authorityConfigured)
{
app.UseAuthentication();
app.UseAuthorization();
}
app.MapConcelierMirrorEndpoints(authorityConfigured, enforceAuthority);
app.MapGet("/.well-known/openapi", ([FromServices] OpenApiDiscoveryDocumentProvider provider, HttpContext context) =>
{
var (payload, etag) = provider.GetDocument();
if (context.Request.Headers.IfNoneMatch.Count > 0)
{
foreach (var candidate in context.Request.Headers.IfNoneMatch)
{
if (Matches(candidate, etag))
{
context.Response.Headers.ETag = etag;
context.Response.Headers.CacheControl = "public, max-age=300, immutable";
return Results.StatusCode(StatusCodes.Status304NotModified);
}
}
}
context.Response.Headers.ETag = etag;
context.Response.Headers.CacheControl = "public, max-age=300, immutable";
return Results.Text(payload, "application/vnd.oai.openapi+json;version=3.1");
static bool Matches(string? candidate, string expected)
{
if (string.IsNullOrWhiteSpace(candidate))
{
return false;
}
var trimmed = candidate.Trim();
if (string.Equals(trimmed, expected, StringComparison.Ordinal))
{
return true;
}
if (trimmed.StartsWith("W/", StringComparison.OrdinalIgnoreCase))
{
var weakValue = trimmed[2..].TrimStart();
return string.Equals(weakValue, expected, StringComparison.Ordinal);
}
return false;
}
}).WithName("GetConcelierOpenApiDocument");
var orchestratorGroup = app.MapGroup("/internal/orch");
if (authorityConfigured)
{
orchestratorGroup.RequireAuthorization();
}
orchestratorGroup.MapPost("/registry", async (
HttpContext context,
[FromBody] OrchestratorRegistryRequest request,
[FromServices] IOrchestratorRegistryStore store,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
{
return tenantError;
}
if (string.IsNullOrWhiteSpace(request.ConnectorId) || string.IsNullOrWhiteSpace(request.Source))
{
return Problem(context, "connectorId and source are required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide connectorId and source.");
}
var now = timeProvider.GetUtcNow();
var record = new OrchestratorRegistryRecord(
tenant,
request.ConnectorId.Trim(),
request.Source.Trim(),
request.Capabilities,
request.AuthRef,
new OrchestratorSchedule(
request.Schedule.Cron,
string.IsNullOrWhiteSpace(request.Schedule.TimeZone) ? "UTC" : request.Schedule.TimeZone,
request.Schedule.MaxParallelRuns,
request.Schedule.MaxLagMinutes),
new OrchestratorRatePolicy(request.RatePolicy.Rpm, request.RatePolicy.Burst, request.RatePolicy.CooldownSeconds),
request.ArtifactKinds,
request.LockKey,
new OrchestratorEgressGuard(request.EgressGuard.Allowlist, request.EgressGuard.AirgapMode),
now,
now);
await store.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
return Results.Accepted();
}).WithName("UpsertOrchestratorRegistry");
orchestratorGroup.MapPost("/heartbeat", async (
HttpContext context,
[FromBody] OrchestratorHeartbeatRequest request,
[FromServices] IOrchestratorRegistryStore store,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
{
return tenantError;
}
if (string.IsNullOrWhiteSpace(request.ConnectorId))
{
return Problem(context, "connectorId is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide connectorId.");
}
if (request.Sequence < 0)
{
return Problem(context, "sequence must be non-negative", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide a non-negative sequence.");
}
var timestamp = request.TimestampUtc ?? timeProvider.GetUtcNow();
var heartbeat = new OrchestratorHeartbeatRecord(
tenant,
request.ConnectorId.Trim(),
request.RunId,
request.Sequence,
request.Status,
request.Progress,
request.QueueDepth,
request.LastArtifactHash,
request.LastArtifactKind,
request.ErrorCode,
request.RetryAfterSeconds,
timestamp);
await store.AppendHeartbeatAsync(heartbeat, cancellationToken).ConfigureAwait(false);
return Results.Accepted();
}).WithName("RecordOrchestratorHeartbeat");
orchestratorGroup.MapPost("/commands", async (
HttpContext context,
[FromBody] OrchestratorCommandRequest request,
[FromServices] IOrchestratorRegistryStore store,
CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
{
return tenantError;
}
if (string.IsNullOrWhiteSpace(request.ConnectorId))
{
return Problem(context, "connectorId is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide connectorId.");
}
if (request.Sequence < 0)
{
return Problem(context, "sequence must be non-negative", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide a non-negative sequence.");
}
var command = new OrchestratorCommandRecord(
tenant,
request.ConnectorId.Trim(),
request.RunId,
request.Sequence,
request.Command,
request.Throttle is null
? null
: new OrchestratorThrottleOverride(
request.Throttle.Rpm,
request.Throttle.Burst,
request.Throttle.CooldownSeconds,
request.Throttle.ExpiresAt),
request.Backfill is null
? null
: new OrchestratorBackfillRange(request.Backfill.FromCursor, request.Backfill.ToCursor),
DateTimeOffset.UtcNow,
request.ExpiresAt);
await store.EnqueueCommandAsync(command, cancellationToken).ConfigureAwait(false);
return Results.Accepted();
}).WithName("EnqueueOrchestratorCommand");
orchestratorGroup.MapGet("/commands", async (
HttpContext context,
[FromQuery] string connectorId,
[FromQuery] Guid runId,
[FromQuery] long? afterSequence,
[FromServices] IOrchestratorRegistryStore store,
CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
{
return tenantError;
}
if (string.IsNullOrWhiteSpace(connectorId))
{
return Problem(context, "connectorId is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide connectorId.");
}
var commands = await store.GetPendingCommandsAsync(tenant, connectorId.Trim(), runId, afterSequence, cancellationToken).ConfigureAwait(false);
return Results.Ok(commands);
}).WithName("GetOrchestratorCommands");
var observationsEndpoint = app.MapGet("/concelier/observations", async (
HttpContext context,
[FromQuery(Name = "observationId")] string[]? observationIds,
[FromQuery(Name = "alias")] string[]? aliases,
[FromQuery(Name = "purl")] string[]? purls,
[FromQuery(Name = "cpe")] string[]? cpes,
[FromQuery(Name = "limit")] int? limit,
[FromQuery(Name = "cursor")] string? cursor,
[FromServices] IAdvisoryObservationQueryService queryService,
CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError))
{
return tenantError;
}
var authorizationError = EnsureTenantAuthorized(context, tenant);
if (authorizationError is not null)
{
return authorizationError;
}
var normalizedTenant = tenant;
var options = new AdvisoryObservationQueryOptions(
normalizedTenant,
observationIds,
aliases,
purls,
cpes,
limit,
cursor);
AdvisoryObservationQueryResult result;
try
{
result = await queryService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
}
catch (FormatException ex)
{
return Results.BadRequest(ex.Message);
}
var response = new AdvisoryObservationQueryResponse(
result.Observations,
new AdvisoryObservationLinksetAggregateResponse(
result.Linkset.Aliases,
result.Linkset.Purls,
result.Linkset.Cpes,
result.Linkset.References,
result.Linkset.Scopes,
result.Linkset.Relationships,
result.Linkset.Confidence,
result.Linkset.Conflicts),
result.NextCursor,
result.HasMore);
return Results.Ok(response);
}).WithName("GetConcelierObservations");
const int DefaultLnmPageSize = 50;
const int MaxLnmPageSize = 200;
app.MapGet("/v1/lnm/linksets", async (
HttpContext context,
[FromQuery(Name = "advisoryId")] string? advisoryId,
[FromQuery(Name = "source")] string? source,
[FromQuery(Name = "page")] int? page,
[FromQuery(Name = "pageSize")] int? pageSize,
[FromQuery(Name = "includeConflicts")] bool? includeConflicts,
[FromServices] IAdvisoryLinksetQueryService queryService,
CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
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;
}
var resolvedPage = NormalizePage(page);
var resolvedPageSize = NormalizePageSize(pageSize);
var advisoryIds = string.IsNullOrWhiteSpace(advisoryId) ? null : new[] { advisoryId.Trim() };
var sources = string.IsNullOrWhiteSpace(source) ? null : new[] { source.Trim() };
var result = await QueryPageAsync(
queryService,
tenant!,
advisoryIds,
sources,
resolvedPage,
resolvedPageSize,
cancellationToken).ConfigureAwait(false);
var items = result.Items
.Select(linkset => ToLnmResponse(linkset, includeConflicts.GetValueOrDefault(true), includeTimeline: false, includeObservations: false))
.ToArray();
return Results.Ok(new LnmLinksetPage(items, resolvedPage, resolvedPageSize, result.Total));
}).WithName("ListLnmLinksets");
app.MapPost("/v1/lnm/linksets/search", async (
HttpContext context,
[FromBody] LnmLinksetSearchRequest request,
[FromServices] IAdvisoryLinksetQueryService queryService,
CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
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;
}
var resolvedPage = NormalizePage(request.Page);
var resolvedPageSize = NormalizePageSize(request.PageSize);
var advisoryIds = string.IsNullOrWhiteSpace(request.AdvisoryId) ? null : new[] { request.AdvisoryId.Trim() };
var sources = string.IsNullOrWhiteSpace(request.Source) ? null : new[] { request.Source.Trim() };
var result = await QueryPageAsync(
queryService,
tenant!,
advisoryIds,
sources,
resolvedPage,
resolvedPageSize,
cancellationToken).ConfigureAwait(false);
var items = result.Items
.Select(linkset => ToLnmResponse(
linkset,
includeConflicts: true,
includeTimeline: request.IncludeTimeline,
includeObservations: request.IncludeObservations))
.ToArray();
return Results.Ok(new LnmLinksetPage(items, resolvedPage, resolvedPageSize, result.Total));
}).WithName("SearchLnmLinksets");
app.MapGet("/v1/lnm/linksets/{advisoryId}", async (
HttpContext context,
string advisoryId,
[FromQuery(Name = "source")] string? source,
[FromServices] IAdvisoryLinksetQueryService queryService,
[FromServices] LinksetCacheTelemetry telemetry,
CancellationToken cancellationToken,
[FromQuery(Name = "includeConflicts")] bool includeConflicts = true,
[FromQuery(Name = "includeObservations")] bool includeObservations = false) =>
{
ApplyNoCache(context.Response);
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;
}
if (string.IsNullOrWhiteSpace(advisoryId))
{
return Results.BadRequest("advisoryId is required.");
}
var stopwatch = Stopwatch.StartNew();
var advisoryIds = new[] { advisoryId.Trim() };
var sources = string.IsNullOrWhiteSpace(source) ? null : new[] { source.Trim() };
var result = await queryService
.QueryAsync(new AdvisoryLinksetQueryOptions(tenant!, advisoryIds, sources, Limit: 1), cancellationToken)
.ConfigureAwait(false);
if (result.Linksets.IsDefaultOrEmpty)
{
return Results.NotFound();
}
var linkset = result.Linksets[0];
var response = ToLnmResponse(linkset, includeConflicts, includeTimeline: false, includeObservations: includeObservations);
telemetry.RecordHit(tenant, linkset.Source);
telemetry.RecordRebuild(tenant, linkset.Source, stopwatch.Elapsed.TotalMilliseconds);
return Results.Ok(response);
}).WithName("GetLnmLinkset");
app.MapGet("/linksets", async (
HttpContext context,
[FromQuery(Name = "limit")] int? limit,
[FromQuery(Name = "cursor")] string? cursor,
[FromServices] IAdvisoryLinksetQueryService queryService,
CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError))
{
return tenantError;
}
var authorizationError = EnsureTenantAuthorized(context, tenant);
if (authorizationError is not null)
{
return authorizationError;
}
var result = await queryService.QueryAsync(
new AdvisoryLinksetQueryOptions(tenant, Limit: limit, Cursor: cursor),
cancellationToken).ConfigureAwait(false);
var payload = new
{
linksets = result.Linksets.Select(ls => new
{
AdvisoryId = ls.AdvisoryId,
Purls = ls.Normalized?.Purls ?? Array.Empty<string>(),
Versions = ls.Normalized?.Versions ?? Array.Empty<string>()
}),
hasMore = result.HasMore,
nextCursor = result.NextCursor
};
return Results.Ok(payload);
}).WithName("ListLinksetsLegacy");
if (authorityConfigured)
{
observationsEndpoint.RequireAuthorization(ObservationsPolicyName);
}
var advisoryIngestEndpoint = app.MapPost("/ingest/advisory", async (
HttpContext context,
AdvisoryIngestRequest request,
[FromServices] IAdvisoryRawService rawService,
[FromServices] TimeProvider timeProvider,
[FromServices] ILogger<Program> logger,
CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
var ingestRequest = request;
if (ingestRequest is null || ingestRequest.Source is null || ingestRequest.Upstream is null || ingestRequest.Content is null || ingestRequest.Identifiers is null)
{
return Problem(context, "Invalid request", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "source, upstream, content, and identifiers sections are required.");
}
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;
}
using var ingestScope = logger.BeginScope(new Dictionary<string, object?>(StringComparer.Ordinal)
{
["tenant"] = tenant,
["source.vendor"] = ingestRequest.Source.Vendor,
["upstream.upstreamId"] = ingestRequest.Upstream.UpstreamId,
["contentHash"] = ingestRequest.Upstream.ContentHash ?? "(null)"
});
AdvisoryRawDocument document;
try
{
logger.LogWarning(
"Binding advisory ingest request hash={Hash}",
ingestRequest.Upstream.ContentHash ?? "(null)");
document = AdvisoryRawRequestMapper.Map(ingestRequest, tenant, timeProvider);
logger.LogWarning(
"Mapped advisory_raw document hash={Hash}",
string.IsNullOrWhiteSpace(document.Upstream.ContentHash) ? "(empty)" : document.Upstream.ContentHash);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
return Problem(context, "Invalid advisory payload", StatusCodes.Status400BadRequest, ProblemTypes.Validation, ex.Message);
}
try
{
var result = await rawService.IngestAsync(document, cancellationToken).ConfigureAwait(false);
var response = new AdvisoryIngestResponse(
result.Record.Id,
result.Inserted,
result.Record.Document.Tenant,
result.Record.Document.Upstream.ContentHash,
result.Record.Document.Supersedes,
result.Record.IngestedAt,
result.Record.CreatedAt);
var statusCode = result.Inserted ? StatusCodes.Status201Created : StatusCodes.Status200OK;
if (result.Inserted)
{
context.Response.Headers.Location = $"/advisories/raw/{Uri.EscapeDataString(result.Record.Id)}";
}
IngestionMetrics.IngestionWriteCounter.Add(
1,
IngestionMetrics.BuildWriteTags(
tenant,
ingestRequest.Source.Vendor ?? "(unknown)",
result.Inserted ? "inserted" : "duplicate"));
return JsonResult(response, statusCode);
}
catch (ConcelierAocGuardException guardException)
{
logger.LogWarning(
guardException,
"AOC guard rejected advisory ingest tenant={Tenant} upstream={UpstreamId} requestHash={RequestHash} documentHash={DocumentHash} codes={Codes}",
tenant,
document.Upstream.UpstreamId,
request!.Upstream?.ContentHash ?? "(null)",
string.IsNullOrWhiteSpace(document.Upstream.ContentHash) ? "(empty)" : document.Upstream.ContentHash,
string.Join(',', guardException.Violations.Select(static violation => violation.ErrorCode)));
IngestionMetrics.IngestionWriteCounter.Add(
1,
IngestionMetrics.BuildWriteTags(
tenant,
ingestRequest.Source.Vendor ?? "(unknown)",
"rejected"));
return MapAocGuardException(context, guardException);
}
});
var advisoryIngestGuardOptions = AocGuardOptions.Default with
{
RequireTenant = false,
RequiredTopLevelFields = AocGuardOptions.Default.RequiredTopLevelFields.Remove("tenant")
};
advisoryIngestEndpoint.RequireAocGuard<AdvisoryIngestRequest>(request =>
{
if (request?.Source is null || request.Upstream is null || request.Content is null || request.Identifiers is null)
{
return Array.Empty<object?>();
}
var guardDocument = AdvisoryRawRequestMapper.Map(request, "guard-tenant", TimeProvider.System);
return new object?[] { guardDocument };
}, guardOptions: advisoryIngestGuardOptions);
if (authorityConfigured)
{
advisoryIngestEndpoint.RequireAuthorization(AdvisoryIngestPolicyName);
}
var advisoryRawListEndpoint = app.MapGet("/advisories/raw", async (
HttpContext context,
[FromServices] IAdvisoryRawService rawService,
CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError))
{
return tenantError;
}
var authorizationError = EnsureTenantAuthorized(context, tenant);
if (authorizationError is not null)
{
return authorizationError;
}
var query = context.Request.Query;
var options = new AdvisoryRawQueryOptions(tenant);
if (query.TryGetValue("vendor", out var vendorValues))
{
options = options with { Vendors = AdvisoryRawRequestMapper.NormalizeStrings(vendorValues) };
}
if (query.TryGetValue("upstreamId", out var upstreamValues))
{
options = options with { UpstreamIds = AdvisoryRawRequestMapper.NormalizeStrings(upstreamValues) };
}
if (query.TryGetValue("alias", out var aliasValues))
{
options = options with { Aliases = AdvisoryRawRequestMapper.NormalizeStrings(aliasValues) };
}
if (query.TryGetValue("purl", out var purlValues))
{
options = options with { PackageUrls = AdvisoryRawRequestMapper.NormalizeStrings(purlValues) };
}
if (query.TryGetValue("hash", out var hashValues))
{
options = options with { ContentHashes = AdvisoryRawRequestMapper.NormalizeStrings(hashValues) };
}
if (query.TryGetValue("since", out var sinceValues))
{
var since = ParseDateTime(sinceValues.FirstOrDefault());
if (since.HasValue)
{
options = options with { Since = since };
}
}
if (query.TryGetValue("limit", out var limitValues) && int.TryParse(limitValues.FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLimit))
{
options = options with { Limit = parsedLimit };
}
if (query.TryGetValue("cursor", out var cursorValues))
{
var cursor = cursorValues.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(cursor))
{
options = options with { Cursor = cursor };
}
}
var result = await rawService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
var records = result.Records
.Select(record => new AdvisoryRawRecordResponse(
record.Id,
record.Document.Tenant,
record.IngestedAt,
record.CreatedAt,
record.Document))
.ToArray();
var response = new AdvisoryRawListResponse(records, result.NextCursor, result.HasMore);
return JsonResult(response);
});
if (authorityConfigured)
{
advisoryRawListEndpoint.RequireAuthorization(AdvisoryReadPolicyName);
}
var advisoryRawGetEndpoint = app.MapGet("/advisories/raw/{id}", async (
string id,
HttpContext context,
[FromServices] IAdvisoryRawService rawService,
CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError))
{
return tenantError;
}
var authorizationError = EnsureTenantAuthorized(context, tenant);
if (authorizationError is not null)
{
return authorizationError;
}
if (string.IsNullOrWhiteSpace(id))
{
return Problem(context, "id is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide an advisory identifier.");
}
var record = await rawService.FindByIdAsync(tenant, id.Trim(), cancellationToken).ConfigureAwait(false);
if (record is null)
{
return Results.NotFound();
}
var response = new AdvisoryRawRecordResponse(
record.Id,
record.Document.Tenant,
record.IngestedAt,
record.CreatedAt,
record.Document);
return JsonResult(response);
});
if (authorityConfigured)
{
advisoryRawGetEndpoint.RequireAuthorization(AdvisoryReadPolicyName);
}
var advisoryRawProvenanceEndpoint = app.MapGet("/advisories/raw/{id}/provenance", async (
string id,
HttpContext context,
[FromServices] IAdvisoryRawService rawService,
CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError))
{
return tenantError;
}
var authorizationError = EnsureTenantAuthorized(context, tenant);
if (authorizationError is not null)
{
return authorizationError;
}
if (string.IsNullOrWhiteSpace(id))
{
return Problem(context, "id is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide an advisory identifier.");
}
var record = await rawService.FindByIdAsync(tenant, id.Trim(), cancellationToken).ConfigureAwait(false);
if (record is null)
{
return Results.NotFound();
}
var response = new AdvisoryRawProvenanceResponse(
record.Id,
record.Document.Tenant,
record.Document.Source,
record.Document.Upstream,
record.Document.Supersedes,
record.IngestedAt,
record.CreatedAt);
return JsonResult(response);
});
if (authorityConfigured)
{
advisoryRawProvenanceEndpoint.RequireAuthorization(AdvisoryReadPolicyName);
}
var advisoryEvidenceEndpoint = app.MapGet("/vuln/evidence/advisories/{advisoryKey}", async (
string advisoryKey,
HttpContext context,
[FromServices] IAdvisoryRawService rawService,
[FromServices] EvidenceBundleAttestationBuilder attestationBuilder,
[FromServices] ILogger<Program> logger,
CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError))
{
return tenantError;
}
var authorizationError = EnsureTenantAuthorized(context, tenant);
if (authorizationError is not null)
{
return authorizationError;
}
if (string.IsNullOrWhiteSpace(advisoryKey))
{
return Problem(context, "advisoryKey is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide an advisory identifier.");
}
var normalizedKey = advisoryKey.Trim();
var canonicalKey = normalizedKey.ToUpperInvariant();
var vendorFilter = AdvisoryRawRequestMapper.NormalizeStrings(context.Request.Query["vendor"]);
var records = await rawService.FindByAdvisoryKeyAsync(
tenant,
canonicalKey,
vendorFilter,
cancellationToken).ConfigureAwait(false);
if (records.Count == 0)
{
return Problem(context, "Advisory not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"No evidence available for {normalizedKey}.");
}
var recordResponses = records
.Select(record => new AdvisoryRawRecordResponse(
record.Id,
record.Document.Tenant,
record.IngestedAt,
record.CreatedAt,
record.Document))
.ToArray();
var evidenceOptions = resolvedConcelierOptions.Evidence ?? new ConcelierOptions.EvidenceBundleOptions();
var attestation = await TryBuildAttestationAsync(
context,
evidenceOptions,
attestationBuilder,
logger,
cancellationToken).ConfigureAwait(false);
var responseKey = recordResponses[0].Document.AdvisoryKey ?? canonicalKey;
var response = new AdvisoryEvidenceResponse(responseKey, recordResponses, attestation);
return JsonResult(response);
});
if (authorityConfigured)
{
advisoryEvidenceEndpoint.RequireAuthorization(AdvisoryReadPolicyName);
}
var advisoryChunksEndpoint = app.MapGet("/advisories/{advisoryKey}/chunks", async (
string advisoryKey,
HttpContext context,
[FromServices] IAdvisoryObservationQueryService observationService,
[FromServices] AdvisoryChunkBuilder chunkBuilder,
[FromServices] IAdvisoryChunkCache chunkCache,
[FromServices] IAdvisoryStore advisoryStore,
[FromServices] IAliasStore aliasStore,
[FromServices] IAdvisoryAiTelemetry telemetry,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
var requestStart = timeProvider.GetTimestamp();
if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError))
{
telemetry.TrackChunkFailure(null, advisoryKey ?? string.Empty, "tenant_unresolved", "validation_error");
return tenantError;
}
var authorizationError = EnsureTenantAuthorized(context, tenant);
if (authorizationError is not null)
{
var failureResult = authorizationError switch
{
UnauthorizedHttpResult => "unauthorized",
_ => "forbidden"
};
telemetry.TrackChunkFailure(tenant, advisoryKey ?? string.Empty, "tenant_not_authorized", failureResult);
return authorizationError;
}
if (string.IsNullOrWhiteSpace(advisoryKey))
{
telemetry.TrackChunkFailure(tenant, string.Empty, "missing_key", "validation_error");
return Problem(context, "advisoryKey is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide an advisory identifier.");
}
var normalizedKey = advisoryKey.Trim();
var chunkSettings = resolvedConcelierOptions.AdvisoryChunks ?? new ConcelierOptions.AdvisoryChunkOptions();
var chunkLimit = ResolveBoundedInt(context.Request.Query["limit"], chunkSettings.DefaultChunkLimit, 1, chunkSettings.MaxChunkLimit);
var observationLimit = ResolveBoundedInt(context.Request.Query["observations"], chunkSettings.DefaultObservationLimit, 1, chunkSettings.MaxObservationLimit);
var minimumLength = ResolveBoundedInt(context.Request.Query["minLength"], chunkSettings.DefaultMinimumLength, 16, chunkSettings.MaxMinimumLength);
var sectionFilter = BuildFilterSet(context.Request.Query["section"]);
var formatFilter = BuildFilterSet(context.Request.Query["format"]);
var resolution = await ResolveAdvisoryAsync(
tenant,
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: aliasCandidates,
limit: observationLimit);
var observationResult = await observationService.QueryAsync(queryOptions, cancellationToken).ConfigureAwait(false);
if (observationResult.Observations.IsDefaultOrEmpty || observationResult.Observations.Length == 0)
{
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(
advisory.AdvisoryKey,
fingerprint,
chunkLimit,
observationLimit,
sectionFilter,
formatFilter,
minimumLength);
var cacheDuration = chunkSettings.CacheDurationSeconds > 0
? TimeSpan.FromSeconds(chunkSettings.CacheDurationSeconds)
: TimeSpan.Zero;
AdvisoryChunkBuildResult buildResult;
var cacheHit = false;
string? cacheKeyValue = null;
if (cacheDuration > TimeSpan.Zero)
{
var cacheKey = AdvisoryChunkCacheKey.Create(tenant, advisory.AdvisoryKey, buildOptions, observations, fingerprint);
cacheKeyValue = cacheKey.Value;
if (chunkCache.TryGet(cacheKey, out var cachedResult))
{
buildResult = cachedResult;
cacheHit = true;
}
else
{
buildResult = chunkBuilder.Build(buildOptions, advisory, observations);
chunkCache.Set(cacheKey, buildResult, cacheDuration);
}
}
else
{
buildResult = chunkBuilder.Build(buildOptions, advisory, observations);
}
// Expose cache transparency for console/clients (deterministic keys + hit/ttl)
var chunkCacheKeyHash = cacheKeyValue is null ? "" : ShortHash(cacheKeyValue);
context.Response.Headers["X-Stella-Cache-Key"] = chunkCacheKeyHash;
context.Response.Headers["X-Stella-Cache-Hit"] = cacheHit ? "1" : "0";
context.Response.Headers["X-Stella-Cache-Ttl"] = cacheDuration.TotalSeconds.ToString(CultureInfo.InvariantCulture);
var duration = timeProvider.GetElapsedTime(requestStart);
var guardrailCounts = buildResult.Telemetry.GuardrailCounts ??
ImmutableDictionary<AdvisoryChunkGuardrailReason, int>.Empty;
telemetry.TrackChunkResult(new AdvisoryAiChunkRequestTelemetry(
tenant,
advisory.AdvisoryKey,
"ok",
buildResult.Response.Truncated,
cacheHit,
observations.Length,
buildResult.Telemetry.SourceCount,
buildResult.Response.Entries.Count,
duration,
guardrailCounts));
return JsonResult(buildResult.Response);
});
if (authorityConfigured)
{
advisoryChunksEndpoint.RequireAuthorization(AdvisoryReadPolicyName);
}
var advisorySummaryEndpoint = app.MapGet("/advisories/summary", async (
HttpContext context,
[FromQuery(Name = "purl")] string[]? purls,
[FromQuery(Name = "alias")] string[]? aliases,
[FromQuery(Name = "source")] string[]? sources,
[FromQuery(Name = "confidence_gte")] double? confidenceGte,
[FromQuery(Name = "conflicts_only")] bool? conflictsOnly,
[FromQuery(Name = "take")] int? take,
[FromQuery(Name = "after")] string? after,
[FromQuery(Name = "sort")] string? sort,
[FromServices] IAdvisoryLinksetQueryService queryService,
CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
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;
}
var normalizedTenant = tenant!.ToLowerInvariant();
var limit = take is null or <= 0 ? 100 : Math.Min(take.Value, 500);
var sortKey = string.IsNullOrWhiteSpace(sort) ? "advisory" : sort.Trim().ToLowerInvariant();
var advisoryIds = aliases?.Where(a => !string.IsNullOrWhiteSpace(a)).Select(a => a.Trim()).ToArray();
var sourceFilters = sources?.Where(s => !string.IsNullOrWhiteSpace(s)).Select(s => s.Trim()).ToArray();
AdvisoryLinksetQueryResult queryResult;
try
{
queryResult = await queryService.QueryAsync(
new AdvisoryLinksetQueryOptions(normalizedTenant, advisoryIds, sourceFilters, Limit: limit, Cursor: after),
cancellationToken).ConfigureAwait(false);
}
catch (FormatException ex)
{
return Results.BadRequest(ex.Message);
}
var items = queryResult.Linksets
.Where(ls => purls is null || purls.Length == 0 || (ls.Normalized?.Purls?.Any(p => purls.Contains(p, StringComparer.OrdinalIgnoreCase)) ?? false))
.Where(ls => !confidenceGte.HasValue || (ls.Confidence ?? 0) >= confidenceGte.Value)
.Where(ls => !conflictsOnly.GetValueOrDefault(false) || (ls.Conflicts?.Count > 0))
.Select(AdvisorySummaryMapper.ToSummary)
.ToArray();
IReadOnlyList<AdvisorySummaryItem> orderedItems;
string? nextCursor;
if (sortKey == "advisory")
{
orderedItems = items
.OrderBy(i => i.AdvisoryKey, StringComparer.Ordinal)
.ThenBy(i => i.ObservedAt, StringComparer.Ordinal)
.Take(limit)
.ToArray();
nextCursor = null; // advisory sort pagination not supported yet
}
else
{
orderedItems = items
.OrderByDescending(i => i.ObservedAt, StringComparer.Ordinal)
.ThenBy(i => i.AdvisoryKey, StringComparer.Ordinal)
.Take(limit)
.ToArray();
nextCursor = queryResult.NextCursor;
}
var cacheKeyString = BuildSummaryCacheKey(normalizedTenant, purls, aliases, sources, confidenceGte, conflictsOnly, sortKey, limit, after);
var cacheHash = ShortHash(cacheKeyString);
context.Response.Headers["X-Stella-Cache-Key"] = cacheHash;
context.Response.Headers["X-Stella-Cache-Hit"] = "0";
context.Response.Headers["X-Stella-Cache-Ttl"] = "0";
var response = AdvisorySummaryMapper.ToResponse(normalizedTenant, orderedItems, nextCursor, sortKey);
return Results.Ok(response);
}).WithName("GetAdvisoriesSummary");
if (authorityConfigured)
{
advisorySummaryEndpoint.RequireAuthorization(AdvisoryReadPolicyName);
}
var aocVerifyEndpoint = app.MapPost("/aoc/verify", async (
HttpContext context,
AocVerifyRequest request,
[FromServices] IAdvisoryRawService rawService,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError))
{
return tenantError;
}
var authorizationError = EnsureTenantAuthorized(context, tenant);
if (authorizationError is not null)
{
return authorizationError;
}
var now = timeProvider.GetUtcNow();
var windowStart = (request?.Since ?? now.AddHours(-24)).ToUniversalTime();
var windowEnd = (request?.Until ?? now).ToUniversalTime();
if (windowEnd < windowStart)
{
return Problem(context, "Invalid verification window", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "'until' must be greater than 'since'.");
}
var limit = request?.Limit ?? 20;
if (limit < 0)
{
limit = 0;
}
var sources = AdvisoryRawRequestMapper.NormalizeStrings(request?.Sources);
var codes = AdvisoryRawRequestMapper.NormalizeStrings(request?.Codes);
var verificationRequest = new AdvisoryRawVerificationRequest(
tenant,
windowStart,
windowEnd,
limit,
sources,
codes);
var result = await rawService.VerifyAsync(verificationRequest, cancellationToken).ConfigureAwait(false);
var violationResponses = result.Violations
.Select(violation => new AocVerifyViolation(
violation.Code,
violation.Count,
violation.Examples.Select(example => new AocVerifyViolationExample(
example.SourceVendor,
example.DocumentId,
example.ContentHash,
example.Path)).ToArray()))
.ToArray();
var metrics = new AocVerifyMetrics(result.CheckedCount, result.Violations.Sum(v => v.Count));
var response = new AocVerifyResponse(
result.Tenant,
new AocVerifyWindow(result.WindowStart, result.WindowEnd),
new AocVerifyChecked(result.CheckedCount, 0),
violationResponses,
metrics,
result.Truncated);
var verificationOutcome = response.Truncated
? "truncated"
: (violationResponses.Length == 0 ? "ok" : "violations");
IngestionMetrics.VerificationCounter.Add(
1,
IngestionMetrics.BuildVerifyTags(tenant, verificationOutcome));
return JsonResult(response);
});
if (authorityConfigured)
{
aocVerifyEndpoint.RequireAuthorization(AocVerifyPolicyName);
}
app.MapGet("/concelier/advisories/{vulnerabilityKey}/replay", async (
string vulnerabilityKey,
DateTimeOffset? asOf,
[FromServices] IAdvisoryEventLog eventLog,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(vulnerabilityKey))
{
return Results.BadRequest("vulnerabilityKey must be provided.");
}
var replay = await eventLog.ReplayAsync(vulnerabilityKey.Trim(), asOf, cancellationToken).ConfigureAwait(false);
if (replay.Statements.Length == 0 && replay.Conflicts.Length == 0)
{
return Results.NotFound();
}
var response = new
{
replay.VulnerabilityKey,
replay.AsOf,
Statements = replay.Statements.Select(statement => new
{
statement.StatementId,
statement.VulnerabilityKey,
statement.AdvisoryKey,
statement.Advisory,
StatementHash = Convert.ToHexString(statement.StatementHash.ToArray()),
statement.AsOf,
statement.RecordedAt,
InputDocumentIds = statement.InputDocumentIds
}).ToArray(),
Conflicts = replay.Conflicts.Select(conflict => new
{
conflict.ConflictId,
conflict.VulnerabilityKey,
conflict.StatementIds,
ConflictHash = Convert.ToHexString(conflict.ConflictHash.ToArray()),
conflict.AsOf,
conflict.RecordedAt,
Details = conflict.CanonicalJson,
Explainer = MergeConflictExplainerPayload.FromCanonicalJson(conflict.CanonicalJson)
}).ToArray()
};
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)
{
app.UseSerilogRequestLogging(options =>
{
options.IncludeQueryInRequestPath = true;
options.GetLevel = (httpContext, elapsedMs, exception) => exception is null ? LogEventLevel.Information : LogEventLevel.Error;
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("RequestId", httpContext.TraceIdentifier);
diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent.ToString());
if (Activity.Current is { TraceId: var traceId } && traceId != default)
{
diagnosticContext.Set("TraceId", traceId.ToString());
}
};
});
}
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
context.Response.ContentType = "application/problem+json";
var feature = context.Features.Get<IExceptionHandlerFeature>();
var error = feature?.Error;
var extensions = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["traceId"] = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier,
};
var problem = Results.Problem(
detail: error?.Message,
instance: context.Request.Path,
statusCode: StatusCodes.Status500InternalServerError,
title: "Unexpected server error",
type: ProblemTypes.JobFailure,
extensions: extensions);
await problem.ExecuteAsync(context);
});
});
if (authorityConfigured)
{
app.Use(async (context, next) =>
{
await next().ConfigureAwait(false);
if (!context.Request.Path.StartsWithSegments("/jobs", StringComparison.OrdinalIgnoreCase))
{
return;
}
if (context.Response.StatusCode != StatusCodes.Status401Unauthorized)
{
return;
}
var optionsMonitor = context.RequestServices.GetRequiredService<IOptions<ConcelierOptions>>().Value.Authority;
if (optionsMonitor is null || !optionsMonitor.Enabled)
{
return;
}
var logger = context.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger(JobAuthorizationAuditFilter.LoggerName);
var matcher = new NetworkMaskMatcher(optionsMonitor.BypassNetworks);
var remote = context.Connection.RemoteIpAddress;
var bypassAllowed = matcher.IsAllowed(remote);
logger.LogWarning(
"Concelier authorization denied route={Route} remote={RemoteAddress} bypassAllowed={BypassAllowed} hasPrincipal={HasPrincipal}",
context.Request.Path.Value ?? string.Empty,
remote?.ToString() ?? "unknown",
bypassAllowed,
context.User?.Identity?.IsAuthenticated ?? false);
});
}
int NormalizePage(int? pageValue)
{
if (!pageValue.HasValue || pageValue.Value <= 0)
{
return 1;
}
return pageValue.Value;
}
int NormalizePageSize(int? size)
{
if (!size.HasValue || size.Value <= 0)
{
return DefaultLnmPageSize;
}
return size.Value > MaxLnmPageSize ? MaxLnmPageSize : size.Value;
}
async Task<(IReadOnlyList<AdvisoryLinkset> Items, int? Total)> QueryPageAsync(
IAdvisoryLinksetQueryService queryService,
string tenant,
IEnumerable<string>? advisoryIds,
IEnumerable<string>? sources,
int page,
int pageSize,
CancellationToken cancellationToken)
{
var cursor = (string?)null;
AdvisoryLinksetQueryResult? result = null;
for (var current = 1; current <= page; current++)
{
result = await queryService
.QueryAsync(new AdvisoryLinksetQueryOptions(tenant, advisoryIds, sources, pageSize, cursor), cancellationToken)
.ConfigureAwait(false);
if (!result.HasMore && current < page)
{
var exhaustedTotal = ((current - 1) * pageSize) + result.Linksets.Length;
return (Array.Empty<AdvisoryLinkset>(), exhaustedTotal);
}
cursor = result.NextCursor;
}
if (result is null)
{
return (Array.Empty<AdvisoryLinkset>(), 0);
}
var total = result.HasMore ? null : (int?)(((page - 1) * pageSize) + result.Linksets.Length);
return (result.Linksets, total);
}
LnmLinksetResponse ToLnmResponse(
AdvisoryLinkset linkset,
bool includeConflicts,
bool includeTimeline,
bool includeObservations)
{
var normalized = linkset.Normalized;
var conflicts = includeConflicts
? (linkset.Conflicts ?? Array.Empty<AdvisoryLinksetConflict>()).Select(c =>
new LnmLinksetConflict(
c.Field,
c.Reason,
c.Values is null ? null : string.Join(", ", c.Values),
ObservedAt: null,
EvidenceHash: c.SourceIds?.FirstOrDefault()))
.ToArray()
: Array.Empty<LnmLinksetConflict>();
var timeline = includeTimeline
? Array.Empty<LnmLinksetTimeline>() // timeline not yet captured in linkset store
: Array.Empty<LnmLinksetTimeline>();
var provenance = linkset.Provenance is null
? new LnmLinksetProvenance(linkset.CreatedAt, null, null, null)
: new LnmLinksetProvenance(
linkset.CreatedAt,
null,
linkset.Provenance.ObservationHashes?.FirstOrDefault(),
null);
var normalizedDto = normalized is null
? null
: new LnmLinksetNormalized(
Aliases: null,
Purl: normalized.Purls,
Versions: normalized.Versions,
Ranges: normalized.Ranges?.Select(r => (object)r).ToArray(),
Severities: normalized.Severities?.Select(s => (object)s).ToArray());
return new LnmLinksetResponse(
linkset.AdvisoryId,
linkset.Source,
normalized?.Purls ?? Array.Empty<string>(),
Array.Empty<string>(),
Summary: null,
PublishedAt: linkset.CreatedAt,
ModifiedAt: linkset.CreatedAt,
Severity: null,
Status: "fact-only",
provenance,
conflicts,
timeline,
normalizedDto,
Cached: false,
Remarks: Array.Empty<string>(),
Observations: includeObservations ? linkset.ObservationIds : Array.Empty<string>());
}
IResult JsonResult<T>(T value, int? statusCode = null)
{
var payload = JsonSerializer.Serialize(value, Program.JsonOptions);
return Results.Content(payload, "application/json", Encoding.UTF8, statusCode);
}
IResult Problem(HttpContext context, string title, int statusCode, string type, string? detail = null, IDictionary<string, object?>? extensions = null)
{
var traceId = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier;
extensions ??= new Dictionary<string, object?>(StringComparer.Ordinal)
{
["traceId"] = traceId,
};
if (!extensions.ContainsKey("traceId"))
{
extensions["traceId"] = traceId;
}
var problemDetails = new ProblemDetails
{
Type = type,
Title = title,
Detail = detail,
Status = statusCode,
Instance = context.Request.Path
};
foreach (var entry in extensions)
{
problemDetails.Extensions[entry.Key] = entry.Value;
}
var payload = JsonSerializer.Serialize(problemDetails, Program.JsonOptions);
return Results.Content(payload, "application/problem+json", Encoding.UTF8, statusCode);
}
bool TryResolveTenant(HttpContext context, bool requireHeader, out string tenant, out IResult? error)
{
tenant = string.Empty;
error = null;
var headerTenant = context.Request.Headers[TenantHeaderName].FirstOrDefault();
var queryTenant = context.Request.Query.TryGetValue("tenant", out var tenantValues) ? tenantValues.FirstOrDefault() : null;
if (requireHeader && string.IsNullOrWhiteSpace(headerTenant))
{
error = Problem(context, "Tenant header required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, $"Header '{TenantHeaderName}' must be provided.");
return false;
}
if (!string.IsNullOrWhiteSpace(headerTenant) && !string.IsNullOrWhiteSpace(queryTenant) &&
!string.Equals(headerTenant.Trim(), queryTenant.Trim(), StringComparison.OrdinalIgnoreCase))
{
error = Problem(context, "Tenant mismatch", StatusCodes.Status400BadRequest, ProblemTypes.Validation, $"Values for '{TenantHeaderName}' and 'tenant' query parameter must match.");
return false;
}
var resolved = !string.IsNullOrWhiteSpace(headerTenant) ? headerTenant : queryTenant;
if (string.IsNullOrWhiteSpace(resolved))
{
error = Problem(context, "Tenant required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, $"Specify the tenant via '{TenantHeaderName}' header or 'tenant' query parameter.");
return false;
}
tenant = resolved.Trim().ToLowerInvariant();
return true;
}
IResult? EnsureTenantAuthorized(HttpContext context, string tenant)
{
if (!authorityConfigured)
{
return null;
}
if (enforceTenantAllowlist && !requiredTenants.Contains(tenant))
{
return Results.Forbid();
}
var principal = context.User;
if (enforceAuthority && (principal?.Identity?.IsAuthenticated != true))
{
return Results.Unauthorized();
}
if (principal?.Identity?.IsAuthenticated == true)
{
var tenantClaim = principal.FindFirstValue(StellaOpsClaimTypes.Tenant);
if (string.IsNullOrWhiteSpace(tenantClaim))
{
return Results.Forbid();
}
var normalizedClaim = tenantClaim.Trim().ToLowerInvariant();
if (!string.Equals(normalizedClaim, tenant, StringComparison.Ordinal))
{
return Results.Forbid();
}
if (enforceTenantAllowlist && !requiredTenants.Contains(normalizedClaim))
{
return Results.Forbid();
}
}
return null;
}
async Task<(Advisory Advisory, ImmutableArray<string> Aliases, string Fingerprint)?> ResolveAdvisoryAsync(
string tenant,
string advisoryKey,
IAdvisoryStore advisoryStore,
IAliasStore aliasStore,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(tenant))
{
return null;
}
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)
{
return ImmutableHashSet<string>.Empty;
}
var builder = ImmutableHashSet.CreateBuilder<string>(StringComparer.OrdinalIgnoreCase);
foreach (var value in values)
{
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
var segments = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (segments.Length == 0)
{
builder.Add(value.Trim());
continue;
}
foreach (var segment in segments)
{
if (!string.IsNullOrWhiteSpace(segment))
{
builder.Add(segment.Trim());
}
}
}
return builder.ToImmutable();
}
int ResolveBoundedInt(StringValues values, int fallback, int minValue, int maxValue)
{
foreach (var value in values)
{
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
{
return Math.Clamp(parsed, minValue, maxValue);
}
}
return Math.Clamp(fallback, minValue, maxValue);
}
static string BuildSummaryCacheKey(
string tenant,
IEnumerable<string>? purls,
IEnumerable<string>? aliases,
IEnumerable<string>? sources,
double? confidenceGte,
bool? conflictsOnly,
string sort,
int take,
string? after)
{
static string Join(IEnumerable<string>? values) =>
values is null
? string.Empty
: string.Join(",", values.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v.ToLowerInvariant()).OrderBy(v => v, StringComparer.Ordinal));
return string.Join("|",
tenant,
Join(purls),
Join(aliases),
Join(sources),
confidenceGte?.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
conflictsOnly.GetValueOrDefault(false) ? "1" : "0",
sort,
take.ToString(CultureInfo.InvariantCulture),
after ?? string.Empty);
}
static string ShortHash(string input)
{
using var sha = SHA256.Create();
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(bytes, 0, 8).ToLowerInvariant();
}
static DateTimeOffset? ParseDateTime(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)
? parsed.ToUniversalTime()
: null;
}
IResult MapAocGuardException(HttpContext context, ConcelierAocGuardException exception)
{
var guardException = new AocGuardException(exception.Result);
return AocHttpResults.Problem(context, guardException);
}
static KeyValuePair<string, object?>[] BuildJobMetricTags(string jobKind, string trigger, string outcome)
=> new[]
{
new KeyValuePair<string, object?>("job.kind", jobKind),
new KeyValuePair<string, object?>("job.trigger", trigger),
new KeyValuePair<string, object?>("job.outcome", outcome),
};
static async Task<AttestationClaims?> TryBuildAttestationAsync(
HttpContext context,
ConcelierOptions.EvidenceBundleOptions evidenceOptions,
EvidenceBundleAttestationBuilder builder,
Microsoft.Extensions.Logging.ILogger logger,
CancellationToken cancellationToken)
{
var bundlePath = context.Request.Query.TryGetValue("bundlePath", out var bundleValues)
? bundleValues.FirstOrDefault()
: null;
if (string.IsNullOrWhiteSpace(bundlePath))
{
return null;
}
var manifestPath = context.Request.Query.TryGetValue("manifestPath", out var manifestValues)
? manifestValues.FirstOrDefault()
: null;
var transparencyPath = context.Request.Query.TryGetValue("transparencyPath", out var transparencyValues)
? transparencyValues.FirstOrDefault()
: null;
var pipelineVersion = context.Request.Query.TryGetValue("pipelineVersion", out var pipelineValues)
? pipelineValues.FirstOrDefault()
: null;
pipelineVersion = string.IsNullOrWhiteSpace(pipelineVersion)
? evidenceOptions.PipelineVersion
: pipelineVersion.Trim();
var root = evidenceOptions.RootAbsolute;
var resolvedBundlePath = ResolveEvidencePath(bundlePath, root);
if (string.IsNullOrWhiteSpace(resolvedBundlePath) || !File.Exists(resolvedBundlePath))
{
return null;
}
var resolvedManifestPath = string.IsNullOrWhiteSpace(manifestPath)
? ResolveSibling(resolvedBundlePath, evidenceOptions.DefaultManifestFileName)
: ResolveEvidencePath(manifestPath!, root);
if (string.IsNullOrWhiteSpace(resolvedManifestPath) || !File.Exists(resolvedManifestPath))
{
return null;
}
var resolvedTransparencyPath = string.IsNullOrWhiteSpace(transparencyPath)
? ResolveSibling(resolvedBundlePath, evidenceOptions.DefaultTransparencyFileName)
: ResolveEvidencePath(transparencyPath!, root);
try
{
return await builder.BuildAsync(
new EvidenceBundleAttestationRequest(
resolvedBundlePath!,
resolvedManifestPath!,
resolvedTransparencyPath,
pipelineVersion ?? "git:unknown"),
cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to build attestation for evidence bundle {BundlePath}", resolvedBundlePath);
return null;
}
}
static string? ResolveEvidencePath(string candidate, string root)
{
if (string.IsNullOrWhiteSpace(candidate))
{
return null;
}
var path = candidate;
if (!Path.IsPathRooted(path))
{
path = Path.Combine(root, path);
}
var fullPath = Path.GetFullPath(path);
if (!string.IsNullOrWhiteSpace(root))
{
var rootPath = Path.GetFullPath(root)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (!fullPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase))
{
return null;
}
}
return fullPath;
}
static string? ResolveSibling(string? bundlePath, string? fileName)
{
if (string.IsNullOrWhiteSpace(bundlePath) || string.IsNullOrWhiteSpace(fileName))
{
return null;
}
var directory = Path.GetDirectoryName(bundlePath);
if (string.IsNullOrWhiteSpace(directory))
{
return null;
}
return Path.Combine(directory, fileName);
}
void ApplyNoCache(HttpResponse response)
{
if (response is null)
{
return;
}
response.Headers.CacheControl = "no-store, no-cache, max-age=0, must-revalidate";
response.Headers.Pragma = "no-cache";
response.Headers["Expires"] = "0";
}
await InitializeMongoAsync(app);
app.MapGet("/health", ([FromServices] IOptions<ConcelierOptions> opts, [FromServices] ServiceStatus status, HttpContext context) =>
{
ApplyNoCache(context.Response);
var snapshot = status.CreateSnapshot();
var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d);
var storage = new StorageBootstrapHealth(
Driver: opts.Value.Storage.Driver,
Completed: snapshot.BootstrapCompletedAt is not null,
CompletedAt: snapshot.BootstrapCompletedAt,
DurationMs: snapshot.BootstrapDuration?.TotalMilliseconds);
var telemetry = new TelemetryHealth(
Enabled: opts.Value.Telemetry.Enabled,
Tracing: opts.Value.Telemetry.EnableTracing,
Metrics: opts.Value.Telemetry.EnableMetrics,
Logging: opts.Value.Telemetry.EnableLogging);
var response = new HealthDocument(
Status: "healthy",
StartedAt: snapshot.StartedAt,
UptimeSeconds: uptimeSeconds,
Storage: storage,
Telemetry: telemetry);
return JsonResult(response);
});
app.MapGet("/ready", async ([FromServices] IMongoDatabase database, [FromServices] ServiceStatus status, HttpContext context, CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
var stopwatch = Stopwatch.StartNew();
try
{
await database.RunCommandAsync((Command<BsonDocument>)"{ ping: 1 }", cancellationToken: cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
status.RecordMongoCheck(success: true, latency: stopwatch.Elapsed, error: null);
var snapshot = status.CreateSnapshot();
var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d);
var mongo = new MongoReadyHealth(
Status: "ready",
LatencyMs: snapshot.LastMongoLatency?.TotalMilliseconds,
CheckedAt: snapshot.LastReadyCheckAt,
Error: null);
var response = new ReadyDocument(
Status: "ready",
StartedAt: snapshot.StartedAt,
UptimeSeconds: uptimeSeconds,
Mongo: mongo);
return JsonResult(response);
}
catch (Exception ex)
{
stopwatch.Stop();
status.RecordMongoCheck(success: false, latency: stopwatch.Elapsed, error: ex.Message);
var snapshot = status.CreateSnapshot();
var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d);
var mongo = new MongoReadyHealth(
Status: "unready",
LatencyMs: snapshot.LastMongoLatency?.TotalMilliseconds,
CheckedAt: snapshot.LastReadyCheckAt,
Error: snapshot.LastMongoError ?? ex.Message);
var response = new ReadyDocument(
Status: "unready",
StartedAt: snapshot.StartedAt,
UptimeSeconds: uptimeSeconds,
Mongo: mongo);
var extensions = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["mongoLatencyMs"] = snapshot.LastMongoLatency?.TotalMilliseconds,
["mongoError"] = snapshot.LastMongoError ?? ex.Message,
};
return Problem(context, "Mongo unavailable", StatusCodes.Status503ServiceUnavailable, ProblemTypes.ServiceUnavailable, snapshot.LastMongoError ?? ex.Message, extensions);
}
});
app.MapGet("/diagnostics/aliases/{seed}", async (string seed, [FromServices] AliasGraphResolver resolver, HttpContext context, CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
if (string.IsNullOrWhiteSpace(seed))
{
return Problem(context, "Seed advisory key is required.", StatusCodes.Status400BadRequest, ProblemTypes.Validation);
}
var component = await resolver.BuildComponentAsync(seed, cancellationToken).ConfigureAwait(false);
var aliases = component.AliasMap.ToDictionary(
static kvp => kvp.Key,
static kvp => kvp.Value
.Select(record => new
{
record.Scheme,
record.Value,
UpdatedAt = record.UpdatedAt
})
.ToArray());
var response = new
{
Seed = component.SeedAdvisoryKey,
Advisories = component.AdvisoryKeys,
Collisions = component.Collisions
.Select(collision => new
{
collision.Scheme,
collision.Value,
AdvisoryKeys = collision.AdvisoryKeys
})
.ToArray(),
Aliases = aliases
};
return JsonResult(response);
});
var jobsListEndpoint = app.MapGet("/jobs", async (string? kind, int? limit, [FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
var take = Math.Clamp(limit.GetValueOrDefault(50), 1, 200);
var runs = await coordinator.GetRecentRunsAsync(kind, take, cancellationToken).ConfigureAwait(false);
var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray();
return JsonResult(payload);
}).AddEndpointFilter<JobAuthorizationAuditFilter>();
if (enforceAuthority)
{
jobsListEndpoint.RequireAuthorization(JobsPolicyName);
}
var jobByIdEndpoint = app.MapGet("/jobs/{runId:guid}", async (Guid runId, [FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
var run = await coordinator.GetRunAsync(runId, cancellationToken).ConfigureAwait(false);
if (run is null)
{
return Problem(context, "Job run not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"Job run '{runId}' was not found.");
}
return JsonResult(JobRunResponse.FromSnapshot(run));
}).AddEndpointFilter<JobAuthorizationAuditFilter>();
if (enforceAuthority)
{
jobByIdEndpoint.RequireAuthorization(JobsPolicyName);
}
var jobDefinitionsEndpoint = app.MapGet("/jobs/definitions", async ([FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
var definitions = await coordinator.GetDefinitionsAsync(cancellationToken).ConfigureAwait(false);
if (definitions.Count == 0)
{
return JsonResult(Array.Empty<JobDefinitionResponse>());
}
var definitionKinds = definitions.Select(static definition => definition.Kind).ToArray();
var lastRuns = await coordinator.GetLastRunsAsync(definitionKinds, cancellationToken).ConfigureAwait(false);
var responses = new List<JobDefinitionResponse>(definitions.Count);
foreach (var definition in definitions)
{
lastRuns.TryGetValue(definition.Kind, out var lastRun);
responses.Add(JobDefinitionResponse.FromDefinition(definition, lastRun));
}
return JsonResult(responses);
}).AddEndpointFilter<JobAuthorizationAuditFilter>();
if (enforceAuthority)
{
jobDefinitionsEndpoint.RequireAuthorization(JobsPolicyName);
}
var jobDefinitionEndpoint = app.MapGet("/jobs/definitions/{kind}", async (string kind, [FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
var definition = (await coordinator.GetDefinitionsAsync(cancellationToken).ConfigureAwait(false))
.FirstOrDefault(d => string.Equals(d.Kind, kind, StringComparison.Ordinal));
if (definition is null)
{
return Problem(context, "Job definition not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"Job kind '{kind}' is not registered.");
}
var lastRuns = await coordinator.GetLastRunsAsync(new[] { definition.Kind }, cancellationToken).ConfigureAwait(false);
lastRuns.TryGetValue(definition.Kind, out var lastRun);
var response = JobDefinitionResponse.FromDefinition(definition, lastRun);
return JsonResult(response);
}).AddEndpointFilter<JobAuthorizationAuditFilter>();
if (enforceAuthority)
{
jobDefinitionEndpoint.RequireAuthorization(JobsPolicyName);
}
var jobDefinitionRunsEndpoint = app.MapGet("/jobs/definitions/{kind}/runs", async (string kind, int? limit, [FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
var definition = (await coordinator.GetDefinitionsAsync(cancellationToken).ConfigureAwait(false))
.FirstOrDefault(d => string.Equals(d.Kind, kind, StringComparison.Ordinal));
if (definition is null)
{
return Problem(context, "Job definition not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"Job kind '{kind}' is not registered.");
}
var take = Math.Clamp(limit.GetValueOrDefault(20), 1, 200);
var runs = await coordinator.GetRecentRunsAsync(kind, take, cancellationToken).ConfigureAwait(false);
var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray();
return JsonResult(payload);
}).AddEndpointFilter<JobAuthorizationAuditFilter>();
if (enforceAuthority)
{
jobDefinitionRunsEndpoint.RequireAuthorization(JobsPolicyName);
}
var activeJobsEndpoint = app.MapGet("/jobs/active", async ([FromServices] IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
var runs = await coordinator.GetActiveRunsAsync(cancellationToken).ConfigureAwait(false);
var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray();
return JsonResult(payload);
}).AddEndpointFilter<JobAuthorizationAuditFilter>();
if (enforceAuthority)
{
activeJobsEndpoint.RequireAuthorization(JobsPolicyName);
}
var triggerJobEndpoint = app.MapPost("/jobs/{*jobKind}", async (string jobKind, JobTriggerRequest request, [FromServices] IJobCoordinator coordinator, HttpContext context) =>
{
ApplyNoCache(context.Response);
request ??= new JobTriggerRequest();
request.Parameters ??= new Dictionary<string, object?>(StringComparer.Ordinal);
var trigger = string.IsNullOrWhiteSpace(request.Trigger) ? "api" : request.Trigger;
var lifetime = context.RequestServices.GetRequiredService<IHostApplicationLifetime>();
var result = await coordinator.TriggerAsync(jobKind, request.Parameters, trigger, lifetime.ApplicationStopping).ConfigureAwait(false);
var outcome = result.Outcome;
var tags = BuildJobMetricTags(jobKind, trigger, outcome.ToString().ToLowerInvariant());
switch (outcome)
{
case JobTriggerOutcome.Accepted:
JobMetrics.TriggerCounter.Add(1, tags);
if (result.Run is null)
{
return Results.StatusCode(StatusCodes.Status202Accepted);
}
var acceptedRun = JobRunResponse.FromSnapshot(result.Run);
context.Response.Headers.Location = $"/jobs/{acceptedRun.RunId}";
return JsonResult(acceptedRun, StatusCodes.Status202Accepted);
case JobTriggerOutcome.NotFound:
JobMetrics.TriggerConflictCounter.Add(1, tags);
return Problem(context, "Job not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, result.ErrorMessage ?? $"Job '{jobKind}' is not registered.");
case JobTriggerOutcome.Disabled:
JobMetrics.TriggerConflictCounter.Add(1, tags);
return Problem(context, "Job disabled", StatusCodes.Status423Locked, ProblemTypes.Locked, result.ErrorMessage ?? $"Job '{jobKind}' is disabled.");
case JobTriggerOutcome.AlreadyRunning:
JobMetrics.TriggerConflictCounter.Add(1, tags);
return Problem(context, "Job already running", StatusCodes.Status409Conflict, ProblemTypes.Conflict, result.ErrorMessage ?? $"Job '{jobKind}' already has an active run.");
case JobTriggerOutcome.LeaseRejected:
JobMetrics.TriggerConflictCounter.Add(1, tags);
return Problem(context, "Job lease rejected", StatusCodes.Status409Conflict, ProblemTypes.LeaseRejected, result.ErrorMessage ?? $"Job '{jobKind}' could not acquire a lease.");
case JobTriggerOutcome.InvalidParameters:
{
JobMetrics.TriggerConflictCounter.Add(1, tags);
var extensions = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["parameters"] = request.Parameters,
};
return Problem(context, "Invalid job parameters", StatusCodes.Status400BadRequest, ProblemTypes.Validation, result.ErrorMessage, extensions);
}
case JobTriggerOutcome.Cancelled:
{
JobMetrics.TriggerConflictCounter.Add(1, tags);
var extensions = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["run"] = result.Run is null ? null : JobRunResponse.FromSnapshot(result.Run),
};
return Problem(context, "Job cancelled", StatusCodes.Status409Conflict, ProblemTypes.Conflict, result.ErrorMessage ?? $"Job '{jobKind}' was cancelled before completion.", extensions);
}
case JobTriggerOutcome.Failed:
{
JobMetrics.TriggerFailureCounter.Add(1, tags);
var extensions = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["run"] = result.Run is null ? null : JobRunResponse.FromSnapshot(result.Run),
};
return Problem(context, "Job execution failed", StatusCodes.Status500InternalServerError, ProblemTypes.JobFailure, result.ErrorMessage, extensions);
}
default:
JobMetrics.TriggerFailureCounter.Add(1, tags);
return Problem(context, "Unexpected job outcome", StatusCodes.Status500InternalServerError, ProblemTypes.JobFailure, $"Job '{jobKind}' returned outcome '{outcome}'.");
}
}).AddEndpointFilter<JobAuthorizationAuditFilter>();
if (enforceAuthority)
{
triggerJobEndpoint.RequireAuthorization(JobsPolicyName);
}
var concelierHealthEndpoint = app.MapGet("/obs/concelier/health", (
HttpContext context,
TimeProvider timeProvider) =>
{
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
{
return tenantError!;
}
var now = timeProvider.GetUtcNow();
var payload = new ConcelierHealthResponse(
Tenant: tenant,
QueueDepth: 0,
IngestLatencyP50Ms: 0,
IngestLatencyP99Ms: 0,
ErrorRate1h: 0.0,
SloBurnRate: 0.0,
Window: "5m",
UpdatedAt: now.ToString("O", CultureInfo.InvariantCulture));
return Results.Ok(payload);
});
var concelierTimelineEndpoint = app.MapGet("/obs/concelier/timeline", async (
HttpContext context,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
{
return tenantError!;
}
context.Response.Headers.CacheControl = "no-store";
context.Response.ContentType = "text/event-stream";
var now = timeProvider.GetUtcNow();
var evt = new ConcelierTimelineEvent(
Type: "ingest.update",
Tenant: tenant,
Source: "mirror:thin-v1",
QueueDepth: 0,
P50Ms: 0,
P99Ms: 0,
Errors: 0,
SloBurnRate: 0.0,
TraceId: null,
OccurredAt: now.ToString("O", CultureInfo.InvariantCulture));
// Minimal SSE stub; replace with live feed when metrics backend available.
await context.Response.WriteAsync($"event: ingest.update\n");
await context.Response.WriteAsync($"data: {JsonSerializer.Serialize(evt)}\n\n", cancellationToken);
await context.Response.Body.FlushAsync(cancellationToken);
return Results.Empty;
});
await app.RunAsync();
static PluginHostOptions BuildPluginOptions(ConcelierOptions options, string contentRoot)
{
var pluginOptions = new PluginHostOptions
{
BaseDirectory = options.Plugins.BaseDirectory ?? contentRoot,
PluginsDirectory = options.Plugins.Directory ?? Path.Combine(contentRoot, "StellaOps.Concelier.PluginBinaries"),
PrimaryPrefix = "StellaOps.Concelier",
EnsureDirectoryExists = true,
RecursiveSearch = false,
};
if (options.Plugins.SearchPatterns.Count == 0)
{
pluginOptions.SearchPatterns.Add("StellaOps.Concelier.Plugin.*.dll");
}
else
{
foreach (var pattern in options.Plugins.SearchPatterns)
{
if (!string.IsNullOrWhiteSpace(pattern))
{
pluginOptions.SearchPatterns.Add(pattern);
}
}
}
return pluginOptions;
}
static async Task InitializeMongoAsync(WebApplication app)
{
await using var scope = app.Services.CreateAsyncScope();
var bootstrapper = scope.ServiceProvider.GetRequiredService<MongoBootstrapper>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("MongoBootstrapper");
var status = scope.ServiceProvider.GetRequiredService<ServiceStatus>();
var stopwatch = Stopwatch.StartNew();
try
{
await bootstrapper.InitializeAsync(app.Lifetime.ApplicationStopping).ConfigureAwait(false);
stopwatch.Stop();
status.MarkBootstrapCompleted(stopwatch.Elapsed);
logger.LogInformation("Mongo bootstrap completed in {ElapsedMs} ms", stopwatch.Elapsed.TotalMilliseconds);
}
catch (Exception ex)
{
stopwatch.Stop();
status.RecordMongoCheck(success: false, latency: stopwatch.Elapsed, error: ex.Message);
logger.LogCritical(ex, "Mongo bootstrap failed after {ElapsedMs} ms", stopwatch.Elapsed.TotalMilliseconds);
throw;
}
}
public partial class Program
{
public static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
private static JsonSerializerOptions CreateJsonOptions()
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.Converters.Add(new JsonStringEnumConverter());
return options;
}
}