Add OpenAPI specification for Link-Not-Merge Policy APIs
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Introduced a new OpenAPI YAML file for the StellaOps Concelier service. - Defined endpoints for listing linksets, retrieving linksets by advisory ID, and searching linksets. - Included detailed parameter specifications and response schemas for each endpoint. - Established components for reusable parameters and schemas, enhancing API documentation clarity.
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
@@ -9,16 +8,30 @@ namespace StellaOps.Concelier.WebService.Contracts;
|
||||
public sealed record LnmLinksetResponse(
|
||||
[property: JsonPropertyName("advisoryId")] string AdvisoryId,
|
||||
[property: JsonPropertyName("source")] string Source,
|
||||
[property: JsonPropertyName("observations")] IReadOnlyList<string> Observations,
|
||||
[property: JsonPropertyName("normalized")] LnmLinksetNormalized? Normalized,
|
||||
[property: JsonPropertyName("conflicts")] IReadOnlyList<LnmLinksetConflict>? Conflicts,
|
||||
[property: JsonPropertyName("purl")] IReadOnlyList<string> Purl,
|
||||
[property: JsonPropertyName("cpe")] IReadOnlyList<string> Cpe,
|
||||
[property: JsonPropertyName("summary")] string? Summary,
|
||||
[property: JsonPropertyName("publishedAt")] DateTimeOffset? PublishedAt,
|
||||
[property: JsonPropertyName("modifiedAt")] DateTimeOffset? ModifiedAt,
|
||||
[property: JsonPropertyName("severity")] string? Severity,
|
||||
[property: JsonPropertyName("status")] string? Status,
|
||||
[property: JsonPropertyName("provenance")] LnmLinksetProvenance? Provenance,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
|
||||
[property: JsonPropertyName("builtByJobId")] string? BuiltByJobId,
|
||||
[property: JsonPropertyName("cached")] bool Cached);
|
||||
[property: JsonPropertyName("conflicts")] IReadOnlyList<LnmLinksetConflict> Conflicts,
|
||||
[property: JsonPropertyName("timeline")] IReadOnlyList<LnmLinksetTimeline> Timeline,
|
||||
[property: JsonPropertyName("normalized")] LnmLinksetNormalized? Normalized,
|
||||
[property: JsonPropertyName("cached")] bool Cached,
|
||||
[property: JsonPropertyName("remarks")] IReadOnlyList<string> Remarks,
|
||||
[property: JsonPropertyName("observations")] IReadOnlyList<string> Observations);
|
||||
|
||||
public sealed record LnmLinksetPage(
|
||||
[property: JsonPropertyName("items")] IReadOnlyList<LnmLinksetResponse> Items,
|
||||
[property: JsonPropertyName("page")] int Page,
|
||||
[property: JsonPropertyName("pageSize")] int PageSize,
|
||||
[property: JsonPropertyName("total")] int? Total);
|
||||
|
||||
public sealed record LnmLinksetNormalized(
|
||||
[property: JsonPropertyName("purls")] IReadOnlyList<string>? Purls,
|
||||
[property: JsonPropertyName("aliases")] IReadOnlyList<string>? Aliases,
|
||||
[property: JsonPropertyName("purl")] IReadOnlyList<string>? Purl,
|
||||
[property: JsonPropertyName("versions")] IReadOnlyList<string>? Versions,
|
||||
[property: JsonPropertyName("ranges")] IReadOnlyList<object>? Ranges,
|
||||
[property: JsonPropertyName("severities")] IReadOnlyList<object>? Severities);
|
||||
@@ -26,16 +39,41 @@ public sealed record LnmLinksetNormalized(
|
||||
public sealed record LnmLinksetConflict(
|
||||
[property: JsonPropertyName("field")] string Field,
|
||||
[property: JsonPropertyName("reason")] string Reason,
|
||||
[property: JsonPropertyName("values")] IReadOnlyList<string>? Values);
|
||||
[property: JsonPropertyName("observedValue")] string? ObservedValue,
|
||||
[property: JsonPropertyName("observedAt")] DateTimeOffset? ObservedAt,
|
||||
[property: JsonPropertyName("evidenceHash")] string? EvidenceHash);
|
||||
|
||||
public sealed record LnmLinksetTimeline(
|
||||
[property: JsonPropertyName("event")] string Event,
|
||||
[property: JsonPropertyName("at")] DateTimeOffset? At,
|
||||
[property: JsonPropertyName("evidenceHash")] string? EvidenceHash);
|
||||
|
||||
public sealed record LnmLinksetProvenance(
|
||||
[property: JsonPropertyName("observationHashes")] IReadOnlyList<string>? ObservationHashes,
|
||||
[property: JsonPropertyName("toolVersion")] string? ToolVersion,
|
||||
[property: JsonPropertyName("policyHash")] string? PolicyHash);
|
||||
[property: JsonPropertyName("ingestedAt")] DateTimeOffset? IngestedAt,
|
||||
[property: JsonPropertyName("connectorId")] string? ConnectorId,
|
||||
[property: JsonPropertyName("evidenceHash")] string? EvidenceHash,
|
||||
[property: JsonPropertyName("dsseEnvelopeHash")] string? DsseEnvelopeHash);
|
||||
|
||||
public sealed record LnmLinksetQuery(
|
||||
[Required]
|
||||
[property: JsonPropertyName("advisoryId")] string AdvisoryId,
|
||||
[Required]
|
||||
[property: JsonPropertyName("source")] string Source,
|
||||
[property: JsonPropertyName("includeConflicts")] bool IncludeConflicts = true);
|
||||
[property: JsonPropertyName("source")] string? Source = null,
|
||||
[property: JsonPropertyName("includeConflicts")] bool IncludeConflicts = true,
|
||||
[property: JsonPropertyName("includeObservations")] bool IncludeObservations = false);
|
||||
|
||||
public sealed record LnmLinksetSearchRequest(
|
||||
[property: JsonPropertyName("purl")] IReadOnlyList<string>? Purl,
|
||||
[property: JsonPropertyName("cpe")] IReadOnlyList<string>? Cpe,
|
||||
[property: JsonPropertyName("ghsa")] string? Ghsa,
|
||||
[property: JsonPropertyName("cve")] string? Cve,
|
||||
[property: JsonPropertyName("advisoryId")] string? AdvisoryId,
|
||||
[property: JsonPropertyName("source")] string? Source,
|
||||
[property: JsonPropertyName("severityMin")] double? SeverityMin,
|
||||
[property: JsonPropertyName("severityMax")] double? SeverityMax,
|
||||
[property: JsonPropertyName("publishedSince")] DateTimeOffset? PublishedSince,
|
||||
[property: JsonPropertyName("modifiedSince")] DateTimeOffset? ModifiedSince,
|
||||
[property: JsonPropertyName("includeTimeline")] bool IncludeTimeline = false,
|
||||
[property: JsonPropertyName("includeObservations")] bool IncludeObservations = false,
|
||||
[property: JsonPropertyName("page")] int? Page = null,
|
||||
[property: JsonPropertyName("pageSize")] int? PageSize = null,
|
||||
[property: JsonPropertyName("sort")] string? Sort = null);
|
||||
|
||||
@@ -118,6 +118,7 @@ builder.Services.AddOptions<AdvisoryObservationEventPublisherOptions>()
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddConcelierAocGuards();
|
||||
builder.Services.AddConcelierLinksetMappers();
|
||||
builder.Services.TryAddSingleton<IAdvisoryLinksetQueryService, AdvisoryLinksetQueryService>();
|
||||
builder.Services.AddSingleton<IMeterFactory>(MeterProvider.Default.GetMeterProvider());
|
||||
builder.Services.AddSingleton<LinksetCacheTelemetry>();
|
||||
builder.Services.AddAdvisoryRawServices();
|
||||
@@ -619,14 +620,17 @@ var observationsEndpoint = app.MapGet("/concelier/observations", async (
|
||||
return Results.Ok(response);
|
||||
}).WithName("GetConcelierObservations");
|
||||
|
||||
app.MapGet("/v1/lnm/linksets/{advisoryId}", async (
|
||||
const int DefaultLnmPageSize = 50;
|
||||
const int MaxLnmPageSize = 200;
|
||||
|
||||
app.MapGet("/v1/lnm/linksets", async (
|
||||
HttpContext context,
|
||||
string advisoryId,
|
||||
[FromQuery(Name = "source")] string source,
|
||||
[FromQuery(Name = "includeConflicts")] bool includeConflicts,
|
||||
[FromServices] IAdvisoryLinksetLookup linksetLookup,
|
||||
[FromServices] LinksetCacheTelemetry telemetry,
|
||||
TimeProvider timeProvider,
|
||||
[FromQuery(Name = "advisoryId")] string? advisoryId,
|
||||
[FromQuery(Name = "source")] string? source,
|
||||
[FromQuery(Name = "page")] int? page,
|
||||
[FromQuery(Name = "pageSize")] int? pageSize,
|
||||
[FromQuery(Name = "includeConflicts")] bool? includeConflicts,
|
||||
[FromServices] IAdvisoryLinksetQueryService queryService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
@@ -642,36 +646,116 @@ app.MapGet("/v1/lnm/linksets/{advisoryId}", async (
|
||||
return authorizationError;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(advisoryId) || string.IsNullOrWhiteSpace(source))
|
||||
var resolvedPage = NormalizePage(page);
|
||||
var resolvedPageSize = NormalizePageSize(pageSize);
|
||||
|
||||
var advisoryIds = string.IsNullOrWhiteSpace(advisoryId) ? null : new[] { advisoryId.Trim() };
|
||||
var sources = string.IsNullOrWhiteSpace(source) ? null : new[] { source.Trim() };
|
||||
|
||||
var result = await QueryPageAsync(
|
||||
queryService,
|
||||
tenant!,
|
||||
advisoryIds,
|
||||
sources,
|
||||
resolvedPage,
|
||||
resolvedPageSize,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var items = result.Items
|
||||
.Select(linkset => ToLnmResponse(linkset, includeConflicts.GetValueOrDefault(true), includeTimeline: false, includeObservations: false))
|
||||
.ToArray();
|
||||
|
||||
return Results.Ok(new LnmLinksetPage(items, resolvedPage, resolvedPageSize, result.Total));
|
||||
}).WithName("ListLnmLinksets");
|
||||
|
||||
app.MapPost("/v1/lnm/linksets/search", async (
|
||||
HttpContext context,
|
||||
[FromBody] LnmLinksetSearchRequest request,
|
||||
[FromServices] IAdvisoryLinksetQueryService queryService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
|
||||
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
||||
{
|
||||
return Results.BadRequest("advisoryId and source are required.");
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
var authorizationError = EnsureTenantAuthorized(context, tenant);
|
||||
if (authorizationError is not null)
|
||||
{
|
||||
return authorizationError;
|
||||
}
|
||||
|
||||
var resolvedPage = NormalizePage(request.Page);
|
||||
var resolvedPageSize = NormalizePageSize(request.PageSize);
|
||||
|
||||
var advisoryIds = string.IsNullOrWhiteSpace(request.AdvisoryId) ? null : new[] { request.AdvisoryId.Trim() };
|
||||
var sources = string.IsNullOrWhiteSpace(request.Source) ? null : new[] { request.Source.Trim() };
|
||||
|
||||
var result = await QueryPageAsync(
|
||||
queryService,
|
||||
tenant!,
|
||||
advisoryIds,
|
||||
sources,
|
||||
resolvedPage,
|
||||
resolvedPageSize,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var items = result.Items
|
||||
.Select(linkset => ToLnmResponse(
|
||||
linkset,
|
||||
includeConflicts: true,
|
||||
includeTimeline: request.IncludeTimeline,
|
||||
includeObservations: request.IncludeObservations))
|
||||
.ToArray();
|
||||
|
||||
return Results.Ok(new LnmLinksetPage(items, resolvedPage, resolvedPageSize, result.Total));
|
||||
}).WithName("SearchLnmLinksets");
|
||||
|
||||
app.MapGet("/v1/lnm/linksets/{advisoryId}", async (
|
||||
HttpContext context,
|
||||
string advisoryId,
|
||||
[FromQuery(Name = "source")] string? source,
|
||||
[FromQuery(Name = "includeConflicts")] bool includeConflicts = true,
|
||||
[FromQuery(Name = "includeObservations")] bool includeObservations = false,
|
||||
[FromServices] IAdvisoryLinksetQueryService queryService,
|
||||
[FromServices] LinksetCacheTelemetry telemetry,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
|
||||
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
var authorizationError = EnsureTenantAuthorized(context, tenant);
|
||||
if (authorizationError is not null)
|
||||
{
|
||||
return authorizationError;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(advisoryId))
|
||||
{
|
||||
return Results.BadRequest("advisoryId is required.");
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var advisoryIds = new[] { advisoryId.Trim() };
|
||||
var sources = string.IsNullOrWhiteSpace(source) ? null : new[] { source.Trim() };
|
||||
|
||||
var options = new AdvisoryLinksetQueryOptions(tenant!, Source: source.Trim(), AdvisoryId: advisoryId.Trim());
|
||||
var linksets = await linksetLookup.FindByTenantAsync(options.TenantId, options.Source, options.AdvisoryId, cancellationToken).ConfigureAwait(false);
|
||||
var result = await queryService
|
||||
.QueryAsync(new AdvisoryLinksetQueryOptions(tenant!, advisoryIds, sources, limit: 1), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (linksets.Count == 0)
|
||||
if (result.Linksets.IsDefaultOrEmpty)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var linkset = linksets[0];
|
||||
var response = new LnmLinksetResponse(
|
||||
linkset.AdvisoryId,
|
||||
linkset.Source,
|
||||
linkset.Observations,
|
||||
linkset.Normalized is null
|
||||
? null
|
||||
: new LnmLinksetNormalized(linkset.Normalized.Purls, linkset.Normalized.Versions, linkset.Normalized.Ranges, linkset.Normalized.Severities),
|
||||
includeConflicts ? linkset.Conflicts : Array.Empty<LnmLinksetConflict>(),
|
||||
linkset.Provenance is null
|
||||
? null
|
||||
: new LnmLinksetProvenance(linkset.Provenance.ObservationHashes, linkset.Provenance.ToolVersion, linkset.Provenance.PolicyHash),
|
||||
linkset.CreatedAt,
|
||||
linkset.BuiltByJobId,
|
||||
Cached: true);
|
||||
var linkset = result.Linksets[0];
|
||||
var response = ToLnmResponse(linkset, includeConflicts, includeTimeline: false, includeObservations: includeObservations);
|
||||
|
||||
telemetry.RecordHit(tenant, linkset.Source);
|
||||
telemetry.RecordRebuild(tenant, linkset.Source, stopwatch.Elapsed.TotalMilliseconds);
|
||||
@@ -679,6 +763,45 @@ app.MapGet("/v1/lnm/linksets/{advisoryId}", async (
|
||||
return Results.Ok(response);
|
||||
}).WithName("GetLnmLinkset");
|
||||
|
||||
app.MapGet("/linksets", async (
|
||||
HttpContext context,
|
||||
[FromQuery(Name = "limit")] int? limit,
|
||||
[FromQuery(Name = "cursor")] string? cursor,
|
||||
[FromServices] IAdvisoryLinksetQueryService queryService,
|
||||
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;
|
||||
}
|
||||
|
||||
var result = await queryService.QueryAsync(
|
||||
new AdvisoryLinksetQueryOptions(tenant, Limit: limit, Cursor: cursor),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var payload = new
|
||||
{
|
||||
linksets = result.Linksets.Select(ls => new
|
||||
{
|
||||
AdvisoryId = ls.AdvisoryId,
|
||||
Purls = ls.Normalized?.Purls ?? Array.Empty<string>(),
|
||||
Versions = ls.Normalized?.Versions ?? Array.Empty<string>()
|
||||
}),
|
||||
hasMore = result.HasMore,
|
||||
nextCursor = result.NextCursor
|
||||
};
|
||||
|
||||
return Results.Ok(payload);
|
||||
}).WithName("ListLinksetsLegacy");
|
||||
|
||||
if (authorityConfigured)
|
||||
{
|
||||
observationsEndpoint.RequireAuthorization(ObservationsPolicyName);
|
||||
@@ -1453,6 +1576,120 @@ if (authorityConfigured)
|
||||
});
|
||||
}
|
||||
|
||||
int NormalizePage(int? pageValue)
|
||||
{
|
||||
if (!pageValue.HasValue || pageValue.Value <= 0)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
return pageValue.Value;
|
||||
}
|
||||
|
||||
int NormalizePageSize(int? size)
|
||||
{
|
||||
if (!size.HasValue || size.Value <= 0)
|
||||
{
|
||||
return DefaultLnmPageSize;
|
||||
}
|
||||
|
||||
return size.Value > MaxLnmPageSize ? MaxLnmPageSize : size.Value;
|
||||
}
|
||||
|
||||
async Task<(IReadOnlyList<AdvisoryLinkset> Items, int? Total)> QueryPageAsync(
|
||||
IAdvisoryLinksetQueryService queryService,
|
||||
string tenant,
|
||||
IEnumerable<string>? advisoryIds,
|
||||
IEnumerable<string>? sources,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var cursor = (string?)null;
|
||||
AdvisoryLinksetQueryResult? result = null;
|
||||
|
||||
for (var current = 1; current <= page; current++)
|
||||
{
|
||||
result = await queryService
|
||||
.QueryAsync(new AdvisoryLinksetQueryOptions(tenant, advisoryIds, sources, pageSize, cursor), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!result.HasMore && current < page)
|
||||
{
|
||||
var exhaustedTotal = ((current - 1) * pageSize) + result.Linksets.Length;
|
||||
return (Array.Empty<AdvisoryLinkset>(), exhaustedTotal);
|
||||
}
|
||||
|
||||
cursor = result.NextCursor;
|
||||
}
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return (Array.Empty<AdvisoryLinkset>(), 0);
|
||||
}
|
||||
|
||||
var total = result.HasMore ? null : (int?)(((page - 1) * pageSize) + result.Linksets.Length);
|
||||
return (result.Linksets, total);
|
||||
}
|
||||
|
||||
LnmLinksetResponse ToLnmResponse(
|
||||
AdvisoryLinkset linkset,
|
||||
bool includeConflicts,
|
||||
bool includeTimeline,
|
||||
bool includeObservations)
|
||||
{
|
||||
var normalized = linkset.Normalized;
|
||||
var conflicts = includeConflicts
|
||||
? (linkset.Conflicts ?? Array.Empty<AdvisoryLinksetConflict>()).Select(c =>
|
||||
new LnmLinksetConflict(
|
||||
c.Field,
|
||||
c.Reason,
|
||||
c.Values is null ? null : string.Join(", ", c.Values),
|
||||
ObservedAt: null,
|
||||
EvidenceHash: c.SourceIds?.FirstOrDefault()))
|
||||
.ToArray()
|
||||
: Array.Empty<LnmLinksetConflict>();
|
||||
|
||||
var timeline = includeTimeline
|
||||
? Array.Empty<LnmLinksetTimeline>() // timeline not yet captured in linkset store
|
||||
: Array.Empty<LnmLinksetTimeline>();
|
||||
|
||||
var provenance = linkset.Provenance is null
|
||||
? new LnmLinksetProvenance(linkset.CreatedAt, null, null, null)
|
||||
: new LnmLinksetProvenance(
|
||||
linkset.CreatedAt,
|
||||
connectorId: null,
|
||||
evidenceHash: linkset.Provenance.ObservationHashes?.FirstOrDefault(),
|
||||
dsseEnvelopeHash: null);
|
||||
|
||||
var normalizedDto = normalized is null
|
||||
? null
|
||||
: new LnmLinksetNormalized(
|
||||
Aliases: null,
|
||||
Purl: normalized.Purls,
|
||||
Versions: normalized.Versions,
|
||||
Ranges: normalized.Ranges?.Select(r => (object)r).ToArray(),
|
||||
Severities: normalized.Severities?.Select(s => (object)s).ToArray());
|
||||
|
||||
return new LnmLinksetResponse(
|
||||
linkset.AdvisoryId,
|
||||
linkset.Source,
|
||||
normalized?.Purls ?? Array.Empty<string>(),
|
||||
Array.Empty<string>(),
|
||||
Summary: null,
|
||||
PublishedAt: linkset.CreatedAt,
|
||||
ModifiedAt: linkset.CreatedAt,
|
||||
Severity: null,
|
||||
Status: "fact-only",
|
||||
provenance,
|
||||
conflicts,
|
||||
timeline,
|
||||
normalizedDto,
|
||||
Cached: false,
|
||||
Remarks: Array.Empty<string>(),
|
||||
Observations: includeObservations ? linkset.ObservationIds : Array.Empty<string>());
|
||||
}
|
||||
|
||||
IResult JsonResult<T>(T value, int? statusCode = null)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(value, jsonOptions);
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: StellaOps Concelier – Link-Not-Merge Policy APIs
|
||||
version: "0.1.0"
|
||||
description: Fact-only advisory/linkset retrieval for Policy Engine consumers.
|
||||
servers:
|
||||
- url: /
|
||||
description: Relative base path (API Gateway rewrites in production).
|
||||
tags:
|
||||
- name: Linksets
|
||||
description: Link-Not-Merge linkset retrieval
|
||||
paths:
|
||||
/v1/lnm/linksets:
|
||||
get:
|
||||
summary: List linksets
|
||||
tags: [Linksets]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
- name: includeConflicts
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: boolean, default: true }
|
||||
- name: includeObservations
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: boolean, default: false }
|
||||
- $ref: '#/components/parameters/purl'
|
||||
- $ref: '#/components/parameters/cpe'
|
||||
- $ref: '#/components/parameters/ghsa'
|
||||
- $ref: '#/components/parameters/cve'
|
||||
- $ref: '#/components/parameters/advisoryId'
|
||||
- $ref: '#/components/parameters/source'
|
||||
- $ref: '#/components/parameters/severityMin'
|
||||
- $ref: '#/components/parameters/severityMax'
|
||||
- $ref: '#/components/parameters/publishedSince'
|
||||
- $ref: '#/components/parameters/modifiedSince'
|
||||
- $ref: '#/components/parameters/page'
|
||||
- $ref: '#/components/parameters/pageSize'
|
||||
- $ref: '#/components/parameters/sort'
|
||||
responses:
|
||||
"200":
|
||||
description: Deterministically ordered list of linksets
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PagedLinksets'
|
||||
/v1/lnm/linksets/{advisoryId}:
|
||||
get:
|
||||
summary: Get linkset by advisory ID
|
||||
tags: [Linksets]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
- name: advisoryId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: source
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: string }
|
||||
- name: includeConflicts
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: boolean, default: true }
|
||||
- name: includeObservations
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: boolean, default: false }
|
||||
responses:
|
||||
"200":
|
||||
description: Linkset with provenance and conflicts
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Linkset'
|
||||
"404":
|
||||
description: Not found
|
||||
/v1/lnm/linksets/search:
|
||||
post:
|
||||
summary: Search linksets (body filters)
|
||||
tags: [Linksets]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LinksetSearchRequest'
|
||||
responses:
|
||||
"200":
|
||||
description: Deterministically ordered search results
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PagedLinksets'
|
||||
components:
|
||||
parameters:
|
||||
Tenant:
|
||||
name: Tenant
|
||||
in: header
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Tenant identifier (required).
|
||||
purl:
|
||||
name: purl
|
||||
in: query
|
||||
schema:
|
||||
type: array
|
||||
items: { type: string }
|
||||
style: form
|
||||
explode: true
|
||||
cpe:
|
||||
name: cpe
|
||||
in: query
|
||||
schema: { type: string }
|
||||
ghsa:
|
||||
name: ghsa
|
||||
in: query
|
||||
schema: { type: string }
|
||||
cve:
|
||||
name: cve
|
||||
in: query
|
||||
schema: { type: string }
|
||||
advisoryId:
|
||||
name: advisoryId
|
||||
in: query
|
||||
schema: { type: string }
|
||||
source:
|
||||
name: source
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
severityMin:
|
||||
name: severityMin
|
||||
in: query
|
||||
schema:
|
||||
type: number
|
||||
format: float
|
||||
severityMax:
|
||||
name: severityMax
|
||||
in: query
|
||||
schema:
|
||||
type: number
|
||||
format: float
|
||||
publishedSince:
|
||||
name: publishedSince
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
modifiedSince:
|
||||
name: modifiedSince
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
page:
|
||||
name: page
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 1
|
||||
pageSize:
|
||||
name: pageSize
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 200
|
||||
default: 50
|
||||
sort:
|
||||
name: sort
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- modifiedAt desc
|
||||
- modifiedAt asc
|
||||
- publishedAt desc
|
||||
- publishedAt asc
|
||||
- severity desc
|
||||
- severity asc
|
||||
- source
|
||||
- advisoryId
|
||||
description: Default modifiedAt desc; ties advisoryId asc, source asc.
|
||||
schemas:
|
||||
LinksetSearchRequest:
|
||||
type: object
|
||||
properties:
|
||||
purl: { type: array, items: { type: string } }
|
||||
cpe: { type: array, items: { type: string } }
|
||||
ghsa: { type: string }
|
||||
cve: { type: string }
|
||||
advisoryId: { type: string }
|
||||
source: { type: string }
|
||||
severityMin: { type: number }
|
||||
severityMax: { type: number }
|
||||
publishedSince: { type: string, format: date-time }
|
||||
modifiedSince: { type: string, format: date-time }
|
||||
includeTimeline: { type: boolean, default: false }
|
||||
includeObservations: { type: boolean, default: false }
|
||||
includeConflicts: { type: boolean, default: true }
|
||||
page: { type: integer, minimum: 1, default: 1 }
|
||||
pageSize: { type: integer, minimum: 1, maximum: 200, default: 50 }
|
||||
sort: { type: string, enum: [modifiedAt desc, modifiedAt asc, publishedAt desc, publishedAt asc, severity desc, severity asc, source, advisoryId] }
|
||||
PagedLinksets:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/Linkset' }
|
||||
page: { type: integer }
|
||||
pageSize: { type: integer }
|
||||
total: { type: integer }
|
||||
Linkset:
|
||||
type: object
|
||||
required: [advisoryId, source, purl, cpe, provenance]
|
||||
properties:
|
||||
advisoryId: { type: string }
|
||||
source: { type: string }
|
||||
purl: { type: array, items: { type: string } }
|
||||
cpe: { type: array, items: { type: string } }
|
||||
summary: { type: string }
|
||||
publishedAt: { type: string, format: date-time }
|
||||
modifiedAt: { type: string, format: date-time }
|
||||
severity: { type: string, description: Source-native severity label }
|
||||
status: { type: string }
|
||||
provenance: { $ref: '#/components/schemas/LinksetProvenance' }
|
||||
conflicts:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/LinksetConflict' }
|
||||
timeline:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/LinksetTimeline' }
|
||||
normalized:
|
||||
type: object
|
||||
properties:
|
||||
aliases: { type: array, items: { type: string } }
|
||||
purl: { type: array, items: { type: string } }
|
||||
versions: { type: array, items: { type: string } }
|
||||
ranges: { type: array, items: { type: object } }
|
||||
severities: { type: array, items: { type: object } }
|
||||
cached:
|
||||
type: boolean
|
||||
description: True if served from cache; provenance.evidenceHash present for integrity.
|
||||
remarks:
|
||||
type: array
|
||||
items: { type: string }
|
||||
observations:
|
||||
type: array
|
||||
items: { type: string }
|
||||
LinksetProvenance:
|
||||
type: object
|
||||
properties:
|
||||
ingestedAt: { type: string, format: date-time }
|
||||
connectorId: { type: string }
|
||||
evidenceHash: { type: string }
|
||||
dsseEnvelopeHash: { type: string }
|
||||
LinksetConflict:
|
||||
type: object
|
||||
properties:
|
||||
field: { type: string }
|
||||
reason: { type: string }
|
||||
observedValue: { type: string }
|
||||
observedAt: { type: string, format: date-time }
|
||||
evidenceHash: { type: string }
|
||||
LinksetTimeline:
|
||||
type: object
|
||||
properties:
|
||||
event: { type: string }
|
||||
at: { type: string, format: date-time }
|
||||
evidenceHash: { type: string }
|
||||
@@ -110,7 +110,9 @@ internal static class LinksetCorrelation
|
||||
.Select(i => i.Purls
|
||||
.Select(ExtractPackageKey)
|
||||
.Where(k => !string.IsNullOrWhiteSpace(k))
|
||||
.Select(k => k!)
|
||||
.ToHashSet(StringComparer.Ordinal))
|
||||
.Select(set => new HashSet<string>(set, StringComparer.Ordinal))
|
||||
.ToList();
|
||||
|
||||
var seed = packageKeysPerInput.FirstOrDefault() ?? new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
@@ -311,6 +311,54 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
Assert.True(string.IsNullOrEmpty(secondPayload.NextCursor));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LnmLinksetsEndpoints_ReturnFactOnlyLinksets()
|
||||
{
|
||||
var tenant = "tenant-lnm-list";
|
||||
var documents = new[]
|
||||
{
|
||||
CreateLinksetDocument(
|
||||
tenant,
|
||||
"nvd",
|
||||
"ADV-002",
|
||||
new[] { "obs-2" },
|
||||
new[] { "pkg:npm/demo@2.0.0" },
|
||||
new[] { "2.0.0" },
|
||||
new DateTime(2025, 1, 6, 0, 0, 0, DateTimeKind.Utc)),
|
||||
CreateLinksetDocument(
|
||||
tenant,
|
||||
"osv",
|
||||
"ADV-001",
|
||||
new[] { "obs-1" },
|
||||
new[] { "pkg:npm/demo@1.0.0" },
|
||||
new[] { "1.0.0" },
|
||||
new DateTime(2025, 1, 5, 0, 0, 0, DateTimeKind.Utc))
|
||||
};
|
||||
|
||||
await SeedLinksetDocumentsAsync(documents);
|
||||
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Stella-Tenant", tenant);
|
||||
|
||||
var listResponse = await client.GetAsync("/v1/lnm/linksets?pageSize=1&page=1");
|
||||
listResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var listPayload = await listResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var firstItem = listPayload.GetProperty("items").EnumerateArray().First();
|
||||
Assert.Equal("ADV-002", firstItem.GetProperty("advisoryId").GetString());
|
||||
Assert.Contains("pkg:npm/demo@2.0.0", firstItem.GetProperty("purl").EnumerateArray().Select(x => x.GetString()));
|
||||
Assert.True(firstItem.GetProperty("conflicts").EnumerateArray().Count() >= 0);
|
||||
|
||||
var detailResponse = await client.GetAsync("/v1/lnm/linksets/ADV-001?source=osv&includeObservations=true");
|
||||
detailResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var detailPayload = await detailResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("ADV-001", detailPayload.GetProperty("advisoryId").GetString());
|
||||
Assert.Equal("osv", detailPayload.GetProperty("source").GetString());
|
||||
Assert.Contains("pkg:npm/demo@1.0.0", detailPayload.GetProperty("purl").EnumerateArray().Select(x => x.GetString()));
|
||||
Assert.Contains("obs-1", detailPayload.GetProperty("observations").EnumerateArray().Select(x => x.GetString()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ObservationsEndpoint_ReturnsBadRequestWhenTenantMissing()
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<OutputType>Exe</OutputType>
|
||||
<PackAsTool>true</PackAsTool>
|
||||
<ToolCommandName>stella-forensic-verify</ToolCommandName>
|
||||
<PackageOutputPath>../../out/tools</PackageOutputPath>
|
||||
|
||||
@@ -60,7 +60,7 @@ public static class MerkleRootVerifier
|
||||
{
|
||||
var provider = timeProvider ?? TimeProvider.System;
|
||||
if (leaves is null) throw new ArgumentNullException(nameof(leaves));
|
||||
expectedRoot ??= throw new ArgumentNullException(nameof(expectedRoot));
|
||||
if (expectedRoot is null) throw new ArgumentNullException(nameof(expectedRoot));
|
||||
|
||||
var leafList = leaves.ToList();
|
||||
var computed = MerkleTree.ComputeRoot(leafList);
|
||||
@@ -79,7 +79,7 @@ public static class ChainOfCustodyVerifier
|
||||
{
|
||||
var provider = timeProvider ?? TimeProvider.System;
|
||||
if (hops is null) throw new ArgumentNullException(nameof(hops));
|
||||
expectedHead ??= throw new ArgumentNullException(nameof(expectedHead));
|
||||
if (expectedHead is null) throw new ArgumentNullException(nameof(expectedHead));
|
||||
|
||||
var list = hops.ToList();
|
||||
if (list.Count == 0)
|
||||
|
||||
Reference in New Issue
Block a user