feat: Add initial implementation of Vulnerability Resolver Jobs
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Created project for StellaOps.Scanner.Analyzers.Native.Tests with necessary dependencies.
- Documented roles and guidelines in AGENTS.md for Scheduler module.
- Implemented IResolverJobService interface and InMemoryResolverJobService for handling resolver jobs.
- Added ResolverBacklogNotifier and ResolverBacklogService for monitoring job metrics.
- Developed API endpoints for managing resolver jobs and retrieving metrics.
- Defined models for resolver job requests and responses.
- Integrated dependency injection for resolver job services.
- Implemented ImpactIndexSnapshot for persisting impact index data.
- Introduced SignalsScoringOptions for configurable scoring weights in reachability scoring.
- Added unit tests for ReachabilityScoringService and RuntimeFactsIngestionService.
- Created dotnet-filter.sh script to handle command-line arguments for dotnet.
- Established nuget-prime project for managing package downloads.
This commit is contained in:
master
2025-11-18 07:52:15 +02:00
parent e69b57d467
commit 8355e2ff75
299 changed files with 13293 additions and 2444 deletions

View File

@@ -1,37 +1,46 @@
# AGENTS
## Role
ASP.NET Minimal API surface for Excititor ingest, provider administration, reconciliation, export, and verification flows.
# Excititor WebService Charter
## Mission
Expose Excititor APIs (console VEX views, graph/Vuln Explorer feeds, observation intake/health) while honoring the Aggregation-Only Contract (no consensus/severity logic in this service).
## Scope
- Program bootstrap, DI wiring for connectors/normalizers/export/attestation/policy/storage.
- HTTP endpoints `/excititor/*` with authentication, authorization scopes, request validation, and deterministic responses.
- Job orchestration bridges for Worker hand-off (when co-hosted) and offline-friendly configuration.
- Observability (structured logs, metrics, tracing) aligned with StellaOps conventions.
- Optional/minor DI dependencies on minimal APIs must be declared with `[FromServices] SomeType? service = null` parameters so endpoint tests do not require bespoke service registrations.
## Participants
- StellaOps.Cli sends `excititor` verbs to this service via token-authenticated HTTPS.
- Worker receives scheduled jobs and uses shared infrastructure via common DI extensions.
- Authority service provides tokens; WebService enforces scopes before executing operations.
## Interfaces & contracts
- DTOs for ingest/export requests, run metadata, provider management.
- Background job interfaces for ingest/resume/reconcile triggering.
- Health/status endpoints exposing pull/export history and current policy revision.
## In/Out of scope
In: HTTP hosting, request orchestration, DI composition, auth/authorization, logging.
Out: long-running ingestion loops (Worker), export rendering (Export module), connector implementations.
## Observability & security expectations
- Enforce bearer token scopes, enforce audit logging (request/response correlation IDs, provider IDs).
- Emit structured events for ingest runs, export invocations, attestation references.
- Provide built-in counters/histograms for latency and throughput.
## Tests
- Minimal API contract/unit tests and integration harness will live in `../StellaOps.Excititor.WebService.Tests`.
- Working directory: `src/Excititor/StellaOps.Excititor.WebService`
- HTTP APIs, DTOs, controllers, authz filters, composition root, telemetry hooks.
- Wiring to Core/Storage libraries; no direct policy or consensus logic.
## Required Reading
- `docs/modules/excititor/architecture.md`
- `docs/modules/platform/architecture-overview.md`
- `docs/modules/excititor/README.md#latest-updates`
- `docs/modules/excititor/vex_observations.md`
- `docs/ingestion/aggregation-only-contract.md`
- `docs/modules/excititor/implementation_plan.md`
## Working Agreement
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.
## Roles
- Backend developer (.NET 10 / C# preview).
- QA automation (integration + API contract tests).
## Working Agreements
1. Update sprint `Delivery Tracker` when tasks move TODO→DOING→DONE/BLOCKED; mirror notes in Execution Log.
2. Keep APIs aggregation-only: persist raw observations, provenance, and precedence pointers; never merge/weight/consensus here.
3. Enforce tenant scoping and RBAC on all endpoints; default-deny for cross-tenant data.
4. Offline-first: no external network calls; rely on cached/mirrored feeds only.
5. Observability: structured logs, counters, optional OTEL traces behind configuration flags.
## Testing
- Prefer deterministic API/integration tests under `__Tests` with seeded Mongo fixtures.
- Verify RBAC/tenant isolation, idempotent ingestion, and stable ordering of VEX aggregates.
- Use ISO-8601 UTC timestamps and stable sorting in responses; assert on content hashes where applicable.
## Determinism & Data
- MongoDB is the canonical store; never apply consensus transformations before persistence.
- Ensure paged/list endpoints use explicit sort keys (e.g., vendor, upstreamId, version, createdUtc).
- Avoid nondeterministic clocks/randomness; inject clocks and GUID providers for tests.
## Boundaries
- Do not modify Policy Engine or Cartographer schemas from here; consume published contracts only.
- Configuration via appsettings/environment; no hard-coded secrets.
## Ready-to-Start Checklist
- Required docs reviewed.
- Test database/fixtures prepared (no external dependencies).
- Feature flags defined for new endpoints before exposing them.

View File

@@ -471,7 +471,8 @@ app.MapGet("/v1/vex/observations/{vulnerabilityId}/{productKey}", async (
var providerFilter = BuildStringFilterSet(context.Request.Query["providerId"]);
var statusFilter = BuildStatusFilter(context.Request.Query["status"]);
var since = ParseSinceTimestamp(context.Request.Query["since"]);
var limit = ResolveLimit(context.Request.Query["limit"], defaultValue: 200, min: 1, max: 500);
// Evidence chunks follow doc limits: default 500, max 2000.
var limit = ResolveLimit(context.Request.Query["limit"], defaultValue: 500, min: 1, max: 2000);
var request = new VexObservationProjectionRequest(
tenant,
@@ -514,6 +515,10 @@ app.MapGet("/v1/vex/observations/{vulnerabilityId}/{productKey}", async (
result.Truncated,
statements);
// Set total/truncated headers for clients (spec: Excititor-Results-*).
context.Response.Headers["Excititor-Results-Total"] = result.TotalCount.ToString(CultureInfo.InvariantCulture);
context.Response.Headers["Excititor-Results-Truncated"] = result.Truncated ? "true" : "false";
return Results.Json(response);
});
@@ -562,11 +567,21 @@ app.MapGet("/v1/vex/evidence/chunks", async (
}
catch (OperationCanceledException)
{
EvidenceTelemetry.RecordChunkOutcome(tenant, "cancelled");
return Results.StatusCode(StatusCodes.Status499ClientClosedRequest);
}
catch
{
EvidenceTelemetry.RecordChunkOutcome(tenant, "error");
throw;
}
context.Response.Headers["X-Total-Count"] = result.TotalCount.ToString(CultureInfo.InvariantCulture);
context.Response.Headers["X-Truncated"] = result.Truncated ? "true" : "false";
EvidenceTelemetry.RecordChunkOutcome(tenant, "success", result.Chunks.Count, result.Truncated);
EvidenceTelemetry.RecordChunkSignatureStatus(tenant, result.Chunks);
// Align headers with published contract.
context.Response.Headers["Excititor-Results-Total"] = result.TotalCount.ToString(CultureInfo.InvariantCulture);
context.Response.Headers["Excititor-Results-Truncated"] = result.Truncated ? "true" : "false";
context.Response.ContentType = "application/x-ndjson";
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);

View File

@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Linq;
using StellaOps.Excititor.Core.Aoc;
using StellaOps.Excititor.WebService.Contracts;
using StellaOps.Excititor.WebService.Services;
namespace StellaOps.Excititor.WebService.Telemetry;
@@ -24,6 +26,18 @@ internal static class EvidenceTelemetry
unit: "statements",
description: "Distribution of statements returned per observation projection request.");
private static readonly Counter<long> EvidenceRequestCounter =
Meter.CreateCounter<long>(
"excititor.vex.evidence.requests",
unit: "requests",
description: "Number of evidence chunk requests handled by the evidence APIs.");
private static readonly Histogram<int> EvidenceChunkHistogram =
Meter.CreateHistogram<int>(
"excititor.vex.evidence.chunk_count",
unit: "chunks",
description: "Distribution of evidence chunks streamed per request.");
private static readonly Counter<long> SignatureStatusCounter =
Meter.CreateCounter<long>(
"excititor.vex.signature.status",
@@ -53,13 +67,27 @@ internal static class EvidenceTelemetry
return;
}
ObservationStatementHistogram.Record(
returnedCount,
new[]
{
new KeyValuePair<string, object?>("tenant", normalizedTenant),
new KeyValuePair<string, object?>("outcome", outcome),
});
ObservationStatementHistogram.Record(returnedCount, tags);
}
public static void RecordChunkOutcome(string? tenant, string outcome, int chunkCount = 0, bool truncated = false)
{
var normalizedTenant = NormalizeTenant(tenant);
var tags = new[]
{
new KeyValuePair<string, object?>("tenant", normalizedTenant),
new KeyValuePair<string, object?>("outcome", outcome),
new KeyValuePair<string, object?>("truncated", truncated),
};
EvidenceRequestCounter.Add(1, tags);
if (!string.Equals(outcome, "success", StringComparison.OrdinalIgnoreCase))
{
return;
}
EvidenceChunkHistogram.Record(chunkCount, tags);
}
public static void RecordSignatureStatus(string? tenant, IReadOnlyList<VexObservationStatementProjection> statements)
@@ -72,6 +100,7 @@ internal static class EvidenceTelemetry
var normalizedTenant = NormalizeTenant(tenant);
var missing = 0;
var unverified = 0;
var verified = 0;
foreach (var statement in statements)
{
@@ -86,6 +115,10 @@ internal static class EvidenceTelemetry
{
unverified++;
}
else
{
verified++;
}
}
if (missing > 0)
@@ -103,11 +136,62 @@ internal static class EvidenceTelemetry
{
SignatureStatusCounter.Add(
unverified,
new[]
{
new KeyValuePair<string, object?>("tenant", normalizedTenant),
new KeyValuePair<string, object?>("status", "unverified"),
});
BuildSignatureTags(normalizedTenant, "unverified"));
}
if (verified > 0)
{
SignatureStatusCounter.Add(
verified,
BuildSignatureTags(normalizedTenant, "verified"));
}
}
public static void RecordChunkSignatureStatus(string? tenant, IReadOnlyList<VexEvidenceChunkResponse> chunks)
{
if (chunks is null || chunks.Count == 0)
{
return;
}
var normalizedTenant = NormalizeTenant(tenant);
var unsigned = 0;
var unverified = 0;
var verified = 0;
foreach (var chunk in chunks)
{
var signature = chunk.Signature;
if (signature is null)
{
unsigned++;
continue;
}
if (signature.VerifiedAt is null)
{
unverified++;
}
else
{
verified++;
}
}
if (unsigned > 0)
{
SignatureStatusCounter.Add(unsigned, BuildSignatureTags(normalizedTenant, "unsigned"));
}
if (unverified > 0)
{
SignatureStatusCounter.Add(unverified, BuildSignatureTags(normalizedTenant, "unverified"));
}
if (verified > 0)
{
SignatureStatusCounter.Add(verified, BuildSignatureTags(normalizedTenant, "verified"));
}
}
@@ -151,4 +235,11 @@ internal static class EvidenceTelemetry
private static string NormalizeSurface(string? surface)
=> string.IsNullOrWhiteSpace(surface) ? "unknown" : surface.ToLowerInvariant();
private static KeyValuePair<string, object?>[] BuildSignatureTags(string tenant, string status)
=> new[]
{
new KeyValuePair<string, object?>("tenant", tenant),
new KeyValuePair<string, object?>("status", status),
};
}

