Align AOC tasks for Excititor and Concelier

This commit is contained in:
master
2025-10-31 18:50:15 +02:00
committed by root
parent 9e6d9fbae8
commit 8da4e12a90
334 changed files with 35528 additions and 34546 deletions

View File

@@ -1,94 +1,94 @@
# TASKS — Epic 1: Aggregation-Only Contract
> **AOC Reminder:** Excititor WebService publishes raw statements/linksets only; derived precedence/severity belongs to Policy overlays.
| ID | Status | Owner(s) | Depends on | Notes |
|---|---|---|---|---|
| EXCITITOR-WEB-AOC-19-001 `Raw VEX ingestion APIs` | TODO | Excititor WebService Guild | EXCITITOR-CORE-AOC-19-001, EXCITITOR-STORE-AOC-19-001 | Implement `POST /ingest/vex`, `GET /vex/raw*`, and `POST /aoc/verify` endpoints. Enforce Authority scopes, tenant injection, and guard pipeline to ensure only immutable VEX facts are persisted. |
> Docs alignment (2025-10-26): See AOC reference §45 and authority scopes doc for required tokens/behaviour.
| EXCITITOR-WEB-AOC-19-002 `AOC observability + metrics` | TODO | Excititor WebService Guild, Observability Guild | EXCITITOR-WEB-AOC-19-001 | Export metrics (`ingestion_write_total`, `aoc_violation_total`, signature verification counters) and tracing spans matching Conseiller naming. Ensure structured logging includes tenant, source vendor, upstream id, and content hash. |
> Docs alignment (2025-10-26): Metrics/traces/log schema in `docs/observability/observability.md`.
| EXCITITOR-WEB-AOC-19-003 `Guard + schema test harness` | TODO | QA Guild | EXCITITOR-WEB-AOC-19-001 | Add unit/integration tests for schema validation, forbidden field rejection (`ERR_AOC_001/006/007`), and supersedes behavior using CycloneDX-VEX & CSAF fixtures with deterministic expectations. |
> Docs alignment (2025-10-26): Error codes + CLI verification in `docs/modules/cli/guides/cli-reference.md`.
| EXCITITOR-WEB-AOC-19-004 `Batch ingest validation` | TODO | Excititor WebService Guild, QA Guild | EXCITITOR-WEB-AOC-19-003, EXCITITOR-CORE-AOC-19-002 | Build large fixture ingest covering mixed VEX statuses, verifying raw storage parity, metrics, and CLI `aoc verify` compatibility. Document load test/runbook updates. |
> Docs alignment (2025-10-26): Offline/air-gap workflows captured in `docs/deploy/containers.md` §5.
## Policy Engine v2
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| EXCITITOR-POLICY-20-001 `Policy selection endpoints` | TODO | Excititor WebService Guild | WEB-POLICY-20-001, EXCITITOR-CORE-AOC-19-004 | Provide VEX lookup APIs supporting PURL/advisory batching, scope filtering, and tenant enforcement with deterministic ordering + pagination. |
## StellaOps Console (Sprint 23)
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| EXCITITOR-CONSOLE-23-001 `VEX aggregation views` | TODO | Excititor WebService Guild, BE-Base Platform Guild | EXCITITOR-LNM-21-201, EXCITITOR-LNM-21-202 | Expose `/console/vex` endpoints returning grouped VEX statements per advisory/component with status chips, justification metadata, precedence trace pointers, and tenant-scoped filters for Console explorer. |
| EXCITITOR-CONSOLE-23-002 `Dashboard VEX deltas` | TODO | Excititor WebService Guild | EXCITITOR-CONSOLE-23-001, EXCITITOR-LNM-21-203 | Provide aggregated counts for VEX overrides (new, not_affected, revoked) powering Console dashboard + live status ticker; emit metrics for policy explain integration. |
| EXCITITOR-CONSOLE-23-003 `VEX search helpers` | TODO | Excititor WebService Guild | EXCITITOR-CONSOLE-23-001 | Deliver rapid lookup endpoints of VEX by advisory/component for Console global search; ensure response includes provenance and precedence context; include caching and RBAC. |
## Graph Explorer v1
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
## Link-Not-Merge v1
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| EXCITITOR-LNM-21-201 `Observation APIs` | TODO | Excititor WebService Guild, BE-Base Platform Guild | EXCITITOR-LNM-21-001 | Add VEX observation read endpoints with filters, pagination, RBAC, and tenant scoping. |
| EXCITITOR-LNM-21-202 `Linkset APIs` | TODO | Excititor WebService Guild | EXCITITOR-LNM-21-002, EXCITITOR-LNM-21-003 | Implement linkset read/export/evidence endpoints returning correlation/conflict payloads and map errors to `ERR_AGG_*`. |
| EXCITITOR-LNM-21-203 `Event publishing` | TODO | Excititor WebService Guild, Platform Events Guild | EXCITITOR-LNM-21-005 | Publish `vex.linkset.updated` events, document schema, and ensure idempotent delivery. |
## Graph & Vuln Explorer v1
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| EXCITITOR-GRAPH-24-101 `VEX summary API` | TODO | Excititor WebService Guild | EXCITITOR-GRAPH-24-001 | Provide endpoints delivering VEX status summaries per component/asset for Vuln Explorer integration. |
| EXCITITOR-GRAPH-24-102 `Evidence batch API` | TODO | Excititor WebService Guild | EXCITITOR-LNM-21-201 | Add batch VEX observation retrieval optimized for Graph overlays/tooltips. |
## VEX Lens (Sprint 30)
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| EXCITITOR-VEXLENS-30-001 `VEX evidence enrichers` | TODO | Excititor WebService Guild, VEX Lens Guild | EXCITITOR-VULN-29-001, VEXLENS-30-005 | Include issuer hints, signatures, and product trees in evidence payloads for VEX Lens; Label: VEX-Lens. |
## Vulnerability Explorer (Sprint 29)
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| EXCITITOR-VULN-29-001 `VEX key canonicalization` | TODO | Excititor WebService Guild | EXCITITOR-LNM-21-001 | Canonicalize (lossless) VEX advisory/product keys (map to `advisory_key`, capture product scopes); expose original sources in `links[]`; AOC-compliant: no merge, no derived fields, no suppression; backfill existing records. |
| EXCITITOR-VULN-29-002 `Evidence retrieval` | TODO | Excititor WebService Guild | EXCITITOR-VULN-29-001, VULN-API-29-003 | Provide `/vuln/evidence/vex/{advisory_key}` returning raw VEX statements filtered by tenant/product scope for Explorer evidence tabs. |
| EXCITITOR-VULN-29-004 `Observability` | TODO | Excititor WebService Guild, Observability Guild | EXCITITOR-VULN-29-001 | Add metrics/logs for VEX normalization, suppression scopes, withdrawn statements; emit events consumed by Vuln Explorer resolver. |
## Advisory AI (Sprint 31)
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| EXCITITOR-AIAI-31-001 `Justification enrichment` | TODO | Excititor WebService Guild | EXCITITOR-VULN-29-001 | Expose normalized VEX justifications, product trees, and paragraph anchors for Advisory AI conflict explanations. |
| EXCITITOR-AIAI-31-002 `VEX chunk API` | TODO | Excititor WebService Guild | EXCITITOR-AIAI-31-001, VEXLENS-30-006 | Provide `/vex/evidence/chunks` endpoint returning tenant-scoped VEX statements with signature metadata and scope scores for RAG. |
| EXCITITOR-AIAI-31-003 `Telemetry` | TODO | Excititor WebService Guild, Observability Guild | EXCITITOR-AIAI-31-001 | Emit metrics/logs for VEX chunk usage, signature verification failures, and guardrail triggers. |
## Observability & Forensics (Epic 15)
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| EXCITITOR-WEB-OBS-50-001 `Telemetry adoption` | TODO | Excititor WebService Guild | TELEMETRY-OBS-50-001, EXCITITOR-OBS-50-001 | Adopt telemetry core for VEX APIs, ensure responses include trace IDs & correlation headers, and update structured logging for read endpoints. |
| EXCITITOR-WEB-OBS-51-001 `Observability health endpoints` | TODO | Excititor WebService Guild | EXCITITOR-WEB-OBS-50-001, WEB-OBS-51-001 | Implement `/obs/excititor/health` summarizing ingest/link SLOs, signature failure counts, and conflict trends for Console dashboards. |
| EXCITITOR-WEB-OBS-52-001 `Timeline streaming` | TODO | Excititor WebService Guild | EXCITITOR-WEB-OBS-50-001, TIMELINE-OBS-52-003 | Provide SSE bridge for VEX timeline events with tenant filters, pagination, and guardrails. |
| EXCITITOR-WEB-OBS-53-001 `Evidence APIs` | TODO | Excititor WebService Guild, Evidence Locker Guild | EXCITITOR-OBS-53-001, EVID-OBS-53-003 | Expose `/evidence/vex/*` endpoints that fetch locker bundles, enforce scopes, and surface verification metadata. |
| EXCITITOR-WEB-OBS-54-001 `Attestation APIs` | TODO | Excititor WebService Guild | EXCITITOR-OBS-54-001, PROV-OBS-54-001 | Add `/attestations/vex/*` endpoints returning DSSE verification state, builder identity, and chain-of-custody links. |
| EXCITITOR-WEB-OBS-55-001 `Incident mode toggles` | TODO | Excititor WebService Guild, DevOps Guild | EXCITITOR-OBS-55-001, WEB-OBS-55-001 | Provide incident mode API for VEX pipelines with activation audit logs and retention override previews. |
## Air-Gapped Mode (Epic 16)
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| EXCITITOR-WEB-AIRGAP-56-001 | TODO | Excititor WebService Guild | AIRGAP-IMP-58-001, EXCITITOR-AIRGAP-56-001 | Support mirror bundle registration via APIs, expose bundle provenance in VEX responses, and block external connectors in sealed mode. |
| EXCITITOR-WEB-AIRGAP-56-002 | TODO | Excititor WebService Guild, AirGap Time Guild | EXCITITOR-WEB-AIRGAP-56-001, AIRGAP-TIME-58-001 | Return VEX staleness metrics and time anchor info in API responses for Console/CLI use. |
| EXCITITOR-WEB-AIRGAP-57-001 | TODO | Excititor WebService Guild, AirGap Policy Guild | AIRGAP-POL-56-001 | Map sealed-mode violations to standardized error payload with remediation guidance. |
| EXCITITOR-WEB-AIRGAP-58-001 | TODO | Excititor WebService Guild, AirGap Importer Guild | EXCITITOR-WEB-AIRGAP-56-001, TIMELINE-OBS-53-001 | Emit timeline events for VEX bundle imports with bundle ID, scope, and actor metadata. |
## SDKs & OpenAPI (Epic 17)
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| EXCITITOR-WEB-OAS-61-001 | TODO | Excititor WebService Guild | OAS-61-001 | Implement `/.well-known/openapi` discovery endpoint with spec version metadata. |
| EXCITITOR-WEB-OAS-61-002 | TODO | Excititor WebService Guild | APIGOV-61-001 | Standardize error envelope responses and update controller/unit tests. |
| EXCITITOR-WEB-OAS-62-001 | TODO | Excititor WebService Guild | EXCITITOR-OAS-61-002 | Add curated examples for VEX observation/linkset endpoints and ensure portal displays them. |
| EXCITITOR-WEB-OAS-63-001 | TODO | Excititor WebService Guild, API Governance Guild | APIGOV-63-001 | Emit deprecation headers and update docs for retiring VEX APIs. |
# TASKS — Epic 1: Aggregation-Only Contract
> **AOC Reminder:** Excititor WebService publishes raw statements/linksets only; derived precedence/severity belongs to Policy overlays.
| ID | Status | Owner(s) | Depends on | Notes |
|---|---|---|---|---|
| EXCITITOR-WEB-AOC-19-001 `Raw VEX ingestion APIs` | TODO | Excititor WebService Guild | EXCITITOR-CORE-AOC-19-001, EXCITITOR-STORE-AOC-19-001 | Implement `POST /ingest/vex`, `GET /vex/raw*`, and `POST /aoc/verify` endpoints. Enforce Authority scopes, tenant injection, and guard pipeline to ensure only immutable VEX facts are persisted. |
> Docs alignment (2025-10-26): See AOC reference §45 and authority scopes doc for required tokens/behaviour.
| EXCITITOR-WEB-AOC-19-002 `AOC observability + metrics` | TODO | Excititor WebService Guild, Observability Guild | EXCITITOR-WEB-AOC-19-001 | Export metrics (`ingestion_write_total`, `aoc_violation_total`, signature verification counters) and tracing spans matching Conseiller naming. Ensure structured logging includes tenant, source vendor, upstream id, and content hash. |
> Docs alignment (2025-10-26): Metrics/traces/log schema in `docs/observability/observability.md`.
| EXCITITOR-WEB-AOC-19-003 `Guard + schema test harness` | TODO | QA Guild | EXCITITOR-WEB-AOC-19-001 | Add unit/integration tests for schema validation, forbidden field rejection (`ERR_AOC_001/006/007`), and supersedes behavior using CycloneDX-VEX & CSAF fixtures with deterministic expectations. |
> Docs alignment (2025-10-26): Error codes + CLI verification in `docs/modules/cli/guides/cli-reference.md`.
| EXCITITOR-WEB-AOC-19-004 `Batch ingest validation` | TODO | Excititor WebService Guild, QA Guild | EXCITITOR-WEB-AOC-19-003, EXCITITOR-CORE-AOC-19-002 | Build large fixture ingest covering mixed VEX statuses, verifying raw storage parity, metrics, and CLI `aoc verify` compatibility. Document load test/runbook updates. |
> Docs alignment (2025-10-26): Offline/air-gap workflows captured in `docs/deploy/containers.md` §5.
## Policy Engine v2
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| EXCITITOR-POLICY-20-001 `Policy selection endpoints` | TODO | Excititor WebService Guild | WEB-POLICY-20-001, EXCITITOR-CORE-AOC-19-004 | Provide VEX lookup APIs supporting PURL/advisory batching, scope filtering, and tenant enforcement with deterministic ordering + pagination. |
## StellaOps Console (Sprint 23)
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| EXCITITOR-CONSOLE-23-001 `VEX aggregation views` | TODO | Excititor WebService Guild, BE-Base Platform Guild | EXCITITOR-LNM-21-201, EXCITITOR-LNM-21-202 | Expose `/console/vex` endpoints returning grouped VEX statements per advisory/component with status chips, justification metadata, precedence trace pointers, and tenant-scoped filters for Console explorer. |
| EXCITITOR-CONSOLE-23-002 `Dashboard VEX deltas` | TODO | Excititor WebService Guild | EXCITITOR-CONSOLE-23-001, EXCITITOR-LNM-21-203 | Provide aggregated counts for VEX overrides (new, not_affected, revoked) powering Console dashboard + live status ticker; emit metrics for policy explain integration. |
| EXCITITOR-CONSOLE-23-003 `VEX search helpers` | TODO | Excititor WebService Guild | EXCITITOR-CONSOLE-23-001 | Deliver rapid lookup endpoints of VEX by advisory/component for Console global search; ensure response includes provenance and precedence context; include caching and RBAC. |
## Graph Explorer v1
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
## Link-Not-Merge v1
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| EXCITITOR-LNM-21-201 `Observation APIs` | TODO | Excititor WebService Guild, BE-Base Platform Guild | EXCITITOR-LNM-21-001 | Add VEX observation read endpoints with filters, pagination, RBAC, and tenant scoping. |
| EXCITITOR-LNM-21-202 `Linkset APIs` | TODO | Excititor WebService Guild | EXCITITOR-LNM-21-002, EXCITITOR-LNM-21-003 | Implement linkset read/export/evidence endpoints returning correlation/conflict payloads and map errors to `ERR_AGG_*`. |
| EXCITITOR-LNM-21-203 `Event publishing` | TODO | Excititor WebService Guild, Platform Events Guild | EXCITITOR-LNM-21-005 | Publish `vex.linkset.updated` events, document schema, and ensure idempotent delivery. |
## Graph & Vuln Explorer v1
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| EXCITITOR-GRAPH-24-101 `VEX summary API` | TODO | Excititor WebService Guild | EXCITITOR-GRAPH-24-001 | Provide endpoints delivering VEX status summaries per component/asset for Vuln Explorer integration. |
| EXCITITOR-GRAPH-24-102 `Evidence batch API` | TODO | Excititor WebService Guild | EXCITITOR-LNM-21-201 | Add batch VEX observation retrieval optimized for Graph overlays/tooltips. |
## VEX Lens (Sprint 30)
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| EXCITITOR-VEXLENS-30-001 `VEX evidence enrichers` | TODO | Excititor WebService Guild, VEX Lens Guild | EXCITITOR-VULN-29-001, VEXLENS-30-005 | Include issuer hints, signatures, and product trees in evidence payloads for VEX Lens; Label: VEX-Lens. |
## Vulnerability Explorer (Sprint 29)
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| EXCITITOR-VULN-29-001 `VEX key canonicalization` | TODO | Excititor WebService Guild | EXCITITOR-LNM-21-001 | Canonicalize (lossless) VEX advisory/product keys (map to `advisory_key`, capture product scopes); expose original sources in `links[]`; AOC-compliant: no merge, no derived fields, no suppression; backfill existing records. |
| EXCITITOR-VULN-29-002 `Evidence retrieval` | TODO | Excititor WebService Guild | EXCITITOR-VULN-29-001, VULN-API-29-003 | Provide `/vuln/evidence/vex/{advisory_key}` returning raw VEX statements filtered by tenant/product scope for Explorer evidence tabs. |
| EXCITITOR-VULN-29-004 `Observability` | TODO | Excititor WebService Guild, Observability Guild | EXCITITOR-VULN-29-001 | Add metrics/logs for VEX normalization, suppression scopes, withdrawn statements; emit events consumed by Vuln Explorer resolver. |
## Advisory AI (Sprint 31)
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| EXCITITOR-AIAI-31-001 `Justification enrichment` | TODO | Excititor WebService Guild | EXCITITOR-VULN-29-001 | Expose normalized VEX justifications, product trees, and paragraph anchors for Advisory AI conflict explanations. |
| EXCITITOR-AIAI-31-002 `VEX chunk API` | TODO | Excititor WebService Guild | EXCITITOR-AIAI-31-001, VEXLENS-30-006 | Provide `/vex/evidence/chunks` endpoint returning tenant-scoped VEX statements with signature metadata and scope scores for RAG. |
| EXCITITOR-AIAI-31-003 `Telemetry` | TODO | Excititor WebService Guild, Observability Guild | EXCITITOR-AIAI-31-001 | Emit metrics/logs for VEX chunk usage, signature verification failures, and guardrail triggers. |
## Observability & Forensics (Epic 15)
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| EXCITITOR-WEB-OBS-50-001 `Telemetry adoption` | TODO | Excititor WebService Guild | TELEMETRY-OBS-50-001, EXCITITOR-OBS-50-001 | Adopt telemetry core for VEX APIs, ensure responses include trace IDs & correlation headers, and update structured logging for read endpoints. |
| EXCITITOR-WEB-OBS-51-001 `Observability health endpoints` | TODO | Excititor WebService Guild | EXCITITOR-WEB-OBS-50-001, WEB-OBS-51-001 | Implement `/obs/excititor/health` summarizing ingest/link SLOs, signature failure counts, and conflict trends for Console dashboards. |
| EXCITITOR-WEB-OBS-52-001 `Timeline streaming` | TODO | Excititor WebService Guild | EXCITITOR-WEB-OBS-50-001, TIMELINE-OBS-52-003 | Provide SSE bridge for VEX timeline events with tenant filters, pagination, and guardrails. |
| EXCITITOR-WEB-OBS-53-001 `Evidence APIs` | TODO | Excititor WebService Guild, Evidence Locker Guild | EXCITITOR-OBS-53-001, EVID-OBS-53-003 | Expose `/evidence/vex/*` endpoints that fetch locker bundles, enforce scopes, and surface verification metadata. |
| EXCITITOR-WEB-OBS-54-001 `Attestation APIs` | TODO | Excititor WebService Guild | EXCITITOR-OBS-54-001, PROV-OBS-54-001 | Add `/attestations/vex/*` endpoints returning DSSE verification state, builder identity, and chain-of-custody links. |
| EXCITITOR-WEB-OBS-55-001 `Incident mode toggles` | TODO | Excititor WebService Guild, DevOps Guild | EXCITITOR-OBS-55-001, WEB-OBS-55-001 | Provide incident mode API for VEX pipelines with activation audit logs and retention override previews. |
## Air-Gapped Mode (Epic 16)
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| EXCITITOR-WEB-AIRGAP-56-001 | TODO | Excititor WebService Guild | AIRGAP-IMP-58-001, EXCITITOR-AIRGAP-56-001 | Support mirror bundle registration via APIs, expose bundle provenance in VEX responses, and block external connectors in sealed mode. |
| EXCITITOR-WEB-AIRGAP-56-002 | TODO | Excititor WebService Guild, AirGap Time Guild | EXCITITOR-WEB-AIRGAP-56-001, AIRGAP-TIME-58-001 | Return VEX staleness metrics and time anchor info in API responses for Console/CLI use. |
| EXCITITOR-WEB-AIRGAP-57-001 | TODO | Excititor WebService Guild, AirGap Policy Guild | AIRGAP-POL-56-001 | Map sealed-mode violations to standardized error payload with remediation guidance. |
| EXCITITOR-WEB-AIRGAP-58-001 | TODO | Excititor WebService Guild, AirGap Importer Guild | EXCITITOR-WEB-AIRGAP-56-001, TIMELINE-OBS-53-001 | Emit timeline events for VEX bundle imports with bundle ID, scope, and actor metadata. |
## SDKs & OpenAPI (Epic 17)
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| EXCITITOR-WEB-OAS-61-001 | TODO | Excititor WebService Guild | OAS-61-001 | Implement `/.well-known/openapi` discovery endpoint with spec version metadata. |
| EXCITITOR-WEB-OAS-61-002 | TODO | Excititor WebService Guild | APIGOV-61-001 | Standardize error envelope responses and update controller/unit tests. |
| EXCITITOR-WEB-OAS-62-001 | TODO | Excititor WebService Guild | EXCITITOR-OAS-61-002 | Add curated examples for VEX observation/linkset endpoints and ensure portal displays them. |
| EXCITITOR-WEB-OAS-63-001 | TODO | Excititor WebService Guild, API Governance Guild | APIGOV-63-001 | Emit deprecation headers and update docs for retiring VEX APIs. |

View File

@@ -2,6 +2,6 @@ If you are working on this file you need to read docs/modules/excititor/ARCHITEC
# TASKS
| Task | Owner(s) | Depends on | Notes |
|---|---|---|---|
|EXCITITOR-ATTEST-01-003 Verification suite & observability|Team Excititor Attestation|EXCITITOR-ATTEST-01-002|DOING (2025-10-22) Continuing implementation: build `IVexAttestationVerifier`, wire metrics/logging, and add regression tests. Draft plan in `EXCITITOR-ATTEST-01-003-plan.md` (2025-10-19) guides scope; updating with worknotes as progress lands.|
|EXCITITOR-ATTEST-01-003 Verification suite & observability|Team Excititor Attestation|EXCITITOR-ATTEST-01-002|DOING (2025-10-22) Continuing implementation: build `IVexAttestationVerifier`, wire metrics/logging, and add regression tests. Draft plan in `EXCITITOR-ATTEST-01-003-plan.md` (2025-10-19) guides scope; updating with worknotes as progress lands.<br>2025-10-31: Verifier now tolerates duplicate source providers from AOC raw projections, downgrades offline Rekor verification to a degraded result, and enforces trusted signer registry checks with detailed diagnostics/tests.|
> Remark (2025-10-22): Added verifier implementation + metrics/tests; next steps include wiring into WebService/Worker flows and expanding negative-path coverage.

