up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-27 23:44:42 +02:00
parent ef6e4b2067
commit 3b96b2e3ea
298 changed files with 47516 additions and 1168 deletions

View File

@@ -76,6 +76,14 @@ services.AddRedHatCsafConnector();
services.Configure<MirrorDistributionOptions>(configuration.GetSection(MirrorDistributionOptions.SectionName));
services.AddSingleton<MirrorRateLimiter>();
services.TryAddSingleton(TimeProvider.System);
// CRYPTO-90-001: Crypto provider abstraction for pluggable hashing algorithms (GOST/SM support)
services.AddSingleton<IVexHashingService>(sp =>
{
// When ICryptoProviderRegistry is available, use it for pluggable algorithms
var registry = sp.GetService<StellaOps.Cryptography.ICryptoProviderRegistry>();
return new VexHashingService(registry);
});
services.AddSingleton<IVexObservationProjectionService, VexObservationProjectionService>();
services.AddScoped<IVexObservationQueryService, VexObservationQueryService>();
@@ -387,6 +395,471 @@ app.MapGet("/openapi/excititor.json", () =>
}
}
}
},
// WEB-OBS-53-001: Evidence API endpoints
["/evidence/vex/list"] = new
{
get = new
{
summary = "List VEX evidence exports",
parameters = new object[]
{
new { name = "X-Stella-Tenant", @in = "header", schema = new { type = "string" }, required = false },
new { name = "limit", @in = "query", schema = new { type = "integer", minimum = 1, maximum = 100 }, required = false },
new { name = "cursor", @in = "query", schema = new { type = "string" }, required = false }
},
responses = new Dictionary<string, object>
{
["200"] = new
{
description = "Evidence list response",
content = new Dictionary<string, object>
{
["application/json"] = new
{
examples = new Dictionary<string, object>
{
["evidence-list"] = new
{
value = new
{
items = new[] {
new {
bundleId = "vex-bundle-2025-11-24-001",
tenant = "acme",
format = "openvex",
createdAt = "2025-11-24T00:00:00Z",
itemCount = 42,
merkleRoot = "sha256:abc123...",
sealed_ = false
}
},
nextCursor = (string?)null
}
}
}
}
}
}
}
}
},
["/evidence/vex/bundle/{bundleId}"] = new
{
get = new
{
summary = "Get VEX evidence bundle details",
parameters = new object[]
{
new { name = "bundleId", @in = "path", schema = new { type = "string" }, required = true },
new { name = "X-Stella-Tenant", @in = "header", schema = new { type = "string" }, required = false }
},
responses = new Dictionary<string, object>
{
["200"] = new
{
description = "Bundle detail response",
content = new Dictionary<string, object>
{
["application/json"] = new
{
examples = new Dictionary<string, object>
{
["bundle-detail"] = new
{
value = new
{
bundleId = "vex-bundle-2025-11-24-001",
tenant = "acme",
format = "openvex",
specVersion = "0.2.0",
createdAt = "2025-11-24T00:00:00Z",
itemCount = 42,
merkleRoot = "sha256:abc123...",
sealed_ = false,
metadata = new { source = "excititor" }
}
}
}
}
}
},
["404"] = new
{
description = "Bundle not found",
content = new Dictionary<string, object>
{
["application/json"] = new
{
schema = new { @ref = "#/components/schemas/Error" }
}
}
}
}
}
},
["/evidence/vex/lookup"] = new
{
get = new
{
summary = "Lookup evidence for vulnerability/product pair",
parameters = new object[]
{
new { name = "vulnerabilityId", @in = "query", schema = new { type = "string" }, required = true, example = "CVE-2024-12345" },
new { name = "productKey", @in = "query", schema = new { type = "string" }, required = true, example = "pkg:npm/lodash@4.17.21" },
new { name = "X-Stella-Tenant", @in = "header", schema = new { type = "string" }, required = false }
},
responses = new Dictionary<string, object>
{
["200"] = new
{
description = "Evidence lookup response",
content = new Dictionary<string, object>
{
["application/json"] = new
{
examples = new Dictionary<string, object>
{
["lookup-result"] = new
{
value = new
{
vulnerabilityId = "CVE-2024-12345",
productKey = "pkg:npm/lodash@4.17.21",
evidence = new[] {
new { bundleId = "vex-bundle-001", observationId = "obs-001" }
},
queriedAt = "2025-11-24T12:00:00Z"
}
}
}
}
}
}
}
}
},
// WEB-OBS-54-001: Attestation API endpoints
["/attestations/vex/list"] = new
{
get = new
{
summary = "List VEX attestations",
parameters = new object[]
{
new { name = "limit", @in = "query", schema = new { type = "integer", minimum = 1, maximum = 200 }, required = false },
new { name = "cursor", @in = "query", schema = new { type = "string" }, required = false },
new { name = "vulnerabilityId", @in = "query", schema = new { type = "string" }, required = false },
new { name = "productKey", @in = "query", schema = new { type = "string" }, required = false },
new { name = "X-Stella-Tenant", @in = "header", schema = new { type = "string" }, required = false }
},
responses = new Dictionary<string, object>
{
["200"] = new
{
description = "Attestation list response",
content = new Dictionary<string, object>
{
["application/json"] = new
{
examples = new Dictionary<string, object>
{
["attestation-list"] = new
{
value = new
{
items = new[] {
new {
attestationId = "att-2025-001",
tenant = "acme",
createdAt = "2025-11-24T00:00:00Z",
predicateType = "https://in-toto.io/attestation/v1",
subjectDigest = "sha256:abc123...",
valid = true,
builderId = "excititor:redhat"
}
},
nextCursor = (string?)null,
hasMore = false,
count = 1
}
}
}
}
}
}
}
}
},
["/attestations/vex/{attestationId}"] = new
{
get = new
{
summary = "Get VEX attestation details with DSSE verification state",
parameters = new object[]
{
new { name = "attestationId", @in = "path", schema = new { type = "string" }, required = true },
new { name = "X-Stella-Tenant", @in = "header", schema = new { type = "string" }, required = false }
},
responses = new Dictionary<string, object>
{
["200"] = new
{
description = "Attestation detail response with chain-of-custody",
content = new Dictionary<string, object>
{
["application/json"] = new
{
examples = new Dictionary<string, object>
{
["attestation-detail"] = new
{
value = new
{
attestationId = "att-2025-001",
tenant = "acme",
createdAt = "2025-11-24T00:00:00Z",
predicateType = "https://in-toto.io/attestation/v1",
subject = new { digest = "sha256:abc123...", name = "CVE-2024-12345/pkg:npm/lodash@4.17.21" },
builder = new { id = "excititor:redhat", builderId = "excititor:redhat" },
verification = new { valid = true, verifiedAt = "2025-11-24T00:00:00Z", signatureType = "dsse" },
chainOfCustody = new[] {
new { step = 1, actor = "excititor:redhat", action = "created", timestamp = "2025-11-24T00:00:00Z" }
}
}
}
}
}
}
},
["404"] = new
{
description = "Attestation not found",
content = new Dictionary<string, object>
{
["application/json"] = new
{
schema = new { @ref = "#/components/schemas/Error" }
}
}
}
}
}
},
["/attestations/vex/lookup"] = new
{
get = new
{
summary = "Lookup attestations by linkset or observation",
parameters = new object[]
{
new { name = "linksetId", @in = "query", schema = new { type = "string" }, required = false },
new { name = "observationId", @in = "query", schema = new { type = "string" }, required = false },
new { name = "limit", @in = "query", schema = new { type = "integer", minimum = 1, maximum = 100 }, required = false },
new { name = "X-Stella-Tenant", @in = "header", schema = new { type = "string" }, required = false }
},
responses = new Dictionary<string, object>
{
["200"] = new
{
description = "Attestation lookup response",
content = new Dictionary<string, object>
{
["application/json"] = new
{
examples = new Dictionary<string, object>
{
["lookup-result"] = new
{
value = new
{
subjectDigest = "linkset-001",
attestations = new[] {
new { attestationId = "att-001", valid = true }
},
queriedAt = "2025-11-24T12:00:00Z"
}
}
}
}
}
},
["400"] = new
{
description = "Missing required parameter",
content = new Dictionary<string, object>
{
["application/json"] = new
{
schema = new { @ref = "#/components/schemas/Error" }
}
}
}
}
}
},
// EXCITITOR-LNM-21-201: Observation API endpoints
["/vex/observations"] = new
{
get = new
{
summary = "List VEX observations with filters",
parameters = new object[]
{
new { name = "limit", @in = "query", schema = new { type = "integer", minimum = 1, maximum = 100 }, required = false },
new { name = "cursor", @in = "query", schema = new { type = "string" }, required = false },
new { name = "vulnerabilityId", @in = "query", schema = new { type = "string" }, required = false, example = "CVE-2024-12345" },
new { name = "productKey", @in = "query", schema = new { type = "string" }, required = false, example = "pkg:npm/lodash@4.17.21" },
new { name = "providerId", @in = "query", schema = new { type = "string" }, required = false, example = "excititor:redhat" },
new { name = "X-Stella-Tenant", @in = "header", schema = new { type = "string" }, required = false }
},
responses = new Dictionary<string, object>
{
["200"] = new
{
description = "Observation list response",
content = new Dictionary<string, object>
{
["application/json"] = new
{
examples = new Dictionary<string, object>
{
["observation-list"] = new
{
value = new
{
items = new[] {
new {
observationId = "obs-2025-001",
tenant = "acme",
providerId = "excititor:redhat",
vulnerabilityId = "CVE-2024-12345",
productKey = "pkg:npm/lodash@4.17.21",
status = "not_affected",
createdAt = "2025-11-24T00:00:00Z"
}
},
nextCursor = (string?)null
}
}
}
}
}
},
["400"] = new
{
description = "Missing required filter",
content = new Dictionary<string, object>
{
["application/json"] = new
{
schema = new { @ref = "#/components/schemas/Error" },
examples = new Dictionary<string, object>
{
["missing-filter"] = new
{
value = new
{
error = new
{
code = "ERR_PARAMS",
message = "At least one filter is required: vulnerabilityId+productKey or providerId"
}
}
}
}
}
}
}
}
}
},
["/vex/observations/{observationId}"] = new
{
get = new
{
summary = "Get VEX observation by ID",
parameters = new object[]
{
new { name = "observationId", @in = "path", schema = new { type = "string" }, required = true },
new { name = "X-Stella-Tenant", @in = "header", schema = new { type = "string" }, required = false }
},
responses = new Dictionary<string, object>
{
["200"] = new
{
description = "Observation detail response",
content = new Dictionary<string, object>
{
["application/json"] = new
{
examples = new Dictionary<string, object>
{
["observation-detail"] = new
{
value = new
{
observationId = "obs-2025-001",
tenant = "acme",
providerId = "excititor:redhat",
streamId = "stream-001",
upstream = new { upstreamId = "RHSA-2024:001", fetchedAt = "2025-11-24T00:00:00Z" },
content = new { format = "csaf", specVersion = "2.0" },
statements = new[] {
new { vulnerabilityId = "CVE-2024-12345", productKey = "pkg:npm/lodash@4.17.21", status = "not_affected" }
},
linkset = new { aliases = new[] { "CVE-2024-12345" }, purls = new[] { "pkg:npm/lodash@4.17.21" } },
createdAt = "2025-11-24T00:00:00Z"
}
}
}
}
}
},
["404"] = new
{
description = "Observation not found",
content = new Dictionary<string, object>
{
["application/json"] = new
{
schema = new { @ref = "#/components/schemas/Error" }
}
}
}
}
}
},
["/vex/observations/count"] = new
{
get = new
{
summary = "Get observation count for tenant",
parameters = new object[]
{
new { name = "X-Stella-Tenant", @in = "header", schema = new { type = "string" }, required = false }
},
responses = new Dictionary<string, object>
{
["200"] = new
{
description = "Count response",
content = new Dictionary<string, object>
{
["application/json"] = new
{
examples = new Dictionary<string, object>
{
["count"] = new
{
value = new { count = 1234 }
}
}
}
}
}
}
}
}
},
components = new
@@ -451,6 +924,8 @@ app.MapPost("/airgap/v1/vex/import", async (
[FromServices] AirgapSignerTrustService trustService,
[FromServices] AirgapModeEnforcer modeEnforcer,
[FromServices] IAirgapImportStore store,
[FromServices] IVexTimelineEventEmitter timelineEmitter,
[FromServices] IVexHashingService hashingService,
[FromServices] ILoggerFactory loggerFactory,
[FromServices] TimeProvider timeProvider,
[FromBody] AirgapImportRequest request,
@@ -465,6 +940,7 @@ app.MapPost("/airgap/v1/vex/import", async (
? (int?)null
: (int)Math.Round((nowUtc - request.SignedAt.Value).TotalSeconds);
var traceId = Activity.Current?.TraceId.ToString();
var timeline = new List<AirgapTimelineEntry>();
void RecordEvent(string eventType, string? code = null, string? message = null)
{
@@ -481,6 +957,54 @@ app.MapPost("/airgap/v1/vex/import", async (
};
timeline.Add(entry);
logger.LogInformation("Airgap timeline event {EventType} bundle={BundleId} gen={Gen} tenant={Tenant} code={Code}", eventType, entry.BundleId, entry.MirrorGeneration, tenantId, code);
// WEB-AIRGAP-58-001: Emit timeline event to persistent store for SSE streaming
_ = EmitTimelineEventAsync(eventType, code, message);
}
async Task EmitTimelineEventAsync(string eventType, string? code, string? message)
{
try
{
var attributes = new Dictionary<string, string>(StringComparer.Ordinal)
{
["bundle_id"] = request.BundleId ?? string.Empty,
["mirror_generation"] = request.MirrorGeneration ?? string.Empty
};
if (stalenessSeconds.HasValue)
{
attributes["staleness_seconds"] = stalenessSeconds.Value.ToString(CultureInfo.InvariantCulture);
}
if (!string.IsNullOrEmpty(code))
{
attributes["error_code"] = code;
}
if (!string.IsNullOrEmpty(message))
{
attributes["message"] = message;
}
var eventId = $"airgap-{request.BundleId}-{request.MirrorGeneration}-{nowUtc:yyyyMMddHHmmssfff}";
var streamId = $"airgap:{request.BundleId}:{request.MirrorGeneration}";
var evt = new TimelineEvent(
eventId,
tenantId,
"airgap-import",
streamId,
eventType,
traceId ?? Guid.NewGuid().ToString("N"),
justificationSummary: message ?? string.Empty,
nowUtc,
evidenceHash: null,
payloadHash: request.PayloadHash,
attributes.ToImmutableDictionary());
await timelineEmitter.EmitAsync(evt, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to emit timeline event {EventType} for bundle {BundleId}", eventType, request.BundleId);
}
}
RecordEvent("airgap.import.started");
@@ -528,7 +1052,8 @@ app.MapPost("/airgap/v1/vex/import", async (
var manifestPath = $"mirror/{request.BundleId}/{request.MirrorGeneration}/manifest.json";
var evidenceLockerPath = $"evidence/{request.BundleId}/{request.MirrorGeneration}/bundle.ndjson";
var manifestHash = ComputeSha256($"{request.BundleId}:{request.MirrorGeneration}:{request.PayloadHash}");
// CRYPTO-90-001: Use IVexHashingService for pluggable crypto algorithms
var manifestHash = hashingService.ComputeHash($"{request.BundleId}:{request.MirrorGeneration}:{request.PayloadHash}");
RecordEvent("airgap.import.completed");
@@ -578,12 +1103,7 @@ app.MapPost("/airgap/v1/vex/import", async (
});
});
static string ComputeSha256(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
}
// CRYPTO-90-001: ComputeSha256 removed - now using IVexHashingService for pluggable crypto
app.MapPost("/v1/attestations/verify", async (
[FromServices] IVexAttestationClient attestationClient,
@@ -1666,10 +2186,13 @@ app.MapGet("/obs/excititor/health", async (
app.MapGet("/obs/excititor/timeline", async (
HttpContext context,
IOptions<VexMongoStorageOptions> storageOptions,
[FromServices] IVexTimelineEventStore timelineStore,
TimeProvider timeProvider,
ILoggerFactory loggerFactory,
[FromQuery] string? cursor,
[FromQuery] int? limit,
[FromQuery] string? eventType,
[FromQuery] string? providerId,
CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, storageOptions.Value, requireHeader: true, out var tenant, out var tenantError))
@@ -1680,44 +2203,71 @@ app.MapGet("/obs/excititor/timeline", async (
var logger = loggerFactory.CreateLogger("ExcititorTimeline");
var take = Math.Clamp(limit.GetValueOrDefault(10), 1, 100);
var startId = 0;
// Parse cursor as ISO-8601 timestamp or Last-Event-ID header
DateTimeOffset? cursorTimestamp = null;
var candidateCursor = cursor ?? context.Request.Headers["Last-Event-ID"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(candidateCursor) && !int.TryParse(candidateCursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out startId))
if (!string.IsNullOrWhiteSpace(candidateCursor))
{
return Results.BadRequest(new { error = "cursor must be integer" });
if (DateTimeOffset.TryParse(candidateCursor, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed))
{
cursorTimestamp = parsed;
}
else
{
return Results.BadRequest(new { error = new { code = "ERR_CURSOR", message = "cursor must be ISO-8601 timestamp" } });
}
}
context.Response.Headers.CacheControl = "no-store";
context.Response.Headers["X-Accel-Buffering"] = "no";
context.Response.Headers["Link"] = "</openapi/excititor.json>; rel=\"describedby\"; type=\"application/json\"";
context.Response.ContentType = "text/event-stream";
await context.Response.WriteAsync("retry: 5000\n\n", cancellationToken).ConfigureAwait(false);
// Fetch real timeline events from the store
IReadOnlyList<TimelineEvent> events;
var now = timeProvider.GetUtcNow();
var events = Enumerable.Range(startId, take)
.Select(id => new ExcititorTimelineEvent(
Type: "evidence.update",
Tenant: tenant,
Source: "vex-runtime",
Count: 0,
Errors: 0,
TraceId: null,
OccurredAt: now.ToString("O", CultureInfo.InvariantCulture)))
.ToList();
foreach (var (evt, idx) in events.Select((e, i) => (e, i)))
if (!string.IsNullOrWhiteSpace(eventType))
{
events = await timelineStore.FindByEventTypeAsync(tenant, eventType, take, cancellationToken).ConfigureAwait(false);
}
else if (!string.IsNullOrWhiteSpace(providerId))
{
events = await timelineStore.FindByProviderAsync(tenant, providerId, take, cancellationToken).ConfigureAwait(false);
}
else if (cursorTimestamp.HasValue)
{
// Get events after the cursor timestamp
events = await timelineStore.FindByTimeRangeAsync(tenant, cursorTimestamp.Value, now, take, cancellationToken).ConfigureAwait(false);
}
else
{
events = await timelineStore.GetRecentAsync(tenant, take, cancellationToken).ConfigureAwait(false);
}
foreach (var evt in events)
{
cancellationToken.ThrowIfCancellationRequested();
var id = startId + idx;
await context.Response.WriteAsync($"id: {id}\n", cancellationToken).ConfigureAwait(false);
await context.Response.WriteAsync($"event: {evt.Type}\n", cancellationToken).ConfigureAwait(false);
await context.Response.WriteAsync($"data: {JsonSerializer.Serialize(evt)}\n\n", cancellationToken).ConfigureAwait(false);
var sseEvent = new ExcititorTimelineEvent(
Type: evt.EventType,
Tenant: evt.Tenant,
Source: evt.ProviderId,
Count: evt.Attributes.TryGetValue("observation_count", out var countStr) && int.TryParse(countStr, out var count) ? count : 1,
Errors: evt.Attributes.TryGetValue("error_count", out var errStr) && int.TryParse(errStr, out var errCount) ? errCount : 0,
TraceId: evt.TraceId,
OccurredAt: evt.CreatedAt.ToString("O", CultureInfo.InvariantCulture));
await context.Response.WriteAsync($"id: {evt.CreatedAt:O}\n", cancellationToken).ConfigureAwait(false);
await context.Response.WriteAsync($"event: {evt.EventType}\n", cancellationToken).ConfigureAwait(false);
await context.Response.WriteAsync($"data: {JsonSerializer.Serialize(sseEvent)}\n\n", cancellationToken).ConfigureAwait(false);
}
await context.Response.Body.FlushAsync(cancellationToken).ConfigureAwait(false);
var nextCursor = startId + events.Count;
context.Response.Headers["X-Next-Cursor"] = nextCursor.ToString(CultureInfo.InvariantCulture);
logger.LogInformation("obs excititor timeline emitted {Count} events for tenant {Tenant} start {Start} next {Next}", events.Count, tenant, startId, nextCursor);
var nextCursor = events.Count > 0 ? events[^1].CreatedAt.ToString("O", CultureInfo.InvariantCulture) : now.ToString("O", CultureInfo.InvariantCulture);
context.Response.Headers["X-Next-Cursor"] = nextCursor;
logger.LogInformation("obs excititor timeline emitted {Count} events for tenant {Tenant} cursor {Cursor} next {Next}", events.Count, tenant, candidateCursor, nextCursor);
return Results.Empty;
}).WithName("GetExcititorTimeline");
@@ -1726,11 +2276,13 @@ IngestEndpoints.MapIngestEndpoints(app);
ResolveEndpoint.MapResolveEndpoint(app);
MirrorEndpoints.MapMirrorEndpoints(app);
app.MapGet("/v1/vex/observations", async (HttpContext _, CancellationToken __) =>
Results.StatusCode(StatusCodes.Status501NotImplemented));
// Evidence and Attestation APIs (WEB-OBS-53-001, WEB-OBS-54-001)
EvidenceEndpoints.MapEvidenceEndpoints(app);
AttestationEndpoints.MapAttestationEndpoints(app);
app.MapGet("/v1/vex/linksets", async (HttpContext _, CancellationToken __) =>
Results.StatusCode(StatusCodes.Status501NotImplemented));
// Observation and Linkset APIs (EXCITITOR-LNM-21-201, EXCITITOR-LNM-21-202)
ObservationEndpoints.MapObservationEndpoints(app);
LinksetEndpoints.MapLinksetEndpoints(app);
app.Run();