View File

@@ -1,34 +1,39 @@
# AGENTS
## Role
Background processing host coordinating scheduled pulls, retries, reconciliation, verification, and cache maintenance for Excititor.
# Excititor Worker Charter
## Mission
Run Excititor background jobs (ingestion, linkset extraction, dedup/idempotency enforcement) under the Aggregation-Only Contract; orchestrate Core + Storage without applying consensus or severity.
## Scope
- Hosted service (Worker Service) wiring timers/queues for provider pulls and reconciliation cycles.
- Resume token management, retry policies, and failure quarantines for connectors.
- Re-verification of stored attestations and cache garbage collection routines.
- Operational metrics and structured logging for offline-friendly monitoring.
## Participants
- Triggered by WebService job requests or internal schedules to run connector pulls.
- Collaborates with Storage.Mongo repositories and Attestation verification utilities.
- Emits telemetry consumed by observability stack and CLI status queries.
## Interfaces & contracts
- Scheduler abstractions, provider run controllers, retry/backoff strategies, and queue processors.
- Hooks for policy revision changes and cache GC thresholds.
## In/Out of scope
In: background orchestration, job lifecycle management, observability for worker operations.
Out: HTTP endpoint definitions, domain modeling, connector-specific parsing logic.
## Observability & security expectations
- Publish metrics for pull latency, failure counts, retry depth, cache size, and verification outcomes.
- Log correlation IDs & provider IDs; avoid leaking secret config values.
## Tests
- Worker orchestration tests, timer controls, and retry behavior will live in `../StellaOps.Excititor.Worker.Tests`.
- Working directory: `src/Excititor/StellaOps.Excititor.Worker`
- Job runners, pipelines, scheduling, DI wiring, health checks, telemetry for background tasks.
## Required Reading
- `docs/modules/excititor/architecture.md`
- `docs/modules/platform/architecture-overview.md`
- `docs/modules/excititor/vex_observations.md`
- `docs/ingestion/aggregation-only-contract.md`
- `docs/modules/excititor/implementation_plan.md`
## Working Agreement
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.
## Roles
- Backend/worker engineer (.NET 10).
- QA automation (background job + integration tests).
## Working Agreements
1. Track task status in sprint files; log notable operational decisions in Execution Log.
2. Respect tenant isolation on all job inputs/outputs; never process cross-tenant data.
3. Idempotent processing only: guard against duplicate bundles and repeated messages.
4. Offline-first; no external fetches during jobs.
5. Observability: structured logs, counters, and optional OTEL traces behind config flags.
## Testing & Determinism
- Provide deterministic job fixtures with seeded clocks/IDs; assert stable ordering of outputs and retries.
- Simulate failure/retry paths; ensure idempotent writes in Storage.
- Keep timestamps UTC ISO-8601; inject clock/GUID providers for tests.
## Boundaries
- Delegate domain logic to Core and persistence to Storage.Mongo; avoid embedding policy or UI concerns.
- Configuration via appsettings/environment; no hard-coded secrets.
## Ready-to-Start Checklist
- Required docs reviewed.
- Test harness prepared for background jobs (including retry/backoff settings).
- Feature flags defined for new pipelines before enabling in production runs.