View File

@@ -64,12 +64,12 @@ internal sealed class VexAttestationVerifier : IVexAttestationVerifier
var stopwatch = Stopwatch.StartNew();
var diagnostics = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
var resultLabel = "valid";
var rekorState = "skipped";
var component = request.IsReverify ? "worker" : "webservice";
try
{
var resultLabel = "valid";
var rekorState = "skipped";
var component = request.IsReverify ? "worker" : "webservice";
try
{
if (string.IsNullOrWhiteSpace(request.Envelope))
{
diagnostics["envelope.state"] = "missing";
@@ -173,13 +173,17 @@ internal sealed class VexAttestationVerifier : IVexAttestationVerifier
resultLabel = "degraded";
}
diagnostics["signature.state"] = "present";
return BuildResult(true);
}
catch (Exception ex)
{
diagnostics["error"] = ex.GetType().Name;
diagnostics["error.message"] = ex.Message;
if (rekorState is "offline" && resultLabel != "invalid")
{
resultLabel = "degraded";
}
return BuildResult(true);
}
catch (Exception ex)
{
diagnostics["error"] = ex.GetType().Name;
diagnostics["error.message"] = ex.Message;
resultLabel = "error";
_logger.LogError(ex, "Unexpected exception verifying attestation for export {ExportId}", request.Attestation.ExportId);
return BuildResult(false);
@@ -201,16 +205,113 @@ internal sealed class VexAttestationVerifier : IVexAttestationVerifier
{
diagnostics["result"] = resultLabel;
diagnostics["component"] = component;
diagnostics["rekor.state"] = rekorState;
return new VexAttestationVerification(isValid, diagnostics.ToImmutable());
}
}
private static bool TryDeserializeEnvelope(
string envelopeJson,
out DsseEnvelope envelope,
ImmutableDictionary<string, string>.Builder diagnostics)
{
diagnostics["rekor.state"] = rekorState;
return new VexAttestationVerification(isValid, diagnostics.ToImmutable());
}
}
private async ValueTask<bool> VerifySignaturesAsync(
ReadOnlyMemory<byte> payloadBytes,
IReadOnlyList<DsseSignature> signatures,
ImmutableDictionary<string, string>.Builder diagnostics,
CancellationToken cancellationToken)
{
if (signatures is null || signatures.Count == 0)
{
diagnostics["signature.state"] = "missing";
return false;
}
if (_trustedSigners.Count == 0)
{
diagnostics["signature.state"] = "skipped";
diagnostics["signature.reason"] = "trust_not_configured";
return true;
}
if (_cryptoRegistry is null)
{
diagnostics["signature.state"] = "error";
diagnostics["signature.reason"] = "registry_unavailable";
return false;
}
foreach (var signature in signatures)
{
if (string.IsNullOrWhiteSpace(signature.Signature))
{
diagnostics["signature.state"] = "error";
diagnostics["signature.reason"] = "empty_signature";
return false;
}
if (string.IsNullOrWhiteSpace(signature.KeyId))
{
diagnostics["signature.state"] = "error";
diagnostics["signature.reason"] = "missing_key_id";
return false;
}
if (!_trustedSigners.TryGetValue(signature.KeyId, out var signerOptions))
{
diagnostics["signature.state"] = "error";
diagnostics["signature.reason"] = "untrusted_key";
diagnostics["signature.keyId"] = signature.KeyId;
return false;
}
byte[] signatureBytes;
try
{
signatureBytes = Convert.FromBase64String(signature.Signature);
}
catch (FormatException)
{
diagnostics["signature.state"] = "error";
diagnostics["signature.reason"] = "invalid_signature_encoding";
diagnostics["signature.keyId"] = signature.KeyId;
return false;
}
CryptoSignerResolution resolution;
try
{
resolution = _cryptoRegistry.ResolveSigner(
CryptoCapability.Verification,
signerOptions.Algorithm,
new CryptoKeyReference(signerOptions.KeyReference, signerOptions.ProviderHint));
}
catch (Exception ex)
{
diagnostics["signature.state"] = "error";
diagnostics["signature.reason"] = "resolution_failed";
diagnostics["signature.error"] = ex.GetType().Name;
diagnostics["signature.keyId"] = signature.KeyId;
return false;
}
var verified = await resolution.Signer
.VerifyAsync(payloadBytes, signatureBytes, cancellationToken)
.ConfigureAwait(false);
if (!verified)
{
diagnostics["signature.state"] = "error";
diagnostics["signature.reason"] = "verification_failed";
diagnostics["signature.keyId"] = signature.KeyId;
return false;
}
}
diagnostics["signature.state"] = "verified";
return true;
}
private static bool TryDeserializeEnvelope(
string envelopeJson,
out DsseEnvelope envelope,
ImmutableDictionary<string, string>.Builder diagnostics)
{
try
{
envelope = JsonSerializer.Deserialize<DsseEnvelope>(envelopeJson, EnvelopeSerializerOptions)
@@ -471,19 +572,20 @@ internal sealed class VexAttestationVerifier : IVexAttestationVerifier
}
}
private static bool SetEquals(IReadOnlyCollection<string>? left, ImmutableArray<string> right)
{
if (left is null)
{
return right.IsDefaultOrEmpty;
}
if (left.Count != right.Length)
{
return false;
}
var leftSet = new HashSet<string>(left, StringComparer.Ordinal);
return right.All(leftSet.Contains);
}
}
private static bool SetEquals(IReadOnlyCollection<string>? left, ImmutableArray<string> right)
{
if (left is null || left.Count == 0)
{
return right.IsDefaultOrEmpty || right.Length == 0;
}
if (right.IsDefaultOrEmpty)
{
return false;
}
var leftSet = new HashSet<string>(left, StringComparer.Ordinal);
var rightSet = new HashSet<string>(right, StringComparer.Ordinal);
return leftSet.SetEquals(rightSet);
}
}

View File

@@ -1,365 +1,365 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Authentication;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Events;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Metadata;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.State;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub;
public sealed class RancherHubConnector : VexConnectorBase
{
private const int MaxDigestHistory = 200;
private static readonly VexConnectorDescriptor StaticDescriptor = new(
id: "excititor:suse.rancher",
kind: VexProviderKind.Hub,
displayName: "SUSE Rancher VEX Hub")
{
Tags = ImmutableArray.Create("hub", "suse", "offline"),
};
private readonly RancherHubMetadataLoader _metadataLoader;
private readonly RancherHubEventClient _eventClient;
private readonly RancherHubCheckpointManager _checkpointManager;
private readonly RancherHubTokenProvider _tokenProvider;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IEnumerable<IVexConnectorOptionsValidator<RancherHubConnectorOptions>> _validators;
private RancherHubConnectorOptions? _options;
private RancherHubMetadataResult? _metadata;
public RancherHubConnector(
RancherHubMetadataLoader metadataLoader,
RancherHubEventClient eventClient,
RancherHubCheckpointManager checkpointManager,
RancherHubTokenProvider tokenProvider,
IHttpClientFactory httpClientFactory,
ILogger<RancherHubConnector> logger,
TimeProvider timeProvider,
IEnumerable<IVexConnectorOptionsValidator<RancherHubConnectorOptions>>? validators = null)
: base(StaticDescriptor, logger, timeProvider)
{
_metadataLoader = metadataLoader ?? throw new ArgumentNullException(nameof(metadataLoader));
_eventClient = eventClient ?? throw new ArgumentNullException(nameof(eventClient));
_checkpointManager = checkpointManager ?? throw new ArgumentNullException(nameof(checkpointManager));
_tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider));
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_validators = validators ?? Array.Empty<IVexConnectorOptionsValidator<RancherHubConnectorOptions>>();
}
public override async ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken)
{
_options = VexConnectorOptionsBinder.Bind(
Descriptor,
settings,
validators: _validators);
_metadata = await _metadataLoader.LoadAsync(_options, cancellationToken).ConfigureAwait(false);
LogConnectorEvent(LogLevel.Information, "validate", "Rancher hub discovery loaded.", new Dictionary<string, object?>
{
["discoveryUri"] = _options.DiscoveryUri.ToString(),
["subscriptionUri"] = _metadata.Metadata.Subscription.EventsUri.ToString(),
["requiresAuth"] = _metadata.Metadata.Subscription.RequiresAuthentication,
["fromOffline"] = _metadata.FromOfflineSnapshot,
});
}
public override async IAsyncEnumerable<VexRawDocument> FetchAsync(VexConnectorContext context, [EnumeratorCancellation] CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
if (_options is null)
{
throw new InvalidOperationException("Connector must be validated before fetch operations.");
}
if (_metadata is null)
{
_metadata = await _metadataLoader.LoadAsync(_options, cancellationToken).ConfigureAwait(false);
}
var checkpoint = await _checkpointManager.LoadAsync(Descriptor.Id, context, cancellationToken).ConfigureAwait(false);
var digestHistory = checkpoint.Digests.ToList();
var dedupeSet = new HashSet<string>(checkpoint.Digests, StringComparer.OrdinalIgnoreCase);
var latestCursor = checkpoint.Cursor;
var latestPublishedAt = checkpoint.LastPublishedAt ?? checkpoint.EffectiveSince;
var stateChanged = false;
LogConnectorEvent(LogLevel.Information, "fetch_start", "Starting Rancher hub event ingestion.", new Dictionary<string, object?>
{
["since"] = checkpoint.EffectiveSince?.ToString("O"),
["cursor"] = checkpoint.Cursor,
["subscriptionUri"] = _metadata.Metadata.Subscription.EventsUri.ToString(),
["offline"] = checkpoint.Cursor is null && _options.PreferOfflineSnapshot,
});
await foreach (var batch in _eventClient.FetchEventBatchesAsync(
_options,
_metadata.Metadata,
checkpoint.Cursor,
checkpoint.EffectiveSince,
_metadata.Metadata.Subscription.Channels,
cancellationToken).ConfigureAwait(false))
{
LogConnectorEvent(LogLevel.Debug, "batch", "Processing Rancher hub batch.", new Dictionary<string, object?>
{
["cursor"] = batch.Cursor,
["nextCursor"] = batch.NextCursor,
["count"] = batch.Events.Length,
["offline"] = batch.FromOfflineSnapshot,
});
if (!string.IsNullOrWhiteSpace(batch.NextCursor) && !string.Equals(batch.NextCursor, latestCursor, StringComparison.Ordinal))
{
latestCursor = batch.NextCursor;
stateChanged = true;
}
else if (string.IsNullOrWhiteSpace(latestCursor) && !string.IsNullOrWhiteSpace(batch.Cursor))
{
latestCursor = batch.Cursor;
}
foreach (var record in batch.Events)
{
cancellationToken.ThrowIfCancellationRequested();
var result = await ProcessEventAsync(record, batch, context, dedupeSet, digestHistory, cancellationToken).ConfigureAwait(false);
if (result.ProcessedDocument is not null)
{
yield return result.ProcessedDocument;
stateChanged = true;
if (result.PublishedAt is { } published && (latestPublishedAt is null || published > latestPublishedAt))
{
latestPublishedAt = published;
}
}
else if (result.Quarantined)
{
stateChanged = true;
}
}
}
var trimmed = TrimHistory(digestHistory);
if (trimmed)
{
stateChanged = true;
}
if (stateChanged || !string.Equals(latestCursor, checkpoint.Cursor, StringComparison.Ordinal) || latestPublishedAt != checkpoint.LastPublishedAt)
{
await _checkpointManager.SaveAsync(
Descriptor.Id,
latestCursor,
latestPublishedAt,
digestHistory.ToImmutableArray(),
cancellationToken).ConfigureAwait(false);
}
}
public override ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> throw new NotSupportedException("RancherHubConnector relies on format-specific normalizers for CSAF/OpenVEX payloads.");
public RancherHubMetadata? GetCachedMetadata() => _metadata?.Metadata;
private async Task<EventProcessingResult> ProcessEventAsync(
RancherHubEventRecord record,
RancherHubEventBatch batch,
VexConnectorContext context,
HashSet<string> dedupeSet,
List<string> digestHistory,
CancellationToken cancellationToken)
{
var quarantineKey = BuildQuarantineKey(record);
if (dedupeSet.Contains(quarantineKey))
{
return EventProcessingResult.QuarantinedOnly;
}
if (record.DocumentUri is null || string.IsNullOrWhiteSpace(record.Id))
{
await QuarantineAsync(record, batch, "missing documentUri or id", context, cancellationToken).ConfigureAwait(false);
AddQuarantineDigest(quarantineKey, dedupeSet, digestHistory);
return EventProcessingResult.QuarantinedOnly;
}
var client = _httpClientFactory.CreateClient(RancherHubConnectorOptions.HttpClientName);
using var request = await CreateDocumentRequestAsync(record.DocumentUri, cancellationToken).ConfigureAwait(false);
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
await QuarantineAsync(record, batch, $"document fetch failed ({(int)response.StatusCode} {response.StatusCode})", context, cancellationToken).ConfigureAwait(false);
AddQuarantineDigest(quarantineKey, dedupeSet, digestHistory);
return EventProcessingResult.QuarantinedOnly;
}
var contentBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
var publishedAt = record.PublishedAt ?? UtcNow();
var metadata = BuildMetadata(builder => builder
.Add("rancher.event.id", record.Id)
.Add("rancher.event.type", record.Type)
.Add("rancher.event.channel", record.Channel)
.Add("rancher.event.published", publishedAt)
.Add("rancher.event.cursor", batch.NextCursor ?? batch.Cursor)
.Add("rancher.event.offline", batch.FromOfflineSnapshot ? "true" : "false")
.Add("rancher.event.declaredDigest", record.DocumentDigest));
var format = ResolveFormat(record.DocumentFormat);
var document = CreateRawDocument(format, record.DocumentUri, contentBytes, metadata);
if (!string.IsNullOrWhiteSpace(record.DocumentDigest))
{
var declared = NormalizeDigest(record.DocumentDigest);
var computed = NormalizeDigest(document.Digest);
if (!string.Equals(declared, computed, StringComparison.OrdinalIgnoreCase))
{
await QuarantineAsync(record, batch, $"digest mismatch (declared {record.DocumentDigest}, computed {document.Digest})", context, cancellationToken).ConfigureAwait(false);
AddQuarantineDigest(quarantineKey, dedupeSet, digestHistory);
return EventProcessingResult.QuarantinedOnly;
}
}
if (!dedupeSet.Add(document.Digest))
{
return EventProcessingResult.Skipped;
}
digestHistory.Add(document.Digest);
await context.RawSink.StoreAsync(document, cancellationToken).ConfigureAwait(false);
return new EventProcessingResult(document, false, publishedAt);
}
private static bool TrimHistory(List<string> digestHistory)
{
if (digestHistory.Count <= MaxDigestHistory)
{
return false;
}
var excess = digestHistory.Count - MaxDigestHistory;
digestHistory.RemoveRange(0, excess);
return true;
}
private async Task<HttpRequestMessage> CreateDocumentRequestAsync(Uri documentUri, CancellationToken cancellationToken)
{
var request = new HttpRequestMessage(HttpMethod.Get, documentUri);
if (_metadata?.Metadata.Subscription.RequiresAuthentication ?? false)
{
var token = await _tokenProvider.GetAccessTokenAsync(_options!, cancellationToken).ConfigureAwait(false);
if (token is not null)
{
var scheme = string.IsNullOrWhiteSpace(token.TokenType) ? "Bearer" : token.TokenType;
request.Headers.Authorization = new AuthenticationHeaderValue(scheme, token.Value);
}
}
return request;
}
private async Task QuarantineAsync(
RancherHubEventRecord record,
RancherHubEventBatch batch,
string reason,
VexConnectorContext context,
CancellationToken cancellationToken)
{
var metadata = BuildMetadata(builder => builder
.Add("rancher.event.id", record.Id)
.Add("rancher.event.type", record.Type)
.Add("rancher.event.channel", record.Channel)
.Add("rancher.event.quarantine", "true")
.Add("rancher.event.error", reason)
.Add("rancher.event.cursor", batch.NextCursor ?? batch.Cursor)
.Add("rancher.event.offline", batch.FromOfflineSnapshot ? "true" : "false"));
var sourceUri = record.DocumentUri ?? _metadata?.Metadata.Subscription.EventsUri ?? _options!.DiscoveryUri;
var payload = Encoding.UTF8.GetBytes(record.RawJson);
var document = CreateRawDocument(VexDocumentFormat.Csaf, sourceUri, payload, metadata);
await context.RawSink.StoreAsync(document, cancellationToken).ConfigureAwait(false);
LogConnectorEvent(LogLevel.Warning, "quarantine", "Rancher hub event moved to quarantine.", new Dictionary<string, object?>
{
["eventId"] = record.Id ?? "(missing)",
["reason"] = reason,
});
}
private static void AddQuarantineDigest(string key, HashSet<string> dedupeSet, List<string> digestHistory)
{
if (dedupeSet.Add(key))
{
digestHistory.Add(key);
}
}
private static string BuildQuarantineKey(RancherHubEventRecord record)
{
if (!string.IsNullOrWhiteSpace(record.Id))
{
return $"quarantine:{record.Id}";
}
Span<byte> hash = stackalloc byte[32];
var bytes = Encoding.UTF8.GetBytes(record.RawJson);
if (!SHA256.TryHashData(bytes, hash, out _))
{
using var sha = SHA256.Create();
hash = sha.ComputeHash(bytes);
}
return $"quarantine:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static string NormalizeDigest(string digest)
{
if (string.IsNullOrWhiteSpace(digest))
{
return digest;
}
var trimmed = digest.Trim();
return trimmed.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
? trimmed.ToLowerInvariant()
: $"sha256:{trimmed.ToLowerInvariant()}";
}
private static VexDocumentFormat ResolveFormat(string? format)
{
if (string.IsNullOrWhiteSpace(format))
{
return VexDocumentFormat.Csaf;
}
return format.ToLowerInvariant() switch
{
"csaf" or "csaf_json" or "json" => VexDocumentFormat.Csaf,
"cyclonedx" or "cyclonedx_vex" => VexDocumentFormat.CycloneDx,
"openvex" => VexDocumentFormat.OpenVex,
"oci" or "oci_attestation" or "attestation" => VexDocumentFormat.OciAttestation,
_ => VexDocumentFormat.Csaf,
};
}
private sealed record EventProcessingResult(VexRawDocument? ProcessedDocument, bool Quarantined, DateTimeOffset? PublishedAt)
{
public static EventProcessingResult QuarantinedOnly { get; } = new(null, true, null);
public static EventProcessingResult Skipped { get; } = new(null, false, null);
}
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Authentication;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Events;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Metadata;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.State;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub;
public sealed class RancherHubConnector : VexConnectorBase
{
private const int MaxDigestHistory = 200;
private static readonly VexConnectorDescriptor StaticDescriptor = new(
id: "excititor:suse.rancher",
kind: VexProviderKind.Hub,
displayName: "SUSE Rancher VEX Hub")
{
Tags = ImmutableArray.Create("hub", "suse", "offline"),
};
private readonly RancherHubMetadataLoader _metadataLoader;
private readonly RancherHubEventClient _eventClient;
private readonly RancherHubCheckpointManager _checkpointManager;
private readonly RancherHubTokenProvider _tokenProvider;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IEnumerable<IVexConnectorOptionsValidator<RancherHubConnectorOptions>> _validators;
private RancherHubConnectorOptions? _options;
private RancherHubMetadataResult? _metadata;
public RancherHubConnector(
RancherHubMetadataLoader metadataLoader,
RancherHubEventClient eventClient,
RancherHubCheckpointManager checkpointManager,
RancherHubTokenProvider tokenProvider,
IHttpClientFactory httpClientFactory,
ILogger<RancherHubConnector> logger,
TimeProvider timeProvider,
IEnumerable<IVexConnectorOptionsValidator<RancherHubConnectorOptions>>? validators = null)
: base(StaticDescriptor, logger, timeProvider)
{
_metadataLoader = metadataLoader ?? throw new ArgumentNullException(nameof(metadataLoader));
_eventClient = eventClient ?? throw new ArgumentNullException(nameof(eventClient));
_checkpointManager = checkpointManager ?? throw new ArgumentNullException(nameof(checkpointManager));
_tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider));
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_validators = validators ?? Array.Empty<IVexConnectorOptionsValidator<RancherHubConnectorOptions>>();
}
public override async ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken)
{
_options = VexConnectorOptionsBinder.Bind(
Descriptor,
settings,
validators: _validators);
_metadata = await _metadataLoader.LoadAsync(_options, cancellationToken).ConfigureAwait(false);
LogConnectorEvent(LogLevel.Information, "validate", "Rancher hub discovery loaded.", new Dictionary<string, object?>
{
["discoveryUri"] = _options.DiscoveryUri.ToString(),
["subscriptionUri"] = _metadata.Metadata.Subscription.EventsUri.ToString(),
["requiresAuth"] = _metadata.Metadata.Subscription.RequiresAuthentication,
["fromOffline"] = _metadata.FromOfflineSnapshot,
});
}
public override async IAsyncEnumerable<VexRawDocument> FetchAsync(VexConnectorContext context, [EnumeratorCancellation] CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
if (_options is null)
{
throw new InvalidOperationException("Connector must be validated before fetch operations.");
}
if (_metadata is null)
{
_metadata = await _metadataLoader.LoadAsync(_options, cancellationToken).ConfigureAwait(false);
}
var checkpoint = await _checkpointManager.LoadAsync(Descriptor.Id, context, cancellationToken).ConfigureAwait(false);
var digestHistory = checkpoint.Digests.ToList();
var dedupeSet = new HashSet<string>(checkpoint.Digests, StringComparer.OrdinalIgnoreCase);
var latestCursor = checkpoint.Cursor;
var latestPublishedAt = checkpoint.LastPublishedAt ?? checkpoint.EffectiveSince;
var stateChanged = false;
LogConnectorEvent(LogLevel.Information, "fetch_start", "Starting Rancher hub event ingestion.", new Dictionary<string, object?>
{
["since"] = checkpoint.EffectiveSince?.ToString("O"),
["cursor"] = checkpoint.Cursor,
["subscriptionUri"] = _metadata.Metadata.Subscription.EventsUri.ToString(),
["offline"] = checkpoint.Cursor is null && _options.PreferOfflineSnapshot,
});
await foreach (var batch in _eventClient.FetchEventBatchesAsync(
_options,
_metadata.Metadata,
checkpoint.Cursor,
checkpoint.EffectiveSince,
_metadata.Metadata.Subscription.Channels,
cancellationToken).ConfigureAwait(false))
{
LogConnectorEvent(LogLevel.Debug, "batch", "Processing Rancher hub batch.", new Dictionary<string, object?>
{
["cursor"] = batch.Cursor,
["nextCursor"] = batch.NextCursor,
["count"] = batch.Events.Length,
["offline"] = batch.FromOfflineSnapshot,
});
if (!string.IsNullOrWhiteSpace(batch.NextCursor) && !string.Equals(batch.NextCursor, latestCursor, StringComparison.Ordinal))
{
latestCursor = batch.NextCursor;
stateChanged = true;
}
else if (string.IsNullOrWhiteSpace(latestCursor) && !string.IsNullOrWhiteSpace(batch.Cursor))
{
latestCursor = batch.Cursor;
}
foreach (var record in batch.Events)
{
cancellationToken.ThrowIfCancellationRequested();
var result = await ProcessEventAsync(record, batch, context, dedupeSet, digestHistory, cancellationToken).ConfigureAwait(false);
if (result.ProcessedDocument is not null)
{
yield return result.ProcessedDocument;
stateChanged = true;
if (result.PublishedAt is { } published && (latestPublishedAt is null || published > latestPublishedAt))
{
latestPublishedAt = published;
}
}
else if (result.Quarantined)
{
stateChanged = true;
}
}
}
var trimmed = TrimHistory(digestHistory);
if (trimmed)
{
stateChanged = true;
}
if (stateChanged || !string.Equals(latestCursor, checkpoint.Cursor, StringComparison.Ordinal) || latestPublishedAt != checkpoint.LastPublishedAt)
{
await _checkpointManager.SaveAsync(
Descriptor.Id,
latestCursor,
latestPublishedAt,
digestHistory.ToImmutableArray(),
cancellationToken).ConfigureAwait(false);
}
}
public override ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> throw new NotSupportedException("RancherHubConnector relies on format-specific normalizers for CSAF/OpenVEX payloads.");
public RancherHubMetadata? GetCachedMetadata() => _metadata?.Metadata;
private async Task<EventProcessingResult> ProcessEventAsync(
RancherHubEventRecord record,
RancherHubEventBatch batch,
VexConnectorContext context,
HashSet<string> dedupeSet,
List<string> digestHistory,
CancellationToken cancellationToken)
{
var quarantineKey = BuildQuarantineKey(record);
if (dedupeSet.Contains(quarantineKey))
{
return EventProcessingResult.QuarantinedOnly;
}
if (record.DocumentUri is null || string.IsNullOrWhiteSpace(record.Id))
{
await QuarantineAsync(record, batch, "missing documentUri or id", context, cancellationToken).ConfigureAwait(false);
AddQuarantineDigest(quarantineKey, dedupeSet, digestHistory);
return EventProcessingResult.QuarantinedOnly;
}
var client = _httpClientFactory.CreateClient(RancherHubConnectorOptions.HttpClientName);
using var request = await CreateDocumentRequestAsync(record.DocumentUri, cancellationToken).ConfigureAwait(false);
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
await QuarantineAsync(record, batch, $"document fetch failed ({(int)response.StatusCode} {response.StatusCode})", context, cancellationToken).ConfigureAwait(false);
AddQuarantineDigest(quarantineKey, dedupeSet, digestHistory);
return EventProcessingResult.QuarantinedOnly;
}
var contentBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
var publishedAt = record.PublishedAt ?? UtcNow();
var metadata = BuildMetadata(builder => builder
.Add("rancher.event.id", record.Id)
.Add("rancher.event.type", record.Type)
.Add("rancher.event.channel", record.Channel)
.Add("rancher.event.published", publishedAt)
.Add("rancher.event.cursor", batch.NextCursor ?? batch.Cursor)
.Add("rancher.event.offline", batch.FromOfflineSnapshot ? "true" : "false")
.Add("rancher.event.declaredDigest", record.DocumentDigest));
var format = ResolveFormat(record.DocumentFormat);
var document = CreateRawDocument(format, record.DocumentUri, contentBytes, metadata);
if (!string.IsNullOrWhiteSpace(record.DocumentDigest))
{
var declared = NormalizeDigest(record.DocumentDigest);
var computed = NormalizeDigest(document.Digest);
if (!string.Equals(declared, computed, StringComparison.OrdinalIgnoreCase))
{
await QuarantineAsync(record, batch, $"digest mismatch (declared {record.DocumentDigest}, computed {document.Digest})", context, cancellationToken).ConfigureAwait(false);
AddQuarantineDigest(quarantineKey, dedupeSet, digestHistory);
return EventProcessingResult.QuarantinedOnly;
}
}
if (!dedupeSet.Add(document.Digest))
{
return EventProcessingResult.Skipped;
}
digestHistory.Add(document.Digest);
await context.RawSink.StoreAsync(document, cancellationToken).ConfigureAwait(false);
return new EventProcessingResult(document, false, publishedAt);
}
private static bool TrimHistory(List<string> digestHistory)
{
if (digestHistory.Count <= MaxDigestHistory)
{
return false;
}
var excess = digestHistory.Count - MaxDigestHistory;
digestHistory.RemoveRange(0, excess);
return true;
}
private async Task<HttpRequestMessage> CreateDocumentRequestAsync(Uri documentUri, CancellationToken cancellationToken)
{
var request = new HttpRequestMessage(HttpMethod.Get, documentUri);
if (_metadata?.Metadata.Subscription.RequiresAuthentication ?? false)
{
var token = await _tokenProvider.GetAccessTokenAsync(_options!, cancellationToken).ConfigureAwait(false);
if (token is not null)
{
var scheme = string.IsNullOrWhiteSpace(token.TokenType) ? "Bearer" : token.TokenType;
request.Headers.Authorization = new AuthenticationHeaderValue(scheme, token.Value);
}
}
return request;
}
private async Task QuarantineAsync(
RancherHubEventRecord record,
RancherHubEventBatch batch,
string reason,
VexConnectorContext context,
CancellationToken cancellationToken)
{
var metadata = BuildMetadata(builder => builder
.Add("rancher.event.id", record.Id)
.Add("rancher.event.type", record.Type)
.Add("rancher.event.channel", record.Channel)
.Add("rancher.event.quarantine", "true")
.Add("rancher.event.error", reason)
.Add("rancher.event.cursor", batch.NextCursor ?? batch.Cursor)
.Add("rancher.event.offline", batch.FromOfflineSnapshot ? "true" : "false"));
var sourceUri = record.DocumentUri ?? _metadata?.Metadata.Subscription.EventsUri ?? _options!.DiscoveryUri;
var payload = Encoding.UTF8.GetBytes(record.RawJson);
var document = CreateRawDocument(VexDocumentFormat.Csaf, sourceUri, payload, metadata);
await context.RawSink.StoreAsync(document, cancellationToken).ConfigureAwait(false);
LogConnectorEvent(LogLevel.Warning, "quarantine", "Rancher hub event moved to quarantine.", new Dictionary<string, object?>
{
["eventId"] = record.Id ?? "(missing)",
["reason"] = reason,
});
}
private static void AddQuarantineDigest(string key, HashSet<string> dedupeSet, List<string> digestHistory)
{
if (dedupeSet.Add(key))
{
digestHistory.Add(key);
}
}
private static string BuildQuarantineKey(RancherHubEventRecord record)
{
if (!string.IsNullOrWhiteSpace(record.Id))
{
return $"quarantine:{record.Id}";
}
Span<byte> hash = stackalloc byte[32];
var bytes = Encoding.UTF8.GetBytes(record.RawJson);
if (!SHA256.TryHashData(bytes, hash, out _))
{
using var sha = SHA256.Create();
hash = sha.ComputeHash(bytes);
}
return $"quarantine:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static string NormalizeDigest(string digest)
{
if (string.IsNullOrWhiteSpace(digest))
{
return digest;
}
var trimmed = digest.Trim();
return trimmed.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase)
? trimmed.ToLowerInvariant()
: $"sha256:{trimmed.ToLowerInvariant()}";
}
private static VexDocumentFormat ResolveFormat(string? format)
{
if (string.IsNullOrWhiteSpace(format))
{
return VexDocumentFormat.Csaf;
}
return format.ToLowerInvariant() switch
{
"csaf" or "csaf_json" or "json" => VexDocumentFormat.Csaf,
"cyclonedx" or "cyclonedx_vex" => VexDocumentFormat.CycloneDx,
"openvex" => VexDocumentFormat.OpenVex,
"oci" or "oci_attestation" or "attestation" => VexDocumentFormat.OciAttestation,
_ => VexDocumentFormat.Csaf,
};
}
private sealed record EventProcessingResult(VexRawDocument? ProcessedDocument, bool Quarantined, DateTimeOffset? PublishedAt)
{
public static EventProcessingResult QuarantinedOnly { get; } = new(null, true, null);
public static EventProcessingResult Skipped { get; } = new(null, false, null);
}
}

