feat: Add documentation and task tracking for Sprints 508 to 514 in Ops & Offline
- Created detailed markdown files for Sprints 508 (Ops Offline Kit), 509 (Samples), 510 (AirGap), 511 (Api), 512 (Bench), 513 (Provenance), and 514 (Sovereign Crypto Enablement) outlining tasks, dependencies, and owners. - Introduced a comprehensive Reachability Evidence Delivery Guide to streamline the reachability signal process. - Implemented unit tests for Advisory AI to block known injection patterns and redact secrets. - Added AuthoritySenderConstraintHelper to manage sender constraints in OpenIddict transactions.
This commit is contained in:
@@ -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 scope in concelierOptions.Authority.RequiredScopes)
|
||||
{
|
||||
resourceOptions.RequiredScopes.Add(scope);
|
||||
}
|
||||
foreach (var audience in concelierOptions.Authority.Audiences)
|
||||
{
|
||||
resourceOptions.Audiences.Add(audience);
|
||||
}
|
||||
|
||||
foreach (var network in concelierOptions.Authority.BypassNetworks)
|
||||
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(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
resourceOptions.BypassNetworks.Add(network);
|
||||
}
|
||||
});
|
||||
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
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
advisoryKey,
|
||||
canonicalKey,
|
||||
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;
|
||||
| ||||