View File

@@ -1,37 +1,40 @@
# AGENTS
## Role
Domain source of truth for VEX statements, consensus rollups, and trust policy orchestration across all Excititor services.
# Excititor Core Charter
## Mission
Provide ingestion/domain logic for VEX observations and linksets under the Aggregation-Only Contract: store raw facts, provenance, and precedence pointers without computing consensus or severity.
## Scope
- Records for raw document metadata, normalized claims, consensus projections, and export descriptors.
- Policy + weighting engine that projects provider trust tiers into consensus status outcomes.
- Connector, normalizer, export, and attestation contracts shared by WebService, Worker, and plug-ins.
- Deterministic hashing utilities (query signatures, artifact digests, attestation subjects).
## Participants
- Excititor WebService uses the models to persist ingress/egress payloads and to perform consensus mutations.
- Excititor Worker executes reconciliation and verification routines using policy helpers defined here.
- Export/Attestation modules depend on record definitions for envelopes and manifest payloads.
## Interfaces & contracts
- `IVexConnector`, `INormalizer`, `IExportEngine`, `ITransparencyLogClient`, `IArtifactStore`, and policy abstractions for consensus resolution.
- Value objects for provider metadata, VexClaim, VexConsensusEntry, ExportManifest, QuerySignature.
- Deterministic comparer utilities and stable JSON serialization helpers for tests and cache keys.
## In/Out of scope
In: domain invariants, policy evaluation helpers, deterministic serialization, shared abstractions.
Out: Mongo persistence implementations, HTTP endpoints, background scheduling, concrete connector logic.
## Observability & security expectations
- Avoid secret handling; provide structured logging extension methods for consensus decisions.
- Emit correlation identifiers and query signatures without embedding PII.
- Ensure deterministic logging order to keep reproducibility guarantees intact.
## Tests
- Unit coverage lives in `../StellaOps.Excititor.Core.Tests` (to be scaffolded) focusing on consensus, policy gates, and serialization determinism.
- Golden fixtures must rely on canonical JSON snapshots produced via stable serializers.
- Working directory: `src/Excititor/__Libraries/StellaOps.Excititor.Core`
- Domain models, validators, linkset extraction, idempotent upserts, tenant guards, and invariants shared by WebService/Worker.
- No UI concerns; no policy evaluation.
## Required Reading
- `docs/modules/excititor/architecture.md`
- `docs/modules/platform/architecture-overview.md`
- `docs/modules/excititor/vex_observations.md`
- `docs/ingestion/aggregation-only-contract.md`
- `docs/modules/excititor/implementation_plan.md`
## Working Agreement
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.
## Roles
- Backend library engineer (.NET 10 / C# preview).
- QA automation (unit + integration against Mongo fixtures).
## Working Agreements
1. Update sprint status on task transitions; log notable decisions in sprint Execution Log.
2. Enforce idempotent ingestion: uniqueness on `(vendor, upstreamId, contentHash, tenant)` and append-only supersede chains.
3. Preserve provenance fields and reconciled-from metadata when building linksets; never drop issuer data.
4. Tenant isolation is mandatory: all queries/commands include tenant scope; cross-tenant writes must be rejected.
5. Offline-first; avoid fetching external resources at runtime.
## Testing & Determinism
- Write deterministic tests: seeded clocks/GUIDs, stable ordering of collections, ISO-8601 UTC timestamps.
- Cover linkset extraction ordering, supersede chain construction, and duplicate prevention.
- Use Mongo in-memory/test harness fixtures; do not rely on live services.
## Boundaries
- Do not embed Policy Engine rules or Cartographer schemas here; expose contracts for consumers instead.
- Keep serialization shapes versioned; document breaking changes in `docs/modules/excititor/changes.md` if created.
## Ready-to-Start Checklist
- Required docs reviewed.
- Deterministic test fixtures in place.
- Feature flags/config options identified for any behavioral changes.

View File

@@ -0,0 +1,154 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Excititor.Core.Observations;
public static class VexLinksetUpdatedEventFactory
{
public const string EventType = "vex.linkset.updated";
public static VexLinksetUpdatedEvent Create(
string tenant,
string linksetId,
string vulnerabilityId,
string productKey,
IEnumerable<VexObservation> observations,
IEnumerable<VexObservationDisagreement> disagreements,
DateTimeOffset createdAtUtc)
{
var normalizedTenant = Ensure(tenant, nameof(tenant)).ToLowerInvariant();
var normalizedLinksetId = Ensure(linksetId, nameof(linksetId));
var normalizedVulnerabilityId = Ensure(vulnerabilityId, nameof(vulnerabilityId));
var normalizedProductKey = Ensure(productKey, nameof(productKey));
var observationRefs = (observations ?? Enumerable.Empty<VexObservation>())
.Where(obs => obs is not null)
.SelectMany(obs => obs.Statements.Select(statement => new VexLinksetObservationRef(
observationId: obs.ObservationId,
providerId: obs.ProviderId,
status: statement.Status.ToString().ToLowerInvariant(),
confidence: statement.Signals?.Severity?.Score)))
.Distinct(VexLinksetObservationRefComparer.Instance)
.OrderBy(refItem => refItem.ProviderId, StringComparer.OrdinalIgnoreCase)
.ThenBy(refItem => refItem.ObservationId, StringComparer.Ordinal)
.ToImmutableArray();
var disagreementList = (disagreements ?? Enumerable.Empty<VexObservationDisagreement>())
.Where(d => d is not null)
.Select(d => new VexObservationDisagreement(
providerId: Normalize(d.ProviderId),
status: Normalize(d.Status),
justification: VexObservation.TrimToNull(d.Justification),
confidence: d.Confidence is null ? null : Math.Clamp(d.Confidence.Value, 0.0, 1.0)))
.Distinct(DisagreementComparer.Instance)
.OrderBy(d => d.ProviderId, StringComparer.OrdinalIgnoreCase)
.ThenBy(d => d.Status, StringComparer.OrdinalIgnoreCase)
.ThenBy(d => d.Justification ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
return new VexLinksetUpdatedEvent(
EventType,
normalizedTenant,
normalizedLinksetId,
normalizedVulnerabilityId,
normalizedProductKey,
observationRefs,
disagreementList,
createdAtUtc);
}
private static string Normalize(string value) => Ensure(value, nameof(value));
private static string Ensure(string value, string name)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException($"{name} cannot be null or whitespace", name);
}
return value.Trim();
}
private sealed class VexLinksetObservationRefComparer : IEqualityComparer<VexLinksetObservationRef>
{
public static readonly VexLinksetObservationRefComparer Instance = new();
public bool Equals(VexLinksetObservationRef? x, VexLinksetObservationRef? y)
{
if (ReferenceEquals(x, y))
{
return true;
}
if (x is null || y is null)
{
return false;
}
return string.Equals(x.ObservationId, y.ObservationId, StringComparison.Ordinal)
&& string.Equals(x.ProviderId, y.ProviderId, StringComparison.OrdinalIgnoreCase)
&& string.Equals(x.Status, y.Status, StringComparison.OrdinalIgnoreCase)
&& Nullable.Equals(x.Confidence, y.Confidence);
}
public int GetHashCode(VexLinksetObservationRef obj)
{
var hash = new HashCode();
hash.Add(obj.ObservationId, StringComparer.Ordinal);
hash.Add(obj.ProviderId, StringComparer.OrdinalIgnoreCase);
hash.Add(obj.Status, StringComparer.OrdinalIgnoreCase);
hash.Add(obj.Confidence);
return hash.ToHashCode();
}
}
private sealed class DisagreementComparer : IEqualityComparer<VexObservationDisagreement>
{
public static readonly DisagreementComparer Instance = new();
public bool Equals(VexObservationDisagreement? x, VexObservationDisagreement? y)
{
if (ReferenceEquals(x, y))
{
return true;
}
if (x is null || y is null)
{
return false;
}
return string.Equals(x.ProviderId, y.ProviderId, StringComparison.OrdinalIgnoreCase)
&& string.Equals(x.Status, y.Status, StringComparison.OrdinalIgnoreCase)
&& string.Equals(x.Justification, y.Justification, StringComparison.OrdinalIgnoreCase)
&& Nullable.Equals(x.Confidence, y.Confidence);
}
public int GetHashCode(VexObservationDisagreement obj)
{
var hash = new HashCode();
hash.Add(obj.ProviderId, StringComparer.OrdinalIgnoreCase);
hash.Add(obj.Status, StringComparer.OrdinalIgnoreCase);
hash.Add(obj.Justification, StringComparer.OrdinalIgnoreCase);
hash.Add(obj.Confidence);
return hash.ToHashCode();
}
}
}
public sealed record VexLinksetObservationRef(
string ObservationId,
string ProviderId,
string Status,
double? Confidence);
public sealed record VexLinksetUpdatedEvent(
string EventType,
string Tenant,
string LinksetId,
string VulnerabilityId,
string ProductKey,
ImmutableArray<VexLinksetObservationRef> Observations,
ImmutableArray<VexObservationDisagreement> Disagreements,
DateTimeOffset CreatedAtUtc);