View File

@@ -1,242 +1,242 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Formats.CycloneDX;
internal static class CycloneDxComponentReconciler
{
public static CycloneDxReconciliationResult Reconcile(IEnumerable<VexClaim> claims)
{
ArgumentNullException.ThrowIfNull(claims);
var catalog = new ComponentCatalog();
var diagnostics = new Dictionary<string, SortedSet<string>>(StringComparer.Ordinal);
var componentRefs = new Dictionary<(string VulnerabilityId, string ProductKey), string>();
foreach (var claim in claims)
{
if (claim is null)
{
continue;
}
var component = catalog.GetOrAdd(claim.Product, claim.ProviderId, diagnostics);
componentRefs[(claim.VulnerabilityId, claim.Product.Key)] = component.BomRef;
}
var components = catalog.Build();
var orderedDiagnostics = diagnostics.Count == 0
? ImmutableDictionary<string, string>.Empty
: diagnostics.ToImmutableDictionary(
static pair => pair.Key,
pair => string.Join(",", pair.Value.OrderBy(static value => value, StringComparer.Ordinal)),
StringComparer.Ordinal);
return new CycloneDxReconciliationResult(
components,
componentRefs.ToImmutableDictionary(),
orderedDiagnostics);
}
private sealed class ComponentCatalog
{
private readonly Dictionary<string, MutableComponent> _components = new(StringComparer.Ordinal);
private readonly HashSet<string> _bomRefs = new(StringComparer.Ordinal);
public MutableComponent GetOrAdd(VexProduct product, string providerId, IDictionary<string, SortedSet<string>> diagnostics)
{
if (_components.TryGetValue(product.Key, out var existing))
{
existing.Update(product, providerId, diagnostics);
return existing;
}
var bomRef = GenerateBomRef(product);
var component = new MutableComponent(product.Key, bomRef);
component.Update(product, providerId, diagnostics);
_components[product.Key] = component;
return component;
}
public ImmutableArray<CycloneDxComponentEntry> Build()
=> _components.Values
.Select(static component => component.ToEntry())
.OrderBy(static entry => entry.BomRef, StringComparer.Ordinal)
.ToImmutableArray();
private string GenerateBomRef(VexProduct product)
{
if (!string.IsNullOrWhiteSpace(product.Purl))
{
var normalized = product.Purl!.Trim();
if (_bomRefs.Add(normalized))
{
return normalized;
}
}
var baseRef = Sanitize(product.Key);
if (_bomRefs.Add(baseRef))
{
return baseRef;
}
var hash = ComputeShortHash(product.Key + product.Name);
var candidate = FormattableString.Invariant($"{baseRef}-{hash}");
while (!_bomRefs.Add(candidate))
{
candidate = FormattableString.Invariant($"{candidate}-{hash}");
}
return candidate;
}
private static string Sanitize(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "component";
}
var builder = new StringBuilder(value.Length);
foreach (var ch in value)
{
builder.Append(char.IsLetterOrDigit(ch) ? char.ToLowerInvariant(ch) : '-');
}
var sanitized = builder.ToString().Trim('-');
return string.IsNullOrEmpty(sanitized) ? "component" : sanitized;
}
private static string ComputeShortHash(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
SHA256.HashData(bytes, hash);
return Convert.ToHexString(hash[..6]).ToLowerInvariant();
}
}
private sealed class MutableComponent
{
public MutableComponent(string key, string bomRef)
{
ProductKey = key;
BomRef = bomRef;
}
public string ProductKey { get; }
public string BomRef { get; }
private string? _name;
private string? _version;
private string? _purl;
private string? _cpe;
private readonly SortedDictionary<string, string> _properties = new(StringComparer.Ordinal);
public void Update(VexProduct product, string providerId, IDictionary<string, SortedSet<string>> diagnostics)
{
if (!string.IsNullOrWhiteSpace(product.Name) && ShouldReplace(_name, product.Name))
{
_name = product.Name;
}
if (!string.IsNullOrWhiteSpace(product.Version) && ShouldReplace(_version, product.Version))
{
_version = product.Version;
}
if (!string.IsNullOrWhiteSpace(product.Purl))
{
var trimmed = product.Purl!.Trim();
if (string.IsNullOrWhiteSpace(_purl))
{
_purl = trimmed;
}
else if (!string.Equals(_purl, trimmed, StringComparison.OrdinalIgnoreCase))
{
AddDiagnostic(diagnostics, "purl_conflict", FormattableString.Invariant($"{ProductKey}:{_purl}->{trimmed}"));
}
}
else
{
AddDiagnostic(diagnostics, "missing_purl", FormattableString.Invariant($"{ProductKey}:{providerId}"));
}
if (!string.IsNullOrWhiteSpace(product.Cpe))
{
_cpe = product.Cpe;
}
if (product.ComponentIdentifiers.Length > 0)
{
_properties["stellaops/componentIdentifiers"] = string.Join(';', product.ComponentIdentifiers.OrderBy(static identifier => identifier, StringComparer.OrdinalIgnoreCase));
}
}
public CycloneDxComponentEntry ToEntry()
{
ImmutableArray<CycloneDxProperty>? properties = _properties.Count == 0
? null
: _properties.Select(static pair => new CycloneDxProperty(pair.Key, pair.Value)).ToImmutableArray();
return new CycloneDxComponentEntry(
BomRef,
_name ?? ProductKey,
_version,
_purl,
_cpe,
properties);
}
private static bool ShouldReplace(string? existing, string candidate)
{
if (string.IsNullOrWhiteSpace(candidate))
{
return false;
}
if (string.IsNullOrWhiteSpace(existing))
{
return true;
}
return candidate.Length > existing.Length;
}
private static void AddDiagnostic(IDictionary<string, SortedSet<string>> diagnostics, string key, string value)
{
if (!diagnostics.TryGetValue(key, out var set))
{
set = new SortedSet<string>(StringComparer.Ordinal);
diagnostics[key] = set;
}
set.Add(value);
}
}
}
internal sealed record CycloneDxReconciliationResult(
ImmutableArray<CycloneDxComponentEntry> Components,
ImmutableDictionary<(string VulnerabilityId, string ProductKey), string> ComponentRefs,
ImmutableDictionary<string, string> Diagnostics);
internal sealed record CycloneDxComponentEntry(
[property: JsonPropertyName("bom-ref")] string BomRef,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("version"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Version,
[property: JsonPropertyName("purl"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Purl,
[property: JsonPropertyName("cpe"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Cpe,
[property: JsonPropertyName("properties"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<CycloneDxProperty>? Properties);
internal sealed record CycloneDxProperty(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("value")] string Value);
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Formats.CycloneDX;
internal static class CycloneDxComponentReconciler
{
public static CycloneDxReconciliationResult Reconcile(IEnumerable<VexClaim> claims)
{
ArgumentNullException.ThrowIfNull(claims);
var catalog = new ComponentCatalog();
var diagnostics = new Dictionary<string, SortedSet<string>>(StringComparer.Ordinal);
var componentRefs = new Dictionary<(string VulnerabilityId, string ProductKey), string>();
foreach (var claim in claims)
{
if (claim is null)
{
continue;
}
var component = catalog.GetOrAdd(claim.Product, claim.ProviderId, diagnostics);
componentRefs[(claim.VulnerabilityId, claim.Product.Key)] = component.BomRef;
}
var components = catalog.Build();
var orderedDiagnostics = diagnostics.Count == 0
? ImmutableDictionary<string, string>.Empty
: diagnostics.ToImmutableDictionary(
static pair => pair.Key,
pair => string.Join(",", pair.Value.OrderBy(static value => value, StringComparer.Ordinal)),
StringComparer.Ordinal);
return new CycloneDxReconciliationResult(
components,
componentRefs.ToImmutableDictionary(),
orderedDiagnostics);
}
private sealed class ComponentCatalog
{
private readonly Dictionary<string, MutableComponent> _components = new(StringComparer.Ordinal);
private readonly HashSet<string> _bomRefs = new(StringComparer.Ordinal);
public MutableComponent GetOrAdd(VexProduct product, string providerId, IDictionary<string, SortedSet<string>> diagnostics)
{
if (_components.TryGetValue(product.Key, out var existing))
{
existing.Update(product, providerId, diagnostics);
return existing;
}
var bomRef = GenerateBomRef(product);
var component = new MutableComponent(product.Key, bomRef);
component.Update(product, providerId, diagnostics);
_components[product.Key] = component;
return component;
}
public ImmutableArray<CycloneDxComponentEntry> Build()
=> _components.Values
.Select(static component => component.ToEntry())
.OrderBy(static entry => entry.BomRef, StringComparer.Ordinal)
.ToImmutableArray();
private string GenerateBomRef(VexProduct product)
{
if (!string.IsNullOrWhiteSpace(product.Purl))
{
var normalized = product.Purl!.Trim();
if (_bomRefs.Add(normalized))
{
return normalized;
}
}
var baseRef = Sanitize(product.Key);
if (_bomRefs.Add(baseRef))
{
return baseRef;
}
var hash = ComputeShortHash(product.Key + product.Name);
var candidate = FormattableString.Invariant($"{baseRef}-{hash}");
while (!_bomRefs.Add(candidate))
{
candidate = FormattableString.Invariant($"{candidate}-{hash}");
}
return candidate;
}
private static string Sanitize(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "component";
}
var builder = new StringBuilder(value.Length);
foreach (var ch in value)
{
builder.Append(char.IsLetterOrDigit(ch) ? char.ToLowerInvariant(ch) : '-');
}
var sanitized = builder.ToString().Trim('-');
return string.IsNullOrEmpty(sanitized) ? "component" : sanitized;
}
private static string ComputeShortHash(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
SHA256.HashData(bytes, hash);
return Convert.ToHexString(hash[..6]).ToLowerInvariant();
}
}
private sealed class MutableComponent
{
public MutableComponent(string key, string bomRef)
{
ProductKey = key;
BomRef = bomRef;
}
public string ProductKey { get; }
public string BomRef { get; }
private string? _name;
private string? _version;
private string? _purl;
private string? _cpe;
private readonly SortedDictionary<string, string> _properties = new(StringComparer.Ordinal);
public void Update(VexProduct product, string providerId, IDictionary<string, SortedSet<string>> diagnostics)
{
if (!string.IsNullOrWhiteSpace(product.Name) && ShouldReplace(_name, product.Name))
{
_name = product.Name;
}
if (!string.IsNullOrWhiteSpace(product.Version) && ShouldReplace(_version, product.Version))
{
_version = product.Version;
}
if (!string.IsNullOrWhiteSpace(product.Purl))
{
var trimmed = product.Purl!.Trim();
if (string.IsNullOrWhiteSpace(_purl))
{
_purl = trimmed;
}
else if (!string.Equals(_purl, trimmed, StringComparison.OrdinalIgnoreCase))
{
AddDiagnostic(diagnostics, "purl_conflict", FormattableString.Invariant($"{ProductKey}:{_purl}->{trimmed}"));
}
}
else
{
AddDiagnostic(diagnostics, "missing_purl", FormattableString.Invariant($"{ProductKey}:{providerId}"));
}
if (!string.IsNullOrWhiteSpace(product.Cpe))
{
_cpe = product.Cpe;
}
if (product.ComponentIdentifiers.Length > 0)
{
_properties["stellaops/componentIdentifiers"] = string.Join(';', product.ComponentIdentifiers.OrderBy(static identifier => identifier, StringComparer.OrdinalIgnoreCase));
}
}
public CycloneDxComponentEntry ToEntry()
{
ImmutableArray<CycloneDxProperty>? properties = _properties.Count == 0
? null
: _properties.Select(static pair => new CycloneDxProperty(pair.Key, pair.Value)).ToImmutableArray();
return new CycloneDxComponentEntry(
BomRef,
_name ?? ProductKey,
_version,
_purl,
_cpe,
properties);
}
private static bool ShouldReplace(string? existing, string candidate)
{
if (string.IsNullOrWhiteSpace(candidate))
{
return false;
}
if (string.IsNullOrWhiteSpace(existing))
{
return true;
}
return candidate.Length > existing.Length;
}
private static void AddDiagnostic(IDictionary<string, SortedSet<string>> diagnostics, string key, string value)
{
if (!diagnostics.TryGetValue(key, out var set))
{
set = new SortedSet<string>(StringComparer.Ordinal);
diagnostics[key] = set;
}
set.Add(value);
}
}
}
internal sealed record CycloneDxReconciliationResult(
ImmutableArray<CycloneDxComponentEntry> Components,
ImmutableDictionary<(string VulnerabilityId, string ProductKey), string> ComponentRefs,
ImmutableDictionary<string, string> Diagnostics);
internal sealed record CycloneDxComponentEntry(
[property: JsonPropertyName("bom-ref")] string BomRef,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("version"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Version,
[property: JsonPropertyName("purl"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Purl,
[property: JsonPropertyName("cpe"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Cpe,
[property: JsonPropertyName("properties"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<CycloneDxProperty>? Properties);
internal sealed record CycloneDxProperty(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("value")] string Value);

View File

@@ -1,228 +1,228 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Formats.CycloneDX;
/// <summary>
/// Serialises normalized VEX claims into CycloneDX VEX documents with reconciled component references.
/// </summary>
public sealed class CycloneDxExporter : IVexExporter
{
public VexExportFormat Format => VexExportFormat.CycloneDx;
public VexContentAddress Digest(VexExportRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var document = BuildDocument(request, out _);
var json = VexCanonicalJsonSerializer.Serialize(document);
return ComputeDigest(json);
}
public async ValueTask<VexExportResult> SerializeAsync(
VexExportRequest request,
Stream output,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(output);
var document = BuildDocument(request, out var metadata);
var json = VexCanonicalJsonSerializer.Serialize(document);
var digest = ComputeDigest(json);
var buffer = Encoding.UTF8.GetBytes(json);
await output.WriteAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false);
return new VexExportResult(digest, buffer.LongLength, metadata);
}
private CycloneDxExportDocument BuildDocument(VexExportRequest request, out ImmutableDictionary<string, string> metadata)
{
var signature = VexQuerySignature.FromQuery(request.Query);
var signatureHash = signature.ComputeHash();
var generatedAt = request.GeneratedAt.UtcDateTime.ToString("O", CultureInfo.InvariantCulture);
var reconciliation = CycloneDxComponentReconciler.Reconcile(request.Claims);
var vulnerabilityEntries = BuildVulnerabilities(request.Claims, reconciliation.ComponentRefs);
var missingJustifications = request.Claims
.Where(static claim => claim.Status == VexClaimStatus.NotAffected && claim.Justification is null)
.Select(static claim => FormattableString.Invariant($"{claim.VulnerabilityId}:{claim.Product.Key}"))
.Distinct(StringComparer.Ordinal)
.OrderBy(static key => key, StringComparer.Ordinal)
.ToImmutableArray();
var properties = ImmutableArray.Create(new CycloneDxProperty("stellaops/querySignature", signature.Value));
metadata = BuildMetadata(signature, reconciliation.Diagnostics, generatedAt, vulnerabilityEntries.Length, reconciliation.Components.Length, missingJustifications);
var document = new CycloneDxExportDocument(
BomFormat: "CycloneDX",
SpecVersion: "1.6",
SerialNumber: FormattableString.Invariant($"urn:uuid:{BuildDeterministicGuid(signatureHash.Digest)}"),
Version: 1,
Metadata: new CycloneDxMetadata(generatedAt),
Components: reconciliation.Components,
Vulnerabilities: vulnerabilityEntries,
Properties: properties);
return document;
}
private static ImmutableArray<CycloneDxVulnerabilityEntry> BuildVulnerabilities(
ImmutableArray<VexClaim> claims,
ImmutableDictionary<(string VulnerabilityId, string ProductKey), string> componentRefs)
{
var entries = ImmutableArray.CreateBuilder<CycloneDxVulnerabilityEntry>();
foreach (var claim in claims)
{
if (!componentRefs.TryGetValue((claim.VulnerabilityId, claim.Product.Key), out var componentRef))
{
continue;
}
var analysis = new CycloneDxAnalysis(
State: MapStatus(claim.Status),
Justification: claim.Justification?.ToString().ToLowerInvariant(),
Responses: null);
var affects = ImmutableArray.Create(new CycloneDxAffectEntry(componentRef));
var properties = ImmutableArray.Create(
new CycloneDxProperty("stellaops/providerId", claim.ProviderId),
new CycloneDxProperty("stellaops/documentDigest", claim.Document.Digest));
var vulnerabilityId = claim.VulnerabilityId;
var bomRef = FormattableString.Invariant($"{vulnerabilityId}#{Normalize(componentRef)}");
entries.Add(new CycloneDxVulnerabilityEntry(
Id: vulnerabilityId,
BomRef: bomRef,
Description: claim.Detail,
Analysis: analysis,
Affects: affects,
Properties: properties));
}
return entries
.ToImmutable()
.OrderBy(static entry => entry.Id, StringComparer.Ordinal)
.ThenBy(static entry => entry.BomRef, StringComparer.Ordinal)
.ToImmutableArray();
}
private static string Normalize(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "component";
}
var builder = new StringBuilder(value.Length);
foreach (var ch in value)
{
builder.Append(char.IsLetterOrDigit(ch) ? char.ToLowerInvariant(ch) : '-');
}
var normalized = builder.ToString().Trim('-');
return string.IsNullOrEmpty(normalized) ? "component" : normalized;
}
private static string MapStatus(VexClaimStatus status)
=> status switch
{
VexClaimStatus.Affected => "affected",
VexClaimStatus.NotAffected => "not_affected",
VexClaimStatus.Fixed => "resolved",
VexClaimStatus.UnderInvestigation => "under_investigation",
_ => "unknown",
};
private static ImmutableDictionary<string, string> BuildMetadata(
VexQuerySignature signature,
ImmutableDictionary<string, string> diagnostics,
string generatedAt,
int vulnerabilityCount,
int componentCount,
ImmutableArray<string> missingJustifications)
{
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
builder["cyclonedx.querySignature"] = signature.Value;
builder["cyclonedx.generatedAt"] = generatedAt;
builder["cyclonedx.vulnerabilityCount"] = vulnerabilityCount.ToString(CultureInfo.InvariantCulture);
builder["cyclonedx.componentCount"] = componentCount.ToString(CultureInfo.InvariantCulture);
foreach (var diagnostic in diagnostics.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
{
builder[$"cyclonedx.{diagnostic.Key}"] = diagnostic.Value;
}
if (!missingJustifications.IsDefaultOrEmpty && missingJustifications.Length > 0)
{
builder["policy.justification_missing"] = string.Join(",", missingJustifications);
}
return builder.ToImmutable();
}
private static string BuildDeterministicGuid(string digest)
{
if (string.IsNullOrWhiteSpace(digest) || digest.Length < 32)
{
return Guid.NewGuid().ToString();
}
var hex = digest[..32];
var bytes = Enumerable.Range(0, hex.Length / 2)
.Select(i => byte.Parse(hex.Substring(i * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture))
.ToArray();
return new Guid(bytes).ToString();
}
private static VexContentAddress ComputeDigest(string json)
{
var bytes = Encoding.UTF8.GetBytes(json);
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
SHA256.HashData(bytes, hash);
var digest = Convert.ToHexString(hash).ToLowerInvariant();
return new VexContentAddress("sha256", digest);
}
}
internal sealed record CycloneDxExportDocument(
[property: JsonPropertyName("bomFormat")] string BomFormat,
[property: JsonPropertyName("specVersion")] string SpecVersion,
[property: JsonPropertyName("serialNumber")] string SerialNumber,
[property: JsonPropertyName("version")] int Version,
[property: JsonPropertyName("metadata")] CycloneDxMetadata Metadata,
[property: JsonPropertyName("components")] ImmutableArray<CycloneDxComponentEntry> Components,
[property: JsonPropertyName("vulnerabilities")] ImmutableArray<CycloneDxVulnerabilityEntry> Vulnerabilities,
[property: JsonPropertyName("properties"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<CycloneDxProperty>? Properties);
internal sealed record CycloneDxMetadata(
[property: JsonPropertyName("timestamp")] string Timestamp);
internal sealed record CycloneDxVulnerabilityEntry(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("bom-ref")] string BomRef,
[property: JsonPropertyName("description"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Description,
[property: JsonPropertyName("analysis")] CycloneDxAnalysis Analysis,
[property: JsonPropertyName("affects")] ImmutableArray<CycloneDxAffectEntry> Affects,
[property: JsonPropertyName("properties")] ImmutableArray<CycloneDxProperty> Properties);
internal sealed record CycloneDxAnalysis(
[property: JsonPropertyName("state")] string State,
[property: JsonPropertyName("justification"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Justification,
[property: JsonPropertyName("response"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<string>? Responses);
internal sealed record CycloneDxAffectEntry(
[property: JsonPropertyName("ref")] string Reference);
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Formats.CycloneDX;
/// <summary>
/// Serialises normalized VEX claims into CycloneDX VEX documents with reconciled component references.
/// </summary>
public sealed class CycloneDxExporter : IVexExporter
{
public VexExportFormat Format => VexExportFormat.CycloneDx;
public VexContentAddress Digest(VexExportRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var document = BuildDocument(request, out _);
var json = VexCanonicalJsonSerializer.Serialize(document);
return ComputeDigest(json);
}
public async ValueTask<VexExportResult> SerializeAsync(
VexExportRequest request,
Stream output,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(output);
var document = BuildDocument(request, out var metadata);
var json = VexCanonicalJsonSerializer.Serialize(document);
var digest = ComputeDigest(json);
var buffer = Encoding.UTF8.GetBytes(json);
await output.WriteAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false);
return new VexExportResult(digest, buffer.LongLength, metadata);
}
private CycloneDxExportDocument BuildDocument(VexExportRequest request, out ImmutableDictionary<string, string> metadata)
{
var signature = VexQuerySignature.FromQuery(request.Query);
var signatureHash = signature.ComputeHash();
var generatedAt = request.GeneratedAt.UtcDateTime.ToString("O", CultureInfo.InvariantCulture);
var reconciliation = CycloneDxComponentReconciler.Reconcile(request.Claims);
var vulnerabilityEntries = BuildVulnerabilities(request.Claims, reconciliation.ComponentRefs);
var missingJustifications = request.Claims
.Where(static claim => claim.Status == VexClaimStatus.NotAffected && claim.Justification is null)
.Select(static claim => FormattableString.Invariant($"{claim.VulnerabilityId}:{claim.Product.Key}"))
.Distinct(StringComparer.Ordinal)
.OrderBy(static key => key, StringComparer.Ordinal)
.ToImmutableArray();
var properties = ImmutableArray.Create(new CycloneDxProperty("stellaops/querySignature", signature.Value));
metadata = BuildMetadata(signature, reconciliation.Diagnostics, generatedAt, vulnerabilityEntries.Length, reconciliation.Components.Length, missingJustifications);
var document = new CycloneDxExportDocument(
BomFormat: "CycloneDX",
SpecVersion: "1.6",
SerialNumber: FormattableString.Invariant($"urn:uuid:{BuildDeterministicGuid(signatureHash.Digest)}"),
Version: 1,
Metadata: new CycloneDxMetadata(generatedAt),
Components: reconciliation.Components,
Vulnerabilities: vulnerabilityEntries,
Properties: properties);
return document;
}
private static ImmutableArray<CycloneDxVulnerabilityEntry> BuildVulnerabilities(
ImmutableArray<VexClaim> claims,
ImmutableDictionary<(string VulnerabilityId, string ProductKey), string> componentRefs)
{
var entries = ImmutableArray.CreateBuilder<CycloneDxVulnerabilityEntry>();
foreach (var claim in claims)
{
if (!componentRefs.TryGetValue((claim.VulnerabilityId, claim.Product.Key), out var componentRef))
{
continue;
}
var analysis = new CycloneDxAnalysis(
State: MapStatus(claim.Status),
Justification: claim.Justification?.ToString().ToLowerInvariant(),
Responses: null);
var affects = ImmutableArray.Create(new CycloneDxAffectEntry(componentRef));
var properties = ImmutableArray.Create(
new CycloneDxProperty("stellaops/providerId", claim.ProviderId),
new CycloneDxProperty("stellaops/documentDigest", claim.Document.Digest));
var vulnerabilityId = claim.VulnerabilityId;
var bomRef = FormattableString.Invariant($"{vulnerabilityId}#{Normalize(componentRef)}");
entries.Add(new CycloneDxVulnerabilityEntry(
Id: vulnerabilityId,
BomRef: bomRef,
Description: claim.Detail,
Analysis: analysis,
Affects: affects,
Properties: properties));
}
return entries
.ToImmutable()
.OrderBy(static entry => entry.Id, StringComparer.Ordinal)
.ThenBy(static entry => entry.BomRef, StringComparer.Ordinal)
.ToImmutableArray();
}
private static string Normalize(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "component";
}
var builder = new StringBuilder(value.Length);
foreach (var ch in value)
{
builder.Append(char.IsLetterOrDigit(ch) ? char.ToLowerInvariant(ch) : '-');
}
var normalized = builder.ToString().Trim('-');
return string.IsNullOrEmpty(normalized) ? "component" : normalized;
}
private static string MapStatus(VexClaimStatus status)
=> status switch
{
VexClaimStatus.Affected => "affected",
VexClaimStatus.NotAffected => "not_affected",
VexClaimStatus.Fixed => "resolved",
VexClaimStatus.UnderInvestigation => "under_investigation",
_ => "unknown",
};
private static ImmutableDictionary<string, string> BuildMetadata(
VexQuerySignature signature,
ImmutableDictionary<string, string> diagnostics,
string generatedAt,
int vulnerabilityCount,
int componentCount,
ImmutableArray<string> missingJustifications)
{
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
builder["cyclonedx.querySignature"] = signature.Value;
builder["cyclonedx.generatedAt"] = generatedAt;
builder["cyclonedx.vulnerabilityCount"] = vulnerabilityCount.ToString(CultureInfo.InvariantCulture);
builder["cyclonedx.componentCount"] = componentCount.ToString(CultureInfo.InvariantCulture);
foreach (var diagnostic in diagnostics.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
{
builder[$"cyclonedx.{diagnostic.Key}"] = diagnostic.Value;
}
if (!missingJustifications.IsDefaultOrEmpty && missingJustifications.Length > 0)
{
builder["policy.justification_missing"] = string.Join(",", missingJustifications);
}
return builder.ToImmutable();
}
private static string BuildDeterministicGuid(string digest)
{
if (string.IsNullOrWhiteSpace(digest) || digest.Length < 32)
{
return Guid.NewGuid().ToString();
}
var hex = digest[..32];
var bytes = Enumerable.Range(0, hex.Length / 2)
.Select(i => byte.Parse(hex.Substring(i * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture))
.ToArray();
return new Guid(bytes).ToString();
}
private static VexContentAddress ComputeDigest(string json)
{
var bytes = Encoding.UTF8.GetBytes(json);
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
SHA256.HashData(bytes, hash);
var digest = Convert.ToHexString(hash).ToLowerInvariant();
return new VexContentAddress("sha256", digest);
}
}
internal sealed record CycloneDxExportDocument(
[property: JsonPropertyName("bomFormat")] string BomFormat,
[property: JsonPropertyName("specVersion")] string SpecVersion,
[property: JsonPropertyName("serialNumber")] string SerialNumber,
[property: JsonPropertyName("version")] int Version,
[property: JsonPropertyName("metadata")] CycloneDxMetadata Metadata,
[property: JsonPropertyName("components")] ImmutableArray<CycloneDxComponentEntry> Components,
[property: JsonPropertyName("vulnerabilities")] ImmutableArray<CycloneDxVulnerabilityEntry> Vulnerabilities,
[property: JsonPropertyName("properties"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<CycloneDxProperty>? Properties);
internal sealed record CycloneDxMetadata(
[property: JsonPropertyName("timestamp")] string Timestamp);
internal sealed record CycloneDxVulnerabilityEntry(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("bom-ref")] string BomRef,
[property: JsonPropertyName("description"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Description,
[property: JsonPropertyName("analysis")] CycloneDxAnalysis Analysis,
[property: JsonPropertyName("affects")] ImmutableArray<CycloneDxAffectEntry> Affects,
[property: JsonPropertyName("properties")] ImmutableArray<CycloneDxProperty> Properties);
internal sealed record CycloneDxAnalysis(
[property: JsonPropertyName("state")] string State,
[property: JsonPropertyName("justification"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Justification,
[property: JsonPropertyName("response"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ImmutableArray<string>? Responses);
internal sealed record CycloneDxAffectEntry(
[property: JsonPropertyName("ref")] string Reference);

View File

@@ -1,217 +1,217 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Formats.OpenVEX;
/// <summary>
/// Serializes merged VEX statements into canonical OpenVEX export documents.
/// </summary>
public sealed class OpenVexExporter : IVexExporter
{
public OpenVexExporter()
{
}
public VexExportFormat Format => VexExportFormat.OpenVex;
public VexContentAddress Digest(VexExportRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var document = BuildDocument(request, out _);
var json = VexCanonicalJsonSerializer.Serialize(document);
return ComputeDigest(json);
}
public async ValueTask<VexExportResult> SerializeAsync(
VexExportRequest request,
Stream output,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(output);
var metadata = BuildDocument(request, out var exportMetadata);
var json = VexCanonicalJsonSerializer.Serialize(metadata);
var digest = ComputeDigest(json);
var buffer = Encoding.UTF8.GetBytes(json);
await output.WriteAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false);
return new VexExportResult(digest, buffer.LongLength, exportMetadata);
}
private OpenVexExportDocument BuildDocument(VexExportRequest request, out ImmutableDictionary<string, string> metadata)
{
var mergeResult = OpenVexStatementMerger.Merge(request.Claims);
var signature = VexQuerySignature.FromQuery(request.Query);
var signatureHash = signature.ComputeHash();
var generatedAt = request.GeneratedAt.UtcDateTime.ToString("O", CultureInfo.InvariantCulture);
var sourceProviders = request.Claims
.Select(static claim => claim.ProviderId)
.Distinct(StringComparer.Ordinal)
.OrderBy(static provider => provider, StringComparer.Ordinal)
.ToImmutableArray();
var statements = mergeResult.Statements
.Select(statement => MapStatement(statement))
.ToImmutableArray();
var document = new OpenVexDocumentSection(
Id: FormattableString.Invariant($"openvex:export:{signatureHash.Digest}"),
Author: "StellaOps Excititor",
Version: "1",
Created: generatedAt,
LastUpdated: generatedAt,
Profile: "stellaops-export/v1");
var metadataSection = new OpenVexExportMetadata(
generatedAt,
signature.Value,
sourceProviders,
mergeResult.Diagnostics);
metadata = BuildMetadata(signature, mergeResult, sourceProviders, generatedAt);
return new OpenVexExportDocument(document, statements, metadataSection);
}
private static ImmutableDictionary<string, string> BuildMetadata(
VexQuerySignature signature,
OpenVexMergeResult mergeResult,
ImmutableArray<string> sourceProviders,
string generatedAt)
{
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
metadataBuilder["openvex.querySignature"] = signature.Value;
metadataBuilder["openvex.generatedAt"] = generatedAt;
metadataBuilder["openvex.statementCount"] = mergeResult.Statements.Length.ToString(CultureInfo.InvariantCulture);
metadataBuilder["openvex.providerCount"] = sourceProviders.Length.ToString(CultureInfo.InvariantCulture);
var sourceCount = mergeResult.Statements.Sum(static statement => statement.Sources.Length);
metadataBuilder["openvex.sourceCount"] = sourceCount.ToString(CultureInfo.InvariantCulture);
foreach (var diagnostic in mergeResult.Diagnostics.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
{
metadataBuilder[$"openvex.diagnostic.{diagnostic.Key}"] = diagnostic.Value;
}
return metadataBuilder.ToImmutable();
}
private static OpenVexExportStatement MapStatement(OpenVexMergedStatement statement)
{
var products = ImmutableArray.Create(
new OpenVexExportProduct(
Id: statement.Product.Key,
Name: statement.Product.Name ?? statement.Product.Key,
Version: statement.Product.Version,
Purl: statement.Product.Purl,
Cpe: statement.Product.Cpe));
var sources = statement.Sources
.Select(source => new OpenVexExportSource(
Provider: source.ProviderId,
Status: source.Status.ToString().ToLowerInvariant(),
Justification: source.Justification?.ToString().ToLowerInvariant(),
DocumentDigest: source.DocumentDigest,
SourceUri: source.DocumentSource.ToString(),
Detail: source.Detail,
FirstObserved: source.FirstSeen.UtcDateTime.ToString("O", CultureInfo.InvariantCulture),
LastObserved: source.LastSeen.UtcDateTime.ToString("O", CultureInfo.InvariantCulture)))
.ToImmutableArray();
var statementId = FormattableString.Invariant($"{statement.VulnerabilityId}#{NormalizeProductKey(statement.Product.Key)}");
return new OpenVexExportStatement(
Id: statementId,
Vulnerability: statement.VulnerabilityId,
Status: statement.Status.ToString().ToLowerInvariant(),
Justification: statement.Justification?.ToString().ToLowerInvariant(),
Timestamp: statement.FirstObserved.UtcDateTime.ToString("O", CultureInfo.InvariantCulture),
LastUpdated: statement.LastObserved.UtcDateTime.ToString("O", CultureInfo.InvariantCulture),
Products: products,
Statement: statement.Detail,
Sources: sources);
}
private static string NormalizeProductKey(string key)
{
if (string.IsNullOrWhiteSpace(key))
{
return "unknown";
}
var builder = new StringBuilder(key.Length);
foreach (var ch in key)
{
builder.Append(char.IsLetterOrDigit(ch) ? char.ToLowerInvariant(ch) : '-');
}
var normalized = builder.ToString().Trim('-');
return string.IsNullOrEmpty(normalized) ? "unknown" : normalized;
}
private static VexContentAddress ComputeDigest(string json)
{
var bytes = Encoding.UTF8.GetBytes(json);
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
SHA256.HashData(bytes, hash);
var digest = Convert.ToHexString(hash).ToLowerInvariant();
return new VexContentAddress("sha256", digest);
}
}
internal sealed record OpenVexExportDocument(
OpenVexDocumentSection Document,
ImmutableArray<OpenVexExportStatement> Statements,
OpenVexExportMetadata Metadata);
internal sealed record OpenVexDocumentSection(
[property: JsonPropertyName("@context")] string Context = "https://openvex.dev/ns/v0.2",
[property: JsonPropertyName("id")] string Id = "",
[property: JsonPropertyName("author")] string Author = "",
[property: JsonPropertyName("version")] string Version = "1",
[property: JsonPropertyName("created")] string Created = "",
[property: JsonPropertyName("last_updated")] string LastUpdated = "",
[property: JsonPropertyName("profile")] string Profile = "");
internal sealed record OpenVexExportStatement(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("vulnerability")] string Vulnerability,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("justification"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Justification,
[property: JsonPropertyName("timestamp")] string Timestamp,
[property: JsonPropertyName("last_updated")] string LastUpdated,
[property: JsonPropertyName("products")] ImmutableArray<OpenVexExportProduct> Products,
[property: JsonPropertyName("statement"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Statement,
[property: JsonPropertyName("sources")] ImmutableArray<OpenVexExportSource> Sources);
internal sealed record OpenVexExportProduct(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("version"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Version,
[property: JsonPropertyName("purl"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Purl,
[property: JsonPropertyName("cpe"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Cpe);
internal sealed record OpenVexExportSource(
[property: JsonPropertyName("provider")] string Provider,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("justification"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Justification,
[property: JsonPropertyName("document_digest")] string DocumentDigest,
[property: JsonPropertyName("source_uri")] string SourceUri,
[property: JsonPropertyName("detail"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Detail,
[property: JsonPropertyName("first_observed")] string FirstObserved,
[property: JsonPropertyName("last_observed")] string LastObserved);
internal sealed record OpenVexExportMetadata(
[property: JsonPropertyName("generated_at")] string GeneratedAt,
[property: JsonPropertyName("query_signature")] string QuerySignature,
[property: JsonPropertyName("source_providers")] ImmutableArray<string> SourceProviders,
[property: JsonPropertyName("diagnostics")] ImmutableDictionary<string, string> Diagnostics);
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Formats.OpenVEX;
/// <summary>
/// Serializes merged VEX statements into canonical OpenVEX export documents.
/// </summary>
public sealed class OpenVexExporter : IVexExporter
{
public OpenVexExporter()
{
}
public VexExportFormat Format => VexExportFormat.OpenVex;
public VexContentAddress Digest(VexExportRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var document = BuildDocument(request, out _);
var json = VexCanonicalJsonSerializer.Serialize(document);
return ComputeDigest(json);
}
public async ValueTask<VexExportResult> SerializeAsync(
VexExportRequest request,
Stream output,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(output);
var metadata = BuildDocument(request, out var exportMetadata);
var json = VexCanonicalJsonSerializer.Serialize(metadata);
var digest = ComputeDigest(json);
var buffer = Encoding.UTF8.GetBytes(json);
await output.WriteAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false);
return new VexExportResult(digest, buffer.LongLength, exportMetadata);
}
private OpenVexExportDocument BuildDocument(VexExportRequest request, out ImmutableDictionary<string, string> metadata)
{
var mergeResult = OpenVexStatementMerger.Merge(request.Claims);
var signature = VexQuerySignature.FromQuery(request.Query);
var signatureHash = signature.ComputeHash();
var generatedAt = request.GeneratedAt.UtcDateTime.ToString("O", CultureInfo.InvariantCulture);
var sourceProviders = request.Claims
.Select(static claim => claim.ProviderId)
.Distinct(StringComparer.Ordinal)
.OrderBy(static provider => provider, StringComparer.Ordinal)
.ToImmutableArray();
var statements = mergeResult.Statements
.Select(statement => MapStatement(statement))
.ToImmutableArray();
var document = new OpenVexDocumentSection(
Id: FormattableString.Invariant($"openvex:export:{signatureHash.Digest}"),
Author: "StellaOps Excititor",
Version: "1",
Created: generatedAt,
LastUpdated: generatedAt,
Profile: "stellaops-export/v1");
var metadataSection = new OpenVexExportMetadata(
generatedAt,
signature.Value,
sourceProviders,
mergeResult.Diagnostics);
metadata = BuildMetadata(signature, mergeResult, sourceProviders, generatedAt);
return new OpenVexExportDocument(document, statements, metadataSection);
}
private static ImmutableDictionary<string, string> BuildMetadata(
VexQuerySignature signature,
OpenVexMergeResult mergeResult,
ImmutableArray<string> sourceProviders,
string generatedAt)
{
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
metadataBuilder["openvex.querySignature"] = signature.Value;
metadataBuilder["openvex.generatedAt"] = generatedAt;
metadataBuilder["openvex.statementCount"] = mergeResult.Statements.Length.ToString(CultureInfo.InvariantCulture);
metadataBuilder["openvex.providerCount"] = sourceProviders.Length.ToString(CultureInfo.InvariantCulture);
var sourceCount = mergeResult.Statements.Sum(static statement => statement.Sources.Length);
metadataBuilder["openvex.sourceCount"] = sourceCount.ToString(CultureInfo.InvariantCulture);
foreach (var diagnostic in mergeResult.Diagnostics.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
{
metadataBuilder[$"openvex.diagnostic.{diagnostic.Key}"] = diagnostic.Value;
}
return metadataBuilder.ToImmutable();
}
private static OpenVexExportStatement MapStatement(OpenVexMergedStatement statement)
{
var products = ImmutableArray.Create(
new OpenVexExportProduct(
Id: statement.Product.Key,
Name: statement.Product.Name ?? statement.Product.Key,
Version: statement.Product.Version,
Purl: statement.Product.Purl,
Cpe: statement.Product.Cpe));
var sources = statement.Sources
.Select(source => new OpenVexExportSource(
Provider: source.ProviderId,
Status: source.Status.ToString().ToLowerInvariant(),
Justification: source.Justification?.ToString().ToLowerInvariant(),
DocumentDigest: source.DocumentDigest,
SourceUri: source.DocumentSource.ToString(),
Detail: source.Detail,
FirstObserved: source.FirstSeen.UtcDateTime.ToString("O", CultureInfo.InvariantCulture),
LastObserved: source.LastSeen.UtcDateTime.ToString("O", CultureInfo.InvariantCulture)))
.ToImmutableArray();
var statementId = FormattableString.Invariant($"{statement.VulnerabilityId}#{NormalizeProductKey(statement.Product.Key)}");
return new OpenVexExportStatement(
Id: statementId,
Vulnerability: statement.VulnerabilityId,
Status: statement.Status.ToString().ToLowerInvariant(),
Justification: statement.Justification?.ToString().ToLowerInvariant(),
Timestamp: statement.FirstObserved.UtcDateTime.ToString("O", CultureInfo.InvariantCulture),
LastUpdated: statement.LastObserved.UtcDateTime.ToString("O", CultureInfo.InvariantCulture),
Products: products,
Statement: statement.Detail,
Sources: sources);
}
private static string NormalizeProductKey(string key)
{
if (string.IsNullOrWhiteSpace(key))
{
return "unknown";
}
var builder = new StringBuilder(key.Length);
foreach (var ch in key)
{
builder.Append(char.IsLetterOrDigit(ch) ? char.ToLowerInvariant(ch) : '-');
}
var normalized = builder.ToString().Trim('-');
return string.IsNullOrEmpty(normalized) ? "unknown" : normalized;
}
private static VexContentAddress ComputeDigest(string json)
{
var bytes = Encoding.UTF8.GetBytes(json);
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
SHA256.HashData(bytes, hash);
var digest = Convert.ToHexString(hash).ToLowerInvariant();
return new VexContentAddress("sha256", digest);
}
}
internal sealed record OpenVexExportDocument(
OpenVexDocumentSection Document,
ImmutableArray<OpenVexExportStatement> Statements,
OpenVexExportMetadata Metadata);
internal sealed record OpenVexDocumentSection(
[property: JsonPropertyName("@context")] string Context = "https://openvex.dev/ns/v0.2",
[property: JsonPropertyName("id")] string Id = "",
[property: JsonPropertyName("author")] string Author = "",
[property: JsonPropertyName("version")] string Version = "1",
[property: JsonPropertyName("created")] string Created = "",
[property: JsonPropertyName("last_updated")] string LastUpdated = "",
[property: JsonPropertyName("profile")] string Profile = "");
internal sealed record OpenVexExportStatement(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("vulnerability")] string Vulnerability,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("justification"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Justification,
[property: JsonPropertyName("timestamp")] string Timestamp,
[property: JsonPropertyName("last_updated")] string LastUpdated,
[property: JsonPropertyName("products")] ImmutableArray<OpenVexExportProduct> Products,
[property: JsonPropertyName("statement"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Statement,
[property: JsonPropertyName("sources")] ImmutableArray<OpenVexExportSource> Sources);
internal sealed record OpenVexExportProduct(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("version"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Version,
[property: JsonPropertyName("purl"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Purl,
[property: JsonPropertyName("cpe"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Cpe);
internal sealed record OpenVexExportSource(
[property: JsonPropertyName("provider")] string Provider,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("justification"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Justification,
[property: JsonPropertyName("document_digest")] string DocumentDigest,
[property: JsonPropertyName("source_uri")] string SourceUri,
[property: JsonPropertyName("detail"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Detail,
[property: JsonPropertyName("first_observed")] string FirstObserved,
[property: JsonPropertyName("last_observed")] string LastObserved);
internal sealed record OpenVexExportMetadata(
[property: JsonPropertyName("generated_at")] string GeneratedAt,
[property: JsonPropertyName("query_signature")] string QuerySignature,
[property: JsonPropertyName("source_providers")] ImmutableArray<string> SourceProviders,
[property: JsonPropertyName("diagnostics")] ImmutableDictionary<string, string> Diagnostics);

View File

@@ -1,282 +1,282 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Formats.OpenVEX;
/// <summary>
/// Provides deterministic merging utilities for OpenVEX statements derived from normalized VEX claims.
/// </summary>
public static class OpenVexStatementMerger
{
private static readonly ImmutableDictionary<VexClaimStatus, int> StatusRiskPrecedence = new Dictionary<VexClaimStatus, int>
{
[VexClaimStatus.Affected] = 3,
[VexClaimStatus.UnderInvestigation] = 2,
[VexClaimStatus.Fixed] = 1,
[VexClaimStatus.NotAffected] = 0,
}.ToImmutableDictionary();
public static OpenVexMergeResult Merge(IEnumerable<VexClaim> claims)
{
ArgumentNullException.ThrowIfNull(claims);
var statements = new List<OpenVexMergedStatement>();
var diagnostics = new Dictionary<string, SortedSet<string>>(StringComparer.Ordinal);
foreach (var group in claims
.Where(static claim => claim is not null)
.GroupBy(static claim => (claim.VulnerabilityId, claim.Product.Key)))
{
var orderedClaims = group
.OrderBy(static claim => claim.ProviderId, StringComparer.Ordinal)
.ThenBy(static claim => claim.Document.Digest, StringComparer.Ordinal)
.ToImmutableArray();
if (orderedClaims.IsDefaultOrEmpty)
{
continue;
}
var mergedProduct = MergeProduct(orderedClaims);
var sources = BuildSources(orderedClaims);
var firstSeen = orderedClaims.Min(static claim => claim.FirstSeen);
var lastSeen = orderedClaims.Max(static claim => claim.LastSeen);
var statusSet = orderedClaims
.Select(static claim => claim.Status)
.Distinct()
.ToArray();
if (statusSet.Length > 1)
{
AddDiagnostic(
diagnostics,
"openvex.status_conflict",
FormattableString.Invariant($"{group.Key.VulnerabilityId}:{group.Key.Key}={string.Join('|', statusSet.Select(static status => status.ToString().ToLowerInvariant()))}"));
}
var canonicalStatus = SelectCanonicalStatus(statusSet);
var justification = SelectJustification(canonicalStatus, orderedClaims, diagnostics, group.Key);
if (canonicalStatus == VexClaimStatus.NotAffected && justification is null)
{
AddDiagnostic(
diagnostics,
"policy.justification_missing",
FormattableString.Invariant($"{group.Key.VulnerabilityId}:{group.Key.Key}"));
}
var detail = BuildDetail(orderedClaims);
statements.Add(new OpenVexMergedStatement(
group.Key.VulnerabilityId,
mergedProduct,
canonicalStatus,
justification,
detail,
sources,
firstSeen,
lastSeen));
}
var orderedStatements = statements
.OrderBy(static statement => statement.VulnerabilityId, StringComparer.Ordinal)
.ThenBy(static statement => statement.Product.Key, StringComparer.Ordinal)
.ToImmutableArray();
var orderedDiagnostics = diagnostics.Count == 0
? ImmutableDictionary<string, string>.Empty
: diagnostics.ToImmutableDictionary(
static pair => pair.Key,
pair => string.Join(",", pair.Value.OrderBy(static entry => entry, StringComparer.Ordinal)),
StringComparer.Ordinal);
return new OpenVexMergeResult(orderedStatements, orderedDiagnostics);
}
private static VexClaimStatus SelectCanonicalStatus(IReadOnlyCollection<VexClaimStatus> statuses)
{
if (statuses.Count == 0)
{
return VexClaimStatus.UnderInvestigation;
}
return statuses
.OrderByDescending(static status => StatusRiskPrecedence.GetValueOrDefault(status, -1))
.ThenBy(static status => status.ToString(), StringComparer.Ordinal)
.First();
}
private static VexJustification? SelectJustification(
VexClaimStatus canonicalStatus,
ImmutableArray<VexClaim> claims,
IDictionary<string, SortedSet<string>> diagnostics,
(string Vulnerability, string ProductKey) groupKey)
{
var relevantClaims = claims
.Where(claim => claim.Status == canonicalStatus)
.ToArray();
if (relevantClaims.Length == 0)
{
relevantClaims = claims.ToArray();
}
var justifications = relevantClaims
.Select(static claim => claim.Justification)
.Where(static justification => justification is not null)
.Cast<VexJustification>()
.Distinct()
.ToArray();
if (justifications.Length == 0)
{
return null;
}
if (justifications.Length > 1)
{
AddDiagnostic(
diagnostics,
"openvex.justification_conflict",
FormattableString.Invariant($"{groupKey.Vulnerability}:{groupKey.ProductKey}={string.Join('|', justifications.Select(static justification => justification.ToString().ToLowerInvariant()))}"));
}
return justifications
.OrderBy(static justification => justification.ToString(), StringComparer.Ordinal)
.First();
}
private static string? BuildDetail(ImmutableArray<VexClaim> claims)
{
var details = claims
.Select(static claim => claim.Detail)
.Where(static detail => !string.IsNullOrWhiteSpace(detail))
.Select(static detail => detail!.Trim())
.Distinct(StringComparer.Ordinal)
.ToArray();
if (details.Length == 0)
{
return null;
}
return string.Join("; ", details.OrderBy(static detail => detail, StringComparer.Ordinal));
}
private static ImmutableArray<OpenVexSourceEntry> BuildSources(ImmutableArray<VexClaim> claims)
{
var builder = ImmutableArray.CreateBuilder<OpenVexSourceEntry>(claims.Length);
foreach (var claim in claims)
{
builder.Add(new OpenVexSourceEntry(
claim.ProviderId,
claim.Status,
claim.Justification,
claim.Document.Digest,
claim.Document.SourceUri,
claim.Detail,
claim.FirstSeen,
claim.LastSeen));
}
return builder
.ToImmutable()
.OrderBy(static source => source.ProviderId, StringComparer.Ordinal)
.ThenBy(static source => source.DocumentDigest, StringComparer.Ordinal)
.ToImmutableArray();
}
private static VexProduct MergeProduct(ImmutableArray<VexClaim> claims)
{
var key = claims[0].Product.Key;
var names = claims
.Select(static claim => claim.Product.Name)
.Where(static name => !string.IsNullOrWhiteSpace(name))
.Select(static name => name!)
.Distinct(StringComparer.Ordinal)
.ToArray();
var versions = claims
.Select(static claim => claim.Product.Version)
.Where(static version => !string.IsNullOrWhiteSpace(version))
.Select(static version => version!)
.Distinct(StringComparer.Ordinal)
.ToArray();
var purls = claims
.Select(static claim => claim.Product.Purl)
.Where(static purl => !string.IsNullOrWhiteSpace(purl))
.Select(static purl => purl!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var cpes = claims
.Select(static claim => claim.Product.Cpe)
.Where(static cpe => !string.IsNullOrWhiteSpace(cpe))
.Select(static cpe => cpe!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var identifiers = claims
.SelectMany(static claim => claim.Product.ComponentIdentifiers)
.Where(static identifier => !string.IsNullOrWhiteSpace(identifier))
.Select(static identifier => identifier!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static identifier => identifier, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
return new VexProduct(
key,
names.Length == 0 ? claims[0].Product.Name : names.OrderByDescending(static name => name.Length).ThenBy(static name => name, StringComparer.Ordinal).First(),
versions.Length == 0 ? claims[0].Product.Version : versions.OrderByDescending(static version => version.Length).ThenBy(static version => version, StringComparer.Ordinal).First(),
purls.Length == 0 ? claims[0].Product.Purl : purls.OrderBy(static purl => purl, StringComparer.OrdinalIgnoreCase).First(),
cpes.Length == 0 ? claims[0].Product.Cpe : cpes.OrderBy(static cpe => cpe, StringComparer.OrdinalIgnoreCase).First(),
identifiers);
}
private static void AddDiagnostic(
IDictionary<string, SortedSet<string>> diagnostics,
string code,
string value)
{
if (!diagnostics.TryGetValue(code, out var entries))
{
entries = new SortedSet<string>(StringComparer.Ordinal);
diagnostics[code] = entries;
}
entries.Add(value);
}
}
public sealed record OpenVexMergeResult(
ImmutableArray<OpenVexMergedStatement> Statements,
ImmutableDictionary<string, string> Diagnostics);
public sealed record OpenVexMergedStatement(
string VulnerabilityId,
VexProduct Product,
VexClaimStatus Status,
VexJustification? Justification,
string? Detail,
ImmutableArray<OpenVexSourceEntry> Sources,
DateTimeOffset FirstObserved,
DateTimeOffset LastObserved);
public sealed record OpenVexSourceEntry(
string ProviderId,
VexClaimStatus Status,
VexJustification? Justification,
string DocumentDigest,
Uri DocumentSource,
string? Detail,
DateTimeOffset FirstSeen,
DateTimeOffset LastSeen)
{
public string DocumentDigest { get; } = string.IsNullOrWhiteSpace(DocumentDigest)
? throw new ArgumentException("Document digest must be provided.", nameof(DocumentDigest))
: DocumentDigest.Trim();
}
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Formats.OpenVEX;
/// <summary>
/// Provides deterministic merging utilities for OpenVEX statements derived from normalized VEX claims.
/// </summary>
public static class OpenVexStatementMerger
{
private static readonly ImmutableDictionary<VexClaimStatus, int> StatusRiskPrecedence = new Dictionary<VexClaimStatus, int>
{
[VexClaimStatus.Affected] = 3,
[VexClaimStatus.UnderInvestigation] = 2,
[VexClaimStatus.Fixed] = 1,
[VexClaimStatus.NotAffected] = 0,
}.ToImmutableDictionary();
public static OpenVexMergeResult Merge(IEnumerable<VexClaim> claims)
{
ArgumentNullException.ThrowIfNull(claims);
var statements = new List<OpenVexMergedStatement>();
var diagnostics = new Dictionary<string, SortedSet<string>>(StringComparer.Ordinal);
foreach (var group in claims
.Where(static claim => claim is not null)
.GroupBy(static claim => (claim.VulnerabilityId, claim.Product.Key)))
{
var orderedClaims = group
.OrderBy(static claim => claim.ProviderId, StringComparer.Ordinal)
.ThenBy(static claim => claim.Document.Digest, StringComparer.Ordinal)
.ToImmutableArray();
if (orderedClaims.IsDefaultOrEmpty)
{
continue;
}
var mergedProduct = MergeProduct(orderedClaims);
var sources = BuildSources(orderedClaims);
var firstSeen = orderedClaims.Min(static claim => claim.FirstSeen);
var lastSeen = orderedClaims.Max(static claim => claim.LastSeen);
var statusSet = orderedClaims
.Select(static claim => claim.Status)
.Distinct()
.ToArray();
if (statusSet.Length > 1)
{
AddDiagnostic(
diagnostics,
"openvex.status_conflict",
FormattableString.Invariant($"{group.Key.VulnerabilityId}:{group.Key.Key}={string.Join('|', statusSet.Select(static status => status.ToString().ToLowerInvariant()))}"));
}
var canonicalStatus = SelectCanonicalStatus(statusSet);
var justification = SelectJustification(canonicalStatus, orderedClaims, diagnostics, group.Key);
if (canonicalStatus == VexClaimStatus.NotAffected && justification is null)
{
AddDiagnostic(
diagnostics,
"policy.justification_missing",
FormattableString.Invariant($"{group.Key.VulnerabilityId}:{group.Key.Key}"));
}
var detail = BuildDetail(orderedClaims);
statements.Add(new OpenVexMergedStatement(
group.Key.VulnerabilityId,
mergedProduct,
canonicalStatus,
justification,
detail,
sources,
firstSeen,
lastSeen));
}
var orderedStatements = statements
.OrderBy(static statement => statement.VulnerabilityId, StringComparer.Ordinal)
.ThenBy(static statement => statement.Product.Key, StringComparer.Ordinal)
.ToImmutableArray();
var orderedDiagnostics = diagnostics.Count == 0
? ImmutableDictionary<string, string>.Empty
: diagnostics.ToImmutableDictionary(
static pair => pair.Key,
pair => string.Join(",", pair.Value.OrderBy(static entry => entry, StringComparer.Ordinal)),
StringComparer.Ordinal);
return new OpenVexMergeResult(orderedStatements, orderedDiagnostics);
}
private static VexClaimStatus SelectCanonicalStatus(IReadOnlyCollection<VexClaimStatus> statuses)
{
if (statuses.Count == 0)
{
return VexClaimStatus.UnderInvestigation;
}
return statuses
.OrderByDescending(static status => StatusRiskPrecedence.GetValueOrDefault(status, -1))
.ThenBy(static status => status.ToString(), StringComparer.Ordinal)
.First();
}
private static VexJustification? SelectJustification(
VexClaimStatus canonicalStatus,
ImmutableArray<VexClaim> claims,
IDictionary<string, SortedSet<string>> diagnostics,
(string Vulnerability, string ProductKey) groupKey)
{
var relevantClaims = claims
.Where(claim => claim.Status == canonicalStatus)
.ToArray();
if (relevantClaims.Length == 0)
{
relevantClaims = claims.ToArray();
}
var justifications = relevantClaims
.Select(static claim => claim.Justification)
.Where(static justification => justification is not null)
.Cast<VexJustification>()
.Distinct()
.ToArray();
if (justifications.Length == 0)
{
return null;
}
if (justifications.Length > 1)
{
AddDiagnostic(
diagnostics,
"openvex.justification_conflict",
FormattableString.Invariant($"{groupKey.Vulnerability}:{groupKey.ProductKey}={string.Join('|', justifications.Select(static justification => justification.ToString().ToLowerInvariant()))}"));
}
return justifications
.OrderBy(static justification => justification.ToString(), StringComparer.Ordinal)
.First();
}
private static string? BuildDetail(ImmutableArray<VexClaim> claims)
{
var details = claims
.Select(static claim => claim.Detail)
.Where(static detail => !string.IsNullOrWhiteSpace(detail))
.Select(static detail => detail!.Trim())
.Distinct(StringComparer.Ordinal)
.ToArray();
if (details.Length == 0)
{
return null;
}
return string.Join("; ", details.OrderBy(static detail => detail, StringComparer.Ordinal));
}
private static ImmutableArray<OpenVexSourceEntry> BuildSources(ImmutableArray<VexClaim> claims)
{
var builder = ImmutableArray.CreateBuilder<OpenVexSourceEntry>(claims.Length);
foreach (var claim in claims)
{
builder.Add(new OpenVexSourceEntry(
claim.ProviderId,
claim.Status,
claim.Justification,
claim.Document.Digest,
claim.Document.SourceUri,
claim.Detail,
claim.FirstSeen,
claim.LastSeen));
}
return builder
.ToImmutable()
.OrderBy(static source => source.ProviderId, StringComparer.Ordinal)
.ThenBy(static source => source.DocumentDigest, StringComparer.Ordinal)
.ToImmutableArray();
}
private static VexProduct MergeProduct(ImmutableArray<VexClaim> claims)
{
var key = claims[0].Product.Key;
var names = claims
.Select(static claim => claim.Product.Name)
.Where(static name => !string.IsNullOrWhiteSpace(name))
.Select(static name => name!)
.Distinct(StringComparer.Ordinal)
.ToArray();
var versions = claims
.Select(static claim => claim.Product.Version)
.Where(static version => !string.IsNullOrWhiteSpace(version))
.Select(static version => version!)
.Distinct(StringComparer.Ordinal)
.ToArray();
var purls = claims
.Select(static claim => claim.Product.Purl)
.Where(static purl => !string.IsNullOrWhiteSpace(purl))
.Select(static purl => purl!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var cpes = claims
.Select(static claim => claim.Product.Cpe)
.Where(static cpe => !string.IsNullOrWhiteSpace(cpe))
.Select(static cpe => cpe!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var identifiers = claims
.SelectMany(static claim => claim.Product.ComponentIdentifiers)
.Where(static identifier => !string.IsNullOrWhiteSpace(identifier))
.Select(static identifier => identifier!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static identifier => identifier, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
return new VexProduct(
key,
names.Length == 0 ? claims[0].Product.Name : names.OrderByDescending(static name => name.Length).ThenBy(static name => name, StringComparer.Ordinal).First(),
versions.Length == 0 ? claims[0].Product.Version : versions.OrderByDescending(static version => version.Length).ThenBy(static version => version, StringComparer.Ordinal).First(),
purls.Length == 0 ? claims[0].Product.Purl : purls.OrderBy(static purl => purl, StringComparer.OrdinalIgnoreCase).First(),
cpes.Length == 0 ? claims[0].Product.Cpe : cpes.OrderBy(static cpe => cpe, StringComparer.OrdinalIgnoreCase).First(),
identifiers);
}
private static void AddDiagnostic(
IDictionary<string, SortedSet<string>> diagnostics,
string code,
string value)
{
if (!diagnostics.TryGetValue(code, out var entries))
{
entries = new SortedSet<string>(StringComparer.Ordinal);
diagnostics[code] = entries;
}
entries.Add(value);
}
}
public sealed record OpenVexMergeResult(
ImmutableArray<OpenVexMergedStatement> Statements,
ImmutableDictionary<string, string> Diagnostics);
public sealed record OpenVexMergedStatement(
string VulnerabilityId,
VexProduct Product,
VexClaimStatus Status,
VexJustification? Justification,
string? Detail,
ImmutableArray<OpenVexSourceEntry> Sources,
DateTimeOffset FirstObserved,
DateTimeOffset LastObserved);
public sealed record OpenVexSourceEntry(
string ProviderId,
VexClaimStatus Status,
VexJustification? Justification,
string DocumentDigest,
Uri DocumentSource,
string? Detail,
DateTimeOffset FirstSeen,
DateTimeOffset LastSeen)
{
public string DocumentDigest { get; } = string.IsNullOrWhiteSpace(DocumentDigest)
? throw new ArgumentException("Document digest must be provided.", nameof(DocumentDigest))
: DocumentDigest.Trim();
}

View File

@@ -1,87 +1,87 @@
using System;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Excititor.Policy;
public interface IVexPolicyDiagnostics
{
VexPolicyDiagnosticsReport GetDiagnostics();
}
public sealed record VexPolicyDiagnosticsReport(
string Version,
string RevisionId,
string Digest,
int ErrorCount,
int WarningCount,
DateTimeOffset GeneratedAt,
ImmutableArray<VexPolicyIssue> Issues,
ImmutableArray<string> Recommendations,
ImmutableDictionary<string, double> ActiveOverrides);
public sealed class VexPolicyDiagnostics : IVexPolicyDiagnostics
{
private readonly IVexPolicyProvider _policyProvider;
private readonly TimeProvider _timeProvider;
public VexPolicyDiagnostics(
IVexPolicyProvider policyProvider,
TimeProvider? timeProvider = null)
{
_policyProvider = policyProvider ?? throw new ArgumentNullException(nameof(policyProvider));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public VexPolicyDiagnosticsReport GetDiagnostics()
{
var snapshot = _policyProvider.GetSnapshot();
var issues = snapshot.Issues;
var errorCount = issues.Count(static issue => issue.Severity == VexPolicyIssueSeverity.Error);
var warningCount = issues.Count(static issue => issue.Severity == VexPolicyIssueSeverity.Warning);
var overrides = snapshot.ConsensusOptions.ProviderOverrides
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.ToImmutableDictionary();
var recommendations = BuildRecommendations(errorCount, warningCount, overrides);
return new VexPolicyDiagnosticsReport(
snapshot.Version,
snapshot.RevisionId,
snapshot.Digest,
errorCount,
warningCount,
_timeProvider.GetUtcNow(),
issues,
recommendations,
overrides);
}
private static ImmutableArray<string> BuildRecommendations(
int errorCount,
int warningCount,
ImmutableDictionary<string, double> overrides)
{
var messages = ImmutableArray.CreateBuilder<string>();
if (errorCount > 0)
{
messages.Add("Resolve policy errors before running consensus; defaults are used while errors persist.");
}
if (warningCount > 0)
{
messages.Add("Review policy warnings via CLI/Web diagnostics and adjust configuration as needed.");
}
if (overrides.Count > 0)
{
messages.Add($"Provider overrides active for: {string.Join(", ", overrides.Keys)}.");
}
messages.Add("Refer to docs/modules/excititor/architecture.md for policy upgrade and diagnostics guidance.");
return messages.ToImmutable();
}
}
using System;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Excititor.Policy;
public interface IVexPolicyDiagnostics
{
VexPolicyDiagnosticsReport GetDiagnostics();
}
public sealed record VexPolicyDiagnosticsReport(
string Version,
string RevisionId,
string Digest,
int ErrorCount,
int WarningCount,
DateTimeOffset GeneratedAt,
ImmutableArray<VexPolicyIssue> Issues,
ImmutableArray<string> Recommendations,
ImmutableDictionary<string, double> ActiveOverrides);
public sealed class VexPolicyDiagnostics : IVexPolicyDiagnostics
{
private readonly IVexPolicyProvider _policyProvider;
private readonly TimeProvider _timeProvider;
public VexPolicyDiagnostics(
IVexPolicyProvider policyProvider,
TimeProvider? timeProvider = null)
{
_policyProvider = policyProvider ?? throw new ArgumentNullException(nameof(policyProvider));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public VexPolicyDiagnosticsReport GetDiagnostics()
{
var snapshot = _policyProvider.GetSnapshot();
var issues = snapshot.Issues;
var errorCount = issues.Count(static issue => issue.Severity == VexPolicyIssueSeverity.Error);
var warningCount = issues.Count(static issue => issue.Severity == VexPolicyIssueSeverity.Warning);
var overrides = snapshot.ConsensusOptions.ProviderOverrides
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.ToImmutableDictionary();
var recommendations = BuildRecommendations(errorCount, warningCount, overrides);
return new VexPolicyDiagnosticsReport(
snapshot.Version,
snapshot.RevisionId,
snapshot.Digest,
errorCount,
warningCount,
_timeProvider.GetUtcNow(),
issues,
recommendations,
overrides);
}
private static ImmutableArray<string> BuildRecommendations(
int errorCount,
int warningCount,
ImmutableDictionary<string, double> overrides)
{
var messages = ImmutableArray.CreateBuilder<string>();
if (errorCount > 0)
{
messages.Add("Resolve policy errors before running consensus; defaults are used while errors persist.");
}
if (warningCount > 0)
{
messages.Add("Review policy warnings via CLI/Web diagnostics and adjust configuration as needed.");
}
if (overrides.Count > 0)
{
messages.Add($"Provider overrides active for: {string.Join(", ", overrides.Keys)}.");
}
messages.Add("Refer to docs/modules/excititor/architecture.md for policy upgrade and diagnostics guidance.");
return messages.ToImmutable();
}
}

View File

@@ -1,19 +1,19 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<Compile Remove="..\..\..\StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" />
<Compile Remove="..\..\..\StellaOps.Concelier.Tests.Shared\MongoFixtureCollection.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Attestation/StellaOps.Excititor.Attestation.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
</ItemGroup>
</Project>
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<Compile Remove="..\..\..\StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" />
<Compile Remove="..\..\..\StellaOps.Concelier.Tests.Shared\MongoFixtureCollection.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Attestation/StellaOps.Excititor.Attestation.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,54 +1,57 @@
using System.Collections.Immutable;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Cryptography;
using StellaOps.Excititor.Attestation.Dsse;
using StellaOps.Excititor.Attestation.Signing;
using StellaOps.Excititor.Attestation.Transparency;
using StellaOps.Excititor.Attestation.Verification;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Attestation.Tests;
public sealed class VexAttestationVerifierTests : IDisposable
{
private readonly VexAttestationMetrics _metrics = new();
[Fact]
public async Task VerifyAsync_ReturnsValid_WhenEnvelopeMatches()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync();
var verifier = CreateVerifier(options => options.RequireTransparencyLog = false);
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, metadata, envelope),
CancellationToken.None);
Assert.True(verification.IsValid);
Assert.Equal("valid", verification.Diagnostics["result"]);
}
[Fact]
public async Task VerifyAsync_ReturnsInvalid_WhenDigestMismatch()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync();
var verifier = CreateVerifier(options => options.RequireTransparencyLog = false);
var tamperedMetadata = new VexAttestationMetadata(
metadata.PredicateType,
metadata.Rekor,
"sha256:deadbeef",
metadata.SignedAt);
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, tamperedMetadata, envelope),
CancellationToken.None);
Assert.False(verification.IsValid);
Assert.Equal("invalid", verification.Diagnostics["result"]);
Assert.Equal("sha256:deadbeef", verification.Diagnostics["metadata.envelopeDigest"]);
}
[Fact]
namespace StellaOps.Excititor.Attestation.Tests;
public sealed class VexAttestationVerifierTests : IDisposable
{
private readonly VexAttestationMetrics _metrics = new();
[Fact]
public async Task VerifyAsync_ReturnsValid_WhenEnvelopeMatches()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync();
var verifier = CreateVerifier(options => options.RequireTransparencyLog = false);
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, metadata, envelope),
CancellationToken.None);
Assert.True(verification.IsValid);
Assert.Equal("valid", verification.Diagnostics["result"]);
}
[Fact]
public async Task VerifyAsync_ReturnsInvalid_WhenDigestMismatch()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync();
var verifier = CreateVerifier(options => options.RequireTransparencyLog = false);
var tamperedMetadata = new VexAttestationMetadata(
metadata.PredicateType,
metadata.Rekor,
"sha256:deadbeef",
metadata.SignedAt);
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, tamperedMetadata, envelope),
CancellationToken.None);
Assert.False(verification.IsValid);
Assert.Equal("invalid", verification.Diagnostics["result"]);
Assert.Equal("sha256:deadbeef", verification.Diagnostics["metadata.envelopeDigest"]);
}
[Fact]
public async Task VerifyAsync_AllowsOfflineTransparency_WhenConfigured()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync(includeRekor: true);
@@ -65,48 +68,122 @@ public sealed class VexAttestationVerifierTests : IDisposable
Assert.True(verification.IsValid);
Assert.Equal("offline", verification.Diagnostics["rekor.state"]);
Assert.Equal("degraded", verification.Diagnostics["result"]);
}
[Fact]
public async Task VerifyAsync_ReturnsInvalid_WhenTransparencyRequiredAndMissing()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync(includeRekor: false);
var verifier = CreateVerifier(options =>
{
options.RequireTransparencyLog = true;
options.AllowOfflineTransparency = false;
});
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, metadata, envelope),
CancellationToken.None);
Assert.False(verification.IsValid);
Assert.Equal("missing", verification.Diagnostics["rekor.state"]);
Assert.Equal("invalid", verification.Diagnostics["result"]);
}
[Fact]
public async Task VerifyAsync_ReturnsInvalid_WhenTransparencyUnavailableAndOfflineDisallowed()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync(includeRekor: true);
var transparency = new ThrowingTransparencyLogClient();
var verifier = CreateVerifier(options =>
{
options.RequireTransparencyLog = true;
options.AllowOfflineTransparency = false;
}, transparency);
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, metadata, envelope),
CancellationToken.None);
Assert.False(verification.IsValid);
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync(includeRekor: false);
var verifier = CreateVerifier(options =>
{
options.RequireTransparencyLog = true;
options.AllowOfflineTransparency = false;
});
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, metadata, envelope),
CancellationToken.None);
Assert.False(verification.IsValid);
Assert.Equal("missing", verification.Diagnostics["rekor.state"]);
Assert.Equal("invalid", verification.Diagnostics["result"]);
}
[Fact]
public async Task VerifyAsync_ReturnsInvalid_WhenTransparencyUnavailableAndOfflineDisallowed()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync(includeRekor: true);
var transparency = new ThrowingTransparencyLogClient();
var verifier = CreateVerifier(options =>
{
options.RequireTransparencyLog = true;
options.AllowOfflineTransparency = false;
}, transparency);
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, metadata, envelope),
CancellationToken.None);
Assert.False(verification.IsValid);
Assert.Equal("unreachable", verification.Diagnostics["rekor.state"]);
Assert.Equal("invalid", verification.Diagnostics["result"]);
}
private async Task<(VexAttestationRequest Request, VexAttestationMetadata Metadata, string Envelope)> CreateSignedAttestationAsync(bool includeRekor = false)
[Fact]
public async Task VerifyAsync_HandlesDuplicateSourceProviders()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync(
includeRekor: false,
sourceProviders: ImmutableArray.Create("provider-a", "provider-a"));
var normalizedRequest = request with { SourceProviders = ImmutableArray.Create("provider-a") };
var verifier = CreateVerifier(options => options.RequireTransparencyLog = false);
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(normalizedRequest, metadata, envelope),
CancellationToken.None);
Assert.True(verification.IsValid);
Assert.Equal("valid", verification.Diagnostics["result"]);
}
[Fact]
public async Task VerifyAsync_ReturnsValid_WhenTrustedSignerConfigured()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync(includeRekor: false);
var registry = new StubCryptoProviderRegistry(success: true);
var verifier = CreateVerifier(options =>
{
options.RequireTransparencyLog = false;
options.RequireSignatureVerification = true;
options.TrustedSigners = ImmutableDictionary<string, VexAttestationVerificationOptions.TrustedSignerOptions>.Empty.Add(
"key",
new VexAttestationVerificationOptions.TrustedSignerOptions
{
Algorithm = StubCryptoProviderRegistry.Algorithm,
KeyReference = StubCryptoProviderRegistry.KeyReference
});
}, transparency: null, registry: registry);
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, metadata, envelope),
CancellationToken.None);
Assert.True(verification.IsValid);
Assert.Equal("verified", verification.Diagnostics["signature.state"]);
}
[Fact]
public async Task VerifyAsync_ReturnsInvalid_WhenSignatureFailsAndRequired()
{
var (request, metadata, envelope) = await CreateSignedAttestationAsync(includeRekor: false);
var registry = new StubCryptoProviderRegistry(success: false);
var verifier = CreateVerifier(options =>
{
options.RequireTransparencyLog = false;
options.RequireSignatureVerification = true;
options.TrustedSigners = ImmutableDictionary<string, VexAttestationVerificationOptions.TrustedSignerOptions>.Empty.Add(
"key",
new VexAttestationVerificationOptions.TrustedSignerOptions
{
Algorithm = StubCryptoProviderRegistry.Algorithm,
KeyReference = StubCryptoProviderRegistry.KeyReference
});
}, transparency: null, registry: registry);
var verification = await verifier.VerifyAsync(
new VexAttestationVerificationRequest(request, metadata, envelope),
CancellationToken.None);
Assert.False(verification.IsValid);
Assert.Equal("error", verification.Diagnostics["signature.state"]);
Assert.Equal("verification_failed", verification.Diagnostics["signature.reason"]);
}
private async Task<(VexAttestationRequest Request, VexAttestationMetadata Metadata, string Envelope)> CreateSignedAttestationAsync(
bool includeRekor = false,
ImmutableArray<string>? sourceProviders = null)
{
var signer = new FakeSigner();
var builder = new VexDsseBuilder(signer, NullLogger<VexDsseBuilder>.Instance);
@@ -115,13 +192,14 @@ public sealed class VexAttestationVerifierTests : IDisposable
var verifier = CreateVerifier(options => options.RequireTransparencyLog = false);
var client = new VexAttestationClient(builder, options, NullLogger<VexAttestationClient>.Instance, verifier, transparency);
var providers = sourceProviders ?? ImmutableArray.Create("provider-a");
var request = new VexAttestationRequest(
ExportId: "exports/unit-test",
QuerySignature: new VexQuerySignature("filters"),
Artifact: new VexContentAddress("sha256", "cafebabe"),
Format: VexExportFormat.Json,
CreatedAt: DateTimeOffset.UtcNow,
SourceProviders: ImmutableArray.Create("provider-a"),
SourceProviders: providers,
Metadata: ImmutableDictionary<string, string>.Empty);
var response = await client.SignAsync(request, CancellationToken.None);
@@ -129,7 +207,10 @@ public sealed class VexAttestationVerifierTests : IDisposable
return (request, response.Attestation, envelope);
}
private VexAttestationVerifier CreateVerifier(Action<VexAttestationVerificationOptions>? configureOptions = null, ITransparencyLogClient? transparency = null)
private VexAttestationVerifier CreateVerifier(
Action<VexAttestationVerificationOptions>? configureOptions = null,
ITransparencyLogClient? transparency = null,
ICryptoProviderRegistry? registry = null)
{
var options = new VexAttestationVerificationOptions();
configureOptions?.Invoke(options);
@@ -137,7 +218,8 @@ public sealed class VexAttestationVerifierTests : IDisposable
NullLogger<VexAttestationVerifier>.Instance,
transparency,
Options.Create(options),
_metrics);
_metrics,
registry);
}
public void Dispose()
@@ -147,25 +229,93 @@ public sealed class VexAttestationVerifierTests : IDisposable
private sealed class FakeSigner : IVexSigner
{
internal static readonly string SignatureBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("signature"));
public ValueTask<VexSignedPayload> SignAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexSignedPayload("signature", "key"));
=> ValueTask.FromResult(new VexSignedPayload(SignatureBase64, "key"));
}
private sealed class FakeTransparencyLogClient : ITransparencyLogClient
{
public ValueTask<TransparencyLogEntry> SubmitAsync(DsseEnvelope envelope, CancellationToken cancellationToken)
=> ValueTask.FromResult(new TransparencyLogEntry(Guid.NewGuid().ToString(), "https://rekor.example/entries/123", "42", null));
public ValueTask<bool> VerifyAsync(string entryLocation, CancellationToken cancellationToken)
=> ValueTask.FromResult(true);
}
private sealed class ThrowingTransparencyLogClient : ITransparencyLogClient
{
public ValueTask<TransparencyLogEntry> SubmitAsync(DsseEnvelope envelope, CancellationToken cancellationToken)
=> throw new NotSupportedException();
=> ValueTask.FromResult(new TransparencyLogEntry(Guid.NewGuid().ToString(), "https://rekor.example/entries/123", "42", null));
public ValueTask<bool> VerifyAsync(string entryLocation, CancellationToken cancellationToken)
=> ValueTask.FromResult(true);
}
private sealed class ThrowingTransparencyLogClient : ITransparencyLogClient
{
public ValueTask<TransparencyLogEntry> SubmitAsync(DsseEnvelope envelope, CancellationToken cancellationToken)
=> throw new NotSupportedException();
public ValueTask<bool> VerifyAsync(string entryLocation, CancellationToken cancellationToken)
=> throw new HttpRequestException("rekor unavailable");
}
private sealed class StubCryptoProviderRegistry : ICryptoProviderRegistry
{
public const string Algorithm = "ed25519";
public const string KeyReference = "stub-key";
private readonly StubCryptoSigner _signer;
private readonly IReadOnlyCollection<ICryptoProvider> _providers = Array.Empty<ICryptoProvider>();
public StubCryptoProviderRegistry(bool success)
{
_signer = new StubCryptoSigner("key", Algorithm, success);
}
public IReadOnlyCollection<ICryptoProvider> Providers => _providers;
public bool TryResolve(string preferredProvider, out ICryptoProvider provider)
{
provider = null!;
return false;
}
public ICryptoProvider ResolveOrThrow(CryptoCapability capability, string algorithmId)
=> throw new NotSupportedException();
public CryptoSignerResolution ResolveSigner(
CryptoCapability capability,
string algorithmId,
CryptoKeyReference keyReference,
string? preferredProvider = null)
{
if (!string.Equals(keyReference.KeyId, _signer.KeyId, StringComparison.Ordinal))
{
throw new InvalidOperationException($"Unknown key '{keyReference.KeyId}'.");
}
return new CryptoSignerResolution(_signer, "stub");
}
}
private sealed class StubCryptoSigner : ICryptoSigner
{
private readonly bool _success;
private readonly byte[] _expectedSignature;
public StubCryptoSigner(string keyId, string algorithmId, bool success)
{
KeyId = keyId;
AlgorithmId = algorithmId;
_success = success;
_expectedSignature = Convert.FromBase64String(FakeSigner.SignatureBase64);
}
public string KeyId { get; }
public string AlgorithmId { get; }
public ValueTask<byte[]> SignAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public ValueTask<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(_success && signature.Span.SequenceEqual(_expectedSignature.Span));
public JsonWebKey ExportPublicJsonWebKey()
=> new JsonWebKey();
}
}

View File

@@ -1,22 +1,22 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
</ItemGroup>
<ItemGroup>
<Compile Remove="..\..\..\StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" />
<Compile Remove="..\..\..\StellaOps.Concelier.Tests.Shared\MongoFixtureCollection.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Connectors.Cisco.CSAF/StellaOps.Excititor.Connectors.Cisco.CSAF.csproj" />
</ItemGroup>
</Project>
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
</ItemGroup>
<ItemGroup>
<Compile Remove="..\..\..\StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" />
<Compile Remove="..\..\..\StellaOps.Concelier.Tests.Shared\MongoFixtureCollection.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Connectors.Cisco.CSAF/StellaOps.Excititor.Connectors.Cisco.CSAF.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,429 +1,429 @@
using System.Collections.Immutable;
using System.Globalization;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Events;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Metadata;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.State;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using Xunit;
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Tests.Connectors;
public sealed class RancherHubConnectorTests
{
[Fact]
public async Task FetchAsync_OfflineSnapshot_StoresDocumentAndUpdatesCheckpoint()
{
using var fixture = await ConnectorFixture.CreateAsync();
var sink = new InMemoryRawSink();
var context = fixture.CreateContext(sink);
var documents = await CollectAsync(fixture.Connector.FetchAsync(context, CancellationToken.None));
documents.Should().HaveCount(1);
var document = documents[0];
document.Digest.Should().Be(fixture.ExpectedDocumentDigest);
document.Metadata.Should().ContainKey("rancher.event.id").WhoseValue.Should().Be("evt-1");
document.Metadata.Should().ContainKey("rancher.event.cursor").WhoseValue.Should().Be("cursor-2");
sink.Documents.Should().HaveCount(1);
var state = fixture.StateRepository.State;
state.Should().NotBeNull();
state!.LastUpdated.Should().Be(DateTimeOffset.Parse("2025-10-19T12:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal));
state.DocumentDigests.Should().Contain(fixture.ExpectedDocumentDigest);
state.DocumentDigests.Should().Contain("checkpoint:cursor-2");
state.DocumentDigests.Count.Should().BeLessOrEqualTo(ConnectorFixture.MaxDigestHistory + 1);
}
[Fact]
public async Task FetchAsync_WhenDocumentDownloadFails_QuarantinesEvent()
{
using var fixture = await ConnectorFixture.CreateAsync();
fixture.Handler.SetRoute(fixture.DocumentUri, () => new HttpResponseMessage(HttpStatusCode.InternalServerError));
var sink = new InMemoryRawSink();
var context = fixture.CreateContext(sink);
var documents = await CollectAsync(fixture.Connector.FetchAsync(context, CancellationToken.None));
documents.Should().BeEmpty();
sink.Documents.Should().HaveCount(1);
var quarantined = sink.Documents[0];
quarantined.Metadata.Should().Contain("rancher.event.quarantine", "true");
quarantined.Metadata.Should().ContainKey("rancher.event.error").WhoseValue.Should().Contain("document fetch failed");
var state = fixture.StateRepository.State;
state.Should().NotBeNull();
state!.DocumentDigests.Should().Contain(d => d.StartsWith("quarantine:", StringComparison.Ordinal));
}
[Fact]
public async Task FetchAsync_ReplayingSnapshot_SkipsDuplicateDocuments()
{
using var fixture = await ConnectorFixture.CreateAsync();
var firstSink = new InMemoryRawSink();
var firstContext = fixture.CreateContext(firstSink);
await CollectAsync(fixture.Connector.FetchAsync(firstContext, CancellationToken.None));
var secondSink = new InMemoryRawSink();
var secondContext = fixture.CreateContext(secondSink);
var secondRunDocuments = await CollectAsync(fixture.Connector.FetchAsync(secondContext, CancellationToken.None));
secondRunDocuments.Should().BeEmpty();
secondSink.Documents.Should().BeEmpty();
var state = fixture.StateRepository.State;
state.Should().NotBeNull();
state!.DocumentDigests.Should().Contain(fixture.ExpectedDocumentDigest);
}
[Fact]
public async Task FetchAsync_TrimsPersistedDigestHistory()
{
var existingDigests = Enumerable.Range(0, ConnectorFixture.MaxDigestHistory + 5)
.Select(i => $"sha256:{i:X32}")
.ToImmutableArray();
var initialState = new VexConnectorState(
"excititor:suse.rancher",
DateTimeOffset.Parse("2025-10-18T00:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal),
ImmutableArray.CreateBuilder<string>()
.Add("checkpoint:cursor-old")
.AddRange(existingDigests)
.ToImmutable());
using var fixture = await ConnectorFixture.CreateAsync(initialState);
var sink = new InMemoryRawSink();
var context = fixture.CreateContext(sink);
await CollectAsync(fixture.Connector.FetchAsync(context, CancellationToken.None));
var state = fixture.StateRepository.State;
state.Should().NotBeNull();
state!.DocumentDigests.Should().Contain(d => d.StartsWith("checkpoint:", StringComparison.Ordinal));
state.DocumentDigests.Count.Should().Be(ConnectorFixture.MaxDigestHistory + 1);
}
private static async Task<List<VexRawDocument>> CollectAsync(IAsyncEnumerable<VexRawDocument> source)
{
var list = new List<VexRawDocument>();
await foreach (var document in source.ConfigureAwait(false))
{
list.Add(document);
}
return list;
}
#region helpers
private sealed class ConnectorFixture : IDisposable
{
public const int MaxDigestHistory = 200;
private readonly IServiceProvider _serviceProvider;
private readonly TempDirectory _tempDirectory;
private readonly HttpClient _httpClient;
private ConnectorFixture(
RancherHubConnector connector,
InMemoryConnectorStateRepository stateRepository,
RoutingHttpMessageHandler handler,
IServiceProvider serviceProvider,
TempDirectory tempDirectory,
HttpClient httpClient,
Uri documentUri,
string documentDigest)
{
Connector = connector;
StateRepository = stateRepository;
Handler = handler;
_serviceProvider = serviceProvider;
_tempDirectory = tempDirectory;
_httpClient = httpClient;
DocumentUri = documentUri;
ExpectedDocumentDigest = $"sha256:{documentDigest}";
}
public RancherHubConnector Connector { get; }
public InMemoryConnectorStateRepository StateRepository { get; }
public RoutingHttpMessageHandler Handler { get; }
public Uri DocumentUri { get; }
public string ExpectedDocumentDigest { get; }
public VexConnectorContext CreateContext(InMemoryRawSink sink, DateTimeOffset? since = null)
=> new(
since,
VexConnectorSettings.Empty,
sink,
new NoopSignatureVerifier(),
new NoopNormalizerRouter(),
_serviceProvider,
ImmutableDictionary<string, string>.Empty);
public void Dispose()
{
_httpClient.Dispose();
_tempDirectory.Dispose();
}
public static async Task<ConnectorFixture> CreateAsync(VexConnectorState? initialState = null)
{
var tempDirectory = new TempDirectory();
var documentPayload = "{\"document\":\"payload\"}";
var documentDigest = ComputeSha256Hex(documentPayload);
var documentUri = new Uri("https://hub.test/events/evt-1.json");
var eventsPayload = """
{
"cursor": "cursor-1",
"nextCursor": "cursor-2",
"events": [
{
"id": "evt-1",
"type": "vex.statement.published",
"channel": "rancher/rke2",
"publishedAt": "2025-10-19T12:00:00Z",
"document": {
"uri": "https://hub.test/events/evt-1.json",
"sha256": "DOC_DIGEST",
"format": "csaf"
}
}
]
}
""".Replace("DOC_DIGEST", documentDigest, StringComparison.Ordinal);
var eventsPath = tempDirectory.Combine("events.json");
await File.WriteAllTextAsync(eventsPath, eventsPayload, Encoding.UTF8).ConfigureAwait(false);
var eventsChecksum = ComputeSha256Hex(eventsPayload);
var discoveryPayload = """
{
"hubId": "excititor:suse.rancher",
"title": "SUSE Rancher VEX Hub",
"subscription": {
"eventsUri": "https://hub.test/events",
"checkpointUri": "https://hub.test/checkpoint",
"channels": [ "rancher/rke2" ],
"requiresAuthentication": false
},
"offline": {
"snapshotUri": "EVENTS_URI",
"sha256": "EVENTS_DIGEST"
}
}
"""
.Replace("EVENTS_URI", new Uri(eventsPath).ToString(), StringComparison.Ordinal)
.Replace("EVENTS_DIGEST", eventsChecksum, StringComparison.Ordinal);
var discoveryPath = tempDirectory.Combine("discovery.json");
await File.WriteAllTextAsync(discoveryPath, discoveryPayload, Encoding.UTF8).ConfigureAwait(false);
var handler = new RoutingHttpMessageHandler();
handler.SetRoute(documentUri, () => JsonResponse(documentPayload));
var httpClient = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(10),
};
var httpFactory = new SingletonHttpClientFactory(httpClient);
var memoryCache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new System.IO.Abstractions.FileSystem();
var tokenProvider = new RancherHubTokenProvider(httpFactory, memoryCache, NullLogger<RancherHubTokenProvider>.Instance);
var metadataLoader = new RancherHubMetadataLoader(httpFactory, memoryCache, tokenProvider, fileSystem, NullLogger<RancherHubMetadataLoader>.Instance);
var eventClient = new RancherHubEventClient(httpFactory, tokenProvider, fileSystem, NullLogger<RancherHubEventClient>.Instance);
var stateRepository = new InMemoryConnectorStateRepository(initialState);
var checkpointManager = new RancherHubCheckpointManager(stateRepository);
var validators = new[] { new RancherHubConnectorOptionsValidator(fileSystem) };
var connector = new RancherHubConnector(
metadataLoader,
eventClient,
checkpointManager,
tokenProvider,
httpFactory,
NullLogger<RancherHubConnector>.Instance,
TimeProvider.System,
validators);
var settingsValues = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
settingsValues["DiscoveryUri"] = "https://hub.test/.well-known/rancher-hub.json";
settingsValues["OfflineSnapshotPath"] = discoveryPath;
settingsValues["PreferOfflineSnapshot"] = "true";
var settings = new VexConnectorSettings(settingsValues.ToImmutable());
await connector.ValidateAsync(settings, CancellationToken.None).ConfigureAwait(false);
var services = new ServiceCollection().BuildServiceProvider();
return new ConnectorFixture(
connector,
stateRepository,
handler,
services,
tempDirectory,
httpClient,
documentUri,
documentDigest);
}
private static HttpResponseMessage JsonResponse(string payload)
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
};
return response;
}
}
private sealed class SingletonHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingletonHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class RoutingHttpMessageHandler : HttpMessageHandler
{
private readonly Dictionary<Uri, Queue<Func<HttpResponseMessage>>> _routes = new();
public void SetRoute(Uri uri, params Func<HttpResponseMessage>[] responders)
{
ArgumentNullException.ThrowIfNull(uri);
if (responders is null || responders.Length == 0)
{
_routes.Remove(uri);
return;
}
_routes[uri] = new Queue<Func<HttpResponseMessage>>(responders);
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.RequestUri is not null &&
_routes.TryGetValue(request.RequestUri, out var queue) &&
queue.Count > 0)
{
var responder = queue.Count > 1 ? queue.Dequeue() : queue.Peek();
var response = responder();
response.RequestMessage = request;
return Task.FromResult(response);
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent($"No response configured for {request.RequestUri}", Encoding.UTF8, "text/plain"),
});
}
}
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
{
public InMemoryConnectorStateRepository(VexConnectorState? initialState = null)
{
State = initialState;
}
public VexConnectorState? State { get; private set; }
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
=> ValueTask.FromResult(State);
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
{
State = state;
return ValueTask.CompletedTask;
}
}
private sealed class InMemoryRawSink : IVexRawDocumentSink
{
public List<VexRawDocument> Documents { get; } = new();
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
{
Documents.Add(document);
return ValueTask.CompletedTask;
}
}
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
{
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
}
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
{
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
}
private sealed class TempDirectory : IDisposable
{
private readonly string _path;
public TempDirectory()
{
_path = Path.Combine(Path.GetTempPath(), "stellaops-excititor-tests", Guid.NewGuid().ToString("n"));
Directory.CreateDirectory(_path);
}
public string Combine(string relative) => Path.Combine(_path, relative);
public void Dispose()
{
try
{
if (Directory.Exists(_path))
{
Directory.Delete(_path, recursive: true);
}
}
catch
{
// Best-effort cleanup.
}
}
}
private static string ComputeSha256Hex(string payload)
{
var bytes = Encoding.UTF8.GetBytes(payload);
return ComputeSha256Hex(bytes);
}
private static string ComputeSha256Hex(ReadOnlySpan<byte> payload)
{
Span<byte> buffer = stackalloc byte[32];
SHA256.HashData(payload, buffer);
return Convert.ToHexString(buffer).ToLowerInvariant();
}
#endregion
}
using System.Collections.Immutable;
using System.Globalization;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Configuration;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Events;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Metadata;
using StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.State;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using Xunit;
namespace StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.Tests.Connectors;
public sealed class RancherHubConnectorTests
{
[Fact]
public async Task FetchAsync_OfflineSnapshot_StoresDocumentAndUpdatesCheckpoint()
{
using var fixture = await ConnectorFixture.CreateAsync();
var sink = new InMemoryRawSink();
var context = fixture.CreateContext(sink);
var documents = await CollectAsync(fixture.Connector.FetchAsync(context, CancellationToken.None));
documents.Should().HaveCount(1);
var document = documents[0];
document.Digest.Should().Be(fixture.ExpectedDocumentDigest);
document.Metadata.Should().ContainKey("rancher.event.id").WhoseValue.Should().Be("evt-1");
document.Metadata.Should().ContainKey("rancher.event.cursor").WhoseValue.Should().Be("cursor-2");
sink.Documents.Should().HaveCount(1);
var state = fixture.StateRepository.State;
state.Should().NotBeNull();
state!.LastUpdated.Should().Be(DateTimeOffset.Parse("2025-10-19T12:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal));
state.DocumentDigests.Should().Contain(fixture.ExpectedDocumentDigest);
state.DocumentDigests.Should().Contain("checkpoint:cursor-2");
state.DocumentDigests.Count.Should().BeLessOrEqualTo(ConnectorFixture.MaxDigestHistory + 1);
}
[Fact]
public async Task FetchAsync_WhenDocumentDownloadFails_QuarantinesEvent()
{
using var fixture = await ConnectorFixture.CreateAsync();
fixture.Handler.SetRoute(fixture.DocumentUri, () => new HttpResponseMessage(HttpStatusCode.InternalServerError));
var sink = new InMemoryRawSink();
var context = fixture.CreateContext(sink);
var documents = await CollectAsync(fixture.Connector.FetchAsync(context, CancellationToken.None));
documents.Should().BeEmpty();
sink.Documents.Should().HaveCount(1);
var quarantined = sink.Documents[0];
quarantined.Metadata.Should().Contain("rancher.event.quarantine", "true");
quarantined.Metadata.Should().ContainKey("rancher.event.error").WhoseValue.Should().Contain("document fetch failed");
var state = fixture.StateRepository.State;
state.Should().NotBeNull();
state!.DocumentDigests.Should().Contain(d => d.StartsWith("quarantine:", StringComparison.Ordinal));
}
[Fact]
public async Task FetchAsync_ReplayingSnapshot_SkipsDuplicateDocuments()
{
using var fixture = await ConnectorFixture.CreateAsync();
var firstSink = new InMemoryRawSink();
var firstContext = fixture.CreateContext(firstSink);
await CollectAsync(fixture.Connector.FetchAsync(firstContext, CancellationToken.None));
var secondSink = new InMemoryRawSink();
var secondContext = fixture.CreateContext(secondSink);
var secondRunDocuments = await CollectAsync(fixture.Connector.FetchAsync(secondContext, CancellationToken.None));
secondRunDocuments.Should().BeEmpty();
secondSink.Documents.Should().BeEmpty();
var state = fixture.StateRepository.State;
state.Should().NotBeNull();
state!.DocumentDigests.Should().Contain(fixture.ExpectedDocumentDigest);
}
[Fact]
public async Task FetchAsync_TrimsPersistedDigestHistory()
{
var existingDigests = Enumerable.Range(0, ConnectorFixture.MaxDigestHistory + 5)
.Select(i => $"sha256:{i:X32}")
.ToImmutableArray();
var initialState = new VexConnectorState(
"excititor:suse.rancher",
DateTimeOffset.Parse("2025-10-18T00:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal),
ImmutableArray.CreateBuilder<string>()
.Add("checkpoint:cursor-old")
.AddRange(existingDigests)
.ToImmutable());
using var fixture = await ConnectorFixture.CreateAsync(initialState);
var sink = new InMemoryRawSink();
var context = fixture.CreateContext(sink);
await CollectAsync(fixture.Connector.FetchAsync(context, CancellationToken.None));
var state = fixture.StateRepository.State;
state.Should().NotBeNull();
state!.DocumentDigests.Should().Contain(d => d.StartsWith("checkpoint:", StringComparison.Ordinal));
state.DocumentDigests.Count.Should().Be(ConnectorFixture.MaxDigestHistory + 1);
}
private static async Task<List<VexRawDocument>> CollectAsync(IAsyncEnumerable<VexRawDocument> source)
{
var list = new List<VexRawDocument>();
await foreach (var document in source.ConfigureAwait(false))
{
list.Add(document);
}
return list;
}
#region helpers
private sealed class ConnectorFixture : IDisposable
{
public const int MaxDigestHistory = 200;
private readonly IServiceProvider _serviceProvider;
private readonly TempDirectory _tempDirectory;
private readonly HttpClient _httpClient;
private ConnectorFixture(
RancherHubConnector connector,
InMemoryConnectorStateRepository stateRepository,
RoutingHttpMessageHandler handler,
IServiceProvider serviceProvider,
TempDirectory tempDirectory,
HttpClient httpClient,
Uri documentUri,
string documentDigest)
{
Connector = connector;
StateRepository = stateRepository;
Handler = handler;
_serviceProvider = serviceProvider;
_tempDirectory = tempDirectory;
_httpClient = httpClient;
DocumentUri = documentUri;
ExpectedDocumentDigest = $"sha256:{documentDigest}";
}
public RancherHubConnector Connector { get; }
public InMemoryConnectorStateRepository StateRepository { get; }
public RoutingHttpMessageHandler Handler { get; }
public Uri DocumentUri { get; }
public string ExpectedDocumentDigest { get; }
public VexConnectorContext CreateContext(InMemoryRawSink sink, DateTimeOffset? since = null)
=> new(
since,
VexConnectorSettings.Empty,
sink,
new NoopSignatureVerifier(),
new NoopNormalizerRouter(),
_serviceProvider,
ImmutableDictionary<string, string>.Empty);
public void Dispose()
{
_httpClient.Dispose();
_tempDirectory.Dispose();
}
public static async Task<ConnectorFixture> CreateAsync(VexConnectorState? initialState = null)
{
var tempDirectory = new TempDirectory();
var documentPayload = "{\"document\":\"payload\"}";
var documentDigest = ComputeSha256Hex(documentPayload);
var documentUri = new Uri("https://hub.test/events/evt-1.json");
var eventsPayload = """
{
"cursor": "cursor-1",
"nextCursor": "cursor-2",
"events": [
{
"id": "evt-1",
"type": "vex.statement.published",
"channel": "rancher/rke2",
"publishedAt": "2025-10-19T12:00:00Z",
"document": {
"uri": "https://hub.test/events/evt-1.json",
"sha256": "DOC_DIGEST",
"format": "csaf"
}
}
]
}
""".Replace("DOC_DIGEST", documentDigest, StringComparison.Ordinal);
var eventsPath = tempDirectory.Combine("events.json");
await File.WriteAllTextAsync(eventsPath, eventsPayload, Encoding.UTF8).ConfigureAwait(false);
var eventsChecksum = ComputeSha256Hex(eventsPayload);
var discoveryPayload = """
{
"hubId": "excititor:suse.rancher",
"title": "SUSE Rancher VEX Hub",
"subscription": {
"eventsUri": "https://hub.test/events",
"checkpointUri": "https://hub.test/checkpoint",
"channels": [ "rancher/rke2" ],
"requiresAuthentication": false
},
"offline": {
"snapshotUri": "EVENTS_URI",
"sha256": "EVENTS_DIGEST"
}
}
"""
.Replace("EVENTS_URI", new Uri(eventsPath).ToString(), StringComparison.Ordinal)
.Replace("EVENTS_DIGEST", eventsChecksum, StringComparison.Ordinal);
var discoveryPath = tempDirectory.Combine("discovery.json");
await File.WriteAllTextAsync(discoveryPath, discoveryPayload, Encoding.UTF8).ConfigureAwait(false);
var handler = new RoutingHttpMessageHandler();
handler.SetRoute(documentUri, () => JsonResponse(documentPayload));
var httpClient = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(10),
};
var httpFactory = new SingletonHttpClientFactory(httpClient);
var memoryCache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new System.IO.Abstractions.FileSystem();
var tokenProvider = new RancherHubTokenProvider(httpFactory, memoryCache, NullLogger<RancherHubTokenProvider>.Instance);
var metadataLoader = new RancherHubMetadataLoader(httpFactory, memoryCache, tokenProvider, fileSystem, NullLogger<RancherHubMetadataLoader>.Instance);
var eventClient = new RancherHubEventClient(httpFactory, tokenProvider, fileSystem, NullLogger<RancherHubEventClient>.Instance);
var stateRepository = new InMemoryConnectorStateRepository(initialState);
var checkpointManager = new RancherHubCheckpointManager(stateRepository);
var validators = new[] { new RancherHubConnectorOptionsValidator(fileSystem) };
var connector = new RancherHubConnector(
metadataLoader,
eventClient,
checkpointManager,
tokenProvider,
httpFactory,
NullLogger<RancherHubConnector>.Instance,
TimeProvider.System,
validators);
var settingsValues = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
settingsValues["DiscoveryUri"] = "https://hub.test/.well-known/rancher-hub.json";
settingsValues["OfflineSnapshotPath"] = discoveryPath;
settingsValues["PreferOfflineSnapshot"] = "true";
var settings = new VexConnectorSettings(settingsValues.ToImmutable());
await connector.ValidateAsync(settings, CancellationToken.None).ConfigureAwait(false);
var services = new ServiceCollection().BuildServiceProvider();
return new ConnectorFixture(
connector,
stateRepository,
handler,
services,
tempDirectory,
httpClient,
documentUri,
documentDigest);
}
private static HttpResponseMessage JsonResponse(string payload)
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
};
return response;
}
}
private sealed class SingletonHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingletonHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class RoutingHttpMessageHandler : HttpMessageHandler
{
private readonly Dictionary<Uri, Queue<Func<HttpResponseMessage>>> _routes = new();
public void SetRoute(Uri uri, params Func<HttpResponseMessage>[] responders)
{
ArgumentNullException.ThrowIfNull(uri);
if (responders is null || responders.Length == 0)
{
_routes.Remove(uri);
return;
}
_routes[uri] = new Queue<Func<HttpResponseMessage>>(responders);
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.RequestUri is not null &&
_routes.TryGetValue(request.RequestUri, out var queue) &&
queue.Count > 0)
{
var responder = queue.Count > 1 ? queue.Dequeue() : queue.Peek();
var response = responder();
response.RequestMessage = request;
return Task.FromResult(response);
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent($"No response configured for {request.RequestUri}", Encoding.UTF8, "text/plain"),
});
}
}
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
{
public InMemoryConnectorStateRepository(VexConnectorState? initialState = null)
{
State = initialState;
}
public VexConnectorState? State { get; private set; }
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
=> ValueTask.FromResult(State);
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, MongoDB.Driver.IClientSessionHandle? session = null)
{
State = state;
return ValueTask.CompletedTask;
}
}
private sealed class InMemoryRawSink : IVexRawDocumentSink
{
public List<VexRawDocument> Documents { get; } = new();
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
{
Documents.Add(document);
return ValueTask.CompletedTask;
}
}
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
{
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
}
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
{
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
}
private sealed class TempDirectory : IDisposable
{
private readonly string _path;
public TempDirectory()
{
_path = Path.Combine(Path.GetTempPath(), "stellaops-excititor-tests", Guid.NewGuid().ToString("n"));
Directory.CreateDirectory(_path);
}
public string Combine(string relative) => Path.Combine(_path, relative);
public void Dispose()
{
try
{
if (Directory.Exists(_path))
{
Directory.Delete(_path, recursive: true);
}
}
catch
{
// Best-effort cleanup.
}
}
}
private static string ComputeSha256Hex(string payload)
{
var bytes = Encoding.UTF8.GetBytes(payload);
return ComputeSha256Hex(bytes);
}
private static string ComputeSha256Hex(ReadOnlySpan<byte> payload)
{
Span<byte> buffer = stackalloc byte[32];
SHA256.HashData(payload, buffer);
return Convert.ToHexString(buffer).ToLowerInvariant();
}
#endregion
}

View File

@@ -1,23 +1,23 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Connectors.SUSE.RancherVEXHub/StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Storage.Mongo/StellaOps.Excititor.Storage.Mongo.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Remove="..\..\..\StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" />
<Compile Remove="..\..\..\StellaOps.Concelier.Tests.Shared\MongoFixtureCollection.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
</ItemGroup>
</Project>
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Connectors.SUSE.RancherVEXHub/StellaOps.Excititor.Connectors.SUSE.RancherVEXHub.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Storage.Mongo/StellaOps.Excititor.Storage.Mongo.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Remove="..\..\..\StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" />
<Compile Remove="..\..\..\StellaOps.Concelier.Tests.Shared\MongoFixtureCollection.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
</ItemGroup>
</Project>

View File

@@ -1,169 +1,169 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Policy;
using System.Diagnostics.Metrics;
namespace StellaOps.Excititor.Core.Tests;
public class VexPolicyDiagnosticsTests
{
[Fact]
public void GetDiagnostics_ReportsCountsRecommendationsAndOverrides()
{
var overrides = new[]
{
new KeyValuePair<string, double>("provider-a", 0.8),
new KeyValuePair<string, double>("provider-b", 0.6),
};
var snapshot = new VexPolicySnapshot(
"custom/v1",
new VexConsensusPolicyOptions(
version: "custom/v1",
providerOverrides: overrides),
new BaselineVexConsensusPolicy(),
ImmutableArray.Create(
new VexPolicyIssue("sample.error", "Blocking issue.", VexPolicyIssueSeverity.Error),
new VexPolicyIssue("sample.warning", "Non-blocking issue.", VexPolicyIssueSeverity.Warning)),
"rev-test",
"ABCDEF");
var fakeProvider = new FakePolicyProvider(snapshot);
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2025, 10, 16, 17, 0, 0, TimeSpan.Zero));
var diagnostics = new VexPolicyDiagnostics(fakeProvider, fakeTime);
var report = diagnostics.GetDiagnostics();
Assert.Equal("custom/v1", report.Version);
Assert.Equal("rev-test", report.RevisionId);
Assert.Equal("ABCDEF", report.Digest);
Assert.Equal(1, report.ErrorCount);
Assert.Equal(1, report.WarningCount);
Assert.Equal(fakeTime.GetUtcNow(), report.GeneratedAt);
Assert.Collection(report.Issues,
issue => Assert.Equal("sample.error", issue.Code),
issue => Assert.Equal("sample.warning", issue.Code));
Assert.Equal(new[] { "provider-a", "provider-b" }, report.ActiveOverrides.Keys.OrderBy(static key => key, StringComparer.Ordinal));
Assert.Contains(report.Recommendations, message => message.Contains("Resolve policy errors", StringComparison.OrdinalIgnoreCase));
Assert.Contains(report.Recommendations, message => message.Contains("provider-a", StringComparison.OrdinalIgnoreCase));
Assert.Contains(report.Recommendations, message => message.Contains("docs/modules/excititor/architecture.md", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void GetDiagnostics_WhenNoIssues_StillReturnsDefaultRecommendation()
{
var fakeProvider = new FakePolicyProvider(VexPolicySnapshot.Default);
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2025, 10, 16, 17, 0, 0, TimeSpan.Zero));
var diagnostics = new VexPolicyDiagnostics(fakeProvider, fakeTime);
var report = diagnostics.GetDiagnostics();
Assert.Equal(0, report.ErrorCount);
Assert.Equal(0, report.WarningCount);
Assert.Empty(report.ActiveOverrides);
Assert.Single(report.Recommendations);
}
[Fact]
public void PolicyProvider_ComputesRevisionAndDigest_AndEmitsTelemetry()
{
using var listener = new MeterListener();
var reloadMeasurements = 0;
string? lastRevision = null;
listener.InstrumentPublished += (instrument, _) =>
{
if (instrument.Meter.Name == "StellaOps.Excititor.Policy" &&
instrument.Name == "vex.policy.reloads")
{
listener.EnableMeasurementEvents(instrument);
}
};
listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
{
reloadMeasurements++;
foreach (var tag in tags)
{
if (tag.Key is "revision" && tag.Value is string revision)
{
lastRevision = revision;
break;
}
}
});
listener.Start();
var optionsMonitor = new MutableOptionsMonitor<VexPolicyOptions>(new VexPolicyOptions());
var provider = new VexPolicyProvider(optionsMonitor, NullLogger<VexPolicyProvider>.Instance);
var snapshot1 = provider.GetSnapshot();
Assert.Equal("rev-1", snapshot1.RevisionId);
Assert.False(string.IsNullOrWhiteSpace(snapshot1.Digest));
var snapshot2 = provider.GetSnapshot();
Assert.Equal("rev-1", snapshot2.RevisionId);
Assert.Equal(snapshot1.Digest, snapshot2.Digest);
optionsMonitor.Update(new VexPolicyOptions
{
ProviderOverrides = new Dictionary<string, double>
{
["provider-a"] = 0.4
}
});
var snapshot3 = provider.GetSnapshot();
Assert.Equal("rev-2", snapshot3.RevisionId);
Assert.NotEqual(snapshot1.Digest, snapshot3.Digest);
listener.Dispose();
Assert.True(reloadMeasurements >= 2);
Assert.Equal("rev-2", lastRevision);
}
private sealed class FakePolicyProvider : IVexPolicyProvider
{
private readonly VexPolicySnapshot _snapshot;
public FakePolicyProvider(VexPolicySnapshot snapshot)
{
_snapshot = snapshot;
}
public VexPolicySnapshot GetSnapshot() => _snapshot;
}
private sealed class MutableOptionsMonitor<T> : IOptionsMonitor<T>
{
private T _value;
public MutableOptionsMonitor(T value)
{
_value = value;
}
public T CurrentValue => _value;
public T Get(string? name) => _value;
public void Update(T newValue) => _value = newValue;
public IDisposable OnChange(Action<T, string?> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static readonly NullDisposable Instance = new();
public void Dispose()
{
}
}
}
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Policy;
using System.Diagnostics.Metrics;
namespace StellaOps.Excititor.Core.Tests;
public class VexPolicyDiagnosticsTests
{
[Fact]
public void GetDiagnostics_ReportsCountsRecommendationsAndOverrides()
{
var overrides = new[]
{
new KeyValuePair<string, double>("provider-a", 0.8),
new KeyValuePair<string, double>("provider-b", 0.6),
};
var snapshot = new VexPolicySnapshot(
"custom/v1",
new VexConsensusPolicyOptions(
version: "custom/v1",
providerOverrides: overrides),
new BaselineVexConsensusPolicy(),
ImmutableArray.Create(
new VexPolicyIssue("sample.error", "Blocking issue.", VexPolicyIssueSeverity.Error),
new VexPolicyIssue("sample.warning", "Non-blocking issue.", VexPolicyIssueSeverity.Warning)),
"rev-test",
"ABCDEF");
var fakeProvider = new FakePolicyProvider(snapshot);
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2025, 10, 16, 17, 0, 0, TimeSpan.Zero));
var diagnostics = new VexPolicyDiagnostics(fakeProvider, fakeTime);
var report = diagnostics.GetDiagnostics();
Assert.Equal("custom/v1", report.Version);
Assert.Equal("rev-test", report.RevisionId);
Assert.Equal("ABCDEF", report.Digest);
Assert.Equal(1, report.ErrorCount);
Assert.Equal(1, report.WarningCount);
Assert.Equal(fakeTime.GetUtcNow(), report.GeneratedAt);
Assert.Collection(report.Issues,
issue => Assert.Equal("sample.error", issue.Code),
issue => Assert.Equal("sample.warning", issue.Code));
Assert.Equal(new[] { "provider-a", "provider-b" }, report.ActiveOverrides.Keys.OrderBy(static key => key, StringComparer.Ordinal));
Assert.Contains(report.Recommendations, message => message.Contains("Resolve policy errors", StringComparison.OrdinalIgnoreCase));
Assert.Contains(report.Recommendations, message => message.Contains("provider-a", StringComparison.OrdinalIgnoreCase));
Assert.Contains(report.Recommendations, message => message.Contains("docs/modules/excititor/architecture.md", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void GetDiagnostics_WhenNoIssues_StillReturnsDefaultRecommendation()
{
var fakeProvider = new FakePolicyProvider(VexPolicySnapshot.Default);
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2025, 10, 16, 17, 0, 0, TimeSpan.Zero));
var diagnostics = new VexPolicyDiagnostics(fakeProvider, fakeTime);
var report = diagnostics.GetDiagnostics();
Assert.Equal(0, report.ErrorCount);
Assert.Equal(0, report.WarningCount);
Assert.Empty(report.ActiveOverrides);
Assert.Single(report.Recommendations);
}
[Fact]
public void PolicyProvider_ComputesRevisionAndDigest_AndEmitsTelemetry()
{
using var listener = new MeterListener();
var reloadMeasurements = 0;
string? lastRevision = null;
listener.InstrumentPublished += (instrument, _) =>
{
if (instrument.Meter.Name == "StellaOps.Excititor.Policy" &&
instrument.Name == "vex.policy.reloads")
{
listener.EnableMeasurementEvents(instrument);
}
};
listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
{
reloadMeasurements++;
foreach (var tag in tags)
{
if (tag.Key is "revision" && tag.Value is string revision)
{
lastRevision = revision;
break;
}
}
});
listener.Start();
var optionsMonitor = new MutableOptionsMonitor<VexPolicyOptions>(new VexPolicyOptions());
var provider = new VexPolicyProvider(optionsMonitor, NullLogger<VexPolicyProvider>.Instance);
var snapshot1 = provider.GetSnapshot();
Assert.Equal("rev-1", snapshot1.RevisionId);
Assert.False(string.IsNullOrWhiteSpace(snapshot1.Digest));
var snapshot2 = provider.GetSnapshot();
Assert.Equal("rev-1", snapshot2.RevisionId);
Assert.Equal(snapshot1.Digest, snapshot2.Digest);
optionsMonitor.Update(new VexPolicyOptions
{
ProviderOverrides = new Dictionary<string, double>
{
["provider-a"] = 0.4
}
});
var snapshot3 = provider.GetSnapshot();
Assert.Equal("rev-2", snapshot3.RevisionId);
Assert.NotEqual(snapshot1.Digest, snapshot3.Digest);
listener.Dispose();
Assert.True(reloadMeasurements >= 2);
Assert.Equal("rev-2", lastRevision);
}
private sealed class FakePolicyProvider : IVexPolicyProvider
{
private readonly VexPolicySnapshot _snapshot;
public FakePolicyProvider(VexPolicySnapshot snapshot)
{
_snapshot = snapshot;
}
public VexPolicySnapshot GetSnapshot() => _snapshot;
}
private sealed class MutableOptionsMonitor<T> : IOptionsMonitor<T>
{
private T _value;
public MutableOptionsMonitor(T value)
{
_value = value;
}
public T CurrentValue => _value;
public T Get(string? name) => _value;
public void Update(T newValue) => _value = newValue;
public IDisposable OnChange(Action<T, string?> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static readonly NullDisposable Instance = new();
public void Dispose()
{
}
}
}
}

View File

@@ -1,73 +1,73 @@
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Formats.CSAF;
namespace StellaOps.Excititor.Formats.CSAF.Tests;
public sealed class CsafExporterTests
{
[Fact]
public async Task SerializeAsync_WritesDeterministicCsafDocument()
{
var claims = ImmutableArray.Create(
new VexClaim(
"CVE-2025-3000",
"vendor:example",
new VexProduct("pkg:example/app@1.0.0", "Example App", "1.0.0", "pkg:example/app@1.0.0"),
VexClaimStatus.Affected,
new VexClaimDocument(VexDocumentFormat.Csaf, "sha256:doc1", new Uri("https://example.com/csaf/advisory1.json")),
new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2025, 10, 11, 0, 0, 0, TimeSpan.Zero),
detail: "Impact on Example App 1.0.0"),
new VexClaim(
"CVE-2025-3000",
"vendor:example",
new VexProduct("pkg:example/app@1.0.0", "Example App", "1.0.0", "pkg:example/app@1.0.0"),
VexClaimStatus.NotAffected,
new VexClaimDocument(VexDocumentFormat.Csaf, "sha256:doc2", new Uri("https://example.com/csaf/advisory2.json")),
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero),
justification: VexJustification.ComponentNotPresent),
new VexClaim(
"ADVISORY-1",
"vendor:example",
new VexProduct("pkg:example/lib@2.0.0", "Example Lib", "2.0.0"),
VexClaimStatus.NotAffected,
new VexClaimDocument(VexDocumentFormat.Csaf, "sha256:doc3", new Uri("https://example.com/csaf/advisory3.json")),
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero),
justification: null));
var request = new VexExportRequest(
VexQuery.Empty,
ImmutableArray<VexConsensus>.Empty,
claims,
new DateTimeOffset(2025, 10, 13, 0, 0, 0, TimeSpan.Zero));
var exporter = new CsafExporter();
var digest = exporter.Digest(request);
await using var stream = new MemoryStream();
var result = await exporter.SerializeAsync(request, stream, CancellationToken.None);
digest.Should().NotBeNull();
digest.Should().Be(result.Digest);
stream.Position = 0;
using var document = JsonDocument.Parse(stream);
var root = document.RootElement;
root.GetProperty("document").GetProperty("tracking").GetProperty("id").GetString()!.Should().StartWith("stellaops:csaf");
root.GetProperty("product_tree").GetProperty("full_product_names").GetArrayLength().Should().Be(2);
root.GetProperty("vulnerabilities").EnumerateArray().Should().HaveCount(2);
var metadata = root.GetProperty("metadata");
metadata.GetProperty("query_signature").GetString().Should().NotBeNull();
metadata.GetProperty("diagnostics").EnumerateObject().Select(p => p.Name).Should().Contain("policy.justification_missing");
result.Metadata.Should().ContainKey("csaf.vulnerabilityCount");
result.Metadata["csaf.productCount"].Should().Be("2");
}
}
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Formats.CSAF;
namespace StellaOps.Excititor.Formats.CSAF.Tests;
public sealed class CsafExporterTests
{
[Fact]
public async Task SerializeAsync_WritesDeterministicCsafDocument()
{
var claims = ImmutableArray.Create(
new VexClaim(
"CVE-2025-3000",
"vendor:example",
new VexProduct("pkg:example/app@1.0.0", "Example App", "1.0.0", "pkg:example/app@1.0.0"),
VexClaimStatus.Affected,
new VexClaimDocument(VexDocumentFormat.Csaf, "sha256:doc1", new Uri("https://example.com/csaf/advisory1.json")),
new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2025, 10, 11, 0, 0, 0, TimeSpan.Zero),
detail: "Impact on Example App 1.0.0"),
new VexClaim(
"CVE-2025-3000",
"vendor:example",
new VexProduct("pkg:example/app@1.0.0", "Example App", "1.0.0", "pkg:example/app@1.0.0"),
VexClaimStatus.NotAffected,
new VexClaimDocument(VexDocumentFormat.Csaf, "sha256:doc2", new Uri("https://example.com/csaf/advisory2.json")),
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero),
justification: VexJustification.ComponentNotPresent),
new VexClaim(
"ADVISORY-1",
"vendor:example",
new VexProduct("pkg:example/lib@2.0.0", "Example Lib", "2.0.0"),
VexClaimStatus.NotAffected,
new VexClaimDocument(VexDocumentFormat.Csaf, "sha256:doc3", new Uri("https://example.com/csaf/advisory3.json")),
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero),
justification: null));
var request = new VexExportRequest(
VexQuery.Empty,
ImmutableArray<VexConsensus>.Empty,
claims,
new DateTimeOffset(2025, 10, 13, 0, 0, 0, TimeSpan.Zero));
var exporter = new CsafExporter();
var digest = exporter.Digest(request);
await using var stream = new MemoryStream();
var result = await exporter.SerializeAsync(request, stream, CancellationToken.None);
digest.Should().NotBeNull();
digest.Should().Be(result.Digest);
stream.Position = 0;
using var document = JsonDocument.Parse(stream);
var root = document.RootElement;
root.GetProperty("document").GetProperty("tracking").GetProperty("id").GetString()!.Should().StartWith("stellaops:csaf");
root.GetProperty("product_tree").GetProperty("full_product_names").GetArrayLength().Should().Be(2);
root.GetProperty("vulnerabilities").EnumerateArray().Should().HaveCount(2);
var metadata = root.GetProperty("metadata");
metadata.GetProperty("query_signature").GetString().Should().NotBeNull();
metadata.GetProperty("diagnostics").EnumerateObject().Select(p => p.Name).Should().Contain("policy.justification_missing");
result.Metadata.Should().ContainKey("csaf.vulnerabilityCount");
result.Metadata["csaf.productCount"].Should().Be("2");
}
}

View File

@@ -1,37 +1,37 @@
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Formats.CycloneDX;
namespace StellaOps.Excititor.Formats.CycloneDX.Tests;
public sealed class CycloneDxComponentReconcilerTests
{
[Fact]
public void Reconcile_AssignsBomRefsAndDiagnostics()
{
var claims = ImmutableArray.Create(
new VexClaim(
"CVE-2025-7000",
"vendor:one",
new VexProduct("pkg:demo/component@1.0.0", "Demo Component", "1.0.0", "pkg:demo/component@1.0.0"),
VexClaimStatus.Affected,
new VexClaimDocument(VexDocumentFormat.CycloneDx, "sha256:doc1", new Uri("https://example.com/vex/1")),
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow),
new VexClaim(
"CVE-2025-7000",
"vendor:two",
new VexProduct("component-key", "Component Key"),
VexClaimStatus.NotAffected,
new VexClaimDocument(VexDocumentFormat.CycloneDx, "sha256:doc2", new Uri("https://example.com/vex/2")),
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow));
var result = CycloneDxComponentReconciler.Reconcile(claims);
result.Components.Should().HaveCount(2);
result.ComponentRefs.Should().ContainKey(("CVE-2025-7000", "component-key"));
result.Diagnostics.Keys.Should().Contain("missing_purl");
}
}
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Formats.CycloneDX;
namespace StellaOps.Excititor.Formats.CycloneDX.Tests;
public sealed class CycloneDxComponentReconcilerTests
{
[Fact]
public void Reconcile_AssignsBomRefsAndDiagnostics()
{
var claims = ImmutableArray.Create(
new VexClaim(
"CVE-2025-7000",
"vendor:one",
new VexProduct("pkg:demo/component@1.0.0", "Demo Component", "1.0.0", "pkg:demo/component@1.0.0"),
VexClaimStatus.Affected,
new VexClaimDocument(VexDocumentFormat.CycloneDx, "sha256:doc1", new Uri("https://example.com/vex/1")),
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow),
new VexClaim(
"CVE-2025-7000",
"vendor:two",
new VexProduct("component-key", "Component Key"),
VexClaimStatus.NotAffected,
new VexClaimDocument(VexDocumentFormat.CycloneDx, "sha256:doc2", new Uri("https://example.com/vex/2")),
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow));
var result = CycloneDxComponentReconciler.Reconcile(claims);
result.Components.Should().HaveCount(2);
result.ComponentRefs.Should().ContainKey(("CVE-2025-7000", "component-key"));
result.Diagnostics.Keys.Should().Contain("missing_purl");
}
}

View File

@@ -1,47 +1,47 @@
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Formats.CycloneDX;
namespace StellaOps.Excititor.Formats.CycloneDX.Tests;
public sealed class CycloneDxExporterTests
{
[Fact]
public async Task SerializeAsync_WritesCycloneDxVexDocument()
{
var claims = ImmutableArray.Create(
new VexClaim(
"CVE-2025-6000",
"vendor:demo",
new VexProduct("pkg:demo/component@1.2.3", "Demo Component", "1.2.3", "pkg:demo/component@1.2.3"),
VexClaimStatus.Fixed,
new VexClaimDocument(VexDocumentFormat.CycloneDx, "sha256:doc1", new Uri("https://example.com/cyclonedx/1")),
new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2025, 10, 11, 0, 0, 0, TimeSpan.Zero),
detail: "Issue resolved in 1.2.3"));
var request = new VexExportRequest(
VexQuery.Empty,
ImmutableArray<VexConsensus>.Empty,
claims,
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero));
var exporter = new CycloneDxExporter();
await using var stream = new MemoryStream();
var result = await exporter.SerializeAsync(request, stream, CancellationToken.None);
stream.Position = 0;
using var document = JsonDocument.Parse(stream);
var root = document.RootElement;
root.GetProperty("bomFormat").GetString().Should().Be("CycloneDX");
root.GetProperty("components").EnumerateArray().Should().HaveCount(1);
root.GetProperty("vulnerabilities").EnumerateArray().Should().HaveCount(1);
result.Metadata.Should().ContainKey("cyclonedx.vulnerabilityCount");
result.Metadata["cyclonedx.componentCount"].Should().Be("1");
result.Digest.Algorithm.Should().Be("sha256");
}
}
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Formats.CycloneDX;
namespace StellaOps.Excititor.Formats.CycloneDX.Tests;
public sealed class CycloneDxExporterTests
{
[Fact]
public async Task SerializeAsync_WritesCycloneDxVexDocument()
{
var claims = ImmutableArray.Create(
new VexClaim(
"CVE-2025-6000",
"vendor:demo",
new VexProduct("pkg:demo/component@1.2.3", "Demo Component", "1.2.3", "pkg:demo/component@1.2.3"),
VexClaimStatus.Fixed,
new VexClaimDocument(VexDocumentFormat.CycloneDx, "sha256:doc1", new Uri("https://example.com/cyclonedx/1")),
new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2025, 10, 11, 0, 0, 0, TimeSpan.Zero),
detail: "Issue resolved in 1.2.3"));
var request = new VexExportRequest(
VexQuery.Empty,
ImmutableArray<VexConsensus>.Empty,
claims,
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero));
var exporter = new CycloneDxExporter();
await using var stream = new MemoryStream();
var result = await exporter.SerializeAsync(request, stream, CancellationToken.None);
stream.Position = 0;
using var document = JsonDocument.Parse(stream);
var root = document.RootElement;
root.GetProperty("bomFormat").GetString().Should().Be("CycloneDX");
root.GetProperty("components").EnumerateArray().Should().HaveCount(1);
root.GetProperty("vulnerabilities").EnumerateArray().Should().HaveCount(1);
result.Metadata.Should().ContainKey("cyclonedx.vulnerabilityCount");
result.Metadata["cyclonedx.componentCount"].Should().Be("1");
result.Digest.Algorithm.Should().Be("sha256");
}
}

View File

@@ -1,49 +1,49 @@
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Formats.OpenVEX;
namespace StellaOps.Excititor.Formats.OpenVEX.Tests;
public sealed class OpenVexExporterTests
{
[Fact]
public async Task SerializeAsync_ProducesCanonicalOpenVexDocument()
{
var claims = ImmutableArray.Create(
new VexClaim(
"CVE-2025-5000",
"vendor:alpha",
new VexProduct("pkg:alpha/app@2.0.0", "Alpha App", "2.0.0", "pkg:alpha/app@2.0.0"),
VexClaimStatus.NotAffected,
new VexClaimDocument(VexDocumentFormat.OpenVex, "sha256:doc1", new Uri("https://example.com/openvex/alpha")),
new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2025, 10, 11, 0, 0, 0, TimeSpan.Zero),
justification: VexJustification.ComponentNotPresent,
detail: "Component not shipped."));
var request = new VexExportRequest(
VexQuery.Empty,
ImmutableArray<VexConsensus>.Empty,
claims,
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero));
var exporter = new OpenVexExporter();
await using var stream = new MemoryStream();
var result = await exporter.SerializeAsync(request, stream, CancellationToken.None);
stream.Position = 0;
using var document = JsonDocument.Parse(stream);
var root = document.RootElement;
root.GetProperty("document").GetProperty("author").GetString().Should().Be("StellaOps Excititor");
root.GetProperty("statements").GetArrayLength().Should().Be(1);
var statement = root.GetProperty("statements")[0];
statement.GetProperty("status").GetString().Should().Be("not_affected");
statement.GetProperty("products")[0].GetProperty("id").GetString().Should().Be("pkg:alpha/app@2.0.0");
result.Metadata.Should().ContainKey("openvex.statementCount");
result.Metadata["openvex.statementCount"].Should().Be("1");
result.Digest.Algorithm.Should().Be("sha256");
}
}
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Formats.OpenVEX;
namespace StellaOps.Excititor.Formats.OpenVEX.Tests;
public sealed class OpenVexExporterTests
{
[Fact]
public async Task SerializeAsync_ProducesCanonicalOpenVexDocument()
{
var claims = ImmutableArray.Create(
new VexClaim(
"CVE-2025-5000",
"vendor:alpha",
new VexProduct("pkg:alpha/app@2.0.0", "Alpha App", "2.0.0", "pkg:alpha/app@2.0.0"),
VexClaimStatus.NotAffected,
new VexClaimDocument(VexDocumentFormat.OpenVex, "sha256:doc1", new Uri("https://example.com/openvex/alpha")),
new DateTimeOffset(2025, 10, 10, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2025, 10, 11, 0, 0, 0, TimeSpan.Zero),
justification: VexJustification.ComponentNotPresent,
detail: "Component not shipped."));
var request = new VexExportRequest(
VexQuery.Empty,
ImmutableArray<VexConsensus>.Empty,
claims,
new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero));
var exporter = new OpenVexExporter();
await using var stream = new MemoryStream();
var result = await exporter.SerializeAsync(request, stream, CancellationToken.None);
stream.Position = 0;
using var document = JsonDocument.Parse(stream);
var root = document.RootElement;
root.GetProperty("document").GetProperty("author").GetString().Should().Be("StellaOps Excititor");
root.GetProperty("statements").GetArrayLength().Should().Be(1);
var statement = root.GetProperty("statements")[0];
statement.GetProperty("status").GetString().Should().Be("not_affected");
statement.GetProperty("products")[0].GetProperty("id").GetString().Should().Be("pkg:alpha/app@2.0.0");
result.Metadata.Should().ContainKey("openvex.statementCount");
result.Metadata["openvex.statementCount"].Should().Be("1");
result.Digest.Algorithm.Should().Be("sha256");
}
}

