up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
cryptopro-linux-csp / build-and-test (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
sm-remote-ci / build-and-test (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
cryptopro-linux-csp / build-and-test (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
sm-remote-ci / build-and-test (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
This commit is contained in:
@@ -1,40 +1,23 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Storage;
|
||||
using StellaOps.Excititor.WebService.Contracts;
|
||||
using StellaOps.Excititor.WebService.Services;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Attestation API endpoints (WEB-OBS-54-001).
|
||||
/// Exposes /attestations/vex/* endpoints returning DSSE verification state,
|
||||
/// builder identity, and chain-of-custody links.
|
||||
/// Attestation API endpoints (temporarily disabled while Mongo is removed and Postgres storage is adopted).
|
||||
/// </summary>
|
||||
public static class AttestationEndpoints
|
||||
{
|
||||
public static void MapAttestationEndpoints(this WebApplication app)
|
||||
{
|
||||
// GET /attestations/vex/list - List attestations
|
||||
app.MapGet("/attestations/vex/list", async (
|
||||
// GET /attestations/vex/list
|
||||
app.MapGet("/attestations/vex/list", (
|
||||
HttpContext context,
|
||||
IOptions<VexStorageOptions> storageOptions,
|
||||
[FromServices] IMongoDatabase database,
|
||||
TimeProvider timeProvider,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] string? cursor,
|
||||
[FromQuery] string? vulnerabilityId,
|
||||
[FromQuery] string? productKey,
|
||||
CancellationToken cancellationToken) =>
|
||||
IOptions<VexStorageOptions> storageOptions) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
|
||||
if (scopeResult is not null)
|
||||
@@ -42,70 +25,22 @@ public static class AttestationEndpoints
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
|
||||
if (!TryResolveTenant(context, storageOptions.Value, requireHeader: false, out _, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
var take = Math.Clamp(limit.GetValueOrDefault(50), 1, 200);
|
||||
var collection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Attestations);
|
||||
var builder = Builders<BsonDocument>.Filter;
|
||||
var filters = new List<FilterDefinition<BsonDocument>>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(vulnerabilityId))
|
||||
{
|
||||
filters.Add(builder.Eq("VulnerabilityId", vulnerabilityId.Trim().ToUpperInvariant()));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(productKey))
|
||||
{
|
||||
filters.Add(builder.Eq("ProductKey", productKey.Trim().ToLowerInvariant()));
|
||||
}
|
||||
|
||||
// Parse cursor if provided
|
||||
if (!string.IsNullOrWhiteSpace(cursor) && TryDecodeCursor(cursor, out var cursorTime, out var cursorId))
|
||||
{
|
||||
var ltTime = builder.Lt("IssuedAt", cursorTime);
|
||||
var eqTimeLtId = builder.And(
|
||||
builder.Eq("IssuedAt", cursorTime),
|
||||
builder.Lt("_id", cursorId));
|
||||
filters.Add(builder.Or(ltTime, eqTimeLtId));
|
||||
}
|
||||
|
||||
var filter = filters.Count == 0 ? builder.Empty : builder.And(filters);
|
||||
var sort = Builders<BsonDocument>.Sort.Descending("IssuedAt").Descending("_id");
|
||||
|
||||
var documents = await collection
|
||||
.Find(filter)
|
||||
.Sort(sort)
|
||||
.Limit(take)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var items = documents.Select(doc => ToListItem(doc, tenant, timeProvider)).ToList();
|
||||
|
||||
string? nextCursor = null;
|
||||
var hasMore = documents.Count == take;
|
||||
if (hasMore && documents.Count > 0)
|
||||
{
|
||||
var last = documents[^1];
|
||||
var lastTime = last.GetValue("IssuedAt", BsonNull.Value).ToUniversalTime();
|
||||
var lastId = last.GetValue("_id", BsonNull.Value).AsString;
|
||||
nextCursor = EncodeCursor(lastTime, lastId);
|
||||
}
|
||||
|
||||
var response = new VexAttestationListResponse(items, nextCursor, hasMore, items.Count);
|
||||
return Results.Ok(response);
|
||||
return Results.Problem(
|
||||
detail: "Attestation listing is temporarily unavailable during Postgres migration (Mongo/BSON removed).",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable,
|
||||
title: "Service unavailable");
|
||||
}).WithName("ListVexAttestations");
|
||||
|
||||
// GET /attestations/vex/{attestationId} - Get attestation details
|
||||
app.MapGet("/attestations/vex/{attestationId}", async (
|
||||
// GET /attestations/vex/{attestationId}
|
||||
app.MapGet("/attestations/vex/{attestationId}", (
|
||||
HttpContext context,
|
||||
string attestationId,
|
||||
IOptions<VexStorageOptions> storageOptions,
|
||||
[FromServices] IVexAttestationLinkStore attestationStore,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
IOptions<VexStorageOptions> storageOptions) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
|
||||
if (scopeResult is not null)
|
||||
@@ -113,235 +48,23 @@ public static class AttestationEndpoints
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
|
||||
if (!TryResolveTenant(context, storageOptions.Value, requireHeader: false, out _, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(attestationId))
|
||||
{
|
||||
return Results.BadRequest(new { error = new { code = "ERR_ATTESTATION_ID", message = "attestationId is required" } });
|
||||
return Results.Problem(
|
||||
detail: "attestationId is required.",
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "Validation error");
|
||||
}
|
||||
|
||||
var attestation = await attestationStore.FindAsync(attestationId.Trim(), cancellationToken).ConfigureAwait(false);
|
||||
if (attestation is null)
|
||||
{
|
||||
return Results.NotFound(new { error = new { code = "ERR_NOT_FOUND", message = $"Attestation '{attestationId}' not found" } });
|
||||
}
|
||||
|
||||
// Build subject from observation context
|
||||
var subjectDigest = attestation.Metadata.TryGetValue("digest", out var dig) ? dig : attestation.ObservationId;
|
||||
var subject = new VexAttestationSubject(
|
||||
Digest: subjectDigest,
|
||||
DigestAlgorithm: "sha256",
|
||||
Name: $"{attestation.VulnerabilityId}/{attestation.ProductKey}",
|
||||
Uri: null);
|
||||
|
||||
var builder = new VexAttestationBuilderIdentity(
|
||||
Id: attestation.SupplierId,
|
||||
Version: null,
|
||||
BuilderId: attestation.SupplierId,
|
||||
InvocationId: attestation.ObservationId);
|
||||
|
||||
// Get verification state from metadata
|
||||
var isValid = attestation.Metadata.TryGetValue("verified", out var verified) && verified == "true";
|
||||
DateTimeOffset? verifiedAt = null;
|
||||
if (attestation.Metadata.TryGetValue("verifiedAt", out var verifiedAtStr) &&
|
||||
DateTimeOffset.TryParse(verifiedAtStr, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsedVerifiedAt))
|
||||
{
|
||||
verifiedAt = parsedVerifiedAt;
|
||||
}
|
||||
|
||||
var verification = new VexAttestationVerificationState(
|
||||
Valid: isValid,
|
||||
VerifiedAt: verifiedAt,
|
||||
SignatureType: attestation.Metadata.GetValueOrDefault("signatureType", "dsse"),
|
||||
KeyId: attestation.Metadata.GetValueOrDefault("keyId"),
|
||||
Issuer: attestation.Metadata.GetValueOrDefault("issuer"),
|
||||
EnvelopeDigest: attestation.Metadata.GetValueOrDefault("envelopeDigest"),
|
||||
Diagnostics: attestation.Metadata);
|
||||
|
||||
var custodyLinks = new List<VexAttestationCustodyLink>
|
||||
{
|
||||
new(
|
||||
Step: 1,
|
||||
Actor: attestation.SupplierId,
|
||||
Action: "created",
|
||||
Timestamp: attestation.IssuedAt,
|
||||
Reference: attestation.AttestationId)
|
||||
};
|
||||
|
||||
// Add linkset link
|
||||
custodyLinks.Add(new VexAttestationCustodyLink(
|
||||
Step: 2,
|
||||
Actor: "excititor",
|
||||
Action: "linked_to_observation",
|
||||
Timestamp: attestation.IssuedAt,
|
||||
Reference: attestation.LinksetId));
|
||||
|
||||
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["observationId"] = attestation.ObservationId,
|
||||
["linksetId"] = attestation.LinksetId,
|
||||
["vulnerabilityId"] = attestation.VulnerabilityId,
|
||||
["productKey"] = attestation.ProductKey
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(attestation.JustificationSummary))
|
||||
{
|
||||
metadata["justificationSummary"] = attestation.JustificationSummary;
|
||||
}
|
||||
|
||||
var response = new VexAttestationDetailResponse(
|
||||
AttestationId: attestation.AttestationId,
|
||||
Tenant: tenant,
|
||||
CreatedAt: attestation.IssuedAt,
|
||||
PredicateType: attestation.Metadata.GetValueOrDefault("predicateType", "https://in-toto.io/attestation/v1"),
|
||||
Subject: subject,
|
||||
Builder: builder,
|
||||
Verification: verification,
|
||||
ChainOfCustody: custodyLinks,
|
||||
Metadata: metadata);
|
||||
|
||||
return Results.Ok(response);
|
||||
return Results.Problem(
|
||||
detail: "Attestation retrieval is temporarily unavailable during Postgres migration (Mongo/BSON removed).",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable,
|
||||
title: "Service unavailable");
|
||||
}).WithName("GetVexAttestation");
|
||||
|
||||
// GET /attestations/vex/lookup - Lookup attestations by linkset or observation
|
||||
app.MapGet("/attestations/vex/lookup", async (
|
||||
HttpContext context,
|
||||
IOptions<VexStorageOptions> storageOptions,
|
||||
[FromServices] IMongoDatabase database,
|
||||
TimeProvider timeProvider,
|
||||
[FromQuery] string? linksetId,
|
||||
[FromQuery] string? observationId,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(linksetId) && string.IsNullOrWhiteSpace(observationId))
|
||||
{
|
||||
return Results.BadRequest(new { error = new { code = "ERR_PARAMS", message = "Either linksetId or observationId is required" } });
|
||||
}
|
||||
|
||||
var take = Math.Clamp(limit.GetValueOrDefault(50), 1, 100);
|
||||
var collection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Attestations);
|
||||
var builder = Builders<BsonDocument>.Filter;
|
||||
|
||||
FilterDefinition<BsonDocument> filter;
|
||||
if (!string.IsNullOrWhiteSpace(linksetId))
|
||||
{
|
||||
filter = builder.Eq("LinksetId", linksetId.Trim());
|
||||
}
|
||||
else
|
||||
{
|
||||
filter = builder.Eq("ObservationId", observationId!.Trim());
|
||||
}
|
||||
|
||||
var sort = Builders<BsonDocument>.Sort.Descending("IssuedAt");
|
||||
|
||||
var documents = await collection
|
||||
.Find(filter)
|
||||
.Sort(sort)
|
||||
.Limit(take)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var items = documents.Select(doc => ToListItem(doc, tenant, timeProvider)).ToList();
|
||||
|
||||
var response = new VexAttestationLookupResponse(
|
||||
SubjectDigest: linksetId ?? observationId ?? string.Empty,
|
||||
Attestations: items,
|
||||
QueriedAt: timeProvider.GetUtcNow());
|
||||
|
||||
return Results.Ok(response);
|
||||
}).WithName("LookupVexAttestations");
|
||||
}
|
||||
|
||||
private static VexAttestationListItem ToListItem(BsonDocument doc, string tenant, TimeProvider timeProvider)
|
||||
{
|
||||
return new VexAttestationListItem(
|
||||
AttestationId: doc.GetValue("_id", BsonNull.Value).AsString ?? string.Empty,
|
||||
Tenant: tenant,
|
||||
CreatedAt: doc.GetValue("IssuedAt", BsonNull.Value).IsBsonDateTime
|
||||
? new DateTimeOffset(doc["IssuedAt"].ToUniversalTime(), TimeSpan.Zero)
|
||||
: timeProvider.GetUtcNow(),
|
||||
PredicateType: "https://in-toto.io/attestation/v1",
|
||||
SubjectDigest: doc.GetValue("ObservationId", BsonNull.Value).AsString ?? string.Empty,
|
||||
Valid: doc.Contains("Metadata") && !doc["Metadata"].IsBsonNull &&
|
||||
doc["Metadata"].AsBsonDocument.Contains("verified") &&
|
||||
doc["Metadata"]["verified"].AsString == "true",
|
||||
BuilderId: doc.GetValue("SupplierId", BsonNull.Value).AsString);
|
||||
}
|
||||
|
||||
private static bool TryResolveTenant(HttpContext context, VexStorageOptions options, out string tenant, out IResult? problem)
|
||||
{
|
||||
tenant = options.DefaultTenant;
|
||||
problem = null;
|
||||
|
||||
if (context.Request.Headers.TryGetValue("X-Stella-Tenant", out var headerValues) && headerValues.Count > 0)
|
||||
{
|
||||
var requestedTenant = headerValues[0]?.Trim();
|
||||
if (string.IsNullOrEmpty(requestedTenant))
|
||||
{
|
||||
problem = Results.BadRequest(new { error = new { code = "ERR_TENANT", message = "X-Stella-Tenant header must not be empty" } });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(requestedTenant, options.DefaultTenant, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
problem = Results.Json(
|
||||
new { error = new { code = "ERR_TENANT_FORBIDDEN", message = $"Tenant '{requestedTenant}' is not allowed" } },
|
||||
statusCode: StatusCodes.Status403Forbidden);
|
||||
return false;
|
||||
}
|
||||
|
||||
tenant = requestedTenant;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryDecodeCursor(string cursor, out DateTime timestamp, out string id)
|
||||
{
|
||||
timestamp = default;
|
||||
id = string.Empty;
|
||||
try
|
||||
{
|
||||
var payload = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(cursor));
|
||||
var parts = payload.Split('|');
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!DateTimeOffset.TryParse(parts[0], CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
timestamp = parsed.UtcDateTime;
|
||||
id = parts[1];
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string EncodeCursor(DateTime timestamp, string id)
|
||||
{
|
||||
var payload = FormattableString.Invariant($"{timestamp:O}|{id}");
|
||||
return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(payload));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user