feat(graph-api): Add schema review notes for upcoming Graph API changes
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

feat(sbomservice): Add placeholder for SHA256SUMS in LNM v1 fixtures

docs(devportal): Create README for SDK archives in public directory

build(devportal): Implement offline bundle build script

test(devportal): Add link checker script for validating links in documentation

test(devportal): Create performance check script for dist folder size

test(devportal): Implement accessibility check script using Playwright and Axe

docs(devportal): Add SDK quickstart guide with examples for Node.js, Python, and cURL

feat(excititor): Implement MongoDB storage for airgap import records

test(findings): Add unit tests for export filters hash determinism

feat(findings): Define attestation contracts for ledger web service

feat(graph): Add MongoDB options and service collection extensions for graph indexing

test(graph): Implement integration tests for MongoDB provider and service collection extensions

feat(zastava): Define configuration options for Zastava surface secrets

build(tests): Create script to run Concelier linkset tests with TRX output
This commit is contained in:
StellaOps Bot
2025-11-22 19:22:30 +02:00
parent ca09400069
commit 48702191be
76 changed files with 3878 additions and 1081 deletions

View File

@@ -0,0 +1,29 @@
namespace StellaOps.Findings.Ledger.WebService.Contracts;
public sealed record AttestationQueryRequest(
string TenantId,
string? ArtifactId,
string? FindingId,
string? AttestationId,
string? Status,
DateTimeOffset? SinceRecordedAt,
DateTimeOffset? UntilRecordedAt,
int Limit,
string FiltersHash,
AttestationPagingKey? PagingKey);
public sealed record AttestationPagingKey(DateTimeOffset RecordedAt, string AttestationId);
public sealed record AttestationExportItem(
string AttestationId,
string ArtifactId,
string? FindingId,
string VerificationStatus,
DateTimeOffset VerificationTime,
string DsseDigest,
string? RekorEntryId,
string? EvidenceBundleRef,
string LedgerEventId,
DateTimeOffset RecordedAt,
string MerkleLeafHash,
string RootHash);

View File

