sprints enhancements

This commit is contained in:
StellaOps Bot
2025-12-25 19:52:30 +02:00
parent ef6ac36323
commit b8b2d83f4a
138 changed files with 25133 additions and 594 deletions

View File

@@ -0,0 +1,400 @@
// -----------------------------------------------------------------------------
// CanonicalAdvisoryEndpointExtensions.cs
// Sprint: SPRINT_8200_0012_0003_CONCEL_canonical_advisory_service
// Tasks: CANSVC-8200-016 through CANSVC-8200-019
// Description: API endpoints for canonical advisory service
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Mvc;
using StellaOps.Concelier.Core.Canonical;
using StellaOps.Concelier.WebService.Results;
using HttpResults = Microsoft.AspNetCore.Http.Results;
namespace StellaOps.Concelier.WebService.Extensions;
/// <summary>
/// Endpoint extensions for canonical advisory operations.
/// </summary>
internal static class CanonicalAdvisoryEndpointExtensions
{
private const string CanonicalReadPolicy = "Concelier.Canonical.Read";
private const string CanonicalIngestPolicy = "Concelier.Canonical.Ingest";
public static void MapCanonicalAdvisoryEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/v1/canonical")
.WithTags("Canonical Advisories");
// GET /api/v1/canonical/{id} - Get canonical advisory by ID
group.MapGet("/{id:guid}", async (
Guid id,
ICanonicalAdvisoryService service,
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));
})
.WithName("GetCanonicalById")
.WithSummary("Get canonical advisory by ID")
.Produces<CanonicalAdvisoryResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// GET /api/v1/canonical?cve={cve}&artifact={artifact} - Query canonical advisories
group.MapGet("/", async (
[FromQuery] string? cve,
[FromQuery] string? artifact,
[FromQuery] string? mergeHash,
[FromQuery] int? offset,
[FromQuery] int? limit,
ICanonicalAdvisoryService service,
HttpContext context,
CancellationToken ct) =>
{
// Query by merge hash takes precedence
if (!string.IsNullOrEmpty(mergeHash))
{
var byHash = await service.GetByMergeHashAsync(mergeHash, ct).ConfigureAwait(false);
return byHash is null
? HttpResults.Ok(new CanonicalAdvisoryListResponse { Items = [], TotalCount = 0 })
: HttpResults.Ok(new CanonicalAdvisoryListResponse
{
Items = [MapToResponse(byHash)],
TotalCount = 1
});
}
// Query by CVE
if (!string.IsNullOrEmpty(cve))
{
var byCve = await service.GetByCveAsync(cve, ct).ConfigureAwait(false);
return HttpResults.Ok(new CanonicalAdvisoryListResponse
{
Items = byCve.Select(MapToResponse).ToList(),
TotalCount = byCve.Count
});
}
// Query by artifact
if (!string.IsNullOrEmpty(artifact))
{
var byArtifact = await service.GetByArtifactAsync(artifact, ct).ConfigureAwait(false);
return HttpResults.Ok(new CanonicalAdvisoryListResponse
{
Items = byArtifact.Select(MapToResponse).ToList(),
TotalCount = byArtifact.Count
});
}
// Generic query with pagination
var options = new CanonicalQueryOptions
{
Offset = offset ?? 0,
Limit = limit ?? 50
};
var result = await service.QueryAsync(options, ct).ConfigureAwait(false);
return HttpResults.Ok(new CanonicalAdvisoryListResponse
{
Items = result.Items.Select(MapToResponse).ToList(),
TotalCount = result.TotalCount,
Offset = result.Offset,
Limit = result.Limit
});
})
.WithName("QueryCanonical")
.WithSummary("Query canonical advisories by CVE, artifact, or merge hash")
.Produces<CanonicalAdvisoryListResponse>(StatusCodes.Status200OK);
// POST /api/v1/canonical/ingest/{source} - Ingest raw advisory
group.MapPost("/ingest/{source}", async (
string source,
[FromBody] RawAdvisoryRequest request,
ICanonicalAdvisoryService service,
HttpContext context,
CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(source))
{
return HttpResults.BadRequest(new { error = "Source is required" });
}
if (string.IsNullOrWhiteSpace(request.Cve))
{
return HttpResults.BadRequest(new { error = "CVE is required" });
}
if (string.IsNullOrWhiteSpace(request.AffectsKey))
{
return HttpResults.BadRequest(new { error = "AffectsKey is required" });
}
var rawAdvisory = new RawAdvisory
{
SourceAdvisoryId = request.SourceAdvisoryId ?? $"{source.ToUpperInvariant()}-{request.Cve}",
Cve = request.Cve,
AffectsKey = request.AffectsKey,
VersionRangeJson = request.VersionRangeJson,
Weaknesses = request.Weaknesses ?? [],
PatchLineage = request.PatchLineage,
Severity = request.Severity,
Title = request.Title,
Summary = request.Summary,
VendorStatus = request.VendorStatus,
RawPayloadJson = request.RawPayloadJson,
FetchedAt = request.FetchedAt ?? DateTimeOffset.UtcNow
};
var result = await service.IngestAsync(source, rawAdvisory, ct).ConfigureAwait(false);
var response = new IngestResultResponse
{
CanonicalId = result.CanonicalId,
MergeHash = result.MergeHash,
Decision = result.Decision.ToString(),
SourceEdgeId = result.SourceEdgeId,
SignatureRef = result.SignatureRef,
ConflictReason = result.ConflictReason
};
return result.Decision == MergeDecision.Conflict
? HttpResults.Conflict(response)
: HttpResults.Ok(response);
})
.WithName("IngestAdvisory")
.WithSummary("Ingest raw advisory from source into canonical pipeline")
.Produces<IngestResultResponse>(StatusCodes.Status200OK)
.Produces<IngestResultResponse>(StatusCodes.Status409Conflict)
.Produces(StatusCodes.Status400BadRequest);
// POST /api/v1/canonical/ingest/{source}/batch - Batch ingest advisories
group.MapPost("/ingest/{source}/batch", async (
string source,
[FromBody] IEnumerable<RawAdvisoryRequest> requests,
ICanonicalAdvisoryService service,
HttpContext context,
CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(source))
{
return HttpResults.BadRequest(new { error = "Source is required" });
}
var rawAdvisories = requests.Select(request => new RawAdvisory
{
SourceAdvisoryId = request.SourceAdvisoryId ?? $"{source.ToUpperInvariant()}-{request.Cve}",
Cve = request.Cve ?? throw new InvalidOperationException("CVE is required"),
AffectsKey = request.AffectsKey ?? throw new InvalidOperationException("AffectsKey is required"),
VersionRangeJson = request.VersionRangeJson,
Weaknesses = request.Weaknesses ?? [],
PatchLineage = request.PatchLineage,
Severity = request.Severity,
Title = request.Title,
Summary = request.Summary,
VendorStatus = request.VendorStatus,
RawPayloadJson = request.RawPayloadJson,
FetchedAt = request.FetchedAt ?? DateTimeOffset.UtcNow
}).ToList();
var results = await service.IngestBatchAsync(source, rawAdvisories, ct).ConfigureAwait(false);
var response = new BatchIngestResultResponse
{
Results = results.Select(r => new IngestResultResponse
{
CanonicalId = r.CanonicalId,
MergeHash = r.MergeHash,
Decision = r.Decision.ToString(),
SourceEdgeId = r.SourceEdgeId,
SignatureRef = r.SignatureRef,
ConflictReason = r.ConflictReason
}).ToList(),
Summary = new BatchIngestSummary
{
Total = results.Count,
Created = results.Count(r => r.Decision == MergeDecision.Created),
Merged = results.Count(r => r.Decision == MergeDecision.Merged),
Duplicates = results.Count(r => r.Decision == MergeDecision.Duplicate),
Conflicts = results.Count(r => r.Decision == MergeDecision.Conflict)
}
};
return HttpResults.Ok(response);
})
.WithName("IngestAdvisoryBatch")
.WithSummary("Batch ingest multiple advisories from source")
.Produces<BatchIngestResultResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);
// PATCH /api/v1/canonical/{id}/status - Update canonical status
group.MapPatch("/{id:guid}/status", async (
Guid id,
[FromBody] UpdateStatusRequest request,
ICanonicalAdvisoryService service,
HttpContext context,
CancellationToken ct) =>
{
if (!Enum.TryParse<CanonicalStatus>(request.Status, true, out var status))
{
return HttpResults.BadRequest(new { error = "Invalid status", validValues = Enum.GetNames<CanonicalStatus>() });
}
await service.UpdateStatusAsync(id, status, ct).ConfigureAwait(false);
return HttpResults.Ok(new { id, status = status.ToString() });
})
.WithName("UpdateCanonicalStatus")
.WithSummary("Update canonical advisory status")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);
}
private static CanonicalAdvisoryResponse MapToResponse(CanonicalAdvisory canonical) => new()
{
Id = canonical.Id,
Cve = canonical.Cve,
AffectsKey = canonical.AffectsKey,
MergeHash = canonical.MergeHash,
Status = canonical.Status.ToString(),
Severity = canonical.Severity,
EpssScore = canonical.EpssScore,
ExploitKnown = canonical.ExploitKnown,
Title = canonical.Title,
Summary = canonical.Summary,
VersionRange = canonical.VersionRange,
Weaknesses = canonical.Weaknesses,
CreatedAt = canonical.CreatedAt,
UpdatedAt = canonical.UpdatedAt,
SourceEdges = canonical.SourceEdges.Select(e => new SourceEdgeResponse
{
Id = e.Id,
SourceName = e.SourceName,
SourceAdvisoryId = e.SourceAdvisoryId,
SourceDocHash = e.SourceDocHash,
VendorStatus = e.VendorStatus?.ToString(),
PrecedenceRank = e.PrecedenceRank,
HasDsseEnvelope = e.DsseEnvelope is not null,
FetchedAt = e.FetchedAt
}).ToList()
};
}
#region Response DTOs
/// <summary>
/// Response for a single canonical advisory.
/// </summary>
public sealed record CanonicalAdvisoryResponse
{
public Guid Id { get; init; }
public required string Cve { get; init; }
public required string AffectsKey { get; init; }
public required string MergeHash { get; init; }
public required string Status { get; init; }
public string? Severity { get; init; }
public decimal? EpssScore { get; init; }
public bool ExploitKnown { get; init; }
public string? Title { get; init; }
public string? Summary { get; init; }
public VersionRange? VersionRange { get; init; }
public IReadOnlyList<string> Weaknesses { get; init; } = [];
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
public IReadOnlyList<SourceEdgeResponse> SourceEdges { get; init; } = [];
}
/// <summary>
/// Response for a source edge.
/// </summary>
public sealed record SourceEdgeResponse
{
public Guid Id { get; init; }
public required string SourceName { get; init; }
public required string SourceAdvisoryId { get; init; }
public required string SourceDocHash { get; init; }
public string? VendorStatus { get; init; }
public int PrecedenceRank { get; init; }
public bool HasDsseEnvelope { get; init; }
public DateTimeOffset FetchedAt { get; init; }
}
/// <summary>
/// Response for a list of canonical advisories.
/// </summary>
public sealed record CanonicalAdvisoryListResponse
{
public IReadOnlyList<CanonicalAdvisoryResponse> Items { get; init; } = [];
public long TotalCount { get; init; }
public int Offset { get; init; }
public int Limit { get; init; }
}
/// <summary>
/// Response for ingest result.
/// </summary>
public sealed record IngestResultResponse
{
public Guid CanonicalId { get; init; }
public required string MergeHash { get; init; }
public required string Decision { get; init; }
public Guid? SourceEdgeId { get; init; }
public Guid? SignatureRef { get; init; }
public string? ConflictReason { get; init; }
}
/// <summary>
/// Response for batch ingest.
/// </summary>
public sealed record BatchIngestResultResponse
{
public IReadOnlyList<IngestResultResponse> Results { get; init; } = [];
public required BatchIngestSummary Summary { get; init; }
}
/// <summary>
/// Summary of batch ingest results.
/// </summary>
public sealed record BatchIngestSummary
{
public int Total { get; init; }
public int Created { get; init; }
public int Merged { get; init; }
public int Duplicates { get; init; }
public int Conflicts { get; init; }
}
#endregion
#region Request DTOs
/// <summary>
/// Request to ingest a raw advisory.
/// </summary>
public sealed record RawAdvisoryRequest
{
public string? SourceAdvisoryId { get; init; }
public string? Cve { get; init; }
public string? AffectsKey { get; init; }
public string? VersionRangeJson { get; init; }
public IReadOnlyList<string>? Weaknesses { get; init; }
public string? PatchLineage { get; init; }
public string? Severity { get; init; }
public string? Title { get; init; }
public string? Summary { get; init; }
public VendorStatus? VendorStatus { get; init; }
public string? RawPayloadJson { get; init; }
public DateTimeOffset? FetchedAt { get; init; }
}
/// <summary>
/// Request to update canonical status.
/// </summary>
public sealed record UpdateStatusRequest
{
public required string Status { get; init; }
}
#endregion

View File

@@ -511,6 +511,9 @@ app.UseDeprecationHeaders();
app.MapConcelierMirrorEndpoints(authorityConfigured, enforceAuthority);
// Canonical advisory endpoints (Sprint 8200.0012.0003)
app.MapCanonicalAdvisoryEndpoints();
app.MapGet("/.well-known/openapi", ([FromServices] OpenApiDiscoveryDocumentProvider provider, HttpContext context) =>
{
var (payload, etag) = provider.GetDocument();