View File

@@ -352,21 +352,23 @@ public sealed record VexObservationStatement
}
}
public sealed record VexObservationLinkset
{
public VexObservationLinkset(
IEnumerable<string>? aliases,
IEnumerable<string>? purls,
IEnumerable<string>? cpes,
IEnumerable<VexObservationReference>? references,
IEnumerable<string>? reconciledFrom = null)
{
Aliases = NormalizeSet(aliases, toLower: true);
Purls = NormalizeSet(purls, toLower: false);
Cpes = NormalizeSet(cpes, toLower: false);
References = NormalizeReferences(references);
ReconciledFrom = NormalizeSet(reconciledFrom, toLower: false);
}
public sealed record VexObservationLinkset
{
public VexObservationLinkset(
IEnumerable<string>? aliases,
IEnumerable<string>? purls,
IEnumerable<string>? cpes,
IEnumerable<VexObservationReference>? references,
IEnumerable<string>? reconciledFrom = null,
IEnumerable<VexObservationDisagreement>? disagreements = null)
{
Aliases = NormalizeSet(aliases, toLower: true);
Purls = NormalizeSet(purls, toLower: false);
Cpes = NormalizeSet(cpes, toLower: false);
References = NormalizeReferences(references);
ReconciledFrom = NormalizeSet(reconciledFrom, toLower: false);
Disagreements = NormalizeDisagreements(disagreements);
}
public ImmutableArray<string> Aliases { get; }
@@ -374,9 +376,11 @@ public sealed record VexObservationLinkset
public ImmutableArray<string> Cpes { get; }
public ImmutableArray<VexObservationReference> References { get; }
public ImmutableArray<string> ReconciledFrom { get; }
public ImmutableArray<VexObservationReference> References { get; }
public ImmutableArray<string> ReconciledFrom { get; }
public ImmutableArray<VexObservationDisagreement> Disagreements { get; }
private static ImmutableArray<string> NormalizeSet(IEnumerable<string>? values, bool toLower)
{
@@ -419,19 +423,116 @@ public sealed record VexObservationLinkset
set.Add(reference);
}
return set.Count == 0 ? ImmutableArray<VexObservationReference>.Empty : set.ToImmutableArray();
}
}
return set.Count == 0 ? ImmutableArray<VexObservationReference>.Empty : set.ToImmutableArray();
}
private static ImmutableArray<VexObservationDisagreement> NormalizeDisagreements(
IEnumerable<VexObservationDisagreement>? disagreements)
{
if (disagreements is null)
{
return ImmutableArray<VexObservationDisagreement>.Empty;
}
var comparer = Comparer<VexObservationDisagreement>.Create(static (left, right) =>
{
var providerCompare = StringComparer.OrdinalIgnoreCase.Compare(left.ProviderId, right.ProviderId);
if (providerCompare != 0)
{
return providerCompare;
}
return StringComparer.OrdinalIgnoreCase.Compare(left.Status, right.Status);
});
var set = new SortedSet<VexObservationDisagreement>(Comparer<VexObservationDisagreement>.Create((a, b) =>
{
var providerCompare = StringComparer.OrdinalIgnoreCase.Compare(a.ProviderId, b.ProviderId);
if (providerCompare != 0)
{
return providerCompare;
}
var statusCompare = StringComparer.OrdinalIgnoreCase.Compare(a.Status, b.Status);
if (statusCompare != 0)
{
return statusCompare;
}
var justificationCompare = StringComparer.OrdinalIgnoreCase.Compare(
a.Justification ?? string.Empty,
b.Justification ?? string.Empty);
if (justificationCompare != 0)
{
return justificationCompare;
}
return Nullable.Compare(a.Confidence, b.Confidence);
}));
foreach (var disagreement in disagreements)
{
if (disagreement is null)
{
continue;
}
var normalizedProvider = VexObservation.TrimToNull(disagreement.ProviderId);
var normalizedStatus = VexObservation.TrimToNull(disagreement.Status);
if (normalizedProvider is null || normalizedStatus is null)
{
continue;
}
var normalizedJustification = VexObservation.TrimToNull(disagreement.Justification);
var clampedConfidence = disagreement.Confidence is null
? null
: Math.Clamp(disagreement.Confidence.Value, 0.0, 1.0);
set.Add(new VexObservationDisagreement(
normalizedProvider,
normalizedStatus,
normalizedJustification,
clampedConfidence));
}
return set.Count == 0 ? ImmutableArray<VexObservationDisagreement>.Empty : set.ToImmutableArray();
}
}
public sealed record VexObservationReference
{
public VexObservationReference(string type, string url)
{
Type = VexObservation.EnsureNotNullOrWhiteSpace(type, nameof(type));
Url = VexObservation.EnsureNotNullOrWhiteSpace(url, nameof(url));
}
public sealed record VexObservationReference
{
public VexObservationReference(string type, string url)
{
Type = VexObservation.EnsureNotNullOrWhiteSpace(type, nameof(type));
Url = VexObservation.EnsureNotNullOrWhiteSpace(url, nameof(url));
}
public string Type { get; }
public string Url { get; }
}
public string Url { get; }
}
public sealed record VexObservationDisagreement
{
public VexObservationDisagreement(
string providerId,
string status,
string? justification,
double? confidence)
{
ProviderId = VexObservation.EnsureNotNullOrWhiteSpace(providerId, nameof(providerId));
Status = VexObservation.EnsureNotNullOrWhiteSpace(status, nameof(status));
Justification = justification;
Confidence = confidence;
}
public string ProviderId { get; }
public string Status { get; }
public string? Justification { get; }
public double? Confidence { get; }
}