@@ -17,6 +17,55 @@ public sealed record ExportFindingsRequest(
public sealed record ExportPagingKey(long SequenceNumber, string PolicyVersion, string CycleHash);
public sealed record ExportVexRequest(
string TenantId,
string Shape,
long? SinceSequence,
long? UntilSequence,
DateTimeOffset? SinceObservedAt,
DateTimeOffset? UntilObservedAt,
string? ProductId,
string? AdvisoryId,
string? Status,
string? StatementType,
int PageSize,
string FiltersHash,
ExportPagingKey? PagingKey);
public sealed record ExportAdvisoryRequest(
string TenantId,
string Shape,
long? SinceSequence,
long? UntilSequence,
DateTimeOffset? SinceObservedAt,
DateTimeOffset? UntilObservedAt,
string? Severity,
string? Source,
string? CweId,
bool? Kev,
string? CvssVersion,
decimal? CvssScoreMin,
decimal? CvssScoreMax,
int PageSize,
string FiltersHash,
ExportPagingKey? PagingKey);
public sealed record ExportSbomRequest(
string TenantId,
string Shape,
long? SinceSequence,
long? UntilSequence,
DateTimeOffset? SinceObservedAt,
DateTimeOffset? UntilObservedAt,
string? SubjectDigest,
string? SbomFormat,
string? ComponentPurl,
bool? ContainsNative,
string? SlsaBuildType,
int PageSize,
string FiltersHash,
ExportPagingKey? PagingKey);
public sealed record FindingExportItem(
long EventSequence,
DateTimeOffset ObservedAt,

View File

@@ -151,6 +151,7 @@ builder.Services.AddSingleton<IConsoleCsrfValidator, ConsoleCsrfValidator>();
builder.Services.AddHostedService<LedgerMerkleAnchorWorker>();
builder.Services.AddHostedService<LedgerProjectionWorker>();
builder.Services.AddSingleton<ExportQueryService>();
builder.Services.AddSingleton<AttestationQueryService>();
var app = builder.Build();
@@ -290,20 +291,255 @@ app.MapGet("/ledger/export/findings", async Task<Results<FileStreamHttpResult, J
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status500InternalServerError);
app.MapGet("/ledger/export/vex", () => TypedResults.Json(new ExportPage<VexExportItem>(Array.Empty<VexExportItem>(), null)))
app.MapGet("/v1/ledger/attestations", async Task<Results<FileStreamHttpResult, JsonHttpResult<ExportPage<AttestationExportItem>>, ProblemHttpResult>> (
HttpContext httpContext,
AttestationQueryService attestationQueryService,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
var request = new AttestationQueryRequest(
tenantId,
httpContext.Request.Query["artifactId"].ToString(),
httpContext.Request.Query["findingId"].ToString(),
httpContext.Request.Query["attestationId"].ToString(),
httpContext.Request.Query["status"].ToString(),
ParseDate(httpContext.Request.Query["sinceRecordedAt"]),
ParseDate(httpContext.Request.Query["untilRecordedAt"]),
attestationQueryService.ClampLimit(ParseInt(httpContext.Request.Query["limit"])),
FiltersHash: string.Empty,
PagingKey: null);
var filtersHash = attestationQueryService.ComputeFiltersHash(request);
AttestationPagingKey? pagingKey = null;
var pageToken = httpContext.Request.Query["page_token"].ToString();
if (!string.IsNullOrWhiteSpace(pageToken))
{
if (!attestationQueryService.TryParsePageToken(pageToken, filtersHash, out pagingKey, out var error))
{
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: error ?? "invalid_page_token");
}
}
request = request with { FiltersHash = filtersHash, PagingKey = pagingKey };
ExportPage<AttestationExportItem> page;
try
{
page = await attestationQueryService.GetAttestationsAsync(request, cancellationToken).ConfigureAwait(false);
}
catch (InvalidOperationException ex) when (ex.Message == "filters_hash_mismatch")
{
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "page_token_filters_mismatch");
}
return await WritePagedResponse(httpContext, page, cancellationToken).ConfigureAwait(false);
})
.WithName("LedgerAttestationsList")
.RequireAuthorization(LedgerExportPolicy)
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.MapGet("/ledger/export/vex", async Task<Results<FileStreamHttpResult, JsonHttpResult<ExportPage<VexExportItem>>, ProblemHttpResult>> (
HttpContext httpContext,
ExportQueryService exportQueryService,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
var shape = httpContext.Request.Query["shape"].ToString();
if (string.IsNullOrWhiteSpace(shape))
{
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_shape", detail: "shape is required (canonical|compact).");
}
var request = new ExportVexRequest(
tenantId,
shape,
ParseLong(httpContext.Request.Query["since_sequence"]),
ParseLong(httpContext.Request.Query["until_sequence"]),
ParseDate(httpContext.Request.Query["since_observed_at"]),
ParseDate(httpContext.Request.Query["until_observed_at"]),
httpContext.Request.Query["product_id"].ToString(),
httpContext.Request.Query["advisory_id"].ToString(),
httpContext.Request.Query["status"].ToString(),
httpContext.Request.Query["statement_type"].ToString(),
exportQueryService.ClampPageSize(ParseInt(httpContext.Request.Query["page_size"])),
filtersHash: string.Empty,
PagingKey: null);
var filtersHash = exportQueryService.ComputeFiltersHash(request);
ExportPagingKey? pagingKey = null;
var pageToken = httpContext.Request.Query["page_token"].ToString();
if (!string.IsNullOrWhiteSpace(pageToken))
{
if (!ExportPaging.TryParsePageToken(pageToken, filtersHash, out var parsedKey, out var error))
{
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: error ?? "invalid_page_token");
}
pagingKey = new ExportPagingKey(parsedKey!.SequenceNumber, parsedKey.PolicyVersion, parsedKey.CycleHash);
}
request = request with { FiltersHash = filtersHash, PagingKey = pagingKey };
ExportPage<VexExportItem> page;
try
{
page = await exportQueryService.GetVexAsync(request, cancellationToken).ConfigureAwait(false);
}
catch (InvalidOperationException ex) when (ex.Message == "filters_hash_mismatch")
{
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "page_token_filters_mismatch");
}
return await WritePagedResponse(httpContext, page, cancellationToken).ConfigureAwait(false);
})
.WithName("LedgerExportVex")
.RequireAuthorization(LedgerExportPolicy)
.Produces(StatusCodes.Status200OK);
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.MapGet("/ledger/export/advisories", () => TypedResults.Json(new ExportPage<AdvisoryExportItem>(Array.Empty<AdvisoryExportItem>(), null)))
app.MapGet("/ledger/export/advisories", async Task<Results<FileStreamHttpResult, JsonHttpResult<ExportPage<AdvisoryExportItem>>, ProblemHttpResult>> (
HttpContext httpContext,
ExportQueryService exportQueryService,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
var shape = httpContext.Request.Query["shape"].ToString();
if (string.IsNullOrWhiteSpace(shape))
{
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_shape", detail: "shape is required (canonical|compact).");
}
var kev = ParseBool(httpContext.Request.Query["kev"]);
var cvssScoreMin = ParseDecimal(httpContext.Request.Query["cvss_score_min"]);
var cvssScoreMax = ParseDecimal(httpContext.Request.Query["cvss_score_max"]);
var request = new ExportAdvisoryRequest(
tenantId,
shape,
ParseLong(httpContext.Request.Query["since_sequence"]),
ParseLong(httpContext.Request.Query["until_sequence"]),
ParseDate(httpContext.Request.Query["since_observed_at"]),
ParseDate(httpContext.Request.Query["until_observed_at"]),
httpContext.Request.Query["severity"].ToString(),
httpContext.Request.Query["source"].ToString(),
httpContext.Request.Query["cwe_id"].ToString(),
kev,
httpContext.Request.Query["cvss_version"].ToString(),
cvssScoreMin,
cvssScoreMax,
exportQueryService.ClampPageSize(ParseInt(httpContext.Request.Query["page_size"])),
filtersHash: string.Empty,
PagingKey: null);
var filtersHash = exportQueryService.ComputeFiltersHash(request);
ExportPagingKey? pagingKey = null;
var pageToken = httpContext.Request.Query["page_token"].ToString();
if (!string.IsNullOrWhiteSpace(pageToken))
{
if (!ExportPaging.TryParsePageToken(pageToken, filtersHash, out var parsedKey, out var error))
{
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: error ?? "invalid_page_token");
}
pagingKey = new ExportPagingKey(parsedKey!.SequenceNumber, parsedKey.PolicyVersion, parsedKey.CycleHash);
}
request = request with { FiltersHash = filtersHash, PagingKey = pagingKey };
ExportPage<AdvisoryExportItem> page;
try
{
page = await exportQueryService.GetAdvisoriesAsync(request, cancellationToken).ConfigureAwait(false);
}
catch (InvalidOperationException ex) when (ex.Message == "filters_hash_mismatch")
{
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "page_token_filters_mismatch");
}
return await WritePagedResponse(httpContext, page, cancellationToken).ConfigureAwait(false);
})
.WithName("LedgerExportAdvisories")
.RequireAuthorization(LedgerExportPolicy)
.Produces(StatusCodes.Status200OK);
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.MapGet("/ledger/export/sboms", () => TypedResults.Json(new ExportPage<SbomExportItem>(Array.Empty<SbomExportItem>(), null)))
app.MapGet("/ledger/export/sboms", async Task<Results<FileStreamHttpResult, JsonHttpResult<ExportPage<SbomExportItem>>, ProblemHttpResult>> (
HttpContext httpContext,
ExportQueryService exportQueryService,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
var shape = httpContext.Request.Query["shape"].ToString();
if (string.IsNullOrWhiteSpace(shape))
{
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_shape", detail: "shape is required (canonical|compact).");
}
var request = new ExportSbomRequest(
tenantId,
shape,
ParseLong(httpContext.Request.Query["since_sequence"]),
ParseLong(httpContext.Request.Query["until_sequence"]),
ParseDate(httpContext.Request.Query["since_observed_at"]),
ParseDate(httpContext.Request.Query["until_observed_at"]),
httpContext.Request.Query["subject_digest"].ToString(),
httpContext.Request.Query["sbom_format"].ToString(),
httpContext.Request.Query["component_purl"].ToString(),
ParseBool(httpContext.Request.Query["contains_native"]),
httpContext.Request.Query["slsa_build_type"].ToString(),
exportQueryService.ClampPageSize(ParseInt(httpContext.Request.Query["page_size"])),
filtersHash: string.Empty,
PagingKey: null);
var filtersHash = exportQueryService.ComputeFiltersHash(request);
ExportPagingKey? pagingKey = null;
var pageToken = httpContext.Request.Query["page_token"].ToString();
if (!string.IsNullOrWhiteSpace(pageToken))
{
if (!ExportPaging.TryParsePageToken(pageToken, filtersHash, out var parsedKey, out var error))
{
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: error ?? "invalid_page_token");
}
pagingKey = new ExportPagingKey(parsedKey!.SequenceNumber, parsedKey.PolicyVersion, parsedKey.CycleHash);
}
request = request with { FiltersHash = filtersHash, PagingKey = pagingKey };
ExportPage<SbomExportItem> page;
try
{
page = await exportQueryService.GetSbomsAsync(request, cancellationToken).ConfigureAwait(false);
}
catch (InvalidOperationException ex) when (ex.Message == "filters_hash_mismatch")
{
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "page_token_filters_mismatch");
}
return await WritePagedResponse(httpContext, page, cancellationToken).ConfigureAwait(false);
})
.WithName("LedgerExportSboms")
.RequireAuthorization(LedgerExportPolicy)
.Produces(StatusCodes.Status200OK);
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest);
app.MapPost("/internal/ledger/orchestrator-export", async Task<Results<Accepted<OrchestratorExportResponse>, ProblemHttpResult>> (
HttpContext httpContext,
@@ -394,6 +630,22 @@ app.MapPost("/internal/ledger/airgap-import", async Task<Results<Accepted<Airgap
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status409Conflict);
app.MapGet("/.well-known/openapi", () =>
{
var contentRoot = AppContext.BaseDirectory;
var candidate = Path.GetFullPath(Path.Combine(contentRoot, "../../docs/modules/findings-ledger/openapi/findings-ledger.v1.yaml"));
if (!File.Exists(candidate))
{
return Results.Problem(statusCode: StatusCodes.Status500InternalServerError, title: "openapi_missing", detail: "OpenAPI document not found on server.");
}
var yaml = File.ReadAllText(candidate);
return Results.Text(yaml, "application/yaml");
})
.WithName("LedgerOpenApiDocument")
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status500InternalServerError);
app.Run();
static Created<LedgerEventResponse> CreateCreatedResponse(LedgerEventRecord record)
@@ -444,3 +696,42 @@ static async Task<Results<FileStreamHttpResult, JsonHttpResult<ExportPage<T>>, P
return TypedResults.Json(page);
}
static bool TryGetTenant(HttpContext httpContext, out ProblemHttpResult? problem, out string tenantId)
{
tenantId = string.Empty;
if (!httpContext.Request.Headers.TryGetValue("X-Stella-Tenant", out var tenantValues) || string.IsNullOrWhiteSpace(tenantValues))
{
problem = TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_tenant");
return false;
}
tenantId = tenantValues.ToString();
problem = null;
return true;
}
static int? ParseInt(string value)
{
return int.TryParse(value, out var result) ? result : null;
}
static long? ParseLong(string value)
{
return long.TryParse(value, out var result) ? result : null;
}
static DateTimeOffset? ParseDate(string value)
{
return DateTimeOffset.TryParse(value, out var result) ? result : null;
}
static decimal? ParseDecimal(string value)
{
return decimal.TryParse(value, out var result) ? result : null;
}
static bool? ParseBool(string value)
{
return bool.TryParse(value, out var result) ? result : null;
}