View File

@@ -1,39 +1,39 @@
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Formats.OpenVEX;
namespace StellaOps.Excititor.Formats.OpenVEX.Tests;
public sealed class OpenVexStatementMergerTests
{
[Fact]
public void Merge_DetectsConflictsAndSelectsCanonicalStatus()
{
var claims = ImmutableArray.Create(
new VexClaim(
"CVE-2025-4000",
"vendor:one",
new VexProduct("pkg:demo/app@1.0.0", "Demo App", "1.0.0"),
VexClaimStatus.NotAffected,
new VexClaimDocument(VexDocumentFormat.OpenVex, "sha256:doc1", new Uri("https://example.com/openvex/1")),
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
justification: VexJustification.ComponentNotPresent),
new VexClaim(
"CVE-2025-4000",
"vendor:two",
new VexProduct("pkg:demo/app@1.0.0", "Demo App", "1.0.0"),
VexClaimStatus.Affected,
new VexClaimDocument(VexDocumentFormat.OpenVex, "sha256:doc2", new Uri("https://example.com/openvex/2")),
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow));
var result = OpenVexStatementMerger.Merge(claims);
result.Statements.Should().HaveCount(1);
var statement = result.Statements[0];
statement.Status.Should().Be(VexClaimStatus.Affected);
result.Diagnostics.Should().ContainKey("openvex.status_conflict");
}
}
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Formats.OpenVEX;
namespace StellaOps.Excititor.Formats.OpenVEX.Tests;
public sealed class OpenVexStatementMergerTests
{
[Fact]
public void Merge_DetectsConflictsAndSelectsCanonicalStatus()
{
var claims = ImmutableArray.Create(
new VexClaim(
"CVE-2025-4000",
"vendor:one",
new VexProduct("pkg:demo/app@1.0.0", "Demo App", "1.0.0"),
VexClaimStatus.NotAffected,
new VexClaimDocument(VexDocumentFormat.OpenVex, "sha256:doc1", new Uri("https://example.com/openvex/1")),
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
justification: VexJustification.ComponentNotPresent),
new VexClaim(
"CVE-2025-4000",
"vendor:two",
new VexProduct("pkg:demo/app@1.0.0", "Demo App", "1.0.0"),
VexClaimStatus.Affected,
new VexClaimDocument(VexDocumentFormat.OpenVex, "sha256:doc2", new Uri("https://example.com/openvex/2")),
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow));
var result = OpenVexStatementMerger.Merge(claims);
result.Statements.Should().HaveCount(1);
var statement = result.Statements[0];
statement.Status.Should().Be(VexClaimStatus.Affected);
result.Diagnostics.Should().ContainKey("openvex.status_conflict");
}
}