Align AOC tasks for Excititor and Concelier
This commit is contained in:
@@ -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 §4–5 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 §4–5 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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user