View File

@@ -22,12 +22,6 @@ public sealed class ExportQueryService
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public ExportPage<VexExportItem> GetVexEmpty() => new(Array.Empty<VexExportItem>(), null);
public ExportPage<AdvisoryExportItem> GetAdvisoriesEmpty() => new(Array.Empty<AdvisoryExportItem>(), null);
public ExportPage<SbomExportItem> GetSbomsEmpty() => new(Array.Empty<SbomExportItem>(), null);
public int ClampPageSize(int? requested)
{
if (!requested.HasValue || requested.Value <= 0)
@@ -54,6 +48,64 @@ public sealed class ExportQueryService
return ExportPaging.ComputeFiltersHash(filters);
}
public string ComputeFiltersHash(ExportVexRequest request)
{
var filters = new Dictionary<string, string?>
{
["shape"] = request.Shape,
["since_sequence"] = request.SinceSequence?.ToString(),
["until_sequence"] = request.UntilSequence?.ToString(),
["since_observed_at"] = request.SinceObservedAt?.ToString("O"),
["until_observed_at"] = request.UntilObservedAt?.ToString("O"),
["product_id"] = request.ProductId,
["advisory_id"] = request.AdvisoryId,
["status"] = request.Status,
["statement_type"] = request.StatementType
};
return ExportPaging.ComputeFiltersHash(filters);
}
public string ComputeFiltersHash(ExportAdvisoryRequest request)
{
var filters = new Dictionary<string, string?>
{
["shape"] = request.Shape,
["since_sequence"] = request.SinceSequence?.ToString(),
["until_sequence"] = request.UntilSequence?.ToString(),
["since_observed_at"] = request.SinceObservedAt?.ToString("O"),
["until_observed_at"] = request.UntilObservedAt?.ToString("O"),
["severity"] = request.Severity,
["source"] = request.Source,
["cwe_id"] = request.CweId,
["kev"] = request.Kev?.ToString(),
["cvss_version"] = request.CvssVersion,
["cvss_score_min"] = request.CvssScoreMin?.ToString(),
["cvss_score_max"] = request.CvssScoreMax?.ToString()
};
return ExportPaging.ComputeFiltersHash(filters);
}
public string ComputeFiltersHash(ExportSbomRequest request)
{
var filters = new Dictionary<string, string?>
{
["shape"] = request.Shape,
["since_sequence"] = request.SinceSequence?.ToString(),
["until_sequence"] = request.UntilSequence?.ToString(),
["since_observed_at"] = request.SinceObservedAt?.ToString("O"),
["until_observed_at"] = request.UntilObservedAt?.ToString("O"),
["subject_digest"] = request.SubjectDigest,
["sbom_format"] = request.SbomFormat,
["component_purl"] = request.ComponentPurl,
["contains_native"] = request.ContainsNative?.ToString(),
["slsa_build_type"] = request.SlsaBuildType
};
return ExportPaging.ComputeFiltersHash(filters);
}
public async Task<ExportPage<FindingExportItem>> GetFindingsAsync(ExportFindingsRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
@@ -211,4 +263,41 @@ public sealed class ExportQueryService
return new ExportPage<FindingExportItem>(items, nextPageToken);
}
public Task<ExportPage<VexExportItem>> GetVexAsync(ExportVexRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
if (!string.Equals(request.FiltersHash, ComputeFiltersHash(request), StringComparison.Ordinal))
{
throw new InvalidOperationException("filters_hash_mismatch");
}
// Data source to be implemented; deterministic empty page for now.
return Task.FromResult(new ExportPage<VexExportItem>(Array.Empty<VexExportItem>(), null));
}
public Task<ExportPage<AdvisoryExportItem>> GetAdvisoriesAsync(ExportAdvisoryRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
if (!string.Equals(request.FiltersHash, ComputeFiltersHash(request), StringComparison.Ordinal))
{
throw new InvalidOperationException("filters_hash_mismatch");
}
return Task.FromResult(new ExportPage<AdvisoryExportItem>(Array.Empty<AdvisoryExportItem>(), null));
}
public Task<ExportPage<SbomExportItem>> GetSbomsAsync(ExportSbomRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
if (!string.Equals(request.FiltersHash, ComputeFiltersHash(request), StringComparison.Ordinal))
{
throw new InvalidOperationException("filters_hash_mismatch");
}
return Task.FromResult(new ExportPage<SbomExportItem>(Array.Empty<SbomExportItem>(), null));
}
}