View File

@@ -1,35 +1,37 @@
# AGENTS
## Role
MongoDB persistence layer for Excititor raw documents, claims, consensus snapshots, exports, and cache metadata.
# Excititor Storage (Mongo) Charter
## Mission
Provide Mongo-backed persistence for Excititor ingestion, linksets, and observations with deterministic schemas, indexes, and migrations; keep aggregation-only semantics intact.
## Scope
- Collection schemas, Bson class maps, repositories, and transactional write patterns for ingest/export flows.
- GridFS integration for raw source documents and artifact metadata persistence.
- Migrations, index builders, and bootstrap routines aligned with offline-first deployments.
- Deterministic query helpers used by WebService, Worker, and Export modules.
## Participants
- WebService invokes repositories to store ingest runs, recompute consensus, and register exports.
- Worker relies on repositories for resume markers, retry queues, and cache GC flows.
- Export/Attestation modules pull stored claims/consensus data for snapshot building.
## Interfaces & contracts
- Repository abstractions (`IVexRawStore`, `IVexClaimStore`, `IVexConsensusStore`, `IVexExportStore`, `IVexCacheIndex`) and migration host interfaces.
- Diagnostics hooks providing collection health metrics and schema validation results.
## In/Out of scope
In: MongoDB data access, migrations, transactional semantics, schema documentation.
Out: domain modeling (Core), policy evaluation (Policy), HTTP surfaces (WebService).
## Observability & security expectations
- Emit structured logs for collection/migration events including revision ids and elapsed timings.
- Expose health metrics (counts, queue backlog) and publish to OpenTelemetry when enabled.
- Ensure no raw secret material is logged; mask tokens/URLs in diagnostics.
## Tests
- Integration fixtures (Mongo runner) and schema regression tests will reside in `../StellaOps.Excititor.Storage.Mongo.Tests`.
- Working directory: `src/Excititor/__Libraries/StellaOps.Excititor.Storage.Mongo`
- Collections, indexes, migrations, repository abstractions, and data access helpers shared by WebService/Worker/Core.
## Required Reading
- `docs/modules/excititor/architecture.md`
- `docs/modules/platform/architecture-overview.md`
- `docs/modules/excititor/vex_observations.md`
- `docs/ingestion/aggregation-only-contract.md`
- `docs/modules/excititor/implementation_plan.md`
## Working Agreement
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.
## Roles
- Backend/storage engineer (.NET 10, MongoDB driver ≥3.0).
- QA automation (repository + migration tests).
## Working Agreements
1. Maintain deterministic migrations; record new indexes and shapes in sprint `Execution Log` and module docs if added.
2. Enforce tenant scope in all queries; include partition keys in indexes where applicable.
3. No consensus/weighting logic; store raw facts, provenance, and precedence pointers only.
4. Offline-first; no runtime external calls.
## Testing & Determinism
- Use Mongo test fixtures/in-memory harness with seeded data; assert index presence and sort stability.
- Keep timestamps UTC ISO-8601 and ordering explicit (e.g., vendor, upstreamId, version, createdUtc).
- Avoid nondeterministic ObjectId/GUID usage in tests; seed values.
## Boundaries
- Do not embed Policy Engine or Cartographer schemas; consume published contracts.
- Config via DI/appsettings; no hard-coded connection strings.
## Ready-to-Start Checklist
- Required docs reviewed.
- Test fixture database prepared; migrations scripted and reversible where possible.

View File

@@ -0,0 +1,79 @@
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
namespace StellaOps.Excititor.Storage.Mongo.Migrations;
internal sealed class VexObservationCollectionsMigration : IVexMongoMigration
{
public string Id => "20251117-observations-linksets";
public async ValueTask ExecuteAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(database);
await EnsureObservationsIndexesAsync(database, cancellationToken).ConfigureAwait(false);
await EnsureLinksetIndexesAsync(database, cancellationToken).ConfigureAwait(false);
}
private static Task EnsureObservationsIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
var collection = database.GetCollection<VexObservationRecord>(VexMongoCollectionNames.Observations);
var tenantObservationIndex = Builders<VexObservationRecord>.IndexKeys
.Ascending(x => x.Tenant)
.Ascending(x => x.ObservationId);
var tenantVulnIndex = Builders<VexObservationRecord>.IndexKeys
.Ascending(x => x.Tenant)
.Ascending(x => x.VulnerabilityId);
var tenantProductIndex = Builders<VexObservationRecord>.IndexKeys
.Ascending(x => x.Tenant)
.Ascending(x => x.ProductKey);
var tenantDigestIndex = Builders<VexObservationRecord>.IndexKeys
.Ascending(x => x.Tenant)
.Ascending("Document.Digest");
var tenantProviderStatusIndex = Builders<VexObservationRecord>.IndexKeys
.Ascending(x => x.Tenant)
.Ascending(x => x.ProviderId)
.Ascending(x => x.Status);
return Task.WhenAll(
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexObservationRecord>(tenantObservationIndex, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken),
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexObservationRecord>(tenantVulnIndex), cancellationToken: cancellationToken),
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexObservationRecord>(tenantProductIndex), cancellationToken: cancellationToken),
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexObservationRecord>(tenantDigestIndex), cancellationToken: cancellationToken),
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexObservationRecord>(tenantProviderStatusIndex), cancellationToken: cancellationToken));
}
private static Task EnsureLinksetIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken)
{
var collection = database.GetCollection<VexLinksetRecord>(VexMongoCollectionNames.Linksets);
var tenantLinksetIndex = Builders<VexLinksetRecord>.IndexKeys
.Ascending(x => x.Tenant)
.Ascending(x => x.LinksetId);
var tenantVulnIndex = Builders<VexLinksetRecord>.IndexKeys
.Ascending(x => x.Tenant)
.Ascending(x => x.VulnerabilityId);
var tenantProductIndex = Builders<VexLinksetRecord>.IndexKeys
.Ascending(x => x.Tenant)
.Ascending(x => x.ProductKey);
var tenantDisagreementProviderIndex = Builders<VexLinksetRecord>.IndexKeys
.Ascending(x => x.Tenant)
.Ascending("Disagreements.ProviderId")
.Ascending("Disagreements.Status");
return Task.WhenAll(
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexLinksetRecord>(tenantLinksetIndex, new CreateIndexOptions { Unique = true }), cancellationToken: cancellationToken),
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexLinksetRecord>(tenantVulnIndex), cancellationToken: cancellationToken),
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexLinksetRecord>(tenantProductIndex), cancellationToken: cancellationToken),
collection.Indexes.CreateOneAsync(new CreateIndexModel<VexLinksetRecord>(tenantDisagreementProviderIndex), cancellationToken: cancellationToken));
}
}

