feat: Add initial implementation of Vulnerability Resolver Jobs
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user