save development progress

This commit is contained in:
StellaOps Bot
2025-12-25 23:09:58 +02:00
parent d71853ad7e
commit aa70af062e
351 changed files with 37683 additions and 150156 deletions

View File

@@ -114,6 +114,9 @@ public static class ErrorCodes
/// <summary>AirGap mode is disabled.</summary>
public const string AirGapDisabled = "AIRGAP_DISABLED";
/// <summary>Federation sync is disabled.</summary>
public const string FederationDisabled = "FEDERATION_DISABLED";
/// <summary>Sealed mode violation.</summary>
public const string SealedModeViolation = "SEALED_MODE_VIOLATION";

View File

@@ -1,12 +1,13 @@
// -----------------------------------------------------------------------------
// CanonicalAdvisoryEndpointExtensions.cs
// Sprint: SPRINT_8200_0012_0003_CONCEL_canonical_advisory_service
// Tasks: CANSVC-8200-016 through CANSVC-8200-019
// Tasks: CANSVC-8200-016 through CANSVC-8200-019, ISCORE-8200-030
// Description: API endpoints for canonical advisory service
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Mvc;
using StellaOps.Concelier.Core.Canonical;
using StellaOps.Concelier.Interest;
using StellaOps.Concelier.WebService.Results;
using HttpResults = Microsoft.AspNetCore.Http.Results;
@@ -29,14 +30,25 @@ internal static class CanonicalAdvisoryEndpointExtensions
group.MapGet("/{id:guid}", async (
Guid id,
ICanonicalAdvisoryService service,
IInterestScoringService? scoringService,
HttpContext context,
CancellationToken ct) =>
{
var canonical = await service.GetByIdAsync(id, ct).ConfigureAwait(false);
return canonical is null
? HttpResults.NotFound(new { error = "Canonical advisory not found", id })
: HttpResults.Ok(MapToResponse(canonical));
if (canonical is null)
{
return HttpResults.NotFound(new { error = "Canonical advisory not found", id });
}
// Fetch interest score if scoring service is available
Interest.Models.InterestScore? score = null;
if (scoringService is not null)
{
score = await scoringService.GetScoreAsync(id, ct).ConfigureAwait(false);
}
return HttpResults.Ok(MapToResponse(canonical, score));
})
.WithName("GetCanonicalById")
.WithSummary("Get canonical advisory by ID")
@@ -73,7 +85,7 @@ internal static class CanonicalAdvisoryEndpointExtensions
var byCve = await service.GetByCveAsync(cve, ct).ConfigureAwait(false);
return HttpResults.Ok(new CanonicalAdvisoryListResponse
{
Items = byCve.Select(MapToResponse).ToList(),
Items = byCve.Select(c => MapToResponse(c)).ToList(),
TotalCount = byCve.Count
});
}
@@ -84,7 +96,7 @@ internal static class CanonicalAdvisoryEndpointExtensions
var byArtifact = await service.GetByArtifactAsync(artifact, ct).ConfigureAwait(false);
return HttpResults.Ok(new CanonicalAdvisoryListResponse
{
Items = byArtifact.Select(MapToResponse).ToList(),
Items = byArtifact.Select(c => MapToResponse(c)).ToList(),
TotalCount = byArtifact.Count
});
}
@@ -99,7 +111,7 @@ internal static class CanonicalAdvisoryEndpointExtensions
var result = await service.QueryAsync(options, ct).ConfigureAwait(false);
return HttpResults.Ok(new CanonicalAdvisoryListResponse
{
Items = result.Items.Select(MapToResponse).ToList(),
Items = result.Items.Select(c => MapToResponse(c)).ToList(),
TotalCount = result.TotalCount,
Offset = result.Offset,
Limit = result.Limit
@@ -252,7 +264,9 @@ internal static class CanonicalAdvisoryEndpointExtensions
.Produces(StatusCodes.Status400BadRequest);
}
private static CanonicalAdvisoryResponse MapToResponse(CanonicalAdvisory canonical) => new()
private static CanonicalAdvisoryResponse MapToResponse(
CanonicalAdvisory canonical,
Interest.Models.InterestScore? score = null) => new()
{
Id = canonical.Id,
Cve = canonical.Cve,
@@ -268,6 +282,13 @@ internal static class CanonicalAdvisoryEndpointExtensions
Weaknesses = canonical.Weaknesses,
CreatedAt = canonical.CreatedAt,
UpdatedAt = canonical.UpdatedAt,
InterestScore = score is not null ? new InterestScoreInfo
{
Score = score.Score,
Tier = score.Tier.ToString(),
Reasons = score.Reasons,
ComputedAt = score.ComputedAt
} : null,
SourceEdges = canonical.SourceEdges.Select(e => new SourceEdgeResponse
{
Id = e.Id,
@@ -303,9 +324,21 @@ public sealed record CanonicalAdvisoryResponse
public IReadOnlyList<string> Weaknesses { get; init; } = [];
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
public InterestScoreInfo? InterestScore { get; init; }
public IReadOnlyList<SourceEdgeResponse> SourceEdges { get; init; } = [];
}
/// <summary>
/// Interest score information embedded in advisory response.
/// </summary>
public sealed record InterestScoreInfo
{
public double Score { get; init; }
public required string Tier { get; init; }
public IReadOnlyList<string> Reasons { get; init; } = [];
public DateTimeOffset ComputedAt { get; init; }
}
/// <summary>
/// Response for a source edge.
/// </summary>

View File

@@ -0,0 +1,132 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Federation.Export;
using StellaOps.Concelier.Federation.Models;
using StellaOps.Concelier.WebService.Options;
using StellaOps.Concelier.WebService.Results;
using HttpResults = Microsoft.AspNetCore.Http.Results;
namespace StellaOps.Concelier.WebService.Extensions;
/// <summary>
/// Endpoint extensions for Federation functionality.
/// Per SPRINT_8200_0014_0002_CONCEL_delta_bundle_export.
/// </summary>
internal static class FederationEndpointExtensions
{
public static void MapConcelierFederationEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/v1/federation")
.WithTags("Federation");
// GET /api/v1/federation/export - Export delta bundle
group.MapGet("/export", async (
HttpContext context,
IBundleExportService exportService,
IOptionsMonitor<ConcelierOptions> optionsMonitor,
CancellationToken cancellationToken,
[FromQuery(Name = "since_cursor")] string? sinceCursor = null,
[FromQuery] bool sign = true,
[FromQuery(Name = "max_items")] int maxItems = 10000,
[FromQuery(Name = "compress_level")] int compressLevel = 3) =>
{
var options = optionsMonitor.CurrentValue;
if (!options.Federation.Enabled)
{
return ConcelierProblemResultFactory.FederationDisabled(context);
}
// Validate parameters
if (maxItems < 1 || maxItems > 100_000)
{
return HttpResults.BadRequest(new { error = "max_items must be between 1 and 100000" });
}
if (compressLevel < 1 || compressLevel > 19)
{
return HttpResults.BadRequest(new { error = "compress_level must be between 1 and 19" });
}
var exportOptions = new BundleExportOptions
{
Sign = sign,
MaxItems = maxItems,
CompressionLevel = compressLevel
};
// Set response headers for streaming
context.Response.ContentType = "application/zstd";
context.Response.Headers.ContentDisposition =
$"attachment; filename=\"feedser-bundle-{DateTime.UtcNow:yyyyMMdd-HHmmss}.zst\"";
// Export directly to response stream
var result = await exportService.ExportToStreamAsync(
context.Response.Body,
sinceCursor,
exportOptions,
cancellationToken);
// Add metadata headers
context.Response.Headers.Append("X-Bundle-Hash", result.BundleHash);
context.Response.Headers.Append("X-Export-Cursor", result.ExportCursor);
context.Response.Headers.Append("X-Items-Count", result.Counts.Total.ToString());
return HttpResults.Empty;
})
.WithName("ExportFederationBundle")
.WithSummary("Export delta bundle for federation sync")
.Produces(200, contentType: "application/zstd")
.ProducesProblem(400)
.ProducesProblem(503);
// GET /api/v1/federation/export/preview - Preview export statistics
group.MapGet("/export/preview", async (
HttpContext context,
IBundleExportService exportService,
IOptionsMonitor<ConcelierOptions> optionsMonitor,
CancellationToken cancellationToken,
[FromQuery(Name = "since_cursor")] string? sinceCursor = null) =>
{
var options = optionsMonitor.CurrentValue;
if (!options.Federation.Enabled)
{
return ConcelierProblemResultFactory.FederationDisabled(context);
}
var preview = await exportService.PreviewAsync(sinceCursor, cancellationToken);
return HttpResults.Ok(new
{
since_cursor = sinceCursor,
estimated_canonicals = preview.EstimatedCanonicals,
estimated_edges = preview.EstimatedEdges,
estimated_deletions = preview.EstimatedDeletions,
estimated_size_bytes = preview.EstimatedSizeBytes,
estimated_size_mb = Math.Round(preview.EstimatedSizeBytes / 1024.0 / 1024.0, 2)
});
})
.WithName("PreviewFederationExport")
.WithSummary("Preview export statistics without creating bundle")
.Produces<object>(200)
.ProducesProblem(503);
// GET /api/v1/federation/status - Federation status
group.MapGet("/status", (
HttpContext context,
IOptionsMonitor<ConcelierOptions> optionsMonitor) =>
{
var options = optionsMonitor.CurrentValue;
return HttpResults.Ok(new
{
enabled = options.Federation.Enabled,
site_id = options.Federation.SiteId,
default_compression_level = options.Federation.DefaultCompressionLevel,
default_max_items = options.Federation.DefaultMaxItems
});
})
.WithName("GetFederationStatus")
.WithSummary("Get federation configuration status")
.Produces<object>(200);
}
}

View File

@@ -0,0 +1,311 @@
// -----------------------------------------------------------------------------
// InterestScoreEndpointExtensions.cs
// Sprint: SPRINT_8200_0013_0002_CONCEL_interest_scoring
// Tasks: ISCORE-8200-029 through ISCORE-8200-031
// Description: API endpoints for interest scoring service
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Mvc;
using StellaOps.Concelier.Interest;
using StellaOps.Concelier.Interest.Models;
using HttpResults = Microsoft.AspNetCore.Http.Results;
namespace StellaOps.Concelier.WebService.Extensions;
/// <summary>
/// Endpoint extensions for interest score operations.
/// </summary>
internal static class InterestScoreEndpointExtensions
{
private const string ScoreReadPolicy = "Concelier.Interest.Read";
private const string ScoreAdminPolicy = "Concelier.Interest.Admin";
public static void MapInterestScoreEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/v1")
.WithTags("Interest Scores");
// GET /api/v1/canonical/{id}/score - Get interest score for a canonical advisory
group.MapGet("/canonical/{id:guid}/score", async (
Guid id,
IInterestScoringService scoringService,
CancellationToken ct) =>
{
var score = await scoringService.GetScoreAsync(id, ct).ConfigureAwait(false);
return score is null
? HttpResults.NotFound(new { error = "Interest score not found", canonicalId = id })
: HttpResults.Ok(MapToResponse(score));
})
.WithName("GetInterestScore")
.WithSummary("Get interest score for a canonical advisory")
.Produces<InterestScoreResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// GET /api/v1/scores - Query interest scores
group.MapGet("/scores", async (
[FromQuery] double? minScore,
[FromQuery] double? maxScore,
[FromQuery] int? offset,
[FromQuery] int? limit,
IInterestScoreRepository repository,
CancellationToken ct) =>
{
var scores = await repository.GetAllAsync(offset ?? 0, limit ?? 50, ct).ConfigureAwait(false);
// Filter by score range if specified
var filtered = scores.AsEnumerable();
if (minScore.HasValue)
{
filtered = filtered.Where(s => s.Score >= minScore.Value);
}
if (maxScore.HasValue)
{
filtered = filtered.Where(s => s.Score <= maxScore.Value);
}
var items = filtered.Select(MapToResponse).ToList();
return HttpResults.Ok(new InterestScoreListResponse
{
Items = items,
TotalCount = items.Count,
Offset = offset ?? 0,
Limit = limit ?? 50
});
})
.WithName("QueryInterestScores")
.WithSummary("Query interest scores with optional filtering")
.Produces<InterestScoreListResponse>(StatusCodes.Status200OK);
// GET /api/v1/scores/distribution - Get score distribution statistics
group.MapGet("/scores/distribution", async (
IInterestScoreRepository repository,
CancellationToken ct) =>
{
var distribution = await repository.GetScoreDistributionAsync(ct).ConfigureAwait(false);
return HttpResults.Ok(new ScoreDistributionResponse
{
HighCount = distribution.HighCount,
MediumCount = distribution.MediumCount,
LowCount = distribution.LowCount,
NoneCount = distribution.NoneCount,
TotalCount = distribution.TotalCount,
AverageScore = distribution.AverageScore,
MedianScore = distribution.MedianScore
});
})
.WithName("GetScoreDistribution")
.WithSummary("Get score distribution statistics")
.Produces<ScoreDistributionResponse>(StatusCodes.Status200OK);
// POST /api/v1/canonical/{id}/score/compute - Compute score for a canonical
group.MapPost("/canonical/{id:guid}/score/compute", async (
Guid id,
IInterestScoringService scoringService,
CancellationToken ct) =>
{
var score = await scoringService.ComputeScoreAsync(id, ct).ConfigureAwait(false);
await scoringService.UpdateScoreAsync(score, ct).ConfigureAwait(false);
return HttpResults.Ok(MapToResponse(score));
})
.WithName("ComputeInterestScore")
.WithSummary("Compute and update interest score for a canonical advisory")
.Produces<InterestScoreResponse>(StatusCodes.Status200OK);
// POST /api/v1/scores/recalculate - Admin endpoint to trigger full recalculation
group.MapPost("/scores/recalculate", async (
[FromBody] RecalculateRequest? request,
IInterestScoringService scoringService,
CancellationToken ct) =>
{
int updated;
if (request?.CanonicalIds?.Count > 0)
{
// Batch recalculation for specific IDs
updated = await scoringService.BatchUpdateAsync(request.CanonicalIds, ct).ConfigureAwait(false);
}
else
{
// Full recalculation
updated = await scoringService.RecalculateAllAsync(ct).ConfigureAwait(false);
}
return HttpResults.Accepted((string?)null, new RecalculateResponse
{
Updated = updated,
Mode = request?.CanonicalIds?.Count > 0 ? "batch" : "full",
StartedAt = DateTimeOffset.UtcNow
});
})
.WithName("RecalculateScores")
.WithSummary("Trigger interest score recalculation (full or batch)")
.Produces<RecalculateResponse>(StatusCodes.Status202Accepted);
// POST /api/v1/scores/degrade - Admin endpoint to run stub degradation
group.MapPost("/scores/degrade", async (
[FromBody] DegradeRequest? request,
IInterestScoringService scoringService,
Microsoft.Extensions.Options.IOptions<InterestScoreOptions> options,
CancellationToken ct) =>
{
var threshold = request?.Threshold ?? options.Value.DegradationPolicy.DegradationThreshold;
var degraded = await scoringService.DegradeToStubsAsync(threshold, ct).ConfigureAwait(false);
return HttpResults.Ok(new DegradeResponse
{
Degraded = degraded,
Threshold = threshold,
ExecutedAt = DateTimeOffset.UtcNow
});
})
.WithName("DegradeToStubs")
.WithSummary("Degrade low-interest advisories to stubs")
.Produces<DegradeResponse>(StatusCodes.Status200OK);
// POST /api/v1/scores/restore - Admin endpoint to restore stubs
group.MapPost("/scores/restore", async (
[FromBody] RestoreRequest? request,
IInterestScoringService scoringService,
Microsoft.Extensions.Options.IOptions<InterestScoreOptions> options,
CancellationToken ct) =>
{
var threshold = request?.Threshold ?? options.Value.DegradationPolicy.RestorationThreshold;
var restored = await scoringService.RestoreFromStubsAsync(threshold, ct).ConfigureAwait(false);
return HttpResults.Ok(new RestoreResponse
{
Restored = restored,
Threshold = threshold,
ExecutedAt = DateTimeOffset.UtcNow
});
})
.WithName("RestoreFromStubs")
.WithSummary("Restore stubs with increased interest scores")
.Produces<RestoreResponse>(StatusCodes.Status200OK);
}
private static InterestScoreResponse MapToResponse(InterestScore score) => new()
{
CanonicalId = score.CanonicalId,
Score = score.Score,
Tier = score.Tier.ToString(),
Reasons = score.Reasons,
LastSeenInBuild = score.LastSeenInBuild,
ComputedAt = score.ComputedAt
};
}
#region Response DTOs
/// <summary>
/// Response for an interest score.
/// </summary>
public sealed record InterestScoreResponse
{
public Guid CanonicalId { get; init; }
public double Score { get; init; }
public required string Tier { get; init; }
public IReadOnlyList<string> Reasons { get; init; } = [];
public Guid? LastSeenInBuild { get; init; }
public DateTimeOffset ComputedAt { get; init; }
}
/// <summary>
/// Response for a list of interest scores.
/// </summary>
public sealed record InterestScoreListResponse
{
public IReadOnlyList<InterestScoreResponse> Items { get; init; } = [];
public long TotalCount { get; init; }
public int Offset { get; init; }
public int Limit { get; init; }
}
/// <summary>
/// Response for score distribution.
/// </summary>
public sealed record ScoreDistributionResponse
{
public long HighCount { get; init; }
public long MediumCount { get; init; }
public long LowCount { get; init; }
public long NoneCount { get; init; }
public long TotalCount { get; init; }
public double AverageScore { get; init; }
public double MedianScore { get; init; }
}
/// <summary>
/// Response for recalculation operation.
/// </summary>
public sealed record RecalculateResponse
{
public int Updated { get; init; }
public required string Mode { get; init; }
public DateTimeOffset StartedAt { get; init; }
}
/// <summary>
/// Response for degradation operation.
/// </summary>
public sealed record DegradeResponse
{
public int Degraded { get; init; }
public double Threshold { get; init; }
public DateTimeOffset ExecutedAt { get; init; }
}
/// <summary>
/// Response for restoration operation.
/// </summary>
public sealed record RestoreResponse
{
public int Restored { get; init; }
public double Threshold { get; init; }
public DateTimeOffset ExecutedAt { get; init; }
}
#endregion
#region Request DTOs
/// <summary>
/// Request for recalculation operation.
/// </summary>
public sealed record RecalculateRequest
{
/// <summary>
/// Optional list of canonical IDs to recalculate.
/// If empty or null, full recalculation is performed.
/// </summary>
public IReadOnlyList<Guid>? CanonicalIds { get; init; }
}
/// <summary>
/// Request for degradation operation.
/// </summary>
public sealed record DegradeRequest
{
/// <summary>
/// Optional threshold override. If not specified, uses configured default.
/// </summary>
public double? Threshold { get; init; }
}
/// <summary>
/// Request for restoration operation.
/// </summary>
public sealed record RestoreRequest
{
/// <summary>
/// Optional threshold override. If not specified, uses configured default.
/// </summary>
public double? Threshold { get; init; }
}
#endregion

View File

@@ -0,0 +1,350 @@
// -----------------------------------------------------------------------------
// SbomEndpointExtensions.cs
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
// Tasks: SBOM-8200-022 through SBOM-8200-024
// Description: API endpoints for SBOM registration and learning
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Mvc;
using StellaOps.Concelier.SbomIntegration;
using StellaOps.Concelier.SbomIntegration.Models;
using HttpResults = Microsoft.AspNetCore.Http.Results;
namespace StellaOps.Concelier.WebService.Extensions;
/// <summary>
/// Endpoint extensions for SBOM operations.
/// </summary>
internal static class SbomEndpointExtensions
{
public static void MapSbomEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/v1")
.WithTags("SBOM Learning");
// POST /api/v1/learn/sbom - Register and learn from an SBOM
group.MapPost("/learn/sbom", async (
[FromBody] LearnSbomRequest request,
ISbomRegistryService registryService,
CancellationToken ct) =>
{
var input = new SbomRegistrationInput
{
Digest = request.SbomDigest,
Format = ParseSbomFormat(request.Format),
SpecVersion = request.SpecVersion ?? "1.6",
PrimaryName = request.PrimaryName,
PrimaryVersion = request.PrimaryVersion,
Purls = request.Purls,
Source = request.Source ?? "api",
TenantId = request.TenantId,
ReachabilityMap = request.ReachabilityMap,
DeploymentMap = request.DeploymentMap
};
var result = await registryService.LearnSbomAsync(input, ct).ConfigureAwait(false);
return HttpResults.Ok(new SbomLearnResponse
{
SbomDigest = result.Registration.Digest,
SbomId = result.Registration.Id,
ComponentsProcessed = result.Registration.ComponentCount,
AdvisoriesMatched = result.Matches.Count,
ScoresUpdated = result.ScoresUpdated,
ProcessingTimeMs = result.ProcessingTimeMs
});
})
.WithName("LearnSbom")
.WithSummary("Register SBOM and update interest scores for affected advisories")
.Produces<SbomLearnResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);
// GET /api/v1/sboms/{digest}/affected - Get advisories affecting an SBOM
group.MapGet("/sboms/{digest}/affected", async (
string digest,
ISbomRegistryService registryService,
CancellationToken ct) =>
{
var registration = await registryService.GetByDigestAsync(digest, ct).ConfigureAwait(false);
if (registration is null)
{
return HttpResults.NotFound(new { error = "SBOM not found", digest });
}
var matches = await registryService.GetMatchesAsync(digest, ct).ConfigureAwait(false);
return HttpResults.Ok(new SbomAffectedResponse
{
SbomDigest = digest,
SbomId = registration.Id,
PrimaryName = registration.PrimaryName,
PrimaryVersion = registration.PrimaryVersion,
ComponentCount = registration.ComponentCount,
AffectedCount = matches.Count,
Matches = matches.Select(m => new SbomMatchInfo
{
CanonicalId = m.CanonicalId,
Purl = m.Purl,
IsReachable = m.IsReachable,
IsDeployed = m.IsDeployed,
Confidence = m.Confidence,
Method = m.Method.ToString(),
MatchedAt = m.MatchedAt
}).ToList()
});
})
.WithName("GetSbomAffected")
.WithSummary("Get advisories affecting an SBOM")
.Produces<SbomAffectedResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// GET /api/v1/sboms - List registered SBOMs
group.MapGet("/sboms", async (
[FromQuery] int? offset,
[FromQuery] int? limit,
[FromQuery] string? tenantId,
ISbomRegistryService registryService,
CancellationToken ct) =>
{
var registrations = await registryService.ListAsync(
offset ?? 0,
limit ?? 50,
tenantId,
ct).ConfigureAwait(false);
var count = await registryService.CountAsync(tenantId, ct).ConfigureAwait(false);
return HttpResults.Ok(new SbomListResponse
{
Items = registrations.Select(r => new SbomSummary
{
Id = r.Id,
Digest = r.Digest,
Format = r.Format.ToString(),
PrimaryName = r.PrimaryName,
PrimaryVersion = r.PrimaryVersion,
ComponentCount = r.ComponentCount,
AffectedCount = r.AffectedCount,
RegisteredAt = r.RegisteredAt,
LastMatchedAt = r.LastMatchedAt
}).ToList(),
TotalCount = count,
Offset = offset ?? 0,
Limit = limit ?? 50
});
})
.WithName("ListSboms")
.WithSummary("List registered SBOMs with pagination")
.Produces<SbomListResponse>(StatusCodes.Status200OK);
// GET /api/v1/sboms/{digest} - Get SBOM registration details
group.MapGet("/sboms/{digest}", async (
string digest,
ISbomRegistryService registryService,
CancellationToken ct) =>
{
var registration = await registryService.GetByDigestAsync(digest, ct).ConfigureAwait(false);
if (registration is null)
{
return HttpResults.NotFound(new { error = "SBOM not found", digest });
}
return HttpResults.Ok(new SbomDetailResponse
{
Id = registration.Id,
Digest = registration.Digest,
Format = registration.Format.ToString(),
SpecVersion = registration.SpecVersion,
PrimaryName = registration.PrimaryName,
PrimaryVersion = registration.PrimaryVersion,
ComponentCount = registration.ComponentCount,
AffectedCount = registration.AffectedCount,
Source = registration.Source,
TenantId = registration.TenantId,
RegisteredAt = registration.RegisteredAt,
LastMatchedAt = registration.LastMatchedAt
});
})
.WithName("GetSbom")
.WithSummary("Get SBOM registration details")
.Produces<SbomDetailResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// DELETE /api/v1/sboms/{digest} - Unregister an SBOM
group.MapDelete("/sboms/{digest}", async (
string digest,
ISbomRegistryService registryService,
CancellationToken ct) =>
{
await registryService.UnregisterAsync(digest, ct).ConfigureAwait(false);
return HttpResults.NoContent();
})
.WithName("UnregisterSbom")
.WithSummary("Unregister an SBOM")
.Produces(StatusCodes.Status204NoContent);
// POST /api/v1/sboms/{digest}/rematch - Rematch SBOM against current advisories
group.MapPost("/sboms/{digest}/rematch", async (
string digest,
ISbomRegistryService registryService,
CancellationToken ct) =>
{
try
{
var result = await registryService.RematchSbomAsync(digest, ct).ConfigureAwait(false);
return HttpResults.Ok(new SbomRematchResponse
{
SbomDigest = digest,
PreviousAffectedCount = result.Registration.AffectedCount,
NewAffectedCount = result.Matches.Count,
ProcessingTimeMs = result.ProcessingTimeMs
});
}
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
{
return HttpResults.NotFound(new { error = ex.Message });
}
})
.WithName("RematchSbom")
.WithSummary("Re-match SBOM against current advisory database")
.Produces<SbomRematchResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// GET /api/v1/sboms/stats - Get SBOM registry statistics
group.MapGet("/sboms/stats", async (
[FromQuery] string? tenantId,
ISbomRegistryService registryService,
CancellationToken ct) =>
{
var stats = await registryService.GetStatsAsync(tenantId, ct).ConfigureAwait(false);
return HttpResults.Ok(new SbomStatsResponse
{
TotalSboms = stats.TotalSboms,
TotalPurls = stats.TotalPurls,
TotalMatches = stats.TotalMatches,
AffectedSboms = stats.AffectedSboms,
AverageMatchesPerSbom = stats.AverageMatchesPerSbom
});
})
.WithName("GetSbomStats")
.WithSummary("Get SBOM registry statistics")
.Produces<SbomStatsResponse>(StatusCodes.Status200OK);
}
private static SbomFormat ParseSbomFormat(string? format)
{
return format?.ToLowerInvariant() switch
{
"cyclonedx" => SbomFormat.CycloneDX,
"spdx" => SbomFormat.SPDX,
_ => SbomFormat.CycloneDX
};
}
}
#region Request/Response DTOs
public sealed record LearnSbomRequest
{
public required string SbomDigest { get; init; }
public string? Format { get; init; }
public string? SpecVersion { get; init; }
public string? PrimaryName { get; init; }
public string? PrimaryVersion { get; init; }
public required IReadOnlyList<string> Purls { get; init; }
public string? Source { get; init; }
public string? TenantId { get; init; }
public IReadOnlyDictionary<string, bool>? ReachabilityMap { get; init; }
public IReadOnlyDictionary<string, bool>? DeploymentMap { get; init; }
}
public sealed record SbomLearnResponse
{
public required string SbomDigest { get; init; }
public Guid SbomId { get; init; }
public int ComponentsProcessed { get; init; }
public int AdvisoriesMatched { get; init; }
public int ScoresUpdated { get; init; }
public double ProcessingTimeMs { get; init; }
}
public sealed record SbomAffectedResponse
{
public required string SbomDigest { get; init; }
public Guid SbomId { get; init; }
public string? PrimaryName { get; init; }
public string? PrimaryVersion { get; init; }
public int ComponentCount { get; init; }
public int AffectedCount { get; init; }
public required IReadOnlyList<SbomMatchInfo> Matches { get; init; }
}
public sealed record SbomMatchInfo
{
public Guid CanonicalId { get; init; }
public required string Purl { get; init; }
public bool IsReachable { get; init; }
public bool IsDeployed { get; init; }
public double Confidence { get; init; }
public required string Method { get; init; }
public DateTimeOffset MatchedAt { get; init; }
}
public sealed record SbomListResponse
{
public required IReadOnlyList<SbomSummary> Items { get; init; }
public long TotalCount { get; init; }
public int Offset { get; init; }
public int Limit { get; init; }
}
public sealed record SbomSummary
{
public Guid Id { get; init; }
public required string Digest { get; init; }
public required string Format { get; init; }
public string? PrimaryName { get; init; }
public string? PrimaryVersion { get; init; }
public int ComponentCount { get; init; }
public int AffectedCount { get; init; }
public DateTimeOffset RegisteredAt { get; init; }
public DateTimeOffset? LastMatchedAt { get; init; }
}
public sealed record SbomDetailResponse
{
public Guid Id { get; init; }
public required string Digest { get; init; }
public required string Format { get; init; }
public required string SpecVersion { get; init; }
public string? PrimaryName { get; init; }
public string? PrimaryVersion { get; init; }
public int ComponentCount { get; init; }
public int AffectedCount { get; init; }
public required string Source { get; init; }
public string? TenantId { get; init; }
public DateTimeOffset RegisteredAt { get; init; }
public DateTimeOffset? LastMatchedAt { get; init; }
}
public sealed record SbomRematchResponse
{
public required string SbomDigest { get; init; }
public int PreviousAffectedCount { get; init; }
public int NewAffectedCount { get; init; }
public double ProcessingTimeMs { get; init; }
}
public sealed record SbomStatsResponse
{
public long TotalSboms { get; init; }
public long TotalPurls { get; init; }
public long TotalMatches { get; init; }
public long AffectedSboms { get; init; }
public double AverageMatchesPerSbom { get; init; }
}
#endregion