View File

@@ -60,6 +60,7 @@ public static class VexMongoServiceCollectionExtensions
services.AddSingleton<IVexMongoMigration, VexInitialIndexMigration>();
services.AddSingleton<IVexMongoMigration, VexConsensusSignalsMigration>();
services.AddSingleton<IVexMongoMigration, VexConsensusHoldMigration>();
services.AddSingleton<IVexMongoMigration, VexObservationCollectionsMigration>();
services.AddSingleton<VexMongoMigrationRunner>();
services.AddHostedService<VexMongoMigrationHostedService>();
return services;

View File

@@ -64,12 +64,12 @@ public static class VexMongoMappingRegistry
}
}
public static class VexMongoCollectionNames
{
public const string Migrations = "vex.migrations";
public const string Providers = "vex.providers";
public const string Raw = "vex.raw";
public const string Statements = "vex.statements";
public static class VexMongoCollectionNames
{
public const string Migrations = "vex.migrations";
public const string Providers = "vex.providers";
public const string Raw = "vex.raw";
public const string Statements = "vex.statements";
public const string Claims = Statements;
public const string Consensus = "vex.consensus";
public const string Exports = "vex.exports";
@@ -77,4 +77,6 @@ public static class VexMongoCollectionNames
public const string ConnectorState = "vex.connector_state";
public const string ConsensusHolds = "vex.consensus_holds";
public const string Attestations = "vex.attestations";
public const string Observations = "vex.observations";
public const string Linksets = "vex.linksets";
}

View File

@@ -63,8 +63,8 @@ internal sealed class VexRawDocumentRecord
}
[BsonIgnoreExtraElements]
internal sealed class VexExportManifestRecord
{
internal sealed class VexExportManifestRecord
{
[BsonId]
public string Id { get; set; } = default!;
@@ -164,8 +164,8 @@ internal sealed class VexExportManifestRecord
SizeBytes = manifest.SizeBytes,
};
public VexExportManifest ToDomain()
{
public VexExportManifest ToDomain()
{
var signedAt = SignedAt.HasValue
? new DateTimeOffset(DateTime.SpecifyKind(SignedAt.Value, DateTimeKind.Utc))
: (DateTimeOffset?)null;
@@ -276,6 +276,73 @@ internal sealed class VexQuietStatementRecord
}
}
[BsonIgnoreExtraElements]
internal sealed class VexObservationRecord
{
[BsonId]
public string Id { get; set; } = default!; // observationId
public string Tenant { get; set; } = default!;
public string ObservationId { get; set; } = default!;
public string VulnerabilityId { get; set; } = default!;
public string ProductKey { get; set; } = default!;
public string ProviderId { get; set; } = default!;
public string Status { get; set; } = default!;
public VexObservationDocumentRecord Document { get; set; } = new();
public DateTime CreatedAt { get; set; } = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Utc);
public List<VexLinksetDisagreementRecord> Disagreements { get; set; } = new();
}
[BsonIgnoreExtraElements]
internal sealed class VexObservationDocumentRecord
{
public string Digest { get; set; } = default!;
public string? SourceUri { get; set; }
= null;
}
[BsonIgnoreExtraElements]
internal sealed class VexLinksetRecord
{
[BsonId]
public string Id { get; set; } = default!; // linksetId
public string Tenant { get; set; } = default!;
public string LinksetId { get; set; } = default!;
public string VulnerabilityId { get; set; } = default!;
public string ProductKey { get; set; } = default!;
public DateTime CreatedAt { get; set; } = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Utc);
public List<VexLinksetDisagreementRecord> Disagreements { get; set; } = new();
}
[BsonIgnoreExtraElements]
internal sealed class VexLinksetDisagreementRecord
{
public string ProviderId { get; set; } = default!;
public string Status { get; set; } = default!;
public string? Justification { get; set; }
= null;
public double? Confidence { get; set; }
= null;
}
[BsonIgnoreExtraElements]
internal sealed class VexProviderRecord
{

View File

@@ -0,0 +1,126 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using StellaOps.Excititor.Core.Observations;
using Xunit;
namespace StellaOps.Excititor.Core.Tests.Observations;
public sealed class VexLinksetUpdatedEventFactoryTests
{
[Fact]
public void Create_Normalizes_Sorts_And_Deduplicates()
{
var now = DateTimeOffset.UtcNow;
var observations = new List<VexObservation>
{
CreateObservation("obs-2", "provider-b", VexClaimStatus.Affected, 0.8, now),
CreateObservation("obs-1", "provider-a", VexClaimStatus.NotAffected, 0.1, now),
CreateObservation("obs-1", "provider-a", VexClaimStatus.NotAffected, 0.1, now), // duplicate
};
var disagreements = new[]
{
new VexObservationDisagreement("provider-b", "affected", "reason", 1.2),
new VexObservationDisagreement("provider-b", "affected", "reason", 1.2),
new VexObservationDisagreement("provider-a", "not_affected", null, -0.5),
};
var evt = VexLinksetUpdatedEventFactory.Create(
tenant: "TENANT",
linksetId: "link-123",
vulnerabilityId: "CVE-2025-0001",
productKey: "pkg:demo/app",
observations,
disagreements,
now);
Assert.Equal(VexLinksetUpdatedEventFactory.EventType, evt.EventType);
Assert.Equal("tenant", evt.Tenant);
Assert.Equal(2, evt.Observations.Length);
Assert.Collection(evt.Observations,
first =>
{
Assert.Equal("obs-1", first.ObservationId);
Assert.Equal("provider-a", first.ProviderId);
Assert.Equal("not_affected", first.Status);
Assert.Equal(0.1, first.Confidence);
},
second =>
{
Assert.Equal("obs-2", second.ObservationId);
Assert.Equal("provider-b", second.ProviderId);
Assert.Equal("affected", second.Status);
Assert.Equal(0.8, second.Confidence);
});
Assert.Equal(2, evt.Disagreements.Length);
Assert.Collection(evt.Disagreements,
first =>
{
Assert.Equal("provider-a", first.ProviderId);
Assert.Equal("not_affected", first.Status);
Assert.Equal(0.0, first.Confidence); // clamped
},
second =>
{
Assert.Equal("provider-b", second.ProviderId);
Assert.Equal("affected", second.Status);
Assert.Equal(1.0, second.Confidence); // clamped
Assert.Equal("reason", second.Justification);
});
}
private static VexObservation CreateObservation(
string observationId,
string providerId,
VexClaimStatus status,
double? severity,
DateTimeOffset createdAt)
{
var statement = new VexObservationStatement(
vulnerabilityId: "CVE-2025-0001",
productKey: "pkg:demo/app",
status: status,
lastObserved: createdAt,
purl: "pkg:demo/app",
cpe: null,
evidence: ImmutableArray<System.Text.Json.Nodes.JsonNode>.Empty,
signals: severity is null
? null
: new VexSignalSnapshot(new VexSeveritySignal("cvss", severity, "n/a", vector: null), Kev: null, Epss: null));
var upstream = new VexObservationUpstream(
upstreamId: observationId,
documentVersion: null,
fetchedAt: createdAt,
receivedAt: createdAt,
contentHash: $"sha256:{observationId}",
signature: new VexObservationSignature(true, "sub", "iss", createdAt));
var linkset = new VexObservationLinkset(
aliases: null,
purls: new[] { "pkg:demo/app" },
cpes: null,
references: null);
var content = new VexObservationContent(
format: "csaf",
specVersion: "2.0",
raw: System.Text.Json.Nodes.JsonNode.Parse("{}")!);
return new VexObservation(
observationId,
tenant: "tenant",
providerId,
streamId: "stream",
upstream,
statements: ImmutableArray.Create(statement),
content,
linkset,
createdAt,
supersedes: ImmutableArray<string>.Empty,
attributes: ImmutableDictionary<string, string>.Empty);
}
}

View File

@@ -0,0 +1,64 @@
using System.Collections.Generic;
using System.Linq;
using StellaOps.Excititor.Core.Observations;
using Xunit;
namespace StellaOps.Excititor.Core.Tests.Observations;
public sealed class VexObservationLinksetTests
{
[Fact]
public void Disagreements_Normalize_SortsAndClamps()
{
var disagreements = new[]
{
new VexObservationDisagreement("Provider-B", "affected", "just", 1.2),
new VexObservationDisagreement("provider-a", "not_affected", null, -0.1),
new VexObservationDisagreement("provider-a", "not_affected", null, 0.5),
};
var linkset = new VexObservationLinkset(
aliases: null,
purls: null,
cpes: null,
references: null,
reconciledFrom: null,
disagreements: disagreements);
Assert.Equal(2, linkset.Disagreements.Length);
var first = linkset.Disagreements[0];
Assert.Equal("provider-a", first.ProviderId);
Assert.Equal("not_affected", first.Status);
Assert.Null(first.Justification);
Assert.Equal(0.0, first.Confidence); // clamped from -0.1
var second = linkset.Disagreements[1];
Assert.Equal("Provider-B", second.ProviderId);
Assert.Equal("affected", second.Status);
Assert.Equal("just", second.Justification);
Assert.Equal(1.0, second.Confidence); // clamped from 1.2
}
[Fact]
public void Disagreements_Deduplicates_ByProviderStatusJustificationConfidence()
{
var disagreements = new List<VexObservationDisagreement>
{
new("provider-a", "affected", null, 0.7),
new("provider-a", "affected", null, 0.7),
new("provider-a", "affected", null, 0.7),
};
var linkset = new VexObservationLinkset(
aliases: null,
purls: null,
cpes: null,
references: null,
reconciledFrom: null,
disagreements: disagreements);
Assert.Single(linkset.Disagreements);
Assert.Equal(0.7, linkset.Disagreements[0].Confidence);
}
}

View File

@@ -21,11 +21,12 @@ public sealed class VexMongoMigrationRunnerTests : IAsyncLifetime
[Fact]
public async Task RunAsync_AppliesInitialIndexesOnce()
{
var migrations = new IVexMongoMigration[]
{
new VexInitialIndexMigration(),
new VexConsensusSignalsMigration(),
};
var migrations = new IVexMongoMigration[]
{
new VexInitialIndexMigration(),
new VexConsensusSignalsMigration(),
new VexObservationCollectionsMigration(),
};
var runner = new VexMongoMigrationRunner(_database, migrations, NullLogger<VexMongoMigrationRunner>.Instance);
await runner.RunAsync(CancellationToken.None);
@@ -33,8 +34,8 @@ public sealed class VexMongoMigrationRunnerTests : IAsyncLifetime
var appliedCollection = _database.GetCollection<VexMigrationRecord>(VexMongoCollectionNames.Migrations);
var applied = await appliedCollection.Find(FilterDefinition<VexMigrationRecord>.Empty).ToListAsync();
Assert.Equal(2, applied.Count);
Assert.Equal(migrations.Select(m => m.Id).OrderBy(id => id, StringComparer.Ordinal), applied.Select(record => record.Id).OrderBy(id => id, StringComparer.Ordinal));
Assert.Equal(3, applied.Count);
Assert.Equal(migrations.Select(m => m.Id).OrderBy(id => id, StringComparer.Ordinal), applied.Select(record => record.Id).OrderBy(id => id, StringComparer.Ordinal));
Assert.True(HasIndex(_database.GetCollection<VexRawDocumentRecord>(VexMongoCollectionNames.Raw), "ProviderId_1_Format_1_RetrievedAt_1"));
Assert.True(HasIndex(_database.GetCollection<VexProviderRecord>(VexMongoCollectionNames.Providers), "Kind_1"));
@@ -43,11 +44,19 @@ public sealed class VexMongoMigrationRunnerTests : IAsyncLifetime
Assert.True(HasIndex(_database.GetCollection<VexConsensusRecord>(VexMongoCollectionNames.Consensus), "PolicyRevisionId_1_CalculatedAt_-1"));
Assert.True(HasIndex(_database.GetCollection<VexExportManifestRecord>(VexMongoCollectionNames.Exports), "QuerySignature_1_Format_1"));
Assert.True(HasIndex(_database.GetCollection<VexCacheEntryRecord>(VexMongoCollectionNames.Cache), "QuerySignature_1_Format_1"));
Assert.True(HasIndex(_database.GetCollection<VexCacheEntryRecord>(VexMongoCollectionNames.Cache), "ExpiresAt_1"));
Assert.True(HasIndex(_database.GetCollection<VexStatementRecord>(VexMongoCollectionNames.Statements), "VulnerabilityId_1_Product.Key_1_InsertedAt_-1"));
Assert.True(HasIndex(_database.GetCollection<VexStatementRecord>(VexMongoCollectionNames.Statements), "ProviderId_1_InsertedAt_-1"));
Assert.True(HasIndex(_database.GetCollection<VexStatementRecord>(VexMongoCollectionNames.Statements), "Document.Digest_1"));
}
Assert.True(HasIndex(_database.GetCollection<VexCacheEntryRecord>(VexMongoCollectionNames.Cache), "ExpiresAt_1"));
Assert.True(HasIndex(_database.GetCollection<VexStatementRecord>(VexMongoCollectionNames.Statements), "VulnerabilityId_1_Product.Key_1_InsertedAt_-1"));
Assert.True(HasIndex(_database.GetCollection<VexStatementRecord>(VexMongoCollectionNames.Statements), "ProviderId_1_InsertedAt_-1"));
Assert.True(HasIndex(_database.GetCollection<VexStatementRecord>(VexMongoCollectionNames.Statements), "Document.Digest_1"));
Assert.True(HasIndex(_database.GetCollection<VexObservationRecord>(VexMongoCollectionNames.Observations), "Tenant_1_ObservationId_1"));
Assert.True(HasIndex(_database.GetCollection<VexObservationRecord>(VexMongoCollectionNames.Observations), "Tenant_1_VulnerabilityId_1"));
Assert.True(HasIndex(_database.GetCollection<VexObservationRecord>(VexMongoCollectionNames.Observations), "Tenant_1_ProductKey_1"));
Assert.True(HasIndex(_database.GetCollection<VexObservationRecord>(VexMongoCollectionNames.Observations), "Tenant_1_Document.Digest_1"));
Assert.True(HasIndex(_database.GetCollection<VexObservationRecord>(VexMongoCollectionNames.Observations), "Tenant_1_ProviderId_1_Status_1"));
Assert.True(HasIndex(_database.GetCollection<VexLinksetRecord>(VexMongoCollectionNames.Linksets), "Tenant_1_LinksetId_1"));
Assert.True(HasIndex(_database.GetCollection<VexLinksetRecord>(VexMongoCollectionNames.Linksets), "Tenant_1_VulnerabilityId_1"));
Assert.True(HasIndex(_database.GetCollection<VexLinksetRecord>(VexMongoCollectionNames.Linksets), "Tenant_1_ProductKey_1"));
}
private static bool HasIndex<TDocument>(IMongoCollection<TDocument> collection, string name)
{

View File

@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Linq;
using StellaOps.Excititor.WebService.Contracts;
using StellaOps.Excititor.WebService.Telemetry;
using Xunit;
namespace StellaOps.Excititor.WebService.Tests;
public sealed class EvidenceTelemetryTests
{
[Fact]
public void RecordChunkOutcome_EmitsCounterAndHistogram()
{
var measurements = new List<(string Name, double Value, IReadOnlyList<KeyValuePair<string, object?>> Tags)>();
using var listener = CreateListener((instrument, value, tags) =>
{
measurements.Add((instrument.Name, value, tags.ToList()));
});
EvidenceTelemetry.RecordChunkOutcome("tenant-a", "success", chunkCount: 3, truncated: true);
Assert.Contains(measurements, m => m.Name == "excititor.vex.evidence.requests" && m.Value == 1);
Assert.Contains(measurements, m => m.Name == "excititor.vex.evidence.chunk_count" && m.Value == 3);
var requestTags = measurements.First(m => m.Name == "excititor.vex.evidence.requests").Tags.ToDictionary(kv => kv.Key, kv => kv.Value);
Assert.Equal("tenant-a", requestTags["tenant"]);
Assert.Equal("success", requestTags["outcome"]);
Assert.Equal(true, requestTags["truncated"]);
}
[Fact]
public void RecordChunkSignatureStatus_EmitsSignatureCounters()
{
var measurements = new List<(string Name, double Value, IReadOnlyList<KeyValuePair<string, object?>> Tags)>();
using var listener = CreateListener((instrument, value, tags) =>
{
measurements.Add((instrument.Name, value, tags.ToList()));
});
var now = DateTimeOffset.UtcNow;
var scope = new VexEvidenceChunkScope("pkg:demo/app", "demo", "1.0.0", "pkg:demo/app@1.0.0", null, new[] { "component-a" });
var document = new VexEvidenceChunkDocument("digest-1", "spdx", "https://example.test/vex.json", "r1");
var chunks = new List<VexEvidenceChunkResponse>
{
new("obs-1", "link-1", "CVE-2025-0001", "pkg:demo/app", "provider-a", "Affected", "just", "detail", 0.9, now.AddMinutes(-10), now, scope, document, new VexEvidenceChunkSignature("cosign", "sub", "issuer", "kid", now, null), new Dictionary<string, string>()),
new("obs-2", "link-2", "CVE-2025-0001", "pkg:demo/app", "provider-b", "NotAffected", null, null, null, now.AddMinutes(-8), now, scope, document, null, new Dictionary<string, string>()),
new("obs-3", "link-3", "CVE-2025-0001", "pkg:demo/app", "provider-c", "Affected", null, null, null, now.AddMinutes(-6), now, scope, document, new VexEvidenceChunkSignature("cosign", "sub", "issuer", "kid", null, null), new Dictionary<string, string>()),
};
EvidenceTelemetry.RecordChunkSignatureStatus("tenant-b", chunks);
AssertSignatureMeasurement(measurements, "unsigned", 1, "tenant-b");
AssertSignatureMeasurement(measurements, "unverified", 1, "tenant-b");
AssertSignatureMeasurement(measurements, "verified", 1, "tenant-b");
}
private static MeterListener CreateListener(Action<Instrument, double, ReadOnlySpan<KeyValuePair<string, object?>>> callback)
{
var listener = new MeterListener
{
InstrumentPublished = (instrument, l) =>
{
if (instrument.Meter.Name == EvidenceTelemetry.MeterName)
{
l.EnableMeasurementEvents(instrument);
}
}
};
listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, _) => callback(instrument, measurement, tags));
listener.SetMeasurementEventCallback<int>((instrument, measurement, tags, _) => callback(instrument, measurement, tags));
listener.Start();
return listener;
}
private static void AssertSignatureMeasurement(IEnumerable<(string Name, double Value, IReadOnlyList<KeyValuePair<string, object?>> Tags)> measurements, string status, int expectedValue, string tenant)
{
var match = measurements.FirstOrDefault(m => m.Name == "excititor.vex.signature.status" && m.Tags.Any(t => t.Key == "status" && (string?)t.Value == status));
Assert.Equal(expectedValue, match.Value);
Assert.Contains(match.Tags, t => t.Key == "tenant" && (string?)t.Value == tenant);
}
}