@@ -5,6 +5,8 @@ using System.Globalization;
using System.Linq ;
using System.Security.Claims ;
using System.Text ;
using Microsoft.AspNetCore.Authentication.JwtBearer ;
using Microsoft.IdentityModel.Tokens ;
using Microsoft.AspNetCore.Diagnostics ;
using Microsoft.AspNetCore.Http ;
using Microsoft.AspNetCore.Mvc ;
@@ -16,6 +18,7 @@ using System.Text.Json;
using System.Text.Json.Serialization ;
using Microsoft.Extensions.Logging ;
using Microsoft.Extensions.Options ;
using Microsoft.Extensions.Primitives ;
using MongoDB.Bson ;
using MongoDB.Driver ;
using StellaOps.Concelier.Core.Events ;
@@ -101,6 +104,7 @@ builder.Services.AddConcelierAocGuards();
builder . Services . AddConcelierLinksetMappers ( ) ;
builder . Services . AddAdvisoryRawServices ( ) ;
builder . Services . AddSingleton < IAdvisoryObservationQueryService , AdvisoryObservationQueryService > ( ) ;
builder . Services . AddSingleton < AdvisoryChunkBuilder > ( ) ;
var features = concelierOptions . Features ? ? new ConcelierOptions . FeaturesOptions ( ) ;
@@ -139,6 +143,7 @@ builder.Services.AddAocGuard();
var authorityConfigured = concelierOptions . Authority is { Enabled : true } ;
if ( authorityConfigured )
{
builder . Services . AddStellaOpsAuthClient ( clientOptions = >
@@ -180,36 +185,61 @@ if (authorityConfigured)
}
} ) ;
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 ) )
if ( string . IsNullOrWhiteSpace ( concelierOptions . Authority . TestSigningSecret ) )
{
builder . Services . AddStellaOpsResourceServerAuthentication (
builder . Configuration ,
configurationSection : null ,
configure : resourceOptions = >
{
resourceOptions . MetadataAddress = concelierOptions . Authority . MetadataAddress ;
}
resourceOptions . Authority = concelierOptions . Authority . Issuer ;
resourceOptions . RequireHttpsMetadata = concelierOptions . Authority . RequireHttpsMetadata ;
resourceOptions . BackchannelTimeout = TimeSpan . FromSeconds ( concelierOptions . Authority . BackchannelTimeoutSeconds ) ;
resourceOptions . TokenClockSkew = TimeSpan . FromSeconds ( concelierOptions . Authority . TokenClockSkewSeconds ) ;
foreach ( var audience in concelierOptions . Authority . Audiences )
{
resourceOptions . Audiences . Add ( audience ) ;
}
if ( ! string . IsNullOrWhiteSpace ( concelierOptions . Authority . MetadataAddress ) )
{
resourceOptions . MetadataAddress = concelierOptions . Authority . MetadataAddress ;
}
foreach ( var scop e in concelierOptions . Authority . RequiredScop es)
{
resourceOptions . RequiredScop es. Add ( scop e) ;
}
foreach ( var audienc e in concelierOptions . Authority . Audienc es)
{
resourceOptions . Audienc es. Add ( audienc e) ;
}
foreach ( var network in concelierOptions . Authority . BypassNetwork s)
foreach ( var scope in concelierOptions . Authority . RequiredScope s)
{
resourceOptions . RequiredScopes . Add ( scope ) ;
}
foreach ( var network in concelierOptions . Authority . BypassNetworks )
{
resourceOptions . BypassNetworks . Add ( network ) ;
}
} ) ;
}
else
{
builder . Services . AddAuthentication ( JwtBearerDefaults . AuthenticationScheme )
. AddJwtBearer ( options = >
{
resourceO ptions. BypassNetworks . Add ( network ) ;
}
} ) ;
o ptions. 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
} ;
} ) ;
}
}
builder . Services . AddAuthorization ( options = >
{
@@ -250,6 +280,12 @@ if (resolvedAuthority.Enabled && resolvedAuthority.AllowAnonymousFallback)
"Authority authentication is configured but anonymous fallback remains enabled. Set authority.allowAnonymousFallback to false before 2025-12-31 to complete the rollout." ) ;
}
if ( authorityConfigured )
{
app . UseAuthentication ( ) ;
app . UseAuthorization ( ) ;
}
app . MapConcelierMirrorEndpoints ( authorityConfigured , enforceAuthority ) ;
app . MapGet ( "/.well-known/openapi" , ( [ FromServices ] OpenApiDiscoveryDocumentProvider provider , HttpContext context ) = >
@@ -689,16 +725,18 @@ var advisoryEvidenceEndpoint = app.MapGet("/vuln/evidence/advisories/{advisoryKe
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 ,
advisory Key,
canonical Key,
vendorFilter ,
cancellationToken ) . ConfigureAwait ( false ) ;
if ( records . Count = = 0 )
{
return Results . NotFound ( ) ;
return Problem ( context , "Advisory not found" , StatusCodes . Status404NotFound , ProblemTypes . NotFound , $"No evidence available for {normalizedKey}." ) ;
}
var recordResponses = records
@@ -710,7 +748,8 @@ var advisoryEvidenceEndpoint = app.MapGet("/vuln/evidence/advisories/{advisoryKe
record . Document ) )
. ToArray ( ) ;
var response = new AdvisoryEvidenceResponse ( recordResponses [ 0 ] . Document . AdvisoryKey , recordResponses ) ;
var responseKey = recordResponses [ 0 ] . Document . AdvisoryKey ? ? canonicalKey ;
var response = new AdvisoryEvidenceResponse ( responseKey , recordResponses ) ;
return JsonResult ( response ) ;
} ) ;
if ( authorityConfigured )
@@ -718,6 +757,67 @@ if (authorityConfigured)
advisoryEvidenceEndpoint . RequireAuthorization ( AdvisoryReadPolicyName ) ;
}
var advisoryChunksEndpoint = app . MapGet ( "/advisories/{advisoryKey}/chunks" , async (
string advisoryKey ,
HttpContext context ,
[FromServices] IAdvisoryObservationQueryService observationService ,
[FromServices] AdvisoryChunkBuilder chunkBuilder ,
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 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 queryOptions = new AdvisoryObservationQueryOptions (
tenant ,
aliases : new [ ] { normalizedKey } ,
limit : observationLimit ) ;
var observationResult = await observationService . QueryAsync ( queryOptions , cancellationToken ) . ConfigureAwait ( false ) ;
if ( observationResult . Observations . IsDefaultOrEmpty | | observationResult . Observations . Length = = 0 )
{
return Problem ( context , "Advisory not found" , StatusCodes . Status404NotFound , ProblemTypes . NotFound , $"No observations available for {normalizedKey}." ) ;
}
var buildOptions = new AdvisoryChunkBuildOptions (
normalizedKey ,
chunkLimit ,
observationLimit ,
sectionFilter ,
formatFilter ,
minimumLength ) ;
var response = chunkBuilder . Build ( buildOptions , observationResult . Observations . ToArray ( ) ) ;
return JsonResult ( response ) ;
} ) ;
if ( authorityConfigured )
{
advisoryChunksEndpoint . RequireAuthorization ( AdvisoryReadPolicyName ) ;
}
var aocVerifyEndpoint = app . MapPost ( "/aoc/verify" , async (
HttpContext context ,
AocVerifyRequest request ,
@@ -932,12 +1032,6 @@ if (authorityConfigured)
} ) ;
}
if ( authorityConfigured )
{
app . UseAuthentication ( ) ;
app . UseAuthorization ( ) ;
}
IResult JsonResult < T > ( T value , int? statusCode = null )
{
var payload = JsonSerializer . Serialize ( value , jsonOptions ) ;
@@ -1049,6 +1143,53 @@ IResult? EnsureTenantAuthorized(HttpContext context, string tenant)
return null ;
}
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 DateTimeOffset ? ParseDateTime ( string? value )
{
if ( string . IsNullOrWhiteSpace ( value ) )
@@ -1474,3 +1615,4 @@ static async Task InitializeMongoAsync(WebApplication app)
}
public partial class Program ;