View File

@@ -38,6 +38,12 @@ public sealed class ConcelierOptions
/// </summary>
public AirGapOptions AirGap { get; set; } = new();
/// <summary>
/// Federation sync configuration.
/// Per SPRINT_8200_0014_0002_CONCEL_delta_bundle_export.
/// </summary>
public FederationOptions Federation { get; set; } = new();
/// <summary>
/// Stella Router integration configuration (disabled by default).
/// When enabled, ASP.NET endpoints are automatically registered with the Router.
@@ -266,4 +272,35 @@ public sealed class ConcelierOptions
[JsonIgnore]
public string RootAbsolute { get; internal set; } = string.Empty;
}
/// <summary>
/// Federation sync options for multi-site deployment.
/// </summary>
public sealed class FederationOptions
{
/// <summary>
/// Enable federation endpoints.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Site identifier for this instance.
/// </summary>
public string SiteId { get; set; } = "default";
/// <summary>
/// Default ZST compression level (1-19).
/// </summary>
public int DefaultCompressionLevel { get; set; } = 3;
/// <summary>
/// Default maximum items per export bundle.
/// </summary>
public int DefaultMaxItems { get; set; } = 10_000;
/// <summary>
/// Require bundle signatures.
/// </summary>
public bool RequireSignature { get; set; } = true;
}
}

View File

@@ -305,6 +305,21 @@ public static class ConcelierProblemResultFactory
"AirGap mode is not enabled on this instance.");
}
/// <summary>
/// Creates a 503 Service Unavailable response for Federation disabled.
/// Per SPRINT_8200_0014_0002_CONCEL_delta_bundle_export.
/// </summary>
public static IResult FederationDisabled(HttpContext context)
{
return Problem(
context,
"https://stellaops.org/problems/federation-disabled",
"Federation disabled",
StatusCodes.Status503ServiceUnavailable,
ErrorCodes.FederationDisabled,
"Federation sync is not enabled on this instance.");
}
/// <summary>
/// Creates a 403 Forbidden response for sealed mode violation.
/// </summary>

View File

@@ -23,7 +23,10 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Concelier.Interest/StellaOps.Concelier.Interest.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Concelier.SbomIntegration/StellaOps.Concelier.SbomIntegration.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Concelier.Storage.Postgres/StellaOps.Concelier.Storage.Postgres.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Concelier.Federation/StellaOps.Concelier.Federation.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Concelier.Merge/StellaOps.Concelier.Merge.csproj" />