Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
4127 lines
147 KiB
C#
4127 lines
147 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.Immutable;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Security.Claims;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
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 Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using Microsoft.Extensions.Primitives;
|
|
using StellaOps.Concelier.Core.Events;
|
|
using StellaOps.Concelier.Core.Jobs;
|
|
using StellaOps.Concelier.Core.Observations;
|
|
using StellaOps.Concelier.Core.Linksets;
|
|
using StellaOps.Concelier.Core.Diagnostics;
|
|
using StellaOps.Concelier.Models;
|
|
using StellaOps.Concelier.WebService.Diagnostics;
|
|
using ServiceStatus = StellaOps.Concelier.WebService.Diagnostics.ServiceStatus;
|
|
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 StellaOps.Concelier.WebService.Results;
|
|
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.Concelier.WebService.Deprecation;
|
|
using StellaOps.Aoc.AspNetCore.Routing;
|
|
using StellaOps.Concelier.WebService.Contracts;
|
|
using StellaOps.Concelier.Core.Aoc;
|
|
using StellaOps.Concelier.Core.Raw;
|
|
using StellaOps.Concelier.RawModels;
|
|
using StellaOps.Concelier.Storage.Postgres;
|
|
using StellaOps.Concelier.Core.Attestation;
|
|
using StellaOps.Concelier.Core.Signals;
|
|
using AttestationClaims = StellaOps.Concelier.Core.Attestation.AttestationClaims;
|
|
using StellaOps.Concelier.Core.Orchestration;
|
|
using System.Diagnostics.Metrics;
|
|
using StellaOps.Concelier.Models.Observations;
|
|
using StellaOps.Aoc.AspNetCore.Results;
|
|
using HttpResults = Microsoft.AspNetCore.Http.Results;
|
|
using StellaOps.Concelier.Storage.Advisories;
|
|
using StellaOps.Concelier.Storage.Aliases;
|
|
using StellaOps.Provenance;
|
|
|
|
namespace StellaOps.Concelier.WebService
|
|
{
|
|
|
|
public partial class Program
|
|
{
|
|
private const string JobsPolicyName = "Concelier.Jobs.Trigger";
|
|
private const string ObservationsPolicyName = "Concelier.Observations.Read";
|
|
private const string AdvisoryIngestPolicyName = "Concelier.Advisories.Ingest";
|
|
private const string AdvisoryReadPolicyName = "Concelier.Advisories.Read";
|
|
private const string AocVerifyPolicyName = "Concelier.Aoc.Verify";
|
|
public const string TenantHeaderName = "X-Stella-Tenant";
|
|
|
|
public static async Task Main(string[] args)
|
|
{
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
// For test/CI runs, allow injecting a minimal config before options bind.
|
|
#pragma warning disable ASP0013 // permitted here for test-only override path
|
|
builder.Host.ConfigureAppConfiguration((context, cfg) =>
|
|
{
|
|
if (context.HostingEnvironment.IsEnvironment("Testing"))
|
|
{
|
|
cfg.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
{"Concelier:PostgresStorage:Enabled", "true"},
|
|
{"Concelier:PostgresStorage:ConnectionString", Environment.GetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN") ?? "Host=localhost;Port=5432;Database=concelier_test;Username=postgres;Password=postgres"},
|
|
{"Concelier:PostgresStorage:CommandTimeoutSeconds", "30"},
|
|
{"Concelier:PostgresStorage:SchemaName", "vuln"},
|
|
{"Concelier:Telemetry:Enabled", "false"}
|
|
});
|
|
}
|
|
});
|
|
#pragma warning restore ASP0013
|
|
|
|
var JsonOptions = CreateJsonOptions();
|
|
|
|
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;
|
|
|
|
// For Testing we allow pre-bound options injected via DI to override BindOptions.
|
|
ConcelierOptions concelierOptions;
|
|
|
|
if (builder.Environment.IsEnvironment("Testing"))
|
|
{
|
|
// Allow a fully pre-bound options instance to be supplied by the test host.
|
|
#pragma warning disable ASP0000 // test-only: create provider to fetch pre-bound options
|
|
using var tempProvider = builder.Services.BuildServiceProvider();
|
|
#pragma warning restore ASP0000
|
|
concelierOptions = tempProvider.GetService<IOptions<ConcelierOptions>>()?.Value ?? new ConcelierOptions
|
|
{
|
|
PostgresStorage = new ConcelierOptions.PostgresStorageOptions
|
|
{
|
|
Enabled = true,
|
|
ConnectionString = Environment.GetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN") ?? "Host=localhost;Port=5432;Database=concelier_test;Username=postgres;Password=postgres",
|
|
CommandTimeoutSeconds = 30,
|
|
SchemaName = "vuln"
|
|
},
|
|
Telemetry = new ConcelierOptions.TelemetryOptions
|
|
{
|
|
Enabled = false
|
|
}
|
|
};
|
|
|
|
concelierOptions.PostgresStorage ??= new ConcelierOptions.PostgresStorageOptions
|
|
{
|
|
Enabled = true,
|
|
ConnectionString = Environment.GetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN") ?? "Host=localhost;Port=5432;Database=concelier_test;Username=postgres;Password=postgres",
|
|
CommandTimeoutSeconds = 30,
|
|
SchemaName = "vuln"
|
|
};
|
|
|
|
if (string.IsNullOrWhiteSpace(concelierOptions.PostgresStorage.ConnectionString))
|
|
{
|
|
concelierOptions.PostgresStorage.ConnectionString = Environment.GetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN") ?? string.Empty;
|
|
}
|
|
|
|
ConcelierOptionsPostConfigure.Apply(concelierOptions, contentRootPath);
|
|
// Skip validation in Testing to allow factory-provided wiring.
|
|
}
|
|
else
|
|
{
|
|
concelierOptions = builder.Configuration.BindOptions<ConcelierOptions>(postConfigure: (opts, _) =>
|
|
{
|
|
var testDsn = Environment.GetEnvironmentVariable("CONCELIER_POSTGRES_DSN")
|
|
?? Environment.GetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN");
|
|
|
|
opts.PostgresStorage ??= new ConcelierOptions.PostgresStorageOptions
|
|
{
|
|
Enabled = !string.IsNullOrWhiteSpace(testDsn),
|
|
ConnectionString = testDsn ?? string.Empty,
|
|
SchemaName = "vuln",
|
|
CommandTimeoutSeconds = 30
|
|
};
|
|
|
|
if (string.IsNullOrWhiteSpace(opts.PostgresStorage.ConnectionString) && !string.IsNullOrWhiteSpace(testDsn))
|
|
{
|
|
opts.PostgresStorage.ConnectionString = testDsn;
|
|
opts.PostgresStorage.Enabled = true;
|
|
}
|
|
|
|
ConcelierOptionsPostConfigure.Apply(opts, contentRootPath);
|
|
var skipValidation = string.Equals(Environment.GetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION"), "1", StringComparison.OrdinalIgnoreCase);
|
|
if (!skipValidation)
|
|
{
|
|
ConcelierOptionsValidator.Validate(opts);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Register the chosen options instance so downstream services/tests share it.
|
|
builder.Services.AddSingleton(concelierOptions);
|
|
builder.Services.AddSingleton<IOptions<ConcelierOptions>>(_ => Microsoft.Extensions.Options.Options.Create(concelierOptions));
|
|
|
|
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>();
|
|
|
|
var isTesting = builder.Environment.IsEnvironment("Testing");
|
|
|
|
// Add PostgreSQL storage for all Concelier persistence.
|
|
var postgresOptions = concelierOptions.PostgresStorage ?? throw new InvalidOperationException("PostgreSQL storage must be configured.");
|
|
if (!postgresOptions.Enabled)
|
|
{
|
|
throw new InvalidOperationException("PostgreSQL storage must be enabled.");
|
|
}
|
|
|
|
builder.Services.AddConcelierPostgresStorage(pgOptions =>
|
|
{
|
|
pgOptions.ConnectionString = postgresOptions.ConnectionString;
|
|
pgOptions.CommandTimeoutSeconds = postgresOptions.CommandTimeoutSeconds;
|
|
pgOptions.MaxPoolSize = postgresOptions.MaxPoolSize;
|
|
pgOptions.MinPoolSize = postgresOptions.MinPoolSize;
|
|
pgOptions.ConnectionIdleLifetimeSeconds = postgresOptions.ConnectionIdleLifetimeSeconds;
|
|
pgOptions.Pooling = postgresOptions.Pooling;
|
|
pgOptions.SchemaName = postgresOptions.SchemaName;
|
|
pgOptions.AutoMigrate = postgresOptions.AutoMigrate;
|
|
pgOptions.MigrationsPath = postgresOptions.MigrationsPath;
|
|
});
|
|
|
|
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.AddSingleton<ILinksetCacheTelemetry>(sp => sp.GetRequiredService<LinksetCacheTelemetry>());
|
|
|
|
// Register read-through cache service for LNM linksets (CONCELIER-AIAI-31-002)
|
|
// When Postgres is enabled, uses it as cache backing; otherwise builds from observations directly
|
|
builder.Services.AddSingleton<ReadThroughLinksetCacheService>(sp =>
|
|
{
|
|
var observations = sp.GetRequiredService<IAdvisoryObservationLookup>();
|
|
var telemetry = sp.GetRequiredService<ILinksetCacheTelemetry>();
|
|
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
|
|
|
// Get Postgres cache if available (registered by AddConcelierPostgresStorage)
|
|
var cacheLookup = sp.GetService<IAdvisoryLinksetStore>() as IAdvisoryLinksetLookup;
|
|
var cacheSink = sp.GetService<IAdvisoryLinksetStore>() as IAdvisoryLinksetSink;
|
|
|
|
return new ReadThroughLinksetCacheService(
|
|
observations,
|
|
telemetry,
|
|
timeProvider,
|
|
cacheLookup,
|
|
cacheSink);
|
|
});
|
|
|
|
// Use read-through cache as the primary linkset lookup
|
|
builder.Services.AddSingleton<IAdvisoryLinksetLookup>(sp => sp.GetRequiredService<ReadThroughLinksetCacheService>());
|
|
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>();
|
|
|
|
// Register signals services (CONCELIER-SIG-26-001)
|
|
builder.Services.AddConcelierSignalsServices();
|
|
|
|
// Register orchestration services (CONCELIER-ORCH-32-001)
|
|
builder.Services.AddConcelierOrchestrationServices();
|
|
|
|
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<StellaOps.Concelier.WebService.Diagnostics.ServiceStatus>(sp => new StellaOps.Concelier.WebService.Diagnostics.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();
|
|
}
|
|
|
|
// Deprecation headers for legacy endpoints (CONCELIER-WEB-OAS-63-001)
|
|
app.UseDeprecationHeaders();
|
|
|
|
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 HttpResults.StatusCode(StatusCodes.Status304NotModified);
|
|
}
|
|
}
|
|
}
|
|
|
|
context.Response.Headers.ETag = etag;
|
|
context.Response.Headers.CacheControl = "public, max-age=300, immutable";
|
|
return HttpResults.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 HttpResults.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 HttpResults.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 HttpResults.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 HttpResults.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);
|
|
|
|
var stopwatch = Stopwatch.StartNew();
|
|
AdvisoryObservationQueryResult result;
|
|
try
|
|
{
|
|
result = await queryService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (FormatException ex)
|
|
{
|
|
IngestObservability.IngestErrorsTotal.Add(1, new TagList
|
|
{
|
|
{"tenant", normalizedTenant},
|
|
{"source", "mixed"},
|
|
{"reason", "format"},
|
|
{"stage", "ingest"}
|
|
});
|
|
return ConcelierProblemResultFactory.ValidationFailed(context, ex.Message);
|
|
}
|
|
var elapsed = stopwatch.Elapsed;
|
|
|
|
IngestObservability.IngestLatencySeconds.Record(elapsed.TotalSeconds, new TagList
|
|
{
|
|
{"tenant", normalizedTenant},
|
|
{"source", "mixed"},
|
|
{"stage", "ingest"}
|
|
});
|
|
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 HttpResults.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,
|
|
[FromServices] IAdvisoryObservationQueryService observationQueryService,
|
|
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 = new List<LnmLinksetResponse>(result.Items.Count);
|
|
foreach (var linkset in result.Items)
|
|
{
|
|
var summary = await BuildObservationSummaryAsync(observationQueryService, tenant!, linkset, cancellationToken).ConfigureAwait(false);
|
|
items.Add(ToLnmResponse(linkset, includeConflicts.GetValueOrDefault(true), includeTimeline: false, includeObservations: false, summary));
|
|
}
|
|
|
|
return HttpResults.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,
|
|
[FromServices] IAdvisoryObservationQueryService observationQueryService,
|
|
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 = new List<LnmLinksetResponse>(result.Items.Count);
|
|
foreach (var linkset in result.Items)
|
|
{
|
|
var summary = await BuildObservationSummaryAsync(observationQueryService, tenant!, linkset, cancellationToken).ConfigureAwait(false);
|
|
items.Add(ToLnmResponse(
|
|
linkset,
|
|
includeConflicts: true,
|
|
includeTimeline: request.IncludeTimeline,
|
|
includeObservations: request.IncludeObservations,
|
|
summary));
|
|
}
|
|
|
|
return HttpResults.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] IAdvisoryObservationQueryService observationQueryService,
|
|
[FromServices] IAdvisoryLinksetStore linksetStore,
|
|
[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 ConcelierProblemResultFactory.AdvisoryIdRequired(context);
|
|
}
|
|
|
|
var stopwatch = Stopwatch.StartNew();
|
|
var normalizedAdvisoryId = advisoryId.Trim();
|
|
var advisoryIds = new[] { normalizedAdvisoryId };
|
|
var sources = string.IsNullOrWhiteSpace(source) ? null : new[] { source.Trim() };
|
|
|
|
// Phase 1: Try cache lookup first (CONCELIER-AIAI-31-002)
|
|
var cached = await linksetStore
|
|
.FindByTenantAsync(tenant!, advisoryIds, sources, cursor: null, limit: 1, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
AdvisoryLinkset linkset;
|
|
bool fromCache = false;
|
|
|
|
if (cached.Count > 0)
|
|
{
|
|
// Cache hit
|
|
linkset = cached[0];
|
|
fromCache = true;
|
|
telemetry.RecordHit(tenant, linkset.Source);
|
|
}
|
|
else
|
|
{
|
|
// Cache miss - rebuild from query service
|
|
var result = await queryService
|
|
.QueryAsync(new AdvisoryLinksetQueryOptions(tenant!, advisoryIds, sources, Limit: 1), cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (result.Linksets.IsDefaultOrEmpty)
|
|
{
|
|
return ConcelierProblemResultFactory.AdvisoryNotFound(context, advisoryId);
|
|
}
|
|
|
|
linkset = result.Linksets[0];
|
|
|
|
// Write to cache
|
|
try
|
|
{
|
|
await linksetStore.UpsertAsync(linkset, cancellationToken).ConfigureAwait(false);
|
|
telemetry.RecordWrite(tenant, linkset.Source);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Log but don't fail request on cache write errors
|
|
context.RequestServices.GetRequiredService<ILogger<Program>>()
|
|
.LogWarning(ex, "Failed to write linkset to cache for {AdvisoryId}", normalizedAdvisoryId);
|
|
}
|
|
|
|
telemetry.RecordRebuild(tenant, linkset.Source, stopwatch.Elapsed.TotalMilliseconds);
|
|
}
|
|
|
|
var summary = await BuildObservationSummaryAsync(observationQueryService, tenant!, linkset, cancellationToken).ConfigureAwait(false);
|
|
var response = ToLnmResponse(linkset, includeConflicts, includeTimeline: false, includeObservations: includeObservations, summary, cached: fromCache);
|
|
|
|
return HttpResults.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 HttpResults.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);
|
|
}
|
|
|
|
var chunkStopwatch = Stopwatch.StartNew();
|
|
|
|
try
|
|
{
|
|
var result = await rawService.IngestAsync(document, cancellationToken).ConfigureAwait(false);
|
|
chunkStopwatch.Stop();
|
|
|
|
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"));
|
|
|
|
var telemetrySource = ingestRequest.Source.Vendor ?? "(unknown)";
|
|
var (_, _, conflicts) = AdvisoryLinksetNormalization.FromRawLinksetWithConfidence(document.Linkset, providedConfidence: null);
|
|
var collisionCount = VulnExplorerTelemetry.CountAliasCollisions(conflicts);
|
|
VulnExplorerTelemetry.RecordIdentifierCollisions(tenant, telemetrySource, collisionCount);
|
|
VulnExplorerTelemetry.RecordChunkLatency(tenant, telemetrySource, chunkStopwatch.Elapsed);
|
|
if (VulnExplorerTelemetry.IsWithdrawn(document.Content.Raw))
|
|
{
|
|
VulnExplorerTelemetry.RecordWithdrawnStatement(tenant, telemetrySource);
|
|
}
|
|
|
|
return JsonResult(response, statusCode);
|
|
}
|
|
catch (ConcelierAocGuardException guardException)
|
|
{
|
|
chunkStopwatch.Stop();
|
|
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 ConcelierProblemResultFactory.AdvisoryNotFound(context, id);
|
|
}
|
|
|
|
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 ConcelierProblemResultFactory.AdvisoryNotFound(context, id);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// Advisory observations endpoint - filtered by alias/purl/source with strict tenant scopes.
|
|
// Echoes upstream values + provenance fields only (no merge-derived judgments).
|
|
var advisoryObservationsEndpoint = app.MapGet("/advisories/observations", async (
|
|
HttpContext context,
|
|
[FromServices] IAdvisoryObservationQueryService observationService,
|
|
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 query = context.Request.Query;
|
|
|
|
// Parse query parameters
|
|
string[]? aliases = query.TryGetValue("alias", out var aliasValues)
|
|
? AdvisoryRawRequestMapper.NormalizeStrings(aliasValues).ToArray()
|
|
: null;
|
|
|
|
string[]? purls = query.TryGetValue("purl", out var purlValues)
|
|
? AdvisoryRawRequestMapper.NormalizeStrings(purlValues).ToArray()
|
|
: null;
|
|
|
|
string[]? cpes = query.TryGetValue("cpe", out var cpeValues)
|
|
? AdvisoryRawRequestMapper.NormalizeStrings(cpeValues).ToArray()
|
|
: null;
|
|
|
|
string[]? observationIds = query.TryGetValue("id", out var idValues)
|
|
? AdvisoryRawRequestMapper.NormalizeStrings(idValues).ToArray()
|
|
: null;
|
|
|
|
int? limit = null;
|
|
if (query.TryGetValue("limit", out var limitValues) &&
|
|
int.TryParse(limitValues.FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLimit) &&
|
|
parsedLimit > 0)
|
|
{
|
|
limit = Math.Min(parsedLimit, 200); // Cap at 200
|
|
}
|
|
|
|
string? cursor = null;
|
|
if (query.TryGetValue("cursor", out var cursorValues))
|
|
{
|
|
var cursorValue = cursorValues.FirstOrDefault();
|
|
if (!string.IsNullOrWhiteSpace(cursorValue))
|
|
{
|
|
cursor = cursorValue.Trim();
|
|
}
|
|
}
|
|
|
|
// Build query options with tenant scope
|
|
var options = new AdvisoryObservationQueryOptions(
|
|
tenant,
|
|
observationIds: observationIds,
|
|
aliases: aliases,
|
|
purls: purls,
|
|
cpes: cpes,
|
|
limit: limit,
|
|
cursor: cursor);
|
|
|
|
var result = await observationService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
|
|
|
|
// Map to response contracts
|
|
var linksetResponse = 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);
|
|
|
|
var response = new AdvisoryObservationQueryResponse(
|
|
result.Observations,
|
|
linksetResponse,
|
|
result.NextCursor,
|
|
result.HasMore);
|
|
|
|
return JsonResult(response);
|
|
}).WithName("GetAdvisoryObservations");
|
|
|
|
if (authorityConfigured)
|
|
{
|
|
advisoryObservationsEndpoint.RequireAuthorization(ObservationsPolicyName);
|
|
}
|
|
|
|
// Advisory linksets endpoint - surfaces correlation + conflict payloads with ERR_AGG_* mapping.
|
|
// No synthesis/merge - echoes upstream values only.
|
|
var advisoryLinksetsEndpoint = app.MapGet("/advisories/linksets", async (
|
|
HttpContext context,
|
|
[FromServices] IAdvisoryLinksetQueryService linksetService,
|
|
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 query = context.Request.Query;
|
|
|
|
// Parse advisory IDs (alias values like CVE-*, GHSA-*)
|
|
string[]? advisoryIds = query.TryGetValue("advisoryId", out var advisoryIdValues)
|
|
? AdvisoryRawRequestMapper.NormalizeStrings(advisoryIdValues).ToArray()
|
|
: (query.TryGetValue("alias", out var aliasValues)
|
|
? AdvisoryRawRequestMapper.NormalizeStrings(aliasValues).ToArray()
|
|
: null);
|
|
|
|
string[]? sources = query.TryGetValue("source", out var sourceValues)
|
|
? AdvisoryRawRequestMapper.NormalizeStrings(sourceValues).ToArray()
|
|
: null;
|
|
|
|
int? limit = null;
|
|
if (query.TryGetValue("limit", out var limitValues) &&
|
|
int.TryParse(limitValues.FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLimit) &&
|
|
parsedLimit > 0)
|
|
{
|
|
limit = Math.Min(parsedLimit, 500); // Cap at 500
|
|
}
|
|
|
|
string? cursor = null;
|
|
if (query.TryGetValue("cursor", out var cursorValues))
|
|
{
|
|
var cursorValue = cursorValues.FirstOrDefault();
|
|
if (!string.IsNullOrWhiteSpace(cursorValue))
|
|
{
|
|
cursor = cursorValue.Trim();
|
|
}
|
|
}
|
|
|
|
var options = new AdvisoryLinksetQueryOptions(
|
|
tenant,
|
|
advisoryIds,
|
|
sources,
|
|
limit,
|
|
cursor);
|
|
|
|
var result = await linksetService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
|
|
|
|
// Map to LNM linkset response format
|
|
var items = result.Linksets.Select(linkset => new LnmLinksetResponse(
|
|
linkset.AdvisoryId,
|
|
linkset.Source,
|
|
linkset.Normalized?.Purls ?? Array.Empty<string>(),
|
|
linkset.Normalized?.Cpes ?? Array.Empty<string>(),
|
|
null, // Summary not available in linkset
|
|
null, // PublishedAt
|
|
null, // ModifiedAt
|
|
null, // Severity - no derived judgment
|
|
null, // Status
|
|
linkset.Provenance is not null
|
|
? new LnmLinksetProvenance(
|
|
linkset.CreatedAt,
|
|
null, // ConnectorId
|
|
linkset.Provenance.ObservationHashes?.FirstOrDefault(),
|
|
null) // DsseEnvelopeHash
|
|
: null,
|
|
linkset.Conflicts?.Select(c => new LnmLinksetConflict(
|
|
c.Field,
|
|
c.Reason,
|
|
c.Values?.FirstOrDefault(),
|
|
null,
|
|
null)).ToArray() ?? Array.Empty<LnmLinksetConflict>(),
|
|
Array.Empty<LnmLinksetTimeline>(),
|
|
linkset.Normalized is not null
|
|
? new LnmLinksetNormalized(
|
|
null, // Aliases not in normalized
|
|
linkset.Normalized.Purls,
|
|
linkset.Normalized.Cpes,
|
|
linkset.Normalized.Versions,
|
|
null, // Ranges serialized differently
|
|
null) // Severities not yet populated
|
|
: null,
|
|
false, // Not from cache
|
|
Array.Empty<string>(),
|
|
linkset.ObservationIds.ToArray())).ToArray();
|
|
|
|
var response = new LnmLinksetPage(items, 1, items.Length, null);
|
|
return JsonResult(response);
|
|
}).WithName("GetAdvisoryLinksets");
|
|
|
|
if (authorityConfigured)
|
|
{
|
|
advisoryLinksetsEndpoint.RequireAuthorization(AdvisoryReadPolicyName);
|
|
}
|
|
|
|
// Advisory linksets export endpoint for evidence bundles
|
|
var advisoryLinksetsExportEndpoint = app.MapGet("/advisories/linksets/export", async (
|
|
HttpContext context,
|
|
[FromServices] IAdvisoryLinksetQueryService linksetService,
|
|
[FromServices] TimeProvider timeProvider,
|
|
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 query = context.Request.Query;
|
|
|
|
string[]? advisoryIds = query.TryGetValue("advisoryId", out var advisoryIdValues)
|
|
? AdvisoryRawRequestMapper.NormalizeStrings(advisoryIdValues).ToArray()
|
|
: null;
|
|
|
|
string[]? sources = query.TryGetValue("source", out var sourceValues)
|
|
? AdvisoryRawRequestMapper.NormalizeStrings(sourceValues).ToArray()
|
|
: null;
|
|
|
|
var options = new AdvisoryLinksetQueryOptions(tenant, advisoryIds, sources, 1000, null);
|
|
var result = await linksetService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
|
|
|
|
// Export format with provenance metadata
|
|
var exportItems = result.Linksets.Select(linkset => new
|
|
{
|
|
advisoryId = linkset.AdvisoryId,
|
|
source = linkset.Source,
|
|
tenantId = linkset.TenantId,
|
|
observationIds = linkset.ObservationIds.ToArray(),
|
|
confidence = linkset.Confidence,
|
|
conflicts = linkset.Conflicts?.Select(c => new
|
|
{
|
|
field = c.Field,
|
|
reason = c.Reason,
|
|
values = c.Values,
|
|
sourceIds = c.SourceIds
|
|
}).ToArray(),
|
|
normalized = linkset.Normalized is not null ? new
|
|
{
|
|
purls = linkset.Normalized.Purls,
|
|
cpes = linkset.Normalized.Cpes,
|
|
versions = linkset.Normalized.Versions
|
|
} : null,
|
|
provenance = linkset.Provenance is not null ? new
|
|
{
|
|
observationHashes = linkset.Provenance.ObservationHashes,
|
|
toolVersion = linkset.Provenance.ToolVersion,
|
|
policyHash = linkset.Provenance.PolicyHash
|
|
} : null,
|
|
createdAt = linkset.CreatedAt,
|
|
builtByJobId = linkset.BuiltByJobId
|
|
}).ToArray();
|
|
|
|
var export = new
|
|
{
|
|
tenant = tenant,
|
|
exportedAt = timeProvider.GetUtcNow(),
|
|
count = exportItems.Length,
|
|
hasMore = result.HasMore,
|
|
linksets = exportItems
|
|
};
|
|
|
|
return JsonResult(export);
|
|
}).WithName("ExportAdvisoryLinksets");
|
|
|
|
if (authorityConfigured)
|
|
{
|
|
advisoryLinksetsExportEndpoint.RequireAuthorization(AdvisoryReadPolicyName);
|
|
}
|
|
|
|
// Internal endpoint for publishing observation events to NATS/Redis.
|
|
// Publishes advisory.observation.updated@1 events with tenant + provenance references only.
|
|
app.MapPost("/internal/events/observations/publish", async (
|
|
HttpContext context,
|
|
[FromBody] ObservationEventPublishRequest request,
|
|
[FromServices] IAdvisoryObservationQueryService observationService,
|
|
[FromServices] IAdvisoryObservationEventPublisher? eventPublisher,
|
|
[FromServices] TimeProvider timeProvider,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
ApplyNoCache(context.Response);
|
|
|
|
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
|
{
|
|
return tenantError;
|
|
}
|
|
|
|
if (eventPublisher is null)
|
|
{
|
|
return Problem(context, "Event publishing not configured", StatusCodes.Status503ServiceUnavailable, ProblemTypes.ServiceUnavailable, "Event publisher service is not available.");
|
|
}
|
|
|
|
if (request?.ObservationIds is null || request.ObservationIds.Count == 0)
|
|
{
|
|
return Problem(context, "observationIds required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide at least one observation ID.");
|
|
}
|
|
|
|
var options = new AdvisoryObservationQueryOptions(tenant, observationIds: request.ObservationIds);
|
|
var result = await observationService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
|
|
|
|
var published = 0;
|
|
foreach (var observation in result.Observations)
|
|
{
|
|
var @event = AdvisoryObservationUpdatedEvent.FromObservation(
|
|
observation,
|
|
supersedesId: null,
|
|
traceId: context.TraceIdentifier);
|
|
|
|
await eventPublisher.PublishAsync(@event, cancellationToken).ConfigureAwait(false);
|
|
published++;
|
|
}
|
|
|
|
return HttpResults.Ok(new { tenant, published, requestedCount = request.ObservationIds.Count, timestamp = timeProvider.GetUtcNow() });
|
|
}).WithName("PublishObservationEvents");
|
|
|
|
// Internal endpoint for publishing linkset events to NATS/Redis.
|
|
// Publishes advisory.linkset.updated@1 events with idempotent keys and tenant + provenance references.
|
|
app.MapPost("/internal/events/linksets/publish", async (
|
|
HttpContext context,
|
|
[FromBody] LinksetEventPublishRequest request,
|
|
[FromServices] IAdvisoryLinksetQueryService linksetService,
|
|
[FromServices] IAdvisoryLinksetEventPublisher? eventPublisher,
|
|
[FromServices] TimeProvider timeProvider,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
ApplyNoCache(context.Response);
|
|
|
|
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
|
{
|
|
return tenantError;
|
|
}
|
|
|
|
if (eventPublisher is null)
|
|
{
|
|
return Problem(context, "Event publishing not configured", StatusCodes.Status503ServiceUnavailable, ProblemTypes.ServiceUnavailable, "Event publisher service is not available.");
|
|
}
|
|
|
|
if (request?.AdvisoryIds is null || request.AdvisoryIds.Count == 0)
|
|
{
|
|
return Problem(context, "advisoryIds required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide at least one advisory ID.");
|
|
}
|
|
|
|
var options = new AdvisoryLinksetQueryOptions(tenant, request.AdvisoryIds, null, 500);
|
|
var result = await linksetService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
|
|
|
|
var published = 0;
|
|
foreach (var linkset in result.Linksets)
|
|
{
|
|
var linksetId = $"{linkset.TenantId}:{linkset.Source}:{linkset.AdvisoryId}";
|
|
var @event = AdvisoryLinksetUpdatedEvent.FromLinkset(
|
|
linkset,
|
|
previousLinkset: null,
|
|
linksetId: linksetId,
|
|
traceId: context.TraceIdentifier);
|
|
|
|
await eventPublisher.PublishAsync(@event, cancellationToken).ConfigureAwait(false);
|
|
published++;
|
|
}
|
|
|
|
return HttpResults.Ok(new { tenant, published, requestedCount = request.AdvisoryIds.Count, hasMore = result.HasMore, timestamp = timeProvider.GetUtcNow() });
|
|
}).WithName("PublishLinksetEvents");
|
|
|
|
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 attestationVerifyEndpoint = app.MapPost("/internal/attestations/verify", async (
|
|
VerifyAttestationRequest request,
|
|
HttpContext context,
|
|
[FromServices] EvidenceBundleAttestationBuilder attestationBuilder,
|
|
[FromServices] IOptions<ConcelierOptions> concelierOptions,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
ApplyNoCache(context.Response);
|
|
|
|
if (request is null)
|
|
{
|
|
return Problem(context, "Request body required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide bundle/manifest paths.");
|
|
}
|
|
|
|
var evidenceOptions = concelierOptions.Value.Evidence ?? new ConcelierOptions.EvidenceBundleOptions();
|
|
|
|
var resolved = ResolveEvidencePaths(request, evidenceOptions.RootAbsolute, evidenceOptions);
|
|
if (!resolved.IsValid)
|
|
{
|
|
return Problem(context, resolved.Error!, StatusCodes.Status400BadRequest, ProblemTypes.Validation, resolved.ErrorDetails ?? string.Empty);
|
|
}
|
|
|
|
try
|
|
{
|
|
var claims = await attestationBuilder.BuildAsync(
|
|
new EvidenceBundleAttestationRequest(
|
|
resolved.BundlePath!,
|
|
resolved.ManifestPath!,
|
|
resolved.TransparencyPath,
|
|
request.PipelineVersion ?? evidenceOptions.PipelineVersion ?? "git:unknown"),
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
return HttpResults.Json(claims);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return Problem(context, "Attestation verification failed", StatusCodes.Status400BadRequest, ProblemTypes.Validation, ex.Message);
|
|
}
|
|
});
|
|
if (authorityConfigured)
|
|
{
|
|
attestationVerifyEndpoint.RequireAuthorization(AdvisoryReadPolicyName);
|
|
}
|
|
|
|
// Evidence snapshot (manifest-only) endpoint for Console/VEX consumers
|
|
var evidenceSnapshotEndpoint = app.MapGet("/obs/evidence/advisories/{advisoryKey}", async (
|
|
string advisoryKey,
|
|
HttpContext context,
|
|
[FromServices] IOptions<ConcelierOptions> concelierOptions,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
ApplyNoCache(context.Response);
|
|
|
|
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
|
{
|
|
return tenantError;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(advisoryKey))
|
|
{
|
|
return Problem(context, "advisoryKey is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide an advisory identifier.");
|
|
}
|
|
|
|
var options = concelierOptions.Value.Evidence ?? new ConcelierOptions.EvidenceBundleOptions();
|
|
var baseDir = Path.Combine(options.RootAbsolute ?? options.Root ?? string.Empty, tenant, advisoryKey.Trim());
|
|
var manifestPath = Path.Combine(baseDir, options.DefaultManifestFileName ?? "manifest.json");
|
|
var transparencyPath = Path.Combine(baseDir, options.DefaultTransparencyFileName ?? "transparency.json");
|
|
|
|
if (!File.Exists(manifestPath))
|
|
{
|
|
return Problem(context, "Manifest not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"No manifest for {advisoryKey} in tenant {tenant}.");
|
|
}
|
|
|
|
await using var manifestStream = File.OpenRead(manifestPath);
|
|
var hash = await ComputeSha256Async(manifestStream, cancellationToken).ConfigureAwait(false);
|
|
|
|
var response = new EvidenceSnapshotResponse(
|
|
AdvisoryKey: advisoryKey.Trim(),
|
|
Tenant: tenant,
|
|
ManifestPath: manifestPath,
|
|
ManifestHash: hash,
|
|
TransparencyPath: File.Exists(transparencyPath) ? transparencyPath : null,
|
|
PipelineVersion: options.PipelineVersion);
|
|
|
|
return HttpResults.Json(response);
|
|
});
|
|
if (authorityConfigured)
|
|
{
|
|
evidenceSnapshotEndpoint.RequireAuthorization(AdvisoryReadPolicyName);
|
|
}
|
|
|
|
// Attestation status endpoint (evidence locker proxy)
|
|
var evidenceAttestationEndpoint = app.MapGet("/obs/attestations/advisories/{advisoryKey}", async (
|
|
string advisoryKey,
|
|
HttpContext context,
|
|
[FromServices] IOptions<ConcelierOptions> concelierOptions,
|
|
[FromServices] EvidenceBundleAttestationBuilder attestationBuilder,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
ApplyNoCache(context.Response);
|
|
|
|
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
|
{
|
|
return tenantError;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(advisoryKey))
|
|
{
|
|
return Problem(context, "advisoryKey is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide an advisory identifier.");
|
|
}
|
|
|
|
var options = concelierOptions.Value.Evidence ?? new ConcelierOptions.EvidenceBundleOptions();
|
|
var baseDir = Path.Combine(options.RootAbsolute ?? options.Root ?? string.Empty, tenant, advisoryKey.Trim());
|
|
if (!Directory.Exists(baseDir))
|
|
{
|
|
return Problem(context, "Evidence directory not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"No evidence for {advisoryKey} in tenant {tenant}.");
|
|
}
|
|
|
|
var bundlePath = Directory.EnumerateFiles(baseDir, "*.tar*", SearchOption.TopDirectoryOnly).FirstOrDefault();
|
|
if (bundlePath is null)
|
|
{
|
|
return Problem(context, "Bundle missing", StatusCodes.Status404NotFound, ProblemTypes.NotFound, "No bundle archive found in evidence directory.");
|
|
}
|
|
|
|
var manifestPath = Path.Combine(baseDir, options.DefaultManifestFileName ?? "manifest.json");
|
|
var transparencyPath = Path.Combine(baseDir, options.DefaultTransparencyFileName ?? "transparency.json");
|
|
if (!File.Exists(manifestPath))
|
|
{
|
|
return Problem(context, "Manifest missing", StatusCodes.Status404NotFound, ProblemTypes.NotFound, "Manifest required to build attestation claims.");
|
|
}
|
|
|
|
var claims = await attestationBuilder.BuildAsync(
|
|
new EvidenceBundleAttestationRequest(
|
|
bundlePath,
|
|
manifestPath,
|
|
File.Exists(transparencyPath) ? transparencyPath : null,
|
|
options.PipelineVersion ?? "git:unknown"),
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
var response = new AttestationStatusResponse(
|
|
AdvisoryKey: advisoryKey.Trim(),
|
|
Tenant: tenant,
|
|
Claims: claims,
|
|
BundlePath: bundlePath,
|
|
ManifestPath: manifestPath,
|
|
TransparencyPath: File.Exists(transparencyPath) ? transparencyPath : null,
|
|
PipelineVersion: options.PipelineVersion);
|
|
|
|
return HttpResults.Json(response);
|
|
});
|
|
if (authorityConfigured)
|
|
{
|
|
evidenceAttestationEndpoint.RequireAuthorization(AdvisoryReadPolicyName);
|
|
}
|
|
|
|
// Incident-mode (ingest pause) endpoints
|
|
var incidentGetEndpoint = app.MapGet("/obs/incidents/advisories/{advisoryKey}", async (
|
|
string advisoryKey,
|
|
HttpContext context,
|
|
[FromServices] IOptions<ConcelierOptions> concelierOptions,
|
|
[FromServices] TimeProvider timeProvider,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
ApplyNoCache(context.Response);
|
|
|
|
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
|
{
|
|
return tenantError;
|
|
}
|
|
|
|
var evidenceOptions = concelierOptions.Value.Evidence ?? new ConcelierOptions.EvidenceBundleOptions();
|
|
var status = await IncidentFileStore.ReadAsync(evidenceOptions, tenant!, advisoryKey, timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false);
|
|
if (status is null)
|
|
{
|
|
return Problem(context, "Incident not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, "No incident marker present.");
|
|
}
|
|
|
|
return HttpResults.Json(status);
|
|
});
|
|
if (authorityConfigured)
|
|
{
|
|
incidentGetEndpoint.RequireAuthorization(AdvisoryReadPolicyName);
|
|
}
|
|
|
|
var incidentUpsertEndpoint = app.MapPost("/obs/incidents/advisories/{advisoryKey}", async (
|
|
string advisoryKey,
|
|
IncidentUpsertRequest request,
|
|
HttpContext context,
|
|
[FromServices] IOptions<ConcelierOptions> concelierOptions,
|
|
[FromServices] TimeProvider timeProvider,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
ApplyNoCache(context.Response);
|
|
|
|
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
|
{
|
|
return tenantError;
|
|
}
|
|
|
|
if (request is null)
|
|
{
|
|
return Problem(context, "Request body required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide reason/cooldownMinutes.");
|
|
}
|
|
|
|
var cooldownMinutes = request.CooldownMinutes is null or <= 0 ? 60 : request.CooldownMinutes.Value;
|
|
var evidenceOptions = concelierOptions.Value.Evidence ?? new ConcelierOptions.EvidenceBundleOptions();
|
|
await IncidentFileStore.WriteAsync(
|
|
evidenceOptions,
|
|
tenant!,
|
|
advisoryKey,
|
|
request.Reason ?? "unspecified",
|
|
cooldownMinutes,
|
|
evidenceOptions.PipelineVersion,
|
|
timeProvider.GetUtcNow(),
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
var status = await IncidentFileStore.ReadAsync(evidenceOptions, tenant!, advisoryKey, timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false);
|
|
return HttpResults.Json(status);
|
|
});
|
|
if (authorityConfigured)
|
|
{
|
|
incidentUpsertEndpoint.RequireAuthorization(AdvisoryReadPolicyName);
|
|
}
|
|
|
|
var incidentDeleteEndpoint = app.MapDelete("/obs/incidents/advisories/{advisoryKey}", async (
|
|
string advisoryKey,
|
|
HttpContext context,
|
|
[FromServices] IOptions<ConcelierOptions> concelierOptions,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
ApplyNoCache(context.Response);
|
|
|
|
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
|
{
|
|
return tenantError;
|
|
}
|
|
|
|
var evidenceOptions = concelierOptions.Value.Evidence ?? new ConcelierOptions.EvidenceBundleOptions();
|
|
await IncidentFileStore.DeleteAsync(evidenceOptions, tenant!, advisoryKey, cancellationToken).ConfigureAwait(false);
|
|
return HttpResults.NoContent();
|
|
});
|
|
if (authorityConfigured)
|
|
{
|
|
incidentDeleteEndpoint.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));
|
|
VulnExplorerTelemetry.RecordChunkRequest(
|
|
tenant!,
|
|
result: "ok",
|
|
cacheHit,
|
|
buildResult.Response.Entries.Count,
|
|
duration.TotalMilliseconds);
|
|
|
|
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 ConcelierProblemResultFactory.ValidationFailed(context, 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 HttpResults.Ok(response);
|
|
}).WithName("GetAdvisoriesSummary");
|
|
|
|
// Evidence batch (component-centric) endpoint for graph overlays / evidence exports.
|
|
app.MapPost("/v1/evidence/batch", async (
|
|
HttpContext context,
|
|
[FromBody] EvidenceBatchRequest request,
|
|
[FromServices] IAdvisoryObservationQueryService observationService,
|
|
[FromServices] IAdvisoryLinksetQueryService linksetService,
|
|
[FromServices] TimeProvider timeProvider,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
ApplyNoCache(context.Response);
|
|
|
|
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
|
{
|
|
return tenantError;
|
|
}
|
|
|
|
if (request?.Items is null || request.Items.Count == 0)
|
|
{
|
|
return Problem(context, "At least one batch item is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide items with aliases/purls.");
|
|
}
|
|
|
|
var resolvedObservationLimit = request.ObservationLimit is > 0 and <= 200 ? request.ObservationLimit.Value : 50;
|
|
var resolvedLinksetLimit = request.LinksetLimit is > 0 and <= 200 ? request.LinksetLimit.Value : 50;
|
|
|
|
var responses = new List<EvidenceBatchItemResponse>(request.Items.Count);
|
|
foreach (var item in request.Items)
|
|
{
|
|
var componentId = string.IsNullOrWhiteSpace(item.ComponentId) ? "(unnamed)" : item.ComponentId.Trim();
|
|
var aliases = item.Aliases?.Where(a => !string.IsNullOrWhiteSpace(a)).Select(a => a.Trim()).ToArray();
|
|
var purls = item.Purls?.Where(p => !string.IsNullOrWhiteSpace(p)).Select(p => p.Trim()).ToArray();
|
|
|
|
AdvisoryObservationQueryResult observationResult = new(
|
|
ImmutableArray<AdvisoryObservation>.Empty,
|
|
new AdvisoryObservationLinksetAggregate(
|
|
ImmutableArray<string>.Empty,
|
|
ImmutableArray<string>.Empty,
|
|
ImmutableArray<string>.Empty,
|
|
ImmutableArray<AdvisoryObservationReference>.Empty),
|
|
NextCursor: null,
|
|
HasMore: false);
|
|
|
|
AdvisoryLinksetQueryResult linksetResult = new(
|
|
ImmutableArray<AdvisoryLinkset>.Empty,
|
|
NextCursor: null,
|
|
HasMore: false);
|
|
|
|
if ((aliases?.Length ?? 0) > 0 || (purls?.Length ?? 0) > 0)
|
|
{
|
|
var obsOptions = new AdvisoryObservationQueryOptions(tenant, aliases: aliases, purls: purls, limit: resolvedObservationLimit);
|
|
observationResult = await observationService.QueryAsync(obsOptions, cancellationToken).ConfigureAwait(false);
|
|
|
|
var linksetOptions = new AdvisoryLinksetQueryOptions(tenant, aliases, null, resolvedLinksetLimit);
|
|
linksetResult = await linksetService.QueryAsync(linksetOptions, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
var responseItem = new EvidenceBatchItemResponse(
|
|
componentId,
|
|
observationResult.Observations,
|
|
linksetResult.Linksets,
|
|
observationResult.HasMore || linksetResult.HasMore,
|
|
timeProvider.GetUtcNow());
|
|
|
|
responses.Add(responseItem);
|
|
}
|
|
|
|
return HttpResults.Ok(new EvidenceBatchResponse(responses));
|
|
}).WithName("GetEvidenceBatch");
|
|
|
|
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,
|
|
HttpContext context,
|
|
DateTimeOffset? asOf,
|
|
[FromServices] IAdvisoryEventLog eventLog,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(vulnerabilityKey))
|
|
{
|
|
return ConcelierProblemResultFactory.VulnerabilityKeyRequired(context);
|
|
}
|
|
|
|
var replay = await eventLog.ReplayAsync(vulnerabilityKey.Trim(), asOf, cancellationToken).ConfigureAwait(false);
|
|
if (replay.Statements.Length == 0 && replay.Conflicts.Length == 0)
|
|
{
|
|
return ConcelierProblemResultFactory.VulnerabilityNotFound(context, vulnerabilityKey);
|
|
}
|
|
|
|
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 HttpResults.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 = HttpResults.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,
|
|
LinksetObservationSummary summary,
|
|
DataFreshnessInfo? freshness = null,
|
|
bool cached = false)
|
|
{
|
|
var normalized = linkset.Normalized;
|
|
var severity = summary.Severity ?? (normalized?.Severities?.FirstOrDefault() is { } severityDict
|
|
? ExtractSeverity(severityDict)
|
|
: null);
|
|
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
|
|
? BuildTimeline(linkset, summary)
|
|
: 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,
|
|
Cpe: normalized.Cpes,
|
|
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>(),
|
|
normalized?.Cpes ?? Array.Empty<string>(),
|
|
Summary: null,
|
|
PublishedAt: summary.PublishedAt ?? linkset.CreatedAt,
|
|
ModifiedAt: summary.ModifiedAt ?? linkset.CreatedAt,
|
|
Severity: severity,
|
|
Status: "fact-only",
|
|
provenance,
|
|
conflicts,
|
|
timeline,
|
|
normalizedDto,
|
|
Cached: cached,
|
|
Remarks: Array.Empty<string>(),
|
|
Observations: includeObservations ? linkset.ObservationIds : Array.Empty<string>(),
|
|
Freshness: freshness);
|
|
}
|
|
|
|
string? ExtractSeverity(IReadOnlyDictionary<string, object?> severityDict)
|
|
{
|
|
if (severityDict.TryGetValue("system", out var systemObj) && systemObj is string system && !string.IsNullOrWhiteSpace(system) &&
|
|
severityDict.TryGetValue("score", out var scoreObj))
|
|
{
|
|
return $"{system}:{scoreObj}";
|
|
}
|
|
|
|
if (severityDict.TryGetValue("score", out var scoreOnly) && scoreOnly is not null)
|
|
{
|
|
return scoreOnly.ToString();
|
|
}
|
|
|
|
if (severityDict.TryGetValue("value", out var value) && value is string valueString && !string.IsNullOrWhiteSpace(valueString))
|
|
{
|
|
return valueString;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async Task<LinksetObservationSummary> BuildObservationSummaryAsync(
|
|
IAdvisoryObservationQueryService observationQueryService,
|
|
string tenant,
|
|
AdvisoryLinkset linkset,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (linkset.ObservationIds.Length == 0)
|
|
{
|
|
return LinksetObservationSummary.Empty;
|
|
}
|
|
|
|
var options = new AdvisoryObservationQueryOptions(
|
|
tenant,
|
|
observationIds: linkset.ObservationIds,
|
|
limit: linkset.ObservationIds.Length);
|
|
|
|
var result = await observationQueryService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
|
|
if (result.Observations.IsDefaultOrEmpty)
|
|
{
|
|
return LinksetObservationSummary.Empty;
|
|
}
|
|
|
|
// Observation timelines are not yet populated; return empty summary until ingestion enriches these fields.
|
|
return LinksetObservationSummary.Empty;
|
|
}
|
|
|
|
IReadOnlyList<LnmLinksetTimeline> BuildTimeline(AdvisoryLinkset linkset, LinksetObservationSummary summary)
|
|
{
|
|
var timeline = new List<LnmLinksetTimeline>(3)
|
|
{
|
|
new("created", linkset.CreatedAt, linkset.Provenance?.ObservationHashes?.FirstOrDefault()),
|
|
};
|
|
|
|
if (summary.PublishedAt.HasValue)
|
|
{
|
|
timeline.Add(new LnmLinksetTimeline("published", summary.PublishedAt, summary.EvidenceHash));
|
|
}
|
|
|
|
if (summary.ModifiedAt.HasValue)
|
|
{
|
|
timeline.Add(new LnmLinksetTimeline("modified", summary.ModifiedAt, summary.EvidenceHash));
|
|
}
|
|
|
|
return timeline;
|
|
}
|
|
|
|
IResult JsonResult<T>(T value, int? statusCode = null)
|
|
{
|
|
var payload = JsonSerializer.Serialize(value, JsonOptions);
|
|
return HttpResults.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, string? errorCode = 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;
|
|
}
|
|
|
|
// Per CONCELIER-WEB-OAS-61-002: Add error code extension for machine-readable errors
|
|
if (!string.IsNullOrEmpty(errorCode))
|
|
{
|
|
extensions["error"] = new { code = errorCode, message = detail ?? title };
|
|
}
|
|
|
|
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, JsonOptions);
|
|
return HttpResults.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 HttpResults.Forbid();
|
|
}
|
|
|
|
var principal = context.User;
|
|
|
|
if (enforceAuthority && (principal?.Identity?.IsAuthenticated != true))
|
|
{
|
|
return HttpResults.Unauthorized();
|
|
}
|
|
|
|
if (principal?.Identity?.IsAuthenticated == true)
|
|
{
|
|
var tenantClaim = principal.FindFirstValue(StellaOpsClaimTypes.Tenant);
|
|
if (string.IsNullOrWhiteSpace(tenantClaim))
|
|
{
|
|
return HttpResults.Forbid();
|
|
}
|
|
|
|
var normalizedClaim = tenantClaim.Trim().ToLowerInvariant();
|
|
if (!string.Equals(normalizedClaim, tenant, StringComparison.Ordinal))
|
|
{
|
|
return HttpResults.Forbid();
|
|
}
|
|
|
|
if (enforceTenantAllowlist && !requiredTenants.Contains(normalizedClaim))
|
|
{
|
|
return HttpResults.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;
|
|
}
|
|
|
|
static async Task<string> ComputeSha256Async(Stream stream, CancellationToken cancellationToken)
|
|
{
|
|
stream.Seek(0, SeekOrigin.Begin);
|
|
using var sha = SHA256.Create();
|
|
var hash = await sha.ComputeHashAsync(stream, cancellationToken).ConfigureAwait(false);
|
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
|
}
|
|
|
|
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 effectiveRoot = root ?? string.Empty;
|
|
|
|
var path = candidate;
|
|
if (!Path.IsPathRooted(path) && !string.IsNullOrWhiteSpace(effectiveRoot))
|
|
{
|
|
path = Path.Combine(effectiveRoot, path);
|
|
}
|
|
|
|
var fullPath = Path.GetFullPath(path);
|
|
|
|
if (!string.IsNullOrWhiteSpace(effectiveRoot))
|
|
{
|
|
var rootPath = Path.GetFullPath(effectiveRoot)
|
|
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
|
|
|
if (!fullPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return fullPath;
|
|
}
|
|
|
|
static EvidencePathResolutionResult ResolveEvidencePaths(
|
|
VerifyAttestationRequest request,
|
|
string root,
|
|
ConcelierOptions.EvidenceBundleOptions evidenceOptions)
|
|
{
|
|
var effectiveRoot = string.IsNullOrWhiteSpace(root) ? string.Empty : root;
|
|
|
|
var bundlePath = ResolveEvidencePath(request.BundlePath ?? string.Empty, effectiveRoot);
|
|
if (string.IsNullOrWhiteSpace(bundlePath) || !File.Exists(bundlePath))
|
|
{
|
|
return EvidencePathResolutionResult.Invalid("Bundle path not found", request.BundlePath);
|
|
}
|
|
|
|
var manifestPath = string.IsNullOrWhiteSpace(request.ManifestPath)
|
|
? ResolveSibling(bundlePath, evidenceOptions.DefaultManifestFileName)
|
|
: ResolveEvidencePath(request.ManifestPath!, effectiveRoot);
|
|
|
|
if (string.IsNullOrWhiteSpace(manifestPath) || !File.Exists(manifestPath))
|
|
{
|
|
return EvidencePathResolutionResult.Invalid("Manifest path not found", request.ManifestPath);
|
|
}
|
|
|
|
var transparencyPath = string.IsNullOrWhiteSpace(request.TransparencyPath)
|
|
? ResolveSibling(bundlePath, evidenceOptions.DefaultTransparencyFileName)
|
|
: ResolveEvidencePath(request.TransparencyPath!, effectiveRoot);
|
|
|
|
return EvidencePathResolutionResult.Valid(bundlePath!, manifestPath!, transparencyPath);
|
|
}
|
|
|
|
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 InitializePostgresAsync(app);
|
|
|
|
app.MapGet("/health", ([FromServices] IOptions<ConcelierOptions> opts, [FromServices] StellaOps.Concelier.WebService.Diagnostics.ServiceStatus status, HttpContext context) =>
|
|
{
|
|
ApplyNoCache(context.Response);
|
|
|
|
var snapshot = status.CreateSnapshot();
|
|
var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d);
|
|
|
|
var storage = new StorageHealth(
|
|
Backend: "postgres",
|
|
Ready: snapshot.LastReadySucceeded,
|
|
CheckedAt: snapshot.LastReadyCheckAt,
|
|
LatencyMs: snapshot.LastStorageLatency?.TotalMilliseconds,
|
|
Error: snapshot.LastStorageError);
|
|
|
|
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] StellaOps.Concelier.WebService.Diagnostics.ServiceStatus status,
|
|
[FromServices] ConcelierDataSource dataSource,
|
|
HttpContext context,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
ApplyNoCache(context.Response);
|
|
|
|
var (ready, latency, error) = await CheckPostgresAsync(dataSource, cancellationToken).ConfigureAwait(false);
|
|
status.RecordStorageCheck(ready, latency, error);
|
|
|
|
var snapshot = status.CreateSnapshot();
|
|
var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d);
|
|
|
|
var storage = new StorageHealth(
|
|
Backend: "postgres",
|
|
Ready: ready,
|
|
CheckedAt: snapshot.LastReadyCheckAt,
|
|
LatencyMs: latency.TotalMilliseconds,
|
|
Error: error);
|
|
|
|
var response = new ReadyDocument(
|
|
Status: ready ? "ready" : "degraded",
|
|
StartedAt: snapshot.StartedAt,
|
|
UptimeSeconds: uptimeSeconds,
|
|
Storage: storage);
|
|
|
|
return JsonResult(response);
|
|
});
|
|
|
|
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 HttpResults.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 HttpResults.Ok(payload);
|
|
});
|
|
|
|
var concelierTimelineEndpoint = app.MapGet("/obs/concelier/timeline", async (
|
|
HttpContext context,
|
|
TimeProvider timeProvider,
|
|
ILoggerFactory loggerFactory,
|
|
[FromQuery] string? cursor,
|
|
[FromQuery] int? limit,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
|
{
|
|
return tenantError!;
|
|
}
|
|
|
|
var take = Math.Clamp(limit.GetValueOrDefault(10), 1, 100);
|
|
var startId = 0;
|
|
|
|
var candidateCursor = cursor ?? context.Request.Headers["Last-Event-ID"].FirstOrDefault();
|
|
if (!string.IsNullOrWhiteSpace(candidateCursor) && !int.TryParse(candidateCursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out startId))
|
|
{
|
|
return ConcelierProblemResultFactory.InvalidCursor(context);
|
|
}
|
|
|
|
var logger = loggerFactory.CreateLogger("ConcelierTimeline");
|
|
context.Response.Headers.CacheControl = "no-store";
|
|
context.Response.Headers["X-Accel-Buffering"] = "no";
|
|
context.Response.ContentType = "text/event-stream";
|
|
|
|
// SSE retry hint (5s) to encourage clients to reconnect with cursor
|
|
await context.Response.WriteAsync("retry: 5000\n\n", cancellationToken).ConfigureAwait(false);
|
|
|
|
var now = timeProvider.GetUtcNow();
|
|
|
|
var events = Enumerable.Range(startId, take)
|
|
.Select(id => 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)))
|
|
.ToList();
|
|
|
|
foreach (var (evt, idx) in events.Select((e, i) => (e, i)))
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var id = startId + idx;
|
|
await context.Response.WriteAsync($"id: {id}\n", cancellationToken).ConfigureAwait(false);
|
|
await context.Response.WriteAsync($"event: {evt.Type}\n", cancellationToken).ConfigureAwait(false);
|
|
await context.Response.WriteAsync($"data: {JsonSerializer.Serialize(evt)}\n\n", cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
await context.Response.Body.FlushAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
var nextCursor = startId + events.Count;
|
|
context.Response.Headers["X-Next-Cursor"] = nextCursor.ToString(CultureInfo.InvariantCulture);
|
|
logger.LogInformation("obs timeline emitted {Count} events for tenant {Tenant} starting at {StartId} next {Next}", events.Count, tenant, startId, nextCursor);
|
|
|
|
return HttpResults.Empty;
|
|
});
|
|
|
|
// ==========================================
|
|
// Signals Endpoints (CONCELIER-SIG-26-001)
|
|
// Expose affected symbol/function lists for reachability scoring
|
|
// ==========================================
|
|
|
|
app.MapGet("/v1/signals/symbols", async (
|
|
HttpContext context,
|
|
[FromQuery(Name = "advisoryId")] string? advisoryId,
|
|
[FromQuery(Name = "purl")] string? purl,
|
|
[FromQuery(Name = "symbolType")] string? symbolType,
|
|
[FromQuery(Name = "source")] string? source,
|
|
[FromQuery(Name = "withLocation")] bool? withLocation,
|
|
[FromQuery(Name = "limit")] int? limit,
|
|
[FromQuery(Name = "offset")] int? offset,
|
|
[FromServices] IAffectedSymbolProvider symbolProvider,
|
|
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;
|
|
}
|
|
|
|
// Parse symbol types if provided
|
|
ImmutableArray<AffectedSymbolType>? symbolTypes = null;
|
|
if (!string.IsNullOrWhiteSpace(symbolType))
|
|
{
|
|
var types = symbolType.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
var parsed = new List<AffectedSymbolType>();
|
|
foreach (var t in types)
|
|
{
|
|
if (Enum.TryParse<AffectedSymbolType>(t, ignoreCase: true, out var parsedType))
|
|
{
|
|
parsed.Add(parsedType);
|
|
}
|
|
}
|
|
if (parsed.Count > 0)
|
|
{
|
|
symbolTypes = parsed.ToImmutableArray();
|
|
}
|
|
}
|
|
|
|
// Parse sources if provided
|
|
ImmutableArray<string>? sources = null;
|
|
if (!string.IsNullOrWhiteSpace(source))
|
|
{
|
|
sources = source.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
.ToImmutableArray();
|
|
}
|
|
|
|
var options = new AffectedSymbolQueryOptions(
|
|
TenantId: tenant!,
|
|
AdvisoryId: advisoryId?.Trim(),
|
|
Purl: purl?.Trim(),
|
|
SymbolTypes: symbolTypes,
|
|
Sources: sources,
|
|
WithLocationOnly: withLocation,
|
|
Limit: Math.Clamp(limit ?? 100, 1, 500),
|
|
Offset: Math.Max(offset ?? 0, 0));
|
|
|
|
var result = await symbolProvider.QueryAsync(options, cancellationToken);
|
|
|
|
return HttpResults.Ok(new SignalsSymbolQueryResponse(
|
|
Symbols: result.Symbols.Select(s => ToSymbolResponse(s)).ToList(),
|
|
TotalCount: result.TotalCount,
|
|
HasMore: result.HasMore,
|
|
ComputedAt: result.ComputedAt.ToString("O", CultureInfo.InvariantCulture)));
|
|
}).WithName("QueryAffectedSymbols");
|
|
|
|
app.MapGet("/v1/signals/symbols/advisory/{advisoryId}", async (
|
|
HttpContext context,
|
|
string advisoryId,
|
|
[FromServices] IAffectedSymbolProvider symbolProvider,
|
|
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;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(advisoryId))
|
|
{
|
|
return ConcelierProblemResultFactory.AdvisoryIdRequired(context);
|
|
}
|
|
|
|
var symbolSet = await symbolProvider.GetByAdvisoryAsync(tenant!, advisoryId.Trim(), cancellationToken);
|
|
|
|
return HttpResults.Ok(ToSymbolSetResponse(symbolSet));
|
|
}).WithName("GetAffectedSymbolsByAdvisory");
|
|
|
|
app.MapGet("/v1/signals/symbols/package/{*purl}", async (
|
|
HttpContext context,
|
|
string purl,
|
|
[FromServices] IAffectedSymbolProvider symbolProvider,
|
|
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;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(purl))
|
|
{
|
|
return HttpResults.Problem(
|
|
statusCode: StatusCodes.Status400BadRequest,
|
|
title: "Package URL required",
|
|
detail: "The purl parameter is required.",
|
|
type: "https://stellaops.org/problems/validation");
|
|
}
|
|
|
|
var symbolSet = await symbolProvider.GetByPackageAsync(tenant!, purl.Trim(), cancellationToken);
|
|
|
|
return HttpResults.Ok(ToSymbolSetResponse(symbolSet));
|
|
}).WithName("GetAffectedSymbolsByPackage");
|
|
|
|
app.MapPost("/v1/signals/symbols/batch", async (
|
|
HttpContext context,
|
|
[FromBody] SignalsSymbolBatchRequest request,
|
|
[FromServices] IAffectedSymbolProvider symbolProvider,
|
|
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;
|
|
}
|
|
|
|
if (request.AdvisoryIds is not { Count: > 0 })
|
|
{
|
|
return HttpResults.Problem(
|
|
statusCode: StatusCodes.Status400BadRequest,
|
|
title: "Advisory IDs required",
|
|
detail: "At least one advisoryId is required in the batch request.",
|
|
type: "https://stellaops.org/problems/validation");
|
|
}
|
|
|
|
if (request.AdvisoryIds.Count > 100)
|
|
{
|
|
return HttpResults.Problem(
|
|
statusCode: StatusCodes.Status400BadRequest,
|
|
title: "Batch size exceeded",
|
|
detail: "Maximum batch size is 100 advisory IDs.",
|
|
type: "https://stellaops.org/problems/validation");
|
|
}
|
|
|
|
var results = await symbolProvider.GetByAdvisoriesBatchAsync(tenant!, request.AdvisoryIds, cancellationToken);
|
|
|
|
var response = new SignalsSymbolBatchResponse(
|
|
Results: results.ToDictionary(
|
|
kvp => kvp.Key,
|
|
kvp => ToSymbolSetResponse(kvp.Value)));
|
|
|
|
return HttpResults.Ok(response);
|
|
}).WithName("GetAffectedSymbolsBatch");
|
|
|
|
app.MapGet("/v1/signals/symbols/exists/{advisoryId}", async (
|
|
HttpContext context,
|
|
string advisoryId,
|
|
[FromServices] IAffectedSymbolProvider symbolProvider,
|
|
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;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(advisoryId))
|
|
{
|
|
return ConcelierProblemResultFactory.AdvisoryIdRequired(context);
|
|
}
|
|
|
|
var exists = await symbolProvider.HasSymbolsAsync(tenant!, advisoryId.Trim(), cancellationToken);
|
|
|
|
return HttpResults.Ok(new SignalsSymbolExistsResponse(Exists: exists, AdvisoryId: advisoryId.Trim()));
|
|
}).WithName("CheckAffectedSymbolsExist");
|
|
|
|
await app.RunAsync();
|
|
}
|
|
|
|
static JsonSerializerOptions CreateJsonOptions()
|
|
{
|
|
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
|
options.Converters.Add(new JsonStringEnumConverter());
|
|
return options;
|
|
}
|
|
|
|
// Linkset summary used by advisory summary timeline
|
|
private readonly record struct LinksetObservationSummary(
|
|
DateTimeOffset? PublishedAt,
|
|
DateTimeOffset? ModifiedAt,
|
|
string? Severity,
|
|
string? EvidenceHash)
|
|
{
|
|
public static LinksetObservationSummary Empty { get; } = new(null, null, null, null);
|
|
}
|
|
|
|
// ==========================================
|
|
// Signals API Response Types (CONCELIER-SIG-26-001)
|
|
// ==========================================
|
|
|
|
record SignalsSymbolQueryResponse(
|
|
List<SignalsSymbolResponse> Symbols,
|
|
int TotalCount,
|
|
bool HasMore,
|
|
string ComputedAt);
|
|
|
|
record SignalsSymbolResponse(
|
|
string AdvisoryId,
|
|
string ObservationId,
|
|
string Symbol,
|
|
string SymbolType,
|
|
string? Purl,
|
|
string? Module,
|
|
string? ClassName,
|
|
string? FilePath,
|
|
int? LineNumber,
|
|
string? VersionRange,
|
|
string CanonicalId,
|
|
bool HasSourceLocation,
|
|
SignalsSymbolProvenanceResponse Provenance);
|
|
|
|
record SignalsSymbolProvenanceResponse(
|
|
string Source,
|
|
string Vendor,
|
|
string ObservationHash,
|
|
string FetchedAt,
|
|
string? IngestJobId,
|
|
string? UpstreamId,
|
|
string? UpstreamUrl);
|
|
|
|
record SignalsSymbolSetResponse(
|
|
string TenantId,
|
|
string AdvisoryId,
|
|
List<SignalsSymbolResponse> Symbols,
|
|
List<SignalsSymbolSourceSummaryResponse> SourceSummaries,
|
|
int UniqueSymbolCount,
|
|
bool HasSourceLocations,
|
|
string ComputedAt);
|
|
|
|
record SignalsSymbolSourceSummaryResponse(
|
|
string Source,
|
|
int SymbolCount,
|
|
int WithLocationCount,
|
|
Dictionary<string, int> CountByType,
|
|
string LatestFetchAt);
|
|
|
|
record SignalsSymbolBatchRequest(
|
|
List<string> AdvisoryIds);
|
|
|
|
record SignalsSymbolBatchResponse(
|
|
Dictionary<string, SignalsSymbolSetResponse> Results);
|
|
|
|
record SignalsSymbolExistsResponse(
|
|
bool Exists,
|
|
string AdvisoryId);
|
|
|
|
// ==========================================
|
|
// Signals API Helper Methods
|
|
// ==========================================
|
|
|
|
static SignalsSymbolResponse ToSymbolResponse(AffectedSymbol symbol)
|
|
{
|
|
return new SignalsSymbolResponse(
|
|
AdvisoryId: symbol.AdvisoryId,
|
|
ObservationId: symbol.ObservationId,
|
|
Symbol: symbol.Symbol,
|
|
SymbolType: symbol.SymbolType.ToString(),
|
|
Purl: symbol.Purl,
|
|
Module: symbol.Module,
|
|
ClassName: symbol.ClassName,
|
|
FilePath: symbol.FilePath,
|
|
LineNumber: symbol.LineNumber,
|
|
VersionRange: symbol.VersionRange,
|
|
CanonicalId: symbol.CanonicalId,
|
|
HasSourceLocation: symbol.HasSourceLocation,
|
|
Provenance: new SignalsSymbolProvenanceResponse(
|
|
Source: symbol.Provenance.Source,
|
|
Vendor: symbol.Provenance.Vendor,
|
|
ObservationHash: symbol.Provenance.ObservationHash,
|
|
FetchedAt: symbol.Provenance.FetchedAt.ToString("O", CultureInfo.InvariantCulture),
|
|
IngestJobId: symbol.Provenance.IngestJobId,
|
|
UpstreamId: symbol.Provenance.UpstreamId,
|
|
UpstreamUrl: symbol.Provenance.UpstreamUrl));
|
|
}
|
|
|
|
static SignalsSymbolSetResponse ToSymbolSetResponse(AffectedSymbolSet symbolSet)
|
|
{
|
|
return new SignalsSymbolSetResponse(
|
|
TenantId: symbolSet.TenantId,
|
|
AdvisoryId: symbolSet.AdvisoryId,
|
|
Symbols: symbolSet.Symbols.Select(ToSymbolResponse).ToList(),
|
|
SourceSummaries: symbolSet.SourceSummaries.Select(s => new SignalsSymbolSourceSummaryResponse(
|
|
Source: s.Source,
|
|
SymbolCount: s.SymbolCount,
|
|
WithLocationCount: s.WithLocationCount,
|
|
CountByType: s.CountByType.ToDictionary(kvp => kvp.Key.ToString(), kvp => kvp.Value),
|
|
LatestFetchAt: s.LatestFetchAt.ToString("O", CultureInfo.InvariantCulture))).ToList(),
|
|
UniqueSymbolCount: symbolSet.UniqueSymbolCount,
|
|
HasSourceLocations: symbolSet.HasSourceLocations,
|
|
ComputedAt: symbolSet.ComputedAt.ToString("O", CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
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 InitializePostgresAsync(WebApplication app)
|
|
{
|
|
var dataSource = app.Services.GetService<ConcelierDataSource>();
|
|
var status = app.Services.GetRequiredService<StellaOps.Concelier.WebService.Diagnostics.ServiceStatus>();
|
|
|
|
if (dataSource is null)
|
|
{
|
|
status.RecordStorageCheck(false, TimeSpan.Zero, "PostgreSQL storage not configured");
|
|
return;
|
|
}
|
|
|
|
var stopwatch = Stopwatch.StartNew();
|
|
try
|
|
{
|
|
var (ready, latency, error) = await CheckPostgresAsync(dataSource, CancellationToken.None).ConfigureAwait(false);
|
|
stopwatch.Stop();
|
|
status.RecordStorageCheck(ready, latency, error);
|
|
if (ready)
|
|
{
|
|
status.MarkBootstrapCompleted(latency);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
stopwatch.Stop();
|
|
status.RecordStorageCheck(false, stopwatch.Elapsed, ex.Message);
|
|
}
|
|
}
|
|
|
|
static async Task<(bool Ready, TimeSpan Latency, string? Error)> CheckPostgresAsync(
|
|
ConcelierDataSource dataSource,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var stopwatch = Stopwatch.StartNew();
|
|
try
|
|
{
|
|
await using var connection = await dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = "select 1";
|
|
_ = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
|
stopwatch.Stop();
|
|
return (true, stopwatch.Elapsed, null);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
stopwatch.Stop();
|
|
return (false, stopwatch.Elapsed, ex.Message);
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
}
|